Repository navigation
feat(vlm): add the Cohere Compass (North-Micro-Vision) VLM - #1735
Merged
Merged
Conversation
Adds `model_type: "cohere_compass"` (CohereLabs North-Micro-Vision-Instruct): a Qwen3-VL deepstack vision tower in front of a Command-style parallel text decoder. The vision half, the image processor, the `<|IMAGE_PAD|>` expansion, the MRoPE position-id rule and the weight-prefix remap are the existing Qwen3-VL ones reused; only the decoder and its rotation are new. The rotation is the one thing the issue body got wrong, and it is worth naming. Despite `mrope_interleaved: true` in `config.json`, upstream lists that key under `ignore_keys_at_rope_validation` and never reads it: `CohereCompassRotaryEmbedding` pre-permutes `inv_freq` over the `section[0] + section[1]` head block into evens-then-odds, then assigns axes in contiguous `[H, W, T]` sections. Qwen3-VL's step-3 interleave over the same `[24, 20, 20]` list gives T 24 channels against Compass's 24 to H, over unpermuted frequencies, so substituting one for the other rotates every sliding layer against the wrong table. `cohere_compass_rope.rs` implements the Compass table and `cohere_compass_rope_tests.rs` gates it against a re-derivation of upstream's definition. For the same reason there is no `fast_rope` text-only shortcut on this family: with a permuted `inv_freq`, an all-axes-equal position does not collapse to natural-order 1-D RoPE. Per-layer-type RoPE reads a JSON `null` entry as "no positional encoding at all" rather than "fall back to the default", which is what makes the seven full-attention layers NoPE. The sliding/full split is enforced by two prefill masks plus the SDPA window over a dense `KVCache`, the same contract `cohere2.rs` and `olmo3.rs` use. `preprocessor_config.json`'s resize bounds are read explicitly (65536 / 16777216) because they are not the Qwen family defaults and they decide the image grid. Refs #1354
The three failures were all in the fixtures, not the model, and the DeepStack one is worth keeping the reason for. `deepstack_injects_first_three_layers_only` injected a tensor that was constant across channels. This family normalizes with a mean-subtracting LayerNorm and carries a parallel-block residual, so a uniform per-position offset is invisible end to end: each block's input norm removes it before attention and the MLP see it, and the final norm removes it again. Measured, the "injected" logits differed from the plain run by 1.1e-8, pure float noise, so the assertion would have passed or failed on nothing. The branches are now non-uniform rows from the deterministic Rng. `frequencies_are_pre_permuted_over_the_hw_block` compared `x.powf(-a)` against `1.0 / x.powf(a)` at 1e-9. They are the same value with different f32 rounding, measured 6e-8 apart; the bound is now 1e-6, still three orders below what a wrong permutation moves. `image_pad_expands_to_merged_grid` read `<|VISION_END|>` one index early: 196 pads starting at index 2 end at 197, not 196. Refs #1354
The published checkpoint ships two sidecars that disagree on the image resize bounds, and this was reading the wrong one. `preprocessor_config.json` says `size.shortest_edge` 65536 / `size.longest_edge` 16777216, while the nested `image_processor` block of `processor_config.json` says `min_pixels` 16384 / `max_pixels` 3868706. HF's `AutoProcessor` builds the image processor from the nested block, so 16384 is what the reference actually resizes against. Measured against transformers on `tests/fixtures/test_image.png` (224x224): the reference keeps a 14x14 grid, 49 image tokens, while the 65536 bound upscales to 16x16 and 64 tokens. A two-image prompt then carried 260 image tokens against the oracle's 245, so the whole prompt was a different length and the run had nothing to compare. Nothing failed; the counts just silently disagreed. Precedence is now the nested block, then `preprocessor_config.json`, then the Qwen family defaults, with explicit `min_pixels` / `max_pixels` beating a `size` block within each file, which is the order the HF processor reads them in. Also adds the sliding-window receptive-field gate that was missing: two sliding layers at window 3 must leave position 11 unable to see token 0, and a window wider than the prefix must make the same edit reach it. Refs #1354
4 tasks
7 of 8 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds
model_type: "cohere_compass"(CohereLabs North-Micro-Vision-Instruct, 2.5B): a Qwen3-VL deepstack vision tower in front of a Command-style parallel text decoder. The vision tower, image processor,<|IMAGE_PAD|>expansion, MRoPE position-id rule and weight-prefix remap are the existing Qwen3-VL ones reused; the decoder and its rotation are the new code.The rotation is not Qwen3-VL's interleaved MRoPE
The issue specified
InterleavedMRoPEon the sliding layers. That is wrong, and it is the one thing worth reading the diff for. The checkpoint carriesmrope_interleaved: true, but upstream lists that key underignore_keys_at_rope_validationand never reads it.CohereCompassRotaryEmbeddingpre-permutesinv_freqover thesection[0] + section[1]head block into evens-then-odds, then assigns axes in contiguous[H, W, T]sections. Over the published[24, 20, 20]that gives H 24 channels; Qwen3-VL's step-3 interleave over the same list gives T 24 channels, over unpermuted frequencies. Measured against the transformers class itself at(t, h, w) = (7, 11, 13): this implementation is within 2.2e-7 incos, the specified interleave is off by 1.96. For the same reason there is nofast_ropetext-only shortcut here, since a permutedinv_freqdoes not collapse to natural-order 1-D RoPE when the three axes agree.What changed
src/models/cohere_compass{,_config,_layers,_rope}.rs: the text decoder. Parallel block (one input LayerNorm feeds both attention and the SwiGLU MLP, both land on the residual, nopost_attention_layernorm), tied head timeslogit_scale0.25, per-layer-type RoPE where a JSONnullentry means no positional encoding at all (the seven full-attention layers are NoPE), and the sliding/full split enforced by two prefill masks plus the SDPA window over a denseKVCache, the same contractcohere2.rsandolmo3.rsuse.src/vision/cohere_compass.rs: the VLM wrapper overQwen3VLVisionEncoder, DeepStack state, visual position mask and MRoPE state, plus theQwenVlRuntimeimpl so CLI and server image paths route through the shared Qwen-VL machinery.src/loading/vlm_cohere_compass.rs: loader. Both published weight layouts land on one key space through the existingremap_qwen3_vl_weights. Resize bounds come from the nestedimage_processorblock ofprocessor_config.json, which is what HF'sAutoProcessorreads; the standalonepreprocessor_config.jsonon this checkpoint disagrees and is only the fallback.ModelType,LoadedModel, registry, metadata and docs rows, plus the bilingual technical report.Test plan
Oracle is transformers
5.18.0.dev0reading the same weights: the MLX 4-bit checkpoint dequantized offline into one float32 file in the CohereLabs key layout, so both sides load byte-identical weights and any divergence is the forward pass. Every run was done on both the 4-bit and the float32 checkpoint, which covers both published weight-prefix layouts.cargo test --profile test-fast --features cuda --lib cohere_compasson GB10:test result: ok. 23 passed; 0 failed.CohereCompassRotaryEmbedding:max|cos diff| 2.23e-07,max|sin diff| 3.80e-07; the interleave the issue specified is off by 1.96.get_rope_index, one image (rope_delta = -182) and two images (rope_delta = -224)./v1/chat/completionswithlogprobs: truegives 32/32 identical tokens against the oracle on both checkpoints; the CLI produces the identical string on three prompts, one of them a 79-token prompt.prompt_tokens210 for one image and 245 image tokens for two, both exactly the oracle's counts,completion_tokensnon-zero, EOS reached. The server text equals the CLI text on every prompt. This is the family's known zero-decoder-layer trap, checked rather than assumed.mlxcel archlists the family,mlxcel listshows the checkpoint,mlxcel inspectreportssliding-window: 21 layer(s) capped at 4096 tokens, 7 global.cargo clippy --workspace --all-targets -- -D warningsandcargo fmt --all -- --check: green in CI.' displays'-1.5065 against' features'-1.7449, 0.238 nats), two images at step 0 ('3'-0.511 against"Let's"-1.3771, 0.866 nats). Ruled out: TF32 (MLX_ENABLE_TF32=0changes nothing), both vision activations, DeepStack order and depth, quantization (4-bit and float32 outputs are byte-identical). What remains is the shared Qwen3-VL tower, which this PR does not touch. Filed as Qwen3-VL vision tower is not token-exact against transformers on the image path #1738 with the numbers.cargo test --workspace --profile test-fast --features metal,acceleratewas not run. It cannot run on this host, which is Linux with CUDA and has neither Metal nor Accelerate. The CUDA equivalent above was run instead.Closes #1354