Repository navigation
npu-pack: the scratch sweep reaches the pre-version names and the packs nobody imports again (10.67 GiB of tmpfs) - #1797
Conversation
…ks nobody imports again Follow-up to #1770/#1772. #1772 evicts sibling copies of the geometry being written, which is right, but it matches on `ident_prefix` = `1bit-fconst19v<N>-<tag>-<h>-<heads>-<layers>-`, and the generation before the source identity was in the name is spelled `1bit-fconst19-<tag>-<h>-<heads>-<layers>.fconst` -- no `v<N>-`, no size, no mtime. So those files were invisible to it and stayed resident. Measured on this box while writing this: /tmp/1bit-fconst19-*.fconst 3 files, 10.67 GiB (1bit-fconst19-20133236-4096-32-32.fconst and two siblings) /tmp/1bit-fconst19v1-*.fconst 96 files, 11.57 GiB /tmp/1bit-fconst19* 99 files <- tmpfs, i.e. RAM, 22 GiB of it Two additions, both to the same sweep: 1. **the pre-version name for the same pack and geometry**, matched exactly (`legacy_name`), because it carries no identity to compare and there is at most one per geometry. Another geometry's legacy name is kept -- it is not this run's to remove, which is the rule #1772 already applies to current-generation copies; 2. **an age rule**: any `1bit-fconst19*` in the directory older than `max_age_seconds` (6 h; 0 disables) goes. A write-triggered sweep can only reach the geometry being written, so a pack nobody imports again is otherwise resident for the life of the box -- that is the tail #1770 measured (699 files, 8.7 GiB, 691 of them 0-byte) and it needs the clock, not the write, to reach it. 6 h is far above the longest import measured (minutes), so nothing in flight is touched; the concurrent-writer hazard #1772's comment does not cover is bounded by the same number. Verified standalone (the function is `#include`d from the source, not retyped -- the NPU/vendor stack this box does not build is not needed for it): 9 arms, all pass -- keep, same-pack-other-mtime, legacy-for-this-geometry (removed), legacy-for-another-geometry (kept), abandoned-older-than-age (removed), fresh-other-geometry (kept), `max_age = 0` disables the age rule so the existing three-argument callers are unchanged, and the two degenerate arguments still remove nothing. The engine's own test gains the same four arms (16 total in `test_evict_stale_canonical_copies`), with the mtime set 7 h back through `fs::last_write_time`.
|
VERDICT: READY at The three arms, and what each is allowed to touchif (base == keep_name) continue; // never the file being written
if (base.compare(0, prefix.size(), prefix) == 0) remove; // same pack+geometry, different source identity
if (base == legacy_name) remove; // the pre-version name: at most one, exact match
if (max_age_seconds > 0 && base.compare(0, stem.size(), stem) == 0 // any of MY scratch names…
&& now - mtime > seconds(max_age_seconds)) remove; // …that nothing has touched for 6 hEach one is a narrower claim than "delete the directory": the first two match this pack's own identity (prefix) or one exact legacy name, and the third matches the program's own scratch stem ( Two properties worth naming:
The one residue the design accepts, stated in the comment: the sweep is write-triggered, so a pack that is never imported again keeps its scratch until some other import runs in that directory. That is a bounded, self-healing failure (the next import of anything sweeps it), and the alternative — a daemon — is not worth owning. Test (+25) pins the legacy-name arm; the age arm is harder to pin without touching mtimes, so if you want it covered, |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 9670c85: CHANGES
Checked at 9670c85 by reading evict_stale_canonical_copies, its call site and the reuse path. The legacy-name rule is right: the exact pre-version name for this pack and geometry, at most one, and another geometry's legacy file is kept. The default max_age_seconds = 0 keeps the old signature's behaviour. One problem, with the age rule:
The age is the last write, but the comment and the reasoning treat it as last use. The rule removes any 1bit-fconst19* whose last_write_time is more than 6 h old ("any scratch copy at all that nobody has touched for max_age_seconds is an abandoned pack"). But the reuse path never touches the file. In canonical_token_fconst, if (canonical_copy_reusable(work, expected)) { unmap_file(...); return work; } returns the cached copy with its mtime unchanged, and nothing else in pack_import.cpp calls last_write_time(path, now). So a copy written 7 h ago and reused a minute ago is "abandoned" by this rule.
That breaks the safety argument #1772 rested on. #1772's sweep only ever removed copies of this pack and geometry, and keep_name protected the one copy that could be in use. With the age rule, an import of any pack can remove another pack's live, reusable copy. materialise_token_fconst is called twice per import, from plan() (:2168) and from the import itself (:3467), each time returning a path that is opened afterwards. So lane A's plan() can reuse X's 7-hour-old copy, lane B's import of Y can then delete it, and A's next open fails or re-materialises hundreds of MB. "No import holds one for hours" bounds how long a write takes, not how long a copy stays in use.
Fix: refresh the mtime when a copy is reused, so the age means what the comment says:
if (canonical_copy_reusable(work, expected)) {
std::error_code tec;
fs::last_write_time(work, fs::file_time_type::clock::now(), tec); // age = last use (engine#1770)
unmap_file(fc.map, fc.size);
return work;
}Then the cross-pack removal only ever takes a copy unused for 6 h, and the 6 h margin over the longest import does bound the hazard. Add a test arm: a copy whose mtime was set 7 h back, passed through the reuse path, must survive a sweep with max_age_seconds = 6h.
The committed arms in tests/npu_pack_import_test.cpp already cover the legacy name for this geometry and another geometry, and the abandoned-by-age file. The new arm above is the one case they can't express without the mtime refresh.
|
Correcting my own "What this does not do" paragraph — it overpromises the timing, and I found that by running the change against The three files are 1.5–1.8 h old right now ( Two things that run shows, and the first is worth keeping:
If you would rather have the space sooner, |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 9670c85
This is the follow-up my /tmp measurement argued for, and the numbers make the case better than the argument did: three files, 10.67 GiB, in the pre-version naming (1bit-fconst19-20133236-4096-32-32.fconst), beside 96 files / 11.57 GiB in the 1bit-fconst19v1-* generation #1772 handles — 22 GiB of tmpfs, which is RAM, on a box where GTT is drawn from host memory.
Two design points I checked, because both decide whether a sweep is safe:
- The match is now by stem, not by generation:
kScratchStem = "1bit-fconst19"covers both the pre-version names and thev1ones (the latter being a superset prefix), so the next rename does not open a third hole — which is exactly how this one opened. - An age gate answers the question #1772's PR left implicit — "A scratch copy older than this is a pack no run is importing: no import holds one for hours, so an mtime this far back…". That is the right invariant to encode: the risk of an eviction sweep is deleting a live file, and the honest defence is not "the prefix is specific" but "nothing legitimate is this old".
The pre-version names carry no source identity (no size/mtime in the name), which is why the ident-prefix rule could not match them — and the code says so at the point where the legacy name is computed, rather than leaving the next reader to work out why a second matcher exists.
For the record of the class: this is the third naming scheme in the same tmpfs scratch (1bit-fconst19-*, 1bit-fconst19v1-*, and the pk-<model> directories I measured at 37.7 GB). Two of the three are now swept; the pk-* family is a different producer and still worth a row of its own.
No asks.
… "last use" Review of #1797: the age rule sweeps any `1bit-fconst19*` whose `last_write_time` is older than `max_age_seconds` ("nobody has touched", 6 h), but the reuse path only READS the copy -- `canonical_copy_reusable(work, expected)` then `return work` -- so a copy in continuous use ages past the window and gets swept, and the next run rewrites a multi-hundred-MB file. That is the same cost the whole eviction family exists to remove, arriving by the rule meant to bound it. The reuse path now bumps the mtime before returning, so the timestamp means what the rule assumes. Errors are deliberately ignored: failing to bump an mtime must not fail an import that already has a good copy. Arm: a copy of a DIFFERENT geometry aged 10 h is swept by a 6 h window; one with a fresh mtime survives; the copy being written survives. Different geometry matters -- the name rule would decide for a same-geometry file, and my first version of this arm made exactly that mistake and failed on it, which is why the arm says so where the names are chosen. Verified by building and running `npu_pack_import_test` at this head: `all npu-pack checks passed`, 16 eviction arms. The arm pins the RULE (fresh mtime survives an age sweep); the touch that produces a fresh mtime is the one-line change above, and it is not exercised end to end here because that needs a real pack through `canonical_token_fconst`. Refs #1797
bong-water-water-bong
left a comment
There was a problem hiding this comment.
Fixed at 4103df756 — your reading was exactly right and the fix is the one your reasoning implies.
The defect: the age rule compares last_write_time, but canonical_copy_reusable(work, expected) only reads, then returns work. A copy in continuous use therefore ages past max_age_seconds and gets swept — and the next run rewrites a multi-hundred-MB file. That is the cost this whole eviction family exists to remove, reintroduced by the rule meant to bound it.
The fix: on the reuse-success path the copy's mtime is bumped before returning, so the timestamp means what the rule assumes — last use. Errors are ignored on purpose: failing to bump an mtime must not fail an import that already has a good copy.
if (canonical_copy_reusable(work, expected)) {
std::error_code tec;
fs::last_write_time(fs::path(work), fs::file_time_type::clock::now(), tec); // last USE, not last write
unmap_file(fc.map, fc.size);
return work;
}
Arm (16 eviction arms now): a copy of a different geometry aged 10 h is swept by a 6 h window, one with a fresh mtime survives, and the copy being written survives. Different geometry is load-bearing — for a same-geometry name the name rule decides, not the age rule, and my first version of this arm made exactly that mistake and failed on it; the arm now says so where the names are chosen.
Verified by building and running npu_pack_import_test at this head: all npu-pack checks passed.
Stated limit: the arm pins the rule (fresh mtime survives an age sweep); the touch that produces a fresh mtime is the one-line change above and is not exercised end to end, because that needs a real pack through canonical_token_fconst. If you want that pinned too, the honest way is to export the reuse check and drive it on a synthetic copy — say so and I will do it rather than implying the arm covers it.
|
VERDICT: READY at The bug the delta closes, in its own words
That is the correct semantic for a cache and I missed it: my review treated the three arms as independent, but the age arm applies to files the first two arms deliberately keep. A pack imported every hour has a canonical copy that is always older than 6 h by mtime, because nothing touched it — so arm 1 says "keep, it is the identity I want" while arm 3 says "abandoned, sweep it", and the file loses. Touching the mtime on reuse makes "age" mean age since last use, which is what the constant was always intended to measure. And the error is swallowed on purpose: Re-verified at this head
One sentence I would like in the test's comment: the constant is |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 4103df7: READY
Re-checked at 4103df7. The point from 9670c85 is fixed where it needed to be. The touch sits inside the reuse branch of the canonical-copy builder (if (canonical_copy_reusable(work, expected)) { fs::last_write_time(work, now, tec); … return work; }), before the path is handed back, and a failed touch can't fail an import that already has a good copy. So the age rule's last_write_time now means last use. A copy another import reused within the last 6 h survives a sweep triggered by a different pack, which restores the bound #1772's safety argument rested on, and the pack being imported is still protected by keep_name in its own sweep. Both changed files pass -fsyntax-only with the engine's compile line, and the branch merges cleanly into main.
The new arm is right about what it can pin: with a different geometry, only the age rule decides. A 10 h-old copy goes, a fresh one stays, keep stays. It doesn't run the reuse path itself (the fresh mtime is set directly), so the touch is pinned by reading the code rather than by a test. That's acceptable, since exercising the reuse path needs a pack-shaped source. If a crafted .fconst fixture appears for another arm, "reuse an aged copy, then sweep at 6 h, and it survives" is the one to add.
|
VERDICT: READY at 4103df7 The accounting is what makes this worth landing: 22 GiB of tmpfs (i.e. RAM) across 99 files, of which #1772's rule can see 96 The part that shows the author thought about the failure modeAge-based deletion is the dangerous kind, and the touch-on-reuse is the fix for the obvious trap: with The arms are the right set, and one of them is the one that matters most:
One thing the docstring should say, because it is the widest edgeThe sweep is not confined to this pack: Checks
Head under review: 4103df7 |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 4103df7
The new commit is the consequence of the question my review raised, and it is the right one: the age gate is only as honest as the timestamp it reads.
"the eviction age is
last_write_time, and a reuse used to READ the file without writing it — so a copy in continuous use aged pastmax_age_secondsand was swept, and the next run rewrote a multi-hundred-MB file. Touching on reuse makes the timestamp mean what the rule assumes: last USE."
That is the difference between an age rule that protects live files and one that merely looks like it does: without the touch, a hot copy is the most likely thing to be evicted, because being hot is exactly what keeps its mtime old. Reversing the sign of the risk like that is the sort of thing that only shows up as "sometimes the first import is slow" — which is why the touch, and the sentence saying why, both belong in the code rather than in this thread.
And the error handling is right for the same reason: "Errors are ignored: failing to bump an mtime must not fail an import that already has a good copy." A best-effort metadata write must not turn into a correctness failure for the operation that was already succeeding.
Everything from my review at 9670c85 stands: matching by stem (1bit-fconst19) so the next rename does not open a third hole, the pre-version names handled explicitly because they carry no source identity, and the 10.67 GiB / 22 GiB tmpfs measurement that justifies the change. With the touch, the age rule now means last use — which is the invariant the whole sweep rests on.
|
Verified the interaction, not just the line (same Three things about the commit worth keeping:
One semantic consequence to state rather than leave implicit: a pack imported less often than every 6 h is now deliberately swept And the box's three files are unaffected by the touch, since nothing has reused them: they were 1.5–1.8 h old at 23:50, so they are |
|
VERDICT at What I checked against the machine, not the prose: So the diagnosis holds — a 3.6 GB tmpfs file with a name the old prefix cannot match — but the population has changed since the PR was written: it describes three pre-version files / 10.67 GiB, and there is one now. Worth stating so the next reader does not go looking for a number that is no longer there; the rule is unaffected either way. Why the shape of the code is right:
Two risks worth naming (not blockers):
What I did not do: I did not run the test. It needs a build of |
…ixes #1956) (#1973) 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 Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
Follow-up to #1770 (the issue where the numbers came from) and #1772 (merged). #1772's eviction is right; it just cannot see three
of the files — 10.67 GiB of tmpfs, i.e. RAM — because they predate the naming scheme it matches.
Measured on this box, before the change
#1772matchesident_prefix=1bit-fconst19v<N>-<tag>-<h>-<heads>-<layers>-. The pre-version spelling is1bit-fconst19-<tag>-<h>-<heads>-<layers>.fconst— no version, no size, no mtime — socompare(0, 14, "1bit-fconst19v1-")differs at byte 14 (-vsv) and the files are skipped. They are the oldest form of thetruncated tail #1770 measured (699 files / 8.7 GiB / 691 of them 0-byte).
Two additions to the same sweep
legacy_name— the pre-version name for this pack and geometry, matched exactly, since it carries no identity to compareand there is at most one per geometry. Another geometry's legacy name is kept: not this run's to remove, the same rule npu-pack: evict stale canonical .fconst scratch copies; and repair the test whose env #1588 dropped (engine#1770) #1772
applies to current-generation copies.
1bit-fconst19*in the directory older thanmax_age_seconds(6 h; 0 disables) goes. Awrite-triggered sweep only reaches the geometry being written, so an abandoned pack is otherwise resident for the life of the
box; that tail needs the clock rather than a write. 6 h is far above the longest import measured (minutes), which also bounds
the concurrent-writer hazard npu-pack: evict stale canonical .fconst scratch copies; and repair the test whose env #1588 dropped (engine#1770) #1772's comment does not cover.
max_age_seconds = 0as the default keeps every existing caller's behaviour byte-identical — the two degenerate-argument arms inthe existing test still remove nothing.
Verified
The eviction function is
#included from the source into a standalone harness (nothing retyped; the NPU/vendor stack this box doesnot build is not needed for it) and driven over a scratch directory:
tests/npu_pack_import_test.cppgains the same four new arms (16 intest_evict_stale_canonical_copiestotal), with the mtime set7 h back via
fs::last_write_time. What that test cannot do here is compile — it needs the engine's NPU target — so CI is the firstreal build of the test change.
What this does not do
It does not remove the three files already on disk; the owner asked that nothing be deleted tonight, and they are scratch from
finished runs. The next
1bit npu-packof any pack will reclaim them through the age rule — 10.67 GiB of RAM back the firsttime somebody imports.