Home › Hardware & OS › Storage I/O
algorithm low

SSD and NVMe internals

Problem Engineers designing storage formats, logging and compaction schemes, or benchmarks need to know why SSD speed and wear behave as they do, so their I/O patterns work with the drive instead of against it.

NAND pages and erase blocks, the FTL, garbage collection and write amplification, NVMe queues and queue depth, and 4 KiB alignment, with the numbers that matter.

ssdnvmeflashwrite-amplificationqueue-depthftl
Overview

Flash can't overwrite in place. A NAND page (order of 16 KiB) is programmed once, and erasing works only on whole blocks of hundreds of pages. The firmware (FTL) writes every update to a fresh page, keeps a logical→physical map, and garbage-collects blocks by copying their still-valid pages. Those copies are write amplification. The drive is massively parallel (channels × dies × planes), so throughput comes from queue depth, not from one request.

Key facts

Use when

  • Designing on-disk formats, log and compaction strategies, I/O schedulers and benchmarks, or choosing drives.

Avoid when

  • The data set is RAM-resident. Then the page cache or your buffer pool matters, not the drive.

Deep dive

How it works

In: NVMe read, write, flush and deallocate commands in submission queues · Out: completions; data stored in NAND pages · State: the FTL logical-to-physical map, valid-page counts per erase block, queue pairs · Cost: 4 KiB QD1 read order of 50-100 µs; write amplification about 1× sequential, 2-5× random on a nearly full drive.

host LBA (4 KiB) ──FTL map──► (channel, die, plane, block, page)
block: [p0 p1 p2 ... p255+]   program pages in order, erase whole block
GC: pick block with fewest valid pages → copy valid pages → erase → free list
  1. Geometry: NAND page order of 16 KiB. Erase block = hundreds to thousands of pages, order of MiB. A drive has order of 8-16 channels with several dies each. Every die works independently.
  2. Timings (TLC, order of): read tens of µs, program hundreds of µs, erase several ms. Writes are acknowledged from DRAM or an SLC-mode cache, so they look faster than program time.
  3. FTL map: page-level, about 4 B per 4 KiB → about 1 GiB DRAM per 1 TiB of flash. DRAM-less drives cache the map in a small SRAM or in host memory (NVMe HMB) and slow down on random access over large spans.
  4. Over-provisioning: spare flash that is never exposed. "GB vs GiB" alone gives 7.4 %. Write-heavy enterprise drives expose less capacity. More spare space lets GC find emptier blocks and lowers write amplification.
  5. TRIM / Deallocate: tells the FTL which LBAs are dead, so GC doesn't copy them. Linux: fstrim (periodic) or the discard mount option.
  6. SLC cache: consumer TLC/QLC drives write first in 1-bit mode. After order of tens of GB of sustained writes, speed drops to native TLC/QLC rate. QLC can drop to a few hundred MB/s.
  7. NVMe: submission/completion queue pairs in host memory, doorbell registers, MSI-X interrupts. The spec allows up to 65 535 I/O queues of up to 65 536 entries. Linux blk-mq maps one queue per CPU.

Decision table

SituationChoiceWhy
Need high random-read IOPSKeep order of 32-128 I/Os in flight (io_uring, many threads)Little's law. The device has dozens of independent dies
Latency-critical point readsKeep QD moderate, isolate from writes and GCLatency climbs once the device saturates (271 µs p50 at QD 64 above)
Small random updatesBatch into large sequential writes (log-structured, LSM)Sequential writes keep WA near 1
On-disk page size4 KiB multiples, aligned. 16 KiB fits NAND pagesSub-4 KiB or unaligned writes cause read-modify-write in the FTL
Drive near fullLeave 10-20 % free, or buy drives with more over-provisioningWA and tail latency grow sharply as free space shrinks
BenchmarksPrecondition: fill the drive (twice for steady state), then measure for minutesA fresh drive shows its best case only
Durability-critical writesDrives with power-loss protectionFlushes become nearly free (fsync)

Code

Random 4 KiB reads that bypass the page cache, with one blocking pread per thread, so threads = queue depth. Reports IOPS and latency percentiles. Numbers in Key facts are from this program on an M4 Pro. Tested on macOS, type-checked for Linux.

// Cargo.toml: libc = "0.2". Random 4 KiB reads at queue depth = number of threads.
use std::alloc::{alloc, dealloc, Layout};
use std::fs::{File, OpenOptions};
use std::io::{self, Seek, SeekFrom};
use std::os::unix::fs::FileExt;
use std::time::{Duration, Instant};

const BS: usize = 4096;

/// Uncached file handle: O_DIRECT on Linux, F_NOCACHE on macOS.
fn open_uncached(path: &str) -> io::Result<File> {
    let mut o = OpenOptions::new();
    o.read(true);
    #[cfg(target_os = "linux")]
    { use std::os::unix::fs::OpenOptionsExt; o.custom_flags(libc::O_DIRECT); }
    let f = o.open(path)?;
    #[cfg(target_os = "macos")]
    { use std::os::fd::AsRawFd; unsafe { libc::fcntl(f.as_raw_fd(), libc::F_NOCACHE, 1) }; }
    Ok(f)
}

/// One worker = one outstanding I/O. Returns per-I/O latencies.
fn worker(f: &File, blocks: u64, seed: u64, deadline: Instant) -> Vec<Duration> {
    let layout = Layout::from_size_align(BS, BS).unwrap(); // O_DIRECT needs an aligned buffer
    let buf = unsafe { std::slice::from_raw_parts_mut(alloc(layout), BS) };
    let (mut s, mut lat) = (seed | 1, Vec::new());
    while Instant::now() < deadline {
        s ^= s << 13; s ^= s >> 7; s ^= s << 17;
        let off = (s % blocks) * BS as u64; // 4 KiB-aligned offset
        let t = Instant::now();
        f.read_exact_at(buf, off).expect("pread");
        lat.push(t.elapsed());
    }
    unsafe { dealloc(buf.as_mut_ptr(), layout) };
    lat
}

fn main() -> io::Result<()> {
    let path = std::env::args().nth(1).expect("usage: qd <big file or /dev/nvme0n1 (read-only)>");
    let f = open_uncached(&path)?;
    let blocks = (&f).seek(SeekFrom::End(0))? / BS as u64; // metadata().len() is 0 for block devices
    println!("{:>4} {:>10} {:>9} {:>9}", "QD", "IOPS", "p50 µs", "p99 µs");
    for qd in [1usize, 2, 4, 8, 16, 32, 64] {
        let deadline = Instant::now() + Duration::from_secs(3);
        let mut lat: Vec<Duration> = std::thread::scope(|sc| {
            let hs: Vec<_> = (0..qd).map(|i| { let f = &f; sc.spawn(move || worker(f, blocks, 0x9E37 * (i as u64 + 1), deadline)) }).collect();
            hs.into_iter().flat_map(|h| h.join().unwrap()).collect()
        });
        lat.sort_unstable();
        let us = |q: f64| lat[((lat.len() - 1) as f64 * q) as usize].as_secs_f64() * 1e6;
        println!("{qd:>4} {:>10.0} {:>9.1} {:>9.1}", lat.len() as f64 / 3.0, us(0.5), us(0.99));
    }
    Ok(())
}

The same measurement with fio: fio --name=rr --filename=/dev/nvme0n1 --readonly --rw=randread --bs=4k --direct=1 --ioengine=io_uring --iodepth=32 --runtime=30 --time_based.

Complexity and constants

  • Little's law: QD1 at 75 µs → 13 k IOPS. 1 M IOPS at 100 µs needs 100 I/Os in flight.
  • PCIe link: Gen4 x4 = 16 GT/s × 4 × 128/130 / 8 ≈ 7.9 GB/s. Gen5 x4 ≈ 15.8 GB/s. Drives reach order of 7 and 14 GB/s sequential.
  • Endurance: TLC order of 1000-3000 P/E cycles, QLC order of hundreds to 1000. Rated as TBW or DWPD. Host writes allowed ≈ capacity × P/E cycles / WA.
  • Write amplification = NAND bytes written / host bytes written. Read it from SMART/vendor logs (nvme smart-log, vendor-specific "NAND writes") vs "Data Units Written".

Efficiency

  • Cost: a QD1 4 KiB random read takes order of 50-100 µs on TLC NVMe (74 µs p50, 13 k IOPS on an M4 Pro). The same drive reaches about 230 k IOPS at QD 32-64 but p50 latency rises to 271 µs at QD 64. Bound: latency at low queue depth, then NAND parallelism and the PCIe link.
  • Levers:
    • queue depth 32-128 (io_uring, many threads) — IOPS ≈ QD / latency, 13 k → 230 k (above) — random reads
    • batch small updates into large sequential writes — write amplification 2-5× → about 1× — update-heavy workloads (LSM, log-structured)
    • keep 10-20 % free space or buy extra over-provisioning — fewer valid-page copies in garbage collection — nearly full drives
    • 4 KiB-aligned multiples — avoids read-modify-write in the FTL — on-disk formats
  • Naive → standard → state of the art:
  • naive: one synchronous request at a time · 13 k IOPS (74 µs each)
  • standard: 32-64 requests in flight · about 230 k IOPS
  • state of the art: user-space or polled NVMe with per-core queues (SPDK, io_uring) · 1 M IOPS needs about 100 requests in flight at 100 µs (Little's law)

Implementation notes

  • Inspect: nvme id-ctrl (VWC = volatile write cache present), nvme id-ns -H (LBA formats 512 vs 4096), /sys/block/nvme0n1/queue/{logical_block_size,physical_block_size,optimal_io_size,scheduler}. NVMe usually runs with scheduler none.
  • Switch a namespace to 4 KiB LBAs with nvme format --lbaf=<n>. This erases the namespace.
  • Align partitions to 1 MiB (the default in modern partitioners) and filesystem blocks to 4 KiB.
  • One thread with io_uring and SQPOLL can drive hundreds of thousands of IOPS. Blocking pread needs one thread per outstanding I/O (io_uring).
  • Mixed read/write: reads stuck behind a program or erase on the same die see ms-scale tails. Drives with program/erase suspend help. Rate-limit background compaction.
  • Hot/cold separation: data with similar lifetimes in the same erase blocks lowers GC copying. The host can steer this with ZNS or FDP.

Pitfalls

  • Benchmarking reads on a freshly written file: the page cache (or the drive's DRAM) serves them. Use O_DIRECT, drop caches, or read a large old file.
  • Reading unwritten or trimmed LBAs: many drives return zeros without touching flash, so you get fantasy IOPS.
  • QD1 microbenchmarks that call the drive "slow" when it is idle 95 % of the time.
  • Assuming sequential write speed holds forever on consumer drives (SLC cache exhaustion).
  • Ignoring GC: steady-state random-write IOPS can be order of 3-10× below fresh-out-of-box numbers.

Variants and alternatives

ZNS (Zoned Namespaces)the host writes zones sequentially and resets them. There is no device GC, and WA ≈ 1 if the host manages it well.
FDP (Flexible Data Placement)the host tags writes with placement IDs and the device still does GC. Easier to adopt than ZNS.
Persistent memory / Optaneorder of 10 µs QD1 reads, now discontinued.
HDDorder of 100-200 random IOPS, but cheap sequential capacity.

In production

RocksDB (LSM design for flash, ZNS support via ZenFS), Aerospike and ScyllaDB (direct I/O, per-core queues), SPDK (user-space NVMe driver), Linux blk-mq, fio for measurement.

Implementations

namelangrepoinstallverdictlicencehealthas_ofnotes
fioCaxboe/fiobrew install fio / apt install fio————queue depth, block size and engine sweeps; precondition before measuring
io-uringRusttokio-rs/io-uringcargo add io-uring————submission and completion queues for deep queue depths
nvme-cliClinux-nvme/nvme-cliapt install nvme-cli————nvme smart-log for data units written (write amplification)
smartmontoolsCsmartmontools/smartmontoolsbrew install smartmontools————SMART wear and endurance counters
SPDKCspdk/spdkbuild from source (./configure && make)————user-space polled NVMe driver

Sources

  • Nitin Agrawal et al., Design Tradeoffs for SSD Performance, USENIX ATC, 2008. paper
  • Xiao-Yu Hu et al., Write Amplification Analysis in Flash-Based Solid State Drives, SYSTOR, 2009.
  • Jun He, Sudarsun Kannan, Andrea C. Arpaci-Dusseau, Remzi H. Arpaci-Dusseau, The Unwritten Contract of Solid State Drives, EuroSys, 2017.
  • Matias Bjørling et al., ZNS: Avoiding the Block Interface Tax for Flash-based SSDs, USENIX ATC, 2021. paper
  • NVM Express, NVM Express Base Specification, revision 2.0. spec
  • SNIA, Solid State Storage Performance Test Specification (PTS). spec

Builds on

nvme queues · plannedNVMe submission and completion queue pairs, doorbells and polling from a driver

Used by

set associative flash cache · plannedHash keys to small flash sets/pages for tiny objects with low DRAM index cost (Kangaroo style)
zoned storage · plannedZoned namespaces and SMR: sequential write zones, zone append, host-managed garbage collection

Systems (4):

Alternatives and related

fsync, O_DSYNC and O_DIRECTHow do you make sure data written by a program actually survives a crash or power loss? This guide answers which file-write calls really guarantee durability on Linux and macOS, and in what sequence to use them.io_uringA Linux program doing many file and network operations pays one system call each, which dominates CPU at high rates. It needs to start and collect many operations with few kernel crossings.LSM-treeStorage engines for write-heavy ingest (logs, metrics, events, feeds) struggle with random in-place disk updates, wearing SSDs and limiting write bandwidth. This is an index designed for fast sustained writes.B+ treeA database or file system needs an on-disk index that serves point lookups and range scans with a small, predictable number of disk reads, especially for read-heavy workloads.Write-ahead logDatabases, queues and metadata services must not lose or corrupt committed data when the process crashes or power fails halfway through updating files.Buffer pools for zero-allocation I/OA storage engine, network server or log writer doing I/O at high rates loses time and memory predictability by acquiring a fresh buffer for every read or write. It needs buffer management that costs almost nothing per operation.mmap and the page cacheShould a program read files with ordinary reads or by memory-mapping them, and why do databases avoid mapping? This guide helps engineers choose how to access file data, given what the operating system caches and controls.