Linux internals, made visible

Small writes.
Large writeback.

The same 64-byte file update can lead to 4 KiB or 2 MiB of data written to disk. Follow write() and shared mmap() through the page cache to see where the difference appears.

XFS / iomap model4 KiB filesystem blocksWhole-folio mmap dirtyingIllustration, not a benchmark

A model of the behavior discussed in the linked kernel sources. Actual results depend on filesystem, kernel, cache folio sizes, and workload.

00 / BEFORE THE WRITE

A read can set up
the next write.

Device readahead helps shape the cache before a small write arrives. A wider read window can accommodate larger folios—and a larger later mmap writeback footprint.

A / DEVICE SETUP

Geometry influences readahead

An array can advertise a large optimal I/O size. The block layer can raise readahead to at least twice that size.

optimal_io_size → read_ahead_kb

B / READ WINDOW

Earlier reads fill the cache

2304 KiB

576 × 4 KiB pages fit in this window.

For example, a sequential scan before the updates.

C / CACHE ALLOCATION

A larger folio can fit

2 MiB

A 2 MiB folio can fit in 2304 KiB. Some allocations can reach this size; others can be smaller.

D / LATER 64-BYTE EDIT

Whole-folio dirtying

512×

2 MiB mmap writeback
versus 4 KiB with write().

Ratio of file-data bytes, given the selected folio size.

The reported observation: software RAID across many NVMe drives automatically obtained read_ahead_kb = 2304, and some folios reached 2 MiB. This is consistent with the source mechanisms; the array’s exact RAID level and geometry were not provided.

Why read_ahead_kb is influential, but not an absolute folio-size cap

Window size is one constraint

In page_cache_ra_order(), allocation order is constrained by the readahead window and the mapping’s supported orders. Alignment, file boundaries, and allocation success can reduce actual sizes. With an ordinary 128 KiB window, 128 KiB fits; 2 MiB does not.

A large read request can extend the window toward bdi->io_pages, derived from max_sectors_kb. POSIX_FADV_SEQUENTIAL also doubles a file’s readahead limit. Therefore, 128 KiB in sysfs is not a universal ceiling. Changing it does not split folios already resident in the cache.

Inspect the filesystem-facing device

Check the md or dm device backing the filesystem, as well as its members. Example read-only checks; replace the path and md0 with the correct names:

findmnt -no SOURCE,FSTYPE --target /path/to/journal
cat /sys/class/block/md0/queue/read_ahead_kb
cat /sys/class/block/md0/queue/optimal_io_size
cat /sys/class/block/md0/queue/max_sectors_kb

The two _kb values use KiB; optimal_io_size uses bytes. A default of 128 KiB is a baseline, not a guarantee for every disk.

01 / FOLLOW ONE UPDATE

Same file. Same bytes.
Two paths to disk.

A 64-byte overwrite at 400 MiB + 128 bytes in a 1 GiB file. It falls inside one 4 KiB block of a 2 MiB folio, after earlier reads populated the cache. Compare separate runs from the same clean cache state.

One update through the stack
Existing, clean, cached file · no concurrent writes

2 MiB folios
write() / pwrite()
Buffered file I/O
mmap(MAP_SHARED)
CPU stores through a file mapping
1 Application → cache memory
pwrite(fd, data, 64, offset)

Known range: offset + 64 bytes

memcpy(map + offset, data, 64)

Fault if write-protected; then CPU stores.

2 Page cache · 400–402 MiB region
0 dirty blocks

The cache starts clean.

0 dirty blocks

The cache starts clean.

Block containing the editExtra blocks marked dirtyClean

Each square = 4 KiB. This 2 MiB region contains one 512-block folio.

3 Shared bookkeeping · dirty file
dirty folio + cache tag→inode: I_DIRTY_PAGES→wb->b_dirty

Both paths have one dirty folio. The inode is the kernel’s record for this file.

4 Writeback worker · periodic path
wb_workfn()→wb_do_writeback()→wb_check_old_data_flush()

The worker wakes and calls the checker. If a periodic pass is due, wb_writeback() selects eligible dirty inodes.

Inode old enough?No → keep queuedYes → wb->b_io
5 File’s cache · dirty-tag lookup
do_writepages()→iomap_writepages()→writeback_iter()

1 of 512 folios selected. Clean folios are skipped. Each square above is a 2 MiB region of the file; the pass is limited by its range and budget.

