Skip to content

feat(llmjpvl): port LLM-jp-4 VL and Jagle-VL - #1737

Merged
inureyes merged 3 commits into
mainfrom
feature/issue-1362-llmjpvl-port
Sep 9, 2026
Merged

inureyes merged 3 commits into
mainfrom
feature/issue-1362-llmjpvl-port

Conversation

@inureyes

@inureyes inureyes commented Sep 9, 2026 •

Copy link
Copy Markdown
Member

Summary

Adds model_type: "llmjpvl", the LLM-jp lab's Japanese VLM family. One architecture covers two released checkpoints that differ only in the decoder llm_config.model_type names: llm-jp/llm-jp-4-vl-9B-beta (Llama, 196608-token vocab, untied head) and llm-jp/Jagle-VL-2.2B-Jagle-FineVision (Qwen3, tied embeddings, per-head q/k RMSNorm). A SigLIP2-so400m tower runs behind InternVL-style dynamic tiling, pixel_shuffle(0.5) folds each 512px tile to 256 vectors of width 4608, and the mlp1 connector projects them into the decoder width where they replace the <|image_pad|> positions.

The InternVL runtime is reused as the issue asked (InternVLConnector, InternVLProcessor, insert_internvl_image_tokens, SigLipVisionModel). Four things are genuinely different and each is silent when got wrong: the decoder config lives under llm_config and there is no text_config; the SigLIP tower has no CLS token, so InternViT's [:, 1:, :] slice must not be copied or a real patch row is dropped; the mlp1 LayerNorm takes torch's default 1e-5, not vision_config.layer_norm_eps; and the runtime carries either decoder.

What changed

  • New runtime src/vision/llmjp_vl.rs with the two-arm text backbone src/vision/llmjp_vl_text.rs, and the loader src/loading/vlm_llmjp_vl.rs. Image and stop ids are resolved from the checkpoint's own added_tokens_decoder and generation_config.json, because the two checkpoints agree on none of them (14/15/16 and stops 2, 11 versus 151655/151669/151670 and stops 151675, 151645, 151672).
  • New src/multimodal/llmjp_vl_prompt.rs: the framed image block is spliced where upstream's <image> sits, immediately after the Harmony <|start|>user<|message|> opener. InternVL's "after the first token" fallback would put it inside the system turn here. The split point is located in the rendered text and verified against the caller's token vector before use, so a tokenizer disagreement degrades instead of shifting the block.
  • The shipped template ends a generation render at <|start|>assistant and the checkpoint's processor appends <|channel|>final<|message|>. That completion now lives in ChatTemplateProcessor, the one seam both front ends already share, keyed on the model type because other Harmony templates end the same way and must not be completed.
  • prepare_chat_request now takes the raw-JSON render only when the template actually reads typed media items. This template never does, so minijinja was being handed a content list for {{ message['content'] }} and would have printed the list into the prompt.
  • InternVLProcessor::find_closest_aspect_ratio's aspect-ratio tie-break now uses the upstream area threshold that both families' processing_*.py read. The previous closest-area rule returned 1 tile for a 768x768 image at tile size 512 where the reference returns 2x2 plus a thumbnail.

Validation

Linux aarch64 / GB10 / CUDA sm_121, --profile test-fast --features cuda, both released bf16 originals.

Greedy against the checkpoints' own modeling_llmjpvl.py under torch 2.14.0+cpu bf16 and transformers 4.57.1, on a 512x512 solid red PNG. Jagle-VL: 301 prompt tokens and 赤色です。 on both sides. llm-jp-4-vl-9B: 297 prompt tokens and この画像は、単色の赤色で塗りつぶされた正方形の図形です。 on both sides, repeated identically across three CLI runs. Tile counts match the reference: 224x224 gives 1 tile and 256 image tokens, 1024x768 gives 13 tiles and 3328 image tokens.

Server: Jagle-VL returns 301 prompt tokens and the identical text. The 9B returns 297 prompt tokens and differs at one token (キャンバス for 図形), stably across three requests; the same server run with --max-batch-size 1 returns 図形 and matches the CLI exactly, so the difference is the batched decode path's reduction order on a near-tie token, not the prompt. That the prompt is identical is pinned rather than argued: one test compares every id of the assembled prompt against the reference processor's input_ids on both checkpoints, and another asserts byte equality between the server and CLI renders.

  • cargo clippy --lib --tests --features cuda -- -D warnings and cargo fmt --all clean
  • cargo check --lib --tests --features cuda clean
  • New tests --profile test-fast --features cuda: vision::llmjp_vl 7/7, multimodal::llmjp_vl_prompt 8/8, loading::vlm::llmjp_vl 7/7, server::llmjp_chat_template_tests 5/5, detection 1/1, models::metadata_tests 4/4
  • Regression scopes for the shared files touched: vision::processors::internvl 5/5, server::chat_request 104/104, server::chat_template 154/154, vision::internvl 2/2, multimodal::internvl_prompt 4/4
  • cargo test --workspace --profile test-fast --features metal,accelerate is not runnable on this host (Linux/CUDA, no Metal and no Accelerate); unrun, left to the merge gate

