Compare commits

...

82 Commits

Author SHA1 Message Date
Anastasiia Filippova 95238e5d34 sharded gemma 4 2026-04-23 15:53:57 +02:00
Angelos Katharopoulos ed1fca4cef Thread local generation stream (#1090) 2026-04-22 00:34:09 -07:00
glyphVault 4f5cbd2a4f Fix Gemma 4 KV-shared layers creating unused projections (#1158) 2026-04-21 16:44:13 -07:00
Angelos Katharopoulos 3cd9a52df2 Fix ArraysCache extend (#1177) 2026-04-21 16:41:49 -07:00
Eyüp Can Akman 2f1ab85aec Fix Mistral empty tool_call_end flipping state machine to normal (#1151) 2026-04-21 01:36:56 -07:00
AkashKhamkar f3bb10c141 Fix Gemma4 tool parser: support hyphenated names and braces in strings (#1150) 2026-04-21 01:15:58 -07:00
Sherry Lo e1c24b3237 fix: handle NoneType check for think tokens in TokenizerWrapper (#1167) 2026-04-21 01:13:15 -07:00
Luis Molina f39cb8e934 Fix dwq: check for actual safetensors in target_dir (#1173) 2026-04-21 00:41:53 -07:00
TechToboggan a9856b485d Fix batch dimension mismatch in ArraysCache extend() (#1169)
Co-authored-by: Tristan Stahnke <tristan@melchior.lan>
Co-authored-by: Angelos Katharopoulos <a_katharopoulos@apple.com>
2026-04-20 23:42:03 -07:00
Andrei Panferov e92138cb01 Apertus tie_word_embeddings fix (#1143) 2026-04-20 23:47:40 +02:00
Siiea-ai a401730941 Fix missing tree_reduce import in models/cache.py (#1165) 2026-04-20 11:31:58 -07:00
Tarjei Mandt 6d114686e5 Fix MiniMax M2 parallel tool calling (#1171) 2026-04-20 10:57:20 -07:00
Tarjei Mandt aa4f880fb3 Fix parallel tool call handling in server (#1170) 2026-04-19 23:58:25 -07:00
razorback16 62f38aeb51 Fix batch dimension mismatch in BatchKVCache and BatchRotatingKVCache extend() (#1141) 2026-04-14 17:21:31 -07:00
Angelos Katharopoulos d9c63fff67 Bump the patch version (#1124) 2026-04-08 02:04:27 -07:00
Neil Mehta dcbf6e33d1 Align batch logits processor token contract (#1115) 2026-04-06 18:07:35 -07:00
Angelos Katharopoulos f26fddfd3b Gemma4 final fixes and multi-token think/tool start/end (#1114) 2026-04-06 17:40:48 -07:00
Tarjei Mandt f56d99712c Fix output corruption in speculative decoding (#1109) 2026-04-06 16:00:38 -07:00
spicyneuron c65c27b450 Fix Gemma 4 quantized per-layer projection loading (#1112) 2026-04-06 13:26:07 -07:00
Nic Davidson 3257c3df17 Add Gemma 4 tool call parser (#1105) 2026-04-04 17:21:45 -07:00
Angelos Katharopoulos d4eb136d44 Bring back max-kv-size to the batch generator (#1106) 2026-04-04 17:12:43 -07:00
Prince Canuma 4469ad4647 Add gemma 4 (#1093)
Co-authored-by: N8 <n8@n8programs.com>
Co-authored-by: Angelos Katharopoulos <a_katharopoulos@apple.com>
2026-04-04 04:47:03 -07:00
Matteo Celani f79dba7832 perf: use max instead of argsort in apply_min_p sampling (#1083) 2026-04-01 16:26:59 -07:00
Angelos Katharopoulos 3f9d179fd1 Batch generation refactoring and various fixes (#1072) 2026-04-01 15:07:50 -07:00
Lik Xun Yuan (Lx) 9dc023beed Fix PromptTrie.pop_prefixes() off-by-one when pruning immediate prefixes (#1078)
Signed-off-by: Yuan Lik Xun <lxyuan0420@gmail.com>
Co-authored-by: Angelos Katharopoulos <a_katharopoulos@apple.com>
2026-04-01 11:51:19 -07:00
Adam Durham 9dcefa5272 fix: break shared-buffer memory leak in GatedDeltaNet cache (#1077)
Co-authored-by: Angelos Katharopoulos <a_katharopoulos@apple.com>
2026-03-31 21:27:11 -07:00
Arthur Hjorth bdeac59767 Inserting logits processors into BatchGenerator in batch_generate (#1008)
Co-authored-by: Angelos Katharopoulos <a_katharopoulos@apple.com>
2026-03-31 16:48:13 -07:00
Tarjei Mandt 6ddfdda1ac Fix SSM dt clamp default for Nemotron-H (#1026) 2026-03-30 04:19:27 -07:00
Angelos Katharopoulos 4d3af3cebc Refactor LRUPromptCache (#1019) 2026-03-26 10:04:22 -07:00
Angelos Katharopoulos ed7884cb80 Fix missing cache advance from qwen 3.5 (#1024) 2026-03-19 17:20:38 -07:00
Angelos Katharopoulos f8019f7769 Fix flaky test (#1020) 2026-03-18 12:51:22 -07:00
AndreasPlt 564281f793 Supporting delay in mlx_lm benchmark (#1010) 2026-03-16 17:43:37 -07:00
Angelos Katharopoulos 73c8550478 Nemotron super support (#992) 2026-03-16 10:59:14 -07:00
mm65x ed69f837e6 fall back to ast.literal_eval for malformed JSON in qwen3_coder tool parser (#1004) 2026-03-15 23:00:51 -07:00
mm65x cc393b2862 Handle missing content-length header in server (#1001) 2026-03-15 19:40:29 -07:00
mm65x 2146e4ed18 avoid mutating input in SuScaledRoPE and YarnRoPE (#1003) 2026-03-15 18:13:47 -07:00
Angelos Katharopoulos 735a43b275 Delta net precision (#997) 2026-03-15 15:39:18 -07:00
Ryo Ota 332d94ca6f Add allowed-origins to the server (#987) 2026-03-13 19:22:23 -07:00
n8programs 480934402d Clear cache trainer memory (#986)
Co-authored-by: N8 <n8@n8programs.com>
Co-authored-by: Angelos Katharopoulos <a_katharopoulos@apple.com>
2026-03-13 18:19:53 -07:00
Angelos Katharopoulos ab157c2d18 Fix test after latest MLX update (#996) 2026-03-13 17:07:05 -07:00
Angelos Katharopoulos 5a8ced697e Bump the patch version (#981) 2026-03-10 23:27:59 -07:00
Eyüp Can Akman 760c5abcc8 Fix CompletionsDataset mask_prompt crash (#967) 2026-03-10 18:10:06 -07:00
Angelos Katharopoulos 43ee5455d3 Move to metal agnostic device_info (#979) 2026-03-10 17:41:33 -07:00
Angelos Katharopoulos 23af85703e Late binding caused incorrect cache checkpoint (#976) 2026-03-10 13:53:10 -07:00
rltakashige 89c430a9c2 Eval self.left_padding whenever it is updated in BatchRotatingKVCache (#960)
Co-authored-by: Angelos Katharopoulos <a_katharopoulos@apple.com>
2026-03-10 03:14:31 -07:00
Angelos Katharopoulos 4a21ffdf4b Presence and frequency penalties (#971) 2026-03-09 22:26:39 -07:00
Angelos Katharopoulos 852119b774 Bump the patch version (#959) 2026-03-08 18:04:52 -07:00
Angelos Katharopoulos 044474bc80 Adds tensor parallelism for Qwen 3.5 (#957) 2026-03-06 19:44:53 -08:00
Angelos Katharopoulos 2105aaf9c3 Better caching in the server (#911) 2026-03-06 13:42:56 -08:00
Angelos Katharopoulos cff7273a55 Ensure normalization does not promote to fp32 (#951) 2026-03-06 13:42:10 -08:00
Angelos Katharopoulos fc7d84448b Bump the version (#954) 2026-03-06 13:41:47 -08:00
spicyneuron 47be7150a6 fix: convert() uses incorrect defaults for quantization mode (#935) 2026-03-05 17:02:34 -08:00
Yongyue Sun 35fa620279 Add --prefill-step-size as cmd line argument (#943) 2026-03-04 17:40:01 -08:00
Noah Lyons 8162aaad56 step3p5: use rotating cache for sliding attention layers (#949) 2026-03-04 17:17:29 -08:00
Awni Hannun 834fac934c fix qwen3.5 sanitize (#928) 2026-02-24 17:04:43 -08:00
Awni Hannun 179da774b1 Clear the cache during batch generation (#926) 2026-02-23 19:50:35 -08:00
Awni Hannun 720f2369ba add tokens to eval to avoid large graphs when they are not used (#924) 2026-02-23 14:38:08 -08:00
Flynn 65725dcec2 Add filter guard to list comprehension (#918) 2026-02-23 14:22:54 -08:00
n8programs d4701ba513 clear cache on prompt ingestion in server (#917)
Co-authored-by: N8 <n8@n8programs.com>
2026-02-23 12:13:25 -08:00
Angelos Katharopoulos 321e764e0a Make the cache limits more friendly (#910) 2026-02-19 13:52:09 -08:00
Angelos Katharopoulos 83ff9c96d5 Improve the cache size limits (#906) 2026-02-19 10:13:48 -08:00
Yuri Khrustalev 9c113f7019 Allow reading LFM2 models nested rope params (#908)
Co-authored-by: yuri <yuri@liquid-macstudio-2.local>
2026-02-18 16:25:54 -08:00
Gökdeniz Gülmez 7d6c5e4af7 Add tie_word_embeddings modulars in mistral and qwen3 moe (#889)
* Add tie_word_embeddings option and update model call logic in Mixtral and Qwen3 models

* Update copyright year to 2026 and modify input handling in Mixtral and Qwen3 models
2026-02-18 11:23:40 -08:00
Awni Hannun ad067ea627 bump for next version (#904) 2026-02-17 07:39:58 -08:00
Angelos Katharopoulos d7b91e80f0 Fix sharded rms norm in MiniMax M2.5 (#898) 2026-02-16 17:20:07 -08:00
Awni Hannun 1fd521c3c7 fix qwen3.5 casting to fp32 (#902) 2026-02-16 10:23:31 -08:00
Ryan Goulden 572ada278c server: add usage.prompt_tokens_details.cached_tokens to json response (#849) 2026-02-16 08:37:35 -08:00
Ivan Fioravanti fb47f8fb99 Add the trust remote code option to mlx_lm perplexity (#896) 2026-02-15 20:43:23 -08:00
Tarjei Mandt 7a720882a7 Add JoyAI LLM Flash (#894) 2026-02-15 08:06:44 -08:00
spicyneuron 014ebc6a46 Fix mixed quant predicates for MLA models (#892) 2026-02-15 02:44:01 -08:00
Angelos Katharopoulos c6d9d3c9f5 Share model (#871) 2026-02-13 15:48:37 -08:00
Angelos Katharopoulos bcf630614f Fix save/load of CacheList (#886) 2026-02-12 18:41:48 -08:00
Gökdeniz Gülmez 1974376d70 Add GLM5 (#867)
* Add GLM4 MoE DSA model implementation with configurable parameters

* Update Acknowledgments to include GLM4 MoE DSA support

* format

* update ackn.

* Fixes

* Update acknowledgments to include contributions for GLM MoE DSA and additional architectures

* use dsv32 for glm5

* fix

* Fix rope theta

---------

Co-authored-by: Tarjei Mandt <kernelpool@gmail.com>
Co-authored-by: Awni Hannun <awni@apple.com>
2026-02-12 10:11:16 -08:00
Tarjei Mandt 7e67225e1d Faster DSV32 generation (#885) 2026-02-12 07:35:20 -08:00
JJJYmmm 0fd3126496 [MODEL] support qwen3.5 series w/o vision (#869)
* support text-only qwen3.5 series

Co-authored-by: johnmai-dev <johnmai-dev@users.noreply.github.com>

* add test

* fix sanitize and add test

* make it more readable

* fix lint

---------

Co-authored-by: johnmai-dev <johnmai-dev@users.noreply.github.com>
2026-02-12 07:23:51 -08:00
Tarjei Mandt ca0d1c9630 LongCat MLA (#868)
* LongCat MLA

* Fix comment

* Remove workaround
2026-02-12 06:54:44 -08:00
Awni Hannun 82edd51a1e Devstral tool parser (#874) 2026-02-11 09:41:23 -08:00
Gökdeniz Gülmez aca4c149a1 Make validation set optional in training process (#857) 2026-02-10 15:24:44 -08:00
Tarjei Mandt 8f1c56ec83 Fix DeepSeek V3.2 indexer and weight loading (#866) 2026-02-10 12:15:12 -08:00
viktike 84ae19e675 Pythonic tool calling for LFM2 models (#864)
* Fix tool calling 404 Error: content with non-thinking (instruct) models.

* Add pythonic style tool call parser for LFM2

* test + format

---------

Co-authored-by: Awni Hannun <awni@apple.com>
2026-02-09 15:23:07 -08:00
Awni Hannun 645a326a2e Bump version for next release (#865) 2026-02-09 14:40:57 -08:00
Tarjei Mandt fd6959dca7 Fix Kimi Linear (#853)
* Fix Kimi Linear

* Avoid concat/split

* Use fused rms_norm
2026-02-06 17:31:32 -08:00
60 changed files with 6536 additions and 1401 deletions
+1 -1
View File
@@ -40,5 +40,5 @@ jobs:
run: |
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/
METAL_DEVICE_WRAPPER_TYPE=1 METAL_DEBUG_ERROR_MODE=0 HF_HOME="." python -m xmlrunner discover -v tests -o test-results/
mlx.launch -n 2 tests/model_parallel_tests.py
+6 -2
View File
@@ -10,7 +10,7 @@ MLX LM was developed with contributions from the following individuals:
- Shunta Saito: Added support for PLaMo models.
- Gökdeniz Gülmez: Added support for the following architectures:
OpenBMB's `MiniCPM` and `MiniCPM3`, Kyutai's `Helium`, State-Space's `Mamba v1` and
`Mamba v2`, Z.ai & THUKEG's `GLM`, `GLM4`, Rednote `dots.llm1`, Baidu's `Ernie4.5 MoE`,
`Mamba v2`, Z.ai & THUKEG's `GLM`, `GLM4`, `GLM5 (GLM MoE DSA)`, Rednote `dots.llm1`, Baidu's `Ernie4.5 MoE`,
inclusionAI's `Bailing MoE e.g. Ling-family`, `Bailing MoE Linear e.g. Ling-Linear-family`,
Klear team - Kuaishou Technology's `Klear`, AI21 Lab's `Jamba` IBM's `Granite MoE`,
Meituan's `LongCat`, Nvidia's `Nemotron H`, Swiss-AI's `Apertus`, Nikity's `Lille130m`,
@@ -26,4 +26,8 @@ Added support for the following other features:
MoonshotAI's `Kimi-Linear`, LiquidAI's `LFM2` and `LFM2 MoE`,
Google DeepMind's `Gemma 3`, TII's `Falcon H1` and InterLM's `InternLM 2.5`.
- Ivan Fioravanti: Added support for the following architectures:
ServiceNow-AI's `Apriel 1.5`, Tencent's `Hunyuan Dense V1` and `Hunyuan MoE V1`.
ServiceNow-AI's `Apriel 1.5`, Tencent's `Hunyuan Dense V1` and `Hunyuan MoE V1`.
- Tarjei Mandt: Added support for the following architectures: `Step 3.5 Flash`,
MoonshotAI's `Kimi K2.5`, Upstage's `Solar Open`, LG AI Research's `K-Exaone MoE`,
Meituan's `LongCat Flash Lite` Helped add support for the following model architectures:
Z.ai & THUKEG's `GLM5 (GLM MoE DSA)`
+8 -6
View File
@@ -66,9 +66,10 @@ mlx_lm.lora \
To fine-tune the full model weights, add the `--fine-tune-type full` flag.
Currently supported fine-tuning types are `lora` (default), `dora`, and `full`.
The `--data` argument must specify a path to a `train.jsonl`, `valid.jsonl`
when using `--train` and a path to a `test.jsonl` when using `--test`. For more
details on the data format see the section on [Data](#Data).
The `--data` argument must specify a path to a `train.jsonl` when using
`--train` and a path to a `test.jsonl` when using `--test`. A `valid.jsonl` is
optional; if provided, validation loss will be reported during training. For
more details on the data format see the section on [Data](#Data).
For example, to fine-tune a Mistral 7B you can use `--model
mistralai/Mistral-7B-v0.1`.
@@ -184,9 +185,10 @@ Face.
### Local Datasets
For fine-tuning (`--train`), the data loader expects a `train.jsonl` and a
`valid.jsonl` to be in the data directory. For evaluation (`--test`), the data
loader expects a `test.jsonl` in the data directory.
For fine-tuning (`--train`), the data loader expects a `train.jsonl` to be in
the data directory. A `valid.jsonl` is optional; if present, validation loss
will be reported periodically during training. For evaluation (`--test`), the
data loader expects a `test.jsonl` in the data directory.
Currently, `*.jsonl` files support `chat`, `tools`, `completions`, and `text`
data formats. Here are examples of these formats:
+14 -2
View File
@@ -72,12 +72,24 @@ curl localhost:8080/v1/chat/completions \
- `min_p`: (Optional) A float specifying the min-p sampling parameter.
Defaults to `0.0` (disabled).
- `repetition_penalty`: (Optional) Applies a penalty to repeated tokens.
Defaults to `1.0`.
- `repetition_penalty`: (Optional) Applies a multiplicative penalty to repeated
tokens. Defaults to `0.0` (disabled).
- `repetition_context_size`: (Optional) The size of the context window for
applying repetition penalty. Defaults to `20`.
- `presence_penalty`: (Optional) Applies an additive penalty to tokens
that appeared before. Defaults to `0.0` (disabled).
- `presence_context_size`: (Optional) The size of the context window for
applying presence penalty. Defaults to `20`.
- `frequency_penalty`: (Optional) Applies an additive penalty proportional to
how many times a token appeared previously. Defaults to `0.0` (disabled).
- `frequency_context_size`: (Optional) The size of the context window for
applying frequency penalty. Defaults to `20`.
- `logit_bias`: (Optional) A dictionary mapping token IDs to their bias
values. Defaults to `None`.
+1 -1
View File
@@ -1,3 +1,3 @@
# Copyright © 2023-2025 Apple Inc.
__version__ = "0.30.6"
__version__ = "0.31.3"
+28 -2
View File
@@ -1,6 +1,7 @@
# Copyright © 2025 Apple Inc.
import argparse
import time
import mlx.core as mx
@@ -60,6 +61,18 @@ def setup_arg_parser():
action="store_true",
help="Quantize activations using the same quantization config as the corresponding layer.",
)
parser.add_argument(
"--prefill-step-size",
type=int,
default=2048,
help="Step size for prefill processing (default: 2048)",
)
parser.add_argument(
"--delay",
type=int,
default=0,
help="Delay between each test in seconds (default: 0)",
)
return parser
@@ -103,14 +116,22 @@ def main():
def single_bench():
for response in stream_generate(
model, tokenizer, prompt, max_tokens=generation_tokens
model,
tokenizer,
prompt,
max_tokens=generation_tokens,
prefill_step_size=args.prefill_step_size,
):
pass
return response
def batch_bench():
return batch_generate(
model, tokenizer, prompts, max_tokens=generation_tokens
model,
tokenizer,
prompts,
max_tokens=generation_tokens,
prefill_step_size=args.prefill_step_size,
).stats
if batch_size == 1:
@@ -125,10 +146,15 @@ def main():
rprint(f"Timing with {prompt_tokens=}, {generation_tokens=}, {batch_size=}.")
responses = []
for i in range(args.num_trials):
if args.delay > 0:
time.sleep(args.delay)
tic = time.perf_counter()
response = _bench()
toc = time.perf_counter()
responses.append(response)
results = [(k, getattr(response, k)) for k in report_keys]
results = [f"{k}={v:.3f}" for k, v in results]
results.append(f"total_time={toc - tic:.3f}")
rprint(f"Trial {i+1}: " + ", ".join(results))
def avg(k):
+1
View File
@@ -22,6 +22,7 @@ def main():
"gptq",
"server",
"upload",
"share",
)
subpackages = {
"awq": "quant",
+5 -3
View File
@@ -72,7 +72,7 @@ def mixed_quant_predicate_builder(
if "lm_head" in path:
return {"group_size": group_size, "bits": high_bits, "mode": mode}
return {"group_size": group_size, "bits": low_bits}
return {"group_size": group_size, "bits": low_bits, "mode": mode}
return mixed_quant_predicate
@@ -86,8 +86,8 @@ def convert(
hf_path: str,
mlx_path: str = "mlx_model",
quantize: bool = False,
q_group_size: int = 64,
q_bits: int = 4,
q_group_size: Optional[int] = None,
q_bits: Optional[int] = None,
q_mode: str = "affine",
dtype: Optional[str] = None,
upload_repo: str = None,
@@ -128,6 +128,8 @@ def convert(
if dtype is None:
dtype = config.get("torch_dtype", None)
if dtype is None and (text_config := config.get("text_config", None)):
dtype = text_config.get("dtype", None)
if dtype in MODEL_CONVERSION_DTYPES:
print("[INFO] Using dtype:", dtype)
dtype = getattr(mx, dtype)
+1 -1
View File
@@ -27,7 +27,7 @@ prompts = [
# Set `verbose=True` to see generation statistics
result = batch_generate(
model, tokenizer, prompts, verbose=False, return_prompt_caches=True
model, tokenizer, prompts, verbose=False, return_prompt_caches=True, max_tokens=2048
)
print(result.texts[-1])
+961 -343
View File
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -21,7 +21,7 @@ from .tuner.utils import (
load_adapters,
print_trainable_parameters,
)
from .utils import load, save_config
from .utils import _parse_size, load, save_config
yaml_loader = yaml.SafeLoader
yaml_loader.add_implicit_resolver(
@@ -69,6 +69,7 @@ CONFIG_DEFAULTS = {
"config": None,
"grad_checkpoint": False,
"grad_accumulation_steps": 1,
"clear_cache_threshold": 0,
"lr_schedule": None,
"lora_parameters": {"rank": 8, "dropout": 0.0, "scale": 20.0},
"mask_prompt": False,
@@ -190,6 +191,12 @@ def build_parser():
help="Use gradient checkpointing to reduce memory use.",
default=None,
)
parser.add_argument(
"--clear-cache-threshold",
type=_parse_size,
default=0,
help="Clear the allocator cache between steps if it grows too large.",
)
parser.add_argument(
"--report-to",
type=str,
+9 -2
View File
@@ -167,7 +167,8 @@ class Model(nn.Module):
self.args = args
self.model_type = args.model_type
self.model = ApertusModel(args)
self.lm_head = nn.Linear(args.hidden_size, args.vocab_size, bias=False)
if not args.tie_word_embeddings:
self.lm_head = nn.Linear(args.hidden_size, args.vocab_size, bias=False)
def __call__(
self,
@@ -175,12 +176,18 @@ class Model(nn.Module):
cache: Optional[Any] = None,
) -> mx.array:
out = self.model(inputs, cache)
return self.lm_head(out)
if self.args.tie_word_embeddings:
out = self.model.embed_tokens.as_linear(out)
else:
out = self.lm_head(out)
return out
def sanitize(self, weights):
for k, v in weights.items():
if k.endswith("alpha_p") or k.endswith("alpha_n"):
weights[k] = v.squeeze()
if self.args.tie_word_embeddings:
weights.pop("lm_head.weight", None)
return weights
@property
+482 -26
View File
@@ -1,11 +1,13 @@
# Copyright © 2023-2024 Apple Inc.
import copy
from collections import deque
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
import mlx.core as mx
import mlx.nn as nn
from mlx.utils import tree_flatten, tree_map, tree_unflatten
from mlx.utils import tree_flatten, tree_map, tree_reduce, tree_unflatten
from .base import create_causal_mask
@@ -153,6 +155,11 @@ class _BaseCache:
"""
return 0
@property
def nbytes(self):
"""Return the size of this cache in bytes"""
raise NotImplementedError("Cache sub-class must implement nbytes")
def empty(self):
"""
Return if the cache is empty or not.
@@ -215,6 +222,12 @@ class ConcatenateKVCache(_BaseCache):
def empty(self):
return self.keys is None
@property
def nbytes(self):
if self.keys is None:
return 0
return self.keys.nbytes + self.values.nbytes
class QuantizedKVCache(_BaseCache):
step = 256
@@ -304,6 +317,10 @@ class QuantizedKVCache(_BaseCache):
def empty(self):
return self.keys is None
@property
def nbytes(self):
return tree_reduce(lambda a, x: a + x.nbytes, (self.keys, self.values), 0)
class KVCache(_BaseCache):
step = 256
@@ -383,6 +400,12 @@ class KVCache(_BaseCache):
def empty(self):
return self.keys is None
@property
def nbytes(self):
if self.keys is None:
return 0
return self.keys.nbytes + self.values.nbytes
class RotatingKVCache(_BaseCache):
step = 256
@@ -561,6 +584,12 @@ class RotatingKVCache(_BaseCache):
def empty(self):
return self.keys is None
@property
def nbytes(self):
if self.keys is None:
return 0
return self.keys.nbytes + self.values.nbytes
class ArraysCache(_BaseCache):
def __new__(cls, *args, **kwargs):
@@ -574,6 +603,18 @@ class ArraysCache(_BaseCache):
if left_padding:
self.left_padding = mx.array(left_padding)
@property
def batch_size(self):
for c in self.cache:
if c is not None:
return c.shape[0]
if self.left_padding is not None:
return self.left_padding.size
elif self.lengths is not None:
return self.lengths.size
else:
return 1
def __setitem__(self, idx, value):
self.cache[idx] = value
@@ -592,13 +633,42 @@ class ArraysCache(_BaseCache):
"""
In-place filter to keep just the given indices in the cache.
"""
self.cache = [c[batch_indices] for c in self.cache]
self.cache = [c[batch_indices] if c is not None else None for c in self.cache]
if self.left_padding is not None:
self.left_padding = self.left_padding[batch_indices]
if self.lengths is not None:
self.lengths = self.lengths[batch_indices]
def extend(self, other):
"""
In-place extend this cache with the other cache.
"""
self.cache = [mx.concatenate([c, o]) for c, o in zip(self.cache, other.cache)]
a_batch = self.batch_size
b_batch = other.batch_size
def cat(a, b):
shape = dtype = None
if a is not None:
shape = a.shape
dtype = a.dtype
if b is not None:
shape = b.shape
dtype = b.dtype
if shape is None:
return None
if a is None:
a = mx.zeros((a_batch,) + shape[1:], dtype=dtype)
if b is None:
b = mx.zeros((b_batch,) + shape[1:], dtype=dtype)
return mx.concatenate([a, b])
self.cache = [cat(c, o) for c, o in zip(self.cache, other.cache)]
self.left_padding = cat(self.left_padding, other.left_padding)
self.lengths = cat(self.lengths, other.lengths)
def extract(self, idx):
cache = ArraysCache(len(self.cache))
@@ -633,6 +703,12 @@ class ArraysCache(_BaseCache):
n_state = len(caches[0].cache)
B = len(caches)
cache = cls(n_state)
# All caches are empty so return early
if all(c.empty() for c in caches):
cache.left_padding = mx.array([0] * B)
return cache
for e in range(n_state):
c_init = next(iter(c[e] for c in caches if c[e] is not None))
shape = list(c_init.shape)
@@ -647,6 +723,10 @@ class ArraysCache(_BaseCache):
def empty(self):
return self.cache[0] is None
@property
def nbytes(self):
return sum(c.nbytes for c in self.cache if c is not None)
class ChunkedKVCache(_BaseCache):
step = 256
@@ -724,6 +804,12 @@ class ChunkedKVCache(_BaseCache):
def empty(self):
return self.keys is None
@property
def nbytes(self):
if self.keys is None:
return 0
return self.keys.nbytes + self.values.nbytes
class CacheList(_BaseCache):
def __init__(self, *caches):
@@ -742,16 +828,24 @@ class CacheList(_BaseCache):
@property
def state(self):
return [s for c in self.caches for s in c.state]
return [c.state for c in self.caches]
@state.setter
def state(self, v):
state_lens = [len(c.state) for c in self.caches]
start = 0
for c in self.caches:
l = len(c.state)
c.state = v[start : start + l]
start += l
for c, s in zip(self.caches, v):
c.state = s
@property
def meta_state(self):
return (
[type(c).__name__ for c in self.caches],
[c.meta_state for c in self.caches],
)
@meta_state.setter
def meta_state(self, v):
for c, m in zip(self.caches, v[1]):
c.meta_state = m
def filter(self, batch_indices):
"""
@@ -793,6 +887,18 @@ class CacheList(_BaseCache):
def empty(self):
return self.caches[0].empty()
@property
def nbytes(self):
return sum(c.nbytes for c in self.caches)
@classmethod
def from_state(cls, state, meta_state):
obj = cls.__new__(cls)
obj.caches = [
globals()[c].from_state(s, m) for s, c, m in zip(state, *meta_state)
]
return obj
def dynamic_roll(x, shifts, axis):
n = x.shape[axis]
@@ -911,16 +1017,18 @@ class BatchKVCache(_BaseCache):
"""
In-place filter to keep just the given indices in the cache.
"""
self.keys = self.keys[batch_indices]
self.values = self.values[batch_indices]
if self.keys is not None:
self.keys = self.keys[batch_indices]
self.values = self.values[batch_indices]
self.offset = self.offset[batch_indices]
self.left_padding = self.left_padding[batch_indices]
# Shift left to reduce padding
min_left_pad = self.left_padding.min().item()
if min_left_pad > 0:
self.keys = self.keys[..., min_left_pad:, :]
self.values = self.values[..., min_left_pad:, :]
if self.keys is not None:
self.keys = self.keys[..., min_left_pad:, :]
self.values = self.values[..., min_left_pad:, :]
self._idx -= min_left_pad
self.left_padding -= min_left_pad
@@ -928,15 +1036,31 @@ class BatchKVCache(_BaseCache):
"""
In-place extend this cache with the other cache.
"""
if self.keys is None and other.keys is None:
self.left_padding = mx.concatenate([self.left_padding, other.left_padding])
self.offset = mx.concatenate([self.offset, other.offset])
return
max_idx = max(self._idx, other._idx)
max_size = max(self.keys.shape[2], other.keys.shape[2])
L1 = L2 = 0
if self.keys is not None:
B, H, L1, D = self.keys.shape
M = self.values.shape[3]
if other.keys is not None:
B, H, L2, D = other.keys.shape
M = other.values.shape[3]
max_size = max(L1, L2)
# Pad the keys and values so they are right-justified
# with the index and the same size
def pad(c):
left = max_idx - c._idx
right = max_size - c.keys.shape[2] - left
k, v = c.keys, c.values
if k is None:
Bc = c.offset.shape[0]
k = mx.array([]).reshape(Bc, H, 0, D)
v = mx.array([]).reshape(Bc, H, 0, M)
left = max_idx - c._idx
right = max_size - k.shape[2] - left
if right < 0:
k = k[..., :right, :]
v = v[..., :right, :]
@@ -965,6 +1089,11 @@ class BatchKVCache(_BaseCache):
def merge(cls, caches):
lengths = [c.size() for c in caches]
max_length = max(lengths)
# No cache has content so make an empty one
if max_length == 0:
return BatchKVCache([0] * len(caches))
padding = [max_length - l for l in lengths]
B = len(caches)
H = max(c.keys.shape[1] for c in caches if c.keys is not None)
@@ -988,9 +1117,18 @@ class BatchKVCache(_BaseCache):
return cache
def size(self):
return self._idx
def empty(self):
return self.keys is None
@property
def nbytes(self):
if self.keys is None:
return 0
return self.keys.nbytes + self.values.nbytes
class BatchRotatingKVCache(_BaseCache):
step = 256
@@ -1061,6 +1199,10 @@ class BatchRotatingKVCache(_BaseCache):
self.offset += keys.shape[2]
self._offset += keys.shape[2]
self._idx = self.keys.shape[2]
# Make sure left_padding and offset are evaluated
self.keys = mx.depends(self.keys, (self.left_padding, self.offset))
return self.keys, self.values
def _update_in_place(self, keys, values):
@@ -1111,6 +1253,9 @@ class BatchRotatingKVCache(_BaseCache):
self.offset += S
self._idx += S
# Make sure left_padding and offset are evaluated
self.keys = mx.depends(self.keys, (self.left_padding, self.offset))
# If the buffer is not full, slice off the end
if self._offset < self.max_size:
return (
@@ -1215,8 +1360,9 @@ class BatchRotatingKVCache(_BaseCache):
"""
In-place filter to keep just the given indices in the cache.
"""
self.keys = self.keys[batch_indices]
self.values = self.values[batch_indices]
if self.keys is not None:
self.keys = self.keys[batch_indices]
self.values = self.values[batch_indices]
self.offset = self.offset[batch_indices]
self.left_padding = self.left_padding[batch_indices]
@@ -1224,17 +1370,33 @@ class BatchRotatingKVCache(_BaseCache):
"""
In-place extend this cache with the other cache.
"""
if self.keys is None and other.keys is None:
self.left_padding = mx.concatenate([self.left_padding, other.left_padding])
self.offset = mx.concatenate([self.offset, other.offset])
return
if (self.rotated != other.rotated) or self._idx != other._idx:
self._temporal_order()
other._temporal_order()
max_idx = max(self._idx, other._idx)
max_size = max(self.keys.shape[2], other.keys.shape[2])
L1 = L2 = 0
if self.keys is not None:
B, H, L1, D = self.keys.shape
M = self.values.shape[3]
if other.keys is not None:
B, H, L2, D = other.keys.shape
M = other.values.shape[3]
max_size = max(L1, L2)
def pad(c):
left = max_idx - c._idx
right = max_size - c.keys.shape[2] - left
k, v = c.keys, c.values
if k is None:
Bc = c.offset.shape[0]
k = mx.array([]).reshape(Bc, H, 0, D)
v = mx.array([]).reshape(Bc, H, 0, M)
right = max_size - k.shape[2] - left
if right < 0:
k = k[..., :right, :]
v = v[..., :right, :]
@@ -1253,9 +1415,10 @@ class BatchRotatingKVCache(_BaseCache):
self._offset = max(self._offset, other._offset)
def extract(self, idx):
mx.eval(self.left_padding, self.offset)
cache = RotatingKVCache(self.max_size)
padding = self.left_padding[idx].item()
offset = self.offset[idx].item()
padding = max(0, self.left_padding.tolist()[idx])
offset = self.offset.tolist()[idx]
cache.keys = self.keys[idx : idx + 1]
cache.values = self.values[idx : idx + 1]
cache._idx = self._idx
@@ -1279,6 +1442,11 @@ class BatchRotatingKVCache(_BaseCache):
offsets = [c.offset for c in caches]
lengths = [c.size() for c in caches]
max_length = max(lengths)
# No cache has content so make an empty one
if max_length == 0:
return cls(caches[0].max_size, [0] * len(caches))
padding = [max_length - l for l in lengths]
B = len(caches)
H = max(c.keys.shape[1] for c in caches if c.keys is not None)
@@ -1288,11 +1456,11 @@ class BatchRotatingKVCache(_BaseCache):
keys = mx.zeros((B, H, max_length, Dk), dtype=dt)
values = mx.zeros((B, H, max_length, Dv), dtype=dt)
for i, (p, c) in enumerate(zip(padding, caches)):
for i, (p, l, c) in enumerate(zip(padding, lengths, caches)):
if c.keys is None:
continue
keys[i : i + 1, :, p : p + c._idx] = c._temporal_order(c.keys)
values[i : i + 1, :, p : p + c._idx] = c._temporal_order(c.values)
keys[i : i + 1, :, p : p + l] = c._temporal_order(c.keys)[..., -l:, :]
values[i : i + 1, :, p : p + l] = c._temporal_order(c.values)[..., -l:, :]
cache = cls(caches[0].max_size, padding)
cache.keys = keys
@@ -1303,5 +1471,293 @@ class BatchRotatingKVCache(_BaseCache):
return cache
def size(self):
return min(self._offset, self.max_size)
def empty(self):
return self.keys is None
@property
def nbytes(self):
if self.keys is None:
return 0
return self.keys.nbytes + self.values.nbytes
class TokenBuffer:
"""A simple token buffer that can be efficiently appended to in a similar
fashion to the KVCache.
Perhaps these could share some logic in the future.
"""
step = 256
def __init__(self, tokens=[]):
self._buffer = mx.array(tokens, dtype=mx.int32)
self._size = len(tokens)
def update_and_fetch(self, tokens):
start = self._size
end = start + len(tokens)
new_size = ((end + self.step - 1) // self.step) * self.step
if new_size > self._buffer.size:
self._buffer = mx.concatenate(
[self._buffer, mx.zeros(new_size - self._buffer.size, dtype=mx.int32)]
)
self._buffer[start:end] = tokens
self._size = end
return self._buffer[:end]
@property
def state(self):
return self._buffer
@property
def tokens(self):
return self._buffer[: self._size]
@dataclass
class PromptTrieResult:
model: Any
exact: Optional[List[int]] # Exact match found
shorter: Optional[List[int]] # Longest prefix with a value
longer: Optional[List[int]] # Shortest value that extends beyond tokens
common_prefix: int # Length of common prefix with any path
class PromptTrie:
def __init__(self):
self._trie = {}
def add(self, model: Any, tokens: List[int], value: Any):
if model not in self._trie:
self._trie[model] = {}
current = self._trie[model]
for tok in tokens:
if tok not in current:
current[tok] = {}
current = current[tok]
prev = current.get("__value__", None)
current["__value__"] = value
return prev
def get(self, model: Any, tokens: List[int]):
current = self._trie[model]
for tok in tokens:
current = current[tok]
return current["__value__"]
def pop(self, model: Any, tokens: List[int]):
path = [self._trie[model]]
for tok in tokens:
path.append(path[-1][tok])
value = path[-1].pop("__value__")
for i in range(len(tokens), 0, -1):
node = path[i]
parent = path[i - 1]
tok = tokens[i - 1]
if len(node) > 0:
break
del parent[tok]
return value
def pop_prefixes(self, model: Any, tokens: List[int]):
values = []
current = self._trie[model]
for i, tok in enumerate(tokens):
if "__value__" in current:
values.append((i, current.pop("__value__")))
current = current[tok]
return values
def search(self, model: Any, tokens: List[int]) -> PromptTrieResult:
if model not in self._trie:
return PromptTrieResult(model, None, None, None, 0)
current = self._trie[model]
if not tokens and "__value__" in current:
return PromptTrieResult(model, [], None, None, 0)
# Walk the tokens as far as we can
last_index = -1
index = 0
while index < len(tokens) and tokens[index] in current:
current = current[tokens[index]]
if "__value__" in current:
last_index = index
index += 1
# Got an exact match
if last_index == len(tokens) - 1 >= 0:
return PromptTrieResult(model, tokens, None, None, 0)
# Check if we found a prefix at any point
shorter = None
if last_index > 0:
shorter = tokens[: last_index + 1]
# Check for sequences that are longer
longer = None
common_prefix = index
if index > 0:
best = None
stack = [(current, [])]
while stack:
current, extra = stack.pop()
if "__value__" in current:
if best is None or len(extra) < len(best):
best = extra
elif best is None or len(extra) < len(best):
for tok in current:
stack.append((current[tok], extra + [tok]))
longer = tokens[:index] + best
return PromptTrieResult(model, None, shorter, longer, common_prefix)
class LRUPromptCache:
@dataclass
class CacheEntry:
prompt_cache: List[Any]
nbytes: int
cache_type: str
class CacheOrder:
def __init__(self, ordering: List[str] = ["assistant", "user", "system"]):
self._ordering = ordering
self._lrus = {k: deque() for k in ordering}
def __len__(self):
return sum(len(lru) for lru in self._lrus.values())
def push(self, model: Any, tokens: List[Any], cache_type: str = "assistant"):
self._lrus[cache_type].append((model, tokens))
def remove(self, model: Any, tokens: List[Any]):
for cache_type in self._ordering:
try:
self._lrus[cache_type].remove((model, tokens))
break
except ValueError:
pass
def pop(self):
i = 0
while i + 1 < len(self._ordering):
lru_a = self._lrus[self._ordering[i]]
lru_b = self._lrus[self._ordering[i + 1]]
if lru_a and len(lru_a) >= len(lru_b):
return lru_a.popleft()
i += 1
return lru_b.popleft()
def __init__(self, max_size: int = 10, max_bytes: int = 1 << 63):
self.max_size = max_size
self.max_bytes = max_bytes
self._trie = PromptTrie()
self._lru = LRUPromptCache.CacheOrder()
self._n_bytes = 0
self._n_bytes_by_type = {k: 0 for k in self._lru._ordering}
def __len__(self):
return len(self._lru)
@property
def nbytes(self):
return self._n_bytes
def fetch_nearest_cache(self, model: Any, tokens: List[int]):
result = self._trie.search(model, tokens)
if result.exact is not None:
cache_entry = self._trie.get(result.model, result.exact)
return copy.deepcopy(cache_entry.prompt_cache), []
short_length = len(result.shorter) if result.shorter is not None else 0
if result.longer is not None and result.common_prefix > short_length:
cache_entry = self._trie.get(result.model, result.longer)
if can_trim_prompt_cache(cache_entry.prompt_cache):
cache = copy.deepcopy(cache_entry.prompt_cache)
prefix = min(len(tokens) - 1, result.common_prefix)
num_to_trim = len(result.longer) - prefix
trim_prompt_cache(cache, num_to_trim)
return cache, tokens[prefix:]
if short_length > 0:
cache_entry = self._trie.get(result.model, result.shorter)
return copy.deepcopy(cache_entry.prompt_cache), tokens[short_length:]
return None, tokens
def insert_cache(
self,
model: Any,
tokens: List[int],
prompt_cache: List[Any],
*,
cache_type: str = "assistant",
):
# Make the cache entry
entry = LRUPromptCache.CacheEntry(
prompt_cache, sum(c.nbytes for c in prompt_cache), cache_type
)
# Insert into the trie and update the byte counter and lru position
self._n_bytes += entry.nbytes
self._n_bytes_by_type[cache_type] += entry.nbytes
prev = self._trie.add(model, tokens, entry)
if prev is not None:
self._n_bytes -= prev.nbytes
self._n_bytes_by_type[prev.cache_type] -= prev.nbytes
self._lru.remove(model, tokens)
self._lru.push(model, tokens, cache_type)
# If it is a trimmable cache remove all prefixes cause they just take
# space
if can_trim_prompt_cache(prompt_cache):
for prefix_len, entry in self._trie.pop_prefixes(model, tokens):
self._n_bytes -= entry.nbytes
self._n_bytes_by_type[entry.cache_type] -= entry.nbytes
self._lru.remove(model, tokens[:prefix_len])
# Ensure we match the constraints
if len(self._lru) > self.max_size:
model, tokens = self._lru.pop()
entry = self._trie.pop(model, tokens)
self._n_bytes -= entry.nbytes
self._n_bytes_by_type[entry.cache_type] -= entry.nbytes
while self._n_bytes > self.max_bytes:
model, tokens = self._lru.pop()
entry = self._trie.pop(model, tokens)
self._n_bytes -= entry.nbytes
self._n_bytes_by_type[entry.cache_type] -= entry.nbytes
def trim_to(
self, *, n_sequences: Optional[int] = None, n_bytes: Optional[int] = None
):
n_sequences = max(0, n_sequences) if n_sequences is not None else 1 << 63
n_bytes = max(0, n_bytes) if n_bytes is not None else 1 << 63
while len(self._lru) > n_sequences:
model, tokens = self._lru.pop()
entry = self._trie.pop(model, tokens)
self._n_bytes -= entry.nbytes
self._n_bytes_by_type[entry.cache_type] -= entry.nbytes
while self._n_bytes > n_bytes:
model, tokens = self._lru.pop()
entry = self._trie.pop(model, tokens)
self._n_bytes -= entry.nbytes
self._n_bytes_by_type[entry.cache_type] -= entry.nbytes
def stats_by_type(self):
result = {}
for cache_type in self._lru._ordering:
result[cache_type] = {
"n_sequences": len(self._lru._lrus[cache_type]),
"n_bytes": self._n_bytes_by_type[cache_type],
}
return result
+43 -26
View File
@@ -71,7 +71,7 @@ class Indexer(nn.Module):
self.rope = initialize_rope(
dims=args.qk_rope_head_dim,
base=args.rope_theta,
traditional=False,
traditional=True,
max_position_embeddings=args.max_position_embeddings,
scaling_config=args.rope_scaling,
)
@@ -87,19 +87,15 @@ class Indexer(nn.Module):
b, s, _ = x.shape
q = self.wq_b(qr)
q = q.reshape(b, s, self.n_heads, self.head_dim).swapaxes(1, 2)
q_pe, q_nope = mx.split(q, [self.rope_head_dim], axis=-1)
offset = cache.offset if cache is not None else 0
q_pe = self.rope(q_pe, offset=offset)
q = mx.concatenate([q_pe, q_nope], axis=-1)
k = self.wk(x)
k = self.k_norm(k)
k = mx.reshape(k, (b, 1, s, self.head_dim))
k_pe, k_nope = mx.split(k, [self.rope_head_dim], axis=-1)
k_pe = self.rope(k_pe, offset=offset)
k = mx.concatenate([k_pe, k_nope], axis=-1)
offset = cache.offset if cache is not None else 0
q = self.rope(q, offset=offset)
k = self.rope(k, offset=offset)
if cache is not None:
k, _ = cache.update_and_fetch(k, mx.zeros([b, 1, s, 0]))
if k.shape[2] <= self.index_topk:
@@ -209,15 +205,30 @@ class DeepseekV32Attention(nn.Module):
topk_indices = self.indexer(x, qr, mask, cache=cache[1])
if topk_indices is not None:
shape = list(topk_indices.shape)
shape[-1] = keys.shape[2]
sparse_mask = mx.zeros(shape, dtype=mx.bool_)
sparse_mask = mx.put_along_axis(
sparse_mask, topk_indices, mx.array(True), axis=-1
)
if mask is not None:
sparse_mask = sparse_mask & mask
mask = sparse_mask
if L == 1:
idx = topk_indices[:, :, 0, :, None]
kv_latent = mx.take_along_axis(
kv_latent,
mx.broadcast_to(idx, idx.shape[:-1] + (kv_latent.shape[-1],)),
axis=2,
)
k_pe = mx.take_along_axis(
k_pe,
mx.broadcast_to(idx, idx.shape[:-1] + (k_pe.shape[-1],)),
axis=2,
)
if mask is not None:
mask = mx.take_along_axis(mask, topk_indices, axis=-1)
else:
shape = list(topk_indices.shape)
shape[-1] = kv_latent.shape[2]
sparse_mask = mx.zeros(shape, dtype=mx.bool_)
sparse_mask = mx.put_along_axis(
sparse_mask, topk_indices, mx.array(True), axis=-1
)
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:
@@ -481,6 +492,16 @@ class Model(nn.Module):
return self.lm_head(out)
def sanitize(self, weights):
# Remove multi-token prediction layers
mpt_layer = self.args.num_hidden_layers
new_weights = {}
for k, v in weights.items():
parts = k.split(".")
if len(parts) >= 3 and parts[1] == "layers" and int(parts[2]) >= mpt_layer:
continue
new_weights[k] = v
weights = new_weights
def dequant(weight, scale_inv):
dtype = mx.bfloat16
weight = mx.from_fp8(weight, dtype=mx.bfloat16)
@@ -521,6 +542,7 @@ class Model(nn.Module):
for e in range(self.args.n_routed_experts)
]
weights[f"{prefix}.mlp.switch_mlp.{m}.{k}"] = mx.stack(to_join)
prefix = f"model.layers.{l}.self_attn"
if f"{prefix}.kv_b_proj.weight" in weights:
layer = self.model.layers[l].self_attn.embed_q
quantized = f"{prefix}.kv_b_proj.scales" in weights
@@ -557,12 +579,7 @@ class Model(nn.Module):
weights[f"{prefix}.embed_q.weight"] = wk
weights[f"{prefix}.unembed_out.weight"] = wv
# Remove multi-token prediction layer and any unused precomputed rotary freqs
return {
k: v
for k, v in weights.items()
if not k.startswith("model.layers.61") and "rotary_emb.inv_freq" not in k
}
return weights
def shard(self, group: Optional[mx.distributed.Group] = None):
group = group or mx.distributed.init()
+10 -9
View File
@@ -7,9 +7,7 @@ import mlx.nn as nn
@partial(mx.compile, shapeless=True)
def compute_g(A_log, a, dt_bias):
return mx.exp(-mx.exp(A_log.astype(mx.float32)) * nn.softplus(a + dt_bias)).astype(
A_log.dtype
)
return mx.exp(-mx.exp(A_log.astype(mx.float32)) * nn.softplus(a + dt_bias))
def _make_gated_delta_kernel(has_mask=False, vectorized=False):
@@ -83,6 +81,8 @@ def _make_gated_delta_kernel(has_mask=False, vectorized=False):
if (thread_index_in_simdgroup == 0) {{
y[dv_idx] = static_cast<InT>(out);
}}
}} else {{
y[dv_idx] = static_cast<InT>(0);
}}
// Increment data pointers to next time step
q_ += Hk * Dk;
@@ -94,7 +94,7 @@ def _make_gated_delta_kernel(has_mask=False, vectorized=False):
}}
for (int i = 0; i < n_per_t; ++i) {{
auto s_idx = n_per_t * dk_idx + i;
o_state[s_idx] = static_cast<InT>(state[i]);
o_state[s_idx] = static_cast<StT>(state[i]);
}}
"""
inputs = ["q", "k", "v", "g", "beta", "state_in", "T"]
@@ -165,7 +165,7 @@ def _gated_delta_step_ops(
if mask is not None:
mask = mx.expand_dims(mask, axis=(1, 2, 3))
state = mx.where(mask, state, old_state)
return y, state
return y.astype(q.dtype), state
def gated_delta_kernel(
@@ -180,6 +180,7 @@ def gated_delta_kernel(
B, T, Hk, Dk = k.shape
Hv, Dv = v.shape[2:]
input_type = q.dtype
state_type = state.dtype
if g.ndim == 4:
kernel = _gated_delta_kernel_vec
inputs = [q, k, v, g, beta, state, T]
@@ -197,6 +198,7 @@ def gated_delta_kernel(
inputs=inputs,
template=[
("InT", input_type),
("StT", state_type),
("Dk", Dk),
("Dv", Dv),
("Hk", Hk),
@@ -205,7 +207,7 @@ def gated_delta_kernel(
grid=(32, Dv, B * Hv),
threadgroup=(32, 4, 1),
output_shapes=[(B, T, Hv, Dv), state.shape],
output_dtypes=[input_type, input_type],
output_dtypes=[input_type, state_type],
)
@@ -235,7 +237,7 @@ def gated_delta_ops(
B, T, Hk, Dk = q.shape
Hv, Dv = v.shape[-2:]
if state is None:
state = mx.zeros((B, Hv, Dv, Dk), dtype=q.dtype)
state = mx.zeros((B, Hv, Dv, Dk), dtype=mx.float32)
if (repeat_factor := Hv // Hk) > 1:
q = mx.repeat(q, repeat_factor, -2)
@@ -269,13 +271,12 @@ def gated_delta_update(
mask: Optional[mx.array] = None,
use_kernel: bool = True,
) -> Tuple[mx.array, mx.array]:
beta = mx.sigmoid(b)
g = compute_g(A_log, a, dt_bias)
if state is None:
B, _, Hk, Dk = q.shape
Hv, Dv = v.shape[-2:]
state = mx.zeros((B, Hv, Dv, Dk), dtype=q.dtype)
state = mx.zeros((B, Hv, Dv, Dk), dtype=mx.float32)
if not use_kernel or mx.default_device() != mx.gpu or not mx.metal.is_available():
return gated_delta_ops(q, k, v, g, beta, state, mask)
+94
View File
@@ -0,0 +1,94 @@
# Copyright © 2025 Apple Inc.
from dataclasses import dataclass
from typing import Optional
import mlx.core as mx
import mlx.nn as nn
from . import gemma4_text
from .base import BaseModelArgs
@dataclass
class ModelArgs(BaseModelArgs):
model_type: str = "gemma4"
text_config: dict = None
vocab_size: int = 262144
def __post_init__(self):
if self.text_config is None:
self.text_config = {}
self.text_config["vocab_size"] = self.vocab_size
self.text_config["num_attention_heads"] = self.text_config.get(
"num_attention_heads", 8
)
self.text_config["num_key_value_heads"] = self.text_config.get(
"num_key_value_heads", 1
)
class Model(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
self.args = args
self.model_type = args.model_type
self.language_model = gemma4_text.Model(
gemma4_text.ModelArgs.from_dict(args.text_config)
)
def __call__(
self,
inputs: mx.array,
cache=None,
input_embeddings: Optional[mx.array] = None,
per_layer_inputs: Optional[mx.array] = None,
):
return self.language_model(
inputs,
cache=cache,
input_embeddings=input_embeddings,
per_layer_inputs=per_layer_inputs,
)
def sanitize(self, weights):
new_weights = {}
for k, v in weights.items():
starts_w_model = k.startswith("model.")
k = k.removeprefix("model.")
if k.startswith(
(
"vision_tower",
"multi_modal_projector",
"audio_tower",
"embed_audio",
"embed_vision",
)
):
continue
if not starts_w_model:
new_weights[k] = v
continue
if k.startswith("language_model"):
k = k.replace("language_model.", "language_model.model.")
new_weights[k] = v
return self.language_model.sanitize(new_weights)
@property
def layers(self):
return self.language_model.layers
@property
def quant_predicate(self):
return self.language_model.quant_predicate
def make_cache(self):
return self.language_model.make_cache()
def shard(self, group: Optional[mx.distributed.Group] = None):
self.language_model.shard(group)
+728
View File
@@ -0,0 +1,728 @@
# Copyright © 2025 Apple Inc.
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 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 KVCache, RotatingKVCache, _BaseCache
from .rope_utils import initialize_rope
from .switch_layers import SwitchGLU
@dataclass
class ModelArgs(BaseModelArgs):
model_type: str = "gemma4_text"
hidden_size: int = 1536
num_hidden_layers: int = 35
intermediate_size: int = 6144
num_attention_heads: int = 8
head_dim: int = 256
global_head_dim: int = 512
global_partial_rotary_factor: float = 0.25
rms_norm_eps: float = 1e-6
vocab_size: int = 262144
vocab_size_per_layer_input: int = 262144
num_key_value_heads: int = 1
num_global_key_value_heads: Optional[int] = None
num_kv_shared_layers: int = 20
pad_token_id: int = 0
hidden_size_per_layer_input: int = 256
rope_traditional: bool = False
partial_rotary_factor: float = 1.0
rope_parameters: Optional[Dict] = None
sliding_window: int = 512
sliding_window_pattern: int = 5
max_position_embeddings: int = 131072
attention_k_eq_v: bool = False
final_logit_softcapping: float = 30.0
use_double_wide_mlp: bool = True
enable_moe_block: bool = False
num_experts: Optional[int] = None
top_k_experts: Optional[int] = None
moe_intermediate_size: Optional[int] = None
layer_types: Optional[List[str]] = None
tie_word_embeddings: bool = True
def __post_init__(self):
if self.rope_parameters is None:
self.rope_parameters = {
"full_attention": {
"partial_rotary_factor": 0.25,
"rope_theta": 1000000.0,
"rope_type": "proportional",
},
"sliding_attention": {
"partial_rotary_factor": 1.0,
"rope_theta": 10000.0,
"rope_type": "default",
},
}
if self.layer_types is None:
pattern = ["sliding_attention"] * (self.sliding_window_pattern - 1) + [
"full_attention"
]
self.layer_types = (pattern * (self.num_hidden_layers // len(pattern) + 1))[
: self.num_hidden_layers
]
class RMSNormNoScale(nn.Module):
"""RMSNorm without learnable scale."""
def __init__(self, dim: int, eps: float = 1e-6):
super().__init__()
self.eps = eps
def __call__(self, x: mx.array) -> mx.array:
return mx.fast.rms_norm(x, None, self.eps)
@partial(mx.compile, shapeless=True)
def logit_softcap(softcap, x):
return mx.tanh(x / softcap) * softcap
@partial(mx.compile, shapeless=True)
def _complete_square(x2, y2, xy):
return x2 + mx.expand_dims(y2, -1) - 2 * xy
@partial(mx.compile, shapeless=True)
def geglu(gate, x):
return nn.gelu_approx(gate) * x
class MLP(nn.Module):
def __init__(self, config: ModelArgs, layer_idx: int = 0):
super().__init__()
first_kv_shared_layer_idx = (
config.num_hidden_layers - config.num_kv_shared_layers
)
is_kv_shared_layer = layer_idx >= first_kv_shared_layer_idx > 0
use_double_wide = config.use_double_wide_mlp and is_kv_shared_layer
intermediate_size = config.intermediate_size * (2 if use_double_wide else 1)
self.gate_proj = nn.Linear(config.hidden_size, intermediate_size, bias=False)
self.down_proj = nn.Linear(intermediate_size, config.hidden_size, bias=False)
self.up_proj = nn.Linear(config.hidden_size, intermediate_size, bias=False)
def __call__(self, x: mx.array) -> mx.array:
return self.down_proj(geglu(self.gate_proj(x), self.up_proj(x)))
class Router(nn.Module):
"""Expert router: norm -> scale -> project -> top-k -> renormalize."""
def __init__(self, config: ModelArgs):
super().__init__()
self.config = config
self.eps = config.rms_norm_eps
self.proj = nn.Linear(config.hidden_size, config.num_experts, bias=False)
self.scale = mx.ones((config.hidden_size,))
self.per_expert_scale = mx.ones((config.num_experts,))
self._root_size = config.hidden_size**-0.5
def __call__(self, x: mx.array):
x = mx.fast.rms_norm(x, self.scale * self._root_size, self.eps)
expert_scores = self.proj(x)
top_k_indices = mx.argpartition(
expert_scores, kth=-self.config.top_k_experts, axis=-1
)
top_k_indices = top_k_indices[..., -self.config.top_k_experts :]
top_k_weights = mx.take_along_axis(expert_scores, top_k_indices, axis=-1)
top_k_weights = mx.softmax(top_k_weights, axis=-1)
top_k_weights = top_k_weights * self.per_expert_scale[top_k_indices]
return top_k_indices, top_k_weights
class GeGLU(nn.Module):
"""GELU-gated linear unit activation for SwitchGLU."""
def __call__(self, x, gate):
return geglu(gate, x)
class Experts(nn.Module):
"""Sparse MoE using SwitchGLU with gather_mm."""
def __init__(self, config: ModelArgs):
super().__init__()
self.switch_glu = SwitchGLU(
input_dims=config.hidden_size,
hidden_dims=config.moe_intermediate_size,
num_experts=config.num_experts,
activation=GeGLU(),
bias=False,
)
self.sharding_group = None
def __call__(
self, x: mx.array, top_k_indices: mx.array, top_k_weights: mx.array
) -> mx.array:
if self.sharding_group is not None:
x = sum_gradients(self.sharding_group)(x)
w = mx.expand_dims(top_k_weights, -1)
y = self.switch_glu(x, top_k_indices)
y = (w * y).sum(-2)
if self.sharding_group is not None:
y = mx.distributed.all_sum(y, group=self.sharding_group)
return y
class Attention(nn.Module):
def __init__(self, config: ModelArgs, layer_idx: int):
super().__init__()
self.config = config
self.layer_idx = layer_idx
self.layer_type = config.layer_types[layer_idx]
self.is_sliding = self.layer_type == "sliding_attention"
self.has_kv = layer_idx < config.num_hidden_layers - config.num_kv_shared_layers
self.head_dim = (
config.global_head_dim
if self.layer_type == "full_attention"
and hasattr(config, "global_head_dim")
and config.global_head_dim
else config.head_dim
)
dim = config.hidden_size
self.n_heads = config.num_attention_heads
# K-eq-V for full attention layers (26B/31B models)
self.use_k_eq_v = config.attention_k_eq_v and not self.is_sliding
if self.use_k_eq_v and config.num_global_key_value_heads is not None:
self.n_kv_heads = config.num_global_key_value_heads
else:
self.n_kv_heads = config.num_key_value_heads
self.scale = 1.0
self.q_proj = nn.Linear(dim, self.n_heads * self.head_dim, bias=False)
if self.has_kv:
self.k_proj = nn.Linear(dim, self.n_kv_heads * self.head_dim, bias=False)
if not self.use_k_eq_v:
self.v_proj = nn.Linear(
dim, self.n_kv_heads * self.head_dim, bias=False
)
self.o_proj = nn.Linear(self.n_heads * self.head_dim, dim, bias=False)
self.q_norm = nn.RMSNorm(self.head_dim, eps=config.rms_norm_eps)
if self.has_kv:
self.k_norm = nn.RMSNorm(self.head_dim, eps=config.rms_norm_eps)
self.v_norm = RMSNormNoScale(self.head_dim, eps=config.rms_norm_eps)
# RoPE (with partial rotation support)
layer_key = "sliding_attention" if self.is_sliding else "full_attention"
rope_params = config.rope_parameters.get(layer_key, {})
rope_theta = rope_params.get("rope_theta", 10000.0)
self.rope = initialize_rope(
dims=self.head_dim,
traditional=config.rope_traditional,
base=rope_theta,
scaling_config=rope_params,
max_position_embeddings=config.max_position_embeddings,
)
def __call__(
self,
x: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[Any] = None,
shared_kv: Optional[tuple] = None,
offset: Optional[Any] = None,
) -> mx.array:
B, L, _ = x.shape
queries = self.q_proj(x).reshape(B, L, self.n_heads, self.head_dim)
queries = self.q_norm(queries)
if shared_kv is not None:
keys, values = shared_kv
elif not self.has_kv:
raise ValueError(
f"Layer {self.layer_idx} is a KV-shared layer but received no shared_kv"
)
else:
keys = self.k_proj(x).reshape(B, L, self.n_kv_heads, self.head_dim)
values = keys
if not self.use_k_eq_v:
values = self.v_proj(x).reshape(B, L, self.n_kv_heads, self.head_dim)
offset = mx.array(cache.offset) if cache is not None else 0
keys = self.k_norm(keys)
keys = keys.transpose(0, 2, 1, 3)
keys = self.rope(keys, offset=offset)
values = self.v_norm(values)
values = values.transpose(0, 2, 1, 3)
queries = queries.transpose(0, 2, 1, 3)
queries = self.rope(queries, offset=offset)
if cache is not None:
keys, values = cache.update_and_fetch(keys, values)
output = scaled_dot_product_attention(
queries, keys, values, cache=cache, scale=self.scale, mask=mask
)
output = output.transpose(0, 2, 1, 3).reshape(B, L, -1)
return self.o_proj(output), (keys, values), offset
class DecoderLayer(nn.Module):
def __init__(self, config: ModelArgs, layer_idx: int):
super().__init__()
self.config = config
self.layer_idx = layer_idx
self.layer_type = config.layer_types[layer_idx]
self.self_attn = Attention(config, layer_idx)
self.mlp = MLP(config, layer_idx)
self.input_layernorm = nn.RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
self.post_attention_layernorm = nn.RMSNorm(
config.hidden_size, eps=config.rms_norm_eps
)
self.pre_feedforward_layernorm = nn.RMSNorm(
config.hidden_size, eps=config.rms_norm_eps
)
self.post_feedforward_layernorm = nn.RMSNorm(
config.hidden_size, eps=config.rms_norm_eps
)
# MoE (26B model)
self.enable_moe = config.enable_moe_block
if self.enable_moe:
self.router = Router(config)
self.experts = Experts(config)
self.post_feedforward_layernorm_1 = nn.RMSNorm(
config.hidden_size, eps=config.rms_norm_eps
)
self.post_feedforward_layernorm_2 = nn.RMSNorm(
config.hidden_size, eps=config.rms_norm_eps
)
self.pre_feedforward_layernorm_2 = nn.RMSNorm(
config.hidden_size, eps=config.rms_norm_eps
)
# Per-layer input gating (2B/4B models)
self.hidden_size_per_layer_input = config.hidden_size_per_layer_input
if self.hidden_size_per_layer_input:
self.per_layer_input_gate = nn.Linear(
config.hidden_size, self.hidden_size_per_layer_input, bias=False
)
self.per_layer_projection = nn.Linear(
self.hidden_size_per_layer_input, config.hidden_size, bias=False
)
self.post_per_layer_input_norm = nn.RMSNorm(
config.hidden_size, eps=config.rms_norm_eps
)
else:
self.per_layer_input_gate = None
self.per_layer_projection = None
self.post_per_layer_input_norm = None
# Layer scalar
self.layer_scalar = mx.ones((1,))
def __call__(
self,
x: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[Any] = None,
per_layer_input: Optional[mx.array] = None,
shared_kv: Optional[tuple] = None,
offset: Optional[Any] = None,
) -> mx.array:
residual = x
h = self.input_layernorm(x)
h, shared_kv, offset = self.self_attn(
h, mask, cache, shared_kv=shared_kv, offset=offset
)
h = self.post_attention_layernorm(h)
h = residual + h
residual = h
if self.enable_moe:
h1 = self.pre_feedforward_layernorm(h)
h1 = self.mlp(h1)
h1 = self.post_feedforward_layernorm_1(h1)
top_k_indices, top_k_weights = self.router(h)
h2 = self.pre_feedforward_layernorm_2(h)
h2 = self.experts(h2, top_k_indices, top_k_weights)
h2 = self.post_feedforward_layernorm_2(h2)
h = h1 + h2
else:
h = self.pre_feedforward_layernorm(h)
h = self.mlp(h)
h = self.post_feedforward_layernorm(h)
h = residual + h
# Per-layer input gating
if (
self.per_layer_input_gate is not None
and self.per_layer_projection is not None
and self.post_per_layer_input_norm is not None
and per_layer_input is not None
):
residual = h
gate = self.per_layer_input_gate(h)
gate = nn.gelu_approx(gate)
gate = mx.multiply(gate, per_layer_input)
gate = self.per_layer_projection(gate)
gate = self.post_per_layer_input_norm(gate)
h = residual + gate
if self.layer_scalar is not None:
h = h * self.layer_scalar
return h, shared_kv, offset
class Gemma4TextModel(nn.Module):
def __init__(self, config: ModelArgs):
super().__init__()
self.config = config
self.vocab_size = config.vocab_size
self.window_size = config.sliding_window
self.sliding_window_pattern = config.sliding_window_pattern
self.num_hidden_layers = config.num_hidden_layers
self.embed_tokens = nn.Embedding(config.vocab_size, config.hidden_size)
self.embed_scale = config.hidden_size**0.5
self.layers = [
DecoderLayer(config, layer_idx=i) for i in range(config.num_hidden_layers)
]
self.norm = nn.RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
# Per-layer input embeddings (2B/4B models)
self.hidden_size_per_layer_input = config.hidden_size_per_layer_input
if self.hidden_size_per_layer_input:
self.embed_tokens_per_layer = nn.Embedding(
config.vocab_size_per_layer_input,
config.num_hidden_layers * config.hidden_size_per_layer_input,
)
self.embed_tokens_per_layer_scale = config.hidden_size_per_layer_input**0.5
self.per_layer_input_scale = 2.0**-0.5
self.per_layer_projection_scale = config.hidden_size**-0.5
self.per_layer_model_projection = nn.Linear(
config.hidden_size,
config.num_hidden_layers * config.hidden_size_per_layer_input,
bias=False,
)
self.per_layer_projection_norm = nn.RMSNorm(
config.hidden_size_per_layer_input, eps=config.rms_norm_eps
)
else:
self.embed_tokens_per_layer = None
self.per_layer_input_scale = None
self.per_layer_projection_scale = None
self.per_layer_model_projection = None
self.per_layer_projection_norm = None
# Arrange for shared KVs
self.previous_kvs = list(range(len(self.layers)))
if config.num_kv_shared_layers > 0:
N = len(self.layers)
M = N - config.num_kv_shared_layers
kvs_by_type = {}
for i in range(M):
kvs_by_type[self.layers[i].layer_type] = i
for j in range(M, N):
self.previous_kvs[j] = kvs_by_type[self.layers[j].layer_type]
def _get_per_layer_inputs(
self,
input_ids: Optional[mx.array],
input_embeddings: Optional[mx.array] = None,
) -> mx.array:
if input_ids is None:
if input_embeddings is None:
raise RuntimeError(
"input_embeddings must be provided when input_ids are omitted."
)
# Split the sequence dimension if this still holds too much
# memory. 260k vocab means the distance tensor would be ~1GB
# per 2k tokens in bf16.
#
# If the embedding is quantized we have to dequantize it anyway to
# perform the match test.
norms_embedding = self.embed_tokens.weight.square().sum(-1)
norms_input = input_embeddings.square().sum(-1)
distance = _complete_square(
norms_embedding,
norms_input,
self.embed_tokens.as_linear(input_embeddings),
)
# Checks can be added if needed but they necessarily break the GPU
# pipelining and force an eval.
#
# match_counts = (distance < eps).sum(-1)
#
input_ids = mx.argmin(distance, -1)
result = self.embed_tokens_per_layer(input_ids)
result = result * self.embed_tokens_per_layer_scale
return mx.unflatten(
result,
-1,
(self.config.num_hidden_layers, self.hidden_size_per_layer_input),
)
def _project_per_layer_inputs(
self,
input_embeddings: mx.array,
per_layer_inputs: Optional[mx.array] = None,
) -> mx.array:
per_layer_projection = self.per_layer_model_projection(input_embeddings)
per_layer_projection = per_layer_projection * self.per_layer_projection_scale
per_layer_projection = mx.unflatten(
per_layer_projection,
-1,
(self.config.num_hidden_layers, self.hidden_size_per_layer_input),
)
per_layer_projection = self.per_layer_projection_norm(per_layer_projection)
if per_layer_inputs is None:
return per_layer_projection
return (per_layer_projection + per_layer_inputs) * self.per_layer_input_scale
def _make_masks(self, h, cache):
mask = {}
masks = []
for l, c in zip(self.layers, cache):
if l.layer_type not in mask:
if l.layer_type == "full_attention":
mask["full_attention"] = create_attention_mask(h, c)
elif l.layer_type == "sliding_attention":
mask["sliding_attention"] = create_attention_mask(
h, c, window_size=self.window_size
)
masks.append(mask[l.layer_type])
return masks
def __call__(
self,
inputs: mx.array = None,
cache=None,
input_embeddings: Optional[mx.array] = None,
per_layer_inputs: Optional[mx.array] = None,
):
# Make the initial hidden state
if input_embeddings is None:
input_embeddings = self.embed_tokens(inputs)
h = input_embeddings
h = h * self.embed_scale
# Get the extra inputs per layer if we have per layer embeddings
if self.hidden_size_per_layer_input:
if per_layer_inputs is None:
per_layer_inputs = self._get_per_layer_inputs(inputs, input_embeddings)
per_layer_inputs = self._project_per_layer_inputs(h, per_layer_inputs)
if per_layer_inputs is not None:
per_layer_inputs = [
per_layer_inputs[:, :, i, :] for i, _ in enumerate(self.layers)
]
else:
per_layer_inputs = [None] * len(self.layers)
# Make the kv cache list, be sure to append None for all the shared kv
# layers
if cache is None:
cache = [None] * len(self.layers)
else:
cache = cache + [None] * (len(self.layers) - len(cache))
# Apply each layer. We save all intermediate kvs and offset and grab
# the previous one for the shared kv layers.
masks = self._make_masks(h, cache)
intermediates = [(None, None)] * len(self.layers)
for idx, (layer, c, mask, prev_idx, per_layer_input) in enumerate(
zip(
self.layers,
cache,
masks,
self.previous_kvs,
per_layer_inputs,
)
):
kvs, offset = intermediates[prev_idx]
h, kvs, offset = layer(
h,
mask,
c,
per_layer_input=per_layer_input,
shared_kv=kvs,
offset=offset,
)
intermediates[idx] = (kvs, offset)
return self.norm(h)
class Model(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
self.args = args
self.model_type = args.model_type
self.model = Gemma4TextModel(args)
self.final_logit_softcapping = args.final_logit_softcapping
self.tie_word_embeddings = args.tie_word_embeddings
if not self.tie_word_embeddings:
self.lm_head = nn.Linear(args.hidden_size, args.vocab_size, bias=False)
def __call__(
self,
inputs: mx.array,
cache=None,
input_embeddings: Optional[mx.array] = None,
per_layer_inputs: Optional[mx.array] = None,
):
out = self.model(
inputs,
cache=cache,
input_embeddings=input_embeddings,
per_layer_inputs=per_layer_inputs,
)
if self.tie_word_embeddings:
out = self.model.embed_tokens.as_linear(out)
else:
out = self.lm_head(out)
if self.final_logit_softcapping is not None:
out = logit_softcap(self.final_logit_softcapping, out)
return out
def sanitize(self, weights):
sanitized = {}
for k, v in weights.items():
if any(
s in k
for s in (
"self_attn.rotary_emb",
"input_max",
"input_min",
"output_max",
"output_min",
)
):
continue
if k.endswith(".experts.gate_up_proj"):
base = k.removesuffix(".gate_up_proj")
gate, up = map(mx.contiguous, mx.split(v, 2, axis=-2))
sanitized[f"{base}.switch_glu.gate_proj.weight"] = gate
sanitized[f"{base}.switch_glu.up_proj.weight"] = up
continue
if k.endswith(".experts.down_proj"):
base = k.removesuffix(".down_proj")
sanitized[f"{base}.switch_glu.down_proj.weight"] = v
continue
sanitized[k] = v
return sanitized
@property
def quant_predicate(self):
def predicate(path, _):
if path.endswith("router.proj"):
return {"group_size": 64, "bits": 8}
return True
return predicate
@property
def layers(self):
return self.model.layers
@property
def head_dim(self):
return self.args.head_dim
@property
def n_kv_heads(self):
return self.args.num_key_value_heads
def make_cache(self):
first_kv_shared = self.args.num_hidden_layers - self.args.num_kv_shared_layers
caches = []
for i in range(first_kv_shared):
if self.args.layer_types[i] == "full_attention":
caches.append(KVCache())
else:
caches.append(
RotatingKVCache(
max_size=self.args.sliding_window,
keep=0,
)
)
return caches
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_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
)
if hasattr(layer.self_attn, "v_proj"):
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
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
)
if layer.enable_moe:
layer.experts.sharding_group = group
shard_inplace(
layer.experts.switch_glu.gate_proj, "all-to-sharded", group=group
)
shard_inplace(
layer.experts.switch_glu.down_proj, "sharded-to-all", group=group
)
shard_inplace(
layer.experts.switch_glu.up_proj, "all-to-sharded", group=group
)
+53
View File
@@ -0,0 +1,53 @@
# Copyright © 2025 Apple Inc.
from dataclasses import dataclass
from typing import Any, Dict, Optional
from .base import BaseModelArgs
from .deepseek_v32 import Model as DSV32Model
@dataclass
class ModelArgs(BaseModelArgs):
model_type: str
vocab_size: int
hidden_size: int
index_head_dim: int
index_n_heads: int
index_topk: 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: float
kv_lora_rank: int
q_lora_rank: int
qk_rope_head_dim: int
v_head_dim: int
qk_nope_head_dim: int
topk_method: str
scoring_func: str
norm_topk_prob: bool
n_group: int
topk_group: int
num_experts_per_tok: int
moe_layer_freq: int
first_k_dense_replace: int
max_position_embeddings: int
rms_norm_eps: float
rope_parameters: Dict
attention_bias: bool
rope_scaling: Dict = None
rope_theta: Optional[float] = None
def __post_init__(self):
self.rope_scaling = self.rope_parameters
self.rope_theta = self.rope_parameters["rope_theta"]
class Model(DSV32Model):
def __init__(self, config: ModelArgs):
super().__init__(config)
+91 -71
View File
@@ -15,7 +15,7 @@ from .base import (
)
from .cache import ArraysCache, KVCache
from .gated_delta import gated_delta_update
from .rope_utils import initialize_rope
from .mla import MultiLinear
from .switch_layers import SwitchGLU
@@ -165,6 +165,7 @@ class KimiMLAAttention(nn.Module):
self.qk_rope_head_dim = args.qk_rope_head_dim or 0
self.q_head_dim = self.qk_nope_head_dim + self.qk_rope_head_dim
self.v_head_dim = args.v_head_dim or args.head_dim
self.kv_lora_rank = args.kv_lora_rank
self.scale = self.q_head_dim**-0.5
hidden = args.hidden_size
@@ -175,23 +176,14 @@ class KimiMLAAttention(nn.Module):
bias=False,
)
self.kv_a_layernorm = nn.RMSNorm(args.kv_lora_rank, eps=args.rms_norm_eps)
self.kv_b_proj = nn.Linear(
args.kv_lora_rank,
self.num_heads
* (self.q_head_dim - self.qk_rope_head_dim + self.v_head_dim),
bias=False,
self.embed_q = MultiLinear(
self.qk_nope_head_dim, args.kv_lora_rank, self.num_heads
)
self.unembed_out = MultiLinear(
args.kv_lora_rank, self.v_head_dim, self.num_heads
)
self.o_proj = nn.Linear(self.num_heads * self.v_head_dim, hidden, bias=False)
rope_dim = self.qk_rope_head_dim or self.q_head_dim
self.rope = initialize_rope(
rope_dim,
base=args.rope_theta,
traditional=False,
scaling_config=args.rope_scaling,
max_position_embeddings=args.model_max_length,
)
def __call__(
self,
x: mx.array,
@@ -199,51 +191,45 @@ class KimiMLAAttention(nn.Module):
cache: Optional[KVCache] = None,
) -> mx.array:
B, L, _ = x.shape
q_states = self.q_proj(x).reshape(B, L, self.num_heads, self.q_head_dim)
q_pass, q_rot = mx.split(q_states, [self.qk_nope_head_dim], axis=-1)
compressed = self.kv_a_proj_with_mqa(x)
k_pass, k_rot = mx.split(
compressed, [compressed.shape[-1] - self.qk_rope_head_dim], axis=-1
)
k_pass = self.kv_a_layernorm(k_pass)
kv = self.kv_b_proj(k_pass)
kv = kv.reshape(
B,
L,
self.num_heads,
self.q_head_dim - self.qk_rope_head_dim + self.v_head_dim,
)
k_pass, v_states = mx.split(kv, [self.qk_nope_head_dim], axis=-1)
q = self.q_proj(x).reshape(B, L, self.num_heads, self.q_head_dim)
q = q.transpose(0, 2, 1, 3)
q_nope, q_pe = mx.split(q, [self.qk_nope_head_dim], axis=-1)
if self.qk_rope_head_dim:
k_rot = mx.reshape(k_rot, (B, L, 1, self.qk_rope_head_dim))
k_rot = mx.broadcast_to(k_rot, (*k_pass.shape[:-1], self.qk_rope_head_dim))
else:
k_rot = mx.zeros((*k_pass.shape[:-1], 0), dtype=k_pass.dtype)
compressed_kv = self.kv_a_proj_with_mqa(x)
compressed_kv, k_pe = mx.split(compressed_kv, [self.kv_lora_rank], axis=-1)
k_pe = k_pe.reshape(B, L, 1, self.qk_rope_head_dim).transpose(0, 2, 1, 3)
kv_latent = self.kv_a_layernorm(compressed_kv)
queries = mx.concatenate([q_pass, q_rot], axis=-1).transpose(0, 2, 1, 3)
keys = mx.concatenate([k_pass, k_rot], axis=-1).transpose(0, 2, 1, 3)
values = v_states.transpose(0, 2, 1, 3)
kv_latent = mx.expand_dims(kv_latent, axis=1)
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)
kv_latent, k_pe = cache.update_and_fetch(kv_latent, k_pe)
out = scaled_dot_product_attention(
queries,
keys,
values,
cache,
scale=self.scale,
mask=mask,
pe_scores = (q_pe * self.scale) @ k_pe.swapaxes(-1, -2)
if mask is not None:
pe_scores = mx.where(
mask,
pe_scores,
mx.array(mx.finfo(pe_scores.dtype).min, pe_scores.dtype),
)
if L == 1:
q_nope = self.embed_q(q_nope)
k = v = kv_latent
else:
k = self.embed_q(kv_latent, transpose=False)
v = self.unembed_out(kv_latent)
output = scaled_dot_product_attention(
q_nope, k, v, cache=cache, scale=self.scale, mask=pe_scores
)
out = out.transpose(0, 2, 1, 3).reshape(B, L, -1)
return self.o_proj(out)
if L == 1:
output = self.unembed_out(output)
output = output.transpose(0, 2, 1, 3).reshape(B, L, -1)
return self.o_proj(output)
class ShortConv1d(nn.Module):
@@ -277,11 +263,11 @@ class ShortConv1d(nn.Module):
out = nn.silu(self.conv(conv_input))
n_keep = self.kernel_size - 1
if lengths is not None:
ends = mx.clip(cache.lengths, 0, x.shape[1])
ends = mx.clip(lengths, 0, x.shape[1])
positions = (ends[:, None] + mx.arange(n_keep))[..., None]
new_state = mx.take_along_axis(conv_input, positions, axis=1)
else:
new_state = conv_input[:, -n_keep:, :]
new_state = mx.contiguous(conv_input[:, -n_keep:, :])
return out, new_state
@@ -335,39 +321,37 @@ class KimiDeltaAttention(nn.Module):
dtype = x.dtype
if cache is not None:
conv_state, ssm_state = cache
q_state, k_state, v_state, ssm_state = cache
lengths = cache.lengths
else:
conv_state = None
q_state = None
k_state = None
v_state = None
ssm_state = None
lengths = None
if conv_state is None:
if q_state is None:
s = mx.zeros((B, self.conv_kernel - 1, self.projection_dim), dtype=dtype)
q_state = s
k_state = s
v_state = s
else:
q_state, k_state, v_state = conv_state
q_conv, q_state = self.q_conv(self.q_proj(x), q_state, mask, lengths)
k_conv, k_state = self.k_conv(self.k_proj(x), k_state, mask, lengths)
v_conv, v_state = self.v_conv(self.v_proj(x), v_state, mask, lengths)
if cache is not None:
cache[0] = (q_state, k_state, v_state)
cache[0] = q_state
cache[1] = k_state
cache[2] = v_state
q = q_conv.reshape(B, T, self.num_heads, self.head_dim)
k = k_conv.reshape(B, T, self.num_heads, self.head_dim)
v = v_conv.reshape(B, T, self.num_heads, self.head_dim)
def _l2norm(x, eps=1e-6):
norm = mx.linalg.norm(x, axis=-1, keepdims=True)
return x / (norm + eps)
q = _l2norm(q)
k = _l2norm(k)
q = q * self.scale
inv_scale = self.scale
q = (inv_scale**2) * mx.fast.rms_norm(q, None, 1e-6)
k = inv_scale * mx.fast.rms_norm(k, None, 1e-6)
a_logits = self.f_b_proj(self.f_a_proj(x)).reshape(
B, T, self.num_heads, self.head_dim
@@ -388,7 +372,7 @@ class KimiDeltaAttention(nn.Module):
)
if cache is not None:
cache[1] = ssm_state
cache[3] = ssm_state
cache.advance(T)
gate = self.g_b_proj(self.g_a_proj(x)).reshape(
@@ -462,7 +446,7 @@ class KimiLinearModel(nn.Module):
cache = [None] * len(self.layers)
ssm_mask = create_ssm_mask(h, cache[self.ssm_idx])
attn_mask = create_attention_mask(h, cache[self.attn_idx])
attn_mask = create_attention_mask(h, cache[self.attn_idx], return_array=True)
for layer, layer_cache in zip(self.layers, cache):
mask = ssm_mask if layer.is_linear else attn_mask
@@ -500,7 +484,7 @@ class Model(nn.Module):
caches: List[Any] = []
for layer in self.layers:
if layer.is_linear:
caches.append(ArraysCache(size=2))
caches.append(ArraysCache(size=4))
else:
caches.append(KVCache())
return caches
@@ -568,6 +552,42 @@ class Model(nn.Module):
if weights[dt_key].ndim > 1:
weights[dt_key] = mx.reshape(weights[dt_key], (-1,))
attn_prefix = f"{prefix}.self_attn"
kv_b_key = f"{attn_prefix}.kv_b_proj.weight"
if kv_b_key in weights:
qk_nope = self.args.qk_nope_head_dim or self.args.head_dim
v_head = self.args.v_head_dim or self.args.head_dim
head_dim = qk_nope + v_head
num_heads = self.args.num_attention_heads
quantized = f"{attn_prefix}.kv_b_proj.scales" in weights
v = weights.pop(kv_b_key)
if quantized:
dims = self.args.kv_lora_rank
scales = weights.pop(f"{attn_prefix}.kv_b_proj.scales")
biases = weights.pop(f"{attn_prefix}.kv_b_proj.biases")
bits = (v.shape[-1] * 32) // dims
group_size = dims // scales.shape[-1]
v = mx.dequantize(
v, scales, biases, bits=bits, group_size=group_size
)
v = v.reshape(num_heads, head_dim, -1)
wk = mx.contiguous(v[:, :qk_nope, :].swapaxes(-1, -2))
wv = mx.contiguous(v[:, qk_nope:, :])
if quantized:
wk, wk_s, wk_b = mx.quantize(wk, bits=bits, group_size=group_size)
wv, wv_s, wv_b = mx.quantize(wv, bits=bits, group_size=group_size)
weights[f"{attn_prefix}.embed_q.scales"] = wk_s
weights[f"{attn_prefix}.embed_q.biases"] = wk_b
weights[f"{attn_prefix}.unembed_out.scales"] = wv_s
weights[f"{attn_prefix}.unembed_out.biases"] = wv_b
weights[f"{attn_prefix}.embed_q.weight"] = wk
weights[f"{attn_prefix}.unembed_out.weight"] = wv
return weights
@property
+4 -1
View File
@@ -32,11 +32,14 @@ class ModelArgs(BaseModelArgs):
block_multiple_of: int
block_ffn_dim_multiplier: float
block_auto_adjust_ff_dim: bool
rope_theta: float
rope_theta: float = 1000000.0
rope_parameters: Optional[dict] = None
full_attn_idxs: Optional[List[int]] = None
layer_types: Optional[List[str]] = None
def __post_init__(self):
if self.rope_parameters is not None and "rope_theta" in self.rope_parameters:
self.rope_theta = self.rope_parameters["rope_theta"]
if self.num_key_value_heads is None:
self.num_key_value_heads = self.num_attention_heads
if self.full_attn_idxs is None:
+4 -1
View File
@@ -35,11 +35,14 @@ class ModelArgs(BaseModelArgs):
norm_eps: float
conv_bias: bool
conv_L_cache: int
rope_theta: float
rope_theta: float = 1000000.0
rope_parameters: Optional[dict] = None
full_attn_idxs: Optional[List[int]] = None
layer_types: Optional[List[str]] = None
def __post_init__(self):
if self.rope_parameters is not None and "rope_theta" in self.rope_parameters:
self.rope_theta = self.rope_parameters["rope_theta"]
if self.full_attn_idxs is None:
self.full_attn_idxs = [
i
+107 -54
View File
@@ -9,6 +9,7 @@ from mlx.nn.layers.distributed import shard_inplace, shard_linear, sum_gradients
from .activations import swiglu
from .base import BaseModelArgs, create_attention_mask, scaled_dot_product_attention
from .cache import CacheList, KVCache
from .mla import MultiLinear
from .rope_utils import initialize_rope
from .switch_layers import SwitchGLU
@@ -80,10 +81,11 @@ class LongcatFlashMLA(nn.Module):
bias=args.attention_bias,
)
self.kv_a_layernorm = nn.RMSNorm(self.kv_lora_rank)
self.kv_b_proj = nn.Linear(
self.kv_lora_rank,
self.num_attention_heads * (self.qk_nope_head_dim + args.v_head_dim),
bias=False,
self.embed_q = MultiLinear(
self.qk_nope_head_dim, self.kv_lora_rank, self.num_attention_heads
)
self.unembed_out = MultiLinear(
self.kv_lora_rank, self.v_head_dim, self.num_attention_heads
)
self.o_proj = nn.Linear(
@@ -122,56 +124,59 @@ class LongcatFlashMLA(nn.Module):
B, L, _ = x.shape
if self.q_lora_rank is None:
q_states = self.q_proj(x)
q = self.q_proj(x)
else:
q_states = self.q_b_proj(self.q_a_layernorm(self.q_a_proj(x)))
q = self.q_b_proj(self.q_a_layernorm(self.q_a_proj(x)))
q_states = q_states.reshape(B, L, -1, self.qk_head_dim).transpose(0, 2, 1, 3)
if self.mla_scale_q_lora is not None:
q_states = q_states * self.mla_scale_q_lora
q_pass, q_rot = mx.split(q_states, [self.qk_nope_head_dim], axis=-1)
compressed_kv = self.kv_a_proj_with_mqa(x)
k_pass, k_rot = mx.split(compressed_kv, [self.kv_lora_rank], axis=-1)
k_pass = self.kv_a_layernorm(k_pass)
if self.mla_scale_kv_lora is not None:
k_pass = k_pass * self.mla_scale_kv_lora
key_shape = (B, L, -1, self.qk_nope_head_dim + self.v_head_dim)
k_pass = self.kv_b_proj(k_pass).reshape(*key_shape).transpose(0, 2, 1, 3)
k_pass, value_states = mx.split(k_pass, [self.qk_nope_head_dim], axis=-1)
k_rot = k_rot.reshape(B, 1, L, self.qk_rope_head_dim)
if cache is not None:
q_rot = self.rope(q_rot, cache.offset)
k_rot = self.rope(k_rot, cache.offset)
else:
q_rot = self.rope(q_rot)
k_rot = self.rope(k_rot)
k_rot = mx.broadcast_to(k_rot, (*k_pass.shape[:-1], k_rot.shape[-1]))
query_states = mx.concatenate([q_pass, q_rot], axis=-1)
key_states = mx.concatenate([k_pass, k_rot], axis=-1)
if cache is not None:
key_states, value_states = cache.update_and_fetch(key_states, value_states)
attn_output = scaled_dot_product_attention(
query_states,
key_states,
value_states,
cache=cache,
scale=self.scale,
mask=mask,
q = q.reshape(B, L, self.num_attention_heads, self.qk_head_dim).transpose(
0, 2, 1, 3
)
attn_output = attn_output.transpose(0, 2, 1, 3).reshape(B, L, -1)
return self.o_proj(attn_output)
if self.mla_scale_q_lora is not None:
q = q * self.mla_scale_q_lora
q_nope, q_pe = mx.split(q, [self.qk_nope_head_dim], axis=-1)
compressed_kv = self.kv_a_proj_with_mqa(x)
compressed_kv, k_pe = mx.split(compressed_kv, [self.kv_lora_rank], axis=-1)
k_pe = k_pe.reshape(B, L, 1, self.qk_rope_head_dim).transpose(0, 2, 1, 3)
kv_latent = self.kv_a_layernorm(compressed_kv)
if self.mla_scale_kv_lora is not None:
kv_latent = kv_latent * self.mla_scale_kv_lora
offset = cache.offset if cache is not None else 0
q_pe = self.rope(q_pe, offset)
k_pe = self.rope(k_pe, offset)
kv_latent = mx.expand_dims(kv_latent, axis=1)
if cache is not None:
kv_latent, k_pe = cache.update_and_fetch(kv_latent, k_pe)
pe_scores = (q_pe * self.scale) @ k_pe.swapaxes(-1, -2)
if mask is not None:
pe_scores = mx.where(
mask,
pe_scores,
mx.array(mx.finfo(pe_scores.dtype).min, pe_scores.dtype),
)
if L == 1:
q_nope = self.embed_q(q_nope)
k = v = kv_latent
else:
k = self.embed_q(kv_latent, transpose=False)
v = self.unembed_out(kv_latent)
output = scaled_dot_product_attention(
q_nope, k, v, cache=cache, scale=self.scale, mask=pe_scores
)
if L == 1:
output = self.unembed_out(output)
output = output.transpose(0, 2, 1, 3).reshape(B, L, -1)
return self.o_proj(output)
class LongcatFlashMLP(nn.Module):
@@ -339,7 +344,7 @@ class LongcatFlashModel(nn.Module):
if cache is None:
cache = [(None, None)] * self.num_layers
mask = create_attention_mask(h, cache[0][0])
mask = create_attention_mask(h, cache[0][0], return_array=True)
for layer, c in zip(self.layers, cache):
h = layer(h, mask, cache=c)
@@ -395,6 +400,47 @@ class Model(nn.Module):
]
weights[f"{prefix}.mlp.switch_mlp.{m}.{k}"] = mx.stack(to_join)
for l in range(self.args.num_layers):
for i in range(2):
prefix = f"model.layers.{l}.self_attn.{i}"
kv_b_key = f"{prefix}.kv_b_proj.weight"
if kv_b_key in weights:
num_heads = self.args.num_attention_heads
head_dim = self.args.qk_nope_head_dim + self.args.v_head_dim
quantized = f"{prefix}.kv_b_proj.scales" in weights
v = weights.pop(kv_b_key)
if quantized:
dims = self.args.kv_lora_rank
scales = weights.pop(f"{prefix}.kv_b_proj.scales")
biases = weights.pop(f"{prefix}.kv_b_proj.biases")
bits = (v.shape[-1] * 32) // dims
group_size = dims // scales.shape[-1]
v = mx.dequantize(
v, scales, biases, bits=bits, group_size=group_size
)
v = v.reshape(num_heads, head_dim, -1)
wk = mx.contiguous(
v[:, : self.args.qk_nope_head_dim, :].swapaxes(-1, -2)
)
wv = mx.contiguous(v[:, self.args.qk_nope_head_dim :, :])
if quantized:
wk, wk_s, wk_b = mx.quantize(
wk, bits=bits, group_size=group_size
)
wv, wv_s, wv_b = mx.quantize(
wv, bits=bits, group_size=group_size
)
weights[f"{prefix}.embed_q.scales"] = wk_s
weights[f"{prefix}.embed_q.biases"] = wk_b
weights[f"{prefix}.unembed_out.scales"] = wv_s
weights[f"{prefix}.unembed_out.biases"] = wv_b
weights[f"{prefix}.embed_q.weight"] = wk
weights[f"{prefix}.unembed_out.weight"] = wv
new_weights = {}
for k, v in weights.items():
if k.startswith("model.mtp"):
@@ -408,6 +454,7 @@ class Model(nn.Module):
def shard(self, group: Optional[mx.distributed.Group] = None):
group = group or mx.distributed.init()
N = group.size()
rank = group.rank()
for layer in self.model.layers:
for attn in layer.self_attn:
@@ -419,11 +466,17 @@ class Model(nn.Module):
attn.q_b_proj = shard_linear(
attn.q_b_proj, "all-to-sharded", group=group
)
attn.kv_b_proj = shard_linear(
attn.kv_b_proj, "all-to-sharded", group=group
)
attn.o_proj = shard_linear(attn.o_proj, "sharded-to-all", group=group)
attn.num_attention_heads //= N
num_heads = attn.num_attention_heads
sh = rank * num_heads
eh = sh + num_heads
def shard_heads(w):
return w[sh:eh]
attn.embed_q.apply(shard_heads)
attn.unembed_out.apply(shard_heads)
for mlp in layer.mlps:
mlp.gate_proj = shard_linear(
+1 -1
View File
@@ -161,7 +161,7 @@ class LongcatFlashNgramModel(nn.Module):
h = self.ngram_embeddings(input_ids, cache=cache[0])
mask = create_attention_mask(h, cache[1][0])
mask = create_attention_mask(h, cache[1][0], return_array=True)
for layer, c in zip(self.layers, cache[1:]):
h = layer(h, mask, cache=c)
+56 -6
View File
@@ -1,6 +1,7 @@
# Copyright © 2025 Apple Inc.
from dataclasses import dataclass
from functools import lru_cache
from typing import Any, List, Optional
import mlx.core as mx
@@ -33,6 +34,55 @@ class ModelArgs(BaseModelArgs):
use_qk_norm: bool = True
@lru_cache
def sharded_rms_norm(group):
@mx.compile
def _cast_square_sum(x):
return x.astype(mx.float32).square().sum(-1, keepdims=True)
@mx.compile
def _normalize(x, norm2, w, eps):
norm2 = mx.distributed.all_sum(norm2, group=group)
norm = mx.rsqrt(norm2 / (x.shape[-1] * group.size()) + eps)
return (x.astype(mx.float32) * norm * w).astype(x.dtype)
# Split the compile so that x upcasting doesn't break the compile and we
# have 2 kernels generated 1 for f(x) = square(upcast(x)) and another
# g(x) = downcast(upcast(x) * norm * w)
def _inner_sharded_rms_norm(x, w, eps):
return _normalize(x, _cast_square_sum(x), w, eps)
return _inner_sharded_rms_norm
class ShardedRMSNorm(nn.Module):
def __init__(
self, dims: int, eps: float = 1e-5, group: Optional[mx.distributed.Group] = None
):
super().__init__()
group = group or mx.distributed.init()
self.weight = mx.ones((dims // group.size(),))
self.group = group
self.eps = eps
def _extra_repr(self):
return f"{self.weight.shape[0] * self.group.size()}, eps={self.eps}"
def __call__(self, x):
return sharded_rms_norm(self.group)(x, self["weight"], self.eps)
@classmethod
def from_rms_norm(
cls, norm_module, *, group: Optional[mx.distributed.Group] = None
):
sn = cls(norm_module.weight.shape[0], norm_module.eps, group=group)
sn.weight = mx.contiguous(
mx.split(norm_module.weight, group.size(), axis=-1)[group.rank()]
)
return sn
class MiniMaxAttention(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
@@ -295,12 +345,12 @@ class Model(nn.Module):
layer.self_attn.o_proj, "sharded-to-all", group=group
)
if layer.self_attn.use_qk_norm:
layer.self_attn.q_norm.weight = layer.self_attn.q_norm.weight.split(
N, axis=-1
)[rank]
layer.self_attn.k_norm.weight = layer.self_attn.k_norm.weight.split(
N, axis=-1
)[rank]
layer.self_attn.q_norm = ShardedRMSNorm.from_rms_norm(
layer.self_attn.q_norm, group=group
)
layer.self_attn.k_norm = ShardedRMSNorm.from_rms_norm(
layer.self_attn.k_norm, group=group
)
layer.self_attn.num_attention_heads //= N
layer.self_attn.num_key_value_heads //= N
+20 -8
View File
@@ -1,4 +1,4 @@
# Copyright © 2023-2024 Apple Inc.
# Copyright © 2026 Apple Inc.
from dataclasses import dataclass
from typing import Any, Dict, Optional, Union
@@ -25,6 +25,7 @@ class ModelArgs(BaseModelArgs):
rope_theta: float = 1e6
rope_traditional: bool = False
rope_scaling: Optional[Dict[str, Union[float, str]]] = None
tie_word_embeddings: bool = False
def __post_init__(self):
if self.num_key_value_heads is None:
@@ -162,8 +163,12 @@ class MixtralModel(nn.Module):
self,
inputs: mx.array,
cache=None,
):
h = self.embed_tokens(inputs)
input_embeddings: Optional[mx.array] = None,
) -> mx.array:
if input_embeddings is not None:
h = input_embeddings
else:
h = self.embed_tokens(inputs)
if cache is None:
cache = [None] * len(self.layers)
@@ -179,20 +184,27 @@ class MixtralModel(nn.Module):
class Model(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
self.args = args
self.model_type = args.model_type
self.model = MixtralModel(args)
self.lm_head = nn.Linear(args.hidden_size, args.vocab_size, bias=False)
self.args = args
if not args.tie_word_embeddings:
self.lm_head = nn.Linear(args.hidden_size, args.vocab_size, bias=False)
def __call__(
self,
inputs: mx.array,
cache=None,
):
out = self.model(inputs, cache)
return self.lm_head(out)
input_embeddings: Optional[mx.array] = None,
) -> mx.array:
out = self.model(inputs, cache, input_embeddings)
if self.args.tie_word_embeddings:
return self.model.embed_tokens.as_linear(out)
else:
return self.lm_head(out)
def sanitize(self, weights):
if self.args.tie_word_embeddings:
weights.pop("lm_head.weight", None)
if "model.layers.0.block_sparse_moe.experts.0.w1.weight" not in weights:
return weights
for l in range(self.args.num_hidden_layers):
+1 -1
View File
@@ -26,7 +26,7 @@ class MultiLinear(nn.Module):
self,
group_size: int,
bits: int,
mode: str,
mode: str = "affine",
):
num_heads, output_dims, input_dims = self.weight.shape
ql = QuantizedMultiLinear(
+45 -9
View File
@@ -40,10 +40,12 @@ class ModelArgs(BaseModelArgs):
layer_norm_epsilon: float
use_bias: bool
use_conv_bias: bool
hybrid_override_pattern: List[str]
hybrid_override_pattern: Optional[List[str]] = None
layers_block_type: Optional[List[str]] = None
head_dim: Optional[int] = None
moe_intermediate_size: Optional[int] = None
moe_shared_expert_intermediate_size: Optional[int] = None
moe_latent_size: Optional[int] = None
n_group: Optional[int] = None
n_routed_experts: Optional[int] = None
n_shared_experts: Optional[int] = None
@@ -55,13 +57,20 @@ class ModelArgs(BaseModelArgs):
time_step_min: Optional[float] = None
time_step_max: Optional[float] = None
# Map from layers_block_type names to single-char pattern codes
_block_type_to_char = {"mamba": "M", "attention": "*", "moe": "E", "mlp": "-"}
def __post_init__(self):
if (
self.time_step_limit is None
and self.time_step_min is not None
and self.time_step_max is not None
):
self.time_step_limit = (self.time_step_min, self.time_step_max)
if self.time_step_limit is None:
self.time_step_limit = (0.0, float("inf"))
# Normalize to hybrid_override_pattern (single-char list)
if self.hybrid_override_pattern is None and self.layers_block_type is not None:
self.hybrid_override_pattern = [
self._block_type_to_char[t] for t in self.layers_block_type
]
if self.hybrid_override_pattern is not None:
self.num_hidden_layers = len(self.hybrid_override_pattern)
class MambaRMSNormGated(nn.Module):
@@ -365,8 +374,16 @@ class NemotronHMoE(nn.Module):
super().__init__()
self.config = config
self.num_experts_per_tok = config.num_experts_per_tok
self.moe_latent_size = config.moe_latent_size
# When latent projection is used, experts operate on the latent dim
expert_input_dim = (
config.moe_latent_size
if config.moe_latent_size is not None
else config.hidden_size
)
self.switch_mlp = SwitchMLP(
config.hidden_size,
expert_input_dim,
config.moe_intermediate_size,
config.n_routed_experts,
activation=nn.ReLU2(),
@@ -379,12 +396,30 @@ class NemotronHMoE(nn.Module):
config, intermediate_size=intermediate_size
)
# Latent projection layers for dimensionality reduction before/after experts
if config.moe_latent_size is not None:
self.fc1_latent_proj = nn.Linear(
config.hidden_size, config.moe_latent_size, bias=config.mlp_bias
)
self.fc2_latent_proj = nn.Linear(
config.moe_latent_size, config.hidden_size, bias=config.mlp_bias
)
def __call__(self, x):
residuals = x
inds, scores = self.gate(x)
if self.moe_latent_size is not None:
x = self.fc1_latent_proj(x)
y = self.switch_mlp(x, inds)
y = (y * scores[..., None]).sum(axis=-2).astype(y.dtype)
if self.moe_latent_size is not None:
y = self.fc2_latent_proj(y)
if self.config.n_shared_experts is not None:
y = y + self.shared_experts(x)
y = y + self.shared_experts(residuals)
return y
@@ -501,6 +536,7 @@ class Model(nn.Module):
return caches
def sanitize(self, weights):
weights = {k: v for (k, v) in weights.items() if not k.startswith("mtp.")}
for k, v in weights.items():
if "conv1d.weight" in k and v.shape[-1] != 1:
weights[k] = v.moveaxis(2, 1)
+531
View File
@@ -0,0 +1,531 @@
# Copyright © 2026 Apple Inc.
from dataclasses import dataclass, field
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_inplace, shard_linear, sum_gradients
from mlx.utils import tree_map
from .base import (
BaseModelArgs,
create_attention_mask,
create_ssm_mask,
)
from .cache import ArraysCache, KVCache
from .gated_delta import gated_delta_update
from .qwen3_next import Qwen3NextAttention as Attention
from .qwen3_next import Qwen3NextMLP as MLP
from .qwen3_next import Qwen3NextRMSNormGated as RMSNormGated
from .qwen3_next import Qwen3NextSparseMoeBlock as SparseMoeBlock
@dataclass
class TextModelArgs(BaseModelArgs):
model_type: str = ""
hidden_size: int = 4096
intermediate_size: int = 14336
num_hidden_layers: int = 32
num_attention_heads: int = 32
rms_norm_eps: float = 1e-6
vocab_size: int = 151936
num_key_value_heads: int = 8
max_position_embeddings: int = 131072
linear_num_value_heads: int = 64
linear_num_key_heads: int = 16
linear_key_head_dim: int = 192
linear_value_head_dim: int = 128
linear_conv_kernel_dim: int = 4
tie_word_embeddings: bool = False
attention_bias: bool = False
head_dim: Optional[int] = None
full_attention_interval: int = 4
# MoE fields (optional, for Qwen3_5MoeForConditionalGeneration)
num_experts: int = 0
num_experts_per_tok: int = 0
decoder_sparse_step: int = 1
shared_expert_intermediate_size: int = 0
moe_intermediate_size: int = 0
norm_topk_prob: bool = True
# Rope parameters
rope_parameters: Optional[Dict[str, Union[float, str, bool, List[int]]]] = field(
default_factory=lambda: {
"type": "default",
"mrope_section": [11, 11, 10],
"rope_theta": 100000,
"partial_rotary_factor": 0.25,
}
)
# Derived from rope_parameters (set in __post_init__)
partial_rotary_factor: float = 0.25
rope_theta: float = 100000.0
rope_scaling: Optional[Dict[str, Union[float, str]]] = None
def __post_init__(self):
if self.head_dim is None:
self.head_dim = self.hidden_size // self.num_attention_heads
if self.rope_parameters:
if (
"type" not in self.rope_parameters
and "rope_type" in self.rope_parameters
):
self.rope_parameters["type"] = self.rope_parameters.pop("rope_type")
self.partial_rotary_factor = self.rope_parameters.get(
"partial_rotary_factor", 0.25
)
self.rope_theta = self.rope_parameters.get("rope_theta", 100000.0)
self.rope_scaling = self.rope_parameters
class GatedDeltaNet(nn.Module):
def __init__(self, config: TextModelArgs):
super().__init__()
self.hidden_size = config.hidden_size
self.num_v_heads = config.linear_num_value_heads
self.num_k_heads = config.linear_num_key_heads
self.head_k_dim = config.linear_key_head_dim
self.head_v_dim = config.linear_value_head_dim
self.key_dim = self.head_k_dim * self.num_k_heads
self.value_dim = self.head_v_dim * self.num_v_heads
if self.num_v_heads % self.num_k_heads != 0:
raise ValueError(
f"num_v_heads ({self.num_v_heads}) must be divisible by num_k_heads ({self.num_k_heads})"
)
self.conv_kernel_size = config.linear_conv_kernel_dim
self.layer_norm_epsilon = config.rms_norm_eps
self.conv_dim = self.key_dim * 2 + self.value_dim
self.conv1d = nn.Conv1d(
in_channels=self.conv_dim,
out_channels=self.conv_dim,
bias=False,
kernel_size=self.conv_kernel_size,
groups=self.conv_dim,
padding=0,
)
self.in_proj_qkv = nn.Linear(
self.hidden_size, self.key_dim * 2 + self.value_dim, bias=False
)
self.in_proj_z = nn.Linear(self.hidden_size, self.value_dim, bias=False)
self.in_proj_b = nn.Linear(self.hidden_size, self.num_v_heads, bias=False)
self.in_proj_a = nn.Linear(self.hidden_size, self.num_v_heads, bias=False)
self.dt_bias = mx.ones(self.num_v_heads)
A = mx.random.uniform(low=0, high=16, shape=(self.num_v_heads,))
self.A_log = mx.log(A)
self.norm = RMSNormGated(self.head_v_dim, eps=self.layer_norm_epsilon)
self.out_proj = nn.Linear(self.value_dim, self.hidden_size, bias=False)
self.sharding_group = None
def __call__(
self,
inputs: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[Any] = None,
) -> mx.array:
B, S, _ = inputs.shape
if self.sharding_group is not None:
inputs = sum_gradients(self.sharding_group)(inputs)
qkv = self.in_proj_qkv(inputs)
z = self.in_proj_z(inputs).reshape(B, S, self.num_v_heads, self.head_v_dim)
b = self.in_proj_b(inputs)
a = self.in_proj_a(inputs)
if cache is not None and cache[0] is not None:
conv_state = cache[0]
else:
conv_state = mx.zeros(
(B, self.conv_kernel_size - 1, self.conv_dim),
dtype=inputs.dtype,
)
if mask is not None:
qkv = mx.where(mask[..., None], qkv, 0)
conv_input = mx.concatenate([conv_state, qkv], axis=1)
if cache is not None:
n_keep = self.conv_kernel_size - 1
if cache.lengths is not None:
ends = mx.clip(cache.lengths, 0, S)
positions = (ends[:, None] + mx.arange(n_keep))[..., None]
cache[0] = mx.take_along_axis(conv_input, positions, axis=1)
else:
cache[0] = mx.contiguous(conv_input[:, -n_keep:, :])
conv_out = nn.silu(self.conv1d(conv_input))
q, k, v = [
t.reshape(B, S, h, d)
for t, h, d in zip(
mx.split(conv_out, [self.key_dim, 2 * self.key_dim], -1),
[self.num_k_heads, self.num_k_heads, self.num_v_heads],
[self.head_k_dim, self.head_k_dim, self.head_v_dim],
)
]
state = cache[1] if cache else None
inv_scale = k.shape[-1] ** -0.5
q = (inv_scale**2) * mx.fast.rms_norm(q, None, 1e-6)
k = inv_scale * mx.fast.rms_norm(k, None, 1e-6)
out, state = gated_delta_update(
q,
k,
v,
a,
b,
self.A_log,
self.dt_bias,
state,
mask,
use_kernel=not self.training,
)
if cache is not None:
cache[1] = state
cache.advance(S)
out = self.norm(out, z)
out = self.out_proj(out.reshape(B, S, -1))
if self.sharding_group is not None:
out = mx.distributed.all_sum(out, group=self.sharding_group)
return out
class DecoderLayer(nn.Module):
def __init__(self, args: TextModelArgs, layer_idx: int):
super().__init__()
self.is_linear = (layer_idx + 1) % args.full_attention_interval != 0
if self.is_linear:
self.linear_attn = GatedDeltaNet(args)
else:
self.self_attn = Attention(args)
self.input_layernorm = nn.RMSNorm(args.hidden_size, eps=args.rms_norm_eps)
self.post_attention_layernorm = nn.RMSNorm(
args.hidden_size, eps=args.rms_norm_eps
)
if args.num_experts > 0:
self.mlp = SparseMoeBlock(args)
else:
self.mlp = MLP(args.hidden_size, args.intermediate_size)
def __call__(
self,
x: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[Any] = None,
) -> mx.array:
if self.is_linear:
r = self.linear_attn(self.input_layernorm(x), mask, cache)
else:
r = self.self_attn(self.input_layernorm(x), mask, cache)
h = x + r
out = h + self.mlp(self.post_attention_layernorm(h))
return out
class Qwen3_5TextModel(nn.Module):
def __init__(self, args: TextModelArgs):
super().__init__()
self.embed_tokens = nn.Embedding(args.vocab_size, args.hidden_size)
self.layers = [
DecoderLayer(args=args, layer_idx=i) for i in range(args.num_hidden_layers)
]
self.norm = nn.RMSNorm(args.hidden_size, eps=args.rms_norm_eps)
self.ssm_idx = 0
self.fa_idx = args.full_attention_interval - 1
def __call__(
self,
inputs: mx.array,
cache: Optional[Any] = None,
input_embeddings: Optional[mx.array] = None,
) -> mx.array:
if input_embeddings is not None:
hidden_states = input_embeddings
else:
hidden_states = self.embed_tokens(inputs)
if cache is None:
cache = [None] * len(self.layers)
fa_mask = create_attention_mask(hidden_states, cache[self.fa_idx])
ssm_mask = create_ssm_mask(hidden_states, cache[self.ssm_idx])
for layer, c in zip(self.layers, cache):
mask = ssm_mask if layer.is_linear else fa_mask
hidden_states = layer(hidden_states, mask=mask, cache=c)
return self.norm(hidden_states)
class TextModel(nn.Module):
def __init__(self, args: TextModelArgs):
super().__init__()
self.args = args
self.model_type = args.model_type
self.model = Qwen3_5TextModel(args)
if not args.tie_word_embeddings:
self.lm_head = nn.Linear(args.hidden_size, args.vocab_size, bias=False)
def __call__(
self,
inputs: mx.array,
cache: Optional[Any] = None,
input_embeddings: Optional[mx.array] = None,
) -> mx.array:
out = self.model(inputs, cache, input_embeddings=input_embeddings)
if self.args.tie_word_embeddings:
out = self.model.embed_tokens.as_linear(out)
else:
out = self.lm_head(out)
return out
@property
def layers(self):
return self.model.layers
def make_cache(self):
return [ArraysCache(size=2) if l.is_linear else KVCache() for l in self.layers]
def sanitize(self, weights):
has_mtp_weights = any("mtp." in k for k in weights)
has_unsanitized_conv1d = any(
"conv1d.weight" in k and v.shape[-1] != 1 for k, v in weights.items()
)
should_shift_norm_weights = has_mtp_weights or has_unsanitized_conv1d
weights = {k: v for k, v in weights.items() if "mtp." not in k}
if self.args.tie_word_embeddings:
weights.pop("lm_head.weight", None)
norm_keys = (
".input_layernorm.weight",
".post_attention_layernorm.weight",
"model.norm.weight",
".q_norm.weight",
".k_norm.weight",
)
for k, v in weights.items():
if "conv1d.weight" in k and v.shape[-1] != 1:
weights[k] = v.moveaxis(2, 1)
if should_shift_norm_weights and any(k.endswith(sfx) for sfx in norm_keys):
if v.ndim == 1:
weights[k] = v + 1.0
return weights
@property
def quant_predicate(self):
if self.args.num_experts <= 0:
return None
def predicate(path, _):
if path.endswith("mlp.gate") or path.endswith("shared_expert_gate"):
return {"group_size": 64, "bits": 8}
return True
return predicate
@property
def cast_predicate(self):
def predicate(path: str):
if path.endswith("A_log"):
return False
return True
return predicate
@dataclass
class ModelArgs(BaseModelArgs):
model_type: str
text_config: dict
@classmethod
def from_dict(cls, params):
if "text_config" not in params:
return cls(model_type=params["model_type"], text_config=params)
return super().from_dict(params)
class Model(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
self.args = args
self.model_type = args.model_type
self.language_model = TextModel(TextModelArgs.from_dict(args.text_config))
def __call__(
self,
inputs: mx.array,
cache=None,
input_embeddings: Optional[mx.array] = None,
):
return self.language_model(
inputs, cache=cache, input_embeddings=input_embeddings
)
def sanitize(self, weights):
sanitized = {}
for key, value in weights.items():
if key.startswith("vision_tower") or key.startswith("model.visual"):
continue
if key.startswith("model.visual"):
continue
if key.startswith("model.language_model"):
key = key.replace("model.language_model", "language_model.model")
elif key.startswith("language_model."):
pass
else:
key = "language_model." + key
sanitized[key] = value
return self.language_model.sanitize(sanitized)
def shard(self, group=None):
group = group or mx.distributed.init()
N = group.size()
rank = group.rank()
# A sharding factory for the convolution in gated delta net
def conv_sharding(key_dim):
return lambda p, w: (0, [key_dim, 2 * key_dim])
def repeat_kv_layer_inplace(layer, h):
# No repeat needed cause we have more heads than nodes
if N <= h:
return
# Repeat function to apply to the layer weights
def _repeat(p):
s = p.shape
p = p.reshape(h, s[0] // h, *s[1:])
p = mx.repeat(p, N // h, axis=0)
p = p.reshape(-1, *s[1:])
return p
layer.update(tree_map(_repeat, layer.parameters()))
for layer in self.layers:
# Linear attention
if layer.is_linear:
kd = layer.linear_attn.key_dim
layer.linear_attn.sharding_group = group
shard_inplace(layer.linear_attn.conv1d, conv_sharding(kd), group=group)
layer.linear_attn.conv1d.groups //= N
shard_inplace(
layer.linear_attn.in_proj_qkv,
"all-to-sharded",
segments=[kd, 2 * kd],
group=group,
)
shard_inplace(
layer.linear_attn.in_proj_z, "all-to-sharded", group=group
)
shard_inplace(
layer.linear_attn.in_proj_b, "all-to-sharded", group=group
)
shard_inplace(
layer.linear_attn.in_proj_a, "all-to-sharded", group=group
)
layer.linear_attn.dt_bias = mx.contiguous(
mx.split(layer.linear_attn.dt_bias, N)[rank]
)
layer.linear_attn.A_log = mx.contiguous(
mx.split(layer.linear_attn.A_log, N)[rank]
)
shard_inplace(layer.linear_attn.out_proj, "sharded-to-all", group=group)
layer.linear_attn.num_k_heads //= N
layer.linear_attn.num_v_heads //= N
layer.linear_attn.key_dim //= N
layer.linear_attn.value_dim //= N
layer.linear_attn.conv_dim //= N
# Softmax attention
else:
layer.self_attn.o_proj = shard_linear(
layer.self_attn.o_proj, "sharded-to-all", group=group
)
layer.self_attn.q_proj = shard_linear(
layer.self_attn.q_proj, "all-to-sharded", group=group
)
repeat_kv_layer_inplace(
layer.self_attn.k_proj, layer.self_attn.num_key_value_heads
)
repeat_kv_layer_inplace(
layer.self_attn.v_proj, layer.self_attn.num_key_value_heads
)
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.num_attention_heads //= N
layer.self_attn.num_key_value_heads = max(
1, layer.self_attn.num_key_value_heads // N
)
# MLP
if isinstance(layer.mlp, 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
)
# MoE
else:
layer.mlp.sharding_group = group
shard_inplace(
layer.mlp.shared_expert.gate_proj, "all-to-sharded", group=group
)
shard_inplace(
layer.mlp.shared_expert.down_proj, "sharded-to-all", group=group
)
shard_inplace(
layer.mlp.shared_expert.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.language_model.model.layers
def make_cache(self):
return self.language_model.make_cache()
@property
def quant_predicate(self):
return self.language_model.quant_predicate
@property
def cast_predicate(self):
return self.language_model.cast_predicate
+52
View File
@@ -0,0 +1,52 @@
# Copyright © 2026 Apple Inc.
from dataclasses import dataclass
from .base import BaseModelArgs
from .qwen3_5 import Model as Qwen3_5Model
@dataclass
class ModelArgs(BaseModelArgs):
model_type: str
text_config: dict
@classmethod
def from_dict(cls, params):
if "text_config" not in params:
return cls(model_type=params["model_type"], text_config=params)
return super().from_dict(params)
class Model(Qwen3_5Model):
def sanitize(self, weights):
new_weights = {}
for key, value in weights.items():
if key.startswith("vision_tower") or key.startswith("model.visual"):
continue
if key.startswith("model.language_model"):
key = key.replace("model.language_model", "language_model.model")
elif key.startswith("language_model."):
pass
else:
key = "language_model." + key
new_weights[key] = value
for l in range(self.language_model.args.num_hidden_layers):
prefix = f"language_model.model.layers.{l}.mlp"
gate_up_key = f"{prefix}.experts.gate_up_proj"
if gate_up_key in new_weights:
gate_up = new_weights.pop(gate_up_key)
mid = gate_up.shape[-2] // 2
new_weights[f"{prefix}.switch_mlp.gate_proj.weight"] = gate_up[
..., :mid, :
]
new_weights[f"{prefix}.switch_mlp.up_proj.weight"] = gate_up[
..., mid:, :
]
new_weights[f"{prefix}.switch_mlp.down_proj.weight"] = new_weights.pop(
f"{prefix}.experts.down_proj"
)
return self.language_model.sanitize(new_weights)
+17 -7
View File
@@ -1,4 +1,4 @@
# Copyright © 2025 Apple Inc.
# Copyright © 2026 Apple Inc.
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Union
@@ -123,7 +123,7 @@ class Qwen3MoeSparseMoeBlock(nn.Module):
def __call__(
self,
x: mx.array,
):
) -> mx.array:
gates = self.gate(x)
gates = mx.softmax(gates, axis=-1, precise=True)
@@ -190,7 +190,7 @@ class Qwen3MoeModel(nn.Module):
inputs: mx.array,
cache=None,
input_embeddings: Optional[mx.array] = None,
):
) -> mx.array:
if input_embeddings is not None:
h = input_embeddings
else:
@@ -213,15 +213,25 @@ class Model(nn.Module):
self.args = args
self.model_type = args.model_type
self.model = Qwen3MoeModel(args)
self.lm_head = nn.Linear(args.hidden_size, args.vocab_size, bias=False)
if not args.tie_word_embeddings:
self.lm_head = nn.Linear(args.hidden_size, args.vocab_size, bias=False)
def __call__(
self, inputs: mx.array, cache=None, input_embeddings: Optional[mx.array] = None
):
self,
inputs: mx.array,
cache=None,
input_embeddings: Optional[mx.array] = None,
) -> mx.array:
out = self.model(inputs, cache, input_embeddings)
return self.lm_head(out)
if self.args.tie_word_embeddings:
out = self.model.embed_tokens.as_linear(out)
else:
out = self.lm_head(out)
return out
def sanitize(self, weights):
if self.args.tie_word_embeddings:
weights.pop("lm_head.weight", None)
if "model.layers.0.mlp.experts.0.up_proj.weight" not in weights:
return weights
for l in range(self.args.num_hidden_layers):
+24 -4
View File
@@ -3,10 +3,12 @@
from __future__ import annotations
from dataclasses import dataclass
from functools import partial
from typing import Any, Dict, List, Optional, Tuple, Union
import mlx.core as mx
import mlx.nn as nn
from mlx.nn.layers.distributed import sum_gradients
from .activations import swiglu
from .base import (
@@ -53,6 +55,13 @@ class ModelArgs(BaseModelArgs):
full_attention_interval: int = 4
@partial(mx.compile, shapeless=True)
def _precise_swiglu(h, gate, x):
gate = nn.silu(gate.astype(mx.float32))
x = x.astype(mx.float32)
return (gate * x).astype(h.dtype)
class Qwen3NextRMSNormGated(nn.Module):
def __init__(self, hidden_size: int, eps: float = 1e-6):
super().__init__()
@@ -64,8 +73,9 @@ class Qwen3NextRMSNormGated(nn.Module):
) -> mx.array:
x = mx.fast.rms_norm(hidden_states, self.weight, self.eps)
if gate is not None:
x = swiglu(gate, x)
return x
return _precise_swiglu(hidden_states, gate, x)
else:
return x.astype(hidden_states.dtype)
class Qwen3NextAttention(nn.Module):
@@ -256,7 +266,7 @@ class Qwen3NextGatedDeltaNet(nn.Module):
positions = (ends[:, None] + mx.arange(n_keep))[..., None]
cache[0] = mx.take_along_axis(conv_input, positions, axis=1)
else:
cache[0] = conv_input[:, -n_keep:, :]
cache[0] = mx.contiguous(conv_input[:, -n_keep:, :])
conv_out = nn.silu(self.conv1d(conv_input))
@@ -312,10 +322,15 @@ class Qwen3NextSparseMoeBlock(nn.Module):
self.shared_expert = Qwen3NextMLP(dim, shared_expert_intermediate_size)
self.shared_expert_gate = nn.Linear(dim, 1, bias=False)
self.sharding_group = None
def __call__(
self,
x: mx.array,
) -> mx.array:
if self.sharding_group is not None:
x = sum_gradients(self.sharding_group)(x)
gates = self.gate(x)
gates = mx.softmax(gates, axis=-1, precise=True)
@@ -331,7 +346,12 @@ class Qwen3NextSparseMoeBlock(nn.Module):
shared_y = self.shared_expert(x)
shared_y = mx.sigmoid(self.shared_expert_gate(x)) * shared_y
return y + shared_y
y = y + shared_y
if self.sharding_group is not None:
y = mx.distributed.all_sum(y, group=self.sharding_group)
return y
class Qwen3NextDecoderLayer(nn.Module):
+46 -1
View File
@@ -58,6 +58,7 @@ class SuScaledRoPE(nn.Module):
self._scale = long_mscale or (1.0 if factor <= 1.0 else default_scale(factor))
def __call__(self, x, offset: Union[int, mx.array] = 0):
x = x[...]
x[..., : self.dim] = self._scale * x[..., : self.dim]
return mx.fast.rope(
x,
@@ -71,7 +72,6 @@ class SuScaledRoPE(nn.Module):
class Llama3RoPE(nn.Module):
def __init__(
self,
dims: int,
@@ -183,6 +183,7 @@ class YarnRoPE(nn.Module):
def __call__(self, x, offset=0):
if self.mscale != 1.0:
x = x[...]
x[..., : self.dims] = self.mscale * x[..., : self.dims]
return mx.fast.rope(
x,
@@ -195,6 +196,42 @@ class YarnRoPE(nn.Module):
)
class ProportionalRoPE(nn.Module):
def __init__(
self,
dims: int,
rotated_dims: int,
traditional: bool = False,
base: float = 10000.0,
factor: float = 1.0,
):
super().__init__()
self.dims = dims
self.traditional = traditional
if rotated_dims > dims:
raise ValueError("rotated_dims should be smaller than dims")
exponents = mx.arange(0, rotated_dims, 2, dtype=mx.float32) / dims
self._freqs = mx.concatenate(
[
factor * (base**exponents),
mx.full(((dims - rotated_dims) // 2,), mx.inf),
]
)
def __call__(self, x, offset=0):
return mx.fast.rope(
x,
self.dims,
traditional=self.traditional,
base=None,
scale=1.0,
offset=offset,
freqs=self._freqs,
)
def initialize_rope(
dims,
base,
@@ -253,6 +290,14 @@ def initialize_rope(
short_factor=scaling_config["short_factor"],
long_factor=scaling_config["long_factor"],
)
elif rope_type == "proportional":
return ProportionalRoPE(
dims=dims,
rotated_dims=int(dims * scaling_config.get("partial_rotary_factor", 1.0)),
traditional=traditional,
base=base,
factor=scaling_config.get("factor", 1.0),
)
elif rope_type == "mrope":
mrope_section = scaling_config.get("mrope_section", [])
assert (
+13 -4
View File
@@ -6,6 +6,7 @@ import mlx.nn as nn
@mx.compile
def compute_dt(dt, dt_bias, time_step_limit):
dt = dt.astype(mx.float32)
dt = nn.softplus(dt + dt_bias)
return mx.clip(dt, time_step_limit[0], time_step_limit[1])
@@ -44,7 +45,7 @@ def make_ssm_kernel():
auto idx = d_idx * Ds + s_idx;
auto dB_by_x = x_ * dt_ * static_cast<float>(B_[s_idx]);
auto state = dA * i_state[idx] + dB_by_x;
o_state[idx] = static_cast<T>(state);
o_state[idx] = static_cast<U>(state);
acc += state * C_[s_idx];
}
acc = simd_sum(acc);
@@ -76,15 +77,23 @@ def ssm_update_kernel(
):
n, _, h, d = hidden_states.shape
input_type = hidden_states.dtype
state_type = state.dtype
hb, ds = B.shape[-2:]
dt = compute_dt(dt, dt_bias, time_step_limit)
return _ssm_kernel(
inputs=[hidden_states, A_log, B, C, D, dt, state],
template=[("T", input_type), ("Dh", d), ("Ds", ds), ("H", h), ("G", h // hb)],
template=[
("T", input_type),
("U", state_type),
("Dh", d),
("Ds", ds),
("H", h),
("G", h // hb),
],
grid=(32, d, h * n),
threadgroup=(32, 8, 1),
output_shapes=[(n, 1, h, d), state.shape],
output_dtypes=[input_type, input_type],
output_dtypes=[input_type, state_type],
)
@@ -186,7 +195,7 @@ def ssm_attn(
mx.expand_dims(lengths < 0, (1, 2, 3)), state, next_state
)
return y, next_state
return y.astype(x.dtype), next_state
ys = []
for i in range(0, l, step):
+9 -2
View File
@@ -10,7 +10,7 @@ from mlx.nn.layers.distributed import shard_inplace, shard_linear, sum_gradients
from .activations import swiglu
from .base import BaseModelArgs, create_attention_mask, scaled_dot_product_attention
from .cache import KVCache
from .cache import KVCache, RotatingKVCache
from .rope_utils import initialize_rope
from .switch_layers import SwiGLU, SwitchGLU
@@ -394,7 +394,14 @@ class Model(nn.Module):
return self.model.layers
def make_cache(self):
return [KVCache() for _ in self.layers]
return [
(
RotatingKVCache(max_size=self.args.sliding_window)
if layer.is_sliding
else KVCache()
)
for layer in self.layers
]
def sanitize(self, weights):
remappings = [
+7 -1
View File
@@ -106,6 +106,11 @@ def main():
required=True,
help="Path to model or Hugging Face model ID",
)
parser.add_argument(
"--trust-remote-code",
action="store_true",
help="Enable trusting remote code for tokenizer/model loading from Hugging Face.",
)
parser.add_argument(
"--batch-size", type=int, default=8, help="Batch size for evaluation"
)
@@ -139,7 +144,8 @@ def main():
# Load model
print(f"Loading model from {args.model}...")
model, tokenizer = load(args.model)
tokenizer_config = {"trust_remote_code": True if args.trust_remote_code else None}
model, tokenizer = load(args.model, tokenizer_config=tokenizer_config)
# Count parameters
total_params = get_total_parameters(model)
+6 -2
View File
@@ -314,7 +314,11 @@ def main():
if args.target_dir is not None:
target_dir = Path(args.target_dir)
has_targets = target_dir.exists()
has_targets = (
target_dir.is_dir()
and any((target_dir / "train").glob("*.safetensors"))
and any((target_dir / "valid").glob("*.safetensors"))
)
else:
has_targets = False
target_dir = None
@@ -383,7 +387,7 @@ def main():
del model
if mx.metal.is_available():
max_rec_size = mx.metal.device_info()["max_recommended_working_set_size"]
max_rec_size = mx.device_info()["max_recommended_working_set_size"]
mx.set_wired_limit(max_rec_size)
opt = optimizers.Adam(learning_rate=args.learning_rate, bias_correction=True)
+95 -37
View File
@@ -73,15 +73,28 @@ def make_logits_processors(
logit_bias: Optional[Dict[int, float]] = None,
repetition_penalty: Optional[float] = None,
repetition_context_size: Optional[int] = 20,
presence_penalty: Optional[float] = None,
presence_context_size: Optional[int] = 20,
frequency_penalty: Optional[float] = None,
frequency_context_size: Optional[int] = 20,
):
"""
Make logits processors for use with ``generate_step``.
Args:
repetition_penalty (float, optional): The penalty factor for repeating
tokens.
repetition_penalty (float, optional): A (sign-aware) multiplicative
penalty for repeating tokens.
repetition_context_size (int, optional): The number of tokens to
consider for repetition penalty. Default: ``20``.
presence_penalty (float, optional): An additive penalty to reduce
repeating tokens.
presence_context_size (int, optional): The number of tokens to consider
for the presence penalty. Default: ``20``.
frequency_penalty (float, optional): An additive penalty to reduce
repeating tokens. The tokens are penalized proportionally to their
frequency.
frequency_context_size (int, optional): The number of tokens to consider
for the frequency penalty. Default: ``20``.
logit_bias (dictionary, optional): Additive logit bias.
Returns:
@@ -96,15 +109,20 @@ def make_logits_processors(
values = mx.array(list(logit_bias.values()))
def logit_bias_processor(_, logits):
logits[:, indices] += values
return logits
return logits.at[:, indices].add(values)
logits_processors.append(logit_bias_processor)
if repetition_penalty and repetition_penalty != 0.0:
logits_processors.append(
make_repetition_penalty(repetition_penalty, repetition_context_size)
)
repetition_penalties = [
(make_repetition_penalty, repetition_penalty, repetition_context_size),
(make_presence_penalty, presence_penalty, presence_context_size),
(make_frequency_penalty, frequency_penalty, frequency_context_size),
]
for make_penalty, penalty, context_size in repetition_penalties:
if penalty is not None and penalty != 0:
logits_processors.append(make_penalty(penalty, context_size))
return logits_processors
@@ -163,39 +181,24 @@ def apply_min_p(
raise ValueError(
f"`min_tokens_to_keep` has to be a positive integer, but is {min_tokens_to_keep}"
)
# reference implementation: https://github.com/huggingface/transformers/blob/main/src/transformers/generation/logits_process.py#L531-L605
# Indices sorted in decreasing order
sorted_indices = mx.argsort(-logprobs, axis=-1)
sorted_logprobs = mx.take_along_axis(logprobs, sorted_indices, axis=-1)
# Top probability
top_logprobs = sorted_logprobs[:, 0:1]
# Calculate the min_p threshold
# Mask tokens that have a probability less than the max(p) * min_p
top_logprobs = mx.max(logprobs, axis=-1, keepdims=True)
scaled_min_p = top_logprobs + math.log(min_p)
tokens_to_remove = logprobs < scaled_min_p
# Mask tokens that have a probability less than the scaled min_p
tokens_to_remove = sorted_logprobs < scaled_min_p
tokens_to_remove[..., :min_tokens_to_keep] = False
# Ensure at least min_tokens_to_keep survive the filter
if min_tokens_to_keep > 1:
top_indices = mx.argpartition(logprobs, kth=-min_tokens_to_keep, axis=-1)
top_indices = top_indices[..., -min_tokens_to_keep:]
tokens_to_remove = mx.put_along_axis(
tokens_to_remove,
top_indices,
False,
axis=-1,
)
# Create pool of tokens with probability less than scaled min_p
selected_logprobs = mx.where(tokens_to_remove, -float("inf"), sorted_logprobs)
# Create a mapping to rearrange back to original indices
inverse_indices = mx.put_along_axis(
mx.zeros_like(sorted_indices),
sorted_indices,
mx.arange(sorted_indices.shape[-1], dtype=sorted_indices.dtype),
axis=-1,
)
# Rearrange selected_logprobs back to original order
original_order_logprobs = mx.take_along_axis(
selected_logprobs, inverse_indices, axis=-1
)
return original_order_logprobs
return mx.where(tokens_to_remove, -float("inf"), logprobs)
@partial(mx.compile, inputs=mx.random.state, outputs=mx.random.state)
@@ -307,3 +310,58 @@ def make_repetition_penalty(penalty: float, context_size: int = 20):
return logits
return repetition_penalty_processor
def make_presence_penalty(penalty: float, context_size: int = 20):
"""
Make a presence penalty processor.
Corresponds to the OpenAI option with the same name. Namely, subtracts
``penalty`` from a logit if the token has occured at least once in the
``context_size`` previous tokens.
Args:
penalty (float): The presence penalty to be applied.
context_size (int): The number of previous tokens to use.
Default: ``20``.
Returns:
Callable[[mx.array, List[int]], mx.array]
"""
def presence_penalty_processor(tokens, logits):
if len(tokens) > 0:
tokens = tokens[-context_size:]
logits[:, tokens] -= penalty
return logits
return presence_penalty_processor
def make_frequency_penalty(penalty: float, context_size: int = 20):
"""
Make a frequency penalty processor.
Corresponds to the OpenAI option with the same name. Namely, subtracts
``penalty`` from a logit for every time that the token has occured in the
``context_size`` previous tokens.
The difference with the presence penalty is that the more often a token
occurs the more it will be penalized.
Args:
penalty (float): The frequency penalty to be applied.
context_size (int): The number of previous tokens to use.
Default: ``20``.
Returns:
Callable[[mx.array, List[int]], mx.array]
"""
def frequency_penalty_processor(tokens, logits):
if len(tokens) > 0:
tokens = tokens[-context_size:]
logits = logits.at[:, tokens].subtract(penalty)
return logits
return frequency_penalty_processor
+694 -638
View File
File diff suppressed because it is too large Load Diff
+290
View File
@@ -0,0 +1,290 @@
# Copyright © 2026 Apple Inc.
import argparse
import os
import pickle
import sys
import time
from dataclasses import dataclass
from functools import partial, total_ordering
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Literal, Optional
import mlx.core as mx
from huggingface_hub.errors import LocalEntryNotFoundError
from mlx._distributed_utils.common import Hostfile
from mlx._distributed_utils.launch import launch_jaccl, launch_ring
from tqdm import tqdm
from .utils import hf_repo_to_path
CHUNK_SIZE = 100 * 1024 * 1024
@total_ordering
@dataclass
class DirectoryEntry:
entry_type: Literal["directory", "symlink", "file"]
path: str
dst: Optional[str]
def __lt__(self, other):
order_type = dict(directory=0, symlink=1, file=2)
o1 = order_type[self.entry_type]
o2 = order_type[other.entry_type]
return o1 < o2 or (o1 == o2 and self.path < other.path)
def __eq__(self, other):
return (
self.entry_type == other.entry_type
and self.path == other.path
and self.dst == other.dst
)
@classmethod
def from_path(cls, root, path):
entry_type = {
(True, False): "directory",
(False, True): "symlink",
(False, False): "file",
}[path.is_dir(), path.is_symlink()]
dst = path.readlink() if path.is_symlink() else None
return cls(entry_type, str(path.relative_to(root)), str(dst))
def error(*args, **kwargs):
kwargs["file"] = sys.stderr
print("\033[31m[ERROR]", *args, "\033[0m", **kwargs)
def launch(args):
if args.hostfile is None:
raise ValueError("No hostfile provided")
hostfile = Hostfile.from_file(args.hostfile)
if hostfile.backend == "":
raise ValueError("Backend needs to be defined in the hostfile.")
if len(hostfile.hosts) == 1:
raise ValueError("More than one node needs to be in the hostfile")
launch_args = argparse.Namespace(
backend=hostfile.backend,
cwd=str(Path.cwd()),
env=hostfile.envs,
verbose=False,
python=None,
starting_port=32323,
connections_per_ip=1,
)
cmd = [
sys.executable,
"-m",
"mlx_lm",
"share",
]
if args.path is not None:
cmd += ["--path", args.path]
if args.model is not None:
cmd += ["--model", args.model]
if args.tmpdir is not None:
cmd += ["--tmpdir", args.tmpdir]
if args.dst is not None:
cmd += ["--dst", args.dst]
if hostfile.backend == "ring":
launch_ring(None, hostfile.hosts, launch_args, cmd)
elif hostfile.backend == "jaccl" or hostfile.backend == "jaccl-ring":
launch_jaccl(None, hostfile.hosts, launch_args, cmd)
else:
raise ValueError("Only ring, jaccl and jaccl-ring backends are supported.")
def get_files(path):
if not path.is_dir():
return path.parent, [DirectoryEntry.from_path(path.parent, path)]
files = [DirectoryEntry.from_path(path, f) for f in path.rglob("*")]
return path, sorted(files)
def format_bw(x):
if x >= 1e9:
return f"{x / 1e9:.2} GB/s"
if x >= 1e6:
return f"{x / 1e6:.2} MB/s"
if x >= 1e3:
return f"{x / 1e3:.2} KB/s"
return f"{x:.2} B/s"
def share_file(path, file, src, group=None):
group = group or mx.distributed.init()
all_sum = partial(mx.distributed.all_sum, group=group)
total_size = 0
start_time = time.time()
if group.rank() == src:
with open(path / file, "rb") as f:
f.seek(0, 2)
total_size = f.tell()
f.seek(0)
pbar = tqdm(
total=total_size,
unit="B",
unit_scale=True,
desc=file,
position=1,
leave=False,
)
while True:
data = f.read(CHUNK_SIZE)
if not data:
mx.eval(all_sum(0))
break
mx.eval(all_sum(len(data)))
mx.async_eval(all_sum(data))
pbar.update(len(data))
pbar.close()
else:
with open(path / file, "wb") as f:
data = None
chunk_size = all_sum(0).item()
if chunk_size > 0:
data = all_sum(mx.zeros(chunk_size, dtype=mx.uint8))
mx.eval(data)
while chunk_size > 0:
next_data = None
chunk_size = all_sum(0).item()
if chunk_size > 0:
next_data = all_sum(mx.zeros(chunk_size, dtype=mx.uint8))
mx.async_eval(next_data)
f.write(bytes(data))
data = next_data
return total_size, time.time() - start_time
def share_files(path, files, src, group=None):
group = group or mx.distributed.init()
all_sum = partial(mx.distributed.all_sum, group=group)
if group.rank() == src:
# Share the list first
file_list = pickle.dumps(files)
mx.eval(all_sum(len(file_list)))
mx.eval(all_sum(file_list))
else:
# Get the list first
file_list_size = all_sum(0).item()
data = all_sum(mx.zeros(file_list_size, dtype=mx.uint8))
files = pickle.loads(bytes(data))
# Make the directories and symlinks
for file in files:
if file.entry_type == "directory":
(path / file.path).mkdir()
elif file.entry_type == "symlink":
(path / file.path).symlink_to(file.dst)
# Everybody shares the files
total_size = 0
total_time = 1e-6
pbar = tqdm(total=len(files), desc="Files", position=0, disable=group.rank() != src)
for file in files:
if file.entry_type == "file":
s, t = share_file(path, file.path, src, group)
total_size += s
total_time += t
pbar.update(1)
pbar.set_postfix(speed=format_bw(total_size / total_time))
pbar.close()
def main():
parser = argparse.ArgumentParser(
description="Distribute a model to other nodes using MLX distributed."
)
parser.add_argument("--path", type=str, help="Path to a file or folder to share.")
parser.add_argument(
"--model", type=str, help="The path to a local model or Hugging Face repo"
)
parser.add_argument(
"--hostfile",
type=str,
help="The file containing the hosts and connection information",
)
parser.add_argument(
"--dst",
type=str,
help="The destination path in other nodes (defaults to --path or --model)",
)
parser.add_argument(
"--tmpdir",
type=str,
help="Intermediate temporary directory to ensure successfull transfer",
)
args = parser.parse_args()
if args.path is args.model is None:
parser.error("One of --path or --model must be provided")
mx.set_default_device(mx.cpu)
world = mx.distributed.init()
if world.size() == 1:
launch(args)
return
# Check if any node has the data
path = None
files = []
if args.path is not None and (path := Path(args.path)).exists():
path, files = get_files(path)
elif args.model is not None:
try:
path = hf_repo_to_path(args.model)
if path.parent.name != "snapshots":
raise ValueError(
f"The model repository appears to be corrupted, it resolved to {str(path)}"
)
path, files = get_files(path.parent.parent)
except Exception as e:
pass
has_file = mx.distributed.all_gather(len(files) > 0)
src = has_file.argmax().item()
has_file = has_file.any().item()
if not has_file:
error("The --path needs to exist in at least one node.")
error("If it is a remote repository download it first with `hf download`")
sys.exit(1)
# Share the path that is resolved
if args.dst is None:
if world.rank() == src:
data = str(path).encode("utf-8")
mx.eval(mx.distributed.all_sum(len(data)))
mx.eval(mx.distributed.all_sum(data))
else:
data_size = mx.distributed.all_sum(0).item()
data = mx.distributed.all_sum(mx.zeros(data_size, dtype=mx.uint8))
path = Path(bytes(data).decode("utf-8"))
elif world.rank() != src:
path = Path(args.dst)
with TemporaryDirectory(dir=args.tmpdir) as tmp:
if world.rank() == src:
share_files(path, files, src, world)
else:
share_files(Path(tmp), files, src, world)
path.mkdir(parents=True, exist_ok=True)
os.rename(tmp, path)
+111 -27
View File
@@ -253,6 +253,37 @@ class BPEStreamingDetokenizer(StreamingDetokenizer):
cls._byte_decoder = char_to_bytes
def _infer_thinking(tokenizer):
vocab = tokenizer.get_vocab()
THINK_TOKENS = [
("<think>", "</think>"),
("<longcat_think>", "</longcat_think>"),
]
# Single token thinking modes
for think_start, think_end in THINK_TOKENS:
if think_start in vocab and think_end in vocab:
return (
think_start,
think_end,
(vocab[think_start],),
(vocab[think_end],),
)
# Multi token thinking modes
if "<|channel>" in vocab and "<channel|>" in vocab:
think_start = "<|channel>thought"
think_end = "<channel|>"
return (
think_start,
think_end,
tuple(tokenizer.encode(think_start, add_special_tokens=False)),
tuple(tokenizer.encode(think_end, add_special_tokens=False)),
)
return (None, None, None, None)
class TokenizerWrapper:
"""A wrapper that combines an HF tokenizer and a detokenizer.
@@ -277,10 +308,12 @@ class TokenizerWrapper:
if eos_token_ids is not None
else {tokenizer.eos_token_id}
)
self._think_start = None
self._think_end = None
self._think_start_id = None
self._think_end_id = None
(
self._think_start,
self._think_end,
self._think_start_tokens,
self._think_end_tokens,
) = _infer_thinking(tokenizer)
self._chat_template = chat_template
self.has_chat_template = (
@@ -289,29 +322,20 @@ class TokenizerWrapper:
self._tool_parser = tool_parser
self._tool_call_start = tool_call_start
self._tool_call_end = tool_call_end
vocab = tokenizer.get_vocab()
THINK_TOKENS = [
("<think>", "</think>"),
("<longcat_think>", "</longcat_think>"),
]
for think_start, think_end in THINK_TOKENS:
if think_start in vocab and think_end in vocab:
self._think_start = think_start
self._think_end = think_end
self._think_start_id = vocab[think_start]
self._think_end_id = vocab[think_end]
break
# Disable tool calling if tool call tokens aren't in vocab
if (tool_call_start and tool_call_start not in vocab) or (
tool_call_end and tool_call_end not in vocab
):
self._tool_call_start = None
self._tool_call_end = None
self._tool_parser = None
self._tool_call_start_tokens = None
self._tool_call_end_tokens = None
if tool_call_start is not None:
self._tool_call_start_tokens = tuple(
tokenizer.encode(tool_call_start, add_special_tokens=False)
)
self._tool_call_end_tokens = tuple(
tokenizer.encode(tool_call_end, add_special_tokens=False)
)
def apply_chat_template(self, *args, tokenize=True, **kwargs):
if "enable_thinking" not in kwargs:
kwargs["enable_thinking"] = self.has_thinking
if self._chat_template is not None:
out = self._chat_template(*args, **kwargs)
if tokenize:
@@ -333,6 +357,36 @@ class TokenizerWrapper:
self._eos_token_ids.add(token_id)
def _find(self, tokens, sequence, start=None, end=None, reverse=False):
start = start or 0
end = end or len(tokens)
outer_loop = (
range(end - len(sequence), start - 1, -1)
if reverse
else range(start, end - len(sequence) + 1)
)
for i in outer_loop:
if tokens[i] == sequence[0]:
if all(tokens[i + j] == sequence[j] for j in range(1, len(sequence))):
return i
return -1
def find_think_start(self, tokens, start=None, end=None):
return self._find(tokens, self._think_start_tokens, start=start, end=end)
def rfind_think_start(self, tokens, start=None, end=None):
return self._find(
tokens, self._think_start_tokens, start=start, end=end, reverse=True
)
def find_think_end(self, tokens, start=None, end=None):
return self._find(tokens, self._think_end_tokens, start=start, end=end)
def rfind_think_end(self, tokens, start=None, end=None):
return self._find(
tokens, self._think_end_tokens, start=start, end=end, reverse=True
)
@property
def has_thinking(self):
return self._think_start is not None
@@ -343,7 +397,15 @@ class TokenizerWrapper:
@property
def think_start_id(self):
return self._think_start_id
if self._think_start_tokens is None:
return None
if len(self._think_start_tokens) > 1:
raise ValueError("The start thinking sequence is more than 1 token")
return self._think_start_tokens[0]
@property
def think_start_tokens(self):
return self._think_start_tokens
@property
def think_end(self):
@@ -351,7 +413,15 @@ class TokenizerWrapper:
@property
def think_end_id(self):
return self._think_end_id
if self._think_end_tokens is None:
return None
if len(self._think_end_tokens) > 1:
raise ValueError("The end thinking sequence is more than 1 token")
return self._think_end_tokens[0]
@property
def think_end_tokens(self):
return self._think_end_tokens
@property
def has_tool_calling(self):
@@ -361,10 +431,18 @@ class TokenizerWrapper:
def tool_call_start(self):
return self._tool_call_start
@property
def tool_call_start_tokens(self):
return self._tool_call_start_tokens
@property
def tool_call_end(self):
return self._tool_call_end
@property
def tool_call_end_tokens(self):
return self._tool_call_end_tokens
@property
def tool_parser(self):
return self._tool_parser
@@ -473,12 +551,16 @@ def _infer_tool_parser(chat_template):
return None
elif "<minimax:tool_call>" in chat_template:
return "minimax_m2"
elif "<|tool_call>" in chat_template and "<tool_call|>" in chat_template:
return "gemma4"
elif "<start_function_call>" in chat_template:
return "function_gemma"
elif "<longcat_tool_call>" in chat_template:
return "longcat"
elif "<arg_key>" in chat_template:
return "glm47"
elif "<|tool_list_start|>" in chat_template:
return "pythonic"
elif (
"<tool_call>\\n<function=" in chat_template
or "<tool_call>\n<function=" in chat_template
@@ -486,6 +568,8 @@ def _infer_tool_parser(chat_template):
return "qwen3_coder"
elif "<|tool_calls_section_begin|>" in chat_template:
return "kimi_k2"
elif "[TOOL_CALLS]" in chat_template:
return "mistral"
elif "<tool_call>" in chat_template and "tool_call.name" in chat_template:
return "json_tools"
return None
+65
View File
@@ -0,0 +1,65 @@
# Copyright © 2025 Apple Inc.
import json
from typing import Any, Optional
import regex as re
# Matches <|"|>...<|"|> string literals (Gemma 4's string delimiter).
_GEMMA4_STR = r'<\|"\|>(?:(?!<\|"\|>)[\s\S])*?<\|"\|>'
# Matches call:name{...} with balanced braces via the regex module's
# recursive (?R)-style support. The inner alternatives handle:
# [^{}<] any char that is not a brace or start of <|"|>
# <(?!\|"\|>) a lone '<' that is NOT the start of <|"|>
# <|"|>...<|"|> a complete string literal (braces inside are ignored)
# (?2) recursively balanced nested brace group
_tool_call_regex = re.compile(
r"call:([\w-]+)(\{(?:[^{}<]|<(?!\|\"\|>)|" + _GEMMA4_STR + r"|(?2))*\})",
re.DOTALL,
)
def _gemma4_args_to_json(text: str) -> str:
"""Convert Gemma 4 tool call args to valid JSON.
Gemma 4 uses unquoted keys and <|"|> as string delimiters
instead of standard double quotes.
"""
strings = []
def _capture(m):
strings.append(m.group(1))
return f"\x00{len(strings) - 1}\x00"
# Extract <|"|>-delimited strings and replace with placeholders
text = re.sub(r'<\|"\|>(.*?)<\|"\|>', _capture, text, flags=re.DOTALL)
# Quote bare keys
text = re.sub(r"(?<=[{,])(\w+):", r'"\1":', text)
# Restore captured strings as properly escaped JSON strings
for i, s in enumerate(strings):
text = text.replace(f"\x00{i}\x00", json.dumps(s))
return text
def _parse_single(match: re.Match) -> dict:
"""Parse a single call:name{args} regex match into a tool call dict."""
func_name = match.group(1)
args_str = match.group(2)
json_str = _gemma4_args_to_json(args_str)
arguments = json.loads(json_str)
return dict(name=func_name, arguments=arguments)
def parse_tool_call(text: str, _: Optional[Any] = None):
matches = list(_tool_call_regex.finditer(text))
if not matches:
raise ValueError("No function provided.")
if len(matches) == 1:
return _parse_single(matches[0])
return [_parse_single(m) for m in matches]
tool_call_start = "<|tool_call>"
tool_call_end = "<tool_call|>"
+31 -30
View File
@@ -157,43 +157,44 @@ def _get_param_types_from_config(param_name: str, param_config: dict) -> list[st
def parse_tool_call(text: str, tools: list | None = None):
invoke_match = _invoke_complete_regex.findall(text)
if not invoke_match:
invoke_matches = _invoke_complete_regex.findall(text)
if not invoke_matches:
raise ValueError("No tool call found")
invoke_text = invoke_match[0]
name_match = re.search(r"^([^>]+)", invoke_text)
if not name_match:
return None
function_name = _extract_name(name_match.group(1))
# Get parameter configuration
param_config = {}
param_config_for = {}
if tools:
for tool in tools:
if func := tool.get("function", False):
if func["name"] != function_name:
continue
if params := func.get("parameters", False):
param_config = params.get("properties", {})
param_config_for[func["name"]] = params.get("properties", {})
# Extract parameters
param_dict = {}
for match in _parameter_complete_regex.findall(invoke_text):
param_match = re.search(r"^([^>]+)>(.*)", match, re.DOTALL)
if param_match:
param_name = _extract_name(param_match.group(1))
param_value = param_match.group(2).strip()
if param_value.startswith("\n"):
param_value = param_value[1:]
if param_value.endswith("\n"):
param_value = param_value[:-1]
calls = []
for invoke_text in invoke_matches:
name_match = re.search(r"^([^>]+)", invoke_text)
if not name_match:
continue
function_name = _extract_name(name_match.group(1))
param_config = param_config_for.get(function_name, {})
param_type = _get_param_types_from_config(param_name, param_config)
param_dict = {}
for match in _parameter_complete_regex.findall(invoke_text):
param_match = re.search(r"^([^>]+)>(.*)", match, re.DOTALL)
if param_match:
param_name = _extract_name(param_match.group(1))
param_value = param_match.group(2).strip()
if param_value.startswith("\n"):
param_value = param_value[1:]
if param_value.endswith("\n"):
param_value = param_value[:-1]
param_dict[param_name] = _convert_param_value_with_types(
param_value, param_type
)
param_type = _get_param_types_from_config(param_name, param_config)
return dict(name=function_name, arguments=param_dict)
param_dict[param_name] = _convert_param_value_with_types(
param_value, param_type
)
calls.append(dict(name=function_name, arguments=param_dict))
if len(calls) == 1:
return calls[0]
return calls
+20
View File
@@ -0,0 +1,20 @@
# Copyright © 2026 Apple Inc.
import json
from typing import Any
import regex as re
_tool_call_regex = re.compile(r"\s*(\w+)\[ARGS\]\s*(\{.*\})", re.DOTALL)
tool_call_start = "[TOOL_CALLS]"
tool_call_end = ""
def parse_tool_call(text: str, tools: Any | None = None):
match = _tool_call_regex.search(text)
if match is None:
raise ValueError(f"Could not parse tool call from: {text}")
func_name = match.group(1)
func_args = json.loads(match.group(2))
return dict(name=func_name, arguments=func_args)
+49
View File
@@ -0,0 +1,49 @@
# Copyright © 2026 Apple Inc.
import ast
from typing import Any, Dict, List
import regex as re
"""
Tool parser for Pythonic function call formats.
Parses assistant responses containing tool calls in formats like:
<|tool_call_start|>[function_name(arg1="value1", arg2=2)]<|tool_call_end|>
"""
_tool_call_regex = re.compile(r"\[(\w+)\((.*?)\)\]", re.DOTALL)
_tool_args_regex = re.compile(r'(\w+)=(?:"([^"]*)"|([^,]+))(?:,\s*|$)', re.DOTALL)
def parse_tool_call(text: str, tools: Any | None = None):
match = _tool_call_regex.search(text)
if not match:
raise ValueError("No function provided.")
func_name = match.group(1)
args_str = match.group(2)
arguments = {}
if args_str:
matches = _tool_args_regex.findall(args_str)
for pair in matches:
key = pair[0].strip()
# pair[1] is quoted value, pair[2] is unquoted value
value = pair[1] if pair[1] else pair[2].strip()
# Try to parse the value using ast.literal_eval
try:
value = ast.literal_eval(value)
except (ValueError, SyntaxError):
# If parsing fails, keep as string
pass
arguments[key] = value
return dict(name=func_name, arguments=arguments)
tool_call_start = "<|tool_call_start|>"
tool_call_end = "<|tool_call_end|>"
+5 -1
View File
@@ -4,6 +4,7 @@
Modified from:
https://huggingface.co/Qwen/Qwen3-Coder-30B-A3B-Instruct/blob/main/qwen3coder_tool_parser.py
"""
import ast
import json
from typing import Any, Optional
@@ -70,7 +71,10 @@ def _convert_param_value(param_value: str, param_name: str, param_config: dict)
or param_type.startswith("dict")
or param_type.startswith("list")
):
return json.loads(param_value)
try:
return json.loads(param_value)
except json.JSONDecodeError:
return ast.literal_eval(param_value)
return ast.literal_eval(param_value)
+3 -3
View File
@@ -116,7 +116,7 @@ class CompletionsDataset:
if self.mask_prompt:
offset = len(
self.tokenizer.apply_chat_template(
messages[0],
messages[:-1],
tools=tools,
add_generation_prompt=True,
return_dict=False,
@@ -322,8 +322,8 @@ def load_dataset(args, tokenizer: PreTrainedTokenizer):
"Training set not found or empty. Must provide training set for fine-tuning."
)
if args.train and len(valid) == 0:
raise ValueError(
"Validation set not found or empty. Must provide validation set for fine-tuning."
print(
"Warning: Validation set not found or empty. Training will proceed without validation."
)
if args.test and len(test) == 0:
raise ValueError(
+21 -4
View File
@@ -17,6 +17,11 @@ from .callbacks import TrainingCallback
from .datasets import CacheDataset
def _clear_cache(threshold: int):
if mx.get_cache_memory() > threshold:
mx.clear_cache()
def grad_checkpoint(layer):
"""
Update all instances of type(layer) to use gradient checkpointing.
@@ -70,6 +75,12 @@ class TrainingArgs:
"help": "Number of steps to accumulate gradients before applying an optimizer update."
},
)
clear_cache_threshold: int = field(
default=0,
metadata={
"help": "Clear the allocator cache between steps if it grows too large."
},
)
def default_loss(model, batch, lengths):
@@ -170,6 +181,7 @@ def evaluate(
max_seq_length=2048,
loss: callable = default_loss,
iterate_batches: callable = iterate_batches,
clear_cache_threshold: int = 0,
):
model.eval()
all_losses = mx.array(0.0)
@@ -194,25 +206,27 @@ def evaluate(
all_losses += losses * toks
ntokens += toks
mx.eval(all_losses, ntokens)
_clear_cache(clear_cache_threshold)
all_losses = mx.distributed.all_sum(all_losses, stream=mx.cpu)
ntokens = mx.distributed.all_sum(ntokens, stream=mx.cpu)
avg_loss = (all_losses / ntokens).item()
return (all_losses / ntokens).item()
return avg_loss
def train(
model,
optimizer,
train_dataset,
val_dataset,
val_dataset=None,
args: TrainingArgs = TrainingArgs(),
loss: callable = default_loss,
iterate_batches: callable = iterate_batches,
training_callback: TrainingCallback = None,
):
if mx.metal.is_available():
mx.set_wired_limit(mx.metal.device_info()["max_recommended_working_set_size"])
mx.set_wired_limit(mx.device_info()["max_recommended_working_set_size"])
print(f"Starting training..., iters: {args.iters}")
world = mx.distributed.init()
world_size = world.size()
@@ -269,7 +283,9 @@ def train(
tic = time.perf_counter()
# Report validation loss if needed, the first validation loss
# is always measured before any training.
if it == 1 or it % args.steps_per_eval == 0 or it == args.iters:
if val_dataset and (
it == 1 or it % args.steps_per_eval == 0 or it == args.iters
):
tic = time.perf_counter()
val_loss = evaluate(
model=model,
@@ -310,6 +326,7 @@ def train(
n_tokens += toks
steps += 1
mx.eval(state, losses, n_tokens, grad_accum)
_clear_cache(args.clear_cache_threshold)
train_time += time.perf_counter() - tic
# Report training loss if needed
+17 -2
View File
@@ -47,6 +47,7 @@ MODEL_REMAPPING = {
"llava": "mistral3",
"phi-msft": "phixtral",
"falcon_mamba": "mamba",
"joyai_llm_flash": "deepseek_v3",
"kimi_k2": "deepseek_v3",
"qwen2_5_vl": "qwen2_vl",
"minimax_m2": "minimax",
@@ -56,6 +57,18 @@ MODEL_REMAPPING = {
MAX_FILE_SIZE_GB = 5
def _parse_size(x):
sizes = {"M": 1e6, "G": 1e9, "MB": 1e6, "GB": 1e9, "": 1}
split = 0
for xi in x:
if not (xi.isdigit() or xi == "."):
break
split += 1
digits = float(x[:split])
size = (x[split:]).strip().upper()
return int(digits * sizes[size])
def _unpack_awq_weights(qweight: mx.array) -> mx.array:
bits = 4
pack_factor = 32 // bits
@@ -494,6 +507,8 @@ def sharded_load(
pipeline_group: Optional[mx.distributed.Group] = None,
tensor_group: Optional[mx.distributed.Group] = None,
return_config: bool = False,
*,
tokenizer_config: Optional[Dict[str, Any]] = None,
):
# Get model path with everything but weight safetensors
model_path = _download(
@@ -514,7 +529,7 @@ def sharded_load(
# weights we need to download.
model, config = load_model(model_path, lazy=True, strict=False)
has_pipelining = hasattr(model.model, "pipeline")
has_pipelining = hasattr(model, "model") and hasattr(model.model, "pipeline")
has_tensor_parallel = hasattr(model, "shard")
if pipeline_group is not None and not has_pipelining:
@@ -558,7 +573,7 @@ def sharded_load(
# Load and shard the model, and load the weights
tokenizer = load_tokenizer(
model_path,
{"trust_remote_code": True},
tokenizer_config or {"trust_remote_code": True},
eos_token_ids=config.get("eos_token_id", None),
)
model, _ = load_model(model_path, lazy=True, strict=False)
+2 -1
View File
@@ -10,7 +10,7 @@ sys.path.append(str(package_dir))
from _version import __version__
MIN_MLX_VERSION = "0.30.4"
MIN_MLX_VERSION = "0.31.2"
setup(
name="mlx-lm",
@@ -66,6 +66,7 @@ setup(
"mlx_lm.lora = mlx_lm.lora:main",
"mlx_lm.perplexity = mlx_lm.perplexity:main",
"mlx_lm.server = mlx_lm.server:main",
"mlx_lm.share = mlx_lm.share:main",
"mlx_lm.manage = mlx_lm.manage:main",
"mlx_lm.upload = mlx_lm.upload:main",
]
+31
View File
@@ -61,6 +61,37 @@ class TestDatasets(unittest.TestCase):
self.assertTrue(len(valid[0]) > 0)
self.assertTrue(isinstance(train, datasets.CompletionsDataset))
def test_completions_mask_prompt(self):
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, mask_prompt=True
)
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)
self.assertEqual(len(test), 0)
expected_prompt_tokens = tokenizer.apply_chat_template(
[{"role": "user", "content": data["prompt"]}],
add_generation_prompt=True,
return_dict=False,
)
expected_offset = len(expected_prompt_tokens)
train_tokens, train_offset = train.process(train[0])
valid_tokens, valid_offset = valid.process(valid[0])
self.assertTrue(len(train_tokens) > 0)
self.assertTrue(len(valid_tokens) > 0)
self.assertEqual(train_offset, expected_offset)
self.assertEqual(valid_offset, expected_offset)
self.assertLess(train_offset, len(train_tokens))
self.assertLess(valid_offset, len(valid_tokens))
self.assertEqual(train_tokens[:train_offset], expected_prompt_tokens)
self.assertEqual(valid_tokens[:valid_offset], expected_prompt_tokens)
self.assertTrue(isinstance(train, datasets.CompletionsDataset))
def test_chat(self):
data = {
"messages": [
+187 -13
View File
@@ -9,12 +9,13 @@ import mlx.core as mx
from mlx_lm.generate import (
BatchGenerator,
GenerationResponse,
SequenceStateMachine,
batch_generate,
generate,
generate_step,
stream_generate,
)
from mlx_lm.models.cache import RotatingKVCache
from mlx_lm.models.cache import KVCache, RotatingKVCache
from mlx_lm.sample_utils import make_logits_processors, make_sampler
from mlx_lm.utils import load
@@ -199,7 +200,7 @@ class TestGenerate(unittest.TestCase):
self.model, stop_tokens=self.tokenizer.eos_token_ids, max_tokens=1
)
uids = gen.insert(prompts)
batch_responses = {r.uid: r for r in gen.next()}
batch_responses = {r.uid: r for r in gen.next_generated()}
# Do a test for each prompt the logits are close
for e, prompt in enumerate(prompts):
@@ -241,7 +242,7 @@ class TestGenerate(unittest.TestCase):
batch_responses = {}
not_in = True
iters = 0
while responses := gen.next():
while responses := gen.next_generated():
for r in responses:
not_in &= r.uid not in batch_responses
batch_responses[r.uid] = r
@@ -289,7 +290,7 @@ class TestGenerate(unittest.TestCase):
num_toks = [2, 3, 4, 5]
uids = gen.insert(prompts, max_tokens=num_toks)
batch_responses = {uid: [] for uid in uids}
while responses := gen.next():
while responses := gen.next_generated():
for r in responses:
batch_responses[r.uid].append(r.token)
@@ -337,7 +338,7 @@ class TestGenerate(unittest.TestCase):
)
uids = batch_gen.insert(prompts)
batch_responses = {uid: [] for uid in uids}
while responses := batch_gen.next():
while responses := batch_gen.next_generated():
for r in responses:
batch_responses[r.uid].append(r.logprobs)
@@ -370,7 +371,7 @@ class TestGenerate(unittest.TestCase):
)
prompt = self.tokenizer.encode("hello")
uids = batch_gen.insert([prompt])
response = batch_gen.next()[0]
response = batch_gen.next_generated()[0]
logprobs = response.logprobs
self.assertEqual(logprobs[0].item(), 0.0)
self.assertEqual(logprobs.argmin().item(), 1)
@@ -395,12 +396,48 @@ class TestGenerate(unittest.TestCase):
processors = make_logits_processors(logit_bias)
(uid2,) = batch_gen.insert([prompt], logits_processors=[processors])
responses = batch_gen.next()
responses = batch_gen.next_generated()
responses = {response.uid: response for response in responses}
self.assertEqual(responses[uid0].logprobs[0].item(), 0.0)
self.assertEqual(responses[uid1].logprobs[1].item(), 0.0)
self.assertEqual(responses[uid2].logprobs[2].item(), 0.0)
def test_batch_generate_processor_tokens_match_prompt_on_first_step(self):
prompt = self.tokenizer.encode("hello")
seen = []
def processor(tokens, logits):
seen.append(tokens)
return logits
batch_gen = BatchGenerator(
self.model,
max_tokens=1,
logits_processors=[processor],
)
batch_gen.insert([prompt])
batch_gen.next_generated()
self.assertTrue(hasattr(seen[0], "shape"))
self.assertEqual(seen[0].tolist(), prompt)
def test_batch_generate_function_with_logits_processors(self):
"""Test that batch_generate function with logits_processors produces correct results."""
logit_bias = {0: 2000.0, 1: -2000.0}
processors = make_logits_processors(logit_bias)
prompts = [self.tokenizer.encode("hello")]
response = batch_generate(
self.model,
self.tokenizer,
prompts,
max_tokens=1,
logits_processors=processors,
)
self.assertEqual(len(response.texts), 1)
generated_token = self.tokenizer.encode(response.texts[0])[0]
self.assertEqual(generated_token, 0)
def test_batch_generate_with_samplers(self):
"""Test that batch_generate with logits_processors produces correct results."""
batch_gen = BatchGenerator(
@@ -410,7 +447,7 @@ class TestGenerate(unittest.TestCase):
)
prompt = self.tokenizer.encode("hello")
uids = batch_gen.insert([prompt])
response = batch_gen.next()[0]
response = batch_gen.next_generated()[0]
self.assertEqual(response.token, 1)
del batch_gen
@@ -427,12 +464,47 @@ class TestGenerate(unittest.TestCase):
samplers=[lambda _: mx.array([2]), lambda _: mx.array([3])],
)
responses = batch_gen.next()
responses = batch_gen.next_generated()
responses = {response.uid: response for response in responses}
self.assertEqual(responses[uid0].token, 1)
self.assertEqual(responses[uid1].token, 2)
self.assertEqual(responses[uid2].token, 3)
def test_batch_generate_with_state_machines(self):
"""Test that batch_generate with per-sequence state_machines stops on different tokens."""
batch_gen = BatchGenerator(
self.model,
max_tokens=10,
)
prompt = self.tokenizer.encode("hello")
sm_0 = SequenceStateMachine({"normal": [([0], None)]}, initial="normal")
sm_1 = SequenceStateMachine({"normal": [([1], None)]}, initial="normal")
sm_2 = SequenceStateMachine({"normal": [([2], None)]}, initial="normal")
processor_0 = make_logits_processors({0: 2000.0})
processor_1 = make_logits_processors({1: 2000.0})
processor_2 = make_logits_processors({2: 2000.0})
uid0, uid1, uid2 = batch_gen.insert(
[prompt, prompt, prompt],
logits_processors=[processor_0, processor_1, processor_2],
state_machines=[sm_0, sm_1, sm_2],
)
responses = batch_gen.next_generated()
responses = {response.uid: response for response in responses}
self.assertEqual(responses[uid0].token, 0)
self.assertEqual(responses[uid1].token, 1)
self.assertEqual(responses[uid2].token, 2)
self.assertEqual(responses[uid0].finish_reason, "stop")
self.assertEqual(responses[uid1].finish_reason, "stop")
self.assertEqual(responses[uid2].finish_reason, "stop")
self.assertEqual(responses[uid0].match_sequence, (0,))
self.assertEqual(responses[uid1].match_sequence, (1,))
self.assertEqual(responses[uid2].match_sequence, (2,))
def test_batch_continued_generation(self):
for rotating in [False, True]:
if rotating:
@@ -481,7 +553,7 @@ class TestGenerate(unittest.TestCase):
)
uids = batch_gen.insert(prompts_a)
caches = {uid: None for uid in uids}
while responses := batch_gen.next():
while responses := batch_gen.next_generated():
for r in responses:
if r.finish_reason is not None:
caches[r.uid] = r.prompt_cache
@@ -490,7 +562,7 @@ class TestGenerate(unittest.TestCase):
# Generate the 2nd time
uids = batch_gen.insert(prompts_b, caches=caches)
batch_responses = {uid: [] for uid in uids}
while responses := batch_gen.next():
while responses := batch_gen.next_generated():
for r in responses:
batch_responses[r.uid].append(r.logprobs)
@@ -543,7 +615,7 @@ class TestGenerate(unittest.TestCase):
uids = batch_gen.insert(prompts_a)
caches = {uid: None for uid in uids}
while responses := batch_gen.next():
while responses := batch_gen.next_generated():
for r in responses:
if r.finish_reason is not None:
caches[r.uid] = r.prompt_cache
@@ -553,7 +625,7 @@ class TestGenerate(unittest.TestCase):
# Generate the 2nd time
uids = batch_gen.insert(prompts_b, caches=caches)
batch_responses = {uid: [] for uid in uids}
while responses := batch_gen.next():
while responses := batch_gen.next_generated():
for r in responses:
batch_responses[r.uid].append(r.logprobs)
@@ -632,6 +704,108 @@ class TestGenerate(unittest.TestCase):
model = qwen3_next.Model(args)
self._continued_generation_test_helper(model)
def test_extend_cache_with_empty(self):
from mlx_lm.generate import _extend_cache
from mlx_lm.models.cache import make_prompt_cache
cache_a = make_prompt_cache(self.model)
prompt = mx.array([[1, 2, 3]])
self.model(prompt, cache=cache_a)
mx.eval([c.state for c in cache_a])
result = _extend_cache(cache_a, [])
self.assertEqual(len(result), len(cache_a))
for c in result:
self.assertGreater(c.offset, 0)
result = _extend_cache([], cache_a)
self.assertEqual(len(result), len(cache_a))
for c in result:
self.assertGreater(c.offset, 0)
def test_remove_prompt_batch_updates_currently_processing(self):
prompt_a = self.tokenizer.encode("Write a long story about a cat")
prompt_b = self.tokenizer.encode("Write a long story about a dog")
gen = BatchGenerator(
self.model,
max_tokens=5,
prefill_batch_size=2,
prefill_step_size=4,
completion_batch_size=4,
)
uid_a, uid_b = gen.insert([prompt_a, prompt_b])
gen.next()
found = gen._find_uids([uid_a, uid_b])
for uid in [uid_a, uid_b]:
self.assertIn(uid, found)
self.assertEqual(found[uid][0], 1)
gen.remove([uid_a])
self.assertEqual(len(gen._currently_processing), len(gen._prompt_batch))
found = gen._find_uids([uid_b])
self.assertIn(uid_b, found)
while responses := gen.next_generated():
if all(r.finish_reason is not None for r in responses):
break
def test_batch_max_kv_size_creates_rotating_cache(self):
max_kv_size = 256
gen = BatchGenerator(
self.model,
max_tokens=1,
max_kv_size=max_kv_size,
)
prompt = self.tokenizer.encode("Write a long story about a cat")
gen.insert([prompt])
for r in gen.next_generated():
if r.finish_reason is not None:
for cache in r.prompt_cache:
self.assertIsInstance(cache, RotatingKVCache)
self.assertEqual(cache.max_size, max_kv_size)
def test_batch_max_kv_size_limits_cache_growth(self):
max_kv_size = 5
gen = BatchGenerator(
self.model,
max_tokens=10,
max_kv_size=max_kv_size,
prefill_batch_size=1,
prefill_step_size=128,
completion_batch_size=1,
)
prompt = self.tokenizer.encode("Write a long story about a cat")
gen.insert([prompt])
for r in gen.next_generated():
if r.finish_reason is not None:
for cache in r.prompt_cache:
self.assertLessEqual(cache.keys.shape[2], max_kv_size)
def test_batch_max_kv_size_none_creates_regular_cache(self):
gen = BatchGenerator(
self.model,
max_tokens=1,
max_kv_size=None,
)
prompt = self.tokenizer.encode("Write a long story about a cat")
gen.insert([prompt])
for r in gen.next_generated():
if r.finish_reason is not None:
for cache in r.prompt_cache:
self.assertIsInstance(cache, KVCache)
if __name__ == "__main__":
unittest.main()
+3
View File
@@ -35,6 +35,9 @@ class TestConvertToGGUFWithoutMocks(unittest.TestCase):
mock_tokenizer.get_vocab.return_value = {"<pad>": 0, "hello": 1, "world": 2}
mock_tokenizer.all_special_tokens = ["<pad>"]
mock_tokenizer.all_special_ids = [0]
mock_tokenizer.bos_token_id = None
mock_tokenizer.eos_token_id = None
mock_tokenizer.unk_token_id = None
mock_from_pretrained.return_value = mock_tokenizer
model_path = Path(self.test_dir)
+11 -11
View File
@@ -13,21 +13,21 @@ class TestLosses(unittest.TestCase):
self.assertTrue(can_run_metal())
mx.random.seed(0)
logits_q = mx.random.uniform(shape=(4, 8, 4000), dtype=mx.float32)
logits_p = mx.random.uniform(shape=(4, 8, 4000), dtype=mx.float32)
logits_q = mx.random.normal((2, 4, 4000))
logits_p = mx.random.normal((2, 4, 4000))
with mx.stream(mx.cpu):
expected = kl_div_loss(logits_q, logits_p)
kl = kl_div_loss(logits_q, logits_p)
self.assertTrue(mx.allclose(kl, expected, rtol=1e-4))
self.assertTrue(mx.allclose(kl, expected))
def test_js_div_loss(self):
self.assertTrue(can_run_metal())
mx.random.seed(0)
logits_q = mx.random.uniform(shape=(4, 8, 4000), dtype=mx.float32)
logits_p = mx.random.uniform(shape=(4, 8, 4000), dtype=mx.float32)
logits_q = mx.random.normal((2, 4, 4000))
logits_p = mx.random.normal((2, 4, 4000))
with mx.stream(mx.cpu):
expected = js_div_loss(logits_q, logits_p)
@@ -39,9 +39,9 @@ class TestLosses(unittest.TestCase):
self.assertTrue(can_run_metal())
mx.random.seed(0)
logits_q = mx.random.uniform(shape=(4, 8, 4000), dtype=mx.float32)
logits_p = mx.random.uniform(shape=(4, 8, 4000), dtype=mx.float32)
cotan = mx.random.uniform(shape=(4, 8), dtype=mx.float32)
logits_q = mx.random.normal((2, 4, 4000))
logits_p = mx.random.normal((2, 4, 4000))
cotan = mx.random.normal((2, 4))
with mx.stream(mx.cpu):
expected = mx.vjp(kl_div_loss, [logits_q, logits_p], [cotan])[1][0]
@@ -53,9 +53,9 @@ class TestLosses(unittest.TestCase):
self.assertTrue(can_run_metal())
mx.random.seed(0)
logits_q = mx.random.uniform(shape=(4, 8, 4000), dtype=mx.float32)
logits_p = mx.random.uniform(shape=(4, 8, 4000), dtype=mx.float32)
cotan = mx.random.uniform(shape=(4, 8), dtype=mx.float32)
logits_q = mx.random.normal((2, 4, 4000))
logits_p = mx.random.normal((2, 4, 4000))
cotan = mx.random.normal((2, 4))
with mx.stream(mx.cpu):
expected = mx.vjp(js_div_loss, [logits_q, logits_p], [cotan])[1][0]
+755 -4
View File
@@ -5,12 +5,16 @@ import unittest
import mlx.core as mx
import mlx.nn as nn
from mlx.utils import tree_map
from mlx.utils import tree_flatten, tree_map
from mlx_lm.models import rope_utils
from mlx_lm.models.base import create_causal_mask, scaled_dot_product_attention
from mlx_lm.models.cache import KVCache, RotatingKVCache, make_prompt_cache
from mlx_lm.models.gated_delta import gated_delta_kernel, gated_delta_ops
from mlx_lm.models.gated_delta import (
gated_delta_kernel,
gated_delta_ops,
gated_delta_update,
)
from mlx_lm.models.ssm import ssm_attn, ssm_update
@@ -238,6 +242,67 @@ class TestModels(unittest.TestCase):
)
self.assertTrue(isinstance(rope, rope_utils.Llama3RoPE))
rope = rope_utils.initialize_rope(
16,
base=100.0,
traditional=False,
scaling_config={
"rope_type": "proportional",
"partial_rotary_factor": 0.5,
},
)
self.assertTrue(isinstance(rope, rope_utils.ProportionalRoPE))
expected_freqs = 100.0 ** (mx.arange(0, 8, 2, dtype=mx.float32) / 16)
self.assertTrue(mx.allclose(rope._freqs[:4], expected_freqs))
self.assertTrue(mx.all(mx.isinf(rope._freqs[4:])))
x = mx.arange(16, dtype=mx.float32).reshape(1, 1, 1, 16)
y = rope(x, offset=1)
expected_rotated = mx.fast.rope(
mx.concatenate([x[..., :4], x[..., 8:12]], axis=-1),
8,
traditional=False,
base=None,
scale=1.0,
offset=1,
freqs=expected_freqs,
)
expected = mx.concatenate(
[
expected_rotated[..., :4],
x[..., 4:8],
expected_rotated[..., 4:],
x[..., 12:],
],
axis=-1,
)
mx.eval(y, expected)
self.assertTrue(mx.allclose(y, expected))
def test_su_scaled_rope_no_mutation(self):
rope = rope_utils.SuScaledRoPE(
dims=8,
max_position_embeddings=131072,
original_max_position_embeddings=4096,
long_factor=[1.0] * 4,
)
x = mx.ones((1, 2, 4, 8))
rope(x)
mx.eval(x)
self.assertTrue((x == 1).all())
def test_yarn_rope_no_mutation(self):
rope = rope_utils.YarnRoPE(
dims=8,
scaling_factor=2.0,
mscale=1.0,
mscale_all_dim=0,
)
x = mx.ones((1, 2, 4, 8))
rope(x)
mx.eval(x)
self.assertTrue((x == 1).all())
def test_quantized_sdpa(self):
cache = KVCache()
@@ -531,6 +596,252 @@ class TestModels(unittest.TestCase):
model, args.model_type, args.vocab_size, args.num_hidden_layers
)
def test_qwen3_5_family_convert_then_load_norm_not_shift_twice(self):
text_config = {
"hidden_size": 8,
"intermediate_size": 16,
"num_hidden_layers": 1,
"num_attention_heads": 1,
"num_key_value_heads": 1,
"rms_norm_eps": 1e-5,
"vocab_size": 32,
"linear_num_value_heads": 1,
"linear_num_key_heads": 1,
"linear_key_head_dim": 4,
"linear_value_head_dim": 4,
"linear_conv_kernel_dim": 1,
"full_attention_interval": 1,
"tie_word_embeddings": False,
"max_position_embeddings": 64,
}
hf_norm_key = "model.language_model.layers.0.input_layernorm.weight"
mlx_norm_key = "language_model.model.layers.0.input_layernorm.weight"
for model_type, hf_mtp_key in (
("qwen3_5", "mtp.fc.weights"),
("qwen3_5_moe", "mtp.fc.weight"),
):
module = importlib.import_module(f"mlx_lm.models.{model_type}")
args = module.ModelArgs.from_dict(
{
"model_type": model_type,
"text_config": {"model_type": model_type, **text_config},
}
)
model = module.Model(args)
base = mx.arange(8, dtype=mx.float32)
# Simulate convert sanitize on HF-style keys.
converted = model.sanitize(
{
hf_norm_key: base,
hf_mtp_key: mx.zeros((1,), dtype=mx.float32),
}
)
self.assertIn(mlx_norm_key, converted)
self.assertTrue(mx.array_equal(converted[mlx_norm_key], base + 1.0))
self.assertFalse(any("mtp." in k for k in converted))
# Simulate load sanitize on already-converted keys.
loaded = model.sanitize(converted)
self.assertTrue(
mx.array_equal(loaded[mlx_norm_key], converted[mlx_norm_key])
)
def test_gemma4_convert_then_load_keeps_language_model_prefix(self):
from mlx_lm.models import gemma4
args = gemma4.ModelArgs.from_dict(
{
"model_type": "gemma4",
"vocab_size": 32,
"text_config": {
"model_type": "gemma4_text",
"hidden_size": 8,
"num_hidden_layers": 1,
"intermediate_size": 16,
"num_attention_heads": 1,
"num_key_value_heads": 1,
"num_global_key_value_heads": 1,
"head_dim": 8,
"global_head_dim": 8,
"sliding_window": 8,
"sliding_window_pattern": 1,
"layer_types": ["full_attention"],
"hidden_size_per_layer_input": 0,
"num_kv_shared_layers": 0,
"tie_word_embeddings": True,
},
}
)
model = gemma4.Model(args)
base = mx.arange(8, dtype=mx.float32)
hf_norm_key = "model.language_model.layers.0.input_layernorm.weight"
mlx_norm_key = "language_model.model.layers.0.input_layernorm.weight"
converted = model.sanitize(
{
hf_norm_key: base,
"model.vision_tower.stub": mx.zeros((1,), dtype=mx.float32),
}
)
self.assertIn(mlx_norm_key, converted)
self.assertNotIn(
"language_model.model.model.layers.0.input_layernorm.weight", converted
)
self.assertTrue(mx.array_equal(converted[mlx_norm_key], base))
self.assertFalse(any("vision_tower" in k for k in converted))
loaded = model.sanitize({mlx_norm_key: base})
self.assertIn(mlx_norm_key, loaded)
self.assertNotIn(
"language_model.model.model.layers.0.input_layernorm.weight", loaded
)
self.assertTrue(mx.array_equal(loaded[mlx_norm_key], base))
def test_gemma4_raw_hf_language_model_prefixes_model(self):
from mlx_lm.models import gemma4
args = gemma4.ModelArgs.from_dict(
{
"model_type": "gemma4",
"vocab_size": 32,
"text_config": {
"model_type": "gemma4_text",
"hidden_size": 8,
"num_hidden_layers": 1,
"intermediate_size": 16,
"num_attention_heads": 1,
"num_key_value_heads": 1,
"num_global_key_value_heads": 1,
"head_dim": 8,
"global_head_dim": 8,
"sliding_window": 8,
"sliding_window_pattern": 1,
"layer_types": ["full_attention"],
"hidden_size_per_layer_input": 0,
"num_kv_shared_layers": 0,
"tie_word_embeddings": True,
},
}
)
model = gemma4.Model(args)
base = mx.arange(8, dtype=mx.float32)
hf_norm_key = "model.language_model.layers.0.input_layernorm.weight"
mlx_norm_key = "language_model.model.layers.0.input_layernorm.weight"
converted = model.sanitize({hf_norm_key: base})
self.assertIn(mlx_norm_key, converted)
self.assertTrue(mx.array_equal(converted[mlx_norm_key], base))
def test_gemma4_raw_hf_moe_expert_weights_split_for_switch_glu(self):
from mlx_lm.models import gemma4
args = gemma4.ModelArgs.from_dict(
{
"model_type": "gemma4",
"vocab_size": 32,
"text_config": {
"model_type": "gemma4_text",
"hidden_size": 8,
"num_hidden_layers": 1,
"intermediate_size": 16,
"num_attention_heads": 1,
"num_key_value_heads": 1,
"num_global_key_value_heads": 1,
"head_dim": 8,
"global_head_dim": 8,
"sliding_window": 8,
"sliding_window_pattern": 1,
"layer_types": ["full_attention"],
"hidden_size_per_layer_input": 0,
"num_kv_shared_layers": 0,
"tie_word_embeddings": True,
"enable_moe_block": True,
"num_experts": 2,
"top_k_experts": 1,
"moe_intermediate_size": 3,
},
}
)
model = gemma4.Model(args)
gate_up = mx.arange(2 * 6 * 8, dtype=mx.float32).reshape(2, 6, 8)
down = mx.arange(2 * 8 * 3, dtype=mx.float32).reshape(2, 8, 3)
converted = model.sanitize(
{
"model.language_model.layers.0.experts.gate_up_proj": gate_up,
"model.language_model.layers.0.experts.down_proj": down,
}
)
gate_key = "language_model.model.layers.0.experts.switch_glu.gate_proj.weight"
up_key = "language_model.model.layers.0.experts.switch_glu.up_proj.weight"
down_key = "language_model.model.layers.0.experts.switch_glu.down_proj.weight"
self.assertIn(gate_key, converted)
self.assertIn(up_key, converted)
self.assertIn(down_key, converted)
self.assertTrue(mx.array_equal(converted[gate_key], gate_up[:, :3, :]))
self.assertTrue(mx.array_equal(converted[up_key], gate_up[:, 3:, :]))
self.assertTrue(mx.array_equal(converted[down_key], down))
self.assertFalse(any("gate_up_proj" in k for k in converted))
def test_gemma4_moe_router_quantizes_to_8bit(self):
from mlx_lm.models import gemma4
from mlx_lm.models.switch_layers import QuantizedSwitchLinear
from mlx_lm.utils import quantize_model
args = gemma4.ModelArgs.from_dict(
{
"model_type": "gemma4",
"vocab_size": 64,
"text_config": {
"model_type": "gemma4_text",
"hidden_size": 64,
"num_hidden_layers": 1,
"intermediate_size": 128,
"moe_intermediate_size": 128,
"num_attention_heads": 1,
"num_key_value_heads": 1,
"num_global_key_value_heads": 1,
"head_dim": 64,
"global_head_dim": 64,
"sliding_window": 8,
"sliding_window_pattern": 1,
"layer_types": ["full_attention"],
"hidden_size_per_layer_input": 0,
"num_kv_shared_layers": 0,
"tie_word_embeddings": True,
"enable_moe_block": True,
"num_experts": 8,
"top_k_experts": 2,
},
}
)
model = gemma4.Model(args)
model, config = quantize_model(
model,
{"model_type": "gemma4", "text_config": copy.deepcopy(args.text_config)},
group_size=64,
bits=4,
)
layer = model.language_model.model.layers[0]
self.assertIsInstance(layer.router.proj, nn.QuantizedLinear)
self.assertEqual(layer.router.proj.bits, 8)
self.assertIsInstance(layer.experts.switch_glu.gate_proj, QuantizedSwitchLinear)
self.assertEqual(layer.experts.switch_glu.gate_proj.bits, 4)
self.assertEqual(
config["quantization"]["language_model.model.layers.0.router.proj"]["bits"],
8,
)
self.assertEqual(config["quantization"]["bits"], 4)
def test_qwen2_moe(self):
from mlx_lm.models import qwen2_moe
@@ -709,6 +1020,104 @@ class TestModels(unittest.TestCase):
model, args.model_type, args.vocab_size, args.num_hidden_layers
)
def test_step3p5_make_cache_uses_rotating_for_sliding_layers(self):
from mlx_lm.models import step3p5
args = step3p5.ModelArgs(
model_type="step3p5",
hidden_size=256,
num_hidden_layers=4,
vocab_size=1024,
num_attention_heads=4,
num_attention_groups=2,
head_dim=64,
intermediate_size=512,
rms_norm_eps=1e-5,
rope_theta=[10000.0, 10000.0, 10000.0, 10000.0],
sliding_window=4,
layer_types=[
"full_attention",
"sliding_attention",
"sliding_attention",
"full_attention",
],
partial_rotary_factors=[0.5, 1.0, 1.0, 0.5],
attention_other_setting={
"num_attention_heads": 8,
"num_attention_groups": 2,
},
use_head_wise_attn_gate=True,
moe_num_experts=4,
moe_top_k=2,
moe_intermediate_size=256,
share_expert_dim=256,
moe_layers_enum="1,2,3",
)
model = step3p5.Model(args)
caches = model.make_cache()
self.assertIsInstance(caches[0], KVCache)
self.assertIsInstance(caches[1], RotatingKVCache)
self.assertIsInstance(caches[2], RotatingKVCache)
self.assertIsInstance(caches[3], KVCache)
tokens = mx.array([[1, 2, 3, 4, 5, 6, 7]], dtype=mx.int32)
step = model(tokens[:, :3], cache=caches)
mx.eval(step)
for i in range(3, 7):
step = model(tokens[:, i : i + 1], cache=caches)
mx.eval(step)
self.assertEqual(caches[0].size(), 7)
self.assertEqual(caches[1].size(), args.sliding_window)
self.assertEqual(caches[2].size(), args.sliding_window)
self.assertEqual(caches[3].size(), 7)
def test_step3p5_make_cache_uses_fallback_sliding_pattern(self):
from mlx_lm.models import step3p5
args = step3p5.ModelArgs(
model_type="step3p5",
hidden_size=256,
num_hidden_layers=5,
vocab_size=1024,
num_attention_heads=4,
num_attention_groups=2,
head_dim=64,
intermediate_size=512,
rms_norm_eps=1e-5,
rope_theta=10000.0,
sliding_window=4,
partial_rotary_factors=[1.0] * 5,
use_head_wise_attn_gate=True,
moe_num_experts=4,
moe_top_k=2,
moe_intermediate_size=256,
share_expert_dim=256,
moe_layers_enum="1,2,3,4",
)
model = step3p5.Model(args)
caches = model.make_cache()
self.assertIsInstance(caches[0], RotatingKVCache)
self.assertIsInstance(caches[1], KVCache)
self.assertIsInstance(caches[2], RotatingKVCache)
self.assertIsInstance(caches[3], KVCache)
self.assertIsInstance(caches[4], RotatingKVCache)
tokens = mx.array([[1, 2, 3, 4, 5, 6]], dtype=mx.int32)
step = model(tokens[:, :2], cache=caches)
mx.eval(step)
for i in range(2, 6):
step = model(tokens[:, i : i + 1], cache=caches)
mx.eval(step)
self.assertEqual(caches[0].size(), args.sliding_window)
self.assertEqual(caches[1].size(), 6)
self.assertEqual(caches[2].size(), args.sliding_window)
self.assertEqual(caches[3].size(), 6)
self.assertEqual(caches[4].size(), args.sliding_window)
def test_cohere(self):
from mlx_lm.models import cohere
@@ -1052,6 +1461,206 @@ class TestModels(unittest.TestCase):
model, args.model_type, args.vocab_size, args.num_hidden_layers
)
def test_gemma4_text(self):
from mlx_lm.models import gemma4_text
args = gemma4_text.ModelArgs(
model_type="gemma4_text",
hidden_size=128,
num_hidden_layers=10,
intermediate_size=256,
num_attention_heads=4,
head_dim=32,
global_head_dim=64,
rms_norm_eps=1e-6,
vocab_size=1000,
vocab_size_per_layer_input=1000,
num_key_value_heads=1,
num_kv_shared_layers=4,
hidden_size_per_layer_input=32,
sliding_window=8,
sliding_window_pattern=5,
final_logit_softcapping=30.0,
layer_types=[
"sliding_attention",
"sliding_attention",
"sliding_attention",
"sliding_attention",
"full_attention",
"sliding_attention",
"sliding_attention",
"sliding_attention",
"sliding_attention",
"full_attention",
],
rope_parameters={
"full_attention": {
"partial_rotary_factor": 0.25,
"rope_theta": 1000000.0,
},
"sliding_attention": {
"rope_theta": 10000.0,
},
},
)
model = gemma4_text.Model(args)
self.model_test_runner(
model, args.model_type, args.vocab_size, args.num_hidden_layers
)
def test_gemma4_quantized_embedding_preserves_lookup_scale(self):
from mlx_lm.models import gemma4_text
args = gemma4_text.ModelArgs(
model_type="gemma4_text",
hidden_size=32,
num_hidden_layers=1,
intermediate_size=64,
num_attention_heads=2,
num_key_value_heads=1,
num_global_key_value_heads=1,
head_dim=16,
global_head_dim=16,
sliding_window=8,
sliding_window_pattern=1,
layer_types=["full_attention"],
hidden_size_per_layer_input=0,
vocab_size=4,
num_kv_shared_layers=0,
)
model = gemma4_text.Gemma4TextModel(args)
model.embed_tokens.weight = mx.ones((4, 32), dtype=mx.float32)
model.embed_tokens = model.embed_tokens.to_quantized(group_size=32, bits=8)
token_ids = mx.array([[0, 1]], dtype=mx.int32)
lookup = model.embed_tokens(token_ids) * model.embed_scale
logits = model.embed_tokens.as_linear(mx.ones((1, 1, 32), dtype=mx.float32))
mx.eval(lookup, logits)
self.assertTrue(
mx.allclose(
lookup,
mx.ones((1, 2, 32), dtype=mx.float32) * (32.0**0.5),
)
)
self.assertTrue(
mx.allclose(logits, mx.ones((1, 1, 4), dtype=mx.float32) * 32.0)
)
def test_gemma4_kv_shared_layers_omit_kv_projections(self):
"""KV-shared layers must not create k_proj/v_proj/k_norm/v_norm so that
models saved without redundant weights (e.g. via transformers
save_pretrained) can be loaded with strict=True."""
from mlx_lm.models import gemma4_text
args = gemma4_text.ModelArgs(
model_type="gemma4_text",
hidden_size=128,
num_hidden_layers=10,
intermediate_size=256,
num_attention_heads=4,
head_dim=32,
global_head_dim=64,
rms_norm_eps=1e-6,
vocab_size=1000,
vocab_size_per_layer_input=1000,
num_key_value_heads=1,
num_kv_shared_layers=4,
hidden_size_per_layer_input=32,
sliding_window=8,
sliding_window_pattern=5,
final_logit_softcapping=30.0,
layer_types=[
"sliding_attention",
"sliding_attention",
"sliding_attention",
"sliding_attention",
"full_attention",
"sliding_attention",
"sliding_attention",
"sliding_attention",
"sliding_attention",
"full_attention",
],
rope_parameters={
"full_attention": {
"partial_rotary_factor": 0.25,
"rope_theta": 1000000.0,
},
"sliding_attention": {
"rope_theta": 10000.0,
},
},
)
model = gemma4_text.Model(args)
# Non-shared layers (0-5) should have KV projections
for i in range(6):
attn = model.model.layers[i].self_attn
self.assertTrue(attn.has_kv)
self.assertTrue(hasattr(attn, "k_proj"))
self.assertTrue(hasattr(attn, "k_norm"))
# Shared layers (6-9) should NOT have KV projections
for i in range(6, 10):
attn = model.model.layers[i].self_attn
self.assertFalse(attn.has_kv)
self.assertFalse(hasattr(attn, "k_proj"))
self.assertFalse(hasattr(attn, "k_norm"))
self.assertFalse(hasattr(attn, "v_proj"))
# Verify the model can load weights that omit shared-layer KV params
weights = dict(tree_flatten(model.parameters()))
kv_keys = [
k for k in weights if "k_proj" in k or "v_proj" in k or "k_norm" in k
]
for k in kv_keys:
# All KV keys should belong to non-shared layers (0-5)
layer_idx = int(k.split("layers.")[1].split(".")[0])
self.assertLess(layer_idx, 6)
def test_gemma4_input_embeddings_reconstruct_per_layer_inputs(self):
from mlx_lm.models import gemma4_text
args = gemma4_text.ModelArgs(
model_type="gemma4_text",
hidden_size=32,
num_hidden_layers=2,
intermediate_size=64,
num_attention_heads=2,
num_key_value_heads=1,
num_global_key_value_heads=1,
head_dim=16,
global_head_dim=16,
sliding_window=8,
sliding_window_pattern=1,
layer_types=["full_attention", "full_attention"],
hidden_size_per_layer_input=8,
vocab_size=32,
vocab_size_per_layer_input=32,
num_kv_shared_layers=0,
)
model = gemma4_text.Model(args)
tokens = mx.array([[1, 2, 3]], dtype=mx.int32)
embeddings = model.model.embed_tokens(tokens)
per_layer_inputs = model.model._get_per_layer_inputs(tokens)
direct = model(tokens)
from_embeddings = model(None, input_embeddings=embeddings)
explicit = model(
None,
input_embeddings=embeddings,
per_layer_inputs=per_layer_inputs,
)
mx.eval(direct, from_embeddings, explicit)
self.assertTrue(
mx.allclose(direct.astype(mx.float32), from_embeddings.astype(mx.float32))
)
self.assertTrue(
mx.allclose(direct.astype(mx.float32), explicit.astype(mx.float32))
)
def test_gpt_bigcode(self):
from mlx_lm.models import gpt_bigcode
@@ -1485,6 +2094,50 @@ class TestModels(unittest.TestCase):
"sliding_window": 8,
"sliding_window_pattern": "LLGL",
},
{
"model_type": "gemma4",
"num_hidden_layers": 10,
"vocab_size": 1000,
"text_config": {
"model_type": "gemma4_text",
"hidden_size": 128,
"num_hidden_layers": 10,
"intermediate_size": 128,
"num_attention_heads": 4,
"head_dim": 32,
"global_head_dim": 64,
"rms_norm_eps": 1e-6,
"vocab_size": 1000,
"vocab_size_per_layer_input": 1000,
"num_key_value_heads": 1,
"num_kv_shared_layers": 4,
"hidden_size_per_layer_input": 32,
"sliding_window": 8,
"sliding_window_pattern": 5,
"final_logit_softcapping": 30.0,
"layer_types": [
"sliding_attention",
"sliding_attention",
"sliding_attention",
"sliding_attention",
"full_attention",
"sliding_attention",
"sliding_attention",
"sliding_attention",
"sliding_attention",
"full_attention",
],
"rope_parameters": {
"full_attention": {
"partial_rotary_factor": 0.25,
"rope_theta": 1000000.0,
},
"sliding_attention": {
"rope_theta": 10000.0,
},
},
},
},
{
"model_type": "gemma3n",
"num_hidden_layers": 4,
@@ -1532,7 +2185,7 @@ class TestModels(unittest.TestCase):
"rms_norm_eps": 1e-5,
"vocab_size": 1000,
"num_key_value_heads": 2,
"partial_rotary_factor": 0,
"partial_rotary_factor": 0.5,
"rope_theta": 1000,
},
{
@@ -1560,7 +2213,7 @@ class TestModels(unittest.TestCase):
"use_qk_norm": True,
"tie_word_embeddings": False,
"attention_bias": False,
"partial_rotary_factor": 0.0,
"partial_rotary_factor": 0.5,
},
{
"model_type": "glm4_moe_lite",
@@ -2121,6 +2774,47 @@ class TestModels(unittest.TestCase):
"partial_rotary_factor": 0.5,
"max_position_embeddings": 1000,
},
{
"model_type": "qwen3_5",
"hidden_size": 128,
"num_hidden_layers": 4,
"intermediate_size": 128,
"num_attention_heads": 8,
"num_key_value_heads": 4,
"vocab_size": 1000,
"linear_num_value_heads": 4,
"linear_num_key_heads": 4,
"linear_key_head_dim": 32,
"linear_value_head_dim": 32,
"linear_conv_kernel_dim": 3,
"rms_norm_eps": 1e-5,
"head_dim": 64,
"rope_theta": 1000.0,
"partial_rotary_factor": 0.5,
"max_position_embeddings": 1000,
},
{
"model_type": "qwen3_5_moe",
"hidden_size": 128,
"num_hidden_layers": 4,
"num_attention_heads": 8,
"num_key_value_heads": 4,
"vocab_size": 1000,
"linear_num_value_heads": 4,
"linear_num_key_heads": 4,
"linear_key_head_dim": 32,
"linear_value_head_dim": 32,
"linear_conv_kernel_dim": 3,
"num_experts": 4,
"num_experts_per_tok": 2,
"shared_expert_intermediate_size": 128,
"moe_intermediate_size": 128,
"rms_norm_eps": 1e-5,
"head_dim": 64,
"rope_theta": 1000.0,
"partial_rotary_factor": 0.5,
"max_position_embeddings": 1000,
},
{
"model_type": "kimi_linear",
"vocab_size": 1000,
@@ -2141,6 +2835,9 @@ class TestModels(unittest.TestCase):
"num_experts": 2,
"moe_intermediate_size": 128,
"kv_lora_rank": 8,
"qk_nope_head_dim": 16,
"qk_rope_head_dim": 16,
"v_head_dim": 16,
},
{
"model_type": "afmoe",
@@ -2435,6 +3132,60 @@ class TestModels(unittest.TestCase):
self.assertTrue(mx.allclose(y_op, y_c, rtol=1e-4, atol=1e-4))
self.assertTrue(mx.allclose(st_op, st_c, rtol=1e-4, atol=1e-4))
def test_gated_delta_precision(self):
mx.random.seed(42)
N_STEPS = 512
B = 1
Hk = 4
Hv = 4
Dk = 64
Dv = 64
A_log = mx.zeros((Hv,))
dt_bias = mx.ones((Hv,))
all_q = mx.random.normal(shape=(N_STEPS, B, 1, Hk, Dk)) * 0.1
all_k = mx.random.normal(shape=(N_STEPS, B, 1, Hk, Dk)) * 0.1
all_v = mx.random.normal(shape=(N_STEPS, B, 1, Hv, Dv)) * 0.1
all_a = -7.0 + mx.random.normal(shape=(N_STEPS, B, 1, Hv)) * 0.3
all_b = mx.random.normal(shape=(N_STEPS, B, 1, Hv))
mx.eval(all_q, all_k, all_v, all_a, all_b, A_log, dt_bias)
state_ref = mx.zeros((B, Hv, Dv, Dk), dtype=mx.float32)
for t in range(N_STEPS):
y_ref, state_ref = gated_delta_update(
all_q[t],
all_k[t],
all_v[t],
all_a[t],
all_b[t],
A_log,
dt_bias,
state_ref,
use_kernel=False,
)
mx.eval(y_ref, state_ref)
for use_kernel in (False, True):
state_lo = mx.zeros((B, Hv, Dv, Dk), dtype=mx.bfloat16)
for t in range(N_STEPS):
y_lo, state_lo = gated_delta_update(
all_q[t].astype(mx.bfloat16),
all_k[t].astype(mx.bfloat16),
all_v[t].astype(mx.bfloat16),
all_a[t].astype(mx.bfloat16),
all_b[t].astype(mx.bfloat16),
A_log,
dt_bias,
state_lo,
use_kernel=use_kernel,
)
mx.eval(y_lo, state_lo)
self.assertTrue(mx.allclose(state_lo, state_ref, rtol=0.05, atol=0.01))
self.assertTrue(mx.allclose(y_lo, y_ref, rtol=0.05, atol=0.01))
def test_gated_delta_masked(self):
B = 1
T = 3
+131
View File
@@ -132,6 +132,41 @@ class TestPromptCache(unittest.TestCase):
self.assertTrue(mx.array_equal(k, lk))
self.assertTrue(mx.array_equal(v, lv))
def test_save_load_cache_list(self):
cache_file = os.path.join(self.test_dir, "prompt_cache.safetensors")
cache = [
ArraysCache(size=2),
KVCache(),
RotatingKVCache(8),
ArraysCache(size=2),
ChunkedKVCache(256),
]
for c in cache:
if isinstance(c, ArraysCache):
c[0] = mx.random.uniform(shape=(4, 4, 4))
c[1] = mx.random.uniform(shape=(4, 4, 4))
else:
x = mx.random.uniform(shape=(4, 4, 7, 4))
y = mx.random.uniform(shape=(4, 4, 7, 4))
c.update_and_fetch(x, y)
cache = [CacheList(*cache)]
save_prompt_cache(cache_file, cache)
loaded_cache = load_prompt_cache(cache_file)
for c, lc in zip(cache[0].caches, loaded_cache[0].caches):
if isinstance(c, ArraysCache):
self.assertTrue(mx.array_equal(c[0], lc[0]))
self.assertTrue(mx.array_equal(c[1], lc[1]))
else:
x = mx.random.uniform(shape=(4, 4, 1, 4))
y = mx.random.uniform(shape=(4, 4, 1, 4))
k, v = c.update_and_fetch(x, y)
lk, lv = lc.update_and_fetch(x, y)
self.assertEqual(c.offset, lc.offset)
self.assertTrue(mx.array_equal(k, lk))
self.assertTrue(mx.array_equal(v, lv))
def test_save_load_arrays_cache(self):
cache_file = os.path.join(self.test_dir, "prompt_cache.safetensors")
@@ -627,6 +662,102 @@ class TestPromptCache(unittest.TestCase):
c_out = KVCache.merge((c1, c2))
self.assertEqual(c_out.keys.shape, (2, 4, 4, 4))
def test_extend_with_empty_and_nonempty_batch_caches(self):
"""Extending a batch cache when one side has keys=None should use the
correct batch size for the placeholder, not the batch size from the
non-None side. Regression test for broadcast error in dynamic_roll."""
H, D = 8, 64
max_size = 512
# -- BatchRotatingKVCache --
# Create 2 caches with content and 3 empty caches
c1 = RotatingKVCache(max_size=max_size)
c2 = RotatingKVCache(max_size=max_size)
c1.update_and_fetch(mx.ones((1, H, 5, D)), mx.ones((1, H, 5, D)))
c2.update_and_fetch(mx.ones((1, H, 3, D)), mx.ones((1, H, 3, D)))
batch_full = BatchRotatingKVCache.merge([c1, c2])
empty_caches = [RotatingKVCache(max_size=max_size) for _ in range(3)]
batch_empty = BatchRotatingKVCache.merge(empty_caches)
# Extend non-empty with empty (different batch sizes)
batch_full.extend(batch_empty)
self.assertEqual(batch_full.keys.shape[0], 5)
self.assertEqual(batch_full.offset.shape[0], 5)
# Prompt processing with right padding should not crash
batch_full.prepare(lengths=[10, 8, 12, 7, 11], right_padding=[2, 4, 0, 5, 1])
new_kv = mx.ones((5, H, 12, D))
batch_full.update_and_fetch(new_kv, new_kv)
# Also test empty extending non-empty
batch_full2 = BatchRotatingKVCache.merge(
[RotatingKVCache(max_size=max_size) for _ in range(3)]
)
c3 = RotatingKVCache(max_size=max_size)
c4 = RotatingKVCache(max_size=max_size)
c3.update_and_fetch(mx.ones((1, H, 4, D)), mx.ones((1, H, 4, D)))
c4.update_and_fetch(mx.ones((1, H, 6, D)), mx.ones((1, H, 6, D)))
batch_content = BatchRotatingKVCache.merge([c3, c4])
batch_full2.extend(batch_content)
self.assertEqual(batch_full2.keys.shape[0], 5)
self.assertEqual(batch_full2.offset.shape[0], 5)
# -- BatchKVCache --
c1 = KVCache()
c2 = KVCache()
c1.update_and_fetch(mx.ones((1, H, 5, D)), mx.ones((1, H, 5, D)))
c2.update_and_fetch(mx.ones((1, H, 3, D)), mx.ones((1, H, 3, D)))
batch_full = BatchKVCache.merge([c1, c2])
empty_caches = [KVCache() for _ in range(3)]
batch_empty = BatchKVCache.merge(empty_caches)
batch_full.extend(batch_empty)
self.assertEqual(batch_full.keys.shape[0], 5)
self.assertEqual(batch_full.offset.shape[0], 5)
def test_arrays_cache_extend_with_empty(self):
# test simple merge
c1 = ArraysCache(2)
c2 = ArraysCache(2)
c1[0] = mx.zeros((1, 4, 8))
c1[1] = mx.zeros((1, 4))
c2[0] = mx.zeros((1, 4, 8))
c2[1] = mx.zeros((1, 4))
full = ArraysCache.merge((c1, c2))
self.assertEqual(full[0].shape, (2, 4, 8))
# extend with empty
empty = ArraysCache.merge((ArraysCache(2),))
full.extend(empty)
self.assertEqual(full[0].shape, (3, 4, 8))
self.assertEqual(full[1].shape, (3, 4))
self.assertTrue(mx.all(full[0][2:] == 0))
# making an empty cache with 2 sequences and merging it with
# another one with 2 sequences
empty2 = ArraysCache.merge((ArraysCache(2), ArraysCache(2)))
content = ArraysCache.merge((c1, c2))
empty2.extend(content)
self.assertEqual(empty2[0].shape, (4, 4, 8))
self.assertEqual(empty2[1].shape, (4, 4))
# Extend content with empty
content = ArraysCache.merge((c1, c2))
empty2 = ArraysCache.merge((ArraysCache(2), ArraysCache(2)))
content.extend(empty2)
self.assertEqual(content[0].shape, (4, 4, 8))
self.assertEqual(content[1].shape, (4, 4))
self.assertEqual(content.make_mask(10).shape, (4, 10))
# multiple empty extensions accumulate correctly
stepwise = ArraysCache.merge((c1,))
stepwise.extend(ArraysCache(2))
stepwise.extend(ArraysCache.merge((ArraysCache(2), ArraysCache(2))))
self.assertEqual(stepwise[0].shape, (4, 4, 8))
self.assertEqual(stepwise[1].shape, (4, 4))
def test_window_mask_with_full_kv_cache(self):
c = KVCache()
kv = mx.zeros((1, 1, 32, 128))
+58
View File
@@ -116,6 +116,64 @@ class TestSampleUtils(unittest.TestCase):
new_probs = mx.softmax(apply_xtc(mx.log(probs), 0, 0.1, [0]), -1)
self.assertTrue(mx.allclose(new_probs, probs))
def test_presence_penalty(self):
from mlx_lm.sample_utils import make_presence_penalty
# Token appears multiple times - penalty applied once
tokens = mx.array([0, 0, 0, 1, 1])
logits = mx.zeros((1, 4))
processor = make_presence_penalty(0.5, context_size=5)
result = processor(tokens, logits)
# Token 0 appears 3 times, token 1 appears 2 times - both penalized once
self.assertAlmostEqual(result[0, 0].item(), -0.5)
self.assertAlmostEqual(result[0, 1].item(), -0.5)
# Tokens not in context not penalized
self.assertAlmostEqual(result[0, 2].item(), 0.0)
self.assertAlmostEqual(result[0, 3].item(), 0.0)
def test_frequency_penalty(self):
from mlx_lm.sample_utils import make_frequency_penalty
# Token appears multiple times - penalty applied proportionally
tokens = mx.array([0, 0, 0, 1, 1])
logits = mx.zeros((1, 4))
processor = make_frequency_penalty(0.5, context_size=5)
result = processor(tokens, logits)
# Token 0 appears 3 times -> 3 * 0.5 = 1.5 penalty
self.assertAlmostEqual(result[0, 0].item(), -1.5)
# Token 1 appears 2 times -> 2 * 0.5 = 1.0 penalty
self.assertAlmostEqual(result[0, 1].item(), -1.0)
# Tokens not in context not penalized
self.assertAlmostEqual(result[0, 2].item(), 0.0)
self.assertAlmostEqual(result[0, 3].item(), 0.0)
def test_make_logits_processors(self):
from mlx_lm.sample_utils import make_logits_processors
# Create processors with all three penalty types
tokens = mx.array([0, 0, 0, 1, 1])
# Use non-zero logits so repetition penalty has effect
logits = mx.array([[1.0, 0.5, 0.0, -0.5]])
processors = make_logits_processors(
repetition_penalty=1.5,
repetition_context_size=5,
presence_penalty=0.5,
presence_context_size=5,
frequency_penalty=0.25,
frequency_context_size=5,
)
# Apply all processors
for processor in processors:
logits = processor(tokens, logits)
# Token 0 (appears 3x): 1.0/1.5 - 0.5 - 0.75 = -0.5833
# Token 1 (appears 2x): 0.5/1.5 - 0.5 - 0.5 = -0.6667
# Token 2 (not in context): 0.0 (no penalty)
# Token 3 (not in context): -0.5 (no penalty)
self.assertAlmostEqual(logits[0, 0].item(), -0.5833, places=4)
self.assertAlmostEqual(logits[0, 1].item(), -0.6667, places=4)
self.assertAlmostEqual(logits[0, 2].item(), 0.0, places=4)
self.assertAlmostEqual(logits[0, 3].item(), -0.5, places=4)
if __name__ == "__main__":
unittest.main()
+241 -31
View File
@@ -4,13 +4,20 @@ import http
import io
import json
import threading
import types
import unittest
import mlx.core as mx
import requests
from mlx_lm.models.cache import KVCache
from mlx_lm.server import APIHandler, LRUPromptCache, ResponseGenerator
from mlx_lm.server import (
APIHandler,
LRUPromptCache,
Response,
ResponseGenerator,
_process_control_tokens,
)
from mlx_lm.utils import load
@@ -43,6 +50,11 @@ class DummyModelProvider:
"model": None,
"decode_concurrency": 32,
"prompt_concurrency": 8,
"prefill_step_size": 2048,
"prompt_cache_size": 10,
"prompt_cache_bytes": 1 << 63,
"prompt_cache_total_bytes": None,
"allowed_origins": ["*"],
},
)
@@ -56,6 +68,94 @@ class DummyModelProvider:
assert model in ["default_model", "chat_model"]
return self.model, self.tokenizer
def load_default(self):
return self.load("default_model", None, "default_model")
class MockCache:
def __init__(self, value, is_trimmable: bool = True):
self.value = value
self._is_trimmable = is_trimmable
@property
def nbytes(self):
return len(self.value)
def __eq__(self, other):
return other.value == self.value
def is_trimmable(self):
return self._is_trimmable
def trim(self, n):
assert self._is_trimmable
return n
class TestProcessControlTokens(unittest.TestCase):
@staticmethod
def _r(text, state, match=None):
return Response(text, 0, state, match, 0.0, None, ())
def test_single_tool_call_passes_body_with_open_and_close_crossings(self):
r = self._r
stream = [
r("hi ", "normal"),
r("<tool_call>", "tool", match=(0,)),
r("body", "tool"),
r("</tool_call>", "normal", match=(1,)),
r(" bye", "normal"),
]
ctx = types.SimpleNamespace(
sequences={(0,): "<tool_call>", (1,): "</tool_call>"}
)
out = list(_process_control_tokens(ctx, iter(stream)))
self.assertEqual("".join(t.text for t in out), "hi body bye")
states = [t.state for t in out]
self.assertEqual(sum(1 for a, b in zip(states, states[1:]) if a != b), 2)
def test_back_to_back_tool_calls_emit_state_crossings(self):
r = self._r
stream = [
r("<tool_call>", "tool", match=(0,)),
r("call1_body", "tool"),
r("</tool_call>", "normal", match=(1,)),
r("<tool_call>", "tool", match=(0,)),
r("call2_body", "tool"),
r("</tool_call>", "normal", match=(1,)),
]
ctx = types.SimpleNamespace(
sequences={(0,): "<tool_call>", (1,): "</tool_call>"}
)
out = list(_process_control_tokens(ctx, iter(stream)))
self.assertEqual("".join(t.text for t in out), "call1_bodycall2_body")
states = [t.state for t in out]
crossings = sum(
1 for a, b in zip(states, states[1:]) if a == "tool" and b == "normal"
)
self.assertEqual(crossings, 2)
def test_multi_token_match_preserves_order(self):
r = self._r
match = (10, 11, 12)
stream = [
r("body", "tool"),
r("</", "tool"),
r("tool", "tool"),
r("_call>", "normal", match=match),
r(" ok", "normal"),
]
ctx = types.SimpleNamespace(sequences={match: "</tool_call>"})
out = list(_process_control_tokens(ctx, iter(stream)))
self.assertEqual([t.text for t in out], ["body", "", "", "", " ok"])
self.assertEqual(
[t.state for t in out],
["tool", "tool", "tool", "normal", "normal"],
)
class TestServer(unittest.TestCase):
@classmethod
@@ -180,6 +280,33 @@ class TestServer(unittest.TestCase):
self.assertIn("id", response_body)
self.assertIn("choices", response_body)
def test_make_state_machine_empty_tool_call_end(self):
class FakeTokenizer:
has_thinking = False
has_tool_calling = True
tool_call_start = "[TOOL_CALLS]"
tool_call_end = ""
tool_call_start_tokens = (100,)
tool_call_end_tokens = ()
eos_token_ids = [2]
def convert_ids_to_tokens(self, t):
return f"<eos{t}>"
sm, _ = self.response_generator._make_state_machine(
("fake-empty-end", None, None),
FakeTokenizer(),
stop_words=[],
)
state = sm.make_state()
state, _, s = sm.match(state, 100)
self.assertEqual(s, "tool")
for tok in [42, 43, 44]:
state, _, s = sm.match(state, tok)
self.assertEqual(s, "tool")
state, _, s = sm.match(state, 2)
self.assertIsNone(s)
def test_handle_models(self):
url = f"http://localhost:{self.port}/v1/models"
response = requests.get(url)
@@ -193,18 +320,6 @@ class TestServer(unittest.TestCase):
self.assertEqual(model["object"], "model")
self.assertIn("created", model)
def test_sequence_overlap(self):
from mlx_lm.server import sequence_overlap
self.assertTrue(sequence_overlap([1], [1]))
self.assertTrue(sequence_overlap([1, 2], [1, 2]))
self.assertTrue(sequence_overlap([1, 3], [3, 4]))
self.assertTrue(sequence_overlap([1, 2, 3], [2, 3]))
self.assertFalse(sequence_overlap([1], [2]))
self.assertFalse(sequence_overlap([1, 2], [3, 4]))
self.assertFalse(sequence_overlap([1, 2, 3], [4, 1, 2, 3]))
class TestServerWithDraftModel(unittest.TestCase):
@classmethod
@@ -354,7 +469,6 @@ class TestServerWithDraftModel(unittest.TestCase):
class TestKeepalive(unittest.TestCase):
def test_keepalive_callback(self):
"""Test keepalive callback sends SSE comments and handles errors"""
from unittest.mock import Mock
@@ -404,7 +518,6 @@ class TestKeepalive(unittest.TestCase):
class TestLRUPromptCache(unittest.TestCase):
def test_caching(self):
cache = LRUPromptCache(max_size=10)
@@ -423,18 +536,23 @@ class TestLRUPromptCache(unittest.TestCase):
c[0].update_and_fetch(*get_kv(24))
cache.insert_cache(model, t, c)
# Fetching a cache that is strictly a prefix doesn't remove it from the
# lru cache
tokens = tokens + [20] * 5
c, t = cache.fetch_nearest_cache(model, tokens)
k, v = c[0].state
self.assertTrue((k == v).all().item())
self.assertTrue((k.flatten() == mx.arange(24)).all().item())
self.assertEqual(t, [20] * 5)
self.assertEqual(len(cache._lru), 0)
self.assertEqual(len(cache), 1)
# Inserting a trimmable cache with shared prefix removes the prefixes
tokens = tokens + [30] * 3
c[0].update_and_fetch(*get_kv(8))
cache.insert_cache(model, tokens, c)
self.assertEqual(len(cache), 1)
# Fetching a cache with a shared prefix doesn't remove it either
tokens = tokens[:26] + [40] * 8
c, t = cache.fetch_nearest_cache(model, tokens)
k, v = c[0].state
@@ -443,38 +561,130 @@ class TestLRUPromptCache(unittest.TestCase):
(k.flatten() == mx.concatenate([mx.arange(24), mx.arange(2)])).all().item()
)
self.assertEqual(t, [40] * 8)
self.assertEqual(len(cache._lru), 1)
self.assertEqual(len(cache), 1)
# Inserting a diverged cache actually creates another entry
c[0].update_and_fetch(*get_kv(8))
cache.insert_cache(model, tokens, c)
self.assertEqual(len(cache), 2)
def test_lru(self):
cache = LRUPromptCache(max_size=2)
model = ("test", None, None)
cache.insert_cache(model, [1, 2], ["test1"])
cache.insert_cache(model, [1, 2], ["test1"])
cache.insert_cache(model, [1, 2], [MockCache("test1")])
cache.insert_cache(model, [2, 3], [MockCache("test2")])
c, t = cache.fetch_nearest_cache(model, [1, 2])
self.assertEqual(c, ["test1"])
self.assertEqual(c, [MockCache("test1")])
self.assertEqual(t, [])
c, t = cache.fetch_nearest_cache(model, [1, 2])
self.assertEqual(c, ["test1"])
self.assertEqual(t, [])
c, t = cache.fetch_nearest_cache(model, [1, 2])
self.assertEqual(c, None)
self.assertEqual(t, [1, 2])
c, t = cache.fetch_nearest_cache(model, [1])
self.assertEqual(c, [MockCache("test1")])
self.assertEqual(t, [1])
c, t = cache.fetch_nearest_cache(model, [1, 3, 4])
self.assertEqual(c, [MockCache("test1")])
self.assertEqual(t, [3, 4])
c, t = cache.fetch_nearest_cache(model, [2, 3, 4])
self.assertEqual(c, [MockCache("test2")])
self.assertEqual(t, [4])
c, t = cache.fetch_nearest_cache(model, [2, 4, 5])
self.assertEqual(c, [MockCache("test2")])
self.assertEqual(t, [4, 5])
cache.insert_cache(model, [1, 2], ["test1"])
cache.insert_cache(model, [2, 3], ["test2"])
cache.insert_cache(model, [3, 4], ["test3"])
cache.insert_cache(model, [1, 2], [MockCache("test1")])
cache.insert_cache(model, [2, 3], [MockCache("test2")])
cache.insert_cache(model, [3, 4], [MockCache("test3")])
c, t = cache.fetch_nearest_cache(model, [1, 2])
self.assertEqual(c, None)
self.assertEqual(t, [1, 2])
c, t = cache.fetch_nearest_cache(model, [2, 3])
self.assertEqual(c, ["test2"])
self.assertEqual(c, [MockCache("test2")])
self.assertEqual(t, [])
c, t = cache.fetch_nearest_cache(model, [3, 4])
self.assertEqual(c, ["test3"])
self.assertEqual(c, [MockCache("test3")])
self.assertEqual(t, [])
cache.insert_cache(model, [4, 5], [MockCache("test4")], cache_type="user")
c, t = cache.fetch_nearest_cache(model, [2, 3])
self.assertEqual(c, None)
self.assertEqual(t, [2, 3])
c, t = cache.fetch_nearest_cache(model, [3, 4])
self.assertEqual(c, [MockCache("test3")])
self.assertEqual(t, [])
c, t = cache.fetch_nearest_cache(model, [4, 5])
self.assertEqual(c, [MockCache("test4")])
self.assertEqual(t, [])
cache.insert_cache(model, [5, 6], [MockCache("test5")])
cache.insert_cache(model, [6, 7], [MockCache("test6")])
c, t = cache.fetch_nearest_cache(model, [5, 6])
self.assertEqual(c, None)
self.assertEqual(t, [5, 6])
c, t = cache.fetch_nearest_cache(model, [6, 7])
self.assertEqual(c, [MockCache("test6")])
self.assertEqual(t, [])
c, t = cache.fetch_nearest_cache(model, [4, 5])
self.assertEqual(c, [MockCache("test4")])
self.assertEqual(t, [])
def test_insert_trimmable_cache_removes_immediate_prefix(self):
cache = LRUPromptCache(max_size=10)
model = ("test", None, None)
cache.insert_cache(model, [1, 2], [MockCache("ab")])
self.assertEqual(len(cache), 1)
self.assertEqual(cache.nbytes, 2)
cache.insert_cache(model, [1, 2, 3], [MockCache("abc")])
self.assertEqual(len(cache), 1)
self.assertEqual(cache.nbytes, 3)
def test_insert_empty_tokens_does_not_self_destruct(self):
cache = LRUPromptCache(max_size=10)
model = ("test", None, None)
cache.insert_cache(model, [], [MockCache("root")])
self.assertEqual(len(cache), 1)
self.assertEqual(cache.nbytes, 4)
c, t = cache.fetch_nearest_cache(model, [])
self.assertIsNotNone(c)
self.assertEqual(t, [])
def test_fetch_empty_tokens_after_root_eviction(self):
cache = LRUPromptCache(max_size=10)
model = ("test", None, None)
cache.insert_cache(model, [], [MockCache("root")])
cache.insert_cache(model, [1], [MockCache("a")])
c, t = cache.fetch_nearest_cache(model, [])
self.assertIsNone(c)
self.assertEqual(t, [])
def test_lru_bytes(self):
cache = LRUPromptCache(max_size=100, max_bytes=10)
model = ("test", None, None)
cache.insert_cache(model, [1, 2], [MockCache("aaa")])
cache.insert_cache(model, [3, 4], [MockCache("bbb")])
cache.insert_cache(model, [4, 5], [MockCache("ccc")])
cache.insert_cache(model, [6, 7], [MockCache("ddd")])
self.assertEqual(len(cache), 3)
self.assertEqual(cache.nbytes, 9)
cache.trim_to(n_bytes=7)
self.assertEqual(len(cache), 2)
self.assertEqual(cache.nbytes, 6)
c, t = cache.fetch_nearest_cache(model, [1, 2])
self.assertEqual(c, None)
self.assertEqual(t, [1, 2])
c, t = cache.fetch_nearest_cache(model, [3, 4])
self.assertEqual(c, None)
self.assertEqual(t, [3, 4])
if __name__ == "__main__":
unittest.main()
+8
View File
@@ -101,6 +101,14 @@ class TestTokenizers(unittest.TestCase):
self.assertEqual(tokenizer.think_start, "<think>")
self.assertEqual(tokenizer.think_end, "</think>")
tokenizer_repo = "mlx-community/Llama-3.2-1B-Instruct-4bit"
tokenizer = load_tokenizer(tokenizer_repo)
self.assertFalse(tokenizer.has_thinking)
self.assertIsNone(tokenizer.think_start)
self.assertIsNone(tokenizer.think_end)
self.assertIsNone(tokenizer.think_start_id)
self.assertIsNone(tokenizer.think_end_id)
if __name__ == "__main__":
unittest.main()
+161 -1
View File
@@ -3,20 +3,23 @@ from pathlib import Path
from mlx_lm.tool_parsers import (
function_gemma,
gemma4,
glm47,
json_tools,
kimi_k2,
longcat,
minimax_m2,
mistral,
pythonic,
qwen3_coder,
)
class TestToolParsing(unittest.TestCase):
def test_parsers(self):
test_cases = [
("call:multiply{a:12234585,b:48838483920}", function_gemma),
("call:multiply{a:12234585,b:48838483920}", gemma4),
(
'{"name": "multiply", "arguments": {"a": 12234585, "b": 48838483920}}',
glm47,
@@ -46,6 +49,14 @@ class TestToolParsing(unittest.TestCase):
'{"name": "multiply", "arguments": {"a": 12234585, "b": 48838483920}}',
longcat,
),
(
"[multiply(a=12234585, b=48838483920)]",
pythonic,
),
(
'multiply[ARGS]{"a": 12234585, "b": 48838483920}',
mistral,
),
]
tools = [
@@ -80,6 +91,10 @@ class TestToolParsing(unittest.TestCase):
"call:get_current_temperature{location:<escape>London<escape>}",
function_gemma,
),
(
'call:get_current_temperature{location:<|"|>London<|"|>}',
gemma4,
),
(
'get_current_temperature<arg_key>location</arg_key><arg_value>"London"</arg_value>',
glm47,
@@ -104,6 +119,14 @@ class TestToolParsing(unittest.TestCase):
'{"name": "get_current_temperature", "arguments": {"location": "London"}}',
longcat,
),
(
'[get_current_temperature(location="London")]',
pythonic,
),
(
'get_current_temperature[ARGS]{"location": "London"}',
mistral,
),
]
tools = [
{
@@ -131,6 +154,127 @@ class TestToolParsing(unittest.TestCase):
}
self.assertEqual(tool_call, expected)
def test_qwen3_coder_single_quoted_params(self):
tools = [
{
"type": "function",
"function": {
"name": "search",
"parameters": {
"type": "object",
"properties": {
"filters": {"type": "object"},
"tags": {"type": "array"},
},
},
},
}
]
# single-quoted dict (python-style, not valid JSON)
test_case = (
"<function=search>"
"<parameter=filters>{'category': 'books', 'in_stock': True}</parameter>"
"<parameter=tags>['fiction', 'new']</parameter>"
"</function>"
)
tool_call = qwen3_coder.parse_tool_call(test_case, tools)
self.assertEqual(tool_call["name"], "search")
self.assertEqual(
tool_call["arguments"]["filters"],
{"category": "books", "in_stock": True},
)
self.assertEqual(tool_call["arguments"]["tags"], ["fiction", "new"])
# valid JSON (double-quoted) should still work
test_case = (
"<function=search>"
'<parameter=filters>{"category": "books"}</parameter>'
'<parameter=tags>["fiction", "new"]</parameter>'
"</function>"
)
tool_call = qwen3_coder.parse_tool_call(test_case, tools)
self.assertEqual(tool_call["arguments"]["filters"], {"category": "books"})
self.assertEqual(tool_call["arguments"]["tags"], ["fiction", "new"])
def test_gemma4(self):
# Nested object
test_case = 'call:configure{settings:{enabled:true,name:<|"|>test<|"|>}}'
tool_call = gemma4.parse_tool_call(test_case, None)
self.assertEqual(tool_call["name"], "configure")
self.assertEqual(
tool_call["arguments"],
{"settings": {"enabled": True, "name": "test"}},
)
# Array of strings
test_case = 'call:tag{items:[<|"|>foo<|"|>,<|"|>bar<|"|>]}'
tool_call = gemma4.parse_tool_call(test_case, None)
self.assertEqual(tool_call["name"], "tag")
self.assertEqual(tool_call["arguments"], {"items": ["foo", "bar"]})
# Mixed types
test_case = 'call:search{query:<|"|>hello world<|"|>,limit:10,verbose:false}'
tool_call = gemma4.parse_tool_call(test_case, None)
self.assertEqual(tool_call["name"], "search")
self.assertEqual(
tool_call["arguments"],
{"query": "hello world", "limit": 10, "verbose": False},
)
# Multiple tool calls in a single block (no delimiter between them)
test_case = (
'call:glob{pattern:<|"|>README*.md<|"|>}'
'call:glob{pattern:<|"|>CONTRIBUTING.md<|"|>}'
)
tool_calls = gemma4.parse_tool_call(test_case, None)
self.assertIsInstance(tool_calls, list)
self.assertEqual(len(tool_calls), 2)
self.assertEqual(tool_calls[0]["name"], "glob")
self.assertEqual(tool_calls[0]["arguments"], {"pattern": "README*.md"})
self.assertEqual(tool_calls[1]["name"], "glob")
self.assertEqual(tool_calls[1]["arguments"], {"pattern": "CONTRIBUTING.md"})
# Multiple tool calls with nested args
test_case = (
'call:search{query:<|"|>weather<|"|>,limit:5}'
'call:configure{settings:{enabled:true,name:<|"|>test<|"|>}}'
)
tool_calls = gemma4.parse_tool_call(test_case, None)
self.assertIsInstance(tool_calls, list)
self.assertEqual(len(tool_calls), 2)
self.assertEqual(tool_calls[0]["name"], "search")
self.assertEqual(
tool_calls[0]["arguments"],
{"query": "weather", "limit": 5},
)
self.assertEqual(tool_calls[1]["name"], "configure")
self.assertEqual(
tool_calls[1]["arguments"],
{"settings": {"enabled": True, "name": "test"}},
)
# Hyphenated function name (e.g. manim-video)
test_case = (
'call:manim-video{mode:<|"|>plan<|"|>,prompt:<|"|>explain KV caching<|"|>}'
)
tool_call = gemma4.parse_tool_call(test_case, None)
self.assertEqual(tool_call["name"], "manim-video")
self.assertEqual(
tool_call["arguments"],
{"mode": "plan", "prompt": "explain KV caching"},
)
# Braces inside a string argument (e.g. code snippets or markdown in content)
test_case = (
'call:skill_manage{action:<|"|>create<|"|>,'
'content:<|"|>use a dict like {key: value} in your code<|"|>}'
)
tool_call = gemma4.parse_tool_call(test_case, None)
self.assertEqual(tool_call["name"], "skill_manage")
self.assertEqual(tool_call["arguments"]["action"], "create")
self.assertIn("{", tool_call["arguments"]["content"])
def test_kimi_k2(self):
# Single tool call
test_case = (
@@ -169,6 +313,22 @@ class TestToolParsing(unittest.TestCase):
]
self.assertEqual(tool_calls, expected)
def test_minimax_m2(self):
test_case = (
'<invoke name="search">\n'
'<parameter name="query">weather</parameter>\n'
"</invoke>\n"
'<invoke name="read_file">\n'
'<parameter name="path">/tmp/test.txt</parameter>\n'
"</invoke>"
)
expected = [
{"name": "search", "arguments": {"query": "weather"}},
{"name": "read_file", "arguments": {"path": "/tmp/test.txt"}},
]
tool_calls = minimax_m2.parse_tool_call(test_case, None)
self.assertEqual(expected, tool_calls)
if __name__ == "__main__":
unittest.main()
+60
View File
@@ -3,6 +3,7 @@
import os
import tempfile
import unittest
from pathlib import Path
import mlx.core as mx
import mlx.nn as nn
@@ -123,6 +124,65 @@ class TestUtils(unittest.TestCase):
self.assertEqual(model.custom_attribute, "This is a custom model")
self.assertTrue(hasattr(model, "qwenWeights"))
def test_load_model_gemma4_with_per_layer_projection_quantization(self):
from mlx_lm.models import gemma4
args = gemma4.ModelArgs.from_dict(
{
"model_type": "gemma4",
"vocab_size": 32,
"text_config": {
"model_type": "gemma4_text",
"hidden_size": 32,
"num_hidden_layers": 2,
"intermediate_size": 64,
"num_attention_heads": 2,
"num_key_value_heads": 1,
"num_global_key_value_heads": 1,
"head_dim": 16,
"global_head_dim": 16,
"sliding_window": 8,
"sliding_window_pattern": 1,
"layer_types": ["full_attention", "full_attention"],
"hidden_size_per_layer_input": 32,
"vocab_size_per_layer_input": 32,
"num_kv_shared_layers": 0,
"tie_word_embeddings": True,
},
}
)
model = gemma4.Model(args)
model, config = utils.quantize_model(
model,
{
"model_type": "gemma4",
"vocab_size": args.vocab_size,
"text_config": args.text_config,
},
group_size=32,
bits=4,
)
config["quantization"]["language_model.model.per_layer_model_projection"] = {
"group_size": 32,
"bits": 4,
}
with tempfile.TemporaryDirectory(dir=self.test_dir) as mlx_path:
utils.save_model(mlx_path, model)
utils.save_config(config, os.path.join(mlx_path, "config.json"))
loaded, loaded_config = utils.load_model(Path(mlx_path))
self.assertIn(
"language_model.model.per_layer_model_projection",
loaded_config["quantization"],
)
logits = loaded(mx.array([[1, 2, 3]], dtype=mx.int32))
mx.eval(logits)
self.assertEqual(logits.shape, (1, 3, args.vocab_size))
if __name__ == "__main__":
unittest.main()