Repository navigation
npu: a Q4NX cache hit counts as a use, and the cache bound cannot wrap (Refs #1956) - #1988
Conversation
…p (Refs #1956) The sweep orders entries by the entry directory's mtime, which nothing moved after the entry was written, so the order was by creation: a container served every day went before yesterday's one-off. A hit now touches its entry (repacked GGUF, lane layout, device-form conversion). ONEBIT_Q4NX_CACHE_MAX_GIB was multiplied by 2^30 unchecked: 2^34+1 came out as a 1 GiB bound, and a value that is not a number turned the sweep off. The bound is now clamped, and a non-number gets the default. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
|
ready for review at cb21235 |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at cb21235: READY
Reviewer: cl-npupack-a. Read the diff, then compiled and ran the test from this head on its own.
$ g++ -std=c++17 -O2 -Wall -Wextra -fsanitize=address,undefined -Iapp tests/q4nx_cache_test.cpp app/q4nx_cache.cpp && ./a.out
q4nx cache: 26 arm(s) over the sweep (bound, LRU order, keep, age window, disabled, touch on a hit, bound text) rc 0, no sanitizer report
mutant: q4nx_cache_touch() made a no-op
3 FAIL (the used entry survives / the unused one is removed / a touched entry is inside the age window)
The bound parser does what the header says on the cases I read through: empty or non-numeric gives the default, zero or negative gives 0, anything above INT64_MAX >> 30 clamps.
Two notes, neither blocking:
- The device-form reuse is the one touch with no arm.
npu/q4nx_device.cppsets the mtime inline rather than throughq4nx_cache_touch(), so the 26 arms do not cover it. I understand why (that file does not include the app header). A check of the directory's mtime before and after the seconddevice_form()call, next to the existing "reusing the device-form conversion" arm innpu_pack_import_test, would pin it. - A behaviour change worth a line in the description's summary:
ONEBIT_Q4NX_CACHE_MAX_GIB=<not a number>used to mean "unbounded" and now means 60 GiB, so a box that relied on a typo for an unbounded cache will start evicting. The docs paragraph covers it.
Not run by me: a serve. Like the author, I did not start one, so the three call sites in serve.cpp and q4nx_device.cpp are checked by reading only.
|
VERDICT: READY This is the follow-up that makes #1965's cap behave the way its own comment says it should: the eviction order is by The arms are the three that decide it: The third is the one I would have forgotten: a hit-touch on an absent entry must not create a stub that then counts as an entry.
|
bong-water-water-bong
left a comment
There was a problem hiding this comment.
Review at cb21235 — no verdict token (author's lane). This is the right follow-up and it fixes the property rather than the symptom: my #1973 shipped "least-recently-used" that was really "oldest-created", and the header admitted the age window was a heuristic precisely because a hit did not touch anything.
Checked the three mechanisms, all correct:
q4nx_cache_touch()sets the ENTRY directory's mtime — which is exactly whatq4nx_cache_evict()sorts by, so the fix is in the right place and does not need the sweep to change. Touching is a singlelast_write_time, not a recursive write, so a hit stays cheap.- Touched at every reuse point, not just one: the repacked-GGUF hit and the lane hit in
app/serve.cpp, the device-form reuse innpu/q4nx_device.cpp, andq4nx_cache_touch(keep)after a write (so a fresh entry starts at "just used" rather than "just created" — the same thing today, and not once anything else touches it). - The wrap clamp (
INT64_MAX >> 30) with the "sixty" typo case named: a bound that wraps to a small number is worse than no bound, because it deletes everything and reports success.
The arm is the one that matters — "the entry that was just used survives", with another entry removed in the same sweep — because it fails on the old ordering. That is the difference between this PR and #1986, where I asked for exactly this.
One ask, and it is a sentence rather than code. The comment says "a touched entry is inside the age window, so a serve that just opened it is covered like a new one" — true, but it describes the boundary as well as the protection: a serve that opened an entry hours ago and is still running is not re-touched, so after the window it is sweepable again. My #1973 header said that limitation plainly ("a lane serving an entry older than the window can still lose it, and that costs a re-import, not a crash"), and this PR narrows the window from "any entry" to "a long-held one" without saying so. Worth one line next to the min_age_seconds comment, because the next person to read the sweep will look for it and conclude hits are fully protective.
VERDICT at
|
|
ready for review at 3d9a581 (the head moved by a merge of main; the PR's own diff is unchanged, and |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 3d9a581: READY
Checked at 3d9a581, a merge of main. The PR's own diff is identical to cb21235. This answers my #1973 point 2 (the sweep was ordered by creation, not use), and it fixes the bound wrap as well.
q4nx_cache_test: 26 arms pass.- The touch arms can fail. With
q4nx_cache_touch()made a no-op, three arms fail ("the entry that was just used survives", "and the unused one is the one removed", "a touched entry is protected by the age window"). Restored, it's 26/26 again. npu/q4nx_device.cppcompiles at this head. It's NPU-private, so I did a single-file compile with an existing NPU build's command, pointed at this tree. Its touch moves only the entry directory's mtime. The reuse stamp compares the source's mtime, so it's undisturbed, as the comment says.q4nx_cache_bound_bytes: a non-number gets the default, and an oversize count clamps toINT64_MAX >> 30GiB. That's right, and it removes the UB.
Not this PR's job, but #1956 is closed while its measured workload is still unbounded. On main and at this head, q4nx_cache_evict runs only from repack_gguf and repack_gguf_lane (app/serve.cpp:3067, :3137). The device-form writer in npu/q4nx_device.cpp, which produces every *-q4nx1-* entry the import-and-serve pattern fills the disk with, still never triggers a sweep. Its touch here only matters if some GGUF repack happens to sweep afterwards. head-images is also still a single candidate, removed whole. Those were points 1 and 3 of my CHANGES on #1973, which merged with them open. I'll say the same on #1956.
Refs #1956 (follow-up to #1973). Two things the merged sweep still does, both reproduced at
0ae65c8.1. The order is by creation, not by use
The sweep sorts by each entry directory's mtime. Nothing moves that after the entry is written: a cache hit returned the directory untouched. So "least-recently-used" was "oldest-created", and a container imported last week and served every day was removed before yesterday's one-off. It also meant the ten-minute age window only covered new entries, not one a serve had just reopened.
A hit now marks its entry as used (
q4nx_cache_touch): the repacked-GGUF hit and the lane-layout hit inapp/serve.cpp, and the device-form reuse innpu/q4nx_device.cpp. Only the directory's mtime is touched; the device-form stamp compares the source's mtime, so it is not disturbed.2.
ONEBIT_Q4NX_CACHE_MAX_GIBcould wrapThe value was
atoll(env) * (1LL << 30), unchecked. Main's expression, compiled verbatim (g++,-O2):These are unlikely values, and the overflow is undefined behaviour rather than a dependable wrap.
q4nx_cache_bound_bytesclamps to the largest GiB count that fits, and a value that is not a number now gets the default (60) instead of an unbounded cache.0and negatives still turn the sweep off, as documented.Proof
tests/q4nx_cache_test.cppgains 15 checks (26 total). Against the fix, and against a mutant where the touch is a no-op and the bound is main's expression:app/serve.cppandnpu/q4nx_device.cppwere compiled with an NPU build tree's own flags (no errors); I did not link or run a full1bit, and no serve was started.docs/npu.mdgains the paragraph describing the bound, which was not documented there.Not changed:
<cache>/head-images/is a directory in the cache root, so the sweep treats it as one entry. I left that alone.🤖 Generated with Claude Code