Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Block I/O Layer

Introduction

The block I/O layer is the heart of Linux storage. It sits between the filesystem and the device drivers, managing all read and write requests to block devices. This layer handles request merging, scheduling, plugging/unplugging, and dispatching—optimizing I/O for both throughput and latency.

Understanding the block I/O layer is critical for performance tuning. A misconfigured I/O scheduler or a poor queue depth setting can mean the difference between 10,000 and 500,000 IOPS on the same hardware.

The bio Structure

The bio (block I/O) structure is the fundamental unit of I/O in the Linux block layer:

struct bio {
    struct bio          *bi_next;       /* request queue link */
    struct block_device *bi_bdev;       /* target device */
    unsigned int         bi_opf;        /* op and flags */
    unsigned short       bi_flags;      /* BIO_* flags */
    unsigned short       bi_ioprio;     /* I/O priority */
    unsigned short       bi_write_hint; /* write hint */
    blk_status_t         bi_status;     /* I/O status */
    atomic_t             __bi_remaining;

    struct bvec_iter     bi_iter;       /* current index into bio vec array */
    bio_end_io_t        *bi_end_io;     /* completion callback */
    void                *bi_private;    /* owner-private data */

    unsigned short       bi_vcnt;       /* number of bio_vec's */
    unsigned short       bi_max_vecs;   /* max bio_vec's allocated */
    atomic_t             __bi_cnt;      /* pin count */
    struct bio_vec      *bi_io_vec;     /* scatter-gather list */
    /* ... */
};

bio Lifecycle

sequenceDiagram
    participant FS as Filesystem
    participant BL as Block Layer
    participant Q as Request Queue
    participant SCH as Scheduler
    participant DRV as Driver

    FS->>BL: submit_bio(bio)
    BL->>BL: Attempt merge with existing requests
    BL->>Q: Insert into request queue
    Q->>SCH: Add to scheduler
    SCH->>SCH: Reorder/optimize
    SCH->>Q: Dispatch request
    Q->>DRV: Call make_request_fn or queue_rq
    DRV->>DRV: Send to hardware
    DRV-->>BL: bio_endio(bio, status)
    BL-->>FS: Completion callback

bio_vec: Scatter-Gather I/O

A single bio can reference multiple non-contiguous memory regions via bio_vec:

struct bio_vec {
    struct page *bv_page;    /* page to read/write */
    unsigned int bv_len;     /* bytes in this segment */
    unsigned int bv_offset;  /* offset within page */
};

This enables scatter-gather I/O—a single I/O request can read from or write to multiple memory pages, which is essential for filesystems that store data in page-sized chunks.

Request Queues

Each block device has a request_queue that holds pending I/O requests:

struct request_queue {
    struct request        *last_merge;
    struct elevator_queue *elevator;     /* I/O scheduler */
    struct blk_mq_ops     *mq_ops;      /* blk-mq operations */
    struct blk_mq_tag_set *tag_set;      /* tag set for blk-mq */
    struct list_head       icq_list;
    request_fn_proc       *request_fn;   /* legacy dispatch */
    make_request_fn       *make_request_fn; /* legacy entry */
    struct blk_mq_ctx __percpu *queue_ctx;
    unsigned int           nr_requests;  /* max queued requests */
    /* ... */
};

blk-mq (Multi-Queue Block Layer)

Modern Linux uses blk-mq (Block Multi-Queue), which replaced the legacy single-queue architecture:

graph TD
    subgraph "Legacy (Single Queue)"
        LQ["Single request_queue"]
        LS["Single I/O scheduler"]
        LD["Single dispatch thread"]
    end
    subgraph "blk-mq (Multi-Queue)"
        subgraph "Software Queues (per-CPU)"
            SWQ0["SW Queue CPU 0"]
            SWQ1["SW Queue CPU 1"]
            SWQ2["SW Queue CPU 2"]
        end
        subgraph "Hardware Queues (per-HW-queue)"
            HWQ0["HW Queue 0"]
            HWQ1["HW Queue 1"]
        end
        SWQ0 --> HWQ0
        SWQ1 --> HWQ0
        SWQ2 --> HWQ1
    end

blk-mq in Practice

# View queue configuration
cat /sys/block/nvme0n1/queue/nr_requests
# 1023

cat /sys/block/sda/queue/nr_requests
# 256

