fix(models): honor rope_traditional from config.json on the shared Llama path - #951
Merged
Merged
Conversation
3 tasks
…ama path The shared Llama attention never read `rope_traditional` from `config.json`, while the reference `ModelArgs` in mlx_lm/models/llama.py declares it and passes it straight into `initialize_rope`. A Llama, Qwen2 or Qwen2.5 checkpoint declaring the key was therefore rotated with the split-half convention where upstream rotates interleaved. That failure is silent: both conventions consume and produce the same shapes, the KV cache stays consistent, and the model emits fluent text out of a mis-rotated attention, so no shape test and no reading of the output can catch it. #930 threaded the flag from `ModelArgs` to every RoPE route but left it `#[serde(skip)]`, writable only by `helium::ModelArgs::to_llama3_args`. This removes the skip. The field now deserializes with a default of `false`, so no existing checkpoint changes: none of the 182 local configs declares the key, and the default reproduces today's graph exactly. An explicit `null` maps to `false` rather than erroring, because `#[serde(skip)]` ignored whatever the key held and deserializing it must not turn a previously-ignored key into a load failure. Blast radius, verified rather than assumed. Nine sites deserialize `llama3::ModelArgs` from JSON and now pick the key up: the generic Llama loader, six VLM `text_config` loaders (Pixtral, LLaVA, SmolVLM/Idefics3, Idefics2, InternVL, and FastVLM which parses the whole config because it keeps text fields at the root), both pipeline stage executors, and the tensor-parallel runtime. None injects the key; the VLM loaders copy only `quantization` into `text_config`, so a wrapper-level key cannot leak into a nested text backbone. `sanitize_config_json`, which four of those sites run first, only rewrites `Infinity` and `NaN` literals and leaves the key intact. Tests pin both config shapes and the sanitizer. `MllamaTextConfig::to_llama3_args` was a real gap, not a scoping decision: it has always deserialized `rope_traditional` but rebuilt a synthetic config from a fixed key list that dropped it, so an `mllama` checkpoint declaring the key contradicted its own config in the self-attention layers. The key is now forwarded. Cross-attention layers are unaffected since they apply no RoPE. The two fused quantized launchers keep being bypassed rather than the cxx bridge being extended, and the rationale recorded by #930 is replaced rather than duplicated. Reading both C++ bodies, neither is a fused kernel: each builds the same MLX graph the Rust fallback builds, op for op, saving roughly eleven cxx crossings per layer per forward. The bypass therefore costs FFI overhead, not throughput, and both launchers are opt-in and off by default. Extending the signature would touch a surface shared with two neighboring launchers that hardcode the same constant, add a parameter that is `false` at every call site in the tree, and could not be validated, since no checkpoint pairs the key with the quantized Llama path. What the bypass lacked was observability, so `Attention::from_weights` now prints a one-time stderr notice when a traditional-RoPE checkpoint is loaded while either variable asked for a fused path. `eprintln!` because `tracing` has no subscriber in the CLI binary. Tensor parallelism stays refused for Helium, with a corrected reason. The flag now reaches every rank (`local_llama_args` clones the parsed args, pinned by a new test that asserts the flag on every rank's every layer), but Helium's convention is fixed in upstream code and its published `config.json` carries no such key, so the TP runtime's direct parse still yields `false`. Lifting the refusal means routing the rank-local config through `to_llama3_args`, which deserializing a key cannot deliver. Testing: new `src/models/llama3_tests.rs` covers the parse contract in both directions plus null and non-boolean, both VLM config shapes, the sanitizer, the flag reaching `Attention`, and the fused-prefill bypass on a genuinely quantized block. Correctness is asserted at the logits, not the shapes: a model built from a JSON config with the key is bit-identical to one with the flag set programmatically and separated from the split-half model by both an absolute and a relative floor, and the batched route is pinned against the single-sequence route. The #930 test that asserted the opposite behavior is replaced by one showing Helium still needs its conversion. `cargo test --release --lib` green for `models::llama3` (11), `models::helium` (27), `models::mllama` (my test; the pre-existing `ragged_real_tile_rows_match_reference_masked_full_rows` failure is #939), `loading::` (220), `distributed::` (1266). clippy and fmt clean apart from two warnings already on main. Closes #931
inureyes
force-pushed
the
fix/issue-931-rope-traditional-config
branch
from
July 28, 2026 13:13
93e0fc9 to
72d329d
Compare
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
The shared Llama attention path never read
rope_traditionalfromconfig.json, while the referenceModelArgsinmlx_lm/models/llama.pydeclaresrope_traditional: bool = Falseand passes it straight intoinitialize_rope. A Llama, Qwen2 or Qwen2.5 checkpoint declaring the key was rotated split-half where upstream rotates interleaved. The failure is silent: both conventions consume and produce[batch, heads, seq, head_dim], the KV cache stays consistent, and the model emits fluent text out of a mis-rotated attention. No shape test and no reading of the output can catch it.#930 threaded the flag from
ModelArgsthrough every RoPE route but deliberately left it#[serde(skip)]. This removes the skip.What changed
src/models/llama3.rs—ModelArgs::rope_traditionaldeserializes with a default offalse. An explicitnullmaps tofalserather than erroring, via a smalldeserialize_with: the field was#[serde(skip)], which ignored whatever the key held, so a config carrying"rope_traditional": nullloaded fine and must keep loading. A non-boolean is still rejected. AddedFUSED_CAUSAL_PREFILL_ENV/FUSED_QKV_SPLIT_ROPE_ENV/FUSED_ROPE_ENV_VARSso the gate, the notice and the tests read one list, andreport_fused_rope_bypass_once(), called fromAttention::from_weights.src/models/llama3_tests.rs(new) — the parse contract, the two VLM config shapes, the sanitizer, the flag reachingAttention, the logits-level oracle, the batched route, and the fused-prefill bypass on a genuinely quantized block.src/models/mllama/config.rs—to_llama3_argsnow forwardsrope_traditional, plus a test.src/models/helium_tests.rs— replaced the test that asserted the old behavior; renamedattention_carries_the_traditional_flag_only_for_helium.src/distributed/tensor_parallel/inference.rs— rewrote the Helium TP refusal rationale.src/distributed/tensor_parallel/llama_runtime_tests.rs— newtensor_parallel_llama_propagates_rope_traditional_to_every_rank.docs/environment-variables.md,docs/supported-models.md— the two fused-path rows now state the bypass; the Helium entry's TP claim was wrong after this change and is corrected.The nine deserialization sites
Each was read, not assumed. All pick the key up from wherever the checkpoint declares it and default to
falsewhen it is absent.src/loading/mod.rs:304src/loading/vlm_pixtral.rs:236text_configsub-objectsrc/loading/vlm_llava.rs:173text_configsub-objectsrc/loading/vlm_smolvlm.rs:189text_configsub-objectsrc/loading/vlm_idefics2.rs:170text_configsub-objectsrc/loading/vlm_internvl.rs:125text_configsub-objectsrc/loading/vlm_fastvlm.rs:169src/distributed/pipeline/stage_executor/{llama,mistral}.rssrc/distributed/tensor_parallel/llama_runtime.rs:70Two facts that mattered and are now pinned by tests. First, no site injects the key: the six VLM loaders copy only
quantizationintotext_config, so a wrapper-levelrope_traditionalcannot leak into a nested text backbone, which is correct because in a VLM config the top level describes the multimodal wrapper and not the decoder. Second,sanitize_config_json, which four of these sites run before parsing, only rewritesInfinityandNaNliterals by string substitution and leaves the key intact.MllamaTextConfig::to_llama3_args: a gap, not a scoping decisionIt builds a synthetic config from a fixed key list, so a key it does not name cannot reach the shared decoder.
MllamaTextConfighas always deserializedrope_traditionalwith#[serde(default)], and the conversion dropped it, so anmllamacheckpoint declaring the key rotated split-half in its self-attention layers against what its own config said. Fixed by naming the key. Cross-attention layers are unaffected: they attend to vision features and apply no RoPE.Decision on the fused launchers: keep bypassing
fused_qkv_project_split_ropeandfused_causal_prefill_attentionstill hardcodetraditional = falsein C++, andAttention::forwardstill routes a traditional-RoPE model around both. The #930 rationale comment is replaced, not supplemented. Reasons, in order of weight:quantized_matmul, threeslices,reshape,transpose,fast::rope, which is the same MLX graph the Rust fallback builds op for op. What they save is roughly eleven cxx crossings per layer per forward. The "silent performance cliff" argument for extending the bridge therefore describes FFI call overhead, not throughput, and it is invisible next to the quantized matmuls on either side of it.fused_qkv_project_and_ropeandfused_qkv_project_split_norm_ropehardcode the same constant and serve other families. Changing two of four leaves an inconsistent surface; changing four adds a parameter that isfalseat every call site in the tree and pulls families outside this fix into the blast radius.rope_traditionalwith the quantized Llama path, so an extended bridge would ship on synthetic evidence only.What the bypass actually lacked was observability, so that is fixed rather than argued away:
Attention::from_weightsprints a one-time stderr notice when a traditional-RoPE checkpoint is loaded while either variable asked for a fused path, naming the variables and saying the graph path is used instead.eprintln!becausetracinghas no subscriber in themlxcelCLI binary. Revisit if either launcher is promoted to default-on or grows a real kernel; the notice makes both visible.a_traditional_rope_block_is_routed_around_the_fused_prefill_launcherasserts the chosen behavior on a genuinely quantized block, in both halves: the flag makes the launcher request a no-op, and the same request on the same weights with the flag off produces a visibly different rotation, so the equality is not vacuous.the_fused_qkv_rope_launcher_cannot_express_traditional_rope(from #930) continues to pin the other launcher.Tensor parallelism: refusal kept, rationale corrected
The old reason ("
rope_traditionalis deliberately not deserializable") is no longer true. The flag now reaches every rank: the TP runtime parsesconfig.jsoninto the shared args andlocal_llama_argsclones them, andtensor_parallel_llama_propagates_rope_traditional_to_every_rankasserts the flag on every rank's every layer rather than inferring it from theargs.clone().The refusal survives for a different reason. Helium's convention is fixed in upstream code, not in its config: upstream builds
nn.RoPE(..., traditional=True)and the publishedconfig.jsoncarries norope_traditionalkey at all, whichhelium_still_needs_the_conversion_because_its_config_omits_the_keypins. The TP runtime parsesconfig.jsondirectly and never goes throughto_llama3_args, so a sharded Helium would still parsefalse. Lifting the refusal means routing the rank-local config through the conversion, which is a change to how the runtime builds its config and not something deserializing a key can deliver.The #930 test that had to go
a_llama_config_cannot_turn_on_traditional_rope_through_jsonparsed a Llama config carrying the key and asserted the parsed value wasfalse, which is the exact inverse of this issue's first acceptance criterion. It was replaced (not made to pass) byhelium_still_needs_the_conversion_because_its_config_omits_the_key, which asserts the thing that is actually still true and is the reasonto_llama3_argsis not now redundant.attention_carries_the_traditional_flag_only_for_heliumwas renamed to..._from_its_args, since "only for Helium" stopped being accurate.On the token-level acceptance criterion
The issue asks for a token-level comparison against a reference on a checkpoint that sets
rope_traditional. No such public Llama, Qwen2 or Qwen2.5 checkpoint exists, and none of the 182 local configs declares the key, so there is nothing to generate from. The strongest available substitute is implemented instead, and it is a logits comparison rather than a shape check: a model built from a JSON config carrying the key is bit-identical (max_abs_diff == 0.0) to one with the flag set programmatically, and separated from the split-half model by both an absolute and a relative floor. That proves the key selects exactly the interleaved rotation and nothing else. What a real-checkpoint run would add is that mlxcel's interleaved rotation matches the reference's, and that equivalence isfast_rope(traditional = true)itself, which #930 validated token-exactly against mlx-lm on a real Helium checkpoint.Byte-identical output for existing Llama / Qwen2 / Qwen2.5 checkpoints follows from the default: the parsed args are identical, so the graph is identical. Real-model regression across the local checkpoints is left to the orchestrator.
Test plan
cargo check --release --lib --tests --features metal,accelerateclean (two warnings already onmain)cargo test --release --lib --features metal,accelerate models::llama3— 11 passedcargo test --release --lib --features metal,accelerate models::helium— 27 passedcargo test --release --lib --features metal,accelerate models::mllama— new test passes;ragged_real_tile_rows_match_reference_masked_full_rowsfails onmaintoo (fix(mllama): ragged cross-attention test fails on main, and no CI job runs cargo test #939)cargo test --release --lib --features metal,accelerate loading::— 220 passedcargo test --release --lib --features metal,accelerate distributed::— 1266 passedcargo test --release --lib --features metal,accelerate server::startup— 54 passedcargo clippy --release --lib --tests --features metal,acceleratecleancargo fmt --all -- --checkcleanpython3 scripts/ci/check_cross_repo_refs.pycleanCloses #931