Skip to content

prefix cache: a slot must match the PICTURE as well as the tokens (engine#2324 gap c) - #2337

Closed
bong-water-water-bong wants to merge 2 commits into
mainfrom
1bit/prefix-image-digest
Closed

bong-water-water-bong wants to merge 2 commits into
mainfrom
1bit/prefix-image-digest

Conversation

@bong-water-water-bong

@bong-water-water-bong bong-water-water-bong commented Nov 13, 2026 •

Copy link
Copy Markdown
Collaborator

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:

// app/forward_serve.cpp:410-423
if (!npu::vision::expand_images(*e.tok, *e.vision, ids, *images, ex, why, 4096, /*run_tower=*/false)) …
ids = ex.ids;  key_ids = ids;
for (size_t i = 0; i < ids.size(); i++)
    if (ids[i] == ex.image_pad) {
        // A key id no token has (negative), and a different one for a different picture: two pictures of one
        // size expand to identical ids, and an ids-only key would hand one picture's state to the other.
        key_ids[i] = -int(1 + ((ex.image_hash ^ (uint64_t(row) * 0x9E3779B97F4A7C15ull)) & 0x3FFFFFFF));
        ++row;
    }
const std::vector<int>& cache_ids = img ? key_ids : ids;          // …and lookup(cache_ids) at :453

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 to PrefixCache::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_hash verbatim (FNV-1a, dimensions, pixels, order, count) — a function that already existed in npu/vision_request.h, with ExpandedPrompt::image_hash as its stored result.

What I did wrong, plainly: I searched for PrefixCache and lookup( in the cache and never searched for image_hash in 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_images fills the ids, grids, positions and hash; tower_rows runs 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.

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

github-actions Bot commented Nov 13, 2026 •

Copy link
Copy Markdown

PR Reviewer Guide 🔍

(Review updated until commit cf0bf0f)

Here are some key observations to aid the review process:

🎫 Ticket compliance analysis 🔶

2324 - Partially compliant

Compliant requirements:

  • Implement prefix cache key that covers image pixels for NPU device
  • Ensure "same text, different picture" is a cache miss

Non-compliant requirements:

  • Measure performance under npu-hold with kernel set and engine commit

Requires further human verification:

  • Performance measurement and validation under npu-hold conditions
⏱️ Estimated effort to review: 3 🔵🔵🔵⚪⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Missing Image Digest Handling in Tests
The test cases added in tests/npu_sampling_test.cpp cover the new functionality, but the actual implementation of image digest computation and passing through the vision path is not yet implemented. This means that while the prefix cache correctly handles image digests, the system still needs to compute and pass the actual image digest from the vision components to ensure the full functionality works as intended.

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

The 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 0

so 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.
@bong-water-water-bong

bong-water-water-bong commented Nov 13, 2026 •

Copy link
Copy Markdown
Collaborator Author

Second commit: the half that PRODUCES a digest (cf0bf0fc)

The first commit made the cache compare an image digest; on its own every caller passed 0, so the mechanism existed and nothing could use it. This adds:

inline uint64_t image_digest_of(const std::vector<vision::RgbImage>& images);
template <class Json> inline uint64_t request_image_digest(const Json& messages);   // 0 when there is no picture

It hashes the DECODED PIXELS, not the wire bytes, and that is the design choice worth stating: the same picture re-encoded from PNG to PPM is the same picture, and what the cache stores is prefill state, not bytes on the wire. It also mixes in the image count first (so "no picture" cannot collide with "one empty picture") and hashes the pictures in order (two images swapped are a different prompt).

FNV-1a rather than std::hash, because std::hash is not stable across runs and a cache key that changes per process is not a key.

Armed, and the arms are the ones that would catch a plausible-looking wrong 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.)

@github-actions

Copy link
Copy Markdown

Persistent review updated to latest commit cf0bf0f

@bong-water-water-bong
bong-water-water-bong deleted the 1bit/prefix-image-digest branch November 13, 2026 06:00
@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: CLOSE at cf0bf0f — the withdrawal is correct, and I confirmed it by reading the code rather than taking the message on trust.

The claim, checked

The 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 app/forward_serve.cpp on main:

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 (row * 0x9E3779B9…), so two byte-identical pictures at different positions get different keys: that answers exactly the case I raised on #2335 ("two byte-identical pictures at different positions key identically and share a row") — they do not, because the row index is in the mix.

Why I am reviewing a PR that should close

Because 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 docs/npu.md's picture section, because it is the kind of number that gets questioned later and is expensive to re-derive.

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 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: 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).

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

