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(core): an unvalidated config.json quantization block reaches MLX and terminates the process #929
A quantization block in config.json is read verbatim and threaded into MLX's quantized kernels without any bounds check. A checkpoint that declares "bits": 0 (or any value above 32, or a non-positive group_size) alongside real .scales tensors loads without complaint and then kills the process on the first request that touches a quantized projection. This is not a Rust panic, so it is not containable: per docs/adr/0003-release-panic-unwind-with-core-thread-abort.md the release build unwinds and the worker paths can catch_unwind, but a C++ std::terminate is not a panic, and that ADR already names MLX FFI exceptions as the documented residual abort vector.
This surfaced during the security review of #928 (implementing #836, GPT-NeoX), but the exposure is tree-wide and not specific to that family.
Confirmed chain
1. mlxcel deliberately passes the bad value through.src/lib/mlxcel-core/src/layers.rs:976-984, in reconcile_quantization_layout (declared at line 969):
// Insufficient shape info: trust the caller, mirroring the historical// early-return in `infer_quantization_bits` for empty / scalar shapes.if weight_shape.is_empty() || scales_shape.is_empty() || group_size <= 0 || bits <= 0{returnOk(ReconciledQuant{ bits, group_size,reconciled:false});}
A non-positive group_size or bits is returned unchanged on a "trust the caller" basis. A bits value above 32 is not caught here either: the affine branch calls infer_quantization_bits, which re-derives from shapes, and the block-float branch gates on 32 % bits == 0 and otherwise returns the declared pair as-is. The declared value reaches the kernel exactly as written.
2. MLX rejects it by throwing.mlx/ops.cpp:115 in validate_quantized_input (declared at line 83), pinned commit b7c3dd6d27f45b5365b08a840310187dc503f1db:
if (w.shape(-1) * 32 / bits != scales.shape(-1) * group_size) {
std::ostringstream msg;
msg << "[" << tag << "] The shapes of the weight and scales are "
<< "incompatible based on bits and group_size. w.shape() == "
<< w.shape() << " and scales.shape() == " << scales.shape()
<< " with group_size=" << group_size << " and bits=" << bits;
throwstd::invalid_argument(msg.str());
}
At bits == 0 this is an integer division by zero. At bits > 32 the quotient collapses toward zero and can match no real scales width. A non-positive group_size likewise makes the right-hand side unmatchable. The exception type is std::invalid_argument.
validate_quantized_input is not reached only from quantized_matmul. It is called from extract_quantized_matmul_dims (mlx/ops.cpp:125, the call at line 134), which is the shared entry gate for quantized_matmul (ops.cpp:4628) and gather_qmm (ops.cpp:5417) alike, plus a direct call at ops.cpp:4706. Every quantized matmul flavor in the tree, dense and MoE, divides by bits at that one line.
One platform nuance worth confirming on the CUDA build: division by zero is undefined behavior in C++, and the two targets diverge in practice. On AArch64 the divide instruction returns zero rather than trapping, so the comparison fails and the std::invalid_argument above is what fires. On x86-64 the hardware raises SIGFPE, which kills the process outright before any exception exists. Both outcomes are process death; only the diagnostic differs.
3. The throw is uncatchable.quantized_matmul is declared in the cxx bridge at src/lib/mlxcel-core/src/lib.rs:1108 as returning UniquePtr<MlxArray>, not Result<UniquePtr<MlxArray>>:
cxx generates a noexcept shim for any bridge function that does not return Result, so a C++ throw crossing it calls std::terminate. The C++ side calls mlx::core::quantized_matmul directly with no try/catch in all four relevant places: the quantized_matmul shim itself (src/lib/mlxcel-core/cpp/mlx_cxx_bridge.cpp:2926), dequantize (:2954), and the two fused wrappers quantized_linear_forward (:1063) and quantized_linear_forward_global_scale (:1099). There is no try_quantized_matmul; only 7 declarations in the entire bridge return Result (try_array_to_raw_bytes, try_eval, try_matmul, try_conv2d, try_async_eval, try_conv1d, mlx_load_safetensors), and dequantize (lib.rs:1121) has the same infallible shape as quantized_matmul.
4. Almost no model family guards it, and the per-family guards are now duplicating. Of 163 files under src/models/, 62 declare pub quantization: Option<Quantization>, and three validate it:
That count was 1 when this issue was filed. #930 (Helium) and #946 (Bailing MoE) each landed a near-identical private copy of the same check: validate_quantization at helium.rs:385 and bailing_moe.rs:830 are line-for-line the same bits < 1 || bits > 32 plus group_size < 1 pair that #928 added at gpt_neox.rs:355 (called from validate at gpt_neox.rs:295), down to the wording of the error message. That is the argument for the shared guard getting stronger, not weaker: the check is spreading by copy-paste while the other 59 declaring families stay unguarded.
src/models/gpt2.rs and src/models/gpt_bigcode.rs remain confirmed examples of the exposure: both declare the field (gpt2.rs:84, gpt_bigcode.rs:129) and both read bits back out for the loader (gpt2.rs:211, gpt_bigcode.rs:324) with no range check anywhere.
The defect class
The shape of this recurs and is worth naming, because per-instance fixes keep missing the next one: a config-derived value passes Rust-side validation, violates an undocumented precondition of an MLX C++ entry point, and terminates the process at first inference rather than at load.#928 fixed an earlier instance of exactly this shape, where an odd rotary dims was accepted at load and then aborted inside fast_rope. Anything that only fails once a tensor reaches MLX is a load-time validation gap, not a runtime error path.
Proposed Solution
Scope item 1: bound the quantization params in the shared reconciler
Validate in reconcile_quantization_layout rather than adding a per-family check to each of the 160-plus files under src/models/. The two production call sites, QuantizedEmbedding::from_weights_with_mode (layers.rs:313) and UnifiedLinear::from_weights_with_mode (layers.rs:1183), both go through reconcile_quantization_layout_logged (layers.rs:1059), so one guard covers every family's quantized embeddings and dense projections at once, and covers families added later by default.
One loader is not covered by that guard, and needs its own.SwitchLinear::from_stacked_parts (src/models/switch_layers.rs:281) is the MoE expert loader and never calls the reconciler. It re-derives the bit width itself from the invariant packed_in * 32 == bits * num_groups * group_size, accepts the inferred value only when it lands in 2..=8, and otherwise falls back to the declared bits unchecked. group_size is not inferred at all: a declared group_size of 0 makes the denominator zero, the inference is skipped entirely, and the zero is stored on SwitchLinear::Quantized and handed to the kernel. The MoE path reaches MLX through gather_qmm, which goes through the same extract_quantized_matmul_dims and therefore the same /bits division as the dense path. A fix that guards only reconcile_quantization_layout leaves every quantized MoE checkpoint exposed, so the switch-layer loader needs the equivalent bounds check on the values it ends up storing.
The guard must be a bounds check, not an allowlist of the widths MLX actually supports. This is a real constraint, not a stylistic preference: mlxcel intentionally re-derives bit width from tensor shapes when the declared value disagrees with the stored tensors. That is the entire purpose of the reconciled flag, and it is what makes mixed-precision exports load correctly today (the Qwen3.5/3.6 MoE per-path bit overrides, the minicpm-v mxfp4 group-size case). An allowlist of {2,3,4,5,6,8} would reject checkpoints that work now. Only values that can describe no packing at all should be refused: bits outside 1..=32, and group_size below 1.
Turning the current "trust the caller" early return into an Err is the natural landing spot, since the function already returns Result<ReconciledQuant, String> and already hard-fails for unsupported affine layouts. Callers already surface that Err as a load error. Check whether any caller relies on the permissive early return for a legitimate shape; the empty/scalar-shape case it was originally written for is a separate condition from the non-positive-param case and should stay permissive if so.
Keep the per-family validate_quantization in gpt_neox.rs, helium.rs and bailing_moe.rs, or fold the three copies into the shared guard, but do not leave the shared path unguarded on the grounds that three families check.
Scope item 2: check the width of quantized embedding tables
validate_embedding_table (src/models/gpt2.rs:848, shared by Bailing MoE, GPT-2, GPT-BigCode and GPT-NeoX per its Used by: comment at line 847, called from gpt2.rs:780, gpt_bigcode.rs:761,772, gpt_neox.rs:1127 and bailing_moe.rs:2001) skips the width check when the table is quantized (gpt2.rs:874):
if !table.is_quantized() && !dim_eq(*cols, expected_cols){
The doc comment at lines 843-845 gives the reason: a quantized table is packed along the last axis, so its stored width is a function of bit depth rather than the model width. That is correct as far as it goes, but the consequence is that a quantized embedding whose dequantized width disagrees with hidden_size is accepted at load and then aborts on the first forward. The hidden state reaches fast::layer_norm / fast::rms_norm at the wrong width and MLX throws [layer_norm] weight must have the same size as the last dimension of ... (mlx/fast.cpp:214, and :75 for the rms_norm equivalent), which crosses the same infallible bridge and terminates the same way.
The dequantized width is recoverable at load from the packed width and the (now validated) bit depth, so the check can be restored for the quantized case rather than skipped.
#946 already landed this reconstruction, but privately in one family.validate_quantized_packing (src/models/bailing_moe.rs:934) reconstructs the input width as scales.shape(-1) * group_size using the group_size that reconcile_quantization_layout settled on, compares it against the config width, and separately rejects a .biases whose shape differs from .scales. It is applied to the token table at bailing_moe.rs:1226, to every dense projection including the output head at bailing_moe.rs:1044 (inside validate_projection), and to the stacked MoE experts at bailing_moe.rs:1120 (inside validate_stacked_experts). It is a private fn in that one file. Hoisting it to a shared location and calling it from validate_embedding_table for the quantized case satisfies this scope item for all four families that use the shared helper instead of just Bailing MoE, and gives the MoE gap from scope item 1 a ready-made check as well.
Both items are the same "reject at load rather than terminate at inference" theme, and a fix should cover both.
Acceptance Criteria
reconcile_quantization_layout returns Err for bits outside 1..=32 and for group_size < 1, with a message naming the offending field and value, instead of returning the declared pair unchanged.
The guard is a bounds check, not an allowlist: a declared bit width that disagrees with the stored tensor shapes still reconciles as it does today, and the existing mixed-precision reconciliation tests pass unchanged.
A checkpoint whose config.json declares "bits": 0 (and separately "bits": 33, and "group_size": 0) fails at load with an actionable error naming the field, on every quantizing family, not only gpt_neox, helium and bailing_moe.
The quantized MoE path is covered too: SwitchLinear::from_stacked_parts cannot store a non-positive group_size or an out-of-range bits on SwitchLinear::Quantized, so a bad config fails at load rather than aborting inside gather_qmm.
validate_embedding_table checks the dequantized width of a quantized embedding table against the model width, and rejects a mismatch at load.
A quantized embedding table whose dequantized width disagrees with hidden_size fails at load rather than reaching fast::layer_norm / fast::rms_norm.
Regression tests exist for both scope items that drive the bad values through the real load path, to the point where MLX would be called. These are self-enforcing: if a guard regresses, the C++ throw crosses the infallible cxx bridge and aborts the test binary rather than producing a normal test failure. Note that cargo test always builds with panic = "unwind", but std::terminate is not a panic, so the harness cannot contain it either. A test that exercises only the pure shape helper without reaching a real call site does not satisfy this criterion.
The tree-wide count is re-verified after the fix: no per-family workaround is needed for a family to be covered, and the three existing copies in gpt_neox.rs, helium.rs and bailing_moe.rs are either removed or explicitly kept as a redundant early diagnostic.
Validated against at least one real quantized checkpoint to confirm normal loading is unaffected, per the repository's real-checkpoint validation rule. Not run in PR fix(core): reject quantization params MLX would abort on, at load #956: the release binaries are not built in that environment and the MoE reference checkpoint is 33.6 GB. Left for the follow-up validation pass.
Technical Considerations
The mlx/ops.cpp and mlx/fast.cpp line numbers above are against pinned commit b7c3dd6d27f45b5365b08a840310187dc503f1db, re-confirmed against the local _deps/mlx-src checkout at that commit. Re-check them after an MLX bump; the precondition is what matters, not the line number.
The SIGFPE-versus-throw divergence between AArch64 and x86-64 noted above means the CUDA build may fail even less gracefully than the Metal build. Worth a one-line confirmation during implementation.
Refs
Originating work: #928, #836. Related abort-class hardening: #822, #427, #382, and docs/adr/0003-release-panic-unwind-with-core-thread-abort.md.
Implementation context (machine facts, validation method, and a recurring defect class)
Most relevant to this issue: the "recurring defect class" section below is this issue, so read it first. Two entries in it are the specification: quantized_matmul divides by bits, which is the SIGFPE-versus-std::invalid_argument platform divergence described above, and the scales.shape(-1) * group_size width reconstruction is exactly what scope item 2 needs (including the point that a self-consistency check cannot catch a checkpoint honestly packed for the wrong hidden_size).
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
A
quantizationblock inconfig.jsonis read verbatim and threaded into MLX's quantized kernels without any bounds check. A checkpoint that declares"bits": 0(or any value above 32, or a non-positivegroup_size) alongside real.scalestensors loads without complaint and then kills the process on the first request that touches a quantized projection. This is not a Rust panic, so it is not containable: perdocs/adr/0003-release-panic-unwind-with-core-thread-abort.mdthe release build unwinds and the worker paths cancatch_unwind, but a C++std::terminateis not a panic, and that ADR already names MLX FFI exceptions as the documented residual abort vector.This surfaced during the security review of #928 (implementing #836, GPT-NeoX), but the exposure is tree-wide and not specific to that family.
Confirmed chain
1. mlxcel deliberately passes the bad value through.
src/lib/mlxcel-core/src/layers.rs:976-984, inreconcile_quantization_layout(declared at line 969):A non-positive
group_sizeorbitsis returned unchanged on a "trust the caller" basis. Abitsvalue above 32 is not caught here either: the affine branch callsinfer_quantization_bits, which re-derives from shapes, and the block-float branch gates on32 % bits == 0and otherwise returns the declared pair as-is. The declared value reaches the kernel exactly as written.2. MLX rejects it by throwing.
mlx/ops.cpp:115invalidate_quantized_input(declared at line 83), pinned commitb7c3dd6d27f45b5365b08a840310187dc503f1db:At
bits == 0this is an integer division by zero. Atbits > 32the quotient collapses toward zero and can match no realscaleswidth. A non-positivegroup_sizelikewise makes the right-hand side unmatchable. The exception type isstd::invalid_argument.validate_quantized_inputis not reached only fromquantized_matmul. It is called fromextract_quantized_matmul_dims(mlx/ops.cpp:125, the call at line 134), which is the shared entry gate forquantized_matmul(ops.cpp:4628) andgather_qmm(ops.cpp:5417) alike, plus a direct call atops.cpp:4706. Every quantized matmul flavor in the tree, dense and MoE, divides bybitsat that one line.One platform nuance worth confirming on the CUDA build: division by zero is undefined behavior in C++, and the two targets diverge in practice. On AArch64 the divide instruction returns zero rather than trapping, so the comparison fails and the
std::invalid_argumentabove is what fires. On x86-64 the hardware raisesSIGFPE, which kills the process outright before any exception exists. Both outcomes are process death; only the diagnostic differs.3. The throw is uncatchable.
quantized_matmulis declared in the cxx bridge atsrc/lib/mlxcel-core/src/lib.rs:1108as returningUniquePtr<MlxArray>, notResult<UniquePtr<MlxArray>>:cxx generates a
noexceptshim for any bridge function that does not returnResult, so a C++ throw crossing it callsstd::terminate. The C++ side callsmlx::core::quantized_matmuldirectly with no try/catch in all four relevant places: thequantized_matmulshim itself (src/lib/mlxcel-core/cpp/mlx_cxx_bridge.cpp:2926),dequantize(:2954), and the two fused wrappersquantized_linear_forward(:1063) andquantized_linear_forward_global_scale(:1099). There is notry_quantized_matmul; only 7 declarations in the entire bridge returnResult(try_array_to_raw_bytes,try_eval,try_matmul,try_conv2d,try_async_eval,try_conv1d,mlx_load_safetensors), anddequantize(lib.rs:1121) has the same infallible shape asquantized_matmul.4. Almost no model family guards it, and the per-family guards are now duplicating. Of 163 files under
src/models/, 62 declarepub quantization: Option<Quantization>, and three validate it:That count was 1 when this issue was filed. #930 (Helium) and #946 (Bailing MoE) each landed a near-identical private copy of the same check:
validate_quantizationathelium.rs:385andbailing_moe.rs:830are line-for-line the samebits < 1 || bits > 32plusgroup_size < 1pair that #928 added atgpt_neox.rs:355(called fromvalidateatgpt_neox.rs:295), down to the wording of the error message. That is the argument for the shared guard getting stronger, not weaker: the check is spreading by copy-paste while the other 59 declaring families stay unguarded.src/models/gpt2.rsandsrc/models/gpt_bigcode.rsremain confirmed examples of the exposure: both declare the field (gpt2.rs:84,gpt_bigcode.rs:129) and both readbitsback out for the loader (gpt2.rs:211,gpt_bigcode.rs:324) with no range check anywhere.The defect class
The shape of this recurs and is worth naming, because per-instance fixes keep missing the next one: a config-derived value passes Rust-side validation, violates an undocumented precondition of an MLX C++ entry point, and terminates the process at first inference rather than at load. #928 fixed an earlier instance of exactly this shape, where an odd rotary
dimswas accepted at load and then aborted insidefast_rope. Anything that only fails once a tensor reaches MLX is a load-time validation gap, not a runtime error path.Proposed Solution
Scope item 1: bound the quantization params in the shared reconciler
Validate in
reconcile_quantization_layoutrather than adding a per-family check to each of the 160-plus files undersrc/models/. The two production call sites,QuantizedEmbedding::from_weights_with_mode(layers.rs:313) andUnifiedLinear::from_weights_with_mode(layers.rs:1183), both go throughreconcile_quantization_layout_logged(layers.rs:1059), so one guard covers every family's quantized embeddings and dense projections at once, and covers families added later by default.One loader is not covered by that guard, and needs its own.
SwitchLinear::from_stacked_parts(src/models/switch_layers.rs:281) is the MoE expert loader and never calls the reconciler. It re-derives the bit width itself from the invariantpacked_in * 32 == bits * num_groups * group_size, accepts the inferred value only when it lands in2..=8, and otherwise falls back to the declaredbitsunchecked.group_sizeis not inferred at all: a declaredgroup_sizeof 0 makes the denominator zero, the inference is skipped entirely, and the zero is stored onSwitchLinear::Quantizedand handed to the kernel. The MoE path reaches MLX throughgather_qmm, which goes through the sameextract_quantized_matmul_dimsand therefore the same/bitsdivision as the dense path. A fix that guards onlyreconcile_quantization_layoutleaves every quantized MoE checkpoint exposed, so the switch-layer loader needs the equivalent bounds check on the values it ends up storing.The guard must be a bounds check, not an allowlist of the widths MLX actually supports. This is a real constraint, not a stylistic preference: mlxcel intentionally re-derives bit width from tensor shapes when the declared value disagrees with the stored tensors. That is the entire purpose of the
reconciledflag, and it is what makes mixed-precision exports load correctly today (the Qwen3.5/3.6 MoE per-path bit overrides, the minicpm-v mxfp4 group-size case). An allowlist of{2,3,4,5,6,8}would reject checkpoints that work now. Only values that can describe no packing at all should be refused:bitsoutside1..=32, andgroup_sizebelow 1.Turning the current "trust the caller" early return into an
Erris the natural landing spot, since the function already returnsResult<ReconciledQuant, String>and already hard-fails for unsupported affine layouts. Callers already surface thatErras a load error. Check whether any caller relies on the permissive early return for a legitimate shape; the empty/scalar-shape case it was originally written for is a separate condition from the non-positive-param case and should stay permissive if so.Keep the per-family
validate_quantizationingpt_neox.rs,helium.rsandbailing_moe.rs, or fold the three copies into the shared guard, but do not leave the shared path unguarded on the grounds that three families check.Scope item 2: check the width of quantized embedding tables
validate_embedding_table(src/models/gpt2.rs:848, shared by Bailing MoE, GPT-2, GPT-BigCode and GPT-NeoX per itsUsed by:comment at line 847, called fromgpt2.rs:780,gpt_bigcode.rs:761,772,gpt_neox.rs:1127andbailing_moe.rs:2001) skips the width check when the table is quantized (gpt2.rs:874):The doc comment at lines 843-845 gives the reason: a quantized table is packed along the last axis, so its stored width is a function of bit depth rather than the model width. That is correct as far as it goes, but the consequence is that a quantized embedding whose dequantized width disagrees with
hidden_sizeis accepted at load and then aborts on the first forward. The hidden state reachesfast::layer_norm/fast::rms_normat the wrong width and MLX throws[layer_norm] weight must have the same size as the last dimension of ...(mlx/fast.cpp:214, and:75for therms_normequivalent), which crosses the same infallible bridge and terminates the same way.The dequantized width is recoverable at load from the packed width and the (now validated) bit depth, so the check can be restored for the quantized case rather than skipped.
#946 already landed this reconstruction, but privately in one family.
validate_quantized_packing(src/models/bailing_moe.rs:934) reconstructs the input width asscales.shape(-1) * group_sizeusing thegroup_sizethatreconcile_quantization_layoutsettled on, compares it against the config width, and separately rejects a.biaseswhose shape differs from.scales. It is applied to the token table atbailing_moe.rs:1226, to every dense projection including the output head atbailing_moe.rs:1044(insidevalidate_projection), and to the stacked MoE experts atbailing_moe.rs:1120(insidevalidate_stacked_experts). It is a privatefnin that one file. Hoisting it to a shared location and calling it fromvalidate_embedding_tablefor the quantized case satisfies this scope item for all four families that use the shared helper instead of just Bailing MoE, and gives the MoE gap from scope item 1 a ready-made check as well.Both items are the same "reject at load rather than terminate at inference" theme, and a fix should cover both.
Acceptance Criteria
reconcile_quantization_layoutreturnsErrforbitsoutside1..=32and forgroup_size < 1, with a message naming the offending field and value, instead of returning the declared pair unchanged.config.jsondeclares"bits": 0(and separately"bits": 33, and"group_size": 0) fails at load with an actionable error naming the field, on every quantizing family, not onlygpt_neox,heliumandbailing_moe.SwitchLinear::from_stacked_partscannot store a non-positivegroup_sizeor an out-of-rangebitsonSwitchLinear::Quantized, so a bad config fails at load rather than aborting insidegather_qmm.validate_embedding_tablechecks the dequantized width of a quantized embedding table against the model width, and rejects a mismatch at load.hidden_sizefails at load rather than reachingfast::layer_norm/fast::rms_norm.cargo testalways builds withpanic = "unwind", butstd::terminateis not a panic, so the harness cannot contain it either. A test that exercises only the pure shape helper without reaching a real call site does not satisfy this criterion.gpt_neox.rs,helium.rsandbailing_moe.rsare either removed or explicitly kept as a redundant early diagnostic.cargo clippy --all-targets -- -D warningsandcargo fmt --checkpass.Technical Considerations
mlx/ops.cppandmlx/fast.cppline numbers above are against pinned commitb7c3dd6d27f45b5365b08a840310187dc503f1db, re-confirmed against the local_deps/mlx-srccheckout at that commit. Re-check them after an MLX bump; the precondition is what matters, not the line number.quantized_matmulanddequantizefallible at the cxx boundary, the waytry_evalandtry_matmulalready are. That is defense in depth of the same kind fix(server): catch MLX C++ aborts at the decode FFI boundary so a backend throw fails the request, not the process #822 landed for the decode loop (PR fix(server): survive MLX decode-eval throws instead of aborting the worker #825) and fix: make conv2d/conv1d fallible at the FFI boundary so conv shape faults don't abort the server #427 landed for conv2d/conv1d, and it would convert this class of abort into a request-level error. It is not a substitute for load-time validation: failing the request on every forward pass is still the wrong outcome for a config that could have been rejected once at load.SIGFPE-versus-throw divergence between AArch64 and x86-64 noted above means the CUDA build may fail even less gracefully than the Metal build. Worth a one-line confirmation during implementation.Refs
Originating work: #928, #836. Related abort-class hardening: #822, #427, #382, and
docs/adr/0003-release-panic-unwind-with-core-thread-abort.md.Implementation context (machine facts, validation method, and a recurring defect class)
Most relevant to this issue: the "recurring defect class" section below is this issue, so read it first. Two entries in it are the specification:
quantized_matmuldivides bybits, which is theSIGFPE-versus-std::invalid_argumentplatform divergence described above, and thescales.shape(-1) * group_sizewidth reconstruction is exactly what scope item 2 needs (including the point that a self-consistency check cannot catch a checkpoint honestly packed for the wronghidden_size).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.