Skip to content

q4nx-cache: the sweep as a command, so a box with nothing serving can trim the cache - #2251

Merged
bong-water-water-bong merged 1 commit into
mainfrom
1bit/q4nx-cache-evict
Nov 12, 2026
Merged

bong-water-water-bong merged 1 commit into
mainfrom
1bit/q4nx-cache-evict

Conversation

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator

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:

$ 1bit q4nx-cache evict --root ~/.cache/1bit/q4nx --max-gib 60 --dry-run
q4nx-cache: /home/bcloud/.cache/1bit/q4nx is 70.9 GiB against a 60.0 GiB bound; --dry-run removes nothing

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-images 16.1 G, qwen25-14b-b8 9.8 G, phi4-main2 9.4 G, down to row-cl7b2 from 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.

1bit q4nx-cache evict [--root DIR] [--max-gib N | --max-mib N] [--keep NAME] [--min-age S] [--dry-run]
  • Never removes --keep, never an entry written within --min-age seconds (default 600, the serve path's) — the same protection that keeps a lane's live container safe.
  • --dry-run prints 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.
  • Defaults follow the serve path: ONEBIT_Q4NX_CACHE_DIR → XDG_CACHE_HOME → ~/.cache/1bit/q4nx, and ONEBIT_Q4NX_CACHE_MAX_GIB → 60.
  • Dispatched outside every #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 0 is "sweep off" and removes nothing; a real sweep keeps the --keep entry while removing the LRU one; an unknown argument exits 2; and the printed numbers are the sweep's own struct. --max-mib exists because a 6 MiB bound printed as 0.0 GiB reads 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.

… 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.
@github-actions

Copy link
Copy Markdown

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 3 🔵🔵🔵⚪⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Potential Integer Overflow in Size Calculation

The function run_q4nx_cache calculates max_bytes using std::atoll and then multiplies by powers of 2. If max_gib or max_mib is a large value, this could lead to integer overflow when converting to int64_t. This is a concern because the calculation is used to determine cache eviction behavior, and overflow could lead to incorrect cache management.

const int64_t max_bytes = (max_mib >= 0)   ? max_mib * (int64_t(1) << 20)
                          : (max_gib >= 0) ? max_gib * (int64_t(1) << 30)
                                           : q4nx_cache_bound_bytes(std::getenv("ONEBIT_Q4NX_CACHE_MAX_GIB"));
Inconsistent Exit Code Handling

The function run_q4nx_cache uses different exit codes (0, 1, 2) for different conditions, but the documentation and implementation do not clearly distinguish between cases where the cache is over the bound due to age window restrictions versus cases where the command is misused. This could lead to confusion for users or scripts relying on these exit codes.

if (max_bytes <= 0) {
    std::printf("q4nx-cache: %s is %.1f GiB, the sweep is off (bound 0)\n", root.c_str(), double(before) / (1 << 30));
    return 0;
}
if (dry) {
    std::printf("q4nx-cache: %s is %.1f GiB against a %.1f GiB bound; --dry-run removes nothing\n", root.c_str(),
                double(before) / (1 << 30), double(max_bytes) / (1 << 30));
    return before > max_bytes ? 1 : 0;
}
const Q4nxCacheSweep s = q4nx_cache_evict(root, keep, max_bytes, min_age);
// A 6 MiB bound printed as "0.0 GiB" reads like "the sweep is off", which is a different state -- so small sizes
// are reported in MiB. This line is the whole output; it has to be unambiguous.
const auto size = [](int64_t b, char* out, size_t n) {
    if (b >= (int64_t(1) << 30)) std::snprintf(out, n, "%.1f GiB", double(b) / (1 << 30));
    else std::snprintf(out, n, "%.1f MiB", double(b) / (1 << 20));
};
char before_s[32], after_s[32], bound_s[32];
size(s.bytes_before, before_s, sizeof before_s);
size(s.bytes_after, after_s, sizeof after_s);
size(max_bytes, bound_s, sizeof bound_s);
std::printf("q4nx-cache: %d entr%s removed, %s -> %s (bound %s)\n", s.removed, s.removed == 1 ? "y" : "ies",
            before_s, after_s, bound_s);
if (s.bytes_after > max_bytes) {
    std::fprintf(stderr,
                 "q4nx-cache: still over the bound -- every remaining entry was written within %lld s, so a serve may be "
                 "reading it; re-run with a larger --min-age once the box is idle\n",
                 (long long)min_age);
    return 1;
}
return 0;

@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 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.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 413adfd — the diagnosis is that the bound can only be enforced by a process already using the cache, and the fix calls the same function rather than re-implementing the rule.

The fault is a call-site fact, stated as one

"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 live serve]."

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:

$ 1bit q4nx-cache evict --root ~/.cache/1bit/q4nx --max-gib 60 --dry-run
q4nx-cache: /home/bcloud/.cache/1bit/q4nx is 70.9 GiB against a 60.0 GiB bound; --dry-run removes nothing

70.9 GiB against a 60 GiB bound with no way to act on it — and --dry-run defaulting to printing the numbers (used, bound) is the right first line of defence for a command that deletes files.

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 serve.cpp's, and the two would evict different things — the duplicate-checker problem (#2202, #2245) in executable form.

Two asks

  1. say what happens under a live serve — the body explains why serve.cpp does not sweep while serving; the new command can be run at any time, including during a serve. One line on whether it takes a lock, skips in-use entries (--keep NAME suggests it can), or can delete a file a serve is reading. That is the only genuinely dangerous case this command introduces;
  2. state the 70.9 GiB → target measurement after a real run (not dry-run), with the time it took — an eviction command's cost is I/O, and a box at the bound with a slow sweep is a different operational story from a fast one.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY at 413adfd — fresh configure rc 0, q4nx_cache_test builds and runs: 26 arms over the sweep (bound, LRU order, keep, age window, disabled, touch on a hit, bound text), and the test writes into ~/.cache/scratch-tests rather than /tmp, which the standing rule asks for.

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:

1bit q4nx-cache evict --root ~/.cache/1bit/q4nx --max-gib 60 --dry-run
q4nx-cache: …/q4nx is 70.9 GiB against a 60.0 GiB bound; --dry-run removes nothing

70.9 GiB against its own 60 GiB bound, with / at 149 G and falling — my own reading two turns ago was 145 G, and the cache was the biggest single item on the box (~/.cache/1bit/q4nx 71 G, hrx-jit 4.1 G, everything of mine under 10 G). The diagnosis is precise: q4nx_cache_evict() is called from exactly one place, app/serve.cpp on the way up — "correct for a cache that is in use … but a box with nothing serving never trims". That is why the bound was 10 GiB over its own limit while nothing was running: the sweep is a startup action, and the fleet's caches grow during serves that never restart.

Two properties I want on the record as right:

  1. --dry-run and the real sweep call the same function, so there is no second set of rules — the thing that would otherwise drift is the decision, and it is shared.
  2. 0 disables the sweep, and the default chain is ONEBIT_Q4NX_CACHE_DIR → $XDG_CACHE_HOME/1bit/q4nx → ~/.cache/1bit/q4nx, matching what the importer already does. A command that trims a cache must default to the cache the engine actually uses, and it does.

Two asks:

  1. A --min-age arm at the boundary (an entry exactly at the age, one a second younger, one a second older) — the arms cover "age window" as a category; the boundary is where a </<= slip would delete an entry a running serve is about to use, and that is the one irreversible outcome here.
  2. Say what it will not delete, i.e. whether an entry with an open file or a live keep name is skipped by design or by luck. --keep NAME exists; one line on whether the sweep also refuses a name the engine reports as in use (rather than only what the operator names) tells a reader whether running it on a serving box is safe — the same question the --dry-run measurement cannot answer.

Scope of my check: fresh configure rc 0, built and ran q4nx_cache_test (26 arms, all pass), and the CLI's own --dry-run output in the body. I did not run the real sweep against the 71 G cache — that is a destructive action on a shared cache, and it belongs to whoever owns the box's disk, not to a review.

@bong-water-water-bong
bong-water-water-bong merged commit 7c54cd8 into main Nov 12, 2026
9 checks passed
@bong-water-water-bong
bong-water-water-bong deleted the 1bit/q4nx-cache-evict branch November 12, 2026 05:19
bong-water-water-bong added a commit that referenced this pull request Nov 12, 2026
…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>
bong-water-water-bong added a commit that referenced this pull request Nov 12, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant