Compare commits

..

18 Commits

Author SHA1 Message Date
Angelos Katharopoulos 1b2d11b5c7 Bump the version (#692) 2025-12-18 13:43:49 -08:00
Awni Hannun 657a66c5c4 revert return dict and wrap apply_chat_template (#691) 2025-12-18 13:16:44 -08:00
Awni Hannun 595fb4bdbf bump to transformer v5 (#689) 2025-12-17 16:34:51 -08:00
Angelos Katharopoulos 79a0721c9a Model parallel generation (#676) 2025-12-17 13:35:28 -08:00
Awni Hannun cc3264c22e More useful error message for unsupported batching (#687) 2025-12-17 12:30:53 -08:00
Awni Hannun a227a9e9f3 Add mimo v2 flash (#685)
* add mimo v2 flash

* add test
2025-12-17 06:50:16 -08:00
Awni Hannun cd9ca9f068 fixes for transformers v5 (#684) 2025-12-17 06:08:08 -08:00
Jinhyeok Lee 7744d0f40b fix: server busy-waiting in request queue polling (#674) 2025-12-16 14:16:34 -08:00
Awni Hannun f3ed856610 support nemotron 3 (#678)
* support nemotron 3

* fix

* bump version
2025-12-16 08:50:44 -08:00
Inferencer ede65a1484 Fix for Devstral-2 (#671)
* Fix for Devstral-2

Convert cache offset to int for mx.arange compatibility in attention scale

* fix

---------

Co-authored-by: Awni Hannun <awni@apple.com>
2025-12-11 08:40:12 -08:00
Awni Hannun 3d3e0751a3 fix (#669) 2025-12-09 17:11:51 -08:00
Anthony 085e36e6ab Fix SuScaledRoPE (#660) 2025-12-09 07:59:28 -08:00
Angelos Katharopoulos eea2e5f5de Fix server batching condition for SSMs (#655) 2025-12-08 23:18:53 -08:00
Awni Hannun cb763947ee Fix fusion and test (#668) 2025-12-08 16:39:46 -08:00
Awni Hannun b343a0556f fix dsv32 and gemma3 (#664) 2025-12-08 16:14:10 -08:00
Awni Hannun 82dfd39ef2 default repetition penalty to 0.0 in the server (#658) 2025-12-08 16:14:00 -08:00
Awni Hannun 84996808a2 Use test data zipfile in CI (#662)
* make fewer requests in tests

* token
2025-12-08 16:13:34 -08:00
Hritik Kumar 99f8fd6cc8 fix: calling correct dequantize function (#666) 2025-12-08 13:34:42 -08:00
34 changed files with 1194 additions and 142 deletions
+3 -1
View File
@@ -38,4 +38,6 @@ jobs:
- name: Run tests
shell: bash -l {0}
run: |
python -m xmlrunner discover -v tests -o test-results/
curl -o test_data.zip -L https://github.com/ml-explore/mlx-lm/releases/download/test_data/test_data.zip
unzip test_data.zip
HF_HOME="." python -m xmlrunner discover -v tests -o test-results/
+2 -2
View File
@@ -71,7 +71,7 @@ prompt = "Write a story about Einstein"
messages = [{"role": "user", "content": prompt}]
prompt = tokenizer.apply_chat_template(
messages, add_generation_prompt=True
messages, add_generation_prompt=True,
)
text = generate(model, tokenizer, prompt=prompt, verbose=True)
@@ -130,7 +130,7 @@ prompt = "Write a story about Einstein"
messages = [{"role": "user", "content": prompt}]
prompt = tokenizer.apply_chat_template(
messages, add_generation_prompt=True
messages, add_generation_prompt=True,
)
for response in stream_generate(model, tokenizer, prompt, max_tokens=512):
+1 -1
View File
@@ -1,3 +1,3 @@
# Copyright © 2023-2025 Apple Inc.
__version__ = "0.29.0"
__version__ = "0.30.0"
+11 -2
View File
@@ -6,7 +6,7 @@ import mlx.core as mx
from mlx_lm import batch_generate, load, stream_generate
from mlx_lm.generate import DEFAULT_MODEL
from mlx_lm.utils import pipeline_load
from mlx_lm.utils import pipeline_load, sharded_load
def setup_arg_parser():
@@ -49,6 +49,11 @@ def setup_arg_parser():
help="Number of timing trials",
type=int,
)
parser.add_argument(
"--pipeline",
action="store_true",
help="Use pipelining instead of tensor parallelism",
)
return parser
@@ -59,6 +64,8 @@ def main():
group = mx.distributed.init()
rank = group.rank()
pipeline_group = group if args.pipeline else None
tensor_group = group if not args.pipeline else None
def rprint(*args, **kwargs):
if rank == 0:
@@ -67,7 +74,9 @@ def main():
model_path = args.model or DEFAULT_MODEL
if group.size() > 1:
model, tokenizer, config = pipeline_load(args.model, return_config=True)
model, tokenizer, config = sharded_load(
args.model, pipeline_group, tensor_group, return_config=True
)
else:
model, tokenizer, config = load(
args.model, return_config=True, tokenizer_config={"trust_remote_code": True}
+3 -1
View File
@@ -114,7 +114,9 @@ def main():
if not args.ignore_chat_template and tokenizer.chat_template is not None:
messages = [{"role": "user", "content": args.prompt}]
prompt = tokenizer.apply_chat_template(
messages, add_generation_prompt=False, continue_final_message=True
messages,
add_generation_prompt=False,
continue_final_message=True,
)
else:
+39 -17
View File
@@ -7,7 +7,7 @@ import mlx.core as mx
from .generate import stream_generate
from .models.cache import make_prompt_cache
from .sample_utils import make_sampler
from .utils import load
from .utils import load, sharded_load
DEFAULT_TEMP = 0.0
DEFAULT_TOP_P = 1.0
@@ -79,6 +79,11 @@ def setup_arg_parser():
default=None,
help="System prompt to be used for the chat template",
)
parser.add_argument(
"--pipeline",
action="store_true",
help="Use pipelining instead of tensor parallelism",
)
return parser
@@ -86,28 +91,42 @@ def main():
parser = setup_arg_parser()
args = parser.parse_args()
group = mx.distributed.init()
rank = group.rank()
pipeline_group = group if args.pipeline else None
tensor_group = group if not args.pipeline else None
def rprint(*args, **kwargs):
if rank == 0:
print(*args, **kwargs)
if args.seed is not None:
mx.random.seed(args.seed)
model, tokenizer = load(
args.model,
adapter_path=args.adapter_path,
tokenizer_config={
"trust_remote_code": True if args.trust_remote_code else None
},
)
if group.size() > 1:
if args.adapter_path:
parser.error("Adapters not supported in distributed mode")
model, tokenizer = sharded_load(args.model, pipeline_group, tensor_group)
else:
model, tokenizer = load(
args.model,
adapter_path=args.adapter_path,
tokenizer_config={
"trust_remote_code": True if args.trust_remote_code else None
},
)
def print_help():
print("The command list:")
print("- 'q' to exit")
print("- 'r' to reset the chat")
print("- 'h' to display these commands")
rprint("The command list:")
rprint("- 'q' to exit")
rprint("- 'r' to reset the chat")
rprint("- 'h' to display these commands")
print(f"[INFO] Starting chat session with {args.model}.")
rprint(f"[INFO] Starting chat session with {args.model}.")
print_help()
prompt_cache = make_prompt_cache(model, args.max_kv_size)
while True:
query = input(">> ")
query = input(">> " if rank == 0 else "")
if query == "q":
break
if query == "r":
@@ -120,7 +139,10 @@ def main():
if args.system_prompt is not None:
messages.append({"role": "system", "content": args.system_prompt})
messages.append({"role": "user", "content": query})
prompt = tokenizer.apply_chat_template(messages, add_generation_prompt=True)
prompt = tokenizer.apply_chat_template(
messages,
add_generation_prompt=True,
)
for response in stream_generate(
model,
tokenizer,
@@ -137,8 +159,8 @@ def main():
),
prompt_cache=prompt_cache,
):
print(response.text, flush=True, end="")
print()
rprint(response.text, flush=True, end="")
rprint()
if __name__ == "__main__":
+8 -2
View File
@@ -15,7 +15,10 @@ prompt_cache = make_prompt_cache(model)
# User turn
prompt = "Hi my name is <Name>."
messages = [{"role": "user", "content": prompt}]
prompt = tokenizer.apply_chat_template(messages, add_generation_prompt=True)
prompt = tokenizer.apply_chat_template(
messages,
add_generation_prompt=True,
)
# Assistant response
response = generate(
@@ -29,7 +32,10 @@ response = generate(
# User turn
prompt = "What's my name?"
messages = [{"role": "user", "content": prompt}]
prompt = tokenizer.apply_chat_template(messages, add_generation_prompt=True)
prompt = tokenizer.apply_chat_template(
messages,
add_generation_prompt=True,
)
# Assistant response
response = generate(
+2 -1
View File
@@ -14,7 +14,8 @@ conversation = [{"role": "user", "content": prompt}]
# Transform the prompt into the chat template
prompt = tokenizer.apply_chat_template(
conversation=conversation, add_generation_prompt=True
conversation=conversation,
add_generation_prompt=True,
)
# Specify the maximum number of tokens
@@ -1,19 +1,20 @@
# Copyright © 2024 Apple Inc.
# Copyright © 2025 Apple Inc.
"""
Run with:
```
mlx.launch \
--hostfile /path/to/hosts.json \
/path/to/pipeline_generate.py \
--prompt "hello world"
--backend jaccl \
--env MLX_METAL_FAST_SYNCH=1 \
--hostfile /path/to/hosts.json \
/path/to/sharded_generate.py \
--prompt 'Hello world'
```
Make sure you can run MLX over MPI on two hosts. For more information see the
documentation:
For more information on running distributed programs with MLX see the documentation:
https://ml-explore.github.io/mlx/build/html/usage/distributed.html).
https://ml-explore.github.io/mlx/build/html/usage/distributed.html .
"""
import argparse
@@ -21,13 +22,13 @@ import argparse
import mlx.core as mx
from mlx_lm import stream_generate
from mlx_lm.utils import pipeline_load
from mlx_lm.utils import sharded_load
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="LLM pipelined inference example")
parser = argparse.ArgumentParser(description="LLM distributed inference example")
parser.add_argument(
"--model",
default="mlx-community/DeepSeek-R1-3bit",
default="mlx-community/Llama-3.3-70B-Instruct-4bit",
help="HF repo or path to local model.",
)
parser.add_argument(
@@ -43,19 +44,29 @@ if __name__ == "__main__":
default=256,
help="Maximum number of tokens to generate",
)
parser.add_argument(
"--pipeline",
action="store_true",
help="Use pipelining instead of tensor parallelism",
)
args = parser.parse_args()
group = mx.distributed.init()
rank = group.rank()
pipeline_group = group if args.pipeline else None
tensor_group = group if not args.pipeline else None
def rprint(*args, **kwargs):
if rank == 0:
print(*args, **kwargs)
model, tokenizer = pipeline_load(args.model)
model, tokenizer = sharded_load(args.model, pipeline_group, tensor_group)
messages = [{"role": "user", "content": args.prompt}]
prompt = tokenizer.apply_chat_template(messages, add_generation_prompt=True)
prompt = tokenizer.apply_chat_template(
messages,
add_generation_prompt=True,
)
for response in stream_generate(
model, tokenizer, prompt, max_tokens=args.max_tokens
+3 -1
View File
@@ -31,7 +31,9 @@ prompt = "Multiply 12234585 and 48838483920."
messages = [{"role": "user", "content": prompt}]
prompt = tokenizer.apply_chat_template(
messages, add_generation_prompt=True, tools=list(tools.values())
messages,
add_generation_prompt=True,
tools=list(tools.values()),
)
prompt_cache = make_prompt_cache(model)
+2 -1
View File
@@ -76,8 +76,9 @@ def main() -> None:
if args.dequantize:
print("Dequantizing model")
model = dequantize(model)
model = dequantize_model(model)
config.pop("quantization", None)
config.pop("quantization_config", None)
save_path = Path(args.save_path)
save(
+1 -1
View File
@@ -874,7 +874,7 @@ def _make_cache(model, left_padding):
"""
def to_batch_cache(c):
if isinstance(c, KVCache):
if type(c) is KVCache:
return BatchKVCache(left_padding)
elif isinstance(c, ArraysCache):
c.left_padding = mx.array(left_padding)
+67 -1
View File
@@ -6,6 +6,7 @@ from typing import Any, Dict, Optional
import mlx.core as mx
import mlx.nn as nn
from mlx.nn.layers.distributed import shard_inplace, shard_linear, sum_gradients
from .base import BaseModelArgs, create_attention_mask, scaled_dot_product_attention
from .pipeline import PipelineMixin
@@ -315,13 +316,21 @@ class DeepseekV2MoE(nn.Module):
config=config, intermediate_size=intermediate_size
)
self.sharding_group = None
def __call__(self, x):
if self.sharding_group is not None:
x = sum_gradients(self.sharding_group)(x)
inds, scores = self.gate(x)
y = self.switch_mlp(x, inds)
y = (y * scores[..., None]).sum(axis=-2)
if self.config.n_shared_experts is not None:
y = y + self.shared_experts(x)
if self.sharding_group is not None:
y = mx.distributed.all_sum(y, group=self.sharding_group)
return y
@@ -395,7 +404,8 @@ class DeepseekV2Model(PipelineMixin, nn.Module):
cache[-1].keys = mx.depends(cache[-1].keys, h)
# Broadcast h while keeping it in the graph
h = mx.distributed.all_gather(h)[: h.shape[0]]
if pipeline_size > 1:
h = mx.distributed.all_gather(h)[: h.shape[0]]
return self.norm(h)
@@ -429,6 +439,62 @@ class Model(nn.Module):
weights[f"{prefix}.mlp.switch_mlp.{m}.{k}"] = mx.stack(to_join)
return weights
def shard(self, group: Optional[mx.distributed.Group] = None):
group = group or mx.distributed.init()
N = group.size()
for layer in self.model.layers:
# Shard the self attention
if layer.self_attn.q_lora_rank is None:
layer.self_attn.q_proj = shard_linear(
layer.self_attn.q_proj, "all-to-sharded", group=group
)
else:
layer.self_attn.q_b_proj = shard_linear(
layer.self_attn.q_b_proj, "all-to-sharded", group=group
)
layer.self_attn.kv_b_proj = shard_linear(
layer.self_attn.kv_b_proj, "all-to-sharded", group=group
)
layer.self_attn.o_proj = shard_linear(
layer.self_attn.o_proj, "sharded-to-all", group=group
)
layer.self_attn.num_heads //= N
# Shard the MLP
if isinstance(layer.mlp, DeepseekV2MLP):
layer.mlp.gate_proj = shard_linear(
layer.mlp.gate_proj, "all-to-sharded", group=group
)
layer.mlp.down_proj = shard_linear(
layer.mlp.down_proj, "sharded-to-all", group=group
)
layer.mlp.up_proj = shard_linear(
layer.mlp.up_proj, "all-to-sharded", group=group
)
# Shard the MoE. Shard in place since the MoE should be responsible
# for aggregating the results.
else:
layer.mlp.sharding_group = group
shard_inplace(
layer.mlp.shared_experts.gate_proj, "all-to-sharded", group=group
)
shard_inplace(
layer.mlp.shared_experts.down_proj, "sharded-to-all", group=group
)
shard_inplace(
layer.mlp.shared_experts.up_proj, "all-to-sharded", group=group
)
shard_inplace(
layer.mlp.switch_mlp.gate_proj, "all-to-sharded", group=group
)
shard_inplace(
layer.mlp.switch_mlp.down_proj, "sharded-to-all", group=group
)
shard_inplace(
layer.mlp.switch_mlp.up_proj, "all-to-sharded", group=group
)
@property
def layers(self):
return self.model.pipeline_layers
+67 -1
View File
@@ -7,6 +7,7 @@ from typing import Any, Dict, Optional
import mlx.core as mx
import mlx.nn as nn
from mlx.nn.layers.distributed import shard_inplace, shard_linear, sum_gradients
from .base import BaseModelArgs, create_attention_mask, scaled_dot_product_attention
from .pipeline import PipelineMixin
@@ -256,13 +257,21 @@ class DeepseekV3MoE(nn.Module):
config=config, intermediate_size=intermediate_size
)
self.sharding_group = None
def __call__(self, x):
if self.sharding_group is not None:
x = sum_gradients(self.sharding_group)(x)
inds, scores = self.gate(x)
y = self.switch_mlp(x, inds)
y = (y * scores[..., None]).sum(axis=-2).astype(y.dtype)
if self.config.n_shared_experts is not None:
y = y + self.shared_experts(x)
if self.sharding_group is not None:
y = mx.distributed.all_sum(y, group=self.sharding_group)
return y
@@ -335,7 +344,8 @@ class DeepseekV3Model(PipelineMixin, nn.Module):
cache[-1].keys = mx.depends(cache[-1].keys, h)
# Broadcast h while keeping it in the graph
h = mx.distributed.all_gather(h)[: h.shape[0]]
if pipeline_size > 1:
h = mx.distributed.all_gather(h)[: h.shape[0]]
return self.norm(h)
@@ -419,6 +429,62 @@ class Model(nn.Module):
if not k.startswith("model.layers.61") and "rotary_emb.inv_freq" not in k
}
def shard(self, group: Optional[mx.distributed.Group] = None):
group = group or mx.distributed.init()
N = group.size()
for layer in self.model.layers:
# Shard the self attention
if layer.self_attn.q_lora_rank is None:
layer.self_attn.q_proj = shard_linear(
layer.self_attn.q_proj, "all-to-sharded", group=group
)
else:
layer.self_attn.q_b_proj = shard_linear(
layer.self_attn.q_b_proj, "all-to-sharded", group=group
)
layer.self_attn.kv_b_proj = shard_linear(
layer.self_attn.kv_b_proj, "all-to-sharded", group=group
)
layer.self_attn.o_proj = shard_linear(
layer.self_attn.o_proj, "sharded-to-all", group=group
)
layer.self_attn.num_heads //= N
# Shard the MLP
if isinstance(layer.mlp, DeepseekV3MLP):
layer.mlp.gate_proj = shard_linear(
layer.mlp.gate_proj, "all-to-sharded", group=group
)
layer.mlp.down_proj = shard_linear(
layer.mlp.down_proj, "sharded-to-all", group=group
)
layer.mlp.up_proj = shard_linear(
layer.mlp.up_proj, "all-to-sharded", group=group
)
# Shard the MoE. Shard in place since the MoE should be responsible
# for aggregating the results.
else:
layer.mlp.sharding_group = group = group
shard_inplace(
layer.mlp.shared_experts.gate_proj, "all-to-sharded", group=group
)
shard_inplace(
layer.mlp.shared_experts.down_proj, "sharded-to-all", group=group
)
shard_inplace(
layer.mlp.shared_experts.up_proj, "all-to-sharded", group=group
)
shard_inplace(
layer.mlp.switch_mlp.gate_proj, "all-to-sharded", group=group
)
shard_inplace(
layer.mlp.switch_mlp.down_proj, "sharded-to-all", group=group
)
shard_inplace(
layer.mlp.switch_mlp.up_proj, "all-to-sharded", group=group
)
@property
def layers(self):
return self.model.pipeline_layers
+67 -2
View File
@@ -6,6 +6,7 @@ from typing import Any, Dict, Optional
import mlx.core as mx
import mlx.nn as nn
from mlx.nn.layers.distributed import shard_inplace, shard_linear, sum_gradients
from .base import BaseModelArgs, create_attention_mask, scaled_dot_product_attention
from .cache import CacheList, KVCache
@@ -222,6 +223,11 @@ class DeepseekV32Attention(nn.Module):
if mask is not None:
sparse_mask = sparse_mask & mask
mask = sparse_mask
# Ensure the indexer cache is evaluated even if the topk_indices are unused
# to keep the graph from getting too large
if cache is not None and cache[0] is not None:
cache[0].keys = mx.depends(cache[0].keys, (cache[1].keys, cache[1].values))
output = scaled_dot_product_attention(
queries, keys, values, cache=cache[0], scale=self.scale, mask=mask
)
@@ -328,13 +334,21 @@ class DeepseekV32MoE(nn.Module):
config=config, intermediate_size=intermediate_size
)
self.sharding_group = None
def __call__(self, x):
if self.sharding_group is not None:
x = sum_gradients(self.sharding_group)(x)
inds, scores = self.gate(x)
y = self.switch_mlp(x, inds)
y = (y * scores[..., None]).sum(axis=-2).astype(y.dtype)
if self.config.n_shared_experts is not None:
y = y + self.shared_experts(x)
if self.sharding_group is not None:
y = mx.distributed.all_sum(y, group=self.sharding_group)
return y
@@ -428,10 +442,11 @@ class DeepseekV32Model(nn.Module):
if pipeline_rank != 0:
h = mx.distributed.send(h, (pipeline_rank - 1) % pipeline_size)
if cache[-1] is not None:
cache[-1].keys = mx.depends(cache[-1].keys, h)
cache[-1][0].keys = mx.depends(cache[-1][0].keys, h)
# Broadcast h while keeping it in the graph
h = mx.distributed.all_gather(h)[: h.shape[0]]
if pipeline_size > 1:
h = mx.distributed.all_gather(h)[: h.shape[0]]
return self.norm(h)
@@ -500,6 +515,56 @@ class Model(nn.Module):
if not k.startswith("model.layers.61") and "rotary_emb.inv_freq" not in k
}
def shard(self, group: Optional[mx.distributed.Group] = None):
group = group or mx.distributed.init()
N = group.size()
for layer in self.model.layers:
layer.self_attn.q_b_proj = shard_linear(
layer.self_attn.q_b_proj, "all-to-sharded", group=group
)
layer.self_attn.kv_b_proj = shard_linear(
layer.self_attn.kv_b_proj, "all-to-sharded", group=group
)
layer.self_attn.o_proj = shard_linear(
layer.self_attn.o_proj, "sharded-to-all", group=group
)
layer.self_attn.num_heads //= N
# Shard the MLP
if isinstance(layer.mlp, DeepseekV32MLP):
layer.mlp.gate_proj = shard_linear(
layer.mlp.gate_proj, "all-to-sharded", group=group
)
layer.mlp.down_proj = shard_linear(
layer.mlp.down_proj, "sharded-to-all", group=group
)
layer.mlp.up_proj = shard_linear(
layer.mlp.up_proj, "all-to-sharded", group=group
)
# Shard the MoE. Shard in place since the MoE should be responsible
# for aggregating the results.
else:
layer.mlp.sharding_group = group = group
shard_inplace(
layer.mlp.shared_experts.gate_proj, "all-to-sharded", group=group
)
shard_inplace(
layer.mlp.shared_experts.down_proj, "sharded-to-all", group=group
)
shard_inplace(
layer.mlp.shared_experts.up_proj, "all-to-sharded", group=group
)
shard_inplace(
layer.mlp.switch_mlp.gate_proj, "all-to-sharded", group=group
)
shard_inplace(
layer.mlp.switch_mlp.down_proj, "sharded-to-all", group=group
)
shard_inplace(
layer.mlp.switch_mlp.up_proj, "all-to-sharded", group=group
)
@property
def layers(self):
return self.model.layers[self.model.start_idx : self.model.end_idx]
+14 -7
View File
@@ -54,13 +54,20 @@ class Attention(nn.Module):
self.k_norm = RMSNorm(dims=head_dim, eps=args.rms_norm_eps)
self.is_sliding = (layer_idx + 1) % args.sliding_window_pattern != 0
self.rope = initialize_rope(
dims=head_dim,
base=(args.rope_local_base_freq if self.is_sliding else args.rope_theta),
traditional=False,
max_position_embeddings=args.max_position_embeddings,
scaling_config=args.rope_scaling,
)
if self.is_sliding:
self.rope = initialize_rope(
dims=head_dim,
base=args.rope_local_base_freq,
traditional=False,
)
else:
self.rope = initialize_rope(
dims=head_dim,
base=args.rope_theta,
traditional=False,
max_position_embeddings=args.max_position_embeddings,
scaling_config=args.rope_scaling,
)
def __call__(
self,
+32
View File
@@ -5,6 +5,7 @@ from typing import Any, Dict, List, Optional, Union
import mlx.core as mx
import mlx.nn as nn
from mlx.nn.layers.distributed import shard_linear
from .base import BaseModelArgs, create_attention_mask, scaled_dot_product_attention
from .cache import KVCache, RotatingKVCache
@@ -226,6 +227,37 @@ class Model(nn.Module):
weights.pop("lm_head.weight", None)
return weights
def shard(self, group: Optional[mx.distributed.Group] = None):
group = group or mx.distributed.init()
N = group.size()
for layer in self.model.layers:
# Shard the self attention
layer.self_attn.q_proj = shard_linear(
layer.self_attn.q_proj, "all-to-sharded", group=group
)
layer.self_attn.k_proj = shard_linear(
layer.self_attn.k_proj, "all-to-sharded", group=group
)
layer.self_attn.v_proj = shard_linear(
layer.self_attn.v_proj, "all-to-sharded", group=group
)
layer.self_attn.o_proj = shard_linear(
layer.self_attn.o_proj, "sharded-to-all", group=group
)
layer.self_attn.n_heads //= N
layer.self_attn.n_kv_heads //= N
# Shard the MLP
layer.mlp.gate_proj = shard_linear(
layer.mlp.gate_proj, "all-to-sharded", group=group
)
layer.mlp.down_proj = shard_linear(
layer.mlp.down_proj, "sharded-to-all", group=group
)
layer.mlp.up_proj = shard_linear(
layer.mlp.up_proj, "all-to-sharded", group=group
)
@property
def layers(self):
return self.model.layers
+382
View File
@@ -0,0 +1,382 @@
# Copyright © 2024 Apple Inc.
import math
from dataclasses import dataclass
from functools import partial
from typing import Any, Dict, List, Optional
import mlx.core as mx
import mlx.nn as nn
from .base import BaseModelArgs, create_attention_mask, scaled_dot_product_attention
from .cache import KVCache, RotatingKVCache
from .switch_layers import SwitchGLU
@dataclass
class ModelArgs(BaseModelArgs):
model_type: str
num_experts_per_tok: int
hybrid_layer_pattern: List[int]
moe_layer_freq: List[int]
add_swa_attention_sink_bias: bool
add_full_attention_sink_bias: bool
sliding_window_size: int
vocab_size: int
hidden_size: int
intermediate_size: int
moe_intermediate_size: int
num_hidden_layers: int
num_attention_heads: int
num_key_value_heads: int
n_shared_experts: Optional[int]
n_routed_experts: Optional[int]
routed_scaling_factor: Optional[float]
topk_method: str
scoring_func: str
norm_topk_prob: bool
n_group: int
topk_group: int
max_position_embeddings: int
layernorm_epsilon: float
rope_theta: float
swa_rope_theta: float
swa_num_attention_heads: int
swa_num_key_value_heads: int
head_dim: int
v_head_dim: int
swa_head_dim: int
swa_v_head_dim: int
partial_rotary_factor: int
class Attention(nn.Module):
def __init__(self, args: ModelArgs, is_sliding_window: bool):
super().__init__()
dim = args.hidden_size
self.is_sliding_window = is_sliding_window
if self.is_sliding_window:
self.n_heads = n_heads = args.swa_num_attention_heads
self.n_kv_heads = n_kv_heads = args.swa_num_key_value_heads
self.has_sinks = args.add_swa_attention_sink_bias
head_dim = args.swa_head_dim
v_head_dim = args.swa_v_head_dim
rope_theta = args.swa_rope_theta
else:
self.n_heads = n_heads = args.num_attention_heads
self.n_kv_heads = n_kv_heads = args.num_key_value_heads
self.has_sinks = args.add_full_attention_sink_bias
head_dim = args.head_dim
v_head_dim = args.v_head_dim
rope_theta = args.rope_theta
self.scale = head_dim**-0.5
self.q_proj = nn.Linear(dim, n_heads * head_dim, bias=False)
self.k_proj = nn.Linear(dim, n_kv_heads * head_dim, bias=False)
self.v_proj = nn.Linear(dim, n_kv_heads * v_head_dim, bias=False)
self.o_proj = nn.Linear(n_heads * v_head_dim, dim, bias=False)
if self.has_sinks:
self.attention_sink_bias = mx.ones((self.n_heads,))
else:
self.attention_sink_bias = None
self.rope = nn.RoPE(
int(args.partial_rotary_factor * head_dim),
traditional=False,
base=rope_theta,
)
def __call__(
self,
x: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[Any] = None,
) -> mx.array:
B, L, D = x.shape
queries, keys, values = self.q_proj(x), self.k_proj(x), self.v_proj(x)
queries = queries.reshape(B, L, self.n_heads, -1).transpose(0, 2, 1, 3)
keys = keys.reshape(B, L, self.n_kv_heads, -1).transpose(0, 2, 1, 3)
values = values.reshape(B, L, self.n_kv_heads, -1).transpose(0, 2, 1, 3)
if cache is not None:
queries = self.rope(queries, offset=cache.offset)
keys = self.rope(keys, offset=cache.offset)
keys, values = cache.update_and_fetch(keys, values)
else:
queries = self.rope(queries)
keys = self.rope(keys)
output = scaled_dot_product_attention(
queries,
keys,
values,
cache=cache,
scale=self.scale,
mask=mask,
sinks=self.attention_sink_bias,
)
output = output.transpose(0, 2, 1, 3).reshape(B, L, -1)
return self.o_proj(output)
class MLP(nn.Module):
def __init__(
self, config: ModelArgs, hidden_size: int = None, intermediate_size: int = None
):
super().__init__()
self.config = config
self.hidden_size = config.hidden_size if hidden_size is None else hidden_size
self.intermediate_size = (
config.intermediate_size if intermediate_size is None else intermediate_size
)
self.gate_proj = nn.Linear(self.hidden_size, self.intermediate_size, bias=False)
self.up_proj = nn.Linear(self.hidden_size, self.intermediate_size, bias=False)
self.down_proj = nn.Linear(self.intermediate_size, self.hidden_size, bias=False)
def __call__(self, x):
down_proj = self.down_proj(nn.silu(self.gate_proj(x)) * self.up_proj(x))
return down_proj
@mx.compile
def group_expert_select(
gates,
e_score_correction_bias,
top_k,
n_group,
topk_group,
routed_scaling_factor,
norm_topk_prob,
):
scores = mx.sigmoid(gates.astype(mx.float32))
orig_scores = scores
scores = scores + e_score_correction_bias
if n_group > 1:
scores = mx.unflatten(scores, axis=-1, shape=(n_group, -1))
group_scores = mx.topk(scores, 2, axis=-1).sum(axis=-1, keepdims=True)
k = n_group - topk_group
group_idx = mx.argpartition(group_scores, kth=k - 1, axis=-2)[..., :k, :]
scores = mx.put_along_axis(
scores, mx.stop_gradient(group_idx), mx.array(0.0), axis=-2
)
scores = mx.flatten(scores, -2, -1)
k = top_k
inds = mx.argpartition(-scores, kth=k - 1, axis=-1)[..., :k]
scores = mx.take_along_axis(orig_scores, inds, axis=-1)
if top_k > 1 and norm_topk_prob:
denominator = scores.sum(axis=-1, keepdims=True)
scores = scores / (denominator + 1e-20)
scores = scores * routed_scaling_factor
return inds, scores
class MoEGate(nn.Module):
def __init__(self, config: ModelArgs):
super().__init__()
self.config = config
self.top_k = config.num_experts_per_tok
self.norm_topk_prob = config.norm_topk_prob
self.n_routed_experts = config.n_routed_experts
self.routed_scaling_factor = (
config.routed_scaling_factor
if config.routed_scaling_factor is not None
else 1.0
)
self.n_group = config.n_group
self.topk_group = config.topk_group
self.weight = mx.zeros((self.n_routed_experts, config.hidden_size))
self.e_score_correction_bias = mx.zeros((self.n_routed_experts,))
assert config.topk_method == "noaux_tc", "Unsupported topk method."
def __call__(self, x):
return group_expert_select(
x @ self.weight.T,
self.e_score_correction_bias,
self.top_k,
self.n_group,
self.topk_group,
self.routed_scaling_factor,
self.norm_topk_prob,
)
class MoE(nn.Module):
def __init__(self, config: ModelArgs):
super().__init__()
self.config = config
self.num_experts_per_tok = config.num_experts_per_tok
self.switch_mlp = SwitchGLU(
config.hidden_size,
config.moe_intermediate_size,
config.n_routed_experts,
)
self.gate = MoEGate(config)
if config.n_shared_experts is not None:
intermediate_size = config.moe_intermediate_size * config.n_shared_experts
self.shared_experts = MLP(
config=config, intermediate_size=intermediate_size
)
def __call__(self, x):
inds, scores = self.gate(x)
y = self.switch_mlp(x, inds)
y = (y * scores[..., None]).sum(axis=-2).astype(y.dtype)
if self.config.n_shared_experts is not None:
y = y + self.shared_experts(x)
return y
class DecoderLayer(nn.Module):
def __init__(self, config: ModelArgs, is_moe, is_sliding_window):
super().__init__()
self.self_attn = Attention(config, is_sliding_window)
self.mlp = MoE(config) if is_moe else MLP(config)
self.is_sliding_window = is_sliding_window
self.input_layernorm = nn.RMSNorm(
config.hidden_size, eps=config.layernorm_epsilon
)
self.post_attention_layernorm = nn.RMSNorm(
config.hidden_size, eps=config.layernorm_epsilon
)
def __call__(
self,
x: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[Any] = None,
) -> mx.array:
r = self.self_attn(self.input_layernorm(x), mask, cache)
h = x + r
r = self.mlp(self.post_attention_layernorm(h))
return h + r
class LanguageModel(nn.Module):
def __init__(self, config: ModelArgs):
super().__init__()
self.vocab_size = config.vocab_size
self.embed_tokens = nn.Embedding(config.vocab_size, config.hidden_size)
self.layers = [
DecoderLayer(
config,
is_moe=config.moe_layer_freq[idx] == 1,
is_sliding_window=config.hybrid_layer_pattern[idx] == 1,
)
for idx in range(config.num_hidden_layers)
]
self.norm = nn.RMSNorm(config.hidden_size, eps=config.layernorm_epsilon)
self.swa_idx = config.hybrid_layer_pattern.index(1)
self.ga_idx = config.hybrid_layer_pattern.index(0)
self.sliding_window_size = config.sliding_window_size
def __call__(
self,
x: mx.array,
cache: Optional[Any] = None,
) -> mx.array:
h = self.embed_tokens(x)
if cache is None:
cache = [None] * len(self.layers)
full_mask = create_attention_mask(x, cache[self.ga_idx])
swa_mask = create_attention_mask(
x, cache[self.swa_idx], window_size=self.sliding_window_size
)
for l, c in zip(self.layers, cache):
mask = swa_mask if l.is_sliding_window else full_mask
h = l(h, mask, cache=c)
return self.norm(h)
class Model(nn.Module):
def __init__(self, config: ModelArgs):
super().__init__()
self.args = config
self.model_type = config.model_type
self.model = LanguageModel(config)
self.lm_head = nn.Linear(config.hidden_size, config.vocab_size, bias=False)
def __call__(
self,
inputs: mx.array,
cache: Optional[Any] = None,
):
out = self.model(inputs, cache)
return self.lm_head(out)
def sanitize(self, weights):
def dequant(weight, scale_inv):
dtype = weight.dtype
bs = 128 # block size
m, n = weight.shape
pad_bottom = bs * scale_inv.shape[0] - m
pad_side = bs * scale_inv.shape[1] - n
weight = mx.pad(weight, ((0, pad_bottom), (0, pad_side)))
weight = weight.reshape(
((m + pad_bottom) // bs, bs, (n + pad_side) // bs, bs)
)
weight = (weight * scale_inv[:, None, :, None]).reshape(
m + pad_bottom, n + pad_side
)
return weight[:m, :n].astype(dtype)
# Dequantize fp8
new_weights = {}
for k, v in weights.items():
if "weight_scale_inv" in k:
scale_inv = v
wk = k.replace("_scale_inv", "")
weight = weights[wk]
weight = dequant(weight, scale_inv)
new_weights[wk] = weight
elif k not in new_weights:
new_weights[k] = v
weights = new_weights
# Stack experts
for l in range(self.args.num_hidden_layers):
prefix = f"model.layers.{l}"
for n, m in [("w1", "gate_proj"), ("w2", "down_proj"), ("w3", "up_proj")]:
for k in ["weight", "scales", "biases"]:
if f"{prefix}.mlp.experts.0.{m}.{k}" in weights:
to_join = [
weights.pop(f"{prefix}.mlp.experts.{e}.{m}.{k}")
for e in range(self.args.n_routed_experts)
]
weights[f"{prefix}.mlp.switch_mlp.{m}.{k}"] = mx.stack(to_join)
# Remove multi-token prediction layer
return {k: v for k, v in weights.items() if not k.startswith("model.mtp")}
@property
def layers(self):
return self.model.layers
@property
def cast_predicate(self):
def predicate(k):
return "e_score_correction_bias" not in k
return predicate
def make_cache(self):
caches = []
for l in self.layers:
if l.is_sliding_window:
caches.append(RotatingKVCache(max_size=self.args.sliding_window_size))
else:
caches.append(KVCache())
return caches
+81 -13
View File
@@ -5,9 +5,11 @@ from typing import Any, Dict, List, Optional, Union
import mlx.core as mx
import mlx.nn as nn
from mlx.nn.layers.distributed import shard_linear
from .base import BaseModelArgs, create_attention_mask, scaled_dot_product_attention
from .cache import KVCache, RotatingKVCache
from .pipeline import PipelineMixin
from .rope_utils import initialize_rope
@@ -36,13 +38,17 @@ class ModelArgs(BaseModelArgs):
self.layer_types = ["full_attention"] * self.num_hidden_layers
def _get_llama_4_attn_scale(
start: int, stop: int, beta: float, max_position_embeddings: int
):
def _get_llama_4_attn_scale(size, offset, beta: float, max_position_embeddings: int):
if isinstance(offset, mx.array) and offset.ndim > 0:
offset = offset[:, None]
scaling = 1 + beta * mx.log(
1 + mx.floor(mx.arange(start, stop) / max_position_embeddings)
1 + mx.floor((mx.arange(size) + offset) / max_position_embeddings)
)
return scaling[:, None]
if scaling.ndim == 2:
return scaling[:, None, :, None]
else:
return scaling[:, None]
class Attention(nn.Module):
@@ -146,7 +152,7 @@ class TransformerBlock(nn.Module):
return out
class LanguageModel(nn.Module):
class LanguageModel(PipelineMixin, nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
self.args = args
@@ -167,6 +173,18 @@ class LanguageModel(nn.Module):
self.swa_idx = e
break
def pipeline(self, group):
super().pipeline(group)
self.fa_idx = None
self.swa_idx = None
for e, l in enumerate(self.pipeline_layers):
if self.swa_idx is None and l.use_sliding:
self.swa_idx = e
elif self.fa_idx is None and not l.use_sliding:
self.fa_idx = e
if self.fa_idx is not None and self.swa_idx is not None:
break
def __call__(
self,
inputs: mx.array,
@@ -178,28 +196,47 @@ class LanguageModel(nn.Module):
else:
h = self.embed_tokens(inputs)
pipeline_rank = self.pipeline_rank
pipeline_size = self.pipeline_size
if cache is None:
cache = [None] * len(self.layers)
cache = [None] * len(self.pipeline_layers)
offset = 0
else:
offset = cache[0].offset
fa_mask = create_attention_mask(h, cache[self.fa_idx])
swa_mask = fa_mask = None
if self.fa_idx is not None:
fa_mask = create_attention_mask(h, cache[self.fa_idx])
if self.swa_idx is not None:
swa_mask = create_attention_mask(
h, cache[self.swa_idx], window_size=self.sliding_window
)
attn_scale = _get_llama_4_attn_scale(
inputs.shape[1],
offset,
offset + inputs.shape[1],
self.args.rope_parameters["llama_4_scaling_beta"],
self.args.rope_parameters["original_max_position_embeddings"],
).astype(h.dtype)
for layer, cache in zip(self.layers, cache):
mask = swa_mask if layer.use_sliding else fa_mask
h = layer(h, attn_scale, mask, cache=cache)
# Receive from the previous process in the pipeline
if pipeline_rank < pipeline_size - 1:
h = mx.distributed.recv_like(h, (pipeline_rank + 1))
for l, c in zip(self.pipeline_layers, cache):
mask = swa_mask if l.use_sliding else fa_mask
h = l(h, attn_scale, mask, cache=c)
# Send to the next process in the pipeline
if pipeline_rank != 0:
h = mx.distributed.send(h, (pipeline_rank - 1) % pipeline_size)
if cache[-1] is not None:
cache[-1].keys = mx.depends(cache[-1].keys, h)
# Broadcast h while keeping it in the graph
if pipeline_size > 1:
h = mx.distributed.all_gather(h)[: h.shape[0]]
return self.norm(h)
@@ -249,9 +286,40 @@ class Model(nn.Module):
return weights
def shard(self, group: Optional[mx.distributed.Group] = None):
group = group or mx.distributed.init()
N = group.size()
for layer in self.model.layers:
# Shard the self attention
layer.self_attn.q_proj = shard_linear(
layer.self_attn.q_proj, "all-to-sharded", group=group
)
layer.self_attn.k_proj = shard_linear(
layer.self_attn.k_proj, "all-to-sharded", group=group
)
layer.self_attn.v_proj = shard_linear(
layer.self_attn.v_proj, "all-to-sharded", group=group
)
layer.self_attn.o_proj = shard_linear(
layer.self_attn.o_proj, "sharded-to-all", group=group
)
layer.self_attn.n_heads //= N
layer.self_attn.n_kv_heads //= N
# Shard the MLP
layer.mlp.gate_proj = shard_linear(
layer.mlp.gate_proj, "all-to-sharded", group=group
)
layer.mlp.down_proj = shard_linear(
layer.mlp.down_proj, "sharded-to-all", group=group
)
layer.mlp.up_proj = shard_linear(
layer.mlp.up_proj, "all-to-sharded", group=group
)
@property
def layers(self):
return self.model.layers
return self.model.pipeline_layers
def make_cache(self):
return [
+138 -14
View File
@@ -15,6 +15,7 @@ from .base import (
)
from .cache import KVCache, MambaCache
from .ssm import ssm_update
from .switch_layers import SwitchMLP
@dataclass()
@@ -37,24 +38,34 @@ class ModelArgs(BaseModelArgs):
time_step_limit: Tuple[float, float]
mlp_bias: bool
layer_norm_epsilon: float
rms_norm_eps: float
use_bias: bool
use_conv_bias: bool
residual_in_fp32: bool
hybrid_override_pattern: List[str]
head_dim: Optional[int] = None
moe_intermediate_size: Optional[int] = None
moe_shared_expert_intermediate_size: Optional[int] = None
n_group: Optional[int] = None
n_routed_experts: Optional[int] = None
n_shared_experts: Optional[int] = None
topk_group: Optional[int] = None
num_experts_per_tok: Optional[int] = None
norm_topk_prob: Optional[bool] = None
routed_scaling_factor: Optional[float] = None
class MambaRMSNormGated(nn.Module):
def __init__(self, hidden_size: int, eps: float = 1e-6):
def __init__(self, hidden_size: int, eps: float, group_size: int):
super().__init__()
self.eps = eps
self.weight = mx.ones(hidden_size)
self.group_size = group_size
def __call__(self, hidden_states: mx.array, gate: mx.array = None) -> mx.array:
def __call__(self, x: mx.array, gate: mx.array = None) -> mx.array:
if gate is not None:
hidden_states = hidden_states * nn.silu(gate)
return mx.fast.rms_norm(hidden_states, self.weight, self.eps)
x = x * nn.silu(gate)
x = mx.unflatten(x, axis=-1, shape=(-1, self.group_size))
x = mx.fast.rms_norm(x, weight=None, eps=self.eps)
return self.weight * x.flatten(-2)
class NemotronHMamba2Mixer(nn.Module):
@@ -90,8 +101,11 @@ class NemotronHMamba2Mixer(nn.Module):
self.A_log = mx.log(mx.arange(1, self.num_heads + 1, dtype=mx.float32))
self.D = mx.ones(self.num_heads)
group_size = self.intermediate_size // self.n_groups
self.norm = MambaRMSNormGated(
self.intermediate_size, eps=args.layer_norm_epsilon
self.intermediate_size,
eps=args.layer_norm_epsilon,
group_size=group_size,
)
self.out_proj = nn.Linear(
self.intermediate_size, self.hidden_size, bias=args.mamba_proj_bias
@@ -139,7 +153,7 @@ class NemotronHMamba2Mixer(nn.Module):
self.A_log,
B,
C,
self.D,
self.D.astype(hidden_states.dtype),
dt,
self.dt_bias,
state,
@@ -245,24 +259,113 @@ class NemotronHAttention(nn.Module):
class NemotronHMLP(nn.Module):
def __init__(self, args: ModelArgs):
def __init__(self, args: ModelArgs, intermediate_size=None):
super().__init__()
intermediate_size = intermediate_size or args.intermediate_size
self.up_proj = nn.Linear(
args.hidden_size, args.intermediate_size, bias=args.mlp_bias
args.hidden_size, intermediate_size, bias=args.mlp_bias
)
self.down_proj = nn.Linear(
args.intermediate_size, args.hidden_size, bias=args.mlp_bias
intermediate_size, args.hidden_size, bias=args.mlp_bias
)
def __call__(self, x):
return self.down_proj(nn.relu2(self.up_proj(x)))
@mx.compile
def group_expert_select(
gates,
e_score_correction_bias,
top_k,
n_group,
topk_group,
routed_scaling_factor,
norm_topk_prob,
):
orig_scores = scores = mx.sigmoid(gates.astype(mx.float32))
scores = scores + e_score_correction_bias
if n_group > 1:
scores = mx.unflatten(scores, axis=-1, shape=(n_group, -1))
group_scores = mx.topk(scores, 2, axis=-1).sum(axis=-1, keepdims=True)
k = n_group - topk_group
group_idx = mx.argpartition(group_scores, kth=k - 1, axis=-2)[..., :k, :]
scores = mx.put_along_axis(
scores, mx.stop_gradient(group_idx), mx.array(0.0), axis=-2
)
scores = mx.flatten(scores, -2, -1)
k = top_k
inds = mx.argpartition(-scores, kth=k - 1, axis=-1)[..., :k]
scores = mx.take_along_axis(orig_scores, inds, axis=-1)
if top_k > 1 and norm_topk_prob:
denominator = scores.sum(axis=-1, keepdims=True)
scores = scores / (denominator + 1e-20)
scores = scores * routed_scaling_factor
return inds, scores
class MoEGate(nn.Module):
def __init__(self, config: ModelArgs):
super().__init__()
self.config = config
self.top_k = config.num_experts_per_tok
self.norm_topk_prob = config.norm_topk_prob
self.n_routed_experts = config.n_routed_experts
self.routed_scaling_factor = config.routed_scaling_factor
self.n_group = config.n_group
self.topk_group = config.topk_group
self.weight = mx.zeros((self.n_routed_experts, config.hidden_size))
self.e_score_correction_bias = mx.zeros((self.n_routed_experts,))
def __call__(self, x):
return group_expert_select(
x @ self.weight.T,
self.e_score_correction_bias,
self.top_k,
self.n_group,
self.topk_group,
self.routed_scaling_factor,
self.norm_topk_prob,
)
class NemotronHMoE(nn.Module):
def __init__(self, config: ModelArgs):
super().__init__()
self.config = config
self.num_experts_per_tok = config.num_experts_per_tok
self.switch_mlp = SwitchMLP(
config.hidden_size,
config.moe_intermediate_size,
config.n_routed_experts,
activation=nn.ReLU2(),
)
self.gate = MoEGate(config)
if config.n_shared_experts is not None:
intermediate_size = config.moe_shared_expert_intermediate_size
self.shared_experts = NemotronHMLP(
config, intermediate_size=intermediate_size
)
def __call__(self, x):
inds, scores = self.gate(x)
y = self.switch_mlp(x, inds)
y = (y * scores[..., None]).sum(axis=-2).astype(y.dtype)
if self.config.n_shared_experts is not None:
y = y + self.shared_experts(x)
return y
class NemotronHBlock(nn.Module):
def __init__(self, args: ModelArgs, block_type: str):
super().__init__()
self.residual_in_fp32 = args.residual_in_fp32
self.norm = nn.RMSNorm(args.hidden_size, eps=args.rms_norm_eps)
self.norm = nn.RMSNorm(args.hidden_size, eps=args.layer_norm_epsilon)
self.block_type = block_type
@@ -272,6 +375,8 @@ class NemotronHBlock(nn.Module):
self.mixer = NemotronHAttention(args)
elif self.block_type == "-":
self.mixer = NemotronHMLP(args)
elif self.block_type == "E":
self.mixer = NemotronHMoE(args)
def __call__(
self,
@@ -296,7 +401,7 @@ class NemotronHModel(nn.Module):
NemotronHBlock(args, block_type)
for block_type in args.hybrid_override_pattern
]
self.norm_f = nn.RMSNorm(args.hidden_size, eps=args.rms_norm_eps)
self.norm_f = nn.RMSNorm(args.hidden_size, eps=args.layer_norm_epsilon)
self.fa_idx = 0
self.ssm_idx = 0
for b in args.hybrid_override_pattern:
@@ -372,4 +477,23 @@ class Model(nn.Module):
for k, v in weights.items():
if "conv1d.weight" in k and v.shape[-1] != 1:
weights[k] = v.moveaxis(2, 1)
# Stack experts
for l in range(self.args.num_hidden_layers):
prefix = f"backbone.layers.{l}.mixer"
for m, n in [("down_proj", "fc2"), ("up_proj", "fc1")]:
if f"{prefix}.experts.0.{m}.weight" in weights:
to_join = [
weights.pop(f"{prefix}.experts.{e}.{m}.weight")
for e in range(self.args.n_routed_experts)
]
weights[f"{prefix}.switch_mlp.{n}.weight"] = mx.stack(to_join)
return weights
@property
def cast_predicate(self):
def predicate(k):
return "e_score_correction_bias" not in k and "A_log" not in k
return predicate
+32
View File
@@ -5,6 +5,7 @@ from typing import Any, Dict, Optional, Union
import mlx.core as mx
import mlx.nn as nn
from mlx.nn.layers.distributed import shard_linear
from .base import BaseModelArgs, create_attention_mask, scaled_dot_product_attention
from .rope_utils import initialize_rope
@@ -183,6 +184,37 @@ class Model(nn.Module):
k: v for k, v in weights.items() if "self_attn.rotary_emb.inv_freq" not in k
}
def shard(self, group: Optional[mx.distributed.Group] = None):
group = group or mx.distributed.init()
N = group.size()
for layer in self.model.layers:
# Shard the self attention
layer.self_attn.q_proj = shard_linear(
layer.self_attn.q_proj, "all-to-sharded", group=group
)
layer.self_attn.k_proj = shard_linear(
layer.self_attn.k_proj, "all-to-sharded", group=group
)
layer.self_attn.v_proj = shard_linear(
layer.self_attn.v_proj, "all-to-sharded", group=group
)
layer.self_attn.o_proj = shard_linear(
layer.self_attn.o_proj, "sharded-to-all", group=group
)
layer.self_attn.n_heads //= N
layer.self_attn.n_kv_heads //= N
# Shard the MLP
layer.mlp.gate_proj = shard_linear(
layer.mlp.gate_proj, "all-to-sharded", group=group
)
layer.mlp.down_proj = shard_linear(
layer.mlp.down_proj, "sharded-to-all", group=group
)
layer.mlp.up_proj = shard_linear(
layer.mlp.up_proj, "all-to-sharded", group=group
)
@property
def layers(self):
return self.model.layers
+32
View File
@@ -5,6 +5,7 @@ from typing import Any, Dict, Optional, Union
import mlx.core as mx
import mlx.nn as nn
from mlx.nn.layers.distributed import shard_linear
from .base import BaseModelArgs, create_attention_mask, scaled_dot_product_attention
from .rope_utils import initialize_rope
@@ -185,6 +186,37 @@ class Model(nn.Module):
weights.pop("lm_head.weight", None)
return weights
def shard(self, group: Optional[mx.distributed.Group] = None):
group = group or mx.distributed.init()
N = group.size()
for layer in self.model.layers:
# Shard the self attention
layer.self_attn.q_proj = shard_linear(
layer.self_attn.q_proj, "all-to-sharded", group=group
)
layer.self_attn.k_proj = shard_linear(
layer.self_attn.k_proj, "all-to-sharded", group=group
)
layer.self_attn.v_proj = shard_linear(
layer.self_attn.v_proj, "all-to-sharded", group=group
)
layer.self_attn.o_proj = shard_linear(
layer.self_attn.o_proj, "sharded-to-all", group=group
)
layer.self_attn.n_heads //= N
layer.self_attn.n_kv_heads //= N
# Shard the MLP
layer.mlp.gate_proj = shard_linear(
layer.mlp.gate_proj, "all-to-sharded", group=group
)
layer.mlp.down_proj = shard_linear(
layer.mlp.down_proj, "sharded-to-all", group=group
)
layer.mlp.up_proj = shard_linear(
layer.mlp.up_proj, "all-to-sharded", group=group
)
@property
def layers(self):
return self.model.layers
+27 -9
View File
@@ -43,18 +43,36 @@ class SuScaledRoPE(nn.Module):
long_mscale (float, optional): Scale the input prior to embedding.
"""
super().__init__()
freqs = base ** (mx.arange(0, dims, 2, dtype=mx.float32) / dims)
self._freqs = mx.array(long_factor, dtype=mx.float32) * freqs
self.original_max_position_embeddings = original_max_position_embeddings
self.scale = long_mscale or math.sqrt(
1
+ math.log(max_position_embeddings / original_max_position_embeddings)
/ math.log(original_max_position_embeddings)
)
self.dim = dims
freqs = base ** (mx.arange(0, dims, 2, dtype=mx.float32) / dims)
self._short_freqs = mx.array(short_factor, dtype=mx.float32) * freqs
self._long_freqs = mx.array(long_factor, dtype=mx.float32) * freqs
def default_scale(factor):
return math.sqrt(
1 + math.log(factor) / math.log(original_max_position_embeddings)
)
factor = max_position_embeddings / original_max_position_embeddings
self._short_scale = short_mscale or (
1.0 if factor <= 1.0 else default_scale(factor)
)
self._long_scale = long_mscale or (
1.0 if factor <= 1.0 else default_scale(factor)
)
def __call__(self, x, offset: int = 0):
x[..., : self.dim] = self.scale * x[..., : self.dim]
seq_len = offset + x.shape[-2]
if seq_len > self.original_max_position_embeddings:
freqs = self._long_freqs
scale = self._long_scale
else:
freqs = self._short_freqs
scale = self._short_scale
x[..., : self.dim] = scale * x[..., : self.dim]
return mx.fast.rope(
x,
self.dim,
@@ -62,7 +80,7 @@ class SuScaledRoPE(nn.Module):
base=None,
scale=1.0,
offset=offset,
freqs=self._freqs,
freqs=freqs,
)
+1 -1
View File
@@ -139,7 +139,7 @@ def ssm_attn(
dt = compute_dt(dt, dt_bias, time_step_limit)
repeats = h // g
A = -mx.exp(A_log)
A = -mx.exp(A_log).astype(dt.dtype)
dtA = dt * A.reshape(1, 1, -1)
dtx = dt.reshape(b, l, h, 1) * x
+29 -5
View File
@@ -34,7 +34,13 @@ from huggingface_hub import scan_cache_dir
from ._version import __version__
from .generate import BatchGenerator, stream_generate
from .models.cache import can_trim_prompt_cache, make_prompt_cache, trim_prompt_cache
from .models.cache import (
KVCache,
RotatingKVCache,
can_trim_prompt_cache,
make_prompt_cache,
trim_prompt_cache,
)
from .sample_utils import make_logits_processors, make_sampler
from .utils import load
@@ -378,6 +384,7 @@ class ModelProvider:
self.model = None
self.tokenizer = None
self.draft_model = None
self.cache_types = set()
# Preload the default model if it is provided
self.default_model_map = {}
@@ -448,6 +455,15 @@ class ModelProvider:
elif draft_model_path is not None and draft_model_path != "default_model":
self.draft_model, draft_tokenizer = load(draft_model_path)
validate_draft_tokenizer(draft_tokenizer)
# Figure out the cache types and save them in a set for anybody that
# wants to make a decision based on those.
for c in make_prompt_cache(self.model):
self.cache_types.add(type(c))
if self.draft_model is not None:
for c in make_prompt_cache(self.draft_model):
self.cache_types.add(type(c))
return self.model, self.tokenizer
@@ -477,6 +493,7 @@ class ResponseGenerator:
messages,
tools,
add_generation_prompt=True,
tokenize=True,
**self.model_provider.cli_args.chat_template_args,
)
else:
@@ -490,6 +507,9 @@ class ResponseGenerator:
or self.model_provider.cli_args.draft_model is not None
):
return False
for c in self.model_provider.cache_types:
if c not in (KVCache, RotatingKVCache):
return False
if args.logits.logit_bias is not None:
return False
if args.logits.repetition_penalty != 0:
@@ -512,12 +532,15 @@ class ResponseGenerator:
unprocessed_requests = []
def get_next_request():
def get_next_request(timeout=None):
if unprocessed_requests:
return unprocessed_requests.pop()
else:
try:
return self.requests.get_nowait()
if timeout is not None:
return self.requests.get(timeout=timeout)
else:
return self.requests.get_nowait()
except QueueEmpty:
return None
@@ -529,7 +552,8 @@ class ResponseGenerator:
while not self._stop:
request = None
if not drain_batch:
request = get_next_request()
timeout = 0.1 if batch_generator is None else None
request = get_next_request(timeout=timeout)
# We got a request
if request is not None:
@@ -921,7 +945,7 @@ class APIHandler(BaseHTTPRequestHandler):
self.top_p = self.body.get("top_p", self.response_generator.cli_args.top_p)
self.top_k = self.body.get("top_k", self.response_generator.cli_args.top_k)
self.min_p = self.body.get("min_p", self.response_generator.cli_args.min_p)
self.repetition_penalty = self.body.get("repetition_penalty", 1.0)
self.repetition_penalty = self.body.get("repetition_penalty", 0.0)
self.repetition_context_size = self.body.get("repetition_context_size", 20)
self.xtc_probability = self.body.get("xtc_probability", 0.0)
self.xtc_threshold = self.body.get("xtc_threshold", 0.0)
+6 -13
View File
@@ -89,11 +89,7 @@ class NaiveStreamingDetokenizer(StreamingDetokenizer):
def text(self):
if self._current_tokens:
self._current_text = self._tokenizer.decode(self._current_tokens)
if self._current_text.endswith("\ufffd") or (
self._tokenizer.clean_up_tokenization_spaces
and len(self._current_text) > 0
and self._current_text[-1] == " "
):
if self._current_text.endswith("\ufffd"):
self._current_text = self._current_text[:-1]
if self._current_text and self._current_text[-1] == "\n":
self._text += self._current_text
@@ -161,8 +157,6 @@ class BPEStreamingDetokenizer(StreamingDetokenizer):
_space_matches = (".", "?", "!", ",", "n't", "'m", "'s", "'ve", "'re")
def __init__(self, tokenizer):
self.clean_spaces = tokenizer.clean_up_tokenization_spaces
# Extract the tokens in a list from id to text
self.tokenmap = [None] * len(tokenizer.vocab)
for value, tokenid in tokenizer.vocab.items():
@@ -197,8 +191,6 @@ class BPEStreamingDetokenizer(StreamingDetokenizer):
return current_text
elif not self.text:
return current_text[1:]
elif self.clean_spaces and current_text[1:].startswith(self._space_matches):
return current_text[1:]
return current_text
def add_token(self, token):
@@ -208,10 +200,7 @@ class BPEStreamingDetokenizer(StreamingDetokenizer):
text = self._decode_bytes(self._unflushed)
# For multi-byte utf-8 wait until they are complete
# For single spaces wait until the next token to clean it if needed
if not text.endswith("\ufffd") and not (
len(v) == 1 and self._byte_decoder.get(v[0]) == 32
):
if not text.endswith("\ufffd"):
self.text += self._maybe_trim_space(text)
self._unflushed = ""
@@ -289,6 +278,10 @@ class TokenizerWrapper:
self._tool_call_end = tool_call_end
break
def apply_chat_template(self, *args, **kwargs):
kwargs["return_dict"] = False
return self._tokenizer.apply_chat_template(*args, **kwargs)
def add_eos_token(self, token: str):
token_id = None
try:
+13 -3
View File
@@ -57,7 +57,11 @@ class ChatDataset:
def process(self, d):
messages = d[self.chat_key]
tools = d.get("tools", None)
tokens = self.tokenizer.apply_chat_template(messages, tools=tools)
tokens = self.tokenizer.apply_chat_template(
messages,
tools=tools,
return_dict=False,
)
if self.mask_prompt:
add_generation_prompt = messages[-1].get("role") == "assistant"
offset = len(
@@ -65,6 +69,7 @@ class ChatDataset:
messages[:-1],
tools=tools,
add_generation_prompt=add_generation_prompt,
return_dict=False,
)
)
return (tokens, offset)
@@ -105,11 +110,16 @@ class CompletionsDataset:
{"role": "user", "content": d[self.prompt_key]},
{"role": "assistant", "content": d[self.completion_key]},
]
tokens = self.tokenizer.apply_chat_template(messages, tools=tools)
tokens = self.tokenizer.apply_chat_template(
messages, tools=tools, return_dict=False
)
if self.mask_prompt:
offset = len(
self.tokenizer.apply_chat_template(
messages[0], tools=tools, add_generation_prompt=True
messages[0],
tools=tools,
add_generation_prompt=True,
return_dict=False,
)
)
return (tokens, offset)
+54 -19
View File
@@ -333,7 +333,12 @@ def load(
return model, tokenizer
def pipeline_load(repo, return_config=False):
def sharded_load(
repo,
pipeline_group: Optional[mx.distributed.Group] = None,
tensor_group: Optional[mx.distributed.Group] = None,
return_config: bool = False,
):
# Get model path with everything but weight safetensors
model_path = _download(
repo,
@@ -349,27 +354,50 @@ def pipeline_load(repo, return_config=False):
],
)
# Lazy load and shard model to figure out which weights we need
# Lazy load model to figure out what type of sharding we can do and which
# weights we need to download.
model, config = load_model(model_path, lazy=True, strict=False)
group = mx.distributed.init()
rank = group.rank()
model.model.pipeline(group)
has_pipelining = hasattr(model.model, "pipeline")
has_tensor_parallel = hasattr(model, "shard")
# Figure out which files we need for the local shard
with open(model_path / "model.safetensors.index.json", "r") as fid:
weight_index = json.load(fid)["weight_map"]
if pipeline_group is not None and not has_pipelining:
raise ValueError(
"The model does not support pipelining but a pipeline_group was provided"
)
if tensor_group is not None and not has_tensor_parallel:
raise ValueError(
"The model does not support tensor parallelism but a tensor_group was provided"
)
if not has_pipelining and not has_tensor_parallel:
raise ValueError("The model does not support any sharding")
local_files = set()
for k, _ in tree_flatten(model.parameters()):
if file_name := weight_index.get(k, None) is None:
raise ValueError(
"Pipeline loading is only supported for MLX converted models."
)
local_files.add(weight_index[k])
if pipeline_group is tensor_group is None:
if has_tensor_parallel:
tensor_group = mx.distributed.init()
elif has_pipelining:
pipeline_group = mx.distributed.init()
# Download weights for local shard
_download(repo, allow_patterns=local_files)
# If pipelining then figure out which files we need for the local shard
if pipeline_group is not None:
model.model.pipeline(pipeline_group)
# Figure out which files we need for the local shard
with open(model_path / "model.safetensors.index.json", "r") as fid:
weight_index = json.load(fid)["weight_map"]
local_files = set()
for k, _ in tree_flatten(model.parameters()):
if file_name := weight_index.get(k, None) is None:
raise ValueError(
"Pipeline loading is only supported for MLX converted models."
)
local_files.add(weight_index[k])
# Download weights for local shard
_download(repo, allow_patterns=local_files)
else:
_download(repo)
# Load and shard the model, and load the weights
tokenizer = load_tokenizer(
@@ -378,7 +406,10 @@ def pipeline_load(repo, return_config=False):
eos_token_ids=config.get("eos_token_id", None),
)
model, _ = load_model(model_path, lazy=True, strict=False)
model.model.pipeline(group)
if tensor_group is not None:
model.shard(tensor_group)
if pipeline_group is not None:
model.model.pipeline(pipeline_group)
mx.eval(model.parameters())
# Synchronize processes to avoid timeout
@@ -389,6 +420,10 @@ def pipeline_load(repo, return_config=False):
return model, tokenizer
def pipeline_load(repo, return_config=False):
return sharded_load(repo, mx.distributed.init(), None, return_config)
def make_shards(weights: dict, max_file_size_gb: int = MAX_FILE_SIZE_GB) -> list:
"""
Splits the weights into smaller shards.
@@ -486,7 +521,7 @@ def upload_to_hub(path: str, upload_repo: str):
if tokenizer.chat_template is not None:
messages = [{{"role": "user", "content": prompt}}]
prompt = tokenizer.apply_chat_template(
messages, add_generation_prompt=True
messages, add_generation_prompt=True, return_dict=False,
)
response = generate(model, tokenizer, prompt=prompt, verbose=True)
+1 -1
View File
@@ -26,7 +26,7 @@ setup(
install_requires=[
f"mlx>={MIN_MLX_VERSION}; platform_system == 'Darwin'",
"numpy",
"transformers>=4.39.3",
"transformers==5.0.0rc1",
"sentencepiece",
"protobuf",
"pyyaml",
+6 -4
View File
@@ -21,6 +21,8 @@ class TestDatasets(unittest.TestCase):
cls.test_dir = cls.test_dir_fid.name
if not os.path.isdir(cls.test_dir):
os.mkdir(cls.test_dir_fid.name)
# Only one HF request
AutoTokenizer.from_pretrained(HF_MODEL_PATH)
@classmethod
def tearDownClass(cls):
@@ -37,7 +39,7 @@ class TestDatasets(unittest.TestCase):
data = {"text": "This is an example for the model."}
self.save_data(4 * [data])
args = types.SimpleNamespace(train=True, test=False, data=self.test_dir)
tokenizer = AutoTokenizer.from_pretrained(HF_MODEL_PATH)
tokenizer = AutoTokenizer.from_pretrained(HF_MODEL_PATH, local_files_only=True)
train, valid, test = datasets.load_dataset(args, tokenizer)
self.assertEqual(len(train), 4)
self.assertEqual(len(valid), 4)
@@ -50,7 +52,7 @@ class TestDatasets(unittest.TestCase):
data = {"prompt": "What is the capital of France?", "completion": "Paris."}
self.save_data(4 * [data])
args = types.SimpleNamespace(train=True, test=False, data=self.test_dir)
tokenizer = AutoTokenizer.from_pretrained(HF_MODEL_PATH)
tokenizer = AutoTokenizer.from_pretrained(HF_MODEL_PATH, local_files_only=True)
train, valid, test = datasets.load_dataset(args, tokenizer)
self.assertEqual(len(train), 4)
self.assertEqual(len(valid), 4)
@@ -69,7 +71,7 @@ class TestDatasets(unittest.TestCase):
}
self.save_data(4 * [data])
args = types.SimpleNamespace(train=True, test=False, data=self.test_dir)
tokenizer = AutoTokenizer.from_pretrained(HF_MODEL_PATH)
tokenizer = AutoTokenizer.from_pretrained(HF_MODEL_PATH, local_files_only=True)
train, valid, test = datasets.load_dataset(args, tokenizer)
self.assertEqual(len(train), 4)
self.assertEqual(len(valid), 4)
@@ -91,7 +93,7 @@ class TestDatasets(unittest.TestCase):
test=False,
train=True,
)
tokenizer = AutoTokenizer.from_pretrained(HF_MODEL_PATH)
tokenizer = AutoTokenizer.from_pretrained(HF_MODEL_PATH, local_files_only=True)
train, valid, test = datasets.load_dataset(args, tokenizer)
self.assertTrue(len(train) > 0)
self.assertTrue(len(train[0]) > 0)
+7 -4
View File
@@ -81,7 +81,7 @@ class TestGenerate(unittest.TestCase):
def test_stream_generate_speculative(self):
# Use same model as draft model, this is not a speed test
draft_model, _ = load(self.HF_MODEL_PATH)
draft_model = self.model
results: List[GenerationResponse] = []
drafted: List[bool] = []
@@ -90,7 +90,8 @@ class TestGenerate(unittest.TestCase):
sampler = make_sampler(temp=0.0)
messages = [{"role": "user", "content": "hello"}]
prompt = self.tokenizer.apply_chat_template(
messages, add_generation_prompt=True
messages,
add_generation_prompt=True,
)
for generation_result in stream_generate(
@@ -117,7 +118,8 @@ class TestGenerate(unittest.TestCase):
# get prompt embeddings
messages = [{"role": "user", "content": "Say 'TEST' and nothing else"}]
prompt = self.tokenizer.apply_chat_template(
messages, add_generation_prompt=True
messages,
add_generation_prompt=True,
)
prompt_embeddings = self.model.model.embed_tokens(prompt)
@@ -140,7 +142,8 @@ class TestGenerate(unittest.TestCase):
# get prompt embeddings
messages = [{"role": "user", "content": "Say 'TEST' and nothing else"}]
prompt = self.tokenizer.apply_chat_template(
messages, add_generation_prompt=True
messages,
add_generation_prompt=True,
)
prompt_embeddings = self.model.model.embed_tokens(prompt)
+35
View File
@@ -2045,6 +2045,41 @@ class TestModels(unittest.TestCase):
"type": "yarn",
},
},
{
"model_type": "mimo_v2_flash",
"num_experts_per_tok": 2,
"hybrid_layer_pattern": [0, 1, 0, 1],
"moe_layer_freq": [0, 1, 0, 1],
"add_swa_attention_sink_bias": True,
"add_full_attention_sink_bias": False,
"sliding_window_size": 32,
"vocab_size": 1000,
"hidden_size": 512,
"intermediate_size": 512,
"moe_intermediate_size": 128,
"num_hidden_layers": 4,
"num_attention_heads": 4,
"num_key_value_heads": 2,
"n_shared_experts": 1,
"n_routed_experts": 8,
"routed_scaling_factor": None,
"topk_method": "noaux_tc",
"scoring_func": "sigmoid",
"norm_topk_prob": True,
"n_group": 2,
"topk_group": 1,
"max_position_embeddings": 1000,
"layernorm_epsilon": 1e-5,
"rope_theta": 1000.0,
"swa_rope_theta": 1000.0,
"swa_num_attention_heads": 4,
"swa_num_key_value_heads": 2,
"head_dim": 128,
"v_head_dim": 64,
"swa_head_dim": 128,
"swa_v_head_dim": 64,
"partial_rotary_factor": 0.5,
},
]
for config in test_configs:
model_type = config["model_type"]
+4 -3
View File
@@ -34,6 +34,7 @@ class TestPromptCache(unittest.TestCase):
def setUpClass(cls):
cls.test_dir_fid = tempfile.TemporaryDirectory()
cls.test_dir = cls.test_dir_fid.name
cls.model, cls.tokenizer = load(HF_MODEL_PATH)
@classmethod
def tearDownClass(cls):
@@ -132,7 +133,7 @@ class TestPromptCache(unittest.TestCase):
self.assertTrue(mx.array_equal(v, lv))
def test_cache_with_generate(self):
model, tokenizer = load(HF_MODEL_PATH)
model, tokenizer = self.model, self.tokenizer
prompt = tokenizer.encode("this is a prompt", return_tensors="mlx")[0]
results = list(generate_step(prompt, model, max_tokens=4))
toks, all_logits = zip(*results)
@@ -212,7 +213,7 @@ class TestPromptCache(unittest.TestCase):
self.assertEqual(num_trimmed, 3)
def test_trim_cache_with_generate(self):
model, tokenizer = load(HF_MODEL_PATH)
model, tokenizer = self.model, self.tokenizer
prompt = tokenizer.encode("this is a prompt", return_tensors="mlx")[0]
prompt_cache = make_prompt_cache(model)
@@ -289,7 +290,7 @@ class TestPromptCache(unittest.TestCase):
self.assertEqual(metadata, loaded_metadata)
def test_cache_to_quantized(self):
model, tokenizer = load(HF_MODEL_PATH)
model, tokenizer = self.model, self.tokenizer
prompt = tokenizer.encode("this is a prompt", return_tensors="mlx")[0]
results = zip(range(4), generate_step(prompt, model))
toks, all_logits = zip(*(r[1] for r in results))
+1
View File
@@ -19,6 +19,7 @@ class DummyModelProvider:
HF_MODEL_PATH = "mlx-community/Qwen1.5-0.5B-Chat-4bit"
self.model, self.tokenizer = load(HF_MODEL_PATH)
self.model_key = (HF_MODEL_PATH, None)
self.cache_types = set([KVCache])
# Add draft model support
self.draft_model = None