The Multi-Queue Block Layer blk-mq
The multi-queue block layer — blk-mq — is the kernel subsystem that turns a stream of block-I/O descriptors (
bios) into device commands while scaling across many CPU cores. It replaced the original single-queue block layer, in which every device had onerequest_queueguarded by one global spinlock (queue_lock) — a structure that bottlenecked badly once devices could service millions of I/O operations per second (IOPS) instead of hundreds (LWN, “The multiqueue block layer”). blk-mq’s central idea is a two-level queue model: many per-CPU software staging queues where requests are buffered, merged, and scheduled, feeding a small fixed number of hardware dispatch queues that map onto the device’s own submission queues (kernel docs). It was merged in Linux 3.13 (2014), and as of Linux 5.0 (March 2019) it is the only block layer: the legacy single-queue path and its schedulers (CFQ, the old deadline, noop) were deleted (Axboe, “block: remove legacy IO schedulers”, Oct 2018). This note is the architectural overview; the two queue levels are dissected in Software and Hardware Queues in blk-mq.
This note is pinned to Linux 6.12, a maintained long-term-support (LTS) release (2024-11-17); mainline has since moved into the 7.x series. Every structural fact below was read out of the v6.12 source blobs listed in sources — not from documentation about those files, which as this note demonstrates in two places has already drifted. Where a claim concerns when something appeared or disappeared, it is pinned by two independent methods: an existence check (fetching the same path at several tags and recording which return HTTP 200 and which return 404) and the mainline commit itself, read verbatim from git.kernel.org’s cgit. Anything asserted about a release later than 6.12 is dated where it appears.
Mental Model
Think of blk-mq as a two-stage conveyor belt with many feeder lanes and a few exit gates. Each CPU has its own feeder lane (a software staging queue, struct blk_mq_ctx) into which the I/O it generates is dropped — no two CPUs share a lane, so they never fight over a lock to insert work. Periodically the contents of the feeder lanes are gathered, optionally reordered by an I/O scheduler, and handed to one of a small number of exit gates (hardware dispatch queues, struct blk_mq_hw_ctx, universally abbreviated hctx). Each exit gate corresponds to a real submission queue on the device — a Non-Volatile Memory Express (NVMe) controller, for instance, exposes one submission/completion queue pair per CPU, and blk-mq mirrors that.
The single-queue layer had one lane and one gate for the whole device, with a single lock on the lane. On a machine with dozens of cores all issuing I/O, that lock “will bounce frequently between the processors” (LWN) — the cache line holding the lock ping-pongs between caches, and throughput collapses well below what the hardware can absorb. blk-mq removes the shared lane.
The kernel’s own documentation states the design goal in a single sentence: “the blk-mq API spawns multiple queues with individual entry points local to the CPU, removing the need for a lock” (Documentation/block/blk-mq.rst, v6.12).
flowchart TB subgraph CPUS["Submitting CPUs"] C0["CPU 0"] C1["CPU 1"] C2["CPU 2"] C3["CPU 3"] end C0 --> SW0["software staging queue<br/>struct blk_mq_ctx [0]<br/>per-CPU, own spinlock"] C1 --> SW1["software staging queue<br/>struct blk_mq_ctx [1]"] C2 --> SW2["software staging queue<br/>struct blk_mq_ctx [2]"] C3 --> SW3["software staging queue<br/>struct blk_mq_ctx [3]"] SW0 --> SCHED["optional I/O scheduler<br/>mq-deadline / BFQ / Kyber / none<br/>plus bio-to-request merging"] SW1 --> SCHED SW2 --> SCHED SW3 --> SCHED SCHED --> HW0["hardware dispatch queue<br/>struct blk_mq_hw_ctx 'hctx' 0"] SCHED --> HW1["hardware dispatch queue<br/>hctx 1"] C0 -.->|"direct issue:<br/>no scheduler, hctx not busy"| HW0 HW0 --> SQ0["device submission queue 0<br/>ring buffer in host memory"] HW1 --> SQ1["device submission queue 1"] SQ0 --> DEV["Storage device"] SQ1 --> DEV DEV -.->|"completion returns the tag;<br/>tag indexes tags->rqs[] in O(1)"| HW0 DEV -.-> HW1
The blk-mq two-level model. What it shows: each CPU inserts into its own software staging queue, so insertion takes no cross-CPU lock; the staging queues drain through an optional scheduling and merging step into a small set of hardware dispatch queues (hctx), each bound to a real device submission queue; completions come back carrying the request’s integer tag, which indexes the request array directly. The dotted bypass on the left is the fast path taken when no scheduler is attached and the hardware queue is not backed up — the request skips staging entirely. The insight: the per-CPU feeder lanes are the entire scaling story. Submission is uncontended across cores, and the only fan-in happens at the hardware queues, which the device was built to service in parallel anyway.
Why the Single Queue Had to Die
The original block layer was designed when “a high-performance drive could handle hundreds of I/O operations per second” (LWN). Its core data structure was a single request_queue per device, holding a linked list of pending struct request objects, protected by one spinlock conventionally called queue_lock. Every operation — inserting a freshly built request, merging an adjacent bio into an existing request, the I/O scheduler picking the next request to dispatch, the driver pulling a request off for the device — had to take that one lock.
This is fine when the device is the bottleneck. It is catastrophic when the lock is the bottleneck. LWN describes the request queue bluntly as “one of the biggest bottlenecks in the entire system. It is protected by a single lock which, on a large system, will bounce frequently between the processors. It is a linked list, a notably cache-unfriendly data structure especially when modifications must be made” (LWN).
Both halves of that sentence name a real cost, and they are different costs — worth separating, because blk-mq had to fix both. The lock is a coherence problem: the cache line holding queue_lock is written on every acquisition, so under the MESI-style cache protocol every acquisition invalidates the line in every other core’s cache and pulls it across the interconnect. Throughput therefore falls as core count rises — the classic negative-scaling signature, and the reason the original paper singles out “high NUMA-factor processors systems” as where the problem bites hardest (Bjørling et al. 2013, abstract). The linked list is a locality problem: traversal is pointer-chasing, each node a separate cache line, so merely reading the queue to find a merge candidate evicts useful data, and every modification dirties a line other CPUs may be holding. The kernel’s own documentation reaches the same diagnosis in its own words: the single-queue design “did not scale well in SMP systems due to dirty data in cache and the bottleneck of having a single lock for multiple processors” (blk-mq.rst, v6.12).
Solid-state drives, and then NVMe devices, broke this model. An NVMe SSD can sustain hundreds of thousands to millions of IOPS, and the protocol is designed for parallelism: the host and controller communicate through paired ring buffers in host memory (a submission queue and a completion queue), and a controller can expose one such pair per CPU. Feeding such a device through a single serialized software queue wastes most of its capacity. The fix has to make submission itself parallel — and that is exactly what per-CPU software staging queues do. The fix has to make submission itself parallel — and that is exactly what per-CPU software staging queues do.
flowchart LR subgraph SQ["Single-queue block layer — through Linux 4.20"] direction TB A0["CPU 0"] --> L["queue_lock<br/>ONE spinlock per device"] A1["CPU 1"] --> L A2["CPU 2"] --> L A3["CPU 3"] --> L L --> RQ["one request_queue<br/>linked list of struct request<br/>cache line bounces on every insert"] RQ --> EL["elevator<br/>cfq / deadline / noop"] EL --> DRV["driver request_fn<br/>the DRIVER PULLS work off"] end subgraph MQ["blk-mq — merged 3.13, sole block layer since 5.0"] direction TB B0["CPU 0"] --> S0["ctx 0<br/>own lock"] B1["CPU 1"] --> S1["ctx 1<br/>own lock"] B2["CPU 2"] --> S2["ctx 2<br/>own lock"] B3["CPU 3"] --> S3["ctx 3<br/>own lock"] S0 --> H0["hctx 0"] S1 --> H0 S2 --> H1["hctx 1"] S3 --> H1 H0 --> QR["ops->queue_rq<br/>the LAYER PUSHES work down"] H1 --> QR end
The structure that was replaced, beside the structure that replaced it. What it shows: on the left, every CPU funnels through one spinlock into one linked list, and the driver pulls requests out through its request_fn; on the right, every CPU writes into its own staging queue, those fan into as many hardware queues as the device offers, and blk-mq pushes each request down through ops->queue_rq. The insight: two things changed at once, and the second is the one people forget. The lock went from one-per-device to one-per-CPU — that is the scaling fix. But the hand-off also inverted, from driver-pull to layer-push, which is why converting a driver to blk-mq was never a mechanical rename and why the transition took six years (LWN, “the request layer”).
blk-mq, the work of Jens Axboe and Shaohua Li, was the answer (LWN). It was merged into mainline in 3.13 (early 2014) and became feature-complete around 3.16 (Thomas-Krenn wiki). The Small Computer System Interface (SCSI) layer gained a multi-queue path, scsi-mq, in 3.17 — an existence check pins this precisely: scsi_mq_alloc_queue/scsi_mq_setup_tags are absent from drivers/scsi/scsi_lib.c at v3.16 and present at v3.17. It stayed opt-in for four years: the Kconfig switch SCSI_MQ_DEFAULT exists without a default at v4.18, gains default y at v4.19, and has vanished entirely by v5.0 because by then there was nothing to choose between. That matches LWN’s contemporary account that “the SCSI subsystem only switch[ed] over, after a false start, in the upcoming 4.19 release” (LWN, October 2018).
For several years, therefore, the two block layers genuinely coexisted, drivers being converted one at a time — a period LWN documented in 2017 with the sentence “There are currently two parallel parts to the request layer… both are in active use in current kernels” (LWN, “Block layer introduction part 2: the request layer”). That sentence was true when written and is false now. It is a useful reminder to date any claim about which block layer a given kernel has.
What Was Actually Measured
Two independent measurements bracket the transition, and they are worth keeping apart because they measured different things on different hardware.
The design paper is Matias Bjørling, Jens Axboe, David Nellans and Philippe Bonnet, “Linux block IO: introducing multi-queue SSD access on multi-core systems”, SYSTOR 2013, DOI 10.1145/2485732.2485740 — bibliographic facts (authors, year, venue) confirmed against the OpenAlex record for that DOI, which also reports the work as oa_status: "closed" with no open-access copy anywhere. Its abstract, retrievable from two independent institutional repositories, states the problem and the claim in the authors’ own words: “The IO performance of storage devices has accelerated from hundreds of IOPS five years ago, to hundreds of thousands of IOPS today, and tens of millions of IOPS projected in five years… we demonstrate that the block layer within the operating system, originally designed to handle thousands of IOPS, has become a bottleneck to overall storage system performance, specially on the high NUMA-factor processors systems that are becoming commonplace. We describe the design of a next generation block layer that is capable of handling tens of millions of IOPS on a multi-core system equipped with a single storage device” (University of Copenhagen research portal; identically at the IT University of Copenhagen).
Uncertain
Verify: the specific IOPS numbers, core counts and scaling curves in the SYSTOR 2013 paper. Reason: the paper’s full text could not be retrieved during this research, so no figures from it are quoted here. The author copy long linked from the kernel’s own documentation,
http://kernel.dk/blk-mq.pdf, returns a genuine nginx HTTP 404, as doeshttps://kernel.dk/systor13-final18.pdf— and the latter additionally fails TLS hostname validation. The ACM Digital Library entry is paywalled; OpenAlex reports no open-access location; neither institutional repository record carries an attached PDF. Only the abstract (quoted above, cross-checked between two repositories) is verified. To resolve: obtain the PDF through an ACM subscription or a surviving author mirror and replace the abstract quote with the measured figures. uncertain
The independent measurement that is fully readable comes from Blake Caldwell, “Improving Block-level Efficiency with scsi-mq” (arXiv:1504.07481, April 2015), which evaluated scsi-mq under a Lustre parallel filesystem on HPC-class storage. Two findings: “SCSI write request latency is reduced by as much as 13.6%”, and — the more interesting number — “when profiling the CPU usage of our prototype Lustre filesystem, we found that CPU idle time increased by a factor of 7 with Linux 3.18 and blk-mq as compared to a standard 2.6.32 Linux kernel.” The headline benefit of blk-mq on disk-based arrays was not raw throughput but CPU cost: the same work done with far less of the machine consumed by the block layer. That is exactly the shape of win you get from removing lock contention, and it is why blk-mq helped even devices nowhere near a million IOPS.
The 5.0 Cutover — blk-mq Becomes the Only Block Layer
By late 2018 the conversion was complete enough that Axboe could delete the old path — and he deleted it in two distinct pieces, which is the fact most often garbled.
The first piece removed the legacy schedulers. Commit f382fb0bcef4 (“block: remove legacy IO schedulers”, Jens Axboe, 12 October 2018) deletes block/cfq-iosched.c (4,916 lines), block/deadline-iosched.c (560 lines), block/noop-iosched.c (124 lines), Documentation/block/cfq-iosched.txt, and 70 lines of block/elevator.c — 6,025 deletions across seven files. Its commit message is one line of housekeeping: “Retain the deadline documentation, as that carries over to mq-deadline as well” (commit f382fb0b).
The second piece removed the single-queue request path itself. Commit a1ce35fa4985 (“block: remove dead elevator code”, Jens Axboe, 29 October 2018) strips 1,749 lines out of block/blk-core.c alone, plus 377 from block/elevator.c, 93 from include/linux/blkdev.h and 90 from include/linux/elevator.h — 2,440 deletions in total. Axboe’s own summary of it is the clearest statement of what left the tree:
This removes a bunch of core and elevator related code. On the core front, we remove anything related to queue running, draining, initialization, plugging, and congestions. We also kill anything related to request allocation, merging, retrieval, and completion. Remove any checking for single queue IO schedulers, as they no longer exist. This means we can also delete a bunch of code related to request issue, adding, completion, etc — and all the SQ related ops and helpers. — commit
a1ce35fa
Both landed in Linux 5.0, released March 2019. The release boundary is independently confirmed by existence check, which is stronger evidence than a patch posting because it reads the shipped tree rather than a proposal:
Path (under raw.githubusercontent.com/torvalds/linux/<tag>/) | v4.19 | v4.20 | v5.0 | v5.1 |
|---|---|---|---|---|
block/cfq-iosched.c | 200 | 200 | 404 | 404 |
block/deadline-iosched.c | 200 | 200 | 404 | 404 |
block/noop-iosched.c | 200 | 200 | 404 | 404 |
include/linux/blkdev.h → contains request_fn | yes (11×) | yes (11×) | no | — |
include/linux/blkdev.h → contains blk_init_queue | yes | yes (2×) | no | — |
drivers/scsi/Kconfig → contains SCSI_MQ_DEFAULT | yes (default y) | yes | no | — |
HTTP status and grep counts for each path at each tag, fetched 2026-09-04. What it shows: all three legacy scheduler files exist at v4.20 and are gone at v5.0; the driver-facing legacy symbols request_fn and blk_init_queue disappear from the public header in the same release; and SCSI’s “use blk-mq?” Kconfig option disappears because the question stopped having two answers. The insight: this is a clean release boundary, not a gradual fade. There is no version of Linux 5.x or later in which a driver can register a request_fn. Any document that presents the single-queue block layer as a live alternative to blk-mq predates March 2019 or is simply wrong.
That resolves a question the earlier draft of this note flagged as uncertain: the schedulers and the request_fn infrastructure went in the same release, not across 5.0–5.1.
From 5.0 onward there is exactly one block layer — blk-mq — and exactly one family of I/O schedulers: mq-deadline, BFQ (Budget Fair Queueing), Kyber, and none (a pass-through that does no reordering). An observer upgrading to 5.0.0-rc8 confirmed at the time that CFQ and the legacy deadline scheduler simply no longer existed (Berthon, 2019). The schedulers are covered in Linux IO Schedulers Overview, and which one you get by default is a subtler question than it looks — see Scheduler Selection below. Do not confuse the deleted single-queue deadline with the surviving mq-deadline: they share a design philosophy and almost nothing else.
timeline title blk-mq from proposal to sole block layer 2013 : SYSTOR paper by Bjorling, Axboe, Nellans and Bonnet : LWN covers "The multiqueue block layer" 2014 : Linux 3.13 merges blk-mq : Linux 3.16 feature-complete : Linux 3.17 adds scsi-mq (opt-in) 2015 : Caldwell measures scsi-mq under Lustre - 13.6 percent lower write latency, 7x more CPU idle 2017 : Linux 4.12 adds BFQ and Kyber, the first pluggable blk-mq schedulers 2018 : Linux 4.19 makes scsi-mq the default : commits f382fb0b and a1ce35fa delete the legacy path 2019 : Linux 5.0 ships as the first kernel with only blk-mq 2024 : Linux 6.9 renames blk_mq_init_queue to blk_mq_alloc_queue : Linux 6.12 LTS, the tag this note reads
The transition as a timeline. What it shows: roughly six years elapsed between blk-mq being merged and the old path being deleted, with the pluggable multi-queue schedulers arriving in the middle of that window and SCSI flipping its default only months before the end. The insight: the long overlap is why so much writing about the block layer still describes two of them — the coexistence period outlasted most kernel transitions, so stale descriptions are abundant, and “which block layer does this kernel have?” was a genuine question for six years and has been a settled one for seven.
How a Driver Registers with blk-mq
The driver-facing contract is two structures and a short registration sequence. A driver fills in a struct blk_mq_ops — a table of callbacks — and a struct blk_mq_tag_set describing the queue geometry, then asks blk-mq to build a queue.
The operations table (from include/linux/blk-mq.h, v6.12) centers on one mandatory callback:
struct blk_mq_ops {
/* Queue a new request from block IO. (mandatory) */
blk_status_t (*queue_rq)(struct blk_mq_hw_ctx *,
const struct blk_mq_queue_data *);
/* Queue a list of requests, all belonging to the same queue. */
void (*queue_rqs)(struct request **rqlist);
/* Called to poll for completion of a specific tag. */
int (*poll)(struct blk_mq_hw_ctx *, struct io_comp_batch *);
/* Mark the request as complete. */
void (*complete)(struct request *);
/* Per-hardware-queue and per-request setup/teardown. */
int (*init_hctx)(struct blk_mq_hw_ctx *, void *, unsigned int);
int (*init_request)(struct blk_mq_tag_set *set, struct request *,
unsigned int, unsigned int);
/* Let the driver supply its own CPU->queue mapping. */
void (*map_queues)(struct blk_mq_tag_set *set);
/* ... timeout, get_budget/put_budget, commit_rqs, etc. */
};Line by line: queue_rq is the heart of the driver — blk-mq calls it with a hardware-queue pointer and a blk_mq_queue_data (which carries rq, the request, and last, a hint that this is the last of a batch so the driver can ring the doorbell once). The driver translates the struct request into a device command and submits it, returning BLK_STS_OK on success or BLK_STS_RESOURCE/BLK_STS_DEV_RESOURCE to push back. queue_rqs is the batched variant; a driver “is guaranteed that each request belongs to the same queue” (comment in blk-mq.h) and may leave un-submitted requests on the list for blk-mq to retry individually. poll drives polled completions for low-latency I/O. init_hctx/init_request let the driver allocate per-queue and per-command private data — cmd_size extra bytes are carved out behind each request for exactly this. map_queues lets a driver override the default CPU-to-hardware-queue mapping (the NVMe driver does this; see blk-mq CPU to Queue Mapping).
The full callback table is larger than the excerpt; the ones that matter, and what happens if you omit each, are enumerable:
| Callback | Mandatory? | What it does | If absent |
|---|---|---|---|
queue_rq | Yes | Hand one request to the device. Returns BLK_STS_OK, or BLK_STS_RESOURCE / BLK_STS_DEV_RESOURCE to push back | blk_mq_alloc_tag_set() returns -EINVAL |
queue_rqs | No | Batched variant; blk-mq hands a whole plug list at once and the driver rings the doorbell once | blk-mq falls back to per-request queue_rq |
commit_rqs | Conditionally | “If a driver uses bd->last to judge when to submit requests to hardware, it must define this function” — the explicit doorbell, also used to kick hardware when an error stops a batch early | A driver that batches on bd->last will stall |
complete | No | Driver-side completion, run in softirq or on the issuing CPU | — |
timeout | No | Called when a request outlives its deadline; returns BLK_EH_DONE or BLK_EH_RESET_TIMER | blk-mq just re-arms the timer |
poll | No | Poll for completions instead of waiting for an interrupt; required for HCTX_TYPE_POLL | Polled I/O unavailable |
init_hctx / exit_hctx | No | Per-hardware-queue driver state | — |
init_request / exit_request | No | Per-request driver state, in the cmd_size bytes carved out behind each struct request | — |
get_budget / put_budget | Paired | Reserve a driver-level resource before a tag is taken (SCSI device queue depth) | Must supply both or neither: blk_mq_alloc_tag_set() enforces !get_budget ^ !put_budget and returns -EINVAL |
map_queues | No | Driver-supplied CPU→queue mapping | blk_mq_map_queues() spreads CPUs evenly |
The blk_mq_ops contract, from include/linux/blk-mq.h and the validation in blk_mq_alloc_tag_set() (v6.12). What it shows: exactly one callback is mandatory, one pair is all-or-nothing, and the rest are optional refinements. The insight: the minimum viable blk-mq driver is genuinely small — fill in a tag set, supply queue_rq, call blk_mq_alloc_disk(). Everything else on this list is an optimisation or a safety net, which is why the conversion of a hundred-odd drivers was feasible at all.
The get_budget/put_budget pair deserves a note because it is the answer to a problem tags alone cannot solve. Tags bound the number of requests in flight per hardware queue. But a SCSI host has its own resource limits that cut across queues — a per-LUN queue depth, a per-host command budget. Without a budget hook, blk-mq would hand a request a tag, call queue_rq, get BLK_STS_RESOURCE back, and have to unwind — allocating and freeing a tag for nothing, on every attempt, under load. The budget callbacks let the driver say “no” before a tag is committed. The tag set validation enforcing !get_budget ^ !put_budget is the kernel refusing to let a driver reserve a budget it never releases.
The tag set describes the geometry (from include/linux/blk-mq.h):
struct blk_mq_tag_set {
const struct blk_mq_ops *ops;
struct blk_mq_queue_map map[HCTX_MAX_TYPES];
unsigned int nr_maps; /* number of map[] entries in use */
unsigned int nr_hw_queues; /* number of hardware dispatch queues */
unsigned int queue_depth; /* tags per hardware queue */
unsigned int reserved_tags;
unsigned int cmd_size; /* extra bytes per request for the driver */
int numa_node;
unsigned int flags; /* BLK_MQ_F_* */
/* ... */
};nr_hw_queues is how many hardware dispatch queues the device wants — typically the number of hardware submission queues it exposes; queue_depth is how many in-flight requests each hardware queue allows, which becomes the size of its tag space (tags are dissected in Tag Sets and Request Allocation). The map[] array holds the CPU-to-queue mappings, one per hardware queue type (HCTX_TYPE_DEFAULT, HCTX_TYPE_READ, HCTX_TYPE_POLL) — see Software and Hardware Queues in blk-mq for what queue types mean.
The registration sequence has two steps. First the driver calls blk_mq_alloc_tag_set(set), which validates the geometry and allocates the per-queue tag bitmaps. The validation in v6.12 is strict (block/blk-mq.c): it rejects nr_hw_queues == 0, rejects queue_depth == 0, requires queue_depth >= reserved_tags + BLK_MQ_TAG_MIN, and requires ops->queue_rq to be present. It also clamps: if nr_maps == 1 and the driver asked for more hardware queues than CPUs, it caps nr_hw_queues at nr_cpu_ids, because “there is no use for more h/w queues than cpus if we just have a single map” (comment in blk_mq_alloc_tag_set). It also caps queue_depth at BLK_MQ_MAX_DEPTH (10240) and shrinks it to 64 inside a kdump crash kernel to conserve memory.
Second, the driver builds the actual queue and disk. In v6.12 the entry point is blk_mq_alloc_queue(set, lim, queuedata) (or the convenience wrapper blk_mq_alloc_disk()), which internally calls blk_alloc_queue() then blk_mq_init_allocated_queue() (block/blk-mq.c).
Older documentation and most tutorials name this entry point blk_mq_init_queue(), and that symbol does not exist in 6.12. The rename is pinned exactly. An existence check on include/linux/blk-mq.h finds blk_mq_init_queue present and blk_mq_alloc_queue absent at v6.8, and the reverse at v6.9. The commit is 9ac4dd8c47d5 (“block: pass a queue_limits argument to blk_mq_init_queue”, Christoph Hellwig, 13 February 2024), whose message explains both halves of the change: “Pass a queue_limits to blk_mq_init_queue and apply it if non-NULL. This will allow allocating queues with valid queue limits instead of setting the values one at a time later. Also rename the function to blk_mq_alloc_queue as that is a much better name for a function that allocates a queue” (commit 9ac4dd8c). So: blk_mq_init_queue() is the correct name for kernels up to and including 6.8, blk_mq_alloc_queue() from 6.9 onward. The point of the change is worth absorbing beyond the name — queue limits (maximum segments, logical block size, discard granularity) are now supplied atomically at construction rather than poked in one setter at a time afterwards, which closed a window in which a partially-configured queue was visible.
flowchart TB D["Driver probe()"] --> TS["fill struct blk_mq_tag_set<br/>ops, nr_hw_queues, queue_depth,<br/>reserved_tags, cmd_size, nr_maps, flags"] TS --> ATS["blk_mq_alloc_tag_set(set)"] ATS --> V{"validate"} V -->|"nr_hw_queues == 0<br/>or queue_depth == 0<br/>or no ops->queue_rq"| ERR["-EINVAL"] V -->|"queue_depth > 10240"| CLAMP["clamp to BLK_MQ_MAX_DEPTH"] V -->|"nr_maps == 1 and<br/>nr_hw_queues > nr_cpu_ids"| CAP["cap nr_hw_queues at nr_cpu_ids"] V -->|"is_kdump_kernel()"| KD["queue_depth = 64"] CLAMP --> ALLOC CAP --> ALLOC KD --> ALLOC V -->|ok| ALLOC["allocate per-hw-queue blk_mq_tags:<br/>sbitmap bitmap_tags + breserved_tags<br/>and the struct request array"] ALLOC --> MAP["ops->map_queues() if supplied,<br/>else blk_mq_map_queues():<br/>group_cpus_evenly() fills map[].mq_map[cpu]"] MAP --> Q["blk_mq_alloc_queue(set, lim, queuedata)<br/>or blk_mq_alloc_disk() wrapper"] Q --> IAQ["blk_mq_init_allocated_queue():<br/>allocate per-CPU blk_mq_ctx,<br/>allocate hctx per hw queue into q->hctx_table xarray,<br/>wire ctx->hctxs[type]"] IAQ --> EL["elevator_init_mq():<br/>attach mq-deadline, or nothing"] EL --> ADD["add_disk() — device is live"]
Queue construction, from probe() to a live disk. What it shows: the tag set is validated and clamped first, the tag bitmaps and the struct request array are allocated from it, the CPU→hardware-queue map is computed, and only then is a request_queue built on top with its per-CPU software contexts and its hctx array. The insight: the tag set is the parent object and it is deliberately allocatable once per controller and shareable across many queues — a SCSI host or an NVMe controller creates one tag set and hangs every namespace’s or LUN’s request_queue off it, which is why queue_depth is a property of the tag set, not of the disk. Note also where the elevator gets chosen: at queue construction, by the kernel, before userspace has any say.
Tags Are the Request Pool
The single most load-bearing idea in blk-mq after the two-level queue is that the tag is the request. Not “a request carries a tag” — the tag is an index, and the array it indexes is the only place requests come from.
struct blk_mq_tags (include/linux/blk-mq.h, v6.12) makes this literal. It holds two sbitmap_queue allocators — bitmap_tags for ordinary I/O and breserved_tags for the reserved pool — and two arrays of request pointers:
/* Tag address space map. */
struct blk_mq_tags {
unsigned int nr_tags; /* == queue_depth from the tag set */
unsigned int nr_reserved_tags;
unsigned int active_queues; /* how many queues share this tag set */
struct sbitmap_queue bitmap_tags; /* the free-tag allocator */
struct sbitmap_queue breserved_tags; /* ditto, reserved pool */
struct request **rqs; /* tag -> the request currently in flight */
struct request **static_rqs; /* tag -> its permanently allocated request */
struct list_head page_list; /* the pages the requests were carved from */
spinlock_t lock;
};blk_mq_alloc_rqs() (block/blk-mq.c) fills static_rqs[] at queue-creation time by allocating high-order pages and slicing them into depth fixed-size slots:
/* rq_size is the size of the request plus driver payload,
* rounded to the cacheline size */
rq_size = round_up(sizeof(struct request) + set->cmd_size, cache_line_size());
left = rq_size * depth;
/* ... allocate pages of order <= 4, carve rq_size slots out of each ... */
tags->static_rqs[i] = rq;Three consequences fall straight out of those five lines, and each one explains a behaviour you will otherwise find mysterious.
There is no request allocator on the I/O path. Every struct request the device will ever see already exists before the first I/O is submitted. “Allocating a request” means finding a free bit; freeing one means clearing a bit. No kmalloc, no slab, no allocation failure, and no lock — which is what makes submission cheap enough to keep up with a device doing a million operations a second.
Queue depth is a hard, physical ceiling on driver tags. queue_depth from the tag set is nr_tags, is the number of bits in the sbitmap, and is the number of preallocated requests. If the driver says 1,024, then 1,024 is the absolute maximum of requests in flight at the device per hardware queue, forever, because slot 1,025 does not exist in memory. (A second, growable pool of scheduler tags sits above this when an elevator is attached — see the nr_requests discussion under Failure Modes, where this distinction is exactly what trips people up.)
The driver’s per-command state is free. cmd_size extra bytes sit immediately behind each request in the same cache-line-aligned slot, so a driver reaches its private command structure with pointer arithmetic (blk_mq_rq_to_pdu()) rather than a second allocation. The NVMe driver’s struct nvme_command lives there.
And on the completion side, blk_mq_tag_to_rq(tags, tag) is the whole lookup: return tags->rqs[tag];, guarded by a bounds check and preceded by a prefetch(). The kernel documentation states the payoff plainly — the tag “is generated by the block layer and later reused by the device driver, removing the need to create a redundant identifier… This removes the need to do a linear search to find out which IO has been completed” (blk-mq.rst, v6.12). An NVMe completion queue entry carries a Command Identifier; blk-mq arranges for that identifier to be the tag, so a completion interrupt turns into one array index.
sbitmap — a Bitmap That Does Not Ping-Pong
A naive free-list bitmap would reintroduce exactly the problem blk-mq set out to solve: every CPU doing an atomic test-and-set on the same word, bouncing one cache line around the machine. sbitmap (“scalable bitmap”, lib/sbitmap.c, include/linux/sbitmap.h — copyright Jens Axboe 2013–2014 and Facebook 2016) is the fix, and its kernel-doc comment states the trade openly: “A struct sbitmap is spread over multiple cachelines to avoid ping-pong. This trades off higher memory usage for better scalability.”
The structure is a struct sbitmap_word *map of map_nr words, and struct sbitmap_word is itself ____cacheline_aligned_in_smp with its cleared field separately cache-line aligned. So one word of allocation state occupies at least one full cache line and usually two. On a 64-bit machine that is 64 bits of tag state per 64 bytes of memory — an eightfold memory overhead, bought deliberately.
Spreading the bits is only half the mechanism; CPUs also have to start in different places, or they would all contend on word 0. That is alloc_hint, described in the header as “Cache of last successfully allocated or freed bit. This is per-cpu, which allows multiple users to stick to different cachelines until the map is exhausted.” The hints are seeded at random rather than at zero, so a freshly created queue does not funnel every CPU into the same word.
The last piece is what happens when the bitmap is genuinely full and tasks must sleep. A single wait queue would put a spinlock back in the hot path at exactly the worst moment. struct sbitmap_queue’s comment explains the design: it “uses multiple wait queues and rolling wakeups to avoid contention on the wait queue spinlock. This ensures that we don’t hit a scalability wall when we run out of free bits and have to start putting tasks to sleep.” Concretely SBQ_WAIT_QUEUES is 8 and SBQ_WAKE_BATCH is 8; the wake batch is computed as clamp(depth / SBQ_WAIT_QUEUES, 1, SBQ_WAKE_BATCH), so a waiter is woken roughly once per batch of freed tags rather than every waiter being woken on every free — the block layer’s answer to the thundering herd.
flowchart TB subgraph SB["sbitmap for one hardware queue (queue_depth bits)"] W0["sbitmap_word 0<br/>____cacheline_aligned_in_smp"] W1["sbitmap_word 1"] W2["sbitmap_word 2"] W3["sbitmap_word 3"] end subgraph HINT["per-CPU alloc_hint (seeded at random offsets)"] H0["CPU 0 hint"] H1["CPU 1 hint"] H2["CPU 2 hint"] end H0 -->|"starts scanning here"| W0 H1 -->|"different cacheline"| W2 H2 --> W3 W0 --> IDX["tag = bit index + nr_reserved_tags"] W2 --> IDX W3 --> IDX IDX --> RQ["static_rqs[tag]<br/>preallocated struct request<br/>+ cmd_size driver bytes"] RQ --> DEV["device command<br/>carries tag as its identifier"] DEV -->|"completion returns the tag"| LOOK["blk_mq_tag_to_rq():<br/>return tags->rqs[tag] — O(1)"] SB -.->|"when full: 8 wait queues,<br/>rolling wakeups in batches of 8"| WAIT["sleeping submitters"]
How a tag becomes a request and back again. What it shows: the bitmap is deliberately scattered across cache lines, each CPU starts its scan from its own randomly-seeded hint so different CPUs touch different lines, the winning bit index is the tag, the tag indexes a request that was allocated at queue-creation time, and the device echoes that same integer back on completion. The insight: every expensive thing has been moved out of the I/O path. Memory allocation happened at probe() time; identifier assignment is a bit-set; completion lookup is an array index; and even the sleep path when tags run out is sharded eight ways so that running out of tags does not itself become the bottleneck.
Reserved Tags, and Fair Sharing Between Queues
Two refinements sit on top. Reserved tags (nr_reserved_tags, allocated from the separate breserved_tags bitmap with tag_offset = 0) exist so that a driver can always issue a command even when the ordinary pool is exhausted — SCSI and NVMe reserve tags for aborts, resets and admin commands, precisely the operations you need when the queue is jammed. Ordinary allocations get tag_offset = nr_reserved_tags added to their bit index, so the two pools occupy disjoint ranges of the same static_rqs[] array (blk_mq_get_tag(), block/blk-mq-tag.c).
Fair sharing matters when many request_queues share one tag set — the normal case for SCSI, where every LUN on a host is its own queue but the host has one command pool. hctx_may_queue() (block/blk-mq.h) implements it in four lines of arithmetic:
users = READ_ONCE(hctx->tags->active_queues);
if (!users)
return true;
/* Allow at least some tags */
depth = max((bt->sb.depth + users - 1) / users, 4U);
return __blk_mq_active_requests(hctx) < depth;Each active queue gets a depth/users share of the tag space, floored at 4 so no queue is starved outright, and this check runs before the bit is taken (__blk_mq_get_tag()). It applies only when BLK_MQ_F_TAG_QUEUE_SHARED is set, and there is a nice guard clause above it: if (bt->sb.depth == 1) return true; under the comment “Don’t try dividing an ant.” Note the ordering consequence — a queue can be refused a tag not because the device is full but because its siblings are using their share. Deep dive in Tag Sets and Request Allocation.
When Tags Run Out
The slow path in blk_mq_get_tag() is worth tracing because it contains a subtlety most descriptions miss. On failure, with BLK_MQ_REQ_NOWAIT the function returns BLK_MQ_NO_TAG immediately. Otherwise it loops:
blk_mq_run_hw_queue(data->hctx, false)— under the comment “We’re out of tags on this hardware queue, kick any pending IO submits before going to sleep waiting for some to complete.” Being out of tags may simply mean requests are sitting in staging queues, un-dispatched; running the queue may free some.- Retry the allocation — “as running the queue may also have found completions.”
sbitmap_prepare_to_wait(), retry once more (closing the race where a tag is freed between the failed attempt and going to sleep), thenio_schedule().- On waking, re-resolve everything.
data->ctx = blk_mq_get_ctx(data->q)anddata->hctx = blk_mq_map_queue(...)— because the task may have been migrated to a different CPU while it slept, and a different CPU means a different software queue and possibly a different hardware queue and a different tag set. - If the destination bitmap changed,
sbitmap_queue_wake_up(bt_prev, 1), under the comment “If destination hw queue is changed, fake wake up on previous queue for compensating the wake up miss, so other allocations on previous queue won’t be starved.” A waiter that migrates away silently consumes a wakeup that was meant for the old queue; this hands it back. - Finally, even on success: if
BLK_MQ_S_INACTIVEis set on the hctx (the CPU is going offline), put the tag back and returnBLK_MQ_NO_TAGso the caller retries on a live queue.
Step 5 is the kind of detail that only exists because someone hit the bug. It is also a good argument for reading the source rather than the summary.
The Submission Path — a bio Descending to a Completion
submit_bio() (covered in The Block IO Submission Path) eventually reaches blk_mq_submit_bio() (block/blk-mq.c), which is where the block layer’s per-I/O work actually happens. Reading it top to bottom in v6.12 gives the real sequence, and the ordering is not arbitrary — each step is placed where it is to avoid paying for work that a later step would waste.
blk_mq_peek_cached_request(plug, q, opf)— before anything else, check whether the current task’s plug already holds a cached, pre-allocated request for this queue. A batched submitter (io_uring, or a filesystem writing many blocks) that told the block layer how many I/Os were coming gets requests handed to it with no bitmap operation at all.blk_queue_bounce()andbio_queue_enter()— bounce-buffer any pages the device cannot reach, then take a reference on the queue’sq_usage_counterso it cannot be frozen out from under us.bio_unaligned()check — a bio not aligned to the device’s logical block size is failed here withbio_io_error(), before any resource is committed.__bio_split_to_limits(bio, &q->limits, &nr_segs)— split the bio if it exceeds the device’s maximum segment count or transfer size. Splitting before merging matters: a merge decision made on an over-large bio would have to be undone.bio_integrity_prep()— attach data-integrity metadata if the device supports T10 protection information.blk_mq_attempt_bio_merge()— try to fold this bio into a request that already exists. This happens before a tag is taken, which is the point: a merged bio consumes no tag and no request.- Zone handling —
blk_zone_plug_bio()for zoned devices, which serialises writes within a zone (see Zoned Block Devices). blk_mq_get_new_requests()— only now is a tag allocated and a request claimed, orblk_mq_use_cached_rq()if step 1 found one.blk_mq_bio_to_request()thenblk_crypto_rq_get_keyslot()— fill the request from the bio; acquire an inline-encryption keyslot if the device does hardware crypto.- Dispatch decision. Flush requests go to
blk_insert_flush(). Otherwise, if a plug is active,blk_add_rq_to_plug()and return. If not, the code reads:
hctx = rq->mq_hctx;
if ((rq->rq_flags & RQF_USE_SCHED) ||
(hctx->dispatch_busy && (q->nr_hw_queues == 1 || !is_sync))) {
blk_mq_insert_request(rq, 0);
blk_mq_run_hw_queue(hctx, true);
} else {
blk_mq_run_dispatch_ops(q, blk_mq_try_issue_directly(hctx, rq));
}That else branch is the fast path and it is worth dwelling on: with no scheduler and an idle hardware queue, the request never enters a software staging queue at all. It goes straight from blk_mq_submit_bio() into the driver’s queue_rq. The staging queue is not a mandatory stop on the way to the device; it is a buffer used when buffering helps — when a scheduler wants to reorder, or when the hardware queue is already backed up (dispatch_busy) and batching will amortise the cost. The condition even distinguishes synchronous I/O: for a multi-queue device, an asynchronous write to a busy hctx is worth staging, but a synchronous read is issued directly because someone is waiting on it.
Where a staged request lands is decided by blk_mq_insert_request(), whose four-way branch encodes some hard-won priorities:
- Passthrough requests (SCSI commands, NVMe admin commands) go straight onto
hctx->dispatchviablk_mq_request_bypass_insert(). The comment explains why this is not an optimisation but a deadlock fix: “The device may be in a situation where it can’t handle FS request… If a passthrough request is required to unblock the queues, and it is added to the scheduler queue, there is no chance to dispatch it given we prioritize requests inhctx->dispatch.” - Flush requests also bypass, and are inserted at the head (
BLK_MQ_INSERT_AT_HEAD). The comment quantifies the reason: on NCQ (Native Command Queueing) drives a flush cannot be queued alongside in-flight normal commands, so putting it at the front raises the chance of flush merging — “It is observed that ~10% time is saved in blktests block/004 on disk attached to AHCI/NCQ drive.” - With an elevator, the request goes to
q->elevator->type->ops.insert_requests(), guarded byWARN_ON_ONCE(rq->tag != BLK_MQ_NO_TAG)— a scheduled request holds only a scheduler tag at this point, not a driver tag. - Otherwise, onto the current CPU’s
ctx->rq_lists[hctx->type]underctx->lock, followed byblk_mq_hctx_mark_pending(), which sets this ctx’s bit in the hctx’sctx_mapsbitmap so the dispatcher knows where to look without walking every CPU.
sequenceDiagram participant FS as Filesystem / io_uring participant BIO as submit_bio() participant MQ as blk_mq_submit_bio() participant TAG as sbitmap (tags) participant CTX as ctx — per-CPU staging participant EL as elevator (optional) participant HCTX as hctx — dispatch participant DRV as driver queue_rq callback participant HW as device FS->>BIO: bio (sector, len, pages) BIO->>MQ: q->mq_ops path MQ->>MQ: split to limits, integrity prep MQ->>MQ: attempt merge into an existing request Note over MQ: a merged bio takes NO tag —<br/>merging is checked before allocation MQ->>TAG: blk_mq_get_tag() TAG-->>MQ: tag, i.e. static_rqs[tag] alt no elevator and hctx not busy MQ->>DRV: blk_mq_try_issue_directly() — bypasses staging else scheduler attached, or hctx dispatch_busy MQ->>CTX: blk_mq_insert_request() — mark ctx bit in ctx_map CTX->>EL: elevator insert_requests (if attached) EL->>HCTX: blk_mq_run_hw_queue() drains sched/staging HCTX->>DRV: queue_rq(hctx, rq, last) end DRV->>DRV: blk_mq_start_request(): state = MQ_RQ_IN_FLIGHT,<br/>blk_add_timer(rq), publish rq in tags.rqs[tag] DRV->>HW: device command, identifier == tag HW-->>DRV: completion entry carrying the tag DRV->>HCTX: blk_mq_complete_request(rq) Note over HCTX: blk_mq_tag_to_rq(tag) is an array index,<br/>never a search HCTX->>FS: bio_endio() — original submitter woken
One bio’s full descent and return. What it shows: the order of operations inside blk_mq_submit_bio(), the branch between direct issue and staged issue, and the fact that the tag assigned on the way down is the same integer the hardware returns on the way back up. The insight: notice where merging sits — before tag allocation. Every merge is one fewer tag consumed, one fewer request, one fewer device command and one fewer completion interrupt, which is why merging still earns its keep on devices that have no seek penalty at all.
Merging and Plugging — Amortising the Fast Path
blk-mq inherited two batching mechanisms from the old block layer and kept both, for reasons that survived the move to flash.
Merging folds a new bio into an existing request whose sectors are adjacent. blk_mq_attempt_bio_merge() skips the attempt entirely if blk_queue_nomerges(q) is set or the bio is not mergeable, and otherwise tries two things in a deliberate order. First blk_attempt_plug_merge(), which searches the current task’s own plug list — no lock at all, because the plug is per-task. Only if that fails does it try blk_mq_sched_bio_merge(), which asks the elevator’s ->bio_merge if one exists, and otherwise walks the current software queue’s list under ctx->lock. That last walk is deliberately bounded; the comment in block/blk-mq-sched.c is refreshingly candid: “Reverse check our software queue for entries that we could potentially merge with. Currently includes a hand-wavy stop count of 8, to not spend too much time checking for merges.”
The kernel documentation, incidentally, gets this wrong. blk-mq.rst describes merging with a good example — “requests for sector 3-6, 6-7, 7-9 can become one request for 3-9” — and then says “This technique of merging requests is called plugging.” It is not. Merging and plugging are two different mechanisms that happen to cooperate; see the callout at the end of this section.
Plugging is the batching of submission, not of requests. A submitter that knows it is about to issue several I/Os calls blk_start_plug(), and requests then accumulate on a per-task struct blk_plug instead of going to the queue one at a time. The point is threefold: adjacent requests get a chance to meet each other and merge before anyone sees them; the queue’s locks are taken once for the batch rather than once per request; and if the driver implements queue_rqs, the whole list can be handed down in a single call with a single doorbell write.
The flush thresholds are concrete numbers in block/blk.h and block/blk-mq.c (v6.12):
| Constant / rule | Value | Effect |
|---|---|---|
BLK_MAX_REQUEST_COUNT | 32 | Plug auto-flushes at 32 requests… |
blk_plug_max_rq_count() | 64 when plug->multiple_queues | …but 64 if the plug spans more than one queue, so MD/RAID arrays get enough requests to stripe across members |
BLK_PLUG_FLUSH_SIZE | 128 KiB | Auto-flush if the most recently added request is already ≥ 128 KiB — enough data is queued that waiting only adds latency |
plug->has_elevator | set when a request carries RQF_SCHED_TAGS | Forces the slower per-request path: “Any request allocated from sched tags can’t be issued to ->queue_rqs() directly” |
The batch dispatch is in blk_mq_flush_plug_list(): when the plug covers a single queue, has no elevator, is not being flushed by the scheduler, and the driver supplies queue_rqs, the entire list is pushed down in one call. Any other combination falls back to blk_mq_plug_issue_direct(), one request at a time. This is why queue_rqs matters so much for NVMe under io_uring — it is the difference between one doorbell write per batch and one per I/O.
Uncertain
Verify: whether in-tree
Documentation/block/blk-mq.rsthas since been corrected. As of the v6.12 text, it conflates the two mechanisms, writing “This technique of merging requests is called plugging” — merging combines bios into a request, while plugging batches requests before submission; a plug creates opportunities for merging but is not the same thing. The same file’s “Further reading” section also links the design paper ashttp://kernel.dk/blk-mq.pdf, which returns HTTP 404 (verified 2026-09-04). Reason: this is an in-tree documentation defect observed at one tag, not a claim about the code. To resolve: re-readDocumentation/block/blk-mq.rstat a later tag; if the wording persists, it is a worthwhile documentation patch. uncertain
The Request State Machine — Timeout, Requeue, Completion
A struct request in blk-mq has exactly three states, and the enum is refreshingly small (include/linux/blk-mq.h, v6.12):
enum mq_rq_state {
MQ_RQ_IDLE = 0,
MQ_RQ_IN_FLIGHT = 1,
MQ_RQ_COMPLETE = 2,
};The source carries no comments on these, so here is what each means: MQ_RQ_IDLE — the request exists (it always exists) and is either unclaimed or has been completed and returned to the pool; MQ_RQ_IN_FLIGHT — handed to the driver, deadline armed, visible in tags->rqs[]; MQ_RQ_COMPLETE — a completion has been claimed, by exactly one path. Alongside state the request carries an atomic_t ref and an unsigned long deadline, and the trio is what makes the timeout/completion race resolvable.
The transitions are where the interesting engineering is.
Into flight. blk_mq_start_request() is called by the driver from inside queue_rq, just before it touches the hardware. In order: emit trace_block_rq_issue; if the queue has statistics enabled and this is not a passthrough request, stamp io_start_time_ns and set RQF_STATS; WARN_ON_ONCE(blk_mq_rq_state(rq) != MQ_RQ_IDLE); blk_add_timer(rq); then WRITE_ONCE(rq->state, MQ_RQ_IN_FLIGHT) and publish tags->rqs[rq->tag] = rq. The timer is armed before the state flips and before the request is visible in rqs[], so there is no window in which a request is in flight without a deadline.
Timeout. blk-mq does not run a timer per request — with a million requests a second that would be absurd. It runs one forward-rolling timer per queue, and blk_mq_timeout_work() implements a careful two-pass scan:
- It takes its queue reference with
percpu_ref_tryget(&q->q_usage_counter)rather thanblk_queue_enter(), and the comment explains the deadlock this avoids: “A deadlock might occur if a request is stuck requiring a timeout at the same time a queue freeze is waiting completion, since the timeout code would not be able to acquire the queue reference here.” - First pass,
blk_mq_check_expired, only detects. It sets a flag; it changes nothing. - If anything expired,
blk_mq_wait_quiesce_done(q->tag_set)— an SRCU or RCU synchronisation point, because “before walking tags, we must ensure any submit started before the current time has finished.” - Second pass,
blk_mq_handle_expired, actually acts. - If nothing was pending, every mapped hctx is marked idle with
blk_mq_tag_idle(), releasing its claim on the shared tag budget. - Otherwise
mod_timer(&q->timeout, expired.next)re-arms for the next-earliest deadline.
The two-pass structure exists because the alternative — acting on a request while another CPU is still inside queue_rq for it — is a use-after-free waiting to happen. The RCU/SRCU wait between the passes is what makes “this request is genuinely expired” a safe conclusion rather than a race.
When a request is confirmed expired, blk_mq_rq_timed_out() sets RQF_TIMED_OUT, calls ops->timeout(req), and honours the answer: BLK_EH_DONE means the driver has taken ownership and will complete it itself, so blk-mq returns and does nothing further; BLK_EH_RESET_TIMER means still working, give it more time, and blk_add_timer(req) re-arms. There is no third option — anything else trips WARN_ON_ONCE. That two-valued contract is why driver timeout handlers are so often subtle: returning BLK_EH_DONE without actually completing the request leaks it permanently, tag included.
Requeue. A request that cannot proceed goes back rather than failing. __blk_mq_requeue_request() releases the driver tag (blk_mq_put_driver_tag) — note the scheduler tag is retained — emits trace_block_rq_requeue, notifies rq_qos_requeue, and if the request had been started, walks the state back to MQ_RQ_IDLE and clears RQF_TIMED_OUT. The public blk_mq_requeue_request() adds blk_mq_sched_requeue_request() and puts the request on q->requeue_list under q->requeue_lock, to be re-dispatched by blk_mq_kick_requeue_list(). This is the path behind SCSI retries and NVMe controller resets: the I/O is not lost, it goes back to the start of dispatch.
Completion. blk_mq_complete_request() has to answer a question that did not exist in the single-queue world: on which CPU should the completion run? A device’s interrupt may land on any CPU, but the task waiting for the data is on the CPU that submitted. blk_mq_complete_need_ipi() decides, and every branch is a cost judgement:
if (!IS_ENABLED(CONFIG_SMP) || !test_bit(QUEUE_FLAG_SAME_COMP, &rq->q->queue_flags))
return false;
/* With force threaded interrupts enabled, raising softirq from an SMP
* function call will always result in waking the ksoftirqd thread.
* This is probably worse than completing the request on a different
* cache domain. */
if (force_irqthreads())
return false;
/* same CPU or cache domain and capacity? Complete locally */
if (cpu == rq->mq_ctx->cpu ||
(!test_bit(QUEUE_FLAG_SAME_FORCE, &rq->q->queue_flags) &&
cpus_share_cache(cpu, rq->mq_ctx->cpu) &&
cpus_equal_capacity(cpu, rq->mq_ctx->cpu)))
return false;
/* don't try to IPI to an offline CPU */
return cpu_online(rq->mq_ctx->cpu);Read as policy: send an inter-processor interrupt to the submitting CPU only if it is a different CPU, and it does not share a last-level cache with this one (so the completion data really would be cold), and the two CPUs have equal capacity (on big.LITTLE-style asymmetric systems, bouncing to a little core to save a cache miss is a bad trade), and that CPU is still online. cpus_equal_capacity() is the newest of these conditions and exists purely for heterogeneous ARM systems. QUEUE_FLAG_SAME_FORCE (exposed as rq_affinity=2 in sysfs) removes the cache-sharing escape and forces completion on the exact submitting CPU.
stateDiagram-v2 [*] --> IDLE: blk_mq_get_tag() succeeds<br/>static_rqs[tag] claimed IDLE --> STAGED: blk_mq_insert_request()<br/>ctx->rq_lists[] or elevator IDLE --> IN_FLIGHT: blk_mq_try_issue_directly()<br/>fast path, no staging STAGED --> IN_FLIGHT: blk_mq_run_hw_queue()<br/>then ops->queue_rq() IN_FLIGHT --> IN_FLIGHT: BLK_EH_RESET_TIMER<br/>driver says "still working" IN_FLIGHT --> STAGED: BLK_STS_RESOURCE / requeue<br/>driver tag released,<br/>RQF_TIMED_OUT cleared IN_FLIGHT --> COMPLETE: device completion returns the tag IN_FLIGHT --> COMPLETE: ops->timeout() -> BLK_EH_DONE<br/>driver owns the abort COMPLETE --> [*]: bio_endio(), tag returned to sbitmap note right of IN_FLIGHT blk_add_timer() armed BEFORE the state flips, so no request is ever in flight without a deadline. end note note right of COMPLETE Exactly one path may claim a completion. The timeout scan is two-pass with an RCU/SRCU wait between detect and act, precisely to keep that true. end note
The request state machine, with the paths that are easy to forget. What it shows: only three states, but five ways to leave MQ_RQ_IN_FLIGHT — normal completion, driver-owned abort, timer reset, resource-pressure requeue, and the direct-issue path that skipped staging on the way in. The insight: timeout and completion are racing for the same request, and blk-mq resolves that race structurally rather than with a lock — the deadline is armed before the request is publishable, and the timeout handler proves a request is really expired by waiting out every in-progress submission before it acts.
Scheduler Selection — Why NVMe Gets none
A claim repeated almost everywhere, including in the earlier version of this note, is that the default I/O scheduler is chosen by distribution udev rules. That is at most half true, and the more important half is wrong. The kernel picks first, in elevator_get_default() (block/elevator.c, v6.12), and the whole function is three conditions:
static struct elevator_type *elevator_get_default(struct request_queue *q)
{
if (q->tag_set->flags & BLK_MQ_F_NO_SCHED_BY_DEFAULT)
return NULL;
if (q->nr_hw_queues != 1 &&
!blk_mq_is_shared_tags(q->tag_set->flags))
return NULL;
return elevator_find_get("mq-deadline");
}Read it as English: if the driver asked for no scheduler, none. If the device has more than one hardware queue and does not share tags, none. Otherwise, mq-deadline. An NVMe SSD has one hardware queue per CPU and unshared tags, so it takes the second branch and gets none from the kernel itself, on a machine with no udev rules at all. A SATA SSD or spinning disk driven through SCSI presents a single hardware queue, so it takes the fall-through and gets mq-deadline.
This function is also young. An existence check finds elevator_get_default absent from block/elevator.c at v5.1, v5.2 and v5.3, and present at v5.4 in a simpler form (if (q->nr_hw_queues != 1) return NULL; return elevator_get(q, "mq-deadline", false);); the BLK_MQ_F_NO_SCHED_BY_DEFAULT and shared-tags clauses were added later. So “the kernel chooses” is itself a post-5.4 fact.
flowchart TB START["elevator_init_mq() at queue creation"] --> Q1{"driver set<br/>BLK_MQ_F_NO_SCHED_BY_DEFAULT?"} Q1 -->|yes| NONE["no elevator — 'none'"] Q1 -->|no| Q2{"nr_hw_queues != 1<br/>and tags not shared?"} Q2 -->|yes| NONE Q2 -->|no| MQD["mq-deadline"] NONE --> UDEV["userspace may still override:<br/>echo bfq > /sys/block/X/queue/scheduler"] MQD --> UDEV NONE -.->|"NVMe: many hw queues,<br/>unshared tags"| EX1["nvme0n1 => none"] MQD -.->|"SATA SSD / HDD via scsi-mq:<br/>one hw queue"| EX2["sda => mq-deadline"] MQD -.->|"SD card, eMMC:<br/>one hw queue, slow"| EX3["mmcblk0 => mq-deadline<br/>(BFQ often preferred, by udev)"]
Who actually picks your I/O scheduler. What it shows: the kernel makes the decision at queue-construction time from two bits of information — a driver opt-out flag and the hardware-queue count — and udev only ever overrides that choice afterwards. The insight: the widespread advice “set none for NVMe with a udev rule” is usually a no-op, because the kernel already did. The rules that genuinely matter are the ones that override mq-deadline with bfq on single-queue devices.
Why is none right for NVMe? The reasoning is the inverse of why schedulers existed at all. A rotational disk punishes non-sequential access by tens of milliseconds of seek, so spending microseconds of CPU to reorder requests is enormously profitable. An NVMe device has no seek penalty, executes many commands concurrently across independent channels, and does its own internal scheduling — so reordering in software cannot make the media faster, while the scheduler’s own locking does become a bottleneck at a million IOPS. LWN put the counter-argument fairly when the multi-queue schedulers were merged: even fast devices benefit from scheduling because “a scheduler can coalesce adjacent requests, reducing the overall operation count, and it can prioritize some operations over others” (LWN, “Two new block I/O schedulers for 4.12”). That is exactly why none is not the same as “no block layer”: merging and plugging still happen on the none path. What you give up is reordering and fairness, not batching. LWN’s original 2013 write-up of the design drew precisely this line, before blk-mq had any scheduler at all: “Reordering of requests for locality offers little or no benefit on solid-state devices; indeed, spreading requests out across the device might help with the parallel processing of requests. So reordering will not be done, but coalescing requests will reduce the total number of I/O [operations]” (LWN, June 2013).
The remaining three schedulers divide the space along different axes:
| Scheduler | Merged | Core idea | Key tunables (v6.12 defaults) | Best fit |
|---|---|---|---|---|
none | — | FIFO pass-through; merging and plugging still apply | — | NVMe, and anything where the device outruns the scheduler |
mq-deadline | 4.11 (as proof of concept) | Two FIFOs (read, write) plus a sector-sorted list; a request that hits its deadline preempts the sorted order | read_expire = HZ/2 (500 ms), write_expire = 5*HZ (5 s), writes_starved = 2, fifo_batch = 16, prio_aging_expire = 10*HZ | Single-queue devices; the kernel’s fall-through default |
bfq | 4.12 | Per-process I/O budgets in sectors, plus a large set of interactivity heuristics | low_latency, slice_idle, weights via blkio cgroup | Desktops, phones, SD cards — anywhere interactive latency under load matters more than peak throughput |
kyber | 4.12 | Throttles queue depth per domain to hit explicit latency targets | read target 2 ms, write target 10 ms, discard target 5 s; depths read 256 / write 128 / discard 64 / other 16; KYBER_ASYNC_PERCENT = 75 | Fast multi-queue devices where you still want a latency ceiling |
The four schedulers in a 6.12 kernel, with constants read from block/mq-deadline.c and block/kyber-iosched.c. What it shows: they are not four points on one quality scale but four different control variables — nothing, deadlines, budgets, and depth. The insight: Kyber is the one designed for the blk-mq era. Its header comment says it “controls latency by throttling queue depths using scalable techniques,” and the constant table carries the justification: “Even for fast devices with lots of tags like NVMe, you can saturate the device with only a fraction of the maximum possible queue depth.” Kyber does not reorder much; it simply refuses to let too much I/O be outstanding at once, which is the only lever that still works when the device has no seek penalty. It also reserves headroom for synchronous work — KYBER_ASYNC_PERCENT = 75 under the comment “we reserve 25% of requests for synchronous operations.”
A widely-repeated claim that stopped being true in 6.10
Almost every guide written before 2024 states that
mq-deadlinemust be used with host-managed zoned and SMR devices, because it was the scheduler that held the per-zone write lock guaranteeing writes reach a zone in order. That was true for years, and it is false in 6.12. An existence check onblock/mq-deadline.cfinds 41 occurrences of “zone” at v6.8 and v6.9, and zero at v6.10, v6.11 and v6.12 — whileblk_zone_plug_bioappears inblock/blk-zoned.cat v6.10 and later and not before. Write ordering moved out of the elevator and into the block layer core as zone write plugging, which is why you can seeblk_zone_plug_bio()called directly fromblk_mq_submit_bio()in the submission sequence above. The practical consequence: from 6.10 onward, zoned devices no longer force your scheduler choice, and audevrule pinningmq-deadlineon an SMR drive for ordering reasons is now cargo cult. (Pinned by existence check; the responsible commit series is Damien Le Moal’s zone-write-plugging work in the 6.10 merge window. See Zoned Block Devices.)
Whether the kernel should pick a better default for slow single-queue devices was argued out on the list in October 2018 and never resolved. Linus Walleij proposed making BFQ the default for single-queue devices; Axboe’s response was “I think this should just be done with udev rules, and I’d prefer if the distros would lead the way on this.” The counter-arguments are worth knowing because they are still true: Paolo Valente noted that almost nobody understands I/O schedulers well enough to choose, so the default is the policy for nearly everyone; Walleij pointed out that many systems have no udev at all, and that “on embedded systems where initramfs is not in use, it’s currently not possible to mount the root filesystem using BFQ”; and Damien Le Moal raised the blocker that BFQ does not provide the write-ordering guarantees SMR (shingled magnetic recording) drives require, so it cannot be a blanket default (LWN, “I/O scheduling for single-queue devices”). The discussion “wound down without reaching any sort of clear conclusion,” which is why mq-deadline is still the kernel’s fall-through today. Fuller treatment in Linux IO Schedulers Overview and Choosing an IO Scheduler.
Mapping CPUs onto Hardware Queues
One structural piece remains: given nr_hw_queues hardware queues and nr_cpu_ids CPUs, which CPU feeds which queue? The default is blk_mq_map_queues() (block/blk-mq-cpumap.c, v6.12), and it is short enough to read whole:
void blk_mq_map_queues(struct blk_mq_queue_map *qmap)
{
const struct cpumask *masks;
unsigned int queue, cpu;
masks = group_cpus_evenly(qmap->nr_queues);
if (!masks) { /* allocation failed */
for_each_possible_cpu(cpu)
qmap->mq_map[cpu] = qmap->queue_offset;
return; /* everyone on queue 0 */
}
for (queue = 0; queue < qmap->nr_queues; queue++) {
for_each_cpu(cpu, &masks[queue])
qmap->mq_map[cpu] = qmap->queue_offset + queue;
}
kfree(masks);
}The real work is in group_cpus_evenly(), a generic helper that partitions CPUs into groups while respecting NUMA topology and sibling relationships — so on a two-socket machine the CPUs assigned to one hardware queue tend to share a node, and a completion interrupt for that queue lands near the memory the request touched. mq_map[] is a plain array indexed by CPU, so the runtime lookup is a load; the reverse lookup (blk_mq_hw_queue_to_node()) is a linear scan, with the frank comment “We have no quick way of doing reverse lookups. This is only used at queue init time, so runtime isn’t important.”
A driver that knows better overrides this with ops->map_queues. The NVMe PCIe driver is the instructive case (drivers/nvme/host/pci.c, v6.12): it maps each queue type separately, and for everything except polled queues it uses blk_mq_pci_map_queues(), which derives the mapping from the interrupt affinity the PCI layer already assigned. That alignment is the point — the CPU that submits, the queue it submits into, and the CPU that takes the completion interrupt are the same CPU, so the request and its completion never cross a cache. Polled queues fall back to the generic mapping, under an explicit comment: “The poll queue(s) doesn’t have an IRQ (and hence IRQ affinity), so use the regular blk-mq cpu mapping.” Note also the BUG_ON(i == HCTX_TYPE_DEFAULT) guarding a map with zero queues: read and poll queue sets may be empty, the default set may never be.
The queue types themselves are the enum hctx_type triple — HCTX_TYPE_DEFAULT, HCTX_TYPE_READ, HCTX_TYPE_POLL — and blk_mq_get_hctx_type() selects between them from the operation flags: REQ_POLLED picks POLL, REQ_OP_READ picks READ, everything else falls to DEFAULT. NVMe exposes the split as module parameters, both defaulting to 0: nvme.write_queues (“If not set, reads and writes will share a queue set”) and nvme.poll_queues (“Number of queues to use for polled IO”). So out of the box a Linux machine has one queue set; separating reads from writes, or dedicating queues to polling, is opt-in. The algorithm and its NUMA consequences are dissected in blk-mq CPU to Queue Mapping.
Failure Modes and Common Misunderstandings
“blk-mq means the device must have multiple hardware queues.” No. Plenty of devices declare nr_hw_queues = 1. blk-mq still gives them per-CPU software staging queues — the scaling benefit on the submission side survives even with a single hardware queue, because the hot path (inserting requests) is per-CPU. The single hardware queue is just the fan-in point.
“mq-deadline is the old deadline scheduler.” It is a rewrite for the multi-queue framework. The single-queue deadline was deleted in 5.0. They share a design philosophy (deadline-bounded dispatch) but are different code.
Confusing none with “no block layer.” none is a real, selectable scheduler: a FIFO pass-through that does no reordering, used for fast NVMe where reordering buys nothing and merging is cheap to skip. It is still blk-mq underneath.
Tag exhaustion stalls. Because in-flight requests are bounded by queue_depth tags per hardware queue, a driver that holds tags too long (slow device, stuck command) starves new submissions. The symptom is rising I/O latency with the device not obviously saturated. Diagnose it directly: /sys/kernel/debug/block/<dev>/hctx*/tags prints nr_tags, nr_reserved_tags and the current active_queues, and tags_bitmap dumps the raw sbitmap so you can see how full it is. dispatch_busy on the same hctx is blk-mq’s own exponentially-weighted estimate of how often dispatch is failing. Tag accounting is detailed in Tag Sets and Request Allocation.
Misreading what nr_requests bounds. This is the subtlest thing in blk-mq’s sysfs surface, and the answer depends on whether a scheduler is attached. Writing to nr_requests calls blk_mq_update_nr_requests() → blk_mq_tag_update_depth(), which takes a can_grow argument. With no elevator, can_grow is false: any value above the tag set’s nr_tags is rejected with -EINVAL, because the struct request objects for higher indices were never allocated (static_rqs[] is sized once, at queue creation). With an elevator attached, the call targets hctx->sched_tags — a separate pool — with can_grow = true, so nr_requests may legitimately exceed the driver’s queue_depth, up to MAX_SCHED_RQ (16 * BLKDEV_DEFAULT_RQ = 2,048). The floor is BLKDEV_MIN_RQ = 4 in both cases.
That difference is the whole point of scheduler tags: the elevator wants more requests queued than the device can hold, because reordering a queue of eight requests achieves nothing. So a scheduled device has two nested limits — how many requests may be queued in software (nr_requests, scheduler tags) and how many may be at the device (queue_depth, driver tags) — and a request must win a driver tag at dispatch time on top of the scheduler tag it already holds. Raising the device-side limit is not a sysfs operation at all; for the NVMe PCIe driver it is nvme.io_queue_depth (default 1024, documented as “should >= 2 and < 4096”), set at module load.
Tag starvation between sibling queues on a shared tag set. On a SCSI host, every LUN is its own request_queue but they share one tag set. hctx_may_queue() gives each active queue only depth/active_queues tags. A workload that is slow on one LUN can therefore appear as reduced maximum concurrency on an unrelated LUN of the same host — not because the device is busy, but because the fair-share divisor went up. active_queues in the debugfs tags file is where you see this.
Assuming completions are ordered. They are not, and the kernel documentation says so explicitly: “Neither the block layer nor the device protocols guarantee the order of completion of requests. This must be handled by higher layers, like the filesystem” (blk-mq.rst, v6.12). With many hardware queues completing concurrently on different CPUs, out-of-order completion is the normal case, not an edge case. Durability ordering is a filesystem’s job, achieved with flush and FUA (Force Unit Access) requests — which is why REQ_OP_FLUSH gets its own bypass path in blk_mq_insert_request().
A timeout handler that returns BLK_EH_DONE without completing the request. BLK_EH_DONE is a promise: I have taken ownership and I will complete this request. blk-mq then does nothing further — no re-armed timer, no second chance. A driver that returns it and then loses the request leaks a tag permanently, and enough such leaks silently shrink the effective queue depth to zero. The failure looks like a device that gets progressively slower and finally stops, with no errors logged.
Blaming blk-mq for latency that is scheduler policy. mq-deadline’s write_expire is 5 seconds and writes_starved is 2 — meaning reads may legitimately preempt writes twice in a row before a write is forced through. A write-heavy workload seeing multi-second worst-case write latency on a single-queue device is very often observing the documented default ("these limits are SOFT!" says the comment beside them in block/mq-deadline.c), not a bug. Check /sys/block/<dev>/queue/iosched/ before going deeper.
Alternatives and Historical Context
The only alternative to blk-mq is the thing it replaced — the single-queue layer — and that no longer exists in any supported kernel (gone since 5.0/2019). There is therefore no “choose blk-mq vs single-queue” decision in a modern kernel; the choice that remains is which I/O scheduler to attach on top (or none), which is a genuine trade-off covered in Linux IO Schedulers Overview and Choosing an IO Scheduler.
In spirit, blk-mq is the block-layer instance of a kernel-wide pattern: replace a globally locked shared structure with per-CPU data plus a small fan-in, the same pattern used throughout the kernel to scale on many cores (see Linux Kernel Synchronization MOC). NVMe’s own host/controller design — per-CPU submission/completion queue pairs — is the hardware mirror image of blk-mq’s software model, which is why the two compose so cleanly (NVMe Queue Pairs and the Driver).
Production Notes
Userspace sees blk-mq through three interfaces, and knowing which one answers which question saves a lot of guessing.
sysfs is the tuning surface. Under /sys/block/<dev>/queue/: scheduler shows the available and active scheduler in the familiar bracketed form ([none] mq-deadline kyber bfq) and is writable; nr_requests is the per-queue depth (bounded by the driver’s queue_depth with no scheduler, or by MAX_SCHED_RQ = 2,048 with one — see Failure Modes); rq_affinity controls completion steering — 1 is the default cache-domain heuristic, 2 sets QUEUE_FLAG_SAME_FORCE and pins completion to the exact submitting CPU; nomerges disables merging (1 for simple merges only, 2 for none) and is a useful A/B when you suspect merge cost. Under /sys/block/<dev>/mq/<n>/ there is one directory per hardware queue, and in v6.12 it exposes exactly three read-only files — nr_tags, nr_reserved_tags and cpu_list (block/blk-mq-sysfs.c). That is a smaller window than older write-ups imply; the interesting state moved to debugfs.
debugfs is the diagnostic surface, at /sys/kernel/debug/block/<dev>/, and it is where blk-mq will actually tell you what is wrong. The per-hctx attributes in v6.12 (block/blk-mq-debugfs.c) are:
File under hctx<N>/ | What it answers |
|---|---|
state | BLK_MQ_S_STOPPED, S_TAG_ACTIVE, S_SCHED_RESTART, S_INACTIVE — is this queue stopped or restarting? |
flags | The BLK_MQ_F_* bits: SHOULD_MERGE, TAG_QUEUE_SHARED, STACKING, BLOCKING, NO_SCHED, NO_SCHED_BY_DEFAULT |
dispatch | The requests currently stuck on hctx->dispatch — the queue of last resort |
busy | Requests in flight right now |
ctx_map | Which software queues have pending work (the sbitmap the dispatcher consults) |
tags, tags_bitmap | Tag pool size, active_queues, and the raw allocation bitmap |
sched_tags, sched_tags_bitmap | The same for the scheduler’s separate tag pool, when an elevator is attached |
dispatch_busy | Rolling estimate of dispatch failure — non-zero means requests are being staged rather than issued directly |
type | default, read or poll |
The blk-mq debugfs surface (requires CONFIG_BLK_DEBUG_FS, and files are mode 0400). What it shows: every internal structure this note describes has a corresponding file. The insight: the diagnostic path for “my I/O is slow and the device is not busy” is mechanical — read tags to see if you are out of tags, active_queues to see whether a sibling queue is taking your share, dispatch to see if requests are stuck at the last hop, and dispatch_busy to see whether the fast direct-issue path has been abandoned.
Tracepoints are the timeline surface. The block: events map one-to-one onto the stages in this note — block_bio_queue, block_getrq, block_rq_insert, block_rq_issue, block_rq_complete, block_rq_requeue, block_plug, block_unplug — and blktrace/blkparse or bpftrace on top of them will tell you exactly where in the pipeline latency accumulated. A request that shows a long gap between block_rq_insert and block_rq_issue is waiting on dispatch (tags, budget, or the scheduler); a long gap between block_rq_issue and block_rq_complete is waiting on the device.
Who Really Picks Your Scheduler
The earlier version of this note said the default scheduler is “chosen by udev rules.” That is wrong as a first-order description, and the corrected picture matters operationally:
- The kernel chooses first, at queue construction, in
elevator_get_default()—nonefor multi-queue devices with unshared tags (all NVMe),mq-deadlineotherwise. This happens with no userspace involvement whatsoever, beforeadd_disk(). udevmay then override, via rules such as the widely-copied60-ioschedulers.rules, typically to installbfqon rotational or slow single-queue devices.
So the popular advice “add a udev rule setting none for NVMe” is almost always a no-op — the kernel already did it. The rules that change anything are the ones replacing mq-deadline. Verify on the machine in front of you rather than assuming either way: cat /sys/block/<dev>/queue/scheduler, and cat /sys/block/<dev>/queue/rotational plus ls /sys/block/<dev>/mq/ | wc -l to see which branch of elevator_get_default() the device took.
Uncertain
Verify: which
udevrules a specific distribution ships and what they set. Reason: the kernel-side default is now pinned to primary source (block/elevator.c, v6.12, quoted above), but the userspace overlay genuinely does vary by distribution and release, and no distribution’s rule file was fetched during this research. To resolve: read the shipping rules on the target system —grep -r scheduler /usr/lib/udev/rules.d/ /etc/udev/rules.d/— rather than trusting any general claim, this note’s included. uncertain
See Also
- Software and Hardware Queues in blk-mq — the two queue levels in depth (this note’s companion)
- Tag Sets and Request Allocation — how tags bound in-flight I/O and gate request allocation
- blk-mq CPU to Queue Mapping — the algorithm mapping CPUs to hardware queues
- The Block IO Submission Path —
submit_bio()toblk_mq_submit_bio() - Linux IO Schedulers Overview — mq-deadline, BFQ, Kyber, none
- The bio Structure and Request Queues and struct request — the units blk-mq operates on
- NVMe Queue Pairs and the Driver — the hardware queue model blk-mq mirrors
- Zoned Block Devices — where write ordering moved to in 6.10, out of
mq-deadlineand into the block layer core - Linux Kernel Synchronization MOC — the per-CPU-data-plus-fan-in pattern blk-mq is an instance of
- Up: Linux Block Layer and Storage MOC (§2)