# Check if device uses blk-mq
ls /sys/block/sda/mq/
# 0  1  2  3

# Number of hardware queues
ls /sys/block/nvme0n1/mq/ | wc -l
# 32

Plug/Unplug Mechanism

The block layer uses a plug/unplug mechanism to batch I/O requests before dispatching them to the hardware. This improves performance by allowing request merging and sorting.

How Plugging Works

  1. Plug: When a process starts submitting I/O, the request queue is “plugged”—new requests are held in a per-task plug list
  2. Accumulate: Multiple bios accumulate in the plug list during a single filesystem operation
  3. Unplug: When the operation completes (or the plug list is full), the queue is “unplugged”—all accumulated requests are dispatched together
/* Pseudocode for plug/unplug */
blk_start_plug(&plug);          /* Start plugging */
submit_bio(bio1);                /* Queued in plug list */
submit_bio(bio2);                /* Queued in plug list */
submit_bio(bio3);                /* Queued in plug list */
blk_finish_plug(&plug);         /* Unplug: dispatch all at once */
sequenceDiagram
    participant P as Process
    participant PL as Plug List
    participant Q as Request Queue
    participant HW as Hardware

    P->>PL: blk_start_plug()
    P->>PL: submit_bio(1)
    P->>PL: submit_bio(2)
    P->>PL: submit_bio(3)
    Note over PL: Requests merged and sorted
    P->>Q: blk_finish_plug()
    Q->>HW: Dispatch merged requests
    HW-->>P: Completions

I/O Schedulers

The I/O scheduler (also called the elevator) determines the order in which requests are dispatched to the hardware. Linux provides three main schedulers for blk-mq:

Available Schedulers

# Check current and available schedulers
cat /sys/block/sda/queue/scheduler
# [mq-deadline] kyber bfq none

# Change scheduler
echo bfq > /sys/block/sda/queue/scheduler

mq-deadline

mq-deadline is the default scheduler for most block devices. It maintains separate read and write queues and enforces latency deadlines.

graph LR
    subgraph "mq-deadline"
        RQ["Read Queue<br>Sorted by LBA"]
        WQ["Write Queue<br>Sorted by LBA"]
        BATCH["Batch Dispatch<br>Starved?"]
        RQ --> BATCH
        WQ --> BATCH
    end

Key behavior:

  • Maintains sorted read and write queues (by LBA position)
  • Dispatches in batches to minimize seek time
  • Enforces a deadline (default: 500ms for reads, 5s for writes)
  • Reads are prioritized over writes (latency-sensitive)
# mq-deadline tunables
ls /sys/block/sda/queue/iosched/
# fifo_batch  front_merges  read_expire  writes_expire  fifo_batch

echo 128 > /sys/block/sda/queue/iosched/read_expire    # ms
echo 5000 > /sys/block/sda/queue/iosched/writes_expire  # ms
echo 16 > /sys/block/sda/queue/iosched/fifo_batch
echo 1 > /sys/block/sda/queue/iosched/front_merges

BFQ (Budget Fair Queueing)

BFQ provides proportional-share scheduling with per-process fairness, making it ideal for interactive workloads and desktop systems.

Key features:

  • Per-process I/O budgets
  • Low-latency interactive class
  • Proportional bandwidth distribution via cgroups
  • Fairness across processes
# BFQ tunables
ls /sys/block/sda/queue/iosched/
# back_seek_max  back_seek_penalty  fifo_expire_async  fifo_expire_sync
# low_latency  slice_async  slice_idle  slice_sync  timeout_sync

echo 1 > /sys/block/sda/queue/iosched/low_latency  # Enable low-latency mode
echo 128 > /sys/block/sda/queue/iosched/slice_sync   # ms per sync slice
echo 8 > /sys/block/sda/queue/iosched/slice_idle     # ms between dispatches
echo 16384 > /sys/block/sda/queue/iosched/back_seek_max  # sectors

# BFQ with cgroups
# Create a cgroup with I/O weight
mkdir /sys/fs/cgroup/test
echo 200 > /sys/fs/cgroup/test/io.bfq.weight  # Default is 100

Kyber

Kyber is a lightweight scheduler designed for fast devices (NVMe, high-end SSDs). It focuses on latency by adjusting queue depths based on latency targets.

# Kyber tunables
ls /sys/block/nvme0n1/queue/iosched/
# read_lat_nsec  write_lat_nsec

