NVMe-oF Performance#

This page records what the in-process NVMe-oF controller (NVMe-oF Controller) actually delivers, measured with fio from inside a single guest. It complements Performance, which covers the RDMA verbs path over the TCP mesh, and Performance trends, which tracks the 4 KiB random-read number over time from the nightly run.

The numbers here are not storage numbers. Nothing is measured against a real SSD: the namespace is host memory or a host file, and every command capsule and RDMA transfer is emulated in userspace. What the sweep measures is the emulator – the capsule round trip, the fast-registration path, and the RDMA READ/WRITE engine in src/ionic/ionic_datapath.c. Treat a regression here as a regression in that code, not in any disk.

Test Environment#

Component

Value

Host

hpe-rack-15.adc.amd.com (AMD EPYC 7513, 128 threads)

Host kernel

6.8.0-124-generic

Guest

Ubuntu 26.04 LTS, kernel 7.2.3

Guest vCPUs / memory

16 / 32 GiB

Emulated device

1dd8:1002 (ionic)

Active MTU

1024 bytes

Backend

nvmeof:size=8G,bs=4096, file=/var/tmp/ernic-ns0.img,queues=4

Namespace

8 GiB, file-backed, 4 KiB blocks

fio

3.41

nvme-cli

2.16

The controller reports mdts=5 – a 128 KiB maximum transfer – and ioccsz=4, so every command carries a keyed SGL rather than in-capsule data. maxcmd is 128.

Reproducing#

One server and one guest are a complete fabric. This needs the Ansible prerequisites in Testing and, for ernicctl vm-launch, those in systemd Service and ernicctl. Host setup writes /etc/rocm-ernic/rocm-ernic.env from its template, which has no backend setting, so choose the backend after host setup rather than before it:

cd ansible

# 0. Fetch the pinned guest image to where host setup points
#    ERNIC_VM_BACKING (the directory must be writable by you).
. ../ci/guest-image.env
../scripts/fetch-guest-image.sh --tag "$GUEST_ARTIFACT_TAG" \
  --dest "/opt/qemu-images/artifacts/$GUEST_ARTIFACT_TAG" \
  --expect-disk "$GUEST_ARTIFACT_DISK"

# 1. Build and install the service with one instance.  Run the
#    play, not site.yml --tags host-setup: site.yml always
#    registers the VMs, and there are none yet.
ansible-playbook playbooks/host-setup.yml -e ernic_instances=1

# 2. Put the instance on the NVMe-oF backend.
echo 'ERNIC_BACKEND=nvmeof:size=8G,bs=4096,queues=4' |
  sudo tee -a /etc/rocm-ernic/rocm-ernic.env
sudo systemctl restart rocm-ernic

# 3. Attach the guest.
sudo ernicctl vm-launch 1

# 4. Provision the guest, then connect and sweep.
ansible-playbook ci-site.yml --tags guest-setup,nvmeof \
  -e ernic_project_root="$PWD/.." \
  -e ernic_nvmeof_queues=4 \
  -e ernic_nvmeof_perf_csv=/tmp/nvmeof-sweep.csv

Step 4 uses ci-site.yml because it registers the running guest in the ernic_vms group first, which the NVMe-oF play needs (run alone, the play finds no hosts and does nothing), and because it imports no host-setup play, so it leaves the edited env file alone. The play connects and runs the short sweep that feeds the trend chart. The longer sweep below was driven by hand against the resulting /dev/nvme0n1; each run is --direct=1 --time_based --runtime=15 with libaio.

Results#

Block-size sweep#

Random read, queue depth 32, 4 jobs.

Block size

IOPS

GB/s

Mean (us)

p99 (us)

4 KiB

43,113

0.177

2913.4

5603.3

16 KiB

45,353

0.743

2748.7

9633.8

64 KiB

41,397

2.713

3055.7

5275.6

256 KiB

15,034

3.941

8345.6

13959.2

1 MiB

3,552

3.725

34829.0

68681.7

IOPS is flat from 4 KiB to 64 KiB: below 64 KiB the cost is per-capsule, not per-byte, so shrinking the transfer buys nothing. Bandwidth peaks at 256 KiB and then falls at 1 MiB, which is the mdts=5 cap showing through – the guest splits anything over 128 KiB, so a 1 MiB request becomes eight round trips and pays the per-capsule cost eight times.

Access patterns#

4 KiB, queue depth 32, 4 jobs.

Pattern

IOPS

GB/s

Mean (us)

p99 (us)

Random read

45,283

0.185

2753.2

5472.3

Random write

42,317

0.173

2952.9

8159.2

Random 70/30

41,824

0.171

2991.0

6062.1

Writes land within 7% of reads. The two directions run different code – a read makes the controller issue RDMA WRITE into guest memory, a write makes it issue RDMA READ out of it – so the symmetry is the useful result: neither direction carries an extra copy or an extra round trip the other avoids.

Sequential#

1 MiB, queue depth 16, 4 jobs.

Pattern

IOPS

GB/s

Mean (us)

p99 (us)

Read

3,398

3.563

18008.4

32374.8

Write

2,359

2.474

26115.1

103284.7

Sequential read reaches roughly the same ceiling as the 256 KiB random case, confirming the limit is per-transfer overhead rather than locality. The sequential write p99 of 103 ms is the file-backed namespace reaching host writeback; a RAM-backed namespace does not show it.

Queue-depth sweep#

