fix: bounds-check topk_group inside group_mask_scores - #950
Merged
Conversation
Member
Author
|
The red
Formatting was verified locally with |
`group_mask_scores` computed `k = n_group - topk_group` and fed it to `slice_axis` and `argpartition` without ever checking `topk_group` against `n_group` or against the score row. Seven of its eight callers pass the value straight from `config.json`, so four out-of-range regimes were reachable from a checkpoint. The silent regimes are the reason this matters. `topk_group == n_group + 1` gives `k == -1`, and `slice_axis` reads that `end` as "to the end of the axis", so the bottom-k slice became the whole group axis and every group was zeroed. Nothing threw: every caller applies the mask to a selection copy and gathers its combine weights from an untouched `orig_scores`, so an all-equal selection row hands routing to `argpartition`'s tie-break order while the weights stay the genuine unbiased scores. The output stays finite and plausible with the router switched off. `n_group + 2 <= topk_group <= 2 * n_group - 1` zeroes the wrong number of groups the same way, and `topk_group == 0` zeroes every group. The loud regimes abort. `topk_group >= 2 * n_group`, and any negative `topk_group` (a `usize` config field above `i32::MAX` arrives already wrapped through `args.topk_group as i32`), drive `argpartition`'s `kth` out of range; `num_experts % n_group != 0` and a one-expert group fail one step earlier, in the reshape and in the top-2 `argpartition`. `mlxcel_core::argpartition` is declared non-`Result`, so each of these is an uncatchable `std::terminate` at the first forward pass rather than a load error. `group_mask_plan` now classifies the triple before any array work, and `group_mask_scores` returns the scores unmasked when the mask is not well defined. That covers all eight callers at once instead of growing a separate check in seven more model files. An out-of-range config is reported once per process through `eprintln!`, because `tracing::warn!` is a no-op in the `mlxcel` CLI binary where nothing installs a subscriber. In-range behavior is unchanged: the guard is pure `i32` arithmetic on values already held in the gate struct, with no array work and no device round-trip, and `topk_group == n_group` stays the identity it already was. The `// Used by:` comment listed six callers where a grep finds eight; it now names Dots1 and SolarOpen as well. Bailing MoE's load-time `validate_routing` is deliberately untouched, so it still rejects the same configs with the same messages. Validated on Apple M1 Ultra. Each of the eleven new `models::switch_layers` tests fails or aborts on `main` and passes with the guard: all-zero or wrongly-masked rows for the silent regimes, and SIGABRT with `[argpartition] Received invalid kth -5`, `kth 4`, `kth 1` and `[reshape] Cannot reshape array of size 16 into shape (1,3,5)` for the loud ones. `models::bailing_moe` passes 42/42 with no test modified, the deepseek_v3, deepseek_v32, glm4_moe, exaone_moe, solar_open, dots1 and llada2_moe suites pass 61/61 together, and clippy reports nothing new beyond the two pre-existing `host_preprocessor` warnings on `main`. Refs #947
inureyes
force-pushed
the
fix/issue-947-bounds-check-topk-group
branch
from
July 28, 2026 13:13
a93fd6e to
97609b0
Compare
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
group_mask_scores(src/models/switch_layers.rs) computedk = n_group - topk_groupand handed it toslice_axisandargpartitionwith no check oftopk_groupagainstn_groupor against the score row. Seven of its eight callers pass the value straight fromconfig.json. The bounds check now lives inside the function, so all eight call sites are covered at once instead of seven more model files each growing their own copy of it.Why the silent regime matters
topk_group == n_group + 1givesk == -1.slice_axisimplements Python slice semantics, whereend == -1means "to the end of the axis", so the bottom-k slice selected the whole group axis andput_along_axiszeroed every group. That did not produce zero output. Every caller applies the mask to a selection copy and gathers its combine weights from an untouchedorig_scores, so an all-zero (hence all-equal) selection row hands routing toargpartition's tie-break order, which has nothing to do with the router, while the combine weights remain the genuine unbiased scores at those arbitrary experts. Every token routes to the same arbitrary expert set, the output stays finite and in range, and nothing throws or logs. The router is simply off.n_group + 2 <= topk_group <= 2 * n_group - 1resolvesendton_group + kand zeroes the wrong number of groups, equally silently, andtopk_group == 0givesk == n_groupand zeroes every group.The remaining regimes abort instead.
topk_group >= 2 * n_groupand any negativetopk_groupdriveargpartition's normalizedkthout of range;num_experts % n_group != 0and a group holding one expert fail one step earlier, in the reshape and in the top-2argpartitionrespectively.mlxcel_core::argpartitionis declared non-Resultin the bridge, so an MLX C++ throw is an uncatchablestd::terminateat the first forward pass rather than a load error: the model loads cleanly and the server dies on its first request. A negativetopk_groupis reachable without a negative config value, because several callers cast ausizeconfig field throughargs.topk_group as i32, and ausizeabovei32::MAXwraps there beforegroup_mask_scoresever sees it.What changed
src/models/switch_layers.rs: newgroup_mask_plan(shape, n_group, topk_group) -> Result<i32, GroupMaskSkip>classifies the triple before any array work and returnsexperts_per_groupwhen the mask is well defined.group_mask_scoresreturns the scores unmasked on every skip reason. The guard is purei32arithmetic on values already held in the gate struct: no array work, no device round-trip, noeval, so the hot path is unaffected.GroupMaskSkipseparates the two ordinary reasons to mask nothing (n_group <= 1, andtopk_group == n_group, which keeps every group and is what upstream mlx-lm means by it) from the five broken-config reasons (InvalidGroupCount,TopkGroupOutOfRange,UnevenGroups,GroupTooSmall,BadScoreShape). Only the broken ones are reported.report_group_mask_skippedwrites oneeprintln!line per process naming the reason,n_group,topk_group, and the score-row shape.tracing::warn!is a no-op in themlxcelCLI binary, where nothing installs a subscriber, so atracingcall would have been invisible on the CLI path. Every MoE layer of a model shares one(n_group, topk_group)pair, so one line is the right granularity; firing per layer per token would bury it. Verified: eight distinct bad-config calls in one process emit exactly one line.n_group <= 0ahead of then_experts / n_groupdivision so a zero group count cannot divide by zero, and puts the group-width check after divisibility so an empty score row (which passes0 % n_group == 0) is still caught.// Used by:comment listed six callers. A grep finds eight; it now namesDots1andSolarOpenas well, perdocs/code-guidelines.md. The upstream reference was also converted from a baredeepseek_v3.pymention to the web-accessible GitHub URL form the repository guidelines require.models::switch_layersmodule, including one pure test ofgroup_mask_planthat exercises the whole classification table without touching MLX.Caller inventory, verified by grep
Eight production call sites, all of which currently gate the call on
self.n_group > 1. The guard covers that regime too, so it no longer depends on every future caller repeating the convention.src/models/bailing_moe.rs:1491self.topk_groupvalidate_routingsrc/models/deepseek_v3.rs:542self.topk_groupsrc/models/deepseek_v32.rs:613self.topk_group as i32src/models/glm4_moe.rs:778self.topk_group as i32src/models/glm4_moe_lite.rs:640self.topk_groupsrc/models/exaone_moe.rs:340self.topk_group as i32src/models/solar_open.rs:617self.topk_group as i32src/models/dots1.rs:379self.topk_group as i32n_group == topk_groupDeliberately not changed
Bailing MoE's
validate_routingstill rejectstopk_group == n_groupwhere upstream mlx-lm accepts it, and its error text still explains that with anargpartitionrange claim that does not hold against the pinned MLX (a negativekthis normalized, sokth == -1becomesn_group - 1). The issue frames relaxing this as optional context, and the acceptance criteria requirevalidate_routingto keep rejecting the same configs with the same messages, so it is untouched here. Correcting that rationale, and deciding whether to relax the bound, belongs in its own change.src/models/llada2_moe/mod.rs:480carries a private near-duplicate,group_limited_mask, which fills non-kept groups with a large negative constant rather than zero and has the same unguardedk = n_group - topk_grouparithmetic. It is not a caller ofgroup_mask_scores, so it is out of scope for this issue, but it is the same defect and should get its own issue.Test plan
DEVELOPER_DIR=... cargo test --release --lib --features metal,accelerate models::switch_layerspasses 19/19.topk_group = 5returned[0, 0, ... 0]instead of the input row;topk_group = 6returned a row with the wrong two groups zeroed;topk_group = 0returned all zeros;topk_group = 8aborted with[argpartition] Received invalid kth -5 along axis -2 for array with shape: (1,4,1);topk_group = -1aborted with[argpartition] Received invalid kth 4;n_group = 3over 16 experts aborted with[reshape] Cannot reshape array of size 16 into shape (1,3,5);n_group = 16over 16 experts aborted with[argpartition] Received invalid kth 1 along axis -1; andn_group = 0panicked on integer division by zero.n_group = 4, topk_group = 2andtopk_group = 3cases, thetopk_group == n_groupidentity, and a secondn_group = 2, topk_group = 1case all produce the same rows before and after the guard.DEVELOPER_DIR=... cargo test --release --lib --features metal,accelerate models::bailing_moepasses 42/42 with no test modified.models::deepseek_v3,models::deepseek_v32,models::glm4_moe,models::exaone_moe,models::solar_open,models::dots1,models::llada2_moe.DEVELOPER_DIR=... cargo clippy --release --lib --tests --features metal,accelerateexits 0 with nothing new; the only two warnings are the pre-existingsrc/multimodal/host_preprocessor.rsandhost_preprocessor_export.rsones already onmain.cargo fmt --all -- --checkclean.python3 scripts/ci/check_cross_repo_refs.pyclean.main. Not run here: the available local checkpoint ismodels/ling-lite-1.5at 33.6 GB, which is outside this unit's budget. Every published checkpoint in the eight families is in range, so the guard is a no-op for them by construction, and the in-range bit-identity tests above cover the path they take.Closes #947