Skip to content

Add pipeline() support to Qwen2 and Qwen3-Next - #1862

Merged
michalk8 merged 8 commits into
ml-explore:mainfrom
twallgren:pr2-qwen2-qwen3next-pipeline
Sep 14, 2026
Merged

michalk8 merged 8 commits into
ml-explore:mainfrom
twallgren:pr2-qwen2-qwen3next-pipeline

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:
Pipeline parallelism wasn't supported for Qwen2 and Qwen3-Next model architectures. Attempting to use pipeline mode would result in it hanging after a "not supported" error happened on a background thread.

The solution:
Added support for pipeline parallelism for Qwen2 and Qwen3-Next model architectures. It follows the same pattern used for other models with similar structures.

I needed this change so I could properly use pipeline processing on my 3 node cluster with Qwen2 and Qwen3-Next models. I've been running it successfully for a while with great results.

@azamamirza

Copy link
Copy Markdown
Contributor

Same offer as #1861: this PR adds the pipeline paths but no tests, so I wrote coverage and verified it on this branch locally (Apple Silicon, M5 Max):

  1. Added qwen2 and qwen3_next tiny configs to test_pipeline: real forward-pass equality against the unpipelined model under a size-1 group passes for both.
  2. Added both to the uneven layer-assignment check (7 layers on 2 ranks): every layer runs on exactly one rank for qwen3_moe, qwen2 and qwen3_next.
  3. The hybrid rescan behaves correctly at the edge I was most worried about: with full_attention_interval=4 and 7 layers on 2 ranks, the last rank's slice (layers 0-2) is all linear layers and fa_idx correctly stays None, while rank 0 gets its full-attention layer at local index 0. Both asserted.
  4. tests/model_parallel_tests.py on this branch with the additions: 4 passed + 10 subtests. tests/test_models.py -k "qwen2 or qwen3" - 5 passed.

One honest limitation: the fa_idx=None forward path only executes under real multi-rank communication, which a unit test can't reach. The assertions cover the index rescan, and the size-1 forward covers the combined path.

Diff below applies cleanly to this branch if you want to fold it in like the #1861 one.

regression tests (applies to this branch)
diff --git a/tests/model_parallel_tests.py b/tests/model_parallel_tests.py
index 1d9bb89..cb9c621 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 qwen2, qwen3_moe, qwen3_next
 from mlx_lm.models.pipeline import PipelineMixin
 
 
@@ -170,6 +170,45 @@ class TestModelParallel(unittest.TestCase):
                 "max_position_embeddings": 256,
                 "tie_word_embeddings": False,
             },
+            {
+                "model_type": "qwen2",
+                "vocab_size": 128,
+                "hidden_size": 64,
+                "intermediate_size": 128,
+                "num_hidden_layers": 4,
+                "num_attention_heads": 4,
+                "num_key_value_heads": 2,
+                "rms_norm_eps": 1e-5,
+                "rope_theta": 10000.0,
+                "max_position_embeddings": 256,
+                "tie_word_embeddings": False,
+            },
+            {
+                "model_type": "qwen3_next",
+                "vocab_size": 128,
+                "hidden_size": 64,
+                "num_hidden_layers": 4,
+                "intermediate_size": 128,
+                "num_attention_heads": 4,
+                "num_key_value_heads": 2,
+                "head_dim": 16,
+                "linear_num_value_heads": 4,
+                "linear_num_key_heads": 2,
+                "linear_key_head_dim": 16,
+                "linear_value_head_dim": 16,
+                "linear_conv_kernel_dim": 4,
+                "num_experts": 4,
+                "num_experts_per_tok": 2,
+                "decoder_sparse_step": 1,
+                "shared_expert_intermediate_size": 32,
+                "mlp_only_layers": [0],
+                "moe_intermediate_size": 32,
+                "rms_norm_eps": 1e-5,
+                "rope_theta": 10000.0,
+                "partial_rotary_factor": 0.25,
+                "max_position_embeddings": 256,
+                "full_attention_interval": 4,
+            },
         ]
         mx.random.seed(0)
         for config in test_configs:
