Repository navigation
q4nx-cache: the sweep as a command, so a box with nothing serving can trim the cache - #2251
Conversation
… still trim the cache `q4nx_cache_evict()` had exactly one trigger: a serve starting (app/serve.cpp). While the NPU is off nothing serves, so nothing sweeps -- measured on this box at 70.9 GiB against the 60 GiB default bound, with / at 149 G and falling. The bound protects a cache in use; it cannot trim one that is idle. `1bit q4nx-cache evict [--root DIR] [--max-gib N | --max-mib N] [--keep NAME] [--min-age S] [--dry-run]`: - same rules as the serve-time sweep (LRU first, never `--keep`, never an entry inside the min-age window) because it calls the same function; - `--dry-run` prints what would go and removes nothing; exit 1 means "still over the bound", which is a different answer from "nothing to do" (0) and from "bad usage" (2); - defaults are the ones the serve path uses: ONEBIT_Q4NX_CACHE_DIR / XDG_CACHE_HOME / ~/.cache/1bit/q4nx, and ONEBIT_Q4NX_CACHE_MAX_GIB / 60; - `--max-mib` exists because a 6 MiB bound printed as "0.0 GiB" reads like "sweep off" -- small sizes report in MiB. Dispatched outside every #ifdef: host only, no device, so it works while the NPU is off. Arms (4 new, 26 total): --max-gib 0 is "sweep off" and removes nothing; the --keep entry survives a real sweep while the LRU one goes; an unknown argument is exit 2; and the CLI's numbers are the sweep's own.
PR Reviewer Guide 🔍Here are some key observations to aid the review process:
|
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 413adfd: READY
This closes the hole in the eviction work I measured, and the hole was real. #1965/#1973 made the cache evict on write — cap plus min_age, correct — which means a box that never writes a cache entry never sweeps. A machine that stops serving is exactly the machine that stops pruning, so the directory can sit at whatever it grew to, indefinitely.
My own numbers make the case: ~/.cache/1bit/q4nx was 160 GiB / 115 containers, hand-pruned to 77, back to 82 GiB / 21 in nine hours, hand-pruned again, and now sits around 26 GiB / 10. Both reductions were by hand, and both were needed because the sweep had no entry point except a serve.
Exposing the sweep as a main.cpp command makes it something an operator or a cron can run on an idle box, which is the only way a cap actually holds on a machine that is not serving. app/q4nx_cache.{h,cpp} gaining the path is the right home — it is the same code the eviction already uses, not a second implementation, which is what kept this from being a duplicate of #1973.
The test extension matters too: a sweep that only runs on write has one caller and is exercised by serve paths; a sweep that is a command has many, and the arm is what proves the command deletes what it says.
|
VERDICT: READY at The fault is a call-site fact, stated as one
So the bound is enforced only by a process that is already using the cache — which means the box that most needs a sweep (nothing servable, cache full) can never run one. That is a lovely, precise description of a resource bound that exists in code and not in practice, and the observed state is quoted rather than imagined: 70.9 GiB against a 60 GiB bound with no way to act on it — and The three exit codes are the part to keep"the exit codes are three different answers: 0 nothing to [evict]…" — that is the same discipline as #2232's capture verdicts and #2199's exit-1-vs-3: "nothing to do" and "could not do it" must not share a status, or a cron job cannot tell a healthy box from a stuck one. And the decisive engineering choice: "the sweep as a command, calling the same function so there is no second set of rules." A CLI that re-implemented the policy would drift from Two asks
|
|
VERDICT: READY at This is the fix for the disk pressure I have been flagging for four turns, and it starts from a measurement that names the exact state I watched: 70.9 GiB against its own 60 GiB bound, with Two properties I want on the record as right:
Two asks:
Scope of my check: fresh configure rc 0, built and ran |
…me selection as the sweep (#2264) Found by USING #2251 an hour after it merged: `--dry-run` reported "3 entries, 12 GiB" but not which entries, so the question an operator actually has before deleting another lane's imports -- what am I about to lose? -- was unanswerable. - `q4nx_cache_candidates()` is now the ONE selection: LRU first, `keep` and the age window respected, stop at the bound. `q4nx_cache_evict()` is that list plus a remove loop, so a dry run cannot drift from what a real run does. - `--dry-run` prints the entries (size and name, oldest first) and still removes nothing; exit 1 means "there is work". - sizes now go through one MiB/GiB formatter, so a 6 MiB cache no longer prints "0.0 GiB" -- which reads like "the sweep is off", a different state. 6 new arms (32 total): the list stops at the bound, LRU order, each entry carries its size, listing removes nothing, `--keep` is not a candidate, and the age window protects everything inside it. Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg>
…at never serves (#2263) (#2269) * os/, tools/, onnx/: the same usage-range defect, seven of eight already leaking code #2258 fixed `scripts/`: usage text built from `sed -n '16,62p' "$0"`, which is a claim about a file's shape and stops being true when the header above it is edited. The same pattern is in eight more files outside `scripts/`, and **seven had already drifted past the end of their header** -- measured statically, by comparing the range's end with the first line of code: os/v2/mkdebs.sh range 22,45 code starts 44 -> prints code os/v2/mkrepo.sh range 21,27 code starts 23 -> prints code os/v2/mkrootfs.sh range 21,32 code starts 32 -> prints code os/v2/proto.sh range 21,32 code starts 27 -> prints code os/v2/vmtest-v2.sh range 21,34 code starts 31 -> prints code os/v3/puppy/demo-seq-probe.sh range 28,48 code starts 44 -> prints code tools/xhci_keepawake_check.sh range 17,40 code starts 37 -> prints code onnx/bench_npu.sh range 2,30 header ends 45 -> prints only PART of its help (the other direction) `tools/xhci_keepawake_check.sh` is the one that matters most: `docs/axb35-usb-ports.md` and the release notes tell a person to run it with `--require-awake`, and `--help` on it printed code instead of the header. All eight now use the anchored extraction from #2258, defined below the first line of code so its own comment cannot leak: usage() { awk 'NR>1 && /^#/ {sub(/^# ?/,""); print; next} NR>1 {exit}' "$0"; } Verified **statically, without executing them** -- several of these reach for devices (`bench_npu.sh` opens the NPU, which is off by owner order; `demo-seq-probe.sh` and `vmtest-v2.sh` too), and #2258's lesson was that running a help path can take a resource. So: * every file: `bash -n` clean, exactly one `usage()` definition, no self-recursive body, no other line ranges left; * each `usage()` output simulated with the same awk: 16-37 lines of prose, **zero** code-like lines, and `bench_npu.sh` now prints its whole header instead of the first 29 lines of it. * q4nx cache: an invoker for the sweep, because nothing sweeps a box that never serves (engine#2263) `1bit q4nx-cache evict` is correct, tested and merged (#2251) and **nothing calls it**. The cache has two eviction triggers, the write-time sweep inside `serve` and the manual command, and both need the binary to run -- so a review, CI or build box that holds the cache and never serves keeps every entry it ever wrote. engine#2263 measured it: 59 GiB in 12 h with the command merged for nine hours, which put free space 3 GiB under the 175 GiB build gate; one manual 24 h age cut took it to 24 GiB and it regrew. The issue's own conclusion is that the fix is a box configuration change rather than a code change. This is the part that can live in the repository without making that decision for anyone: scripts/install-q4nx-cache-timer.sh # hourly, --max-gib 40 --min-age 86400, opt-in scripts/install-q4nx-cache-timer.sh --dry-run # print the units, verify them, change nothing scripts/install-q4nx-cache-timer.sh --uninstall # remove the units, leave the cache alone It writes only into `~/.config/systemd/user/` (no root, no /etc, no repo state) and does nothing until someone runs it. Three things it refuses, each because the alternative is a timer that fails quietly: * **a binary that predates the command.** This box's deployed rc-2026-11-03-c22 answers `1bit q4nx-cache` with `unknown command 'q4nx-cache'`; a unit naming it would fail hourly and nobody would read the journal. The installer probes the binary and refuses, naming engine#2251. * **`--max-gib 0`.** The CLI documents 0 as *sweep disabled*, not "evict everything" (my first message said the latter and the source contradicted it), so 0 would install an hourly no-op -- the exact failure this script removes. * **an empty `--interval`.** `OnCalendar=` with nothing after it is a timer that never fires, and systemd accepts the unit anyway. The sweep command and its bounds are checked against `app/q4nx_cache.cpp`'s own usage text: `--root`, `--max-gib | --max-mib`, `--keep`, `--min-age`, `--dry-run`. `docs/npu.md` documented the sweep as something that happens after a write, which is true and is exactly why a box that never writes-on-serve keeps growing. It now says so, with the measurement and the installer. Registered as `cmake/tests-q4nx-cache-timer.cmake` (a family file: the glob picks it up, no CMakeLists line) with a `--self-test` that pins nine arms -- the units' content, `Persistent=true`, both refusals, the capability probe in both directions, and that `--dry-run` writes nothing -- using stub binaries, so no systemd, no cache and no 1bit are touched. * q4nx timer: --dry-run now reports systemd's verdict and refuses on it, instead of swallowing it The dry run's promise is that the generated units verify, and the code that was supposed to prove it was if systemd-analyze verify ... 2>&1 | grep -v 'is not executable' ; then :; fi -- an `if` whose branches are both empty, so a unit systemd refused printed its complaint and then the script said "dry run: nothing was changed" and exited 0. The verification was decoration. It now says what systemd said and acts on it: verify: systemd accepts both units dry run: nothing was changed verify: FAILED .../1bit-q4nx-cache.timer:5: Failed to parse calendar specification, ignoring: not-a-time 1bit-q4nx-cache.timer: Timer unit lacks value setting. Refusing. Unit 1bit-q4nx-cache.timer has a bad unit file setting. dry run: nothing was changed, and nothing would be installed until this passes exit 1 `is not executable` is still filtered, because a dry run against a binary that is not installed yet is the normal case; every other complaint decides the exit code, because a unit systemd refuses would otherwise be installed and never run -- the silent-no-op failure this script exists to remove, one level up. Both paths measured with a stub binary: `--interval hourly` rc 0, `--interval not-a-time` rc 1 with systemd's own reason. The 9 self-test arms are unchanged. * q4nx timer: quote the ExecStart path and stop filtering the diagnostic the check exists for (engine#2263) * q4nx timer: a value flag given last refuses instead of spinning, and a relative --1bit is named where it was typed Two asks from the review at 19a28c0, both real. ## The spin `shift 2` on a one-argument list does not shift: it fails and leaves `$#` unchanged, so a value flag given last sent the argument loop round for ever at full CPU. The reviewer's repro, before: for f in --max-gib --interval --1bit --min-age; do timeout 5 bash scripts/install-q4nx-cache-timer.sh --dry-run $f; echo $?; done 124 124 124 124 # the timeout killing a hot loop after: 1 1 1 1 # "--max-gib needs a value", immediately `needs_value() { [ $# -ge 2 ] || die "$1 needs a value"; }` runs before every `shift 2`, and four self-test arms run the exact repro under `timeout 5` and fail the suite if any of them is killed (`rc 124`) -- so the bug cannot come back without turning the row red. Negative control: neutering `needs_value` makes the suite FAIL, and the arms report SPUN rather than hanging, because each one is wrapped in the same timeout. ## The relative path A relative `--1bit` reached systemd as `ExecStart=build1336/1bit`, which answers "Neither a valid executable name nor an absolute path" -- a unit-file complaint about a flag, in the wrong place. It is now refused on the line the mistake was made on, and BEFORE the executability test, so a relative path gets this message rather than "is not executable": install-q4nx-cache-timer: --1bit wants an absolute path, got 'relative/1bit' (systemd requires an absolute ExecStart) One arm pins it. 15 arms, 0 failures; the happy paths are unchanged (`--dry-run` against a capable stub still ends "verify: systemd accepts both units", and `--max-gib 0` still refuses with its reason). --------- Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg> Co-authored-by: bong-water-water-bong <pi@1bit.local>
The cache bound cannot trim an idle cache, and this makes it able to
q4nx_cache_evict()is called from exactly one place:app/serve.cpp, on the way up. That is correct for a cache that is in use — you would not want a sweep racing a serve — but it means a box with nothing serving never trims, and right now nothing serves because the NPU is off.Measured on this box before writing anything:
70.9 GiB against its own 60 GiB bound, with
/at 149 G and falling (177 G four hours earlier). The entries are ordinary imports —head-images16.1 G,qwen25-14b-b89.8 G,phi4-main29.4 G, down torow-cl7b2from 11-10 — so the bound has about 12 G of legitimate work waiting for it that nothing could perform.What this adds: the sweep as a command, calling the same function so there is no second set of rules.
--keep, never an entry written within--min-ageseconds (default 600, the serve path's) — the same protection that keeps a lane's live container safe.--dry-runprints the numbers and removes nothing; the exit codes are three different answers: 0 nothing to do, 1 still over the bound (every remaining entry is inside the age window), 2 bad usage.ONEBIT_Q4NX_CACHE_DIR→XDG_CACHE_HOME→~/.cache/1bit/q4nx, andONEBIT_Q4NX_CACHE_MAX_GIB→ 60.#ifdef— host only, no device — so it works during an NPU shutdown, which is the case that motivated it.Arms (4 new; the file's 26 all pass):
--max-gib 0is "sweep off" and removes nothing; a real sweep keeps the--keepentry while removing the LRU one; an unknown argument exits 2; and the printed numbers are the sweep's own struct.--max-mibexists because a 6 MiB bound printed as0.0 GiBreads exactly like "the sweep is off", and that line is the whole output.Deliberately not done here: no automatic trigger (a timer in the serve path, or a sweep on shutdown) — that is a decision about when a cache may shrink under other lanes, and this PR only gives the operator the command. I also did not run it against the real cache: the entries belong to other lanes, and evicting 12 G of someone's imports is their call, not a lane's.