Closes #1362

Adds `model_type: "llmjpvl"`, one architecture over two released checkpoints that differ only in the decoder `llm_config.model_type` names: llm-jp-4-vl-9B-beta (Llama, 196608-token vocab, untied head) and Jagle-VL-2.2B (Qwen3, tied embeddings, per-head q/k RMSNorm). A SigLIP2-so400m tower runs behind InternVL-style dynamic tiling, `pixel_shuffle(0.5)` folds each 512px tile to 256 vectors of width 4608, and the `mlp1` connector projects them into the decoder width where they replace the `<|image_pad|>` positions.

The InternVL runtime is reused unchanged (`InternVLConnector`, `InternVLProcessor`, `insert_internvl_image_tokens`, `SigLipVisionModel`). Four things differ and each is silent when got wrong: the decoder config lives under `llm_config` and there is no `text_config`; the SigLIP tower has no CLS token, so InternViT's `[:, 1:, :]` slice must not be copied or a real patch row is dropped; the `mlp1` LayerNorm takes torch's default 1e-5 rather than `vision_config.layer_norm_eps`; and the runtime carries either decoder behind `LlmJpTextModel`.

Image and stop ids are resolved from the checkpoint's own `added_tokens_decoder` and `generation_config.json`, because the two checkpoints agree on none of them (14/15/16 and stops 2, 11 versus 151655/151669/151670 and stops 151675, 151645, 151672).

The framed image block is spliced where upstream's `<image>` sits, immediately after the Harmony `<|start|>user<|message|>` opener; InternVL's "after the first token" fallback would place it inside the system turn here. The split point is located in the rendered text and verified against the caller's token vector before use, so a tokenizer disagreement degrades instead of shifting the block.

The shipped template ends a generation render at `<|start|>assistant` and the checkpoint's processor appends `<|channel|>final<|message|>`. That completion now lives in `ChatTemplateProcessor`, the seam both the CLI and the server share, keyed on the model type because other Harmony templates end the same way and must not be completed. `prepare_chat_request` also now takes the raw-JSON render only when the template actually reads typed media items; this template never does, so minijinja was being handed a content list for `{{ message['content'] }}`.

Also aligns `InternVLProcessor::find_closest_aspect_ratio`'s tie-break with the upstream area threshold both families' `processing_*.py` use. The previous closest-area rule returned 1 tile for a 768x768 image at tile size 512 where the reference returns 2x2 plus a thumbnail.

Refs #1362
@inureyes inureyes added status:review Under review type:enhancement New features, capabilities, or significant additions priority:medium Medium priority area:models Model architectures, weights, loading, metadata area:docs User and developer documentation area:inference Generation, sampling, decoding (incl. speculative, DRY) area:cli Command-line interface / CLI flags modelsize:small 4-bit checkpoint <= 10GB; fast iteration, safe for smoke tests modeltype:vlm Vision-language model arch:dense Dense transformer decoder labels Sep 9, 2026
The released bf16 originals ship `patch_embedding.weight` in the HF `[O, I, kH, kW]` layout and an MLX conversion would ship `[O, kH, kW, I]`. `VisionEmbeddings::from_weights` sanitizes the first into the second by a shape test, so the two must reach the encoder as the same weight; until now only the HF branch was exercised, and only indirectly through a real-checkpoint run.

The gate builds the tiny tower twice from seed-identical weights, one map pre-transposed, and demands bit-identical features. The fixture also carries the attention-pooling `head.probe` the loader drops, so the test doubles as evidence that the tower never reads it.

Refs #1362
@inureyes inureyes added status:done Completed and removed status:review Under review labels Sep 9, 2026
@inureyes
inureyes merged commit 13a0df4 into main Sep 9, 2026
13 checks passed
@inureyes
inureyes deleted the feature/issue-1362-llmjpvl-port branch September 14, 2026 11:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

arch:dense Dense transformer decoder area:cli Command-line interface / CLI flags area:docs User and developer documentation area:inference Generation, sampling, decoding (incl. speculative, DRY) area:models Model architectures, weights, loading, metadata modelsize:small 4-bit checkpoint <= 10GB; fast iteration, safe for smoke tests modeltype:vlm Vision-language model priority:medium Medium priority status:done Completed type:enhancement New features, capabilities, or significant additions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(llmjpvl): port LLM-jp-4 VL and Jagle-VL (SigLIP2 tiles + pixel-shuffle mlp1 + Llama/Qwen3 decoder)

1 participant