@@ -247,15 +286,74 @@ 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)))
+        qwen2_config = {
+            "model_type": "qwen2",
+            "vocab_size": 128,
+            "hidden_size": 64,
+            "intermediate_size": 128,
+            "num_hidden_layers": 7,
+            "num_attention_heads": 4,
+            "num_key_value_heads": 2,
+            "rms_norm_eps": 1e-5,
+            "rope_theta": 10000.0,
+            "max_position_embeddings": 256,
+            "tie_word_embeddings": False,
+        }
+        qwen3_next_config = {
+            "model_type": "qwen3_next",
+            "vocab_size": 128,
+            "hidden_size": 64,
+            "num_hidden_layers": 7,
+            "intermediate_size": 128,
+            "num_attention_heads": 4,
+            "num_key_value_heads": 2,
+            "head_dim": 16,
+            "linear_num_value_heads": 4,
+            "linear_num_key_heads": 2,
+            "linear_key_head_dim": 16,
+            "linear_value_head_dim": 16,
+            "linear_conv_kernel_dim": 4,
+            "num_experts": 4,
+            "num_experts_per_tok": 2,
+            "decoder_sparse_step": 1,
+            "shared_expert_intermediate_size": 32,
+            "mlp_only_layers": [0],
+            "moe_intermediate_size": 32,
+            "rms_norm_eps": 1e-5,
+            "rope_theta": 10000.0,
+            "partial_rotary_factor": 0.25,
+            "max_position_embeddings": 256,
+            "full_attention_interval": 4,
+        }
+        for arch, arch_config in (
+            (qwen3_moe, config),
+            (qwen2, qwen2_config),
+            (qwen3_next, qwen3_next_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)))
+
+        # Hybrid edge case: 7 layers on 2 ranks puts layers 0-2 (all linear,
+        # full_attention_interval=4) on the last rank; its rescan must leave
+        # fa_idx as None rather than pointing at a wrong layer. Rank 0 holds
+        # layers 3-6, so its full-attention layer sits at local index 0.
+        args = qwen3_next.ModelArgs.from_dict(qwen3_next_config)
+        model = qwen3_next.Model(args)
+        model.model.pipeline(Group(1, size))
+        self.assertIsNone(model.model.fa_idx)
+        self.assertEqual(model.model.ssm_idx, 0)
+        model = qwen3_next.Model(args)
+        model.model.pipeline(Group(0, size))
+        self.assertEqual(model.model.fa_idx, 0)
+        self.assertEqual(model.model.ssm_idx, 1)
 
 
 if __name__ == "__main__":

@twallgren

Copy link
Copy Markdown
Contributor Author

Thanks again @azamamirza and apologies for the repeat issue with missing tests. I know I had tests in my local changes but they must have gotten dropped by accident during some refactoring. I've applied your diff and confirmed the tests pass.

@twallgren
twallgren force-pushed the pr2-qwen2-qwen3next-pipeline branch from 3318667 to 9c72fc2 Compare September 10, 2026 18:35
Both had no pipeline() at all (Qwen2 had shard(), Qwen3-Next had neither).
Forcing --pipeline on a model without pipeline() support currently hangs
rather than failing cleanly: mlx_lm.server loads the model lazily on the
first request, so sharded_load's "doesn't support pipelining" ValueError
fires deep in a background generate thread that never signals the HTTP
layer back, and the request just blocks forever.

Qwen2: every layer is uniform plain attention (no sliding-window or
linear-attention types), so this is the simplest possible port --
PipelineMixin on Qwen2Model, the standard recv/send/all_gather
boilerplate, a single mask built once per forward call.

Qwen3-Next is a hybrid architecture: 3 of every 4 layers are
Qwen3NextGatedDeltaNet (a linear-attention/state-space layer with a
recurrent state, cached via ArraysCache), 1 of 4 is standard attention
(KVCache). Modeled directly on qwen3_5.py's existing pipeline()
implementation, which already solves the identical hybrid-cache problem:
pipeline() is overridden to re-derive the local index of the first
full-attention and first linear layer within this rank's truncated slice
(the naive fixed indices computed at __init__ time go stale once
pipeline() truncates the layer list), and the anti-graph-pruning
mx.depends guard on the pipeline send branches on whether the last local
layer's cache is a KVCache (.keys) or an ArraysCache (indexed).

Also fixes a latent bug in Qwen3-Next's sanitize(): it gated MoE
expert-weight stacking on a fixed model.layers.0... key and then iterated
every layer in the config, popping each one's per-expert weights
unconditionally. Under pipeline sharding a rank only downloads its own
local layers' weight files, so a rank not owning layer 0 would skip
stacking entirely (wrong, if it needed it), and the rank owning layer 0
would KeyError on a layer it doesn't have. Only reachable for a raw
(unconverted, not MLX-pre-stacked) checkpoint under pipeline sharding.
Detects and iterates by which layers are actually present in the local
weights dict instead.

Live-tested on a real 3-node cluster: even per-node memory split (real
distribution, not silent single-node fallback), output byte-identical to
a known-good non-distributed baseline on a short prompt, and a 60-step
sequential-generation test came back fully correct for both models,
exercising many repeated forward calls reusing the same cache state.

Went through 3 rounds of review (Claude Opus, fresh context each round).
Contributed by @azamamirza in PR review: extends the existing
qwen3_moe uneven-assignment regression test to cover qwen2 and
qwen3_next, plus asserts the qwen3_next hybrid attention/linear-layer
rescan (fa_idx/ssm_idx) lands correctly at an uneven 7-layer/2-rank
split.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
…tion

Same fix as qwen3_moe.py (ml-explore#1875): the comment described an
unreachable pipeline-sharded partial-download scenario. The real bug
is architectural — whether layer 0 is an MoE layer is config-driven,
so a layer-0-only gate is wrong regardless of sharding.
@twallgren
twallgren force-pushed the pr2-qwen2-qwen3next-pipeline branch from a730d19 to 4ececf2 Compare September 11, 2026 22:10
@twallgren
twallgren marked this pull request as ready for review September 11, 2026 22:10
@michalk8
michalk8 self-requested a review September 14, 2026 16:36

@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 d8f7f88 into ml-explore:main Sep 14, 2026
2 checks passed
@twallgren
twallgren deleted the pr2-qwen2-qwen3next-pipeline branch September 14, 2026 16:49
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