QVAC-23075 feat: add VisionPsy Nano support and its Flash preprocessing rule - #205
Conversation
Review StatusCurrent Status: ❌ PENDING Pending reviews: Needs 1 Management or Team Lead, and 1 more from Management, Team Lead, or Member. |
|
Consumer side is tetherto/qvac#3725. Device evidence for both, including the Adreno fix, is on the tetherto/qvac branch bench/QVAC-23075-visionpsy-vlm, which carries the vcpkg overlay. It was PR tetherto/qvac#3726, now closed, and those run records are still readable there. |
8f7393b to
e720726
Compare
VisionPsy Nano is a siglip encoder with an idefics3-style pixel-shuffle merge and a single mm_fc projector, so route it through clip_graph_siglip and reuse the llava-uhd preprocessing. Four differences from idefics3, all taken from get_image_string() in the reference processors.py: position embeddings are interpolated to the actual patch grid, the overview image is emitted first, the delimiters are <|global_image|> and <row_%d_col_%d> with no fake_token_around_image and no row-end token, and each image carries an <image: N> ordinal label once a prompt holds more than one. That label is not in the vocab, so it goes through BPE as text. When the slice grid is 1x1 the overview and the single slice are the same crop, so only one of them is sent, and it is the slice. GlobalAndSplitImages.forward returns the split patch untouched for that grid, before it resizes a global patch, and the two paths do not share a resize kernel: the slice is rendered with image_resize_algo_rf (bicubic) and the overview with image_resize_algo_ov (bilinear). The published mmproj GGUFs declare clip.projector_type = "custom", so add a read-only alias table mapping that string onto PROJECTOR_TYPE_VISIONPSY. The alias is gated on general.name as well, so another model shipping "custom" is not silently loaded as VisionPsy. PROJECTOR_TYPE_NAMES stays canonical and "visionpsy" is what we write out. lookup_token() returns LLAMA_TOKEN_NULL on a miss and no caller checks it, so a vocab without <|global_image|> would splice a garbage id into the prompt. It now throws, but only when there is a vocab to look in: mtmd_get_memory_usage builds a context with no text model and every token misses there, which otherwise cost every llama-server start with this mmproj its mmproj VRAM reservation. The base and Flash checkpoints differ in exactly one thing, resize_to_max_side_len. Base always stretches the long side to max_img_size; Flash rounds it up to a whole number of slices and only then caps it, so an image below the cap keeps its own resolution. Their mmprojs are byte-different but declare identical vision hparams, so nothing in the GGUF distinguishes them and Flash was silently running base preprocessing, inflating a 256x256 image into a 4x4 grid of upscaled slices. Add clip.vision.preproc_no_upscale, read from the GGUF when present, overridable through mtmd_context_params.image_no_upscale and the new --image-no-upscale flag. The published mmprojs do not carry the key, so today the flag is what selects the variant; a re-converted Flash mmproj would need no flag. Tri-state, -1 keeps the model default, so every existing caller is unaffected. The short side is computed in double. The reference is math.ceil(short * scale / p) on Python floats, and where the true quotient lands on an integer, float32 and exact integer arithmetic each disagree with it by a whole slice row: over every size pair up to 3000x3000, float32 differs in 171 cases and exact integers in 92, double in none. image_size == 0 is legal in general but calc_size_no_upscale divides by it, so it is rejected at load, where the rest of the loader reports bad config, rather than asserting per image. Flash on 1024x768 goes from 13 slices to 5, encode 361 ms to 139 ms, prefill 858 tokens to 338; on 256x256 from 17 slices to 1, 474 ms to 38 ms, 1118 tokens to 78. DocVQA ANLS over 30 examples is unchanged at 0.825 versus 0.824, as expected, since those scans are larger than the cap and the two rules converge above it. Both checkpoints are registered in the vision test list. They use the published GGUFs, which -hf resolves to both the language model and the mmproj, and answer "the new york times" to the harness default prompt, so they pass the existing new-york / men-walk assertion. Flash needs the flag on its row, since its published mmproj carries no clip.vision.preproc_no_upscale key and would otherwise duplicate the base row's preprocessing.
…not the view enable_adreno_trans_weight gates three things as a unit: the transpose in set_tensor, the restore in get_tensor and the adreno gemm/gemv selection in mul_mat. set_tensor returns early for views and always decides on the parent, but get_tensor asked about the view, so the two could disagree on the shape predicate and pair a transposed writer with a non-transposed reader. Unrelated to VisionPsy, and kept separate for that reason. The shape predicate itself already rejects the q8_0 layouts the transpose asserts on, so this only closes the view-versus-parent gap.
e720726 to
7a6425a
Compare
QA follow-up: VisionPsy coverage gapsThe core code findings I had are already covered inline, so not duplicating those here. From a QA perspective, I would still like the following coverage before this is treated as merge-ready:
|
tobi-legan
left a comment
There was a problem hiding this comment.
Requesting changes based on the native correctness issues and QA gaps already posted.
Blocking items: the Adreno Q8_0 view readback can overflow/corrupt because the predicate uses the parent layout while restore allocation/dimensions still use the view; VisionPsy preprocessing still has reference-compatibility risks around resize precision and overview generation.
Please also cover the loader validation, ABI/rebuild story, preprocessing golden tests, Adreno view regression, and VisionPsy token/chunk sequence tests before this is merge-ready.
…k on Adreno The SoA buffers cover the whole parent and the Adreno transposed layout is indexed by the parent's row count, but get_tensor sized its staging buffer and its dispatch from the view. A view with fewer rows than its parent therefore strided the transposed source wrongly, and once the dispatch rounded the row count up to the 64-wide work group it wrote past the end of the buffer: a 4096x1 view of a 4096x4096 parent allocates about 4.3 KB and the kernel restores 64 rows into it, about 278.5 KB. Reconstruct the parent instead, exactly as set_tensor already does, and read the view out of it at view_offs. The kernel also gets the row guard the rounded-up dispatch needs, which closes the same overflow for any parent whose row count is not a multiple of 64.
calc_size_preserved_ratio scaled in float32 while both reference processors do it in Python floats. Where the true product lands exactly on a multiple of align_size, float32 rounding pushes it just past and the ceil buys a whole extra row or column of slices. 960x720 with image_size 512 and longest_edge 2048 is the common 4:3 case: the reference refines to 2048x1536 and slices 4x3, float32 gave 2048x2048 and sliced 4x4, so the image gained 4 slices and 256 image tokens the model was never trained to see. Verified against the local Metal build on the base checkpoint: 960x720 drops from 17 image encodes to 13, that is a 4x4 grid to a 4x3 grid plus the overview. 1024x768, 640x480 and 256x256 are unchanged, their scale factors are exact in float32. Sweeping every 16-pixel size pair up to 3000x3000, float32 and double disagree on 10 of 33856, all of them 4:3 or 3:4. The idefics3 and pixtral paths share this helper and both references are also double, so they move the same way.
| // HF's Idefics3 split_images() resizes the tensor it just split | ||
| // (resize_for_vision_encoder runs before it). Sourcing the overview from the original | ||
| // instead leaves the overview pixels off-reference on every grid above 1x1. | ||
| instructions.overview_from_refined = true; |
There was a problem hiding this comment.
overview_from_refined is set unconditionally in the shared idefics3 preprocessor, but only VisionPsy opts out of PAD_CEIL, so every idefics3 overview now has the letterbox bars baked into it.
bdd9b1e97 is right that the reference refines first and takes the global image from that result. But this flag is set for the whole idefics3 family, while the opt-out from aspect-preserving padding is set for one member of it: clip.cpp:1541 gives VisionPsy image_pad_rf = PAD_NONE, and the PROJECTOR_TYPE_IDEFICS3 case at clip.cpp:1525-1530 sets none, keeping the PAD_CEIL default. slice_image then builds refined_img letterboxed (mtmd-image.cpp:758-759) and resizes that into the overview (:762-763).
HF stretches at both steps, so the idefics3 overview used to match the reference and now does not. At image_size=384, preproc_image_size=1920, a 1000x300 input leaves 96px bars top and bottom — 25% of the overview is black.
Reachable with no flag: it needs only clip.vision.preproc_image_size, which conversion/smolvlm.py:30-31 writes unconditionally for every model converted here. CI misses it because the three mmprojs tests.sh downloads predate that key and take the overview-only path.
Distinct from the ordering thread on mtmd-image.cpp:1295, which asked for this change and got it — the gap is that it was gated on projector family rather than on whether the refined image is padded.
Suggested fix: tie the flag to the padding mode, so it disables itself wherever the refined image is letterboxed and no projector has to remember to opt out.
instructions.overview_from_refined = (hparams.image_pad_rf == PAD_NONE);There was a problem hiding this comment.
Taking it. Reverted the overview source in a812964, so the overview comes from the original again for the whole family. Gating on the padding mode fixes the bars but leaves the second resample on VisionPsy, which is your other thread, so the single resize covers both. slice_image and slice_instructions are byte-identical to v10069.0.0 again.
| img_tool::resize(img, refined_img, inst.refined_size, hparams.image_resize_algo_rf, | ||
| hparams.image_pad_rf, hparams.image_pad_color_rf); | ||
|
|
||
| if (inst.overview_from_refined) { |
There was a problem hiding this comment.
Under PAD_NONE the new overview path is geometrically identical to the old one, so the only thing it changes is resampling quality — and it makes it worse.
"Stretch original to refined, then stretch refined to (p, p)" and "stretch original to (p, p)" are the same geometry once padding is out of the picture. For VisionPsy specifically, deriving the overview from refined_img therefore buys no correctness and costs resolution: resize_bilinear (mtmd-image.cpp:233-258) is a 2x2 tap with no antialiasing, while the reference torchvision.transforms.functional.resize defaults to antialias=True.
On the base checkpoint with the committed test-1.jpeg, a mild 640x488 -> 512x512 resize becomes a 3x bicubic upscale to 2048x2048 followed by a 4x downscale back to 512x512. A 2x2 tap at that reduction averages 4 of every 16 source pixels and discards the other 12, where an antialiased kernel weighs all of them. The affected embedding is the overview — the first thing the model sees, behind <|global_image|> — on any image below the 2048 cap.
Worth noting this is not fixed by gating overview_from_refined on the padding mode: VisionPsy is PAD_NONE, so it keeps the refined source either way.
Suggested fix: the cheapest option also resolves the letterboxing comment on line 1303 — keep sourcing the overview from the original wherever the refined image is an unpadded stretch, since the two are equivalent there. If the refined source is worth keeping for its own sake, img_tool::resize needs an antialiased or box-filter path for downscales beyond about 2x.
There was a problem hiding this comment.
Confirmed. image_resize_algo_ov is RESIZE_ALGO_BILINEAR and image_pad_ov is PAD_NONE, so it routes to resize_bilinear, a 2x2 tap with no prefilter, and test-1.jpeg refines 640x488 to 2048x2048, so the overview was a 3x upscale then a 4x downscale. Reverted in a812964.
Reverts the overview source change in bdd9b1e. Chaining the refined resize into the overview is wrong under one padding mode and a downgrade under the other, so both projectors in the family are better off with the single resize. Under PAD_CEIL, which every idefics3 member except VisionPsy keeps, refined_img is letterboxed at mtmd-image.cpp:758 and the overview was resized out of that, so the bars landed inside the global image. Both references stretch at each step and never letterbox, so this was off-reference, not closer to it. It needs only clip.vision.preproc_image_size to reach, which conversion/smolvlm.py writes for every model converted here, and the three mmprojs tests.sh downloads predate that key so they all take the overview-only path. Under PAD_NONE, which is VisionPsy, the refined resize is a plain stretch, so stretching it again to (p, p) is the same geometry as stretching the original once. The only difference is a second resample, and image_resize_algo_ov is RESIZE_ALGO_BILINEAR with image_pad_ov PAD_NONE, which routes to resize_bilinear, a 2x2 tap with no prefilter, where the reference resize is antialiased. On the base checkpoint test-1.jpeg refines 640x488 to 2048x2048, so the overview became a 3x bicubic upscale followed by a 4x downscale that keeps 4 source pixels in every 16. Both found by iancris. slice_image and slice_instructions are now byte-identical to v10069.0.0. tests/test-mtmd-preproc-sizing.cpp still passes; it covers sizing, and this moves pixels only, so it could not have caught either case.
Parses the idefics3-style preprocessing override out of the load config and forwards it to the vision context, so a caller can say "on" or "off" instead of being stuck with whatever the GGUF declares. Unset leaves the model's own value alone. This is what separates the VisionPsy Flash checkpoint from the base one, whose mmprojs are otherwise indistinguishable, so a Flash checkpoint loaded without it silently runs base preprocessing. It changes the image token count, so it moves both accuracy and encode time. LoadConfigHandlers parses the string into common_params, and MtmdLlmContext::initVisionContext copies it into mtmd_context_params next to image_tile_mode. Unit coverage for the parse sits with the other load-config cases. Needs the fabric side, tetherto/qvac-fabric-llm.cpp#205, which adds image_no_upscale to common_params and mtmd_context_params. cpp-lint stays red here until that merges and the registry publishes the next fabric version. Split out at Gianfranco's request. The SDK schema is #3854 and the VLM benchmark is #3855.
Parses the idefics3-style preprocessing override out of the load config and forwards it to the vision context, so a caller can say "on" or "off" instead of being stuck with whatever the GGUF declares. Unset leaves the model's own value alone. This is what separates the VisionPsy Flash checkpoint from the base one, whose mmprojs are otherwise indistinguishable, so a Flash checkpoint loaded without it silently runs base preprocessing. It changes the image token count, so it moves both accuracy and encode time. LoadConfigHandlers parses the string into common_params, and MtmdLlmContext::initVisionContext copies it into mtmd_context_params next to image_tile_mode. Unit coverage for the parse sits with the other load-config cases. Needs the fabric side, tetherto/qvac-fabric-llm.cpp#205, which adds image_no_upscale to common_params and mtmd_context_params. cpp-lint stays red here until that merges and the registry publishes the next fabric version. Split by area. The SDK schema is #3854 and the VLM benchmark is #3855.
Parses the idefics3-style preprocessing override out of the load config and forwards it to the vision context, so a caller can say "on" or "off" instead of being stuck with whatever the GGUF declares. Unset leaves the model's own value alone. This is what separates the VisionPsy Flash checkpoint from the base one, whose mmprojs are otherwise indistinguishable, so a Flash checkpoint loaded without it silently runs base preprocessing. It changes the image token count, so it moves both accuracy and encode time. LoadConfigHandlers parses the string into common_params, and MtmdLlmContext::initVisionContext copies it into mtmd_context_params next to image_tile_mode. Unit coverage for the parse sits with the other load-config cases. Needs the fabric side, tetherto/qvac-fabric-llm.cpp#205, which adds image_no_upscale to common_params and mtmd_context_params. cpp-lint stays red here until that merges and the registry publishes the next fabric version.
The check added for VisionPsy rejected a cap of zero but nothing above it, and preproc_image_size is a GGUF u32 read into an int. Both sizing rules upscale to the cap, so the cap alone decides the grid: 512*195 gives a 195x195 grid at one reserved tile each, and a cap near INT32_MAX overflows the multiply-back in calc_size_preserved_ratio first. Bound the implied grid against CLIP_PREPROC_MAX_TILES_LIMIT, the ceiling the Qwen-VL path already clamps to. A cap that is not a whole number of slices was unchecked too. The slicing loop steps by image_size and calc_size_no_upscale clamps the long side down to the cap, so an off-grid cap emits a ragged trailing slice the reference splitter never produces. test-mtmd-preproc-sizing already asserts that invariant, so enforce it against real metadata as well. The idefics3 overview-only warning and the no-upscale rejection were two independent ifs, so a SmolVLM mmproj with no cap and --image-no-upscale on logged "slicing is effectively off" and then threw. The later checks are else-if now, so the warning describes what actually happens. Only the VisionPsy case read clip.vision.preproc_no_upscale, while the override and the CLI help both treat idefics3 as accepting the same rule, so an idefics3 GGUF declaring it was silently getting base preprocessing. Read the key there too.
Bumps the qvac-fabric vcpkg dependency floor from 10069.0.0 to 10069.1.0 for all 7 fabric consumers, with the matching package version bumps and changelog entries. qvac-fabric 10069.1.0 adds VisionPsy Nano support and its Flash preprocessing rule (tetherto/qvac-fabric-llm.cpp#205) — the fabric side this PR's image_no_upscale load option depends on. - embed-llamacpp 0.32.0 -> 0.33.0 - fabric 0.4.0 -> 0.5.0 - llm-llamacpp 0.43.0 -> 0.44.0 - model-fit 0.1.0 -> 0.2.0 - ocr-ggml 0.16.0 -> 0.17.0 - translation-nmtcpp 0.8.0 -> 0.9.0 - vla-ggml 0.19.0 -> 0.20.0 llm-llamacpp's entry also documents image_no_upscale, since this bump is what creates the 0.44.0 release that publishes it. The other 6 are fabric-only with no API change. Registry publish: tetherto/qvac-registry-vcpkg#317. CI cannot resolve version>= 10069.1.0 until that merges.
#3725) * QVAC-23075 feat[api]: accept image_no_upscale in the addon load config Parses the idefics3-style preprocessing override out of the load config and forwards it to the vision context, so a caller can say "on" or "off" instead of being stuck with whatever the GGUF declares. Unset leaves the model's own value alone. This is what separates the VisionPsy Flash checkpoint from the base one, whose mmprojs are otherwise indistinguishable, so a Flash checkpoint loaded without it silently runs base preprocessing. It changes the image token count, so it moves both accuracy and encode time. LoadConfigHandlers parses the string into common_params, and MtmdLlmContext::initVisionContext copies it into mtmd_context_params next to image_tile_mode. Unit coverage for the parse sits with the other load-config cases. Needs the fabric side, tetherto/qvac-fabric-llm.cpp#205, which adds image_no_upscale to common_params and mtmd_context_params. cpp-lint stays red here until that merges and the registry publishes the next fabric version. * QVAC-23075 chore[notask]: overlay-validate the 7 fabric consumers against PR #205 Rollout Phase A for the VisionPsy fabric change. Adds the shared qvac-fabric overlay port and points all 7 consumers at it, so they build against the fabric PR head before the tag exists and before anything is published to the registry. REF is the commit a812964c9 rather than the branch feat/QVAC-23075-visionpsy or v${VERSION}: the tag does not exist yet, and the fabric PR is open, so a branch REF would silently start meaning a different tree as that PR moves. The port is copied from qvac-registry-vcpkg origin/main, which is two files; android-vulkan-version.cmake is gone from the registry and is deliberately not reinstated here. Consumer version>= pins stay at 10069.0.0 — the overlay bypasses version resolution, so only the overlay port's own version matters here; bumping the pins is Phase B. default-registry.baseline is untouched in all 7. Roster re-derived rather than assumed: git grep -l "qvac-fabric" origin/main -- "packages/*/vcpkg.json" classification-ggml is absent because it consumes the published @qvac/fabric npm package rather than the vcpkg port. TEMPORARY. This commit is reverted by /rollout-phase-b --on-top-of-pr before the fabric dependency bump. The PR itself is meant to merge; this commit is not. Rollout-Overlay: qvac-fabric 10069.1.0 ref=feat/QVAC-23075-visionpsy sha=a812964c93ce692e70fc190857614ef462c43850 * QVAC-23075 chore[notask]: re-pin the qvac-fabric overlay to PR #205 head 4ef2b3fd The fabric PR advanced while the first validation round was running: a812964c9 QVAC-23075 fix: take the idefics3 overview from the original again 4ef2b3fd QVAC-23075 fix: bound the idefics3-style preprocessing metadata at load 4ef2b3fd's parent is a812964c9, so this moves the overlay forward exactly one commit onto the current PR #205 head. Only REF and SHA512 change; the overlay port version stays 10069.1.0 and the 7 consumers' overlay-ports keys are untouched, as are their version>= pins and default-registry.baseline. The SHA512 was computed from the /archive/ tarball and independently matches the overlay on qvac#3814, which pins the same fabric commit. TEMPORARY. This commit and the overlay commit it re-pins are both reverted by /rollout-phase-b --on-top-of-pr, newest first, before the fabric dependency bump. Rollout-Overlay: qvac-fabric 10069.1.0 ref=feat/QVAC-23075-visionpsy sha=4ef2b3fdc0788d38e4e176030c07241ede40c5d0 * QVAC-23075 test: cover image_no_upscale reaching the vision encoder The unit tests stop at common_params, leaving the copy into mtmd_context_params untested -- the hop that silently dropped image_min/max_tokens once before (CHANGELOG 0.24.0). Three runs assert prompt token counts: on well under off, and omitting the key equal to off, which pins fabric's -1 model default end to end. Uses VisionPsy Flash because the override only applies to idefics3-style preprocessing and SmolVLM2's mmproj declares no size cap, which fabric rejects. Manifest pins verified against huggingface.co. * QVAC-23075 test: QVAC_VLM_MODEL swaps the shared VLM pair to VisionPsy Five test files take SmolVLM2 as the setupMultimodalInference default. This points them at VisionPsy Nano base when QVAC_VLM_MODEL=visionpsy, so the same assertions run against a second model. Unset keeps SmolVLM2 byte-identical; an unknown value throws rather than falling back. Both pairs sit in one literal because validate-mobile-manifest.js brace-matches the first `{` after the prestage-set marker -- a ternary would silently drop the second pair from the expected set. Each consumer prestage-ignores the VisionPsy pair, which Device Farm never selects. continuous-batching's ctx_size now travels with the model: SmolVLM2 keeps 4096, VisionPsy needs 8192 for ~862 image tokens across 4 slots. * QVAC-23075 test: unpin continuous-batching assertions from one model's phrasing Four MTMD tests failed under QVAC_VLM_MODEL=visionpsy while all 273 passed on SmolVLM2. No crashes and no context overflow -- every failure was a text assertion tuned to SmolVLM2's output style. - newspaper-content: VisionPsy names the publication ("New York Times") where SmolVLM2 reads the headline ("STORM."); both are true readings. Added times/york. Confirmed fixed -- the three tests carrying this now pass. - primary-yellow: added "sand" for VisionPsy's "sandstone", a yellow-brown shade and a fair reading. Deliberately still open-ended: offering the options instead made it worse, with Llama-3.2-1B picking "Green" off the list and breaking a previously passing test. - count-fingers: raised predict from 16 to 128 and told the system prompt not to explain. VisionPsy opens a <think> trace despite a chat template with no thinking branch; 64 tokens was not enough to close it. Still unverified. containsExpectedWord strips a leading reasoning trace. An unterminated block strips to empty and still fails, so an answer is never matched out of the model's own reasoning text -- the 64-token run proved that matters, since the trace itself contained "10 fingers". * QVAC-23075 test: default the shared VLM pair to VisionPsy QVAC_VLM_MODEL unset now selects VisionPsy Nano base instead of SmolVLM2, so the five tests that take the setupMultimodalInference default cover the model this suite exists for. SmolVLM2 stays reachable as QVAC_VLM_MODEL=smolvlm2. The mobile pre-stage map inverts with it: the five consumers now prestage-ignore the SmolVLM2 pair and their model-manifest entries stage the VisionPsy pair, since Device Farm loads whatever the default is. Leaving it as it was would have staged 545 MB nothing reads and downloaded VisionPsy mid-test, which is the flakiness the pre-stage map exists to prevent. Two consequences worth knowing: - A default run is 12/13 on continuous-batching. count-fingers fails because VisionPsy answers "metamorphs" to a counting question; Llama and SmolVLM2 both answer it. That was left as-is deliberately -- it is a model limitation the assertion reports correctly, not a test defect. - Device Farm now carries VisionPsy's cost: ~862 image tokens per image against SmolVLM2's ~64, and ctx_size 4096/8192 against 2048/4096. SmolVLM2's mmproj declares no preproc_image_size, so fabric gave it an overview-only encode -- it was the cheaper baseline, not the equivalent one. The iOS Jetsam ceiling that image-high-res-aurora already documents is the thing to watch. * QVAC-23075 test: work around a wrong VisionPsy answer in count-fingers VisionPsy answers count-fingers wrong. This does not fix that. It asks a wording the model gets right instead of asserting on the wording it fails, which is a workaround and worth calling one. It is defensible here because the test is "continuous batching MTMD: mixed image+text batch processes all slot types correctly". It covers batch admission and slot scheduling. One text-only slot returning a wrong answer says nothing about either, so gating this test on the answer to a general-knowledge question was testing the wrong thing. It is not evidence the model is fine, and the failure is real. Why a separate vlmUser instead of editing user. CASES feeds two paths with different system prompts: buildPrompt() to Llama-3.2-1B with the verbose 64-word instruction, buildVlmBatchItem() to the VLM pair with the one-word instruction. The two have no wording in common, so editing user just moves the failure. Measured with llama-cli at the same greedy settings the tests use, holding the frame at "How many fingers are on X? Answer with one word.": one typical human hand metamorphs a human hand 5 one human hand metamorphs the human hand 5 a typical human hand metamorphs an adult human hand 5 one hand metamorphs a normal human hand 5 a single human hand 5 your human hand 5 "one" and "typical" break VisionPsy and are exactly what Llama-3.2-1B needs, which answers "Ten" with them and "Fifty" without. SmolVLM2 answers 10 either way. The frame matters as much as the modifier: "are there on a single human hand" returns "No fingers" where "are on a single human hand" returns 5, so re-measure against both models before editing either wording. Reproduced on the research team's own llama.cpp build, upstream 08023072e plus visionpsy-nano.diff, identical on all 18 cells of a 6-wording by 3-model sweep, so this is the model and not the port. Reported separately to research. Verified locally against a fresh addon build, both pairs green: continuous-batching.test.js 13/13 tests and 167/167 asserts for the default visionpsy pair and for QVAC_VLM_MODEL=smolvlm2. * QVAC-23075 chore[notask]: drop the qvac-fabric overlay now that 10069.1.0 is published Reverts the two overlay-validation commits (7210e53 and its re-pin 40a1576). qvac-fabric v10069.1.0 is tagged and published to the registry, so the 7 fabric consumers resolve it from the registry again instead of the local overlay portfile. Note: until the bundled consumer bump lands, the version>= floors here still read 10069.0.0, so this branch builds against the previously published fabric. * QVAC-23075 feat[api]: bump qvac-fabric to 10069.1.0 across consumers Bumps the qvac-fabric vcpkg dependency floor from 10069.0.0 to 10069.1.0 for all 7 fabric consumers, with the matching package version bumps and changelog entries. qvac-fabric 10069.1.0 adds VisionPsy Nano support and its Flash preprocessing rule (tetherto/qvac-fabric-llm.cpp#205) — the fabric side this PR's image_no_upscale load option depends on. - embed-llamacpp 0.32.0 -> 0.33.0 - fabric 0.4.0 -> 0.5.0 - llm-llamacpp 0.43.0 -> 0.44.0 - model-fit 0.1.0 -> 0.2.0 - ocr-ggml 0.16.0 -> 0.17.0 - translation-nmtcpp 0.8.0 -> 0.9.0 - vla-ggml 0.19.0 -> 0.20.0 llm-llamacpp's entry also documents image_no_upscale, since this bump is what creates the 0.44.0 release that publishes it. The other 6 are fabric-only with no API change. Registry publish: tetherto/qvac-registry-vcpkg#317. CI cannot resolve version>= 10069.1.0 until that merges. --------- Co-authored-by: IC <ic.lomugdang@gmail.com> Co-authored-by: Maksim Smatrou <maxim-smotrov@users.noreply.github.com> Co-authored-by: iancris <17702377+iancris@users.noreply.github.com>
Overview
What problem does this PR solve?
clip.projector_type = "custom", so fabric cannot load them.resize_to_max_side_len, but their mmprojs declare identical vision hparams, so nothing in the GGUF separates them and Flash silently ran base preprocessing, inflating a 256x256 image into a 4x4 grid of upscaled slices.get_tensorandset_tensordisagreed about the Adreno q8_0 transpose:set_tensorreturns early for views and always decides on the parent, whileget_tensorasked about the view, so the two could pair a transposed writer with a non-transposed reader.How does it solve it?
clip_graph_siglipand reuses the llava-uhd preprocessing, since it is a siglip encoder with an idefics3-style pixel-shuffle merge and a singlemm_fcprojector. Four deltas from idefics3, all taken fromget_image_string()in the referenceprocessors.py: position embeddings interpolated to the actual patch grid, overview image emitted first, the delimiters<|global_image|>and<row_%d_col_%d>with no fake token and no row-end token, and an<image: N>label before each image once a prompt carries more than one. When the slice grid is 1x1 the overview and the single slice are the same crop, so it sends one of them, and it sends the slice: the reference returns the split patch untouched for that grid, and the slice is rendered bicubic where the overview is bilinear.general.nameas well as the projector string,{ "custom", "VisionPsyNano", PROJECTOR_TYPE_VISIONPSY }, because"custom"is not vendor-specific and another model shipping that string must not be silently loaded as VisionPsy; it would get idefics3 preprocessing and a hard<|global_image|>vocab requirement.PROJECTOR_TYPE_NAMESstays canonical andvisionpsyis what we write out.LLAMA_TOKEN_NULLinto the prompt if<|global_image|>is missing.lookup_token()itself is unchanged and still returnsLLAMA_TOKEN_NULLon a miss for every other projector type, this check is scoped to VisionPsy only. The throw is conditional on there being a vocab to look in, becausemtmd_get_memory_usagebuilds a context with no text model and every token misses there. Without that, everyllama-serverstart with this mmproj lost its mmproj VRAM reservation.clip.vision.preproc_no_upscale, read from the GGUF when present, overridable throughmtmd_context_params.image_no_upscaleand a new--image-no-upscale on|off. Tri-state, -1 keeps the model default, so every existing caller is unaffected. The published mmprojs do not carry the key, so today the flag selects the variant; a re-converted Flash mmproj would need no flag.calc_size_preserved_ratio, float32 differs in 10, all of them 4:3 or 3:4: 960x720 refined to 2048x2048 and sliced 4x4 where the reference gives 2048x1536 and 4x3, so the image gained 4 slices and 256 image tokens.a812964c9. UnderPAD_CEIL, which every idefics3 member except VisionPsy keeps, the refined image is letterboxed, so the bars would land inside the global image; both references stretch at each step and never letterbox. UnderPAD_NONE, which is VisionPsy, the refined resize is a plain stretch, so stretching it again to(p, p)is the same geometry as stretching the original once, and all it adds is a second resample through a 2x2 bilinear tap with no prefilter where the reference resize is antialiased.slice_imageandslice_instructionsare byte-identical tov10069.0.0.image_pad_rf = PAD_CEIL, so the refined image was aspect-preserved inside black bars whereDynamicResize.forwardstretches straight to the target. On the 640x488 test image that left 176 black columns on each side of a 1024x512 frame, about a third of the encoded pixels. VisionPsy has its own hparams case, so idefics3 is untouched.string_formatinclip-impl.h, which returnedstd::string(buf.data(), buf.size())over a buffer sizedsize + 1.mtmd.cppdoes not includecommon.h, so that is the overload the<image: N>label is built with, and the tokenizer got allsize + 1bytes:llama-tokenizeon this vocab gives[44, 5028, 42, 216, 32, 46]for the label and one extra token for the same bytes plus the NUL, so every image in a multi-image prompt carried a garbage token the checkpoint never saw.common/common.cppalready had it right.image_size == 0reachesGGML_ASSERT(align_size > 0)and aborts the process at the first image, so both idefics3-style projectors reject it. A missing cap makes the refined size{0,0}, an empty grid and an overview-only encode, 64 image tokens where the model expects hundreds: VisionPsy rejects that too, idefics3 warns instead, because the shippedggml-org/SmolVLM-500M-Instruct-GGUFmmproj carries no cap and still has to load. A cap below one slice is rejected on the no-upscale path, where it would otherwise reachstd::clampwithlo > hi, which is undefined behaviour rather than a bad size.mtmd.hthat consumers must rebuild against these headers on every libmtmd update.mtmd_context_paramsis passed by value and gains fields whileSOVERSIONstays 0, so an old binary linked against a new library can write past the caller's struct. Not bumpingSOVERSION: upstream set it to 0 in add version to all shared object files ggml-org/llama.cpp#17091 and has appended fields in mtmd: add mtmd_context_params::warmup option ggml-org/llama.cpp#17652, mtmd: add batching API ggml-org/llama.cpp#24384 and mtmd: add load progress callback ggml-org/llama.cpp#24865 without touching it, and our consumers all build fabric from source through a pinned vcpkg port, so there is no old binary to link.get_tensorreconstruct the whole q8_0 parent and read the view out of it atview_offs, the same shapeset_tensoralready used, and asksenable_adreno_trans_weightabout the parent rather than the view. That predicate gates the transpose inset_tensor, the restore inget_tensorand the adreno gemm/gemv selection inmul_matas a unit, so the three must agree. Sizing the staging buffer and the dispatch from the view was the other half of the same bug: the SoA buffers cover the parent and the transposed layout is indexed by the parent's row count, so a 4096x1 view of a 4096x4096 parent allocated about 4.3 KB and the kernel restored 64 rows into it, about 278.5 KB.kernel_restore_block_q8_0_transalso gets the row guard its rounded-up dispatch needs, which closes the same overflow for any parent whose row count is not a multiple of 64. Theq4_0andmxfp4readback paths still size from the view and ignoreview_offs; that is pre-existing, untouched here, and needs Adreno hardware to confirm.Three things reviewers may want to check:
DynamicResize._get_new_hwin the published processor, not from the vendor patch that was circulated. That patch aligns the refined size topatch_size * n_merge, 64, and gives the overview an aspect-preserving size, where the published model aligns tovit_img_size, 512, and resizes the global image to a square. I verified ours line by line againstcustom_transforms.pyandprocessors.pyonqvac/VisionPsy-Nano-460M-Flash.temp-10069, notmaster. The addon requiresqvac-fabric >= 10069.0.0, andmasteris a separate, older line: it carries neithercommon/finetune.hnormtmd_input_text.text_len, so a master-based fabric fails every addon prebuild platform at compile time.masterandv10069.0.0share an ancestor from 2026-04-17 and have diverged 401 and 1616 commits since.GGML_ASSERT(M % 4 == 0)withM = ne1onoutput.weightandtoken_embd.weight(960 x 49218, VisionPsy's vocab is 49218), is already fixed on this base, and more strictly:temp-10069also requiresne2 == 1 && ne3 == 1. The commit that carried our version cherry-picks to empty here and has been dropped.Additional information
How was it tested?
tests/test-mtmd-preproc-sizingpins the refined size, the grid and the slice count against the reference rule for 24 cases, both variants, from 256x256 to 4096x3072 and including the extreme aspect ratios. The expected values come fromDynamicResize._get_new_hwtranscribed to Python and evaluated in doubles, and the Python is in the test header. Putting float32 back fails 960x720, 720x960 and 1920x1440 while 1440x1080 still passes, which is why the class needs several members.tests/test-clip-preproc-metadatacovers the loader validation on metadata-only mmprojs generated at runtime, so nothing is committed and nothing is downloaded: 16 cases over zeroimage_sizeand zero cap with the flag both on and off, a cap below one slice, idefics3 with no cap key loading and warning, and the whole override matrix,-1keeping the GGUF value, 0 and 1 both applying, off against a GGUF that says on being announced, and a projector that does not read the flag warning.tests/test-clip-projector-aliaspins the alias togeneral.name, socustomwith any other name stays unknown.tools/mtmd/tests.shchecks the tile structure per row rather than only the answer text: 34 image encodes for the base row, which passes two images so the ordinal labels are exercised, and 3 for the Flash row. Both rows also pin the prompt sequence over the-vassembly log, the ordinals, the first and last delimiter of each grid, the slice grid line, the chunk total and the overview position. Mutation tested: blankingord_img_tmplleaves both the encode count and the chunk total untouched and is caught only by the ordinal patterns, and flippingov_img_firstleaves them untouched, still answers "the new york times", and is caught only by the overview-position patterns. A 1x1 grid needs an image whose long side is at most 512 and the only committed image is 640x488, so the single-tile path is pinned at the sizing level only.Correction on the Pixel 9 row. That leg ran the language model on Vulkan but the vision encoder on CPU. The addon auto-defaults the projector backend by GPU class (
LlamaModel.cpp): GPU on Adreno 800+, CPU on Mali and on any tier it cannot identify. The device logs from run 31409445243 say so directly, four times per device:which is also why Pixel 9 GPU encodes slower than its own CPU leg, 7223 ms against 6617 ms. So OpenCL is genuinely validated for the vision encoder on S25 Adreno, and Vulkan for the vision encoder is validated on desktop only. Mali Vulkan for the encoder has not been measured; reaching it needs
mmproj-use-gpuset explicitly, which the benchmark now permits.resize_position_embeddingsstill takes its early return for this model on both variants, since every crop is 512x512 and the patch grid is always 32x32. Confirmed withGGML_SCHED_DEBUG=2, zero UPSCALE nodes across every image size.Last full matrix, run 31457923076, all jobs green, taken 2026-08-11
Run ggml-org#335, cognitive preset,
addon@candidatebuilt from this fabric branch through the benchoverlay, 130 rows, 0 errors. First run where every job passed: 10 prebuild platforms, the
desktop leg and both Device Farm legs.
Run 31459302593 then forced the projector onto the Mali GPU, which is the one path the routine
legs cannot reach, and it executes:
Pixel 9, 0 errors, encode 10322 ms forced onto the GPU against 7005 ms on the CPU in the same
run. So Vulkan works for the vision encoder on Mali and is slower there, which is why the addon
defaults it to CPU on that GPU class.
The older device runs in this section predate several changes that alter what the encoder sees: the 1x1 grid now sends the bicubic slice instead of the bilinear overview, a multi-image prompt now carries
<image: N>labels without a trailing NUL token, the aspect-preserving refine is computed in double, and the refined image is no longer letterboxed. Every quality number in this section, run ggml-org#335 included, was taken before 2026-08-13 and is therefore stale, and the overview revert ina812964c9moves the encoder's input again. They are kept for the Adreno history and for the backend coverage they demonstrate, not as current quality figures. A re-run at this head is outstanding. The slice-count table was recomputed here and is unchanged, since none of these changes alter the grid. The earlier run 31392540850 was cancelled: it failed at prebuild on all nine platforms because the overlay still pinned a master-based fabric, which is what surfaced the wrong-base problem above.