* [RFC PATCH v3 0/2] ext4: speed up fast commit on random writes
[not found] <CGME20260722005922epcms2p566d8027c8d1dbb8d9c46e3d3b35b6fd0@epcms2p5>
@ 2026-07-22 0:59 ` Daejun Park
[not found] ` <CGME20260722005922epcms2p566d8027c8d1dbb8d9c46e3d3b35b6fd0@epcms2p2>
` (3 more replies)
0 siblings, 4 replies; 5+ messages in thread
From: Daejun Park @ 2026-07-22 0:59 UTC (permalink / raw)
To: tytso, adilger.kernel
Cc: linux-ext4, linux-kernel, me, libaokun, harshadshirwadkar,
yi.zhang, ojaswin, Daejun Park
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
^ permalink raw reply [flat|nested] 5+ messages in thread
* [RFC PATCH v3 1/2] ext4: fast commit: track disjoint modified ranges per inode
[not found] ` <CGME20260722005922epcms2p566d8027c8d1dbb8d9c46e3d3b35b6fd0@epcms2p2>
@ 2026-07-22 1:00 ` Daejun Park
0 siblings, 0 replies; 5+ messages in thread
From: Daejun Park @ 2026-07-22 1:00 UTC (permalink / raw)
To: tytso, adilger.kernel
Cc: linux-ext4, linux-kernel, me, libaokun, harshadshirwadkar,
yi.zhang, ojaswin, Daejun Park
Fast commit tracks a single coalesced logical range per inode
(i_fc_lblk_start .. i_fc_lblk_len). When an inode is modified at
several disjoint offsets between two commits (e.g. random writes), that
range widens to span [min, max] of all touched offsets. At commit time
ext4_fc_snapshot_inode_data() walks that whole span through the extent
status tree, emitting an ADD_RANGE per mapped segment and a DEL_RANGE
per hole -- including the unmodified ones. On scattered allocations
this produces hundreds to thousands of ranges per commit and exceeds
EXT4_FC_SNAPSHOT_MAX_RANGES, which fails the snapshot and falls back to
a full jbd2 commit -- the heavy path fast commit is meant to avoid.
Replace the single range with a bounded set of up to EXT4_FC_MAX_RANGES
(16) sorted, mutually disjoint ranges. ext4_fc_range_add() inserts and
merges into it; on overflow the two ranges separated by the smallest
gap are coalesced, so the worst case degrades to the old single-span
behaviour. ext4_fc_snapshot_inode_data() now walks only the tracked
ranges. The on-disk fast-commit (TLV) format is unchanged.
On a sparse random-write workload (1 GiB span, 16 disjoint 4 KiB writes
per fsync, 300 fsyncs, dev 7.1.0-rc4): ranges per commit 1095 -> 16,
full-commit fallback 76% -> 0.7%, snap_fail_ranges_cap 226 -> 0.
Signed-off-by: Daejun Park <daejun7.park@samsung.com>
---
fs/ext4/ext4.h | 31 ++++++--
fs/ext4/fast_commit.c | 172 +++++++++++++++++++++++++++++++++---------
2 files changed, 163 insertions(+), 40 deletions(-)
diff --git a/fs/ext4/ext4.h b/fs/ext4/ext4.h
index ddc903738c6b..8e93d30766fd 100644
--- a/fs/ext4/ext4.h
+++ b/fs/ext4/ext4.h
@@ -1042,6 +1042,20 @@ enum ext4_fc_snap_err {
EXT4_FC_SNAP_ERR_INODE_LOC,
};
+/*
+ * Maximum number of disjoint logical-block ranges tracked per inode for a
+ * single fast commit. Scattered allocations that exceed this get their two
+ * closest ranges merged (see ext4_fc_range_add()), degrading gracefully to
+ * the old single coalesced-range behaviour.
+ */
+#define EXT4_FC_MAX_RANGES 16
+
+/* In-memory record of an lblk range modified in the current fast commit. */
+struct ext4_fc_lblk_range {
+ ext4_lblk_t start;
+ ext4_lblk_t len;
+};
+
/*
* fourth extended file system inode data in memory
*/
@@ -1091,11 +1105,16 @@ struct ext4_inode_info {
* protected by sbi->s_fc_lock.
*/
- /* Start of lblk range that needs to be committed in this fast commit */
- ext4_lblk_t i_fc_lblk_start;
-
- /* End of lblk range that needs to be committed in this fast commit */
- ext4_lblk_t i_fc_lblk_len;
+ /*
+ * Disjoint lblk ranges modified in this fast commit. Tracking the
+ * actual modified ranges (instead of one coalesced [min,max]) avoids
+ * snapshotting the whole spanned extent map for scattered allocations.
+ * Sorted by start, mutually disjoint. Bounded by EXT4_FC_MAX_RANGES;
+ * the extra slot is transient room used while inserting before an
+ * overflow merge. Protected by i_fc_lock.
+ */
+ struct ext4_fc_lblk_range i_fc_ranges[EXT4_FC_MAX_RANGES + 1];
+ unsigned int i_fc_nr_ranges;
/*
* Commit-time fast commit snapshots.
@@ -1116,7 +1135,7 @@ struct ext4_inode_info {
spinlock_t i_raw_lock; /* protects updates to the raw inode */
/*
- * Protect concurrent accesses on i_fc_lblk_start, i_fc_lblk_len
+ * Protect concurrent accesses on i_fc_ranges, i_fc_nr_ranges
* and inode's EXT4_FC_STATE_COMMITTING state bit.
*/
spinlock_t i_fc_lock;
diff --git a/fs/ext4/fast_commit.c b/fs/ext4/fast_commit.c
index 4ef796b9b6cb..28560d776862 100644
--- a/fs/ext4/fast_commit.c
+++ b/fs/ext4/fast_commit.c
@@ -222,8 +222,7 @@ static inline void ext4_fc_reset_inode(struct inode *inode)
{
struct ext4_inode_info *ei = EXT4_I(inode);
- ei->i_fc_lblk_start = 0;
- ei->i_fc_lblk_len = 0;
+ ei->i_fc_nr_ranges = 0;
}
void ext4_fc_init_inode(struct inode *inode)
@@ -582,7 +581,7 @@ static int __track_inode(handle_t *handle, struct inode *inode, void *arg,
if (update)
return -EEXIST;
- EXT4_I(inode)->i_fc_lblk_len = 0;
+ EXT4_I(inode)->i_fc_nr_ranges = 0;
return 0;
}
@@ -622,12 +621,81 @@ struct __track_range_args {
ext4_lblk_t start, end;
};
+/*
+ * Record that logical block range [start, end] was modified in the current
+ * fast commit. Maintains a small, bounded set of sorted, mutually disjoint
+ * ranges, merging the new range with any it overlaps or is adjacent to. When
+ * the set would exceed EXT4_FC_MAX_RANGES, the consecutive pair separated by
+ * the smallest gap is merged (absorbing that gap), so the worst case degrades
+ * gracefully to the old single coalesced-range behaviour. Tracking the actual
+ * modified ranges (rather than one [min,max] span) keeps
+ * ext4_fc_snapshot_inode_data() from snapshotting the whole spanned extent map
+ * on scattered allocations. Caller holds ei->i_fc_lock; ei->i_fc_ranges is
+ * non-NULL with room for EXT4_FC_MAX_RANGES + 1 entries.
+ */
+static void ext4_fc_range_add(struct ext4_inode_info *ei,
+ ext4_lblk_t start, ext4_lblk_t end)
+{
+ struct ext4_fc_lblk_range *r = ei->i_fc_ranges;
+ unsigned int n = ei->i_fc_nr_ranges;
+ unsigned int i, j;
+
+ /*
+ * The largest valid lblk is EXT_MAX_BLOCKS - 1, so a range's "first
+ * block after" (start + len) and a region's "end + 1" can each reach
+ * EXT_MAX_BLOCKS. Evaluate those adjacency bounds in 64 bits so they
+ * cannot wrap the ext4_lblk_t (u32) type.
+ */
+
+ /* Skip ranges lying entirely before [start - 1] (no overlap/adjacency). */
+ i = 0;
+ while (i < n && (u64)r[i].start + r[i].len < start)
+ i++;
+
+ /* Absorb every range overlapping or adjacent to the growing [start,end]. */
+ j = i;
+ while (j < n && r[j].start <= (u64)end + 1) {
+ if (r[j].start < start)
+ start = r[j].start;
+ if (r[j].start + r[j].len - 1 > end)
+ end = r[j].start + r[j].len - 1;
+ j++;
+ }
+
+ /* Replace r[i..j-1] with the merged range (j == i is a plain insert). */
+ if (j != i + 1)
+ memmove(&r[i + 1], &r[j], (n - j) * sizeof(*r));
+ r[i].start = start;
+ r[i].len = end - start + 1;
+ ei->i_fc_nr_ranges = n - (j - i) + 1;
+
+ /* Overflow: merge the consecutive pair separated by the smallest gap. */
+ while (ei->i_fc_nr_ranges > EXT4_FC_MAX_RANGES) {
+ ext4_lblk_t best_gap = ~0U;
+ unsigned int best = 0;
+
+ n = ei->i_fc_nr_ranges;
+ for (i = 0; i + 1 < n; i++) {
+ ext4_lblk_t gap = r[i + 1].start -
+ (r[i].start + r[i].len);
+
+ if (gap < best_gap) {
+ best_gap = gap;
+ best = i;
+ }
+ }
+ r[best].len = r[best + 1].start + r[best + 1].len - r[best].start;
+ memmove(&r[best + 1], &r[best + 2],
+ (n - best - 2) * sizeof(*r));
+ ei->i_fc_nr_ranges = n - 1;
+ }
+}
+
/* __track_fn for tracking data updates */
static int __track_range(handle_t *handle, struct inode *inode, void *arg,
bool update)
{
struct ext4_inode_info *ei = EXT4_I(inode);
- ext4_lblk_t oldstart;
struct __track_range_args *__arg =
(struct __track_range_args *)arg;
@@ -636,17 +704,16 @@ static int __track_range(handle_t *handle, struct inode *inode, void *arg,
return -ECANCELED;
}
- oldstart = ei->i_fc_lblk_start;
+ /*
+ * A sub-block punch hole rounds up the start and down the end, passing
+ * end == start - 1: no whole block changed, so there is nothing to
+ * track. (ext4_fc_track_template has already reset the range set for a
+ * new transaction.)
+ */
+ if (__arg->end < __arg->start)
+ return 0;
- if (update && ei->i_fc_lblk_len > 0) {
- ei->i_fc_lblk_start = min(ei->i_fc_lblk_start, __arg->start);
- ei->i_fc_lblk_len =
- max(oldstart + ei->i_fc_lblk_len - 1, __arg->end) -
- ei->i_fc_lblk_start + 1;
- } else {
- ei->i_fc_lblk_start = __arg->start;
- ei->i_fc_lblk_len = __arg->end - __arg->start + 1;
- }
+ ext4_fc_range_add(ei, __arg->start, __arg->end);
return 0;
}
@@ -994,31 +1061,26 @@ static void ext4_fc_free_inode_snap(struct inode *inode)
ei->i_fc_snap = NULL;
}
-static int ext4_fc_snapshot_inode_data(struct inode *inode,
+/*
+ * Snapshot one modified lblk range [start_lblk, end_lblk] into @ranges by
+ * walking the extent status tree, emitting an ADD_RANGE per mapped segment and
+ * a DEL_RANGE per hole. *nr_ranges accumulates the number of ranges produced
+ * for this inode across calls; together with nr_ranges_total (ranges already
+ * produced by earlier inodes in this commit) it is bounded against
+ * EXT4_FC_SNAPSHOT_MAX_RANGES.
+ */
+static int ext4_fc_snapshot_lblk_range(struct inode *inode,
+ ext4_lblk_t start_lblk,
+ ext4_lblk_t end_lblk,
struct list_head *ranges,
unsigned int nr_ranges_total,
- unsigned int *nr_rangesp,
+ unsigned int *nr_ranges,
int *snap_err)
{
- struct ext4_inode_info *ei = EXT4_I(inode);
struct ext4_fc_snap_stats *stats =
&EXT4_SB(inode->i_sb)->s_fc_snap_stats;
- ext4_lblk_t start_lblk, end_lblk, cur_lblk;
- unsigned int nr_ranges = 0;
-
- spin_lock(&ei->i_fc_lock);
- if (ei->i_fc_lblk_len == 0) {
- spin_unlock(&ei->i_fc_lock);
- if (nr_rangesp)
- *nr_rangesp = 0;
- return 0;
- }
- start_lblk = ei->i_fc_lblk_start;
- end_lblk = ei->i_fc_lblk_start + ei->i_fc_lblk_len - 1;
- ei->i_fc_lblk_len = 0;
- spin_unlock(&ei->i_fc_lock);
+ ext4_lblk_t cur_lblk = start_lblk;
- cur_lblk = start_lblk;
ext4_debug("snapshot data ranges %u-%u for inode %llu\n",
start_lblk, end_lblk,
(unsigned long long)inode->i_ino);
@@ -1050,7 +1112,7 @@ static int ext4_fc_snapshot_inode_data(struct inode *inode,
continue;
}
- if (nr_ranges_total + nr_ranges >= EXT4_FC_SNAPSHOT_MAX_RANGES) {
+ if (nr_ranges_total + *nr_ranges >= EXT4_FC_SNAPSHOT_MAX_RANGES) {
atomic64_inc(&stats->snap_fail_ranges_cap);
ext4_fc_set_snap_err(snap_err,
EXT4_FC_SNAP_ERR_RANGES_CAP);
@@ -1063,7 +1125,7 @@ static int ext4_fc_snapshot_inode_data(struct inode *inode,
ext4_fc_set_snap_err(snap_err, EXT4_FC_SNAP_ERR_NOMEM);
return -ENOMEM;
}
- nr_ranges++;
+ (*nr_ranges)++;
range->lblk = cur_lblk;
range->len = len;
@@ -1101,6 +1163,48 @@ static int ext4_fc_snapshot_inode_data(struct inode *inode,
cur_lblk += range->len;
}
+ return 0;
+}
+
+static int ext4_fc_snapshot_inode_data(struct inode *inode,
+ struct list_head *ranges,
+ unsigned int nr_ranges_total,
+ unsigned int *nr_rangesp,
+ int *snap_err)
+{
+ struct ext4_inode_info *ei = EXT4_I(inode);
+ struct ext4_fc_lblk_range tracked[EXT4_FC_MAX_RANGES + 1];
+ unsigned int nr_ranges = 0, nr_tracked, t;
+ int ret;
+
+ spin_lock(&ei->i_fc_lock);
+ nr_tracked = ei->i_fc_nr_ranges;
+ if (nr_tracked == 0) {
+ spin_unlock(&ei->i_fc_lock);
+ if (nr_rangesp)
+ *nr_rangesp = 0;
+ return 0;
+ }
+ memcpy(tracked, ei->i_fc_ranges, nr_tracked * sizeof(tracked[0]));
+ ei->i_fc_nr_ranges = 0;
+ spin_unlock(&ei->i_fc_lock);
+
+ /*
+ * Snapshot only the actually-modified ranges, not the whole [min,max]
+ * span: this is what keeps scattered allocations from blowing past
+ * EXT4_FC_SNAPSHOT_MAX_RANGES and falling back to a full commit.
+ */
+ for (t = 0; t < nr_tracked; t++) {
+ ext4_lblk_t s = tracked[t].start;
+ ext4_lblk_t e = s + tracked[t].len - 1;
+
+ ret = ext4_fc_snapshot_lblk_range(inode, s, e, ranges,
+ nr_ranges_total, &nr_ranges,
+ snap_err);
+ if (ret)
+ return ret;
+ }
+
if (nr_rangesp)
*nr_rangesp = nr_ranges;
return 0;
--
2.43.0
^ permalink raw reply [flat|nested] 5+ messages in thread
* [RFC PATCH v3 2/2] ext4: fast commit: allocate the range array lazily
[not found] ` <CGME20260722005922epcms2p566d8027c8d1dbb8d9c46e3d3b35b6fd0@epcms2p7>
@ 2026-07-22 1:01 ` Daejun Park
0 siblings, 0 replies; 5+ messages in thread
From: Daejun Park @ 2026-07-22 1:01 UTC (permalink / raw)
To: tytso, adilger.kernel
Cc: linux-ext4, linux-kernel, me, libaokun, harshadshirwadkar,
yi.zhang, ojaswin, Daejun Park
Patch 1 keeps the disjoint-range set in a fixed EXT4_FC_MAX_RANGES+1
array. Embedding that in every ext4_inode_info costs ~140 bytes on
inodes that never use fast commit or only ever touch a single
contiguous range.
Keep the first range inline in i_fc_range and allocate the array only
when a second disjoint range appears; free it when the inode is
evicted. The tracking path runs under i_fc_lock, so the array is
allocated with GFP_ATOMIC; on allocation failure we coalesce into the
inline range -- i.e. the original single-range behaviour -- so memory
pressure never forces a full commit. The per-inode fast-commit
footprint drops to 20 bytes.
Signed-off-by: Daejun Park <daejun7.park@samsung.com>
---
fs/ext4/ext4.h | 23 ++++++++++-----
fs/ext4/fast_commit.c | 69 ++++++++++++++++++++++++++++++++++++++++---
fs/ext4/super.c | 1 +
3 files changed, 82 insertions(+), 11 deletions(-)
diff --git a/fs/ext4/ext4.h b/fs/ext4/ext4.h
index 8e93d30766fd..d93cc52cd01e 100644
--- a/fs/ext4/ext4.h
+++ b/fs/ext4/ext4.h
@@ -1106,14 +1106,23 @@ struct ext4_inode_info {
*/
/*
- * Disjoint lblk ranges modified in this fast commit. Tracking the
+ * Logical block ranges modified in this fast commit. Tracking the
* actual modified ranges (instead of one coalesced [min,max]) avoids
* snapshotting the whole spanned extent map for scattered allocations.
- * Sorted by start, mutually disjoint. Bounded by EXT4_FC_MAX_RANGES;
- * the extra slot is transient room used while inserting before an
- * overflow merge. Protected by i_fc_lock.
+ *
+ * The first range is kept inline in i_fc_range, so the common case of a
+ * single contiguous dirty region needs no allocation. When a second
+ * disjoint range appears the inode is upgraded to the i_fc_ranges array
+ * (EXT4_FC_MAX_RANGES + 1 entries, sorted and mutually disjoint; the
+ * extra slot is transient room used while inserting before an overflow
+ * merge), allocated then and freed when the inode is evicted. If that
+ * allocation fails we fall back to coalescing into i_fc_range, i.e. the
+ * original single coalesced-range behaviour. i_fc_nr_ranges counts the
+ * valid ranges; while i_fc_ranges is NULL it is 0 or 1. Protected by
+ * i_fc_lock.
*/
- struct ext4_fc_lblk_range i_fc_ranges[EXT4_FC_MAX_RANGES + 1];
+ struct ext4_fc_lblk_range i_fc_range;
+ struct ext4_fc_lblk_range *i_fc_ranges;
unsigned int i_fc_nr_ranges;
/*
@@ -1135,8 +1144,8 @@ struct ext4_inode_info {
spinlock_t i_raw_lock; /* protects updates to the raw inode */
/*
- * Protect concurrent accesses on i_fc_ranges, i_fc_nr_ranges
- * and inode's EXT4_FC_STATE_COMMITTING state bit.
+ * Protect concurrent accesses on i_fc_range, i_fc_ranges,
+ * i_fc_nr_ranges and inode's EXT4_FC_STATE_COMMITTING state bit.
*/
spinlock_t i_fc_lock;
diff --git a/fs/ext4/fast_commit.c b/fs/ext4/fast_commit.c
index 28560d776862..3d100905d19a 100644
--- a/fs/ext4/fast_commit.c
+++ b/fs/ext4/fast_commit.c
@@ -230,6 +230,7 @@ void ext4_fc_init_inode(struct inode *inode)
struct ext4_inode_info *ei = EXT4_I(inode);
ext4_fc_reset_inode(inode);
+ ei->i_fc_ranges = NULL;
ext4_clear_inode_state(inode, EXT4_STATE_FC_COMMITTING);
ext4_clear_inode_state(inode, EXT4_STATE_FC_REQUEUE);
INIT_LIST_HEAD(&ei->i_fc_list);
@@ -698,6 +699,8 @@ static int __track_range(handle_t *handle, struct inode *inode, void *arg,
struct ext4_inode_info *ei = EXT4_I(inode);
struct __track_range_args *__arg =
(struct __track_range_args *)arg;
+ ext4_lblk_t start = __arg->start, end = __arg->end;
+ ext4_lblk_t s0, e0;
if (inode->i_ino < EXT4_FIRST_INO(inode->i_sb)) {
ext4_debug("Special inode %llu being modified\n", inode->i_ino);
@@ -708,12 +711,66 @@ static int __track_range(handle_t *handle, struct inode *inode, void *arg,
* A sub-block punch hole rounds up the start and down the end, passing
* end == start - 1: no whole block changed, so there is nothing to
* track. (ext4_fc_track_template has already reset the range set for a
- * new transaction.)
+ * new transaction, so we need not do it here.)
*/
- if (__arg->end < __arg->start)
+ if (end < start)
return 0;
- ext4_fc_range_add(ei, __arg->start, __arg->end);
+ /* Already upgraded to the heap array: full multi-interval tracking. */
+ if (ei->i_fc_ranges) {
+ ext4_fc_range_add(ei, start, end);
+ return 0;
+ }
+
+ /* First range of this commit stays inline, no allocation needed. */
+ if (ei->i_fc_nr_ranges == 0) {
+ ei->i_fc_range.start = start;
+ ei->i_fc_range.len = end - start + 1;
+ ei->i_fc_nr_ranges = 1;
+ return 0;
+ }
+
+ /* One inline range so far. */
+ s0 = ei->i_fc_range.start;
+ e0 = s0 + ei->i_fc_range.len - 1;
+
+ /*
+ * Disjoint from it: try to upgrade to the array for exact tracking.
+ * Evaluate the adjacency bounds in 64 bits: the largest valid lblk is
+ * EXT_MAX_BLOCKS - 1, so e0 + 1 and end + 1 can reach EXT_MAX_BLOCKS and
+ * must not wrap the ext4_lblk_t (u32) type.
+ */
+ if (start > (u64)e0 + 1 || (u64)end + 1 < s0) {
+ struct ext4_fc_lblk_range *heap;
+
+ /*
+ * GFP_ATOMIC: we hold i_fc_lock. __GFP_NOWARN: failure is not
+ * fatal -- we fall back to the single coalesced range below --
+ * so it must not splat under memory pressure.
+ */
+ heap = kmalloc_array(EXT4_FC_MAX_RANGES + 1, sizeof(*heap),
+ GFP_ATOMIC | __GFP_NOWARN);
+ if (heap) {
+ heap[0] = ei->i_fc_range;
+ ei->i_fc_ranges = heap;
+ ext4_fc_range_add(ei, start, end);
+ return 0;
+ }
+ /*
+ * Out of memory: fall back to the original single coalesced
+ * range by absorbing the gap below. This over-logs the spanned
+ * extents but stays a valid fast commit (no full-commit
+ * fallback), so there is nothing to mark ineligible.
+ */
+ }
+
+ /* Overlapping/adjacent, or array allocation failed: coalesce inline. */
+ if (start < s0)
+ s0 = start;
+ if (end > e0)
+ e0 = end;
+ ei->i_fc_range.start = s0;
+ ei->i_fc_range.len = e0 - s0 + 1;
return 0;
}
@@ -1185,7 +1242,11 @@ static int ext4_fc_snapshot_inode_data(struct inode *inode,
*nr_rangesp = 0;
return 0;
}
- memcpy(tracked, ei->i_fc_ranges, nr_tracked * sizeof(tracked[0]));
+ if (ei->i_fc_ranges)
+ memcpy(tracked, ei->i_fc_ranges,
+ nr_tracked * sizeof(tracked[0]));
+ else
+ tracked[0] = ei->i_fc_range; /* inline single-range mode */
ei->i_fc_nr_ranges = 0;
spin_unlock(&ei->i_fc_lock);
diff --git a/fs/ext4/super.c b/fs/ext4/super.c
index f2c52cc74676..cfa7e6bec385 100644
--- a/fs/ext4/super.c
+++ b/fs/ext4/super.c
@@ -1455,6 +1455,7 @@ static void ext4_free_in_core_inode(struct inode *inode)
pr_warn("%s: inode %llu still in fc list",
__func__, inode->i_ino);
}
+ kfree(EXT4_I(inode)->i_fc_ranges);
kmem_cache_free(ext4_inode_cachep, EXT4_I(inode));
}
--
2.43.0
^ permalink raw reply [flat|nested] 5+ messages in thread
* Re: [RFC PATCH v3 0/2] ext4: speed up fast commit on random writes
2026-07-22 0:59 ` [RFC PATCH v3 0/2] ext4: speed up fast commit on random writes Daejun Park
[not found] ` <CGME20260722005922epcms2p566d8027c8d1dbb8d9c46e3d3b35b6fd0@epcms2p2>
[not found] ` <CGME20260722005922epcms2p566d8027c8d1dbb8d9c46e3d3b35b6fd0@epcms2p7>
@ 2026-07-22 7:27 ` Andreas Dilger
[not found] ` <CGME20260722005922epcms2p566d8027c8d1dbb8d9c46e3d3b35b6fd0@epcms2p1>
3 siblings, 0 replies; 5+ messages in thread
From: Andreas Dilger @ 2026-07-22 7:27 UTC (permalink / raw)
To: daejun7.park
Cc: tytso, linux-ext4, linux-kernel, me, libaokun, harshadshirwadkar,
yi.zhang, ojaswin
On Jul 21, 2026, at 18:59, Daejun Park <daejun7.park@samsung.com> wrote:
>
> 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?
Have you looked at implementing a more dynamic structure like another
extent status tree, rbtree, xarray or or similar instead of having a
fixed-size array? At *some* point the number of random blocks in the
fast commit will be so large that there is no value to tracking them
separately anymore, but the fixed 16-range limit is clearly too small
based on your testing. It would be useful to do some testing of how
many separate ranges gets worse than the fallback.
Getting this tuning right is always difficult, and will depend on the
application workload, IO size, the storage bandwidth/latency, etc.
Having a dynamic limit is much better than a compile-time constant.
A runtime tunable is the minimum, possibly storing one range directly
in the inode, and using that as a union with the pointer to an allocated
array of ranges. Something like the following that allows a range, with
an inode flag whether the union holds a single range or allocated array:
union {
struct ext4_fc_lblk_range i_fc_range;
struct {
unsigned int r_count;
struct ext4_fc_lblk_range *r_ranges;
} i_fc_range_array;
};
Better would be a union with the root of a dynamic range tree. It is
better to re-use some existing kernel functionality for this instead
of creating yet another tree implementation.
Cheers, Andreas
> 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
Cheers, Andreas
^ permalink raw reply [flat|nested] 5+ messages in thread
* RE:(2) [RFC PATCH v3 0/2] ext4: speed up fast commit on random writes
[not found] ` <CGME20260722005922epcms2p566d8027c8d1dbb8d9c46e3d3b35b6fd0@epcms2p1>
@ 2026-07-23 8:25 ` Daejun Park
0 siblings, 0 replies; 5+ messages in thread
From: Daejun Park @ 2026-07-23 8:25 UTC (permalink / raw)
To: Andreas Dilger
Cc: tytso, linux-ext4, linux-kernel, me, libaokun, harshadshirwadkar,
yi.zhang, ojaswin, Daejun Park
On Jul 22, 2026, at 01:27, Andreas Dilger <adilger@dilger.ca> wrote:
> Have you looked at implementing a more dynamic structure like another
> extent status tree, rbtree, xarray or or similar instead of having a
> fixed-size array? [...] It would be useful to do some testing of how
> many separate ranges gets worse than the fallback.
Thanks -- I implemented the dynamic structure; it replaces the array and the
tunable entirely. v4 tracks the modified ranges in a per-inode rbtree, which
answered both of your questions.
It can't be the shared extent status tree: I probed whether the shrinker
reclaims a mapped es entry for a modified-but-not-yet-committed range, and it
does (448 in one short run under mild pressure). That "modified" mark is not
re-derivable, so marking the shared tree would silently lose modifications -- the
tracker has to own its structure.
So it is a private rbtree, reusing ext4's own machinery (generic rb_*, as the
extent status tree already does for per-inode ranges) -- idiomatic, no new
dependency. The node is a dedicated 32-byte struct with its own slab cache, not
extent_status: extent_status carries an 8-byte es_pblk that fast commit never
uses, and its cache is SLAB_RECLAIM_ACCOUNT, which would misaccount these
non-reclaimable nodes. The rb_root is inline in the inode, so only per-range
nodes are allocated. (I also built and compared a maple-tree version:
behaviour-identical, within ~15% on memory, denser only at the range counts this
series already caps -- so the rbtree, with no dependency, is the choice.)
On "how many is too many": the cap is EXT4_FC_SNAPSHOT_MAX_RANGES (2048), the
limit the snapshot already enforces on emitted ranges -- reused directly, not a
new constant. Each disjoint tracked range snapshots to at least one ADD_RANGE,
so a tracked set larger than that can never fit under the snapshot cap: the fast
commit would overrun it and fall back regardless. That count is exactly where
tracking stops paying off, so at the cap the transaction falls back to a full
commit. (Verified: R=4096 disjoint writes/fsync falls back there; R=1024 tracks
precisely.)
Thanks,
Daejun
^ permalink raw reply [flat|nested] 5+ messages in thread
end of thread, other threads:[~2026-07-23 8:25 UTC | newest]
Thread overview: 5+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
[not found] <CGME20260722005922epcms2p566d8027c8d1dbb8d9c46e3d3b35b6fd0@epcms2p5>
2026-07-22 0:59 ` [RFC PATCH v3 0/2] ext4: speed up fast commit on random writes Daejun Park
[not found] ` <CGME20260722005922epcms2p566d8027c8d1dbb8d9c46e3d3b35b6fd0@epcms2p2>
2026-07-22 1:00 ` [RFC PATCH v3 1/2] ext4: fast commit: track disjoint modified ranges per inode Daejun Park
[not found] ` <CGME20260722005922epcms2p566d8027c8d1dbb8d9c46e3d3b35b6fd0@epcms2p7>
2026-07-22 1:01 ` [RFC PATCH v3 2/2] ext4: fast commit: allocate the range array lazily Daejun Park
2026-07-22 7:27 ` [RFC PATCH v3 0/2] ext4: speed up fast commit on random writes Andreas Dilger
[not found] ` <CGME20260722005922epcms2p566d8027c8d1dbb8d9c46e3d3b35b6fd0@epcms2p1>
2026-07-23 8:25 ` Daejun Park
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
all inboxes | Powered by JetHome®