Add pipeline() support to Qwen2 and Qwen3-Next - #1862
Merged
michalk8 merged 8 commits intoSep 14, 2026
Merged
Conversation
twallgren
force-pushed
the
pr2-qwen2-qwen3next-pipeline
branch
from
September 8, 2026 18:35
55ea174 to
013ee36
Compare
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):
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__": |
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
force-pushed
the
pr2-qwen2-qwen3next-pipeline
branch
from
September 10, 2026 18:35
3318667 to
9c72fc2
Compare
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
force-pushed
the
pr2-qwen2-qwen3next-pipeline
branch
from
September 11, 2026 22:10
a730d19 to
4ececf2
Compare
twallgren
marked this pull request as ready for review
September 11, 2026 22:10
michalk8
self-requested a review
September 14, 2026 16:36
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.
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.