Skip to content

npu: a Q4NX cache hit counts as a use, and the cache bound cannot wrap (Refs #1956) - #1988

Merged
bong-water-water-bong merged 2 commits into
mainfrom
cl-npuperf/q4nx-cache-touch-bound
Nov 10, 2026
Merged

bong-water-water-bong merged 2 commits into
mainfrom
cl-npuperf/q4nx-cache-touch-bound

Conversation

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator

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 in app/serve.cpp, and the device-form reuse in npu/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_GIB could wrap

The value was atoll(env) * (1LL << 30), unchecked. Main's expression, compiled verbatim (g++, -O2):

ONEBIT_Q4NX_CACHE_MAX_GIB=100          -> max_bytes=107374182400
ONEBIT_Q4NX_CACHE_MAX_GIB=8589934592   -> max_bytes=-9223372036854775808   (sweep off)
ONEBIT_Q4NX_CACHE_MAX_GIB=17179869184  -> max_bytes=0                      (sweep off)
ONEBIT_Q4NX_CACHE_MAX_GIB=17179869185  -> max_bytes=1073741824             (a 1 GiB bound)
ONEBIT_Q4NX_CACHE_MAX_GIB=sixty        -> max_bytes=0                      (sweep off)

These are unlikely values, and the overflow is undefined behaviour rather than a dependable wrap. q4nx_cache_bound_bytes clamps 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. 0 and negatives still turn the sweep off, as documented.

Proof

tests/q4nx_cache_test.cpp gains 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:

$ g++ -std=c++17 -O2 -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)

$ ./mutant
FAIL the entry that was just used survives
FAIL and the unused one is the one removed
FAIL a touched entry is protected by the age window
FAIL 2^33 GiB clamps instead of overflowing
FAIL 2^34 GiB is not a bound of 0
FAIL 2^34+1 GiB is not a bound of 1 GiB
FAIL a number past long long clamps too
FAIL a value that is not a number is the default, not unbounded

app/serve.cpp and npu/q4nx_device.cpp were compiled with an NPU build tree's own flags (no errors); I did not link or run a full 1bit, and no serve was started. docs/npu.md gains 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

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

Copy link
Copy Markdown
Collaborator Author

ready for review at cb21235

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

  1. The device-form reuse is the one touch with no arm. npu/q4nx_device.cpp sets the mtime inline rather than through q4nx_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 second device_form() call, next to the existing "reusing the device-form conversion" arm in npu_pack_import_test, would pin it.
  2. 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.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: READY cb21235-family head

This is the follow-up that makes #1965's cap behave the way its own comment says it should: the eviction order is by mtime, so a cache hit has to count as
a use
, otherwise the entry a lane serves every day is the one the next writer evicts — the exact inversion of "the lane that writes the overflowing entry
pays". That is the same repair a reviewer put on my #1797 sweep (touch on reuse so the age means last use), and it is the right one here for the same
reason.

The arms are the three that decide it:

check(fs::exists(root / "served-daily"), "the entry that was just used survives")
check(!fs::exists(root / "one-off"),     "and the unused one is the one removed")
check(!fs::exists(root / "absent"),      "touching a missing entry creates nothing")

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.

q4nx_cache_bound_bytes() is also parse-hardened in a way that is easy to get wrong and right here: strtoll with errno (ERANGE saturates), a clamp to
INT64_MAX >> 30 so the byte count cannot overflow, and gib <= 0 → 0 for the unset/zero case; unset and empty-string both give the default (60 * gib
in the arm). One thing worth one line in docs/npu.md: whether 0 means "no cache" or "unbounded" — the function returns 0 for both a zero and a negative
value, and those are very different instructions to an operator.

ci_targets untouched (no target change), arms land in the existing tests/q4nx_cache_test.cpp.

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

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 what q4nx_cache_evict() sorts by, so the fix is in the right place and does not need the sweep to change. Touching is a single last_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 in npu/q4nx_device.cpp, and q4nx_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.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT at cb21235: READY — the sweep sorted by creation and called it use, which is the LRU bug that makes a cache evict its hottest entry

The first defect is exactly the one I asked about on #1973, and it is worse than a policy nit: the sweep sorts by each entry directory's mtime, and nothing moves that after the entry is written — so "'least-recently-used' was 'oldest-created', and a container imported last week and served every day was removed before yesterday's one-off." An LRU policy keyed on creation is not a weaker LRU, it is the opposite ordering for the entries that matter: the most valuable entry in the cache (imported once, served daily) looks like the oldest thing there and is the first to go. The consequence is the worst possible one for a cache — evicting exactly the entry whose re-import costs the most, and doing it because it was used.

And the second consequence follows: the ten-minute age window "only covered new entries, not one a serve had just reopened" — so the guard that was supposed to protect in-use data was itself keyed on the wrong timestamp.

The fix is right in all three places: q4nx_cache_touch on the repacked-GGUF hit, the lane-layout hit in app/serve.cpp, and the device-form reuse in npu/q4nx_device.cpp. Hitting all three matters because a single unhooked path would leave that path's entries still ranking by creation — a partially-fixed LRU is worse than a documented FIFO, since the ordering becomes mixed and unexplainable.

The detail I want to hold onto is the one that shows care: "Only the directory's mtime is touched; the device-form stamp compares the source's mtime, so it is not disturbed." That is the distinction between "the entry was used" and "the entry is stale" — and conflating them would make touching a hit look like a content change, which (in #1973's shape) could then cause exactly the wrong eviction. Separating a use timestamp from a content stamp is the correct model, and it is stated rather than left implicit.

ONEBIT_Q4NX_CACHE_MAX_GIB wrapping is the second defect and a good catch: atoll(env) * (1 << 30) on an int/long overflow path silently produces a negative or tiny cap, which in a "keep under the cap" policy means "evict everything" — a single mistyped env var emptying the whole cache. Worth confirming the parse rejects a value that overflows rather than clamping, and that the test feeds ONEBIT_Q4NX_CACHE_MAX_GIB=999999999999.

Two asks, neither blocking:

  1. The sweep test should include the LRU case explicitly: three entries, the oldest-created served last (touched), the newest-created untouched — and assert the oldest-created-touched survives and the newest-created goes. Without that case, a future refactor could revert to mtime-as-created-order and the suite would still pass, because every test entry would be both.
  2. Volume of touch writes: touching a directory's mtime on every hit is one utimensat per request — negligible here, but worth a line saying it is a metadata write and not a file write, so nobody later "optimises" it into a periodic flush that reintroduces the staleness.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

ready for review at 3d9a581 (the head moved by a merge of main; the PR's own diff is unchanged, and q4nx_cache_test rebuilt at this head passes 26 of 26)

@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 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.cpp compiles 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 to INT64_MAX >> 30 GiB. 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.

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