[RFC PATCH v4 1/3] ext4: fast commit: track disjoint modified ranges in a private rbtree

Daejun Park <[email protected]> Thu, 30 Jul 2026 09:38:10 +0900
Newsgroups org.kernel.vger.linux-ext4,org.kernel.vger.linux-kernel
Message-ID <20260730003810epcms2p6cd37c6242602c1467b7665cdcc785ea8@epcms2p6>
Fast commit 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 -- an ADD_RANGE per mapped segment and a DEL_RANGE
per hole.  For scattered writes that is hundreds to thousands of ranges
for a handful of modified regions, which overruns
EXT4_FC_SNAPSHOT_MAX_RANGES and forces a full commit.

Track the actually-modified disjoint ranges instead, in a per-inode
rbtree of struct ext4_fc_rnode nodes keyed by logical block.  Overlapping
and adjacent ranges merge on insert, so the set stays disjoint with no
explicit merge step and no fixed bound; density is bounded only by the
snapshot cap.  The snapshot walks only the tracked ranges.  The on-disk
TLV format is unchanged.

The rbtree is reused (generic rb_* plus rbtree_postorder walk) rather than
open-coding a range container, per review (Andreas Dilger).  It is private
to fast commit, not the shared extent status tree: the es-tree is a
reclaimable cache whose shrinker will evict a mapped entry for a
modified-but-not-yet-committed range, which would lose the modification.
The node is a dedicated 32-byte struct with its own slab cache, rather than
the shared extent_status: extent_status carries an 8-byte es_pblk that fast
commit does not use, and its cache is SLAB_RECLAIM_ACCOUNT, which would
misaccount these non-reclaimable nodes as reclaimable.

rb_root is one pointer embedded in the inode, so the tree needs no root
allocation; per-range nodes are allocated as ranges are tracked and freed
at commit.  On a GFP_ATOMIC node allocation failure under i_fc_lock the
transaction falls back to a full commit, so no modification is lost.

Signed-off-by: Daejun Park <[email protected]>
---
 fs/ext4/ext4.h        |  35 ++++--
 fs/ext4/fast_commit.c | 280 +++++++++++++++++++++++++++++++++++-------
 fs/ext4/super.c       |   2 +
 3 files changed, 269 insertions(+), 48 deletions(-)

diff --git a/fs/ext4/ext4.h b/fs/ext4/ext4.h
index 21a951f10636..7eedb609019a 100644
--- a/fs/ext4/ext4.h
+++ b/fs/ext4/ext4.h
@@ -1042,6 +1042,18 @@ enum ext4_fc_snap_err {
 	EXT4_FC_SNAP_ERR_INODE_LOC,
 };
 
+/*
+ * Node of the per-inode fast-commit range rbtree (see i_fc_rt).  A dedicated
+ * 32-byte node with its own slab cache -- deliberately not the shared
+ * extent_status, which carries an unused es_pblk and lives in a
+ * SLAB_RECLAIM_ACCOUNT cache these non-reclaimable FC nodes would misaccount.
+ */
+struct ext4_fc_rnode {
+	struct rb_node rb_node;
+	ext4_lblk_t start;
+	ext4_lblk_t len;
+};
+
 /*
  * fourth extended file system inode data in memory
  */
@@ -1097,11 +1109,19 @@ 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;
+	/*
+	 * Logical block ranges modified in this fast commit, tracked as a set of
+	 * disjoint ranges in a private rbtree of struct ext4_fc_rnode nodes
+	 * (keyed by logical block) instead of one coalesced [min,max] span.  This
+	 * avoids snapshotting the whole spanned extent map for scattered
+	 * allocations.  Overlapping/adjacent ranges merge on insert, so the set
+	 * stays disjoint with no fixed bound; density is bounded only by the
+	 * snapshot cap.  rb_root is one pointer, so the tree needs no root
+	 * allocation -- only per-range nodes, torn down at commit.  i_fc_nr_ranges
+	 * is the exact number of tracked ranges.  Protected by i_fc_lock.
+	 */
+	struct rb_root i_fc_rt;
+	unsigned int i_fc_nr_ranges;
 
 	/*
 	 * Commit-time fast commit snapshots.
@@ -1122,8 +1142,8 @@ 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
-	 * and inode's EXT4_FC_STATE_COMMITTING state bit.
+	 * Protect concurrent accesses on i_fc_rt, i_fc_nr_ranges and inode's
+	 * EXT4_FC_STATE_COMMITTING state bit.
 	 */
 	spinlock_t i_fc_lock;
 