echo 2000 > /sys/block/nvme0n1/queue/iosched/read_lat_nsec   # 2μs target
echo 10000 > /sys/block/nvme0n1/queue/iosched/write_lat_nsec  # 10μs target

Scheduler Comparison

Featuremq-deadlineBFQKyber
Best forServers, general useDesktops, interactiveFast SSDs, NVMe
Merge strategyFront/back mergeFront/back mergeMinimal
FairnessTime-based deadlineProportional shareLatency-based
Per-cgroup controlNoYes (io.bfq.weight)No
OverheadLowMediumVery low
Default deviceSATA/SAS HDD/SSD-NVMe (where applicable)

none (No Scheduler)

For NVMe devices with multiple hardware queues, the scheduler can be set to none:

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

This bypasses the software scheduler entirely, relying on the device’s internal scheduling. This is often the best choice for high-performance NVMe devices.

I/O Priority

Linux supports I/O priorities using the ioprio system:

# Set I/O priority (class + level)
# Classes: 0=none, 1=realtime, 2=best-effort, 3=idle
ionice -c 2 -n 0 dd if=/dev/zero of=/tmp/test bs=1M count=100 &
# Best-effort, priority 0 (highest)

ionice -c 2 -n 7 dd if=/dev/zero of=/tmp/test2 bs=1M count=100 &
# Best-effort, priority 7 (lowest)

ionice -c 3 dd if=/dev/zero of=/tmp/test3 bs=1M count=100 &
# Idle - only runs when no other I/O pending

# Check I/O priority of a process
ionice -p $$
# none: prio 0

Block I/O Tracing

blktrace and blkparse

blktrace captures detailed block I/O events:

# Trace block I/O for 10 seconds
blktrace -d /dev/sda -o - | blktrace -i - -d sda.trace &
sleep 10
kill %1

# Parse and view
blkparse -i sda.trace | head -50
# 253,0    1        1     0.000000000  2345  A   W 12345678 + 8 <- (253,1) 12345600
# 253,0    1        2     0.000001234  2345  Q   W 12345678 + 8
# 253,0    1        3     0.000002345  2345  G   W 12345678 + 8
# 253,0    1        4     0.000003456  2345  I   W 12345678 + 8
# 253,0    1        5     0.000004567  2345  D   W 12345678 + 8
# 253,0    1        6     0.005123456  2345  C   W 12345678 + 8 [0]

# Event types:
# A = Remap (bio remapped)
# Q = Block I/O request queued
# G = Get request
# I = Insert request into scheduler
# D = Dispatch to driver
# C = Complete
# M = Back merge
# F = Front merge

# Aggregate statistics
btt -i sda.trace
# D2C: min=0.001ms, max=15.234ms, avg=0.567ms
# Q2C: min=0.002ms, max=16.789ms, avg=0.890ms

bpftrace I/O Tracing

# Trace I/O latency distribution
bpftrace -e '
tracepoint:block:block_rq_complete {
    @usecs = hist((nsecs - args->alloc_time) / 1000);
}'

# Trace I/O by process
bpftrace -e '
tracepoint:block:block_rq_issue {
    @io_by_comm[comm] = count();
}'

Direct I/O vs Buffered I/O

graph TD
    subgraph "Buffered I/O"
        BA[Application] --> BC[Page Cache]
        BC --> BD[Block Layer]
    end
    subgraph "Direct I/O"
        DA[Application] --> DD[Block Layer]
    end
# Buffered I/O (default) - uses page cache
dd if=/dev/zero of=/tmp/test_buffered bs=1M count=100

# Direct I/O - bypasses page cache
dd if=/dev/zero of=/tmp/test_direct bs=1M count=100 oflag=direct

# O_DIRECT in C
int fd = open("/tmp/test", O_WRONLY | O_DIRECT);
// Buffer must be aligned to block size
void *buf;
posix_memalign(&buf, 4096, 4096);
write(fd, buf, 4096);

I/O Accounting

# Per-device I/O stats
cat /proc/diskstats
#  8  0 sda 123456 789 12345678 9012 567890 123 45678901 2345 0 6789 11357

# Fields (from left):
# major minor name
# reads_completed reads_merged sectors_read read_time_ms
# writes_completed writes_merged sectors_written write_time_ms
# io_in_progress io_time_ms weighted_io_time_ms
# discards_completed discards_merged sectors_discarded discard_time_ms

