Repository navigation
fix(models): mamba and mamba2 treat a scales-without-biases embedding as non-quantized - #1025
Conversation
… as non-quantized Both families gated the quantized embedding branch on `.scales` AND `.biases` being present. Affine quantization stores zero-point biases; the block-float modes (mxfp4, nvfp4, mxfp8) carry none by design, which is exactly what `infer_quantization_mode` keys on. So a block-float embedding export fell to the else branch and built a plain `Embedding` over the raw packed uint32 table. `Embedding::forward` is a bare `take` with no dequantization, so the lookup returns a uint32 array whose last axis is the packed width rather than `hidden_size`. Nothing rejected it at load; it surfaced later as garbage hidden states or as an uncatchable `std::terminate` crossing the cxx bridge at the first forward pass. Latent today: no mamba or mamba2 checkpoint in the recommended set ships a block-float embedding. Live the moment one appears, and it would fail as wrong output rather than as a load error. Both files now resolve the embedding prefix (`backbone.embeddings` or `model.embed_tokens`, one `contains_key` probe) and hand the whole map to `UnifiedEmbedding::from_weights`, which gates on `.scales` alone and infers the mode from bias presence. This supersedes PR #972's decision to keep the hand-built layer. Two of that PR's four reasons do not hold: the `remove` maintains no invariant, since the same function already loads in_proj / x_proj / dt_proj / out_proj / lm_head through `UnifiedLinear::from_weights(&weights, ...)`, which copies and leaves the keys in the map, and there is no leftover-weight check anywhere in the load path; and "mamba requires both planes" is a restatement of the bug. The other two are real and accepted: one lazy `ffi::copy` of the embedding table, which every other model already pays, and storing the reconciled rather than the declared pair, which is a no-op with no warning for a uniform-quant checkpoint and a strict improvement for a mixed-precision one. Routing through the shared loader also picks up `reconcile_quantization_layout` and therefore `validate_quantization_params`, which is the bound `QuantizedEmbedding::new` was carrying on its own since #958. Both files additionally now run `validate_embedding_table`, the same load-time guard BailingMoe, Gpt2, GptBigCode and GptNeoX already run. It is a NEW rejection for these two families: 2-D table, non-zero rows, `vocab_size <= rows`, and width == `hidden_size` for a non-quantized table (reconstructed as `scales.shape(-1) * group_size` for a quantized one). It is also what gives the regression tests an observable, since `embeddings` is private and a forward pass on a regressed load aborts the test binary instead of failing it. `QuantizedEmbedding::new` had no remaining production caller after this and is removed, so the next family cannot rebuild the same defect by hand-building an affine-only layer. `QuantizedMultiLinear::new` stays: it takes `Option<biases>` and cannot express this bug. The `QuantizedEmbedding` half of `hand_built_quantized_layers_bound_their_declared_params` is retargeted at `QuantizedEmbedding::from_weights` over a synthetic `WeightMap`, the path production now uses. Tests, per family: a block-float load (mxfp4 at packed_in 1, mxfp8 at packed_in 2, both dequantizing to hidden_size 8) must succeed; both prefix spellings must load, and a checkpoint carrying neither must fail naming both; a float table narrower than `hidden_size` must be rejected with a message naming `hidden_size`. The existing float-table case now inserts a genuine [8, 8] table rather than the packed [8, 1] one with its scales stripped, because the new guard correctly rejects the latter. The hostile-pair loop over `HOSTILE_QUANT_PARAMS` is unchanged and still passes. Verified that the block-float tests fail cleanly on regression by temporarily restoring the bias-requiring gate: both new cases failed with "backbone.embeddings.weight is [8, 1] but hidden_size is 8", a load error rather than an abort. Not run: the acceptance criterion comparing `models/falcon-mamba-7b-4bit` output. No mamba checkpoint is present locally. Closes #976
Real-checkpoint verification of the new
|
| checkpoint | config | embeddings.weight |
embeddings.scales |
biases |
|---|---|---|---|---|
mlx-community/falcon-mamba-7b-4bit |
falcon_mamba, vocab 65024, hidden 4096, gs 64, bits 4 |
U32 [65024, 512] |
F16 [65024, 64] |
present |
mlx-community/mamba2-1.3b-4bit |
mamba2, vocab 50288, hidden 2048, gs 64, bits 4 |
U32 [50288, 256] |
F16 [50288, 32] |
present |
mlx-community/mamba2-130m |
mamba2, vocab 50288, hidden 768, no quantization |
F16 [50288, 768] |
absent | absent |
Against the four checks the guard applies:
| checkpoint | 2-D | vocab_size <= rows |
width | packing invariant | verdict |
|---|---|---|---|---|---|
| falcon-mamba-7b-4bit | yes | 65024 <= 65024 | scales.shape(-1) * gs = 64 * 64 = 4096 == hidden_size |
512 * 32 == 4 * 64 * 64 (16384) | loads |
| mamba2-1.3b-4bit | yes | 50288 <= 50288 | 32 * 64 = 2048 == hidden_size | 256 * 32 == 4 * 32 * 64 (8192) | loads |
| mamba2-130m | yes | 50288 <= 50288 | cols 768 == hidden_size (non-quantized branch) | n/a, float table | loads |
All three pass, so the guard adds no rejection for the checkpoints this family is validated against today. It exercises both branches of the guard as well: two affine quantized tables and one float table.
The same arithmetic covers the "loads and generates identically" criterion for the affine path. Both quantized checkpoints satisfy packed_in * 32 == bits * groups * group_size exactly at their declared group_size 64 / bits 4, so reconcile_quantization_layout returns the declared pair unchanged with reconciled = false and emits no warning. The reconciled pair this PR now stores is byte-identical to the declared pair the old hand-built path stored, which is why routing through the shared loader cannot change what these two checkpoints produce.
Still not executed: an actual generation run on real weights. The above is shape-level ground truth, not an output comparison.
Implementation Review SummaryIntentStop Verdict: the PR implements issue #976 correctly and completely. No CRITICAL or HIGH finding, nothing auto-fixed, no commit added. 1. Prefix resolution: dropping the per-tensor
|
Security and performance reviewScope: the 7-file diff at No CRITICAL and no HIGH findings. Nothing auto-fixed; the branch is unchanged. Config-declared integers reaching arithmetic: clean, and strictly stronger than the path it replaces
One property worth recording since two more families now exercise it: the block-float branch of the reconciler derives Panic reachability: clean, at parity with the old pathThe new path is Out-of-bounds gather: closed for these two families, and this is the main security gainConfirmed against the vendored MLX source rather than assumed. Both No bypass exists. Pre-existing and global, stated for completeness rather than as a finding against this PR:
|
… guard The security review on PR #1025 flagged a MEDIUM coverage gap: the three new tests per family covered the block-float regression, both prefix spellings, and the hidden_size width clause of validate_embedding_table, but none of them drove a config whose vocab_size overstates the table's row count. That clause is the one that actually closes the out-of-bounds embedding gather, since MLX's take does not range-check a positive index, and it is also the one place a caller can silently mis-wire the guard by passing the wrong config field into the claimed_rows slot. Add one test per family, mirroring the shape already used by gpt_neox_tests.rs, gpt2_tests.rs, gpt_bigcode_tests.rs and helium_tests.rs for this same clause: a [3, 8] float table under backbone.embeddings.weight with the existing vocab_size 8 config fails the load naming vocab_size, and a [64, 8] padded table still loads. Both reuse the fixtures this PR already hoisted (embedding_config, embedding_test_base_weights) and assert on the load Result only, since a forward pass on a bad load aborts the test binary instead of failing it. Validation: - cargo fmt --all -- --check - cargo check --lib --tests - cargo clippy --lib --tests -- -D warnings - cargo test --lib models::mamba (16 passed, 2 pre-existing ignored) - Confirmed both new tests fail cleanly, rather than abort, when the claimed_rows > rows clause in validate_embedding_table is temporarily disabled; reverted before committing. Refs #976
PR Finalization CompleteSummary
Validation
Commit |
…dates the bias plane (#1030) ## Summary `mlxcel_core::layers::QuantizedMultiLinear` stored no quantization mode and hardcoded `"affine"` at all three of its kernel calls, while `from_weights` took `.biases` as an `Option` and never checked it. A block-float `embed_q` / `unembed_out` plane (`.scales`, no `.biases`, which is what mxfp4 / nvfp4 / mxfp8 ship by design and exactly what `infer_quantization_mode` keys on) therefore satisfied `MultiLinear::from_weights`'s `.scales`-only gate, loaded without complaint, and then handed `quantized_matmul` a null bias pointer under a declared affine mode. MLX's `validate_mode_with_type` throws "Biases must be provided for affine quantization" on precisely that, and the throw crosses the cxx bridge as an uncatchable abort at the first MLA forward rather than a load error. This is the sibling PR #1027 flagged and deliberately left out of scope. It follows that PR's treatment of `kimi_linear`'s private `MultiLinear` (mode inferred from bias presence, `validate_quantization_biases` at load) and the older precedent at `src/models/gpt_oss.rs`. ## What changed - `src/lib/mlxcel-core/src/layers.rs`, `QuantizedMultiLinear`: new `mode: &'static str` field, derived at load with `infer_quantization_mode(biases.is_some(), group_size, bits)` in both constructors, plus a `validate_quantization_biases` call in `from_weights` that prefixes the error with the tensor prefix the way the sibling loaders do. - `forward` and `forward_no_transpose` were two copies of the same body differing only in the `transpose` bool, each with its own hardcoded `"affine"`. They now share one private `quantized_matmul` helper, so the mode reaches both by construction rather than by two edits staying in sync. The issue called out missing one of the two as the obvious way to get this half right; collapsing them is how this PR makes that impossible rather than merely careful. - `dequantize` reads the same field and the same new `biases_ptr()` helper. It is callerless in tree, but MLX applies `validate_mode_with_type` inside `dequantize` too, so leaving one of the three hardcoded would have parked the same abort one call away from where this removes it. That is a small deliberate widening of the issue's stated scope, which named only the two forwards. - Doc updates: the `Used by:` lists on `infer_quantization_mode` and `validate_quantization_biases`, a note on `MultiLinear::from_weights` recording why gating on `.scales` alone is correct and what it needs from the quantized arm, and a paragraph in `docs/adding-models.md` explaining why `QuantizedMultiLinear::new` survives on the "already carries the bound" list when the embedding constructor next to it in the same section does not. ## Decision on `QuantizedMultiLinear::new`: kept, made mode-aware Kept, with the signature unchanged and the mode inferred internally. The removal of `QuantizedEmbedding::new` in PR #1025 was about a signature that forced the defect: it required a `biases` argument and hardcoded `mode: "affine"`, so a block-float caller could not describe its own checkpoint at all and fell to the dense branch over a packed uint32 table. `QuantizedMultiLinear::new` takes `Option<biases>`, and once the mode is derived from that `Option` with the same helper `from_weights` uses, it can describe every plane layout the loader can and cannot store a mode that contradicts them. The removal criterion does not apply to it. The closer in-tree precedent is `QuantizedWeight::new_with_mode` (issue #973): also callerless, also pre-emptive, kept and made fallible so the bound lands on the path the next caller that hand-builds a quantized layer will take. `docs/adding-models.md` also lists `QuantizedMultiLinear::{new, from_weights}` in the table of loaders that already carry the bound, i.e. it is documented API model authors are pointed at, unlike the embedding constructor the same section now explicitly steers people away from. Removing it would additionally have stranded `hand_built_quantized_layers_bound_their_declared_params`, whose embedding half PR #1025 already retargeted at `from_weights`; the remaining half would have collapsed into the existing `multi_linear_loader_bounds_its_declared_params` and the test name would have stopped meaning anything. Worth recording precisely, because it is a correction rather than an agreement: PR #1025's stated reason for keeping it ("it takes `Option<biases>` and so cannot express this bug") was true of the embedding gate defect but not of this one. With `biases: None` and a hardcoded affine forward, `new` could express issue #1028 exactly. Inferring the mode is what makes that sentence true for both defect classes, and the doc comment on `new` now says so instead of repeating the incomplete claim. ## One thing in the issue that is not satisfiable as written Acceptance criterion 2 asks that "a plane whose declared mode contradicts the bias plane it ships fails at load with a message naming the prefix". Nothing declares a mode on this path: `QuantizedMultiLinear::from_weights` takes only `group_size` and `bits`, its four callers thread the top-level `quantization` block into exactly those two, and this PR infers the mode rather than adding a declared-mode parameter, for the same reason PR #1027 recorded for `kimi_linear` (every other layer in these models infers through the shared loaders, so reading a declaration here alone would make one projection disagree with the rest of the model). With the mode inferred from `biases.is_some()`, the `validate_quantization_biases` call one line later is consistent by construction and cannot fire on any input. It is not decorative, and this is stronger than the equivalent note in PR #1027. Regressing the inference back to a literal `"affine"` makes the guard fire and turns the block-float load into a load error naming the prefix, which is what the new tests actually observed when the regression was applied. So the check converts a future break of the coupling, including a declared-mode or per-prefix-override caller, into a load error at the point the mode is stored instead of an abort inside `quantized_matmul`. The tests assert that pairing rather than pretending a contradiction is reachable through the public loader today. ## Out of scope, deliberately - No reconciliation against tensor shapes. The type's doc already records that it stores the declared pair verbatim and never calls `reconcile_quantization_layout`; that is a separate scoped gap and this PR does not touch it. - `src/models/glm4_moe_lite.rs` is untouched. Its missing MLA sanitizer is issue #1029. ## Test plan New tests in the `layers.rs` inline `mod tests`: - `multi_linear_loader_infers_its_quantization_mode_from_the_biases_plane`: pins the stored mode across affine, mxfp4, nvfp4 and mxfp8 planes, each with the packed width and group count its `group_size` / `bits` pair actually implies, and pins it on the `MultiLinear` enum path the four families call as well as on the inner loader. Keeps a dense plane as a negative control so a change that treats everything as quantized cannot pass. - `multi_linear_loader_cannot_store_a_mode_that_contradicts_its_planes`: loads six real export pairs with and without a bias plane and asserts the stored `(mode, biases)` pair always satisfies MLX's own precondition, then asserts the guard rejects both directions of the contradiction, then asserts the hand-built constructor derives the mode identically so it is not the one remaining way to build a contradicting layer. - The existing `hand_built_quantized_layers_bound_their_declared_params` gains a block-float positive control on `new` and pins the mode on both halves. The existing `multi_linear_loader_bounds_its_declared_params` is unchanged in behavior; its inline fixture was hoisted into the shared `mla_plane` / `mla_quantized_weights` helpers the new tests use, so all three drive the same geometry. Every assertion is on the load `Result` or on a stored field. No test runs a forward pass, because a forward on a bad load aborts the test binary instead of failing the test. Failure was confirmed rather than assumed: temporarily replacing the `infer_quantization_mode` call in `from_weights` with a literal `"affine"` made both new tests fail (the mode assertion in one, the load `Result` in the other, the latter because the `validate_quantization_biases` guard fired). The temporary change was reverted before committing and the restored line verified by grep. Commands actually run on this branch, all green: - [x] `cargo fmt --all -- --check` - [x] `cargo check -p mlxcel-core --lib --tests` - [x] `cargo check --lib --tests` - [x] `cargo clippy -p mlxcel-core --lib --tests -- -D warnings`, exit 0 - [x] `cargo clippy --lib --tests -- -D warnings`, exit 0 - [x] `cargo test -p mlxcel-core --lib layers::tests` (74 passed, up from 72) - [x] `cargo test --lib models::deepseek_v3::` (14 passed) - [x] `cargo test --lib models::deepseek_v32` (14 passed) - [x] `cargo test --lib models::longcat_flash_ngram` (2 passed) - [x] `cargo test --lib models::glm4_moe_lite` (1 passed) - [x] `cargo doc -p mlxcel-core --no-deps --lib`, checked only to confirm the new intra-doc links resolve. The single unresolved link in `layers.rs` is at line 4244, outside every hunk in this diff, and the crate's 102 doc warnings are pre-existing. Not run here: `cargo clippy --workspace --all-targets -- -D warnings` and the full test suite, both left to the orchestrator. No MLA checkpoint is present on this machine, so the affine positive path was confirmed only against synthetic fixtures, not against real weights, and no block-float MLA export exists to confirm the new path end to end. Closes #1028
… as non-quantized (lablup#1025) ## Summary `MambaModel::from_weights` and `Mamba2Model::from_weights` decided whether the embedding table was quantized by requiring both `.scales` and `.biases`. Affine quantization stores zero-point biases; the block-float modes (mxfp4, nvfp4, mxfp8) carry none by design, which is exactly what `infer_quantization_mode` keys on. A block-float embedding export therefore fell to the else branch and built a plain `Embedding` over the raw packed uint32 table. `Embedding::forward` is a bare `mlx::core::take` with no dequantization, so the lookup returned a uint32 array whose last axis was the packed width (`hidden_size * bits / 32`) rather than `hidden_size`. Nothing rejected it at load: the model reported as loaded and the failure surfaced later as garbage hidden states, or as an MLX C++ throw crossing the cxx bridge as an uncatchable `std::terminate` at the first forward pass. The defect is latent today because no mamba or mamba2 checkpoint in the recommended set ships a block-float embedding. It goes live the moment one appears, and it would present as wrong output rather than as a load error, which is the expensive way to find it. Both files now resolve the embedding prefix (`backbone.embeddings` or `model.embed_tokens`, one `contains_key` probe) and hand the whole map to `UnifiedEmbedding::from_weights`, which gates on `.scales` alone and infers the mode from bias presence. ## This supersedes PR lablup#972 lablup#972, merged for issue lablup#958, declined to route mamba through the shared loader and instead made the hand-built `QuantizedEmbedding::new` fallible. Two of its four stated reasons do not hold: - "takes the table out of the map by `remove`, so it cannot address a single-prefix map loader": the prefix is resolvable at the call site with one `contains_key`, and the `remove` maintains no invariant. The same function already loads in_proj / x_proj / dt_proj / out_proj / lm_head through `UnifiedLinear::from_weights(&weights, ...)`, which copies and leaves the keys in the map, and there is no leftover-weight check anywhere in the load path. - "treats `.biases` as optional where mamba requires both": that requirement is the bug this PR fixes, so the reason is circular. The other two reasons are real, and this PR accepts them as costs: - Routing costs one lazy `ffi::copy` (`mlx::core::copy`) of the embedding table instead of moving it out of the map. That is a lazy-array node share rather than an element copy, and it is what every other model already pays. - The loader stores the reconciled pair rather than the declared one. For a uniform-quant checkpoint reconciliation is a no-op and emits no warning; for a mixed-precision one it is a strict improvement, since the declared top-level pair is the wrong one for an embedding quantized at a different bit width. Routing through the shared loader also picks up `reconcile_quantization_layout` and therefore `validate_quantization_params`, which is the bound `QuantizedEmbedding::new` was carrying on its own since lablup#958. ## New load-time rejection for these two families Both files now call `validate_embedding_table` (`src/models/gpt2.rs`) right after the embedding loads. This is a NEW rejection for mamba and mamba2: it requires a 2-D table with non-zero rows, `vocab_size <= rows`, and, for a non-quantized table, a width equal to `hidden_size` (for a quantized table it reconstructs the dequantized width as `scales.shape(-1) * group_size` through `validate_quantized_packing`). It is the same guard BailingMoe, Gpt2, GptBigCode and GptNeoX already run, so this is not a new policy, only a new caller. It is also what gives the regression tests an observable. `MambaModel::embeddings` is private and the test module is a sibling rather than a descendant, so a test cannot inspect the variant, and a forward pass on a regressed load aborts the test binary instead of failing it. With the guard in place the assertion is simply that the block-float load succeeds, because the regressed path (a `Regular` embedding over the packed table) now fails at load. ## `QuantizedEmbedding::new` removed After this change it has zero production callers across all four workspace members (verified by grep). It is removed, so the next family cannot rebuild the same defect by hand-building an affine-only layer that requires a `biases` argument. `QuantizedMultiLinear::new` stays: it is pre-emptive and also callerless, but it takes `Option<biases>` and so cannot express this bug. ## What changed - `src/models/mamba.rs`, `src/models/mamba2.rs`: prefix resolution plus `UnifiedEmbedding::from_weights`, then `validate_embedding_table`. The missing-embedding error still names both spellings rather than regressing to the loader's single-prefix "Weight not found". The stale comment describing the `remove` chain is replaced with one that says why the gate is `.scales`-only. - `src/lib/mlxcel-core/src/layers.rs`: `QuantizedEmbedding::new` removed, with the reason recorded on `from_weights`; the three doc references to it retargeted; the `QuantizedEmbedding` half of `hand_built_quantized_layers_bound_their_declared_params` retargeted at `QuantizedEmbedding::from_weights` over a synthetic `WeightMap`, keeping the `QuantizedMultiLinear::new` half intact. - `src/models/mamba_tests.rs`, `src/models/mamba2_tests.rs`: the existing fixture closures hoisted into shared helpers, the existing test's doc comment corrected, and three new tests per family (see below). - `src/models/gpt2.rs`, `src/lib/mlxcel-core/src/layers.rs`: "Used by" lists updated to include Mamba and Mamba2. - `docs/adding-models.md`: `QuantizedEmbedding::new` removed from the table of loaders that already carry the quant bound, with a note explaining why hand-building an embedding is no longer the answer for a multi-spelling prefix. ## Tests Per family: - The block-float regression (`*_loads_a_block_float_embedding_as_quantized`): mxfp4 at `packed_in` 1 and mxfp8 at `packed_in` 2, both dequantizing to `hidden_size` 8, `.scales` present and `.biases` absent, load must succeed. nvfp4 is not covered because group_size 16 cannot describe a width of 8 with a non-zero number of groups, and inventing a second `hidden_size` for it would have been dishonest geometry. - Both prefix spellings (`*_resolves_both_embedding_prefix_spellings`): affine and block-float both load under `model.embed_tokens.*`, and a checkpoint carrying neither spelling fails with a message naming both. - The guard itself (`*_rejects_an_embedding_table_narrower_than_hidden_size`): a float table whose width is not `hidden_size` errors at load with a message naming `hidden_size`. - The existing float-table case in `*_rejects_quantization_params_that_would_abort_the_embedding_lookup` now inserts a genuine [8, 8] float table instead of reusing the packed [8, 1] one with its scales stripped, because the new guard correctly rejects the latter. The guard was not weakened to accommodate the old fixture. - The hostile-pair loop over `switch_layers::HOSTILE_QUANT_PARAMS` is unchanged and still passes: with `.biases` present the inferred mode is affine, and `reconcile_quantization_layout` calls `validate_quantization_params` before it looks at any shape. The block-float tests were confirmed to fail on regression by temporarily restoring the bias-requiring gate in `mamba.rs`: both new cases failed with `backbone.embeddings.weight is [8, 1] but hidden_size is 8`, which is a load error rather than an abort. The temporary change was reverted before committing. ## Test plan Commands actually run, all green: - [x] `cargo fmt --check` - [x] `cargo clippy --lib --tests -- -D warnings` - [x] `cargo clippy -p mlxcel-core --lib --tests -- -D warnings` - [x] `cargo check --lib --tests` - [x] `cargo check -p mlxcel-core --lib --tests` - [x] `cargo test --lib models::mamba` (14 passed, 2 ignored; the ignored two are the pre-existing `requires serial MLX execution` conv-state tests) - [x] `cargo test -p mlxcel-core --lib layers::tests` (72 passed), including `hand_built_quantized_layers_bound_their_declared_params` - [x] `cargo test --lib models::gpt2::tests` (27 passed), since the shared guard gained callers Orchestrator-side, after the above: - [x] `cargo clippy --workspace --all-targets -- -D warnings` (all four workspace members, every target), exit 0 - [x] `cargo fmt --all -- --check`, the exact CI gate - [x] Real-checkpoint shape verification of the new guard against `falcon-mamba-7b-4bit`, `mamba2-1.3b-4bit` and `mamba2-130m`, see the comment below Pre-existing and unrelated: a bare `cargo test --workspace --lib` on a Linux/CUDA box built without `--features cuda` aborts the root lib test binary with `terminate called ... [cuda_kernel] No CUDA back-end` (SIGABRT) after roughly 1926 tests. This was confirmed to reproduce identically on `main` at e1ea5c2 with the same warm target directory (1927 tests, same abort), so it is a property of this build configuration rather than of this branch. Every candidate test in the in-flight window passes when run individually. CI does not use this configuration. Not run: the acceptance criterion comparing `models/falcon-mamba-7b-4bit` output before and after. No mamba or mamba2 checkpoint is present under `models/` on this machine, so the affine path was not confirmed against real weights. The affine path is covered by the synthetic positive control in the existing guard test, which is weaker evidence. `cargo clippy --all-targets -- -D warnings` and the full workspace test suite were also not run here; only `--lib --tests` was, plus the narrow test filters above. Closes lablup#976
…dates the bias plane (lablup#1030) ## Summary `mlxcel_core::layers::QuantizedMultiLinear` stored no quantization mode and hardcoded `"affine"` at all three of its kernel calls, while `from_weights` took `.biases` as an `Option` and never checked it. A block-float `embed_q` / `unembed_out` plane (`.scales`, no `.biases`, which is what mxfp4 / nvfp4 / mxfp8 ship by design and exactly what `infer_quantization_mode` keys on) therefore satisfied `MultiLinear::from_weights`'s `.scales`-only gate, loaded without complaint, and then handed `quantized_matmul` a null bias pointer under a declared affine mode. MLX's `validate_mode_with_type` throws "Biases must be provided for affine quantization" on precisely that, and the throw crosses the cxx bridge as an uncatchable abort at the first MLA forward rather than a load error. This is the sibling PR lablup#1027 flagged and deliberately left out of scope. It follows that PR's treatment of `kimi_linear`'s private `MultiLinear` (mode inferred from bias presence, `validate_quantization_biases` at load) and the older precedent at `src/models/gpt_oss.rs`. ## What changed - `src/lib/mlxcel-core/src/layers.rs`, `QuantizedMultiLinear`: new `mode: &'static str` field, derived at load with `infer_quantization_mode(biases.is_some(), group_size, bits)` in both constructors, plus a `validate_quantization_biases` call in `from_weights` that prefixes the error with the tensor prefix the way the sibling loaders do. - `forward` and `forward_no_transpose` were two copies of the same body differing only in the `transpose` bool, each with its own hardcoded `"affine"`. They now share one private `quantized_matmul` helper, so the mode reaches both by construction rather than by two edits staying in sync. The issue called out missing one of the two as the obvious way to get this half right; collapsing them is how this PR makes that impossible rather than merely careful. - `dequantize` reads the same field and the same new `biases_ptr()` helper. It is callerless in tree, but MLX applies `validate_mode_with_type` inside `dequantize` too, so leaving one of the three hardcoded would have parked the same abort one call away from where this removes it. That is a small deliberate widening of the issue's stated scope, which named only the two forwards. - Doc updates: the `Used by:` lists on `infer_quantization_mode` and `validate_quantization_biases`, a note on `MultiLinear::from_weights` recording why gating on `.scales` alone is correct and what it needs from the quantized arm, and a paragraph in `docs/adding-models.md` explaining why `QuantizedMultiLinear::new` survives on the "already carries the bound" list when the embedding constructor next to it in the same section does not. ## Decision on `QuantizedMultiLinear::new`: kept, made mode-aware Kept, with the signature unchanged and the mode inferred internally. The removal of `QuantizedEmbedding::new` in PR lablup#1025 was about a signature that forced the defect: it required a `biases` argument and hardcoded `mode: "affine"`, so a block-float caller could not describe its own checkpoint at all and fell to the dense branch over a packed uint32 table. `QuantizedMultiLinear::new` takes `Option<biases>`, and once the mode is derived from that `Option` with the same helper `from_weights` uses, it can describe every plane layout the loader can and cannot store a mode that contradicts them. The removal criterion does not apply to it. The closer in-tree precedent is `QuantizedWeight::new_with_mode` (issue lablup#973): also callerless, also pre-emptive, kept and made fallible so the bound lands on the path the next caller that hand-builds a quantized layer will take. `docs/adding-models.md` also lists `QuantizedMultiLinear::{new, from_weights}` in the table of loaders that already carry the bound, i.e. it is documented API model authors are pointed at, unlike the embedding constructor the same section now explicitly steers people away from. Removing it would additionally have stranded `hand_built_quantized_layers_bound_their_declared_params`, whose embedding half PR lablup#1025 already retargeted at `from_weights`; the remaining half would have collapsed into the existing `multi_linear_loader_bounds_its_declared_params` and the test name would have stopped meaning anything. Worth recording precisely, because it is a correction rather than an agreement: PR lablup#1025's stated reason for keeping it ("it takes `Option<biases>` and so cannot express this bug") was true of the embedding gate defect but not of this one. With `biases: None` and a hardcoded affine forward, `new` could express issue lablup#1028 exactly. Inferring the mode is what makes that sentence true for both defect classes, and the doc comment on `new` now says so instead of repeating the incomplete claim. ## One thing in the issue that is not satisfiable as written Acceptance criterion 2 asks that "a plane whose declared mode contradicts the bias plane it ships fails at load with a message naming the prefix". Nothing declares a mode on this path: `QuantizedMultiLinear::from_weights` takes only `group_size` and `bits`, its four callers thread the top-level `quantization` block into exactly those two, and this PR infers the mode rather than adding a declared-mode parameter, for the same reason PR lablup#1027 recorded for `kimi_linear` (every other layer in these models infers through the shared loaders, so reading a declaration here alone would make one projection disagree with the rest of the model). With the mode inferred from `biases.is_some()`, the `validate_quantization_biases` call one line later is consistent by construction and cannot fire on any input. It is not decorative, and this is stronger than the equivalent note in PR lablup#1027. Regressing the inference back to a literal `"affine"` makes the guard fire and turns the block-float load into a load error naming the prefix, which is what the new tests actually observed when the regression was applied. So the check converts a future break of the coupling, including a declared-mode or per-prefix-override caller, into a load error at the point the mode is stored instead of an abort inside `quantized_matmul`. The tests assert that pairing rather than pretending a contradiction is reachable through the public loader today. ## Out of scope, deliberately - No reconciliation against tensor shapes. The type's doc already records that it stores the declared pair verbatim and never calls `reconcile_quantization_layout`; that is a separate scoped gap and this PR does not touch it. - `src/models/glm4_moe_lite.rs` is untouched. Its missing MLA sanitizer is issue lablup#1029. ## Test plan New tests in the `layers.rs` inline `mod tests`: - `multi_linear_loader_infers_its_quantization_mode_from_the_biases_plane`: pins the stored mode across affine, mxfp4, nvfp4 and mxfp8 planes, each with the packed width and group count its `group_size` / `bits` pair actually implies, and pins it on the `MultiLinear` enum path the four families call as well as on the inner loader. Keeps a dense plane as a negative control so a change that treats everything as quantized cannot pass. - `multi_linear_loader_cannot_store_a_mode_that_contradicts_its_planes`: loads six real export pairs with and without a bias plane and asserts the stored `(mode, biases)` pair always satisfies MLX's own precondition, then asserts the guard rejects both directions of the contradiction, then asserts the hand-built constructor derives the mode identically so it is not the one remaining way to build a contradicting layer. - The existing `hand_built_quantized_layers_bound_their_declared_params` gains a block-float positive control on `new` and pins the mode on both halves. The existing `multi_linear_loader_bounds_its_declared_params` is unchanged in behavior; its inline fixture was hoisted into the shared `mla_plane` / `mla_quantized_weights` helpers the new tests use, so all three drive the same geometry. Every assertion is on the load `Result` or on a stored field. No test runs a forward pass, because a forward on a bad load aborts the test binary instead of failing the test. Failure was confirmed rather than assumed: temporarily replacing the `infer_quantization_mode` call in `from_weights` with a literal `"affine"` made both new tests fail (the mode assertion in one, the load `Result` in the other, the latter because the `validate_quantization_biases` guard fired). The temporary change was reverted before committing and the restored line verified by grep. Commands actually run on this branch, all green: - [x] `cargo fmt --all -- --check` - [x] `cargo check -p mlxcel-core --lib --tests` - [x] `cargo check --lib --tests` - [x] `cargo clippy -p mlxcel-core --lib --tests -- -D warnings`, exit 0 - [x] `cargo clippy --lib --tests -- -D warnings`, exit 0 - [x] `cargo test -p mlxcel-core --lib layers::tests` (74 passed, up from 72) - [x] `cargo test --lib models::deepseek_v3::` (14 passed) - [x] `cargo test --lib models::deepseek_v32` (14 passed) - [x] `cargo test --lib models::longcat_flash_ngram` (2 passed) - [x] `cargo test --lib models::glm4_moe_lite` (1 passed) - [x] `cargo doc -p mlxcel-core --no-deps --lib`, checked only to confirm the new intra-doc links resolve. The single unresolved link in `layers.rs` is at line 4244, outside every hunk in this diff, and the crate's 102 doc warnings are pre-existing. Not run here: `cargo clippy --workspace --all-targets -- -D warnings` and the full test suite, both left to the orchestrator. No MLA checkpoint is present on this machine, so the affine positive path was confirmed only against synthetic fixtures, not against real weights, and no block-float MLA export exists to confirm the new path end to end. Closes lablup#1028
Summary
MambaModel::from_weightsandMamba2Model::from_weightsdecided whether the embedding table was quantized by requiring both.scalesand.biases. Affine quantization stores zero-point biases; the block-float modes (mxfp4, nvfp4, mxfp8) carry none by design, which is exactly whatinfer_quantization_modekeys on. A block-float embedding export therefore fell to the else branch and built a plainEmbeddingover the raw packed uint32 table.Embedding::forwardis a baremlx::core::takewith no dequantization, so the lookup returned a uint32 array whose last axis was the packed width (hidden_size * bits / 32) rather thanhidden_size. Nothing rejected it at load: the model reported as loaded and the failure surfaced later as garbage hidden states, or as an MLX C++ throw crossing the cxx bridge as an uncatchablestd::terminateat the first forward pass.The defect is latent today because no mamba or mamba2 checkpoint in the recommended set ships a block-float embedding. It goes live the moment one appears, and it would present as wrong output rather than as a load error, which is the expensive way to find it.
Both files now resolve the embedding prefix (
backbone.embeddingsormodel.embed_tokens, onecontains_keyprobe) and hand the whole map toUnifiedEmbedding::from_weights, which gates on.scalesalone and infers the mode from bias presence.This supersedes PR #972
#972, merged for issue #958, declined to route mamba through the shared loader and instead made the hand-built
QuantizedEmbedding::newfallible. Two of its four stated reasons do not hold:remove, so it cannot address a single-prefix map loader": the prefix is resolvable at the call site with onecontains_key, and theremovemaintains no invariant. The same function already loads in_proj / x_proj / dt_proj / out_proj / lm_head throughUnifiedLinear::from_weights(&weights, ...), which copies and leaves the keys in the map, and there is no leftover-weight check anywhere in the load path..biasesas optional where mamba requires both": that requirement is the bug this PR fixes, so the reason is circular.The other two reasons are real, and this PR accepts them as costs:
ffi::copy(mlx::core::copy) of the embedding table instead of moving it out of the map. That is a lazy-array node share rather than an element copy, and it is what every other model already pays.Routing through the shared loader also picks up
reconcile_quantization_layoutand thereforevalidate_quantization_params, which is the boundQuantizedEmbedding::newwas carrying on its own since #958.New load-time rejection for these two families
Both files now call
validate_embedding_table(src/models/gpt2.rs) right after the embedding loads. This is a NEW rejection for mamba and mamba2: it requires a 2-D table with non-zero rows,vocab_size <= rows, and, for a non-quantized table, a width equal tohidden_size(for a quantized table it reconstructs the dequantized width asscales.shape(-1) * group_sizethroughvalidate_quantized_packing). It is the same guard BailingMoe, Gpt2, GptBigCode and GptNeoX already run, so this is not a new policy, only a new caller.It is also what gives the regression tests an observable.
MambaModel::embeddingsis private and the test module is a sibling rather than a descendant, so a test cannot inspect the variant, and a forward pass on a regressed load aborts the test binary instead of failing it. With the guard in place the assertion is simply that the block-float load succeeds, because the regressed path (aRegularembedding over the packed table) now fails at load.QuantizedEmbedding::newremovedAfter this change it has zero production callers across all four workspace members (verified by grep). It is removed, so the next family cannot rebuild the same defect by hand-building an affine-only layer that requires a
biasesargument.QuantizedMultiLinear::newstays: it is pre-emptive and also callerless, but it takesOption<biases>and so cannot express this bug.What changed
src/models/mamba.rs,src/models/mamba2.rs: prefix resolution plusUnifiedEmbedding::from_weights, thenvalidate_embedding_table. The missing-embedding error still names both spellings rather than regressing to the loader's single-prefix "Weight not found". The stale comment describing theremovechain is replaced with one that says why the gate is.scales-only.src/lib/mlxcel-core/src/layers.rs:QuantizedEmbedding::newremoved, with the reason recorded onfrom_weights; the three doc references to it retargeted; theQuantizedEmbeddinghalf ofhand_built_quantized_layers_bound_their_declared_paramsretargeted atQuantizedEmbedding::from_weightsover a syntheticWeightMap, keeping theQuantizedMultiLinear::newhalf intact.src/models/mamba_tests.rs,src/models/mamba2_tests.rs: the existing fixture closures hoisted into shared helpers, the existing test's doc comment corrected, and three new tests per family (see below).src/models/gpt2.rs,src/lib/mlxcel-core/src/layers.rs: "Used by" lists updated to include Mamba and Mamba2.docs/adding-models.md:QuantizedEmbedding::newremoved from the table of loaders that already carry the quant bound, with a note explaining why hand-building an embedding is no longer the answer for a multi-spelling prefix.Tests
Per family:
*_loads_a_block_float_embedding_as_quantized): mxfp4 atpacked_in1 and mxfp8 atpacked_in2, both dequantizing tohidden_size8,.scalespresent and.biasesabsent, load must succeed. nvfp4 is not covered because group_size 16 cannot describe a width of 8 with a non-zero number of groups, and inventing a secondhidden_sizefor it would have been dishonest geometry.*_resolves_both_embedding_prefix_spellings): affine and block-float both load undermodel.embed_tokens.*, and a checkpoint carrying neither spelling fails with a message naming both.*_rejects_an_embedding_table_narrower_than_hidden_size): a float table whose width is nothidden_sizeerrors at load with a message naminghidden_size.*_rejects_quantization_params_that_would_abort_the_embedding_lookupnow inserts a genuine [8, 8] float table instead of reusing the packed [8, 1] one with its scales stripped, because the new guard correctly rejects the latter. The guard was not weakened to accommodate the old fixture.switch_layers::HOSTILE_QUANT_PARAMSis unchanged and still passes: with.biasespresent the inferred mode is affine, andreconcile_quantization_layoutcallsvalidate_quantization_paramsbefore it looks at any shape.The block-float tests were confirmed to fail on regression by temporarily restoring the bias-requiring gate in
mamba.rs: both new cases failed withbackbone.embeddings.weight is [8, 1] but hidden_size is 8, which is a load error rather than an abort. The temporary change was reverted before committing.Test plan
Commands actually run, all green:
cargo fmt --checkcargo clippy --lib --tests -- -D warningscargo clippy -p mlxcel-core --lib --tests -- -D warningscargo check --lib --testscargo check -p mlxcel-core --lib --testscargo test --lib models::mamba(14 passed, 2 ignored; the ignored two are the pre-existingrequires serial MLX executionconv-state tests)cargo test -p mlxcel-core --lib layers::tests(72 passed), includinghand_built_quantized_layers_bound_their_declared_paramscargo test --lib models::gpt2::tests(27 passed), since the shared guard gained callersOrchestrator-side, after the above:
cargo clippy --workspace --all-targets -- -D warnings(all four workspace members, every target), exit 0cargo fmt --all -- --check, the exact CI gatefalcon-mamba-7b-4bit,mamba2-1.3b-4bitandmamba2-130m, see the comment belowPre-existing and unrelated: a bare
cargo test --workspace --libon a Linux/CUDA box built without--features cudaaborts the root lib test binary withterminate called ... [cuda_kernel] No CUDA back-end(SIGABRT) after roughly 1926 tests. This was confirmed to reproduce identically onmainat e1ea5c2 with the same warm target directory (1927 tests, same abort), so it is a property of this build configuration rather than of this branch. Every candidate test in the in-flight window passes when run individually. CI does not use this configuration.Not run: the acceptance criterion comparing
models/falcon-mamba-7b-4bitoutput before and after. No mamba or mamba2 checkpoint is present undermodels/on this machine, so the affine path was not confirmed against real weights. The affine path is covered by the synthetic positive control in the existing guard test, which is weaker evidence.cargo clippy --all-targets -- -D warningsand the full workspace test suite were also not run here; only--lib --testswas, plus the narrow test filters above.Closes #976