@@ -3062,6 +3082,7 @@ void ext4_end_bitmap_read(struct bio *bio);
 int ext4_fc_info_show(struct seq_file *seq, void *v);
 void ext4_fc_init(struct super_block *sb, journal_t *journal);
 void ext4_fc_init_inode(struct inode *inode);
+void ext4_fc_free_range_tree(struct inode *inode);
 void ext4_fc_track_range(handle_t *handle, struct inode *inode, ext4_lblk_t start,
 			 ext4_lblk_t end);
 void __ext4_fc_track_unlink(handle_t *handle, struct inode *inode,
diff --git a/fs/ext4/fast_commit.c b/fs/ext4/fast_commit.c
index 062103e42cd8..4550c5dae39c 100644
--- a/fs/ext4/fast_commit.c
+++ b/fs/ext4/fast_commit.c
@@ -185,6 +185,7 @@
 #include <trace/events/ext4.h>
 static struct kmem_cache *ext4_fc_dentry_cachep;
 static struct kmem_cache *ext4_fc_range_cachep;
+static struct kmem_cache *ext4_fc_rnode_cachep;
 
 /*
  * Avoid spending unbounded time/memory snapshotting highly fragmented files
@@ -202,19 +203,54 @@ static inline void ext4_fc_set_snap_err(int *snap_err, int err)
 
 static void ext4_fc_free_inode_snap(struct inode *inode);
 
+/* Allocate/free a node of the private FC range rbtree (dedicated cache). */
+static struct ext4_fc_rnode *ext4_fc_rnode_alloc(void)
+{
+	return kmem_cache_alloc(ext4_fc_rnode_cachep, GFP_ATOMIC | __GFP_NOWARN);
+}
+
+static void ext4_fc_rnode_free(struct ext4_fc_rnode *node)
+{
+	kmem_cache_free(ext4_fc_rnode_cachep, node);
+}
+
+/*
+ * Free the inode's private range rbtree and reset it to empty.  The tree is
+ * per-commit state, so keeping it would pin memory on an inode that may never
+ * scatter again.  Caller holds ei->i_fc_lock (except from init, where the
+ * inode is not yet reachable, and from the inode teardown in super.c, where it
+ * is no longer reachable).
+ */
+void ext4_fc_free_range_tree(struct inode *inode)
+{
+	struct ext4_inode_info *ei = EXT4_I(inode);
+	struct ext4_fc_rnode *node, *tmp;
+
+	rbtree_postorder_for_each_entry_safe(node, tmp, &ei->i_fc_rt, rb_node)
+		ext4_fc_rnode_free(node);
+	ei->i_fc_rt = RB_ROOT;
+}
+
+/* Drop the tracked range set and reset to an empty tree. */
 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;
+	ext4_fc_free_range_tree(inode);
+	ei->i_fc_nr_ranges = 0;
 }
 
 void ext4_fc_init_inode(struct inode *inode)
 {
 	struct ext4_inode_info *ei = EXT4_I(inode);
 
-	ext4_fc_reset_inode(inode);
+	/*
+	 * This also runs from the slab constructor (init_once()), i.e. on
+	 * uninitialised memory, so it must not free anything: just empty the
+	 * tree root.  Use ext4_fc_reset_inode() for a live inode instead.
+	 */
+	ei->i_fc_rt = RB_ROOT;
+	ei->i_fc_nr_ranges = 0;
 	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);
@@ -566,7 +602,11 @@ static int __track_inode(handle_t *handle, struct inode *inode, void *arg,
 	if (update)
 		return -EEXIST;
 
-	EXT4_I(inode)->i_fc_lblk_len = 0;
+	/*
+	 * Drop the range set via the helper rather than just zeroing a count:
+	 * a live range tree has to be freed here or it leaks.
+	 */
+	ext4_fc_reset_inode(inode);
 
 	return 0;
 }
@@ -606,32 +646,129 @@ struct __track_range_args {
 	ext4_lblk_t start, end;
 };
 
