Compare commits

..

41 Commits

Author SHA1 Message Date
Awni Hannun 5e6a7f6895 version (#558) 2025-10-17 14:41:44 -07:00
Daniel Nakov edc656a85c Add support for nanochat (#554)
* Add support for nanochat

* format

* compile softcap

* add test

---------

Co-authored-by: dnakov <3777433+dnakov@users.noreply.github.com>
Co-authored-by: Awni Hannun <awni@apple.com>
2025-10-15 12:59:54 -07:00
Awni Hannun a4c6470390 benchmarks (#552) 2025-10-15 12:24:12 -07:00
Vincent Amato 1d114498f2 Add Qwen3-VL (Dense) language model implementation (#553)
* Added Qwen3-VL dense language model

* Added Qwen3-VL dense language model test
2025-10-14 12:28:46 -07:00
Gökdeniz Gülmez b1fc49a9f2 Adding jamba (#544)
* add modelargs

* adding mlp and sdp attentino

* updates

* adding the rest

* updates

* finish

* format

* upd. ackn.

* nits + format

* speedup

* fix

---------

Co-authored-by: Awni Hannun <awni@apple.com>
2025-10-14 08:44:50 -07:00
Tarjei Mandt 4781244aaf Optimize Bailing MoE (#550)
* Optimize Bailing MoE

* Fix formatting
2025-10-14 07:45:07 -07:00
Awni Hannun 49ba6dff08 Support data parallel eval for generation tasks (#549)
* Support data parallel eval for generation tasks

* comment
2025-10-13 13:49:58 -07:00
Esakkivel Esakkiraja c3b4a15851 Added gradient accumulation to training loop (#511) 2025-10-13 11:27:15 -07:00
Awni Hannun a4e32ef5a5 Fix mask for batched SSM (#546) 2025-10-10 14:30:27 -07:00
Vincent Amato cceb45d6b6 Add Qwen3-VL language model implementation (#547)
* Added Qwen3-VL language model implementation.

* Formatted code

* Removed redundent test and added quant_predicate propoerty
2025-10-09 21:42:46 -07:00
Awni Hannun 0b8c1668d2 fix cuda install (#542) 2025-10-08 16:03:43 -07:00
Mauricio Barba Da Costa f876b3f775 minor typing issues (#540) 2025-10-08 06:07:35 -07:00
Victor Nogueira 373c63c08f Fix example command to quantize a model using GPTQ (#539) 2025-10-08 06:07:18 -07:00
Prince Canuma 344755a1f6 Add lfm2 moe (#537)
* add lfm2 moe

* fix config, loading and expert bias

* add test

* nits

---------

Co-authored-by: Awni Hannun <awni.hannun@gmail.com>
2025-10-07 16:18:47 -07:00
mzfive abb185cb66 Fix: Add __future__ annotations import to qwen3_next.py for Python 3.9 compatibility (#533)
* Fix Python 3.8/3.9 compatibility in qwen3_next.py

Add missing `from __future__ import annotations` import to fix
Python 3.8/3.9 compatibility.

The `|` union syntax (PEP 604) requires Python 3.10+ or the
__future__ import. This change maintains the declared
python_requires>=3.8 compatibility.

Fixes compatibility with macOS system Python (3.9.6).

* format

---------

Co-authored-by: Awni Hannun <awni@apple.com>
2025-10-07 11:28:36 -07:00
Awni Hannun 49919e8563 Cleanup and simplify model I/O (#532)
* Cleanup and simplify model I/O

* fix test
2025-10-07 06:57:51 -07:00
Awni Hannun 7168556aaa Remove act loss and add temp in DWQ (#500) 2025-10-07 06:06:23 -07:00
shepardxia 1e71de53e3 GPT2 Batching Fix (#529) 2025-10-07 00:36:48 -07:00
Awni Hannun f318741784 fix bailing moe (#521) 2025-10-02 15:15:14 -07:00
Awni Hannun 0edd0a0cd1 Fix lora MoEs (#522) 2025-10-02 15:15:00 -07:00
Awni Hannun 44c74e1d04 memory efficient ssm (#525) 2025-10-02 15:14:48 -07:00
Gökdeniz Gülmez 81c3c193cf removings (#524) 2025-10-02 11:49:39 -07:00
Gabe Goodhart b264da7602 feat: Refactor granitemoehybrid to support dense and non-hybrid variants (#518)
* feat: Refactor granitemoehybrid to support dense and non-hybrid variants

Written with Claude Code. Initial prompt:

I need to modify the model support implemented in `mlx_lm/models/granitemoehybrid.py` in two ways:

* Support optionally using a dense block in place of MoE. The dense block should look like `mlx_lm/models/granite.py` instead of `mlx_lm/models/granitemoe.py`.

* Support the case where there are no `mamba` layers (ie non-hybrid). This should devolve to exactly `granite.py` or `granitemoe.py` depending on whether the block after attention is dense or MoE.

You can test this using the following two models:

* Dense w/ hybrid: /Users/ghart/models/dmf_models/granite-4.0-h-micro-r250918a
* Dense w/ non-hybrid: /Users/ghart/models/dmf_models/granite-4.0-micro-r250918a

Branch: GraniteFourDense

Signed-off-by: Gabe Goodhart <ghart@us.ibm.com>

* refact: Clean up Claude's code a bit

Branch: GraniteFourDense

Signed-off-by: Gabe Goodhart <ghart@us.ibm.com>

* style: pre-commit format

Branch: GraniteFourDense

Signed-off-by: Gabe Goodhart <ghart@us.ibm.com>

* version bump

---------

Signed-off-by: Gabe Goodhart <ghart@us.ibm.com>
Co-authored-by: Awni Hannun <awni@apple.com>
2025-10-02 07:21:08 -07:00
Ivan Fioravanti cf8cfd0a1b Add Apriel 1.5 (#520) 2025-10-01 21:49:16 -07:00
Nathan Sashihara f96344dfe6 Mixed quantization affects attention in DeepSeek V3, others (#506) 2025-10-01 21:20:37 -07:00
Gökdeniz Gülmez 9a4039a518 Add Olmo3 (#445)
* in. com.

* done

* making it trainable

* upd. ackn.

* format

* make tie_word_embeddings false

* fix index_id number

* default layer_types

* nits

* working inference

* finish

* finish

* format

* nits

* comment

---------

Co-authored-by: Awni Hannun <awni@apple.com>
2025-10-01 16:03:13 -07:00
Awni Hannun 16ca0b3a17 simplify to_lora (#515) 2025-09-30 10:23:57 -07:00
Tarjei Mandt e1e66c0d64 Fix: Correct weight masking for zero-computation experts in LongCat Flash MoE (#508) 2025-09-30 08:45:07 -07:00
Tarjei Mandt 380789d067 Fix batching for models with nested cache structures (#510) 2025-09-30 07:59:57 -07:00
Awni Hannun a1d079e930 fix bailing moe (#514) 2025-09-29 13:42:18 -07:00
Awni Hannun 0c0b72221f Use depends in pipeline parallel (#483) 2025-09-26 16:42:51 -07:00
Daniel Nakov dcb4b9ba6d Add Code World Model support (#505)
* Add sliding-window support to LLaMA

* nits

* version

---------

Co-authored-by: dnakov <3777433+dnakov@users.noreply.github.com>
Co-authored-by: Awni Hannun <awni@apple.com>
2025-09-26 15:22:12 -07:00
Awni Hannun 358b4d2ab5 fix (#503) 2025-09-26 08:48:24 -07:00
Prince Canuma 1a4d24ed5f Add Falcon H1 (#231)
* working inference

* minor refactor

* update rope

* add multipliers

* add gated rms

* temp fix

* fix all issues

* Empty commit message

Co-authored-by: Hamza Yous <HamzaYousLM@users.noreply.github.com>

* creds

Co-authored-by: Hamza Yous <HamzaYousLM@users.noreply.github.com>

* fix conv weight sanitize

* add tests

* rename config to args

* refactor RMSNormGated

* remove unused

* fix  multi-turn chat

* format

* replace at and set

* optimize infer: 42 -> 45 tok/s

* generate mup vector in Model

* remove comment

* refactor cache

* update mamba mask

* remove cache pos

* cleanup and speedup

* more cleanup

* more cleanup

* use mamba op + big speedup

* Fix batching with cache list

---------

Co-authored-by: younesbelkada <younes.belkada@tii.ae>
Co-authored-by: Hamza Yous <HamzaYousLM@users.noreply.github.com>
Co-authored-by: Awni Hannun <awni@apple.com>
2025-09-24 07:58:23 -07:00
Awni Hannun 47e1710f23 qwen3 next batching (#478)
* qwen3 next batching

* fix None mask
2025-09-23 20:59:11 -07:00
Awni Hannun 50012d153d Add batch support for sliding window cache (#487)
* add batch support for sliding window cache

* fix

* fix masks

* fix cache

* another test

* comment
2025-09-23 20:56:47 -07:00
Awni Hannun eaf1748ea5 enable training for qwen3 next (#496) 2025-09-23 15:50:38 -07:00
Awni Hannun ffc0ecc1ca fix loading for qwen2 VL (#491) 2025-09-23 13:12:37 -07:00
Awni Hannun 4096aabdba fix for LFM2 (#493) 2025-09-23 13:12:28 -07:00
Awni Hannun 36963eec80 Fix KV cache quantization for hybrid models (#495) 2025-09-23 13:12:17 -07:00
Aria Wong f22120ef83 Fixing missing parameter passing for model_config in utils.load() (#494) 2025-09-23 13:02:35 -07:00
56 changed files with 3239 additions and 805 deletions
+3 -3
View File
@@ -11,9 +11,9 @@ MLX LM was developed with contributions from the following individuals:
- Gökdeniz Gülmez: Added support for the following architectures: OpenBMB's
`MiniCPM` and `MiniCPM3`, Kyutai's `Helium`, State-Space's `Mamba v1`, `Mamba v2`, Z.ai &
THUKEG's `GLM`, `GLM4`, Rednote `dots.llm1`, Baisu's `Ernie4.5 MoE`, inclusionAI's
`Bailing MoE e.g. Ling-family`, Klear team - Kuaishou Technology's `Klear`,
IBM's `Granite MoE`, Meituan's `LongCat`, Nvidia's `Nemotron H`, Swiss-AI's
`Apertus`, Nikity's `Lille130m`, Alibaba Qwen's `Qwen3Next`, and Allenai's `OLMoE`;
`Bailing MoE e.g. Ling-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`, Alibaba Qwen's `Qwen3Next`, and Allenai's `OLMoE` and `Olmo 3`;
Helped add support for the following model architectures: Alibaba Qwen's `Qwen3 & Qwen3MoE)`;
Added support for the following training algorithms: `Full Weight Fine-Tuning`, and the `Muon`
optimizer; Added support for the following other features: `Multiple Optimizers
+63
View File
@@ -0,0 +1,63 @@
# Benchmarks
## Commands
The command for evaluating on MMLU Pro:
```
mlx_lm.evaluate --model model/repo --task mmlu_pro
```
The command for efficiency benchmarks:
```
mlx_lm.benchmark --model model/repo -p 2048 -g 128
```
To get the package versions run:
```
python -m mlx --version && python -m mlx_lm --version
```
## Models
<details>
<summary> Qwen/Qwen3-4B-Instruct-2507 </summary>
Precision | MMLU Pro | Prompt (2048) tok/sec | Generation (128) tok/sec | Memory GB | Repo
--------- | -------- | ------------------- | ------------------------ | --------- | ----
bf16 | 64.05 | 1780.63 | 52.47 | 9.02 | Qwen/Qwen3-4B-Instruct-2507
q8 | 63.85 | 1606.573| 86.907 | 5.254 | mlx-community/Qwen3-4B-Instruct-2507-8bit
q6 | 63.53 | 1576.73 | 104.68 | 4.25 | mlx-community/Qwen3-4B-Instruct-2507-6bit
q5 g32 | 63.16 | 1570.80 | 110.29 | 4.00 | mlx-community/Qwen3-4B-Instruct-2507-5bit-g32
q5 | 62.38 | 1584.33 | 116.39 | 3.86 | mlx-community/Qwen3-4B-Instruct-2507-5bit
q4 g32 | 61.46 | 1610.03 | 126.00 | 3.603 | mlx-community/Qwen3-4B-Instruct-2507-4bit-g32
q4 | 60.72 | 1622.27 | 134.52 | 3.35 | mlx-community/Qwen3-4B-Instruct-2507-4bit
- Performance benchmark on 64GB M4 Max
- mlx 0.29.2.dev20251008+85a8824a8
- mlx-lm 0.28.2
- macOS 26.1
</details>
<details>
<summary> Qwen/Qwen3-30B-A3B-Instruct-2507 </summary>
Precision | MMLU Pro | Prompt (2048) tok/sec | Generation (128) tok/sec | Memory GB | Repo
--------- | -------- | ------------------- | ------------------------ | --------- | ----
bf16 | 72.62 | :skull: | :skull: | :skull: | Qwen/Qwen3-30B-A3B-Instruct-2507
q8 | 72.46 | 1719.47 | 83.16 | 33.46 | mlx-community/Qwen3-30B-A3B-Instruct-2507-8bit
q6 | 72.41 | 1667.45 | 94.14 | 25.82 | mlx-community/Qwen3-30B-A3B-Instruct-2507-6bit
q5 | 71.97 | 1664.24 | 101.00 |22.01 | mlx-community/Qwen3-30B-A3B-Instruct-2507-5bit
q4 | 70.71 | 1753.90 | 113.33 |18.20 | mlx-community/Qwen3-30B-A3B-Instruct-2507-4bit
- Performance benchmarks on 64GB M4 Max
- mlx 0.29.2.dev20251008+85a8824a8
- mlx-lm 0.28.2
- macOS 26.1
</details>
+1 -1
View File
@@ -129,7 +129,7 @@ mlx_lm.awq --help
Use `mlx_lm.gptq` to run GPTQ on a given model. For example:
```bash
mlx_lm.awq --model Qwen/Qwen3-0.6B
mlx_lm.gptq --model Qwen/Qwen3-0.6B
```
The script can take anywhere from a few minutes to several hours depending on
+4 -1
View File
@@ -371,7 +371,10 @@ of memory. Here are some tips to reduce memory use should you need to do so:
2. Try using a smaller batch size with `--batch-size`. The default is `4` so
setting this to `2` or `1` will reduce memory consumption. This may slow
things down a little, but will also reduce the memory use.
things down a little, but will also reduce the memory use. You can increase
the effective batch size without increasing the memory use by accumulating
gradients using `--grad-accumulation-steps <N>` which will accumulate the
gradient of `<N>` batches before updating the parameters.
3. Reduce the number of layers to fine-tune with `--num-layers`. The default
is `16`, so you can try `8` or `4`. This reduces the amount of memory
+1 -19
View File
@@ -30,8 +30,6 @@ To see a full list of options run:
mlx_lm.server --help
```
## Chat completions API
You can make a request to the model by running:
```shell
@@ -130,23 +128,7 @@ curl localhost:8080/v1/chat/completions \
- `completion_tokens`: The number of tokens generated.
- `total_tokens`: The total number of tokens, i.e. the sum of the above two fields.
## Responses API
The responses API follows the [OpenAI responses API
spec](https://platform.openai.com/docs/quickstart?api-mode=responses)
To make a request, use the `/reponses` endpoint. For exapmle:
```shell
curl localhost:8080/responses \
-H "Content-Type: application/json" \
-d '{
"input": [{"role": "user", "content": "Say this is a test!"}],
"temperature": 0.7
}'
```
## Models API
### List Models
Use the `v1/models` endpoint to list available models:
+8 -3
View File
@@ -25,7 +25,12 @@ if __name__ == "__main__":
if len(sys.argv) < 2:
raise ValueError(f"CLI requires a subcommand in {subcommands}")
subcommand = sys.argv.pop(1)
if subcommand not in subcommands:
if subcommand in subcommands:
submodule = importlib.import_module(f"mlx_lm.{subcommand}")
submodule.main()
elif subcommand == "--version":
from mlx_lm import __version__
print(__version__)
else:
raise ValueError(f"CLI requires a subcommand in {subcommands}")
submodule = importlib.import_module(f"mlx_lm.{subcommand}")
submodule.main()
+1 -1
View File
@@ -1,3 +1,3 @@
# Copyright © 2023-2025 Apple Inc.
__version__ = "0.28.0"
__version__ = "0.28.3"
+10 -18
View File
@@ -4,13 +4,8 @@ import argparse
import mlx.core as mx
from mlx_lm import batch_generate, stream_generate
from mlx_lm import batch_generate, load, stream_generate
from mlx_lm.generate import DEFAULT_MODEL
from mlx_lm.tokenizer_utils import load_tokenizer
from mlx_lm.utils import (
fetch_from_hub,
get_model_path,
)
def setup_arg_parser():
@@ -25,11 +20,6 @@ def setup_arg_parser():
),
default=None,
)
parser.add_argument(
"--trust-remote-code",
action="store_true",
help="Enable trusting remote code for tokenizer",
)
parser.add_argument(
"--prompt-tokens",
"-p",
@@ -68,14 +58,13 @@ def main():
model_path = args.model or DEFAULT_MODEL
model_path, _ = get_model_path(model_path, revision=None)
model, config, _ = fetch_from_hub(model_path, trust_remote_code=True)
tokenizer = load_tokenizer(
model_path,
eos_token_ids=[], # Empty to avoid early stopping
tokenizer_config_extra={"trust_remote_code": True},
model, tokenizer, config = load(
args.model, return_config=True, tokenizer_config={"trust_remote_code": True}
)
# Empty to avoid early stopping
tokenizer._eos_token_ids = {}
prompt_tokens = args.prompt_tokens
generation_tokens = args.generation_tokens
batch_size = args.batch_size
@@ -96,7 +85,10 @@ def main():
model, tokenizer, prompts, max_tokens=generation_tokens
).stats
_bench = batch_bench
if batch_size == 1:
_bench = single_bench
else:
_bench = batch_bench
print("Running warmup..")
_bench()
+11 -8
View File
@@ -10,8 +10,7 @@ from mlx.utils import tree_map_with_path
from .utils import (
dequantize_model,
fetch_from_hub,
get_model_path,
load,
quantize_model,
save,
upload_to_hub,
@@ -63,7 +62,9 @@ def mixed_quant_predicate_builder(
or index >= 7 * num_layers // 8
or (index - num_layers // 8) % 3 == 2
)
if "v_proj" in path and use_more_bits:
if (
"v_proj" in path or "v_a_proj" in path or "v_b_proj" in path
) and use_more_bits:
return {"group_size": group_size, "bits": high_bits}
if "down_proj" in path and use_more_bits:
return {"group_size": group_size, "bits": high_bits}
@@ -107,9 +108,12 @@ def convert(
)
print("[INFO] Loading")
model_path, hf_path = get_model_path(hf_path, revision=revision)
model, config, tokenizer = fetch_from_hub(
model_path, lazy=True, trust_remote_code=trust_remote_code
model, tokenizer, config = load(
hf_path,
revision=revision,
return_config=True,
tokenizer_config={"trust_remote_code": trust_remote_code},
lazy=True,
)
if isinstance(quant_predicate, str):
@@ -154,11 +158,10 @@ def convert(
save(
mlx_path,
model_path,
hf_path,
model,
tokenizer,
config,
hf_repo=hf_path,
)
if upload_repo is not None:
+39 -1
View File
@@ -304,11 +304,16 @@ class MLXLM(LM):
continuation: str
The generated continuation.
"""
group = mx.distributed.init()
# split data accross ranks
total_requests = len(requests)
requests = requests[group.rank() :: group.size()]
logging.info("Generating continuation for %d sequences." % len(requests))
contexts, options = zip(*[req.args for req in requests])
# The second element of the tuple contains:
# {'do_sample': False, 'until': ['\n\n'], 'temperature': 0}
completions = []
# Tokenize all contexts
contexts = [
@@ -333,6 +338,39 @@ class MLXLM(LM):
until = opt["until"]
if any(u in text for u in until):
completions[e] = _rstrip_until(text, until)
# Gather the completions
if group.size() > 1:
with mx.stream(mx.cpu):
pad_to = (total_requests + group.size() - 1) // group.size()
pad = pad_to - len(completions)
completions = [list(c.encode("utf-8")) for c in completions]
max_len = mx.array(max(len(c) for c in completions))
max_len = mx.distributed.all_max(max_len).item()
lengths = mx.array([len(c) for c in completions] + [0] * pad)
completions = mx.array(
[c + [0] * (max_len - len(c)) for c in completions]
+ [[0] * max_len] * pad,
mx.uint8,
)
completions = (
mx.distributed.all_gather(completions[None])
.swapaxes(0, 1)
.flatten(0, 1)
.tolist()
)
lengths = (
mx.distributed.all_gather(lengths[None])
.swapaxes(0, 1)
.flatten(0, 1)
.tolist()
)
completions = completions[:total_requests]
lengths = lengths[:total_requests]
completions = [
bytearray(c[:l]).decode() for c, l in zip(completions, lengths)
]
return completions
+4 -2
View File
@@ -1,5 +1,5 @@
# The path to the local model directory or Hugging Face repo.
model: "mlx-community/Llama-3.2-1B-Instruct"
model: "mlx-community/Llama-3.2-1B-Instruct-bf16"
# Whether or not to train (boolean)
train: true
@@ -47,6 +47,9 @@ steps_per_report: 10
# Number of training steps between validations.
steps_per_eval: 200
# Number of micro-steps to accumulate before each optimizer update.
grad_accumulation_steps: 1
# Load path to resume training with the given adapter weights.
resume_adapter_file: null
@@ -90,4 +93,3 @@ lora_parameters:
# valid_split: "train[-100:]"
# prompt_feature: "text"
# completion_feature: "summary"
-59
View File
@@ -1,59 +0,0 @@
# Copyright © 2025 Apple Inc.
"""
Examples using the OpenAI responses endpoint with mlx_lm.server.
To run, first start the server:
>>> mlx_lm.server
Then run this script.
More documentation on the API spec here:
https://platform.openai.com/docs/quickstart?api-mode=responses
"""
from openai import OpenAI
model = "mlx-community/Qwen3-4B-Instruct-2507-4bit"
### Basic response example
client = OpenAI(base_url="http://localhost:8080/v1", api_key="not-needed")
response = client.responses.create(
model=model, input="Write a one-sentence bedtime story about a unicorn."
)
print(response.output_text)
### Input with roles
response = client.responses.create(
model=model,
input=[
{
"role": "user",
"content": [
{
"type": "input_text",
"text": "Write a one-sentence bedtime story about a unicorn.",
},
],
}
],
)
print(response.output_text)
### Streaming
stream = client.responses.create(
model=model,
input=[
{
"role": "user",
"content": "Say 'double bubble bath' ten times fast.",
},
],
stream=True,
)
for event in stream:
print(event)
+6 -14
View File
@@ -6,8 +6,7 @@ from mlx.utils import tree_flatten, tree_unflatten
from .gguf import convert_to_gguf
from .tuner.utils import dequantize, load_adapters
from .utils import (
fetch_from_hub,
get_model_path,
load,
save,
upload_to_hub,
)
@@ -62,11 +61,9 @@ def main() -> None:
print("Loading pretrained model")
args = parse_arguments()
model_path, hf_path = get_model_path(args.model)
model, config, tokenizer = fetch_from_hub(model_path)
model.freeze()
model = load_adapters(model, args.adapter_path)
model, tokenizer, config = load(
args.model, adapter_path=args.adapter_path, return_config=True
)
fused_linears = [
(n, m.fuse(de_quantize=args.de_quantize))
@@ -85,11 +82,10 @@ def main() -> None:
save_path = Path(args.save_path)
save(
save_path,
model_path,
args.model,
model,
tokenizer,
config,
hf_repo=hf_path,
donate_model=False,
)
@@ -100,13 +96,9 @@ def main() -> None:
f"Model type {model_type} not supported for GGUF conversion."
)
weights = dict(tree_flatten(model.parameters()))
convert_to_gguf(model_path, weights, config, str(save_path / args.gguf_path))
convert_to_gguf(save_path, weights, config, str(save_path / args.gguf_path))
if args.upload_repo is not None:
if hf_path is None:
raise ValueError(
"Must provide original Hugging Face repo to upload local model."
)
upload_to_hub(args.save_path, args.upload_repo)
+31 -26
View File
@@ -26,8 +26,11 @@ from .models import cache
from .models.cache import (
ArraysCache,
BatchKVCache,
BatchRotatingKVCache,
CacheList,
KVCache,
QuantizedKVCache,
RotatingKVCache,
load_prompt_cache,
)
from .sample_utils import make_sampler
@@ -284,16 +287,11 @@ class GenerationResponse:
def maybe_quantize_kv_cache(prompt_cache, quantized_kv_start, kv_group_size, kv_bits):
if (
kv_bits is not None
and not isinstance(prompt_cache[0], cache.QuantizedKVCache)
and prompt_cache[0].offset > quantized_kv_start
):
for i in range(len(prompt_cache)):
if isinstance(prompt_cache[i], cache.KVCache):
prompt_cache[i] = prompt_cache[i].to_quantized(
group_size=kv_group_size, bits=kv_bits
)
if kv_bits is None:
return
for e, c in enumerate(prompt_cache):
if hasattr(c, "to_quantized") and c.offset >= quantized_kv_start:
prompt_cache[e] = c.to_quantized(group_size=kv_group_size, bits=kv_bits)
def generate_step(
@@ -301,7 +299,7 @@ def generate_step(
model: nn.Module,
*,
max_tokens: int = 256,
sampler: Optional[Callable[mx.array, mx.array]] = None,
sampler: Optional[Callable[[mx.array], mx.array]] = None,
logits_processors: Optional[List[Callable[[mx.array, mx.array], mx.array]]] = None,
max_kv_size: Optional[int] = None,
prompt_cache: Optional[Any] = None,
@@ -309,7 +307,7 @@ def generate_step(
kv_bits: Optional[int] = None,
kv_group_size: int = 64,
quantized_kv_start: int = 0,
prompt_progress_callback: Optional[Callable[int, int]] = None,
prompt_progress_callback: Optional[Callable[[int], int]] = None,
input_embeddings: Optional[mx.array] = None,
) -> Generator[Tuple[mx.array, mx.array], None, None]:
"""
@@ -335,7 +333,7 @@ def generate_step(
kv_group_size (int): Group size for KV cache quantization. Default: ``64``.
quantized_kv_start (int): Step to begin using a quantized KV cache.
when ``kv_bits`` is non-None. Default: ``0``.
prompt_progress_callback (Callable[int, int]): A call-back which takes the
prompt_progress_callback (Callable[[int], int]): A call-back which takes the
prompt tokens processed so far and the total number of prompt tokens.
input_embeddings (mx.array, optional): Input embeddings to use instead of or in
conjunction with prompt tokens. Default: ``None``.
@@ -468,7 +466,7 @@ def speculative_generate_step(
*,
num_draft_tokens: int = 2,
max_tokens: int = 256,
sampler: Optional[Callable[mx.array, mx.array]] = None,
sampler: Optional[Callable[[mx.array], mx.array]] = None,
logits_processors: Optional[List[Callable[[mx.array, mx.array], mx.array]]] = None,
prompt_cache: Optional[Any] = None,
prefill_step_size: int = 512,
@@ -487,7 +485,7 @@ def speculative_generate_step(
speculative decoding. Default: ``2``.
max_tokens (int): The maximum number of tokens. Use``-1`` for an infinite
generator. Default: ``256``.
sampler (Callable[mx.array, mx.array], optional): A sampler for sampling a
sampler (Callable[[mx.array], mx.array], optional): A sampler for sampling a
token from a vector of log probabilities. Default: ``None``.
logits_processors (List[Callable[[mx.array, mx.array], mx.array]], optional):
A list of functions that take tokens and logits and return the processed
@@ -862,18 +860,25 @@ def _make_cache(model, left_padding):
Convert a list of regular caches into their corresponding
batch-aware caches.
"""
def to_batch_cache(c):
if isinstance(c, KVCache):
return BatchKVCache(left_padding)
elif isinstance(c, ArraysCache):
c.left_padding = mx.array(left_padding)
return c
elif isinstance(c, RotatingKVCache):
if c.keep > 0:
raise ValueError("RotatingKVCache with keep tokens is not supported.")
return BatchRotatingKVCache(c.max_size, left_padding)
elif isinstance(c, CacheList):
return CacheList(*(to_batch_cache(sub_c) for sub_c in c.caches))
else:
raise ValueError(f"{type(c)} does not yet support batching")
if hasattr(model, "make_cache"):
cache = model.make_cache()
batch_cache = []
for c in cache:
if isinstance(c, KVCache):
batch_cache.append(BatchKVCache(left_padding))
elif isinstance(c, ArraysCache):
c.left_padding = mx.array(left_padding)
batch_cache.append(c)
else:
raise ValueError(f"{type(c)} does not yet support batching")
return batch_cache
return [to_batch_cache(c) for c in cache]
else:
return [BatchKVCache(left_padding) for _ in model.layers]
@@ -892,7 +897,7 @@ class BatchGenerator:
model,
max_tokens: int = 128,
stop_tokens: Optional[set] = None,
sampler: Optional[Callable[mx.array, mx.array]] = None,
sampler: Optional[Callable[[mx.array], mx.array]] = None,
completion_batch_size: int = 32,
prefill_batch_size: int = 8,
prefill_step_size: int = 2048,
+9 -20
View File
@@ -40,7 +40,7 @@ yaml_loader.add_implicit_resolver(
)
CONFIG_DEFAULTS = {
"model": "mlx_model",
"model": "Qwen/Qwen3-0.6b",
"train": False,
"fine_tune_type": "lora",
"optimizer": "adam",
@@ -51,7 +51,7 @@ CONFIG_DEFAULTS = {
"sgd": {},
"adafactor": {},
},
"data": "data/",
"data": "mlx-community/WikiSQL",
"seed": 0,
"num_layers": 16,
"batch_size": 4,
@@ -68,10 +68,10 @@ CONFIG_DEFAULTS = {
"max_seq_length": 2048,
"config": None,
"grad_checkpoint": False,
"grad_accumulation_steps": 1,
"lr_schedule": None,
"lora_parameters": {"rank": 8, "dropout": 0.0, "scale": 20.0},
"mask_prompt": False,
"wandb": None, # will be deprecated in a future release
"report_to": None,
"project_name": None,
}
@@ -142,6 +142,11 @@ def build_parser():
type=int,
help="Number of training steps between validations.",
)
parser.add_argument(
"--grad-accumulation-steps",
type=int,
help="Number of steps to accumulate before each optimizer update.",
)
parser.add_argument(
"--resume-adapter-file",
type=str,
@@ -185,15 +190,6 @@ def build_parser():
help="Use gradient checkpointing to reduce memory use.",
default=None,
)
parser.add_argument( # will be deprecated in a future release
"--wandb",
type=str,
default=None,
help=(
"The 'wandb' argument is deprecated and will be removed in a future release. "
"Use 'report_to: wandb' and 'project_name' in the configuration instead."
),
)
parser.add_argument(
"--report-to",
type=str,
@@ -265,6 +261,7 @@ def train_model(
adapter_file=adapter_file,
max_seq_length=args.max_seq_length,
grad_checkpoint=args.grad_checkpoint,
grad_accumulation_steps=args.grad_accumulation_steps,
)
# Initialize the selected optimizer
@@ -314,14 +311,6 @@ def evaluate_model(args, model: nn.Module, test_set):
def run(args, training_callback: TrainingCallback = None):
np.random.seed(args.seed)
if args.wandb is not None:
warnings.warn(
"The 'wandb' argument is deprecated and will be removed in a future release. "
"Use 'report_to: wandb' and 'project_name' in the configuration instead.",
DeprecationWarning,
)
args.report_to = "wandb"
args.project_name = args.wandb
training_callback = get_reporting_callbacks(
args.report_to,
project_name=args.project_name,
+23 -6
View File
@@ -1,6 +1,7 @@
# Copyright © 2025 Apple Inc.
from dataclasses import dataclass
from functools import partial
from typing import Any, Dict, Optional, Union
import mlx.core as mx
@@ -37,6 +38,7 @@ class ModelArgs(BaseModelArgs):
use_qk_norm: bool = False
tie_word_embeddings: bool = False
partial_rotary_factor: float = 1.0
rotary_dim: Optional[int] = None
moe_router_enable_expert_bias: bool = False
moe_router_enable_routed_scaling: bool = True
routed_scaling_factor: float = 1.0
@@ -47,6 +49,18 @@ class ModelArgs(BaseModelArgs):
moe_router_enable_shared_expert: bool = True
@partial(mx.compile, shapeless=True)
def swiglu(gate, up):
return nn.silu(gate) * up
@partial(mx.compile, shapeless=True)
def aggregate_expert_outputs(expert_outputs, scores):
return (
(expert_outputs * scores[..., None]).sum(axis=-2).astype(expert_outputs.dtype)
)
class BailingMoeMLP(nn.Module):
def __init__(self, args: ModelArgs, intermediate_size: Optional[int] = None):
super().__init__()
@@ -67,7 +81,7 @@ class BailingMoeMLP(nn.Module):
)
def __call__(self, x) -> mx.array:
return self.down_proj(nn.silu(self.gate_proj(x)) * self.up_proj(x))
return self.down_proj(swiglu(self.gate_proj(x), self.up_proj(x)))
class BailingMoeAttention(nn.Module):
@@ -94,8 +108,10 @@ class BailingMoeAttention(nn.Module):
self.key_layernorm = nn.RMSNorm(self.head_dim, eps=args.rms_norm_eps)
self.query_layernorm = nn.RMSNorm(self.head_dim, eps=args.rms_norm_eps)
if (rope_dim := args.rotary_dim) is None:
rope_dim = int(self.head_dim * args.partial_rotary_factor)
self.rope = initialize_rope(
int(self.head_dim * args.partial_rotary_factor),
rope_dim,
args.rope_theta,
traditional=False,
scaling_config=args.rope_scaling,
@@ -146,6 +162,7 @@ class BailingMoeAttention(nn.Module):
return self.dense(output)
@mx.compile
def group_expert_select(
gates,
e_score_correction_bias,
@@ -171,15 +188,15 @@ def group_expert_select(
k = n_group - topk_group
group_idx = mx.argpartition(group_scores, kth=k - 1, axis=-2)[..., :k, :]
scores = mx.put_along_axis(
scores, mx.stop_gradient(group_idx), mx.array(0.0), axis=-2
scores, mx.stop_gradient(group_idx), mx.array(0.0, scores.dtype), axis=-2
)
scores = mx.flatten(scores, -2, -1)
k = top_k
inds = mx.argpartition(-scores, kth=k - 1, axis=-1)[..., :k]
inds = mx.argpartition(scores, kth=-k, axis=-1)[..., -k:]
scores = mx.take_along_axis(orig_scores, inds, axis=-1)
if top_k > 1 and norm_topk_prob:
denominator = scores.sum(axis=-1, keepdims=True)
denominator = scores.sum(axis=-1, keepdims=True) + 1e-20
scores = scores / denominator
scores = scores * routed_scaling_factor
@@ -245,7 +262,7 @@ class BailingMoeSparseMoeBlock(nn.Module):
def __call__(self, x):
topk_idx, topk_weight = self.gate(x)
out = self.switch_mlp(x, topk_idx)
out = (out * topk_weight[..., None]).sum(axis=-2)
out = aggregate_expert_outputs(out, topk_weight)
if self.shared_experts is not None:
out = out + self.shared_experts(x)
return out
+272 -23
View File
@@ -1,5 +1,6 @@
# Copyright © 2023-2024 Apple Inc.
import copy
from typing import Any, Dict, List, Optional
import mlx.core as mx
@@ -73,10 +74,10 @@ def load_prompt_cache(file_name, return_metadata=False):
arrays = tree_unflatten(list(arrays.items()))
cache_metadata = tree_unflatten(list(cache_metadata.items()))
info, metadata, classes = cache_metadata
cache = [globals()[c]() for c in classes]
for c, state, meta_state in zip(cache, arrays, info):
c.state = state
c.meta_state = meta_state
cache = [
globals()[c].from_state(state, meta_state)
for c, state, meta_state in zip(classes, arrays, info)
]
if return_metadata:
return cache, metadata
return cache
@@ -141,6 +142,14 @@ class _BaseCache:
def is_trimmable(self):
return False
@classmethod
def from_state(cls, state, meta_state):
# Create an instance of cls without calling __init__
obj = cls.__new__(cls)
obj.state = state
obj.meta_state = meta_state
return obj
class ConcatenateKVCache(_BaseCache):
"""ConcatenateKVCache the simplest KV cache implementation.
@@ -188,11 +197,12 @@ class ConcatenateKVCache(_BaseCache):
class QuantizedKVCache(_BaseCache):
step = 256
def __init__(self, group_size: int = 64, bits: int = 8):
self.keys = None
self.values = None
self.offset = 0
self.step = 256
self.group_size = group_size
self.bits = bits
@@ -254,11 +264,11 @@ class QuantizedKVCache(_BaseCache):
@property
def meta_state(self):
return tuple(map(str, (self.step, self.offset, self.group_size, self.bits)))
return tuple(map(str, (self.offset, self.group_size, self.bits)))
@meta_state.setter
def meta_state(self, v):
self.step, self.offset, self.group_size, self.bits = map(int, v)
self.offset, self.group_size, self.bits = map(int, v)
def is_trimmable(self):
return True
@@ -273,11 +283,12 @@ class QuantizedKVCache(_BaseCache):
class KVCache(_BaseCache):
step = 256
def __init__(self):
self.keys = None
self.values = None
self.offset = 0
self.step = 256
def update_and_fetch(self, keys, values):
prev = self.offset
@@ -341,14 +352,14 @@ class KVCache(_BaseCache):
class RotatingKVCache(_BaseCache):
step = 256
def __init__(self, max_size=None, keep=0, step=256):
def __init__(self, max_size, keep=0):
self.keep = keep
self.keys = None
self.values = None
self.offset = 0
self.max_size = max_size
self.step = step
self._idx = 0
def _trim(self, trim_size, v, append=None):
@@ -388,10 +399,11 @@ class RotatingKVCache(_BaseCache):
# preserve context
self.keys = self._temporal_order(self.keys)
self.values = self._temporal_order(self.values)
self._idx = self.keys.shape[2]
# The largest size is self.max_size + S to ensure
# The largest size is self.max_size + S - 1 to ensure
# every token gets at least self.max_size context
trim_size = self._idx - self.max_size
trim_size = self._idx - self.max_size + 1
self.keys = self._trim(trim_size, self.keys, keys)
self.values = self._trim(trim_size, self.values, values)
self.offset += keys.shape[2]
@@ -459,13 +471,11 @@ class RotatingKVCache(_BaseCache):
@property
def meta_state(self):
return tuple(
map(str, (self.keep, self.max_size, self.step, self.offset, self._idx))
)
return tuple(map(str, (self.keep, self.max_size, self.offset, self._idx)))
@meta_state.setter
def meta_state(self, v):
self.keep, self.max_size, self.step, self.offset, self._idx = map(
self.keep, self.max_size, self.offset, self._idx = map(
int,
v,
)
@@ -487,7 +497,7 @@ class RotatingKVCache(_BaseCache):
):
if N > 1:
window_size = window_size or self.max_size
offset = min(self.max_size, self.offset)
offset = min(self.max_size - 1, self.offset)
if offset + N > window_size or return_array:
return create_causal_mask(N, offset, window_size=window_size)
else:
@@ -500,16 +510,19 @@ class RotatingKVCache(_BaseCache):
idx = self._idx
if idx >= self.max_size:
idx = 0
mask_size = min(self.max_size, self.offset)
if self.offset < self.max_size:
mask_size = self.offset + 1
else:
mask_size = self.max_size
mask = mx.arange(mask_size) >= (mask_size - window_size)
mask = mx.roll(mask, shift=idx + 1)
return mask[:, None]
return mask
class ArraysCache(_BaseCache):
def __init__(self, size, left_padding: Optional[List[int]] = None):
self.cache = [None] * size
self.left_padding = left_padding
self.left_padding = mx.array(left_padding) if left_padding else None
def __setitem__(self, idx, value):
self.cache[idx] = value
@@ -552,7 +565,7 @@ class MambaCache(ArraysCache):
class ChunkedKVCache(KVCache):
def __init__(self, chunk_size=None):
def __init__(self, chunk_size):
super().__init__()
self.chunk_size = chunk_size
self.start_position = 0
@@ -603,7 +616,7 @@ class ChunkedKVCache(KVCache):
self.chunk_size, self.start_position = map(int, v)
class CacheList(KVCache):
class CacheList(_BaseCache):
def __init__(self, *caches):
self.caches = caches
@@ -631,8 +644,24 @@ class CacheList(KVCache):
c.state = v[start : start + l]
start += l
def filter(self, batch_indices):
"""
In-place filter to keep just the given indices in the cache.
"""
for c in self.caches:
c.filter(batch_indices)
def extend(self, other):
"""
In-place extend this cache with the other cache.
"""
for c, o in zip(self.caches, other.caches):
c.extend(o)
class BatchKVCache(_BaseCache):
step = 256
def __init__(self, left_padding: List[int]):
"""
The BatchKV cache expects inputs to be left-padded.
@@ -657,7 +686,6 @@ class BatchKVCache(_BaseCache):
self.left_padding = mx.array(left_padding)
self.offset = mx.array([-l for l in left_padding])
self._idx = 0
self.step = 256
def update_and_fetch(self, keys, values):
prev = self._idx
@@ -756,3 +784,224 @@ class BatchKVCache(_BaseCache):
mx.concatenate, zip(*(pad(self), pad(other)))
)
self._idx = max_idx
class BatchRotatingKVCache(_BaseCache):
step = 256
def __init__(self, max_size, left_padding: List[int]):
self.keys = None
self.values = None
self.left_padding = mx.array(left_padding)
self.offset = mx.array([-l for l in left_padding])
self.max_size = max_size
self._idx = 0
self._offset = 0
self.rotated = False
def _trim(self, trim_size, v, append=None):
if trim_size > 0:
v = v[..., trim_size:, :]
if append is not None:
return mx.concatenate([v, append], axis=2)
return v
def _temporal_order(self):
"""
Rearrange the cache into temporal order.
"""
if self.rotated:
self.keys = mx.roll(self.keys, -self._idx, axis=2)
self.values = mx.roll(self.values, -self._idx, axis=2)
self._idx = self.keys.shape[2]
self.rotated = False
def _update_concat(self, keys, values):
if self.keys is None:
self.keys = keys
self.values = values
else:
# Put the keys/values in temporal order to
# preserve context
self._temporal_order()
# Slice off the end if needed
if self.keys.shape[2] > self._idx:
self.keys = self.keys[..., : self._idx, :]
self.values = self.values[..., : self._idx, :]
# The largest size is self.max_size + S - 1 to ensure
# every token gets at least self.max_size context
trim_size = self._idx - self.max_size + 1
if trim_size > 0:
self.left_padding -= trim_size
self.keys = self._trim(trim_size, self.keys, keys)
self.values = self._trim(trim_size, self.values, values)
self.offset += keys.shape[2]
self._offset += keys.shape[2]
self._idx = self.keys.shape[2]
return self.keys, self.values
def _update_in_place(self, keys, values):
# May not have hit the max size yet, so potentially
# keep growing the cache
B, n_kv_heads, S, k_head_dim = keys.shape
prev = self._offset
if self.keys is None or (
prev >= self.keys.shape[2] and self.keys.shape[2] < self.max_size
):
v_head_dim = values.shape[3]
new_size = min(self.step, self.max_size - prev)
k_shape = (B, n_kv_heads, new_size, k_head_dim)
v_shape = (B, n_kv_heads, new_size, v_head_dim)
new_k = mx.zeros(k_shape, keys.dtype)
new_v = mx.zeros(v_shape, values.dtype)
if self.keys is not None:
self.keys = mx.concatenate([self.keys, new_k], axis=2)
self.values = mx.concatenate([self.values, new_v], axis=2)
else:
self.keys, self.values = new_k, new_v
self._idx = prev
# Trim if needed
trim_size = self.keys.shape[2] - self.max_size
if trim_size > 0:
self.keys = self._trim(trim_size, self.keys)
self.values = self._trim(trim_size, self.values)
self._idx = self.max_size
self.left_padding -= trim_size
# Rotate
if self._idx == self.max_size:
self.rotated = True
self._idx = 0
if self.rotated:
self.left_padding -= S
# Assign
self.keys[..., self._idx : self._idx + S, :] = keys
self.values[..., self._idx : self._idx + S, :] = values
self._offset += S
self.offset += S
self._idx += S
# If the buffer is not full, slice off the end
if self._offset < self.max_size:
return (
self.keys[..., : self._offset, :],
self.values[..., : self._offset, :],
)
return self.keys, self.values
def update_and_fetch(self, keys, values):
if keys.shape[2] == 1:
return self._update_in_place(keys, values)
return self._update_concat(keys, values)
@property
def state(self):
k, v = self.keys, self.values
if self._offset < k.shape[2]:
k, v = k[..., : self._offset, :], v[..., : self._offset, :]
return k, v, self.offset, self.left_padding
@state.setter
def state(self, v):
self.keys, self.values, self.offset, self.left_padding = v
@property
def meta_state(self):
return tuple(map(str, (self.max_size, self._offset, self._idx, self.rotated)))
@meta_state.setter
def meta_state(self, v):
self.max_size, self._offset, self._idx = map(
int,
v[:3],
)
self.rotated = bool(v[3])
def is_trimmable(self):
return self._offset < self.max_size
def trim(self, n):
n = min(self._offset, n)
self._offset -= n
self._idx -= n
self.offset -= n
return n
def to_quantized(self, group_size: int = 64, bits: int = 4) -> QuantizedKVCache:
raise NotImplementedError("BatchRotatingKVCache Quantization NYI")
def make_mask(
self, N: int, window_size: Optional[int] = None, return_array: bool = False
):
left_padding = self.left_padding
window_size = window_size or self.max_size
offset = min(self.max_size - 1, self._offset)
rinds = mx.arange(offset + N)
linds = mx.arange(offset, offset + N) if offset else rinds
linds = linds[:, None]
rinds = rinds[None]
mask = linds >= rinds
mask &= linds < rinds + window_size
if (trim_size := self._idx - self.max_size + int(N > 1)) > 0:
left_padding = left_padding - trim_size
rotated = N == 1 and (self.rotated or self._idx >= self.max_size)
if rotated:
left_padding = left_padding - 1
mask = mask & (rinds >= mx.expand_dims(left_padding, (1, 2, 3)))
if rotated:
idx = self._idx
if idx >= self.max_size:
idx = 0
mask = mx.roll(mask, shift=idx + 1, axis=-1)
return mask
def filter(self, batch_indices):
"""
In-place filter to keep just the given indices in the cache.
"""
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]
def extend(self, other):
"""
In-place extend this cache with the other cache.
"""
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])
def pad(c):
left = max_idx - c._idx
right = max_size - c.keys.shape[2] - left
k, v = c.keys, c.values
if right < 0:
k = k[..., :right, :]
v = v[..., :right, :]
right = 0
if left != 0 or right != 0:
pad = [(0, 0), (0, 0), (left, right), (0, 0)]
k = mx.pad(k, pad)
v = mx.pad(v, pad)
left_padding = c.left_padding + left
return k, v, c.offset, left_padding
self.keys, self.values, self.offset, self.left_padding = map(
mx.concatenate, zip(*(pad(self), pad(other)))
)
self._idx = max_idx
self._offset = max(self._offset, other._offset)
+2
View File
@@ -414,6 +414,8 @@ class DeepseekV2Model(nn.Module):
# Send to the next process in the pipeline
if pipeline_rank != 0:
h = mx.distributed.send(h, (pipeline_rank - 1) % pipeline_size)
if cache[-1] is not None:
cache[-1].keys = mx.depends(cache[-1].keys, h)
# Broadcast h while keeping it in the graph
h = mx.distributed.all_gather(h)[: h.shape[0]]
+2
View File
@@ -446,6 +446,8 @@ class DeepseekV3Model(nn.Module):
# Send to the next process in the pipeline
if pipeline_rank != 0:
h = mx.distributed.send(h, (pipeline_rank - 1) % pipeline_size)
if cache[-1] is not None:
cache[-1].keys = mx.depends(cache[-1].keys, h)
# Broadcast h while keeping it in the graph
h = mx.distributed.all_gather(h)[: h.shape[0]]
+479
View File
@@ -0,0 +1,479 @@
# Copyright © 2025 Apple Inc.
from dataclasses import dataclass, field
from typing import List, Optional
import mlx.core as mx
import mlx.nn as nn
from .base import (
BaseModelArgs,
create_attention_mask,
create_ssm_mask,
scaled_dot_product_attention,
)
from .cache import CacheList, KVCache, MambaCache
from .rope_utils import initialize_rope
from .ssm import ssm_update
@dataclass
class ModelArgs(BaseModelArgs):
attention_bias: bool = False
attention_in_multiplier: float = 1.0
attention_out_multiplier: float = 0.9375
embedding_multiplier: float = 5.656854249492381
head_dim: int = 64
hidden_size: int = 1024
initializer_range: float = 0.02
intermediate_size: int = 2048
key_multiplier: float = 0.390625
lm_head_multiplier: float = 0.0390625
mamba_chunk_size: int = 128
mamba_conv_bias: bool = True
mamba_d_conv: int = 4
mamba_d_head: int = 64
mamba_d_ssm: int = 1536
mamba_d_state: int = 128
mamba_expand: int = 2
mamba_n_groups: int = 1
mamba_n_heads: int = 24
mamba_norm_before_gate: bool = False
mamba_proj_bias: bool = False
mamba_rms_norm: bool = False
mamba_use_mlp: bool = True
max_position_embeddings: int = 131072
mlp_bias: bool = False
mlp_expansion_factor: int = 8
mlp_multipliers: List[float] = field(
default_factory=lambda: [0.8838834764831844, 0.5859375]
)
model_type: str = "falcon_h1"
num_attention_heads: int = 8
num_hidden_layers: int = 36
num_key_value_heads: int = 2
projectors_bias: bool = False
rms_norm_eps: float = 1e-05
rope_traditional: bool = False
rope_scaling: Optional[float] = None
rope_theta: float = 100000000000.0
ssm_in_multiplier: float = 1.25
ssm_multipliers: List[float] = field(
default_factory=lambda: [
0.3535533905932738,
0.25,
0.3535533905932738,
0.5,
0.3535533905932738,
]
)
ssm_out_multiplier: float = 0.23570226039551587
vocab_size: int = 32784
class FalconH1RMSNormGated(nn.Module):
def __init__(self, hidden_size, eps=1e-6, n_groups=1, norm_before_gate=True):
super().__init__()
self.weight = mx.ones((hidden_size,))
self.variance_epsilon = eps
self.n_groups = n_groups
self.norm_before_gate = norm_before_gate
def __call__(self, hidden_states, gate=None):
if not self.norm_before_gate and gate is not None:
hidden_states = hidden_states * nn.silu(gate)
hidden_states = mx.fast.rms_norm(
hidden_states, self.weight, self.variance_epsilon
)
if self.norm_before_gate and gate is not None:
hidden_states = hidden_states * nn.silu(gate)
return hidden_states
def compute_mup_vector(args):
intermediate_size = args.mamba_d_ssm
groups_time_state_size = args.mamba_n_groups * args.mamba_d_state
num_heads = args.mamba_n_heads
sizes = [
intermediate_size,
intermediate_size,
groups_time_state_size,
groups_time_state_size,
num_heads,
]
return mx.concatenate(
[
mx.broadcast_to(mx.array(m), (s,))
for s, m in zip(sizes, args.ssm_multipliers)
]
)
class FalconH1Attention(nn.Module):
def __init__(self, args):
super().__init__()
self.hidden_size = args.hidden_size
self.num_heads = args.num_attention_heads
self.num_kv_heads = args.num_key_value_heads
self.head_dim = args.head_dim
self.scale = self.head_dim**-0.5
self.q_proj = nn.Linear(
self.hidden_size, self.num_heads * self.head_dim, bias=args.attention_bias
)
self.k_proj = nn.Linear(
self.hidden_size,
self.num_kv_heads * self.head_dim,
bias=args.attention_bias,
)
self.v_proj = nn.Linear(
self.hidden_size,
self.num_kv_heads * self.head_dim,
bias=args.attention_bias,
)
self.o_proj = nn.Linear(
self.num_heads * self.head_dim, self.hidden_size, bias=args.attention_bias
)
self.rope = initialize_rope(
self.head_dim,
args.rope_theta,
args.rope_traditional,
args.rope_scaling,
args.max_position_embeddings,
)
def __call__(self, x, mask=None, cache=None):
B, L, _ = x.shape
queries = self.q_proj(x)
keys = self.k_proj(x)
values = self.v_proj(x)
queries = queries.reshape(B, L, self.num_heads, -1).transpose(0, 2, 1, 3)
keys = keys.reshape(B, L, self.num_kv_heads, -1).transpose(0, 2, 1, 3)
values = values.reshape(B, L, self.num_kv_heads, -1).transpose(0, 2, 1, 3)
if cache is not None:
queries = self.rope(queries, offset=cache.offset)
keys = self.rope(keys, offset=cache.offset)
keys, values = cache.update_and_fetch(keys, values)
else:
queries = self.rope(queries)
keys = self.rope(keys)
output = scaled_dot_product_attention(
queries, keys, values, mask=mask, scale=self.scale, cache=cache
)
output = output.transpose(0, 2, 1, 3).reshape(B, L, -1)
return self.o_proj(output)
class FalconH1Mixer(nn.Module):
def __init__(self, args):
super().__init__()
self.num_heads = args.mamba_n_heads
self.hidden_size = args.hidden_size
self.ssm_state_size = args.mamba_d_state
self.conv_kernel_size = args.mamba_d_conv
self.intermediate_size = args.mamba_d_ssm
self.use_conv_bias = args.mamba_conv_bias
self.layer_norm_epsilon = args.rms_norm_eps
self.groups_time_state_size = args.mamba_n_groups * self.ssm_state_size
self.n_groups = args.mamba_n_groups
self.head_dim = args.mamba_d_head
self.chunk_size = args.mamba_chunk_size
self.time_step_limit = (0.0, float("inf"))
self.time_step_min = 0.001
self.time_step_max = 0.1
self.conv_dim = self.intermediate_size + 2 * self.n_groups * self.ssm_state_size
self.conv1d = nn.Conv1d(
in_channels=self.conv_dim,
out_channels=self.conv_dim,
bias=self.use_conv_bias,
kernel_size=self.conv_kernel_size,
groups=self.conv_dim,
)
projection_size = self.intermediate_size + self.conv_dim + self.num_heads
self.in_proj = nn.Linear(
self.hidden_size,
projection_size,
bias=args.mamba_proj_bias,
)
self.dt_bias = mx.ones(self.num_heads)
A = mx.arange(1, self.num_heads + 1)
self.A_log = mx.log(A)
self.mamba_rms_norm = args.mamba_rms_norm
if self.mamba_rms_norm:
self.norm = FalconH1RMSNormGated(
self.intermediate_size,
eps=self.layer_norm_epsilon,
n_groups=self.n_groups,
norm_before_gate=args.mamba_norm_before_gate,
)
self.D = mx.ones(self.num_heads)
self.out_proj = nn.Linear(
self.intermediate_size, self.hidden_size, bias=args.projectors_bias
)
def _apply_conv(
self, conv_input: mx.array, cache: Optional[MambaCache] = None
) -> mx.array:
if cache is None or cache[0] is None:
conv_state = mx.zeros(
(conv_input.shape[0], self.conv_kernel_size - 1, self.conv_dim),
dtype=conv_input.dtype,
)
else:
conv_state = cache[0]
padded_input = mx.concatenate([conv_state, conv_input], axis=1)
if cache is not None:
cache[0] = padded_input[:, -(self.conv_kernel_size - 1) :]
conv_output = self.conv1d(padded_input)
return nn.silu(conv_output)
def _ssm(
self,
hidden_states: mx.array,
B: mx.array,
C: mx.array,
dt: mx.array,
state: Optional[mx.array] = None,
mask: Optional[mx.array] = None,
) -> mx.array:
batch_size, seq_len, _ = hidden_states.shape
hidden_states = hidden_states.reshape(
batch_size, seq_len, self.num_heads, self.head_dim
)
B = B.reshape(batch_size, seq_len, self.n_groups, self.ssm_state_size)
C = C.reshape(batch_size, seq_len, self.n_groups, self.ssm_state_size)
y, state = ssm_update(
hidden_states,
self.A_log,
B,
C,
self.D,
dt,
self.dt_bias,
state,
self.time_step_limit,
mask,
)
return y.reshape(batch_size, seq_len, self.intermediate_size), state
def __call__(self, input_states, cache=None, mask: Optional[mx.array] = None):
projected_states = self.in_proj(input_states)
gate, conv_input, dt = mx.split(
projected_states,
[self.intermediate_size, self.intermediate_size + self.conv_dim],
axis=-1,
)
if mask is not None:
conv_input = mx.where(mask[..., None], conv_input, 0)
conv_output = self._apply_conv(conv_input, cache)
hidden_states_ssm, B, C = mx.split(
conv_output,
[
self.intermediate_size,
self.intermediate_size + self.n_groups * self.ssm_state_size,
],
axis=-1,
)
state = cache[1] if cache else None
y, state = self._ssm(hidden_states_ssm, B, C, dt, state, mask)
if cache:
cache[1] = state
if self.mamba_rms_norm:
y = self.norm(y, gate)
else:
y = y * nn.silu(gate)
return self.out_proj(y)
class FalconH1MLP(nn.Module):
def __init__(self, args):
super().__init__()
hidden_size = args.hidden_size
intermediate_size = args.intermediate_size
self.gate_proj = nn.Linear(hidden_size, intermediate_size, bias=args.mlp_bias)
self.up_proj = nn.Linear(hidden_size, intermediate_size, bias=args.mlp_bias)
self.down_proj = nn.Linear(intermediate_size, hidden_size, bias=args.mlp_bias)
def __call__(self, x):
y = self.up_proj(x) * nn.silu(self.gate_proj(x))
y = self.down_proj(y)
return y
class FalconH1DecoderLayer(nn.Module):
def __init__(self, args):
super().__init__()
self.feed_forward = FalconH1MLP(args)
head_dim = args.head_dim
self.channels_attn = (
args.num_attention_heads * head_dim
+ 2 * args.num_key_value_heads * head_dim
)
self.mamba = FalconH1Mixer(args=args)
self.self_attn = FalconH1Attention(args)
self.input_layernorm = nn.RMSNorm(args.hidden_size, eps=args.rms_norm_eps)
self.pre_ff_layernorm = nn.RMSNorm(args.hidden_size, eps=args.rms_norm_eps)
def __call__(
self,
h: mx.array,
cache,
attn_mask: Optional[mx.array],
mamba_mask: Optional[mx.array],
) -> mx.array:
residual = h
h = self.input_layernorm(h)
mamba_h = self.mamba(input_states=h, cache=cache[0], mask=mamba_mask)
attn_h = self.self_attn(
h,
mask=attn_mask,
cache=cache[1],
)
h = residual + mamba_h + attn_h
residual = h
h = self.pre_ff_layernorm(h)
h = self.feed_forward(h)
return residual + h
class FalconH1Model(nn.Module):
def __init__(self, args):
super().__init__()
self.args = args
self.vocab_size = args.vocab_size
self.hidden_size = args.hidden_size
self.embed_tokens = nn.Embedding(self.vocab_size, self.hidden_size)
self._mup_vector = compute_mup_vector(args)
self.layers = [
FalconH1DecoderLayer(args) for _ in range(args.num_hidden_layers)
]
self.final_layernorm = nn.RMSNorm(self.hidden_size, eps=args.rms_norm_eps)
def __call__(self, inputs, cache=None):
h = self.embed_tokens(inputs)
h = h
if cache is None:
cache = [(None, None) * len(self.layers)]
mamba_mask = create_ssm_mask(h, cache[0][0])
attn_mask = create_attention_mask(h, cache[0][1])
for layer, c in zip(self.layers, cache):
h = layer(
h,
cache=c,
attn_mask=attn_mask,
mamba_mask=mamba_mask,
)
return self.final_layernorm(h)
class Model(nn.Module):
def __init__(self, args):
super().__init__()
self.args = args
self.model_type = args.model_type
self.model = FalconH1Model(args=args)
self.lm_head = nn.Linear(args.hidden_size, args.vocab_size, bias=False)
def __call__(self, inputs, cache=None):
hidden_states = self.model(inputs, cache=cache)
return self.lm_head(hidden_states)
def sanitize(self, weights):
# Check if needs sanitization
c1d = weights["model.layers.0.mamba.conv1d.weight"]
if c1d.shape[-1] <= c1d.shape[1]:
return weights
sanitized_weights = {}
args = self.args
for name, param in weights.items():
# Fold-in multipliers
if name.endswith("embed_tokens.weight"):
param *= args.embedding_multiplier
elif name.endswith("lm_head.weight"):
param *= args.lm_head_multiplier
elif name.endswith("q_proj.weight") or name.endswith("k_proj.weight"):
param *= args.attention_in_multiplier
elif name.endswith("key_proj.weight"):
param *= args.attention_in_multiplier * args.key_multiplier
elif name.endswith("o_proj.weight"):
param *= args.attention_out_multiplier
elif name.endswith("out_proj.weight"):
param *= args.ssm_out_multiplier
elif name.endswith("gate_proj.weight"):
param *= args.mlp_multipliers[0]
elif name.endswith("down_proj.weight"):
param *= args.mlp_multipliers[1]
elif name.endswith("in_proj.weight"):
param *= (
args.ssm_in_multiplier
* self.model._mup_vector.astype(param.dtype)[:, None]
)
elif "conv1d.weight" in name:
param = param.transpose(0, 2, 1)
sanitized_weights[name] = param
return sanitized_weights
def make_cache(self):
return [
CacheList(MambaCache(), KVCache())
for _ in range(self.args.num_hidden_layers)
]
@property
def layers(self):
return self.model.layers
+75 -43
View File
@@ -12,10 +12,11 @@ def compute_g(A_log, a, dt_bias):
)
def _make_gated_delta_kernel():
def _make_gated_delta_kernel(has_mask=False):
if not mx.metal.is_available():
return None
source = """
mask_source = "mask[b_idx * T + t]" if has_mask else "true"
source = f"""
auto n = thread_position_in_grid.z;
auto b_idx = n / Hv;
auto hv_idx = n % Hv;
@@ -38,36 +39,38 @@ def _make_gated_delta_kernel():
auto o_state = state_out + (n * Dv + dv_idx) * Dk;
float state[n_per_t];
for (int i = 0; i < n_per_t; ++i) {
auto s_idx = n_per_t * dk_idx + i;
state[i] = static_cast<float>(i_state[s_idx]);
}
for (int i = 0; i < n_per_t; ++i) {{
auto s_idx = n_per_t * dk_idx + i;
state[i] = static_cast<float>(i_state[s_idx]);
}}
// beta, g: [B, T, Hv]
auto g_ = g + b_idx * T * Hv;
auto beta_ = beta + b_idx * T * Hv;
for (int t = 0; t < T; ++t) {
float kv_mem = 0.0f;
for (int i = 0; i < n_per_t; ++i) {
auto s_idx = n_per_t * dk_idx + i;
state[i] = state[i] * g_[hv_idx];
kv_mem += state[i] * k_[s_idx];
}
kv_mem = simd_sum(kv_mem);
for (int t = 0; t < T; ++t) {{
if ({mask_source}) {{
float kv_mem = 0.0f;
for (int i = 0; i < n_per_t; ++i) {{
auto s_idx = n_per_t * dk_idx + i;
state[i] = state[i] * g_[hv_idx];
kv_mem += state[i] * k_[s_idx];
}}
kv_mem = simd_sum(kv_mem);
auto delta = (v_[dv_idx] - kv_mem) * beta_[hv_idx];
auto delta = (v_[dv_idx] - kv_mem) * beta_[hv_idx];
float out = 0.0f;
for (int i = 0; i < n_per_t; ++i) {
auto s_idx = n_per_t * dk_idx + i;
state[i] = state[i] + k_[s_idx] * delta;
out += state[i] * q_[s_idx];
}
out = simd_sum(out);
if (thread_index_in_simdgroup == 0) {
y[dv_idx] = static_cast<InT>(out);
}
float out = 0.0f;
for (int i = 0; i < n_per_t; ++i) {{
auto s_idx = n_per_t * dk_idx + i;
state[i] = state[i] + k_[s_idx] * delta;
out += state[i] * q_[s_idx];
}}
out = simd_sum(out);
if (thread_index_in_simdgroup == 0) {{
y[dv_idx] = static_cast<InT>(out);
}}
}}
// Increment data pointers to next time step
q_ += Hk * Dk;
k_ += Hk * Dk;
@@ -75,23 +78,28 @@ def _make_gated_delta_kernel():
y += Hv * Dv;
g_ += Hv;
beta_ += Hv;
}
for (int i = 0; i < n_per_t; ++i) {
}}
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]);
}
}}
"""
inputs = ["q", "k", "v", "g", "beta", "state_in", "T"]
if has_mask:
inputs.append("mask")
return mx.fast.metal_kernel(
name="gated_delta_step",
input_names=["q", "k", "v", "g", "beta", "state_in", "T"],
name="gated_delta_step" + "_mask" if has_mask else "",
input_names=inputs,
output_names=["y", "state_out"],
source=source,
)
_gated_delta_kernel = _make_gated_delta_kernel()
_gated_delta_kernel_masked = _make_gated_delta_kernel(True)
@mx.compile
def _gated_delta_step_ops(
q: mx.array,
k: mx.array,
@@ -99,6 +107,7 @@ def _gated_delta_step_ops(
g: mx.array,
beta: mx.array,
state: mx.array,
mask: Optional[mx.array] = None,
) -> Tuple[mx.array, mx.array]:
"""
Ops-based reference implementation for a single recurrent step.
@@ -114,12 +123,15 @@ def _gated_delta_step_ops(
"""
# Decay
old_state = state
state = state * g[..., None, None]
kv_mem = (state * k[..., None, :]).sum(axis=-1) # [B, H, Dv]
delta = (v - kv_mem) * beta[..., None] # [B, H, Dv]
state = state + k[..., None, :] * delta[..., None]
# Output projection along key dim with q
y = (state * q[..., None, :]).sum(axis=-1) # [B, H, Dv]
if mask is not None:
state = mx.where(mask, state, old_state)
return y, state
@@ -130,12 +142,18 @@ def gated_delta_kernel(
g: mx.array,
beta: mx.array,
state: mx.array,
mask: Optional[mx.array] = None,
) -> Tuple[mx.array, mx.array]:
B, T, Hk, Dk = k.shape
Hv, Dv = v.shape[2:]
input_type = q.dtype
return _gated_delta_kernel(
inputs=[q, k, v, g, beta, state, T],
kernel = _gated_delta_kernel
inputs = [q, k, v, g, beta, state, T]
if mask is not None:
kernel = _gated_delta_kernel_masked
inputs.append(mask)
return kernel(
inputs=inputs,
template=[
("InT", input_type),
("Dk", Dk),
@@ -157,6 +175,7 @@ def gated_delta_ops(
g: mx.array,
beta: mx.array,
state: Optional[mx.array] = None,
mask: Optional[mx.array] = None,
) -> Tuple[mx.array, mx.array]:
"""
Ops-based reference implementation for prompt prefill (sequential loop).
@@ -181,14 +200,25 @@ def gated_delta_ops(
ys = []
for t in range(T):
y, state = _gated_delta_step_ops(
q[:, t],
k[:, t],
v[:, t],
g[:, t],
beta[:, t],
state,
)
if mask is not None:
y, state = _gated_delta_step_ops(
q[:, t],
k[:, t],
v[:, t],
g[:, t],
beta[:, t],
state,
mask[:, t],
)
else:
y, state = _gated_delta_step_ops(
q[:, t],
k[:, t],
v[:, t],
g[:, t],
beta[:, t],
state,
)
ys.append(y)
y = mx.stack(ys, axis=1)
return y, state
@@ -203,6 +233,8 @@ def gated_delta_update(
A_log: mx.array,
dt_bias: mx.array,
state: Optional[mx.array] = None,
mask: Optional[mx.array] = None,
use_kernel: bool = True,
) -> Tuple[mx.array, mx.array]:
beta = mx.sigmoid(b)
@@ -213,7 +245,7 @@ def gated_delta_update(
if state is None:
state = mx.zeros((B, Hv, Dv, Dk), dtype=q.dtype)
if mx.default_device() != mx.gpu or not mx.metal.is_available():
return gated_delta_ops(q, k, v, g, beta, state)
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)
else:
return gated_delta_kernel(q, k, v, g, beta, state)
return gated_delta_kernel(q, k, v, g, beta, state, mask)
+1 -6
View File
@@ -87,8 +87,6 @@ class Attention(nn.Module):
keys = self.rope(keys)
# Sliding window
if isinstance(mask, mx.array) and mask.shape[-1] != keys.shape[-2]:
mask = mask[..., -keys.shape[-2] :]
output = scaled_dot_product_attention(
queries, keys, values, cache=cache, scale=self.scale, mask=mask
)
@@ -194,7 +192,6 @@ class Gemma3Model(nn.Module):
cache[0],
window_size=self.window_size,
)
for i, (layer, c) in enumerate(zip(self.layers, cache)):
is_global = (
i % self.sliding_window_pattern == self.sliding_window_pattern - 1
@@ -246,7 +243,5 @@ class Model(nn.Module):
):
caches.append(KVCache())
else:
caches.append(
RotatingKVCache(max_size=self.args.sliding_window, keep=0)
)
caches.append(RotatingKVCache(max_size=self.args.sliding_window))
return caches
+4 -2
View File
@@ -1,4 +1,4 @@
# Copyright © 2023-2024 Apple Inc.
# Copyright © 2023 - 2024 Apple Inc.
from dataclasses import dataclass
from typing import Any, Optional
@@ -138,7 +138,9 @@ class GPT2Model(nn.Module):
if cache[0] is not None:
offset = cache[0].offset
position_ids = mx.arange(offset, offset + L)
offset = mx.array(offset)
position_ids = mx.arange(L) + offset[..., None]
hidden_states += self.wpe(position_ids)
mask = create_attention_mask(hidden_states, cache[0])
+101 -33
View File
@@ -20,6 +20,7 @@ from .switch_layers import SwitchGLU
@dataclass
class ModelArgs(BaseModelArgs):
# Required fields (no defaults)
model_type: str
vocab_size: int
hidden_size: int
@@ -29,34 +30,42 @@ class ModelArgs(BaseModelArgs):
num_attention_heads: int
num_key_value_heads: int
attention_bias: bool
# Scalar multipliers
embedding_multiplier: float
attention_multiplier: float
logits_scaling: float
residual_multiplier: float
# MoE parameters
num_local_experts: int
num_experts_per_tok: int
shared_intermediate_size: int
# Mamba parameters
mamba_n_heads: int
mamba_d_head: int
mamba_proj_bias: bool
mamba_d_state: int
mamba_d_conv: int
mamba_n_groups: int
mamba_conv_bias: bool
layer_types: List[str]
rms_norm_eps: float
rope_theta: float
# Optional fields (with defaults)
# MoE parameters (optional for dense mode)
num_local_experts: Optional[int] = None
num_experts_per_tok: Optional[int] = None
shared_intermediate_size: Optional[int] = None
# Mamba parameters (optional for non-hybrid mode)
mamba_n_heads: Optional[int] = None
mamba_d_head: Optional[int] = None
mamba_proj_bias: Optional[bool] = None
mamba_d_state: Optional[int] = None
mamba_d_conv: Optional[int] = None
mamba_n_groups: Optional[int] = None
mamba_conv_bias: Optional[bool] = None
# Dense MLP parameters (for non-MoE mode)
mlp_bias: bool = False
# Other optional parameters
position_embedding_type: str = "rope"
tie_word_embeddings: bool = True
time_step_limit: Tuple[float, float] = (0.001, 100.0)
# Mode flags - inferred from num_local_experts
@property
def use_moe(self) -> bool:
return bool(self.num_local_experts)
class GraniteMoeHybridRMSNormGated(nn.Module):
def __init__(self, hidden_size: int, eps: float = 1e-6):
@@ -314,11 +323,27 @@ class GraniteMoeHybridSharedMLP(nn.Module):
return self.output_linear(nn.silu(gate) * up)
class GraniteMoeHybridMLP(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
dim = args.hidden_size
hidden_dim = args.intermediate_size
mlp_bias = args.mlp_bias
self.gate_proj = nn.Linear(dim, hidden_dim, bias=mlp_bias)
self.down_proj = nn.Linear(hidden_dim, dim, bias=mlp_bias)
self.up_proj = nn.Linear(dim, hidden_dim, bias=mlp_bias)
def __call__(self, x) -> mx.array:
return self.down_proj(nn.silu(self.gate_proj(x)) * self.up_proj(x))
class GraniteMoeHybridLayer(nn.Module):
def __init__(self, args: ModelArgs, layer_type: str):
super().__init__()
self.layer_type = layer_type
self.residual_multiplier = args.residual_multiplier
self.use_moe = args.use_moe
self.input_layernorm = nn.RMSNorm(args.hidden_size, eps=args.rms_norm_eps)
@@ -329,8 +354,14 @@ class GraniteMoeHybridLayer(nn.Module):
else:
raise ValueError(f"Unknown layer type: {layer_type}")
self.shared_mlp = GraniteMoeHybridSharedMLP(args)
self.block_sparse_moe = GraniteMoeHybridMoE(args)
# MoE or dense MLP after attention/mamba
if self.use_moe:
self.shared_mlp = GraniteMoeHybridSharedMLP(args)
self.block_sparse_moe = GraniteMoeHybridMoE(args)
else:
# Dense MLP mode
self.mlp = GraniteMoeHybridMLP(args)
self.post_attention_layernorm = nn.RMSNorm(
args.hidden_size, eps=args.rms_norm_eps
)
@@ -352,13 +383,16 @@ class GraniteMoeHybridLayer(nn.Module):
hidden_states = residual + hidden_states * self.residual_multiplier
# Second block: MoE + shared_mlp
# Second block: MoE + shared_mlp OR dense MLP
residual = hidden_states
normed = self.post_attention_layernorm(hidden_states)
moe_out = self.block_sparse_moe(normed)
shared_out = self.shared_mlp(normed)
mlp_out = moe_out + shared_out
if self.use_moe:
moe_out = self.block_sparse_moe(normed)
shared_out = self.shared_mlp(normed)
mlp_out = moe_out + shared_out
else:
mlp_out = self.mlp(normed)
hidden_states = residual + mlp_out * self.residual_multiplier
@@ -375,9 +409,16 @@ class GraniteMoeHybridModel(nn.Module):
]
self.norm = nn.RMSNorm(args.hidden_size, eps=args.rms_norm_eps)
self.embedding_multiplier = args.embedding_multiplier
self.fa_idx = args.layer_types.index("attention")
self.ssm_idx = args.layer_types.index("mamba")
self.layer_types = args.layer_types
# Handle hybrid vs non-hybrid mode
self.fa_idx = (
args.layer_types.index("attention")
if "attention" in args.layer_types
else None
)
self.ssm_idx = (
args.layer_types.index("mamba") if "mamba" in args.layer_types else None
)
def __call__(
self,
@@ -389,11 +430,16 @@ class GraniteMoeHybridModel(nn.Module):
if cache is None:
cache = [None] * len(self.layers)
attn_mask = create_attention_mask(hidden_states, cache[self.fa_idx])
mamba_mask = create_ssm_mask(hidden_states, cache[self.ssm_idx])
# Create masks based on what layer types exist
attn_mask = None
mamba_mask = None
cache_counter = 0
for layer, c, layer_type in zip(self.layers, cache, self.layer_types):
if self.fa_idx is not None:
attn_mask = create_attention_mask(hidden_states, cache[self.fa_idx])
if self.ssm_idx is not None:
mamba_mask = create_ssm_mask(hidden_states, cache[self.ssm_idx])
for layer, c in zip(self.layers, cache):
mask = attn_mask if layer.layer_type == "attention" else mamba_mask
hidden_states = layer(hidden_states, mask=mask, cache=c)
@@ -443,8 +489,11 @@ class Model(nn.Module):
if "conv1d.weight" in k and v.shape[-1] != 1:
weights[k] = v.moveaxis(2, 1)
# Handle MoE weight transformation to SwitchGLU format
if "model.layers.0.block_sparse_moe.input_linear.weight" in weights:
# Handle MoE weight transformation to SwitchGLU format (only for MoE models)
if (
self.args.use_moe
and "model.layers.0.block_sparse_moe.input_linear.weight" in weights
):
for l in range(self.args.num_hidden_layers):
prefix = f"model.layers.{l}.block_sparse_moe"
@@ -461,12 +510,31 @@ class Model(nn.Module):
f"{prefix}.output_linear.weight"
)
# Handle dense MLP weight transformation (for dense models)
elif (
not self.args.use_moe
and "model.layers.0.shared_mlp.input_linear.weight" in weights
):
for l in range(self.args.num_hidden_layers):
prefix = f"model.layers.{l}.shared_mlp"
# Transform shared_mlp weights to standard mlp weights
input_weight = weights.pop(f"{prefix}.input_linear.weight")
# Split into gate and up projections (each half)
gate_proj, up_proj = mx.split(input_weight, 2, axis=0)
weights[f"model.layers.{l}.mlp.gate_proj.weight"] = gate_proj
weights[f"model.layers.{l}.mlp.up_proj.weight"] = up_proj
weights[f"model.layers.{l}.mlp.down_proj.weight"] = weights.pop(
f"{prefix}.output_linear.weight"
)
return weights
@property
def quant_predicate(self):
def predicate(path, _):
if path.endswith("router.layer"):
if self.args.use_moe and path.endswith("router.layer"):
return {"group_size": 64, "bits": 8}
return True
+375
View File
@@ -0,0 +1,375 @@
# Copyright © 2025 Apple Inc.
import math
from dataclasses import dataclass
from typing import Any, List, Optional, Union
import mlx.core as mx
import mlx.nn as nn
from .base import (
BaseModelArgs,
create_attention_mask,
create_ssm_mask,
scaled_dot_product_attention,
)
from .cache import KVCache, MambaCache
from .switch_layers import SwitchGLU
@dataclass
class ModelArgs(BaseModelArgs):
model_type: str
hidden_size: int
intermediate_size: int
num_hidden_layers: int
num_attention_heads: int
num_key_value_heads: int
attn_layer_offset: int
attn_layer_period: int
expert_layer_offset: int
expert_layer_period: int
mamba_d_conv: int
mamba_d_state: int
mamba_expand: int
num_experts: int
num_experts_per_tok: int
rms_norm_eps: float
max_position_embeddings: int
vocab_size: int
mamba_dt_rank: Union[str, int] = "auto"
mamba_proj_bias: bool = False
mamba_conv_bias: bool = True
layers_block_type: Optional[List[str]] = None
tie_word_embeddings: bool = True
def __post_init__(self):
if self.mamba_dt_rank == "auto":
self.mamba_dt_rank = math.ceil(self.hidden_size / 16)
if self.layers_block_type is None:
self.layers_block_type = [
(
"attention"
if i % self.attn_layer_period == self.attn_layer_offset
else "mamba"
)
for i in range(self.num_hidden_layers)
]
class JambaMLP(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
self.gate_proj = nn.Linear(args.hidden_size, args.intermediate_size, bias=False)
self.up_proj = nn.Linear(args.hidden_size, args.intermediate_size, bias=False)
self.down_proj = nn.Linear(args.intermediate_size, args.hidden_size, bias=False)
def __call__(self, x: mx.array) -> mx.array:
return self.down_proj(nn.silu(self.gate_proj(x)) * self.up_proj(x))
class JambaAttention(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
self.num_attention_heads = args.num_attention_heads
self.num_key_value_heads = args.num_key_value_heads
self.head_dim = args.hidden_size // args.num_attention_heads
self.scale = self.head_dim**-0.5
self.q_proj = nn.Linear(
args.hidden_size, args.num_attention_heads * self.head_dim, bias=False
)
self.k_proj = nn.Linear(
args.hidden_size, args.num_key_value_heads * self.head_dim, bias=False
)
self.v_proj = nn.Linear(
args.hidden_size, args.num_key_value_heads * self.head_dim, bias=False
)
self.o_proj = nn.Linear(
args.num_attention_heads * self.head_dim, args.hidden_size, bias=False
)
def __call__(
self,
x: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[Any] = None,
) -> mx.array:
B, L, D = x.shape
queries, keys, values = self.q_proj(x), self.k_proj(x), self.v_proj(x)
queries = queries.reshape(B, L, self.num_attention_heads, -1).transpose(
0, 2, 1, 3
)
keys = keys.reshape(B, L, self.num_key_value_heads, -1).transpose(0, 2, 1, 3)
values = values.reshape(B, L, self.num_key_value_heads, -1).transpose(
0, 2, 1, 3
)
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)
@mx.compile
def fma(a, b, c):
return a * b + c
class JambaMambaMixer(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
self.hidden_size = args.hidden_size
self.ssm_state_size = args.mamba_d_state
self.conv_kernel_size = args.mamba_d_conv
self.intermediate_size = args.mamba_expand * args.hidden_size
self.time_step_rank = args.mamba_dt_rank
self.use_conv_bias = args.mamba_conv_bias
self.use_bias = args.mamba_proj_bias
self.in_proj = nn.Linear(
self.hidden_size, self.intermediate_size * 2, bias=self.use_bias
)
self.conv1d = nn.Conv1d(
in_channels=self.intermediate_size,
out_channels=self.intermediate_size,
kernel_size=self.conv_kernel_size,
groups=self.intermediate_size,
bias=self.use_conv_bias,
padding=0,
)
self.x_proj = nn.Linear(
self.intermediate_size,
self.time_step_rank + self.ssm_state_size * 2,
bias=False,
)
self.dt_proj = nn.Linear(self.time_step_rank, self.intermediate_size, bias=True)
A = mx.repeat(
mx.arange(1.0, self.ssm_state_size + 1.0).reshape([1, self.ssm_state_size]),
repeats=self.intermediate_size,
axis=0,
)
self.A_log = mx.log(A)
self.D = mx.ones([self.intermediate_size])
self.out_proj = nn.Linear(
self.intermediate_size, self.hidden_size, bias=self.use_bias
)
self.dt_layernorm = nn.RMSNorm(self.time_step_rank, eps=args.rms_norm_eps)
self.b_layernorm = nn.RMSNorm(self.ssm_state_size, eps=args.rms_norm_eps)
self.c_layernorm = nn.RMSNorm(self.ssm_state_size, eps=args.rms_norm_eps)
def ssm_step(self, x, A, state=None):
T = x.shape[1]
D = self.D
deltaBC = self.x_proj(x)
delta, B, C = mx.split(
deltaBC,
[self.time_step_rank, self.time_step_rank + self.ssm_state_size],
axis=-1,
)
delta, B, C = self.dt_layernorm(delta), self.b_layernorm(B), self.c_layernorm(C)
delta = nn.softplus(self.dt_proj(delta))
new_state = mx.expand_dims(delta * x, -1) * mx.expand_dims(B, -2)
dtA = mx.exp(mx.expand_dims(delta, -1) * A)
# TODO, speed up prefill with chunked scan
for t in range(T):
if state is not None:
new_state[:, t] = fma(state, dtA[:, t], new_state[:, t])
state = new_state[:, t]
y = (new_state @ mx.expand_dims(C, -1)).squeeze(-1)
y = y + D * x
return y, new_state[:, -1]
def _process_sequence(self, x, conv_state, ssm_state):
xz = self.in_proj(x)
x, z = xz.split(indices_or_sections=2, axis=-1)
K = self.conv_kernel_size
if conv_state is not None:
x_full = mx.concatenate([conv_state, x], axis=1)
else:
x_full = mx.pad(x, [(0, 0), (K - 1, 0), (0, 0)])
conv_out = self.conv1d(x_full)
conv_state = x_full[:, -(K - 1) :, :]
x = nn.silu(conv_out)
A = -mx.exp(self.A_log)
y, ssm_state = self.ssm_step(x, A, ssm_state)
z = self.out_proj(nn.silu(z) * y)
return z, (conv_state, ssm_state)
def __call__(self, x, cache):
if cache is None:
conv_state, ssm_state = None, None
else:
conv_state, ssm_state = cache[0], cache[1]
output, (conv_state, ssm_state) = self._process_sequence(
x, conv_state, ssm_state
)
if cache is not None:
cache[0] = conv_state
cache[1] = ssm_state
return output
class JambaSparseMoeBlock(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
self.num_experts_per_tok = args.num_experts_per_tok
self.router = nn.Linear(args.hidden_size, args.num_experts, bias=False)
self.switch_mlp = SwitchGLU(
args.hidden_size, args.intermediate_size, args.num_experts
)
def __call__(self, x: mx.array) -> mx.array:
gates = self.router(x)
k = self.num_experts_per_tok
inds = mx.stop_gradient(mx.argpartition(-gates, kth=k - 1, axis=-1)[..., :k])
scores = mx.take_along_axis(gates, inds, axis=-1)
scores = mx.softmax(scores, axis=-1, precise=True)
y = self.switch_mlp(x, inds)
y = (y * scores[..., None]).sum(axis=-2)
return y
class JambaDecoderLayer(nn.Module):
def __init__(self, args: ModelArgs, layer_type: str):
super().__init__()
self.is_attn = layer_type == "attention"
if self.is_attn:
self.self_attn = JambaAttention(args)
else:
self.mamba = JambaMambaMixer(args)
ffn_layer_class = JambaSparseMoeBlock if args.num_experts > 1 else JambaMLP
self.feed_forward = ffn_layer_class(args)
self.input_layernorm = nn.RMSNorm(args.hidden_size, eps=args.rms_norm_eps)
self.pre_ff_layernorm = nn.RMSNorm(args.hidden_size, eps=args.rms_norm_eps)
def __call__(
self,
x: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[Any] = None,
) -> mx.array:
if self.is_attn:
h = self.self_attn(self.input_layernorm(x), mask, cache)
else:
h = self.mamba(self.input_layernorm(x), cache)
r = x + h
out = r + self.feed_forward(self.pre_ff_layernorm(r))
return out
class JambaModel(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
self.embed_tokens = nn.Embedding(args.vocab_size, args.hidden_size)
self.layers = [JambaDecoderLayer(args, t) for t in args.layers_block_type]
self.final_layernorm = nn.RMSNorm(args.hidden_size, eps=args.rms_norm_eps)
self.attn_idx = args.layers_block_type.index("attention")
self.ssm_idx = args.layers_block_type.index("mamba")
def __call__(
self,
inputs: mx.array,
cache: Optional[Any] = None,
) -> mx.array:
h = self.embed_tokens(inputs)
if cache is None:
cache = [None] * len(self.layers)
attn_mask = create_attention_mask(h, cache[self.attn_idx])
ssm_mask = create_ssm_mask(h, cache[self.ssm_idx])
for layer, c in zip(self.layers, cache):
mask = attn_mask if layer.is_attn else ssm_mask
h = layer(h, mask=mask, cache=c)
return self.final_layernorm(h)
class Model(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
self.model_type = args.model_type
self.args = args
self.model = JambaModel(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,
) -> mx.array:
out = self.model(inputs, cache)
if self.args.tie_word_embeddings:
out = self.model.embed_tokens.as_linear(out)
else:
out = self.lm_head(out)
return out
def make_cache(self):
caches = []
for layer in self.model.layers:
if layer.is_attn:
caches.append(KVCache())
else:
caches.append(MambaCache())
return caches
def sanitize(self, weights):
for k, v in weights.items():
if "conv1d.weight" in k and v.shape[-1] != 1:
weights[k] = v.moveaxis(2, 1)
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):
prefix = f"model.layers.{l}"
for n, m in [("w1", "gate_proj"), ("w2", "down_proj"), ("w3", "up_proj")]:
for k in ["weight", "scales", "biases"]:
if f"{prefix}.block_sparse_moe.experts.0.{n}.{k}" in weights:
to_join = [
weights.pop(
f"{prefix}.block_sparse_moe.experts.{e}.{n}.{k}"
)
for e in range(self.args.num_local_experts)
]
weights[f"{prefix}.block_sparse_moe.switch_mlp.{m}.{k}"] = (
mx.stack(to_join)
)
return weights
@property
def layers(self):
return self.model.layers
@property
def quant_predicate(self):
def predicate(path, _):
if path.endswith("router"):
return {"group_size": 64, "bits": 8}
return True
return predicate
-5
View File
@@ -18,11 +18,6 @@ class ModelArgs(BaseModelArgs):
def __post_init__(self):
self.text_config["tie_word_embeddings"] = False
self.text_config["full_attn_idxs"] = [
i
for i, layer_type in enumerate(self.text_config["layer_types"])
if layer_type == "full_attention"
]
class Model(nn.Module):
+12 -2
View File
@@ -31,8 +31,19 @@ class ModelArgs(BaseModelArgs):
block_multiple_of: int
block_ffn_dim_multiplier: float
block_auto_adjust_ff_dim: bool
full_attn_idxs: List[int]
rope_theta: float
full_attn_idxs: Optional[List[int]] = None
layer_types: Optional[List[str]] = None
def __post_init__(self):
if self.num_key_value_heads is None:
self.num_key_value_heads = self.num_attention_heads
if self.full_attn_idxs is None:
self.full_attn_idxs = [
i
for i, layer_type in enumerate(self.layer_types)
if layer_type == "full_attention"
]
class Attention(nn.Module):
@@ -122,7 +133,6 @@ class ShortConv(nn.Module):
mask: Optional[mx.array] = None,
cache: Optional[Any] = None,
):
seqlen = x.shape[1]
BCx = self.in_proj(x)
B, C, x = mx.split(BCx, 3, axis=-1)
Bx = B * x
+372
View File
@@ -0,0 +1,372 @@
# Copyright © 2025 Apple Inc.
from dataclasses import dataclass
from typing import Any, List, Optional
import mlx.core as mx
import mlx.nn as nn
from .base import (
BaseModelArgs,
create_attention_mask,
create_ssm_mask,
scaled_dot_product_attention,
)
from .cache import ArraysCache, KVCache
from .switch_layers import SwitchGLU
@dataclass
class ModelArgs(BaseModelArgs):
model_type: str
vocab_size: int
hidden_size: int
intermediate_size: int
moe_intermediate_size: int
num_hidden_layers: int
num_experts: int
num_experts_per_tok: int
norm_topk_prob: bool
num_attention_heads: int
num_key_value_heads: int
max_position_embeddings: int
use_expert_bias: bool
num_dense_layers: int
norm_eps: float
conv_bias: bool
conv_L_cache: int
rope_theta: float
full_attn_idxs: Optional[List[int]] = None
layer_types: Optional[List[str]] = None
def __post_init__(self):
if self.full_attn_idxs is None:
self.full_attn_idxs = [
i
for i, layer_type in enumerate(self.layer_types)
if layer_type == "full_attention"
]
class Attention(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
dim = args.hidden_size
self.n_heads = n_heads = args.num_attention_heads
self.n_kv_heads = n_kv_heads = args.num_key_value_heads
self.head_dim = head_dim = args.hidden_size // n_heads
self.scale = head_dim**-0.5
self.q_layernorm = nn.RMSNorm(head_dim, eps=args.norm_eps)
self.k_layernorm = nn.RMSNorm(head_dim, eps=args.norm_eps)
self.q_proj = nn.Linear(dim, n_heads * head_dim, bias=False)
self.k_proj = nn.Linear(dim, n_kv_heads * head_dim, bias=False)
self.v_proj = nn.Linear(dim, n_kv_heads * head_dim, bias=False)
self.out_proj = nn.Linear(n_heads * head_dim, dim, bias=False)
self.rope = nn.RoPE(
self.head_dim,
base=args.rope_theta,
traditional=False,
)
def __call__(
self,
x: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[Any] = None,
) -> mx.array:
B, L, D = x.shape
queries, keys, values = self.q_proj(x), self.k_proj(x), self.v_proj(x)
queries = self.q_layernorm(queries.reshape(B, L, self.n_heads, -1)).transpose(
0, 2, 1, 3
)
keys = self.k_layernorm(keys.reshape(B, L, self.n_kv_heads, -1)).transpose(
0, 2, 1, 3
)
values = values.reshape(B, L, self.n_kv_heads, -1).transpose(0, 2, 1, 3)
if cache is not None:
queries = self.rope(queries, offset=cache.offset)
keys = self.rope(keys, offset=cache.offset)
keys, values = cache.update_and_fetch(keys, values)
else:
queries = self.rope(queries)
keys = self.rope(keys)
output = scaled_dot_product_attention(
queries, keys, values, cache=cache, mask=mask, scale=self.scale
)
output = output.transpose(0, 2, 1, 3).reshape(B, L, -1)
return self.out_proj(output)
class ShortConv(nn.Module):
def __init__(
self,
args: ModelArgs,
layer_idx: int,
):
super().__init__()
self.args = args
self.layer_idx = layer_idx
self.L_cache = args.conv_L_cache
self.bias = args.conv_bias
self.conv = nn.Conv1d(
in_channels=args.hidden_size,
out_channels=args.hidden_size,
kernel_size=self.L_cache,
groups=args.hidden_size,
bias=self.bias,
)
self.in_proj = nn.Linear(args.hidden_size, 3 * args.hidden_size, bias=self.bias)
self.out_proj = nn.Linear(args.hidden_size, args.hidden_size, bias=self.bias)
def __call__(
self,
x: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[Any] = None,
):
BCx = self.in_proj(x)
B, C, x = mx.split(BCx, 3, axis=-1)
Bx = B * x
if mask is not None:
Bx = mx.where(mask[..., None], Bx, 0)
state = None
if cache is not None:
state = cache[0]
if state is None:
state = mx.zeros(
(Bx.shape[0], self.L_cache - 1, self.args.hidden_size), dtype=Bx.dtype
)
Bx = mx.concatenate([state, Bx], axis=-2)
if cache is not None:
cache[0] = Bx[:, -(self.L_cache - 1) :]
conv_out = self.conv(Bx)
y = C * conv_out
return self.out_proj(y)
class MLP(nn.Module):
def __init__(self, config: ModelArgs, intermediate_size: Optional[int] = None):
super().__init__()
self.hidden_size = config.hidden_size
self.intermediate_size = (
config.intermediate_size if intermediate_size is None else intermediate_size
)
self.gate_proj = nn.Linear(self.hidden_size, self.intermediate_size, bias=False)
self.up_proj = nn.Linear(self.hidden_size, self.intermediate_size, bias=False)
self.down_proj = nn.Linear(self.intermediate_size, self.hidden_size, bias=False)
def __call__(self, x) -> mx.array:
return self.down_proj(nn.silu(self.gate_proj(x)) * self.up_proj(x))
class Lfm2MoeSparseMoeBlock(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
dim = args.hidden_size
intermediate_size = args.moe_intermediate_size
self.num_experts = num_experts = args.num_experts
self.top_k = args.num_experts_per_tok
self.norm_topk_prob = args.norm_topk_prob
self.use_expert_bias = args.use_expert_bias
self.gate = nn.Linear(dim, num_experts, bias=False)
self.switch_mlp = SwitchGLU(dim, intermediate_size, num_experts)
if self.use_expert_bias:
self.expert_bias = mx.zeros((self.num_experts,))
def __call__(
self,
x: mx.array,
):
gates = self.gate(x).astype(mx.float32)
gates = mx.softmax(gates, axis=-1)
if self.use_expert_bias:
gates += self.expert_bias
k = self.top_k
inds = mx.argpartition(gates, kth=-k, axis=-1)[..., -k:]
scores = mx.take_along_axis(gates, inds, axis=-1)
if self.norm_topk_prob:
scores /= mx.sum(scores, axis=-1, keepdims=True) + 1e-20
scores = scores.astype(x.dtype)
y = self.switch_mlp(x, inds)
y = (y * scores[..., None]).sum(axis=-2)
return y
class Lfm2DecoderLayer(nn.Module):
def __init__(self, args: ModelArgs, layer_idx: int):
super().__init__()
self.is_attention_layer = layer_idx in args.full_attn_idxs
if self.is_attention_layer:
self.self_attn = Attention(args)
else:
self.conv = ShortConv(args, layer_idx)
self.feed_forward = (
MLP(
config=args,
intermediate_size=args.intermediate_size,
)
if layer_idx < args.num_dense_layers
else Lfm2MoeSparseMoeBlock(args)
)
self.operator_norm = nn.RMSNorm(args.hidden_size, eps=args.norm_eps)
self.ffn_norm = nn.RMSNorm(args.hidden_size, eps=args.norm_eps)
def __call__(
self,
x: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[Any] = None,
) -> mx.array:
if self.is_attention_layer:
r = self.self_attn(self.operator_norm(x), mask=mask, cache=cache)
else:
r = self.conv(
self.operator_norm(x),
mask=mask,
cache=cache,
)
h = x + r
out = h + self.feed_forward(self.ffn_norm(h))
return out
class Lfm2Model(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
self.args = args
self.vocab_size = args.vocab_size
self.num_hidden_layers = args.num_hidden_layers
self.embed_tokens = nn.Embedding(args.vocab_size, args.hidden_size)
self.layers = [
Lfm2DecoderLayer(args, layer_idx=i) for i in range(args.num_hidden_layers)
]
self.embedding_norm = nn.RMSNorm(args.hidden_size, eps=args.norm_eps)
self.fa_idx = args.full_attn_idxs[0]
self.conv_idx = 0
for i in range(args.num_hidden_layers):
if i in args.full_attn_idxs:
self.conv_idx += 1
else:
break
def __call__(
self,
inputs: mx.array,
cache=None,
input_embeddings: Optional[mx.array] = None,
):
if input_embeddings is not None:
h = input_embeddings
else:
h = self.embed_tokens(inputs)
if cache is None:
cache = [None] * len(self.layers)
attn_mask = create_attention_mask(h, cache[self.fa_idx])
conv_mask = create_ssm_mask(h, cache[self.conv_idx])
for layer, c in zip(self.layers, cache):
mask = attn_mask if layer.is_attention_layer else conv_mask
h = layer(h, mask, cache=c)
return self.embedding_norm(h)
class Model(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
self.args = args
self.model_type = args.model_type
self.model = Lfm2Model(args)
def __call__(
self,
inputs: mx.array,
cache=None,
input_embeddings: Optional[mx.array] = None,
):
out = self.model(inputs, cache, input_embeddings)
return self.model.embed_tokens.as_linear(out)
def sanitize(self, weights):
sanitized_weights = {}
for name, param in weights.items():
if "conv.weight" in name:
if param.shape[-1] > param.shape[1]:
param = param.transpose(0, 2, 1)
replacements = {
"w1.weight": "gate_proj.weight",
"w2.weight": "down_proj.weight",
"w3.weight": "up_proj.weight",
}
for old, new in replacements.items():
if old in name:
name = name.replace(old, new)
sanitized_weights[name] = param
for l in range(self.args.num_hidden_layers):
prefix = f"model.layers.{l}"
# Only sanitize MoE layer weights
for n in ["gate_proj", "down_proj", "up_proj"]:
if f"{prefix}.feed_forward.experts.0.{n}.weight" in sanitized_weights:
to_join = [
sanitized_weights.pop(
f"{prefix}.feed_forward.experts.{e}.{n}.weight"
)
for e in range(self.args.num_experts)
]
sanitized_weights[
f"{prefix}.feed_forward.switch_mlp.{n}.weight"
] = mx.stack(to_join)
return sanitized_weights
@property
def layers(self):
return self.model.layers
def make_cache(self):
return [
KVCache() if l.is_attention_layer else ArraysCache(size=1)
for l in self.layers
]
@property
def quant_predicate(self):
def predicate(path, _):
if path.endswith("feed_forward.gate"):
return {"group_size": 64, "bits": 8}
return True
return predicate
@property
def cast_predicate(self):
def predicate(k):
return "expert_bias" not in k
return predicate
+37 -6
View File
@@ -1,12 +1,13 @@
# Copyright © 2023-2024 Apple Inc.
from dataclasses import dataclass
from typing import Any, Dict, Optional, Union
from typing import Any, Dict, List, Optional, Union
import mlx.core as mx
import mlx.nn as nn
from .base import BaseModelArgs, create_attention_mask, scaled_dot_product_attention
from .cache import KVCache, RotatingKVCache
from .rope_utils import initialize_rope
@@ -28,11 +29,16 @@ class ModelArgs(BaseModelArgs):
rope_traditional: bool = False
rope_scaling: Optional[Dict[str, Union[float, str]]] = None
tie_word_embeddings: bool = True
layer_types: Optional[List[str]] = None
sliding_window: Optional[int] = None
def __post_init__(self):
if self.num_key_value_heads is None:
self.num_key_value_heads = self.num_attention_heads
if self.layer_types is None:
self.layer_types = ["full_attention"] * self.num_hidden_layers
class Attention(nn.Module):
def __init__(self, args: ModelArgs):
@@ -114,10 +120,11 @@ class MLP(nn.Module):
class TransformerBlock(nn.Module):
def __init__(self, args: ModelArgs):
def __init__(self, args: ModelArgs, use_sliding: bool = False):
super().__init__()
self.num_attention_heads = args.num_attention_heads
self.hidden_size = args.hidden_size
self.use_sliding = use_sliding
self.self_attn = Attention(args)
self.mlp = MLP(args)
self.input_layernorm = nn.RMSNorm(args.hidden_size, eps=args.rms_norm_eps)
@@ -145,12 +152,21 @@ class LlamaModel(nn.Module):
self.args = args
self.vocab_size = args.vocab_size
self.num_hidden_layers = args.num_hidden_layers
self.layer_types = args.layer_types
self.sliding_window = args.sliding_window
assert self.vocab_size > 0
self.embed_tokens = nn.Embedding(args.vocab_size, args.hidden_size)
self.layers = [
TransformerBlock(args=args) for _ in range(args.num_hidden_layers)
TransformerBlock(args=args, use_sliding=layer_type == "sliding_attention")
for layer_type in self.layer_types
]
self.norm = nn.RMSNorm(args.hidden_size, eps=args.rms_norm_eps)
self.fa_idx = self.layer_types.index("full_attention")
self.swa_idx = None
for e, l in enumerate(self.layers):
if l.use_sliding:
self.swa_idx = e
break
def __call__(
self,
@@ -166,10 +182,15 @@ class LlamaModel(nn.Module):
if cache is None:
cache = [None] * len(self.layers)
mask = create_attention_mask(h, cache[0])
fa_mask = create_attention_mask(h, cache[self.fa_idx])
if self.swa_idx is not None:
swa_mask = create_attention_mask(
h, cache[self.swa_idx], window_size=self.sliding_window
)
for layer, c in zip(self.layers, cache):
h = layer(h, mask, cache=c)
for layer, cache in zip(self.layers, cache):
mask = swa_mask if layer.use_sliding else fa_mask
h = layer(h, mask, cache=cache)
return self.norm(h)
@@ -208,3 +229,13 @@ class Model(nn.Module):
@property
def layers(self):
return self.model.layers
def make_cache(self):
return [
(
RotatingKVCache(max_size=self.model.sliding_window)
if layer.use_sliding
else KVCache()
)
for layer in self.layers
]
+1 -1
View File
@@ -235,7 +235,7 @@ class LongcatFlashMoE(nn.Module):
regular_outputs = self.switch_mlp(hidden_states, topk_indices)
weighted_outputs = regular_outputs * topk_weights[..., None]
weighted_outputs = regular_outputs * regular_weights[..., None]
# Add identity expert contribution if needed
assert self.zero_expert_type == "identity"
+233
View File
@@ -0,0 +1,233 @@
# Copyright © 2025 Apple Inc.
import math
from dataclasses import dataclass
from functools import partial
from typing import Any, Optional
import mlx.core as mx
import mlx.nn as nn
from .base import BaseModelArgs, create_attention_mask, scaled_dot_product_attention
@dataclass
class ModelArgs(BaseModelArgs):
model_type: str = "nanochat"
hidden_size: int = 1280
num_hidden_layers: int = 20
num_attention_heads: int = 10
num_key_value_heads: int = 10
vocab_size: int = 65536
max_position_embeddings: int = 2048
intermediate_size: int = 5120 # 4 * hidden_size
rope_theta: float = 10000.0
def rms_norm(x):
"""Functional RMSNorm with no learnable parameters."""
return mx.fast.rms_norm(x, None, 1e-5)
def apply_rotary_emb(x, offset, base=10000.0, freqs=None):
"""Apply RoPE with blocked layout.
Args:
x: Input tensor in (B, H, T, D) format
offset: Position offset for KV caching
base: RoPE base frequency (default 10000.0)
freqs: Precomputed negated frequencies (optional)
Returns:
Tensor with RoPE applied, same shape as input
"""
head_dim = x.shape[-1]
if freqs is None:
# Compute negated frequencies
half_D = head_dim // 2
freqs = -mx.exp(
mx.arange(0.0, half_D, dtype=mx.float32) * (math.log(base) / half_D)
)
# Use traditional=False + negated freqs
return mx.fast.rope(
x,
dims=head_dim,
traditional=False,
base=None,
freqs=freqs,
scale=1.0,
offset=offset,
)
class Attention(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
self.hidden_size = args.hidden_size
self.num_heads = args.num_attention_heads
self.num_kv_heads = args.num_key_value_heads
self.head_dim = self.hidden_size // self.num_heads
self.scale = self.head_dim**-0.5
self.rope_theta = args.rope_theta
self.c_q = nn.Linear(
self.hidden_size, self.num_heads * self.head_dim, bias=False
)
self.c_k = nn.Linear(
self.hidden_size, self.num_kv_heads * self.head_dim, bias=False
)
self.c_v = nn.Linear(
self.hidden_size, self.num_kv_heads * self.head_dim, bias=False
)
self.c_proj = nn.Linear(self.hidden_size, self.hidden_size, bias=False)
# Precompute negated RoPE frequencies for awni's approach
half_D = self.head_dim // 2
self._rope_freqs = -mx.exp(
mx.arange(0.0, half_D, dtype=mx.float32)
* (math.log(self.rope_theta) / half_D)
)
def __call__(
self,
x: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[Any] = None,
) -> mx.array:
B, L, _ = x.shape
queries = self.c_q(x)
keys = self.c_k(x)
values = self.c_v(x)
# Reshape to (B, L, H, D) then transpose to (B, H, L, D)
queries = queries.reshape(B, L, self.num_heads, self.head_dim).transpose(
0, 2, 1, 3
)
keys = keys.reshape(B, L, self.num_kv_heads, self.head_dim).transpose(
0, 2, 1, 3
)
values = values.reshape(B, L, self.num_kv_heads, self.head_dim).transpose(
0, 2, 1, 3
)
# Apply RoPE using precomputed frequencies (expects B, H, T, D format)
offset = cache.offset if cache is not None else 0
queries = apply_rotary_emb(
queries, offset=offset, base=self.rope_theta, freqs=self._rope_freqs
)
keys = apply_rotary_emb(
keys, offset=offset, base=self.rope_theta, freqs=self._rope_freqs
)
# QK norm (critical feature of nanochat!)
queries = rms_norm(queries)
keys = rms_norm(keys)
# Handle KV cache after transpose
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
)
# Reshape back
output = output.transpose(0, 2, 1, 3).reshape(B, L, self.hidden_size)
return self.c_proj(output)
class MLP(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
self.c_fc = nn.Linear(args.hidden_size, args.intermediate_size, bias=False)
self.c_proj = nn.Linear(args.intermediate_size, args.hidden_size, bias=False)
def __call__(self, x: mx.array) -> mx.array:
# Critical: nanochat uses ReLU^2, not GELU!
x = self.c_fc(x)
x = nn.relu2(x)
return self.c_proj(x)
class TransformerBlock(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
self.attn = Attention(args)
self.mlp = MLP(args)
def __call__(
self,
x: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[Any] = None,
) -> mx.array:
# Pre-norm architecture with functional RMSNorm
h = x + self.attn(rms_norm(x), mask=mask, cache=cache)
out = h + self.mlp(rms_norm(h))
return out
class NanoChatModel(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
self.args = args
self.wte = nn.Embedding(args.vocab_size, args.hidden_size)
self.h = [TransformerBlock(args) for _ in range(args.num_hidden_layers)]
def __call__(
self,
inputs: mx.array,
cache=None,
) -> mx.array:
h = self.wte(inputs)
# Critical: norm after token embedding
h = rms_norm(h)
if cache is None:
cache = [None] * len(self.h)
mask = create_attention_mask(h, cache[0])
for layer, c in zip(self.h, cache):
h = layer(h, mask=mask, cache=c)
# Critical: final norm before lm_head
h = rms_norm(h)
return h
@partial(mx.compile, shapeless=True)
def softcap(logits, cap=15.0):
return cap * mx.tanh(logits / cap)
class Model(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
self.args = args
self.model_type = args.model_type
self.transformer = NanoChatModel(args)
self.lm_head = nn.Linear(args.hidden_size, args.vocab_size, bias=False)
def __call__(
self,
inputs: mx.array,
cache=None,
) -> mx.array:
out = self.transformer(inputs, cache=cache)
logits = self.lm_head(out)
# Critical: logits softcap (nanochat uses softcap=15)
logits = softcap(logits)
return logits
@property
def layers(self):
return self.transformer.h
+226
View File
@@ -0,0 +1,226 @@
# Copyright © 2025 Apple Inc.
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Union
import mlx.core as mx
import mlx.nn as nn
from .base import BaseModelArgs, create_attention_mask, scaled_dot_product_attention
from .rope_utils import initialize_rope
@dataclass
class ModelArgs(BaseModelArgs):
model_type: str
hidden_size: int
num_hidden_layers: int
intermediate_size: int
num_attention_heads: int
rms_norm_eps: float
vocab_size: int
max_position_embeddings: int
sliding_window: int
rope_theta: float
attention_bias: bool = False
layer_types: Optional[List[str]] = None
num_key_value_heads: Optional[int] = None
head_dim: Optional[int] = None
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:
self.num_key_value_heads = self.num_attention_heads
if self.layer_types is None:
self.layer_types = [
"full_attention" if (i + 1) % 4 == 0 else "sliding_attention"
for i in range(self.num_hidden_layers)
]
class Olmo3Attention(nn.Module):
def __init__(self, args: ModelArgs, layer_idx: int):
super().__init__()
self.num_attention_heads = args.num_attention_heads
self.num_key_value_heads = args.num_key_value_heads
self.layer_idx = layer_idx
self.head_dim = args.head_dim or args.hidden_size // args.num_attention_heads
self.scale = self.head_dim**-0.5
self.q_proj = nn.Linear(
args.hidden_size,
args.num_attention_heads * self.head_dim,
bias=args.attention_bias,
)
self.k_proj = nn.Linear(
args.hidden_size,
args.num_key_value_heads * self.head_dim,
bias=args.attention_bias,
)
self.v_proj = nn.Linear(
args.hidden_size,
args.num_key_value_heads * self.head_dim,
bias=args.attention_bias,
)
self.o_proj = nn.Linear(
args.num_attention_heads * self.head_dim,
args.hidden_size,
bias=args.attention_bias,
)
self.q_norm = nn.RMSNorm(
args.num_attention_heads * self.head_dim, eps=args.rms_norm_eps
)
self.k_norm = nn.RMSNorm(
args.num_key_value_heads * self.head_dim, eps=args.rms_norm_eps
)
self.is_full = args.layer_types[layer_idx] == "full_attention"
if self.is_full:
self.rope = nn.RoPE(self.head_dim, traditional=False, base=args.rope_theta)
else:
self.rope = initialize_rope(
self.head_dim,
traditional=False,
base=args.rope_theta,
scaling_config=args.rope_scaling,
max_position_embeddings=args.max_position_embeddings,
)
def __call__(
self,
x: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[Any] = None,
) -> mx.array:
B, L, _ = x.shape
queries = self.q_norm(self.q_proj(x))
keys = self.k_norm(self.k_proj(x))
values = self.v_proj(x)
queries = queries.reshape(B, L, self.num_attention_heads, -1).transpose(
0, 2, 1, 3
)
keys = keys.reshape(B, L, self.num_key_value_heads, -1).transpose(0, 2, 1, 3)
values = values.reshape(B, L, self.num_key_value_heads, -1).transpose(
0, 2, 1, 3
)
if cache is not None:
queries = self.rope(queries, offset=cache.offset)
keys = self.rope(keys, offset=cache.offset)
keys, values = cache.update_and_fetch(keys, values)
else:
queries = self.rope(queries)
keys = self.rope(keys)
output = scaled_dot_product_attention(
queries, keys, values, cache=cache, scale=self.scale, mask=mask
)
output = output.transpose(0, 2, 1, 3).reshape(B, L, -1)
return self.o_proj(output)
class Olmo3MLP(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
self.gate_proj = nn.Linear(args.hidden_size, args.intermediate_size, bias=False)
self.down_proj = nn.Linear(args.intermediate_size, args.hidden_size, bias=False)
self.up_proj = nn.Linear(args.hidden_size, args.intermediate_size, bias=False)
def __call__(self, x: mx.array) -> mx.array:
return self.down_proj(nn.silu(self.gate_proj(x)) * self.up_proj(x))
class Olmo3DecoderLayer(nn.Module):
def __init__(self, args: ModelArgs, layer_idx: int):
super().__init__()
self.num_attention_heads = args.num_attention_heads
self.hidden_size = args.hidden_size
self.self_attn = Olmo3Attention(args, layer_idx=layer_idx)
self.mlp = Olmo3MLP(args)
self.post_attention_layernorm = nn.RMSNorm(
args.hidden_size, eps=args.rms_norm_eps
)
self.post_feedforward_layernorm = nn.RMSNorm(
args.hidden_size, eps=args.rms_norm_eps
)
self.args = args
def __call__(
self,
x: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[Any] = None,
) -> mx.array:
r = self.post_attention_layernorm(self.self_attn(x, mask, cache))
h = x + r
r = self.post_feedforward_layernorm(self.mlp(h))
out = h + r
return out
class Olmo3Model(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
self.sliding_window = args.sliding_window
self.embed_tokens = nn.Embedding(args.vocab_size, args.hidden_size)
self.layers = [
Olmo3DecoderLayer(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.swa_idx = args.layer_types.index("sliding_attention")
self.ga_idx = args.layer_types.index("full_attention")
self.layer_types = args.layer_types
def __call__(
self,
inputs: mx.array,
cache: Optional[Any] = None,
) -> mx.array:
h = self.embed_tokens(inputs)
if cache is None:
cache = [None] * len(self.layers)
full_mask = create_attention_mask(h, cache[self.ga_idx])
sliding_window_mask = create_attention_mask(
h, cache[self.swa_idx], window_size=self.sliding_window
)
for layer, c, layer_type in zip(self.layers, cache, self.layer_types):
mask = full_mask if layer_type == "full_attention" else sliding_window_mask
h = layer(h, mask, cache=c)
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 = Olmo3Model(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,
) -> mx.array:
out = self.model(inputs, cache)
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
+1 -1
View File
@@ -20,7 +20,7 @@ class ModelArgs(BaseModelArgs):
def from_dict(cls, params):
if "text_config" not in params:
return cls(model_type=params["model_type"], text_config=params)
return cls(**params)
return super().from_dict(params)
class Model(nn.Module):
+7 -2
View File
@@ -141,8 +141,12 @@ class Qwen3Model(nn.Module):
self,
inputs: mx.array,
cache=None,
input_embeddings: Optional[mx.array] = None,
):
h = self.embed_tokens(inputs)
if input_embeddings is not None:
h = input_embeddings
else:
h = self.embed_tokens(inputs)
if cache is None:
cache = [None] * len(self.layers)
@@ -167,8 +171,9 @@ class Model(nn.Module):
self,
inputs: mx.array,
cache=None,
input_embeddings: Optional[mx.array] = None,
):
out = self.model(inputs, cache)
out = self.model(inputs, cache, input_embeddings)
if self.args.tie_word_embeddings:
out = self.model.embed_tokens.as_linear(out)
else:
+7 -5
View File
@@ -188,8 +188,12 @@ class Qwen3MoeModel(nn.Module):
self,
inputs: mx.array,
cache=None,
input_embeddings: Optional[mx.array] = None,
):
h = self.embed_tokens(inputs)
if input_embeddings is not None:
h = input_embeddings
else:
h = self.embed_tokens(inputs)
if cache is None:
cache = [None] * len(self.layers)
@@ -211,11 +215,9 @@ class Model(nn.Module):
self.lm_head = nn.Linear(args.hidden_size, args.vocab_size, bias=False)
def __call__(
self,
inputs: mx.array,
cache=None,
self, inputs: mx.array, cache=None, input_embeddings: Optional[mx.array] = None
):
out = self.model(inputs, cache)
out = self.model(inputs, cache, input_embeddings)
return self.lm_head(out)
def sanitize(self, weights):
+27 -6
View File
@@ -1,12 +1,19 @@
# Copyright © 2025 Apple Inc.
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple, Union
import mlx.core as mx
import mlx.nn as nn
from .base import BaseModelArgs, create_attention_mask, scaled_dot_product_attention
from .base import (
BaseModelArgs,
create_attention_mask,
create_ssm_mask,
scaled_dot_product_attention,
)
from .cache import KVCache, MambaCache
from .gated_delta import gated_delta_update
from .rope_utils import initialize_rope
@@ -237,6 +244,8 @@ class Qwen3NextGatedDeltaNet(nn.Module):
mixed_qkv = mx.concatenate(
[q.reshape(B, S, -1), k.reshape(B, S, -1), v.reshape(B, S, -1)], axis=-1
)
if mask is not None:
mixed_qkv = mx.where(mask[..., None], mixed_qkv, 0)
conv_input = mx.concatenate([conv_state, mixed_qkv], axis=1)
if cache is not None:
cache[0] = conv_input[:, -(self.conv_kernel_size - 1) :]
@@ -251,14 +260,23 @@ class Qwen3NextGatedDeltaNet(nn.Module):
)
]
if cache is not None:
state = cache[1]
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)
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
@@ -350,6 +368,7 @@ class Qwen3NextModel(nn.Module):
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__(
@@ -362,9 +381,11 @@ class Qwen3NextModel(nn.Module):
if cache is None:
cache = [None] * len(self.layers)
mask = create_attention_mask(hidden_states, cache[self.fa_idx])
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)
+57
View File
@@ -0,0 +1,57 @@
# Copyright © 2025 Apple Inc.
from dataclasses import dataclass
from typing import Optional
import mlx.core as mx
import mlx.nn as nn
from mlx.utils import tree_flatten, tree_unflatten
from . import qwen3
from .base import BaseModelArgs
@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 = qwen3.Model(qwen3.ModelArgs.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):
weights = tree_unflatten(list(weights.items()))
weights.pop("vision_tower", None)
weights = dict(tree_flatten(weights))
sanitized = {}
for key, value in weights.items():
if not key.startswith("language_model."):
key = "language_model." + key
sanitized[key] = value
return sanitized
@property
def layers(self):
return self.language_model.model.layers
+77
View File
@@ -0,0 +1,77 @@
# Copyright © 2025 Apple Inc.
from dataclasses import dataclass
from typing import Optional
import mlx.core as mx
import mlx.nn as nn
from mlx.utils import tree_flatten, tree_unflatten
from . import qwen3_moe
from .base import BaseModelArgs
@dataclass
class ModelArgs(BaseModelArgs):
model_type: str
text_config: dict
class Model(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
self.args = args
self.model_type = args.model_type
self.language_model = qwen3_moe.Model(
qwen3_moe.ModelArgs.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):
weights = tree_unflatten(list(weights.items()))
weights.pop("visual", None)
weights = dict(
tree_flatten(
{
"language_model": {
"model": weights["language_model"]["model"],
"lm_head": weights["language_model"]["lm_head"],
}
}
)
)
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 weights:
gate_up = weights.pop(gate_up_key)
mid = gate_up.shape[-1] // 2
weights[f"{prefix}.switch_mlp.gate_proj.weight"] = gate_up[
..., :mid
].swapaxes(-2, -1)
weights[f"{prefix}.switch_mlp.up_proj.weight"] = gate_up[
..., mid:
].swapaxes(-2, -1)
weights[f"{prefix}.switch_mlp.down_proj.weight"] = weights.pop(
f"{prefix}.experts.down_proj"
).swapaxes(-2, -1)
return weights
@property
def quant_predicate(self):
return self.language_model.quant_predicate
@property
def layers(self):
return self.language_model.model.layers
+42 -26
View File
@@ -114,6 +114,7 @@ def ssm_attn(
state: Optional[mx.array] = None,
time_step_limit: Tuple[float, float] = (0.001, 100.0),
mask: Optional[mx.array] = None,
step: int = 256,
) -> Tuple[mx.array, mx.array]:
"""SSD-SSM forward pass.
@@ -127,6 +128,7 @@ def ssm_attn(
dt_bias: Bias for time deltas of shape (num_heads,).
time_step_limit: Minimum and maximum value for time deltas.
mask: Optional multiplicative mask.
step: Step size for processing x.
Code modified from
https://github.com/cartesia-ai/edge/blob/main/cartesia-mlx/cartesia_mlx/layers/ssd/ops.py
@@ -138,38 +140,52 @@ def ssm_attn(
dt = compute_dt(dt, dt_bias, time_step_limit)
repeats = h // g
A = -mx.exp(A_log)
B = mx.transpose(B, (0, 2, 3, 1))
# A * s + B * C
CB = mx.swapaxes(C, 1, 2) @ B
CB = mx.repeat(CB, repeats, axis=1)
dtA = dt * A.reshape(1, 1, -1)
decay = mx.exp(segsum(dtA.swapaxes(1, 2), mask=mask))
surrogate_attention_matrix = mx.tril(CB * decay, 0)
dtx = dt.reshape(b, l, h, 1) * x
y = surrogate_attention_matrix @ dtx.swapaxes(1, 2)
y = mx.swapaxes(y, 1, 2)
decay = decay[:, :, -1:, :].transpose(0, 3, 1, 2)
B = mx.repeat(B, h // g, axis=1).swapaxes(2, 3)
dtxdecay = dtx * decay
dtxdecay = dtxdecay.swapaxes(1, 2).swapaxes(2, 3)
def _step(dtx, dtA, B, C, state, mask):
s = dtx.shape[1]
B = mx.transpose(B, (0, 2, 3, 1))
next_state = dtxdecay @ B
CB = mx.swapaxes(C, 1, 2) @ B
CB = mx.repeat(CB, repeats, axis=1)
if state is not None:
exp_dtA_cumsum = mx.exp(mx.cumsum(dtA, axis=-2))
next_state += exp_dtA_cumsum[:, -1, :, None, None] * state
state = state.reshape((b, 1, g, repeats, dh, d))
C = C.reshape(b, l, g, 1, d, 1)
y_prev = (state @ C).squeeze(-1).flatten(2, 3)
y += exp_dtA_cumsum[..., None] * y_prev
decay = mx.exp(segsum(dtA.swapaxes(1, 2), mask=mask))
y += x * D.reshape(1, 1, h, 1)
return y, next_state
surrogate_attention_matrix = mx.tril(CB * decay, 0)
y = surrogate_attention_matrix @ dtx.swapaxes(1, 2)
y = mx.swapaxes(y, 1, 2)
decay = decay[:, :, -1:, :].transpose(0, 3, 1, 2)
B = mx.repeat(B, h // g, axis=1).swapaxes(2, 3)
dtxdecay = dtx * decay
dtxdecay = dtxdecay.swapaxes(1, 2).swapaxes(2, 3)
next_state = dtxdecay @ B
if state is not None:
exp_dtA_cumsum = mx.exp(mx.cumsum(dtA, axis=-2))
next_state += exp_dtA_cumsum[:, -1, :, None, None] * state
state = state.reshape((b, 1, g, repeats, dh, d))
C = C.reshape(b, s, g, 1, d, 1)
y_prev = (state @ C).squeeze(-1).flatten(2, 3)
y += exp_dtA_cumsum[..., None] * y_prev
return y, next_state
ys = []
for i in range(0, l, step):
y, state = _step(
dtx[:, i : i + step],
dtA[:, i : i + step],
B[:, i : i + step],
C[:, i : i + step],
state,
None if mask is None else mask[..., i : i + step],
)
ys.append(y)
y = mx.concatenate(ys, axis=1) + x * D.reshape(1, 1, h, 1)
return y, state
def ssm_update(
+3 -6
View File
@@ -16,8 +16,7 @@ from mlx_lm.models.base import create_attention_mask
from mlx_lm.models.switch_layers import SwitchLinear
from mlx_lm.quant.utils import load_data
from mlx_lm.utils import (
fetch_from_hub,
get_model_path,
load,
save,
)
@@ -555,8 +554,7 @@ def main():
mx.random.seed(args.seed)
model_path, hf_repo = get_model_path(args.model, revision=None)
model, config, tokenizer = fetch_from_hub(model_path, lazy=True)
model, tokenizer, config = load(args.model, lazy=True, return_config=True)
model_type = config["model_type"]
if (awq_config := AWQ_MODEL_CONFIGS.get(model_type, None)) is None:
@@ -580,9 +578,8 @@ def main():
config = update_config(model, config)
save(
args.mlx_path,
model_path,
args.model,
model,
tokenizer,
config,
hf_repo=hf_repo,
)
+41 -88
View File
@@ -9,7 +9,7 @@ import mlx.core as mx
import mlx.nn as nn
import mlx.optimizers as optimizers
import numpy as np
from mlx.utils import tree_flatten, tree_map
from mlx.utils import tree_map
from tqdm import tqdm
from mlx_lm.tuner.datasets import load_dataset
@@ -17,32 +17,12 @@ from mlx_lm.tuner.losses import kl_div_loss
from mlx_lm.tuner.trainer import grad_checkpoint, iterate_batches
from mlx_lm.tuner.utils import print_trainable_parameters
from mlx_lm.utils import (
fetch_from_hub,
get_model_path,
load,
quantize_model,
save,
)
class Catcher(nn.Module):
def __init__(self, module):
super().__init__()
self.module = module
def __call__(self, *args, **kwargs):
outputs = self.module(*args, **kwargs)
self.outputs = outputs[0] if isinstance(outputs, tuple) else outputs
return outputs
def __getattr__(self, key: str):
if (value := self.get(key)) is not None:
return value
elif (m := self.get("module")) is not None:
return getattr(m, key)
else:
super(nn.Module, self).__getattribute__(key)
def dwq_quantize(
model,
q_model,
@@ -51,10 +31,9 @@ def dwq_quantize(
valid_data,
batch_size: int = 2,
max_seq_length: int = 2048,
activation_layer_step: float = 0.25,
activation_loss_weight: float = 1.0,
dtype: mx.Dtype = mx.bfloat16,
gradient_checkpoint: bool = False,
temperature: float = 2.0,
):
group = mx.distributed.init()
world_size = group.size()
@@ -65,52 +44,35 @@ def dwq_quantize(
tqdm.write(*args, **kwargs)
def unfreeze(_, m):
if hasattr(m, "bits") and hasattr(m, "group_size") and m.mode == "affine":
if (
hasattr(m, "bits")
and hasattr(m, "group_size")
and m.mode == "affine"
and m.bits < 8
):
m.unfreeze(keys=["scales", "biases"], recurse=False)
q_model.train()
q_model.apply_to_modules(unfreeze)
print_trainable_parameters(q_model)
layer_id_step = max(int(activation_layer_step * len(model.layers)), 1)
layer_ids = list(range(len(model.layers)))[layer_id_step::layer_id_step]
for lid in layer_ids:
model.layers[lid] = Catcher(model.layers[lid])
q_model.layers[lid] = Catcher(q_model.layers[lid])
if gradient_checkpoint:
grad_checkpoint(q_model.layers[0])
def forward(model, inputs):
logits = model(inputs)
extra_targets = [
model.layers[lid].outputs.astype(mx.float32) for lid in layer_ids
]
for lid in layer_ids:
model.layers[lid].outputs = None
return logits, extra_targets
scale = 1 / temperature
def loss_fn(params, x, targets, extra_targets, lengths):
def loss_fn(params, x, targets, lengths):
q_model.update(tree_map(lambda x: x.astype(dtype), params))
logits, q_extra_targets = forward(q_model, x)
losses = kl_div_loss(logits, targets)
logits = q_model(x)
losses = kl_div_loss(scale * logits, scale * targets)
mask = mx.arange(1, 1 + targets.shape[1]) < lengths[:, 1:]
ntoks = mask.sum()
kl_loss = (mask * losses).sum() / ntoks
act_loss = mx.stack(
[
(mask * (qe - e).abs().mean(axis=-1)).sum() / ntoks
for qe, e in zip(q_extra_targets, extra_targets)
]
)
act_loss = act_loss.mean()
loss = kl_loss + activation_loss_weight * act_loss
return loss, ntoks, kl_loss, act_loss
loss = (mask * losses).sum() / ntoks
return loss, ntoks
def step(inputs, targets, extra_targets, lengths, params):
(loss, ntoks, *_), grads = mx.value_and_grad(loss_fn)(
params, inputs, targets, extra_targets, lengths
def step(inputs, targets, lengths, params):
(loss, ntoks), grads = mx.value_and_grad(loss_fn)(
params, inputs, targets, lengths
)
grads = nn.average_gradients(grads)
params = opt.apply_gradients(grads, params)
@@ -118,36 +80,24 @@ def dwq_quantize(
def validate(params, it):
v_loss = 0.0
v_kl_loss = 0.0
v_act_loss = 0.0
v_tokens = 0
for it, (batch, lengths) in tqdm(
enumerate(iterate_batches(valid_data, batch_size, max_seq_length)),
for batch, lengths in tqdm(
iterate_batches(valid_data, batch_size, max_seq_length),
total=len(valid_data) // batch_size,
desc="Computing validation loss",
leave=False,
):
batch = batch[:, :-1]
targets, extra_targets = forward(model, batch)
mx.eval(targets, extra_targets)
loss, ntoks, kl_loss, act_loss = loss_fn(
params, batch, targets, extra_targets, lengths
)
targets = model(batch)
mx.eval(targets)
loss, ntoks = loss_fn(params, batch, targets, lengths)
mx.eval(loss, ntoks)
loss = mx.distributed.all_sum(loss, stream=mx.cpu).item() / world_size
kl_loss = mx.distributed.all_sum(kl_loss, stream=mx.cpu).item() / world_size
act_loss = (
mx.distributed.all_sum(act_loss, stream=mx.cpu).item() / world_size
)
ntoks = mx.distributed.all_sum(ntoks, stream=mx.cpu).item()
v_tokens += ntoks
v_loss += loss * ntoks
v_kl_loss += kl_loss * ntoks
v_act_loss += act_loss * ntoks
loss = v_loss / v_tokens
kl_loss = v_kl_loss / v_tokens
act_loss = v_act_loss / v_tokens
rprint(f"Validation: {it=}, {loss=:.3f}, {kl_loss=:.3f}, {act_loss=:.3f}")
rprint(f"Validation: {it=}, {loss=:.3f}")
return loss
# Accumulate learned weights in higher precision
@@ -172,9 +122,9 @@ def dwq_quantize(
)
):
batch = batch[:, :-1]
targets, extra_targets = forward(model, batch)
mx.eval(targets, extra_targets)
loss, ntoks, params = step(batch, targets, extra_targets, lengths, params)
targets = model(batch)
mx.eval(targets)
loss, ntoks, params = step(batch, targets, lengths, params)
mx.eval(loss, params)
loss = mx.distributed.all_sum(loss, stream=mx.cpu).item() / world_size
ntoks = mx.distributed.all_sum(ntoks, stream=mx.cpu).item()
@@ -206,8 +156,6 @@ def dwq_quantize(
)
q_model.update(tree_map(lambda x: x.astype(dtype), params))
for lid in layer_ids:
q_model.layers[lid] = q_model.layers[lid].module
def load_data(
@@ -273,7 +221,7 @@ def main():
default=2048,
help="Number of samples to use for training.",
)
parser.add_argument("--max-seq-length", type=int, default=2049)
parser.add_argument("--max-seq-length", type=int, default=1025)
parser.add_argument("--seed", type=int, default=123)
parser.add_argument("--learning-rate", type=float, default=1e-6)
parser.add_argument("--batch-size", type=int, default=4)
@@ -299,9 +247,10 @@ def main():
np.random.seed(args.seed)
mx.random.seed(args.seed)
model_path, hf_repo = get_model_path(args.model, revision=None)
model, config, tokenizer = fetch_from_hub(
model_path, lazy=True, trust_remote_code=True
model, tokenizer, config = load(
args.model,
lazy=True,
return_config=True,
)
train_data, valid_data = load_data(
@@ -309,9 +258,10 @@ def main():
)
if args.quantized_model is not None:
q_model_path, _ = get_model_path(args.quantized_model, revision=None)
q_model, config, _ = fetch_from_hub(
q_model_path, lazy=True, trust_remote_code=True
q_model, tokenizer, config = load(
args.quantized_model,
lazy=True,
return_config=True,
)
if "quantization" not in config:
raise ValueError("Quantized model must already be quantized.")
@@ -324,6 +274,10 @@ def main():
bits=args.bits,
)
if mx.metal.is_available():
max_rec_size = mx.metal.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)
dwq_quantize(
model,
@@ -337,9 +291,8 @@ def main():
)
save(
args.mlx_path,
model_path,
args.model,
q_model,
tokenizer,
config,
hf_repo=hf_repo,
)
+2 -7
View File
@@ -16,8 +16,6 @@ from mlx_lm.tuner.losses import kl_div_loss
from mlx_lm.tuner.trainer import grad_checkpoint
from mlx_lm.utils import (
compute_bits_per_weight,
fetch_from_hub,
get_model_path,
load,
quantize_model,
save,
@@ -187,9 +185,9 @@ def main():
args = parser.parse_args()
group = mx.distributed.init()
model, tokenizer, config = load(args.model, return_config=True)
if args.sensitivities is None:
model, tokenizer = load(args.model)
mx.random.seed(args.seed)
data = load_data(tokenizer, num_samples=-1, sequence_length=512)
@@ -211,8 +209,6 @@ def main():
sensitivities = json.load(fid)
sensitivities = dict(sensitivities)
model_path, hf_repo = get_model_path(args.model, revision=None)
model, config, tokenizer = fetch_from_hub(model_path, lazy=True)
mx.random.seed(args.seed)
data = load_data(tokenizer, num_samples=-1, sequence_length=512)
@@ -251,11 +247,10 @@ def main():
save(
args.mlx_path,
model_path,
args.model,
model,
tokenizer,
config,
hf_repo=hf_repo,
)
print(f"Peak memory used: {mx.get_peak_memory() / 1000**3:.3f}GB")
+3 -6
View File
@@ -18,8 +18,7 @@ from mlx_lm.models.switch_layers import QuantizedSwitchLinear, SwitchLinear
from mlx_lm.quant.utils import load_data
from mlx_lm.utils import (
compute_bits_per_weight,
fetch_from_hub,
get_model_path,
load,
save,
)
@@ -202,8 +201,7 @@ def main():
mx.random.seed(args.seed)
model_path, hf_repo = get_model_path(args.model, revision=None)
model, config, tokenizer = fetch_from_hub(model_path, lazy=True)
model, tokenizer, config = load(args.model, lazy=True, return_config=True)
calibration_data = load_data(tokenizer, args.num_samples, args.sequence_length)
model, config["quantization"] = gptq_quantize(
@@ -220,11 +218,10 @@ def main():
save(
args.mlx_path,
model_path,
args.model,
model,
tokenizer,
config,
hf_repo=hf_repo,
)
+6 -65
View File
@@ -279,8 +279,6 @@ class APIHandler(BaseHTTPRequestHandler):
endpoints = {
"/v1/completions": self.handle_text_completions,
"/v1/chat/completions": self.handle_chat_completions,
"/responses": self.handle_responses,
"/v1/responses": self.handle_responses,
"/chat/completions": self.handle_chat_completions,
}
@@ -330,7 +328,6 @@ class APIHandler(BaseHTTPRequestHandler):
self.max_tokens = self.body.get(
"max_tokens", self.model_provider.cli_args.max_tokens
)
self.temperature = self.body.get(
"temperature", self.model_provider.cli_args.temp
)
@@ -495,18 +492,6 @@ class APIHandler(BaseHTTPRequestHandler):
"id": None,
}
if self.stream and self.object_type == "response" and finish_reason is None:
return {
"type": "response.output_text.delta",
"delta": text,
# TODO, these need valid values
"sequence_number": None,
"item_id": None,
"output_index": 1,
"content_index": 0,
"logprobs": [],
}
# Static response
response = {
"id": self.request_id,
@@ -514,27 +499,13 @@ class APIHandler(BaseHTTPRequestHandler):
"object": self.object_type,
"model": self.requested_model,
"created": self.created,
}
if self.object_type == "response":
response["output"] = [
"choices": [
{
"type": "message",
"role": "assistant",
"content": [{"text": text, "type": "output_text"}],
}
]
if self.stream:
return {"response": response, "type": "response.completed"}
return response
response["choices"] = [
{
"index": 0,
"finish_reason": finish_reason,
},
]
"index": 0,
"finish_reason": finish_reason,
},
],
}
if token_logprobs or top_logprobs or tokens:
response["choices"][0]["logprobs"] = {
@@ -893,36 +864,6 @@ class APIHandler(BaseHTTPRequestHandler):
return prompt
def handle_responses(self) -> List[int]:
body = self.body
system_prompt = body.get("instructions")
prompt = body["input"]
tools = body.get("tools")
messages = []
if system_prompt:
messages = [{"role": "system", "content": system_prompt}]
if isinstance(prompt, list):
for message in prompt:
content = message["content"]
if isinstance(content, list):
if len(content) != 1 or content[0]["type"] != "input_text":
raise ValueError("Unsupported content type.")
message["content"] = content[0]["text"]
messages.append(message)
else:
messages.append({"role": "user", "content": prompt})
# Determine response type
self.request_id = f"resp_{uuid.uuid4()}"
self.object_type = "response"
prompt = self.tokenizer.apply_chat_template(
messages,
tools=tools,
add_generation_prompt=True,
**self.model_provider.cli_args.chat_template_args,
)
return prompt
def handle_text_completions(self) -> List[int]:
"""
Handle a text completion request.
+15 -9
View File
@@ -176,19 +176,25 @@ class LoRASwitchLinear(nn.Module):
self.lora_a = mx.random.uniform(
low=-scale,
high=scale,
shape=(r * num_experts, input_dims),
shape=(num_experts, r, input_dims),
)
self.lora_b = mx.zeros(shape=(num_experts, output_dims, r))
self.num_experts = num_experts
def __call__(self, x, indices):
shape = x.shape[:-3] + (self.num_experts, -1)
y = self.linear(x, indices)
z = (self.dropout(x) @ self.lora_a.T).reshape(shape)
z = mx.take_along_axis(z, indices[..., None], axis=-2)
z = z[..., None, :] @ self.lora_b[indices].swapaxes(-2, -1)
def __call__(self, x, indices, sorted_indices=False):
y = self.linear(x, indices, sorted_indices=sorted_indices)
z = mx.gather_mm(
self.dropout(x),
self.lora_a.swapaxes(-1, -2),
rhs_indices=indices,
sorted_indices=sorted_indices,
)
z = mx.gather_mm(
z,
self.lora_b.swapaxes(-1, -2),
rhs_indices=indices,
sorted_indices=sorted_indices,
)
return y + (self.scale * z).astype(x.dtype)
+32 -12
View File
@@ -10,7 +10,7 @@ import mlx.core as mx
import mlx.nn as nn
import numpy as np
from mlx.nn.utils import average_gradients
from mlx.utils import tree_flatten
from mlx.utils import tree_flatten, tree_map
from tqdm import tqdm
from .callbacks import TrainingCallback
@@ -64,6 +64,12 @@ class TrainingArgs:
default=False,
metadata={"help": "Use gradient checkpointing to reduce memory use."},
)
grad_accumulation_steps: int = field(
default=1,
metadata={
"help": "Number of steps to accumulate gradients before applying an optimizer update."
},
)
def default_loss(model, batch, lengths):
@@ -209,22 +215,29 @@ def train(
if args.grad_checkpoint:
grad_checkpoint(model.layers[0])
loss_value_and_grad = nn.value_and_grad(model, loss)
grad_accum_steps = args.grad_accumulation_steps
if grad_accum_steps < 1:
raise ValueError("grad_accumulation_steps must be at least 1")
state = [model.state, optimizer.state, mx.random.state]
@partial(mx.compile, inputs=state, outputs=state)
def step(batch):
# Forward and backward pass
def step(batch, prev_grad, do_update):
(lvalue, toks), grad = loss_value_and_grad(model, *batch)
# All reduce the gradients if running in distributed mode
grad = average_gradients(grad)
if prev_grad is not None:
grad = tree_map(lambda x, y: x + y, grad, prev_grad)
# Model update
optimizer.update(model, grad)
if do_update:
grad = average_gradients(grad)
if grad_accum_steps > 1:
grad = tree_map(lambda x: x / grad_accum_steps, grad)
optimizer.update(model, grad)
grad = None
return lvalue, toks
loss_value_and_grad = nn.value_and_grad(model, loss)
return lvalue, toks, grad
model.train()
losses = 0
@@ -232,6 +245,8 @@ def train(
steps = 0
trained_tokens = 0
train_time = 0
grad_accum = None
# Main training loop
for it, batch in zip(
range(1, args.iters + 1),
@@ -276,11 +291,16 @@ def train(
tic = time.perf_counter()
lvalue, toks = step(batch)
lvalue, toks, grad_accum = step(
batch,
grad_accum,
it % grad_accum_steps == 0,
)
losses += lvalue
n_tokens += toks
steps += 1
mx.eval(state, losses, n_tokens)
mx.eval(state, losses, n_tokens, grad_accum)
train_time += time.perf_counter() - tic
# Report training loss if needed
+18 -135
View File
@@ -7,7 +7,7 @@ from typing import Dict
import mlx.core as mx
import mlx.nn as nn
import mlx.optimizers as opt
from mlx.utils import tree_flatten, tree_unflatten
from mlx.utils import tree_flatten, tree_map_with_path, tree_unflatten
from ..models.switch_layers import QuantizedSwitchLinear, SwitchLinear
from .dora import DoRAEmbedding, DoRALinear
@@ -81,140 +81,23 @@ def linear_to_lora_layers(
dropout=config["dropout"],
)
keys = config.get("keys", None)
if keys is not None:
keys = set(keys)
elif model.model_type in {
"mistral",
"mistral3",
"llama",
"llama4_text",
"lfm2",
"phi",
"mixtral",
"nemotron",
"stablelm",
"hunyuan",
"qwen2",
"qwen2_moe",
"qwen3",
"qwen3_moe",
"phimoe",
"gemma",
"gemma2",
"gemma3",
"gemma3_text",
"granite",
"helium",
"starcoder2",
"cohere",
"cohere2",
"minicpm",
"minicpm3",
"minicpm4",
"deepseek",
"olmo2",
"olmoe",
"internlm3",
"glm4",
"glm",
"mimo",
"ernie4_5",
"dots1",
"smollm3",
"exaone4",
"hunyuan_v1_dense",
"gpt_oss",
"ernie4_5_moe",
"granitemoe",
"longcat_flash",
"seed_oss",
"apertus",
"qwen3_next",
"Klear",
"lille-130m",
}:
keys = {"self_attn.q_proj", "self_attn.v_proj"}
if model.model_type in ["mixtral", "phimoe"]:
keys.add("block_sparse_moe.gate")
if model.model_type in ["qwen2_moe", "qwen3_next"]:
keys.add("mlp.gate")
keys.add("mlp.shared_expert_gate")
if model.model_type in ["olmoe", "qwen3_moe", "dots1", "Klear"]:
keys.add("mlp.gate")
if model.model_type in ["longcat_flash"]:
keys.add("mlp.router.classifier")
if model.model_type == "lille-130m":
keys.add("attention.qkv_proj")
keys.add("attention.out_proj")
keys.add("feed_forward.gate_proj")
keys.add("feed_forward.up_proj")
keys.add("feed_forward.down_proj")
elif model.model_type == "qwen3_next":
keys.add("linear_attn.in_proj_qkvz")
keys.add("linear_attn.out_proj")
keys.add("linear_attn.in_proj_ba")
keys.add("linear_attn.dt_bias")
keys.add("self_attn.q_proj")
keys.add("self_attn.k_proj")
keys.add("self_attn.v_proj")
keys.add("self_attn.o_proj")
elif model.model_type == "gpt_bigcode":
keys = {"attn.c_attn"}
elif model.model_type == "gpt2":
keys = {"attn.c_attn"}
elif model.model_type == "gpt_neox":
keys = {"attention.query_key_value"}
elif model.model_type == "olmo":
keys = {"att_proj"}
elif model.model_type == "openelm":
keys = {"attn.qkv_proj"}
elif model.model_type == "phi3":
keys = {"self_attn.qkv_proj"}
elif model.model_type == "phi-msft":
keys = {"mixer.Wqkv", "moe.gate"}
elif model.model_type == "dbrx":
keys = {"norm_attn_norm.attn.Wqkv", "ffn.router.layer"}
elif model.model_type == "internlm2":
keys = {"attention.wqkv", "attention.wo"}
elif model.model_type in {
"deepseek_v2",
"deepseek_v3",
"longcat_flash",
"minicpm3",
}:
keys = {
"self_attn.q_proj",
"self_attn.q_a_proj",
"self_attn.q_b_proj",
"self_attn.kv_a_proj_with_mqa",
"self_attn.kv_b_proj",
}
elif model.model_type == "mamba":
keys = {
"mixer.in_proj",
"mixer.x_proj",
"mixer.dt_proj",
"mixer.out_proj",
}
elif model.model_type == "mamba2":
keys = {
"mixer.in_proj",
"mixer.out_proj",
}
elif model.model_type == "exaone":
keys = {"attn.attention.q_proj", "attn.attention.v_proj"}
elif model.model_type == "bailing_moe":
keys = {"attention.query_key_value", "attention.dense"}
elif model.model_type == "nemotron_h":
keys.add("mixer.in_proj")
keys.add("mixer.out_proj")
keys.add("mixer.q_proj")
keys.add("mixer.k_proj")
keys.add("mixer.v_proj")
keys.add("mixer.o_proj")
else:
raise ValueError(f"Lora does not support {model.model_type}")
if (keys := config.get("keys", None)) is None:
keys = set()
def get_keys_for_lora(p, m):
types = (
nn.Linear,
nn.QuantizedLinear,
SwitchLinear,
QuantizedSwitchLinear,
nn.Embedding,
nn.QuantizedEmbedding,
)
if hasattr(m, "to_lora") or isinstance(m, types):
keys.add(p)
for l in model.layers:
l.apply_to_modules(get_keys_for_lora)
for l in model.layers[-max(num_layers, 0) :]:
lora_layers = [(k, to_lora(m)) for k, m in l.named_modules() if k in keys]
+54 -45
View File
@@ -42,6 +42,7 @@ from .tuner.utils import get_total_parameters, load_adapters
# Constants
MODEL_REMAPPING = {
"mistral": "llama",
"llava": "mistral3",
"phi-msft": "phixtral",
"falcon_mamba": "mamba",
"kimi_k2": "deepseek_v3",
@@ -81,9 +82,7 @@ def compute_bits_per_weight(model):
return model_bytes * 8 / model_params
def get_model_path(
path_or_hf_repo: str, revision: Optional[str] = None
) -> Tuple[Path, Optional[str]]:
def _download(path_or_hf_repo: str, revision: Optional[str] = None) -> Path:
"""
Ensures the model is available locally. If the path does not exist locally,
it is downloaded from the Hugging Face Hub.
@@ -93,12 +92,11 @@ def get_model_path(
revision (str, optional): A revision id which can be a branch name, a tag, or a commit hash.
Returns:
Tuple[Path, str]: A tuple containing the local file path and the Hugging Face repo ID.
Path: The local file path.
"""
model_path = Path(path_or_hf_repo)
if not model_path.exists():
hf_path = path_or_hf_repo
model_path = Path(
snapshot_download(
path_or_hf_repo,
@@ -116,16 +114,12 @@ def get_model_path(
],
)
)
else:
from huggingface_hub import ModelCard
card_path = model_path / "README.md"
if card_path.is_file():
card = ModelCard.load(card_path)
hf_path = card.data.base_model
else:
hf_path = None
return model_path, hf_path
return model_path
def hf_repo_to_path(hf_repo):
return Path(snapshot_download(hf_repo, local_files_only=True))
def load_config(model_path: Path) -> dict:
@@ -237,7 +231,12 @@ def load(
model_config={},
adapter_path: Optional[str] = None,
lazy: bool = False,
) -> Tuple[nn.Module, TokenizerWrapper]:
return_config: bool = False,
revision: str = None,
) -> Union[
Tuple[nn.Module, TokenizerWrapper],
Tuple[nn.Module, TokenizerWrapper, Dict[str, Any]],
]:
"""
Load the model and tokenizer from a given path or a huggingface repository.
@@ -252,16 +251,19 @@ def load(
lazy (bool): If ``False`` eval the model parameters to make sure they are
loaded in memory before returning, otherwise they will be loaded
when needed. Default: ``False``
return_config (bool: If ``True`` return the model config as the last item..
revision (str, optional): A revision id which can be a branch name, a tag, or a commit hash.
Returns:
Tuple[nn.Module, TokenizerWrapper]: A tuple containing the loaded model and tokenizer.
Union[Tuple[nn.Module, TokenizerWrapper], Tuple[nn.Module, TokenizerWrapper, Dict[str, Any]]]:
A tuple containing the loaded model, tokenizer and, if requested, the model config.
Raises:
FileNotFoundError: If config file or safetensors are not found.
ValueError: If model class or args class are not found.
"""
model_path, _ = get_model_path(path_or_hf_repo)
model_path = _download(path_or_hf_repo, revision=revision)
model, config = load_model(model_path, lazy)
model, config = load_model(model_path, lazy, model_config=model_config)
if adapter_path is not None:
model = load_adapters(model, adapter_path)
model.eval()
@@ -269,19 +271,10 @@ def load(
model_path, tokenizer_config, eos_token_ids=config.get("eos_token_id", None)
)
return model, tokenizer
def fetch_from_hub(
model_path: Path, lazy: bool = False, trust_remote_code: bool = False
) -> Tuple[nn.Module, dict, PreTrainedTokenizer]:
model, config = load_model(model_path, lazy)
tokenizer = load_tokenizer(
model_path,
eos_token_ids=config.get("eos_token_id", None),
tokenizer_config_extra={"trust_remote_code": trust_remote_code},
)
return model, config, tokenizer
if return_config:
return model, tokenizer, config
else:
return model, tokenizer
def make_shards(weights: dict, max_file_size_gb: int = MAX_FILE_SIZE_GB) -> list:
@@ -308,24 +301,28 @@ def make_shards(weights: dict, max_file_size_gb: int = MAX_FILE_SIZE_GB) -> list
return shards
def create_model_card(path: Union[str, Path], hf_path: Union[str, Path]):
def create_model_card(path: Union[str, Path], hf_path: Union[str, Path, None]):
"""
Uploads the model to Hugging Face hub.
Args:
path (Union[str, Path]): Local path to the model.
hf_path (Union[str, Path]): Path to the original Hugging Face model.
hf_path (Union[str, Path, None]): Path to the original Hugging Face model.
"""
from huggingface_hub import ModelCard
from huggingface_hub import ModelCard, ModelCardData
card = ModelCard.load(hf_path)
if hf_path is None:
card = ModelCard.from_template(ModelCardData(language="en"))
else:
card = ModelCard.load(hf_path)
card.data.library_name = "mlx"
card.data.pipeline_tag = "text-generation"
if card.data.tags is None:
card.data.tags = ["mlx"]
elif "mlx" not in card.data.tags:
card.data.tags += ["mlx"]
card.data.base_model = str(hf_path)
if hf_path is not None:
card.data.base_model = str(hf_path)
card.text = ""
card.save(os.path.join(path, "README.md"))
@@ -345,15 +342,22 @@ def upload_to_hub(path: str, upload_repo: str):
logging.set_verbosity_info()
card_path = Path(path) / "README.md"
card = ModelCard.load(card_path)
hf_path = card.data.base_model
card.text = dedent(
f"""
# {upload_repo}
hf_path = card.data.base_model
if hf_path is not None:
provenance = f"""
This model [{upload_repo}](https://huggingface.co/{upload_repo}) was
converted to MLX format from [{hf_path}](https://huggingface.co/{hf_path})
using mlx-lm version **{__version__}**.
"""
else:
provenance = ""
card.text = dedent(
f"""
# {upload_repo}
{provenance}
## Use with mlx
```bash
@@ -544,14 +548,20 @@ def save_config(
def save(
dst_path: Union[str, Path],
src_path: Union[str, Path],
src_path_or_repo: Union[str, Path],
model: nn.Module,
tokenizer: TokenizerWrapper,
config: Dict[str, Any],
hf_repo: Optional[str] = None,
donate_model: bool = True,
):
src_path = Path(src_path)
src_path = Path(src_path_or_repo)
if not src_path.exists():
hf_repo = src_path_or_repo
src_path = hf_repo_to_path(hf_repo)
else:
hf_repo = None
dst_path = Path(dst_path)
save_model(dst_path, model, donate_model=True)
save_config(config, config_path=dst_path / "config.json")
@@ -561,8 +571,7 @@ def save(
for file in glob.glob(str(src_path / p)):
shutil.copy(file, dst_path)
if hf_repo is not None:
create_model_card(dst_path, hf_repo)
create_model_card(dst_path, hf_repo)
def common_prefix_len(list1, list2):
-6
View File
@@ -1,6 +0,0 @@
mlx>=0.29.1
numpy
transformers>=4.39.3
protobuf
pyyaml
jinja2
+13 -4
View File
@@ -6,12 +6,12 @@ from pathlib import Path
from setuptools import setup
package_dir = Path(__file__).parent / "mlx_lm"
with open("requirements.txt") as fid:
requirements = [l.strip() for l in fid.readlines()]
sys.path.append(str(package_dir))
from _version import __version__
MIN_MLX_VERSION = "0.29.2"
setup(
name="mlx-lm",
version=__version__,
@@ -23,13 +23,22 @@ setup(
author="MLX Contributors",
url="https://github.com/ml-explore/mlx-lm",
license="MIT",
install_requires=requirements,
install_requires=[
f"mlx>={MIN_MLX_VERSION}; platform_system == 'Darwin'",
"numpy",
"transformers>=4.39.3",
"protobuf",
"pyyaml",
"jinja2",
],
packages=["mlx_lm", "mlx_lm.models", "mlx_lm.quant", "mlx_lm.tuner"],
python_requires=">=3.8",
extras_require={
"test": ["datasets", "lm-eval"],
"train": ["datasets", "tqdm"],
"evaluate": ["lm-eval", "tqdm"],
"cuda": [f"mlx[cuda]>={MIN_MLX_VERSION}"],
"cpu": [f"mlx[cpu]>={MIN_MLX_VERSION}"],
},
entry_points={
"console_scripts": [
+23 -16
View File
@@ -28,13 +28,6 @@ def swapped_with_identity(obj, func):
class TestLora(unittest.TestCase):
def setUp(self):
self.capturedOutput = StringIO()
sys.stdout = self.capturedOutput
def tearDown(self):
sys.stdout = sys.__stdout__
def test_llama(self):
from mlx_lm.models import llama
@@ -48,7 +41,6 @@ class TestLora(unittest.TestCase):
vocab_size=10_000,
tie_word_embeddings=False,
)
lora_layers = 4
def check_config(params, expected_trainable_parameters=None):
@@ -68,10 +60,13 @@ class TestLora(unittest.TestCase):
self.assertEqual(trainable_params, expected_trainable_parameters)
params = {"rank": 8, "dropout": 0.0, "scale": 10.0}
check_config(params)
nparams = (
args.hidden_size * 2 * 4 + (args.intermediate_size + args.hidden_size) * 3
) * lora_layers
check_config(params, expected_trainable_parameters=nparams * params["rank"])
params["rank"] = 1
check_config(params)
check_config(params, expected_trainable_parameters=nparams * params["rank"])
params["keys"] = ["self_attn.k_proj"]
check_config(params)
@@ -191,7 +186,7 @@ class TestDora(unittest.TestCase):
dora_layers = 4
def check_config(params):
def check_config(params, expected_params=None):
n_keys = 2
if "keys" in params:
n_keys = len(params["keys"])
@@ -201,17 +196,29 @@ class TestDora(unittest.TestCase):
trainable_params = sum(
v.size for _, v in tree_flatten(model.trainable_parameters())
)
self.assertEqual(
trainable_params,
expected_params = expected_params or (
dora_layers
* (params["rank"] * hidden_size * 2 * n_keys + n_keys * hidden_size),
* (params["rank"] * hidden_size * 2 * n_keys + n_keys * hidden_size)
)
self.assertEqual(trainable_params, expected_params)
params = {"rank": 8, "alpha": 16, "dropout": 0.0, "scale": 10.0}
check_config(params)
nparams = (
args.hidden_size * 2 * 4 + (args.intermediate_size + args.hidden_size) * 3
)
nparams = (
nparams * params["rank"] + 5 * args.hidden_size + 2 * args.intermediate_size
) * dora_layers
check_config(params, expected_params=nparams)
params["rank"] = 1
check_config(params)
nparams = (
args.hidden_size * 2 * 4 + (args.intermediate_size + args.hidden_size) * 3
)
nparams = (
nparams * params["rank"] + 5 * args.hidden_size + 2 * args.intermediate_size
) * dora_layers
check_config(params, expected_params=nparams * params["rank"])
params["keys"] = ["self_attn.k_proj"]
check_config(params)
+51
View File
@@ -11,6 +11,7 @@ from mlx_lm.generate import (
generate,
stream_generate,
)
from mlx_lm.models.cache import RotatingKVCache
from mlx_lm.sample_utils import make_logits_processors, make_sampler
from mlx_lm.utils import load
@@ -301,6 +302,56 @@ class TestGenerate(unittest.TestCase):
batch_tokens = batch_responses[uids[e]]
self.assertEqual(tokens, batch_tokens)
def test_batch_sliding_window(self):
prompts = [
"Write a story about Einstein",
"Hi",
"What time is it?",
"How tall is Mt Everest?",
]
prompts = [
self.tokenizer.apply_chat_template(
[{"role": "user", "content": p}],
tokenize=True,
add_generation_prompt=True,
)
for p in prompts
]
self.model.make_cache = lambda: [
RotatingKVCache(max_size=4) for _ in self.model.layers
]
batch_gen = BatchGenerator(
self.model,
stop_tokens=self.tokenizer.eos_token_ids,
max_tokens=10,
prefill_batch_size=1,
prefill_step_size=8,
completion_batch_size=2,
)
uids = batch_gen.insert(prompts)
batch_responses = {uid: [] for uid in uids}
while responses := batch_gen.next():
for r in responses:
batch_responses[r.uid].append(r.logprobs)
for e, uid in enumerate(uids):
for i, response in enumerate(
stream_generate(
self.model,
self.tokenizer,
prompts[e],
max_tokens=10,
)
):
batch_logprobs = batch_responses[uid][i]
logprobs = response.logprobs
self.assertTrue(
mx.allclose(batch_logprobs, logprobs, rtol=1e-4, atol=1e-4)
)
del self.model.make_cache
if __name__ == "__main__":
unittest.main()
+209 -7
View File
@@ -37,7 +37,7 @@ class TestModels(unittest.TestCase):
def test_rotating_kv_cache(self):
b, h, d = 1, 2, 32
cache = RotatingKVCache(max_size=8, step=4)
cache = RotatingKVCache(max_size=8)
k = mx.random.uniform(shape=(b, h, 2, d))
v = mx.random.uniform(shape=(b, h, 2, d))
@@ -70,7 +70,7 @@ class TestModels(unittest.TestCase):
idx %= 8
# Try with nonzero keep
cache = RotatingKVCache(max_size=8, step=4, keep=2)
cache = RotatingKVCache(max_size=8, keep=2)
# Check a large update
k = mx.random.uniform(shape=(b, h, 20, d))
@@ -98,7 +98,7 @@ class TestModels(unittest.TestCase):
# alternating prompt/prefill with generation
d = 4
h = 2
cache = RotatingKVCache(max_size=18, step=4)
cache = RotatingKVCache(max_size=18)
x = mx.random.uniform(shape=(1, h, 8, d))
k, v = cache.update_and_fetch(x, x)
@@ -175,6 +175,49 @@ class TestModels(unittest.TestCase):
sums = mask.sum(axis=1)
self.assertTrue(mx.array_equal(sums, expected_sums))
def test_llama_model_sliding_attention(self):
from mlx_lm.models import llama
args = llama.ModelArgs(
model_type="llama",
hidden_size=64,
num_hidden_layers=4,
intermediate_size=256,
num_attention_heads=8,
num_key_value_heads=4,
rms_norm_eps=1e-5,
vocab_size=128,
sliding_window=4,
layer_types=[
"full_attention",
"sliding_attention",
"sliding_attention",
"full_attention",
],
tie_word_embeddings=False,
rope_theta=10000.0,
)
model = llama.Model(args)
tokens = mx.array([[1, 2, 3, 4, 5]], dtype=mx.int32)
out = model(tokens)
mx.eval(out)
self.assertEqual(out.shape, (1, 5, args.vocab_size))
caches = model.make_cache()
self.assertIsInstance(caches[0], KVCache)
self.assertIsInstance(caches[1], RotatingKVCache)
self.assertIsInstance(caches[2], RotatingKVCache)
self.assertIsInstance(caches[3], KVCache)
caches = model.make_cache()
step = model(tokens[:, :2], cache=caches)
mx.eval(step)
step = model(tokens[:, 2:3], cache=caches)
mx.eval(step)
self.assertEqual(caches[0].offset, 3)
self.assertEqual(caches[1].offset, 3)
def test_rope(self):
rope = rope_utils.initialize_rope(32, base=100, traditional=False)
self.assertTrue(isinstance(rope, nn.RoPE))
@@ -304,6 +347,35 @@ class TestModels(unittest.TestCase):
model, args.model_type, args.vocab_size, args.num_hidden_layers
)
def test_lfm2_moe(self):
from mlx_lm.models import lfm2_moe
args = lfm2_moe.ModelArgs(
model_type="lfm2_moe",
hidden_size=1024,
intermediate_size=7168,
num_hidden_layers=4,
num_attention_heads=4,
num_key_value_heads=2,
norm_eps=1e-5,
vocab_size=10_000,
full_attn_idxs=[0, 1, 2],
rope_theta=10000,
max_position_embeddings=1000,
conv_bias=True,
conv_L_cache=3,
moe_intermediate_size=1792,
num_dense_layers=2,
num_experts=4,
num_experts_per_tok=2,
norm_topk_prob=True,
use_expert_bias=True,
)
model = lfm2_moe.Model(args)
self.model_test_runner(
model, args.model_type, args.vocab_size, args.num_hidden_layers
)
def test_bitnet(self):
from mlx_lm.models import bitnet
@@ -666,6 +738,19 @@ class TestModels(unittest.TestCase):
model, args.model_type, args.vocab_size, args.num_hidden_layers
)
def test_falcon_h1(self):
from mlx_lm.models import falcon_h1
args = falcon_h1.ModelArgs(
model_type="falcon_h1",
num_hidden_layers=12,
vocab_size=10000,
)
model = falcon_h1.Model(args)
self.model_test_runner(
model, args.model_type, args.vocab_size, args.num_hidden_layers
)
def test_gpt2(self):
from mlx_lm.models import gpt2
@@ -1650,6 +1735,50 @@ class TestModels(unittest.TestCase):
"num_hidden_layers": 4,
"vocab_size": 1000,
},
{
"model_type": "qwen3_vl_moe",
"text_config": {
"model_type": "qwen3_moe",
"hidden_size": 128,
"num_hidden_layers": 4,
"intermediate_size": 256,
"num_attention_heads": 4,
"num_key_value_heads": 2,
"rms_norm_eps": 1e-5,
"head_dim": 32,
"vocab_size": 1000,
"decoder_sparse_step": 1,
"mlp_only_layers": [],
"num_experts_per_tok": 2,
"num_experts": 4,
"moe_intermediate_size": 128,
"rope_theta": 1000,
"max_position_embeddings": 1000,
"tie_word_embeddings": False,
"norm_topk_prob": True,
},
"num_hidden_layers": 4,
"vocab_size": 1000,
},
{
"model_type": "qwen3_vl",
"text_config": {
"model_type": "qwen3",
"hidden_size": 128,
"num_hidden_layers": 4,
"intermediate_size": 256,
"num_attention_heads": 4,
"num_key_value_heads": 2,
"rms_norm_eps": 1e-5,
"vocab_size": 1000,
"head_dim": 32,
"max_position_embeddings": 1000,
"tie_word_embeddings": False,
"rope_theta": 1000,
},
"num_hidden_layers": 4,
"vocab_size": 1000,
},
{
"model_type": "seed_oss",
"hidden_size": 128,
@@ -1768,6 +1897,48 @@ class TestModels(unittest.TestCase):
"time_step_limit": (0.01, 10),
"time_step_rank": "auto",
},
{
"model_type": "olmo3",
"num_heads": 8,
"head_dim": 16,
"vocab_size": 1000,
"hidden_size": 128,
"intermediate_size": 128,
"num_attention_heads": 8,
"rope_theta": 1000,
"num_hidden_layers": 8,
"rms_norm_eps": 1e-4,
"sliding_window": 128,
"tie_word_embeddings": True,
"max_position_embeddings": 1000,
},
{
"model_type": "jamba",
"hidden_size": 128,
"intermediate_size": 128,
"num_hidden_layers": 8,
"num_attention_heads": 4,
"num_key_value_heads": 2,
"attn_layer_offset": 1,
"attn_layer_period": 2,
"expert_layer_offset": 1,
"expert_layer_period": 2,
"mamba_d_conv": 4,
"mamba_d_state": 128,
"mamba_expand": 128,
"num_experts": 4,
"num_experts_per_tok": 2,
"rms_norm_eps": 1e-5,
"max_position_embeddings": 1000,
"vocab_size": 1000,
},
{
"model_type": "nanochat",
"hidden_size": 1280,
"num_hidden_layers": 20,
"vocab_size": 32,
"intermediate_size": 128,
},
]
for config in test_configs:
model_type = config["model_type"]
@@ -1849,9 +2020,9 @@ class TestModels(unittest.TestCase):
self.assertTrue(mx.allclose(out_state, out_state_m, atol=1e-4, rtol=1e-4))
def test_gated_delta(self):
mx.random.seed(0)
for B in [1, 2]:
for T in [1, 2]:
B = 1
Hk = 16
Hv = 32
Dk = 128
@@ -1860,14 +2031,45 @@ class TestModels(unittest.TestCase):
q = mx.random.normal(shape=(B, T, Hk, Dk))
k = mx.random.normal(shape=(B, T, Hk, Dk))
v = mx.random.normal(shape=(B, T, Hv, Dv))
g = mx.random.normal(shape=(B, T, Hv))
beta = mx.random.normal(shape=(B, T, Hv))
g = mx.random.uniform(shape=(B, T, Hv))
beta = mx.random.uniform(shape=(B, T, Hv))
state = mx.random.normal(shape=(B, Hv, Dk, Dv))
y_op, st_op = gated_delta_ops(q, k, v, g, beta, state)
y_c, st_c = gated_delta_kernel(q, k, v, g, beta, state)
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-3))
self.assertTrue(mx.allclose(st_op, st_c, rtol=1e-4, atol=1e-4))
def test_gated_delta_masked(self):
B = 1
T = 3
Hk = 16
Hv = 32
Dk = 128
Dv = 128
mx.random.seed(0)
q = mx.random.normal(shape=(B, T, Hk, Dk))
k = mx.random.normal(shape=(B, T, Hk, Dk))
v = mx.random.normal(shape=(B, T, Hv, Dv))
g = mx.random.normal(shape=(B, T, Hv))
mask = mx.array([[False, True, True]])
beta = mx.random.normal(shape=(B, T, Hv))
state = mx.random.normal(shape=(B, Hv, Dk, Dv))
y_gt, st_gt = gated_delta_ops(
q[:, 1:],
k[:, 1:],
v[:, 1:],
g[:, 1:],
beta[:, 1:],
state,
)
for fn in [gated_delta_ops, gated_delta_kernel]:
y, st = fn(q, k, v, g, beta, state, mask)
y = y[:, 1:]
self.assertTrue(mx.allclose(y, y_gt, rtol=1e-4, atol=1e-4))
self.assertTrue(mx.allclose(st, st_gt, rtol=1e-4, atol=1e-3))
if __name__ == "__main__":
+113 -5
View File
@@ -11,6 +11,7 @@ from mlx_lm.generate import generate_step
from mlx_lm.models.base import create_attention_mask, create_causal_mask
from mlx_lm.models.cache import (
BatchKVCache,
BatchRotatingKVCache,
CacheList,
ChunkedKVCache,
KVCache,
@@ -391,7 +392,7 @@ class TestPromptCache(unittest.TestCase):
kv = mx.zeros((1, 1, 10, 32))
cache.update_and_fetch(kv, kv)
mask = cache.make_mask(3, window_size=5)
self.assertEqual(mask.shape, (3, 11))
self.assertEqual(mask.shape, (3, 10))
self.assertTrue(mx.all(mask.sum(axis=-1) == 5))
for i in range(3):
s = 11 - 3 + i
@@ -405,7 +406,7 @@ class TestPromptCache(unittest.TestCase):
self.assertEqual(mask, None)
mask = cache.make_mask(1, window_size=5)
self.assertEqual(mask.squeeze(1).tolist(), [True] + [False] * 3 + [True] * 4)
self.assertEqual(mask.tolist(), [True] + [False] * 3 + [True] * 4)
cmask = create_attention_mask(mx.zeros((1, 1, 32)), cache, window_size=5)
self.assertTrue(mx.array_equal(cmask, mask))
@@ -413,9 +414,7 @@ class TestPromptCache(unittest.TestCase):
cache.update_and_fetch(kv, kv)
mask = cache.make_mask(1, window_size=5)
self.assertEqual(
mask.squeeze(1).tolist(), [True] * 2 + [False] * 3 + [True] * 3
)
self.assertEqual(mask.tolist(), [True] * 2 + [False] * 3 + [True] * 3)
cmask = create_attention_mask(mx.zeros((1, 1, 32)), cache, window_size=5)
self.assertTrue(mx.array_equal(cmask, mask))
@@ -460,6 +459,115 @@ class TestPromptCache(unittest.TestCase):
self.assertEqual(cache_a.offset.tolist(), [6, 7, 6, 1, 4])
self.assertEqual(cache_a.left_padding.tolist(), [2, 1, 2, 7, 4])
def test_batch_rotating_kv_cache(self):
cache = BatchRotatingKVCache(max_size=4, left_padding=[2, 0])
mask = cache.make_mask(4)
self.assertFalse(mx.any(mask[0, 0, 0, :]))
self.assertTrue(
mx.array_equal(mask[1, 0, 0, :], mx.array([True, False, False, False]))
)
# Batch update works
k, v = mx.zeros((2, 1, 4, 8)), mx.zeros((2, 1, 4, 8))
k, v = cache.update_and_fetch(k, v)
mask = cache.make_mask(4)
k, v = mx.zeros((2, 1, 4, 8)), mx.zeros((2, 1, 4, 8))
k, v = cache.update_and_fetch(k, v)
self.assertEqual(mask.shape[-2:], (4, k.shape[2]))
self.assertEqual(
mask[0, 0, 0, :].tolist(), [False, True, True, True, False, False, False]
)
# Single query update works
cache = BatchRotatingKVCache(max_size=4, left_padding=[2, 0])
k, v = mx.zeros((2, 1, 4, 8)), mx.zeros((2, 1, 4, 8))
k, v = cache.update_and_fetch(k, v)
mask = cache.make_mask(1)
k, v = mx.zeros((2, 1, 1, 8)), mx.zeros((2, 1, 1, 8))
k, v = cache.update_and_fetch(k, v)
self.assertEqual(mask.shape[-2:], (1, k.shape[2]))
self.assertEqual(mask[0, 0, 0].tolist(), [True, False, True, True])
self.assertEqual(mask[1, 0, 0].tolist(), [True, True, True, True])
# Check filtering
cache = BatchRotatingKVCache(max_size=4, left_padding=[2, 0, 3])
k, v = mx.zeros((3, 1, 3, 8)), mx.zeros((3, 1, 3, 8))
cache.update_and_fetch(k, v)
cache.filter(mx.array([1]))
self.assertEqual(cache.keys.shape, (1, 1, 3, 8))
# Check extend
cache = BatchRotatingKVCache(max_size=4, left_padding=[2, 1])
other = BatchRotatingKVCache(max_size=4, left_padding=[2, 2])
k, v = mx.zeros((2, 1, 5, 8)), mx.zeros((2, 1, 5, 8))
cache.update_and_fetch(k, v)
other.update_and_fetch(k, v)
k, v = mx.zeros((2, 1, 1, 8)), mx.zeros((2, 1, 1, 8))
cache.update_and_fetch(k, v)
cache.extend(other)
# Check mask when going from prompt -> extend -> prompt
cache = BatchRotatingKVCache(max_size=8, left_padding=[4])
k, v = mx.zeros((1, 1, 8, 8)), mx.zeros((1, 1, 8, 8))
cache.update_and_fetch(k, v)
mask = cache.make_mask(1)
self.assertEqual(
mask.squeeze().tolist(), [True, False, False, False, True, True, True, True]
)
k, v = mx.zeros((1, 1, 1, 8)), mx.zeros((1, 1, 1, 8))
cache.update_and_fetch(k, v)
mask = cache.make_mask(2)
expected = mx.array(
[
[False, False, False, True, True, True, True, True, False],
[False, False, False, True, True, True, True, True, True],
]
)
self.assertTrue(mx.array_equal(mask.squeeze(), expected))
def test_save_load_batch_caches(self):
cache_file = os.path.join(self.test_dir, "prompt_cache.safetensors")
cache = [
MambaCache(left_padding=[1, 2]),
BatchKVCache(left_padding=[1, 2]),
BatchRotatingKVCache(max_size=10, left_padding=[1, 2]),
]
for c in cache:
if isinstance(c, MambaCache):
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)
save_prompt_cache(cache_file, cache)
loaded_cache = load_prompt_cache(cache_file)
left_padding = mx.array([1, 2])
for c, lc in zip(cache, loaded_cache):
self.assertTrue(mx.array_equal(c.left_padding, left_padding))
def test_rotating_cache_updates(self):
cache = RotatingKVCache(max_size=8)
k = v = mx.zeros((1, 1, 10, 1))
cache.update_and_fetch(k, v)
for _ in range(3):
k = v = mx.zeros((1, 1, 1, 1))
cache.update_and_fetch(k, v)
k = v = mx.zeros((1, 1, 3, 1))
k, v = cache.update_and_fetch(k, v)
self.assertEqual(k.shape[2], 10)
self.assertEqual(v.shape[2], 10)
if __name__ == "__main__":
unittest.main()
+32
View File
@@ -27,7 +27,10 @@ class TestUtils(unittest.TestCase):
cls.test_dir_fid.cleanup()
def test_load(self):
from mlx_lm.models.qwen2 import Model as Qwen2Model
model, _ = utils.load(HF_MODEL_PATH)
self.assertIsInstance(model, Qwen2Model)
model_lazy, _ = utils.load(HF_MODEL_PATH, lazy=True)
@@ -91,6 +94,35 @@ class TestUtils(unittest.TestCase):
self.assertEqual(model.layers[0].mlp.up_proj.scales.dtype, mx.bfloat16)
self.assertEqual(model.layers[-1].mlp.up_proj.scales.dtype, mx.bfloat16)
def test_load_model_with_custom_get_classes(self):
class CustomQwenModel(nn.Module):
def __init__(self, args):
super().__init__()
self.config = args
self.custom_attribute = "This is a custom model"
def load_weights(self, weights, **kwargs):
self.qwenWeights = weights
class CustomQwenConfig:
@classmethod
def from_dict(cls, config):
instance = cls()
for k, v in config.items():
setattr(instance, k, v)
return instance
def custom_get_classes(config):
return CustomQwenModel, CustomQwenConfig
model_path = utils.hf_repo_to_path(HF_MODEL_PATH)
model, _ = utils.load_model(model_path, get_model_classes=custom_get_classes)
self.assertIsInstance(model, CustomQwenModel)
self.assertTrue(hasattr(model, "custom_attribute"))
self.assertEqual(model.custom_attribute, "This is a custom model")
self.assertTrue(hasattr(model, "qwenWeights"))
if __name__ == "__main__":
unittest.main()
-51
View File
@@ -1,51 +0,0 @@
import unittest
from pathlib import Path
import mlx.nn as nn
from mlx_lm.models.qwen2 import Model as Qwen2Model
from mlx_lm.utils import get_model_path, load_model
HF_MODEL_PATH = "mlx-community/Qwen1.5-0.5B-Chat-4bit"
class TestLoadModelCustomGetClasses(unittest.TestCase):
def test_load_model_with_custom_get_classes(self):
class CustomQwenModel(nn.Module):
def __init__(self, args):
super().__init__()
self.config = args
self.custom_attribute = "This is a custom model"
def load_weights(self, weights, **kwargs):
self.qwenWeights = weights
class CustomQwenConfig:
@classmethod
def from_dict(cls, config):
instance = cls()
for k, v in config.items():
setattr(instance, k, v)
return instance
def custom_get_classes(config):
return CustomQwenModel, CustomQwenConfig
model_path, _ = get_model_path(HF_MODEL_PATH)
model, _ = load_model(model_path, get_model_classes=custom_get_classes)
self.assertIsInstance(model, CustomQwenModel)
self.assertTrue(hasattr(model, "custom_attribute"))
self.assertEqual(model.custom_attribute, "This is a custom model")
self.assertTrue(hasattr(model, "qwenWeights"))
def test_load_model_with_default_get_classes(self):
model_path, _ = get_model_path(HF_MODEL_PATH)
model, _ = load_model(model_path)
self.assertIsInstance(model, Qwen2Model)
if __name__ == "__main__":
unittest.main()