On this page

Block Device Layer

Coverage: bio → request → plug/unplug → blk-mq (multi-queue) → IO scheduler (mq-deadline/kyber/bfq) → blktrace/blkparse debugging Kernel versions: 2.6 ~ 6.x, with a focus on the blk-mq refactoring (3.13~5.x)

Overview

The block layer is a translation layer between the file system and device drivers. Its input is the bio submitted by the file system ("read sector N, place it in these pages, notify me when done"), and its output is the request sent to the device driver. In between, the block layer performs merging, sorting, and scheduling, aiming to minimize IO latency while maximizing throughput.

From the single-request queue in 2.6 to the multi-queue architecture (blk-mq) in 5.x, the Linux block layer underwent a complete rewrite—the driver model shifted from "one queue and one lock per device" to "per-CPU submit queues + multiple hardware queues."


bio: The Atomic Unit of Block IO

// include/linux/blk_types.h
struct bio {
    struct block_device *bi_bdev;        // Target block device
    sector_t            bi_sector;       // Starting sector (in 512B units)
    unsigned int        bi_size;         // Remaining IO bytes
    unsigned int        bi_status;       // Completion status (BLK_STS_OK/error)

    struct bio_vec      *bi_io_vec;      // Page vector (scatter-gather list)
    unsigned short      bi_vcnt;         // Number of vector entries
    unsigned short      bi_max_vecs;

    bio_end_io_t        *bi_end_io;      // Completion callback
    void                *bi_private;     // Callback context

    unsigned short      bi_write_hint;   // IO priority hint
    unsigned short      bi_ioprio;
};

bio_vec: Describing IO on a Memory Page

// include/linux/bvec.h
struct bio_vec {
    struct page     *bv_page;    // Target page
    unsigned int    bv_len;      // Length of this segment
    unsigned int    bv_offset;   // Offset within the page
};

A bio can contain multiple bio_vecs—this is scatter-gather IO. The file system can pack non-contiguous memory pages into a single IO, and the block layer and driver will then split them into sizes acceptable to the device.

Bio Submission Path

// block/blk-core.c
void submit_bio(struct bio *bio) {
    // 1. Check if using blk-cgroup → account to cgroup
    // 2. Check if bio size exceeds device limits → split if needed
    // 3. Call submit_bio_noacct() → __submit_bio_noacct_mq()
    //    → blk_mq_submit_bio()  (blk-mq path, current default)
}

blk-mq: Multi-Queue Architecture

Why Migrate from blk-sq to blk-mq

blk-sq (Single Queue, 2.6 ~ 3.x):
  struct request_queue *q;  ← One large lock protects all submissions
  All CPUs compete for q->queue_lock → Multi-core scalability bottleneck
  Single-threaded model of IO scheduler → Peaks for high IOPS devices (NVMe)

blk-mq (3.13+, fully replaces blk-sq in 5.x):
  Two-layer queue structure:
    Software Queues (ctx): per-CPU or per-cgroup → Zero-lock contention
    Hardware Queues (hctx): Mapped to device hardware queues (NVMe SQ, SCSI tag)

  Tags: Used to manage the number of concurrent requests in hardware queues
    → Each hardware queue has a fixed tag pool → Natural throttling

Data Structures

// include/linux/blk-mq.h
struct blk_mq_hw_ctx {
    unsigned int        queue_num;       // Hardware queue number
    struct request_queue *queue;
    struct blk_mq_tags  *tags;           // Tag pool (limits concurrency)
    unsigned int        nr_ctx;          // Number of software queues

    struct list_head    dispatch;        // Requests coming from the scheduler
    unsigned int        dispatched;

    struct blk_mq_ctx   **ctxs;          // Child software queues
};

struct blk_mq_ctx {
    unsigned int        cpu;             // Bound CPU
    struct blk_mq_hw_ctx *hctxs[];       // Mapped hardware queues
    struct list_head    rq_lists[];      // Submit list
};

Submission Path: Plug → Scheduler → Dispatch → Driver

Bio Submission Path: Five Stages of blk_mq_submit_bio() block/blk-mq.c ① Is bio too large? Exceeds limit → blk_bio_discard_split()/blk_bio_segment_split() splits into multiple bios ② Plug merge attempt blk_mq_attempt_plug_merge() → front/back merge (merge to front/back of existing request) ③ Allocate request + tag blk_mq_get_new_requests() → blk_mq_get_tag() fetches tag, waits if exhausted ④ Initialize request blk_mq_rq_ctx_init() → associates bio, completes initialization ⑤ Insert into scheduler blk_mq_sched_insert_request() → decides whether to go through scheduler or dispatch directly Yes No Has IO Scheduler blk_mq_sched_try_insert_merge() → queues to scheduler No IO Scheduler blk_mq_run_dispatch_ops() → direct dispatch The five stages execute sequentially: split if too large, merge into existing request if possible, wait if tags are insufficient; finally, the insertion stage decides whether to queue for the IO scheduler or dispatch directly to the hardware queue.

Plug: Batch Submission

// block/blk-mq.c + block/blk-plug.c
// Plug is a per-task bio accumulation mechanism:
//   blk_start_plug(current)     // Start plug
//   submit_bio() × N            // Accumulate N bios, do not dispatch immediately
//   blk_finish_plug(current)    // Flush all bios at once

