fix(models): decompose kv_b_proj so GLM4 MoE Lite loads its own layout - #1032
Conversation
`glm4_moe_lite.rs` had no `sanitize_weights` and no MLA `kv_b_proj` decomposition anywhere, while `MlaAttention::from_weights` loads `embed_q` and `unembed_out` unconditionally. Every published `glm4_moe_lite` checkpoint stores the MLA up-projection as a single `self_attn.kv_b_proj.weight` and ships no `embed_q` tensor at all, so the family failed the canonical layout of its own architecture with `Weight not found: model.layers.0.self_attn.embed_q.weight`. This is a live failure, not a latent one: it is the first thing any checkpoint of this family hits. `sanitize_weights` is adapted from `youtu_vl_lm_sanitize.rs`, the most complete of the five existing MLA decompositions and the only one that cross-checks the tensor against `[num_heads * head_dim, kv_lora_rank]` before reshaping. That cross-check is carried over deliberately: the reshape below it is where a `config.json` that disagrees with the stored tensor stops being recoverable, because MLX reports a bad reshape by throwing and the throw crosses the cxx bridge as `UniquePtr<MlxArray>` rather than `Result`, aborting the process during weight sanitization instead of failing the load. A quantized `kv_b_proj` is dequantized before the per-head split, since slicing in packed space would leave each half carrying group scales and biases that describe the whole row. The packed pair is solved by the shared `mlxcel_core::layers::infer_mla_quantization_params` (issue #958) and the scales-without-biases plane is refused in the wording standardized across all five sanitizers in PR #1027 (issue #1026), verbatim. The code lives in its own module rather than being appended to `glm4_moe_lite.rs`, the same split `youtu_vl_lm_sanitize.rs` uses, because the runtime module is already well past the 500-line target. `Glm4MoeLiteModel::load` calls it, matching the two sibling MLA families that share this exact registration route (`DeepSeekV3`, `DeepSeekV32`, both `WeightLoadRoute::ConfigBacked`). The `weight_builder` route reached from `load_model_with_adapter` still bypasses every sanitizer for all three of those families, because the `ConfigBacked` registration macro has no per-family hook where the hand-written `Special` route has one; that pre-existing gap is left alone here and is worth its own issue. Five tests drive the real `sanitize_weights` and the real `Glm4MoeLiteModel::from_weights` with synthetic weight maps and assert on the returned `Result`. None runs a forward pass, because a load that produced a malformed `embed_q` would abort the test binary through the cxx bridge rather than fail one test. The first test pins the unsanitized failure taken from the real loader, so it would still fail if the decomposition were removed and `MlaAttention` were relaxed to tolerate a missing `embed_q` instead. Verified with `cargo test --lib models::glm4_moe_lite` (6 passed), `cargo test --lib models::longcat_flash_ngram` (2 passed), `cargo test --lib models::youtu_vl` (4 passed), `cargo clippy --lib --tests -- -D warnings`, and `cargo fmt --all -- --check`. Closes #1029
`Glm4MoeLiteStageExecutor::load` is a registered load path for this family, and it delegates to the shared `load_glm4_family_stage_model`, which went straight from `load_text_weights` to `filter_weight_map` with no sanitize step. The single-process loader gained the MLA decomposition in this branch, but the pipeline-parallel route did not, so `glm4_moe_lite` still failed the canonical checkpoint layout there with `Weight not found: model.layers.N.self_attn.embed_q.weight`. The generic now takes a sanitize hook and calls it on the full weight map, immediately after loading and before the stage filter. That ordering is required, not incidental: published checkpoints ship `self_attn.kv_b_proj` rather than the `embed_q` / `unembed_out` pair the block reads, and filtering first would drop the `kv_b_proj` keys of every out-of-range layer before the split could consume them. This is what `DeepSeekV3StageExecutor::load` and `GlmMoeDsaStageExecutor::load` already do for the same reason. Glm4 and Glm4Moe need no weight surgery and pass a `no_sanitize` free function. The sanitize test fixture also could not tell the two halves apart. `qk_nope_head_dim` and `v_head_dim` were both 4, which left `[HEADS, KV_LORA_RANK, QK_NOPE]` and `[HEADS, V_HEAD, KV_LORA_RANK]` valid with the nope and v halves swapped, and every fixture tensor was filled with zeros, so no assertion could observe element order at all. A wrong half or a wrong reshape / transpose axis order would have passed. `v_head_dim` is now 6, and a new test runs the decomposition over a `0, 1, 2, ...` ramp and asserts the exact element identities against the HF reference layout (`kv_b_proj` stored `[num_heads * (qk_nope + v_head), kv_lora_rank]`, row-major, head-major, nope rows before v rows within each head). That failure mode is worse than a load error: mis-split weights load, run, and emit plausible text. Verified with `cargo check --lib --tests`, `cargo test --lib models::glm4_moe_lite` (7 passed, 0 failed), `cargo clippy --lib --tests -- -D warnings`, and `cargo fmt --all -- --check`. No integration test was added for the stage-executor path because `tests/pipeline_stage_executor_real_models.rs` requires a real checkpoint directory on disk. Refs #1029
Implementation Review SummaryIntentGive The decomposition is numerically correctChecked four independent ways, since this PR adds real numerical behavior rather than converting a panic into an error:
Geometry confirmed against a real config
The other three review points hold as the PR describes them: the slices are copied contiguous (matching the four, not kimi); the shape cross-check cannot reject anything that would otherwise have worked, because a row count that disagrees with Findings Addressed
Both in Remaining Items (reported, not fixed here)
On the shared-helper extractionThe reasoning checks out. The slice and transpose difference really is incidental ( Verification
|
Security and Performance ReviewScope: No CRITICAL or HIGH findings. Nothing auto-fixed; working tree untouched. The two HIGH items from the implementation review were already closed in 1. Abort and panic surface in the decomposition
2. The shape cross-checkConfirmed it cannot reject a checkpoint that would otherwise have loaded and run correctly. The 3. The
|
Summary
src/models/glm4_moe_lite.rshad nosanitize_weightsand no MLAkv_b_projdecomposition anywhere, whileMlaAttention::from_weightsloadsembed_qandunembed_outunconditionally (src/models/glm4_moe_lite.rs:327-333). Every publishedglm4_moe_litecheckpoint stores the MLA up-projection as a singleself_attn.kv_b_proj.weightand ships noembed_qtensor at all, so the family failed the canonical layout of its own architecture withWeight not found: model.layers.0.self_attn.embed_q.weight. This is a live failure, not a latent one, and every other MLA family in the tree already carries this decomposition.What changed
src/models/glm4_moe_lite_sanitize.rs(new):sanitize_weightsdecomposeskv_b_projinto the per-headembed_q/unembed_outpair. Adapted fromsrc/models/youtu_vl_lm_sanitize.rs, the most complete of the five existing implementations and the only one that cross-checks the tensor against[num_heads * head_dim, kv_lora_rank]before reshaping. Reusesmlxcel_core::layers::infer_mla_quantization_paramsfor the packed pair (issue fix(models): bound quantization params in the family-local MoE expert loaders #958) and refuses a scales-without-biases plane in the wording standardized across all five sanitizers in PR fix(models): MLA kv_b_proj sanitizers reject scales-without-biases #1027 (issue fix(models): the MLA kv_b_proj sanitizers panic on a scales-without-biases plane #1026), verbatim.src/models/glm4_moe_lite.rs: declares the sanitize module, re-exportssanitize_weights, and calls it fromGlm4MoeLiteModel::loadbetweenload_text_weightsandfrom_weights.src/models/glm4_moe_lite_sanitize_tests.rs(new): five tests.The code lives in its own module rather than being appended to
glm4_moe_lite.rs, the same splityoutu_vl_lm_sanitize.rsuses, because the runtime module is already well past the repo's 500-line target.Why the shape cross-check is carried over
Of the five sibling sanitizers only Youtu-VL cross-checks the tensor shape, and it is worth having. The reshape below the split is where a
config.jsonthat disagrees with the stored tensor stops being recoverable: MLX reports a bad reshape by throwing, and that throw crosses the cxx bridge asUniquePtr<MlxArray>rather thanResult, so it aborts the process during weight sanitization instead of failing the load. The check cannot reject anything that would otherwise have worked, because a row count that disagrees withnum_heads * head_dimproduces a latent width the forward path cannot consume even when the reshape happens to succeed.Why the quantized plane is dequantized first
The split runs per head along the row axis. Slicing the packed plane would leave each half carrying group scales and biases that describe the whole row, so the halves have to be produced in dense space.
dequantizeis hardcoded"affine"here, the same as in all five siblings, which is why a block-floatkv_b_projis refused by name rather than silently mis-decomposed.The
loadversusfrom_weightswiring decisionsrc/model_metadata.rs:185registers two entry points for this family, and theWeightLoadRoute::ConfigBackedarm atsrc/loading/mod.rs:673reachesfrom_weightswithout going throughload, so a sanitizer placed only inloaddoes not cover it. I checked what the siblings actually do for their own weight route before choosing, and the answer is not uniform:KimiLinearandLongcatFlash/LongcatFlashNgramareWeightLoadRoute::Special, and that route does sanitize:src/loading/special.rs:241-262takes an owned copy throughcopy_weight_mapand runs the family sanitizer beforefrom_weights.Youtu-VLis a VLM withadapter: Some(...), soload_model_with_adapterrejects it before any weight route runs. Not applicable.DeepSeekV3andDeepSeekV32areWeightLoadRoute::ConfigBacked, the same route as this family, and both share the gap:try_load_config_backed_model_from_weightsis macro-generated (src/loading/config_backed.rs:50-68) and calls$weight_builder(weights, &args)with no hook where a sanitizer could go.So the gap is not a property of MLA families, it is a property of the
ConfigBackedroute: the hand-writtenSpecialroute has a place to put the sanitizer and the macro-generated one does not. It affects exactly the threeConfigBackedMLA families, andload_model_from_weightsis called from one place only (src/loading/mod.rs:636), so the blast radius is LoRA adapter loading, not ordinary model loading.Decision: sanitize in
loadonly, matchingDeepSeekV3andDeepSeekV32exactly. Closing the gap unilaterally for this one family means either moving it to theSpecialroute or adding a per-family hook to a macro that drives around sixty registrations, and the second of those fixes the siblings, which this PR was explicitly scoped not to do. Sanitizing insidefrom_weightswas rejected on a separate ground: it would need an ownedcopy_weight_map, and theloadpath already owns its map, so the dir route would copy the whole weight map a second time. That is a real peak-memory regression on a large MoE in exchange for a route this family is not yet used on.The sibling gap deserves its own issue. It is a genuine pre-existing bug, not an accepted design: LoRA adapter loading is already broken for
DeepSeekV3andDeepSeekV32on the canonical checkpoint layout today, failing with the sameWeight not found: ...embed_q.weightthis PR fixes for the dir route. The asymmetry with theSpecialroute is the evidence that it was an oversight. The fix wants to be one hook on theConfigBackedregistration, applied to all three families at once with its own tests, not three copies.On extracting a shared helper for the six decomposition blocks
PR #1027 declined this for three recorded reasons. Checked against the tree, all three hold but two are weaker than they look:
w_fullshape. True, and this PR makes it two of six. This is the one real behavioral difference, and it is a difference the other four should lose, not one worth preserving.mlxcel_core::slicewith explicit stops plusswap_axes; the rest useslice_axisplustranspose_axes. This is incidental, not semantic:slice_axistreatsend == -1as "to the end of the axis" (src/lib/mlxcel-core/src/utils.rs:52-61), which is exactly what Kimi's explicit stop computes, and its in-code note aboutstop=-1is about the rawslice, not aboutslice_axis.MultiLinear's matmul sees well-formed strides regardless of backend.What genuinely varies per family is the prefix and the layer predicate: four use
model.layers.{l}.self_attn, LongCat carries a sub-attention index (self_attn.0/self_attn.1), and Kimi skips its linear-attention layers. Both are naturally parameters, not obstacles. Adecompose_mla_kv_b_proj(&mut weights, prefix, layer_label, geometry) -> Result<(), String>next toinfer_mla_quantization_paramsinmlxcel_core::layerswould be roughly sixty lines and would delete something like two hundred and fifty across five files.Conclusion: the extraction is now justified, and it should not ride in this PR. It changes behavior for four shipped families in three ways at once (they gain the shape cross-check, Kimi gains contiguity, Kimi changes slice helper), which needs a per-family test matrix and its own bisect point. Bundling it here would also make a fix that restores a family's basic loadability impossible to revert on its own. PR #1027 declined once on the record, so overturning that call belongs in an issue where the three reasons can be answered, not in a paragraph attached to an unrelated fix. Recommend filing it as a follow-up.
Test plan
Five tests in
src/models/glm4_moe_lite_sanitize_tests.rs, all driving the realsanitize_weightsand the realGlm4MoeLiteModel::from_weightswith synthetic weight maps built frommlxcel_core::from_slice_f32, all asserting on the returnedResult. None runs a forward pass: a load that produced a malformedembed_qreaches MLX through the cxx bridge asUniquePtr<MlxArray>rather thanResult, so a throw there aborts the whole test binary instead of failing one test.sanitize_synthesizes_the_mla_pair_no_checkpoint_ships: asserts the unsanitized checkpoint fails with exactlyWeight not found: model.layers.0.self_attn.embed_q.weight(the failure the issue reports, taken from the real loader rather than restated), then that the sanitized one loads and carriesembed_qat[heads, kv_lora_rank, qk_nope]andunembed_outat[heads, v_head, kv_lora_rank].sanitize_dequantizes_kv_b_proj_before_the_per_head_split: an honest affine 4-bit plane (packed_in * 32 == bits * num_groups * group_size, UINT32 packed sodequantizedoes not throw on the positive control) decomposes to the same shapes, consumes all three source planes, and leaves both halves dense.sanitize_rejects_scales_with_no_biases: positive control first, then a block-float plane is refused namingmodel.layers.0.self_attn.kv_b_proj.biasesin PR fix(models): MLA kv_b_proj sanitizers reject scales-without-biases #1027's wording.sanitize_leaves_an_already_decomposed_checkpoint_alone: a checkpoint shipping the pair plus a stalekv_b_projkeeps itskv_b_proj.weight, which is the observable proof thecontinuefired rather than the split running.sanitize_rejects_a_kv_b_proj_that_disagrees_with_the_config: a tensor carrying an extra head is refused before the reshape, naming the shape the config describes.Commands actually run, all from a clean run on this branch:
cargo test --lib models::glm4_moe_lite(6 passed, 0 failed)cargo test --lib models::longcat_flash_ngram(2 passed, 0 failed)cargo test --lib models::youtu_vl(4 passed, 0 failed)cargo clippy --lib --tests -- -D warnings(exit 0)cargo fmt --all -- --check(exit 0)cargo check --lib --tests(clean)Not run here: the full-workspace clippy and test sweep, and any load against a real
glm4_moe_litecheckpoint. Noglm4_moe_liteweights are present on this machine, and the three public repos are tiny random fixtures rather than a released model, so the fix is verified against the published tensor layout and not against a production checkpoint.Review round 2
Two findings from implementation review, fixed in
0069c6c7.The pipeline stage-executor route never sanitized.
Glm4MoeLiteStageExecutor::loadis a second registered load path for this family (StageFamily::Glm4MoeLite), and it delegates to the sharedload_glm4_family_stage_model, which went fromload_text_weightsstraight tofilter_weight_mapwith no sanitize step.models::glm4_moe_lite::TransformerBlock::from_weightsreadsembed_qunconditionally, so the canonical layout still failed there with the sameWeight not found: model.layers.N.self_attn.embed_q.weight.DeepSeekV3StageExecutor::load(src/distributed/pipeline/stage_executor/deepseek_v3.rs:84) andGlmMoeDsaStageExecutor::load(src/distributed/pipeline/stage_executor/glm_moe_dsa.rs:42) both sanitize the full weight map before the filter. The generic now takes aSanitize: Fn(WeightMap, &A) -> Result<WeightMap, String>hook called in that position;Glm4MoeLiteStageExecutorpassessanitize_weightsand the two non-MLA GLM4 families pass ano_sanitizepassthrough. This is distinct from theConfigBackedgap discussed above, which stays open and cross-family.The tests could not tell the two halves apart. The fixture set
qk_nope_head_dimandv_head_dimboth to 4, which leaves[HEADS, KV_LORA_RANK, QK_NOPE]and[HEADS, V_HEAD, KV_LORA_RANK]both valid with the nope and v halves swapped, and every fixture tensor was zero-filled, so no assertion could observe element order at all. A wrong half, a wrong reshape axis order or a missing transpose would all have passed.v_head_dimis now 6, and a sixth test decomposes a0, 1, 2, ...ramp and asserts the exact element identities against the reference layout:embed_q[h][r][c] == kv_b[(h * head_dim + c) * kv_lora_rank + r]for the transposed nope half, andunembed_out[h][d][r] == kv_b[(h * head_dim + qk_nope + d) * kv_lora_rank + r]for the v half as stored. It passes, so the decomposition is now pinned at the element level rather than by shape alone.Re-verified after both:
cargo test --lib models::glm4_moe_lite(7 passed, 0 failed),cargo test --lib distributed::pipeline(317 passed, 0 failed),cargo clippy --lib --tests -- -D warnings(exit 0),cargo fmt --all -- --check(exit 0).Closes #1029