Skip to content

fix(core): bound the declared quantization mode string at load - #1006

Merged
inureyes merged 1 commit into
mainfrom
fix/issue-973-bound-quantization-mode
Aug 2, 2026
Merged

inureyes merged 1 commit into
mainfrom
fix/issue-973-bound-quantization-mode

Conversation

@inureyes

@inureyes inureyes commented Aug 2, 2026

Copy link
Copy Markdown
Member

Summary

quantization.mode was the one value in the quantization block that nothing in mlxcel-core bounded. #972 bounded group_size and bits and deliberately left the mode out of scope, and the only bound that existed anywhere was gemma4's private SUPPORTED_QUANT_SCHEMES. This adds mlxcel_core::layers::validate_quantization_mode, an allowlist mirroring MLX's own string_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 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 was an uncatchable std::terminate at 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's mode check becomes stricter as a result, which is the right direction; its quant_method check keeps the permissive normalization it has always had, because a quant_method never reaches an MLX kernel.

An empty string is a declared value, not "not declared". "mode": "" survives obj.get("mode").and_then(|v| v.as_str()) as Some(""), reaches gather_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 .biases

Rejected at load rather than repaired, and generalized to MLX's full precondition. Repairing via infer_quantization_mode would swap one uncatchable abort for another: relabelling a genuinely affine plane as block-float makes MLX throw Scale type must be uint8 but received type float16 instead. It also guesses, and silently overrides a declaration, which is what #467 exists to prevent.

validate_quantization_biases instead enforces both halves of MLX's validate_mode_with_type: affine requires .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 through UnifiedLinear::from_weights / QuantizedEmbedding::from_weights, which derive the mode from bias presence via infer_quantization_mode and 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: new SUPPORTED_QUANTIZATION_MODES, validate_quantization_mode and validate_quantization_biases; called from reconcile_quantization_layout (covering every UnifiedLinear and UnifiedEmbedding, and making the mode == "affine" branch total rather than a silent fallthrough), from UnifiedLinear::from_weights_with_mode and QuantizedEmbedding::from_weights_with_mode for the bias half, and from QuantizedWeight::new_with_mode, which becomes Result (it had no callers).
  • src/models/switch_layers.rs: SwitchLinear::from_stacked_parts bounds the mode next to the existing validate_quantization_params, covering both the pre-stacked and per-expert layouts reached through the pub from_weights_with_mode. Adds the shared HOSTILE_QUANT_MODES table beside HOSTILE_QUANT_PARAMS.
  • src/models/gpt_oss.rs: Quantization::validate stops 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_weights bounds the mode and the bias pairing where it stores them.
  • src/models/config.rs: QuantizationArgs::get_mode returns Result<&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_SCHEMES is now the core constant rather than a private copy, and the mode comparison delegates to the core function. gemma4 keeps its quant_method and 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_mode among the entry points that "never reach reconcile_quantization_layout". It does reach it, through reconcile_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, naming ExpertLinear::from_weights in its place; all five surfaces are covered either way.

Test plan

Real-model runs, since CI runs no cargo test:

  • models/gpt-oss-20b-mxfp4 loads and generates unchanged. The one local checkpoint whose declared mode reaches a kernel: top-level mxfp4 for the 72 expert planes, 98 affine per-prefix overrides, and 24 mode-less overrides that must keep resolving to affine rather than inheriting mxfp4.
  • Synthetic hostile configs (symlink farms in a scratchpad, the checkpoint itself untouched) fail at load with a message naming the four modes: hostile top level with valid overrides (the live gap, since the experts read Quantization::defaults), hostile per-prefix mode with a valid top level, and affine declared over the bias-less expert planes.
  • models/minicpm-v-4.6-mxfp4 (block-float through reconcile_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-4bit and models/qwen3.5-2b-4bit (affine MoE and affine dense) unchanged.
  • cargo fmt --all -- --check
  • cargo clippy --release --all-targets --features metal,accelerate -- -D warnings (the release form; make verify-clippy uses the debug profile, which would cold-build MLX here)
  • cargo test --release --features metal,accelerate --no-fail-fast

Regression tests drive real load paths and assert on the returned Result without 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

`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
@inureyes inureyes added status:review Under review type:bug Bug fixes, error corrections, or issue resolutions priority:medium Medium priority area:models Model architectures, weights, loading, metadata area:core mlxcel-core: MLX FFI, primitives, KV cache, layers labels Aug 2, 2026
@inureyes
inureyes merged commit f4a4dfe into main Aug 2, 2026
5 checks passed
@inureyes
inureyes deleted the fix/issue-973-bound-quantization-mode branch August 2, 2026 16:28
@inureyes inureyes self-assigned this Aug 31, 2026
@inureyes inureyes added status:done Completed and removed status:review Under review labels Sep 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:core mlxcel-core: MLX FFI, primitives, KV cache, layers area:models Model architectures, weights, loading, metadata priority:medium Medium priority status:done Completed type:bug Bug fixes, error corrections, or issue resolutions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(core): bound the declared quantization mode string, not just group_size and bits

1 participant