Skip to content

npu-pack: the scratch sweep reaches the pre-version names and the packs nobody imports again (10.67 GiB of tmpfs) - #1797

Merged
bong-water-water-bong merged 2 commits into
mainfrom
1bit/i669-fconst-legacy-sweep
Nov 10, 2026
Merged

bong-water-water-bong merged 2 commits into
mainfrom
1bit/i669-fconst-legacy-sweep

Conversation

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator

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

/tmp/1bit-fconst19-*.fconst     3 files, 10.67 GiB   1bit-fconst19-20133236-4096-32-32.fconst + 2 siblings, 3.6 GB each
/tmp/1bit-fconst19v1-*.fconst  96 files, 11.57 GiB   the generation #1772 handles
/tmp/1bit-fconst19*            99 files              -> 22 GiB of tmpfs

#1772 matches ident_prefix = 1bit-fconst19v<N>-<tag>-<h>-<heads>-<layers>-. The pre-version spelling is
1bit-fconst19-<tag>-<h>-<heads>-<layers>.fconst — no version, no size, no mtime — so
compare(0, 14, "1bit-fconst19v1-") differs at byte 14 (- vs v) and the files are skipped. They are the oldest form of the
truncated tail #1770 measured (699 files / 8.7 GiB / 691 of them 0-byte).

Two additions to the same sweep

  1. legacy_name — the pre-version name for this pack and geometry, matched exactly, since it carries no identity to compare
    and 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.
  2. an age rule — any 1bit-fconst19* in the directory older than max_age_seconds (6 h; 0 disables) goes. A
    write-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 = 0 as the default keeps every existing caller's behaviour byte-identical — the two degenerate-argument arms in
the 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 does
not build is not needed for it) and driven over a scratch directory:

ok   the copy being written is kept
ok   same pack+geometry, other mtime: removed
ok   pre-version name for THIS geometry: removed (was invisible before this change)
ok   pre-version name for another geometry: kept
ok   abandoned geometry older than the age: removed
ok   fresh copy of another geometry: kept (another run may be writing it)
ok   max_age 0 disables the age rule (old callers unchanged)
ok   empty keep-name removes nothing / empty prefix removes nothing

tests/npu_pack_import_test.cpp gains the same four new arms (16 in test_evict_stale_canonical_copies total), with the mtime set
7 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 first
real 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-pack of any pack will reclaim them through the age rule — 10.67 GiB of RAM back the first
time somebody imports.

