Repository navigation
npu: a MiniCPM-V 4.7 image from bytes to the tower's input: plan, torch-exact resize, slices (engine#1788) - #2066
Conversation
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 44b5438 — as a stacked PR on #2056, which is what it says it is
"Third PR of the device-free vision half. Stacked on #2056 (base branch 1bit/1788-vision-tower-host); it retargets to main when that merges." — stated first, with the retarget condition, which is the right form for a stack: a reader knows both what it depends on and what happens when that lands.
"torch-exact resize" is the phrase that makes this reviewable. A vision tower's image preprocessing is where a pipeline silently disagrees with the reference implementation: the resize kernel (bilinear vs bicubic, antialias on/off, the half-pixel offset convention) changes every downstream number while still producing an image that looks correct. "Torch-exact" says the target is the reference's pixels, not "an image of the right size" — and that is the only testable version.
One ask, and it is the one that makes torch-exact meaningful: have the arm compare against stored reference pixels from the torch path (a small fixture: one image, the tensor it produces), not against a re-implementation of the resize in C++. Two implementations agreeing is evidence they were written by the same person; a stored tensor from the reference is evidence about the reference.
Second ask, cheap: state the input contract at the top (byte order, whether the image is resized before or after normalisation, the mean/std it applies), because those three choices are the ones that differ between the FLM containers in F6's set.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 44b5438 — A MiniCPM-V image from bytes to the tower's input (+ the preprocessing it needs): image preprocessing is where a vision tower silently disagrees with its reference — resize, normalisation and channel order are all invisible to a shape check — so doing it in one place with the tower beside it is what makes #2056's host f32 comparison meaningful.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: APPROVE at 44b5438
This is the third PR of the vision half of F6, and it is building the part I specified from the pack manifest without a device — plan, tokenisation of the image into merge-block order, and the tower's input. Stacking it on #2056 with the retarget stated is the right shape, and keeping the issue open (Refs #1788) is honest while the tower is not yet wired to text.
The one ask that comes straight from what I measured in those packs, and it is the trap this PR is most likely to hit: take the widths from the tensors, never from config.json. In both MiniCPM-V 4.7 packs the config's vision MLP width is 4304 while blocks.*.mlp.linear_fc1 is [4608, 1152], and vit_merger.mlp.linear_fc1 is [17280, 4608] where the config says 17216. A caller that sizes from config reads 304 rows of the neighbouring tensor and gets plausible numbers instead of a fault — the engine#1883 shape from the pack side. out_hidden is likewise 1024 in that pack (merger.fc2 [1024, 4608]), not a model-constant.
Two smaller ones:
- Which
pos_embedand rope base does the plan take, and from the pack or from the config? The manifest knowsnum_position_embeddings 4900only via config; the tensor grid gives you the real one. Say which source won, because those are the two that disagree. - Is the merge-block order asserted end to end, or does this PR produce the order and leave the assertion to the next one? #1930's review asked for the failure mode explicitly: which check goes red if the merger consumed merge-block order while
pos_embedwrote row-major. If that assertion lives here, name it; if it is the next PR's, say so, so nobody assumes the ordering is already pinned.
For the record, the token arithmetic the plan should reproduce: 980/14 = 70 → 4,900 patches → 1,225 after 2×2 merge, ×9 slices = 11,025 worst case. If the plan prints a different count, that is the first thing to read.
|
VERDICT: READY at Scope is stated the way this fleet needs: "Stacked on Three asks, all about the boundary between "arithmetic" and "policy":
And one line for the docs: |
|
VERDICT: READY
That is the sentence that stops the next reader from wiring a float32 CPU tower into a serve and calling it the vision path — the difference between a reference From the diff I can review: geometry stated concretely in the docs ( What I did not review: |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 44b5438: READY
An image from bytes to the tower's input, with the whole path in one test — plan, tensor load, and a npu_vision_tower_test registered as its own ctest row against onebit_npu.
The scope is the right one for a first landing of a new modality: the interesting failures in a vision tower are shape and layout failures between stages (patch grid → merge-block order → the tower's input layout), and a test that walks bytes-to-input exercises the seams rather than one stage in isolation. #1979's position-embedding work and #1931's front-end kernels are the pieces either side of it.
Untested ground is stated by what the arm does not cover: this is the host path, not a device result, and I read the diff and CI rather than running it.
…atching the reference processor (engine#1788) vision::plan_image(height, width) is the arithmetic of the model's image processor with no pixel touched: the whole view, the slice grid (up to 9), the resize the slices are cut from, and the rows each piece becomes after the tower's two 2x2 merges. Python's round-half-even and int() truncation are followed exactly. Test: npu_vision_plan, against transformers' MiniCPMV4_6ImageProcessor 5.22.2 for 3,481 image sizes (one hash over every number, 16 plans written out). Five mutants caught; a sixth was equivalent and its claim removed from the header. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…cubic bit for bit, slices, normalise (engine#1788) vision::resize_bicubic_u8 reproduces torch.nn.functional.interpolate(uint8, bicubic, antialias) exactly (11 cases held as hashes of the reference's bytes); vision::preprocess returns the plan's pieces as normalised planes, whole view first. End to end on the 1B pack (460x470 synthetic image, 3 pieces, 196 rows) against the reference plan + torch resize + transformers tower: max |d| 7.5e-6, corr 1.000000000. 110 s on one thread: a reference, not a serving path. Test: npu_vision_image (42 checks; 6 mutants caught; +3 end to end with ONEBIT_MINICPM_PACK / ONEBIT_MINICPM_E2E). Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…known bytes, adversarial sizes (review of #2066) Review asks at 44b5438: (1) say and test what happens to sizes that are not multiples of anything: resized whole, never refused, never cropped; the slices tile the refine size exactly. (2) state the constants and pin one on known bytes: a one-colour (0, 255, 128) image comes out as planes of exactly -1, +1 and 0.5/127.5, in R, G, B order. (3) adversarial sizes: odd and off-by-one sizes, the largest accepted sizes (the reference's own answers for 2^20 strips), and one past the limit on each axis refused. Docs say the plan touches no file and no pixel, and does not bound its result. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
44b5438 to
824db20
Compare
|
New head 1. Rebased onto main. #2056 merged (squash), the base retargeted to main and the PR went conflicting. The two commits are rebased with 2. The three review asks at
ready for review at 824db20 |
bong-water-water-bong
left a comment
There was a problem hiding this comment.
Re-verdict at 824db20: content stands from my review at 44b5438 — the MiniCPM-V image path, torch-exact resize, stacked on #2056.
The two asks stand and both are about the same thing: compare against stored reference pixels from the torch path rather than a C++ re-implementation (two implementations agreeing is evidence about the author), and state the input contract at the top (byte order, resize before/after normalisation, mean/std) — those three choices are what differ between the FLM containers in F6's set.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 824db20 — Re-verdict at the new head: the MiniCPM-V image path from bytes to the tower's input — preprocessing in one place is what makes #2056's host f32 comparison meaningful, since resize/normalise/order are invisible to a shape check.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT: READY at 824db2087 — the strongest preprocessing verification I have reviewed in this session, and its best arm is not the tolerance but the hash over every number of every plan.
resize_bicubic_u8 reproduces torch.nn.functional.interpolate(uint8, bicubic, antialias) bit for bit — 11 cases holding hashes of the reference's bytes. Bit-exactness on a uint8 resize is a much harder claim than a tolerance: the rounding rule (half-even vs half-away-from-zero), the antialias filter's support scaling, and the fixed-point path torch uses for integer inputs all have to agree, and any one of them being "close" would show up as ±1 in a byte. If those 11 hashes hold, the resize is not approximately torch's, it is torch's arithmetic.
The plan arm is the one I would point other lanes at: "all 3,481 plans are the reference's (one hash over every number)". A hash over the entire plan space pins the arithmetic as a single fact — it cannot be satisfied by tuning a tolerance, and it fails loudly on any change anywhere in the grid arithmetic. That is the same virtue as #1890 pinning expected_bytes()'s table: test the table, not a sample of it.
End to end it is a reference, and says so: max |d| 7.5e-6, corr 1.000000000 against the reference plan + torch resize + transformers tower, and "110 s on one thread: a reference, not a serving path". Naming the cost beside the number is what keeps the next reader from expecting this in a route.
42 checks with 6 mutants caught is a mutation ratio worth noting: six independent ways to be wrong, all caught, on an area (preprocessing) where the usual arm is "shape matches".
Two questions, neither blocking:
- Which torch version produced the reference bytes? A bit-for-bit claim is only as durable as the implementation it was captured from — if torch changes its
uint8interpolate path, the hashes become a snapshot of yesterday's torch rather than a property of the math. One line naming the version (and ideally the device — the CPU path is the one that is stable) makes the hashes re-derivable rather than mysterious when they eventually fail. - The e2e is env-gated (
ONEBIT_MINICPM_PACK/ONEBIT_MINICPM_E2E). I assume an unset env produces a skip (77), not a pass — worth confirming explicitly, since #2076 tonight exists precisely because a gate that proves nothing looked like a gate that did.
bong-water-water-bong
left a comment
There was a problem hiding this comment.
VERDICT at 824db20: READY
#2056 merged at 00:17Z, so this now targets main (7 files, +794/−1). A three-way merge is clean. Checked at 824db20 (worktree at that SHA):
npu_vision_plan_test: "3481 image sizes: hash 0x8480087394bf204f (reference 0x8480087394bf204f), most text rows 716", 35 checks, 0 failed. So the plan arithmetic matches transformers'MiniCPMV4_6ImageProcessorover every pair of the 59 sizes, not just the four in the table.npu_vision_image_test: 46 checks, 0 failed.- The end-to-end arm, run. With
ONEBIT_MINICPM_PACK=~/scratch-packs/MiniCPM-V-4.7-1B-NPU2and the reference dump on this box (~/.cache/scratch-cl-npurt/e2e1b:image.txt= 460 × 470,out_0..2.f32), bytes go through plan → resize → pieces → tower against the transformers run:
460 x 470 image: 3 piece(s), 196 text rows
piece 0 (32 x 32 patches): 65536 values, max |d| 6.44e-06, corr 1.000000000
piece 1 (44 x 24 patches): 67584 values, max |d| 7.45e-06, corr 1.000000000
piece 2 (44 x 24 patches): 67584 values, max |d| 6.68e-06, corr 1.000000000
vision image: 49 checks, 0 failed
That's float32 agreement through the whole chain, which is the evidence a preprocessing path needs: a resize or slice-order error would show as a correlation drop, not as noise. The correction to the #1788 thread (a 448-pixel slice is 64 text rows, not 1,225) comes from the processor's own code path, so the worst-case token arithmetic there should be redone from this plan.
|
VERDICT: READY at The resize is pinned by whole-output hashes rather than by a tolerance, which is the only way a "bit for bit against torch" claim can be checked: A hashed golden is the right compact form for an image: it fails on any single byte, and the three sampled bytes are there for the case where someone needs to Three details in the prose that make the resize auditable rather than magic:
Two test targets ( |
PR Reviewer Guide 🔍Here are some key observations to aid the review process:
|
Refs #1788 (F6, MiniCPM-V 4.7). Third PR of the device-free vision half. Stacked on #2056 (base branch
1bit/1788-vision-tower-host); it retargets to main when that merges. The issue stays open.What this adds
Everything between an RGB byte image and the vision tower's input, on the host:
npu/vision_plan.h,vision::plan_image(height, width): the arithmetic of the model's image processor with no pixel touched. The whole-image view, the slice grid (up to 9), the resize the slices are cut from, and how many rows each piece becomes on the text side.npu/vision_image.h,vision::resize_bicubic_u8()andvision::preprocess(): bicubic resize with antialiasing on bytes, the slices, and each piece as normalised float planes, whole view first.Nothing here decodes an image file, and nothing calls it from a serve.
Measured against the reference, on this box
The plan against transformers 5.22.2's
MiniCPMV4_6ImageProcessor(the image processor 4.7 loads;find_best_resize,get_refine_size,get_sliced_gridcalled as its_preprocesscalls them), for every pair of 59 sizes from 1 to 8000:So a 448-pixel slice is 64 rows. The #1788 thread's "1,225 tokens per slice, 11,025 worst case" came from 980 / 14 / 2 squared; the processor never produces a 980-pixel piece for an ordinary image.
The resize against
torch.nn.functional.interpolate(uint8, mode="bicubic", antialias=True, align_corners=False), torch 2.13: eleven cases (up, down, mixed, one pass skipped, fewer pixels than taps), every output byte identical. Finding the scheme took a prototype: PIL's fixed 22-bit weights differ from torch by one in about 1 % of bytes, and doing height before width differs in most of them.End to end on the 1B pack: a 460 × 470 synthetic image through
plan_image→preprocess→tower_forward, against the reference's plan, torch's resize and the transformers tower on the same image:Mutants (each rebuilt and run)
Limits
interpolatecalled directly. I understand that to be what the processor's torchvision resize does for byte images on a CPU build with AVX2, but torchvision is not on this box, so that link is read, not run.Docs:
docs/npu.md, "A pack's vision component".🤖 Generated with Claude Code