# Per-process I/O
iotop -oP
# Total DISK READ:  123.45 M/s | Total DISK WRITE: 67.89 M/s
#   PID  PRIO  USER     DISK READ  DISK WRITE  SWAPIN    IO>    COMMAND
#  1234  be/4  root     100.00 M/s    0.00 B/s  0.00 %  99.99 % dd if=/dev/sda

References

Multi-Queue Block Layer (blk-mq) Deep Dive

Queue Topology

The blk-mq architecture maps software queues to hardware queues:

graph TD
    subgraph "Per-CPU Software Queues"
        SQ0["SW Queue 0<br>CPU 0"]
        SQ1["SW Queue 1<br>CPU 1"]
        SQ2["SW Queue 2<br>CPU 2"]
        SQ3["SW Queue 3<br>CPU 3"]
    end
    subgraph "Hardware Dispatch Queues"
        HWQ0["HW Queue 0<br>NVMe queue pair 0"]
        HWQ1["HW Queue 1<br>NVMe queue pair 1"]
    end
    SQ0 --> HWQ0
    SQ1 --> HWQ0
    SQ2 --> HWQ1
    SQ3 --> HWQ1

Tag Sets

The blk_mq_tag_set structure defines the hardware queue topology:

struct blk_mq_tag_set {
    const struct blk_mq_ops *ops;
    unsigned int            nr_hw_queues;   /* number of HW queues */
    unsigned int            queue_depth;    /* max tags per HW queue */
    unsigned int            cmd_size;       /* per-request driver data */
    int                     numa_node;      /* NUMA affinity */
    unsigned int            flags;          /* BLK_MQ_F_* */
    void                    *driver_data;   /* driver private */
};

Queue Depth and Performance

# View current queue depth
$ cat /sys/block/nvme0n1/queue/nr_requests
1023

# Check tag usage
$ cat /sys/block/nvme0n1/mq/0/tags
# nr_tags=1023, nr_reserved=0
# depth=128, wake_batch=32
# tags_in_use=45, active_queues=1

# Adjust queue depth (rarely needed)
$ echo 256 > /sys/block/nvme0n1/queue/nr_requests

io_uring Block I/O

io_uring provides high-performance asynchronous I/O with minimal system call overhead:

#include <liburing.h>

struct io_uring ring;
struct io_uring_params params = {};

/* Initialize io_uring */
io_uring_queue_init_params(256, &ring, &params);

/* Prepare a read submission */
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, 4096, 0);
sqe->flags |= IOSQE_FIXED_FILE;

/* Submit and wait */
io_uring_submit(&ring);
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);

printf("Read %d bytes\n", cqe->res);
io_uring_cqe_seen(&ring, cqe);

io_uring vs Traditional I/O

Aspectread/writeaioio_uring
System calls per I/O1 (blocking)1 (submit) + 1 (wait)0 (SQ polling)
BatchingNoYes (submit)Yes (submit ring)
Completion modelBlockingEventfd / pollingCQ ring / polling
Buffer managementApp-managedApp-managedProvided buffers
Kernel overheadMediumMediumLowest

io_uring Block Device Access

/* Open block device directly */
int fd = open("/dev/nvme0n1", O_RDWR | O_DIRECT);

/* Align buffer to block size */
void *buf;
posix_memalign(&buf, 4096, 4096);

/* Submit via io_uring */
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, 4096, offset);

Writeback and Dirty Pages

Dirty Page Lifecycle

graph TD
    A[Application write] --> B[Page cache
    dirty page]
    B --> C{Dirty ratio
    exceeded?}
    C -- No --> D[Continue]
    C -- Yes --> E[Kwriteback
    thread wakes]
    E --> F[Select dirty pages
    by age/priority]
    F --> G[Create bio for
    writeback]
    G --> H[submit_bio]
    H --> I[Block layer]
    I --> J[Device]
    J --> K[Page marked clean]

Writeback Parameters

# Dirty ratio — % of total RAM before writeback starts
$ cat /proc/sys/vm/dirty_ratio
20

# Background dirty ratio — % for background writeback
$ cat /proc/sys/vm/dirty_background_ratio
10

# Dirty expiry time (centiseconds)
$ cat /proc/sys/vm/dirty_expire_centisecs
3000   # 30 seconds

