Repository navigation
feat(models): add Jina VLM (jvlm) vision-language support - #1076
Conversation
Adds native support for Jina AI's small multilingual VLM. A 27-layer SigLIP-so400m-class ViT feeds a Molmo-style connector (two intermediate layers concatenated, learned pad embeddings, 2x2 mean-query attention pooling, SwiGLU projector) into a Qwen2-class decoder with an OLMo tensor layout. Validated end to end on the real `jinaai/jina-vlm-mlx` checkpoint through both the CLI and the server. The checkpoint declares `model_type: jvlm`, not the `jina_vlm` the issue expected, and it declares it at the top level and in both sub-configs. Detection routes on `jvlm`; `jina_vlm` is accepted as an alias because that is the upstream mlx-vlm module name and a hand-edited config could carry it. Three details had to come from the checkpoint's own vendor code rather than the mlx-vlm port, because they change the output and the port disagrees: `vit_layers` resolves against a hidden-state list that includes the `post_lnorm` output, so `[-4, -10]` selects the outputs of layers 24 and 18 rather than 23 and 17. Layers 25/26 and `post_norm` are then never consumed and are not loaded at all. The token grid uses `_molmo_get_patches_from_tiling`, which rounds each crop window up to a multiple of the pooling size individually. The shortcut in the mlx-vlm port agrees only when the crop window is already even, which it is for Molmo (16 patches) and is not here (19), so a 2x2 tiling would come out one pooled column short per axis. The coverage mask gets a trailing `-1` row rather than a leading `1.0` row. That is an upstream off-by-one (the thumbnail is prepended to the crops but the mask row is appended) and it makes the connector treat the last crop as partially padded. Reproduced deliberately: it is what the model was trained with. The scatter is additive rather than the reference's assignment, and the two are equivalent: upstream adds `embed(<im_patch>)` to every connector feature and then assigns the sum, and every target row is an `<im_patch>` token by construction, so its embedding is already exactly the term being added. `sequence_state_layout()` is delegated explicitly rather than left to the trait default, which would infer a model-owned placeholder from `supports_batching()` and hand `forward` an empty cache slice on the server path (the Falcon-OCR failure mode from #848: zero decoder layers, no panic, fluent garbage). The MLX conversion also drops upstream's `chat_template.jinja`, so that template is built in and used when a checkpoint declares none. Without it the server renders the generic `User:`/`Assistant:` fallback, and the model answers "17" to "What is the capital of France?" where its own template gives "Paris". The built-in is consulted only after every declared source has come up empty, so it cannot shadow what a checkpoint ships. Validation: `mlxcel generate` on a 640x480 photo of two cats on a pink couch with two remotes returns "two cats are comfortably resting on a pink couch ... surrounded by two remote controls", and the server returns the same text at the same 824 prompt tokens. 39 unit tests over config normalization, the ViT layer resolution, the connector, the image processor, the scatter, and the cache-layout declaration. Closes #849
The built-in Jina VLM chat template only accepted a flattened string `content`, but the server routes any request carrying a media content part through `apply_raw_with_kwargs` with `content` as a JSON array of typed parts. minijinja then failed with "tried to use + operator on unsupported types string and sequence" on every image request, the server silently fell back to the generic `User: X\n\nAssistant: ` prompt shape, and the server and CLI prompts diverged even though their token counts happened to agree. The template now mirrors upstream's `content['type']` loop and emits the literal `<|image|>` for image parts, which is the marker the Jina VLM runtime splices its processor-built image blocks at. `format_jina_vlm_prompt` is replaced by `split_jina_vlm_prompt`, which splits the rendered prompt at each `<|image|>` marker the way the reference `JinaVLMProcessor._interleave_text_and_image_tokens` replaces each marker token in place, and returns `n_images + 1` segments so image block `i` lands between segment `i` and segment `i + 1`. The old function stripped every marker and re-templated, recognizing an already-rendered prompt only when it started with `User:`, so a system turn was wrapped a second time into a doubled trailing `Assistant: Assistant:` and every image block was forced to the head of the first user turn regardless of where the marker sat. A system turn and text placed before or between images now keep their position. Surplus markers and markerless images are reconciled against the image count so a marker never opens a feature-less block. The loader now clamps `patch_size`, `pooling_h` and `pooling_w` to at least 1, so a malformed `preprocessor_config.json` with a zero in any of them fails into the defaults instead of panicking the process during load. The `output_suppressed_token_ids` doc comment no longer lists `<im_slice>`, which the returned vec never contained. Validated on the real 4-bit `jina-vlm-mlx` checkpoint on GB10 CUDA: the CLI still reports 814 image tokens and 824 total tokens with a grounded caption, the server now reports the same 824 prompt tokens with zero "Chat template render (raw) failed" warnings in its log and produces byte-identical text to the CLI, and a text-only request still answers "Paris." at 16 prompt tokens. Unit tests: 40 passed for `jina_vlm`, 97 for `server::chat_template`, 42 for `multimodal::vlm_runtime`, 158 for `loading::vlm`, 0 failed, plus a clean `cargo clippy --features cuda --lib --bins --tests -- -D warnings`. Refs #849
Implementation Review SummaryIntentAdd native Jina VLM ( Findings Addressed
Remaining Items
Verification of the three highest-risk areas1. Chat-template safety. The built-in is reached only after 2. The three deliberate divergences from the mlx-vlm port. All three were checked against 3. Post-fix validation (GB10, CUDA sm_121, release)
Verification
|
`Molmo2Embedding::forward` concatenated the base embedding table and the 128-row extension table on every call, and `forward` runs once per decode step. On the Jina VLM checkpoint the base table is BF16 [151936, 2048] (622 MB), so each generated token re-materialized a fresh ~623 MB concatenated array just to gather a single row. The concatenation now happens once in `from_weights`, which stores a single `table` field of shape [vocab_size + additional_vocab_size, hidden_size] and calls `eval` on it so the result is materialized at load time instead of re-entering the MLX graph per step. `forward` is reduced to the gather. The two `copy` calls in `from_weights` are dropped as well, since `concatenate` already returns a fresh owned array; that also removes two large load-time copies. Loader error strings and the `Result<Self, String>` signature are unchanged. Measured on GB10 (CUDA sm_121, release build) with `generate -m models/mlx/jina-vlm-mlx -n 128 --temp 0.0`: 83.1 / 83.1 / 83.9 tok/s before, 148.16 / 148.85 / 149.43 tok/s after, roughly a 1.8x decode speedup. The greedy output is byte-identical to the baseline (verified by checksum), since the concatenation, axis, and gather are unchanged and only hoisted. `Molmo2Embedding` is shared by molmo, molmo2, molmo_point, and jina_vlm, all of which reach it only through `forward`/`from_weights`, so all four benefit. Verified with `cargo clippy --features cuda --lib -- -D warnings`, the `molmo` (7 tests) and `jina_vlm` (40 tests) lib test filters, and greedy smoke runs against molmo-7b and molmo2-4b.
Three review findings on the Jina VLM image path, all reachable from an unauthenticated request and all fixed without changing numerics. The first resize widened the whole decoded image to f32 before resizing it, costing 12 bytes per source pixel on a buffer sized by the request. The decoder's allocation budget is charged in the native colour type, so a 16384x16384 8-bit grayscale PNG that compresses to a few hundred KB is charged 268 MB, becomes 805 MB after to_rgb8, and then 3.22 GB at the widening step, with all three buffers live at once and up to 16 images per request. `smart_resize` already bounds the destination to `max_pixels` before that point, so the full-resolution f32 copy never needed to exist. `resize_bilinear` now has a u8 entry point that applies `v as f32 / 255.0` at sample time, sharing one body with the f32 entry point so the interpolation arithmetic is untouched. The decoded RGB buffer is also dropped before cropping starts. The feature merge expressed a sparse row scatter as a dense `[seq_len, n_targets]` one-hot matrix plus a full GEMM. Both dimensions grow with the image count, so the cost was quadratic in it: 16 images at this processor's worst case gives seq_len ~33k against ~31k targets, a ~2 GB bf16 one-hot and a ~4.2 TFLOP matmul, on the serialized model thread where it blocks every other client. `scatter_add` is the same operation, since the merge is additive and every non-selected one-hot term was an exact `0.0 * x`. Jina VLM is the first family to route all images through this construction; the Molmo relatives it was copied from only ever process images[0]. The feature row index was bounds-checked against the sequence length but never against the connector output's row count. MLX's `take` does not bounds-check, and the CUDA gather kernel only wraps negative indices, so an out-of-range row is an out-of-bounds device read: silent garbage, or an illegal-address fault that poisons the CUDA context and kills the process rather than the request. The processor and the tower agree on the count today, but they parse it from separate config sources, so the bound is now enforced where the pairs are built. Validation on GB10 (CUDA sm_121): new unit tests pin both rewrites as bit-exact rather than approximate. The u8 sampler is compared bit-for-bit against convert-then-resize across downscale, upscale, single-axis and identity shapes; the scatter is compared bit-for-bit against the one-hot matmul it replaces at f32 and bf16. Peak RSS on a 12000x12000 source drops from 3,109,244 kB to 1,685,476 kB and is now flat in source resolution. Greedy generation on models/mlx/jina-vlm-mlx is unchanged: 424 image tokens / 434 total before and after, and text-only output is identical. Note for reviewers: greedy image-path generation on this checkpoint is already nondeterministic at 18e1a32, independent of this change. Twenty runs of the unmodified binary produced two distinct completions (17/3); twenty runs of the fixed binary produced the same two completions (10/10). Both replaced operations were verified reproducible in isolation over 64 repeats at the real problem size, so the source is elsewhere in the vision tower and is worth a separate look.
MLX sits behind a cxx bridge, so a C++ throw does not unwind into a Rust Result: it crosses as a process abort. Release-profile worker threads are fail-fast, so a Rust panic is a process kill rather than a per-request error. Both mean a hand-edited or malformed checkpoint has to be rejected in Rust, at load, while it is still a reportable Err. Four places in the jvlm port did not. Text geometry was silently defaulted. Every field in JinaVlmTextConfig::from_json falls back to the released 3B value when its key is missing, null, a string, or negative, and num_attention_heads, num_key_value_heads, head_dim and intermediate_size were read only from the nested block_config path despite the doc comment promising both spellings. A variant shipping a flat HF text_config resolved num_hidden_layers and took the 3B head geometry for the rest, which keeps every Q/K/V slice of the fused qkv in range: MLX clamps nothing, throws nothing, the reshape stays self-consistent by construction, and attention runs on the wrong parts of the projection while the output still reads fluently. Two changes: the HF aliases the doc comment already promised (nested spelling still wins, so the released checkpoint never reaches one), and validate_fused_qkv_geometry, which cross-checks the loaded attn.qkv against the config at load. Affine packing compresses the input axis only, so the row count is logical in both the dense [4096, 2048] bf16 and the quantized [4096, 256] uint32 case and one row check covers both; the input width splits, comparing the axis directly when dense and going through mlxcel_core::layers::validate_quantized_packing when quantized. vision_config.patch_size, pooling_h and pooling_w were unbounded. All three divide the patch grid in the tower and the connector: 0 is a divide-by-zero panic on the first image request, and a negative makes the pad width negative, which throws inside mlxcel_core::pad and aborts. The same loader file already clamps the processor's copies of these three keys 75 lines below. They are rejected rather than clamped here, following vlm_locateanything: the two consumers of each number have to agree, and flooring one of them silently desynchronizes the pooled feature count from the emitted <im_patch> count. pos_embed and pad_embed ranks were trusted from the safetensors file. A 1-D pos_embed panics on pe_shape[1], a 3-D one drops elements and aborts in reshape, and a single-row pad_embed is silently clamped by MLX's slice to a zero-row result that aborts in the following reshape. Both are now checked in their from_weights constructors. pad_embed stays optional; the field now documents that this is deliberate and that its absence costs the learned pad markers on unevenly tiled images. crop_h and crop_w underflowed. With overlap_margins summing above base_input_size.0 / patch_size the plain subtraction wrapped to ~1.8e19 in release, pooled_h stayed astronomically large, and the pooled-grid loop never terminated: no fault, no panic, just a wedged model worker and a server that stops serving. Both now use saturating_sub, matching crop_window_patches in the same function, and build_processor rejects the degenerate margin pair at load so it never gets that far. Validated on GB10 (CUDA sm_121, --features cuda). cargo clippy --features cuda --lib -- -D warnings clean, cargo fmt clean, 52 jina_vlm and 171 vision::processors tests pass under --profile test-fast. Regression-gated on the real jina-vlm-mlx checkpoint: text-only generation is byte-identical to before, and the image path still loads with no new diagnostic and reports the same 424 image tokens, 434 total tokens.
Security and performance reviewFocus: the server-reachable surface (chat template rendering, image preprocessing on untrusted uploads), the split embedding bounds, quantized checkpoint loading, and the decode hot path. Three commits pushed: Verdict on attacker-controlled chat contentAttacker-controlled chat content cannot destabilize the server. The built-in The change to the shared resolution path is behaviour-preserving for every other model: FixedHIGH (perf, HIGH ( HIGH ( MEDIUM ( CRITICAL-class robustness ( HIGH robustness ( HIGH robustness ( MEDIUM ( Verified clean
Not fixed, worth a follow-up
VerificationRelease build with |
…chain (#1081) Bilingual technical reports for the three VLM ports merged on 2026-08-07, following the existing `TECHNICAL_REPORTS/` convention. Each report is dated by its PR's merge date rather than the authoring date, and each Korean file mirrors its English counterpart section for section. - `1070-locateanything-vlm-20260807.{en,ko}.md` for PR #1070 (issue #847), LocateAnything grounding VLM. - `1075-falcon-ocr-early-fusion-20260807.{en,ko}.md` for PR #1075 (issue #848), Falcon-OCR early-fusion VLM. - `1076-jina-vlm-20260807.{en,ko}.md` for PR #1076 (issue #849), Jina VLM. The reports concentrate on what real-checkpoint validation caught and synthetic parity would have missed. The LocateAnything report records that the released checkpoint is `mixed_4_8` rather than uniform 4-bit, which hard-errors in `FusedQKVLinear::from_weights_separate`, and that this breaks any family loading an `mlx_lm --quant-predicate mixed_4_8` conversion rather than LocateAnything alone. The Falcon-OCR report records the `sequence_state_layout()` trait default that let the scheduler allocate an empty cache vector, so the server ran zero of 22 decoder layers with no panic and no log line while the CLI stayed correct because it builds its own caches. The Jina VLM report records that the checkpoint declares `model_type: jvlm` rather than the `jina_vlm` the issue assumed, and that the chat-template fallback's matching 824 prompt-token count across CLI and server was coincidence rather than evidence that the two front ends rendered the same prompt. Each report also carries an explicit section for what was not verified, so the gaps are on the record: no numeric oracle parity for Jina VLM, the Apple Silicon bf16 branch that cannot be exercised on the Linux CUDA box, absent layout detection for Falcon-OCR, and the pre-existing failures on `main` that were control-run before being attributed anywhere. Documentation only. No source, test, or build changes.
## Summary Adds native support for Jina AI's small multilingual VLM (`model_type: jvlm`, `jinaai/jina-vlm` / `jinaai/jina-vlm-mlx`). A 27-layer SigLIP-so400m-class ViT feeds a Molmo-style connector into a Qwen2-class decoder with an OLMo tensor layout. Validated end to end on the real 4-bit MLX checkpoint through both `mlxcel generate` and `mlxcel-server`. ## `model_type` is `jvlm`, not `jina_vlm` The issue body says `jina_vlm`. No checkpoint declares that: `jinaai/jina-vlm-mlx` says `jvlm` at the top level **and** in `text_config.model_type` and `vision_config.model_type`, with `architectures: ["JinaVLMForConditionalGeneration"]` and `auto_map` pointing at `modeling_jvlm.JinaVLM`. `jina_vlm` is only the upstream mlx-vlm module name. Detection routes on `jvlm`, and accepts `jina_vlm` as an alias so a hand-edited config still lands in the right loader; a unit test pins both. ## Three places where the checkpoint's own code and the mlx-vlm port disagree mlx-vlm's `models/jina_vlm/` is the obvious reference, but it is a reimplementation, and on three points it does not match the vendor code shipped with the checkpoint (`modeling_jvlm.py`, `blocks_jvlm.py`, `image_processing_jvlm.py`, `processing_jvlm.py`). The vendor code wins here, because that is what the model was trained with. All three are silent when wrong: they degrade the description rather than raise. **`vit_layers` resolves against a list that includes the post-norm output.** `JinaVLMVisionModel.get_visual_features` appends the `post_lnorm` output to `hidden_states`, so the list is 28 long for a 27-layer tower and `[-4, -10]` selects the outputs of layers **24 and 18**, not 23 and 17. Layers 25/26 and `post_norm` are then never consumed, so they are not loaded at all. The concatenation order is `vit_layers` order, not sorted, because it fixes which half of `vl_connector.pooling.q`'s 2304-wide input each layer feeds. **The token grid rounds each crop window up separately.** `_molmo_get_patches_from_tiling` rounds `crop_window + margin` up to a multiple of the pooling size per crop, then sums. The `tiles * window + margins`-then-round shortcut agrees only when the crop window is already even, which it is for Molmo (16 patches) and is not here (19): a 2x2 tiling comes out 46 where the reference says 48, one pooled column short per axis. **The coverage mask gets a trailing `-1` row, not a leading `1.0` row.** Upstream appends the sentinel after the crop masks even though the thumbnail was prepended to the pixel values, so mask row *k* belongs to crop *k+1* and the last crop reads as partially padded (`pad_embed[1]` is added to it). That is an upstream off-by-one, reproduced deliberately and pinned by a test, for the same reason mlxcel's Molmo v1 processor reproduces its own version of it. ## The scatter is additive, and that is exactly the reference's assignment `JinaVLMModel._encode_images` adds the `<im_patch>` (151938) token embedding to every connector feature and then *assigns* the sum over the target rows. Every target row is an `<im_patch>` token by construction, because `image_input_idx` is built from `nonzero(tokens == <im_patch>)`, so its embedding is already exactly the term being added and `embed + feature` equals `feature + embed`. Adding avoids a second embedding lookup and a masked write. A test pins the arithmetic so a future switch to an overwrite has to be deliberate. ## The Falcon-OCR trap `sequence_state_layout()` is delegated to the text model explicitly rather than left to the trait default. The default infers a model-owned placeholder from `supports_batching()`, and a wrapper that ever returns `false` there makes the server allocate an empty external cache; `forward`'s `layers.iter().zip(caches.iter_mut())` then runs zero decoder layers with no panic and no log line. That is what shipped in lablup#848. There is a regression test asserting `DenseKvCache` and a layer count matching `num_layers()`, and the server path below was exercised for real rather than assumed. ## The chat template the MLX conversion dropped `jinaai/jina-vlm` ships a `chat_template.jinja`; the MLX conversion does not copy it, and `tokenizer_config.json` has no `chat_template` either. The server then falls back to the generic `User:\n\nAssistant: ` template, which renders a prompt shape the model never saw. The difference is not subtle: asked for the capital of France it answers `17` under the generic template and `Paris` under its own. So that template is now built in and consulted **only after every declared source has come up empty** (`tokenizer_config.json`, `chat_template.jinja`, `chat_template.json`), keyed on `model_type`, so it cannot shadow what a checkpoint actually ships. Three tests cover the fallback, the no-shadowing rule, and that an unknown template-less model still reports no template. With it, the server's image request renders the same 824-token prompt as the CLI. ## What changed - `src/models/jina_vlm.rs`: nested `text_config` normalization plus the decoder (fused `attn.qkv`, per-head Q/K RMSNorm, fused `ffn.gate_up` ordered `[up, gate]`, rotate-half RoPE at theta 1e6, untied 4-bit `lm_head`). The split base-plus-extension embedding is Molmo's `Molmo2Embedding`, reused rather than re-declared. - `src/vision/encoders/jina_vlm.rs`: the ViT tower (linear patch embed over patchified pixels, learned absolute positions, no class token, fused QKV, tanh-GELU MLP) and the connector (multi-layer concat, `pad_and_partial_pad`, 2x2 mean-query cross-attention pooling, SwiGLU projector). Not reusing `encoders::siglip` because that module expects `q_proj`/`k_proj`/`v_proj` and `layer_norm1/2`. - `src/vision/processors/jina_vlm.rs`: `smart_resize` with Python's banker's rounding, `select_tiling`, the overlap-and-resize cropper, the `<im_start> [<im_patch> * w <im_col>] * h <im_end>` block, and `build_image_input_idx`. Resampling is bilinear with `align_corners=false` and no antialiasing, matching the checkpoint's `interpolation: bilinear` / `antialias: false` rather than the PIL bicubic the mlx-vlm port hardcodes. - `src/vision/jina_vlm.rs`: the runtime (feature scatter, cache-layout declaration, structural-token suppression). - `src/loading/vlm_jina_vlm.rs`: config normalization for the nested schema, processor construction from `preprocessor_config.json`, EOS resolution, and the `<|image|>` id. - `src/multimodal/vlm_runtime.rs`: prompt assembly (` User: ` + image block + text + ` Assistant:`), the BOS the reference processor prepends, and the `image_input_idx` offset. Multi-image prompts concatenate their crops and blocks. - Control-plane wiring: `ModelType::JinaVLM`, the `"jvlm"` / `"jina_vlm"` detection arm, `for_each_model_registration!`, `LoadedModel`, `VlmRuntimeRef::JinaVlm`, `try_load_vlm_model_from_dir`, the `generate_vlm` summary line, and the TP arch-string table. - `src/server/chat_template.rs`: the built-in fallback described above. - `docs/supported-models.md`. No weight-key remapping step: the checkpoint already spells keys as `language_model.*` / `vision_model.*` / `vl_connector.*`. Mixed precision (language layer 0, every norm, both embedding tables, the ViT patch embedding and positional table, and `vision_model.layers.N.ffn.down` in bf16; the rest 4-bit affine) needs no special handling because `UnifiedLinear::from_weights` decides per prefix by probing for `.scales`, but the top-level `quantization` block does have to be threaded into the vision side too, not just the text stack. ## Real-checkpoint validation GB10, CUDA (sm_121), release build, `models/mlx/jina-vlm-mlx` (`jinaai/jina-vlm-mlx`, 4-bit / group 64). Image: a 640x480 photo of two cats lying on a pink couch with two TV remotes between them. ``` $ ./target/release/mlxcel generate -m models/mlx/jina-vlm-mlx --image coco_cats.jpg \ -p "Describe the image." -n 128 --temp 0.0 Jina VLM: inserted 1 image block(s) (814 image tokens, 824 total tokens) Generating... Describe the image. In the image, two cats are comfortably resting on a pink couch. One cat is lying on its side, while the other cat is lying on its back. Both cats are surrounded by two remote controls, one positioned closer to the left cat and the other closer to the right cat. The scene appears to be a cozy and peaceful moment between the cats and their human companions. [Generated 75 tokens in 2.44s = 30.75 tok/s] ``` Two cats, pink couch, two remotes: every grounded detail is right. A second, more pointed question rules out a generic caption: ``` $ ./target/release/mlxcel generate -m models/mlx/jina-vlm-mlx --image coco_cats.jpg \ -p "How many cats are in the picture, what color is the couch, and what objects are lying next to them?" -n 96 --temp 0.0 There are two cats in the picture, and they are lying on a pink couch. Next to the cats, there are two remote controls, one on each side of the cats. ``` The 814 image tokens are the arithmetic working out end to end: `smart_resize` takes 640x480 to 644x476, `select_tiling` picks 2x2 against the 266-pixel crop window, the crop block is `24 patch + 1 col` per row over 24 rows plus framing (602) and the thumbnail block is `14 + 1` over 14 rows plus framing (212). 5 crops of 729 patches, 980 pooled features, 772 of them valid, which is exactly the `<im_patch>` count. `./target/release/mlxcel arch` lists `Jina VLM (SigLIP-so400m + 2x2 attention pooling + Qwen2 text)`. ### Server ``` $ ./target/release/mlxcel-server --model models/mlx/jina-vlm-mlx --port 18653 $ curl -X POST localhost:18653/v1/chat/completions -d @image_request.json prompt_tokens 824 'In the image, two cats are comfortably resting on a pink couch. One cat is lying down on its side, while the other cat is lying down on its back. Both cats are surrounded by two remote controls, one positioned closer to the left cat and the other closer to the right cat. The scene appears to be a cozy and peaceful moment between the two cats.' ``` 824 prompt tokens on the server, the same count the CLI reports, which is the check that the two front ends render the same prompt. Text-only on the same server: ``` $ curl -X POST localhost:18653/v1/chat/completions \ -d '{"messages":[{"role":"user","content":"What is the capital of France? Answer in one word."}],"max_tokens":24,"temperature":0}' prompt_tokens 16 'Paris.' ``` Two concurrent image requests were also run through the batch scheduler and both answered correctly (`There are two animals in the image, which are cats.` / `The couch is pink.`). ## Test plan - [x] `MLX_ENABLE_TF32=0 cargo test --profile test-fast --features cuda -p mlxcel --lib jina_vlm -- --test-threads=1` -> 39 passed - [x] `MLX_ENABLE_TF32=0 cargo test --profile test-fast --features cuda -p mlxcel --lib server::chat_template -- --test-threads=1` -> 95 passed, 1 ignored - [x] `MLX_ENABLE_TF32=0 cargo test --profile test-fast --features cuda -p mlxcel --lib multimodal::vlm_runtime -- --test-threads=1` -> 37 passed - [x] `MLX_ENABLE_TF32=0 cargo test --profile test-fast --features cuda -p mlxcel --lib models::detection -- --test-threads=1` -> 26 passed - [x] `MLX_ENABLE_TF32=0 cargo test --profile test-fast --features cuda -p mlxcel --lib model_metadata -- --test-threads=1` -> 8 passed - [x] `MLX_ENABLE_TF32=0 cargo test --profile test-fast --features cuda -p mlxcel --lib models::metadata_tests -- --test-threads=1` -> 4 passed - [x] `cargo clippy --features cuda --lib --bins --tests -- -D warnings` clean - [x] `cargo fmt --all` clean - [x] `cargo build --release --features cuda --bin mlxcel --bin mlxcel-server` plus the CLI and server runs above The synthetic tests cover the pieces that fail quietly: that negative `vit_layers` resolve against the post-norm slot and keep their order, that a fully padded crop is zeroed before the connector (with a live control crop so the check cannot pass by producing zeros everywhere), that the pad embedding fires only where the mask says so, that the pooled-patch to `<im_patch>` mapping is a bijection, that incremental decode lands on the same logits as a full-sequence pass (which is what catches a wrong RoPE offset or a QK norm on the wrong axis), and that the runtime declares the dense KV layout. ## Not verified No numeric parity against a PyTorch or mlx-vlm oracle: neither runs on this box, so the evidence is grounded generation on a real photo plus the token-count arithmetic above, not a logit diff. Multi-image prompts are implemented (crops and blocks concatenate, `image_input_idx` offsets accumulate) but were only exercised with one image. Tensor parallelism is refused for VLM-kind models before the arch-string table is read, so the `qwen2` entry there is only kept for totality. Closes lablup#849
…blup#849 VLM port chain (lablup#1081) Bilingual technical reports for the three VLM ports merged on 2026-08-07, following the existing `TECHNICAL_REPORTS/` convention. Each report is dated by its PR's merge date rather than the authoring date, and each Korean file mirrors its English counterpart section for section. - `1070-locateanything-vlm-20260807.{en,ko}.md` for PR lablup#1070 (issue lablup#847), LocateAnything grounding VLM. - `1075-falcon-ocr-early-fusion-20260807.{en,ko}.md` for PR lablup#1075 (issue lablup#848), Falcon-OCR early-fusion VLM. - `1076-jina-vlm-20260807.{en,ko}.md` for PR lablup#1076 (issue lablup#849), Jina VLM. The reports concentrate on what real-checkpoint validation caught and synthetic parity would have missed. The LocateAnything report records that the released checkpoint is `mixed_4_8` rather than uniform 4-bit, which hard-errors in `FusedQKVLinear::from_weights_separate`, and that this breaks any family loading an `mlx_lm --quant-predicate mixed_4_8` conversion rather than LocateAnything alone. The Falcon-OCR report records the `sequence_state_layout()` trait default that let the scheduler allocate an empty cache vector, so the server ran zero of 22 decoder layers with no panic and no log line while the CLI stayed correct because it builds its own caches. The Jina VLM report records that the checkpoint declares `model_type: jvlm` rather than the `jina_vlm` the issue assumed, and that the chat-template fallback's matching 824 prompt-token count across CLI and server was coincidence rather than evidence that the two front ends rendered the same prompt. Each report also carries an explicit section for what was not verified, so the gaps are on the record: no numeric oracle parity for Jina VLM, the Apple Silicon bf16 branch that cannot be exercised on the Linux CUDA box, absent layout detection for Falcon-OCR, and the pre-existing failures on `main` that were control-run before being attributed anywhere. Documentation only. No source, test, or build changes.
Summary
Adds native support for Jina AI's small multilingual VLM (
model_type: jvlm,jinaai/jina-vlm/jinaai/jina-vlm-mlx). A 27-layer SigLIP-so400m-class ViT feeds a Molmo-style connector into a Qwen2-class decoder with an OLMo tensor layout. Validated end to end on the real 4-bit MLX checkpoint through bothmlxcel generateandmlxcel-server.model_typeisjvlm, notjina_vlmThe issue body says
jina_vlm. No checkpoint declares that:jinaai/jina-vlm-mlxsaysjvlmat the top level and intext_config.model_typeandvision_config.model_type, witharchitectures: ["JinaVLMForConditionalGeneration"]andauto_mappointing atmodeling_jvlm.JinaVLM.jina_vlmis only the upstream mlx-vlm module name. Detection routes onjvlm, and acceptsjina_vlmas an alias so a hand-edited config still lands in the right loader; a unit test pins both.Three places where the checkpoint's own code and the mlx-vlm port disagree
mlx-vlm's
models/jina_vlm/is the obvious reference, but it is a reimplementation, and on three points it does not match the vendor code shipped with the checkpoint (modeling_jvlm.py,blocks_jvlm.py,image_processing_jvlm.py,processing_jvlm.py). The vendor code wins here, because that is what the model was trained with. All three are silent when wrong: they degrade the description rather than raise.vit_layersresolves against a list that includes the post-norm output.JinaVLMVisionModel.get_visual_featuresappends thepost_lnormoutput tohidden_states, so the list is 28 long for a 27-layer tower and[-4, -10]selects the outputs of layers 24 and 18, not 23 and 17. Layers 25/26 andpost_normare then never consumed, so they are not loaded at all. The concatenation order isvit_layersorder, not sorted, because it fixes which half ofvl_connector.pooling.q's 2304-wide input each layer feeds.The token grid rounds each crop window up separately.
_molmo_get_patches_from_tilingroundscrop_window + marginup to a multiple of the pooling size per crop, then sums. Thetiles * window + margins-then-round shortcut agrees only when the crop window is already even, which it is for Molmo (16 patches) and is not here (19): a 2x2 tiling comes out 46 where the reference says 48, one pooled column short per axis.The coverage mask gets a trailing
-1row, not a leading1.0row. Upstream appends the sentinel after the crop masks even though the thumbnail was prepended to the pixel values, so mask row k belongs to crop k+1 and the last crop reads as partially padded (pad_embed[1]is added to it). That is an upstream off-by-one, reproduced deliberately and pinned by a test, for the same reason mlxcel's Molmo v1 processor reproduces its own version of it.The scatter is additive, and that is exactly the reference's assignment
JinaVLMModel._encode_imagesadds the<im_patch>(151938) token embedding to every connector feature and then assigns the sum over the target rows. Every target row is an<im_patch>token by construction, becauseimage_input_idxis built fromnonzero(tokens == <im_patch>), so its embedding is already exactly the term being added andembed + featureequalsfeature + embed. Adding avoids a second embedding lookup and a masked write. A test pins the arithmetic so a future switch to an overwrite has to be deliberate.The Falcon-OCR trap
sequence_state_layout()is delegated to the text model explicitly rather than left to the trait default. The default infers a model-owned placeholder fromsupports_batching(), and a wrapper that ever returnsfalsethere makes the server allocate an empty external cache;forward'slayers.iter().zip(caches.iter_mut())then runs zero decoder layers with no panic and no log line. That is what shipped in #848. There is a regression test assertingDenseKvCacheand a layer count matchingnum_layers(), and the server path below was exercised for real rather than assumed.The chat template the MLX conversion dropped
jinaai/jina-vlmships achat_template.jinja; the MLX conversion does not copy it, andtokenizer_config.jsonhas nochat_templateeither. The server then falls back to the genericUser:\n\nAssistant:template, which renders a prompt shape the model never saw. The difference is not subtle: asked for the capital of France it answers17under the generic template andParisunder its own.So that template is now built in and consulted only after every declared source has come up empty (
tokenizer_config.json,chat_template.jinja,chat_template.json), keyed onmodel_type, so it cannot shadow what a checkpoint actually ships. Three tests cover the fallback, the no-shadowing rule, and that an unknown template-less model still reports no template. With it, the server's image request renders the same 824-token prompt as the CLI.What changed
src/models/jina_vlm.rs: nestedtext_confignormalization plus the decoder (fusedattn.qkv, per-head Q/K RMSNorm, fusedffn.gate_upordered[up, gate], rotate-half RoPE at theta 1e6, untied 4-bitlm_head). The split base-plus-extension embedding is Molmo'sMolmo2Embedding, reused rather than re-declared.src/vision/encoders/jina_vlm.rs: the ViT tower (linear patch embed over patchified pixels, learned absolute positions, no class token, fused QKV, tanh-GELU MLP) and the connector (multi-layer concat,pad_and_partial_pad, 2x2 mean-query cross-attention pooling, SwiGLU projector). Not reusingencoders::siglipbecause that module expectsq_proj/k_proj/v_projandlayer_norm1/2.src/vision/processors/jina_vlm.rs:smart_resizewith Python's banker's rounding,select_tiling, the overlap-and-resize cropper, the<im_start> [<im_patch> * w <im_col>] * h <im_end>block, andbuild_image_input_idx. Resampling is bilinear withalign_corners=falseand no antialiasing, matching the checkpoint'sinterpolation: bilinear/antialias: falserather than the PIL bicubic the mlx-vlm port hardcodes.src/vision/jina_vlm.rs: the runtime (feature scatter, cache-layout declaration, structural-token suppression).src/loading/vlm_jina_vlm.rs: config normalization for the nested schema, processor construction frompreprocessor_config.json, EOS resolution, and the<|image|>id.src/multimodal/vlm_runtime.rs: prompt assembly (User:+ image block + text +Assistant:), the BOS the reference processor prepends, and theimage_input_idxoffset. Multi-image prompts concatenate their crops and blocks.ModelType::JinaVLM, the"jvlm"/"jina_vlm"detection arm,for_each_model_registration!,LoadedModel,VlmRuntimeRef::JinaVlm,try_load_vlm_model_from_dir, thegenerate_vlmsummary line, and the TP arch-string table.src/server/chat_template.rs: the built-in fallback described above.docs/supported-models.md.No weight-key remapping step: the checkpoint already spells keys as
language_model.*/vision_model.*/vl_connector.*. Mixed precision (language layer 0, every norm, both embedding tables, the ViT patch embedding and positional table, andvision_model.layers.N.ffn.downin bf16; the rest 4-bit affine) needs no special handling becauseUnifiedLinear::from_weightsdecides per prefix by probing for.scales, but the top-levelquantizationblock does have to be threaded into the vision side too, not just the text stack.Real-checkpoint validation
GB10, CUDA (sm_121), release build,
models/mlx/jina-vlm-mlx(jinaai/jina-vlm-mlx, 4-bit / group 64). Image: a 640x480 photo of two cats lying on a pink couch with two TV remotes between them.Two cats, pink couch, two remotes: every grounded detail is right. A second, more pointed question rules out a generic caption:
The 814 image tokens are the arithmetic working out end to end:
smart_resizetakes 640x480 to 644x476,select_tilingpicks 2x2 against the 266-pixel crop window, the crop block is24 patch + 1 colper row over 24 rows plus framing (602) and the thumbnail block is14 + 1over 14 rows plus framing (212). 5 crops of 729 patches, 980 pooled features, 772 of them valid, which is exactly the<im_patch>count../target/release/mlxcel archlistsJina VLM (SigLIP-so400m + 2x2 attention pooling + Qwen2 text).Server
824 prompt tokens on the server, the same count the CLI reports, which is the check that the two front ends render the same prompt. Text-only on the same server:
Two concurrent image requests were also run through the batch scheduler and both answered correctly (
There are two animals in the image, which are cats./The couch is pink.).Test plan
MLX_ENABLE_TF32=0 cargo test --profile test-fast --features cuda -p mlxcel --lib jina_vlm -- --test-threads=1-> 39 passedMLX_ENABLE_TF32=0 cargo test --profile test-fast --features cuda -p mlxcel --lib server::chat_template -- --test-threads=1-> 95 passed, 1 ignoredMLX_ENABLE_TF32=0 cargo test --profile test-fast --features cuda -p mlxcel --lib multimodal::vlm_runtime -- --test-threads=1-> 37 passedMLX_ENABLE_TF32=0 cargo test --profile test-fast --features cuda -p mlxcel --lib models::detection -- --test-threads=1-> 26 passedMLX_ENABLE_TF32=0 cargo test --profile test-fast --features cuda -p mlxcel --lib model_metadata -- --test-threads=1-> 8 passedMLX_ENABLE_TF32=0 cargo test --profile test-fast --features cuda -p mlxcel --lib models::metadata_tests -- --test-threads=1-> 4 passedcargo clippy --features cuda --lib --bins --tests -- -D warningscleancargo fmt --allcleancargo build --release --features cuda --bin mlxcel --bin mlxcel-serverplus the CLI and server runs aboveThe synthetic tests cover the pieces that fail quietly: that negative
vit_layersresolve against the post-norm slot and keep their order, that a fully padded crop is zeroed before the connector (with a live control crop so the check cannot pass by producing zeros everywhere), that the pad embedding fires only where the mask says so, that the pooled-patch to<im_patch>mapping is a bijection, that incremental decode lands on the same logits as a full-sequence pass (which is what catches a wrong RoPE offset or a QK norm on the wrong axis), and that the runtime declares the dense KV layout.Not verified
No numeric parity against a PyTorch or mlx-vlm oracle: neither runs on this box, so the evidence is grounded generation on a real photo plus the token-count arithmetic above, not a logit diff. Multi-image prompts are implemented (crops and blocks concatenate,
image_input_idxoffsets accumulate) but were only exercised with one image. Tensor parallelism is refused for VLM-kind models before the arch-string table is read, so theqwen2entry there is only kept for totality.Closes #849