Lab 1: Virtio-Block, End to End
Background
This is a trace-it-and-measure lab. You will build a backing file from scratch, attach it as a
second drive to a microVM, drive real I/O against it with dd and fio from inside the guest, and
then follow a single block request through every hop: the guest driver's descriptor chain, the MMIO
kick, the ioeventfd, the device's queue handler on the VMM thread, the host pread/pwrite on the
backing file's fd, the used-ring completion, and the injected interrupt. Then you will switch the
block device's io_engine from Sync (plain pread/pwrite) to io_uring (Async) and
measure the difference with real numbers and real syscall traces.
You traced block once in Level 7 Lab 7.1. This
lab goes further: you control the backing file (so you can correlate exact offsets), you run a proper
fio workload (so the numbers mean something), and you compare the two io engines head to head. The
goal is that "a guest disk read" becomes, in your head, a precise sequence of named functions and one
host syscall at a known offset.
Why This Lab Matters for Contributors
- Almost every block-device issue — a hang, a wrong byte count, a rate-limiter accounting bug, an
io_uring-vs-Sync discrepancy, a partial-read edge case — is debugged by walking exactly this path and reading exactly these syscalls. The issue-roadmap virtio stage is full of them. - The Sync-vs-
io_uringcomparison is a real engineering decision operators make; you should be able to explain the trade-off with measurements, not adjectives. See the io-engines engineering chapter. - It cements three deep dives: virtqueues (the rings), virtio-transport-mmio (the kick), and virtio-block (the request semantics and engines).
Prerequisites
- The masterclass overview read; Level 7 complete.
- A built
firecrackerand a kernel + rootfs you can boot (Lab 1.3). fioavailable inside the guest (most Ubuntu rootfs images have it or canapt install fio;ddalways works as a fallback).- Verify the block device, the queue, the io engines, and the host I/O calls exist on your branch:
# All must return hits. If a path is empty, the layout moved — find by role.
rg -l "impl VirtioDevice for" src/vmm/src/devices/virtio/block/
rg -n "fn process_queue|fn process|fn handle_.*event|request_queue" \
src/vmm/src/devices/virtio/block/
rg -n "io_engine|IoEngine|FileEngine|Sync|Async|io_uring" src/vmm/src/devices/virtio/block/
rg -n "pread|pwrite|read_exact_at|write_all_at|read_at|write_at" \
src/vmm/src/devices/virtio/block/
The Path You Are Tracing
sequenceDiagram
participant GK as Guest kernel (virtio-blk driver)
participant GM as Guest memory (virtqueue)
participant KVM as KVM
participant EM as EventManager (VMM thread)
participant BLK as Block device (VirtioDevice)
participant FS as Backing file (host fd)
GK->>GM: 1. build chain: header | data | status
GK->>GM: 2. avail.ring[idx]=head; avail.idx++
GK->>KVM: 3. MMIO write to QueueNotify (the kick)
KVM-->>EM: 4. ioeventfd signals → epoll wakes VMM thread
EM->>BLK: 5. queue handler runs (process_queue)
BLK->>GM: 6. Queue::pop → walk chain, bounds-check
BLK->>BLK: 7. parse virtio_blk_req (type, sector)
BLK->>FS: 8. pread/pwrite (Sync) OR io_uring submit (Async)
FS-->>BLK: 9. bytes / completion
BLK->>GM: 10. write data into WRITE descriptor (on a read)
BLK->>GM: 11. write status byte = VIRTIO_BLK_S_OK
BLK->>GM: 12. add_used(head, len); used.idx++
BLK->>KVM: 13. raise IRQ via irqfd; InterruptStatus |= USED_RING
KVM-->>GK: 14. guest interrupt; driver reaps used ring
Each numbered step below maps to this diagram.
Step-by-Step Tasks
Step 1: Build a backing file you control
A virtio-block drive in Firecracker is just a flat file on the host exposed to the guest as a block device. Make one, give it recognizable contents so you can spot it in a trace, and format it.
WORK=/tmp/blk-lab; mkdir -p "$WORK"; cd "$WORK"
# A 256 MiB scratch disk, pre-allocated (not sparse) so reads actually hit the file.
dd if=/dev/zero of=scratch.ext4 bs=1M count=256 status=progress
# Put a filesystem on it and seed a marker file at a known place.
mkfs.ext4 -q scratch.ext4
mkdir -p mnt && sudo mount -o loop scratch.ext4 mnt
echo "FIRECRACKER-BLOCK-LAB-MARKER" | sudo tee mnt/marker.txt >/dev/null
# A 16 MiB file of a repeating byte pattern, for predictable read offsets.
sudo dd if=/dev/zero bs=1M count=16 2>/dev/null | tr '\0' '\125' | sudo tee mnt/pattern.bin >/dev/null
sudo umount mnt
ls -l scratch.ext4
# Note the absolute path; you will pass it to /drives and watch its fd in strace.
echo "BACKING=$WORK/scratch.ext4"
Note: Use a non-sparse file (
ddof real zeros, nottruncate/fallocate --punch-hole) so that guest reads actually transfer bytes from disk and yourstraceoffsets are real. A sparse hole would be served from the page cache as zeros and muddy the measurement.
Step 2: Boot a microVM with a logger and the scratch drive
Boot from your build, configure a logger so trace lines are visible, attach the rootfs and the
scratch drive, and start. Keep the io_engine unset for now (defaults to Sync — verify on your
branch).
ARCH=$(uname -m)
BIN=build/cargo_target/${ARCH}-unknown-linux-musl/debug/firecracker # from your repo root
API=/tmp/fc-blk.sock
LOG=/tmp/fc-blk.log
rm -f "$API"; : > "$LOG"
sudo "$BIN" --api-sock "$API" &
curl -X PUT --unix-socket "$API" --data \
'{"log_path":"'"$LOG"'","level":"Warning","show_level":true,"show_log_origin":true}' \
http://localhost/logger
curl -X PUT --unix-socket "$API" --data \
'{"kernel_image_path":"./vmlinux-6.1.x","boot_args":"console=ttyS0 reboot=k panic=1"}' \
http://localhost/boot-source
curl -X PUT --unix-socket "$API" --data \
'{"drive_id":"rootfs","path_on_host":"./ubuntu-24.04.ext4","is_root_device":true,"is_read_only":false}' \
http://localhost/drives/rootfs
# The scratch drive. Sync engine is the default; we set it explicitly for clarity.
curl -X PUT --unix-socket "$API" --data \
'{"drive_id":"scratch","path_on_host":"/tmp/blk-lab/scratch.ext4","is_root_device":false,"is_read_only":false,"io_engine":"Sync"}' \
http://localhost/drives/scratch
curl -X PUT --unix-socket "$API" --data \
'{"vcpu_count":2,"mem_size_mib":1024}' http://localhost/machine-config
curl -X PUT --unix-socket "$API" --data '{"action_type":"InstanceStart"}' http://localhost/actions
Tip: Confirm the API accepts
io_engineon your branch before you depend on it:rg -n "io_engine|IoEngine|Sync|Async" src/firecracker/swagger/firecracker.yamlIf
io_engineis rejected, your branch may name it differently or gate it —rgthe swagger and thevmm_configdrive parsing (rg -n "io_engine" src/vmm/src/vmm_config/).
Inside the guest, the scratch drive appears as the second virtio-block device (/dev/vdb; the rootfs
is /dev/vda):
# On the guest serial console:
lsblk
# vda ... / <- rootfs
# vdb ... <- your scratch drive
ls -l /sys/block/vdb
Step 3: Instrument the device's queue handler
Add three temporary log points to the block device so you can see each I/O. Find the exact function and field names first — they drift between branches.
# The queue handler the EventManager calls on a kick.
rg -n "fn process_queue|fn process\b|fn handle_.*event|request_queue" \
src/vmm/src/devices/virtio/block/
# The request struct/fields (type, sector) and the status constants.
rg -n "struct Request\b|struct RequestHeader|sector|request_type|r#type|VIRTIO_BLK_T_IN|VIRTIO_BLK_T_OUT|VIRTIO_BLK_S_OK" \
src/vmm/src/devices/virtio/block/
Add minimal log lines at three points (adjust field names to what rg showed):
#![allow(unused)] fn main() { // 1. Top of the queue handler — the EventManager just woke us (steps 4–5). log::warn!("[trace] block kick: queue event fired"); // 2. After parsing each request header (step 7). log::warn!( "[trace] block req: type={:?} sector={} len={}", request.request_type, request.sector, data_len ); // 3. Right after the host I/O completes, before add_used (steps 9–11). log::warn!("[trace] block io done: status=OK len={}", len); }
Rebuild and re-boot with the new binary (re-run Step 2):
tools/devtool build # debug build is fine for tracing
Warning: These log lines run on the VMM thread, which is the data plane for every device. Keep them to one cheap
log::warn!each — no allocation, no formatting of large buffers, nothing blocking. And remove them before any PR; trace instrumentation is never part of a contribution.
Step 4: Generate a controlled read and correlate it in the trace
In another terminal, tail the log. Then issue a read you control from inside the guest, bypassing the page cache so each block truly hits the device.
# Host terminal:
tail -f /tmp/fc-blk.log
# Guest serial console — direct read of the scratch device, 256 × 4 KiB blocks:
dd if=/dev/vdb of=/dev/null bs=4096 count=256 iflag=direct
In the log you should see a burst of:
[Warning] [trace] block kick: queue event fired
[Warning] [trace] block req: type=In sector=0 len=4096
[Warning] [trace] block io done: status=OK len=4096
[Warning] [trace] block req: type=In sector=8 len=4096
...
The type is a read (In), the sector values ascend by 8 (4096 bytes ÷ 512 bytes/sector = 8
sectors), and each len is 4096. This is steps 6–12 of the diagram, made visible.
Step 5: Confirm the host syscall and match the offset
Now nail the host side: which syscall served that read, on which fd, at what offset. With the firecracker process running, strace the I/O syscalls while you repeat the guest read.
# Host: find the process, then strace its threads' I/O syscalls.
pgrep -a firecracker
sudo strace -f -e trace=pread64,pwrite64,preadv,pwritev,preadv2,pwritev2,io_uring_enter \
-p "$(pgrep -n firecracker)" 2>&1 | grep -E 'pread|pwrite|io_uring' | head -40
# Guest: a read at a known LBA so you can compute the host offset.
# skip=1024 blocks of 4096 = byte offset 1024*4096 = 4194304 in the device,
# which is host file offset 4194304 in scratch.ext4.
dd if=/dev/vdb of=/dev/null bs=4096 count=4 skip=1024 iflag=direct
You should see, on the Sync engine, lines like:
[pid NNNN] pread64(14, "...", 4096, 4194304) = 4096
[pid NNNN] pread64(14, "...", 4096, 4198400) = 4096
...
Map it: guest device byte offset skip*bs = 1024*4096 = 4194304 equals the host pread64 offset
4194304 on fd 14 (the scratch file's fd). That is step 8 — the guest's read() became a host
pread at exactly sector * 512 on a flat file. Confirm fd 14 is your backing file:
sudo ls -l /proc/$(pgrep -n firecracker)/fd | grep scratch.ext4
Note: Without the jailer the backing-file path and fd are visible directly under
/proc/<pid>/fd. Under the jailer the process is chrooted and possibly PID-namespaced; that is a later concern. Run firecracker directly for this lab.
Step 6: Trace a write, and notice the descriptor-direction flip
Writes are the mirror image. Drive a write into the scratch device and watch type=Out and host
pwrite64.
# Guest: write 4 MiB of a pattern straight to the device (DESTROYS the fs on vdb — that's fine,
# it's a scratch disk; remount/reformat if you want it back).
dd if=/dev/zero of=/dev/vdb bs=4096 count=1024 oflag=direct
# Your trace:
[Warning] [trace] block req: type=Out sector=... len=4096
# strace:
[pid NNNN] pwrite64(14, "...", 4096, ...) = 4096
The key conceptual point: on a read, the data descriptor carries the WRITE flag (the device
writes the bytes it read into guest memory); on a write, the data descriptor is
device-readable (no WRITE flag — the device reads the bytes out of guest memory and pwrites
them). This is the direction-bit confusion from the virtqueues deep
dive. Add a temporary trace of the chain's descriptor flags to see
it for yourself.
Step 7: Switch to the io_uring engine and re-trace
Now change the engine. Tear down the VM, and re-attach the scratch drive with io_engine: "Async"
(the io_uring backend — verify the spelling on your branch).
# Stop the VM (Ctrl-C the firecracker process or send a reset), reformat the scratch disk,
# then re-boot exactly as in Step 2 but with the scratch drive's io_engine set to Async:
curl -X PUT --unix-socket "$API" --data \
'{"drive_id":"scratch","path_on_host":"/tmp/blk-lab/scratch.ext4","is_root_device":false,"is_read_only":false,"io_engine":"Async"}' \
http://localhost/drives/scratch
Re-run the strace from Step 5 during a guest read. On the Async engine you will see
io_uring_enter instead of pread64/pwrite64 — the device submits SQEs (submission queue entries)
to a kernel io_uring ring and reaps CQEs (completions), rather than issuing one blocking syscall per
I/O:
[pid NNNN] io_uring_enter(15, 1, 0, 0, NULL, 8) = 1
[pid NNNN] io_uring_enter(15, 4, 0, 0, NULL, 8) = 4 # multiple I/Os submitted in one syscall
Find how Firecracker drives the ring — there is a completion eventfd registered with the
EventManager so reaped completions wake the VMM thread the same way a kick does:
rg -n "io_uring|IoUring|submit|EnterFlags|completion|cqe|sqe|Async" src/vmm/src/devices/virtio/block/
rg -n "io_uring" src/vmm/src/io_uring/ 2>/dev/null # FC has historically vendored a small io_uring wrapper — rg to confirm
Step 8: Benchmark Sync vs io_uring with fio
Numbers, not adjectives. Run the same fio workload under each engine and compare. Use a queue depth
1 so
io_uring's batching can show — at QD=1 the engines are near-identical because there is no inflight parallelism to exploit.
# Inside the guest, on the scratch device (vdb). Random 4k reads, queue depth 32, direct I/O:
fio --name=randread --filename=/dev/vdb --rw=randread --bs=4k --iodepth=32 \
--ioengine=libaio --direct=1 --size=128m --runtime=30 --time_based --group_reporting
Run it once with the host-side scratch drive on Sync, reboot with Async, run it again. Record
IOPS, bandwidth, and average/99th-percentile latency from the fio summary for each. Then re-run the
strace during each fio and count syscalls:
# Host, during each fio run: how many I/O syscalls in 5 seconds?
timeout 5 sudo strace -f -c -e trace=pread64,pwrite64,preadv,pwritev,io_uring_enter \
-p "$(pgrep -n firecracker)" 2>&1 | tail -20
You are looking for the structural difference: Sync issues roughly one pread64/pwrite64 per
I/O on the VMM thread (each a blocking syscall in the critical path); Async issues far fewer
io_uring_enter calls because it batches many in-flight requests per syscall and reaps completions
asynchronously. Whether that translates to higher IOPS depends on the workload, the queue depth, and
the host storage — which is exactly the point of measuring rather than assuming.
Implementation Requirements / Deliverables
-
A non-sparse backing file you created, formatted, and seeded; its absolute path and the guest
device it became (
/dev/vdb). -
A reading log naming the file:function for each hop: the
Blockstruct,activate, the queue handler, the request parser, and the host I/O call — each with thergyou used. -
Trace output from a controlled
ddread showingtype=In, ascending sectors, and 4096-byte lengths. -
straceoutput showing the hostpread64at an offset you computed from the guest LBA, on the fd you confirmed is the scratch file. -
Trace output from a write showing
type=Outand hostpwrite64, plus a one-sentence explanation of the descriptor-direction flip. -
A Sync-vs-
io_uringcomparison table: the strace syscall structure (pread64/pwrite64vsio_uring_enter) and thefioIOPS / bandwidth / latency for each. - All trace instrumentation removed from your working tree.
Troubleshooting
No [trace] lines appear
The logger must be configured before InstanceStart, with level at or below Warning. Confirm
Firecracker's own startup lines are in the log first. Then check your trace is in the queue handler,
not in activate (which runs once). If you rebuilt but did not re-launch the new binary, you are
still running the old one — re-check the path under build/cargo_target/....
strace -p shows nothing on the backing file
Three causes: you are on the io_uring engine (trace io_uring_enter too); the reads are served from
the guest page cache (use iflag=direct / --direct=1); or you are stracing the wrong thread (use
-f to follow all threads). The host I/O happens on the VMM thread, not the API thread.
io_engine: "Async" is rejected
io_uring requires a host kernel new enough to support the operations Firecracker uses, and the field
may be gated or spelled differently on your branch. rg -n "io_engine|Async|io_uring" src/firecracker/swagger/firecracker.yaml src/vmm/src/vmm_config/ to see the exact accepted values and
any version guard. On an old host kernel, fall back to Sync and note the limitation.
fio shows no difference between engines
You are probably at queue depth 1, where there is no in-flight parallelism for io_uring to batch.
Raise --iodepth to 32+ and ensure --direct=1. Also confirm the host storage is not the bottleneck
(a slow disk hides any VMM-side difference) — try a tmpfs-backed file to isolate the device path.
The guest sees the scratch disk as read-only / missing
Check is_read_only in the /drives/scratch PUT and that path_on_host is correct and readable by
the firecracker process. lsblk in the guest must show vdb; if not, the drive PUT failed — re-issue
it and read the curl response.
Expected Output
# Trace during a controlled read (Sync engine):
[Warning] [trace] block kick: queue event fired
[Warning] [trace] block req: type=In sector=8192 len=4096
[Warning] [trace] block io done: status=OK len=4096
# strace, Sync engine — one pread per I/O at sector*512:
[pid NNNN] pread64(14, "...", 4096, 4194304) = 4096
# strace, Async engine — batched submissions:
[pid NNNN] io_uring_enter(15, 8, 0, 0, NULL, 8) = 8
# A representative fio comparison table you produce (numbers are host-dependent):
Engine IOPS(4k randread QD32) BW avg lat p99 lat I/O syscalls/5s
Sync ~K ~MB/s ~us ~us ~ one pread per I/O
io_uring ~K (often higher) ~MB/s ~us ~us ~ far fewer io_uring_enter
Stretch Goals
- Count descriptors per chain. Trace the number of descriptors and their flags per request. Confirm the three-descriptor header/data/status shape, and find a request that uses multiple data descriptors (a large contiguous read the guest split).
- Watch the interrupt side. Find where the device raises the IRQ (the
IrqTrigger/InterruptStatus/ irqfd path), add a trace there, and correlate it tocat /proc/interrupts | grep virtiocounts in the guest moving. See interrupts-and-irqchip. - Apply a rate limiter. PATCH the scratch drive with a bandwidth rate limiter and watch the
fiobandwidth cap and the syscall cadence change. Read rate-limiting-token-bucket; this is the same limiter Lab 2 applies to net. - read-only and flush. Attach the drive
is_read_only: true, attempt a guest write, and trace theVIRTIO_BLK_S_IOERRstatus path. Then trace afsyncfrom the guest and find theVIRTIO_BLK_T_FLUSHrequest and its hostfsync/fdatasync. - Measure the no-exit kick. Reason quantitatively: how many VM exits would a naive
(no-
ioeventfd) design take per I/O versus theioeventfddesign? Tie it to the the-event-manager epoll loop.
Validation / Self-check
Answer without notes; these gate completion.
- Which ring does the guest write to hand the device a request, and which does the device write on
completion? Who bumps
avail.idxand who bumpsused.idx? - What are the three descriptors in a block-read chain, and which carry the
WRITEflag? Why does the data descriptor carryWRITEon a read but not on a write? - Given a guest
dd ... bs=4096 skip=1024, what host file offset do you expect in thepread64, and why? - What exactly happens between the guest writing
QueueNotifyand the block device's queue handler running? Where doesioeventfdremove a VM exit? - Structurally, how does the host syscall pattern differ between the Sync and
io_uringengines, and under what workload does that difference show up infio? - If you removed the
add_usedcall from the handler, what would the guest observe, and why? - Why must the queue handler stay fast and non-blocking, and which thread runs it?
Cross-references: virtqueues, virtio-transport-mmio, virtio-block, io-engines, the-event-manager.
Next: Lab 2 — Virtio-Net and TAP: stand up a host TAP, route it, SSH into the guest, and trace an RX/TX frame across the TAP fd and the two virtqueues.