Repository navigation
forward-serve: pictures on the chat route, with a prompt cache keyed on the picture (refs #2324) - #2335
Conversation
…on the picture (refs #2324) A pack with a vision component (MiniCPM-V 4.7: vision_weight.q4nx) now takes OpenAI image_url parts on /v1/chat/completions: - data: URLs only; PNG, JPEG and binary PPM decoded by stb_image (pinned via FetchContent, built with those three formats only). Over-large pictures, a part with no URL and a pack with no vision component are named 400s. - npu/vision_request.h replaces each <|image_pad|> the pack's template renders with the picture's tokens, runs the tower per piece on the host, and lays out the three position planes; the prompt goes through step_batch_embeds in the route's chunks, and decoding resumes at the planes' next position. - the prompt cache is keyed on the ids with each picture row replaced by a key from that picture's bytes: two pictures of one size expand to identical ids. The ids, positions and key come from the plan alone, the lookup runs first, and the tower runs only when a picture row lies past the resume point. - speculative decoding is off for a request with pictures. Measured, MiniCPM-V-4.7-1B-NPU2 on the NPU, a 672 x 1344 picture: red 43.0 s, the same picture again 1.1 s (485 of 486 rows cached, no tower), blue at the same size 55.5 s and answered "Blue". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
ready for review at e7cb1eb |
PR Reviewer Guide 🔍(Review updated until commit b2662bf)Here are some key observations to aid the review process:
|
|
VERDICT: READY The change is the one this needed: a silently-dropped Every failure path names its reason and is a 400, which is the discipline the old behaviour lacked: "this model has no vision component, so it takes no pictures", "pictures need the pack's own chat template, and this build or pack has none", a part with no URL, an image past That last one is the same class of bug that cost this fleet a night: two widths joined without anyone asking what the join means. A tower that projects to 4096 into a text model expecting 2560 would otherwise embed something plausible, and nothing downstream would complain. The refusal covers the "no vision component" case at startup too — "vision component not read (…): pictures are refused" on stderr — so a pack without the component fails at the first picture with a reason, instead of at the first surprising answer.
Two things worth knowing, neither blocking:
Not verified by me: the device run (the 35B row on this route) — that needs an |
|
VERDICT: READY at The cache key is the interesting decision
That is the exact failure this route would otherwise have had, and it is invisible in the obvious test: send Replacing the picture rows with a hash-derived key is the right fix and it is the same reasoning as #2320's "is this another PR's WORK?" — two artefacts that look identical are not, and the identity must be carried by content rather than by shape. The expansion rule is stated precisely" Two asks
And the scope line is the right one"The 35B row follows on this route as a device run; it needs no code change" — so this is the code half, with the device measurement named as the next step rather than implied. That is how a route lands: code with a host proof, then a device row on the same route. |
|
The cache-key hazard #2334 warns about is actually closed here, and the way it is closed is the interesting part. #2334 says, correctly, that a prefix cache keyed on ids alone would replay KV state built from a different picture; here the key is extended where the picture lives: key_ids = ids;
… for each expanded image row:
key_ids[i] = -int(1 + ((ex.image_hash ^ (uint64_t(row) * 0x9E3779B97F4A7C15ull)) & 0x3FFFFFFF));
const std::vector<int>& cache_ids = img ? key_ids : ids;Four things about that are right: the substituted values are negative, so they cannot collide with any real token id; the row index is mixed in, so a picture cannot share a key with itself shifted; One question, with the arithmetic, because this key decides KV reuse: the hash is truncated to 30 bits per row ( What I would want as an arm: two pictures of the same size with different pixels, both put through the cache, asserting the second does not hit the first's state. The body's table already measures red → blue → red answering Interaction with #2334: that PR adds a comment saying the ids-only key is safe only while every request is text, and that whoever wires the tower must extend the key first. This PR is that wiring and does extend it — so the two should merge together (or #2334 should be reworded to "extended in #2335, and here is the invariant to keep"), otherwise the tree ends up documenting a hazard that no longer exists while the guard sits unremarked in another PR. |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: CHANGES at e7cb1eb
The measurement is the best kind -- a positive and a negative for the exact hazard #2334 just wrote down -- and one file is missing from the diff.
The cache evidence is what this PR needed to show, and it shows both directions:
| picture | answer | time | cached |
|---|---|---|---|
| 672 × 1344 red | Red |
43.0 s | 0 |
| the same picture again | Red |
1.1 s | 485 of 486, tower not run |
| 672 × 1344 blue, same size | Blue |
55.5 s | 0 -- "the picture key keeps it apart" |
The third row is the one that matters: a same-size different picture gets 0 cached and the right answer. That is precisely the failure #2334 documented ("a hit would replay KV state built from a different image … a wrong answer that reports itself as a cache hit"), tested in both
directions rather than argued. And 43.0 → 1.1 s with the tower skipped is the win that makes the key worth its complexity.
The blocker: stb_image is a new third-party dependency and NOTICE is not in the diff.
NOTICE exists to list exactly these, one block per dependency (nlohmann/json at line 160, cpp-httplib at 165, each with URL, copyright line and License: MIT). This PR fetches nothings/stb at a pinned commit, compiles it into the app, and ships it in a binary -- so it needs
its block, and stb is public domain / MIT so the entry is easy:
stb_image (nothings/stb 9600522)
https://github.com/nothings/stb
Copyright (c) 2017 Sean Barrett
License: MIT (or public domain)
There is no tool that would have caught this (grep -rln NOTICE tools/ci tests finds no check), so it is a review item rather than a lint item -- and it is the kind of thing that is trivial now and unpleasant in a release audit later. NOTICE-BINARY is worth a glance too, since the
image decoder is compiled in.
Two things about the dependency itself that the comment already answers well: it is pinned by commit (GIT_TAG 9600522de53973336fa033a289858a4354cbb04c), not a branch, and app/stb_image_impl.cpp compiles PNG, JPEG and PNM only -- so the decoders that exist are the three the request can carry, not the full header. That is the right shape for a
parser of untrusted bytes: the smallest surface that does the job, behind a 16 M-pixel cap and the by-name refusals (a picture over 16 M pixels, a part with no URL, a non-base64 URL, bytes that are no image).
Also right, and each is a decision rather than a default: deterministic speculative decoding off for these requests ("its rotary positions are not the drafter's slots"); the tower run only when a picture row lies past the resume point ("a prompt-cache hit that covers every image row does not need it"); and the honest scope line --
"the route has no fabricated end-to-end arm in CI: the tower needs a pack's 1.1 GB vision weights" -- with ctest -j 6 at 284/284 and the forward arms by hand.
One note on the key, from #2334's own reasoning: key_ids[i] = -int(1 + ((image_hash ^ (row * 0x9E3779B97F4A7C15)) & 0x3FFFFFFF)) is sound for the hazard (negative ids cannot collide with tokens; different hashes give different ids), and it is a 30-bit truncation of a 64-bit FNV-1a -- a
false hit needs a 30-bit collision, which is fine for a cache key and worth recording in the comment next to "a different one for a different picture".
Not run: the device rows (1.1 s / 43.0 s / 55.5 s are yours, in npu-hold); build is still pending on this head.
…key's width noted (refs #2324) Review of #2335: the new dependency had no NOTICE block. nothings/stb at the pinned commit, PNG/JPEG/PNM only, MIT or public domain. The cache key's comment now says it is 30 bits of the 64-bit hash, mixed per row. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…30-bit collision (refs #2324) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 8b31e50: READY
The key is a digest of the image bytes — I checked the hash itself rather than the sentence describing it, because a key on geometry would have reproduced exactly the collision #2334 warns about.
uint64_t images_hash(const std::vector<RgbImage>& images) {
uint64_t h = 1469598103934665603ull; // FNV-1a basis
auto mix = … { h ^= p[i]; h *= 1099511628211ull; };
for (const RgbImage& im : images) {
const int32_t dims[2] = {im.height, im.width};
mix(dims, sizeof dims); // dimensions first
mix(im.rgb.data(), im.rgb.size()); // then every RGB byteAnd the ids-only rule is preserved rather than abandoned — key_ids[i] is derived from image_hash, so two pictures of one size expand to different synthetic ids and the cache cannot hand one's state to the other. That is the better of the two available designs: instead of changing the cache's contract, it makes the ids a faithful function of the image content, and the comment says why ("two pictures of one size expand to identical ids, so a prompt cache keyed on ids alone would hand one picture's state to the other").
Two things I checked that are not in the description:
- The hash covers the decoded RGB, not the file bytes. That is the right choice and worth stating explicitly: a PNG and a JPEG of the same picture hash the same, which is correct for a KV cache — same pixels, same activations, and a shared entry is a hit on the same computation rather than a false hit.
kMaxImagePixelsis a resource guard, not a nicety: a decoder handed a hostile header can otherwise be asked to allocate for a 100k × 100k image. Refusing by name (rather than clamping) is right — a clamped image is a different picture, and this PR's own cache key would then disagree with what was rendered.
And the refusals keep the #2327/#2329 shape: PNG/JPEG/PPM decoded, everything else refused by name, plus oversized pictures and parts with no URL. Same discipline, one layer wider.
One note, not a blocker: 64-bit FNV-1a is not adversarial-grade. Two distinct images can be made to collide, and a collision here means a cache hit returning another picture's KV — the exact hazard #2334 describes, at 2⁻³²-ish rather than the ~2⁶⁴ a random function gives. For a local serve route where the requester would be poisoning their own cache this is fine; if this cache ever becomes shared between clients, that line is the one to revisit.
PR-Agent command was not runGitHub returned an incomplete or inconsistent changed-file set for this pull request, so PR-Agent stopped instead of analyzing only part of it. GitHub limits changed-file responses to 3,000 files. If this pull request changes more than 3,000 files, split it into smaller pull requests and run the command again. Otherwise, retry the command. |
|
Answering the CHANGES at
Both commits are comments and ready for review at b2662bf |
|
Persistent review updated to latest commit b2662bf |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at b2662bf - withdraws my CHANGES at e7cb1eb; both items are in, and the second one is better than what I asked
NOTICE has its block, in the file's own format (URL, copyright line, License:), and with more information than the two existing entries carry -- it names the pin, the compilers actually built (PNG, JPEG and PNM only) and the file that compiles them:
stb_image (nothings/stb 9600522; PNG, JPEG and PNM only, app/stb_image_impl.cpp)
https://github.com/nothings/stb
Copyright (c) 2017 Sean Barrett
License: MIT (or public domain, at the user's choice)
The or public domain, at the user's choice is the accurate form for stb rather than a guess at "MIT", which is what a NOTICE entry usually becomes when it is written from memory.
And the collision note now says the number, which is what I meant by "worth recording next to a different one for a different picture":
// The key keeps 30 bits of the 64-bit FNV-1a hash. The row is mixed in the same way for every picture,
// so two pictures share keys exactly when their hashes agree in those 30 bits: a false hit is one
// 30-bit collision (about 1 in 1e9 per pair), acceptable for a cache key."exactly when their hashes agree in those 30 bits" is the precise statement -- it is not "collision risk is low", it is the condition, so a reader can decide whether 1 in 1e9 per pair is the right bar for this cache rather than taking the word "acceptable" on trust.
Everything from e7cb1eb stands: the both-directions measurement (43.0 s cold / 1.1 s with 485 of 486 cached and the tower skipped / 55.5 s and 0 cached for a same-size different picture), speculative decoding off for image requests, the tower run only when a picture row lies past the resume point, the by-name refusals, the 16 M-pixel cap, ctest -j 6 284/284, and build pass on this head with the dependency fetched.
One last thing worth knowing, not a condition: npu_image_request_test now builds app/stb_image_impl.cpp, so the decode path's arms are in CI while the tower's are not -- which is exactly the split the body states ("the route has no fabricated end-to-end arm in CI: the tower needs a pack's 1.1 GB vision weights"). A reader who wants the end-to-end evidence finds it in the three-row table, not in a test, and that is the honest arrangement rather than a hidden one.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at b2662bf: READY
They answered my collision note by quantifying it, and the number is right — which is the outcome I wanted from raising it: not a bigger hash, but a stated limit.
// 30-bit collision (about 1 in 1e9 per pair), acceptable for a cache key.
key_ids[i] = -int(1 + ((ex.image_hash ^ (uint64_t(row) * 0x9E3779B97F4A7C15ull)) & 0x3FFFFFFF));
0x3FFFFFFF is 2³⁰ ≈ 1.07e9, so 1 in 1e9 per pair is the correct per-pair figure, and the id (not the 64-bit FNV) is what sets it. That is a fair trade to state and accept, because there is no verification on lookup — nothing compares image_hash after a hit — so a collision would alias silently, and a silent alias is only acceptable when its rate is known and small.
One refinement, not an objection: the per-pair number is not the number that governs a cache. Across N distinct images the birthday bound is ~N²/2³¹ — so 1,000 distinct images give ~5 in 10,000, and ~50,000 would make a collision likely. At serve-route cardinalities (tens to a few hundred prompts with pictures in a cache window) the risk is ~1e-5 or smaller and the comment's conclusion holds. It becomes wrong only if this cache is long-lived across many distinct pictures or shared between clients — the same condition as my earlier FNV note, and now with a number attached to it.
Worth recording the shape of this exchange, because it is the good version: I raised a limit, the author measured it, stated it at the line where the limit lives, and called it acceptable. That is strictly better than either an unstated 30-bit truncation or a defensive jump to SHA-256.
Everything I verified at 8b31e50 still holds — images_hash is FNV-1a over (h, w) then every RGB byte per image in order, and the ids-only cache rule is preserved by making the id a function of the image content.
No further asks.
Refs #2324 (pictures and the picture prefix cache on
--device npu). The 35B row follows on this route as a device run; it needs no code change.What was missing. The engine had every step for a picture (the vision pack reader, the host tower checked against transformers,
plan_image,preprocess,image_tokens,prompt_positions), chained only byminicpm_image_probe.forward-servesilently droppedimage_urlparts: the ChatML path keeps text only, and the template path rendered<|image_pad|>with nothing behind it.Change.
app/npu_image_request.h: an OpenAI request's pictures, in message order, as RGB bytes.data:URLs are taken; nothing is fetched from the network.stb_image, pinned throughFetchContentlikenlohmann_jsonand built inapp/stb_image_impl.cppwith only those three formats and no file I/O.npu/vision_request.h:expand_images()replaces the k-th<|image_pad|>with<image_id>k</image_id>plus the picture's tokens, and lays out the three position planes.tower_rows()runspreprocessand the host tower separately.app/forward_serve.cpp:step_batchfor text, orstep_batch_embedswith that chunk's rows and planes for a prompt with pictures. So chunking, pinning and resume are unchanged.embed_row()writes a one-row cache.docs/npu.md: a "Pictures on the chat route" section.Measured.
MiniCPM-V-4.7-1B-NPU2from the hub,1bit serve --device npu, the text model on the NPU (npu-hold, amdxdna clean before and after), "What colour is this image? Answer in one word.":Red/Blue/RedRedRedBlueBefore the tower skip, the same hit took 59.2 s on the NPU, because the host tower ran again. The image probe on the NPU answers
redandBluefor the 448 × 448 pair.Tests.
npu_image_request_test: crafted PPM and PNG (stored deflate) data URLs decode to exactly the pixels written; request order is kept across both URL spellings; a network URL, a non-base64 URL and non-image bytes are each refused by name; the hash follows the pixels, not the encoding.ctest -j 6without the device tests: 284/284. Forward arms by hand rc 0.The route has no fabricated end-to-end arm in CI: the tower needs a pack's 1.1 GB vision weights, and the tower's own arithmetic is already pinned by
npu_vision_tower_test.🤖 Generated with Claude Code