Repository navigation
fix(core): bound the declared quantization mode string at load - #1006
Merged
Merged
Conversation
`quantization.mode` was the one value in the `quantization` block that nothing in `mlxcel-core` bounded. PR #972 bounded `group_size` and `bits` and deliberately left the mode out of scope, and the only bound anywhere in the tree was gemma4's private `SUPPORTED_QUANT_SCHEMES`, wired into gemma4's two loaders alone. An unrecognized mode was neither rejected nor defaulted. On the shared loader path it took the block-float branch of `reconcile_quantization_layout`, which on a genuine affine tensor re-derives the group size back onto the declared value, so nothing looked reconciled and not even the divergence warning fired. The string was then stored verbatim and handed to `quantized_matmul` / `gather_qmm` / `dequantize` on every forward pass. Those cross the cxx bridge as `UniquePtr<MlxArray>` rather than `Result`, so MLX's `std::invalid_argument` from `string_to_quantization_mode` became an uncatchable `std::terminate` at the first forward pass, after the model had already loaded without a warning. Adds `mlxcel_core::layers::validate_quantization_mode`, an allowlist mirroring MLX's own parser, plus `SUPPORTED_QUANTIZATION_MODES` and `validate_quantization_biases`. An allowlist is right here even though `validate_quantization_params` deliberately is not one: that guard stays permissive because mlxcel re-derives an effective bit width from tensor shapes, and there is no analogous re-derivation from the declared mode string. The comparison is exact: no trimming, no case folding, and an empty string is a declared value rather than "not declared". MLX compares the raw string byte-for-byte, so normalizing before the comparison would accept a spelling that is then stored and forwarded unchanged, which is the gap being closed. "Not declared" is the absence of the key, which every config reader already resolves to `"affine"`. `validate_quantization_biases` additionally enforces both halves of MLX's `validate_mode_with_type`: `affine` requires a `.biases` plane and every block-float mode requires its absence. This closes the related case where `ExpertLinear::from_weights` entered the quantized branch on `.scales` presence alone and built a bias-less plane under a declared `affine` mode that then aborted in `gather_qmm`. Rejecting rather than repairing via `infer_quantization_mode`, because relabelling a genuinely affine plane as block-float trades one uncatchable abort for another (`Scale type must be uint8`) and silently overrides a declaration. Because the predicate is MLX's own, it can only refuse a load that was already going to abort, and it is a provable no-op for every caller that goes through `from_weights`, which derives the mode from bias presence. Wiring: `reconcile_quantization_layout` covers every `UnifiedLinear` and `UnifiedEmbedding` and its `mode == "affine"` branch becomes total rather than a silent fallthrough; `QuantizedWeight::new_with_mode` becomes `Result` (it had no callers); `SwitchLinear::from_stacked_parts` covers the MoE experts reached through the `pub` `from_weights_with_mode`; gpt-oss `Quantization::validate` stops discarding the mode and walks the top-level pair plus every per-prefix override, and `ExpertLinear::from_weights` bounds it where it stores it; `QuantizationArgs::get_mode` returns `Result` so the next family cannot inherit the hole; gemma4 delegates its `mode` comparison and borrows the core constant, keeping only its `quant_method` and ModelOpt-NVFP4 policy for issue #467. `src/models/config.rs` turned out to be worse than "zero callers": it was never declared as a module, so the whole file was orphaned and never compiled, linted, or tested. `pub mod config;` is added to `src/models/mod.rs` so the bound `get_mode` now carries is real rather than notional. The file compiles clean under `-D warnings` and its tests run for the first time. Validated on real checkpoints: `models/gpt-oss-20b-mxfp4` (the only local checkpoint whose declared mode reaches a kernel, mixing a top-level mxfp4 default for the experts with 98 affine per-prefix overrides and 24 mode-less ones that must keep resolving to affine), `models/minicpm-v-4.6-mxfp4`, `models/gemma-4-31b-it-nvfp4`, `models/qwen3-30b-a3b-4bit` and `models/qwen3.5-2b-4bit` all load and generate unchanged. Synthetic hostile configs built as scratchpad symlink farms now fail at load naming the four modes, in all three shapes: a hostile top-level mode with valid per-prefix overrides (the live gap, since the experts read `Quantization::defaults` rather than `quant_for`), a hostile per-prefix mode under a valid top level, and `affine` declared over the bias-less expert planes. Closes #973
This was referenced Aug 2, 2026
This was referenced Aug 3, 2026
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
quantization.modewas the one value in thequantizationblock that nothing inmlxcel-corebounded. #972 boundedgroup_sizeandbitsand deliberately left the mode out of scope, and the only bound that existed anywhere was gemma4's privateSUPPORTED_QUANT_SCHEMES. This addsmlxcel_core::layers::validate_quantization_mode, an allowlist mirroring MLX's ownstring_to_quantization_mode, and wires it into every path a declared mode can reach a kernel through.An unrecognized mode was neither rejected nor defaulted. On the shared loader path it took the block-float branch of
reconcile_quantization_layout, which on a genuine affine tensor re-derives the group size back onto the declared value, so nothing looked reconciled and not even the divergence warning fired. The string was then stored verbatim and handed toquantized_matmul/gather_qmm/dequantizeon every forward pass. Those cross the cxx bridge asUniquePtr<MlxArray>rather thanResult, so MLX'sstd::invalid_argumentwas an uncatchablestd::terminateat the first forward pass, after the model had already loaded without a warning.Two decisions the issue asked to be made explicitly
Exact comparison: no trimming, no case folding. MLX compares the raw string byte-for-byte and has no normalization of its own, so
" Affine"parses in gemma4's old check and throws in the kernel. Normalizing before the comparison would accept a spelling that is then stored and forwarded unchanged, which is exactly the gap being closed. mlxcel also does not silently rewrite declared metadata (the #467 policy is to warn, not override), so there is no correct place to put a normalized value even if one were computed. gemma4'smodecheck becomes stricter as a result, which is the right direction; itsquant_methodcheck keeps the permissive normalization it has always had, because aquant_methodnever reaches an MLX kernel.An empty string is a declared value, not "not declared".
"mode": ""survivesobj.get("mode").and_then(|v| v.as_str())asSome(""), reachesgather_qmm, and aborts. "Not declared" is the absence of the key, which every config reader already resolves to"affine"before anything is validated. gemma4 used to accept an empty mode; it no longer does.Item 6: affine with no
.biasesRejected at load rather than repaired, and generalized to MLX's full precondition. Repairing via
infer_quantization_modewould swap one uncatchable abort for another: relabelling a genuinely affine plane as block-float makes MLX throwScale type must be uint8 but received type float16instead. It also guesses, and silently overrides a declaration, which is what #467 exists to prevent.validate_quantization_biasesinstead enforces both halves of MLX'svalidate_mode_with_type:affinerequires.biases, every block-float mode requires their absence. Because the predicate is MLX's own, it can only refuse a load that was already going to abort. It is a provable no-op for every caller that goes throughUnifiedLinear::from_weights/QuantizedEmbedding::from_weights, which derive the mode from bias presence viainfer_quantization_modeand are consistent by construction. Only a caller threading a config-declared mode can trip it, which today is gpt-oss alone.What changed
src/lib/mlxcel-core/src/layers.rs: newSUPPORTED_QUANTIZATION_MODES,validate_quantization_modeandvalidate_quantization_biases; called fromreconcile_quantization_layout(covering everyUnifiedLinearandUnifiedEmbedding, and making themode == "affine"branch total rather than a silent fallthrough), fromUnifiedLinear::from_weights_with_modeandQuantizedEmbedding::from_weights_with_modefor the bias half, and fromQuantizedWeight::new_with_mode, which becomesResult(it had no callers).src/models/switch_layers.rs:SwitchLinear::from_stacked_partsbounds the mode next to the existingvalidate_quantization_params, covering both the pre-stacked and per-expert layouts reached through thepubfrom_weights_with_mode. Adds the sharedHOSTILE_QUANT_MODEStable besideHOSTILE_QUANT_PARAMS.src/models/gpt_oss.rs:Quantization::validatestops discarding the mode and walks the top-level pair plus every per-prefix override, so a hostile mode is blamed on the prefix that declared it.ExpertLinear::from_weightsbounds the mode and the bias pairing where it stores them.src/models/config.rs:QuantizationArgs::get_modereturnsResult<&str, String>. It has no production callers and is the obvious thing for the next family to reach for, so the bound belongs on the signature.src/models/mod.rs:pub mod config;. See below.src/models/gemma4.rs:SUPPORTED_QUANT_SCHEMESis now the core constant rather than a private copy, and themodecomparison delegates to the core function. gemma4 keeps itsquant_methodand ModelOpt-NVFP4 policy, which is genuinely gemma4-shaped and covers fix: externally quantized 4-bit Gemma 4 (OptiQ) decodes to degenerate output that is trapped entirely in reasoning_content #467.One premise corrected
The issue lists
QuantizedEmbedding::from_weights_with_modeamong the entry points that "never reachreconcile_quantization_layout". It does reach it, throughreconcile_quantization_layout_logged, so the mode allowlist covers it via item 2 with no separate call needed. Only the bias-pairing half had to be added there. The numbered proposal's own item 3 lists a different (correct) set of four, namingExpertLinear::from_weightsin its place; all five surfaces are covered either way.Test plan
Real-model runs, since CI runs no
cargo test:models/gpt-oss-20b-mxfp4loads and generates unchanged. The one local checkpoint whose declared mode reaches a kernel: top-levelmxfp4for the 72 expert planes, 98affineper-prefix overrides, and 24 mode-less overrides that must keep resolving toaffinerather than inheritingmxfp4.Quantization::defaults), hostile per-prefix mode with a valid top level, andaffinedeclared over the bias-less expert planes.models/minicpm-v-4.6-mxfp4(block-float throughreconcile_quantization_layout) unchanged.models/gemma-4-31b-it-nvfp4(ModelOpt NVFP4, the fix: externally quantized 4-bit Gemma 4 (OptiQ) decodes to degenerate output that is trapped entirely in reasoning_content #467 path) unchanged.models/qwen3-30b-a3b-4bitandmodels/qwen3.5-2b-4bit(affine MoE and affine dense) unchanged.cargo fmt --all -- --checkcargo clippy --release --all-targets --features metal,accelerate -- -D warnings(the release form;make verify-clippyuses the debug profile, which would cold-build MLX here)cargo test --release --features metal,accelerate --no-fail-fastRegression tests drive real load paths and assert on the returned
Resultwithout running a forward pass, because an MLX throw would abort the test binary rather than fail it. Each hostile case is paired with a positive control that loads first.Closes #973