You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
fix(models): honor rope_traditional from config.json on the shared Llama path #931
The shared Llama attention path still never reads rope_traditional from config.json, but the code around it has moved since this issue was filed. #930 (implementing #837, Kyutai Helium) has MERGED, as 1f21b1f4c, so on main today src/models/llama3.rs does carry ModelArgs::rope_traditional and Attention::rope_traditional, and both graph RoPE routes do receive the flag instead of a literal false:
// src/models/llama3.rs, Attention::forward (single-sequence prefill and decode)let q = mlxcel_core::fast_rope(&q,self.rope_dims,self.rope_traditional,self.rope_base,1.0, offset);let k = mlxcel_core::fast_rope(&k,self.rope_dims,self.rope_traditional,self.rope_base,1.0, offset);
What #930 deliberately did not do is deserialize the field (src/models/llama3.rs:70-80):
/// Rotate interleaved channel pairs `(2i, 2i+1)` instead of the split-half/// pairs `(i, i + dims/2)`.////// Deliberately **not** deserialized. Every family that reaches this loader/// today rotates split-half, and a checkpoint author who writes/// `"rope_traditional": true` into a Llama `config.json` must not be able to/// silently change how an existing checkpoint decodes. [...]#[serde(skip)]pub rope_traditional: bool,
The only writer is helium::ModelArgs::to_llama3_args (src/models/helium.rs:415), which sets it programmatically. So after #930 the plumbing exists on the graph paths, but a Llama or Qwen2 config.json that declares "rope_traditional": true is still silently ignored, and #930 pins that behavior with a test, a_llama_config_cannot_turn_on_traditional_rope_through_json (src/models/helium_tests.rs:296), which parses a Llama config carrying the key and asserts the parsed value is false. That was the right scoping call for a single model port. It leaves the general question open, which is what this issue is for. One consequence for whoever picks this up: that test asserts the exact opposite of the first acceptance criterion below, so the fix must replace it rather than keep it green.
Correct behavior
The reference implementation carries the key and honors it. In mlx_lm/models/llama.py, ModelArgs declares rope_traditional: bool = False and Attention.__init__ passes args.rope_traditional straight into initialize_rope:
Any checkpoint whose config.json sets the key therefore rotates interleaved under the reference and split-half under mlxcel. mlxcel diverges from the reference for exactly that class of checkpoint.
Which families inherit the gap
Re-verified against main at a3c823611, after the Helium merge.
src/model_metadata.rs still registers exactly two model types against models::llama3::ModelArgs:
src/models/qwen2.rs is still a pure re-export (pub use super::llama3::{Attention, Llama3Model as Qwen2Model, MLP, ModelArgs, Quantization, RopeScaling, TransformerBlock};), and src/models/detection.rs maps "qwen2" => Ok(ModelType::Qwen2), which is the model_type string Qwen2.5 checkpoints ship. So Llama, Qwen2 and Qwen2.5 all share the affected attention. Helium's merge did not add a third entry to that list: model_metadata.rs:207 registers Helium against its own models::helium::ModelArgs, which reaches the shared decoder by conversion (to_llama3_args) rather than by a second registration.
The two registrations are not the whole surface, though. llama3::ModelArgs is deserialized from JSON in nine further places, and every one of them would begin honoring the key the moment it becomes deserializable:
Site
What it parses
src/loading/mod.rs:304
the generic Llama fallback loader
src/loading/vlm_pixtral.rs:236
Pixtral text_config
src/loading/vlm_llava.rs:173
LLaVA text_config
src/loading/vlm_smolvlm.rs:189
SmolVLM / Idefics3 text_config
src/loading/vlm_idefics2.rs:170
Idefics2 text_config
src/loading/vlm_internvl.rs:125
InternVL text_config
src/loading/vlm_fastvlm.rs:169
FastVLM, from the full config
src/distributed/pipeline/stage_executor/llama.rs:44 and .../mistral.rs:69
The Llama-3.2-Vision (mllama) text decoder is the one reuser that would not inherit it: MllamaTextConfig::to_llama3_args (src/models/mllama/config.rs:77) builds a synthetic JSON object from a fixed key list that never contains rope_traditional, so it keeps parsing to false regardless of what the checkpoint declares.
The // Used by: inventory now lives on Attention::forward (llama3.rs:149-155), and forward_split_attention (:348-350) defers to it rather than repeating a shorter list:
Used by: Llama (llama / mistral checkpoints), Qwen2 / Qwen2.5 (which re-export this attention), Helium (which reuses it with rope_traditional set), the Llama-3.2-Vision (mllama) text decoder's self-attention layers, every VLM whose text backbone is Llama3Model or Qwen2Model (Pixtral, LLaVA, SmolVLM / Idefics3, Idefics2, InternVL, FastVLM, dots.ocr), the llama and mistral pipeline stage executors, and the tensor-parallel Llama runtime.
The tensor-parallel Llama runtime builds its per-rank config with local_llama_args in src/distributed/tensor_parallel/llama_runtime.rs:1878, which does let mut local = args.clone(); and mutates only the sharded dimensions. A deserialized flag would propagate to every rank for free, but that should be asserted rather than assumed. Related: tensor parallel is currently REFUSED for Helium for precisely this reason, recorded at src/distributed/tensor_parallel/inference.rs:251-261 ("a sharded Helium would silently rotate split-half pairs while the single-process path rotates interleaved pairs"). Closing this issue removes that blocker, so that comment and the runtime_kind_for gap it describes need revisiting in the same change.
Whether any local checkpoint triggers it
Re-scanned every config.json under models/ on main at a3c823611 (182 files, following symlinks): no checkpoint declares rope_traditional. 22 of those configs declare a model_type of llama or qwen2, so the affected families are well represented locally, but none of them sets the key. There is no reproducer in the local checkpoint set today, and validating a fix will need either a public checkpoint that ships the key or a synthetic config plus a reference comparison.
Why this matters: the failure is silent
Applying the split-half rotation to a model trained with the traditional (interleaved) convention, or the reverse, is not a crash and not a shape error. Both conventions consume [batch, heads, seq, head_dim] and produce the same shape, the KV cache stays consistent, attention runs, and the model emits fluent, plausible, grammatical text. Nothing throws, no assertion fires, and reading the output does not reveal it: the degradation looks like a slightly worse model, not like a bug.
This is invisible to shape tests, to cache-consistency tests, and to logits-shape tests. The only thing that catches it is a token-level comparison against a reference implementation on the same weights and the same prompt. That constraint dictates what an acceptance test for this fix has to be, so it is stated here rather than left to the implementer to rediscover. #930 added traditional_and_split_half_rope_are_different_rotations (src/models/helium_tests.rs), which demonstrates the same point at the fast_rope level: the two conventions agree on every shape and disagree on the values.
Technical Considerations
The two fused quantized launchers still cannot express the flag. In src/lib/mlxcel-core/cpp/mlx_cxx_bridge.cpp, inside fused_qkv_project_split_rope (declared at line 3822):
The third positional argument is MLX's traditional parameter, hardcoded false in both bodies, and neither the cxx bridge signature nor the Rust helper takes a flag to override it. Both launchers are reachable only for quantized weights and both are opt-in behind an environment variable (MLXCEL_ENABLE_FUSED_QKV_SPLIT_ROPE and MLXCEL_ENABLE_FUSED_CAUSAL_PREFILL_ATTENTION respectively). Two neighboring launchers in the same file, fused_qkv_project_and_rope and fused_qkv_project_split_norm_rope, hardcode false the same way; they are not on the Llama attention path today, but a bridge signature change should account for them.
#930 handled this by gating both fused branches on !self.rope_traditional (llama3.rs:198 and llama3.rs:235), so a traditional-RoPE model always falls back to the graph path that does receive the flag, and recorded the decision and its rationale in the # Traditional RoPE and the fused fast paths section of the Attention::forward doc comment (llama3.rs:157-183). That is acceptable for one family whose flag is fixed at load time and whose fast paths are off by default. It is not a general answer for a config-driven flag: it means any checkpoint that sets the key silently loses both quantized fast paths, and the loss is invisible at the call site.
Acceptance Criteria
llama3::ModelArgs::rope_traditional is deserialized from config.json with a default of false, matching the reference ModelArgs default.
The flag reaches every RoPE route the Llama attention can take: single-sequence prefill and decode (fast_rope), batched decode and batched or padded prefill (fast_rope_batched), and paged decode (which applies RoPE before the paged dispatch).
The nine non-registration deserialization sites listed above (the generic Llama loader, six VLM text_config loaders, the two pipeline stage executors, and the tensor-parallel runtime) are each either confirmed to pick the flag up correctly or explicitly scoped out with a reason. The decision is recorded, not left implicit.
The tensor-parallel Llama runtime propagates the flag to every rank, verified by a test rather than inferred from the args.clone() in local_llama_args. The Helium tensor-parallel refusal documented at src/distributed/tensor_parallel/inference.rs:251-261 is re-evaluated, and either lifted or kept with an updated rationale.
A decision is made and implemented for the two fused quantized launchers: either the cxx bridge carries the flag, or the launchers stay bypassed when it is set. Whichever is chosen replaces the interim rationale already recorded in the Attention::forward doc comment (llama3.rs:157-183).
A test asserts the chosen fused-path behavior directly, so that the assumption it rests on fails loudly if the launchers later change.
Correctness is proved by a token-level comparison against a reference implementation, not by a shape assertion or a "runs without error" check. Same weights, same prompt, same sampling settings, comparing generated token ids (or per-step logits within a stated tolerance) against the reference for a checkpoint that sets rope_traditional. A shape-only or smoke test does not satisfy this criterion, because the wrong rotation is shape-correct and produces fluent output.
Existing Llama, Qwen2 and Qwen2.5 checkpoints produce byte-identical output to the current build. The default is false and none of these checkpoints declares the key, so nothing about their graph or their generated tokens may change. Verify on at least one Llama checkpoint and one Qwen2 or Qwen2.5 checkpoint from the recommended test set.
A unit test covers the parse itself: a config that omits the key yields false, a config that sets it to true yields true.
cargo fmt --all -- --check and cargo clippy --all-targets -- -D warnings are clean.
Open Question
Should the cxx bridge signature be extended so fused_qkv_project_split_rope and fused_causal_prefill_attention can express the rotation convention, or should they keep being bypassed when the flag is set?
Extend the bridge. Correct in all cases and removes a silent performance cliff. Cost: it is an FFI signature change on a surface shared by every model family that reaches these launchers, so it touches the Rust helper, the C++ body, and every call site at once, and it has to be revalidated on the quantized paths for the families already using them. It also adds a parameter that is false for every caller except one, which is maintenance burden with no immediate user.
Keep bypassing. Zero risk to existing families and no FFI churn. Cost: a checkpoint that sets the key silently loses both quantized fast paths, and nothing at the call site tells the user why. Since neither path is on by default today the cost is currently theoretical, but it becomes real the moment either one is promoted to default-on, and at that point the regression would appear only for traditional-RoPE checkpoints and would be hard to attribute.
A middle option worth weighing: keep the bypass, but emit a one-time warning at load when a checkpoint sets rope_traditional while a fused path was requested through its environment variable, so the fallback is at least observable. Note that tracing::warn! is a no-op in the CLI binary (see the implementation context below), so such a warning has to be eprintln!.
References
Originating work: #930 (implementation, merged as 1f21b1f4c) and #837 (the Helium port that surfaced this).
Implementation context (machine facts, validation method, and a recurring defect class)
Most relevant to this issue: the token-exact reference oracle. Applying the wrong RoPE convention produces correctly shaped tensors and fluent text, so it is invisible to shape tests and to "runs without error" checks; only a token id comparison against mlx-lm separates the two conventions. Also relevant: the fused quantized launchers hardcode traditional = false in C++, so extending them means a cxx bridge signature change, which the build notes below bear on.
Collected while porting five text-model families in #924, #926, #928, #930, and #946. This
is background for whoever picks this up, not additional scope.
Build and test on the current development machine (Apple M1 Ultra, macOS)
Every cargo invocation needs DEVELOPER_DIR=/Applications/Xcode-26.6.0.app/Contents/Developer, or cmake fails with xcrun: error: unable to find utility "metal". xcode-select still points at CommandLineTools.
Only the RELEASE profile is warm. A debug cargo check triggers a COLD MLX C++ build that takes many minutes. Use DEVELOPER_DIR=... cargo check --release --lib --tests --features metal,accelerate.
Narrow test selectors only: DEVELOPER_DIR=... cargo test --release --lib --features metal,accelerate models::<module>. A bare cargo test, cargo test --lib, or cargo clippy --workspace --all-targets runs long enough to stall an agent with a stream-idle watchdog, and a cold cargo build --release can exceed it on the MLX C++ link step alone.
cargo fmt --all -- --check, never a bare-path fmt check, which produces spurious edition-2024 diffs.
tracing::warn! is a NO-OP in the mlxcel CLI binary; only src/server/startup.rs installs a subscriber. Use eprintln! for any CLI-facing diagnostic.
A test that trips an MLX C++ throw aborts the whole test binary with SIGABRT rather than failing cleanly. That is expected, and the abort is stronger evidence than a clean assertion failure. One SIGABRT on the first run after a fresh link that does not reproduce on reruns is a cold Metal-initialization race, observed independently by two reviewers.
Validating model behavior: a token-exact reference oracle
Shape tests do not catch the failures that matter here. A wrong prefill/decode offset, a flat instead of interleaved QKV split, the wrong RoPE convention, or a routing bias applied to the wrong copy all produce correctly-shaped tensors and fluent, plausible output. Only the token id sequence separates them.
What worked: create a scratch venv, pip install mlx-lm, and drive mlx_lm.generate.stream_generate with make_sampler(temp=0.0), printing both text and token ids. Then compare against mlxcel generate. Pass --no-chat-template to mlxcel even when the checkpoint ships a chat template, so both sides see the same raw prompt, and run the templated path separately as a usability check.
Caveat: the oracle is blind wherever mlxcel and mlx-lm make the same choice. GELU was one such case, closed by monkey-patching the reference to the other variant and regenerating. Note mlxcel_core::utils::gelu_approx is erf-based despite its name, while MLX Python's nn.gelu_approx really is the tanh form.
A recurring defect class worth checking for
A value from config.json passes every Rust-side check, violates an undocumented precondition of an MLX C++ entry point, and kills the process at the FIRST FORWARD PASS rather than failing at load. Most cxx bridge functions are declared returning UniquePtr<MlxArray> rather than Result, so the C++ throw is an uncatchable std::terminate, not something catch_unwind contains. The model loads cleanly and the server dies on its first request. This class produced one CRITICAL and five HIGH findings across the five ports.
Specifics established by reading the pinned MLX checkout rather than assuming:
Gathers do NOT range-check positive indices. take wraps negative indices but an out-of-range positive index silently returns values belonging to no row, and that reaches the logits with no fault. Bound every gather by the real tensor shape, never by a config field.
slice CLAMPS an out-of-range stop instead of throwing, so a too-wide split silently loses trailing channels.
fast_rope requires dims even, positive, and no larger than the last axis.
rms_norm and layer_norm never inspect eps. A NaN or negative eps yields NaN hidden states with no error at all, which is harder to diagnose than a crash.
matmul throws on an inner-dimension mismatch, which is why an unchecked INPUT axis is fatal even where the row axis bounds only an argmax.
quantized_matmul divides by bits, so "bits": 0 is a division by zero. On AArch64 the divide returns 0 and std::invalid_argument fires; on x86-64 the hardware raises SIGFPE and kills the process before any exception exists.
Reconstruct a quantized input width the way MLX does, as scales.shape(-1) * group_size, and check .biases shape equals .scales shape. A self-consistency check cannot catch a checkpoint honestly packed for the wrong hidden_size, because such a checkpoint IS self-consistent.
Put zero checks BEFORE divisibility checks, because 0.is_multiple_of(0) is true.
Avoid unbounded probe loops over 0..n_layer in the load path: a huge n_layer hangs with a flat allocation footprint, so no OOM kill rescues it.
src/models/gpt_neox.rs and src/models/helium.rs carry the current ModelArgs::validate / validate_weights shape to copy from.
Conventions that bite
The // Used by: comments above shared helpers are the designated discovery mechanism for "what breaks if I change this", and they can themselves be stale. One was found listing six callers where there were eight. Verify by grep rather than trusting the comment, and update it when you touch the helper.
mlxcel arch is the architecture registry. mlxcel list lists downloaded models and will not tell you whether a family is supported.
Problem / Background
Current behavior
The shared Llama attention path still never reads
rope_traditionalfromconfig.json, but the code around it has moved since this issue was filed. #930 (implementing #837, Kyutai Helium) has MERGED, as1f21b1f4c, so onmaintodaysrc/models/llama3.rsdoes carryModelArgs::rope_traditionalandAttention::rope_traditional, and both graph RoPE routes do receive the flag instead of a literalfalse:What #930 deliberately did not do is deserialize the field (
src/models/llama3.rs:70-80):The only writer is
helium::ModelArgs::to_llama3_args(src/models/helium.rs:415), which sets it programmatically. So after #930 the plumbing exists on the graph paths, but a Llama or Qwen2config.jsonthat declares"rope_traditional": trueis still silently ignored, and #930 pins that behavior with a test,a_llama_config_cannot_turn_on_traditional_rope_through_json(src/models/helium_tests.rs:296), which parses a Llama config carrying the key and asserts the parsed value isfalse. That was the right scoping call for a single model port. It leaves the general question open, which is what this issue is for. One consequence for whoever picks this up: that test asserts the exact opposite of the first acceptance criterion below, so the fix must replace it rather than keep it green.Correct behavior
The reference implementation carries the key and honors it. In
mlx_lm/models/llama.py,ModelArgsdeclaresrope_traditional: bool = FalseandAttention.__init__passesargs.rope_traditionalstraight intoinitialize_rope:Any checkpoint whose
config.jsonsets the key therefore rotates interleaved under the reference and split-half under mlxcel. mlxcel diverges from the reference for exactly that class of checkpoint.Which families inherit the gap
Re-verified against
mainata3c823611, after the Helium merge.src/model_metadata.rsstill registers exactly two model types againstmodels::llama3::ModelArgs:Llama => { ... args: models::llama3::ModelArgs, weight_builder: models::Llama3Model::from_weights, ... }(model_metadata.rs:96)Qwen2 => { ... args: models::llama3::ModelArgs, weight_builder: models::Qwen2Model::from_weights, ... }(model_metadata.rs:100)src/models/qwen2.rsis still a pure re-export (pub use super::llama3::{Attention, Llama3Model as Qwen2Model, MLP, ModelArgs, Quantization, RopeScaling, TransformerBlock};), andsrc/models/detection.rsmaps"qwen2" => Ok(ModelType::Qwen2), which is themodel_typestring Qwen2.5 checkpoints ship. So Llama, Qwen2 and Qwen2.5 all share the affected attention. Helium's merge did not add a third entry to that list:model_metadata.rs:207registers Helium against its ownmodels::helium::ModelArgs, which reaches the shared decoder by conversion (to_llama3_args) rather than by a second registration.The two registrations are not the whole surface, though.
llama3::ModelArgsis deserialized from JSON in nine further places, and every one of them would begin honoring the key the moment it becomes deserializable:src/loading/mod.rs:304src/loading/vlm_pixtral.rs:236text_configsrc/loading/vlm_llava.rs:173text_configsrc/loading/vlm_smolvlm.rs:189text_configsrc/loading/vlm_idefics2.rs:170text_configsrc/loading/vlm_internvl.rs:125text_configsrc/loading/vlm_fastvlm.rs:169src/distributed/pipeline/stage_executor/llama.rs:44and.../mistral.rs:69src/distributed/tensor_parallel/llama_runtime.rs:70The Llama-3.2-Vision (
mllama) text decoder is the one reuser that would not inherit it:MllamaTextConfig::to_llama3_args(src/models/mllama/config.rs:77) builds a synthetic JSON object from a fixed key list that never containsrope_traditional, so it keeps parsing tofalseregardless of what the checkpoint declares.The
// Used by:inventory now lives onAttention::forward(llama3.rs:149-155), andforward_split_attention(:348-350) defers to it rather than repeating a shorter list:The tensor-parallel Llama runtime builds its per-rank config with
local_llama_argsinsrc/distributed/tensor_parallel/llama_runtime.rs:1878, which doeslet mut local = args.clone();and mutates only the sharded dimensions. A deserialized flag would propagate to every rank for free, but that should be asserted rather than assumed. Related: tensor parallel is currently REFUSED for Helium for precisely this reason, recorded atsrc/distributed/tensor_parallel/inference.rs:251-261("a sharded Helium would silently rotate split-half pairs while the single-process path rotates interleaved pairs"). Closing this issue removes that blocker, so that comment and theruntime_kind_forgap it describes need revisiting in the same change.Whether any local checkpoint triggers it
Re-scanned every
config.jsonundermodels/onmainata3c823611(182 files, following symlinks): no checkpoint declaresrope_traditional. 22 of those configs declare amodel_typeofllamaorqwen2, so the affected families are well represented locally, but none of them sets the key. There is no reproducer in the local checkpoint set today, and validating a fix will need either a public checkpoint that ships the key or a synthetic config plus a reference comparison.Why this matters: the failure is silent
Applying the split-half rotation to a model trained with the traditional (interleaved) convention, or the reverse, is not a crash and not a shape error. Both conventions consume
[batch, heads, seq, head_dim]and produce the same shape, the KV cache stays consistent, attention runs, and the model emits fluent, plausible, grammatical text. Nothing throws, no assertion fires, and reading the output does not reveal it: the degradation looks like a slightly worse model, not like a bug.This is invisible to shape tests, to cache-consistency tests, and to logits-shape tests. The only thing that catches it is a token-level comparison against a reference implementation on the same weights and the same prompt. That constraint dictates what an acceptance test for this fix has to be, so it is stated here rather than left to the implementer to rediscover. #930 added
traditional_and_split_half_rope_are_different_rotations(src/models/helium_tests.rs), which demonstrates the same point at thefast_ropelevel: the two conventions agree on every shape and disagree on the values.Technical Considerations
The two fused quantized launchers still cannot express the flag. In
src/lib/mlxcel-core/cpp/mlx_cxx_bridge.cpp, insidefused_qkv_project_split_rope(declared at line 3822):and inside
fused_causal_prefill_attention(declared at line 4028):The third positional argument is MLX's
traditionalparameter, hardcodedfalsein both bodies, and neither the cxx bridge signature nor the Rust helper takes a flag to override it. Both launchers are reachable only for quantized weights and both are opt-in behind an environment variable (MLXCEL_ENABLE_FUSED_QKV_SPLIT_ROPEandMLXCEL_ENABLE_FUSED_CAUSAL_PREFILL_ATTENTIONrespectively). Two neighboring launchers in the same file,fused_qkv_project_and_ropeandfused_qkv_project_split_norm_rope, hardcodefalsethe same way; they are not on the Llama attention path today, but a bridge signature change should account for them.#930 handled this by gating both fused branches on
!self.rope_traditional(llama3.rs:198andllama3.rs:235), so a traditional-RoPE model always falls back to the graph path that does receive the flag, and recorded the decision and its rationale in the# Traditional RoPE and the fused fast pathssection of theAttention::forwarddoc comment (llama3.rs:157-183). That is acceptable for one family whose flag is fixed at load time and whose fast paths are off by default. It is not a general answer for a config-driven flag: it means any checkpoint that sets the key silently loses both quantized fast paths, and the loss is invisible at the call site.Acceptance Criteria
llama3::ModelArgs::rope_traditionalis deserialized fromconfig.jsonwith a default offalse, matching the referenceModelArgsdefault.fast_rope), batched decode and batched or padded prefill (fast_rope_batched), and paged decode (which applies RoPE before the paged dispatch).text_configloaders, the two pipeline stage executors, and the tensor-parallel runtime) are each either confirmed to pick the flag up correctly or explicitly scoped out with a reason. The decision is recorded, not left implicit.args.clone()inlocal_llama_args. The Helium tensor-parallel refusal documented atsrc/distributed/tensor_parallel/inference.rs:251-261is re-evaluated, and either lifted or kept with an updated rationale.Attention::forwarddoc comment (llama3.rs:157-183).rope_traditional. A shape-only or smoke test does not satisfy this criterion, because the wrong rotation is shape-correct and produces fluent output.falseand none of these checkpoints declares the key, so nothing about their graph or their generated tokens may change. Verify on at least one Llama checkpoint and one Qwen2 or Qwen2.5 checkpoint from the recommended test set.to_llama3_args, and every Helium test stays green excepta_llama_config_cannot_turn_on_traditional_rope_through_json(src/models/helium_tests.rs:296), which asserts the current non-deserializing behavior and must be replaced by a test asserting the parse.false, a config that sets it totrueyieldstrue.cargo fmt --all -- --checkandcargo clippy --all-targets -- -D warningsare clean.Open Question
Should the cxx bridge signature be extended so
fused_qkv_project_split_ropeandfused_causal_prefill_attentioncan express the rotation convention, or should they keep being bypassed when the flag is set?falsefor every caller except one, which is maintenance burden with no immediate user.A middle option worth weighing: keep the bypass, but emit a one-time warning at load when a checkpoint sets
rope_traditionalwhile a fused path was requested through its environment variable, so the fallback is at least observable. Note thattracing::warn!is a no-op in the CLI binary (see the implementation context below), so such a warning has to beeprintln!.References
Originating work: #930 (implementation, merged as
1f21b1f4c) and #837 (the Helium port that surfaced this).Implementation context (machine facts, validation method, and a recurring defect class)
Most relevant to this issue: the token-exact reference oracle. Applying the wrong RoPE convention produces correctly shaped tensors and fluent text, so it is invisible to shape tests and to "runs without error" checks; only a token id comparison against mlx-lm separates the two conventions. Also relevant: the fused quantized launchers hardcode
traditional = falsein C++, so extending them means a cxx bridge signature change, which the build notes below bear on.Collected while porting five text-model families in #924, #926, #928, #930, and #946. This
is background for whoever picks this up, not additional scope.
Build and test on the current development machine (Apple M1 Ultra, macOS)
DEVELOPER_DIR=/Applications/Xcode-26.6.0.app/Contents/Developer, or cmake fails withxcrun: error: unable to find utility "metal".xcode-selectstill points at CommandLineTools.cargo checktriggers a COLD MLX C++ build that takes many minutes. UseDEVELOPER_DIR=... cargo check --release --lib --tests --features metal,accelerate.DEVELOPER_DIR=... cargo test --release --lib --features metal,accelerate models::<module>. A barecargo test,cargo test --lib, orcargo clippy --workspace --all-targetsruns long enough to stall an agent with a stream-idle watchdog, and a coldcargo build --releasecan exceed it on the MLX C++ link step alone.cargo fmt --all -- --check, never a bare-path fmt check, which produces spurious edition-2024 diffs.cargo testat all (onlychanges,cargo-deny,cargo-fmt, and the cross-repo-ref guard). Local runs plus a real checkpoint are the only gate. Two deterministically failing tests were found sitting onmainduring this series for exactly that reason: see fix(mllama): ragged cross-attention test fails on main, and no CI job runs cargo test #939, and theFAMILY_ORDERentries fixed in feat(models): add Ant Group Ling / Bailing MoE (bailing_moe) text model support #946.tracing::warn!is a NO-OP in themlxcelCLI binary; onlysrc/server/startup.rsinstalls a subscriber. Useeprintln!for any CLI-facing diagnostic.Validating model behavior: a token-exact reference oracle
Shape tests do not catch the failures that matter here. A wrong prefill/decode offset, a flat instead of interleaved QKV split, the wrong RoPE convention, or a routing bias applied to the wrong copy all produce correctly-shaped tensors and fluent, plausible output. Only the token id sequence separates them.
What worked: create a scratch venv,
pip install mlx-lm, and drivemlx_lm.generate.stream_generatewithmake_sampler(temp=0.0), printing both text and token ids. Then compare againstmlxcel generate. Pass--no-chat-templateto mlxcel even when the checkpoint ships a chat template, so both sides see the same raw prompt, and run the templated path separately as a usability check.Caveat: the oracle is blind wherever mlxcel and mlx-lm make the same choice. GELU was one such case, closed by monkey-patching the reference to the other variant and regenerating. Note
mlxcel_core::utils::gelu_approxis erf-based despite its name, while MLX Python'snn.gelu_approxreally is the tanh form.A recurring defect class worth checking for
A value from
config.jsonpasses every Rust-side check, violates an undocumented precondition of an MLX C++ entry point, and kills the process at the FIRST FORWARD PASS rather than failing at load. Most cxx bridge functions are declared returningUniquePtr<MlxArray>rather thanResult, so the C++ throw is an uncatchablestd::terminate, not somethingcatch_unwindcontains. The model loads cleanly and the server dies on its first request. This class produced one CRITICAL and five HIGH findings across the five ports.Specifics established by reading the pinned MLX checkout rather than assuming:
takewraps negative indices but an out-of-range positive index silently returns values belonging to no row, and that reaches the logits with no fault. Bound every gather by the real tensor shape, never by a config field.sliceCLAMPS an out-of-range stop instead of throwing, so a too-wide split silently loses trailing channels.fast_roperequiresdimseven, positive, and no larger than the last axis.rms_normandlayer_normnever inspecteps. A NaN or negative eps yields NaN hidden states with no error at all, which is harder to diagnose than a crash.matmulthrows on an inner-dimension mismatch, which is why an unchecked INPUT axis is fatal even where the row axis bounds only an argmax.quantized_matmuldivides bybits, so"bits": 0is a division by zero. On AArch64 the divide returns 0 andstd::invalid_argumentfires; on x86-64 the hardware raisesSIGFPEand kills the process before any exception exists.scales.shape(-1) * group_size, and check.biasesshape equals.scalesshape. A self-consistency check cannot catch a checkpoint honestly packed for the wronghidden_size, because such a checkpoint IS self-consistent.0.is_multiple_of(0)is true.0..n_layerin the load path: a hugen_layerhangs with a flat allocation footprint, so no OOM kill rescues it.src/models/gpt_neox.rsandsrc/models/helium.rscarry the currentModelArgs::validate/validate_weightsshape to copy from.Conventions that bite
// Used by:comments above shared helpers are the designated discovery mechanism for "what breaks if I change this", and they can themselves be stale. One was found listing six callers where there were eight. Verify by grep rather than trusting the comment, and update it when you touch the helper.mlxcel archis the architecture registry.mlxcel listlists downloaded models and will not tell you whether a family is supported.