[RFC PATCH v3 0/2] ext4: speed up fast commit on random writes

From: Daejun Park

Date: Tue Jul 21 2026 - 21:24:20 EST


Fast commit is meant to make fsync cheap, but on random-write workloads it
defeats itself. ext4 still tracks a single coalesced [min,max] logical range
per inode (i_fc_lblk_start/len). When an inode is dirtied at several disjoint
offsets between two commits, that span widens to cover them all, and at commit
time ext4_fc_snapshot_inode_data() walks the whole span through the extent
status tree -- emitting an ADD_RANGE per mapped segment and a DEL_RANGE per
hole inside it. For scattered writes that is hundreds to thousands of ranges
even though only a handful of regions were actually modified.

The recently merged fast-commit snapshot work did not change this: it caps a
snapshot at EXT4_FC_SNAPSHOT_MAX_RANGES (2048) and fails over to a full commit
when the span exceeds it. Measured on dev (7.1.0-rc4) with a sparse
random-write workload (1 GiB span, R disjoint 4 KiB writes per fsync, 300
fsyncs):

R=16 regions/fsync: ranges/commit 1095, full-commit fallback 76%
(snap_fail_ranges_cap on 226 of 300 fsyncs)

Fast commit barely functions on this workload.

This series tracks the actually-modified disjoint ranges instead of one span,
and snapshots only those:

1/2 replaces the single [min,max] range with a small, bounded set of sorted,
disjoint ranges (up to EXT4_FC_MAX_RANGES = 16; the two closest are
merged on overflow, so the worst case degrades to the old single-span
behaviour). ext4_fc_snapshot_inode_data() then walks only the tracked
ranges. The on-disk TLV format is unchanged.

2/2 allocates the range array lazily: the first range stays inline, the
array is allocated only when a second disjoint range appears, and on an
allocation failure we fall back to the inline single range. Per-inode
fast-commit footprint stays ~20 bytes.

Result on the same workload (dev, patched):

R=16: ranges/commit 1095 -> 16, fallback 76% -> 0.7%,
snap_fail_ranges_cap 226 -> 0

i.e. the over-logging and the fallback storm are gone when the number of dirty
regions is within the bound.

Honest limitation: when the number of disjoint regions per fsync exceeds
EXT4_FC_MAX_RANGES (e.g. R=64 with the bound at 16), the overflow merge
re-coalesces and the win largely disappears (fallback 95% -> 87%). A larger or
tunable bound recovers it -- see the open question below.

Testing (on dev, patched):
- crash recovery: deterministic writes + fsync, kill -9 QEMU (power loss),
reboot -> fast-commit replay -> verify every fsync'd block, e2fsck -fn.
9600/9600 blocks verified, 0 mismatch, e2fsck clean. Run with R=64, so the
overflow-merge path is exercised.
- ext4/generic fast-commit xfstests (ext4/044 ext4/045 generic/455 456 457
470 482): ext4/044, ext4/045, generic/456 pass; generic/457, 470 skip
(reflink/dax unsupported on ext4); generic/455 fails identically on
unpatched dev (pre-existing, patch-unrelated); generic/482's single failure
in a combined run did not reproduce (3/3 pass in isolation on the patched
kernel).
- on-disk format unchanged, so e2fsprogs needs no update.
- both patches are checkpatch --strict clean.

Posted as RFC -- this sits right on top of the new snapshot code and I would
like direction before a non-RFC submission:

- Is tracking disjoint ranges in-core (this series) the right shape, or would
you rather the snapshot side skip holes/unmodified extents some other way?
- EXT4_FC_MAX_RANGES is fixed at 16. The R>bound case above wants it larger
or tunable (sysfs/mount). Preference?
- The array is allocated under i_fc_lock with GFP_ATOMIC, falling back to the
inline range on failure. Acceptable, or move the allocation outside the
lock?
- Overflow merges the two ranges with the smallest gap -- acceptable
heuristic?

Changes since v2 [1]:
- Evaluate the range adjacency bounds in 64 bits to remove a latent
ext4_lblk_t (u32) overflow. The largest valid lblk is
EXT_MAX_BLOCKS - 1 (the shrink path in ext4_setattr() passes it; the data
path is bounded by the m_lblk >= EXT_MAX_BLOCKS check), so "end + 1" and a
range's "start + len" can reach EXT_MAX_BLOCKS. These were computed in u32
in ext4_fc_range_add() (1/2) and __track_range() (2/2). On reachable inputs
they stop one short of wrapping, so there is no behavioural change, but the
arithmetic is now done in u64 so it cannot wrap regardless of caller --
matching ext4_fc_snapshot_lblk_range(), which already used 64-bit bounds.
No other site in the series shares this pattern (audited).

Changes since v1 [2]:
- Rebased from v6.17-rc3 onto ext4.git dev; re-implemented on top of the
merged fast-commit snapshot model (v1 targeted the old
ext4_fc_write_inode_data(), which no longer exists).
- Motivation now uses the in-tree snap_fail_ranges_cap counter.
- From and Signed-off-by use the same identity.
- Reposted as RFC.

[1] v2: https://lore.kernel.org/linux-ext4/20260623082331epcms2p4798e9d26b06f9a005bcca7b2edf395d3@epcms2p4/
[2] v1: https://lore.kernel.org/linux-ext4/20260611044733epcms2p38013ae683a283555526f70e4eab6d2a9@epcms2p3/

Daejun Park (2):
ext4: fast commit: track disjoint modified ranges per inode
ext4: fast commit: allocate the range array lazily

fs/ext4/ext4.h | 42 ++++++--
fs/ext4/fast_commit.c | 231 ++++++++++++++++++++++++++++++++++++------
fs/ext4/super.c | 1 +
3 files changed, 234 insertions(+), 40 deletions(-)


base-commit: c143957520c6c9b5cd72e0de8b52b814f0c576fe
--
2.43.0