instar bench — benchmark the sandboxed I/O path¶
instar bench issues a scripted sequence of read or write requests
against a disk image and reports how long they took — the sandboxed
equivalent of qemu-img bench.
Read this before trusting a number. instar bench does not
measure what qemu-img bench measures. qemu-img bench times qemu's
block layer running over the host page cache. instar bench times
instar's own end-to-end sandboxed I/O path: the guest format layer
(qcow2/vmdk/vhd/vhdx/raw parsing) → the virtio-block device → the
host's ioeventfd dispatch → the host I/O thread → the underlying file
I/O. These are different stacks doing different amounts of work, and
their absolute numbers are not comparable to each other. What is
useful and reproducible: running both tools against the same
image with the same arguments and comparing the two numbers —
that comparison isolates the sandbox's own overhead, and is the
methodology this document is built around (see "Sandbox-overhead
methodology" below).
Synopsis¶
instar bench [-c COUNT] [-d DEPTH] [-s BUFFER_SIZE] [-S STEP_SIZE]
[-o OFFSET] [-w] [--pattern NUM] [--flush-interval NUM]
[--no-drain] [-t CACHE] [-i AIO] [-n] [-f FMT] [-q] [-U]
[--image-opts] [--output {human,json}] FILENAME
Options:
-c, --count <COUNT> Number of requests to issue. Default 75000.
Range [1, 2147483647].
-d, --depth <DEPTH> Queue depth. Default 64. Range
[1, 2147483647]. Echoed for output parity;
execution is serial in v1 (see below).
-s, --buffer-size <SIZE> Bytes per request (suffixes accepted:
b/k/K/m/M/g/G/t/T/p/P/e/E). Default 4096.
Range [1, 2147483647], further capped at
2 MiB by instar.
-S, --step-size <SIZE> Byte offset advance per request (same
suffixes as -s). Default 0, meaning "use
the buffer size". Range [0, 2147483647].
-o, --offset <SIZE> The first request's byte offset (same
suffixes as -s). Default 0.
Range [0, 9223372036854775807] (i64::MAX).
-w Run a write test instead of a read test.
Destructive -- see "Write tests" below.
--pattern <NUM> Byte pattern used to fill write buffers.
Default 0. Range [0, 255]. Silently
ignored (not an error) on read tests.
--flush-interval <NUM> Issue a flush every N completed requests.
Default 0 (never). Range [0, 2147483647].
Requires -w when nonzero; must be >= depth.
--no-drain Accepted no-op -- v1's serial execution
always drains the (single-entry) queue.
-t, --cache <CACHE> Cache mode. Only 'writeback' (qemu's
default) is supported; other valid qemu
modes are refused as not-yet-supported.
-i, --aio <AIO> AIO backend selector. Every value is
refused; no backend is implemented yet.
-n Native AIO. Refused; not yet supported.
-f, --format <FMT> Format hint. Ignored -- the input format
is auto-detected, like dd's -f posture.
-q Suppress the progress indicator. instar
has none; accepted as a no-op.
-U, --force-share Force sharing. instar performs no image
locking; accepted as a no-op.
--image-opts Indicates FILENAME is a complete image
specification. Not yet supported.
--output <FORMAT> human (default) | json
FILENAME is the image to benchmark. The full flag surface is
reported by instar bench --help.
What the number means¶
The timing bracket is a host wall-clock measurement: it starts
when the guest emits a start marker and ends when the guest emits the
result message. The guest emits the start marker only after
config validation, the format probe/open, cached-state setup, and
buffer/transfer-plan setup are complete — immediately before
submitting the first request, mirroring qemu-img's own
gettimeofday bracket placement. Guest boot, image open, and table
loads all happen before the marker and are excluded from the
measured window.
Within that bracket, four caveats govern what the resulting number actually represents:
-dis echoed, not obeyed. The depth value is validated and reported in the header and in--output json'sdepthfield for output parity withqemu-img, but v1's guest driver is synchronous and single-buffer: every request is submitted, completed, and only then is the next one submitted.--output json'seffective-depthis always1, regardless of-d. A bench run at-d 64does not exercise 64-deep queuing; it exercises serialized, one-at-a-time I/O.- The virtio transport moves data in 64 KiB sectors. A request
whose offset or length is not aligned to that sector size works
through the covering sectors: reads fetch them and discard the
excess, writes perform a sub-sector read-modify-write. A single
bench "request" of
-sbytes may therefore correspond to several virtio round trips, not one. - Write-test metadata decisions happen inside the bracket. For
qcow2, every touched cluster's L1/L2 metadata is read uncached from
disk at the moment it is needed, and refcount write-back is
concentrated at flush points and again at the end of the run — all
of that work is inside the timed window.
qemu-imginstead amortises its metadata through an in-memory cache across the whole run. This means write timings are not like-for-like at fine grain between the two tools, even on identical arguments. - Only two comparisons are meaningful. Numbers are comparable across different instar versions, on the same host and image (the perf-regression use case). Numbers are comparable instar-vs-qemu on the same invocation, same image — that comparison is the sandbox-overhead measurement described next. Nothing else is comparable: not instar-vs-qemu across different arguments, not instar-vs-any-other-tool, and not the absolute number in isolation.
Sandbox-overhead methodology¶
Because instar bench and qemu-img bench measure genuinely
different stacks, the useful signal is not either number alone but
the ratio between them on an identical invocation. A worked
example:
$ qemu-img create -f qcow2 probe.qcow2 256M
$ for i in 1 2 3 4 5; do
qemu-img bench -f qcow2 -c 20000 -s 4096 probe.qcow2
done
$ for i in 1 2 3 4 5; do
instar bench -f qcow2 -c 20000 -s 4096 probe.qcow2
done
Take the median completion time from each set of five runs and
compare them (instar_median / qemu_median). Repeating several times
and comparing medians (rather than a single run) smooths out
scheduler jitter — both tools' single-run timings vary run to run on
a shared, busy host.
Which of the four caveats above apply depends on whether the invocation is a read or a write test:
- Read tests (no
-w): the serialized-depth caveat and the 64 KiB transport-granularity caveat both apply. The metadata-in- bracket caveat does not — nothing is written. - Write tests (
-w): all four caveats apply, and the metadata-in-bracket caveat dominates on allocating writes (fresh clusters, cluster-straddling requests) — the sandbox-overhead gap is measurably wider there than on simple overwrite-in-place writes, because instar's refcount write-back happens inside the bracket where qemu's does not.
Use -f explicitly on both sides of the comparison (as in the
example above) — an unspecified format on a raw image makes
qemu-img print an unrelated auto-detection warning to stderr that
has nothing to do with the measurement.
Write tests (-w)¶
-w is destructive. It mutates the target image in place,
with no confirmation prompt, exactly like qemu-img bench -w.
Point it only at a scratch image or a copy you are willing to lose.
The v1 write envelope¶
Write tests are supported on raw and qcow2 images (including qcow2 overlays with a backing chain) only. Any other discovered format — vmdk, vhd, vhdx — is refused before the guest launches (see Refusals and errors).
A qcow2 image must additionally pass every one of these gates, or the guest refuses the write test:
| Gate | Refuses when |
|---|---|
refcount_bits != 16 |
The image does not use 16-bit refcounts. |
| Compression | The image has the compression incompatible-feature bit set (or any unrecognised incompatible-feature bit). |
| Extended L2 | The image uses the extended-L2 (subcluster) feature. |
| External data file | The image stores data in an external file. |
| Encryption | The image is LUKS-encrypted (crypt_method != 0). |
| Dirty / corrupt | The image's dirty or corrupt incompatible bits are set. |
| Internal snapshots | The image has one or more internal snapshots (nb_snapshots > 0) — bench overwrites clusters without a per-write ownership check, so a snapshot-shared cluster must never be touched. |
Allocation never grows the refcount table; if a write test on an
otherwise-eligible qcow2 image exhausts the existing refcount table's
free clusters, the run refuses with bench: image too large for
in-place bench write (one 16-bit refblock at 64 KiB clusters covers
2 GiB of address space, so this is rare in practice but is a
documented v1 limitation, not a bug).
Write-back design¶
For each scheduled offset, bench writes bufsize bytes of the
--pattern byte at that virtual offset through the format layer. For
qcow2, an already-allocated, already-OFLAG_COPIED cluster is
patched in place (a sub-sector read-modify-write, no metadata
change); an unallocated cluster is allocated, filled with its current
virtual content (read through the backing chain, or zero-filled if
there is none), patched, and its L1/L2 entries are updated.
Metadata write-back is write-through for L2 and L1, staged for
refcounts, with refcounts written back last: a data cluster is
written to disk, then the L2 entry that makes it reachable, then (if
a new L2 table was allocated) the L1 entry — no fsync between these.
Refcount updates accumulate in staged refblocks and are written back
at each --flush-interval flush point and once more after the run
completes. This ordering means a crash mid-bench leaves, at worst,
leaked clusters (allocated and reachable via L2, but
under-counted in the refcount table — reported and repaired cleanly
by qemu-img check), and never a dangling L2 pointer into
unallocated space. This is the same benign artifact class a
qemu-img bench -w crash produces.
Overlay COW correctness¶
Writes against a qcow2 overlay never touch the parent image: all allocation and all data lands in the top image only, and every parent in the chain stays attached read-only. When an allocating write needs to fill a newly-allocated cluster's untouched bytes, it reads the cluster's current virtual content through the backing chain (falling through to the parent, or to zero if there is no parent) before patching the pattern window — this single rule both makes overlay COW correct and covers the zero-fill case for a genuinely fresh image.
Refusals and errors¶
All failure messages exit 1. Host-side checks run before the guest
launches; guest-side checks are mapped from the guest's structured
result code. Messages below are verbatim from the source
(validate_bench_args, run_bench, and map_bench_error in
src/vmm/src/main.rs).
Host-side (rejected before the guest runs)¶
Each of the seven numeric options has two failure forms: an
unparseable value produces the value-echoing form
(Invalid <name> specified: '<value>'.); an out-of-range number
produces the Must be between form. These checks run in the order
listed.
| Condition | Message |
|---|---|
-c unparseable |
Invalid request count specified: '<v>'. |
-c < 1 or > 2147483647 |
Invalid request count specified. Must be between 1 and 2147483647. |
-d unparseable |
Invalid queue depth specified: '<v>'. |
-d < 1 or > 2147483647 |
Invalid queue depth specified. Must be between 1 and 2147483647. |
-s unparseable |
Invalid buffer size specified: '<v>'. |
-s < 1 or > 2147483647 (qemu's bound fires first) |
Invalid buffer size specified. Must be between 1 and 2147483647. |
-s inside qemu's range but > 2 MiB |
bench: buffer sizes above 2 MiB are not yet supported |
-S unparseable |
Invalid step size specified: '<v>'. |
-S > 2147483647 (0 is valid) |
Invalid step size specified. Must be between 0 and 2147483647. |
-o unparseable |
Invalid offset specified: '<v>'. |
-o negative or > i64::MAX |
Invalid offset specified. Must be between 0 and 9223372036854775807. |
--pattern unparseable |
Invalid pattern byte specified: '<v>'. |
--pattern outside [0, 255] |
Invalid pattern byte specified. Must be between 0 and 255. |
--flush-interval unparseable |
Invalid flush interval specified: '<v>'. |
--flush-interval outside [0, 2147483647] |
Invalid flush interval specified. Must be between 0 and 2147483647. |
Nonzero flush interval without -w |
--flush-interval is only available in write tests |
| Nonzero flush interval < depth | Flush interval can't be smaller than depth |
-t a valid-but-unsupported qemu cache mode (none, writethrough, directsync, unsafe) |
bench: cache mode '<v>' is not yet supported |
-t anything else unrecognised |
Invalid cache mode |
-i any value |
bench: aio backend '<v>' is not yet supported |
-n |
bench: native AIO (-n) is not yet supported |
--image-opts together with -f/--format |
--image-opts and --format are mutually exclusive |
--image-opts alone |
bench: --image-opts is not yet supported |
| Filename count != 1 | Expecting one image file name + second line Try 'instar bench --help' for more info |
-w against a discovered format other than raw/qcow2 |
bench: write tests are not yet supported for <fmt> |
| Backing chain discovery failure (e.g. a zero-byte image) | error discovering backing chain for <file>: <detail> |
Guest-side (mapped from the structured result code)¶
| Condition | Message |
|---|---|
| I/O read failure | Failed request: Input/output error (bare, qemu-parity text) |
| Guest rejected the configuration | bench: guest rejected the configuration |
| Unsupported input format | bench: unsupported input format |
| Image parse failed | bench: failed to parse the image |
| I/O write failure | bench: writing back to the image failed |
| Flush failure | bench: flushing the image failed |
| Write test refused by the write-envelope gate | bench: write tests are not supported for this image (<reason>) (see the write-gate table below) |
| qcow2 allocation exhausted | bench: image too large for in-place bench write |
| No result received at all | bench: guest did not return a result |
| Result arrived with no preceding start marker | bench: guest sent a result without a start marker |
Write-gate reasons¶
<reason> in the ERROR_WRITE_UNSUPPORTED message above comes from
a small gate-id enum carried in the guest result's error_detail
field:
| Gate id | Reason text | Triggered by |
|---|---|---|
| 0 | format not supported |
A non-{raw, qcow2} format reaching the guest under -w (defence in depth; the host already refuses this before launch). |
| 1 | refcount_bits != 16 |
The qcow2 image does not use 16-bit refcounts. |
| 2 | compression |
Compression incompatible-feature bit set (or an unrecognised incompatible-feature bit); also raised mid-run if a compressed L2 entry is encountered. |
| 3 | extended L2 |
The qcow2 image uses extended L2 (subclusters). |
| 4 | external data file |
The qcow2 image stores data in an external file. |
| 5 | encryption |
The qcow2 image is LUKS-encrypted. |
| 6 | dirty or corrupt |
The qcow2 dirty or corrupt incompatible bits are set. |
| 7 | internal snapshots |
nb_snapshots > 0; also raised mid-run if an allocated cluster without OFLAG_COPIED (snapshot-shared) is found. |
| anything else | unsupported feature |
Reserved for future gate ids. |
--output json¶
--output json replaces the three human-readable lines entirely
— the default (human) path is byte-parity with qemu-img, and
json is a wholly separate rendering, not an addition to it.
| Key | Type | Notes |
|---|---|---|
filename |
string | The benchmarked image path, as given. |
format |
string | The discovered top-of-chain format. |
count |
integer | -c, as validated. |
depth |
integer | -d, as validated and echoed — not obeyed (see "What the number means"). |
effective-depth |
integer | Always 1: v1 executes serially regardless of -d. |
buffer-size |
integer | -s, in bytes. |
step-size |
integer | The effective step: -S's value, or buffer-size when -S was 0/unset. |
offset |
integer | -o, in bytes. |
write |
boolean | Whether this was a write test. |
pattern |
integer | --pattern's byte value (0-255). |
flush-interval |
integer | --flush-interval, as validated. |
no-drain |
boolean | --no-drain's presence. |
flushes-issued |
integer | The number of flushes the guest actually issued. |
elapsed-seconds |
number | The host timing bracket, at microsecond precision (six decimal places) — finer than the human line's three-decimal %.3f rounding. |
requests-per-second |
number | Derived: count / elapsed-seconds. |
bytes-per-second |
number | Derived: count * buffer-size / elapsed-seconds. |
The derived rate fields are computed once and included directly (rather than left for a consumer to recompute) so a downstream perf-tracking job never has to reproduce the division and risk rounding drift against the value instar itself reports.
Known divergences from qemu-img bench¶
Each entry below is recorded in the KNOWN_BENCH_DIVERGENCES
registry in tests/test_bench.py, named
by its registry key, so a cross-validation mismatch that is not
registered there is treated as a real regression.
depth-serialized.-dis echoed in the header for output parity, but bench executes serially in v1;--output jsonalways reports"effective-depth": 1regardless of the requested depth.wrap-rule-10-0-8. instar adopts qemu master's fixed wrap rule (offset %= image_size - bufsize) so a wrapped request never overruns EOF; qemu-img 10.0.8 still ships the older% image_sizerule and can hitFailed request: Input/output errornear EOF on a request that wraps past the last full buffer. Observed on both the read path (a 10240-byte image, phase 1) and the write path (a cluster-straddling schedule wrapping past a 10 MiB raw image, phase 5).cache-modes-refused.-twith any cache mode other than the defaultwritebackis refused (bench: cache mode '<v>' is not yet supported);qemu-imgruns every valid cache mode (e.g.-t none) successfully.aio-refused.-i <anything>is refused (bench: aio backend '<v>' is not yet supported);qemu-imgruns with any recognised aio backend.native-aio-refused.-nis refused (bench: native AIO (-n) is not yet supported);qemu-imgalso fails for-nalone, but for an unrelated host requirement (aio=native ... requires cache.direct=on) — both fail, for different reasons, not a "qemu succeeds" divergence.image-opts-refused.--image-optsalone is refused (bench: --image-opts is not yet supported);qemu-imgruns it. (--image-optscombined with-f/--formatis qemu parity: both refuse with the same mutual-exclusion text.)bufsize-cap-2mib.-svalues inside qemu's[1, 2147483647]range but above 2 MiB are refused (bench: buffer sizes above 2 MiB are not yet supported);qemu-imgruns them (confirmed live with-s 3M).help-hint-names-instar. The filename-count error's second line names instar, not qemu-img:Try 'instar bench --help' for more infovs qemu'sTry 'qemu-img bench --help' for more info.zero-byte-early-failure. instar fails during backing-chain discovery for a zero-byte image, before any header ever prints (error discovering backing chain for <file>: ...); qemu-img prints the header unconditionally then fails the first request (Failed request: Input/output error). Both exit 1, but the failure point and text are unrelated.write-formats-limited.-wis refused on vmdk/vhd/vhdx (bench: write tests are not yet supported for <fmt>);qemu-imgwrites all of them. instar v1 only supports write tests on raw and qcow2.secure-raw-detection. A headerless raw image (no MBR or other recognisable signature) is refused as an unsupported format — instar's security posture requires a recognisable signature before a file is treated as raw;qemu-imgbenches it happily.
Examples¶
Default read test (defaults: 75000 requests, 4 KiB each, depth 64):
$ instar bench -f raw disk.raw
Sending 75000 read requests, 4096 bytes each, 64 in parallel (starting at offset 0, step size 4096)
Run completed in 2.841 seconds.
A smaller, illustrative run:
$ instar bench -c 100 -f raw disk.raw
Sending 100 read requests, 4096 bytes each, 64 in parallel (starting at offset 0, step size 4096)
Run completed in 0.004 seconds.
The Ceph cli_migration.sh write invocation
(qemu-img bench -f qcow2 -w -c 65536 -d 16 --pattern 65 -s 4096),
adapted to instar. -d 16 is accepted and echoed but every request
still runs serially (see "What the number means") — instar exercises
the same request sequence as the Ceph script, not the same queuing
behaviour:
$ instar bench -w -c 65536 -d 16 --pattern 65 -s 4096 -f qcow2 disk.qcow2
Sending 65536 write requests, 4096 bytes each, 16 in parallel (starting at offset 0, step size 4096)
Run completed in 11.247 seconds.
--flush-interval on a write test (the "Sending flush every..." line
only appears when the interval is nonzero):
$ instar bench -w -c 100 --pattern 65 --flush-interval 50 -d 1 -f raw disk.raw
Sending 100 write requests, 4096 bytes each, 1 in parallel (starting at offset 0, step size 4096)
Sending flush every 50 requests
Run completed in 0.016 seconds.
--output json:
$ instar bench -c 100 -f raw --output json disk.raw
{
"filename": "disk.raw",
"format": "raw",
"count": 100,
"depth": 64,
"effective-depth": 1,
"buffer-size": 4096,
"step-size": 4096,
"offset": 0,
"write": false,
"pattern": 0,
"flush-interval": 0,
"no-drain": false,
"flushes-issued": 0,
"elapsed-seconds": 0.005576,
"requests-per-second": 17934.38,
"bytes-per-second": 73459203.96
}
Refused — a validation error (live transcript):
$ instar bench -c 0 -f raw disk.raw
Error: "Invalid request count specified. Must be between 1 and 2147483647."
Refused — a write-gate error on a qcow2 image with an internal snapshot (live transcript; note the header still prints before the guest-side gate fires):
$ instar bench -w -c 10 -f qcow2 disk.qcow2
Sending 10 write requests, 4096 bytes each, 64 in parallel (starting at offset 0, step size 4096)
Error: "bench: write tests are not supported for this image (internal snapshots)"
Future work¶
- True queue depth. The guest driver is synchronous
single-buffer in v1; a real in-flight-request queue would make
-ddo something, at the cost of a materially more complex guest driver. - TSC-based per-request latencies. The current bracket reports one aggregate elapsed time; per-request latency histograms would need a cheap in-guest clock source.
-t none/ O_DIRECT. Bypass the host page cache, matching qemu's-t nonecache mode, instead of refusing it.- vmdk/vhd/vhdx writes. Extend the write envelope beyond raw/qcow2 once each format's allocating-write path exists.
- A CI perf-tracking job. Consume
--output json's derived rates to catch performance regressions across instar versions — the reason the JSON schema includes precomputed rates today. - Buffers above 2 MiB. Lift
BENCH_MAX_BUFSIZEonce a larger scratch/staging budget is available. - An explicit host-path measurement mode. A mode that measures the host's own I/O path directly (bypassing the sandbox) would let the sandbox-overhead ratio be computed without a second qemu-img invocation.
For the request-schedule crate, see
src/crates/bench/. For the guest operation,
see src/operations/bench/. For the
divergence registry, see KNOWN_BENCH_DIVERGENCES at the top of
tests/test_bench.py. See also
usage.md.