blk-mq’s two-level design rests on two distinct kernel objects. The software staging queue — struct blk_mq_ctx, one per CPU — is where an I/O request first lands after submission; it is the per-CPU buffer where requests accumulate so they can be merged and scheduled before being committed to the device (kernel docs). The hardware dispatch queue — struct blk_mq_hw_ctx, universally abbreviated hctx — is the other end: a small fixed number of these, each bound to one of the device’s real submission queues, forming “the last step of the block layer submission code before the low level device driver taking ownership of the request” (kernel docs). Many software queues funnel into each hardware queue. This note dissects both structures, the dispatch flow that moves requests from one to the other (blk_mq_run_hw_queue → blk_mq_dispatch_rq_list), the BLK_MQ_F_* behavioural flags, and how nr_hw_queues relates to the device’s queue count. The architectural overview is in The Multi-Queue Block Layer blk-mq.
This note is pinned to Linux 6.12 LTS (released 2024-11-17). Structure definitions and dispatch code below are quoted from the v6.12 source blobs listed in sources.
Boundary with the hub note
The Multi-Queue Block Layer blk-mq owns the overall architecture — why the single-queue layer died, the 5.0 cutover, the tag/sbitmap subsystem, the request state machine, plugging, and the historical narrative. This note owns the two queue structures themselves (blk_mq_ctx and blk_mq_hw_ctx), the map between them, and the dispatch code that walks it. Where the two touch — tags, scheduler selection, the CPU-mapping algorithm — this note states only what is needed to make the structures make sense and links out. The mapping algorithm belongs to blk-mq CPU to Queue Mapping; the tag pools belong to Tag Sets and Request Allocation.
The Central Idea: Two Queues Exist for Two Different Reasons
The single most useful sentence about blk-mq’s two-level design is that the two levels are not two stages of the same pipeline — they are answers to two unrelated questions.
The software queue exists to make submission lock-free per CPU. This is a pure software concern: on a 32-core machine, thirty-two threads calling submit_bio() simultaneously must not serialise on one lock. The fix is the oldest trick in the kernel’s book — give every CPU its own structure. blk_mq_ctx is allocated __percpu, and a CPU inserting a request touches only its own copy. The number of software queues is therefore fixed by the machine: it is nr_cpu_ids, always, on every device, regardless of what the hardware can do. A USB stick and a 4 TB NVMe drive both get 32 software queues on this 32-CPU host.
The hardware queue exists because the device has a fixed number of real queues. This is a pure hardware concern and the kernel has no say in it. An NVMe controller negotiates some number of submission/completion queue pairs at probe time; an AHCI SATA controller has exactly one command list. nr_hw_queues is whatever the device offers (clamped, as shown below). The number of hardware queues is fixed by the device, not the machine.
Because one number comes from the CPU topology and the other from the device, the map between them is the entire design. Everything structural in blk-mq follows from it: the ctx_map sbitmap exists only because many ctxs can feed one hctx; index_hw[] exists only to locate a ctx’s bit in that map; the round-robin next_cpu walk exists only because one hctx may serve several CPUs.
And here is the consequence that matters most in 2026: on a modern NVMe device, nr_hw_queues usually equals the CPU count, which collapses the two-level scheme into a near pass-through. The NVMe PCIe driver asks for exactly that — nvme_max_io_queues() returns num_possible_cpus() + dev->nr_write_queues + dev->nr_poll_queues (drivers/nvme/host/pci.c line 2302) — so with the default write_queues=0 and poll_queues=0 the driver requests precisely one I/O queue per CPU. When the controller grants them, every hardware queue ends up with nr_ctx == 1: one software queue, one hardware queue, one CPU, all the way down. The staging level still exists structurally, but it stages nothing — a request is inserted into a per-CPU list that is immediately drained by a hardware queue serving only that CPU.
This is measurable rather than theoretical, and the measurement section below shows it on this host: 32 CPUs, 32 hardware queues, every cpu_list containing exactly one CPU.
What that means for whether a scheduler is even used is the punchline. elevator_get_default() decides the default I/O scheduler at disk-registration time, and its rule is short (block/elevator.c lines 558–572):
/* * For single queue devices, default to using mq-deadline. If we have multiple * queues or mq-deadline is not available, default to "none". */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");}
Returning NULL means no elevator is attached at all — elevator_init_mq() simply returns and the queue runs with none. So the test q->nr_hw_queues != 1 is the whole story: a device with more than one hardware queue gets no I/O scheduler by default. Not “gets a fast scheduler” — gets none, meaning blk_mq_sched_dispatch_requests() takes the no-elevator branch and drains the software queues directly through blk_mq_flush_busy_ctxs().
The reasoning is sound. An I/O scheduler’s job is to reorder requests to compensate for a device that cares about order — seek distance on a rotating disk, or head-of-line blocking behind one deep command queue. A device with 32 independent hardware queues has neither problem: there is nothing to reorder because there is no shared resource being contended for, and reordering would cost a global decision point, which is exactly the serialisation the two-level design was built to remove. Attaching a scheduler to a 32-queue NVMe drive reintroduces the bottleneck blk-mq exists to avoid — which is why the default is none and why this box reports [none] even though three schedulers are compiled in and available.
The same logic runs the other way for a single-queue device. With nr_hw_queues == 1, all 32 software queues funnel into one hardware queue, there is a contended shared resource, order does matter, and mq-deadline is attached by default. This is also the case LWN described when single-queue devices were moved onto blk-mq: the concern was precisely that they would lose the scheduling they depended on (LWN, “I/O scheduling for single-queue devices”).
Mental Model
The two queue levels exist to separate where work is collected from where work is committed. Collection must be cheap and lock-free across cores, so it is per-CPU: each CPU drops its requests into its own blk_mq_ctx with no cross-CPU coordination. Commitment must match the hardware, so it is per-device-queue: a small number of blk_mq_hw_ctx structures, each driving one device submission queue. The kernel maps CPUs onto hardware queues — several CPUs typically share one hardware queue — and that mapping is what binds the two levels.
flowchart TB
subgraph SWQ["Software staging queues (per-CPU)"]
direction LR
S0["blk_mq_ctx[0]<br/>rq_lists[DEFAULT/READ/POLL]"]
S1["blk_mq_ctx[1]"]
S2["blk_mq_ctx[2]"]
S3["blk_mq_ctx[3]"]
end
S0 -->|ctx_map bit set| HCTXA
S1 -->|ctx_map bit set| HCTXA
S2 -->|ctx_map bit set| HCTXB
S3 -->|ctx_map bit set| HCTXB
subgraph HWQ["Hardware dispatch queues (hctx)"]
HCTXA["blk_mq_hw_ctx[0]<br/>ctx_map (sbitmap)<br/>dispatch list<br/>tags"]
HCTXB["blk_mq_hw_ctx[1]"]
end
HCTXA -->|queue_rq| DRV["driver"]
HCTXB -->|queue_rq| DRV
DRV --> DEV["device submission queues"]
The two levels and their binding. What it shows: per-CPU software queues hold requests in per-type lists; each hardware queue owns a bitmap (ctx_map) with one bit per software queue mapped to it, set when that software queue has pending work. Dispatch walks the bitmap, drains the marked software queues, and calls the driver’s queue_rq. The insight: the ctx_map sbitmap is the cheap “which software queues have work?” index — the hardware queue never scans empty software queues, it just iterates set bits.
The two structures side by side
Every field difference between the two structures traces back to the “two unrelated questions” framing above. The table is the compressed form of the whole note:
Aspect
struct blk_mq_ctx (software, per-CPU)
struct blk_mq_hw_ctx (hardware, per device queue)
Defined in
block/blk-mq.h (internal — drivers never see it)
include/linux/blk-mq.h (exported — drivers receive it in queue_rq)
Colloquial name
ctx, “software staging queue”
hctx, “hardware dispatch queue”
How many exist
nr_cpu_ids — fixed by the machine
nr_hw_queues — fixed by the device
Allocation
__percpu, via alloc_percpu
one per hardware queue, tracked in q->hctx_table (an xarray)
Holds requests in
rq_lists[HCTX_MAX_TYPES] — one list per queue type
dispatch — only requests the driver already refused
Its lock
ctx->lock, per-CPU, essentially uncontended
hctx->lock, per hardware queue, contended across its CPUs
Index into the other level
index_hw[type] — this ctx’s bit number in the hctx’s ctx_map; hctxs[type] caches the destination hctx
ctxs[] / nr_ctx — the array of ctxs feeding it; ctx_map — one bit per entry
Owns tags?
No
Yes — tags (driver tags) and sched_tags (scheduler tags)
Knows about CPUs?
It is a CPU (ctx->cpu)
cpumask (CPUs served), next_cpu/next_cpu_batch (round-robin run target)
Scheduler state
None
sched_data, owned by the elevator
Visible in sysfs
Only as mq/<N>/cpu<M>/ (an empty kobject directory)
mq/<N>/ with nr_tags, nr_reserved_tags, cpu_list
Lifecycle concern
Follows the CPU (hotplug moves its work)
Follows the device (BLK_MQ_S_STOPPED, BLK_MQ_S_INACTIVE)
Exists to solve
Lock contention between CPUs on submission
Matching the kernel’s request stream to the device’s real queue count
Reading the table: the two rows in bold are the ones that generate every other difference. Because the two counts come from different places, a mapping is required; because a mapping is required, the hctx needs ctx_map/ctxs[]/nr_ctx and the ctx needs index_hw[]/hctxs[]. Note also what the software queue does not have: no tags, no scheduler state, no driver contact. It is a staging buffer and nothing more.
The mapping is many-to-one, and the ratio is the whole story
nr_hw_queues
Typical device
ctx per hctx (32-CPU host)
Default scheduler
What the staging level actually does
1
SATA/AHCI disk, SD card, most virtio-blk guests with one queue
32
mq-deadline
Real work — 32 CPUs’ requests pool, merge, and get reordered before one queue
2–31
Mid-range NVMe, controller with fewer queues than CPUs, nr_maps > 1 splits
2–16
none
Partial pooling — a few CPUs share a queue
32 (== nproc)
Modern NVMe with default module params — this host
1
none
Almost nothing — insert and immediately drain, same CPU
The ratio column is the one to internalise. As it falls to 1, blk-mq’s two-level structure becomes vestigial on the fast path: the code still runs, but no request ever waits in a staging queue for another CPU’s request to join it. The measured consequences of the bottom row — including a kernel counter that never moves — are in the measurement section.
The Software Staging Queue: struct blk_mq_ctx
From the internal header block/blk-mq.h (v6.12), one of these exists per CPU (allocated as __percpu):
Walking the fields: rq_lists[HCTX_MAX_TYPES] is an array of request lists — one per hardware-queue type (HCTX_MAX_TYPES is 3: HCTX_TYPE_DEFAULT, HCTX_TYPE_READ, HCTX_TYPE_POLL). A request inserted on this CPU is appended to the list for its type. lock protects those lists, but it is a per-CPU lock — because there is one blk_mq_ctx per CPU and requests are normally inserted by the CPU that owns the ctx, the lock is almost always uncontended. cpu records which CPU this is. index_hw[type] is this software queue’s index within the hardware queue it maps to for each type — used to find this ctx’s bit in the hardware queue’s ctx_map. hctxs[type] caches the hardware queue this software queue feeds, per type, so insertion can find the destination hctx in O(1).
The ____cacheline_aligned_in_smp annotation (twice) is deliberate: the hot, frequently written part (the lists and their lock) sits on its own cache line, and the whole structure is cache-line aligned, so one CPU writing its software queue does not invalidate a line another CPU is reading. This is the same false-sharing-avoidance discipline used throughout the kernel (Linux Kernel Synchronization MOC).
The single-queue layer had nothing like this — there was one list and one lock for the whole device. Per-CPU staging is the structural change that makes submission scale: “Each CPU submits I/O operations into its own queue, with no interaction with the other CPUs. Contention for the submission queue lock is thus eliminated (when per-CPU queues are used) or greatly reduced (for per-node queues)” (LWN). Merging of adjacent requests and the I/O scheduler’s reordering both happen at this level, while requests are still pooled — once a request is pulled toward the hardware queue, the chance to merge it is gone (the scheduler dispatch logic explicitly notes this; see “Dispatch Flow” below).
The Hardware Dispatch Queue: struct blk_mq_hw_ctx (hctx)
From include/linux/blk-mq.h (v6.12), abbreviated to the load-bearing fields:
struct blk_mq_hw_ctx { struct { spinlock_t lock; /* protects the dispatch list */ struct list_head dispatch; /* requests ready but not yet sent */ unsigned long state; /* BLK_MQ_S_* */ } ____cacheline_aligned_in_smp; struct delayed_work run_work; /* schedule a queue run later */ cpumask_var_t cpumask; /* CPUs this hctx serves */ int next_cpu; /* round-robin run target */ int next_cpu_batch; unsigned long flags; /* BLK_MQ_F_* */ void *sched_data; /* owned by the I/O scheduler */ struct request_queue *queue; struct sbitmap ctx_map; /* one bit per software queue: has work? */ struct blk_mq_ctx *dispatch_from; unsigned int dispatch_busy; unsigned short type; /* HCTX_TYPE_* */ unsigned short nr_ctx; /* number of software queues feeding this */ struct blk_mq_ctx **ctxs; /* the software queues feeding this */ struct blk_mq_tags *tags; /* driver tags */ struct blk_mq_tags *sched_tags; /* scheduler tags */ unsigned int numa_node; unsigned int queue_num; /* index of this hardware queue */ atomic_t nr_active; /* in-flight count (shared-tag case) */ /* ... cpuhp hooks, debugfs, kobj ... */};
The structure header comment states its purpose plainly: it is “State for a hardware queue facing the hardware block device.” Field by field: dispatch is a list of requests that are ready to go to the device but couldn’t be sent — “As soon as the driver can send new requests, requests at this list will be sent first for a fairer dispatch” (header comment). state holds BLK_MQ_S_* bits (STOPPED, TAG_ACTIVE, SCHED_RESTART, INACTIVE) describing whether the queue is running, needs a restart, or has gone inactive because all its CPUs went offline. ctx_map is a scalable bitmap (struct sbitmap) with one bit per software queue that maps to this hctx — when a software queue gains pending work, its bit is set; dispatch iterates set bits to find work without scanning empty queues. ctxs/nr_ctx are the array of software queues that feed this hardware queue, and the count. tags and sched_tags are the tag pools (driver tags vs scheduler tags — see Tag Sets and Request Allocation). cpumask is the set of CPUs this hctx serves; next_cpu/next_cpu_batch drive round-robin selection of which CPU runs the queue. queue_num is this hardware queue’s index, and type records which queue type it is.
A hardware queue, then, is the meeting point: it owns the dispatch list (the last buffer before the driver), the ctx_map index over the software queues that feed it, and the tag space that bounds how many requests can be in flight through it.
Hardware Queue Types
enum hctx_type (in include/linux/blk-mq.h) defines three kinds of hardware queue:
enum hctx_type { HCTX_TYPE_DEFAULT, /* all I/O not otherwise accounted for */ HCTX_TYPE_READ, /* reads only */ HCTX_TYPE_POLL, /* polled I/O of any kind */ HCTX_MAX_TYPES,};
Enumerated from the v6.12 header rather than from memory, with each type’s actual carrier and the driver knob that creates it:
Type
Value
Kernel-doc description
What routes here
Which queues exist
Completion style
HCTX_TYPE_DEFAULT
0
“All I/O not otherwise accounted for.”
Everything not caught below: all writes, discards, flushes, zone ops — and all reads too, unless a READ map exists
Always present; map[0] is mandatory (nr_maps >= 1)
Interrupt
HCTX_TYPE_READ
1
“Just for READ I/O.”
REQ_OP_READ only, and only when the driver built a second map
Only when nr_maps >= 2. For NVMe PCIe this needs the write_queues= module parameter
Interrupt
HCTX_TYPE_POLL
2
“Polled I/O of any kind.”
Any op whose bi_opf carries REQ_POLLED — set by io_uring with IORING_SETUP_IOPOLL
Only when nr_maps == 3. For NVMe PCIe this needs poll_queues=
Enumerated from include/linux/blk-mq.h lines 471–484 (the enum and its kernel-doc) and lines 486–522 (nr_maps, documented as “A number in the range [1, HCTX_MAX_TYPES]”). The trap in row one: it is natural to assume reads go to HCTX_TYPE_READ. They do not, unless the driver asked for a READ map — and by default NVMe does not. On this host write_queues=0 and poll_queues=0, so nr_maps is 1 and every read, write and discard lands on a DEFAULT queue.
static inline enum hctx_type blk_mq_get_hctx_type(blk_opf_t opf){ enum hctx_type type = HCTX_TYPE_DEFAULT; /* * The caller ensure that if REQ_POLLED, poll must be enabled. */ if (opf & REQ_POLLED) type = HCTX_TYPE_POLL; else if ((opf & REQ_OP_MASK) == REQ_OP_READ) type = HCTX_TYPE_READ; return type;}
Note that it always returns a type — including HCTX_TYPE_READ on a device with only one map. The safety net is in the lookup, not the classifier: blk_mq_map_queue() immediately does ctx->hctxs[blk_mq_get_hctx_type(opf)] (block/blk-mq.h lines 107–112), and when the driver declared only one map, ctx->hctxs[HCTX_TYPE_READ] was initialised to point at the sameDEFAULT hctx. The separation is real only if the driver built it; otherwise all three array slots alias one queue. The kernel-doc for map[] says as much: “it’s perfectly legal to share maps between types.”
Type classification and the aliasing that usually undoes it. What it shows: the classifier on the left always produces one of three types, but the array lookup on the right decides whether those types mean anything — with nr_maps == 1 all three slots point at the same hardware queue. The insight: queue types are a driver opt-in, not a kernel default. Reading blk_mq_get_hctx_type() alone would leave you believing this host separates reads from writes; it does not, because nr_maps is 1.
A device may map I/O of different types onto different sets of hardware queues. The classifier is small (blk_mq_get_hctx_type in block/blk-mq.h): if the request has REQ_POLLED set it goes to a POLL queue; otherwise if it is a REQ_OP_READ it goes to a READ queue; otherwise DEFAULT. This is why struct blk_mq_ctx keeps arrays of rq_lists, index_hw, and hctxs indexed by type — a single software queue can feed up to three different hardware queues depending on the operation. The NVMe driver uses this to dedicate poll queues for low-latency polled completion separate from interrupt-driven read/write queues. nr_maps in the tag set says how many of these types the driver actually uses; most simple drivers use one (HCTX_TYPE_DEFAULT only).
How nr_hw_queues Relates to the Device’s Queue Count
nr_hw_queues in the blk_mq_tag_set is the number of blk_mq_hw_ctx structures the queue will have — typically one per hardware submission queue the device exposes. An NVMe controller that advertises one submission/completion queue pair per CPU will set nr_hw_queues to (roughly) the CPU count, giving a near-1:1 CPU-to-hardware-queue mapping. A SATA disk behind AHCI exposes effectively one queue, so its driver sets nr_hw_queues = 1, and all CPUs’ software queues fan into that single hardware queue.
blk-mq imposes a sanity cap at blk_mq_alloc_tag_set time (block/blk-mq.c, v6.12): “There is no use for more h/w queues than cpus if we just have a single map” — so when nr_maps == 1, nr_hw_queues is clamped to nr_cpu_ids. More hardware queues than CPUs would just leave queues permanently idle. The reverse — fewer hardware queues than CPUs — is normal and fine: it simply means several software queues share one hardware queue, which is the common case for everything except top-end NVMe. The exact CPU-to-queue assignment is computed by a separate mapping algorithm (blk_mq_map_queues and friends) owned by blk-mq CPU to Queue Mapping; this note only establishes that the mapping is many-software-to-one-hardware.
The Dispatch Flow: From Staging to Hardware
Requests are run off the queues by blk_mq_run_hw_queue(). It is called after insertion, on completion of other requests, on unquiesce, and from delayed work. Its v6.12 body (block/blk-mq.c):
Reading it: first it computes need_run — there is work to do only if the queue is not quiesced and blk_mq_hctx_has_pending(hctx) is true. That pending check is the index in action — it returns true if the dispatch list is non-empty, or any bit in ctx_map is set (some software queue has work), or the scheduler has work (blk_mq_hctx_has_pending in block/blk-mq.c). If nothing is pending, it returns immediately. If the caller asked for async, or the current CPU is not in this hctx’s cpumask, it defers the run to a kernel worker via blk_mq_delay_run_hw_queue(hctx, 0) so the work lands on a CPU that owns the queue (cache locality). Otherwise it runs the dispatch inline via blk_mq_sched_dispatch_requests().
blk_mq_sched_dispatch_requests() (block/blk-mq-sched.c) chooses how to gather requests. Its logic (__blk_mq_sched_dispatch_requests):
Residual dispatch list first. If hctx->dispatch is non-empty (requests that previously couldn’t be sent), it splices them out and dispatches those first for fairness — they’ve waited longest.
Then the scheduler or the software queues. If an I/O scheduler is attached (hctx->queue->elevator), it pulls requests from the scheduler (blk_mq_do_dispatch_sched), letting the scheduler decide order. If no scheduler, it drains the software queues: blk_mq_flush_busy_ctxs(hctx, &rq_list) walks the ctx_map bitmap, and for each set bit splices that software queue’s request list into a dispatch list, then blk_mq_dispatch_rq_list() sends them.
blk_mq_flush_busy_ctxs is where the bitmap pays off (block/blk-mq.c):
It is invoked once per set bit (sbitmap_for_each_set): the bit number indexes hctx->ctxs[] to find the software queue, its request list is spliced wholesale into the dispatch list under the per-CPUctx->lock (brief, uncontended), and the bit is cleared. After this, every pending request from every busy software queue feeding this hctx sits in one list ready for the driver.
Finally blk_mq_dispatch_rq_list() (block/blk-mq.c) pushes that list to the driver:
do { rq = list_first_entry(list, struct request, queuelist); prep = blk_mq_prep_dispatch_rq(rq, !nr_budgets); /* get driver tag, budget */ if (prep != PREP_DISPATCH_OK) break; list_del_init(&rq->queuelist); bd.rq = rq; bd.last = list_empty(list); /* hint: last of batch */ ret = q->mq_ops->queue_rq(hctx, &bd); /* call the driver */ switch (ret) { case BLK_STS_OK: queued++; break; case BLK_STS_RESOURCE: /* device pushback */ case BLK_STS_DEV_RESOURCE: blk_mq_handle_dev_resource(rq, list); goto out; default: blk_mq_end_request(rq, ret); /* hard error */ }} while (!list_empty(list));
For each request it acquires a driver tag and budget (blk_mq_prep_dispatch_rq), sets bd.last so a batching driver knows when to ring the doorbell once, and calls queue_rq. BLK_STS_OK means accepted; BLK_STS_RESOURCE/BLK_STS_DEV_RESOURCE mean the device is full, so the remaining requests are spliced back onto hctx->dispatch (under hctx->lock) to be retried on the next run — list_splice_tail_init(list, &hctx->dispatch). A memory barrier (smp_mb()) then orders that splice against checking the SCHED_RESTART flag, so a concurrent restart cannot miss the re-added work. This requeue-and-retry is exactly what the dispatch list is for.
Putting the whole descent in one picture, with the branch points that decide whether a request ever visits a staging queue at all:
flowchart TB
SUB["submit_bio()<br/>→ blk_mq_submit_bio()"] --> SPLIT["__bio_split_to_limits()<br/>split to max_sectors_kb / max_segments"]
SPLIT --> MERGE{"blk_mq_attempt_bio_merge()<br/>fits an existing request?"}
MERGE -->|yes| ABSORB["bio absorbed into a request<br/>NO new tag, NO new request<br/>(outcome 2 of 3)"]
MERGE -->|no| TAG["blk_mq_get_new_requests()<br/>allocate a DRIVER TAG from<br/>hctx->tags (sbitmap, 1023 here)"]
TAG --> TAGOK{"tag available?"}
TAGOK -->|no| WAIT["sleep on the tag waitqueue<br/>until a completion frees one"]
WAIT --> TAG
TAGOK -->|yes| PLUG{"current->plug active?"}
PLUG -->|yes| PLUGLIST["queue on the plug list<br/>batch for later flush"]
PLUG -->|no| INSERT["insert into blk_mq_ctx->rq_lists[type]<br/>set this ctx's bit in hctx->ctx_map"]
PLUGLIST --> INSERT
INSERT --> RUN["blk_mq_run_hw_queue()"]
RUN --> PEND{"blk_mq_hctx_has_pending()?<br/>dispatch list OR ctx_map bit OR sched"}
PEND -->|no| DONE["return — nothing to do"]
PEND -->|yes| ELEV{"elevator attached?"}
ELEV -->|"yes (mq-deadline/kyber/bfq)"| SCHED["blk_mq_do_dispatch_sched()<br/>scheduler chooses order"]
ELEV -->|"no — 'none', THIS HOST"| FLUSH["blk_mq_flush_busy_ctxs()<br/>walk ctx_map set bits<br/>splice rq_lists wholesale"]
SCHED --> DISPATCH["blk_mq_dispatch_rq_list()"]
FLUSH --> DISPATCH
DISPATCH --> QRQ["q->mq_ops->queue_rq(hctx, &bd)<br/>bd.last rings the doorbell once"]
QRQ --> STS{"return status"}
STS -->|BLK_STS_OK| DEV["request is on the device"]
STS -->|"BLK_STS_RESOURCE<br/>BLK_STS_DEV_RESOURCE"| REQUEUE["splice remainder back onto<br/>hctx->dispatch, retry next run"]
STS -->|error| FAIL["blk_mq_end_request(rq, err)"]
REQUEUE -.->|"drained FIRST on next run"| DISPATCH
The full submission descent, from submit_bio() to the driver. What it shows: the two places a request can fail to proceed (no tag → sleep; device busy → requeue onto hctx->dispatch), and the elevator branch that this host never takes. The insight: the ABSORB branch is the cheapest possible outcome — a bio that merges into an existing request consumes no tag and creates no dispatch work, which is why merging is attempted before tag allocation rather than after. The residual hctx->dispatch list re-enters at the bottom and is drained ahead of fresh staging-queue work, so a once-rejected request outranks newer arrivals. The three outcomes for the incoming bio itself — split, absorbed, or promoted to a new request — are the subject of the bio note.
The Run-CPU Selection — Round-Robin Across the Mask
When a run is deferred to a worker, blk_mq_hctx_next_cpu() (block/blk-mq.c) picks which CPU runs it, round-robin across the hctx’s cpumask:
static int blk_mq_hctx_next_cpu(struct blk_mq_hw_ctx *hctx){ int next_cpu = hctx->next_cpu; if (hctx->queue->nr_hw_queues == 1 || blk_mq_hctx_empty_cpumask(hctx)) return WORK_CPU_UNBOUND; /* single queue: don't pin */ if (--hctx->next_cpu_batch <= 0) { /* every BLK_MQ_CPU_WORK_BATCH (8) runs */ next_cpu = cpumask_next_and(next_cpu, hctx->cpumask, cpu_online_mask); if (next_cpu >= nr_cpu_ids) next_cpu = blk_mq_first_mapped_cpu(hctx); hctx->next_cpu_batch = BLK_MQ_CPU_WORK_BATCH; } /* ... fall back to WORK_CPU_UNBOUND if the chosen CPU is offline ... */ hctx->next_cpu = next_cpu; return next_cpu;}
The point: it doesn’t switch CPUs on every run (that would thrash caches) — it stays on one CPU for BLK_MQ_CPU_WORK_BATCH (8) runs, then advances to the next online CPU in the mask. For a single-hardware-queue device it returns WORK_CPU_UNBOUND and lets the scheduler place the work anywhere, since there’s no per-queue locality to preserve.
The BLK_MQ_F_* Behavioural Flags
The tag set’s flags field carries BLK_MQ_F_* bits that configure queue behaviour (from include/linux/blk-mq.h, v6.12):
enum { BLK_MQ_F_SHOULD_MERGE = 1 << 0, /* enable request merging */ BLK_MQ_F_TAG_QUEUE_SHARED = 1 << 1, /* tags shared across queues */ BLK_MQ_F_STACKING = 1 << 2, /* device sits atop another blk-mq dev */ BLK_MQ_F_TAG_HCTX_SHARED = 1 << 3, /* hctx-level shared tags */ BLK_MQ_F_BLOCKING = 1 << 4, /* queue_rq may sleep */ BLK_MQ_F_NO_SCHED = 1 << 5, /* forbid attaching an I/O scheduler */ BLK_MQ_F_NO_SCHED_BY_DEFAULT = 1 << 6, /* default to 'none', not mq-deadline */ /* bits 7+ encode the tag allocation policy (FIFO vs round-robin) */};
Their meaning: SHOULD_MERGE turns on adjacent-request merging in the staging queues. TAG_QUEUE_SHARED marks that several request queues share one tag set (common when one controller backs many namespaces/LUNs) — it changes how tag exhaustion and nr_active are accounted (see Tag Sets and Request Allocation). STACKING is set when the device “requires underlying blk-mq device for completing IO” (header comment) — the stacking layers like device-mapper set this. BLOCKING declares that the driver’s queue_rq may sleep; might_sleep_if(... hctx->flags & BLK_MQ_F_BLOCKING) honours it in blk_mq_run_hw_queue (seen above), and the tag set allocates an SRCU (Sleepable Read-Copy-Update) struct (set->srcu in blk_mq_alloc_tag_set) used as the dispatch lock for blocking queues.
Resolved (2026-09-12): an earlier revision of this note flagged uncertainty over whether set->srcu is the lock held around dispatch for BLK_MQ_F_BLOCKING queues. It is, and the macro makes the RCU-vs-SRCU branch explicit (block/blk-mq.h lines 420–439):
/* run the code block in @dispatch_ops with rcu/srcu read lock held */#define __blk_mq_run_dispatch_ops(q, check_sleep, dispatch_ops) \do { \ if ((q)->tag_set->flags & BLK_MQ_F_BLOCKING) { \ struct blk_mq_tag_set *__tag_set = (q)->tag_set; \ int srcu_idx; \ \ might_sleep_if(check_sleep); \ srcu_idx = srcu_read_lock(__tag_set->srcu); \ (dispatch_ops); \ srcu_read_unlock(__tag_set->srcu, srcu_idx); \ } else { \ rcu_read_lock(); \ (dispatch_ops); \ rcu_read_unlock(); \ } \} while (0)
The comment above the macro states the contract — “run the code block in @dispatch_ops with rcu/srcu read lock held” — and the body confirms the branch: blocking queues take srcu_read_lock() on the tag set’s SRCU (not the request queue’s, which matters because several request queues can share one tag set), non-blocking queues take plain rcu_read_lock(). The reason for two mechanisms is that plain RCU read-side critical sections may not sleep, and a BLK_MQ_F_BLOCKING driver’s queue_rq is permitted to. Sleepable RCU (SRCU) allows exactly that at the cost of a slightly heavier read-side. blk_mq_run_dispatch_ops(q, ops) is just __blk_mq_run_dispatch_ops(q, true, ops) — the true enabling the might_sleep_if() debug check. The same branch appears on the waiting side, which is what makes the lock meaningful. blk_mq_quiesce_queue() sets QUEUE_FLAG_QUIESCED via blk_mq_quiesce_queue_nowait() and then calls blk_mq_wait_quiesce_done(), whose entire body is the mirror image of the macro (block/blk-mq.c lines 223–229):
Waiting for a grace period on the same (S)RCU domain that dispatch read-locks is what guarantees every in-flight queue_rq has returned. This is why the kernel-doc can promise that after blk_mq_quiesce_queue() returns, “we make sure no dispatch can happen until the queue is unquiesced” — and it is the mechanism behind the blk_queue_quiesced() check at the top of blk_mq_run_hw_queue() quoted earlier: the flag stops new dispatches, the grace period drains the existing ones.
NO_SCHED forbids attaching any I/O scheduler. NO_SCHED_BY_DEFAULT selects none rather than mq-deadline at registration when there is a single hardware queue or shared hardware queues — the device prefers no reordering. The top bits encode the tag allocation policy (BLK_TAG_ALLOC_FIFO starting from 0, or BLK_TAG_ALLOC_RR round-robin from the last allocated tag).
This Machine: the Real ctx→hctx Map
Everything above is legible in /sys/block/, which is world-readable and is the direct view of this note’s subject. The measurements below were taken on 2026-09-12 on the host this note was written on: an AMD RYZEN AI MAX+ 395 (16 physical cores, 2 threads each, nproc = 32) running Fedora kernel 7.1.13-200.fc44.x86_64, with a Crucial CT4000T710SSD8 3.6 TiB NVMe drive.
Why a 7.1 kernel is quoting 6.12 source
The running kernel is 7.1.13; this note is pinned to 6.12 LTS because that is the vault’s pin. Every structural claim below was checked to still hold on the running kernel by reading its /sys output, and no divergence was found in the attributes this note uses. Where a v6.12 code path is quoted, the URL says v6.12 explicitly.
Thirty-two hardware queues for thirty-two CPUs — the bottom row of the ratio table above, exactly as nvme_max_io_queues() predicts for write_queues=0, poll_queues=0:
The controller’s queue_count is 33, not 32, because NVMe reserves queue 0 as the admin queue — it carries controller commands (identify, create/delete queue, firmware download), never user I/O, and has no blk_mq_hw_ctx. So hardware queue mq/N corresponds to NVMe I/O queue N+1, a correspondence confirmed against the interrupt table in step 4.
Step 2 — the per-hctx attributes
$ cd /sys/block/nvme0n1/mq$ for i in $(ls | sort -n); do> printf "%-5s %-8s %-10s %s\n" "$i" "$(cat $i/nr_tags)" "$(cat $i/nr_reserved_tags)" "$(cat $i/cpu_list)"> done | head -60 1023 0 11 1023 0 172 1023 0 33 1023 0 194 1023 0 45 1023 0 20
The brief for this note was wrong about type
The task brief instructed me to read each hardware queue’s type from /sys/block/<dev>/mq/<N>/type. That file does not exist, and not because of this device or this kernel: block/blk-mq-sysfs.c at v6.12 registers exactly three hctx attributes and no more (lines 105–123):
nr_tags, nr_reserved_tags, cpu_list — that is the complete sysfs surface of a hardware queue. The queue’s type is exposed only through debugfs, at /sys/kernel/debug/block/<dev>/hctx<N>/type, which is root-only on this host (ls: cannot access '/sys/kernel/debug/block/': Permission denied; resolving command: sudo ls /sys/kernel/debug/block/nvme0n1/hctx0/). The type was therefore established from the driver’s nr_maps instead — see the poll_queues/write_queues reading above, which pins nr_maps to 1 and hence every queue to HCTX_TYPE_DEFAULT.
The other two columns are uniform and both informative. nr_tags = 1023 on every queue is the per-hardware-queue driver-tag pool: this device can have 1,023 commands outstanding per queue, so 32 × 1,023 = 32,736 in theory. nr_reserved_tags = 0 means no tags are set aside for BLK_MQ_REQ_RESERVED allocations; NVMe PCIe does not need reserved tags on I/O queues because its admin commands ride the separate admin queue. Note the relationship to the queue-wide knob:
$ cat /sys/block/nvme0n1/queue/nr_requests1023
nr_requests equals nr_tags here, and that is not a coincidence — it is what none means. With no elevator attached there are no scheduler tags, so the only pool bounding in-flight requests is the driver tag pool, and nr_requests reports it. Attach a scheduler and the relationship changes: sched_tags becomes the binding limit and nr_requests becomes independently writable. Full treatment in Tag Sets and Request Allocation.
Step 3 — the map itself
Every cpu_list above contains exactly one CPU. That is the near-pass-through case made concrete: nr_ctx == 1 for all 32 hardware queues. Inverting the listing gives the real map for this machine:
CPU
→ hctx
CPU
→ hctx
CPU
→ hctx
CPU
→ hctx
0
30
8
12
16
31
24
13
1
0
9
14
17
1
25
15
2
28
10
16
18
29
26
17
3
2
11
18
19
3
27
19
4
4
12
20
20
5
28
21
5
6
13
22
21
7
29
23
6
8
14
24
22
9
30
25
7
10
15
26
23
11
31
27
The measured CPU→hctx map on this host, produced by inverting the 32 cpu_list files. What it shows: a bijection, not an identity — CPU 0 feeds hardware queue 30, CPU 1 feeds queue 0. The insight: the mapping is a permutation chosen by the interrupt layer, not by arithmetic on the CPU number. Anyone who assumes hctx[i] serves cpu[i] will read the wrong queue’s statistics.
The permutation is not random. Low CPUs and high CPUs are SMT siblings on this machine:
so CPU n and CPU n+16 are the two hardware threads of one physical core. Lay the map against that and the rule appears: consecutive hardware queues are assigned to the two threads of the same physical core. Queues 0 and 1 go to CPUs 1 and 17 (core 1), queues 2 and 3 to CPUs 3 and 19 (core 3), queues 30 and 31 to CPUs 0 and 16 (core 0). The cores are visited in the order the PCI MSI-X vectors were allocated, which is why core 0 ends up last rather than first.
Drawn for this device — four of the sixteen core-pairs, with the structure that generalises to all of them:
The measured ctx→hctx→device-queue chain on this host. What it shows: a strict 1:1:1 chain — one CPU, one software queue, one hardware queue, one NVMe submission/completion queue pair, one MSI-X interrupt — with SMT siblings landing on adjacent hardware queues and the admin queue (nvme0q0, not shown) shifting every index by one. The insight: every arrow in this diagram is a bijection, so nothing in it funnels. The ctx_map sbitmap on each hctx is a one-bit bitmap; blk_mq_flush_busy_ctxs() iterates a single set bit; the round-robin next_cpu walk has one candidate. The two-level architecture is fully present and doing almost no work — contrast the single-queue case, where all 32 of these CPU boxes would converge on one hctx box.
Step 4 — why that permutation, proved against the interrupt table
The mapping is not blk-mq’s invention. For a PCI device with managed interrupts, blk-mq asks the interrupt layer which CPUs each vector serves and copies the answer. That is directly checkable, because the NVMe I/O queues have one IRQ each:
Compare against the sysfs cpu_list values from step 2:
NVMe I/O queue
IRQ
IRQ smp_affinity_list
blk-mq hctx
hctx cpu_list
Match?
nvme0q1
47
1
mq/0
1
✅
nvme0q2
69
17
mq/1
17
✅
nvme0q31
98
0
mq/30
0
✅
nvme0q32
99
16
mq/31
16
✅
Two independent kernel subsystems reporting the same assignment. What it shows: each hardware queue’s cpu_list is byte-for-byte the affinity mask of the MSI-X interrupt that completes that queue, and the off-by-one (nvme0q<N+1> ↔ mq/<N>) is the admin queue occupying slot 0. The insight: the ctx→hctx map is derived from IRQ affinity, not computed alongside it. The device driver hands blk-mq the interrupt affinity masks and blk-mq builds mq_map from them, which is why the map looks like an arbitrary permutation — it inherits the PCI layer’s vector-allocation order. This is what makes submission and completion land on the same CPU: the CPU that submitted is the CPU the completion interrupt targets.
That last sentence is the design goal the whole permutation serves, and it sets up the most striking measurement on this box.
Step 5 — the BLOCK softirq that never fires
Generating I/O and watching the counters move is the payoff. fio is not installed here (which fio → not found; resolving command: sudo dnf install fio), so dd was used. A first attempt with if=/dev/zero produced nonsense byte counts, for a reason worth recording:
/home is btrfs with zstd compression, so 400 MiB of zeros reached the device as a few MiB. Sourcing incompressible data from /dev/urandom via tmpfs fixes it:
(BLOCK softirq total is the sum of the 32 per-CPU columns of the BLOCK: row in /proc/softirqs.)
Three things happened, and one conspicuously did not:
Counter
Δ
Interpretation
nvme0n1 sectors written
+821,256
821,256 × 512 B = 401.0 MiB — the payload, confirming O_DIRECT reached the device
nvme0n1 writes completed
+3,242
821,256 / 3,242 ≈ 253 sectors ≈ 126.6 KiB per request — pinned to max_sectors_kb
dm-0 sectors written
+821,256
identical to the NVMe — dm-crypt re-emits, it does not amplify
BLOCK softirq, all 32 CPUs
+0
3,242 request completions raised the BLOCK softirq exactly zero times
The per-request size is the first result and it is a direct sighting of bio splitting:
$ cat /sys/block/nvme0n1/queue/max_sectors_kb128
dd issued 400 write syscalls of 1 MiB each, and 3,242 requests reached the device — because each 1 MiB bio was split into 128 KiB pieces before becoming requests. 400 × 8 = 3,200, plus fsync and metadata traffic, gives 3,242. The mechanism is [[The bio Structure#the-three-way-fate-of-a-bio|__bio_split_to_limits()]].
The zero is the real finding. Not one of 3,242 completions raised the BLOCK softirq. The reason is in blk_mq_complete_request_remote() (block/blk-mq.c lines 1192–1216):
bool blk_mq_complete_request_remote(struct request *rq){ WRITE_ONCE(rq->state, MQ_RQ_COMPLETE); /* * For request which hctx has only one ctx mapping, * or a polled request, always complete locally, * it's pointless to redirect the completion. */ if ((rq->mq_hctx->nr_ctx == 1 && rq->mq_ctx->cpu == raw_smp_processor_id()) || rq->cmd_flags & REQ_POLLED) return false; if (blk_mq_complete_need_ipi(rq)) { blk_mq_complete_send_ipi(rq); return true; } if (rq->q->nr_hw_queues == 1) { blk_mq_raise_softirq(rq); return true; } return false;}
Returning false means “not completed remotely” — the caller finishes the request inline, in hard-IRQ context, with no softirq and no IPI. Two independent guards force that outcome here, and the second is the stronger one:
The early return at line 1201.nr_ctx == 1 is true for every hardware queue on this device (step 3 showed each cpu_list holding one CPU), and rq->mq_ctx->cpu == raw_smp_processor_id() is true because step 4 showed the completion interrupt is affine to the submitting CPU. Both halves hold, so the function returns immediately.
The nr_hw_queues == 1 gate at line 1211. Even if guard 1 failed, blk_mq_raise_softirq() — the only call site that raises BLOCK_SOFTIRQ on this path — is unreachable unless the device has exactly one hardware queue. This device has 32.
A correction to the finding as it was handed to me
The brief attributed the unmoving counter solely to “blk-mq’s nr_ctx == 1 && same CPU early return”. That guard is real and it does fire first, but it is not what makes the result robust. The BLOCK softirq is structurally unreachable from request completion on any device with nr_hw_queues > 1, because line 1211 gates blk_mq_raise_softirq() on nr_hw_queues == 1 unconditionally. Pin the CPUs differently, break the affinity, defeat guard 1 entirely — the counter still will not move on this drive. The nr_ctx == 1 guard is an optimisation that avoids an IPI; the nr_hw_queues == 1 gate is what forecloses the softirq. Stated the brief’s way, you would predict that taskset-ing dd onto the “wrong” CPU revives the softirq. It would not.
What the middle branch would do is worth naming, since it is the live path on hosts where guard 1 fails. blk_mq_complete_need_ipi() (block/blk-mq.c lines ~1145–1170) checks QUEUE_FLAG_SAME_COMP — the flag behind this knob:
$ cat /sys/block/nvme0n1/queue/rq_affinity1
rq_affinity=1 means “complete on a CPU in the same cache domain as the submitter”; 2 would force the exact submitting CPU (QUEUE_FLAG_SAME_FORCE). With 1, the helper returns false — no IPI needed — whenever cpus_share_cache(cpu, rq->mq_ctx->cpu), because finishing on a sibling that shares L3 is cheaper than an inter-processor interrupt. So on this machine the completion path is: hard IRQ → inline completion → bi_end_io, with neither a softirq nor an IPI in it.
The general shape, with the two counters that would move on other hardware:
flowchart TB
IRQ["device raises MSI-X<br/>completion interrupt"] --> HANDLER["nvme_irq() reaps CQ<br/>calls blk_mq_complete_request()"]
HANDLER --> G1{"nr_ctx == 1<br/>AND same CPU?<br/>(line 1201)"}
G1 -->|"yes — THIS HOST"| INLINE["return false<br/>complete INLINE in hard IRQ"]
G1 -->|no| G2{"blk_mq_complete_need_ipi()?<br/>(rq_affinity / SAME_COMP)"}
G2 -->|yes| IPI["smp_call_function_single_async()<br/>completes on submitter's CPU"]
G2 -->|no| G3{"nr_hw_queues == 1?<br/>(line 1211)"}
G3 -->|"yes — single-queue device"| SIRQ["raise_softirq(BLOCK_SOFTIRQ)<br/>/proc/softirqs BLOCK moves"]
G3 -->|"no — 32 queues here"| INLINE
INLINE --> END["bi_end_io()<br/>wake the waiter"]
IPI --> END
SIRQ --> END
The completion-redirect decision, from blk_mq_complete_request_remote(). What it shows: three possible destinations for a completion — inline in hard-IRQ context, redirected by IPI, or deferred to the BLOCK softirq — and the two guards that choose between them. The insight: both paths out of G3 on a multi-queue device lead to INLINE. The BLOCK softirq is not merely unlikely on this hardware, it is unreachable, which is why a 400 MiB write moved the counter by exactly zero. The softirq exists for single-queue devices, where one completion CPU must fan work back out to many submitters.
Step 6 — the contrast: devices that have no queues at all
The brief for this note described zram0 (8 GiB, swap, priority 100) as “a non-NVMe queue to contrast”. It is a sharper contrast than that, because zram has no blk-mq queue whatsoever:
$ cat /proc/swapsFilename Type Size Used Priority/dev/zram0 partition 8388604 0 100$ ls /sys/block/zram0/mq 2>&1ls: cannot access '/sys/block/zram0/mq': No such file or directory$ cat /sys/block/zram0/queue/scheduler 2>&1cat: /sys/block/zram0/queue/scheduler: No such file or directory$ cat /sys/block/zram0/queue/nr_requests 2>&1cat: /sys/block/zram0/queue/nr_requests: No such file or directory
The same three files are missing from dm-0, the dm-crypt device holding /home. Both are bio-based drivers: they register a ->submit_bio method directly and never allocate a blk_mq_tag_set, so there is no request_queue with hardware queues, no tags, and no elevator to select. A bio handed to zram is compressed and stored in RAM inside the submitter’s own context and completed immediately — there is nothing to schedule because there is no device to wait for. Queueing structures exist to bridge a latency gap to real hardware, and RAM has none.
This is why the three files are the cleanest test for “is this a blk-mq device?”:
Device
mq/ dir
queue/scheduler
queue/nr_requests
Model
Why
nvme0n1
32 entries
[none] mq-deadline kyber bfq
1023
blk-mq, request-based
Real hardware with 32 real submission queues
nvme0n1p3
inherits nvme0n1
inherits
inherits
partition of the above
A partition is an offset, not a queue
dm-0 (dm-crypt)
absent
absent
absent
bio-based stacking
Transforms bios and re-submits downstream
zram0
absent
absent
absent
bio-based
Backed by RAM — no device latency to hide
What /sys/block/<dev>/queue/ does and does not contain, measured on this host. The insight: the presence of mq/ is the discriminator. Two of this machine’s four interesting block devices are not blk-mq devices at all, and the request-based path this note describes never runs for them. Note the middle rows: a single dd to /home traverses both kinds — bio-based dm-crypt on top, request-based blk-mq underneath — which is exactly why the step-5 measurement had to read dm-0 and nvme0n1 separately.
That stacking is visible in the step-5 numbers: dm-0 and nvme0n1p3 reported identical sector counts (+821,256 each). dm-crypt consumed 401 MiB of bios, encrypted them, and emitted 401 MiB of new bios to the partition below. It is a pure bio-to-bio transform, and only at the bottom of the stack does anything become a request with a tag and a hardware queue.
Step 7 — the schedulers this kernel actually offers
Four names, three of them modules. Checked against block/Kconfig.iosched at v6.12 rather than recalled — the file defines exactly three schedulers, and none is deliberately not among them:
Name
Kconfig symbol
Kconfig default
Model
Attached by default when
Appropriate for
none
(no symbol — built in)
n/a
No elevator.blk_mq_sched_dispatch_requests() takes the no-scheduler branch and drains software queues via blk_mq_flush_busy_ctxs(). Merging still happens
nr_hw_queues != 1 and tags not shared — i.e. any multi-queue device
Multi-queue NVMe, anything where reordering cannot pay for its own decision point. This host’s default
mq-deadline
MQ_IOSCHED_DEADLINE
default y
Two sorted red-black trees (read/write) plus FIFO expiry lists. Serves sorted order until a request exceeds its deadline, then jumps to it — bounding worst-case latency
nr_hw_queues == 1, or shared tags, and the module is available
Rotational disks, single-queue SATA/SCSI, anything where seek order or write starvation is real
kyber
MQ_IOSCHED_KYBER
default y
Per-hctx token throttling. Kernel help text: “a low-overhead scheduler suitable for multiqueue and other fast devices. Given target latencies for reads and synchronous writes, it will self-tune queue depths to achieve that goal”
Never by default — opt-in only
Fast multi-queue devices where read latency must be protected from write floods, and none’s unbounded depth causes read stalls
bfq
IOSCHED_BFQ
no default (n)
Budget Fair Queueing: weighted per-process budgets measured in sectors. Kernel help text: “distributes the bandwidth of the device among all processes according to their weights, regardless of the device parameters and with any workload. It also guarantees a low latency to interactive and soft real-time applications”
Never by default — opt-in only
Desktops and shared/multi-tenant hosts where fairness and interactive responsiveness beat raw throughput. Highest per-request overhead of the four
Enumerated from block/Kconfig.iosched at v6.12; the “Model” descriptions of kyber and bfq quote that file’s own help text verbatim. What it shows: the [none] mq-deadline kyber bfq string is the union of one built-in behaviour and three Kconfig-selected modules. The insight:none is not a scheduler that was chosen over the others — it is the absence of one, and the default on this host not because it won a comparison but because elevator_get_default() never even looked, having seen nr_hw_queues != 1. bfq is the only one of the three that is not default y; Fedora enables it as a module, which is why it appears in the list here.
Switching is a single write, and it is worth knowing what it costs before doing it:
$ echo mq-deadline | sudo tee /sys/block/nvme0n1/queue/scheduler # not run here — requires root
Attaching an elevator to this device would allocate sched_tags per hardware queue, route every insertion through blk_mq_do_dispatch_sched() instead of the direct ctx_map drain, and reintroduce a per-queue ordering decision on a device with 32 independent queues and no seek penalty. The default exists for a reason. The measurement was not performed because it needs root (resolving command: the tee line above); switching back is echo none.
Uncertain
Verify: the internal models given for mq-deadline (two red-black trees plus FIFO expiry) and kyber (per-hctx token throttling with self-tuning depths) in the table above. Reason: the Kconfig facts — symbol names, defaults, and the quoted help text — were read directly from block/Kconfig.iosched at v6.12, and the availability list was read from this host’s sysfs, but the algorithm descriptions were not traced through block/mq-deadline.c and block/kyber-iosched.c during this task. To resolve: read those two source files at v6.12, plus Documentation/block/bfq-iosched.rst for BFQ’s budget model. The comparative conclusion (that multi-queue devices get none by default) does not rest on these descriptions — it rests on elevator_get_default(), which is quoted in full above. uncertain
Scheduler selection as a policy question — including who actually picks yours on a modern distro — belongs to The Multi-Queue Block Layer blk-mq and Linux IO Schedulers Overview; this section establishes only what this kernel offers and why none won here.
Failure Modes and Common Misunderstandings
“Each software queue has its own hardware queue.” No — the relationship is many-to-one. With nr_hw_queues = 1 (a SATA disk), every CPU’s software queue feeds the single hardware queue. The ctx_map bitmap exists precisely because one hctx must track many ctxs.
“Requests are dispatched in submission order.” Only with none and no merging. With a scheduler attached, dispatch order is the scheduler’s choice (blk_mq_do_dispatch_sched); and even without one, the residual dispatch list is drained before fresh software-queue work, so a stalled request jumps ahead of newer ones on the next run.
Stuck dispatch list (queue stall). If a driver keeps returning BLK_STS_RESOURCE, requests pile onto hctx->dispatch and the queue must be re-run to drain them. The SCHED_RESTART flag and the smp_mb() ordering in blk_mq_dispatch_rq_list exist to guarantee a re-run happens; a bug in that ordering manifests as I/O that hangs until the request timeout fires. The dispatch-busy EWMA (dispatch_busy, exponentially weighted moving average) decides whether to try direct issue or go through the software queue, balancing latency against avoiding lock contention.
“hctx[i] serves cpu[i].” Measurably false — see the map in the section above, where CPU 0 feeds hardware queue 30. The map is a permutation inherited from MSI-X vector allocation order, and assuming the identity mapping means reading the wrong queue’s nr_tags, or pinning a benchmark thread to a CPU whose queue you are not watching. Always read cpu_list.
“none means no merging.” No. none means no elevator — no reordering, no deadline expiry, no fairness. Merging is a property of the staging queue, not of the scheduler: blk_mq_attempt_bio_merge() runs in blk_mq_submit_bio() before any elevator is consulted, and is gated by BLK_MQ_F_SHOULD_MERGE and the nomerges sysfs knob (0 on this host, meaning all merge attempts enabled). A none queue still coalesces adjacent bios.
“More hardware queues is always better.” The kernel disagrees enough to clamp it (block/blk-mq.c lines 4552–4557): “There is no use for more h/w queues than cpus if we just have a single map”, so nr_hw_queues is reduced to nr_cpu_ids when nr_maps == 1. A hardware queue with no CPU mapped to it is never run — it costs a blk_mq_hw_ctx, a tag pool, and an interrupt vector while dispatching nothing. Note the guard’s precondition: the clamp applies only when nr_maps == 1. With separate READ or POLL maps, the total number of hardware queues legitimately exceeds the CPU count, because each map is independently sized to at most nr_cpu_ids.
“nr_requests is the device’s queue depth.” Only when the scheduler is none, as on this host, where it happens to equal nr_tags. With an elevator attached, nr_requests sizes the scheduler tag pool, which is deliberately allowed to exceed the driver tag pool so the scheduler has a population to sort. Reading nr_requests and concluding “the device accepts N commands” is wrong in that configuration.
CPU hotplug. When all CPUs in an hctx’s cpumask go offline, the queue is marked BLK_MQ_S_INACTIVE and its requests are migrated (the cpuhp_online/cpuhp_dead hooks). A request submitted just before a CPU went offline is handled by the offline-migration path, not lost.