4 KiB random read, single job – the latency curve.

Queue depth

IOPS

Mean (us)

p99 (us)

1

2,867

334.3

378.9

2

7,204

263.6

403.5

4

11,237

337.5

411.6

8

20,191

379.0

493.6

16

39,036

394.3

733.2

32

46,042

678.8

1155.1

64

43,713

1445.7

2408.4

This is the most diagnostic table on the page. Queue depth 1 gives the unloaded round trip: 334 us for a 4 KiB read, covering the capsule SEND, the controller’s RDMA WRITE, and the response capsule. Throughput then scales close to linearly to depth 16 while latency stays near flat, so the pipeline is genuinely parallel rather than serialised behind one lock.

Saturation is at depth 32, around 46,000 IOPS. Past that the curve inverts: depth 64 delivers fewer IOPS at twice the latency, so 32 is the useful operating point and deeper queues only add waiting.

Data integrity#

A --verify=crc32c --verify_fatal=1 pass over 512 MiB of 4 KiB random writes completed with zero verification errors, at 28,519 write IOPS. Combined with the digest round trip in ansible/playbooks/nvmeof-tests.yml, this is the check that the keyed-SGL path moves the right bytes and not merely the right number of bytes.

Known Limits#

Sub-block I/O is rejected#

With the namespace formatted at 4 KiB, 512-byte and 1 KiB requests fail:

fio: io_u error on file /dev/nvme0n1: Invalid argument:
     read offset=2098144256, buflen=512

That is correct behaviour, not a defect – the block layer refuses I/O below the logical block size. Use bs=512 on the backend to measure the small-block path.

The MR budget caps the queue count#

This is the one to know about before changing queues=.

A stock nvme-rdma initiator runs with register_always, and allocates a pool of 128 memory regions per queue at connect time – admin queue included – rather than per I/O. An N-queue controller therefore needs (N + 1) * 128 regions before the first capsule moves, and the emulator’s tables hold a fixed ERNIC_MAX_MR = 2048 (src/ernic_limits.h, which sizes every table that holds a region and the count the guest is told). That puts the ceiling at 15 I/O queues, which ionic_datapath_nvmeof_cfg_check() enforces at startup:

Error: nvmeof backend: queues must be 1..15, one per 128
memory regions in a table of 2048 (got '16')

The table is not sized for the NVMe maximum of 64 queues on purpose: dp_reg_mr() walks the whole table on every fast registration – that is, on every command – so the constant is a per-I/O cost as well as a ceiling.

The measurements above predate that change and were taken at queues=4 against the old 1024-entry table. They have not been rerun. The change does not alter protocol behaviour once a connect has succeeded, but it is not free either: dp_reg_mr() walks the whole table on every fast registration, as above, so doubling the constant doubles that walk. Treat the figures as a baseline for the 1024-entry table rather than as current numbers.

Both CI lanes now aim the connect at the controller’s ceiling rather than letting the initiator choose. nvme-rdma defaults nr_io_queues to num_online_cpus(), so before the change the hosted lane – a 4-vCPU guest – negotiated four queues and needed only 544 regions, comfortably inside even the old 1024-entry table. That is why the lane stayed green through the first failure described below and never covered it. It did not escape the second: 544 regions overran the 256-entry handle map, so the four-queue lane was leaking regions silently the whole time. The connect sites therefore pass -i and -W: nvmf_nr_io_queues() is the sum of nr_io_queues, nr_write_queues and nr_poll_queues, each separately clamped to num_online_cpus(), and nvmf_parse_options() clamps -i to the CPU count a second time on its own, so -i 8 alone cannot lift a 4-vCPU guest past four. Eight of each reaches eight there, and on a larger guest the controller’s Set Features NUMBER_OF_QUEUES reply brings the total back down to eight. Both lanes then read /sys/class/nvme/nvmeN/queue_count back and fail if it does not match, because too few queues is not an error to nvme-cli and the shortfall would otherwise be invisible.

What they match against is min(2 * min(C, cpus), C) + 1, where C is the queues= the instance was started with, not a hard-coded nine: at the same queues=4 this page was measured at the lane should expect five, and a guest with fewer than C / 2 vCPUs cannot reach C however the flags are spent. Coming up short is legitimate and is logged rather than failed – but it does mean that run is not covering the full memory-region budget.

One correction to the arithmetic above, in the safe direction: the pool is sized queue->queue_size, and the admin queue’s is NVME_AQ_DEPTH = 32, not 128. The real demand is N * 128 + 32, so (N + 1) * 128 over-counts by 96 and the 2048-entry table and its 15-queue ceiling remain sound.

Historical note#

Before the memory-region budget was reworked, MAX_MR was 1024 – exactly the 8 * 128 regions the default queues=8 needs for its I/O queues, leaving nothing for the admin queue. The connect died on the last one:

nvme nvme0: creating 8 I/O queues.
ionic 0000:01:00.0 rocm-rdma-ernic0: opcode 3 error 16777216
nvme nvme0: failed to initialize MR pool sized 128 for QID 8
nvme nvme0: rdma connection establishment failed (-22)

Compounding it, the admin queue’s driver-id-to-handle map held only 256 entries, so every region past the 256th was created successfully and then had no map entry. CREATE_MR still reported success, DESTROY_MR could not resolve the handle and silently returned 0, and the regions leaked for the life of the process – which is why a second connect attempt failed earlier than the first, at QID 2 rather than QID 8.