Skip to content

Commit bb8c2ce

Browse files
docs(kernel): FUSE STATX_BLOCKS flush-invalidation patch written and VM-validated — GETATTR storm 1095 -> 70, storm cycle 2.2x faster, du/mmap correctness intact
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
1 parent e493b9f commit bb8c2ce

3 files changed

Lines changed: 163 additions & 0 deletions
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
From 85a047f20045580d3cfafaa7418f16a5a120cfa2 Mon Sep 17 00:00:00 2001
2+
From: ain3sh <ainesh.chatterjee@gmail.com>
3+
Date: Thu, 2 Jul 2026 20:29:01 -0700
4+
Subject: [PATCH] fuse: only invalidate STATX_BLOCKS on flush if pages were
5+
dirtied
6+
7+
Since commit cf576c58b3a2 ("fuse: invalidate inode attr in writeback
8+
cache mode") fuse_flush() invalidates cached attributes so that
9+
st_blocks does not stay stale after buffered writes, since i_blocks is
10+
not maintained under writeback cache. Commit fa5eee57e33e ("fuse:
11+
selective attribute invalidation") narrowed this to STATX_BLOCKS.
12+
13+
The invalidation is unconditional, however: every close(2) throws away
14+
cached STATX_BLOCKS even when the inode was only ever read. Because
15+
plain stat(2) requests the basic mask (which includes STATX_BLOCKS),
16+
any stat-after-close then forces a synchronous FUSE_GETATTR round trip
17+
that the attribute timeout was supposed to elide. Read-mostly
18+
workloads with open/read/close/stat patterns (build systems, git
19+
status style scanners) pay one GETATTR per file per cycle regardless
20+
of attr_timeout.
21+
22+
i_blocks can only go stale through the page cache: every other write
23+
path (direct I/O, writethrough, copy_file_range, fallocate) already
24+
invalidates STATX_BLOCKS at write time via fuse_write_update_attr()
25+
(FUSE_STATX_MODSIZE includes STATX_BLOCKS). So track page-cache
26+
dirtying with a per-inode state bit, set in the buffered writeback
27+
write path and in fuse_page_mkwrite(), and only invalidate at flush
28+
time if the bit is set. The bit is tested-and-cleared, so a flush
29+
that writes back another fd's dirty pages still invalidates (the bit
30+
is per inode, not per file), and the motivating case of commit
31+
cf576c58b3a2 (du reading 0 blocks after a buffered write) is
32+
unaffected.
33+
34+
On a FUSE filesystem with writeback cache and attr_timeout, a
35+
stat+open/read/close loop over 32 files x 32 iterations improves from
36+
18.7us to 8.5us per cycle, with GETATTR requests dropping from 1095
37+
to 70. st_blocks remains correct after buffered and mmap writes.
38+
---
39+
fs/fuse/file.c | 12 ++++++++++--
40+
fs/fuse/fuse_i.h | 5 +++++
41+
2 files changed, 15 insertions(+), 2 deletions(-)
42+
43+
diff --git a/fs/fuse/file.c b/fs/fuse/file.c
44+
index e052a0d44..086831ac5 100644
45+
--- a/fs/fuse/file.c
46+
+++ b/fs/fuse/file.c
47+
@@ -510,9 +510,13 @@ static int fuse_flush(struct file *file, fl_owner_t id)
48+
inval_attr_out:
49+
/*
50+
* In memory i_blocks is not maintained by fuse, if writeback cache is
51+
- * enabled, i_blocks from cached attr may not be accurate.
52+
+ * enabled, i_blocks from cached attr may not be accurate. Only
53+
+ * invalidate if pages were dirtied through the page cache since the
54+
+ * last flush-time invalidation, so that read-only traffic does not
55+
+ * throw away the cached attributes on every close(2).
56+
*/
57+
- if (!err && fm->fc->writeback_cache)
58+
+ if (!err && fm->fc->writeback_cache &&
59+
+ test_and_clear_bit(FUSE_I_BLOCKS_DIRTY, &get_fuse_inode(inode)->state))
60+
fuse_invalidate_attr_mask(inode, STATX_BLOCKS);
61+
return err;
62+
}
63+
@@ -1550,6 +1554,9 @@ static ssize_t fuse_cache_write_iter(struct kiocb *iocb, struct iov_iter *from)
64+
&fuse_iomap_ops,
65+
&fuse_iomap_write_ops,
66+
file);
67+
+ if (written > 0)
68+
+ set_bit(FUSE_I_BLOCKS_DIRTY,
69+
+ &get_fuse_inode(inode)->state);
70+
} else {
71+
written = fuse_perform_write(iocb, from);
72+
}
73+
@@ -2392,6 +2399,7 @@ static vm_fault_t fuse_page_mkwrite(struct vm_fault *vmf)
74+
}
75+
76+
folio_wait_writeback(folio);
77+
+ set_bit(FUSE_I_BLOCKS_DIRTY, &get_fuse_inode(inode)->state);
78+
return VM_FAULT_LOCKED;
79+
}
80+
81+
diff --git a/fs/fuse/fuse_i.h b/fs/fuse/fuse_i.h
82+
index 85f738c53..fd763c749 100644
83+
--- a/fs/fuse/fuse_i.h
84+
+++ b/fs/fuse/fuse_i.h
85+
@@ -257,6 +257,11 @@ enum {
86+
* or the fuse server has an exclusive "lease" on distributed fs
87+
*/
88+
FUSE_I_EXCLUSIVE,
89+
+ /*
90+
+ * Pages were dirtied through the page cache since the last flush-time
91+
+ * STATX_BLOCKS invalidation (writeback cache mode)
92+
+ */
93+
+ FUSE_I_BLOCKS_DIRTY,
94+
};
95+
96+
struct fuse_conn;
97+
--
98+
2.55.0
99+

