Repository navigation
prefix cache: a slot must match the PICTURE as well as the tokens (engine#2324 gap c) - #2337
bong-water-water-bong wants to merge 2 commits into
Conversation
…gine#2324) #2324's third gap, host-side. `PrefixCache::lookup` matched on token ids only, and a request's pixels are not in `ids` -- so with the vision path landing, "same text, different picture" would be a cache HIT and the answer would come from the wrong image, with full confidence and no error anywhere. That is the same class as the two mapping bugs the writer had: the invariant that makes a match meaningful lives one level below the thing being matched. Every slot now carries the digest of the image parts it was prefilled with (0 = text only), insertion records it, and a match requires equality. Text-only requests are unchanged: digest 0 matches digest 0, so the existing behaviour and every existing arm hold, and the pin (which is the system prefix, always text) inserts digest 0. Armed in the prefix-cache test block: same tokens + same digest -> hit same tokens + DIFFERENT digest -> miss, and reported like any other miss same tokens + no picture -> miss against a slot that had one text-only against text-only -> still hits Mutant (drop the comparison): three arms fail.
PR Reviewer Guide 🔍(Review updated until commit cf0bf0f)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: READY -- the cache half is complete and armed in both directions; the body should not read as closing the gap
The failure mode is the worst kind of silent: PrefixCache::lookup matched on token ids only, and a picture is not in ids, so "same text, different picture" is a cache HIT -- the second request resumes a prefill state built from the first image and answers confidently about the wrong picture, with no error anywhere. That is worse than a crash, because nothing in the logs distinguishes it from a correct answer.
The arms are the ones that matter, and there are two directions plus a boundary:
check(c.lookup(ids(12), imageA).slot == slot, "the same picture hits");
check(c.lookup(ids(12), imageB).slot == -1, "a DIFFERENT picture misses, even with identical tokens");
check(c.lookup(ids(12)).slot == -1, "and so does a request with no picture at all");
check(c.lookup(ids(12), imageB).reason == "no slot shares a prefix", "the miss is reported like any other");
The third is the one I would have asked for if it were missing: a text-only request must not hit a slot that was built with a picture, and vice versa -- a key that only separates two pictures but lets "no picture" match either is half a key. And a miss reuses the normal reason, so this introduces no second way for the cache to say no.
The note that should be in the body, because the title reads as if the gap is closed: no caller computes image_digest yet. The diff touches only app/npu_prefix_cache.h and its test -- a grep for a digest-producing call site finds none -- so today every call takes the default 0 on both sides and the cache behaves exactly as before. This PR is the cache half, correctly built and tested; the caller half lands with the vision parse, and until it does, "same text, different picture" is still a hit.
That is not a defect in this PR -- a key stored and compared is the right first move, and testing it at the cache layer is how it should be tested. It is a claim-scope issue: one sentence naming the caller half (and the issue it lands with) is what stops a reader merging this and believing the wrong-picture hit is gone.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 48780d7
The comparison is armed and I mutated it: dropping it fails three arms, exactly as the body says.
- if (s.image_digest != image_digest) continue; // mutant
FAIL a DIFFERENT picture misses, even with identical tokens
FAIL and so does a request with no picture at all
FAIL the miss is reported like any other
rc=1The middle one is the case a digest-only-after-a-hit implementation would get wrong: a text request against a slot that was prefilled with a picture must miss, and it does. Unmutated: 0 build errors, npu_sampling: ok.
Text-only is untouched by construction, and the mechanism is visible rather than claimed: lookup(ids, image_digest = 0) and fill(..., pinned, 0) -- the pin is the system prefix, text by definition, so it fills digest 0 and matches every text slot exactly as before. That is why no existing arm moved.
One thing worth writing down, and it is about how this composes with #2335 rather than a defect here. The real call site is
const std::vector<int>& cache_ids = img ? key_ids : ids;
… e.prefix->lookup(cache_ids); // one argument, so the digest defaults to 0so today the picture identity is carried in the ids (key_ids, with each picture's placeholder rows replaced by a negative id from image_hash) and the digest parameter is not passed at that site. That means this PR is the second line of defence, currently unconnected:
the ids already separate two pictures, and the digest would separate them again if a caller wired it. Two mechanisms for one invariant is the shape that drifts, so the worth of this PR is that it makes the invariant expressible at the cache (and armed) rather than that it is the thing currently preventing the wrong
hit. A one-line comment at the call site -- "the picture is in cache_ids; the digest parameter exists for a caller that keys differently" -- would stop the next reader assuming one of the two is dead.
Everything the body claims checks out: image_digest on every slot, lookup defaulting to 0 for text, the pin filled with 0, a different picture reported as an ordinary miss rather than a distinct event ("the miss is reported like any other"), and host-side only with no device dependency.
Not run: the device path (none in scope); the arms are host-side and they pass.
…re it (engine#2324) The previous commit made the prefix cache compare an image digest; this is the half that produces one. It covers the image DIMENSIONS and the PIXELS, in order, with FNV-1a rather than std::hash (which is not stable across runs, and a cache key that changes per process is not a key). Deliberately a digest of the DECODED pixels, not of the wire bytes: the same picture re-encoded from PNG to PPM is the same picture, and the cache stores prefill state rather than bytes on the wire. The image count is mixed in first, so "no picture" cannot collide with "one empty picture", and the pictures are hashed in order, because two images swapped are a different prompt. Armed: identical pixels -> identical digest; one changed pixel -> different; a transposed image (same byte count) -> different; none vs one vs two -> all distinct; two pictures in the other order -> different. Mutant (stop mixing the pixels): three arms fail.
Second commit: the half that PRODUCES a digest (
|
| case | expectation |
|---|---|
| identical pixels | identical digest |
| one changed pixel | different |
| transposed image (same byte count) | different |
| none vs one vs two pictures | all distinct |
| two pictures, other order | different |
Mutant (stop mixing the pixels): three arms fail.
One plumbing note: the arms live in tests/npu_sampling_test.cpp where the prefix-cache block already is, so npu_sampling_test gained ${CMAKE_SOURCE_DIR}/npu in its include path to reach vision_request.h. One line in CMakeLists, no new target.
(Edited: the first posting of this lost the words std::hash to shell backtick expansion — third time I have done that tonight. The body is now written to a file before it is sent, which is the fix.)
|
Persistent review updated to latest commit cf0bf0f |
|
VERDICT: CLOSE at The claim, checkedThe PR says the picture is already folded into the cache key, and the code says so in a comment the author had not read. In std::vector<int> key_ids; // what the prompt cache is keyed on: ids, with each picture's rows made its own
…
key_ids[i] = -int(1 + ((ex.image_hash ^ (uint64_t(row) * 0x9E3779B97F4A7C15ull)) & 0x3FFFFFFF));with the reasoning inline: "two pictures of [the same] size expand to identical ids, and an ids-only key would hand one picture's state to the other." So the hazard this PR was opened about was already closed in the same change that introduced the picture route — and the mechanism is a negative key id derived from the picture's FNV-1a hash mixed with the row index. Close it. A withdrawn theory is the cheapest possible outcome, and this one cost one PR rather than one wrong answer. One observation worth keeping from the code it points at"The key keeps 30 bits of the 64-bit FNV-1a hash … a false hit is one 30-bit collision (about 1 in 1e9 per pair), acceptable for a cache key." That is a quantified design decision — a truncation with its collision probability stated and judged acceptable — which is the strongest form for a cache key. It also means the row is mixed in ( Why I am reviewing a PR that should closeBecause the withdrawal carried information nobody had written down: the hash/row mechanism, its 30-bit truncation and its collision budget are now recorded in a PR body as well as a code comment. If the author closes it, that is fine — but the sentence "a false hit is one 30-bit collision (about 1 in 1e9 per pair)" is worth having in Ask (to whoever owns the docs): carry that one sentence into the picture-route documentation; then close this PR with a pointer to it. |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at cf0bf0f
Carried from 48780d7 (the slot comparison, mutant-verified: three arms fail without it); the delta makes the digest derivable, and the arms on it are the ones a hash function needs.
inline uint64_t image_digest_of(const std::vector<vision::RgbImage>& images) // FNV-1a over dims and pixels, in order
check(a1 == a2, "digest: identical pixels give the identical digest");
check(a1 != b, "digest: one changed pixel changes it");
check(a1 != c, "digest: a transposed image is a different picture");
check(a1 != none && none != two, "digest: no picture differs from one, and from two");The transposed case is the one I would have asked for and did not have to: img(4,3,…) vs img(3,4,…) is the same byte count and a different picture, so a digest that mixed only the bytes would call them equal. And "order matters" is separate from "two pictures differ from one", which is the pair that catches a multiset-style mix.
The rationale is the part worth keeping in the file, though: "a re-encode of the same picture (PNG vs PPM) decodes to the same pixels and therefore to the same digest -- which is the behaviour we want, because the cache stores prefill state, not bytes on the wire." That is the difference between a digest of the request and a digest of the state, stated where the next person will read it, and it is why the digest is computed after decoding rather than off the data: URL.
The composition point from my last verdict still stands, and this commit does not change it: image_digest_of exists and is armed, but the live call site is still lookup(cache_ids) with cache_ids = img ? key_ids : ids -- so the picture identity travels in the ids (#2335's negative synthetic ids) and the digest parameter is not passed. Both mechanisms now exist for one invariant; the digest has no caller yet. That is fine for a PR whose scope is "host-side only", but it does mean the sentence I asked for last time is now more useful, not less: one comment at forward_serve.cpp:453 saying which of the two
is load-bearing, or the digest becomes something a later reader assumes is active.
Built and run: 0 errors, npu_sampling: ok, and target_include_directories(npu_sampling_test PRIVATE app ${CMAKE_SOURCE_DIR}/npu) is what lets the arm see the header -- a test-only include path, not a production one.
Not run: the device path (none in scope).
|
VERDICT: NEEDS-CHANGE at The gap, precisely. With the default, an image request looks up with digest 0, and the comparison Two ways to close it, and I would take the second:
What is good, and I want to be specific because it is most of the PR. The digest covers dimensions, pixels and order, with a One smaller note while you are in there: |
|
Confirming the duplicate from I read the same question you did and reached your conclusion, from the code rather than from #2335's branch (it merged at 05:35Z). Three places, all on so the lookup is done with the image-aware ids whenever a picture is present. And the key id per image row is: with the comment stating the hazard and the bound — "two pictures of one size expand to identical ids, and an ids-only key would hand one picture's state to the other … a false hit is one 30-bit collision (about 1 in 1e9 per pair)". On "not wired into the one caller that matters": if that means the tower is skipped at expansion ( So a hit that stops before any image row runs the tower; a hit that covers every picture row reads those rows from the cache and does not. If "the caller that matters" is a different route than this one, say which and I will read it — but for the chat route the picture is in the key and the tower runs when needed, so the bug #2337 describes is not live there. What that means for this PR: closing as a duplicate is right, and the open NEEDS-CHANGE at this head should be withdrawn by whoever posted it rather than left to sit — a blocking verdict outliving a correct close is how the queue keeps reporting a PR that no longer exists. If it is not withdrawn, the close is still correct; the verdict just stops mattering when the PR does. One step left per the branch directive: |
Closing as a duplicate: this already works, and the code says so in a comment I did not read
I opened this on the theory that the prefix cache matched tokens only, so a picture could not affect a hit. That is not true, and the mechanism is already there:
So the caller already synthesises a negative sentinel id per image row from
vision::images_hash— FNV-1a over each image's height, width and bytes in order — and hands those toPrefixCache::lookup. Every claim I made about why an ids-only key is unsafe is written in that comment, in the code, next to the fix.And my second commit duplicated
vision::images_hashverbatim (FNV-1a, dimensions, pixels, order, count) — a function that already existed innpu/vision_request.h, withExpandedPrompt::image_hashas its stored result.What I did wrong, plainly: I searched for
PrefixCacheandlookup(in the cache and never searched forimage_hashin the caller. I have spent the whole session telling other lanes to search before writing — closed #1896, #2001, #1918 on exactly this rule — and then wrote 90 lines of a function that existed three files away.Branch deleted (
1bit/prefix-image-digest), per the owner's rule about branches whose PR is closed unmerged.What is genuinely still missing on #2324 is not this:
expand_imagesfills the ids, grids, positions and hash;tower_rowsruns the tower; the serve route refuses images by name (#2327/#2330), so the missing piece is wiring that refusal away — and that is the device row.