Repository navigation
fix(models): read the declared quantization block in deepseek::ModelArgs - #1016
Merged
Merged
Conversation
`deepseek::ModelArgs` declared only flat top-level `group_size` / `bits` keys and no nested `quantization` field, so serde silently discarded the MLX-style `"quantization": {"group_size": N, "bits": M, "mode": ...}` block that every conversion tool emits. `group_size()` / `bits()` then returned the hardcoded 64 / 4 no matter what the checkpoint declared. The three DeepSeek-OCR loaders in `src/loading/vlm_deepseekocr.rs` inherit the root block into `language_config` before deserializing into that struct, so all three inherited nothing, and `DeepSeekModel::load` dropped the block the same way for a plain `deepseek` config.json.
Neither half of the consequence surfaces as a load error. The dense projections, the embedding and the OCR projector go through `reconcile_quantization_layout`, which trusts the group size and re-derives the bit width. The MoE expert planes never reach the reconciler: `SwitchLinear::from_stacked_parts` stores the pair verbatim and hands it to `gather_qmm`, which crosses the cxx bridge as `UniquePtr<MlxArray>` rather than `Result`, so a mismatch is an uncatchable abort at the first routed forward pass. Reading the declared block fixes both at the root.
The nested field adopts the shared `models::config::QuantizationArgs` rather than adding another private `Quantization` copy alongside the 59 already under `src/models/`, which is what let `deepseek.rs` and `deepseek_v2.rs` diverge in the first place. Its three optional fields mean a partially declared block resolves per key instead of failing to deserialize, and its `get_mode()` validates against `SUPPORTED_QUANTIZATION_MODES` at the point the value is read, so `DeepSeekModel::from_weights` can now refuse a mode this affine-only family would otherwise reinterpret. The flat keys stay as a fallback, so any checkpoint spelling them keeps loading. The triplicated `language_config` fixup collapses into one `deepseekocr_language_args` helper, which gives all three loaders a single tested funnel.
Because a declared pair now actually reaches `gather_qmm` for the first time, `switch_layers::validate_expert_quantization_shapes` cross-checks it against the plane it is being stored on. This is MLX's own `packed_in * 32 == bits * num_groups * group_size` precondition restated where the shapes are still in hand, so it can only refuse a load that was already going to abort. On a checkpoint whose config declares 8-bit for genuinely 4-bit expert tensors, that turns a SIGABRT inside the kernel into a load error naming the plane and the arithmetic.
Validated on real checkpoints: `models/deepseek-ocr-4bit` and `models/deepseek-ocr-2-4bit` both declare exactly the 64 / 4 fallback, so their OCR output is byte-identical before and after, and `models/deepseek-vl2-small-4bit` (which loads through `deepseek_v2`) is unchanged. Symlinked copies of `deepseek-ocr-4bit` with an edited config demonstrate arrival: a declared `mxfp4` mode and a declared `(64, 8)` pair are both inert before and both refused at load after.
Closes #975. Follow-up to #958 (PR #972), which bounded the values the expert loaders store; this is the adjacent problem that the values reaching them were not the ones the checkpoint declared. Uses the mode allowlist added by #973.
7 of 10 tasks
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
deepseek::ModelArgsdeclared only flat top-levelgroup_size/bitskeys and had no nestedquantizationfield, so serde silently discarded the MLX-style"quantization": {"group_size": N, "bits": M, "mode": ...}block that every conversion tool emits.group_size()/bits()returned the hardcoded64/4regardless of what the checkpoint declared. The three DeepSeek-OCR loaders insrc/loading/vlm_deepseekocr.rscopy the root block intolanguage_configbefore deserializing into that struct, so the inheritance ran, succeeded, and accomplished nothing on all three;DeepSeekModel::loaddropped the block the same way for a plaindeepseekconfig.json.The declared pair feeds four places in this family, and they fail differently. The dense projections, the embedding and the OCR projector go through
reconcile_quantization_layout, which trustsgroup_sizeand re-derivesbitsfrom the tensor shapes. The MoE expert planes never reach the reconciler:SwitchLinear::from_stacked_partsstores the pair verbatim and hands it togather_qmm, which crosses the cxx bridge asUniquePtr<MlxArray>rather thanResult, so a mismatch is an uncatchablestd::terminateat the first routed forward pass. Reading the declared block is what closes both at the root.What changed
src/models/deepseek.rsgainspub quantization: Option<QuantizationArgs>.group_size()andbits()resolve nested block, then flat key, then family default, independently per key. A newquantization_mode()returns the declared mode (defaulting to"affine"when no block names one) throughQuantizationArgs::get_mode, which validates againstSUPPORTED_QUANTIZATION_MODES.DeepSeekModel::from_weightscalls it and refuses anything but"affine": every plane this family builds is affine, andfrom_stacked_partstakes its quantized arm only when a.biasesplane is present, so a block-float checkpoint would land on theRegulararm with a packeduint32weight and be reinterpreted rather than refused. This mirrors the guardjamba.rscarries for the same reason.src/models/switch_layers.rsgainsvalidate_expert_quantization_shapes, called fromdeepseek::SwitchLinear::from_stacked_parts. A declared pair now actually reachesgather_qmmfor the first time on this family, andvalidate_expert_quantization_paramsdeliberately stays a range rather than an allowlist (so mixed-precision exports keep loading), so an in-range pair can still describe a different tensor than the one on disk. This restates MLX's ownpacked_in * 32 == bits * num_groups * group_sizeprecondition where the shapes are still in hand, so it can only refuse a load that was already going to abort. The other eighteen families onvalidate_expert_quantization_params'sUsed by:list are left alone to keep this one logical change; they are unaffected either way, since none of them was discarding a declared pair.src/models/config.rs: doc only. The sharedQuantizationArgsis adopted rather than a sixtieth privateQuantizationstruct being added (grep -c '^pub struct Quantization\b'oversrc/models/gives 59, and the divergence between thedeepseek.rsanddeepseek_v2.rscopies is what produced this bug).get_mode's "no production callers yet" note is now false and is replaced with aUsed by:line, and the struct doc records why it is the type to reach for.src/loading/vlm_deepseekocr.rs: the triplicatedlanguage_configfixup collapses into onedeepseekocr_language_args(full_config, family)helper used by all three loaders, and the Unlimited-OCR ring-cache window moves intounlimited_ocr_sliding_windowso it still reads the untouchedfull_config. One funnel makes the inheritance testable without a checkpoint on disk and keeps the three from drifting apart again.src/loading/vlm_deepseekocr_tests.rsdrives the helper once per loader label (inherited block honoured, alanguage_config-declared block still winning over the root, the in-tree checkpoints' declaration resolving to the previous value, no declaration keeping the defaults, a partial block resolving per key, an unparseable inherited mode refused,model_typesupplied, sliding-window fallback and clamp).src/models/deepseek_tests.rsadds a nested-spelling run of the expert-plane guard and a shape-mismatch case, so the MoE half is covered at the loader that stores the pair, plus the plain-config.jsonnested read, the flat-spelling fallback, precedence, per-key fallback and the mode allowlist. 21 tests in these two modules.Validation
Real checkpoints,
--temp 0, one 1400x460 text image,main(39e2ceb60) versus this branch.models/deepseek-ocr-4bit{"group_size": 64, "bits": 4, "mode": "affine"}models/deepseek-ocr-2-4bit{"group_size": 64, "bits": 4, "mode": "affine"}models/deepseek-vl2-small-4bit{"group_size": 64, "bits": 4}deepseek_v2, which already had the field)Both DeepSeek-OCR checkpoints declare exactly the fallback pair, so the bug was latent rather than live for them and no output can change. To show the declared block now arrives, three symlinked copies of
deepseek-ocr-4bitwith onlyconfig.jsonedited (weights symlinked, nothing undermodels/written):"mode": "mxfp4"{"group_size": 64, "bits": 8}weight [64, 896, 160]withscales [64, 896, 20],5120against10240){"group_size": 32, "bits": 8}quantized_matmulrejects it on the dense pathThe middle row is the one the new shape guard converts: without it, that config reached
gather_qmmand killed the process with SIGABRT. The last row still aborts, and honestly so: the pair is self-consistent underreconcile_quantization_layout's own arithmetic, and what rejects it is MLX comparing the expanded width against the activation width, which has no load-time equivalent because the activation is not in hand at load. That is the pre-existing #958 class, not something reading the block can close, and a candidate follow-up.Notes
(64, 2)pair that MLX accepts and compute wrong numbers. It is self-consistent by the reconciler's arithmetic, but the expanded input width it implies no longer matches the activation width, and MLX'squantized_matmulthrows on exactly that. On these checkpoints the observed consequence of a wrong pair is an abort, not silent corruption, on both halves. That does not change the fix (the values still have to be read) but it is worth recording, because it means the defect's practical blast radius was "aborts on anything but a 64 / 4 export", not "quietly degrades".deepseek_v2prefill causal: a masking defect in a different module, not config plumbing. Nothing here touchesdeepseek_v2.rs, andmodels/deepseek-vl2-small-4bitis re-run above as the cross-family control.mainmoved.deepseek_v2.rs's nested field is at:117(struct:121, accessors:184/:191), not:114/:118/:181/:188. Inlayers.rs,validate_quantization_paramsis at:1322,infer_quantization_bitsat:1183,reconcile_quantization_layoutat:1623and the divergencetracing::warn!at:1751. Everything else checked out, including the 59 privateQuantizationcopies,QuantizationArgshaving zero production callers, thelib.rs:1092/:1108bridge citations, and both OCR checkpoints declaring exactly the fallback values.deepseek_v2::Quantization, whose fields are required and which carries nomode, so a partially declared block would become a hard deserialization failure and the declared mode would stay invisible; it also predates fix(core): bound the declared quantization mode string, not just group_size and bits #973 / fix(core): bound the declared quantization mode string at load #1006, which added the mode allowlist used here. It is a fork PR, so completing it in place would mean force-pushing over a contributor's branch.Test plan
cargo fmt --all -- --checkcargo clippy --release --lib --tests --features metal,accelerate -- -D warningscargo clippy -p mlxcel-core --release --all-targets --features metal,accelerate -- -D warningscargo test --release --features metal,accelerate(77 targets, 5284 tests, 0 failures)cargo test --release -p mlxcel-core --features metal,accelerate --no-fail-fast -- --test-threads=1(1411 tests, 1 failure:fused_moe_parity_tests::fused_moe_geglu_kernel_matches_references_gemma4_shape, the known deterministic failure tracked as fix(tests): fused MoE GeGLU parity threshold is tighter than the gather_qmm reference's own jitter #964; this PR changes no file undersrc/lib/mlxcel-core/)deepseek-ocr-4bit,deepseek-ocr-2-4bitanddeepseek-vl2-small-4bit, plus the three edited-config demonstrations aboveCloses #975