6 Filesystem → block I/O → storage
0 B
Data submitted so far
0 B
Data submitted so far
Same 64-byte application update.
Step forward to submit writeback.
—

The diagram retains the dirty footprint after submission for comparison. Successful completion ends writeback; it does not by itself promise power-loss durability.

Timing, dirty state, and what this example leaves out

dirty_writeback_centisecs controls periodic wake frequency; dirty_expire_centisecs supplies the age cutoff for old-data selection. The first dirty inode may schedule delayed work via wb_wakeup_delayed(). Age is an inode scheduling heuristic, not a timer attached to each modified byte. Dirty-pressure writeback, reclaim-related work, and explicit sync requests may cause I/O sooner.

A folio dirty flag and iomap’s per-block dirty bitmap are different things. Both paths mark the folio dirty; their block maps differ. Buffered writes track the range copied, not a comparison of old and new byte values. Counts here are file-data writeback bytes, excluding filesystem metadata and journal traffic, and SSD-internal writes. This model uses ordinary buffered I/O and shared mappings, not DAX, direct I/O, or MAP_PRIVATE.

02 / SCALE UP TO A FILE

A little here.
A little everywhere.

Scatter identical updates across a 1 GiB file. Both APIs receive the same offsets and payloads. Change the pattern to see how many cache folios become involved.

Readahead preset: 2304 KiB. Model uses 2 MiB folios; actual allocations can be mixed.

1512
Application updates
2 MiB
512 × 4 KiB
write() data writeback
2 MiB
512 dirty blocks
mmap data writeback
634 MiB
317 dirty folios
mmap ÷ write() data I/O
317×
For the same application updates

The whole file

Each tile is a 2 MiB region. Select one to inspect.

0 · file start1 GiB · file end
Contains an updateNo updateOutline = selected

317 of 512 regions touched.

Uniform sample of distinct blocks across the whole file.

Inside one region

0–2 MiB · one update

write() footprint
4 KiB
mmap footprint
2 MiB
Updated blockExtra writeback

Each square is a 4 KiB block. The highlighted blocks form the writeback footprint after all selected updates.

write(): 512 blocks × 4 KiB = 2 MiB
mmap: 317 folios × 2 MiB = 634 MiB

Model: initially clean cache, fixed-size folios, 4 KiB blocks, unique block locations, and one final writeback after updates coalesce. A 64-byte edit stays within its block. Ratios compare file-data bytes; they are not latency or device-wear predictions.

Fixed sample · 20261009
Why random and maximally scattered are different

With 512 full-block updates, the app changes only 2 MiB. The initial random sample touches 317 large folios: 634 MiB of mmap writeback versus 2 MiB with write(). Confining the same amount of work to 16 MiB touches eight 2 MiB folios. Placing one update in every 2 MiB region touches all 512: 1 GiB versus 2 MiB. That last pattern is a deliberately spread example, not the expected outcome of every random workload.

These counters are computed from the selected offsets. Repeated modifications before a flush can coalesce. A later modification after a completed flush can cause the same folio to be written again.

03 / TWO DIFFERENT GRANULARITIES

A folio is not
a page-table entry.

The page cache groups file data into folios. Page tables map virtual addresses to that memory. A large cache folio can be reached through either small or huge mappings.

CPU PAGE TABLES · TYPICAL x86-64 SIZES
1 PMD entryOne dirty bit covers 2 MiB
PAGE CACHE · SAME MEMORY IN BOTH CASES
One 2 MiB folio512 filesystem blocks · modeled mmap writeback: 2 MiB

A PMD dirty bit says that something in its 2 MiB mapping was written. It does not identify the modified 4 KiB block.

Mapping size changes what the CPU can report.

A 2 MiB folio does not guarantee a PMD mapping. The fault path checks size, alignment, page-table state, and mapping policy. Smaller PTE mappings are used when a huge mapping cannot be installed.

In the whole-folio dirtying behavior modeled here, PTE-mapped large folios can also suffer the amplification. Selecting PTEs alone does not fix the filesystem’s dirty tracking. Buffered write() gets its precision from the supplied offset and copied length, recorded in iomap’s dirty-block bitmap. It does not depend on PTE dirty bits.

Where finer tracking could help

PTE dirty bits provide 4 KiB information in this example. A September 2026 RFC proposed harvesting them for sub-folio dirty tracking. PMD mappings still expose only a coarse dirty bit. This page illustrates the behavior motivating that work; it does not claim the RFC is merged or enabled on your kernel.

Where the large folio came from

The readahead profiles above show the setup stage. A wider window can accommodate larger folios before the writes begin. This mapping diagram specifically shows a 2 MiB folio; the 128 KiB profile uses smaller cache folios, which are PTE-mapped in this x86-64 example. Real files can contain mixed sizes.

What tells writeback which blocks to send?

Same 2 MiB cache folio, same 64-byte edit within one 4 KiB block. Whole-folio mmap dirtying is the behavior modeled here.

Update pathInformation availableModeled file-data writeback
mmap · PMD mappingOne hardware dirty bit covers the entire 2 MiB mapping.2 MiB · whole folio
mmap · PTE mappingsHardware bits identify 4 KiB pages, but the modeled filesystem dirtying path still marks the whole folio.2 MiB · whole folio
Buffered write() / pwrite()The kernel knows the offset and copied length. iomap records the overlapping filesystem blocks.4 KiB · one dirty block

CPU page tables still translate memory addresses during write(), but those mappings are not the mechanism used to track the file’s dirty ranges. “Precise” here means filesystem-block granularity, not byte-sized disk writes.

04 / FOLLOW THE SOURCE

From the picture
to the kernel.

Documentation for the mechanisms, source for the call path, and the discussions connecting them to sparse journal-file updates.

The distinction matters for workloads such as systemd-journald: small, scattered file updates can dirty much larger cache units. This is a conditional explanation of that mechanism, not a claim about every journal installation.

02
The systemd-journald connection ↗

Kernel discussion of small mmap writes, whole-folio writeback, and the contrast with range-aware buffered writes.

LKML · Pedro Falcato · 20 Aug 2026
04
Writeback interval and dirty age ↗

Definitions of dirty_writeback_centisecs and dirty_expire_centisecs, plus background dirty thresholds.

docs.kernel.org · VM sysctl documentation
05
iomap buffered-io.c ↗

Range dirtying in buffered writes, whole-folio dirtying, and extraction of dirty ranges during writeback.

Linux source · fs/iomap/buffered-io.c
07
Finding dirty-tagged folios ↗

Tagged cache lookup, writeback iteration, and dirty-state preparation with folio_mkclean().

Linux source · mm/page-writeback.c
10
Optimal I/O size → readahead ↗

blk_apply_bdi_limits() uses the maximum of the existing setting, twice optimal I/O size, and the baseline.

Linux source · block/blk-settings.c
11
Example: RAID0 geometry ↗

RAID0 computes optimal I/O size from chunk size and disk count. This illustrates array-driven scaling, not the unknown layout of the reported test.

Linux source · drivers/md/raid0.c
12
The 128 KiB baseline ↗

VM_READAHEAD_PAGES defines the baseline. It does not imply every device retains this setting.

Linux source · include/linux/pagemap.h
Function-by-function route through the walkthrough
StepSource entry pointsWhat to look for
Earlier cache populationpage_cache_ra_order()Allocation order is shaped by the current readahead window and filesystem support.
Range-aware buffered write__iomap_write_end()Dirty state for the range actually copied, rounded to blocks.
Shared mmap writeiomap_page_mkwrite()
iomap_dirty_folio()
Write-fault preparation and the full-folio dirtying hook.
Queue dirty inodefilemap_dirty_folio()
__mark_inode_dirty()
Cache tag, inode dirty flag, queue, and dirty timestamp.
Wake and checkwb_workfn()
wb_check_old_data_flush()
Worker calls the periodic checker via wb_do_writeback().
Choose eligible inodeswb_writeback()
move_expired_inodes()
Age cutoff and movement from b_dirty to b_io via queue_io().
Find dirty folioswriteback_iter()
filemap_get_folios_tag()
Dirty-tagged folios within the requested range and budget.
Prepare mapped memoryfolio_clear_dirty_for_io()folio_mkclean() write-protects mappings and reconciles mapped dirty state.
Submit dirty blocksiomap_writeback_folio()
iomap_find_dirty_range()
The block bitmap determines the file-data ranges submitted for I/O.

Source walkthrough based on the Code Browser snapshot identified as v6.19-rc8-185-g2687c848e578. Online source pages and line numbers may change. The linked September 2026 RFC is evidence of a proposal, not of deployment status.