// Design rationale:
//   1. Reduce lock contention (acquire lock once, insert N requests)
//   2. Increase merge opportunities (front/back merge possible during plug)
//   3. File systems typically utilize plug in the write path:
//      ext4_writepages() → blk_start_plug() → multiple submit_bio → blk_finish_plug()

IO Scheduler

The fundamental difference between multi-queue IO schedulers and the old single-queue schedulers is: the scheduler is not global, but per-hctx. Each hardware queue is scheduled independently because different queues correspond to submissions from different CPUs.

mq-deadline

// block/mq-deadline.c
// Maintains separate Red-Black trees (sorted by sector) + FIFO (sorted by clock time) for reads and writes
//
// Core parameters:
//   read_expire:  Max wait time for read requests (default 500ms)
//   write_expire: Max wait time for write requests (default 5000ms)
//   writes_starved: Write request quota (how many batches of reads to process before handling a batch of writes)
//
// Scheduling logic:
//   1. Check FIFO first: if there are timed-out requests → fetch requests near that sector from the RB tree (batch dispatch)
//   2. Otherwise: dispatch by direction batch (prefer reads, but writes_starved ensures writes are not starved)
//   3. Each dispatch sends no more than 16 requests (to avoid starving other queues)
//
// Suitable for: NVMe, general-purpose SSDs

kyber

// block/kyber-iosched.c
// Adaptive scheduling based on token bucket
// Goal: Control read/write queue depth to keep latency within target bounds
//
// Working principle:
//   Maintains latency histograms for reads and writes
//   Dynamically adjusts the token bucket rate for each direction based on latency
//   Low latency → increase bucket → more IO concurrency
//   High latency → decrease bucket → reduce IO concurrency (lower device queue depth)
//
// Suitable for: Latency-sensitive SSD workloads (e.g., databases)

bfq (Budget Fair Queuing)

// block/bfq-iosched.c
// Allocates IO bandwidth per cgroup/process
// Each process has a "budget" (number of sectors it can issue)
// Based on B-WF2Q+ algorithm (similar to CFS fair scheduling)
//
// Features:
//   - Interactive processes automatically get higher weight (detects think time)
//   - Supports cgroup blkio controller
//   - Sequential reads detected → given more budget → high throughput
//
// Suitable for: Desktop systems (ensures background backups do not affect foreground application responsiveness)

Scheduler Switching

cat /sys/block/nvme0n1/queue/scheduler
# [mq-deadline] kyber bfq none

echo kyber > /sys/block/nvme0n1/queue/scheduler

Queue Parameter Tuning

# Number of hardware queues (usually equal to CPU count)
cat /sys/block/nvme0n1/mq/*/cpu_list

# max_sectors_kb: Maximum size of a single IO (default 512KB = 1024 sectors)
cat /sys/block/sda/queue/max_sectors_kb

# nr_requests: Maximum number of requests per hardware queue
cat /sys/block/nvme0n1/queue/nr_requests  # Default 256

# Scheduler parameters
cat /sys/block/nvme0n1/queue/iosched/read_expire  # mq-deadline

Debugging and Observation

# blktrace: Trace the full lifecycle of each IO
blktrace -d /dev/nvme0n1 -o trace
blkparse -i trace > trace.txt
# Output: time, CPU, action (Q=queued, G=get, I=inserted, D=dispatch, C=complete)

# iostat: Device-level statistics
iostat -x 1 nvme0n1
# r/s, w/s, r_await, w_await, aqu-sz (average queue depth), %util

# Raw counters for a single device
cat /sys/block/nvme0n1/stat
# Fields: read_ios, read_merges, read_sectors, read_ticks, write_ios, ...

# blk-mq debugging
cat /sys/kernel/debug/block/nvme0n1/hctx*/tags
# Tag usage for each hardware queue

# ftrace to trace block layer events
echo 1 > /sys/kernel/debug/tracing/events/block/block_bio_queue/enable
echo 1 > /sys/kernel/debug/tracing/events/block/block_rq_complete/enable
cat /sys/kernel/debug/tracing/trace_pipe

Key Configurations and Compilation

# Kernel boot parameters
scsi_mod.use_blk_mq=1    # Force SCSI to use blk-mq (default in 5.0+)
elevator=mq-deadline      # Default IO scheduler
elevator=bfq              # Recommended for desktops

# Kernel compilation options
CONFIG_BLK_MQ=y           # Enable blk-mq
CONFIG_IOSCHED_BFQ=y      # BFQ support
CONFIG_BLK_DEV_THROTTLING=y  # cgroup blkio

References and Further Reading

  • Source Code: block/blk-mq.c (~4000 lines, blk-mq core), block/blk-merge.c, block/mq-deadline.c, block/bfq-iosched.c, block/kyber-iosched.c
  • Kernel Documentation: Documentation/block/, Documentation/block/bfq-iosched.rst
  • LWN:
    • "The multi-queue block layer" (lwn.net/Articles/552904/)
    • "blk-mq and the I/O schedulers" (lwn.net/Articles/738449/)

Keywords: bio, bio_vec, blk-mq, software queue, hardware queue, plug, mq-deadline, kyber, bfq, blktrace, iostat, IO scheduler