# Writeback interval (centiseconds)
$ cat /proc/sys/vm/dirty_writeback_centisecs
500    # 5 seconds

# Per-device writeback
$ cat /sys/block/sda/bdi/writeback
# Shows writeback statistics for this device

Tuning Writeback

# For databases — prefer direct I/O, reduce dirty ratios
$ echo 5 > /proc/sys/vm/dirty_ratio
$ echo 2 > /proc/sys/vm/dirty_background_ratio

# For large file servers — increase dirty ratios
$ echo 40 > /proc/sys/vm/dirty_ratio
$ echo 10 > /proc/sys/vm/dirty_background_ratio

# Check dirty page stats
$ cat /proc/meminfo | grep -i dirty
Dirty:           123456 kB
Writeback:         5678 kB
WritebackTmp:         0 kB

I/O Throttling

Device-Mapper I/O Throttling (dm-ioband / cgroup)

# cgroup v2 I/O max limits
$ echo "8:0 rbps=10485760 wbps=5242880" > /sys/fs/cgroup/limited/io.max
# rbps = read bytes/sec, wbps = write bytes/sec

$ echo "8:0 riops=1000 wiops=500" > /sys/fs/cgroup/limited/io.max
# riops = read IOPS, wiops = write IOPS

# Verify limits
$ cat /sys/fs/cgroup/limited/io.max
8:0 rbps=10485760 wbps=5242880 riops=1000 wiops=500

I/O Latency Control (io.latency)

# Set latency target (microseconds)
$ echo "8:0 target=10000" > /sys/fs/cgroup/limited/io.latency
# If latency exceeds 10ms, throttle competing cgroups

I/O Accounting

Per-Device Statistics

$ cat /proc/diskstats
#  8  0 sda 123456 789 12345678 9012 567890 123 45678901 2345 0 6789 11357

# Fields (from left):
# major minor name
# reads_completed reads_merged sectors_read read_time_ms
# writes_completed writes_merged sectors_written write_time_ms
# io_in_progress io_time_ms weighted_io_time_ms
# discards_completed discards_merged sectors_discarded discard_time_ms

Per-Process I/O

$ iotop -oP
# Total DISK READ:  123.45 M/s | Total DISK WRITE: 67.89 M/s
#   PID  PRIO  USER     DISK READ  DISK WRITE  SWAPIN    IO>    COMMAND
#  1234  be/4  root     100.00 M/s    0.00 B/s  0.00 %  99.99 % dd if=/dev/sda

BPF-Based I/O Analysis

# Trace I/O latency by device and operation
bpftrace -e '
tracepoint:block:block_rq_complete {
    @latency[ args->dev, args->rwbs ] = hist((nsecs - @start[args->dev, args->sector]) / 1000);
}
tracepoint:block:block_rq_issue {
    @start[args->dev, args->sector] = nsecs;
}'

# I/O size distribution
bpftrace -e '
tracepoint:block:block_rq_issue {
    @size[comm] = hist(args->bytes / 1024);
}'

# Count I/O by operation type
bpftrace -e '
tracepoint:block:block_rq_issue {
    @[args->rwbs] = count();
}'

NVMe Block I/O Specifics

NVMe I/O Path

sequenceDiagram
    participant APP as Application
    participant BL as Block Layer
    participant NVME as NVMe Driver
    participant SQ as NVMe SQ
    participant CQ as NVMe CQ
    participant SSD as NVMe SSD

    APP->>BL: submit_bio()
    BL->>NVME: queue_rq()
    NVME->>SQ: Write SQ entry
    NVME->>SQ: Ring SQ doorbell
    SQ->>SSD: PCIe DMA
    SSD->>SSD: Process command
    SSD->>CQ: Write CQ entry
    SSD->>NVME: MSI-X interrupt
    NVME->>BL: blk_mq_complete_request()
    BL->>APP: bio_endio()

NVMe Queue Configuration

# Number of I/O queues
$ cat /sys/block/nvme0n1/device/queue_count
# 32 (one per CPU on modern systems)

# Queue depth
$ cat /sys/block/nvme0n1/queue/nr_requests
# 1023

# Polling mode (for ultra-low latency)
$ echo 1 > /sys/block/nvme0n1/queue/io_poll

# Disable I/O scheduler (best for NVMe)
$ echo none > /sys/block/nvme0n1/queue/scheduler

Further Reading