+/* Link a fresh node for [start,end] into the FC range rbtree (no overlap). */
+static void ext4_fc_rt_link(struct rb_root *root, struct ext4_fc_rnode *new,
+			    ext4_lblk_t start, ext4_lblk_t end)
+{
+	struct rb_node **link = &root->rb_node, *parent = NULL;
+	struct ext4_fc_rnode *node;
+
+	while (*link) {
+		parent = *link;
+		node = rb_entry(parent, struct ext4_fc_rnode, rb_node);
+		if (start < node->start)
+			link = &parent->rb_left;
+		else
+			link = &parent->rb_right;
+	}
+	new->start = start;
+	new->len = end - start + 1;
+	rb_link_node(&new->rb_node, parent, link);
+	rb_insert_color(&new->rb_node, root);
+}
+
+/*
+ * Record that logical block range [start, end] was modified, in the inode's
+ * private range rbtree.  Absorbs every existing node overlapping or adjacent to
+ * [start,end] into one merged node, so the set stays sorted and disjoint with
+ * no fixed bound.  Adjacency bounds are evaluated in 64 bits: the largest valid
+ * lblk is EXT_MAX_BLOCKS - 1, so "end + 1" and a node's "lblk + len" can reach
+ * EXT_MAX_BLOCKS and must not wrap ext4_lblk_t (u32).  Caller holds i_fc_lock.
+ *
+ * On -ENOMEM (GFP_ATOMIC under i_fc_lock) the new range cannot be recorded, so
+ * the transaction falls back to a full commit, which logs everything -- no
+ * modification is lost.  Rare under memory pressure.
+ */
+static void ext4_fc_range_add(handle_t *handle, struct inode *inode,
+			      ext4_lblk_t start, ext4_lblk_t end)
+{
+	struct ext4_inode_info *ei = EXT4_I(inode);
+	struct rb_root *root = &ei->i_fc_rt;
+	struct ext4_fc_rnode *es, *new;
+	struct rb_node *node;
+	unsigned int merged = 0;
+
+	/* Allocate first so the merge/erase below cannot fail partway. */
+	new = ext4_fc_rnode_alloc();
+	if (!new) {
+		/*
+		 * Could not record the range under memory pressure.  A fast
+		 * commit must log every modified range or replay would restore
+		 * an incomplete inode, so fall back to a full commit rather than
+		 * silently drop it.
+		 *
+		 * ext4_fc_mark_ineligible() takes the s_fc_lock mutex, which must
+		 * not be acquired under the i_fc_lock spinlock the caller holds
+		 * (lock order is s_fc_lock before i_fc_lock); drop and retake
+		 * i_fc_lock around it, as __track_dentry_update() does.
+		 */
+		spin_unlock(&ei->i_fc_lock);
+		ext4_fc_mark_ineligible(inode->i_sb, EXT4_FC_REASON_NOMEM, handle);
+		spin_lock(&ei->i_fc_lock);
+		return;
+	}
+
+	/*
+	 * Find the leftmost node that could overlap or adjoin [start,end]: the
+	 * first with "lblk + len >= start" (i.e. its end+1 >= start).
+	 */
+	node = root->rb_node;
+	es = NULL;
+	while (node) {
+		struct ext4_fc_rnode *cur =
+			rb_entry(node, struct ext4_fc_rnode, rb_node);
+
+		if ((u64)cur->start + cur->len < start) {
+			node = node->rb_right;
+		} else {
+			es = cur;
+			node = node->rb_left;
+		}
+	}
+
+	/* Absorb es and its successors while they stay within [.. end+1]. */
+	node = es ? &es->rb_node : NULL;
+	while (node) {
+		es = rb_entry(node, struct ext4_fc_rnode, rb_node);
+		if ((u64)es->start > (u64)end + 1)
+			break;
+		if (es->start < start)
+			start = es->start;
+		if ((u64)es->start + es->len - 1 > end)
+			end = es->start + es->len - 1;
+		node = rb_next(node);
+		rb_erase(&es->rb_node, root);
+		ext4_fc_rnode_free(es);
+		merged++;
+	}
+
+	ext4_fc_rt_link(root, new, start, end);
+	ei->i_fc_nr_ranges += 1 - merged;
+}
+
 /* __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;
+	ext4_lblk_t start = __arg->start, end = __arg->end;
 
 	if (inode->i_ino < EXT4_FIRST_INO(inode->i_sb)) {
 		ext4_debug("Special inode %llu being modified\n", inode->i_ino);
 		return -ECANCELED;
 	}
 
-	oldstart = ei->i_fc_lblk_start;
-
-	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;
-	}
+	/*
+	 * 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, so we need not do it here.)
+	 */
+	if (end < start)
+		return 0;
 
