A single application write may be split into multiple device commands, or it may remain in the cache. Along the block layer, you must track data ranges, queueing positions, and completion promises separately; you cannot directly equate application IOPS with the number of device commands.
Goal
Location
bio, units, splitting, and merging
Sections 1–2
Multi-queue, batching, and scheduling
Sections 3–5
Queue depth, persistence, and observation
Sections 6–9
1. One write does not equal one device command
Buffered writes typically modify the page cache first; subsequent writeback generates block I/O. Cache-hit reads may not access the device at all. Direct I/O bypasses some cache paths, but alignment, filesystem, and device constraints may still cause a single call to be broken down into multiple requests.
A bio describes a block data operation and its memory range; it is not an atomic unit of a disk transaction. It can be cloned, split, and remapped; multiple compatible bios can be merged into the same request. Stacking layers such as device-mapper and RAID may also generate I/O directed at different lower-layer devices.
In modern kernels, the current position and remaining length are mainly described by bi_sector and bi_size in bio.bi_iter, and bi_status is of type blk_status_t. Do not treat the pseudo-structure of old flat members as the currently compilable API.
Three types of "segments"
bio_vec describes the memory range, including the starting page, offset, and length; modern multipage bvecs can span pages and do not necessarily correspond one-to-one with a single page.
The bio iterator describes the portion currently being processed; you cannot deduce the remaining I/O solely based on bi_vcnt.
The DMA scatter-gather segments ultimately used by the drivers undergo mapping and merging, and their count need not match the original bvec count.
The same device byte position uses different units at different layers:
Expression Level
Representation of 1 MiB Start
Byte offset
1048576
Linux block layer 512-byte sector
2048
4096-byte logical block NVMe LBA
256
The block layer sector is fixed at 512-byte counts; this does not imply that the device's logical block must also be 512 bytes.
Two consecutive 4 KiB bios, with device intervals [2048,2056) and [2056,2064) sectors respectively; if the operation, flags, queue limits, etc., are compatible, they may be merged into a single 8 KiB request. Whether the physical memory addresses are adjacent is not the only condition, because requests can use SG (scatter-gather) to represent non-contiguous memory.
Conversely, a 256 KiB bio may be split into multiple requests on paths that allow only 128 KiB transfers, or have stricter segment counts/boundaries. The constraints are not just total bytes, but also include maximum segment count, single-segment length, boundaries, alignment, and special constraints for different operation types.
Therefore, application IOPS, bio count, request count, and device command count are not the same metric. An increased merge rate may reduce command overhead, but this does not mean the number of business requests has decreased.
3. blk-mq: Multi-entry parallelism, not completely lock-free
blk_mq_ctx provides a software submission context per CPU; blk_mq_hw_ctx manages the state for driver dispatch queues. CPUs select hctx based on queue mapping; read, write, poll, and other queue types can also have different mappings.
hctx is a software structure, not the SQ memory directly read by the controller. The driver converts requests into device commands and places them into its own queues. The number of software queues, hardware queues, CPUs, and interrupt vectors are not guaranteed to be equal, and you cannot describe ctx as a generic per-cgroup queue.
Multi-queues disperse shared hotspots, but the queue list, scheduler, tag allocation, and driver may still require locks or atomic operations. A scheduler may also maintain shared state across the request_queue, such as the scheduling state of mq-deadline; you cannot uniformly state that "all schedulers run independently on each hctx." See the blk-mq documentation.
Tags and Backpressure
Tags are used to identify and manage in-flight requests, allowing the completion path to locate the request. There are different configurations such as scheduler tags, driver tags, and shared tag sets; you cannot assume that each request occupies the same fixed hardware tag from creation to completion.
When resources are insufficient, requests may wait, be delayed in dispatch, re-queued, or return with NOWAIT conventions indicating they cannot be executed immediately; this is different from returning a media error after the device has received the command. After accepting a request, the driver must still complete it according to the protocol; do not confuse "temporarily busy" with "owns the request but will complete it later."
4. Plug: Short-term batching, not transactions
Plug associates with the current task, temporarily storing requests suitable for batching, increasing merge opportunities, and reducing per-request dispatch overhead. The basic call form is as follows; this is a local structure within the existing kernel submission path, not for user-space programs:
struct blk_plug plug;blk_start_plug(&plug);/* Submit bios here that are correctly established and managed for their lifetime. */blk_finish_plug(&plug);
The parameter is struct blk_plug *, not current. Plug does not guarantee that all I/O will be held until finish: conditions such as blocking scheduling and capacity can prompt early dispatch. It does not guarantee atomic completion for the entire batch, nor does it guarantee completion in submission order.
While batching increases throughput, it may also increase wait times, so the batch size cannot be expanded indefinitely. Dependencies between reads and writes, flushes, and filesystem transaction ordering are still guaranteed by their respective mechanisms.
5. Schedulers address policy trade-offs
Scheduler
Core Idea
Cost to Verify
none
Does not introduce additional elevator sorting strategies
Still has block layer/driver queueing; does not imply no merging or waiting
mq-deadline
Combines address sorting with expiry queues to balance read/write waits
Expire is a scheduling target, not a hard real-time upper limit for hardware completion
kyber
Limits in-flight requests for different classes based on latency feedback
Token controls concurrency, not a simple fixed-rate token bucket
BFQ
Provides bandwidth, fairness, and responsiveness based on entity budgets and weights
Trade-offs exist between fairness/interactivity optimization, queue wait times, and throughput
Do not replace measurement with "NVMe must use none, desktop must use BFQ." Storage media, device internal concurrency, mixed read/write patterns, cgroups, background writeback, and CPU costs will change the conclusions.
read_expire / write_expire do not guarantee the maximum latency from application submission to completion; writes_starved controls the read/write selection strategy and is not an SLA for each business. Scheduler parameter units and available options should be read from the actual sysfs. Refer to mq-deadline, Kyber, and BFQ.
6. Quantitative relationships between queue depth, throughput, and latency
Under a stable system and the same measurement boundary, use Little's Law L = λ × W:
Input
Value
Completion rate λ
100000 IOPS
Average response time W
200 μs = 0.0002 s
Average in-flight count L
100000 × 0.0002 = 20
Align statistical boundaries first, then interpret this mean.
This 20 is an observed mean, not a requirement to set a specific sysfs parameter to 20. Queues fluctuate, and both software and hardware have their own quotas. Increasing concurrency may improve throughput when the device is not saturated; after saturation, increasing depth further often mainly increases queueing and tail latency.
Similarly, 100000 data operations of 4096 bytes each amount to approximately 409.6 MB/s, or 390.625 MiB/s. IOPS must be interpreted in conjunction with I/O size, read/write ratio, synchronous semantics, and latency.
In the simplified closed queue below, increase service parallelism and then request depth. After the throughput plateau, locate the extra requests.
λ=dmin(Q,K),W=λQ
This ideal closed-loop model keeps Q requests in flight, with K service slots and service time d. λ is completions per second; W is average time within the same boundary.
Preparing the visual
After throughput plateaus, where does extra depth go?
Before saturation, concurrency can raise completion rate. After service slots fill, extra work mainly adds waiting. Little’s law uses means measured over the same boundary.
{"id":"io-depth-throughput-latency","title":"After throughput plateaus, where does extra depth go?","summary":"Before saturation, concurrency can raise completion rate. After service slots fill, extra work mainly adds waiting. Little’s law uses means measured over the same boundary.","height":1000,"html":"<h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p><div class=\"control\"><label for=\"q\" data-i18n=\"q\"></label><output for=\"q\" id=\"q-value\"></output><input id=\"q\" type=\"range\" min=\"1\" max=\"16\" value=\"4\" step=\"1\"></div><div class=\"control\"><label for=\"k\" data-i18n=\"k\"></label><output for=\"k\" id=\"k-value\"></output><input id=\"k\" type=\"range\" min=\"1\" max=\"8\" value=\"4\" step=\"1\"></div><div class=\"control\"><label for=\"d\" data-i18n=\"d\"></label><output for=\"d\" id=\"d-value\"></output><input id=\"d\" type=\"range\" min=\"20\" max=\"1000\" value=\"100\" step=\"20\"></div><div class=\"track-row\"><span data-i18n=\"service\"></span><div id=\"service\" class=\"track\"></div></div><div class=\"track-row\"><span data-i18n=\"queued\"></span><div id=\"queued\" class=\"track\"></div></div><p class=\"caption\" data-i18n=\"rate\"></p><div class=\"curve\"><svg viewBox=\"0 0 320 150\" aria-hidden=\"true\"><path class=\"axes\" d=\"M10 5V135H310\"/><polyline id=\"curve-rate\"/><line id=\"mark-rate\" y1=\"5\" y2=\"135\"/><circle id=\"dot-rate\" r=\"4\"/></svg><span id=\"max-rate\"></span></div><div class=\"axis\"><span>Q=1</span><span>8</span><span>16</span></div><p class=\"caption\" data-i18n=\"latency\"></p><div class=\"curve\"><svg viewBox=\"0 0 320 150\" aria-hidden=\"true\"><path class=\"axes\" d=\"M10 5V135H310\"/><polyline id=\"curve-latency\"/><line id=\"mark-latency\" y1=\"5\" y2=\"135\"/><circle id=\"dot-latency\" r=\"4\"/></svg><span id=\"max-latency\"></span></div><div class=\"axis\"><span>Q=1</span><span>8</span><span>16</span></div><div class=\"metrics\"><div><span data-i18n=\"rate\"></span><strong id=\"rate\"></strong></div><div><span data-i18n=\"latency\"></span><strong id=\"latency\"></strong></div></div><div class=\"formula\" id=\"arithmetic\"></div><div class=\"copy\"><p id=\"explanation\" role=\"status\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"note\"></p></div><details class=\"scope\"><summary data-i18n=\"scopeLabel\"></summary><p data-i18n=\"scope\"></p></details>","css":".intro{margin:8px 0 18px;color:var(--muted);font-size:14px}.caption,.hint{font-size:12px;color:var(--muted);margin-top:12px}.control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 12px;align-items:center;margin:14px 0;font-size:13px}.control label{font-size:13px}.control output{color:var(--accent);min-width:5em;text-align:right;font-variant-numeric:tabular-nums}.control input{grid-column:1/-1;width:100%;margin:0}.presets{display:flex;gap:4px;align-items:stretch}.presets button{flex:1 1 0;min-width:0;overflow-wrap:anywhere;font-size:13px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}.actions button{flex:1;min-width:0;font-size:13px;min-height:42px}.copy{display:grid;font-size:14px;margin:16px 0}.copy>*{grid-area:1/1;margin:0}.reserve{visibility:hidden}.scope{margin-top:14px;color:var(--muted);font-size:12px}.scope summary{padding:8px 0;cursor:pointer}.scope p{margin-top:10px}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.card{border:1px solid var(--rule);border-radius:9px;padding:14px;min-width:0}.card header{font-size:14px;font-weight:600;margin-bottom:10px}.card button{font-size:12px;width:100%;min-width:0}.card small{display:block;font-size:12px;color:var(--muted)}.card code{font-size:12px;overflow-wrap:anywhere}.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.metrics>div{border-left:2px solid var(--accent);padding-left:10px;min-width:0}.metrics span{display:block;min-height:3.2em;color:var(--muted);font-size:12px}.metrics strong{font-size:23px;display:block;font-weight:500;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.formula{border-top:1px solid var(--rule);padding-top:12px;margin-top:14px;font-size:13px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.value{font:500 20px var(--viz-font-ui)}.legend{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:12px;color:var(--muted);margin:12px 0}.legend span:before{content:'';display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--accent);margin-right:6px}.legend .second:before{background:var(--second)}.check{display:flex;align-items:center;gap:8px;font-size:13px;margin:12px 0}.check input{width:16px;height:16px;flex-shrink:0;accent-color:var(--accent)}select,input[type=text],input[type=number]{background:var(--paper);color:var(--ink);font:inherit;font-size:13px;padding:10px;border:1px solid var(--rule);border-radius:7px;max-width:100%;min-width:0}select{width:100%;min-height:42px}.band{height:20px;display:flex;background:var(--surface);border-radius:4px;overflow:hidden}.band i{height:100%;display:block;background:var(--accent);transition:width .25s}.band i.second{background:var(--second)}button:disabled{cursor:default}.status{font-size:13px;min-height:4.8em}.mono{font-family:var(--viz-font-mono);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}\n@media(max-width:480px){.two{gap:8px}.card{padding:12px 9px}.actions button,.presets button{font-size:12px}.metrics strong{font-size:20px}.metrics{gap:8px}}\n@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}\n.track-row{margin:16px 0}.track-row>span{display:block;font-size:12px;color:var(--muted);margin-bottom:7px}.track{display:flex;gap:5px;min-width:0}.cell{flex:1;min-width:0;min-height:48px;border:1px solid var(--rule);border-radius:5px;background:var(--surface);display:flex;align-items:center;justify-content:center;text-align:center;padding:5px 3px;font-size:12px;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.cell.on{background:var(--accent-soft);border-color:var(--accent)}.cell.gold{background:var(--second-soft);border-color:var(--second)}.cell.empty{border-style:dashed;color:var(--muted)}.cell.error{border-color:var(--second);text-decoration:line-through}.choices{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin:14px 0}.choices button{min-width:0;padding:10px 4px;font-size:12px;overflow-wrap:anywhere}.choices button[aria-current=true]{border-color:var(--accent);background:var(--accent-soft)}.relation{display:grid;grid-template-columns:minmax(0,1fr) 28px minmax(0,1fr);align-items:center;gap:6px;margin:12px 0}.relation .cell{min-height:72px}.relation>i{text-align:center;font-style:normal;color:var(--accent)}.readout{font-size:13px;white-space:pre-line;min-height:6.4em;overflow-wrap:anywhere}.plot{position:relative;border-left:1px solid var(--rule);border-bottom:1px solid var(--rule);padding:8px 0;margin:18px 0}.plot-row{height:28px;position:relative;margin:4px 0;background:var(--surface)}.plot-row i{position:absolute;inset-block:5px;background:var(--accent);min-width:2px;transition:width .2s,left .2s}.plot-row b{position:relative;padding:4px 6px;font:11px/28px var(--viz-font-ui);background:var(--paper);border-radius:3px}.axis{display:flex;justify-content:space-between;font-size:11px;color:var(--muted)}\n\n.curve{position:relative}.curve svg{display:block;width:100%;height:auto;max-height:220px;overflow:visible}.curve .axes{fill:none;stroke:var(--rule)}.curve polyline{fill:none;stroke:var(--accent);stroke-width:3}.curve line{stroke:var(--second);stroke-width:1;stroke-dasharray:4 3}.curve circle{fill:var(--second)}.curve>span{position:absolute;left:14px;top:0;font:11px var(--viz-font-ui);background:var(--paper);padding:2px}.formula{min-height:6em}#queued{display:grid;grid-template-columns:repeat(8,minmax(0,1fr))}#queued .cell,#service .cell{min-height:20px;height:20px;padding:0;font-size:9px}","js":"const $=s=>document.querySelector(s);const set=(id,v)=>$('#'+id).textContent=v;const t=k=>viz.t(k);function cells(id,values){$('#'+id).replaceChildren(...values.map(v=>{const e=document.createElement('div');e.className='cell '+(v.cls||'');e.textContent=v.text;e.title=v.title||v.text;return e;}));}function pressed(mode){document.querySelectorAll('[data-mode]').forEach(e=>e.setAttribute('aria-pressed',String(e.dataset.mode===mode)));}function draw(){const q=+$('#q').value,k=+$('#k').value,d=+$('#d').value,rate=Math.min(q,k)*1e6/d,latency=q/rate*1e6;for(const[id,v]of [['q',q],['k',k],['d',d]])set(id+'-value',v);cells('service',Array.from({length:8},(_,i)=>({text:i<k?'●':'—',cls:i<Math.min(q,k)?'on':'empty'})));cells('queued',Array.from({length:16},(_,i)=>({text:i<Math.max(0,q-k)?'●':'',cls:i<Math.max(0,q-k)?'gold':'empty'})));for(const[name,fn,max]of [['rate',n=>Math.min(n,k)*1e6/d,k*1e6/d],['latency',n=>n*d/Math.min(n,k),16*d/k]]){const points=Array.from({length:16},(_,i)=>[10+i*20,135-fn(i+1)/max*120]);$('#curve-'+name).setAttribute('points',points.map(p=>p.join(',')).join(' '));$('#mark-'+name).setAttribute('x1',points[q-1][0]);$('#mark-'+name).setAttribute('x2',points[q-1][0]);$('#dot-'+name).setAttribute('cx',points[q-1][0]);$('#dot-'+name).setAttribute('cy',points[q-1][1]);set('max-'+name,viz.number(max,0)+(name==='rate'?' IOPS':' μs'));}set('rate',viz.number(rate,0));set('latency',viz.number(latency,1));set('arithmetic','λ = min('+q+', '+k+') / '+d+' μs\\nQ = λ × W = '+q);set('explanation',t('note'));}for(const id of ['q','k','d'])$('#'+id).oninput=draw;draw();","audio":false,"strings":{"d":"Service time D per request / μs","heading":"After throughput plateaus, where does extra depth go?","intro":"Change queue depth Q, parallel service slots K, and fixed service time D. Both curves share a Q axis; vertical marks identify the current point.","k":"Parallel service slots K","latency":"Mean response W / μs","note":"Compare both results at the same Q marker. Beyond K, completion rate plateaus while mean response time rises; these are related quantities.","q":"In-flight depth Q","queued":"Requests waiting for service","rate":"Completion rate λ / IOPS","reset":"Reset","scope":"Closed loop with fixed Q, K ideal parallel service slots, and fixed D per request, with no other bottlenecks or jitter. λ=min(Q,K)/D and W=Q/λ. K is model capacity, not a real NVMe internal parallelism constant. Mean curves do not predict tail latency.","scopeLabel":"Model scope and assumptions","service":"Service slots: blue serving, blank unavailable"}}
7. Completion, ordering, and persistence
Block layer completion notification indicates that the operation has ended under the current device stack contract; you cannot assert that data has entered non-volatile media solely based on a normal write completion. Volatile write caches change this boundary.
Flag
Persistence Constraint
REQ_PREFLUSH
Process persistence of previously cached writes before the subsequent data phase
REQ_FUA
Report completion only after this write meets the corresponding persistence conditions
Native device support and block layer simulation produce different command sequences. Neither directly guarantees atomicity for arbitrary write ranges. See Write Cache Control.
The application's fsync() is coordinated by the filesystem for data and necessary metadata, then pushed down as appropriate block layer operations. Directory persistence involved in file creation/renaming also requires consideration of directory synchronization; you cannot interpret the entire file transaction based on a single bio flag.
In the scheduler output, square brackets indicate the currently selected item; the absence of an mq directory may be due to stacked devices or other situations. nr_requests cannot be universally interpreted as the maximum number of commands per queue for physical devices, and max_sectors_kb does not have a fixed default value applicable to all devices.
9. Returning from statistics to evidence
The await in iostat -x includes wait and service times within the corresponding device statistical boundaries; it is not the end-to-end latency of application requests. %util approaching 100% does not equal "throughput has reached the absolute upper limit" for parallel devices, nor can it be treated as CPU utilization.
blktrace/block layer tracepoints can observe queueing, dispatch, and completion, but splitting, merging, retries, partial completions, and event loss can cause simple address-based pairing to fail. Determine identity and hierarchy first, then calculate time differences. Statistical fields are in Block Device Statistics, and tracing methods are in Tracing and Debugging.