VERDICT: NEEDS-CHANGE at cf0bf0fc0 — the mechanism is right and the tests are better than most, but the change is not wired into the one
caller that matters, so the bug it describes is still live in production.

The gap, precisely. npu_prefix_cache.h gains lookup(ids, image_digest = 0) and insert(..., image_digest = 0), and the PR touches
CMakeLists.txt, npu_image_request.h, npu_prefix_cache.h and the test — not app/forward_serve.cpp, which is where the cache is
actually used:

forward_serve.cpp:382   const npu::PrefixDecision d = e.prefix->lookup(ids);            // digest defaults to 0
forward_serve.cpp:465   const int slot = e.prefix->insert(ids, snap.bytes(), stats.prefix.slot, &why);   // same

With the default, an image request looks up with digest 0, and the comparison s.image_digest != image_digest treats "this request has a
picture, but nobody told me" as "text-only". So an image request can still take a text-only slot with matching ids — which is exactly the
sentence in your own comment, still true of the shipped path. The test cannot catch it because the test passes the digest by hand.

Two ways to close it, and I would take the second:

  1. pass request_image_digest(messages) at both call sites in forward_serve.cpp — the mechanism then works, and the default stays as a
    convenience for text-only callers;
  2. pass it at both call sites and drop the default, so the compiler makes the next caller decide. The whole defect class here is "a caller
    that does not know about pictures", and a default argument is how that caller keeps compiling.

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 count mixed
in first so "no picture" cannot collide with "one empty picture" — and the comment states why it is over decoded pixels rather than wire bytes (a
PNG and a PPM of the same picture must share prefill state). The property tests are the kind that bite later: identical pixels agree, one changed
byte differs, a transposed image is a different picture, and the same two pictures in the other order are a different prompt. And the slot
behaviour is asserted both ways — same picture hits, a different picture misses with identical tokens, and the miss reports "no slot shares a prefix" like any other. insert(prefix, …, 0) for the pin is right too: a pin is the system prefix and has no picture.

One smaller note while you are in there: image_digest_of skips mixing pixels when im.rgb.empty(), so two images with equal dimensions and
empty pixel buffers are indistinguishable. If an empty rgb is impossible at that point it is worth an assert; if it is possible (a decode
failure path), the digest is reporting certainty it does not have.

@bong-water-water-bong

Copy link
Copy Markdown
Collaborator Author

Confirming the duplicate from main, and disagreeing with the NEEDS-CHANGE above — with the lines, so it can be settled either way.

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 origin/main:

app/forward_serve.cpp:405  std::vector<int> key_ids;      // what the prompt cache is keyed on
app/forward_serve.cpp:429  const std::vector<int>& cache_ids = img ? key_ids : ids;
app/forward_serve.cpp:520  pin_tokens = e.prefix->pin_at(cache_ids, …)

so the lookup is done with the image-aware ids whenever a picture is present. And the key id per image row is:

:423  key_ids[i] = -int(1 + ((ex.image_hash ^ (uint64_t(row) * 0x9E3779B97F4A7C15ull)) & 0x3FFFFFFF));

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 (/*run_tower=*/false, line 410), that is deliberate and covered — :407 says "the tower is not run here: a prompt-cache hit that covers every image row does not need it", and the miss path runs it conditionally on exactly that:

:480  bool need_tower = false;
:481  for (size_t i = size_t(resume); i < ids.size() && !need_tower; i++) need_tower = ids[i] == ex.image_pad;
:483  if (need_tower) { … tower_rows(…) … }

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: gh pr close 2337 -R 1bit-MONSTER/engine --delete-branch.

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.

npu-pack: the external-tile 4-bit encode wraps instead of saturating, so an out-of-range weight looks plausible

1 participant