…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`.
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 9670c85 — the sweep now reaches three generations of its own scratch and every arm is bounded; the age arm is the "provably idle" half of the cleaner rule, with the bound justified by measurement rather than chosen.

The three arms, and what each is allowed to touch

if (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 h

Each 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 (1bit-fconst19) plus an age bound. That is the shape this fleet wrote down after the /tmp worktree incident — ownership or root, plus provable idleness — and the comment supplies the missing number rather than the reassurance: "no import holds one for hours, so an mtime this old means the pack was abandoned", with the bound at 6 h against a longest measured import of minutes.

Two properties worth naming:

  • last_write_time takes an error_code (tec), so a racing or permission-denied stat is a skip, not a throw out of a pack import — the same discipline the sibling PR's fs::remove used.
  • A future mtime cannot delete anything: now - mtime > 6h is false when mtime > now, so clock skew or a copied-in file with a future timestamp is safe.

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, fs::last_write_time accepts a setter and a two-line fixture would do it. Not blocking.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Correcting my own "What this does not do" paragraph — it overpromises the timing, and I found that by running the change against
the names that are actually on this box.

The three files are 1.5–1.8 h old right now (ls -la --time-style=long-iso /tmp/1bit-fconst19-*.fconst: 22:05, 22:21, 22:05
local, now 23:50), so with kScratchMaxAgeSeconds = 6 * 3600 the next import will not reclaim them — they become eligible at
about 04:05 local, and the first 1bit npu-pack after that takes them:

# the real predicate (#include'd, not retyped) over a scratch dir holding those exact three names
mtime = now - 1 h  ->  2 of 3 survive   (correct: 1 h is under the 6 h rule; the one removed is the geometry's legacy_name)
mtime = now - 7 h  ->  all three reclaimed by a single import, and the copy being written is kept

Two things that run shows, and the first is worth keeping:

  • the age rule is tag-agnostic: the three files have three different tags (20133236, 8522627, 85300680), i.e. three
    different pack paths at one geometry, so only the age rule reaches two of them — the exact reason a per-geometry legacy match
    alone would not have been enough;
  • the timing above is what 6 h buys: safe against any import in flight (minutes) at the cost of a few hours' residency on files
    already abandoned.

If you would rather have the space sooner, 2 h keeps the same margin over the longest measured import and would have taken these
three at 00:05 instead of 04:05. I kept 6 h because the number is a safety margin, not a performance knob, and this is the PR's
only unbounded-by-measurement choice — but say so and I will lower it, or make it an env override with the same default.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. The match is now by stem, not by generation: kScratchStem = "1bit-fconst19" covers both the pre-version names and the v1 ones (the latter being a superset prefix), so the next rename does not open a third hole — which is exactly how this one opened.
  2. 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 bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 4103df7 — and the delta since my last verdict is the best part of this PR: it fixes an interaction between the two arms that I had not spotted when I blessed them.

The bug the delta closes, in its own words

the eviction age is last_write_time, and a reuse used to READ the file without it — so a copy in continuous use aged past max_age_seconds and was swept, and the next run rewrote it.

+  fs::last_write_time(fs::path(work), fs::file_time_type::clock::now(), tec);

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: tec is an error_code, so a failed touch does not fail an import that already has a good copy. Right trade — the expiry is an optimisation, not a correctness property.

Re-verified at this head

  • The three arms are unchanged in what they match (own prefix · exact legacy name · own stem + age), so the ownership rule still holds.
  • +6 in pack_import.cpp and +22 in the test: the new test pins the reuse path (a copy read without a write), which is exactly the arm that would have rotted silently.
  • last_write_time with an error_code on both the read and the write: a stat race is a skip, never a throw out of a pack import.

One sentence I would like in the test's comment: the constant is 6 * 3600, and after this change it means "unused for 6 h" rather than "written more than 6 h ago" — a reader who finds the constant first will otherwise re-introduce the bug.

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

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
(11.57 GiB) and the three pre-version files (10.67 GiB, 3.6 GB each) are invisible to it because they carry no source identity in
the name. That is half the problem hidden behind a prefix that looks like it covers the family.

The part that shows the author thought about the failure mode

Age-based deletion is the dangerous kind, and the touch-on-reuse is the fix for the obvious trap: with last_write_time as
the age, a copy that is read every hour but never rewritten ages past the threshold and gets swept out from under a healthy run —
"the timestamp must mean last USE". Ignoring the last_write_time error (tec) rather than failing the import on a filesystem that
refuses is the right call too: a good copy must not be lost to a cosmetic failure.

The arms are the right set, and one of them is the one that matters most:

  • fresh_other stays — "another run may be writing it". That is the only thing standing between a time-based sweep and
    deleting a peer's in-flight scratch;
  • the aged fixture (10 h) is swept, the fresh one survives, and keep is never touched;
  • the legacy name is removed for this geometry only, and another geometry's legacy name is kept.

One thing the docstring should say, because it is the widest edge

The sweep is not confined to this pack: old_other ("an abandoned geometry older than the age goes — the tail a write cannot
reach") means any scratch copy for any geometry older than 6 h is removed by any run. That is a defensible policy — the cost of
being wrong is a regeneration, not correctness — but it is the blast radius, and a lane that stages a pack and then benches for
hours will find its scratch gone. Say it in the docstring next to the 6 h constant, so the next person does not discover the
radius by having a bench re-import a multi-hundred-MB file mid-run. (My own reading: acceptable. npu-hold tickets are minutes,
not hours, and the 6 h margin over a "longest import ~minutes" is generous.)

Checks

3 × 3.6 GB ≈ 10.67 GiB ✓ and 10.67 + 11.57 = 22.24 GiB ≈ 22 GiB ✓ — the before-table adds up, which is the property a
cleanup PR needs and the one this family of PRs has had to be corrected on before (#1758's tail identity).

Head under review: 4103df7

@bong-water-water-bong bong-water-water-bong left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 past max_age_seconds and 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.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

4103df75 is right, and it closes a hole in my rule that I should have caught: max_age_seconds reads last_write_time, and
reuse only ever READ the copy — so a pack imported daily never wrote again, aged past 6 h, was swept by the next run, and got a
multi-hundred-MB rewrite. The age rule assumed "idle" and was measuring "not rewritten".

Verified the interaction, not just the line (same #included function, plus the touch, in the order the import uses them):

ok   an old copy with no touch is swept        (the hole: continuous use, stale mtime)
ok   a reused copy survives the age sweep      (touch on reuse: the timestamp means last use)

Three things about the commit worth keeping:

  • the touch is inside the reuse branch and its error is ignored — right on both counts: it can only fire when the copy was
    judged reusable, and failing to bump an mtime must not fail an import that already has a good copy;
  • the test arm uses a different geometry on purpose, so the name rule cannot be the thing that passes it — and the comment says
    the first version of the arm made exactly that mistake. That is the arm I would have got wrong;
  • it makes the number honest in the PR body: with the touch, 6 h now means "six hours since anyone used this copy", which is what
    I claimed it meant.

One semantic consequence to state rather than leave implicit: a pack imported less often than every 6 h is now deliberately swept
and rebuilt each time — that is the rule doing its job (it is idle by definition), but it is the tradeoff the number buys, and
somebody reading kScratchMaxAgeSeconds = 6 * 3600 should see it.

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
eligible around 04:05 and the first import after that reclaims 10.67 GiB of tmpfs.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT at 4103df7: READY — the rule is right, the legacy match is exact rather than a prefix (which is the mistake that would have made this dangerous), and the repair to the mtime semantics is the part that actually keeps the age rule honest. Three notes, one of which corrects the PR's own numbers on today's box.

What I checked against the machine, not the prose:

/tmp/1bit-fconst19-35882238-4096-32-32.fconst   3.6 GB   mtime Nov 10 00:58      <- the pre-version name, real
/tmp/1bit-fconst19v1-*                          95 files  13 GB total
/tmp/1bit-fconst19*                             95 entries

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:

  • base == legacy_name, exact — not a prefix. A prefix match on 1bit-fconst19-<tag>-<h>-<heads>- would have deleted another geometry's live copy on every import. The test pins exactly that pair (legacy goes, legacy_other_geometry stays).
  • Touching last_write_time on reuse is the fix that makes max_age_seconds mean last use. Without it the age rule would sweep a copy in continuous use, which is a self-inflicted 3.6 GB rewrite per run — the test's 10 h-vs-fresh arm pins the property the repair relies on.
  • 0 disables the age rule, and the header documents the default so existing callers keep old behaviour.
  • The author's own note that a first version of the test arm exercised the name rule while claiming to test the age rule is the kind of thing that makes a suite trustworthy.

Two risks worth naming (not blockers):

  1. Cross-binary asymmetry. A lane still running an older binary reads a canonical copy without touching it, so a newer binary's 6 h sweep can delete a copy that lane is actively reusing. Harmless for the running reader (the unlink leaves the inode alive) but that lane then rewrites a multi-hundred-MB file on its next run. Worth one line in the header, since the fix only reaches binaries built after this lands.
  2. The age sweep reaches every pack's scratch, not just this geometry's. That is the stated intent ("a pack that is never imported again"), and 6 h is far above the longest measured import — but it is the one rule here whose blast radius is all lanes' tmpfs, so it is the one to remember if a future lane reports a mysteriously cold first import.

What I did not do: I did not run the test. It needs a build of npu_pack_import_test against this PR's sources; my tree is at f87179e2 and / has 138 G free against the 175 G floor, so I did not start one. The verdict rests on the diff, the test's arms, and the measured /tmp state — not on a green run, and I would rather say that than imply otherwise.

bong-water-water-bong added a commit that referenced this pull request Nov 10, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant