Repository navigation
fix(models): Jamba never builds a quantized MoE expert plane #974
Description
Background
PR #972 (issue #958, merged as db6cce9dc) bounded the declared quantization parameters where they are stored, adding mlxcel_core::layers::validate_quantization_params(group_size, bits) (src/lib/mlxcel-core/src/layers.rs:1292) and calling it at the shared loaders plus roughly twenty family-local expert-plane constructors, and making QuantizedEmbedding::new and QuantizedMultiLinear::new return Result. The defect class it exists to close: a config-derived value passes every Rust-side check, violates an undocumented MLX precondition, and terminates the process at the FIRST forward pass rather than failing at load, because quantized_matmul and gather_qmm cross the cxx bridge as UniquePtr<MlxArray> rather than Result (src/lib/mlxcel-core/src/lib.rs:1092, :1108), so the C++ throw is an uncatchable std::terminate that catch_unwind cannot contain.
#958 named eighteen families in scope. While implementing it, the inventory was checked family by family with grep, and Jamba was the one entry of the eighteen that did not match: it has a Quantized expert variant, but nothing anywhere constructs it, so there was no storage point to bound. PR #972 left src/models/jamba.rs unchanged and recorded the finding rather than adding a guard with no call site. This issue is that finding.
The finding, confirmed on current main
What exists. src/models/jamba.rs:337 declares a family-local SwitchLinear enum with two variants. The Quantized variant is defined at :338 and carries weight, scales, biases, group_size and bits. Its forward arm at :353 calls mlxcel_core::gather_qmm correctly, with the mode hardcoded to "affine" at :376. The Regular variant is defined at :345 and its forward arm at :380 does mlxcel_core::take followed by mlxcel_core::matmul. The enum carries #[allow(dead_code)] at :336, under a comment at :335 saying it is kept local because it uses take plus matmul rather than gather_mm.
What is missing. There is no construction site for Quantized anywhere in the tree. JambaModel::from_weights (:1102) takes the MoE branch at :1222 when config.is_expert_layer(i) holds, reads the three expert planes by name, and hands each straight to SwitchLinear::Regular at :1247, :1250 and :1251:
switch_mlp: SwitchGLU {
gate_proj: SwitchLinear::Regular { weight: gate_weight },
up_proj: SwitchLinear::Regular { weight: up_weight },
down_proj: SwitchLinear::Regular { weight: down_weight },
},There is no .scales lookup anywhere in that branch. group_size and bits are computed at :1115 and :1116 and are used only by the embedding at :1119 and the dense UnifiedLinear path at :1258 onward; they never reach the MoE branch. Every other MoE family in the tree branches on scales presence, either through the shared switch_layers::SwitchLinear::from_weights or through a family-local equivalent such as src/models/deepseek.rs:847. #[allow(dead_code)] at :336 is therefore suppressing the symptom of a missing path rather than documenting a deliberate choice.
The per-expert to stacked remap in sanitize_weights (:1053, MoE loop at :1073) has the same shape: the inner while let Some(w) = weights.remove(&format!("{}.experts.{}.{}.weight", base, e, proj)) at :1086 matches only .weight, and the stacked insert at :1094 writes only switch_mlp.{proj}.weight. A genuine per-expert quantized export would arrive at the loader with its .scales and .biases left behind in the map, unstacked and unused.
The two readings, and what distinguishes them
Reading A: the quantized path is genuinely dead code and should be removed, because no real Jamba checkpoint ships quantized expert planes.
Reading B: Jamba silently fails to use quantized experts on a checkpoint that ships them, which is a real functional gap.
What settles it is whether a public quantized Jamba checkpoint with num_experts > 1 exists and what its expert tensors look like. Evidence gathered so far, all of which the implementer should re-check rather than take on trust:
models/jamba-v0.1-4bitcannot distinguish them. Itsconfig.jsondeclares"num_experts": 1, andJambaConfig::is_expert_layer(:215) requiresnum_experts > 1, so the MoE branch is never entered and all 28 layers load through the denseUnifiedLinearpath at:1258. Its 804 tensors contain 252feed_forward.*and zeroswitch_mlp.*or.experts.*. The dense projections are quantized (171.scalesand 171.biasesacross the checkpoint), so this is a real 4-bit checkpoint, just not a MoE one. The directory name is misleading: itsconfig.jsonis byte-identical tomlx-community/AI21-Jamba-Reasoning-3B-4bit(28 layers, hidden 2560, 1.6 GB on disk), andmlx-community/Jamba-v0.1-4bitdoes not resolve on the Hub.mlx-communitycurrently publishes only the Reasoning-3B conversions, all of which declarenum_experts: 1.- A public MoE conversion does exist outside
mlx-community.ncls-p/AI21-Jamba2-Mini-mlx-4Bitdeclaresmodel_type: jamba,num_experts: 16,num_experts_per_tok: 2, 32 layers, hidden 4096, andquantization: {"group_size": 64, "bits": 4, "mode": "affine"}. Its index lists 1015 tensors, of which 144 areswitch_mlp: 48.weight, 48.scalesand 48.biases, which is 16 expert layers times three projections, already pre-stacked. That is exactly the layout the missingQuantizedbranch would consume. It is roughly 29 GB and is not present locally.
If that checkpoint behaves as the code predicts, reading B is correct. The prediction, stated so it can be falsified: sanitize_weights finds no .experts.N keys (the planes are already stacked), so its remap loop is a no-op and the .scales / .biases survive untouched in the map; from_weights enters the MoE branch on the odd layers, weights.remove(".../switch_mlp.gate_proj.weight") returns the packed uint32 plane, and SwitchLinear::Regular is built from it; the first forward pass then reaches mlxcel_core::matmul on a uint32 operand, which MLX rejects with [matmul] Only inexact types are supported but ... (ml-explore/mlx mlx/ops.cpp), and that throw crosses the bridge as an uncatchable abort rather than a load error. Confirming or refuting that first run is the highest-value step in this issue.
Note the confound to rule out before concluding: Jamba2 may differ from Jamba in ways unrelated to quantization, so a load failure on AI21-Jamba2-Mini-mlx-4Bit is not by itself evidence about the expert path. If the checkpoint is unusable for an unrelated reason, fall back to driving JambaModel::from_weights over a synthetic weight map that carries a pre-stacked quantized expert plane, which is the pattern PR #972 used across sixteen families through the insert_stacked_quantized_expert_plane helper in src/models/switch_layers.rs.
Proposed Solution
Contingent on the determination above. If reading B holds:
- In the MoE branch at
src/models/jamba.rs:1225onward, look up{prefix}.feed_forward.switch_mlp.{proj}.scalesand.biasesalongside.weight, and selectSwitchLinear::Quantizedwhen scales are present. - Prefer routing through the shared
switch_layers::SwitchLinearover the family-local enum if the shape handling allows it. The comment at:335justifies the local enum by theRegulararm using take plus matmul rather thangather_mm, which does not by itself justify a private quantized arm, since the quantized arm at:353is already an ordinarygather_qmmcall. - Whichever path is taken, run
switch_layers::validate_expert_quantization_params(prefix, group_size, bits)(src/models/switch_layers.rs:171) on the declared values before storing them, so the family does not reintroduce the fix(models): bound quantization params in the family-local MoE expert loaders #958 abort class the moment the path becomes live. - Extend the per-expert stacking at
:1073to carry.scalesand.biases, or route it throughswitch_layers::stack_individual_experts(src/models/switch_layers.rs:535), which already handles all three planes. - Remove
#[allow(dead_code)]from:336once the variant is constructed. - The local
Quantizedarm hardcodes"affine"at:376and takes a non-optionalbiases, which is right for the affine export above but cannot represent a block-float one. Decide whether to support that here or to fail at load with a clear message. Related: fix(core): bound the declared quantization mode string, not just group_size and bits #973 covers the mode string being unbounded generally.
If reading A holds instead, delete the Quantized variant and the #[allow(dead_code)] attribute together, and record in the file why a quantized Jamba MoE plane is out of scope, so the next reader does not re-add a half-path.
Inspection and reproduction
Confirm the gap without building anything:
# The variant exists.
grep -n "enum SwitchLinear" -A 16 src/models/jamba.rs
# Nothing constructs it. Only Regular appears, at three sites.
grep -n "SwitchLinear::Quantized\|SwitchLinear::Regular" src/models/jamba.rs
# The MoE branch never looks for scales.
sed -n '1222,1258p' src/models/jamba.rs
# The per-expert remap carries only .weight.
sed -n '1073,1096p' src/models/jamba.rsConfirm the local checkpoint cannot exercise the path:
python3 -c "import json; c=json.load(open('models/jamba-v0.1-4bit/config.json')); print({k: c[k] for k in ['model_type','num_experts','num_experts_per_tok','num_hidden_layers','hidden_size']})"
python3 -c "import json; m=json.load(open('models/jamba-v0.1-4bit/model.safetensors.index.json'))['weight_map']; print('total', len(m), '| switch_mlp', len([k for k in m if 'switch_mlp' in k]), '| experts.', len([k for k in m if '.experts.' in k]))"Settle the determination (roughly 29 GB download, and check free space first):
DEVELOPER_DIR=/Applications/Xcode-26.6.0.app/Contents/Developer \
cargo build --release --features metal,accelerate
hf download ncls-p/AI21-Jamba2-Mini-mlx-4Bit --local-dir models/jamba2-mini-4bit
./target/release/mlxcel generate -m models/jamba2-mini-4bit -p "The capital of France is" -n 20 --no-chat-templateLocal validation: possible and not possible
Possible here. The quantized MoE checkpoints available under models/ in the repo root are gpt-oss-20b-mxfp4, deepseek-v2-lite-4bit, qwen3-30b-a3b-4bit, qwen3-moe-4bit, mixtral-8x7b-4bit, jamba-v0.1-4bit, nemotron-h-30b-4bit and llama-4-scout-17b-4bit. models/jamba-v0.1-4bit is the regression control for this change and nothing else: it proves the dense path is untouched, since it never enters the MoE branch. Unit-level validation of the new quantized branch is fully possible locally by driving JambaModel::from_weights over a synthetic pre-stacked expert plane, following the sixteen-family pattern PR #972 established.
Not possible here without a download. No local checkpoint has num_experts > 1, so neither the positive case (a quantized Jamba MoE plane loads and generates coherently) nor the negative case (today's Regular arm aborts on packed weights) can be observed against real weights until ncls-p/AI21-Jamba2-Mini-mlx-4Bit or an equivalent is fetched. deepseek-v2-lite-4bit is not a usable cross-family control right now either: it produces degenerate output for an unrelated reason tracked as #991.
Machine notes
- Pass
DEVELOPER_DIR=/Applications/Xcode-26.6.0.app/Contents/Developeron every cargo invocation. Without it cmake fails withxcrun: error: unable to find utility "metal". - Only the release profile is warm (
target/releaseexists,target/debugdoes not). A plain debugcargo checktriggers a cold MLX C++ build. Usecargo check --release --features metal,accelerateandcargo test --release. - A test that actually trips an MLX C++ throw aborts the whole test binary with SIGABRT rather than failing cleanly, because the bridge functions return
UniquePtr<MlxArray>and cannot propagate the exception. Write the bounds-check regression to assert on the loadResultand never run a forward pass, which is the convention PR fix(models): bound quantization params in the family-local MoE expert loaders #972 settled on.
Acceptance Criteria
- The two readings are resolved with evidence, and the outcome is recorded in the issue before the code change lands: either a public quantized Jamba checkpoint with
num_experts > 1is shown to mis-load today, or it is shown that none exists and the variant is dead. - If the path is implemented: a Jamba checkpoint with
num_experts > 1and quantizedswitch_mlpplanes loads throughSwitchLinear::Quantizedand generates coherent output, verified against anmlx-lmreference on the same weights and the same raw prompt (--no-chat-templateon both sides). - If the path is removed instead: the
Quantizedvariant and itsgather_qmmarm are gone, and the file records why. -
#[allow(dead_code)]is removed from theSwitchLinearenum atsrc/models/jamba.rs:336either way, and the build stays clean under-D warnings. - The declared
group_size/bitsare bounds-checked before they are stored, so a hostile"bits": 0or"group_size": 0fails at load rather than aborting at the first forward pass. Useswitch_layers::validate_expert_quantization_paramsand the sharedHOSTILE_QUANT_PARAMStable atsrc/models/switch_layers.rs:217. - A per-expert (unstacked) quantized layout stacks its
.scalesand.biasesrather than dropping them, or the loader rejects that layout with a clear message instead of silently building aRegularplane from packed weights. - Regression tests drive the values through
JambaModel::from_weightsrather than a helper in isolation, assert on the returnedResult, and never run a forward pass. Each hostile case is paired with a positive control that loads first. -
models/jamba-v0.1-4bitcontinues to load and generate identically tomain, confirming the dense path is untouched. -
cargo clippy --release --lib --tests --features metal,accelerate -- -D warningsis clean andcargo fmt --all -- --checkpasses.
Refs
Follow-up to #958 (PR #972, merged as db6cce9dc). #958 listed jamba.rs under Scope; PR #972 found no expert loader to guard because the quantized construction site does not exist. Same uncatchable-abort class as #929, #822. Sibling follow-ups from the same trace: #973 and #975.
Activity
fix(models): bound quantization params in the family-local MoE expert…
inureyes commentedon Aug 3, 2026
Determination: reading B, and the gap is wider than the issue described
Reading B holds. Two independent lines of evidence, neither of which needed the 29 GB download.
1. Quantized Jamba MoE checkpoints exist in the wild, from more than one publisher
Confirmed from the Hub API and
model.safetensors.index.jsonalone, no weights fetched:num_expertsswitch_mlptensors.experts.Nncls-p/AI21-Jamba2-Mini-mlx-4Bit.weight/ 48.scales/ 48.biases)ssdataanalysis/AI21-Jamba2-Mini-mlx-8Bitncls-palso publishes 6Bit and 8Bit conversions. Both indexes carry 1015 tensors and zero.experts.Nkeys, so the experts are pre-stacked exactly as the issue described. The keys match the loader's lookup verbatim, for examplemodel.layers.1.feed_forward.switch_mlp.gate_proj.{weight,scales,biases}.The issue's control claims all check out.
models/jamba-v0.1-4bit/config.jsonis byte-identical tomlx-community/AI21-Jamba-Reasoning-3B-4bit/config.json(both sha256d5502634907b6d86724088c7173311844ca9d73f31d64098e8b80142c20f7985), it declaresnum_experts: 1, and its 804 tensors are 252feed_forward.*with 0switch_mlp.*, 0.experts.*, 171.scales, 171.biases.mlx-community/Jamba-v0.1-4bitdoes not resolve (HTTP 401) andmlx-communitypublishes only the three Reasoning-3B conversions, allnum_experts: 1. EveryAI21-Jamba2-3BMLX conversion is alsonum_experts: 1, so no small MoE Jamba checkpoint exists to test against.2. The synthetic route confirms the load gap, and finds a second one
Driving
JambaModel::from_weightsover a one-layer config withnum_experts: 2and genuinely packeduint32expert planes:from_weightsreturnsOk. The.scales/.biasesplanes survive untouched andSwitchLinear::Regularis built from the packeduint32weight, exactly as predicted.The falsifiable prediction: two parts confirmed, one refuted
Confirmed: the
sanitize_weightsremap is a no-op on pre-stacked planes, the scales survive unused,Regularis built from the packed plane, and the failure is an uncatchablestd::terminatethrough aUniquePtrbridge rather than a load error.Refuted: the forward never reaches
mlxcel_core::matmul, so[matmul] Only inexact types are supportedis not the message. It aborts two lines earlier, inmlxcel_core::reshape:The reason matters. The same abort happens with dense f32 expert planes, which the issue did not anticipate.
SwitchGLU::forwardexpandedxfrom[b, s, h]to[b, s, 1, h], thenSwitchLinear::Regular::forwardexpanded it again as if it were still 3D, computing[x_shape[0], x_shape[1], 1, x_shape[2]]=[b, s, 1, 1]on an array ofb*s*helements. So the Jamba MoE path could never run at all, for any checkpoint, quantized or dense. It was not "quantized experts silently fall back to a working dense path"; the dense fallback was itself dead on arrival.That is why the local
Regulararm's "different forward shape handling" could not justify keeping the enum: the handling was wrong. This also explains why the defect survived undetected, sincenum_experts > 1is unreachable for every checkpointmlxcelhas ever been run against.Outcome
Implementing reading B by routing Jamba through the shared
switch_layers::SwitchGLU(issue step 2's stated preference), which resolves steps 1 through 6 together and removes the broken shape handling with the enum. The router is also moved toUnifiedLinear, because the real conversions ship it quantized at 8 bits under a 4-bit top-level default andload_linearreads.weightalone.A note on step 3: the guard lands via
SwitchLinear::from_stacked_parts, which already callsmlxcel_core::layers::validate_quantization_paramswith the tensor prefix in the error, so a secondvalidate_expert_quantization_paramscall would be a duplicate. The regression tests still walk the sharedHOSTILE_QUANT_PARAMStable throughJambaModel::from_weightsand assert on the returnedResultwithout a forward pass.fix(models): build quantized Jamba MoE experts through the shared Swi…