perf: stack per-expert tensors from borrows instead of copies - #960
Merged
Conversation
`stack_individual_experts_with_count` called `mlxcel_core::copy` on every per-expert projection tensor before stacking it. The copies existed only to produce owned `UniquePtr` values for `stack_owned`, and contributed nothing to the result: `mlxcel_core::copy` lowers to `mx::copy`, a real `Copy` primitive that allocates and writes a full-size output, and `mx::stack` then allocates its own output and reads each input exactly once, so every byte a copy wrote was read once by the concatenate and discarded. Both ops are lazy and nothing evaluates model parameters between construction and the first forward pass (the `eval_all` calls in `src/models/sanitize.rs` run on the `WeightMap` before `from_weights` is reached, and the model constructor adds no `eval`), so the waste was not load-time allocation. It landed as copy traffic at the first forward, plus a transient of one projection's expert set held live while the concatenate ran. For `models/ling-lite-1.5` that is 5376 per-expert BF16 tensors, 3584 of shape [1408, 2048] and 1792 of [2048, 1408] at 5,767,168 bytes each, 31,004,295,168 bytes against a `total_size` of 33,603,948,544: essentially the whole routed payload, written and re-read for nothing. `ops::stack` already takes `*const MlxArray` and is public, so the per-expert path needed no new borrowing entry point. The pointers are collected and consumed inside the live `&WeightMap` borrow and the map is only read, which is why this does not switch to the `remove`-based draining `src/models/olmoe.rs` uses: this helper is called from a `&WeightMap` context. The stacked node keeps its inputs alive on its own because the C++ `stack` shim copies each `a->inner` into its own `std::vector<array>` and `mx::array` is a refcounted handle. That contract is now pinned by a test that drops the source map before evaluating the stacked array and then checks per-plane values, not just shapes, so a stack that held a borrow would read freed memory rather than fail an assertion. `src/models/phimoe.rs` carried the identical redundant copy over its own expert set and is fixed in the same pass rather than deferred: it built `expert_arrays` with `mlxcel_core::copy` and then collected pointers from them, so the copy was pure overhead there too. It is not a no-copy precedent, contrary to how it is sometimes described; only `olmoe.rs` is. The adjacent PRE-STACKED branch is deliberately NOT included, and that is a decision rather than an oversight. It copies the already-joined `[num_experts, ...]` tensors, which is the same byte volume and the more common mlx-community layout, but removing that copy needs a handle-sharing bridge function that does not exist yet: `mlxcel_core::copy` is currently the only way to get an owned `UniquePtr` from a `&MlxArray`, and a sharing shim would be `std::make_unique<MlxArray>(a.inner)` with no primitive. That changes the cxx FFI surface and, more importantly, changes an invariant every existing `copy` caller gets for free, that the loaded layer holds a node independent of the `WeightMap` entry. Deciding which callers can safely alias needs an audit of what happens to the map afterwards. Filed as #959, which also records that the same pattern sits in every family-local `SwitchLinear` (the `get_weight_copy` helpers) and in phimoe's two whole-map fallbacks, so the shim would pay off well beyond this file. The expert-count return value and the truncated-checkpoint error are unchanged, including the contiguous-gather-until-first-gap semantics; `models::deepseek` still passes both of its direct `stack_individual_experts` tests. The `Used by:` comment on the helper was stale (it named three families) and now lists the nineteen that reach it through the shared `SwitchGLU` / `SwitchLinear` plus DeepSeek v1's direct call, verified by grep. Validated with `cargo test --release --lib --features metal,accelerate` over `models::switch_layers` (22 passed, including the new ownership test) and the shared-loader families `models::{phimoe,olmoe,deepseek,mixtral,qwen2_moe,bailing_moe,dots1,gemma4,minimax,llada2_moe,granitemoehybrid,lfm2,mellum,solar_open,cohere2_moe,moondream3}`, plus clippy and fmt. Token-exact numerics on a real MoE checkpoint are not verified here: the release binaries are not built in this environment and the reference checkpoint is 33.6 GB. Closes #948
8 tasks
Review of the first commit checked the premise this PR inherited from #948 against the pinned MLX checkout and found it false. `mlxcel_core::copy` is not a materializing copy. `mx::copy` builds a `Copy` node whose `Copy::eval_gpu` and `Copy::eval_cpu` both delegate to `Copy::eval`, and that is `out.copy_shared_buffer(inputs[0])` (`mlx/backend/common/common.cpp`), which repoints `array_desc_->data` at the input's buffer. It allocates nothing and writes nothing. The materializing `copy_gpu(..., CopyType::General)` a few lines above in the same file belongs to `Contiguous::eval_gpu`, a different primitive, which is the likely source of the original misreading. So the 31,004,295,168 bytes of first-forward copy traffic that #948 attributes to `models/ling-lite-1.5` was never being spent, and neither was the roughly 352 MiB per-projection peak-memory transient the PR body predicted would disappear. What the change actually removes is one `Copy` graph node per per-expert tensor, 5376 of them for that checkpoint: graph metadata and eval scheduling, not memory traffic. The code is still correct and still worth keeping, it is simply a smaller win than advertised, and the acceptance criterion that would have caught this is the measurement one that is still unticked. The rationale had already been written into two source comments, which is worse than a wrong PR body because it becomes documentation that the next reader trusts. Both are rewritten to state what the pinned source says, with an explicit warning not to reintroduce the traffic claim without re-reading it. Two smaller review points. The ownership test's comment claimed that a stack holding a borrow would read freed memory rather than fail an assertion; freed memory usually reads back intact, so the test is a tripwire and the real guarantee is the by-value `push_back` in the C++ shim. The comment now says so. And the rewritten `Used by:` list needed a caveat for PhiMoE and OLMoE, which pre-stack in their own `sanitize_weights` and so normally take the pre-stacked branch; PhiMoE reaches the per-expert branch only for a checkpoint using `gate_proj`/`up_proj`/`down_proj` names, because its sanitizer probes `experts.0.w1.weight`. Comment-only. `models::switch_layers` (22) and `models::phimoe` (5) still pass, `cargo fmt --all -- --check` clean. Refs #948
6 tasks
The new inline `sanitize_tests` module sat between `sanitize_weights` and `impl SparseMoeBlock`, which trips clippy's `items_after_test_module` on the two items that follow it. Moved to the end of the file, matching how `deepseek_v3.rs` places its own inline tests. No test or production code changed; `models::phimoe` still passes 5 and `models::switch_layers` 22. Refs #948
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
stack_individual_experts_with_countcalledmlxcel_core::copyon every per-expert projection tensor before stacking it. The copies existed only to produce ownedUniquePtrvalues forstack_ownedand contributed nothing to the result, so the stack is now built from borrowed pointers instead. That removes oneCopygraph node per per-expert tensor, 5376 of them formodels/ling-lite-1.5.Correction to the issue's cost model, and to this PR's first draft
Issue #948 states that the copies cost about 31 GB of redundant memory traffic at the first forward pass for Ling-lite, plus a per-projection peak-memory transient of roughly 352 MiB. That is wrong, and this PR originally repeated it. Checked against the pinned MLX checkout:
mx::copybuilds aCopynode (mlx/ops.cpp:303).Copy::eval_gpu(mlx/backend/gpu/primitives.cpp:64) andCopy::eval_cpu(mlx/backend/cpu/primitives.cpp:88) both just callCopy::eval.Copy::eval(mlx/backend/common/common.cpp:55) isout.copy_shared_buffer(inputs[0]), which repointsarray_desc_->dataat the input's buffer. It allocates nothing and writes nothing.The materializing
copy_gpu(..., CopyType::General)a few lines above in that same file belongs toContiguous::eval_gpu, a different primitive, which is the likely source of the original misreading.So there was never 31 GB of traffic to save and never a 352 MiB transient to remove. What this change actually saves is graph metadata and eval-scheduling overhead for 5376 nodes. The change is still correct and still worth landing (it is strictly less indirection for identical numerics), it is just a much smaller win than the issue claims. The acceptance criterion that would have caught this is the before/after measurement, which is the one still unticked.
The corrected reasoning is now in the source comments rather than only here, since a wrong rationale in a comment is worse than a wrong PR body: the next reader trusts it. Both comments carry an explicit warning not to reintroduce the traffic claim without re-reading the pinned MLX source.
What changed
src/models/switch_layers.rsstack_individual_experts_with_countcollects*const MlxArraystraight fromweights.get(...)and callsmlxcel_core::stackinstead of buildingVec<UniquePtr<MlxArray>>viacopyand callingstack_owned. No new borrowing entry point was needed:ops::stackalready takes pointers and is public.&WeightMapborrow, and the map is only read. This deliberately does not switch to theremove-based draining thatsrc/models/olmoe.rsuses, because this helper is called from a&WeightMapcontext.stacked_experts_outlive_the_weight_map_they_were_borrowed_fromdrops the source map before evaluating the stacked array, then checks per-plane values, not just shapes. It is a tripwire rather than a proof, and its comment says so: freed memory usually reads back intact, so the real guarantee is the by-valuearrs.push_back(a->inner)in the C++stackshim (src/lib/mlxcel-core/cpp/mlx_cxx_bridge.cpp:2577), which copies the refcountedmx::arrayhandle so the stacked node holds its inputs independently of the map.Used by:comment was stale, naming three families. It now lists the nineteen that reach the helper through the sharedSwitchGLU/SwitchLinearplus DeepSeek v1's direct call atdeepseek.rs:857, verified by grep, with a caveat that PhiMoE and OLMoE pre-stack in their ownsanitize_weightsand so normally take the pre-stacked branch instead.src/models/phimoe.rsFixed in the same pass rather than deferred. It built
expert_arrayswithmlxcel_core::copyand then collected pointers from those copies, so it carried the identical redundant node over its own expert set. It is not a no-copy precedent, contrary to how it is sometimes described; onlyolmoe.rsis, because that one drains withweights.removeand never copies. A new inlinesanitize_testsmodule covers the stacking path end to end (w1/w2/w3 to gate/down/up, source map dropped before eval, non-expert passthrough, per-expert keys gone), sincephimoe_tests.rsis a sibling module and cannot reach the privatesanitize_weights. This mirrors howdeepseek_v3.rstests its own.Decision on the pre-stacked branch
Not included here. Follow-up #959 was filed for it and has now been closed, because the corrected cost model removes its justification.
The adjacent pre-stacked branch copies the already-joined
[num_experts, ...]tensors, and removing that copy would need a handle-sharing bridge shim (std::make_unique<MlxArray>(a.inner), no primitive) that does not exist today, sincemlxcel_core::copyis currently the only way to get an ownedUniquePtrfrom a&MlxArray. That was worth scoping separately for two reasons: it adds cxx FFI surface, and it changes an invariant every existingcopycaller gets for free, namely that the loaded layer holds a node independent of theWeightMapentry.With
copynow known to be buffer-sharing rather than materializing, the payoff for that shim is graph-node reduction only, across roughly thirteen family-localSwitchLinearimplementations plus phimoe's two whole-map fallbacks. New FFI surface and an aliasing audit across thirteen model files is not a good trade for that, so #959 is closed with the evidence recorded rather than left open to mislead whoever picks it up. It can be reopened if someone measures a real cost.Scope note
The affected-family list is not every MoE family. Fourteen files define their own local
SwitchLinear/SwitchGLUand never reach this branch (Qwen3-MoE and Qwen3-VL-MoE, Qwen3-Next and Qwen3.5, DeepSeek v1/v2/V3/V3.2, GLM4-MoE and GLM4-MoE-Lite, ERNIE 4.5 MoE, ExaOne MoE, Hunyuan-MoE, Llama 4, Jamba, gpt-oss, Step3p5). None of them has a per-expert stacking branch at all, so an unstacked checkpoint for those families fails to load rather than taking a slow path. Verified by grep, not assumed.Test plan
cargo test --release --lib --features metal,accelerate models::switch_layers(22 passed, including the new ownership test)cargo test --release --lib --features metal,accelerate models::phimoe(5 passed, including the newsanitize_testsstacking test)models::{phimoe,olmoe,deepseek,mixtral,qwen2_moe,bailing_moe,dots1,gemma4,minimax,llada2_moe,granitemoehybrid,lfm2,mellum,solar_open,cohere2_moe,moondream3}models::deepseekstill passes both directstack_individual_expertstests, so the expert-count return value and the truncated-checkpoint error keep their contiguous-gather-until-first-gap semanticscargo clippy --release --lib --tests --features metal,accelerateclean apart from the two warnings already onmaininsrc/multimodal/host_preprocessor.rsandhost_preprocessor_export.rscargo fmt --all -- --checkmodels/ling-lite-1.5is the reference case). Not run here: the release binaries are not built in this environment and the checkpoint is 33.6 GB. Left for the follow-up validation pass. Numerics should be identical by construction, since the graph goes fromstack(copy(w_0..w_n))tostack(w_0..w_n)andCopyaliases its input.Closes #948