Add support for Qwen3.5 (#1644)

## Motivation

Qwen3.5 MoE models (e.g., `Qwen3.5-397B-A17B-6bit`) are now supported by
`mlx-lm` via `qwen3_5_moe` model type, but exo lacks tensor parallel
sharding support for this architecture. This prevents running large
Qwen3.5 models across multiple nodes.

Qwen3.5 uses a GatedDeltaNet hybrid attention mechanism similar to
Qwen3-Next, but with a different projection layout — separate
`in_proj_qkv`, `in_proj_z`, `in_proj_b`, `in_proj_a` instead of
Qwen3-Next's combined `in_proj_qkvz` and `in_proj_ba`. This requires
architecture-aware sharding logic.

## Changes (evan summary)

- enable qwen3_5 dense + moe tensor parallelism from config
- defensively skip evalling _cache.keys if it doesn't exist
- ignore kwargs in qwen35 pipeline masking and ensure pipeline segments match global model parameters for mask creation
- add sharding for qwen3_5 moe linear attention
- added another 6 million model cards

## Why It Works

Qwen3.5's GatedDeltaNet has an `in_proj_qkv` linear layer with three
concatenated sections: `[q(key_dim), k(key_dim), v(value_dim)]`. A naive
contiguous split (`segments=1`) would slice across section boundaries,
corrupting q/k/v values and producing garbled output.

By passing `segments=[key_dim, key_dim + key_dim]` to `shard_linear()`,
each section is split independently before distributing across devices.
This ensures every rank receives correctly aligned q, k, and v
components.

The remaining separate projections (`in_proj_z`, `in_proj_b`,
`in_proj_a`) and the MoE layers follow the same `all_to_sharded` /
`sharded_to_all` pattern already used for Qwen3-Next.

Some pipeline splits didn't include an ssm layer or a linear layer resulting in a subset of the model acting like it shouldn't create the appropriate masks for the next layer - we patch the model to manually create such masks.

## Test Plan

tensor sharded 2,3,4 models & pipeline sharded 2,3,4 with simple eval.

---------

Co-authored-by: hw <hw@hwStudio1.local>
Co-authored-by: Ryuichi Leo Takashige <leo@exolabs.net>
Co-authored-by: Evan <evanev7@gmail.com>
This commit is contained in:
Daiz
2026-03-03 23:31:57 +09:00
committed by GitHub
parent 0e1b9501c3
commit 28817d3ee3
24 changed files with 503 additions and 28 deletions
+4 -4
View File
@@ -164,8 +164,9 @@ class KVCache(_BaseCache):
def to_quantized( def to_quantized(
self, group_size: int = ..., bits: int = ... self, group_size: int = ..., bits: int = ...
) -> QuantizedKVCache: ... ) -> QuantizedKVCache: ...
def make_mask(self, *args, **kwargs): # -> array | Literal['causal'] | None: def make_mask(
... self, *args: Any, **kwargs: Any
) -> mx.array | Literal["causal"] | None: ...
class RotatingKVCache(_BaseCache): class RotatingKVCache(_BaseCache):
step = ... step = ...
@@ -218,8 +219,7 @@ class ArraysCache(_BaseCache):
In-place extend this cache with the other cache. In-place extend this cache with the other cache.
""" """
def make_mask(self, N: int): # -> array | None: def make_mask(self, N: int) -> mx.array | None: ...
...
class MambaCache(ArraysCache): class MambaCache(ArraysCache):
def __init__(self, left_padding: Optional[List[int]] = ...) -> None: ... def __init__(self, left_padding: Optional[List[int]] = ...) -> None: ...
+153
View File
@@ -0,0 +1,153 @@
from dataclasses import dataclass
from typing import Any, Optional
import mlx.core as mx
import mlx.nn as nn
from .cache import ArraysCache, KVCache
from .qwen3_next import (
Qwen3NextAttention as Attention,
Qwen3NextMLP as MLP,
Qwen3NextRMSNormGated as RMSNormGated,
Qwen3NextSparseMoeBlock,
)
SparseMoeBlock = Qwen3NextSparseMoeBlock
from .switch_layers import SwitchGLU
@dataclass
class TextModelArgs:
model_type: str
hidden_size: int
intermediate_size: int
num_hidden_layers: int
num_attention_heads: int
rms_norm_eps: float
vocab_size: int
num_key_value_heads: int
max_position_embeddings: int
linear_num_value_heads: int
linear_num_key_heads: int
linear_key_head_dim: int
linear_value_head_dim: int
linear_conv_kernel_dim: int
tie_word_embeddings: bool
attention_bias: bool
head_dim: Optional[int]
full_attention_interval: int
num_experts: int
num_experts_per_tok: int
decoder_sparse_step: int
shared_expert_intermediate_size: int
moe_intermediate_size: int
norm_topk_prob: bool
rope_parameters: Optional[dict[str, Any]]
partial_rotary_factor: float
rope_theta: float
rope_scaling: Optional[dict[str, Any]]
@classmethod
def from_dict(cls, params: dict[str, Any]) -> TextModelArgs: ...
def __post_init__(self) -> None: ...
class GatedDeltaNet(nn.Module):
hidden_size: int
num_v_heads: int
num_k_heads: int
head_k_dim: int
head_v_dim: int
key_dim: int
value_dim: int
conv_kernel_size: int
conv_dim: int
conv1d: nn.Conv1d
in_proj_qkv: nn.Linear
in_proj_z: nn.Linear
in_proj_b: nn.Linear
in_proj_a: nn.Linear
dt_bias: mx.array
A_log: mx.array
norm: RMSNormGated
out_proj: nn.Linear
def __init__(self, config: TextModelArgs) -> None: ...
def __call__(
self,
inputs: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[Any] = None,
) -> mx.array: ...
class DecoderLayer(nn.Module):
is_linear: bool
linear_attn: GatedDeltaNet
self_attn: Attention
input_layernorm: nn.RMSNorm
post_attention_layernorm: nn.RMSNorm
mlp: MLP | SparseMoeBlock
def __init__(self, args: TextModelArgs, layer_idx: int) -> None: ...
def __call__(
self,
x: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[Any] = None,
) -> mx.array: ...
class Qwen3_5TextModel(nn.Module):
embed_tokens: nn.Embedding
layers: list[DecoderLayer]
norm: nn.RMSNorm
ssm_idx: int
fa_idx: int
def __init__(self, args: TextModelArgs) -> None: ...
def __call__(
self,
inputs: mx.array,
cache: Optional[Any] = None,
input_embeddings: Optional[mx.array] = None,
) -> mx.array: ...
class TextModel(nn.Module):
args: TextModelArgs
model_type: str
model: Qwen3_5TextModel
lm_head: nn.Linear
def __init__(self, args: TextModelArgs) -> None: ...
def __call__(
self,
inputs: mx.array,
cache: Optional[Any] = None,
input_embeddings: Optional[mx.array] = None,
) -> mx.array: ...
@property
def layers(self) -> list[DecoderLayer]: ...
def make_cache(self) -> list[ArraysCache | KVCache]: ...
def sanitize(self, weights: dict[str, Any]) -> dict[str, Any]: ...
@dataclass
class ModelArgs:
model_type: str
text_config: dict[str, Any]
@classmethod
def from_dict(cls, params: dict[str, Any]) -> ModelArgs: ...
class Model(nn.Module):
args: ModelArgs
model_type: str
language_model: TextModel
def __init__(self, args: ModelArgs) -> None: ...
def __call__(
self,
inputs: mx.array,
cache: Optional[Any] = None,
input_embeddings: Optional[mx.array] = None,
) -> mx.array: ...
def sanitize(self, weights: dict[str, Any]) -> dict[str, Any]: ...
@property
def layers(self) -> list[DecoderLayer]: ...
def make_cache(self) -> list[ArraysCache | KVCache]: ...
@@ -0,0 +1,19 @@
from dataclasses import dataclass
from typing import Any, Optional
import mlx.core as mx
import mlx.nn as nn
from .cache import ArraysCache, KVCache
from .qwen3_5 import DecoderLayer, Model as Qwen3_5Model, TextModel
@dataclass
class ModelArgs:
model_type: str
text_config: dict[str, Any]
@classmethod
def from_dict(cls, params: dict[str, Any]) -> ModelArgs: ...
class Model(Qwen3_5Model):
def sanitize(self, weights: dict[str, Any]) -> dict[str, Any]: ...
@@ -7,6 +7,15 @@ import mlx.nn as nn
from .switch_layers import SwitchGLU from .switch_layers import SwitchGLU
class Qwen3NextRMSNormGated(nn.Module):
eps: float
weight: mx.array
def __init__(self, hidden_size: int, eps: float = ...) -> None: ...
def __call__(
self, hidden_states: mx.array, gate: mx.array | None = None
) -> mx.array: ...
class Qwen3NextMLP(nn.Module): class Qwen3NextMLP(nn.Module):
gate_proj: nn.Linear gate_proj: nn.Linear
down_proj: nn.Linear down_proj: nn.Linear
+2 -2
View File
@@ -19,7 +19,7 @@ dependencies = [
"anyio==4.11.0", "anyio==4.11.0",
"mlx; sys_platform == 'darwin'", "mlx; sys_platform == 'darwin'",
"mlx[cpu]==0.30.6; sys_platform == 'linux'", "mlx[cpu]==0.30.6; sys_platform == 'linux'",
"mlx-lm==0.30.7", "mlx-lm",
"tiktoken>=0.12.0", # required for kimi k2 tokenizer "tiktoken>=0.12.0", # required for kimi k2 tokenizer
"hypercorn>=0.18.0", "hypercorn>=0.18.0",
"openai-harmony>=0.0.8", "openai-harmony>=0.0.8",
@@ -62,7 +62,7 @@ members = ["rust/exo_pyo3_bindings", "bench"]
[tool.uv.sources] [tool.uv.sources]
exo_pyo3_bindings = { workspace = true } exo_pyo3_bindings = { workspace = true }
mlx = { git = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv.git", branch = "address-rdma-gpu-locks", marker = "sys_platform == 'darwin'" } mlx = { git = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv.git", branch = "address-rdma-gpu-locks", marker = "sys_platform == 'darwin'" }
#mlx-lm = { git = "https://github.com/davidmcc73/mlx-lm", branch = "stable" } mlx-lm = { git = "https://github.com/ml-explore/mlx-lm", rev = "834fac934c4e04de9b3d723e2b9287a2c60cfd4a" }
# Uncomment to use local mlx/mlx-lm development versions: # Uncomment to use local mlx/mlx-lm development versions:
# mlx = { path = "/Users/Shared/mlx", editable=true } # mlx = { path = "/Users/Shared/mlx", editable=true }
# mlx-lm = { path = "/Users/Shared/mlx-lm", editable=true } # mlx-lm = { path = "/Users/Shared/mlx-lm", editable=true }
@@ -0,0 +1,12 @@
model_id = "mlx-community/Qwen3.5-122B-A10B-4bit"
n_layers = 48
hidden_size = 3072
supports_tensor = true
tasks = ["TextGeneration"]
family = "qwen"
quantization = "4bit"
base_model = "Qwen3.5 122B A10B"
capabilities = ["text", "thinking", "thinking_toggle"]
[storage_size]
in_bytes = 69593314272
@@ -0,0 +1,12 @@
model_id = "mlx-community/Qwen3.5-122B-A10B-6bit"
n_layers = 48
hidden_size = 3072
supports_tensor = true
tasks = ["TextGeneration"]
family = "qwen"
quantization = "6bit"
base_model = "Qwen3.5 122B A10B"
capabilities = ["text", "thinking", "thinking_toggle"]
[storage_size]
in_bytes = 100120675296
@@ -0,0 +1,12 @@
model_id = "mlx-community/Qwen3.5-122B-A10B-8bit"
n_layers = 48
hidden_size = 3072
supports_tensor = true
tasks = ["TextGeneration"]
family = "qwen"
quantization = "8bit"
base_model = "Qwen3.5 122B A10B"
capabilities = ["text", "thinking", "thinking_toggle"]
[storage_size]
in_bytes = 130648036320
@@ -0,0 +1,12 @@
model_id = "mlx-community/Qwen3.5-122B-A10B-bf16"
n_layers = 48
hidden_size = 3072
supports_tensor = true
tasks = ["TextGeneration"]
family = "qwen"
quantization = "bf16"
base_model = "Qwen3.5 122B A10B"
capabilities = ["text", "thinking", "thinking_toggle"]
[storage_size]
in_bytes = 245125640160
@@ -0,0 +1,12 @@
model_id = "mlx-community/Qwen3.5-27B-4bit"
n_layers = 64
hidden_size = 5120
supports_tensor = true
tasks = ["TextGeneration"]
family = "qwen"
quantization = "4bit"
base_model = "Qwen3.5 27B"
capabilities = ["text", "thinking"]
[storage_size]
in_bytes = 16054266848
@@ -0,0 +1,12 @@
model_id = "mlx-community/Qwen3.5-27B-8bit"
n_layers = 64
hidden_size = 5120
supports_tensor = true
tasks = ["TextGeneration"]
family = "qwen"
quantization = "8bit"
base_model = "Qwen3.5 27B"
capabilities = ["text", "thinking"]
[storage_size]
in_bytes = 29500943328
@@ -0,0 +1,12 @@
model_id = "mlx-community/Qwen3.5-2B-MLX-8bit"
n_layers = 24
hidden_size = 2048
supports_tensor = true
tasks = ["TextGeneration"]
family = "qwen"
quantization = "8bit"
base_model = "Qwen3.5 2B"
capabilities = ["text", "thinking"]
[storage_size]
in_bytes = 2662787264
@@ -0,0 +1,12 @@
model_id = "mlx-community/Qwen3.5-35B-A3B-4bit"
n_layers = 40
hidden_size = 2048
supports_tensor = true
tasks = ["TextGeneration"]
family = "qwen"
quantization = "4bit"
base_model = "Qwen3.5 35B A3B"
capabilities = ["text", "thinking", "thinking_toggle"]
[storage_size]
in_bytes = 20391405152
@@ -0,0 +1,12 @@
model_id = "mlx-community/Qwen3.5-35B-A3B-8bit"
n_layers = 40
hidden_size = 2048
supports_tensor = true
tasks = ["TextGeneration"]
family = "qwen"
quantization = "8bit"
base_model = "Qwen3.5 35B A3B"
capabilities = ["text", "thinking", "thinking_toggle"]
[storage_size]
in_bytes = 37721130592
@@ -0,0 +1,12 @@
model_id = "mlx-community/Qwen3.5-397B-A17B-4bit"
n_layers = 60
hidden_size = 4096
supports_tensor = true
tasks = ["TextGeneration"]
family = "qwen"
quantization = "4bit"
base_model = "Qwen3.5 397B A17B"
capabilities = ["text", "thinking", "thinking_toggle"]
[storage_size]
in_bytes = 223860768352
@@ -0,0 +1,12 @@
model_id = "mlx-community/Qwen3.5-397B-A17B-6bit"
n_layers = 60
hidden_size = 4096
supports_tensor = true
tasks = ["TextGeneration"]
family = "qwen"
quantization = "6bit"
base_model = "Qwen3.5 397B A17B"
capabilities = ["text", "thinking", "thinking_toggle"]
[storage_size]
in_bytes = 322946674272
@@ -0,0 +1,12 @@
model_id = "mlx-community/Qwen3.5-397B-A17B-8bit"
n_layers = 60
hidden_size = 4096
supports_tensor = true
tasks = ["TextGeneration"]
family = "qwen"
quantization = "8bit"
base_model = "Qwen3.5 397B A17B"
capabilities = ["text", "thinking", "thinking_toggle"]
[storage_size]
in_bytes = 422032580192
@@ -0,0 +1,12 @@
model_id = "mlx-community/Qwen3.5-9B-4bit"
n_layers = 32
hidden_size = 4096
supports_tensor = true
tasks = ["TextGeneration"]
family = "qwen"
quantization = "4bit"
base_model = "Qwen3.5 9B"
capabilities = ["text", "thinking"]
[storage_size]
in_bytes = 5950062560
@@ -0,0 +1,12 @@
model_id = "mlx-community/Qwen3.5-9B-8bit"
n_layers = 32
hidden_size = 4096
supports_tensor = true
tasks = ["TextGeneration"]
family = "qwen"
quantization = "8bit"
base_model = "Qwen3.5 9B"
capabilities = ["text", "thinking"]
[storage_size]
in_bytes = 10426433504
+2
View File
@@ -190,6 +190,8 @@ class ConfigData(BaseModel):
["DeepseekV3ForCausalLM"], ["DeepseekV3ForCausalLM"],
["Qwen3NextForCausalLM"], ["Qwen3NextForCausalLM"],
["Qwen3MoeForCausalLM"], ["Qwen3MoeForCausalLM"],
["Qwen3_5MoeForConditionalGeneration"],
["Qwen3_5ForConditionalGeneration"],
["MiniMaxM2ForCausalLM"], ["MiniMaxM2ForCausalLM"],
["LlamaForCausalLM"], ["LlamaForCausalLM"],
["GptOssForCausalLM"], ["GptOssForCausalLM"],
+107 -15
View File
@@ -16,6 +16,7 @@ from mlx.nn.layers.distributed import (
from mlx_lm.models.base import ( from mlx_lm.models.base import (
scaled_dot_product_attention, # pyright: ignore[reportUnknownVariableType] scaled_dot_product_attention, # pyright: ignore[reportUnknownVariableType]
) )
from mlx_lm.models.cache import ArraysCache, KVCache
from mlx_lm.models.deepseek_v3 import DeepseekV3MLP from mlx_lm.models.deepseek_v3 import DeepseekV3MLP
from mlx_lm.models.deepseek_v3 import Model as DeepseekV3Model from mlx_lm.models.deepseek_v3 import Model as DeepseekV3Model
from mlx_lm.models.deepseek_v32 import DeepseekV32MLP from mlx_lm.models.deepseek_v32 import DeepseekV32MLP
@@ -31,10 +32,19 @@ from mlx_lm.models.llama import Model as LlamaModel
from mlx_lm.models.minimax import MiniMaxAttention from mlx_lm.models.minimax import MiniMaxAttention
from mlx_lm.models.minimax import Model as MiniMaxModel from mlx_lm.models.minimax import Model as MiniMaxModel
from mlx_lm.models.ministral3 import Model as Ministral3Model from mlx_lm.models.ministral3 import Model as Ministral3Model
from mlx_lm.models.qwen3_5 import DecoderLayer as Qwen3_5DecoderLayer
from mlx_lm.models.qwen3_5 import Model as Qwen3_5TextModel
from mlx_lm.models.qwen3_5 import Qwen3_5TextModel as Qwen3_5TextModelInner
from mlx_lm.models.qwen3_5 import SparseMoeBlock as Qwen3_5SparseMoeBlock
from mlx_lm.models.qwen3_5_moe import Model as Qwen3_5MoeModel
from mlx_lm.models.qwen3_moe import Model as Qwen3MoeModel from mlx_lm.models.qwen3_moe import Model as Qwen3MoeModel
from mlx_lm.models.qwen3_moe import Qwen3MoeDecoderLayer, Qwen3MoeSparseMoeBlock from mlx_lm.models.qwen3_moe import Qwen3MoeDecoderLayer, Qwen3MoeSparseMoeBlock
from mlx_lm.models.qwen3_next import Model as Qwen3NextModel from mlx_lm.models.qwen3_next import Model as Qwen3NextModel
from mlx_lm.models.qwen3_next import Qwen3NextDecoderLayer, Qwen3NextSparseMoeBlock from mlx_lm.models.qwen3_next import (
Qwen3NextDecoderLayer,
Qwen3NextGatedDeltaNet,
Qwen3NextSparseMoeBlock,
)
from mlx_lm.models.step3p5 import Model as Step35Model from mlx_lm.models.step3p5 import Model as Step35Model
from mlx_lm.models.step3p5 import Step3p5MLP as Step35MLP from mlx_lm.models.step3p5 import Step3p5MLP as Step35MLP
from mlx_lm.models.step3p5 import Step3p5Model as Step35InnerModel from mlx_lm.models.step3p5 import Step3p5Model as Step35InnerModel
@@ -191,9 +201,10 @@ class PipelineLastLayer(CustomMlxLayer):
# CacheList (used by MLA models like DeepSeekV32, GLM MoE DSA) # CacheList (used by MLA models like DeepSeekV32, GLM MoE DSA)
# doesn't have .keys directly; access via first sub-cache. # doesn't have .keys directly; access via first sub-cache.
_cache = cache[0] if hasattr(cache, "caches") else cache # type: ignore _cache = cache[0] if hasattr(cache, "caches") else cache # type: ignore
_cache.keys = mx.depends(_cache.keys, output) # type: ignore if hasattr(_cache, "keys"): # pyright: ignore[reportAny]
_cache.keys = mx.depends(_cache.keys, output) # type: ignore
mx.eval(output) mx.eval(output)
if cache is not None: if cache is not None and hasattr(_cache, "keys"): # type: ignore
mx.eval(_cache.keys) # type: ignore mx.eval(_cache.keys) # type: ignore
if not self.is_prefill: if not self.is_prefill:
@@ -248,6 +259,32 @@ def get_layers(inner_model_instance: nn.Module) -> list[_LayerCallable]:
return layers return layers
def _patch_qwen35_cache(
model: Qwen3_5TextModel,
fa_idx: int,
has_full_attn: bool,
ssm_idx: int,
has_linear: bool,
) -> None:
# Hacks to make make_mask happy.
original = model.make_cache
def patched() -> list[ArraysCache | KVCache]:
cache: list[ArraysCache | KVCache] = original()
if not has_full_attn:
entry = cache[fa_idx]
orig_make_mask = entry.make_mask
entry.make_mask = lambda n, **_kw: orig_make_mask(n) # type: ignore
if not has_linear:
orig_ssm_make_mask = cache[ssm_idx].make_mask
cache[ssm_idx].make_mask = ( # type: ignore
lambda n, **kw: orig_ssm_make_mask(n, **kw) if kw else None # type: ignore
)
return cache
model.make_cache = patched
def pipeline_auto_parallel( def pipeline_auto_parallel(
model: nn.Module, model: nn.Module,
group: mx.distributed.Group, group: mx.distributed.Group,
@@ -318,6 +355,24 @@ def pipeline_auto_parallel(
inner_model_instance._swa_idx = 0 if not sliding_layers else sliding_layers[0] inner_model_instance._swa_idx = 0 if not sliding_layers else sliding_layers[0]
inner_model_instance._full_idx = 0 if not full_layers else full_layers[0] inner_model_instance._full_idx = 0 if not full_layers else full_layers[0]
if isinstance(inner_model_instance, Qwen3_5TextModelInner):
full_attn_layers = [
i for i, layer in enumerate(layers) if not getattr(layer, "is_linear", True)
]
linear_layers = [
i for i, layer in enumerate(layers) if getattr(layer, "is_linear", False)
]
inner_model_instance.fa_idx = full_attn_layers[0] if full_attn_layers else 0
inner_model_instance.ssm_idx = linear_layers[0] if linear_layers else 0
if not full_attn_layers or not linear_layers:
_patch_qwen35_cache(
cast(Qwen3_5TextModel, model),
fa_idx=inner_model_instance.fa_idx,
has_full_attn=bool(full_attn_layers),
ssm_idx=inner_model_instance.ssm_idx,
has_linear=bool(linear_layers),
)
_set_layers(model, layers) _set_layers(model, layers)
assert isinstance(layers, list), ( assert isinstance(layers, list), (
@@ -347,7 +402,8 @@ def patch_pipeline_model[T](model: T, group: mx.distributed.Group) -> T:
if cache is not None: if cache is not None:
last = cache[-1] # type: ignore last = cache[-1] # type: ignore
dep_cache = last[0] if hasattr(last, "caches") else last # type: ignore dep_cache = last[0] if hasattr(last, "caches") else last # type: ignore
dep_cache.keys = mx.depends(dep_cache.keys, logits) # type: ignore if hasattr(dep_cache, "keys") and dep_cache.keys is not None: # type: ignore
dep_cache.keys = mx.depends(dep_cache.keys, logits) # type: ignore
return logits return logits
@@ -470,7 +526,9 @@ def tensor_auto_parallel(
all_to_sharded_linear_in_place, all_to_sharded_linear_in_place,
sharded_to_all_linear_in_place, sharded_to_all_linear_in_place,
) )
elif isinstance(model, (Qwen3MoeModel, Qwen3NextModel)): elif isinstance(
model, (Qwen3MoeModel, Qwen3NextModel, Qwen3_5TextModel, Qwen3_5MoeModel)
):
tensor_parallel_sharding_strategy = QwenShardingStrategy( tensor_parallel_sharding_strategy = QwenShardingStrategy(
group, group,
all_to_sharded_linear, all_to_sharded_linear,
@@ -865,7 +923,9 @@ class QwenShardingStrategy(TensorParallelShardingStrategy):
on_timeout: TimeoutCallback | None, on_timeout: TimeoutCallback | None,
on_layer_loaded: LayerLoadedCallback | None, on_layer_loaded: LayerLoadedCallback | None,
) -> nn.Module: ) -> nn.Module:
model = cast(Qwen3MoeModel | Qwen3NextModel, model) model = cast(
Qwen3MoeModel | Qwen3NextModel | Qwen3_5TextModel | Qwen3_5MoeModel, model
)
total = len(model.layers) total = len(model.layers)
for i, layer in enumerate(model.layers): for i, layer in enumerate(model.layers):
eval_with_timeout(layer.parameters(), timeout_seconds / total, on_timeout) eval_with_timeout(layer.parameters(), timeout_seconds / total, on_timeout)
@@ -886,16 +946,39 @@ class QwenShardingStrategy(TensorParallelShardingStrategy):
layer.self_attn.n_heads //= self.N layer.self_attn.n_heads //= self.N
layer.self_attn.n_kv_heads //= self.N layer.self_attn.n_kv_heads //= self.N
else: else:
assert isinstance(layer, Qwen3NextDecoderLayer) assert isinstance(layer, (Qwen3NextDecoderLayer, Qwen3_5DecoderLayer))
if hasattr(layer, "linear_attn"): if hasattr(layer, "linear_attn"):
linear_attn = layer.linear_attn linear_attn = layer.linear_attn
linear_attn.in_proj_qkvz = self.all_to_sharded_linear( if isinstance(linear_attn, Qwen3NextGatedDeltaNet):
linear_attn.in_proj_qkvz # Qwen3-Next: combined projections
) linear_attn.in_proj_qkvz = self.all_to_sharded_linear(
linear_attn.in_proj_ba = self.all_to_sharded_linear( linear_attn.in_proj_qkvz
linear_attn.in_proj_ba )
) linear_attn.in_proj_ba = self.all_to_sharded_linear(
linear_attn.in_proj_ba
)
else:
# Qwen3.5: separate projections
# in_proj_qkv has sections [q(key_dim), k(key_dim), v(value_dim)]
# that must be split section-aware, not as a contiguous block
key_dim = linear_attn.key_dim
value_dim = linear_attn.value_dim
linear_attn.in_proj_qkv = shard_linear(
linear_attn.in_proj_qkv,
"all-to-sharded",
segments=[key_dim, key_dim + key_dim],
group=self.group,
)
linear_attn.in_proj_z = self.all_to_sharded_linear(
linear_attn.in_proj_z
)
linear_attn.in_proj_b = self.all_to_sharded_linear(
linear_attn.in_proj_b
)
linear_attn.in_proj_a = self.all_to_sharded_linear(
linear_attn.in_proj_a
)
linear_attn.out_proj = self.sharded_to_all_linear( linear_attn.out_proj = self.sharded_to_all_linear(
linear_attn.out_proj linear_attn.out_proj
) )
@@ -957,11 +1040,20 @@ class QwenShardingStrategy(TensorParallelShardingStrategy):
layer.self_attn.num_key_value_heads //= self.N layer.self_attn.num_key_value_heads //= self.N
# Shard the MoE. # Shard the MoE.
if isinstance(layer.mlp, (Qwen3MoeSparseMoeBlock, Qwen3NextSparseMoeBlock)): if isinstance(
layer.mlp,
(
Qwen3MoeSparseMoeBlock,
Qwen3NextSparseMoeBlock,
Qwen3_5SparseMoeBlock,
),
):
self.all_to_sharded_linear_in_place(layer.mlp.switch_mlp.gate_proj) self.all_to_sharded_linear_in_place(layer.mlp.switch_mlp.gate_proj)
self.sharded_to_all_linear_in_place(layer.mlp.switch_mlp.down_proj) self.sharded_to_all_linear_in_place(layer.mlp.switch_mlp.down_proj)
self.all_to_sharded_linear_in_place(layer.mlp.switch_mlp.up_proj) self.all_to_sharded_linear_in_place(layer.mlp.switch_mlp.up_proj)
if isinstance(layer.mlp, Qwen3NextSparseMoeBlock): if isinstance(
layer.mlp, (Qwen3NextSparseMoeBlock, Qwen3_5SparseMoeBlock)
):
self.all_to_sharded_linear_in_place( self.all_to_sharded_linear_in_place(
layer.mlp.shared_expert.gate_proj layer.mlp.shared_expert.gate_proj
) )
+3
View File
@@ -318,6 +318,9 @@ def get_eos_token_ids_for_model(model_id: ModelId) -> list[int] | None:
return [151336, 151329, 151338] return [151336, 151329, 151338]
elif "gpt-oss" in model_id_lower: elif "gpt-oss" in model_id_lower:
return [200002, 200012] return [200002, 200012]
elif "qwen3.5" in model_id_lower or "qwen-3.5" in model_id_lower:
# For Qwen3.5: 248046 (<|im_end|>), 248044 (<|endoftext|>)
return [248046, 248044]
return None return None
+33
View File
@@ -0,0 +1,33 @@
"""
Generates inference model cards for EXO.
Usage:
uv run tmp/gen_card.py mlx-community/my_cool_model-8bit [repo-id/model-id-2] [...]
Model Cards require cleanup for family & quantization data
"""
import sys
import anyio
from exo.shared.models.model_cards import ModelCard, ModelId
async def main():
if len(sys.argv) == 1:
print(f"USAGE: {sys.argv[0]} repo-id/model-id-1 [repo-id/model-id-2] [...]")
quit(1)
print("Remember! Model Cards require cleanup for family & quantization data")
for arg in sys.argv[1:]:
mid = ModelId(arg)
mc = await ModelCard.fetch_from_hf(mid)
await mc.save(
anyio.Path(__file__).parent.parent
/ "resources"
/ "inference_model_cards"
/ (mid.normalize() + ".toml")
)
if __name__ == "__main__":
anyio.run(main)
Generated
+3 -7
View File
@@ -418,7 +418,7 @@ requires-dist = [
{ name = "mflux", specifier = "==0.15.5" }, { name = "mflux", specifier = "==0.15.5" },
{ name = "mlx", marker = "sys_platform == 'darwin'", git = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv.git?branch=address-rdma-gpu-locks" }, { name = "mlx", marker = "sys_platform == 'darwin'", git = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv.git?branch=address-rdma-gpu-locks" },
{ name = "mlx", extras = ["cpu"], marker = "sys_platform == 'linux'", specifier = "==0.30.6" }, { name = "mlx", extras = ["cpu"], marker = "sys_platform == 'linux'", specifier = "==0.30.6" },
{ name = "mlx-lm", specifier = "==0.30.7" }, { name = "mlx-lm", git = "https://github.com/ml-explore/mlx-lm?rev=834fac934c4e04de9b3d723e2b9287a2c60cfd4a" },
{ name = "msgspec", specifier = ">=0.19.0" }, { name = "msgspec", specifier = ">=0.19.0" },
{ name = "openai-harmony", specifier = ">=0.0.8" }, { name = "openai-harmony", specifier = ">=0.0.8" },
{ name = "pillow", specifier = ">=11.0,<12.0" }, { name = "pillow", specifier = ">=11.0,<12.0" },
@@ -1104,8 +1104,8 @@ wheels = [
[[package]] [[package]]
name = "mlx-lm" name = "mlx-lm"
version = "0.30.7" version = "0.30.8"
source = { registry = "https://pypi.org/simple" } source = { git = "https://github.com/ml-explore/mlx-lm?rev=834fac934c4e04de9b3d723e2b9287a2c60cfd4a#834fac934c4e04de9b3d723e2b9287a2c60cfd4a" }
dependencies = [ dependencies = [
{ name = "jinja2", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "jinja2", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "mlx", version = "0.30.7.dev20260225+257d5692", source = { git = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv.git?branch=address-rdma-gpu-locks#257d5692fc7af6bba3b8afaeb63c549b7d1e43d5" }, marker = "sys_platform == 'darwin'" }, { name = "mlx", version = "0.30.7.dev20260225+257d5692", source = { git = "https://github.com/rltakashige/mlx-jaccl-fix-small-recv.git?branch=address-rdma-gpu-locks#257d5692fc7af6bba3b8afaeb63c549b7d1e43d5" }, marker = "sys_platform == 'darwin'" },
@@ -1115,10 +1115,6 @@ dependencies = [
{ name = "sentencepiece", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "sentencepiece", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
{ name = "transformers", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "transformers", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/66/0d/56542e2ae13ec6f542d3977d7cff89a205d4f6c5122e0ce23f33265f61c9/mlx_lm-0.30.7.tar.gz", hash = "sha256:e5f31ac58d9f2381f28e1ba639ff903e64f7cff1bdc245c0bc97f72264be329c", size = 275764, upload-time = "2026-02-12T18:41:11.86Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/17/a41c798a3d9cbdc47f39c6db5bba4c2cd199203ead26bf911cb03b644070/mlx_lm-0.30.7-py3-none-any.whl", hash = "sha256:17442a4bf01c4c2d3bca1e647712fe44f19890c3f1eadc8589d389e57b44b9bf", size = 386591, upload-time = "2026-02-12T18:41:10.236Z" },
]
[[package]] [[package]]
name = "more-itertools" name = "more-itertools"