.agents/kernel/readpath-micro.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import os
2+
import time
3+
4+
files = [f"r{i:02}.txt" for i in range(32)]
5+
for f in files:
6+
with open(f, "wb") as h:
7+
h.write(b"x" * 4096)
8+
for f in files:
9+
fd = os.open(f, os.O_RDONLY)
10+
os.fsync(fd)
11+
os.close(fd)
12+
13+
# settle: one full pass so write-time invalidations are behind us
14+
for f in files:
15+
os.stat(f)
16+
fd = os.open(f, os.O_RDONLY)
17+
os.read(fd, 4096)
18+
os.close(fd)
19+
20+
# storm shape: stat + open/read/close per file; on unpatched kernels every
21+
# close invalidates STATX_BLOCKS so every stat forces a sync GETATTR
22+
t0 = time.perf_counter()
23+
for _ in range(32):
24+
for f in files:
25+
os.stat(f)
26+
fd = os.open(f, os.O_RDONLY)
27+
os.read(fd, 4096)
28+
os.close(fd)
29+
t1 = time.perf_counter()
30+
print(f"storm: {(t1 - t0) / (32 * 32) * 1e6:.2f}us/cycle")
31+
32+
# correctness (the cf576c58b3a2 du case): st_blocks must be fresh after a
33+
# buffered write + close, i.e. the patch must still invalidate for writers
34+
g = "grow.bin"
35+
with open(g, "wb") as h:
36+
h.write(b"")
37+
os.stat(g)
38+
with open(g, "ab") as h:
39+
h.write(b"z" * (1024 * 1024))
40+
st = os.stat(g)
41+
print(f"blocks-after-1MB-write: {st.st_blocks}")
42+
assert st.st_blocks >= 2040, f"stale st_blocks {st.st_blocks}: du regression!"
43+
44+
# mmap variant of the same correctness check (page_mkwrite path)
45+
import mmap
46+
m = "mmapped.bin"
47+
with open(m, "wb") as h:
48+
h.write(b"\0" * 8192)
49+
os.stat(m)
50+
fd = os.open(m, os.O_RDWR)
51+
mm = mmap.mmap(fd, 8192)
52+
mm[0:8192] = b"y" * 8192
53+
mm.flush()
54+
mm.close()
55+
os.close(fd)
56+
st = os.stat(m)
57+
print(f"blocks-after-mmap-write: {st.st_blocks}")
58+
assert st.st_blocks >= 16, f"stale st_blocks {st.st_blocks} after mmap write"
59+
print("CORRECTNESS OK")

.agents/specs/2026-06-12-enosys-open-eliminate-open-release-round-trips-via-kernel-no_open.notes.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,8 @@ User comment: none
5555
**Type**: milestone
5656
**Context**: SDK gained `ImportSession` (`begin_import` / `import_chunk` / `finish`): one pooled connection plus the directory-path->ino map persist across chunk calls, so imports can be fed incrementally; `import_entries` is now the buffered one-shot wrapper over it. `agentfs clone` now imports all directories in one up-front chunk, then a producer thread parses `git cat-file --batch` output blob-by-blob and sends 4MB/512-entry chunks down a bounded channel while the async consumer imports them — the 124ms cat-file stage now hides entirely under import (stage timings: stream-import 369ms ~= old import alone). Codex n=5: median 0.754s vs native 0.340s = 2.22x (was 0.911s / 2.58x). Correctness: byte-identical `diff -r` vs native clone, clean `git status`, `agentfs integrity` all-ok (cross-chunk parent nlink bumps included), SDK 168 tests x3 parallel + CLI 109 green, noopen-coherence 6/6 both modes. Also fixed a pre-existing test flake exposed by the refactor's timing shift: `overlay_reads_flag_off_falls_back_to_drain_on_write` asserted equality on the process-global batcher enqueue counter, which races under parallel tests; it now asserts the per-inode `has_pending` state immediately after pwrite (a strictly tighter check).
5757
**Resolution**: ~2.2x is the streaming landing, consistent with the floor analysis: remaining budget is git-clone-no-checkout ~300ms + import ~355ms (SQLite ingest of 43MB at ~120MB/s) + mount ~90ms; 1.5x (0.53s) stays blocked by the whole-state double content write. Clone work concludes here.
58+
59+
## 2026-07-03T14:55-07:00 — Upstream kernel patch written and validated in-VM: GETATTR storm 1095 -> 70
60+
**Type**: milestone
61+
**Context**: The read-path floor (kernel close-time STATX_BLOCKS invalidation) is now addressed with an actual mainline patch, validated end-to-end. History: cf576c58b3a2 (v5.8) added full-attr invalidation at fuse_flush because du read st_blocks=0 after buffered writes; fa5eee57e33e (v5.16) narrowed it to STATX_BLOCKS; it remained unconditional — every close(2) drops cached blocks even for read-only fds (and even with no_flush latched, via the inval_attr_out label). Key insight: i_blocks can only go stale through the page cache — all other write paths (direct I/O, writethrough, copy_file_range, fallocate) already invalidate at write time via fuse_write_update_attr (FUSE_STATX_MODSIZE includes STATX_BLOCKS). Patch (17 lines): new FUSE_I_BLOCKS_DIRTY inode state bit, set in fuse_cache_write_iter's iomap writeback branch and fuse_page_mkwrite, test_and_clear-gated invalidation in fuse_flush. Per-inode bit means a reader's close that writes back another fd's dirty pages still invalidates correctly. Validation: virtme-ng, mainline 7.2.0-rc1, same tree +- patch, agentfs storm micro in guest: 18.72 -> 8.51us/cycle (2.2x), GETATTR 1095 -> 70 (15.6x); st_blocks correct after 1MB buffered write (2048) and 8KB mmap write (16) — the du regression case and mkwrite path both verified. checkpatch clean except Signed-off-by (user's DCO) and shallow-clone commit-id false positives.
62+
**Resolution**: Patch + micro archived at .agents/kernel/. Kernel branch fuse-blocks-dirty at ~/src/linux (commit 85a047f20045). Remaining before submission: user's Signed-off-by, get_maintainer.pl routing (Miklos Szeredi, linux-fsdevel@vger.kernel.org, fuse-devel), send via git send-email or b4. If accepted, the agentfs read-path warm floor drops from ~2.1-2.4x toward the persistent-fd profile (stat-only was 1.3us/cycle) and the edit phase loses its 2 stat GETATTRs per edit.

0 commit comments

Comments
 (0)