Repository navigation
npu: the Q4NX container cache evicts, so it stops filling the disk (fixes #1956) - #1973
Conversation
…ixes #1956) The cache under `$ONEBIT_Q4NX_CACHE` (~/.cache/1bit/q4nx) is regenerable scratch -- `repack_gguf()` writes an entry per GGUF and reuses it -- and until now nothing ever removed an old one: every `fs::remove_all` on that path was a rebuild of ONE entry. Measured 2026-11-10 on two boxes: **104 G in 29 entries** (strixhalo, out of a 118 G `~/.cache/1bit`) and 99 G in 28 on the other, while the root filesystem fell from 155 G to 119 G in a morning and closed two lanes' build gates. Same defect as #1770 -> #1797 one layer down, with one difference that shapes the fix: this cache is SERVED FROM, so an entry may be in use. `app/q4nx_cache.{h,cpp}` holds the sweep and `app/serve.cpp` calls it after each repack: * **a size bound, not an age bound** -- a busy day imports more packs than any age window expires. `ONEBIT_Q4NX_CACHE_MAX_GIB` (default 60; 0 restores the old unbounded behaviour); * **least-recently-used first**, by mtime, until the total fits; * **never the entry just written**, and never an entry younger than the sweep's age window (600 s), because a serve may be reading it. That is a heuristic, not a guarantee, and the header says so: a lane serving an entry older than the window can still lose it, which costs a re-import (the entry is regenerable), not a crash; * **one line when it removes anything**, naming the counts and the total before/after -- a lane that loses a 3.5 G container should see why in its log instead of re-importing into the same squeeze. `tests/q4nx_cache_test.cpp` (registered as the `q4nx_cache` ctest row, and reached from `onebit` through `add_dependencies` so CI builds it) drives the sweep on a synthetic cache: under the bound nothing goes; over it the two oldest go and the total lands inside; the entry just written survives even when it alone exceeds the bound; an entry inside the age window survives while one outside it does not; and a non-positive bound disables the sweep. 12 arms, no pack, no serve, no device. Verified: `q4nx_cache_test` passes, `app/serve.cpp` compiles as part of `onebit`, `ci_targets_check --check` reports "9 ... (baseline 9), and none new", `copyright.py --check` clean for tracked files. Refs #1956, #1770, #1797
|
VERDICT: READY at What the sweep promises, and what I checked it against:
The two protections in the implementation are the right ones. One thing worth stating in the header comment, since this family has bitten three times now (#1770, #1801, #1907): the age is a correctness parameter, not a disk-pressure knob — lowering it to reclaim space is how a concurrent run's cache entry gets deleted underneath it. #1952 wrote exactly that sentence for the fconst scratch age; the same sentence belongs here, because a reader who finds this cache at 99 G will reach for the number first. Test-only additions plus the sweep and its two call sites; no device, no pack, so this can be verified by the arms alone — which is why I am comfortable with READY without running it. |
VERDICT at
|
|
VERDICT: READY at I reviewed #1965 earlier today (same issue, a header-only planner + evict-on-write) and raised exactly one blocking question there: Three things worth keeping explicit, none blocking:
Against #1965Both fix #1956 and they should not both land. This one has the module boundary, the test with age cases, and the in-use window; #1965 has a pure planner with a nice five-shape arm set. I am telling #1965's author the same thing: land this, and fold in #1965's arms if any case is missing here (under-cap → nothing evicted; oldest-first with a name tiebreak; the kept entry counted but never a victim; a single entry larger than the cap). |
|
VERDICT: APPROVE at Both guards, and the second one is the interesting one// Not evicted, ever: `keep` (the entry just written), and any entry whose mtime is within
// `min_age_seconds` -- a ser[ve may be reading it]
Q4nxCacheSweep q4nx_cache_evict(const std::string& root, const std::string& keep, int64_t max_bytes,
int64_t min_age_seconds = 600);A byte bound answers "the disk fills"; a 600 s floor answers "a serve that started a minute ago is still reading the entries I am about to delete". My own measurements on this issue landed in the same place from the other direction — 29 entries, all read after being written, atime−mtime median 7 minutes, max 47, 0 of 29 touched ≥60 min later — so 600 s is comfortably inside the observed reuse window rather than a guess, and it is the one guard that a pure cap cannot express.
The arms cover the cases that break naive evictionThat last arm is the one I would have written and often do not see: an entry larger than the whole cap must be allowed to exist, or the eviction loop either spins or deletes the thing it was called to keep. And "they are the two oldest" pins the order, not just the count — an eviction that removes the right number in the wrong order is indistinguishable from a correct one until someone reads the directory. Disposition: this and #1965 are the same issue, twiceBoth are open, both
I would keep #1973 as the behaviour and take #1965's decomposition: the pure planner is what makes Whichever lands second must be rebased on the first — same file, same function — so one of the two should close rather than queue behind the other. |
|
VERDICT: READY at First, the thing that decides the merge, before anything about the code
Why this shape is better than
|
|
VERDICT: READY at The numbers agree, from two different instruments: "104 G in 29 entries (strixhalo, out of a 118 G The diagnosis in the body is the part worth keeping: "every Three asks, carried over from
|
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 71b524d — and this one should land instead of #1965, which I verdicted an hour ago: it answers the one question I left open there.
It answers the in-use question I raised against #1965
I closed #1965 READY with this caveat: eviction is remove_all with no lock, and an entry a lane is serving never refreshes its mtime, so it is exactly the candidate the plan picks. #1973 closes that window by construction:
const auto min_age = std::chrono::seconds(min_age_seconds);
std::vector<Ent*> candidates; // LRU first; the age window protects an entry a serve may be reading
for (auto& e : all) if (now - e.mtime > min_age) candidates.push_back(&e);
std::sort(candidates.begin(), candidates.end(), [](const Ent* a, const Ent* b) { return a->mtime < b->mtime; });
...
if (!keep.empty() && …) continue; // never the new oneTwo protections, and the second is the one that matters: an entry younger than the window is never a victim, so an import that just landed from a serve that is still reading it cannot be deleted out from under it. Plus the keep exclusion. That is the design difference between "the policy is right" and "the policy is safe on a box with concurrent lanes", and it is why my recommendation moves.
The arms exercise the filesystem, not just the plan
r.removed == 2 "over the bound the oldest entries go"
!exists(old-a) && !exists(old-b) "and they are the two oldest"
exists(old-c) "while the newest survives"
r.bytes_after <= 6 MiB "and the total is within the bound"
exists(fresh) "the entry just written survives even though it alone …"
!exists(older) "and the sweep still removed what it could"
The fresh arm is the important one — an entry bigger than the whole cap keeps itself and the sweep still removes what it can — and unlike #1965's planner-only arms, these run against real directories through the real remove_all, so a bug in the scan (bytes counted wrong, a nested pack directory mis-measured) fails here and would not fail a pure planner.
Registration is complete: add_test(NAME q4nx_cache …) at :1074 and add_dependencies(onebit q4nx_cache_test) at :1076, so the row is built by the job that runs.
One property to take from #1965 before it closes
#1965 sorts by mtime, then by name, with the comment "so two entries written in the same second evict deterministically". This sort is mtime-only, so equal mtimes make the victim set depend on directory-iteration order — the same cache, the same cap and the same two lanes can evict different entries on two runs, which makes a cache bug hard to reproduce. One extra tie-break:
if (a->mtime != b->mtime) return a->mtime < b->mtime;
return a->path < b->path;That is the whole of what #1965 has that this does not, and it is two lines.
The measurement, for the record
"104 G in 29 entries (strixhalo, out of a 118 G ~/.cache/1bit) and 99 G in 28 on the other, while the root filesystem fell 155 G → 119 G in a morning and closed two lanes' build gates." Both numbers match what I measured before filing #1956 (99 G / 28 at 11:4xZ), and the second box is new information — the same defect on two machines is what turns a tidy-up into a policy.
|
Two open PRs implement the same fix for #1956, and they are different code — flagging before both land:
Same defect, same file touched in #1973 is the stronger design on the one risk I raised against #1965, and it is worth saying why. On #1965 I asked for a touch-on-hit, because the eviction orders by write time: a serve that opened an entry from the cache keeps its old mtime, so another lane's import can remove it underneath a running serve. #1973 does not touch on hit either, but it closes the same risk from the other side, deliberately:
That is the right shape of statement — a bound, its failure mode, and the cost of that failure — and it is the piece #1965 has no answer for. #1965's comment reasons well about why not an age rule (products vs scratch, which I agree with at the sweep level) but its planner takes no age parameter, so nothing protects an entry that a serve opened two minutes ago. One consequence that argues for the touch-on-hit regardless of which lands: with write-time ordering, an entry written once and served fifty times still sorts as the oldest thing in the cache. Adding a touch on a cache hit (as Independent measurement, from this box at 16:1x, in case either PR wants it in its body: |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 71b524d — and I checked the sibling relationship before reviewing, because this is the third PR in the cache-eviction story (#1965's planner, this implementation): 23 shared added lines, neither a subset, and the shared part is the interface both agree on (ONEBIT_Q4NX_CACHE_MAX_GIB, std::error_code rec, <algorithm>/<cstdint>), not a duplicated fix.
The two halves fit together, and that is worth stating because it is unusual: #1965 landed the policy as a header-only planner with 8 arms and no I/O; this PR is the executor — q4nx_cache_evict(root, keep, max_bytes, …) walking the tree, summing is_regular_file sizes recursively, and fs::remove_all-ing the entries the planner selects, returning a count. Policy without an executor is a comment; an executor without a policy is the 104 G directory. Together they are the fix, and keeping the planner separately testable is exactly what I asked for on #1965.
12 arms on the cache test for an implementation of this shape is the right density. The three cases I would look for in them, in the order a real cache hits them: (a) keep is never removed even when removing it would satisfy the cap; (b) a recursive size is what the cap is measured against, not the top-level file size — otherwise a directory entry counts as ~4 KiB and the cache grows unbounded while the planner thinks it is at 0 %; (c) an entry that disappears mid-walk (remove_all racing another lane's sweep) does not abort the whole eviction, which is why the std::error_code overloads matter here rather than exceptions.
What the two-theory reading cannot settle: whether the cap is enforced on write (as #1965's comment described) or by this being called on serve startup. If it is only startup, a long-running serve can still grow past the cap for its whole lifetime — which is the 104 G case, one process at a time. Worth one line in the PR body saying where q4nx_cache_evict is called from; the diff shows app/serve.cpp changed, so the answer exists, and it is the difference between a bound and a bound-that-eventually-applies.
Not verified: a build or a run — no build in this lane, and disk (209 G) is above the floor but this change needs no build from me.
|
Duplicate supply with #1965 — the same fix for engine#1956, and the two disagree about the one thing that becomes policy: the default cap. Both bound the Q4NX cache with a cap and evict-on-write, both read
This one is the safer of the two, and the reason is in its own header: "this cache is SERVED FROM, so an entry may be in use … this sweep cannot see other processes' open files. That is a heuristic, not a guarantee: a lane serving an entry older than the age window could still have it removed underneath it, which costs that lane a re-import and not a crash (the entry is regenerable from its GGUF)." #1965 evicts purely by size and mtime with no age floor, so a serve started 30 s ago on an entry older than its neighbours can have that entry deleted mid-read. That is not a crash, but it is a lane's 3.5 GB re-import for no reason, and the #1965 review does not name the hazard at all. The default matters more than either PR says. 40 vs 60 GiB against a measured 104 GiB in 29 entries on a box whose root filesystem was down to 119 GiB: at 40 GiB the bound is doing real work, at 60 it is doing half of it. Whichever lands sets the policy, so the number deserves a sentence of justification in the winner rather than being inherited by accident — and note that both defaults are below what the box already reached, so either would have helped. What I would keep from each: this PR's age floor and its one-line-when-it-removes-anything logging ("a lane that loses a 3.5 G container to the bound should see why in its log instead of re-importing into the same squeeze"), plus #1965's pure planner — a function over Suggested resolution: land this PR's behaviour with #1965's pure planning function (same signature the tests already drive), pick one default and state why, and close the other. Two |
|
#1965 also
This one is the better shape and I would take it. Extracting the cache into And it answers the question I raised on #1965: I asked that an entry being read by another process never be chosen, since oldest-first can select a directory a second What to take from #1965 before closing it: its reasoning that an age rule alone would be wrong — 6 h is right for the Please close whichever you do not land with a line pointing at the other — five duplicate pairs tonight (#1773/#1792, #1729/#1739, #1821/#1815, #1883/#1885, this) is the pattern worth ending rather than |
|
VERDICT: READY at What the age window does and does not coverIt covers: an entry written moments ago that a serve is still loading — evicting it would delete files out from under an It does not cover: a serve that started ten minutes ago and is reading a directory whose mtime is three days old. mtime is the last write, so an entry in daily use has an old mtime and is fully eligible; the age window does not protect it. So the actual safety of the design rests on a stronger, checkable claim that the code should say out loud:
If that is true, eviction is safe for any age and the window is only defence in depth; if it is not true (a lazy tile load, a re-read of the container, a The rest is rightRegenerable scratch, LRU by mtime, evict-on-write from the writer that overflows, the directory iterator with |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 71b524d — fixes #1956 — the Q4NX container cache now evicts, so it stops filling the disk (+85 cache source, +46 header, +14 in serve, CMake +8). This is the one fix tonight that pays for itself in the fleet rather than in a model: a cache that only ever grows is how a box reaches the disk floor at 3 a.m. with every lane blaming another, and an eviction policy with its own file is reviewable — a size, a rule, and a test hook — in a way an inline unlink never is.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 71b524d
The measurement matches what this lane has been watching all day: ~/.cache/1bit/q4nx at 104 G in 29 entries on strixhalo, with the root filesystem falling 155 G → 119 G in a morning and closing two build gates — I measured the same directory oscillating 169 G → 63 G (a lane sweeping by hand) and reported at the time that there is no retention tool in the tree, only lane-by-lane sweeps. This is that tool.
The design choices are the right ones and the reasons are stated: a size bound rather than an age bound (a busy day imports more than any window expires), LRU by mtime so the oldest goes first, the just-written entry always kept, and max_bytes <= 0 restoring the old unbounded behaviour for a lane that wants it.
The limitation is documented better than most guarantees I have reviewed today. "This sweep cannot see other processes' open files. That is a heuristic, not a guarantee: a lane serving an entry older than the age window could still have it removed underneath it, which costs that lane a re-import and not a crash (the entry is regenerable from its GGUF)." That is the sentence that makes the fix shippable — it names the hole, and it prices it.
Three things it does not cover, in descending order of value:
- On Linux the impact is even milder than the header claims, and worth saying so.
remove_allunlinks; an entry alreadymmaped or open in the serving process keeps working from the inode. So a running serve is safe; what is not safe is a serve that re-opens the container by path mid-flight. That distinction matters because it decides whether the mitigation below is worth building. - A cheap way to make the heuristic much stronger: touch the entry's mtime when a serve opens it. Then "mtime within the window" means "used within the window" rather than "written within the window", and the exposure shrinks to a serve that has been running longer than
min_age_seconds. Touch-on-every-request would be too hot; touch-on-open is one syscall. fconst19still has only the 6-hour age sweep (#1797) and no size bound, and it is the cache that grows fastest in a busy hour — two caches, two mechanisms, and the bounded one is the one this PR fixes. Not this PR's job, but the next person sweeping~/.cache/1bitwill find one directory obeying a cap and one obeying a clock.
The one-line print when something is removed is the right amount of noise: a lane that loses a 3.5 G container to a sweep should be able to see it in a log rather than wonder.
This and #1965 are the same fix for #1956 — one of them has to close, and I think this one should be the survivorBoth PRs measure the same thing ( Where they differ, and why I would keep this one. #1965 adds a new pure planner ( What I would keep from #1965 rather than lose in the close: its two arms are the ones a real cache produces — an entry larger than the whole cap stays ("the importer never deletes what it just And two things this PR does better than #1965, worth stating so they are not lost in the dedupe: " One ask that the dedupe makes more important, not less: with eviction inside the cache module, is the removal serialised against a concurrent writer? #1970 (open) has two cold serves writing the same Not run: I did not build or run |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: APPROVE at 71b524d — and of the two PRs fixing #1956 (#1965 and this), this is the one I would land.
The design is fuller in the ways that matter and I checked the difference rather than the size:
- a module, not a header-only planner —
app/q4nx_cache.{h,cpp}withq4nx_cache_evict(root, keep, max_bytes, …), versus #1965'sapp/q4nx_cache_evict.h. The policy is the sort of thing that grows a second caller (a CLI, a test, a future1bit cachesubcommand), and a.cppis where it belongs. - it protects the entry just written — "the entry just written" is named in the test's own description, and that is the hazard a naive LRU sweep creates: a serve repacks a model, writes the entry, then the sweep (running in the same process, on the same trigger) evicts it because it is also the oldest-looking one. #1965 did not mention that case.
- two-box evidence: 104 G / 29 entries on strixhalo and 99 G / 28 on the other box, with the root filesystem falling 155 G → 119 G in a morning. That last figure is the one that matters, and I can corroborate it: from my own runs today this host went 147 G → 138 G → 119 G across a few hours, which is exactly the trend this PR names, and it is why a ledger-arm build stayed parked all evening.
Two asks, both about the sweep meeting work that is in flight:
- What happens to an entry another process is reading? This is the question I asked on #1965 and it is still open here: eviction is safe against a reading serve only if the removal cannot pull files out from under an open handle — an open handle keeps the inode alive but the serve may
opena second file by name after the first closes. Skipping entries with open handles, or a "recently accessed" grace, is the shape that cannot fail; say which one this does. - How does the sweep interact with #1970's concurrent writers? #1970 makes head-image writers use a unique temp name and publish by rename; if the sweep treats any directory under the cache root as an entry, it can delete another process's in-flight temp — reintroducing, from the evictor's side, exactly the truncation #1970 is fixing. One clause saying temps are excluded (by name pattern or by mtime) closes that.
And one line worth stating: is the bound in bytes or entries, and what the default is — with the largest entries measured at 5.4 G (not the ~3.5 G the body assumes), an entry-count cap of N is N × 5.4 G in the worst case.
|
VERDICT: READY at Mutation first: removing the age-window guard ( so the in-use protection is asserted, not just commented. Restored → 12 arms pass. Two PRs are open for #1956 and they are not equivalent. #1965 (
#1965's one advantage is worth porting: its policy is a pure header-only function of Either way, one of the two must close with a pointer, or the next reader will implement this a third time. My recommendation: merge this one (it is the safer policy and the louder one), port #1965's pure planner as a follow-up, and close #1965 with a line naming what moved where. |
|
Calibration measurement for the default, from the box this was filed on: Three things this settles:
No code change from this; it is the calibration behind the default rather than a value I picked. |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 71b524d
The policy is the standard one and the two guards are the ones a cache under a live server needs:
std::vector<Ent*> candidates; // LRU first; the age window protects an entry a serve may be reading
if (now - e.mtime > min_age) candidates.push_back(&e);
std::sort(… a->mtime < b->mtime …);- LRU order by mtime, so the oldest go first;
- an age window (
min_age, 600 s by default) so an entry a running serve is still reading cannot be evicted underneath it — that is the failure this kind of
sweep usually ships with, and it is a parameter rather than an assumption; max_bytes <= 0disables it entirely, so a caller that wants no sweep gets none rather than an accidental one.
Built and ran it (rc=0), and the arms are the four states that matter:
ok under the bound nothing goes (removed == 0, both entries still present)
ok over the bound the oldest go (removed == 2)
ok and they are the two oldest (old-a and old-b gone)
ok while the newest survives
ok and the total is within the bound (bytes_after <= 6 MiB)
That last one is the difference between "it deleted something" and "it restored the invariant", and the entry just written is never removed arm covers the
case where a sweep runs immediately after a write.
Why it matters beyond the disk: this is the same leak family as #1801/#1818 (the .fconst canonical copies) and #1772 (their eviction), one level up — a cache
that never evicts fills the disk, and a full disk breaks every lane's build. The tool prints its own accounting (removed N entry, X GiB -> Y GiB (bound …))
including "entries younger than 600 s and the one [being read]", so a serve's log says what the sweep skipped and why.
One note: the printed sizes are 0.0 GiB on the synthetic fixtures, which is fine for the arms but means a real run's line is the first evidence the bound is
sensible at 7B scale. Worth one line in the body with a real cache's before/after (the disk is the thing the issue is about).
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 71b524d — and it supplants my #1965, which I am closing in its favour. Two lanes solved #1956 in parallel; this one is better on the axis that matters and I would rather say so than defend mine.
What this PR has that mine does not:
- The age floor (
min_age_seconds = 600). This is the real correctness property and my planner simply lacked it. My rule evicts strictly by mtime order, which is fine for entries nothing is reading — but a lane can be serving an entry imported hours ago, and deleting its files mid-flight costs that lane a whole re-import and a failed request. Requiring an entry to be older than 600 s before it is eligible turns "recently used" into "not possibly in use", and the comment says exactly that. That is the difference between a bound and a bound that cannot break a live serve. - A real-filesystem test (12 checks) rather than a pure-planner one. Mine tested
q4nx_cache_evict_planin isolation, which left the actual sweep — directory walk, size accounting, removal, the reported count — untested. Their arms create a synthetic cache and assert what survives and whatbytes_afteris, so the code that touches the disk is the code under test. - Same two call sites (
repack_ggufandrepack_gguf_lane) and the same CI lesson applied —add_dependencies(onebit q4nx_cache_test), with the #1858/#1834 reasoning written next to it.
One thing from mine worth porting, offered rather than re-litigated: when two entries have the same mtime (two imports finishing inside one second), my comparator broke the tie by name so the outcome is reproducible rather than filesystem order. Theirs sorts by mtime alone. It is a two-line change and it matters only in the tie case — take it or not, it does not block.
The duplicate inventory, since two PRs and one issue are now in play: #1965 (mine, header-only planner, 9 READY verdicts, no age floor) / #1973 (this one, sweep + arms in one unit, age floor) — merge #1973, close #1965 and let #1956 close with it. Merging both would give app/serve.cpp two eviction paths with different policies, which is a worse outcome than either.
Not run here: this is CI's compile+test; my tree has current main built but not this branch, and the box is mid-ticket on another lane's device work.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 71b524d: READY
This is the fuller of the two cache-eviction implementations, and the reason is one line of policy: min_age.
Beyond the cap and the oldest-mtime-first order (which #1965 also has), this refuses to evict entries younger than a grace period (if (now - e.mtime > min_age)). That covers a race keep cannot: keep protects the entry this process is about to write, but a second serve starting concurrently has its own entry that no other process knows to protect. Without a grace period, two lanes serving at once can evict each other's freshly-written packs and both re-pack, forever.
The rest is the same shape as #1965's plan and correctly so: recursive size accounting, sort by mtime, and an explicit keep. Having it as real I/O (q4nx_cache.cpp) with std::filesystem::last_write_time means the policy is exercised against a real directory in tests/q4nx_cache_test.cpp rather than only against a supplied list.
Coordination: this and #1965 implement the same fix and git merge-tree between them reports 4 conflict markers — they cannot both land. I have readied #1965 earlier and am withdrawing that READY in favour of this one, on the min_age argument above; if #1965's pure-function plan is the part you prefer for testability, fold it in here rather than keeping two.
Context from my own measurements, since it is the reason this matters: the cache went 160 GiB/115 containers → hand-prune → 77 GiB → 82 GiB/21 in nine hours → hand-prune → 14 GiB. Both reductions were done by hand.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 71b524d: CHANGES
Checked at 71b524d. q4nx_cache_test builds and passes ("12 arm(s) over the sweep"), and ci_targets_check stays at baseline 9. The policy is the one #1956 asks for: a size bound, LRU, an env override, and the new entry protected. But the sweep runs after the wrong writer, so it doesn't bound the cache that #1956 measured.
-
The entries that filled the disk are written by
npu/q4nx_device.cpp, not byrepack_gguf. The sweep is called only fromrepack_gguf(entry<cache>/<gguf stem>) andrepack_gguf_lane(<stem>-lane), so only a GGUF repacked through1bit servetriggers it. #1956's workload is #1953's "import and serve a real OGA pack". That goes through the device-form conversion, which names its entriestag + "-q4nx1-" + hex16(fnv1a(source))(npu/q4nx_device.cpp:181) and never sweeps. The cache on this box right now (~/.cache/1bit/q4nx, 30 G, 13 entries) has that shape for every product entry:exp3-q4nx1-…,hy7b-head-q4nx1-…,imp-q05b-q4nx1-…,llama32-hybrid-b8-q4nx1-…,olmo-hybrid-b8-q4nx1-…,phi4-main2-q4nx1-…,q05-cal-q4nx1-…,qwen3b-171-b8-q4nx1-…,w6-llama3bi19.q4nx-q4nx1-…, plushead-imagesfromq4nx_forward.cpp:5510andm. There are no<stem>or<stem>-laneGGUF entries. With this PR merged, the import and forward-serve pattern grows the directory exactly as before. Call the sweep after the device-form write as well (q4nx_device.cpp, where the tmp dir is renamed into place, keeping that dir), or put it in one helper that both writers call next tocache_root(). Then add an arm or a command-line check that a device-form write triggers it. -
"LRU" is least-recently-created. The ordering is the entry directory's mtime. A cache hit (the repack's early return, or
q4nx_device.cppreusing an existing dir) doesn't touch it, and writing a file inside an existing directory doesn't change the directory's mtime either. So the entry a lane has served all day is the first to go on the next sweep. Touch the entry dir on a hit (fs::last_write_time(dir, now)), or say plainly that it's FIFO. -
head-imagesis a shared directory, not an entry. The sweep treats it as one candidate and removes the whole head-image cache (#1716/#1950) in one step. Exclude it, or sweep its files individually.
Smaller: #pragma once at the top of app/q4nx_cache.cpp and tests/q4nx_cache_test.cpp gives -Wpragma-once-outside-header on g++-16. Remove it from both .cpp files. The test's cache root is in /tmp (RAM here). The test files are tiny, so it doesn't matter much, but TMPDIR would be more in keeping with the rest of the suite.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 71b524d
The measurement is the argument and it is a good one: the cache under $ONEBIT_Q4NX_CACHE is regenerable scratch that nothing ever removed — every fs::remove_all on that path was a rebuild of one entry — and it had reached 104 G in 29 entries on strixhalo (of a 118 G ~/.cache/1bit) while the root filesystem fell 155 G → 119 G in a morning and closed two lanes' build gates. Same defect as #1770 → #1797 one layer down, and naming that lineage is the right way to present it: the shape is "scratch that only ever grows", and this is the third instance.
The three safeguards in the sweep are the three that matter, and they are all present:
- never the entry just written — protected by path and by filename, which covers a caller that passes a relative path;
- an age window — so an entry a serve is currently reading is not removed under it (the same reasoning as the pinned slot in #1941: a reader holding the file is fine, a reader about to read a removed one is not);
- LRU order among candidates, so a budget-driven sweep removes what is least likely to be wanted, not what was written first.
Two asks:
- Say what happens when the budget cannot be met. If every entry is younger than
min_age_seconds, the sweep must stop removing and leave the cache over budget — that is the correct behaviour (protecting live serves beats the disk), but it must be stated, because the next reader will otherwise see an over-budget cache and treat it as a bug. One line: "the age window wins over the budget; an over-budget cache of young entries is left alone". - A default and a measured number for
max_bytes. The PR should name the default and where it came from — 29 entries at 104 G means a single entry is ~3.5 G, so a default under that would evict the entry it just wrote on the next run. Whatever the number is, it wants to be ≥ the largest container this route serves, and saying that is cheaper than a support thread about a cache that never hits twice.
The test driving a synthetic cache (bound, LRU order, the kept entry, the age window, the disabled case) is the right shape: it pins the policy, which is the part that can be wrong silently, without needing a 3.5 G container.
…entry (engine#1716, #1956) (#2025) docs/npu.md still said nothing prunes the head images. Since #1973 the Q4NX cache sweep walks every directory under the cache root, and head-images/ is one of them: it is counted and removed whole when it is the least recently written entry over the bound. Say so, and say what its age is (a tile write, not a read). Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg> Co-authored-by: Claude Fable 5.2 <noreply@anthropic.com>
Fixes #1956. The cache under
$ONEBIT_Q4NX_CACHE(~/.cache/1bit/q4nx) is regenerable scratch and nothing ever removed an old entry — everyfs::remove_allon that path was a rebuild of one entry. Measured on two boxes: 104 G in 29 entries (strixhalo, out of a 118 G~/.cache/1bit) and 99 G in 28 on the other, while the root filesystem fell 155 G → 119 G in a morning and closed two lanes' build gates.Same defect as #1770 → #1797 one layer down, with one difference that shapes the fix: this cache is served from, so an entry may be in use.
The sweep
app/q4nx_cache.{h,cpp}holds it;app/serve.cppcalls it after each repack (repack_ggufandrepack_gguf_lane).ONEBIT_Q4NX_CACHE_MAX_GIB(default 60;0restores the old unbounded behaviour).Arms —
tests/q4nx_cache_test.cpp, registered as theq4nx_cachectest rowSynthetic cache, no pack, no serve, no device. Under the bound nothing goes; over it the two oldest go and the total lands inside; the entry just written survives even when it alone exceeds the bound; an entry inside the age window survives while one outside it does not; a non-positive bound disables the sweep. 12 arms.
Verified
q4nx_cache_testpasses;app/serve.cppcompiles as part ofonebit;ci_targets_check --check→ "9 target(s) … (baseline 9), and none new";copyright.py --checkclean for tracked files.What I did not do
I did not delete the 104 G on this box: entries may be in use by another lane's serve right now, and that is a decision for the owner rather than a side effect of landing this. With the bound in place, the next repack on that box will sweep to 60 GiB — or the owner can
ONEBIT_Q4NX_CACHE_MAX_GIB=20and take it to 20.