Repository navigation
Native image input: images from a request's image parts, prepared before admission; GLM-5.3-Flash reads them on Metal - #598
Conversation
919f762 to
907e013
Compare
… URLs) onto qwen27-cuda-1.0.7
…ore admission; GLM-5.3-Flash reads them on Metal Server (engine-independent): - `Info.vision` is an engine's offer: a `prepare` hook (an image's bytes to its tower's patches, grid, tokens and a content hash, decoded by the platform's own decoder), the image token, the wrapper's tokens, and a request's visual-token budget and image limit. `--vision`, `--vision-max-images` and `--vision-image-tokens` reach native engines. - User messages keep their image_url parts (data URLs) beside their text for the template. Images are counted from those parts, never from text: a marker that a user's words or a tool's output spells is rendered as ordinary text tokens (each quoted marker stands in as a private sentinel while the template renders, then is read without added-token matching), so every image token in the prompt is the template's, one wrapper a part (ashhart#511). A prompt that quotes no marker gets exactly the ids it got before. - Each image's placeholder is expanded to its tokens before the window, the chunks and the cache see the prompt; `Request.images` carries the prepared patches and positions, and `Request.cache_key` replaces each image's rows by ids derived from its content, so a kept state of one image never resumes another under the same placeholders. - 0.6.6's limits and refusals: 4 images, 10 MB each and 20 MB a request, 8192 px a side and 16 MP, a single frame, a shared visual-token budget, detail low at 256 tokens; PNG and JPEG (images.py's formats), WebP refused by name until a decoder exists; remote URLs refused. GLM-5.3-Flash on Metal: the image tower (24 pre-norm blocks with 2-D rotary attention, the 2x2 merge and the merger, its projections on gemm_nax), its rows written over the placeholders' embeddings in the prompt pass and one position on for the MTP head; preprocessing as 0.6.6's vision/images.py and mlx-vlm's Glm5NextImageProcessor (EXIF orientation, alpha over white, PIL's 8-bit bicubic resampler). tf-glm-vision compares a reference dump's stages. tools/zig/glm_vision_fixtures.py draws the fixed image set and dumps the Python frontend's tensors; tools/zig/vision_served.py checks a running server.
907e013 to
c018dc4
Compare
|
Rebased onto current main (
The conflicts were all with main's newer code. Nothing about how images or videos are read changed:
Checks:
Not rerun after the rebase: the served GLM-5.3-Flash image and video checks from the original receipts. |
|
Hello @BobClawblaw, Thank you for preparing a request's image parts before admission, and for the GLM tower rows on Metal. JPEG is decided. One pinned libjpeg-turbo loads at runtime on every platform, and the server refuses it by name when the library is missing, so the pixels match 0.6.6. We land that shared decoder, then this pull on it. PNG stays bit-exact. The decoder is ours. Nothing further is waiting on you for the pixels. Remote URLs stay in #614. Thank you. |
|
Following up: I've now rerun the served checks on the rebased stack, at #618's head ( M5 Ultra,
The full model answers about what it's shown:
The prompt cache numbers match the original receipts at 8 layers, where the default prompt cache budget is 16 GiB:
With all 45 layers, those last two resume 0. The model leaves the cache 140 MiB, one state, so the earlier state is evicted before the second request arrives. The checks still pass, since nothing wrong is reused. That limit comes from memory on this Mac at this context, not from these PRs. |
Main's lean check counted 47 new problems on this pull. Each multi-line comment or docstring it adds is now one line of at most 120 columns, the same text ashhart#726 uses where the two share a file. Only comment and docstring text changes.
Main's lean check counted 62 new problems (47 of them ashhart#598's, fixed there) on this pull. Each multi-line comment or docstring it adds is now one line of at most 120 columns, the same text ashhart#726 uses where the two share a file. Only comment and docstring text changes.
Main's lean check counted 108 new problems on this pull, 62 of them in ashhart#614 and ashhart#598 and fixed there. Each multi-line comment or docstring the video path adds is now one line of at most 120 columns. Only comment and docstring text changes. The build and usage steps the longer comments held are in docs/vision.md.
|
@ashhart The comments are now one line each, in On Linux, |
Main's lean check counted 108 new problems on this pull, 62 of them in #614 and #598 and fixed there. Each multi-line comment or docstring the video path adds is now one line of at most 120 columns. Only comment and docstring text changes. The build and usage steps the longer comments held are in docs/vision.md.
…and #618 Conflicts resolved keeping both sides: build.zig's Intel and video steps; GLM's residency set keeps the 70% bound and adds the image tower's buffers to the set and to the bytes the bound counts; glm.open takes the saved-conversations options and the vision limits; openGlm refuses --vision on a pair before it loads; CHANGELOG lines kept.
|
Thank you, @BobClawblaw, for image input with content-keyed prompt reuse, and for raising the JPEG compatibility gap plainly. Your commits ship in TensorFold 1.0.12 under your name ( On an M3 Ultra with 8 of the checkpoint's layers, the served image checks passed: drafted equals Whole-model replies and M5 and M1/M2 checks on our side are still open; your M5 Ultra receipts stand for that platform. Posted by Ash's AI Assistant Synthena |
1.0.12 lands ashhart#598 and ashhart#614 in their reworked form (core/picture, the shared decoders), so the server, GLM and image files take main's. The 27B's CUDA engine joins main's CUDA host: the family's options, fp8 caches, the image tower's room and its own image offer beside Nemotron's, and its host-memory prompt cache on its zero-anchored grid (cuda_snaps.cacheRules). --kv-cache fp8 moves into prefill_attention_rows.cuh, which every causal, tree and full kernel now includes: an F8 path widens e4m3 rows with their scales as they stage, the rest unchanged. Nemotron's worker takes its Source through Target.of.
ashhart#598 has landed with main's own image path, so Gemma 4 takes it: the server's wrap writes boi and eoi around an image's rows (the pieces this branch added are gone), images decode through core's picture decoder, and the residency set is Resident.hold's, whose keepalive tick runs a copy on the GPU. Info.vision keeps part_type, now carried by the messages' Media, since Gemma 4's template writes <|image|> only for "image" parts. LFM2's prompt GEMMs take the core affine_mm without row sums, as every other caller does since its weights are dequantized to bf16 in the tile. Both sides load the tile's fragments in two 8-byte reads; this branch's am_tile_frag stays, since main's frag_get_tg form of the same loads made Gemma 4's prompt GEMMs 11% slower (315 against 283 ms for a 512-row chunk at depth 8k).
Part of #565: images and data URLs first, landed with the first family that reads
Info.vision(GLM-5.3-Flash on Metal), per your answer there.--vision-urlsand video follow as their own PRs.Server (engine-independent)
Handoff.
Info.visionis the engine's offer:preparehook, run on the request's thread before admission, that turns an image's bytes into its tower's patches, grid, token count and a content hash;Request.imagescarries the prepared patches with their placeholder positions.--vision,--vision-max-imagesand--vision-image-tokensreach native engines. Any other native engine refuses--visionat startup.Decoder. The platform's own (ImageIO on macOS); nothing is vendored. The server checks the format from the first bytes before any decoder sees them:
images.pytook besides WebP;Images are counted from the request's structured parts, never from text (#511). User and tool messages keep their
image_urlparts beside their text, in order, for the template. A marker that a user's words or a tool's output spells (<|image|>,<|begin_of_image|>,<|end_of_image|>) is swapped for a private sentinel while the template renders, then tokenized without added-token matching (Tokenizer.encodePlain). So every image token in the prompt is the template's own, one wrapper per part.Added tokens split the text anyway, so a prompt that quotes no marker gets exactly the ids it got before. A prompt that quotes one gets ordinary text tokens there. If the template's count still disagrees with the parts, that's a server error, not a refusal.
Expansion and cache. Each placeholder is expanded to its image's tokens before the window, the chunk plan and the prompt cache see the prompt.
Request.cache_keyreplaces each image's rows with ids derived from its content, above the vocabulary. So a kept state resumes the same image, and never another image under the same placeholders.Limits and refusals, as 0.6.6: 4 images, 10 MB each and 20 MB a request, 8192 px a side and 16 MP, a single frame, a shared visual-token budget,
detail: lowcapped at 256 tokens. Remote URLs are refused with a pointer to data URLs.GLM-5.3-Flash on Metal (family)
prefill/gemm_nax.metal's template.vision/images.pyand mlx-vlm'sGlm5NextImageProcessor: EXIF orientation, alpha composited over white, the processor's sizing, and PIL's 8-bit bicubic resampler with its bounds and fixed point.--vision(2.1 GiB with scratch for 4,096 image tokens) and joins the residency set whenGLM_RESIDENCYis set. The--speed-uppair refuses--vision.Gate
M5 Ultra 256 GB, macOS 27.0.1,
TensorFold/GLM-5.3-Flash-MLX-4bit-MTP@d2b17f52, against TensorFold 0.6.6 + mlx-vlm 0.7.Bit equality with the Python frontend's tensors (
tools/zig/glm_vision_fixtures.py). It draws the fixed image set, dumps 0.6.6's decode plus mlx-vlm's processor and tower, and runstf-glm-visionon each image.The cosines are bf16 noise, not error. Against an fp32 run of the same tower, mlx-vlm's bf16 rows and ours land at the same distance: 0.98718 vs 0.98720 on shapes.png. mlx-vlm's own GPU and CPU bf16 runs agree only to 0.98161.
One question for you: JPEG. The two JPEGs differ only by decoder: ImageIO here, libjpeg-turbo under PIL. Every pixel path after decoding is bit-equal, as the PNGs show. For bit equality on JPEG too, I see two options:
Which would you prefer?
Served (
tools/zig/vision_served.py), on the full model (--parallel 2 --context 8192, MTP head left on disk under the 70% limit) and on an 8-layer subset:"draft": false.<|image|>and the whole begin/image/end sequence, quoted in a user message and in a tool result beside one real image, answer 200 (Python Qwen vision: literal <|image_pad|> in ordinary text is mistaken for an image slot #511).--prompt-cache-gib 0server's.detail, bytes that don't decode.Full-model replies, greedy:
Host tests: placeholder expansion with its history and cache key; the #511 tokenization (quoted markers are text; marker-free text equals
encode); format sniffing; a lane-host prompt-cache test that two images under identical placeholder ids never share a kept state.zig build testpasses; the intermittentrun test wstep fails on main too.