+	ext4_fc_range_add(handle, inode, start, end);
 	return 0;
 }
 
@@ -977,31 +1114,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);
@@ -1033,7 +1165,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);
@@ -1046,7 +1178,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;
@@ -1084,6 +1216,58 @@ 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 rb_root tree = RB_ROOT;
+	struct ext4_fc_rnode *es, *tmp;
+	struct rb_node *node;
+	unsigned int nr_ranges = 0;
+	int ret = 0;
+
+	/*
+	 * Take the whole range tree away from the inode; it is now private, so
+	 * it can be walked lock-free and freed below.  The inode starts a fresh
+	 * (empty) set for the next commit.
+	 */
+	spin_lock(&ei->i_fc_lock);
+	if (ei->i_fc_nr_ranges == 0) {
+		spin_unlock(&ei->i_fc_lock);
+		if (nr_rangesp)
+			*nr_rangesp = 0;
+		return 0;
+	}
+	tree = ei->i_fc_rt;		/* adopt the whole tree */
+	ei->i_fc_rt = RB_ROOT;
+	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 (node = rb_first(&tree); node; node = rb_next(node)) {
+		es = rb_entry(node, struct ext4_fc_rnode, rb_node);
+		ret = ext4_fc_snapshot_lblk_range(inode, es->start,
+						  es->start + es->len - 1,
+						  ranges, nr_ranges_total,
+						  &nr_ranges, snap_err);
+		if (ret)
+			break;
+	}
+	rbtree_postorder_for_each_entry_safe(es, tmp, &tree, rb_node)
+		ext4_fc_rnode_free(es);
+	if (ret)
+		return ret;
+
 	if (nr_rangesp)
 		*nr_rangesp = nr_ranges;
 	return 0;
@@ -2751,16 +2935,30 @@ int __init ext4_fc_init_dentry_cache(void)
 		return -ENOMEM;
 
 	ext4_fc_range_cachep = KMEM_CACHE(ext4_fc_range, SLAB_RECLAIM_ACCOUNT);
-	if (!ext4_fc_range_cachep) {
-		kmem_cache_destroy(ext4_fc_dentry_cachep);
-		return -ENOMEM;
-	}
+	if (!ext4_fc_range_cachep)
+		goto err_range;
+
+	/*
+	 * FC range-tree nodes are pinned until the commit snapshots them, so
+	 * this cache is deliberately not SLAB_RECLAIM_ACCOUNT (unlike the shared
+	 * extent_status cache) -- they must not be counted as reclaimable.
+	 */
+	ext4_fc_rnode_cachep = KMEM_CACHE(ext4_fc_rnode, 0);
+	if (!ext4_fc_rnode_cachep)
+		goto err_rnode;
 
 	return 0;
+
+err_rnode:
+	kmem_cache_destroy(ext4_fc_range_cachep);
+err_range:
+	kmem_cache_destroy(ext4_fc_dentry_cachep);
+	return -ENOMEM;
 }
 
 void ext4_fc_destroy_dentry_cache(void)
 {
+	kmem_cache_destroy(ext4_fc_rnode_cachep);
 	kmem_cache_destroy(ext4_fc_range_cachep);
 	kmem_cache_destroy(ext4_fc_dentry_cachep);
 }
diff --git a/fs/ext4/super.c b/fs/ext4/super.c
index 6c18b5adffca..99eca2b123b1 100644
--- a/fs/ext4/super.c
+++ b/fs/ext4/super.c
@@ -1464,6 +1464,8 @@ static void ext4_free_in_core_inode(struct inode *inode)
 		pr_warn("%s: inode %llu still in fc list",
 			__func__, inode->i_ino);
 	}
+	/* Tear down the fast-commit range tree if one is still live. */
+	ext4_fc_free_range_tree(inode);
 	kmem_cache_free(ext4_inode_cachep, EXT4_I(inode));
 }
 
-- 
2.43.0