Repository navigation
npu: the device-form conversion sweeps the Q4NX cache it writes (refs #2263) - #2271
Conversation
…2263) npu/q4nx_device.cpp device_form() writes entries into the Q4NX cache, but only serve start and `1bit q4nx-cache evict` ever swept it. A box that converted through the forward route and never started a serve grew past ONEBIT_Q4NX_CACHE_MAX_GIB with no sweep at all. The sweep (evict, candidates, dir bytes, bound, touch) moves unchanged from app/q4nx_cache.{h,cpp} to npu/q4nx_cache_sweep.{h,cpp}, built as the onebit_q4nx_cache library that onebit, onebit_npu and q4nx_cache_test link, so npu/ can call it without including app/. app/q4nx_cache.h keeps the command. device_form() sweeps after a new conversion is renamed into place (same bound, same age window, never the entry just written), and its reuse path uses q4nx_cache_touch(). npu_pack_import_test gains two arms: a 2 GiB sparse older entry is removed by a new conversion under a 1 GiB bound, and kept with the bound at 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PR Reviewer Guide 🔍Here are some key observations to aid the review process:
|
|
VERDICT: READY at The asymmetry, named
So the conversion path creates cache entries during normal forward work and never bounds them: a box that runs forward but never serves grows until something else sweeps. And the placement of the fix is right — sweep after the new conversion is renamed into place, using the same bound ( The test does the disk-efficient thing
That is how a size-bound test should be written: the apparent size is what the bound reads, and a sparse file gives it 2 GiB of apparent size for no disk. On a box that has spent the evening between 142 and 226 GiB free, that detail is not pedantry. Two asks
And the split with #2269 is honest"This is the code half. The scheduler half is #2269 (pkg lane), and the two do not overlap." Verified: the only shared file is |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY -- the move is clean, the new evictor protects its own entry by construction, and the one thing I would carry forward is a note my #2264 review already asked about
The "moved unchanged" claim holds where it matters. Comparing the line sets: of 130 removed lines, exactly five are not re-added verbatim, and each is accounted for -- two target_link_libraries lines that now go through onebit_q4nx_cache, a comment whose reference changed from app/q4nx_cache.h to q4nx_cache_sweep.h, a reflowed doc sentence, and fs::last_write_time(dir, ...) which became q4nx_cache_touch(dir). That last one is a small improvement rather than a drift: the touch now runs behind if (fs::is_directory(entry, ec)), so a name that is not a directory no longer has its timestamp written. Everything else in the sweep -- the candidate selection, the LRU ordering, the bound arithmetic, the age window -- is byte-identical.
The new evictor is safe by construction, which is the part that could have been dangerous. A conversion now deletes other lanes' entries, so the one invariant that must hold is that it cannot delete its own:
q4nx_cache_evict(root, dir, q4nx_cache_bound_bytes(std::getenv("ONEBIT_Q4NX_CACHE_MAX_GIB")));dir -- the entry just renamed into place -- is passed as keep, and q4nx_cache_candidates() skips by full path or by filename, so the entry is protected even if the root spelling differs. The age window (600 s by default) protects the other fresh entries as a second line of defence. Same bound and same env var as serve start (0 = no sweep), so the two writers cannot disagree about policy.
One copy of the code, three consumers is the right shape: onebit_q4nx_cache is a static library linked by onebit, onebit_npu (PUBLIC, so its dependents get the header) and q4nx_cache_test. A second copy of the sweep would have been the drift that #2264 exists to prevent.
The two new arms pin exactly the two properties: an older 2 GiB sparse entry is removed while the just-written entry survives, and with the bound at 0 nothing is removed. Using a sparse file so the test costs no disk is the detail that makes it runnable in CI.
What I would carry forward (it is #2264's subject, not this PR's, and this PR only relocates the line): the sweep's own note is still "%.1f GiB -> %.1f GiB (bound %.1f GiB)" while the CLI summary uses the MiB/GiB formatter, so a small cache prints 0.0 GiB from the library and 2.0 MiB from the CLI. Since the library is now shared by three binaries and is the only output a conversion-only box gets, that line matters more after this move than it did before it -- worth one size() call in the same file.
And the honest scope is stated: a box that writes nothing still never sweeps, which is #2269's case, and the 60 GiB bound stays a policy call. Both are the right things to leave out of a code-only change.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 503e36f: READY
This solves the problem better than the fix I proposed on #2263, by putting the seam on the correct side of the link. I had suggested extracting app/q4nx_cache.cpp into a third onebit_q4nx_cache target so both onebit and onebit_npu could link it. Moving the sweep down into npu/ achieves the same thing with no new target: npu/q4nx_device.cpp calls it as a local, and app/q4nx_cache.cpp — which sits in onebit, which already links onebit_npu — calls down. One fewer target, and no chance of the two ever disagreeing about which direction to depend.
The thing I checked hardest is that #2264's guarantee survived the move, because relocating a selection function is exactly how a dry-run drifts from its action:
app/q4nx_cache.cpp 0 fs::remove_all, 1 q4nx_cache_candidates() // the --dry-run
npu/q4nx_cache_sweep.cpp 1 fs::remove_all, 2 q4nx_cache_candidates() // inside q4nx_cache_evict
One removal site, inside the one function, using the one selection — so --dry-run still reports exactly what the action does, and now there is a single implementation of that rule in the tree rather than a copy.
The safety property is explicit rather than incidental, which is what I would have asked for:
q4nx_cache_touch(dir);
q4nx_cache_evict(root, dir, q4nx_cache_bound_bytes(std::getenv("ONEBIT_Q4NX_CACHE_MAX_GIB")));dir — the entry this conversion just wrote — is passed as keep. So a conversion cannot evict the container it just produced, and there is no write→evict→write loop. The touch before it puts the new entry last in LRU order. Leaning on min_age alone would have made that a timing assumption; this makes it a fact.
It also reads the same ONEBIT_Q4NX_CACHE_MAX_GIB knob as serve, so the two paths cannot disagree about the bound.
And it removes the property that produced #2263: every box that converts — import, container, the device tests, benches — now bounds the cache as it writes, instead of waiting for a serve that a review box never starts.
One note, not a blocker: the default bound here is 60 GiB while #2269's timer defaults to 40. Not a conflict — both bound it — but the effective steady state between timer runs is the looser of the two, which is worth a line in docs/npu.md so an operator is not surprised by the difference.
…s no conversion sweeps, and the command sweeps the writers' root (refs #2263) On top of #2271, which makes device_form() sweep and moves the sweep to npu/q4nx_cache_sweep. - npu/q4nx_cache_sweep.cpp: each head-image key (headimg-<digest>, all its tiles) is its own LRU entry, aged by its newest tile. Before, head-images/ was one entry, so the sweep could keep every pack's head or remove all of them. On strixhalo 11-12 it was 16 G of 23 G in 36 keys, most unread for a day. - npu/forward/src/q4nx_head_tile_cache.cpp: a head-image hit marks the tile it read. - app/forward_serve.cpp: both forward loads sweep once init succeeds (q4nx_cache_sweep_after_load()). A pack that needs no conversion writes head images, and device_form() sweeps only when it converts. - q4nx_cache_default_root(): $ONEBIT_Q4NX_CACHE, else ~/.cache/1bit/q4nx, shared by serve and `1bit q4nx-cache evict`. The command read ONEBIT_Q4NX_CACHE_DIR / $XDG_CACHE_HOME, which no writer uses. - tests/q4nx_cache_test.cpp: 8 new arms (40); docs/npu.md updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e CLI (refs #2263) (#2275) * q4nx cache: the sweep's note prints sizes in MiB below 1 GiB, like the CLI (refs #2263) Review of #2271: the library's note printed "%.1f GiB" for every size, so a small cache or bound read "0.0 GiB" from the library and "2.0 MiB" from the CLI. Since #2271 the library is linked by three binaries and its note is the only output a conversion-only box gets. q4nx_cache_size_text() (GiB from 1 GiB up, MiB below) lives in npu/q4nx_cache_sweep.cpp and is used by the note and by every size the `1bit q4nx-cache` command prints, including the "sweep is off" line. Four q4nx_cache_test arms pin it (6 MiB, 0, just under 1 GiB, 60 GiB). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * q4nx cache: a size within half a MiB of a GiB reads as GiB, not "1024.0 MiB" (engine#2263) --------- Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: bong-water-water-bong <pi@1bit.local>
…s no conversion sweeps, and the command sweeps the writers' root (refs #2263) On top of #2271, which makes device_form() sweep and moves the sweep to npu/q4nx_cache_sweep. - npu/q4nx_cache_sweep.cpp: each head-image key (headimg-<digest>, all its tiles) is its own LRU entry, aged by its newest tile. Before, head-images/ was one entry, so the sweep could keep every pack's head or remove all of them. On strixhalo 11-12 it was 16 G of 23 G in 36 keys, most unread for a day. - npu/forward/src/q4nx_head_tile_cache.cpp: a head-image hit marks the tile it read. - app/forward_serve.cpp: both forward loads sweep once init succeeds (q4nx_cache_sweep_after_load()). A pack that needs no conversion writes head images, and device_form() sweeps only when it converts. - q4nx_cache_default_root(): $ONEBIT_Q4NX_CACHE, else ~/.cache/1bit/q4nx, shared by serve and `1bit q4nx-cache evict`. The command read ONEBIT_Q4NX_CACHE_DIR / $XDG_CACHE_HOME, which no writer uses. - tests/q4nx_cache_test.cpp: 8 new arms (40); docs/npu.md updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s no conversion sweeps, and the command sweeps the writers' root (refs #2263) (#2276) On top of #2271, which makes device_form() sweep and moves the sweep to npu/q4nx_cache_sweep. - npu/q4nx_cache_sweep.cpp: each head-image key (headimg-<digest>, all its tiles) is its own LRU entry, aged by its newest tile. Before, head-images/ was one entry, so the sweep could keep every pack's head or remove all of them. On strixhalo 11-12 it was 16 G of 23 G in 36 keys, most unread for a day. - npu/forward/src/q4nx_head_tile_cache.cpp: a head-image hit marks the tile it read. - app/forward_serve.cpp: both forward loads sweep once init succeeds (q4nx_cache_sweep_after_load()). A pack that needs no conversion writes head images, and device_form() sweeps only when it converts. - q4nx_cache_default_root(): $ONEBIT_Q4NX_CACHE, else ~/.cache/1bit/q4nx, shared by serve and `1bit q4nx-cache evict`. The command read ONEBIT_Q4NX_CACHE_DIR / $XDG_CACHE_HOME, which no writer uses. - tests/q4nx_cache_test.cpp: 8 new arms (40); docs/npu.md updated. Co-authored-by: bong-water-water-bong <bong-water-water-bong@1bit.gg> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Refs #2263. This is the code half. The scheduler half is #2269 (pkg lane), and the two do not overlap.
What was wrong
The Q4NX cache has three writers and only one evictor.
npu/q4nx_device.cpp'sdevice_form()writes entries for the forward route, but only serve start (app/serve.cpp) and1bit q4nx-cache evictever callq4nx_cache_evict(). So a box that converts through the forward route and never starts a serve grows pastONEBIT_Q4NX_CACHE_MAX_GIBwith no sweep at all. The issue's census notes thatnpu/could not call the sweep without includingapp/.Change
q4nx_cache_evict,q4nx_cache_candidates,q4nx_dir_bytes,q4nx_cache_bound_bytes,q4nx_cache_touchand their structs move unchanged fromapp/q4nx_cache.{h,cpp}tonpu/q4nx_cache_sweep.{h,cpp}. Every removed line appears unchanged in the new files. They build asonebit_q4nx_cache, a static library thatonebit,onebit_npu(PUBLIC) andq4nx_cache_testlink, so each binary carries one copy.app/q4nx_cache.hkeeps the1bit q4nx-cachecommand and includes the sweep header.device_form()sweeps after a new conversion is renamed into place. It uses the same bound (ONEBIT_Q4NX_CACHE_MAX_GIB, default 60,0= no sweep) and the same age window, and never removes the entry it just wrote. Its reuse path now callsq4nx_cache_touch()in place of a copy of the same call.docs/npu.md's cache-bound paragraph says the conversion sweeps too, and that1bit q4nx-cache evictremains the manual trigger for a box that writes nothing.What this does not do: a box that writes nothing still never sweeps, which is #2269's case. The bound stays 60 GiB, and lowering it is a policy call. The 59 G in the issue was under the 60 GiB bound.
Tests
npu_pack_import_testhas two new arms. In both, a 2 GiB older entry (a sparse file, so no real disk) sits in the cache past the age window.ONEBIT_Q4NX_CACHE_MAX_GIB=1, a new conversion removes it and keeps its own entry. The log line isremoved 1 entry, 2.0 GiB -> 0.0 GiB (bound 1.0 GiB, …).=0it is kept.q4nx_cache_evictcall removed fromdevice_form(), the test returns rc 1 with exactly one FAIL,cache sweep: a new conversion over a 1 GiB bound removes the older 2 GiB entry. Restored, it returns rc 0.q4nx_cache_testpasses all 32 arms against the moved code.ctest -j 6passes 267 of 267, with skips, excludingnpu_device_lock,device_holdersanddevice_route. The NPU is off, and this build has no NPU runtime (ONEBIT_NPU=OFF).tools/copyright.py --checkreturns rc 0.🤖 Generated with Claude Code