Skip to content

Update DeepSeek-V3.2 to use PipelineMixin - #1861

Merged
michalk8 merged 4 commits into
ml-explore:mainfrom
twallgren:pr1-pipeline-layer-drop-fix
Sep 11, 2026
Merged

michalk8 merged 4 commits into
ml-explore:mainfrom
twallgren:pr1-pipeline-layer-drop-fix

Conversation

@twallgren

@twallgren twallgren commented Sep 8, 2026

Copy link
Copy Markdown
Contributor
  • ☑️ I understand it is strictly prohibited to use AI to write PR description
  • AI usage disclosure: AI was used to write the fix and the tests with some guidance from me. It went through many rounds of code reviews with a wide variety of agent frameworks and models to check for any issues with the core logic, styling, edge cases, etc.

The issue this is addressing:
deepseek_v32.py had its own independent pipeline implementation, duplicating logic that already existed in the shared PipelineMixin base class but not benefiting from bug fixes.

The solution:
Converted DeepseekV32Model to inherit PipelineMixin like every other pipeline-capable model so it benefits from the shared, tested pipeline logic (including uneven-split support) instead of maintaining another copy of similar code.

Note: an earlier version of this PR also fixed a layer-drop bug directly in pipeline.py's core split calculation, but that was independently fixed and merged upstream in #1816 first so that part was dropped from this PR to avoid duplicating it.

I needed these fixes to get DeepSeek-V3.2-family models running correctly in pipeline mode on my 3-node cluster.

@twallgren
twallgren force-pushed the pr1-pipeline-layer-drop-fix branch from a882678 to fce0e1e Compare September 8, 2026 18:28
@twallgren twallgren changed the title Fix pipeline() silently dropping a layer when rank sizes differ Fix qwen3_moe.py sanitize() gating and update DeepSeek-V3.2 to use PipelineMixin Sep 8, 2026
@azamamirza

Copy link
Copy Markdown
Contributor

Ran into the deepseek_v32 side of this yesterday while working on #1816: it has the same silently-dropped-layers bug that PR fixed in the mixin (7 layers on 2 ranks ran layer 3 on no rank), and @michalk8 suggested exactly this subclassing approach as a follow-up in the #1816 discussion. You got there first, and this version is cleaner than what I had staged: dropping the override entirely also picks up the new split= argument for free.

Verified locally on Apple Silicon (M5 Max):

  1. Applied my regression tests from the Fix PipelineMixin dropping layers on uneven splits, add explicit per-rank splits #1816 follow-up work to this branch unmodified: all pass, including a 7-layer / 2-rank uneven assignment check on the real DeepseekV32Model (fails on current main, passes here) and a tiny-config forward-equality test through test_pipeline with a size-1 group.
  2. tests/model_parallel_tests.py: 4 passed + 8 subtests. tests/test_models.py -k "deepseek or qwen3": 6 passed.
  3. The sanitize() change reads correct to me but I did not execute a pipeline-sharded checkpoint load to verify that half.
  4. We hit the original issue testing on a 2 Mac ring of uneven hardware (M4 Max and M5 Max machines with differing specs).

This PR has no committed tests; the diff below adds the deepseek_v32 coverage and applies cleanly to this branch if you want to fold it in.

regression tests (applies to this branch)
diff --git a/tests/model_parallel_tests.py b/tests/model_parallel_tests.py
index 1d9bb89..af73c83 100644
--- a/tests/model_parallel_tests.py
+++ b/tests/model_parallel_tests.py
@@ -6,7 +6,7 @@ import unittest
 import mlx.core as mx
 
 import mlx_lm
-from mlx_lm.models import qwen3_moe
+from mlx_lm.models import deepseek_v32, qwen3_moe
 from mlx_lm.models.pipeline import PipelineMixin
 
 
@@ -170,6 +170,29 @@ class TestModelParallel(unittest.TestCase):
                 "max_position_embeddings": 256,
                 "tie_word_embeddings": False,
             },
+            {
+                "model_type": "deepseek_v32",
+                "vocab_size": 128,
+                "hidden_size": 64,
+                "index_head_dim": 16,
+                "index_n_heads": 2,
+                "index_topk": 4,
+                "intermediate_size": 128,
+                "moe_intermediate_size": 32,
+                "num_hidden_layers": 4,
+                "num_attention_heads": 4,
+                "num_key_value_heads": 4,
+                "n_shared_experts": 1,
+                "n_routed_experts": 4,
+                "kv_lora_rank": 8,
+                "q_lora_rank": 8,
+                "qk_rope_head_dim": 8,
+                "v_head_dim": 16,
+                "qk_nope_head_dim": 16,
+                "num_experts_per_tok": 2,
+                "first_k_dense_replace": 1,
+                "max_position_embeddings": 256,
+            },
         ]
         mx.random.seed(0)
         for config in test_configs:
@@ -247,15 +270,40 @@ class TestModelParallel(unittest.TestCase):
         }
 
         size = 2
-        args = qwen3_moe.ModelArgs.from_dict(config)
-        assigned = []
-        for rank in range(size):
-            model = qwen3_moe.Model(args)
-            model.model.pipeline(Group(rank, size))
-            assigned.extend(
-                i for i, l in enumerate(model.model.layers) if l is not None
-            )
-        self.assertEqual(sorted(assigned), list(range(7)))
+        dsv32_config = {
+            "model_type": "deepseek_v32",
+            "vocab_size": 128,
+            "hidden_size": 64,
+            "index_head_dim": 16,
+            "index_n_heads": 2,
+            "index_topk": 4,
+            "intermediate_size": 128,
+            "moe_intermediate_size": 32,
+            "num_hidden_layers": 7,
+            "num_attention_heads": 4,
+            "num_key_value_heads": 4,
+            "n_shared_experts": 1,
+            "n_routed_experts": 4,
+            "kv_lora_rank": 8,
+            "q_lora_rank": 8,
+            "qk_rope_head_dim": 8,
+            "v_head_dim": 16,
+            "qk_nope_head_dim": 16,
+            "num_experts_per_tok": 2,
+            "first_k_dense_replace": 1,
+            "max_position_embeddings": 256,
+        }
+        for arch, arch_config in ((qwen3_moe, config), (deepseek_v32, dsv32_config)):
+            with self.subTest(model_type=arch_config["model_type"]):
+                args = arch.ModelArgs.from_dict(arch_config)
+                assigned = []
+                for rank in range(size):
+                    model = arch.Model(args)
+                    model.model.pipeline(Group(rank, size))
+                    assigned.extend(
+                        i for i, l in enumerate(model.model.layers) if l is not None
+                    )
+                self.assertEqual(sorted(assigned), list(range(7)))
 
 
 if __name__ == "__main__":

@twallgren

Copy link
Copy Markdown
Contributor Author

Thanks @azamamirza for the review, feedback, testing, and regression tests! I applied your diff and confirmed that the tests pass. I'll remove the draft status.
Since you're working on the uneven split topic, you might be interested in #1862 and #1863 as well.

@twallgren
twallgren marked this pull request as ready for review September 8, 2026 21:11
ml-explore#1816 already fixed PipelineMixin's own layer-dropping bug (start_idx
computed from this rank's own size rather than a real prefix sum,
silently omitting a layer whenever ranks have different sizes) with a
cleaner API than what this branch originally proposed for that part --
dropped here in favor of it.

deepseek_v32.py wasn't touched by that fix because it never used
PipelineMixin at all -- it carried its own fully independent copy of the
same buggy logic, unrelated to and unfixed by ml-explore#1816. Rather than
patching that copy a third time, this converts DeepseekV32Model to
actually inherit PipelineMixin (matching qwen3_5.py/ministral3.py), so
it gets ml-explore#1816's tested fix directly instead of maintaining a parallel
implementation of the same bug-prone logic. Same behavior-preserving
transformation applied elsewhere: __call__ walks pipeline_layers instead
of manually indexing layers[start_idx + i] over a hand-tracked
num_layers, and the outer Model.layers property returns
model.pipeline_layers instead of re-deriving the same slice by hand.
Also added the same empty-cache guards used elsewhere in this codebase
(cache and cache[-1] is not None, etc.) for a rank that ends up with
zero local layers.

qwen3_moe.py's sanitize() has an unrelated bug in the same problem
space, not touched by ml-explore#1816: it gates MoE expert-weight-stacking on a
fixed model.layers.0... key. Under pipeline sharding a rank only has its
own local layers' weight files downloaded, so a rank not owning layer 0
sees that key absent and skips stacking entirely for its own local
layers -- silently wrong for any raw (unconverted) checkpoint under
pipeline parallelism. Fixed to detect and iterate by which layers are
actually present in the local weights dict instead.
Contributed by @azamamirza in PR review: extends the existing
qwen3_moe uneven-assignment regression test to also cover
deepseek_v32 now that it uses the shared PipelineMixin.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
@twallgren
twallgren force-pushed the pr1-pipeline-layer-drop-fix branch from b8edaa7 to b8b3b94 Compare September 9, 2026 17:56

@michalk8 michalk8 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you please split this PR into two, one for DeepSeek to use PipelineMixin (happy to merge) and the Qwen3 MoE in a separate one (I'm not sure about this one yet, if it's indeed a bug, it might be present in other models).

Comment thread mlx_lm/models/deepseek_v32.py Outdated
Comment thread tests/model_parallel_tests.py Outdated
cache and cache[0]/cache[-1] guards in DeepseekV32Model.__call__ are
unnecessary and inconsistent with qwen3_moe.py's PipelineMixin usage.

test_pipeline_uneven_model's deepseek_v32 subtest duplicated coverage
already provided by the qwen3_moe case now that both models delegate
to the same PipelineMixin.pipeline() implementation.
Per review feedback on ml-explore#1861: this PR now only converts
DeepseekV32Model to PipelineMixin. The qwen3_moe.py sanitize() gate
fix moved to its own branch/PR since it's an independent bug in a
different model.
@twallgren twallgren changed the title Fix qwen3_moe.py sanitize() gating and update DeepSeek-V3.2 to use PipelineMixin Update DeepSeek-V3.2 to use PipelineMixin Sep 10, 2026
@twallgren

Copy link
Copy Markdown
Contributor Author

Thanks for the review and feedback, @michalk8! I've split the PR into two as you requested. The new PR is #1875.

@twallgren
twallgren requested a review from michalk8 September 10, 2026 18:14

@michalk8 michalk8 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, thanks!

@michalk8
michalk8 merged commit 1840cbb into ml-explore:main Sep 11, 2026
2 checks passed
@twallgren
twallgren deleted the pr1-pipeline-layer-drop-fix branch September 14, 2026 16:49
twallgren added a commit to twallgren/mlx-lm that referenced this pull request Sep 14, 2026
This branch's old duplicate PipelineMixin-conversion commit
reintroduced the cache and cache[0]/cache[-1] guards that were
removed from deepseek_v32.py during ml-explore#1861's review (inconsistent
with qwen3_moe.py's established PipelineMixin pattern). Match
upstream/main exactly.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants