Compare commits

...

114 Commits

Author SHA1 Message Date
Angelos Katharopoulos 6eb9059ce6 Replace recursion with an explicit stack 2025-07-06 16:31:49 -07:00
Angelos Katharopoulos 39a389c654 Add DP search across multiple quant options 2025-07-06 16:31:47 -07:00
Angelos Katharopoulos 29b74d0f95 Fix the formating 2025-07-06 16:11:38 -07:00
Angelos Katharopoulos 93b907f5d5 Move dwq and dynamic_quant to kl-loss 2025-07-06 15:54:58 -07:00
Angelos Katharopoulos ed92899d1d Add custom kl loss to tuner losses 2025-07-04 19:18:31 -07:00
Prince Canuma 5fa62eb5f5 Add bitnet1.58 with custom metal kernel (#219)
* add bitnet

* update activation to relu2

* working bitnet

* remove artifacts

* remove logging

* add custom post quant

* fix dtype and add compile

* fixed weight unpack

* add custom kernel to avoid memory overhead

* compile relu2

* fix weight scale

* remove unused

* add tests and update tuner utils

* update acknowledgements

* add kernel caching

* add act_quant and set float16 as default dtype

* use mx.add and move scaling to kernel

* remove act quant

* move bitlinear layers to separate file

* feat: add falcon-e and other bitnet support

* refactor: address comments

* add support for 1.58bit N-bit quants

* 43.85% speedup in generation performance (M3 max)

* refactor utils

* remove masking (2% gen speed improvement)

* add quantization config

* test llama bitnet

* refactor apply_hf_quant

* default threadgroup: 64 -> 32

* add comment

* fix prompt processing perf

* remove modulo

* compile kernel in the constructor

* Improve the bitnet kernel

* remove benchmark

* refactor bitlinear swap

* format

* remove llama changes

* revert utils

* faster + cleanup

* not trainable

* fix tests

---------

Co-authored-by: younesbelkada <younes.belkada@tii.ae>
Co-authored-by: Angelos Katharopoulos <a_katharopoulos@apple.com>
Co-authored-by: Awni Hannun <awni@apple.com>
2025-07-02 16:34:07 -07:00
Gökdeniz Gülmez e8f8729854 Adding support for rednote-hilab/dots.llm1.inst (#211)
* initial

* adding the code

* update ackn.

* make trainable

* clean ups

* fix ModelArgs

* fix args

* fix

* fix router forward

* fix sanitize

* nits

* adding default vals

* fix Convert indices to int32

* renaming like the torch version

* fixes

---------

Co-authored-by: Awni Hannun <awni@apple.com>
2025-07-02 13:03:26 -07:00
Awni Hannun e8c2cfce6a Allow converting local models (#265)
* allow converting local models

* fix dequantization
2025-07-02 12:35:18 -07:00
John Mai 5431546b1e Add Ernie4.5 (#263)
* feat: Add Ernie4.5

* nits

* fix

---------

Co-authored-by: Awni Hannun <awni@apple.com>
2025-07-02 07:42:38 -07:00
Awni Hannun c2f7facb66 Gemma3n text only support (#258)
* enable tool use in the server and add an example using openai client (#217)

* simplify and speedup

* fix rope

* [Gemma3n] Only create required KV cache layers (#260)

This commit makes the following changes
1. Refactors the calculation of which layers are shared up to the LanguageModel class. Each layer now receives the cache it is meant to operate on, whether unique or shared.
2. Add a make_cache method that constructs the KV cache layers according to the config. It only creates up to the first shared layer index.

This fixes a bug in the pre-fill logic, which would throw an error when attempting to evaluate KV cache layers that had not yet (and would never be) updated with keys / values.

* fix quantization + nits

* compile

---------

Co-authored-by: will-lms <will@lmstudio.ai>
2025-06-30 20:03:00 -07:00
Awni Hannun 36d0d04ecd allow models to be pickled + test (#261) 2025-06-29 11:27:40 -07:00
Awni Hannun e6dfe18344 enable tool use in the server and add an example using openai client (#217) 2025-06-26 06:02:28 -07:00
muhtasham 74a47b1434 fix tokenizer empty string (#253) 2025-06-25 07:16:19 -07:00
will-lms d0ef4bcf17 Pipe input_embeddings through mistral3 model_type (#254) 2025-06-25 07:16:07 -07:00
Awni Hannun 7c13b0defc fix cast predicate (#243) 2025-06-17 15:07:31 -07:00
Awni Hannun d9bd78a4db support cuda back-end (#241) 2025-06-17 14:00:43 -07:00
Angelos Katharopoulos 19287dc922 Implementation of AFM in MLX (#232) 2025-06-12 12:24:45 -07:00
Magic Yang 4a3b2a978f feat: add MiniCPM4 model structure code minicpm4.py and minicpm4 mode… (#212)
* feat: add MiniCPM4 model structure code minicpm4.py and minicpm4 model_type

* revert: remove .gitignore changes from PR

* fix and cleanup su rope

---------

Co-authored-by: Awni Hannun <awni@apple.com>
2025-06-09 07:58:21 -07:00
Awni Hannun f009881e5c fix dynamic quant for bias (#216) 2025-06-08 21:18:16 -07:00
Awni Hannun 584780a05f fix quant package (#214) 2025-06-07 08:40:42 -07:00
Awni Hannun e673a97c80 some nits in lora (#208) 2025-06-05 16:33:16 -07:00
Awni Hannun 3be51537a3 Dynamic quants (#202)
* dynamic quants + reorg

* readme

* angelos fix

* Change sensitivity metric

* update version

* fix rebase

---------

Co-authored-by: Angelos Katharopoulos <a_katharopoulos@apple.com>
2025-06-02 12:31:42 -07:00
Awni Hannun 19153e1671 Add total params to metadata + cleanup (#207)
* add total params to metadata + cleanup

* comments
2025-06-02 12:08:06 -07:00
Awni Hannun 1db99d41a2 fix chat templates (#205) 2025-06-01 08:50:04 -07:00
Gökdeniz Gülmez d1d0771e3f Update LoRA training logic to reset lora_parameters when fine-tuning with full model (#200) 2025-05-29 07:29:07 -07:00
Gökdeniz Gülmez e8980c050b nits for qwen3_moe file (#199)
* nits

* remove unused initialize_rope, math inputs

* remove Literal import in req_gemma

* remove unused math import

* remove unused Tuple import in phi

* remove unused Tuple import in openlm

* remove unused Tuple import in olmo

* remove unused dataclass_fields import in nemotron

* __"__

* __"__

* add apple compyrigth to mimo

* more clean up

* more nits
2025-05-28 21:46:59 -07:00
Awni Hannun 3cc61aa64d DWQ updates (#196)
* dwq nits

* remove temp

* fix sort

* padding
2025-05-28 21:13:31 -07:00
Ryan 77edf17bc0 Add Evaluation Progress (#158)
* Add Evaluation Progress

Adding a tqdm progress bar for evaluate to make it more transparent what’s happening.

* Add flag to suppress progress

Add a quick boolean to suppress displaying the evaluation by default, but show it for —test.

* nits / simplify

* fix

---------

Co-authored-by: Awni Hannun <awni@apple.com>
2025-05-23 06:44:36 -07:00
Awni Hannun 71e8e57c2e Faster top-p and min-p sampling (#187)
* Faster top-p and min-p sampling

* comment
2025-05-19 16:55:38 -07:00
Ryan 1b555aaa08 Add the correct directory for LoRA Data (#188) 2025-05-19 15:52:58 -07:00
Jesse Pangburn 77898fd22d fix server.py null tool content (#185) (#186)
* fix server.py null tool content (#185)

* move null tool content fix into process_message_content (#185)
2025-05-19 11:20:01 -07:00
Gökdeniz Gülmez f2aa9419d9 fix (#184) 2025-05-19 07:26:43 -07:00
Awni Hannun 064c75d78e fix server cache (#183) 2025-05-17 19:22:09 -07:00
Matt Clayton 0824576a57 Pixtral text support, pipe input_embeddings through llama arch (#181) 2025-05-16 19:15:00 -07:00
Ian 5960ee9c7a fix upload to hub usage (#180) 2025-05-16 06:45:01 -07:00
Gökdeniz Gülmez 29f8e7765d Wandb fix (#177)
* fix

* fix

* Enhance WandBCallback to log training and validation information with iteration steps. This improves the tracking of training progress in Weights & Biases.
2025-05-14 20:56:57 -07:00
Matt Clayton f93589cb7d Add input_embeddings input to generate_step, Gemma 3, Qwen 2 (#179)
* Add input_embeddings input to generate_step, Gemma 3, Qwen 2

* PR responses

* Better docstring for input_embeddings
2025-05-14 11:54:51 -07:00
Awni Hannun 864f5ce118 version bump 2025-05-14 06:19:52 -07:00
Awni Hannun 5101aebe05 Don't hide exception with wrong message when HF model isn't found (#173)
* don't hide exception with wrong message

* don't hide exception with wrong message

* more nits

* fix test
2025-05-13 13:57:33 -07:00
Ivan Fioravanti 1ca5474822 Update LORA.md for wikisql (#176)
Fix for #174
2025-05-13 11:51:02 -07:00
Bence Szalai 4401043b0c Do not pass empty tools array to the tokenizer (#170)
* Do not pass empty tools array to the tokenizer

* Update mlx_lm/server.py

---------

Co-authored-by: Awni Hannun <awni.hannun@gmail.com>
2025-05-13 10:18:22 -07:00
rrziehe 76c30edbd4 add default params to better run quen3 (#162)
* add default param to better run quen3

* code formatting

* Refactor APIHandler to use chat template arguments and remove disable thinking option

* fix test
2025-05-13 06:39:30 -07:00
Awni Hannun 854c580f72 DWQ extra activation penalty (#161)
* Add L1 activation penalty for DWQ

* Update defaults + layer selection

* revert default temp

* add some tips to the docs

* nits
2025-05-12 17:21:52 -07:00
prldev 2973b75c8a Allow to use mistral3 model type for lora (#172) 2025-05-12 06:13:10 -07:00
Gökdeniz Gülmez 4b484773cf adding report-to-wandb (#9)
* update lora_config.yaml + LORA.md + lora.py

* code formatting

* udpaet Acknowledgements.md

* nits

* Refactor WandB integration in lora.py and trainer.py

- Updated WandB reporting mechanism to use a project name argument instead of a boolean flag.
- Removed the old TrainingCallback class definition from trainer.py and imported it from callbacks.
- Adjusted argument parsing to accommodate the new WandB configuration.

* Enhance WandBCallback to include log directory in initialization

- Added log_dir parameter to WandBCallback constructor for specifying the logging directory.
- Updated lora.py to pass adapter_path as log_dir when initializing WandBCallback.

* nits

* formating

* README.md

* update example yaml

* nits

* nits

* nits in readme

---------

Co-authored-by: Awni Hannun <awni@apple.com>
2025-05-09 19:43:11 -07:00
Awni Hannun f1572d4586 mistral3 (#166) 2025-05-09 19:42:53 -07:00
cblomert c592f76f6a Fixed calculation of n_dims for odd bit quants (#165) 2025-05-09 06:17:43 -07:00
Yi Hong Ang 02a0241581 Add rudimentary health check (#155) 2025-05-08 00:35:53 -07:00
Awni Hannun 4a176da038 Speedup mlx_lm.evaluate and allow mlx_lm.dwq to load already quantized model (#148)
* Allow loading already quantized model + nits

* updates in eval

* add template args

* speed up eval

* fix

* shuffle data based on seed
2025-05-07 13:24:08 -07:00
Awni Hannun 66e9cc480b better calibration data (#159) 2025-05-07 07:47:11 -07:00
Awni Hannun 68f6e5be23 improvements / defaults for awq (#157) 2025-05-06 12:02:49 -07:00
Awni Hannun f031c97dc5 fix lora test (#156) 2025-05-06 12:02:34 -07:00
Awni Hannun 13087ecf98 Distilled weight quantization (#129)
* Distilled weight quantization

* Use tuner functions + more realistic dataset

* some updates to dwq + support distributed + doc

* add temp + fix switch to make copyable

* nits in readme

* comment
2025-05-03 09:06:02 -07:00
Awni Hannun 989b291159 refactor save and include generation config (#143) 2025-05-02 10:01:17 -07:00
Prince Canuma 68e33d9a13 Add mixed-3-4bit quant (#145) 2025-05-02 07:17:21 -07:00
jolonf 60c93b8caa Optimize prompt cache handling and add tests (Fixes #137) (#138)
* Optimize prompt cache handling and add tests (Fixes #137)

* Address review: Consolidate common_prefix_len, refactor prompt cache reset
2025-05-01 21:21:41 -07:00
John Mai d25eb2b3e5 feat: Add support for Xiaomi MiMo model (#142)
*  feat: Add support for XiaomiMiMo/MiMo-7B-SFT model

* 🎨 style: Format code

* refactor: Remove MTP layer
2025-05-01 21:21:11 -07:00
Ivan Fioravanti c48de5082c Update AWQ evaluate and upload (#128)
* Update AWQ evaluate and upload

Added seed to evaluate command and fixed upload parameters.

* Update AWQ.md

Seed removed
2025-04-29 13:10:52 -07:00
Awni Hannun b839de1716 Fix Qwen3 MoE fine-tunig (#135) 2025-04-29 11:41:29 -07:00
Prince Canuma 5c2c18d6a3 Add Qwen3 and Qwen3-MoE (#41)
* add qwen3 support

* add tests

* add qwen3 to trainer

* rename

* working moe

* add moe to trainer and tests

* fix: normalize expert topk prob

* refactor

* format

* nits

---------

Co-authored-by: Awni Hannun <awni@apple.com>
2025-04-28 06:44:17 -07:00
Chris McMaster 36faf2edfd Update baichuan_m1.py (#127)
* Update baichuan_m1.py

The sanitize method was failing with quantized models. I've simplified it to be consistent with implementations in other models. It works now.

* Reimplement pre-normalization for non-quantized model

* Update baichuan_m1.py

---------

Co-authored-by: Awni Hannun <awni.hannun@gmail.com>
2025-04-26 06:30:59 -07:00
Awni Hannun d6a9e61572 fix scan (#125) 2025-04-25 14:40:27 -07:00
Neil Mehta ce2358d297 Add top-k sampling parameter to the CLI (#120) 2025-04-25 07:29:29 -07:00
Awni Hannun a9e1e82c61 fix quant sdpa and gemma3 (#121) 2025-04-24 17:15:10 -07:00
Dirky 9df9689fdc Adding support for XTC (eXclude Top Choice) (#110)
* feat: Added XTC logit processor

* feat: Excluding newline/EOS chars from XTC

* fix: Avoiding non-vectorized operation

* feat: Moved XTC to samplers + correcting threshold allowed range

* feat: Added XTC sampler to mlx_lm.chat and mlx_lm.generate

* test: Adding test for apply_xtc

* test: Updated test to match the new XTC behavior

* feat: Simplified XTC implementation

* feat: XTC special tokens exclusion simplification

* fix: Corrected wrong compare for xtc_probability and threshold verif in server

* test: Resetting probs and logprobs to avoid failures in CI

* Fix tests

* Change the special tokens name a bit

* Fix it in mlx_lm.server

---------

Co-authored-by: Dirky <dirky@dirky.fr>
Co-authored-by: Angelos Katharopoulos <a_katharopoulos@apple.com>
2025-04-24 14:25:30 -07:00
Chris McMaster dd24e000b6 Add Baichuan M1 Model (#116)
* Add Baichuan M1

* Revert .gitignore

* Fixed multi-turn

* Fixed multiturn

* use as_strided to vectorize conv op

* use split

* fix up conv

* use cache list for conv cache

---------

Co-authored-by: Awni Hannun <awni@apple.com>
2025-04-23 15:04:57 -07:00
Neil Mehta 5c04f07f3b During model conversion, default dtype to config.json torch_dtype (#117) 2025-04-23 13:56:34 -07:00
Ivan Fioravanti f816fdabd0 Update AWQ docs to include default values (#115)
* Update AWQ.md

Default options included to clarify that 3bit quantization is deafult.

* change defaults, install command, readme nits

---------

Co-authored-by: Awni Hannun <awni@apple.com>
2025-04-23 13:56:21 -07:00
Alex Barron 69195f8632 Support AWQ Quantization (#88)
* Add learned AWQ quantization

* remove comment

* add MOE support

* fix gemma

* nits

* decouple config

* fix indices bug

* fix moe and upload command

* enable clip search for MoEs

* Add readme

---------

Co-authored-by: Alex Barron <abarron22@apple.com>
Co-authored-by: Awni Hannun <awni@apple.com>
2025-04-22 15:43:12 -07:00
Awni Hannun c904be4ba3 try and scan only mlx compatible LLMs (#114) 2025-04-22 15:02:45 -07:00
Awni Hannun c3cbafb688 Server draft (#85)
* Add support for draft models and speculative decoding in server

* Add documentation for speculative decoding feature in server

* Add draft model support and related tests in TestServer

* nits

* allow specify on request

---------

Co-authored-by: art.aquino <art.aquino@compute.tech>
2025-04-22 13:57:22 -07:00
Awni Hannun 3b9b0ed052 fix llama4 cache (#108) 2025-04-19 09:00:01 -07:00
Prince Canuma 1bfb7c2407 Patch tokenizer utils to match MLX-VLM (#107)
* add return tokenizer

* Add error handling for JSON loading

* format
2025-04-19 08:28:52 -07:00
Gökdeniz Gülmez cff94b7fbf Fix GLM4 Repetition (#105)
* fix

* clean up MLP

* format

---------

Co-authored-by: Awni Hannun <awni@apple.com>
2025-04-19 06:53:37 -07:00
Angelos Katharopoulos ba83487ed9 Inform the gather mm regarding the sorted indices (#100)
* Inform the gather mm regarding the sorted indices

* Change the condition for sorting and passing sorted_indices

* Change sorting again

* Remove num_experts member variable

* version bump

---------

Co-authored-by: Awni Hannun <awni@apple.com>
2025-04-18 12:28:59 -07:00
Awni Hannun c9f747bfaf fix gemma3 in fp16 (#102) 2025-04-18 10:03:34 -07:00
Derek Lewis db368f25f3 Fix GPT2 Incremental Generation by Correctly Applying Position Embeddings and Offsets (#93)
* fix positional embeddings for gpt2

* Update mlx_lm/models/gpt2.py

---------

Co-authored-by: Awni Hannun <awni.hannun@gmail.com>
2025-04-18 06:39:11 -07:00
Gökdeniz Gülmez 23ba454f8c Adding Support for GLM4 (#99)
* Initial Commit

* Works

* fix MLP and RoPE

* formating

* clean up

* make it trainable

* format

* uptade acknowledgements
2025-04-17 14:57:57 -07:00
Awni Hannun a8e8f5755a kimi_vl text only (#91) 2025-04-13 06:48:22 -06:00
cavit99 b4c88dc5e7 Support nemotron-nas (#86)
* support nemotron-nas

* nits

---------

Co-authored-by: Awni Hannun <awni@apple.com>
2025-04-11 12:39:16 -07:00
Neil Mehta ebf6124267 Remove vision_config from llama4 and from model conversion (#87) 2025-04-10 09:39:02 -07:00
Awni Hannun 9a60e96249 Fix ChunkedKVCache serialization (#81)
* fix ChunkedKVCache serialization

* fix mask when provided
2025-04-07 14:13:44 -07:00
Awni Hannun d79ba69021 fix dsv2 (#79) 2025-04-07 13:31:43 -07:00
Awni Hannun 3962ce6de9 Llama4 text only (#74)
* Llama4 text only

* Add naive chunked attention

* add custom cache for chunk attention

* fix mixed quant
2025-04-06 16:05:44 -07:00
Alex Barron 59c2844cc2 Distributed Eval (#70)
* distributed evaluate
2025-04-05 04:31:49 -07:00
Angelos Katharopoulos eb06c1621f Move from python -m mlx_lm.generate to python -m mlx_lm generate (#71)
* Add all of the commands in mlx_lm/__main__.py

* Add deprecation notices
2025-04-04 16:53:18 -07:00
Awni Hannun 0895f8f5cf Update logits to new mlx + version bump (#63)
* update logits to new mlx + version bump

* fix test

* fix test
2025-04-03 16:59:12 -07:00
Awni Hannun 81f9d8211d fix / improve dsv3 (#67)
* fix / improve dsv3

* add predicate to control weights that can be cast
2025-04-03 13:17:04 -07:00
Neil Ferguson 191d81d1a0 Remove alpha parameter from LoRA defaults (#65)
* Remove "alpha"

* Remove alpha from tests
2025-04-02 20:25:29 -07:00
Ivan Fioravanti 1958691940 ConcatenatedDataset error fix (#60)
* Update dataset configuration in LORA.md and enhance ConcatenatedDataset to avoid AttributeError: 'ConcatenatedDataset' object has no attribute 'process'.

* change fix

---------

Co-authored-by: Awni Hannun <awni@apple.com>
2025-03-31 10:49:22 -07:00
Angelos Katharopoulos 2e96d0730a Ensure the gather_mm is in order (#52) 2025-03-28 12:24:50 -07:00
Gökdeniz Gülmez c158143831 fix missing self references in ChatDataset class (#55)
* fix

* nit'

---------

Co-authored-by: Awni Hannun <awni@apple.com>
2025-03-27 12:56:34 -07:00
Awni Hannun d00af36bda dynamic dataset preprocessing (#54) 2025-03-27 08:32:51 -07:00
Awni Hannun b92c8f3eda set wired limit and compile step (#53) 2025-03-25 19:58:55 -07:00
Awni Hannun 455cdac5df remove metal in memory APIs (#50) 2025-03-24 16:19:09 -07:00
Awni Hannun a53225747f Refactor utils (#37)
* refactor out of utils

* remove comment

* format

* move generate as well
2025-03-22 11:54:51 -07:00
chaihahaha 2d4c134ec2 Add support for IPv6 server (#16) 2025-03-21 18:31:28 -07:00
Awni Hannun e2e62d9085 fix manifest (#43) 2025-03-21 17:49:17 -07:00
Awni Hannun fd175f11d5 Updates for causal mask (#40)
* updates for causal mask

* updates for causal mask
2025-03-21 08:50:44 -07:00
Awni Hannun 465b107c2a Fix gemma3 and cohere2 sliding window (#39)
* fix gemma3 and cohere2 sliding window

* fix
2025-03-21 08:50:35 -07:00
Gökdeniz Gülmez 93cc7d319f Fix acknowledgments typo (#34)
* fix

* final fix
2025-03-18 18:47:34 -07:00
Ikko Eltociear Ashimine d4275716f6 docs: update README.md (#33)
minor fix
2025-03-18 11:58:40 -07:00
Awni Hannun 3b3df251d3 Fix tags and allow fine-tuning of gemma3 (#31)
* fix tags and allow fine-tuning of gemma3

* patch_bump
2025-03-18 10:27:22 -07:00
Awni Hannun c16c2984ba user prompt cache for completions in server (#29) 2025-03-18 10:10:37 -07:00
Awni Hannun 1dc5de4fce fix mask for gemma2 (#27) 2025-03-18 10:09:45 -07:00
Awni Hannun ed8087f723 fix cohere2 (#11) 2025-03-18 10:09:32 -07:00
Awni Hannun 50f4cad769 Add yarn option for qwen2 (#4) 2025-03-18 10:09:20 -07:00
Awni Hannun ee044da0a8 dequantize dsv3 (#32) 2025-03-18 08:57:53 -07:00
Gökdeniz Gülmez 466544baff Adding support for MiniCPM3 (#24)
* initial commit

* update ACKNOWLEDGMENTS.md

* working inference but generating only "<unk>"

* update

* fix sanitize

* remove np from MiniCPM implementation

* clean up, same problem

* making it trainable

* use surope and remove custom long rope implementation

* fix training

* nits

---------

Co-authored-by: Awni Hannun <awni@apple.com>
2025-03-18 08:16:20 -07:00
Awni Hannun 59d2005a8b remove vision config (#26) 2025-03-17 17:04:48 -07:00
sealad886 2ec4db9dfc Update mixed_quant_predicate_builder calls to include high_bits parameter (#20) 2025-03-17 07:50:06 -07:00
Awni Hannun 70d555b325 gemma3 text only support (#21) 2025-03-17 07:46:52 -07:00
Neil Mehta a57288b877 Update link in warning message (#13) 2025-03-16 20:01:58 -07:00
Awni Hannun 60b6b18219 version 2025-03-13 15:37:13 -07:00
104 changed files with 9081 additions and 1618 deletions
+1 -2
View File
@@ -31,13 +31,12 @@ jobs:
source env/bin/activate
pip install --upgrade pip
pip install unittest-xml-reporting
cd llms/
pip install -e ".[test]"
- run:
name: Run Python tests
command: |
source env/bin/activate
python -m xmlrunner discover -v llms/tests -o test-results/
python -m xmlrunner discover -v tests -o test-results/
- store_test_results:
path: test-results
+2 -2
View File
@@ -8,5 +8,5 @@ with a short description of your contribution(s) below. For example:
MLX LM was developed with contributions from the following individuals:
- Shunta Saito: Added support for PLaMo models.
- Prince Canuma: Helped add support for `Starcoder2` models.
- Gökdeniz Gülmez: Added support for `MiniCPM`, `Helium`, `Mamba version 1`, `OLMoE` archtectures and support for `full-fine-tuning`.
- Gökdeniz Gülmez: Added support for the following architectures: OpenBMB's `MiniCPM` and `MiniCPM3`, Kyutai's `Helium`, State-Space's`Mamba v1`, Z.ai & THUKEG's `GLM4`, Rednote `dots.llm1`, and Allenai's `OLMoE`; Added support for the following training algorithms: `full-fine-tuning`; Added support for the following other features: `Multiple Optimizers to choose for training`, and `reporting training metrics to WandB (Weights & Biases)`.
- Prince Canuma: Helped add support for the following model architectures: HuggingFace's `Starcoder2`, Cohere's `Cohere (1 and 2)`, Alibaba Qwen's `Qwen (2, 3 and MoE)`, Microsoft's `Phi (3 and 3.5 MoE)`, `BitNet1.58`, Meta's `Llama (3 and 4)`, Google DeepMind's `Gemma 3`, and InterLM's `InternLM 2.5`.
+1 -1
View File
@@ -1,2 +1,2 @@
include mlx_lm/requirements.txt
include requirements.txt
recursive-include mlx_lm/ *.py
+1 -1
View File
@@ -220,7 +220,7 @@ The cached prompt is treated as a prefix to the supplied prompt. Also notice
when using a cached prompt, the model to use is read from the cache and need
not be supplied explicitly.
Prompt caching can also be used in the Python API in order to to avoid
Prompt caching can also be used in the Python API in order to avoid
recomputing the prompt. This is useful in multi-turn dialogues or across
requests that use the same context. See the
[example](https://github.com/ml-explore/mlx-lm/blob/main/mlx_lm/examples/chat.py)
+149
View File
@@ -0,0 +1,149 @@
# Learned Quantization
To reduce the quality loss from quantization MLX LM has several options:
- Distilled Weight Quantization (DWQ)
- Activation-aware Weight Quantization (AWQ)[^1]
- Dynamic quantization
All methods use calibration data to tune parameters or hyper-parameters of the
model. DWQ fine-tunes non-quantized parameters (including quantization scales
and biases) using the non-quantized model as a teacher. AWQ scales and clips
the weights prior to quantization. Dynamic quantization estimates the
sensitivity of a model's outputs to each layer and uses a higher precision for
layers which have higher sensitivity.
Dynamic quantization is the fastest to run. DWQ takes longer but typically
yields better results. You can also cascade methods. For example a dynamically
quantized model can be further refined with DWQ.
To get started, first install the requirements:
```
pip install mlx-lm[quant]
```
### DWQ
Use `mlx_lm.dwq` to run DWQ on a given model. For example:
```bash
mlx_lm.dwq --model mistralai/Mistral-7B-Instruct-v0.3
```
Some important options, along with their default values are:
- `--mlx-path mlx_model`: The location to save the DWQ model.
- `--bits 4`: Precision of the quantization.
- `--num-samples 1024`: Number of samples to use. Using more samples can lead to
better results but takes longer.
- `--batch-size 8`: Use a smaller batch size to reduce the memory footprint.
For a full list of options run:
```bash
mlx_lm.dwq --help
```
#### Tips
- DWQ works best distilling to lower precision, anywhere from 2-bit to 4-bit
models.
- Distilling 16-bit precision to 8-bit and even 6-bit often doesn't work well.
The loss starts out so low that it's difficult to reduce further.
- Decreasing the quantization group size (e.g. `--group-size 32`) doubles the
number of tunable parameters and can work much better.
- If the loss is oscillating and not going down consistently, try reducing the
learning rate. If it is decreasing but slowly, try increasing the learning
rate.
- As a rule of thumb, lower precision can benefit from a higher learning rate
since the loss starts out higher. Conversely, higher precision needs a lower
learning rate.
#### Memory Use
A few options to reduce memory use for DWQ:
- Distill from an 8-bit model instead of a 16-bit model. The 8-bit
models are usually as good as 16-bit precision models.
- Use a shorter maximum sequence length. The default is 2048. Using
`--max-seq-length 512` reduces the memory and still gets good results.
- Use a smaller batch size, e.g. `--batch-size 1`
### Dynamic Quantization
Use `mlx_lm.dynamic_quant` to generate a dynamic quantization of given model.
For example:
```bash
mlx_lm.dynamic_quant --model mistralai/Mistral-7B-Instruct-v0.3
```
The script will estimate the sensitivity for each quantizable layer in the
model. It will then quantize the model using higher precision (default 5 bits)
for the more sensitive layers and lower precision (default 4 bits) for the
rest. The script also saves a JSON file with each layer's sensitivities which
saves needing to compute it multiple times to make different precision quants
of the same model.
Some important options are:
- `--target-bpw`: The target bits-per-weight. For a given set of quantization
parameters only certain ranges are possible. For example, with the default
parameters a BPW in the range `[4.5, 5.5]` is achievable.
- `--sensitivities`: A path to a precomputed sensitivities file.
- `--low-bits`: The number of bits to use for the less sensitive layers.
- `--high-bits`: The number of bits to use for the more sensitive layers.
### AWQ
Use `mlx_lm.awq` to run AWQ on a given model. For example:
```bash
mlx_lm.awq --model mistralai/Mistral-7B-Instruct-v0.3
```
The script can take anywhere form a few minutes to several hours to run
depending on the model size and the number of samples.
Some important options, along with their default values, are:
- `--mlx-path mlx_model`: The location to save the AWQ model.
- `--bits 4`: Precision of the quantization.
- `--num-samples 32`: Number of samples to use. Using more samples can lead to
better results but takes longer.
- `--n-grid 10`: The granularity of the AWQ search. A larger grid can lead to
better results but takes longer.
For a full list of options run:
```bash
mlx_lm.awq --help
```
### Evaluate
Once the training script finishes, you can evaluate the quality of the model
on downstream tasks using `mlx_lm.evaluate`. For example:
```bash
mlx_lm.evaluate \
--model mlx_model \
--tasks winogrande boolq arc_challenge arc_easy hellaswag openbookqa piqa social_iqa
```
### Upload to Hugging Face
Use `mlx_lm.upload` to upload the quantized model to the Hugging Face Hub. For
example:
```bash
mlx_lm.upload \
--path mlx_model \
--upload-repo mlx-community/Mistral-7B-Instruct-v0.3-3bit-DWQ
```
[^1]: Refer to the [paper](https://arxiv.org/abs/2306.00978)
and [github repository](https://github.com/mit-han-lab/llm-awq) for more
details.
+9 -4
View File
@@ -76,6 +76,11 @@ You can specify the output location with `--adapter-path`.
You can resume fine-tuning with an existing adapter with
`--resume-adapter-file <path_to_adapters.safetensors>`.
#### Logging
You can log training metrics to Weights & Biases by passing a project name with
the `--wandb` flag. Make sure to install wandb with `pip install wandb`.
#### Prompt Masking
The default training computes a loss for every token in the sample. You can
@@ -291,7 +296,7 @@ example:
```yaml
hf_dataset:
name: "billsum"
path: "billsum"
prompt_feature: "text"
completion_feature: "summary"
```
@@ -308,12 +313,12 @@ with the same structure as above. For example:
```yaml
hf_dataset:
- name: "Open-Orca/OpenOrca"
- path: "Open-Orca/OpenOrca"
train_split: "train[:90%]"
valid_split: "train[-10%:]"
prompt_feature: "question"
completion_feature: "response"
- name: "trl-lib/ultrafeedback_binarized"
- path: "trl-lib/ultrafeedback_binarized"
train_split: "train[:90%]"
valid_split: "train[-10%:]"
chat_feature: "chosen"
@@ -379,7 +384,7 @@ mlx_lm.lora \
--train \
--batch-size 1 \
--num-layers 4 \
--data wikisql
--data mlx-community/wikisql
```
The above command on an M1 Max with 32 GB runs at about 250
-50
View File
@@ -1,50 +0,0 @@
# Model Merging
You can use `mlx-lm` to merge models and upload them to the Hugging
Face hub or save them locally for LoRA fine tuning.
The main command is `mlx_lm.merge`:
```shell
mlx_lm.merge --config config.yaml
```
The merged model will be saved by default in `mlx_merged_model`. To see a
full list of options run:
```shell
mlx_lm.merge --help
```
Here is an example `config.yaml`:
```yaml
models:
- OpenPipe/mistral-ft-optimized-1218
- mlabonne/NeuralHermes-2.5-Mistral-7B
method: slerp
parameters:
t:
- filter: self_attn
value: [0, 0.5, 0.3, 0.7, 1]
- filter: mlp
value: [1, 0.5, 0.7, 0.3, 0]
- value: 0.5
```
The `models` field is a list of Hugging Face repo ids. The first model in the
list is treated as the base model into which the remaining models are merged.
The `method` field is the merging method. Right now `slerp` is the only
supported method.
The `parameters` are the corresponding parameters for the given `method`.
Each parameter is a list with `filter` determining which layer the parameter
applies to and `value` determining the actual value used. The last item in
the list without a `filter` field is the default.
If `value` is a list, it specifies the start and end values for the
corresponding segment of blocks. In the example above, the models have 32
blocks. For blocks 1-8, the layers with `self_attn` in the name will use the
values `np.linspace(0, 0.5, 8)`, the same layers in the next 8 blocks (9-16)
will use `np.linspace(0.5, 0.3, 8)`, and so on.
+14 -2
View File
@@ -54,18 +54,24 @@ curl localhost:8080/v1/chat/completions \
sequences of tokens on which the generation should stop.
- `max_tokens`: (Optional) An integer specifying the maximum number of tokens
to generate. Defaults to `100`.
to generate. Defaults to `512`.
- `stream`: (Optional) A boolean indicating if the response should be
streamed. If true, responses are sent as they are generated. Defaults to
false.
- `temperature`: (Optional) A float specifying the sampling temperature.
Defaults to `1.0`.
Defaults to `0.0`.
- `top_p`: (Optional) A float specifying the nucleus sampling parameter.
Defaults to `1.0`.
- `top_k`: (Optional) An integer specifying the top-k sampling parameter.
Defaults to `0` (disabled).
- `min_p`: (Optional) A float specifying the min-p sampling parameter.
Defaults to `0.0` (disabled).
- `repetition_penalty`: (Optional) Applies a penalty to repeated tokens.
Defaults to `1.0`.
@@ -86,6 +92,12 @@ curl localhost:8080/v1/chat/completions \
- `adapters`: (Optional) A string path to low-rank adapters. The path must be
relative to the directory the server was started in.
- `draft_model`: (Optional) Specifies a smaller model to use for speculative
decoding. Set to `null` to unload.
- `num_draft_tokens`: (Optional) The number of draft tokens the draft model
should predict at once. Defaults to `3`.
### Response Fields
- `id`: A unique identifier for the chat.
+3 -1
View File
@@ -6,4 +6,6 @@ from ._version import __version__
os.environ["TRANSFORMERS_NO_ADVISORY_WARNINGS"] = "1"
from .utils import convert, generate, load, stream_generate
from .convert import convert
from .generate import generate, stream_generate
from .utils import load
+28
View File
@@ -0,0 +1,28 @@
# Copyright © 2025 Apple Inc.
import importlib
import sys
if __name__ == "__main__":
subcommands = {
"quant.awq",
"quant.dwq",
"quant.dynamic_quant",
"cache_prompt",
"chat",
"convert",
"evaluate",
"fuse",
"generate",
"lora",
"server",
"manage",
"upload",
}
if len(sys.argv) < 2:
raise ValueError(f"CLI requires a subcommand in {subcommands}")
subcommand = sys.argv.pop(1)
if subcommand not in subcommands:
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-2024 Apple Inc.
__version__ = "0.21.6"
__version__ = "0.25.3"
+7 -2
View File
@@ -7,8 +7,9 @@ import time
import mlx.core as mx
from .generate import generate_step
from .models.cache import make_prompt_cache, save_prompt_cache
from .utils import generate_step, load
from .utils import load
DEFAULT_QUANTIZED_KV_START = 5000
@@ -147,7 +148,7 @@ def main():
pass
print()
print(f"Peak memory: {mx.metal.get_peak_memory() / 1e9:.3f} GB")
print(f"Peak memory: {mx.get_peak_memory() / 1e9:.3f} GB")
print("Saving...")
metadata = {}
@@ -158,4 +159,8 @@ def main():
if __name__ == "__main__":
print(
"Calling `python -m mlx_lm.cache_prompt...` directly is deprecated."
" Use `mlx_lm.cache_prompt...` or `python -m mlx_lm cache_prompt ...` instead."
)
main()
+29 -3
View File
@@ -1,16 +1,18 @@
# Copyright © 2023-2024 Apple Inc.
import argparse
import json
import mlx.core as mx
from .generate import stream_generate
from .models.cache import make_prompt_cache
from .sample_utils import make_sampler
from .utils import load, stream_generate
from .utils import load
DEFAULT_TEMP = 0.0
DEFAULT_TOP_P = 1.0
DEFAULT_XTC_PROBABILITY = 0.0
DEFAULT_XTC_THRESHOLD = 0.0
DEFAULT_SEED = None
DEFAULT_MAX_TOKENS = 256
DEFAULT_MODEL = "mlx-community/Llama-3.2-3B-Instruct-4bit"
@@ -36,6 +38,18 @@ def setup_arg_parser():
parser.add_argument(
"--top-p", type=float, default=DEFAULT_TOP_P, help="Sampling top-p"
)
parser.add_argument(
"--xtc-probability",
type=float,
default=DEFAULT_XTC_PROBABILITY,
help="Probability of XTC sampling to happen each next token",
)
parser.add_argument(
"--xtc-threshold",
type=float,
default=0.0,
help="Thresold the probs of each next token candidate to be sampled by XTC",
)
parser.add_argument(
"--seed",
type=int,
@@ -97,7 +111,15 @@ def main():
tokenizer,
prompt,
max_tokens=args.max_tokens,
sampler=make_sampler(args.temp, args.top_p),
sampler=make_sampler(
args.temp,
args.top_p,
xtc_threshold=args.xtc_threshold,
xtc_probability=args.xtc_probability,
xtc_special_tokens=(
tokenizer.encode("\n") + list(tokenizer.eos_token_ids)
),
),
prompt_cache=prompt_cache,
):
print(response.text, flush=True, end="")
@@ -105,4 +127,8 @@ def main():
if __name__ == "__main__":
print(
"Calling `python -m mlx_lm.chat...` directly is deprecated."
" Use `mlx_lm.chat...` or `python -m mlx_lm chat ...` instead."
)
main()
+173 -17
View File
@@ -1,23 +1,174 @@
# Copyright © 2023-2024 Apple Inc.
import argparse
from pathlib import Path
from typing import Callable, Optional, Union
from . import utils
from .utils import convert
import mlx.core as mx
import mlx.nn as nn
from mlx.utils import tree_map_with_path
QUANT_RECIPES = [
"mixed_2_6",
"mixed_3_6",
]
from .utils import (
dequantize_model,
fetch_from_hub,
get_model_path,
quantize_model,
save,
upload_to_hub,
)
def quant_args(arg):
if arg not in QUANT_RECIPES:
raise argparse.ArgumentTypeError(
f"Invalid q-recipe {arg!r}. Choose from: {QUANT_RECIPES}"
)
def mixed_quant_predicate_builder(
recipe: str, model: nn.Module
) -> Callable[[str, nn.Module, dict], Union[bool, dict]]:
high_bits = 6
group_size = 64
if recipe == "mixed_2_6":
low_bits = 2
elif recipe == "mixed_3_4":
low_bits = 3
high_bits = 4
elif recipe == "mixed_3_6":
low_bits = 3
elif recipe == "mixed_4_6":
low_bits = 4
else:
return getattr(utils, arg)
raise ValueError("Invalid quant recipe {recipe}")
down_keys = [k for k, _ in model.named_modules() if "down_proj" in k]
if len(down_keys) == 0:
raise ValueError("Model does not have expected keys for mixed quant.")
# Look for the layer index location in the path:
for layer_location, k in enumerate(down_keys[0].split(".")):
if k.isdigit():
break
num_layers = len(model.layers)
def mixed_quant_predicate(
path: str,
module: nn.Module,
config: dict,
) -> Union[bool, dict]:
"""Implements mixed quantization predicates with similar choices to, for example, llama.cpp's Q4_K_M.
Ref: https://github.com/ggerganov/llama.cpp/blob/917786f43d0f29b7c77a0c56767c0fa4df68b1c5/src/llama.cpp#L5265
By Alex Barron: https://gist.github.com/barronalex/84addb8078be21969f1690c1454855f3
"""
if not hasattr(module, "to_quantized"):
return False
if module.weight.shape[1] % group_size != 0:
return False
index = (
int(path.split(".")[layer_location])
if len(path.split(".")) > layer_location
else 0
)
use_more_bits = (
index < num_layers // 8
or index >= 7 * num_layers // 8
or (index - num_layers // 8) % 3 == 2
)
if "v_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}
if "lm_head" in path:
return {"group_size": group_size, "bits": high_bits}
return {"group_size": group_size, "bits": low_bits}
return mixed_quant_predicate
QUANT_RECIPES = ["mixed_2_6", "mixed_3_4", "mixed_3_6", "mixed_4_6"]
MODEL_CONVERSION_DTYPES = ["float16", "bfloat16", "float32"]
def convert(
hf_path: str,
mlx_path: str = "mlx_model",
quantize: bool = False,
q_group_size: int = 64,
q_bits: int = 4,
dtype: Optional[str] = None,
upload_repo: str = None,
revision: Optional[str] = None,
dequantize: bool = False,
quant_predicate: Optional[
Union[Callable[[str, nn.Module, dict], Union[bool, dict]], str]
] = None,
):
# Check the save path is empty
if isinstance(mlx_path, str):
mlx_path = Path(mlx_path)
if mlx_path.exists():
raise ValueError(
f"Cannot save to the path {mlx_path} as it already exists."
" Please delete the file/directory or specify a new path to save to."
)
print("[INFO] Loading")
model_path, hf_path = get_model_path(hf_path, revision=revision)
model, config, tokenizer = fetch_from_hub(model_path, lazy=True)
def base_quant_predicate(path, module, config):
if not hasattr(module, "to_quantized"):
return False
if module.weight.shape[1] % q_group_size != 0:
return False
return True
if isinstance(quant_predicate, str):
quant_predicate = mixed_quant_predicate_builder(quant_predicate, model)
quant_predicate = quant_predicate or base_quant_predicate
if dtype is None:
dtype = config.get("torch_dtype", None)
if dtype in MODEL_CONVERSION_DTYPES:
print("[INFO] Using dtype:", dtype)
dtype = getattr(mx, dtype)
cast_predicate = getattr(model, "cast_predicate", lambda _: True)
def set_dtype(k, v):
if cast_predicate(k) and mx.issubdtype(v.dtype, mx.floating):
return v.astype(dtype)
else:
return v
model.update(tree_map_with_path(set_dtype, model.parameters()))
if quantize and dequantize:
raise ValueError("Choose either quantize or dequantize, not both.")
if quantize:
print("[INFO] Quantizing")
model, config = quantize_model(
model, config, q_group_size, q_bits, quant_predicate=quant_predicate
)
if dequantize:
print("[INFO] Dequantizing")
config.pop("quantization", None)
config.pop("quantization_config", None)
model = dequantize_model(model)
save(
mlx_path,
model_path,
model,
tokenizer,
config,
hf_repo=hf_path,
)
if upload_repo is not None:
upload_to_hub(mlx_path, upload_repo)
def configure_parser() -> argparse.ArgumentParser:
@@ -46,16 +197,17 @@ def configure_parser() -> argparse.ArgumentParser:
)
parser.add_argument(
"--quant-predicate",
help=f"Mixed-bit quantization recipe. Choices: {QUANT_RECIPES}",
type=quant_args,
help=f"Mixed-bit quantization recipe.",
choices=QUANT_RECIPES,
type=str,
required=False,
)
parser.add_argument(
"--dtype",
help="Type to save the non-quantized parameters.",
help="Type to save the non-quantized parameters. Defaults to config.json's `torch_dtype` or the current model weights dtype.",
type=str,
choices=["float16", "bfloat16", "float32"],
default="float16",
choices=MODEL_CONVERSION_DTYPES,
default=None,
)
parser.add_argument(
"--upload-repo",
@@ -80,4 +232,8 @@ def main():
if __name__ == "__main__":
print(
"Calling `python -m mlx_lm.convert ...` directly is deprecated."
" Use `mlx_lm.convert ...` or `python -m mlx_lm convert ...` instead."
)
main()
+159 -136
View File
@@ -5,12 +5,14 @@ Adapted from a PyTorch implementation by David Grangier
"""
import argparse
import collections
import copy
import json
import logging
import os
from importlib.metadata import version
from pathlib import Path
from typing import Optional, Union
from typing import Any, Optional
import lm_eval
import mlx.core as mx
@@ -20,19 +22,10 @@ from lm_eval.api.model import LM
from lm_eval.api.registry import register_model
from tqdm import tqdm
from .generate import stream_generate
from .models.base import create_causal_mask
from .models.cache import make_prompt_cache
from .utils import load, stream_generate
PAD = 0
def _len_longest_common_prefix(a, b):
l = 0
for item_a, item_b in zip(a, b):
if item_a != item_b:
break
l += 1
return l
from .utils import common_prefix_len, load
def _rstrip_until(s, untils):
@@ -43,75 +36,85 @@ def _rstrip_until(s, untils):
return s[: min(f)]
def _pad_inputs(
inputs,
maxlen,
genlen=0,
pad_left=False,
pad_multiple=32,
truncate=False,
):
# pad the prompts to the left with at least genlen tokens.
actual_maxlen = max(len(p) for p in inputs) + genlen
if actual_maxlen > maxlen:
if not truncate:
raise ValueError("Inputs are too long.")
else: # drop begining
actual_maxlen = maxlen
inputs = [p[max(0, len(p) - maxlen) :] for p in inputs]
if pad_multiple > 0:
maxlen = (actual_maxlen + pad_multiple - 1) // pad_multiple
maxlen *= pad_multiple
assert PAD == 0
lr = np.array((1, 0) if pad_left else (0, 1))
return np.stack(
[np.pad(np.array(x, np.int32), lr * (maxlen - len(x))) for x in inputs],
def _pad_inputs(inputs):
lengths = np.array([len(x) for x in inputs])
maxlen = lengths.max()
padded = np.stack(
[np.pad(x, (0, maxlen - len(x))) for x in inputs],
axis=0,
)
return mx.array(padded), mx.array(lengths)
def chat_template_fn(**extra_kwargs):
def apply_chat_template(self, chat_history, add_generation_prompt=True) -> str:
return self.tokenizer.apply_chat_template(
chat_history,
tokenize=False,
add_generation_prompt=add_generation_prompt,
continue_final_message=not add_generation_prompt,
**extra_kwargs,
)
return apply_chat_template
@register_model("mlxlm")
class MLXLM(LM):
tokenizer_name = lm_eval.models.huggingface.HFLM.tokenizer_name
apply_chat_template = chat_template_fn()
def __init__(
self,
path_or_hf_repo: str,
batch_size: int = 16,
max_tokens: Optional[int] = None,
use_chat_template: Optional[bool] = None,
) -> None:
super().__init__()
self._batch_size = batch_size
self._model, self.tokenizer = load(path_or_hf_repo)
self._max_tokens = max_tokens or self.tokenizer.model_max_length
self.use_chat_template = use_chat_template or (
self.tokenizer.chat_template is not None
)
self._batch_size = 8
self.use_chat_template = use_chat_template
if use_chat_template is None:
self.use_chat_template = self.tokenizer.chat_template is not None
def _score_fn(self, inputs, tokenize=True, step_size=32):
if tokenize:
inputs = self._tokenize(inputs)
inputs = _pad_inputs(inputs, self._max_tokens, truncate=False)
inputs = mx.array(inputs)
def _process_prompt(self, prompt, step_size: int = 2048):
prompt = mx.array(prompt)[None]
cache = make_prompt_cache(self._model)
for i in range(0, prompt.shape[1], step_size):
logits = self._model(prompt[:, i : i + step_size], cache=cache)
mx.eval([c.state for c in cache])
mx.clear_cache()
logprobs = nn.log_softmax(logits[:, -1, :].astype(mx.float32))
return logprobs, cache
def _score_fn(self, inputs, cache: Optional[Any] = None, step_size: int = 2048):
inputs, lengths = _pad_inputs(inputs)
inputs, targets = inputs[..., :-1], inputs[..., 1:]
cache = make_prompt_cache(self._model)
mask = targets != PAD
cache = cache or make_prompt_cache(self._model)
lengths += cache[0].offset
scores, is_greedy = [], []
for i in range(0, inputs.shape[1], step_size):
logits = self._model(inputs[:, i : i + step_size], cache=cache)
inp = inputs[:, i : i + step_size]
T = inp.shape[1]
offset = cache[0].offset
mask = create_causal_mask(T, offset, lengths=lengths)
logits = self._model(inp, cache=cache, mask=mask)
log_probs = nn.log_softmax(logits.astype(mx.float32))
score = mx.take_along_axis(
log_probs, targets[:, i : i + step_size, mx.newaxis], axis=-1
)[..., 0]
ig = mask[:, i : i + step_size] * (
targets[:, i : i + step_size] == mx.argmax(logits, axis=-1)
)
ig = targets[:, i : i + step_size] == mx.argmax(logits, axis=-1)
ig = mx.where(mx.arange(T) + offset < lengths[:, None], ig, False)
mx.eval(score, ig)
mx.metal.clear_cache()
mx.clear_cache()
is_greedy.append(ig)
scores.append(score)
@@ -119,38 +122,7 @@ class MLXLM(LM):
scores = mx.concatenate(scores, axis=1)
is_greedy = mx.concatenate(is_greedy, axis=1)
return scores, mask.sum(axis=-1), is_greedy
def _loglikelihood(self, texts, score_spans=None, tokenize=True):
# sort by length to get batches with little padding.
sorted_indices = sorted(range(len(texts)), key=lambda i: -len(texts[i]))
sorted_inputs = [texts[sorted_indices[i]] for i in range(len(texts))]
sorted_spans = None
if score_spans is not None:
sorted_spans = [score_spans[sorted_indices[i]] for i in range(len(texts))]
results = []
for i in tqdm(range(0, len(sorted_inputs), self._batch_size)):
batch = sorted_inputs[i : i + self._batch_size]
scores, length, is_greedy = self._score_fn(batch, tokenize=tokenize)
for j in range(len(batch)):
if sorted_spans is None: # full sequence score
mask = mx.arange(scores[j].shape[-1]) < length
score = (scores[j].astype(mx.float32) * mask).sum(axis=-1)
ig = (is_greedy[j].astype(mx.int32) * mask).sum(axis=-1)
else: # subsequence score
start, end = sorted_spans[i + j]
score = scores[j][start:end].astype(mx.float32).sum()
ig = is_greedy[j][start:end].astype(mx.int32).sum()
length = end - start
results.append((score.item(), ig.item(), length))
# reorder the outputs
inv_sort = np.argsort(sorted_indices)
results = [results[inv_sort[i]] for i in range(len(results))]
return results
return scores, lengths, is_greedy
def _tokenize(self, texts):
return [
@@ -182,39 +154,65 @@ class MLXLM(LM):
"""
logging.info("Estimating loglikelihood for %d pairs." % len(requests))
# tokenize prefix and prefix + completion for all requests.
tokenized = self._tokenize(
[t for r in requests for t in [r.args[0], r.args[0] + r.args[1]]]
)
group = mx.distributed.init()
# max length (prefix + completion) and longest common prefix per question.
length_stats = {}
for prefix, completed in zip(tokenized[0::2], tokenized[1::2]):
max_completed_l, min_prefix_l = length_stats.get(prefix, (0, 1e8))
length_stats[prefix] = (
max(max_completed_l, len(completed)),
min(min_prefix_l, _len_longest_common_prefix(prefix, completed)),
)
# Group by common prefix
group_reqs = collections.defaultdict(list)
for idx, req in enumerate(requests):
group_reqs[req.args[0]].append((idx, req.args[1]))
questions = list(group_reqs.keys())
responses = []
indices = []
for v in group_reqs.values():
idx, resp = zip(*v)
indices.extend(idx)
responses.append(resp)
# split data accross ranks
questions = questions[group.rank() :: group.size()]
responses = responses[group.rank() :: group.size()]
# truncate requests for completed sequences longer than model context.
shortened = []
completion_spans = []
long_completions = 0
for prefix, completed in zip(tokenized[0::2], tokenized[1::2]):
max_completed_l, prefix_l = length_stats[prefix]
scores, is_greedy = [], []
for q, rs in tqdm(zip(questions, responses), total=len(questions)):
prefix = self._tokenize([q])[0]
full_sequences = self._tokenize([q + r for r in rs])
max_completed_l = max(len(s) for s in full_sequences)
# compute truncation length
truncation = max(0, max_completed_l - self._max_tokens - 1)
prefix_l = prefix_l - truncation
if prefix_l <= 0:
# completion too long, prefix is eliminated for some requests.
orig_prefix_l = len(prefix)
prefix_l = max(len(prefix) - truncation, 0)
prefix = prefix[len(prefix) - prefix_l :]
# If the entire prompt got truncated ignore the question
if prefix_l == 0:
long_completions += 1
truncation = max(0, len(completed) - self._max_tokens - 1)
prefix_l = 1
# truncate the completed sequence
completed = completed[truncation:]
shortened.append(completed)
# scores do not include initial bos, substract 1 to span bounds
completion_spans.append((prefix_l - 1, len(completed) - 1))
all_scores.extend([-float("inf")] * len(rs))
all_is_greedy.extend([False] * len(rs))
continue
# model scoring, returns num_requests x (logp, is_greedy, length).
logprobs, cache = self._process_prompt(prefix)
max_idx = mx.argmax(logprobs).item()
for s in full_sequences:
inputs = s[len(prefix) :]
# The logprobs from the last token of the prompt are
# for the first input token
scores.append(logprobs[0, inputs[0]].item())
is_greedy.append((inputs[0] == max_idx))
if len(inputs) == 1:
continue
score, _, ig = self._score_fn(
mx.array(inputs)[None, :], cache=copy.deepcopy(cache)
)
scores[-1] += mx.sum(score).item()
is_greedy[-1] &= mx.all(ig).item()
scores = mx.array(scores)
is_greedy = mx.array(is_greedy)
if long_completions > 0:
logging.info(
@@ -222,16 +220,23 @@ class MLXLM(LM):
+ "completion longer than context."
)
# model scoring, returns num_requests x (logp, is_greedy, length).
results = self._loglikelihood(
shortened,
score_spans=completion_spans,
tokenize=False,
)
return [(r[0], r[1] == r[2]) for r in results]
num_results = len(requests)
tokenizer_name = lm_eval.models.huggingface.HFLM.tokenizer_name
apply_chat_template = lm_eval.models.huggingface.HFLM.apply_chat_template
# all gather the results across groups
if group.size() > 1:
per_group = int(np.ceil(num_results / group.size()))
scores = mx.pad(scores, ((0, per_group - len(scores)),))
is_greedy = mx.pad(is_greedy, ((0, per_group - len(is_greedy))))
scores = mx.distributed.all_gather(scores[mx.newaxis], stream=mx.cpu)
is_greedy = mx.distributed.all_gather(is_greedy[mx.newaxis], stream=mx.cpu)
mx.eval(scores, is_greedy)
scores = scores.T.reshape(-1)
is_greedy = is_greedy.T.reshape(-1)
inv_sort = mx.argsort(mx.array(indices))
scores = scores[:num_results][inv_sort]
is_greedy = is_greedy[:num_results][inv_sort]
return list(zip(scores.tolist(), is_greedy.tolist()))
def loglikelihood_rolling(self, requests) -> list[float]:
"""Compute full log-likelihood of a string, with no truncation, for perplexity computation
@@ -268,8 +273,15 @@ class MLXLM(LM):
logging.info(
"Estimating loglikelihood rolling for %d sequences." % len(requests)
)
inputs = [req.args[0] for req in requests]
return [t[0] for t in self._loglikelihood(inputs)]
inputs = self._tokenize([req.args[0] for req in requests])
all_scores = []
for i in tqdm(range(0, len(texts), self._batch_size)):
batch = texts[i : i + self._batch_size]
scores, lengths, _ = self._score_fn(batch)
mask = mx.arange(scores.shape[-1]) < lengths[:, None]
all_scores.extend((mask * scores).sum(axis=-1).tolist())
return all_scores
def generate_until(self, requests) -> list[str]:
"""Generate greedily until a stopping sequence
@@ -324,7 +336,7 @@ def main():
"--output-dir", default=".", help="Output directory for result files."
)
parser.add_argument("--batch-size", type=int, default=16, help="Batch size")
parser.add_argument("--num-shots", type=int, default=0, help="Number of shots")
parser.add_argument("--num-shots", type=int, default=None, help="Number of shots")
parser.add_argument(
"--max-tokens",
type=int,
@@ -332,7 +344,7 @@ def main():
)
parser.add_argument(
"--limit",
default=100,
default=None,
help="Limit the number of examples per task.",
type=int,
)
@@ -352,6 +364,14 @@ def main():
"otherwise `False`.",
default=None,
)
parser.add_argument(
"--chat-template-args",
type=json.loads,
help="""A JSON formatted string of arguments for the tokenizer's "
"apply_chat_template, e.g. '{"enable_thinking":false}'""",
default="{}",
)
args = parser.parse_args()
output_dir = Path(args.output_dir)
@@ -364,10 +384,11 @@ def main():
lm = MLXLM(
args.model,
batch_size=args.batch_size,
max_tokens=args.max_tokens,
use_chat_template=args.apply_chat_template,
)
MLXLM.apply_chat_template = chat_template_fn(**args.chat_template_args)
results = lm_eval.simple_evaluate(
model=lm,
tasks=args.tasks,
@@ -381,12 +402,14 @@ def main():
fewshot_random_seed=args.seed,
)
model_name = args.model.replace("/", "_")
task_names = "_".join(args.tasks)
ver = version("lm_eval")
filename = f"eval_{model_name}_{task_names}_{args.num_shots:02d}_v_{ver}.json"
output_path = output_dir / filename
output_path.write_text(json.dumps(results["results"], indent=4))
print("Results:")
for result in results["results"].values():
print(json.dumps(result, indent=4))
file_keys = ["eval", args.model.replace("/", "_"), version("lm_eval")]
if args.num_shots is not None:
file_keys += [f"{args.num_shots:02d}"]
file_keys += args.tasks
filename = "_".join(file_keys)
if mx.distributed.init().rank() == 0:
output_path = output_dir / filename
output_path.write_text(json.dumps(results["results"], indent=4))
print("Results:")
for result in results["results"].values():
print(json.dumps(result, indent=4))
+6 -3
View File
@@ -1,5 +1,5 @@
# The path to the local model directory or Hugging Face repo.
model: "mlx_model"
model: "mlx-community/Llama-3.2-1B-Instruct"
# Whether or not to train (boolean)
train: true
@@ -17,7 +17,7 @@ optimizer: adamw
# bias_correction: true
# Directory with {train, valid, test}.jsonl files
data: "/path/to/training/data"
data: "mlx-community/WikiSQL"
# The PRNG seed
seed: 0
@@ -37,6 +37,9 @@ val_batches: 25
# Adam learning rate.
learning_rate: 1e-5
# Whether to report the logs to WandB
# wand: "wandb-project"
# Number of training steps between loss reporting.
steps_per_report: 10
@@ -81,7 +84,7 @@ lora_parameters:
# arguments: [1e-5, 1000, 1e-7] # passed to scheduler
#hf_dataset:
# name: "billsum"
# path: "billsum"
# train_split: "train[:1000]"
# valid_split: "train[-100:]"
# prompt_feature: "text"
+65
View File
@@ -0,0 +1,65 @@
# Copyright © 2025 Apple Inc.
"""
This is an example of tool use with mlx_lm and the OpenAI client.
To run, first start the server:
>>> mlx_lm.server
Then run this script.
"""
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8080/v1", api_key="not-needed")
model = "mlx-community/qwen3-4b-4bit-DWQ"
messages = [{"role": "user", "content": "What's the weather in Boston?"}]
tools = [
{
"type": "function",
"function": {
"name": "get_current_weather",
"description": "Get the current weather in a given location",
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "The city and state, e.g. San Francisco, CA",
},
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"]},
},
"required": ["location"],
},
},
}
]
def get_current_weather(**kwargs):
return "51 Farenheit, clear skies"
functions = {"get_current_weather": get_current_weather}
# The first query generates a tool call
response = client.chat.completions.create(
model=model,
messages=messages,
tools=tools,
)
# Call the function
function = response.choices[0].message.tool_calls[0].function
tool_result = functions[function.name](**function.arguments)
# Put the result of the function in the messages and generate the final
# response:
messages.append({"role": "tool", "name": function.name, "content": tool_result})
response = client.chat.completions.create(
model=model,
messages=messages,
tools=tools,
)
print(response.choices[0].message.content)
+8 -4
View File
@@ -5,8 +5,7 @@ Run with:
```
mlx.launch \
--hostfile /path/to/hosts.txt \
--backend mpi \
--hostfile /path/to/hosts.json \
/path/to/pipeline_generate.py \
--prompt "hello world"
```
@@ -19,14 +18,19 @@ https://ml-explore.github.io/mlx/build/html/usage/distributed.html).
import argparse
import json
import resource
from pathlib import Path
import mlx.core as mx
from huggingface_hub import snapshot_download
from mlx.utils import tree_flatten
from mlx_lm import load, stream_generate
from mlx_lm.utils import load_model, load_tokenizer
# Needed for 8 bit model
resource.setrlimit(resource.RLIMIT_NOFILE, (2048, 4096))
def download(repo: str, allow_patterns: list[str]) -> Path:
return Path(
@@ -48,7 +52,7 @@ def shard_and_load(repo):
# which weights we need
model, _ = load_model(model_path, lazy=True, strict=False)
group = mx.distributed.init(backend="mpi")
group = mx.distributed.init()
rank = group.rank()
model.model.pipeline(group)
@@ -97,7 +101,7 @@ if __name__ == "__main__":
)
args = parser.parse_args()
group = mx.distributed.init(backend="mpi")
group = mx.distributed.init()
rank = group.rank()
def rprint(*args, **kwargs):
+21 -33
View File
@@ -1,19 +1,14 @@
import argparse
import glob
import shutil
from pathlib import Path
from mlx.utils import tree_flatten, tree_unflatten
from .gguf import convert_to_gguf
from .tuner.dora import DoRAEmbedding, DoRALinear
from .tuner.lora import LoRAEmbedding, LoRALinear, LoRASwitchLinear
from .tuner.utils import dequantize, load_adapters
from .utils import (
fetch_from_hub,
get_model_path,
save_config,
save_weights,
save,
upload_to_hub,
)
@@ -38,12 +33,6 @@ def parse_arguments() -> argparse.Namespace:
default="adapters",
help="Path to the trained adapter weights and config.",
)
parser.add_argument(
"--hf-path",
type=str,
default=None,
help="Path to the original Hugging Face model. Required for upload if --model is a local directory.",
)
parser.add_argument(
"--upload-repo",
help="The Hugging Face repo to upload the model to.",
@@ -73,14 +62,16 @@ def main() -> None:
print("Loading pretrained model")
args = parse_arguments()
model_path = get_model_path(args.model)
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)
fused_linears = [
(n, m.fuse()) for n, m in model.named_modules() if hasattr(m, "fuse")
(n, m.fuse(de_quantize=args.de_quantize))
for n, m in model.named_modules()
if hasattr(m, "fuse")
]
if fused_linears:
@@ -89,23 +80,18 @@ def main() -> None:
if args.de_quantize:
print("De-quantizing model")
model = dequantize(model)
weights = dict(tree_flatten(model.parameters()))
save_path = Path(args.save_path)
save_weights(save_path, weights)
py_files = glob.glob(str(model_path / "*.py"))
for file in py_files:
shutil.copy(file, save_path)
tokenizer.save_pretrained(save_path)
if args.de_quantize:
config.pop("quantization", None)
save_config(config, config_path=save_path / "config.json")
save_path = Path(args.save_path)
save(
save_path,
model_path,
model,
tokenizer,
config,
hf_repo=hf_path,
donate_model=False,
)
if args.export_gguf:
model_type = config["model_type"]
@@ -113,18 +99,20 @@ def main() -> None:
raise ValueError(
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))
if args.upload_repo is not None:
hf_path = args.hf_path or (
args.model if not Path(args.model).exists() else 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, hf_path)
upload_to_hub(args.save_path, args.upload_repo)
if __name__ == "__main__":
print(
"Calling `python -m mlx_lm.fuse...` directly is deprecated."
" Use `mlx_lm.fuse...` or `python -m mlx_lm fuse ...` instead."
)
main()
+596 -3
View File
@@ -1,20 +1,44 @@
# Copyright © 2023-2024 Apple Inc.
import argparse
import contextlib
import functools
import json
import sys
import time
from dataclasses import dataclass
from typing import (
Any,
Callable,
Generator,
List,
Optional,
Tuple,
Union,
)
import mlx.core as mx
import mlx.nn as nn
from mlx.utils import tree_reduce
from transformers import PreTrainedTokenizer
from .models.cache import QuantizedKVCache, load_prompt_cache
from .models import cache
from .models.cache import (
QuantizedKVCache,
load_prompt_cache,
)
from .sample_utils import make_sampler
from .utils import generate, load
from .tokenizer_utils import TokenizerWrapper
from .utils import does_model_support_input_embeddings, load
DEFAULT_PROMPT = "hello"
DEFAULT_MAX_TOKENS = 100
DEFAULT_TEMP = 0.0
DEFAULT_TOP_P = 1.0
DEFAULT_MIN_P = 0.0
DEFAULT_TOP_K = 0
DEFAULT_XTC_PROBABILITY = 0.0
DEFAULT_XTC_THRESHOLD = 0.0
DEFAULT_MIN_TOKENS_TO_KEEP = 1
DEFAULT_SEED = None
DEFAULT_MODEL = "mlx-community/Llama-3.2-3B-Instruct-4bit"
@@ -81,6 +105,21 @@ def setup_arg_parser():
parser.add_argument(
"--min-p", type=float, default=DEFAULT_MIN_P, help="Sampling min-p"
)
parser.add_argument(
"--top-k", type=int, default=DEFAULT_TOP_K, help="Sampling top-k"
)
parser.add_argument(
"--xtc-probability",
type=float,
default=DEFAULT_XTC_PROBABILITY,
help="Probability of XTC sampling to happen each next token",
)
parser.add_argument(
"--xtc-threshold",
type=float,
default=0.0,
help="Thresold the probs of each next token candidate to be sampled by XTC",
)
parser.add_argument(
"--min-tokens-to-keep",
type=int,
@@ -162,6 +201,547 @@ def setup_arg_parser():
return parser
# A stream on the default device just for generation
generation_stream = mx.new_stream(mx.default_device())
@contextlib.contextmanager
def wired_limit(model: nn.Module, streams: Optional[List[mx.Stream]] = None):
"""
A context manager to temporarily change the wired limit.
Note, the wired limit should not be changed during an async eval. If an
async eval could be running pass in the streams to synchronize with prior
to exiting the context manager.
"""
if not mx.metal.is_available():
try:
yield
finally:
return
model_bytes = tree_reduce(
lambda acc, x: acc + x.nbytes if isinstance(x, mx.array) else acc, model, 0
)
max_rec_size = mx.metal.device_info()["max_recommended_working_set_size"]
if model_bytes > 0.9 * max_rec_size:
model_mb = model_bytes // 2**20
max_rec_mb = max_rec_size // 2**20
print(
f"[WARNING] Generating with a model that requires {model_mb} MB "
f"which is close to the maximum recommended size of {max_rec_mb} "
"MB. This can be slow. See the documentation for possible work-arounds: "
"https://github.com/ml-explore/mlx-lm/tree/main#large-models"
)
old_limit = mx.set_wired_limit(max_rec_size)
try:
yield
finally:
if streams is not None:
for s in streams:
mx.synchronize(s)
else:
mx.synchronize()
mx.set_wired_limit(old_limit)
@dataclass
class GenerationResponse:
"""
The output of :func:`stream_generate`.
Args:
text (str): The next segment of decoded text. This can be an empty string.
token (int): The next token.
from_draft (bool): Whether the token was generated by the draft model.
logprobs (mx.array): A vector of log probabilities.
prompt_tokens (int): The number of tokens in the prompt.
prompt_tps (float): The prompt processing tokens-per-second.
generation_tokens (int): The number of generated tokens.
generation_tps (float): The tokens-per-second for generation.
peak_memory (float): The peak memory used so far in GB.
finish_reason (str): The reason the response is being sent: "length", "stop" or `None`
"""
text: str
token: int
logprobs: mx.array
from_draft: bool
prompt_tokens: int
prompt_tps: float
generation_tokens: int
generation_tps: float
peak_memory: float
finish_reason: Optional[str] = None
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
)
def generate_step(
prompt: mx.array,
model: nn.Module,
*,
max_tokens: int = 256,
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,
prefill_step_size: int = 2048,
kv_bits: Optional[int] = None,
kv_group_size: int = 64,
quantized_kv_start: int = 0,
prompt_progress_callback: Optional[Callable[int, int]] = None,
input_embeddings: Optional[mx.array] = None,
) -> Generator[Tuple[mx.array, mx.array], None, None]:
"""
A generator producing token ids based on the given prompt from the model.
Args:
prompt (mx.array): The input prompt.
model (nn.Module): The model to use for generation.
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
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
logits. Default: ``None``.
max_kv_size (int, optional): Maximum size of the key-value cache. Old
entries (except the first 4 tokens) will be overwritten.
prompt_cache (List[Any], optional): A pre-computed prompt cache. Note, if
provided, the cache will be updated in place.
prefill_step_size (int): Step size for processing the prompt.
kv_bits (int, optional): Number of bits to use for KV cache quantization.
None implies no cache quantization. Default: ``None``.
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 tokens processed so far and the total number of prompt tokens.
input_embeddings (mx.array, optional): Input embeddings to use in place of
prompt tokens. Default: ``None``.
Yields:
Tuple[mx.array, mx.array]: One token and a vector of log probabilities.
"""
if input_embeddings is not None:
if not does_model_support_input_embeddings(model):
raise ValueError("Model does not support input embeddings.")
if len(prompt) != 0:
raise ValueError(
"If using input embeddings, prompt tokens must be an empty array."
)
tokens = None
# Create the KV cache for generation
if prompt_cache is None:
prompt_cache = cache.make_prompt_cache(
model,
max_kv_size=max_kv_size,
)
prompt_progress_callback = prompt_progress_callback or (lambda *_: None)
quantize_cache_fn = functools.partial(
maybe_quantize_kv_cache,
quantized_kv_start=quantized_kv_start,
kv_group_size=kv_group_size,
kv_bits=kv_bits,
)
sampler = sampler or (lambda x: mx.argmax(x, axis=-1))
def _model_call(y):
if y.ndim == 3:
return model(None, cache=prompt_cache, input_embeddings=y)
else:
return model(y, cache=prompt_cache)
def _step(y):
nonlocal tokens
with mx.stream(generation_stream):
logits = _model_call(y[None])
logits = logits[:, -1, :]
if logits_processors and input_embeddings is None:
tokens = mx.concat([tokens, y]) if tokens is not None else y
for processor in logits_processors:
logits = processor(tokens, logits)
quantize_cache_fn(prompt_cache)
logprobs = logits - mx.logsumexp(logits, keepdims=True)
y = sampler(logprobs)
return y, logprobs.squeeze(0)
using_embeddings = input_embeddings is not None
y = input_embeddings if using_embeddings else prompt
with mx.stream(generation_stream):
total_prompt_tokens = y.shape[0]
prompt_processed_tokens = 0
while y.shape[0] > prefill_step_size:
_model_call(y[:prefill_step_size][None])
quantize_cache_fn(prompt_cache)
mx.eval([c.state for c in prompt_cache])
prompt_progress_callback(prompt_processed_tokens, total_prompt_tokens)
prompt_processed_tokens += prefill_step_size
y = y[prefill_step_size:]
mx.clear_cache()
y, logprobs = _step(y)
mx.async_eval(y, logprobs)
n = 0
while True:
if n != max_tokens:
next_y, next_logprobs = _step(y)
mx.async_eval(next_y, next_logprobs)
if n == 0:
mx.eval(y)
prompt_progress_callback(total_prompt_tokens, total_prompt_tokens)
if n == max_tokens:
break
yield y.item(), logprobs
if n % 256 == 0:
mx.clear_cache()
y, logprobs = next_y, next_logprobs
n += 1
def speculative_generate_step(
prompt: mx.array,
model: nn.Module,
draft_model: nn.Module,
*,
num_draft_tokens=2,
max_tokens: int = 256,
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,
kv_bits: Optional[int] = None,
kv_group_size: int = 64,
quantized_kv_start: int = 0,
) -> Generator[Tuple[mx.array, mx.array, bool], None, None]:
"""
A generator producing token ids based on the given prompt from the model.
Args:
prompt (mx.array): The input prompt.
model (nn.Module): The model to use for generation.
draft_model (nn.Module): The draft model for speculative decoding.
num_draft_tokens (int, optional): The number of draft tokens for
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
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
logits. Default: ``None``.
prompt_cache (List[Any], optional): A pre-computed prompt cache. Note, if
provided, the cache will be updated in place. The cache must be trimmable.
prefill_step_size (int): Step size for processing the prompt.
kv_bits (int, optional): Number of bits to use for KV cache quantization.
None implies no cache quantization. Default: ``None``.
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``.
Yields:
Tuple[mx.array, mx.array, bool]: One token, a vector of log probabilities,
and a bool indicating if the token was generated by the draft model
"""
y = prompt.astype(mx.uint32)
prev_tokens = None
# Create the KV cache for generation
if prompt_cache is None:
model_cache = cache.make_prompt_cache(model)
draft_cache = cache.make_prompt_cache(draft_model)
else:
model_cache = prompt_cache[: len(model.layers)]
draft_cache = prompt_cache[len(model.layers) :]
sampler = sampler or (lambda x: mx.argmax(x, axis=-1))
quantize_cache_fn = functools.partial(
maybe_quantize_kv_cache,
quantized_kv_start=quantized_kv_start,
kv_group_size=kv_group_size,
kv_bits=kv_bits,
)
def _process_and_sample(tokens, logits):
if logits_processors:
for processor in logits_processors:
logits = processor(tokens, logits)
logprobs = logits - mx.logsumexp(logits, axis=-1, keepdims=True)
y = sampler(logprobs)
return y, logprobs
def _step(model, cache, y, n_predict=1):
with mx.stream(generation_stream):
logits = model(y[None], cache=cache)
logits = logits[:, -n_predict:, :]
quantize_cache_fn(cache)
if logits_processors:
nonlocal prev_tokens
out_y, out_logprobs = [], []
if n_predict > 1:
y = y[: -(n_predict - 1)]
for i in range(n_predict):
prev_tokens = (
mx.concat([prev_tokens, y]) if prev_tokens is not None else y
)
y, logprobs = _process_and_sample(prev_tokens, logits[:, i, :])
out_y.append(y)
out_logprobs.append(logprobs)
return mx.concatenate(out_y, axis=0), mx.concatenate(
out_logprobs, axis=0
)
else:
return _process_and_sample(None, logits.squeeze(0))
def _prefill(model, cache, y):
while y.size > prefill_step_size:
model(y[:prefill_step_size][None], cache=cache)
quantize_cache_fn(cache)
mx.eval([c.state for c in cache])
y = y[prefill_step_size:]
mx.clear_cache()
return y
def _rewind_cache(num_draft, num_accept):
cache.trim_prompt_cache(model_cache, num_draft - num_accept)
cache.trim_prompt_cache(draft_cache, max(num_draft - num_accept - 1, 0))
def _draft_generate(y, num_draft):
if num_draft == 0:
return mx.array([], mx.uint32)
ys = []
for _ in range(num_draft):
y, _ = _step(draft_model, draft_cache, y)
mx.async_eval(y)
ys.append(y)
return mx.concatenate(ys)
with mx.stream(generation_stream):
draft_y = _prefill(draft_model, draft_cache, y)
y = _prefill(model, model_cache, y)
ntoks = 0
# Set these so the finally block doesn't raise
num_draft = 0
n = 0
try:
while True:
num_draft = min(max_tokens - ntoks, num_draft_tokens)
draft_tokens = _draft_generate(draft_y, num_draft)
if prev_tokens is not None:
prev_tokens = prev_tokens[: prev_tokens.size - y.size - num_draft + 1]
y = mx.concatenate([y, draft_tokens])
tokens, logprobs = _step(model, model_cache, y, num_draft + 1)
mx.eval(tokens, draft_tokens)
draft_tokens = draft_tokens.tolist()
tokens = tokens.tolist()
n = 0
while n < num_draft:
tn, dtn, lpn = tokens[n], draft_tokens[n], logprobs[n]
if tn != dtn:
break
n += 1
ntoks += 1
yield tn, lpn, True
if ntoks == max_tokens:
break
if ntoks < max_tokens:
ntoks += 1
yield tokens[n], logprobs[n], False
if ntoks == max_tokens:
break
y = mx.array([tokens[n]], mx.uint32)
draft_y = y
# If we accepted all the draft tokens, include the last
# draft token in the next draft step since it hasn't been
# processed yet by the draft model
if n == num_draft:
draft_y = mx.concatenate(
[mx.array(draft_tokens[-1:], mx.uint32), draft_y]
)
if prev_tokens is not None:
prev_tokens = prev_tokens[: -max(num_draft - n, 1)]
_rewind_cache(num_draft, n)
finally:
_rewind_cache(num_draft, n)
def stream_generate(
model: nn.Module,
tokenizer: Union[PreTrainedTokenizer, TokenizerWrapper],
prompt: Union[str, mx.array, List[int]],
draft_model: Optional[nn.Module] = None,
**kwargs,
) -> Generator[GenerationResponse, None, None]:
"""
A generator producing text based on the given prompt from the model.
Args:
model (nn.Module): The model to use for generation.
tokenizer (PreTrainedTokenizer): The tokenizer.
prompt (Union[str, mx.array, List[int]]): The input prompt string or
integer tokens.
draft_model (Optional[nn.Module]): An optional draft model. If provided
then speculative decoding is used. The draft model must use the same
tokenizer as the main model. Default: ``None``.
kwargs: The remaining options get passed to :func:`generate_step`.
See :func:`generate_step` for more details.
Yields:
GenerationResponse: An instance containing the generated text segment and
associated metadata. See :class:`GenerationResponse` for details.
"""
if not isinstance(tokenizer, TokenizerWrapper):
tokenizer = TokenizerWrapper(tokenizer)
if not isinstance(prompt, mx.array):
if isinstance(prompt, str):
# Try to infer if special tokens are needed
add_special_tokens = tokenizer.bos_token is None or not prompt.startswith(
tokenizer.bos_token
)
prompt = tokenizer.encode(prompt, add_special_tokens=add_special_tokens)
prompt = mx.array(prompt)
detokenizer = tokenizer.detokenizer
if draft_model is None:
kwargs.pop("num_draft_tokens", None)
token_generator = generate_step(prompt, model, **kwargs)
# from_draft always false for non-speculative generation
token_generator = (
(token, logprobs, False) for token, logprobs in token_generator
)
else:
kwargs.pop("max_kv_size", None)
token_generator = speculative_generate_step(
prompt, model, draft_model, **kwargs
)
with wired_limit(model, [generation_stream]):
detokenizer.reset()
tic = time.perf_counter()
for n, (token, logprobs, from_draft) in enumerate(token_generator):
if n == 0:
prompt_time = time.perf_counter() - tic
prompt_tps = prompt.size / prompt_time
tic = time.perf_counter()
if token in tokenizer.eos_token_ids:
break
detokenizer.add_token(token)
yield GenerationResponse(
text=detokenizer.last_segment,
token=token,
logprobs=logprobs,
from_draft=from_draft,
prompt_tokens=prompt.size,
prompt_tps=prompt_tps,
generation_tokens=n + 1,
generation_tps=(n + 1) / (time.perf_counter() - tic),
peak_memory=mx.get_peak_memory() / 1e9,
finish_reason=None,
)
detokenizer.finalize()
yield GenerationResponse(
text=detokenizer.last_segment,
token=token,
logprobs=logprobs,
from_draft=from_draft,
prompt_tokens=prompt.size,
prompt_tps=prompt_tps,
generation_tokens=n + 1,
generation_tps=(n + 1) / (time.perf_counter() - tic),
peak_memory=mx.get_peak_memory() / 1e9,
finish_reason="stop" if token in tokenizer.eos_token_ids else "length",
)
def generate(
model: nn.Module,
tokenizer: Union[PreTrainedTokenizer, TokenizerWrapper],
prompt: Union[str, List[int]],
verbose: bool = False,
formatter: Optional[Callable] = None,
**kwargs,
) -> str:
"""
Generate a complete response from the model.
Args:
model (nn.Module): The language model.
tokenizer (PreTrainedTokenizer): The tokenizer.
prompt (Union[str, List[int]]): The input prompt string or integer tokens.
verbose (bool): If ``True``, print tokens and timing information.
Default: ``False``.
kwargs: The remaining options get passed to :func:`stream_generate`.
See :func:`stream_generate` for more details.
"""
if formatter is not None:
print(
"[Warning] Text formatting is deprecated and no longer used. "
"The argument will be removed in a future version."
)
if verbose:
print("=" * 10)
text = ""
for response in stream_generate(model, tokenizer, prompt, **kwargs):
if verbose:
print(response.text, end="", flush=True)
text += response.text
if verbose:
print()
print("=" * 10)
if len(text) == 0:
print("No text generated for this prompt")
return
print(
f"Prompt: {response.prompt_tokens} tokens, "
f"{response.prompt_tps:.3f} tokens-per-sec"
)
print(
f"Generation: {response.generation_tokens} tokens, "
f"{response.generation_tps:.3f} tokens-per-sec"
)
print(f"Peak memory: {response.peak_memory:.3f} GB")
return text
def main():
parser = setup_arg_parser()
args = parser.parse_args()
@@ -263,7 +843,16 @@ def main():
raise ValueError("Draft model tokenizer does not match model tokenizer.")
else:
draft_model = None
sampler = make_sampler(args.temp, args.top_p, args.min_p, args.min_tokens_to_keep)
sampler = make_sampler(
args.temp,
args.top_p,
args.min_p,
args.min_tokens_to_keep,
top_k=args.top_k,
xtc_probability=args.xtc_probability,
xtc_threshold=args.xtc_threshold,
xtc_special_tokens=tokenizer.encode("\n") + list(tokenizer.eos_token_ids),
)
response = generate(
model,
tokenizer,
@@ -284,4 +873,8 @@ def main():
if __name__ == "__main__":
print(
"Calling `python -m mlx_lm.generate...` directly is deprecated."
" Use `mlx_lm.generate...` or `python -m mlx_lm generate ...` instead."
)
main()
+32 -18
View File
@@ -1,5 +1,3 @@
# Copyright © 2024 Apple Inc.
import argparse
import math
import os
@@ -7,13 +5,14 @@ import re
import types
from pathlib import Path
import mlx.core as mx
import mlx.nn as nn
import mlx.optimizers as optim
import numpy as np
import yaml
from .tokenizer_utils import TokenizerWrapper
from .tuner.datasets import load_dataset
from .tuner.callbacks import WandBCallback
from .tuner.datasets import CacheDataset, load_dataset
from .tuner.trainer import TrainingArgs, TrainingCallback, evaluate, train
from .tuner.utils import (
build_schedule,
@@ -66,8 +65,9 @@ CONFIG_DEFAULTS = {
"config": None,
"grad_checkpoint": False,
"lr_schedule": None,
"lora_parameters": {"rank": 8, "alpha": 16, "dropout": 0.0, "scale": 10.0},
"lora_parameters": {"rank": 8, "dropout": 0.0, "scale": 20.0},
"mask_prompt": False,
"wandb": None,
}
@@ -179,6 +179,12 @@ def build_parser():
help="Use gradient checkpointing to reduce memory use.",
default=None,
)
parser.add_argument(
"--wandb",
type=str,
default=None,
help="WandB project name to report training metrics. Disabled if None.",
)
parser.add_argument("--seed", type=int, help="The PRNG seed")
return parser
@@ -186,11 +192,11 @@ def build_parser():
def train_model(
args,
model: nn.Module,
tokenizer: TokenizerWrapper,
train_set,
valid_set,
training_callback: TrainingCallback = None,
):
mx.random.seed(args.seed)
model.freeze()
if args.num_layers > len(model.layers):
raise ValueError(
@@ -201,6 +207,8 @@ def train_model(
if args.fine_tune_type == "full":
for l in model.layers[-max(args.num_layers, 0) :]:
l.unfreeze()
args.lora_parameters = None
elif args.fine_tune_type in ["lora", "dora"]:
# Convert linear layers to lora/dora layers and unfreeze in the process
linear_to_lora_layers(
@@ -238,8 +246,6 @@ def train_model(
grad_checkpoint=args.grad_checkpoint,
)
model.train()
# Initialize the selected optimizer
lr = build_schedule(args.lr_schedule) if args.lr_schedule else args.learning_rate
@@ -258,22 +264,18 @@ def train_model(
# Train model
train(
model=model,
tokenizer=tokenizer,
args=training_args,
optimizer=opt,
train_dataset=train_set,
val_dataset=valid_set,
train_dataset=CacheDataset(train_set),
val_dataset=CacheDataset(valid_set),
training_callback=training_callback,
)
def evaluate_model(args, model: nn.Module, tokenizer: TokenizerWrapper, test_set):
model.eval()
def evaluate_model(args, model: nn.Module, test_set):
test_loss = evaluate(
model=model,
dataset=test_set,
tokenizer=tokenizer,
dataset=CacheDataset(test_set),
batch_size=args.batch_size,
num_batches=args.test_batches,
max_seq_length=args.max_seq_length,
@@ -287,6 +289,14 @@ def evaluate_model(args, model: nn.Module, tokenizer: TokenizerWrapper, test_set
def run(args, training_callback: TrainingCallback = None):
np.random.seed(args.seed)
if args.wandb is not None:
training_callback = WandBCallback(
project_name=args.wandb,
log_dir=args.adapter_path,
config=vars(args),
wrapped_callback=training_callback,
)
print("Loading pretrained model")
model, tokenizer = load(args.model)
@@ -300,13 +310,13 @@ def run(args, training_callback: TrainingCallback = None):
elif args.train:
print("Training")
train_model(args, model, tokenizer, train_set, valid_set, training_callback)
train_model(args, model, train_set, valid_set, training_callback)
else:
raise ValueError("Must provide at least one of --train or --test")
if args.test:
print("Testing")
evaluate_model(args, model, tokenizer, test_set)
evaluate_model(args, model, test_set)
def main():
@@ -332,4 +342,8 @@ def main():
if __name__ == "__main__":
print(
"Calling `python -m mlx_lm.lora...` directly is deprecated."
" Use `mlx_lm.lora...` or `python -m mlx_lm lora ...` instead."
)
main()
+4
View File
@@ -136,4 +136,8 @@ def main():
if __name__ == "__main__":
print(
"Calling `python -m mlx_lm.manage...` directly is deprecated."
" Use `mlx_lm.manage...` or `python -m mlx_lm manage ...` instead."
)
main()
-172
View File
@@ -1,172 +0,0 @@
# Copyright © 2023-2024 Apple Inc.
import argparse
import glob
import shutil
from pathlib import Path
from typing import Optional
import mlx.core as mx
import mlx.nn as nn
import numpy as np
import yaml
from mlx.utils import tree_flatten, tree_map
from .utils import (
fetch_from_hub,
get_model_path,
save_config,
save_weights,
upload_to_hub,
)
def configure_parser() -> argparse.ArgumentParser:
"""
Configures and returns the argument parser for the script.
Returns:
argparse.ArgumentParser: Configured argument parser.
"""
parser = argparse.ArgumentParser(description="Merge multiple models.")
parser.add_argument("--config", type=str, help="Path to the YAML config.")
parser.add_argument(
"--mlx-path",
type=str,
default="mlx_merged_model",
help="Path to save the MLX model.",
)
parser.add_argument(
"--upload-repo",
help="The Hugging Face repo to upload the model to.",
type=str,
default=None,
)
return parser
def slerp(t, w1, w2, eps=1e-5):
"""
Spherical linear interpolation
Args:
t (float): Interpolation weight in [0.0, 1.0]
w1 (mx.array): First input
w2 (mx.array): Second input
eps (float): Constant for numerical stability
Returns:
mx.array: Interpolated result
"""
t = float(t)
if t == 0:
return w1
elif t == 1:
return w2
# Normalize
v1 = w1 / mx.linalg.norm(w1)
v2 = w2 / mx.linalg.norm(w2)
# Angle
dot = mx.clip((v1 * v2).sum(), 0.0, 1.0)
theta = mx.arccos(dot)
sin_theta = mx.sin(theta + eps)
s1 = mx.sin(theta * (1 - t)) / sin_theta
s2 = mx.sin(theta * t) / sin_theta
return s1 * w1 + s2 * w2
def merge_models(base_model: nn.Module, model: nn.Module, config: dict):
method = config.get("method", None)
if method != "slerp":
raise ValueError(f"Merge method {method} not supported")
num_layers = len(model.layers)
def unpack_values(vals):
if isinstance(vals, (int, float)):
return np.full(num_layers, vals)
bins = len(vals) - 1
sizes = [num_layers // bins] * bins
sizes[-1] = num_layers - sum(sizes[:-1])
return np.concatenate(
[np.linspace(v1, v2, s) for v1, v2, s in zip(vals[:-1], vals[1:], sizes)]
)
param_list = config["parameters"]["t"]
params = {}
filter_keys = set()
for pl in param_list[:-1]:
params[pl["filter"]] = unpack_values(pl["value"])
filter_keys.add(pl["filter"])
default = unpack_values(param_list[-1]["value"])
for e in range(num_layers):
bl = base_model.layers[e]
l = model.layers[e]
base_weights = bl.parameters()
weights = l.parameters()
for k, w1 in base_weights.items():
w2 = weights[k]
t = params.get(k, default)[e]
base_weights[k] = tree_map(lambda x, y: slerp(t, x, y), w1, w2)
base_model.update(base_weights)
def merge(
config: str,
mlx_path: str = "mlx_model",
upload_repo: Optional[str] = None,
):
with open(config, "r") as fid:
merge_conf = yaml.safe_load(fid)
print("[INFO] Loading")
model_paths = merge_conf.get("models", [])
if len(model_paths) < 2:
raise ValueError(f"Expected at least 2 models, got {len(model_paths)}.")
# Load all models
base_hf_path = model_paths[0]
base_path = get_model_path(base_hf_path)
base_model, base_config, tokenizer = fetch_from_hub(base_path, lazy=True)
models = []
for mp in model_paths[1:]:
model, model_config, _ = fetch_from_hub(get_model_path(mp), lazy=True)
base_type = base_config["model_type"]
model_type = model_config["model_type"]
if base_type != model_type:
raise ValueError(
f"Can only merge models of the same type,"
f" but got {base_type} and {model_type}."
)
models.append(model)
# Merge models into base model
for m in models:
merge_models(base_model, m, merge_conf)
# Save base model
mlx_path = Path(mlx_path)
weights = dict(tree_flatten(base_model.parameters()))
del models, base_model
save_weights(mlx_path, weights, donate_weights=True)
py_files = glob.glob(str(base_path / "*.py"))
for file in py_files:
shutil.copy(file, mlx_path)
tokenizer.save_pretrained(mlx_path)
save_config(config, config_path=mlx_path / "config.json")
if upload_repo is not None:
upload_to_hub(mlx_path, upload_repo, base_hf_path)
def main():
parser = configure_parser()
args = parser.parse_args()
merge(**vars(args))
if __name__ == "__main__":
main()
+397
View File
@@ -0,0 +1,397 @@
# Copyright © 2025 Apple Inc.
import math
from dataclasses import dataclass
from functools import partial
from itertools import accumulate
from typing import Any, Dict, 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 ConcatenateKVCache, KVCache
from .rope_utils import initialize_rope
@dataclass
class ModelArgs(BaseModelArgs):
model_type: str
vocab_size: int
hidden_dim: int
num_layers: int
num_kv_reuse_layers: int
num_heads: int
num_kv_heads: int
hidden_dim_scale_factor: float = 3.25
rope_theta: float = 50000
rms_norm_eps: float = 1e-5
class FusedLoRALinear(nn.Module):
def __init__(
self,
input_dims: int,
output_dims: list[int],
r: int = 8,
dropout: float = 0.0,
scale: float = 20.0,
):
super().__init__()
self.linear = FusedLinear(input_dims, output_dims)
self.dropout = nn.Dropout(p=dropout)
self.scale = scale
scale = 1 / math.sqrt(input_dims)
self.lora_a = [
mx.random.uniform(low=-scale, high=scale, shape=(input_dims, r))
for _ in output_dims
]
self.lora_b = [mx.zeros((r, od)) for od in output_dims]
def fuse(self, de_quantize: bool = False):
linear = self.linear
weight = linear.weight
is_quantized = isinstance(linear, FusedQuantizedLinear)
# Use the same type as the linear weight if not quantized
dtype = weight.dtype
if is_quantized:
dtype = linear.scales.dtype
weight = mx.dequantize(
weight,
linear.scales,
linear.biases,
linear.group_size,
linear.bits,
)
input_dims = weight.shape[-1]
output_dims = linear.output_dims
fused_linear = FusedLinear(input_dims, output_dims)
fused_linear.weight = weight
deltas = [
((self.scale * b.T) @ a.T).astype(dtype)
for a, b in zip(self.lora_a, self.lora_b)
]
delta = mx.concatenate(deltas, axis=0)
fused_linear.weight = weight + delta
if is_quantized and not de_quantize:
fused_linear = fused_linear.to_quantized(linear.group_size, linear.bits)
return fused_linear
def __call__(self, x):
dt = x.dtype
y = self.linear(x)
x = self.dropout(x)
z = [(x @ a) @ b for a, b in zip(self.lora_a, self.lora_b)]
return tuple(yi + (self.scale * zi).astype(dt) for yi, zi in zip(y, z))
class FusedQuantizedLinear(nn.QuantizedLinear):
def __init__(self, input_dims, output_dims, group_size: int = 64, bits: int = 4):
*indices, output_dims = accumulate(output_dims)
self.indices = indices
super().__init__(
input_dims, output_dims, bias=False, group_size=group_size, bits=bits
)
@property
def input_dims(self):
return self.scales.shape[-1] * self.group_size
@property
def output_dims(self):
indices = [0] + self.indices + [self.weight.shape[0]]
return [indices[i] - indices[i - 1] for i in range(1, len(indices))]
def __call__(self, x):
x = super().__call__(x)
return x.split(self.indices, axis=-1)
def to_lora(self, r: int = 8, dropout: float = 0.0, scale: float = 20.0):
lora_lin = FusedLoRALinear(self.input_dims, self.output_dims, r, dropout, scale)
lora_lin.linear = self
return lora_lin
class FusedLinear(nn.Linear):
def __init__(self, input_dims, output_dims):
*indices, output_dims = accumulate(output_dims)
self.indices = indices
super().__init__(input_dims, output_dims, bias=False)
@property
def input_dims(self):
return self.weight.shape[-1]
@property
def output_dims(self):
indices = [0] + self.indices + [self.weight.shape[0]]
return [indices[i] - indices[i - 1] for i in range(1, len(indices))]
def __call__(self, x):
x = super().__call__(x)
return x.split(self.indices, axis=-1)
def to_quantized(self, group_size: int = 64, bits: int = 4):
input_dims = self.input_dims
output_dims = self.output_dims
ql = FusedQuantizedLinear(input_dims, output_dims, group_size, bits)
ql.weight, ql.scales, ql.biases = mx.quantize(self.weight, group_size, bits)
return ql
def to_lora(self, r: int = 8, dropout: float = 0.0, scale: float = 20.0):
lora_lin = FusedLoRALinear(self.input_dims, self.output_dims, r, dropout, scale)
lora_lin.linear = self
return lora_lin
@partial(mx.compile, shapeless=True)
def fake_8bit_quant(x, scale):
dt = x.dtype
x = x.astype(mx.float32)
x = (x / scale).round()
x = mx.clip(x, -128, 127)
return (x * scale).astype(dt)
class Attention(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
dim = args.hidden_dim
self.n_heads = n_heads = args.num_heads
self.n_kv_heads = n_kv_heads = args.num_kv_heads
self.head_dim = head_dim = args.hidden_dim // n_heads
self.scale = head_dim**-0.5
qkv_dim = (n_heads + 2 * n_kv_heads) * head_dim
self.qkv_proj = FusedLinear(
dim, [n_heads * head_dim] + 2 * [n_kv_heads * head_dim]
)
self.out_proj = nn.Linear(dim, dim, bias=False)
self.rope = initialize_rope(
self.head_dim,
args.rope_theta,
True,
)
self.q_norm = nn.RMSNorm(head_dim)
self.k_norm = nn.RMSNorm(head_dim)
self.quant_key_scale = mx.array(1.0)
self.quant_value_scale = mx.array(1.0)
def __call__(
self,
x: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[Any] = None,
) -> mx.array:
B, L, D = x.shape
# Get the queries, keys and values
queries, keys, values = self.qkv_proj(x)
# Prepare the queries, keys and values for the attention computation
queries = queries.reshape(B, L, self.n_heads, -1).transpose(0, 2, 1, 3)
keys = keys.reshape(B, L, self.n_kv_heads, -1).transpose(0, 2, 1, 3)
values = values.reshape(B, L, self.n_kv_heads, -1).transpose(0, 2, 1, 3)
if cache is not None:
queries = self.q_norm(self.rope(queries, offset=cache.offset))
keys = self.k_norm(self.rope(keys, offset=cache.offset))
keys = fake_8bit_quant(keys, self.quant_key_scale)
values = fake_8bit_quant(values, self.quant_value_scale)
keys, values = cache.update_and_fetch(keys, values)
else:
queries = self.q_norm(self.rope(queries))
keys = self.k_norm(self.rope(keys))
keys = fake_8bit_quant(keys, self.quant_key_scale)
values = fake_8bit_quant(values, self.quant_value_scale)
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.out_proj(output)
class KVReuseAttention(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
dim = args.hidden_dim
self.n_heads = n_heads = args.num_heads
self.head_dim = head_dim = args.hidden_dim // n_heads
self.scale = head_dim**-0.5
self.q_proj = nn.Linear(dim, dim, bias=False)
self.out_proj = nn.Linear(dim, dim, bias=False)
self.rope = initialize_rope(
self.head_dim,
args.rope_theta,
True,
)
self.q_norm = nn.RMSNorm(head_dim)
def __call__(
self,
x: mx.array,
keys: mx.array,
values: mx.array,
mask: Optional[mx.array] = None,
) -> mx.array:
B, L, D = x.shape
_, _, S, _ = keys.shape
queries = self.q_proj(x)
queries = queries.reshape(B, L, self.n_heads, -1).transpose(0, 2, 1, 3)
queries = self.q_norm(self.rope(queries, offset=S - L))
output = scaled_dot_product_attention(
queries, keys, values, cache=None, scale=self.scale, mask=mask
)
output = output.transpose(0, 2, 1, 3).reshape(B, L, -1)
return self.out_proj(output)
@partial(mx.compile, shapeless=True)
def _swiglu(g, x):
return nn.silu(g) * x
class MLP(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
dim = args.hidden_dim
hidden_dim = int(dim * args.hidden_dim_scale_factor)
self.gate_proj = nn.Linear(dim, hidden_dim, bias=False)
self.down_proj = nn.Linear(hidden_dim, dim, bias=False)
self.up_proj = nn.Linear(dim, hidden_dim, bias=False)
def __call__(self, x) -> mx.array:
g = self.gate_proj(x)
x = self.up_proj(x)
return self.down_proj(_swiglu(g, x))
class TransformerBlock(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
self.self_attn = Attention(args)
self.mlp = MLP(args)
self.input_layernorm = nn.RMSNorm(args.hidden_dim, eps=args.rms_norm_eps)
self.post_attention_layernorm = nn.RMSNorm(
args.hidden_dim, eps=args.rms_norm_eps
)
def __call__(
self,
x: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[Any] = None,
) -> mx.array:
r = self.self_attn(self.input_layernorm(x), mask, cache)
h = x + r
r = self.mlp(self.post_attention_layernorm(h))
out = h + r
return out
class KVReuseTransformerBlock(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
self.self_attn = KVReuseAttention(args)
self.mlp = MLP(args)
self.input_layernorm = nn.RMSNorm(args.hidden_dim, eps=args.rms_norm_eps)
self.post_attention_layernorm = nn.RMSNorm(
args.hidden_dim, eps=args.rms_norm_eps
)
def __call__(
self,
x: mx.array,
keys: mx.array,
values: mx.array,
mask: Optional[mx.array] = None,
) -> mx.array:
r = self.self_attn(self.input_layernorm(x), keys, values, mask)
h = x + r
r = self.mlp(self.post_attention_layernorm(h))
out = h + r
return out
class AFMModel(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
self.args = args
self.vocab_size = args.vocab_size
self.embedding = nn.Embedding(args.vocab_size, args.hidden_dim)
self.layers = [
TransformerBlock(args)
for _ in range(args.num_layers - args.num_kv_reuse_layers)
]
self.kv_reuse_layers = [
KVReuseTransformerBlock(args) for _ in range(args.num_kv_reuse_layers)
]
self.output_norm = nn.RMSNorm(args.hidden_dim, eps=args.rms_norm_eps)
def __call__(
self,
inputs: mx.array,
mask: mx.array = None,
cache=None,
):
h = self.embedding(inputs)
if mask is None:
mask = create_attention_mask(h, cache)
if cache is None:
cache = [None] * len(self.layers)
cache[-1] = ConcatenateKVCache()
for layer, c in zip(self.layers, cache):
h = layer(h, mask, cache=c)
keys, values = cache[-1].state
for layer in self.kv_reuse_layers:
h = layer(h, keys, values, mask)
return self.output_norm(h)
class Model(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
self.args = args
self.model_type = args.model_type
self.model = AFMModel(args)
def __call__(
self,
inputs: mx.array,
mask: mx.array = None,
cache=None,
):
out = self.model(inputs, mask, cache)
out = self.model.embedding.as_linear(out)
return out
def make_cache(self):
return [KVCache() for _ in range(len(self.model.layers))]
@property
def layers(self):
return self.model.layers + self.model.kv_reuse_layers
+226
View File
@@ -0,0 +1,226 @@
# 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, scaled_dot_product_attention
from .cache import CacheList, KVCache, MambaCache, RotatingKVCache
@dataclass
class ModelArgs(BaseModelArgs):
vocab_size: int
hidden_size: int
intermediate_size: int
num_hidden_layers: int
num_attention_heads: int
num_key_value_heads: int
rope_theta: float
sliding_window: int
sliding_window_layers: List[int]
conv_window: int
rms_norm_eps: float
model_type: str = "baichuan_m1"
num_swa_attention_heads: Optional[int] = None
num_swa_key_value_heads: Optional[int] = None
tie_word_embeddings: bool = False
class Attention(nn.Module):
def __init__(self, config: ModelArgs, layer_idx: Optional[int] = None):
super().__init__()
self.config = config
self.layer_idx = layer_idx
if layer_idx is None:
raise ValueError("Layer index must be provided to Attention module.")
self.is_swa = layer_idx in config.sliding_window_layers
self.num_heads = (
config.num_swa_attention_heads
if self.is_swa and config.num_swa_attention_heads
else config.num_attention_heads
)
self.num_kv_heads = (
config.num_swa_key_value_heads
if self.is_swa and config.num_swa_key_value_heads
else config.num_key_value_heads
)
self.hidden_size = config.hidden_size
self.head_dim = self.hidden_size // self.num_heads
assert self.head_dim * self.num_heads == self.hidden_size
self.scale = self.head_dim**-0.5
self.W_pack = nn.Linear(
config.hidden_size,
self.hidden_size + 2 * self.num_kv_heads * self.head_dim,
bias=False,
)
self.o_proj = nn.Linear(
self.num_heads * self.head_dim, config.hidden_size, bias=False
)
self.rope = nn.RoPE(self.head_dim, traditional=False, base=config.rope_theta)
self.conv_window = config.conv_window
assert self.conv_window == 2
self.conv_k = mx.zeros((1, 1, self.num_kv_heads, 1, self.conv_window))
self.conv_v = mx.zeros((1, 1, self.num_kv_heads, 1, self.conv_window))
def _custom_convolution(self, u, weights, state=None):
B, H, L, D = u.shape
weights = weights.reshape((1, H, self.conv_window, 1, 1))
w0 = weights[:, :, 0]
w1 = weights[:, :, 1]
if state is None:
state = mx.zeros((B, H, 1, D), u.dtype)
if L > 1:
u_prev = mx.concatenate([state, u[:, :, :-1]], axis=2)
else:
u_prev = state
return u_prev * w0 + u * w1
def __call__(
self, x: mx.array, mask: mx.array = None, cache: Any = None
) -> mx.array:
B, L, D = x.shape
proj = self.W_pack(x)
q, k, v = mx.split(proj, (D, D + self.num_kv_heads * self.head_dim), axis=-1)
q = q.reshape(B, L, self.num_heads, self.head_dim).transpose(0, 2, 1, 3)
k = k.reshape(B, L, self.num_kv_heads, self.head_dim).transpose(0, 2, 1, 3)
v = v.reshape(B, L, self.num_kv_heads, self.head_dim).transpose(0, 2, 1, 3)
if cache is not None:
offset = cache[1].offset
last_k, last_v = cache[0][0], cache[0][1]
else:
offset = 0
last_k, last_v = None, None
k_init = k
v_init = v
k = self._custom_convolution(k, self.conv_k, state=last_k)
v = self._custom_convolution(v, self.conv_v, state=last_v)
q = self.rope(q, offset=offset)
k = self.rope(k, offset=offset)
if cache is not None:
k, v = cache[1].update_and_fetch(k, v)
if L > 0:
cache[0][0] = k_init[:, :, -1:, :]
cache[0][1] = v_init[:, :, -1:, :]
out = scaled_dot_product_attention(
q, k, v, cache=cache[1], scale=self.scale, mask=mask
)
out = out.transpose(0, 2, 1, 3).reshape(B, L, -1)
return self.o_proj(out)
class MLP(nn.Module):
def __init__(self, config: ModelArgs):
super().__init__()
self.gate_proj = nn.Linear(
config.hidden_size, config.intermediate_size, bias=False
)
self.up_proj = nn.Linear(
config.hidden_size, config.intermediate_size, bias=False
)
self.down_proj = nn.Linear(
config.intermediate_size, config.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 DecoderLayer(nn.Module):
def __init__(self, config: ModelArgs, layer_idx: int):
super().__init__()
self.self_attn = Attention(config, layer_idx)
self.mlp = MLP(config)
self.input_layernorm = nn.RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
self.post_attention_layernorm = nn.RMSNorm(
config.hidden_size, eps=config.rms_norm_eps
)
def __call__(
self, x: mx.array, mask: mx.array = None, cache: Any = None
) -> mx.array:
r = self.self_attn(self.input_layernorm(x), mask, cache)
x = x + r
r = self.mlp(self.post_attention_layernorm(x))
return x + r
class BaichuanModel(nn.Module):
def __init__(self, config: ModelArgs):
super().__init__()
self.args = config
self.embed_tokens = nn.Embedding(config.vocab_size, config.hidden_size)
self.layers = [DecoderLayer(config, i) for i in range(config.num_hidden_layers)]
self.norm = nn.RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
def __call__(
self, inputs: mx.array, mask: mx.array = None, cache: Any = None
) -> mx.array:
x = self.embed_tokens(inputs)
if mask is None:
if cache is not None:
c = [cache[0][1]]
mask = create_attention_mask(x, c)
if cache is None:
cache = [None] * len(self.layers)
for layer, c in zip(self.layers, cache):
x = layer(x, mask, c)
return self.norm(x)
class Model(nn.Module):
def __init__(self, config: ModelArgs):
super().__init__()
self.config = config
self.model_type = config.model_type
self.model = BaichuanModel(config)
self.tie_word_embeddings = config.tie_word_embeddings
if not config.tie_word_embeddings:
self.lm_head = nn.Linear(config.hidden_size, config.vocab_size, bias=False)
def make_cache(self) -> List[Any]:
caches = []
for i, layer in enumerate(self.model.layers):
is_swa = i in self.config.sliding_window_layers
conv_cache = MambaCache()
if is_swa:
kv_cache = RotatingKVCache(max_size=self.config.sliding_window)
else:
kv_cache = KVCache()
caches.append(CacheList(conv_cache, kv_cache))
return caches
def sanitize(self, weights: dict) -> dict:
is_quantized = "lm_head.scales" in weights
if not is_quantized and "lm_head.weight" in weights:
w = weights["lm_head.weight"]
dtype = w.dtype
w = w.astype(mx.float32)
norm = mx.linalg.norm(w, axis=-1, keepdims=True)
w = (w / (norm + 1e-7)).astype(dtype)
weights["lm_head.weight"] = w
return weights
def __call__(
self, inputs: mx.array, mask: mx.array = None, cache: Any = None
) -> mx.array:
outputs = self.model(inputs, mask, cache)
return self.lm_head(outputs)
@property
def layers(self) -> List[nn.Module]:
return self.model.layers
+20 -7
View File
@@ -42,19 +42,24 @@ def create_causal_mask(
return mask
def create_attention_mask(h: mx.array, cache: Optional[Any] = None):
def create_attention_mask(
h: mx.array, cache: Optional[Any] = None, return_array: bool = False
):
T = h.shape[1]
if T > 1:
window_size = None
offset = 0
window_size = None
if cache is not None and cache[0] is not None:
c = cache[0]
offset = c.offset
if hasattr(c, "max_size"):
offset = min(c.max_size, c.offset)
window_size = c.max_size
else:
offset = c.offset
mask = create_causal_mask(T, offset, window_size=window_size)
offset = min(window_size, offset)
return_array = return_array or offset + T > window_size
if return_array:
return create_causal_mask(T, offset, window_size=window_size)
else:
return "causal"
else:
mask = None
return mask
@@ -84,7 +89,15 @@ def quantized_scaled_dot_product_attention(
queries, *q_keys, transpose=True, group_size=group_size, bits=bits
)
if mask is not None:
scores += mask
if isinstance(mask, str):
qL, kL = scores.shape[-2:]
q_indices = mx.arange(kL - qL, kL)
k_indices = mx.arange(kL)
mask = q_indices[:, None] >= k_indices[None]
if mask.dtype == mx.bool_:
scores = mx.where(mask, scores, mx.finfo(scores.dtype).min)
else:
scores += mask
scores = mx.softmax(scores, axis=-1, precise=True)
out = mx.quantized_matmul(
scores, *q_values, transpose=False, group_size=group_size, bits=bits
+131
View File
@@ -0,0 +1,131 @@
# Copyright © 2025 Apple Inc.
import mlx.core as mx
import mlx.nn as nn
from mlx.nn.layers.quantized import QuantizedLinear
def make_bitlinear_kernel():
"""
Custom Metal kernel that performs matrix multiplication directly on
packed weights and scales the output. This eliminates the need to
store unpacked weights in memory.
"""
source = """
constexpr int M = 4;
constexpr int BLOCK = 32;
uint tid = thread_position_in_grid.y;
uint in_offset = thread_position_in_grid.x;
uint batch_idx = tid / (out_features / 4);
uint row_idx = tid % (out_features / 4);
float sum[4] = {0.0};
for (uint i = in_offset * M; i < in_features; i += BLOCK * M) {
float v[M];
for (int j=0; j<M; j++) {
v[j] = x[batch_idx * in_features + i + j];
}
for (int j=0; j<M; j++) {
uint8_t w = packed_weights[row_idx * in_features + i + j];
sum[0] += v[j] * ((w & 3) - 1);
sum[1] += v[j] * (((w >> 2) & 3) - 1);
sum[2] += v[j] * (((w >> 4) & 3) - 1);
sum[3] += v[j] * (((w >> 6) & 3) - 1);
}
}
for (int j=0; j<4; j++) {
sum[j] = simd_sum(sum[j]);
}
// Apply weight scaling by diving them or multiplying them
if (in_offset == 0) {
float scale = invert_weight_scales ? 1 / weight_scale[0] : weight_scale[0];
for (int i=0; i<4; i++) {
out[batch_idx * out_features + row_idx + i * (out_features/4)] = static_cast<T>(sum[i] * scale);
}
}
"""
return mx.fast.metal_kernel(
name="bitlinear_matmul",
input_names=["x", "packed_weights", "weight_scale"],
output_names=["out"],
source=source,
)
_bitlinear_kernel = make_bitlinear_kernel()
class BitLinear(nn.Module):
"""
BitLinear module with memory-efficient weight handling.
"""
def __init__(
self,
in_features,
out_features,
bias=True,
invert_weight_scales=False,
):
super().__init__()
self.in_features = in_features
self.out_features = out_features
# Calculate packed dimensions - the first dimension gets packed 4:1
# The weights are ternary so can be represented with 2 bits, and they
# are packed in uint8 tensors, hence the number of values per item is 4
packed_out_features = (out_features + 3) // 4
self.weight = mx.zeros((packed_out_features, in_features), dtype=mx.uint8)
self.invert_weight_scales = invert_weight_scales
self.weight_scale = mx.array([1.0])
if bias:
self.bias = mx.zeros((out_features,))
else:
self.bias = None
def execute_matmul_kernel(self, x, packed_weights):
original_shape = x.shape
if len(original_shape) > 2:
x = x.reshape(-1, original_shape[-1])
total_batch_elements, in_features = x.shape
out_features = self.out_features
dtype = self.weight_scale.dtype
assert x.dtype == dtype, "Wrong type for input."
out = _bitlinear_kernel(
inputs=[
x,
packed_weights,
self.weight_scale,
],
template=[
("T", dtype),
("invert_weight_scales", self.invert_weight_scales),
("in_features", in_features),
("out_features", out_features),
],
grid=(32, total_batch_elements * out_features // 4, 1),
threadgroup=(32, 1, 1), # SIMD width is 32 threads
output_shapes=[(total_batch_elements, out_features)],
output_dtypes=[dtype],
)[0]
if len(original_shape) > 2:
out = out.reshape(*original_shape[:-1], out_features)
return out
def __call__(self, x):
y = self.execute_matmul_kernel(x, self.weight)
if self.bias is not None:
y = mx.add(y, self.bias)
return y
+215
View File
@@ -0,0 +1,215 @@
# Copyright © 2023-2024 Apple Inc.
from dataclasses import dataclass
from functools import partial
from typing import Any, Dict, Optional, Union
import mlx.core as mx
import mlx.nn as nn
from .base import BaseModelArgs, create_attention_mask, scaled_dot_product_attention
from .bitlinear_layers import BitLinear
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
num_key_value_heads: int
rms_norm_eps: float
vocab_size: int
head_dim: Optional[int] = None
max_position_embeddings: Optional[int] = None
attention_bias: bool = False
mlp_bias: bool = False
rope_theta: float = 10000
rope_traditional: bool = False
rope_scaling: Optional[Dict[str, Union[float, str]]] = None
tie_word_embeddings: bool = True
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.head_dim or args.hidden_size // n_heads
self.scale = head_dim**-0.5
attention_bias = args.attention_bias
self.q_proj = BitLinear(dim, n_heads * head_dim, bias=attention_bias)
self.k_proj = BitLinear(dim, n_kv_heads * head_dim, bias=attention_bias)
self.v_proj = BitLinear(dim, n_kv_heads * head_dim, bias=attention_bias)
self.o_proj = BitLinear(n_heads * head_dim, dim, bias=attention_bias)
self.rope = initialize_rope(
self.head_dim,
args.rope_theta,
args.rope_traditional,
args.rope_scaling,
args.max_position_embeddings,
)
self.attn_sub_norm = 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:
B, L, D = x.shape
queries, keys, values = self.q_proj(x), self.k_proj(x), self.v_proj(x)
# Prepare the queries, keys and values for the attention computation
queries = queries.reshape(B, L, self.n_heads, -1).transpose(0, 2, 1, 3)
keys = keys.reshape(B, L, self.n_kv_heads, -1).transpose(0, 2, 1, 3)
values = values.reshape(B, L, self.n_kv_heads, -1).transpose(0, 2, 1, 3)
if cache is not None:
queries = self.rope(queries, offset=cache.offset)
keys = self.rope(keys, offset=cache.offset)
keys, values = cache.update_and_fetch(keys, values)
else:
queries = self.rope(queries)
keys = self.rope(keys)
output = scaled_dot_product_attention(
queries, keys, values, cache=cache, scale=self.scale, mask=mask
)
output = output.transpose(0, 2, 1, 3).reshape(B, L, -1)
output = self.attn_sub_norm(output)
output = self.o_proj(output)
return output
@partial(mx.compile, shapeless=True)
def relu2(x):
return mx.square(nn.relu(x))
class MLP(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
dim = args.hidden_size
hidden_dim = args.intermediate_size
if hasattr(args, "mlp_bias"):
mlp_bias = args.mlp_bias
else:
mlp_bias = False
self.gate_proj = BitLinear(dim, hidden_dim, bias=mlp_bias)
self.down_proj = BitLinear(hidden_dim, dim, bias=mlp_bias)
self.up_proj = BitLinear(dim, hidden_dim, bias=mlp_bias)
self.ffn_sub_norm = nn.RMSNorm(args.intermediate_size, eps=args.rms_norm_eps)
def __call__(self, x) -> mx.array:
x = relu2(self.gate_proj(x)) * self.up_proj(x)
x = self.ffn_sub_norm(x)
x = self.down_proj(x)
return x
class TransformerBlock(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
self.num_attention_heads = args.num_attention_heads
self.hidden_size = args.hidden_size
self.self_attn = Attention(args)
self.mlp = MLP(args)
self.input_layernorm = nn.RMSNorm(args.hidden_size, eps=args.rms_norm_eps)
self.post_attention_layernorm = nn.RMSNorm(
args.hidden_size, eps=args.rms_norm_eps
)
def __call__(
self,
x: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[Any] = None,
) -> mx.array:
r = self.self_attn(self.input_layernorm(x), mask, cache)
h = x + r
r = self.mlp(self.post_attention_layernorm(h))
out = h + r
return out
class LlamaModel(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 = [
TransformerBlock(args=args) for _ in range(args.num_hidden_layers)
]
self.norm = nn.RMSNorm(args.hidden_size, eps=args.rms_norm_eps)
def __call__(
self,
inputs: mx.array,
mask: mx.array = None,
cache=None,
):
h = self.embed_tokens(inputs)
if mask is None:
mask = create_attention_mask(h, cache)
if cache is None:
cache = [None] * len(self.layers)
for layer, c in zip(self.layers, cache):
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 = LlamaModel(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,
mask: mx.array = None,
cache=None,
):
out = self.model(inputs, mask, cache)
if self.args.tie_word_embeddings:
out = self.model.embed_tokens.as_linear(out)
else:
out = self.lm_head(out)
return out
def sanitize(self, weights):
# Remove unused precomputed rotary freqs
weights = {
k: v for k, v in weights.items() if "self_attn.rotary_emb.inv_freq" not in k
}
if self.args.tie_word_embeddings:
weights.pop("lm_head.weight", None)
return weights
@property
def layers(self):
return self.model.layers
+108 -1
View File
@@ -12,7 +12,7 @@ def make_prompt_cache(
max_kv_size: Optional[int] = None,
) -> List[Any]:
"""
Construct the model's cache for use when cgeneration.
Construct the model's cache for use in generation.
This function will defer the cache construction to the model if it has a
``make_cache`` method, otherwise it will make a default KV cache.
@@ -129,6 +129,40 @@ class _BaseCache:
return False
class ConcatenateKVCache(_BaseCache):
"""ConcatenateKVCache the simplest KV cache implementation.
Can be used as a mock KV cache or when large blocks are being processed at
a time in which case KVCache isn't necessarily faster. Consider using the
KVCache with a larger step size before using this cache.
"""
def __init__(self):
self.keys = None
self.values = None
self.offset = 0
def update_and_fetch(self, keys, values):
if self.keys is None:
self.keys = keys
self.values = values
else:
self.keys = mx.concatenate([self.keys, keys], axis=-2)
self.values = mx.concatenate([self.values, values], axis=-2)
self.offset = self.keys.shape[-2]
return self.keys, self.values
@property
def state(self):
return self.keys, self.values
@state.setter
def state(self, v):
self.keys, self.values = v
self.offset = self.keys.shape[-2]
class QuantizedKVCache(_BaseCache):
def __init__(self, group_size: int = 64, bits: int = 8):
self.keys = None
@@ -436,3 +470,76 @@ class MambaCache(_BaseCache):
@state.setter
def state(self, v):
self.cache = v
class ChunkedKVCache(KVCache):
def __init__(self, chunk_size=None):
super().__init__()
self.chunk_size = chunk_size
self.start_position = 0
def maybe_trim_front(self):
# Maintain the cache below the chunk size
if self.keys is not None and self.keys.shape[2] >= self.chunk_size:
self.start_position += self.keys.shape[2] - self.chunk_size
self.keys = self.keys[..., -self.chunk_size :, :]
self.values = self.values[..., -self.chunk_size :, :]
def update_and_fetch(self, keys, values):
prev = self.offset - self.start_position
if self.keys is None or (prev + keys.shape[2]) > self.keys.shape[2]:
B, n_kv_heads, _, k_head_dim = keys.shape
v_head_dim = values.shape[3]
n_steps = (self.step + keys.shape[2] - 1) // self.step
k_shape = (B, n_kv_heads, n_steps * self.step, k_head_dim)
v_shape = (B, n_kv_heads, n_steps * self.step, 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:
if prev % self.step != 0:
self.keys = self.keys[..., :prev, :]
self.values = self.values[..., :prev, :]
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.offset += keys.shape[2]
end = self.offset - self.start_position
self.keys[..., prev:end, :] = keys
self.values[..., prev:end, :] = values
return self.keys[..., :end, :], self.values[..., :end, :]
def trim(self, n):
n = min(self.offset - self.start_position, n)
self.offset -= n
return n
@property
def meta_state(self):
return tuple(map(str, (self.chunk_size, self.start_position)))
@meta_state.setter
def meta_state(self, v):
self.chunk_size, self.start_position = map(int, v)
class CacheList(KVCache):
def __init__(self, *caches):
self.caches = caches
def __getitem__(self, idx):
return self.caches[idx]
@property
def state(self):
return [s for c in self.caches for s in c.state]
@state.setter
def state(self, v):
state_lens = [len(c.state) for c in self.caches]
start = 0
for c in self.caches:
l = len(c.state)
c.state = v[start : start + l]
start += l
+1 -1
View File
@@ -1,7 +1,7 @@
# Copyright © 2023-2024 Apple Inc.
from dataclasses import dataclass
from typing import Any, Optional, Tuple
from typing import Any, Optional
import mlx.core as mx
import mlx.nn as nn
+28 -7
View File
@@ -83,15 +83,22 @@ class Attention(nn.Module):
if cache is not None:
keys, values = cache.update_and_fetch(keys, values)
if self.use_sliding_window and mask is not None:
if self.use_sliding_window and isinstance(mask, mx.array):
key_len = keys.shape[-2]
if mask.shape[-1] != key_len:
mask = mask[..., -key_len:]
# TODO: maybe remove cast once fused mask is supported since attention
# may be in higher precision
sdpa_type = mx.float32 if queries.dtype == mx.float16 else queries.dtype
output = scaled_dot_product_attention(
queries, keys, values, cache=cache, scale=self.scale, mask=mask
)
queries.astype(sdpa_type),
keys,
values,
cache=cache,
scale=self.scale,
mask=mask,
).astype(queries.dtype)
output = output.transpose(0, 2, 1, 3).reshape(B, L, -1)
return self.o_proj(output)
@@ -126,9 +133,11 @@ class TransformerBlock(nn.Module):
mask: Optional[mx.array] = None,
cache: Optional[Tuple[mx.array, mx.array]] = None,
) -> mx.array:
h = self.input_layernorm(x)
attn_h = self.self_attn(h, mask, cache)
ff_h = self.mlp(h)
return attn_h + ff_h + x
@@ -161,10 +170,22 @@ class CohereModel(nn.Module):
if mask is None:
j = self.args.sliding_window_pattern
mask = create_attention_mask(h, cache[j - 1 : j])
full_mask = create_attention_mask(h, cache[j - 1 : j])
sliding_window_mask = create_attention_mask(h, cache)
for layer, c in zip(self.layers, cache):
h = layer(h, mask, c)
for i, (layer, c) in enumerate(zip(self.layers, cache)):
is_global = (
i % self.args.sliding_window_pattern
== self.args.sliding_window_pattern - 1
)
local_mask = mask
if mask is None and is_global:
local_mask = full_mask
elif mask is None:
local_mask = sliding_window_mask
h = layer(h, local_mask, c)
return self.norm(h)
+2 -3
View File
@@ -1,7 +1,7 @@
# Copyright © 2023-2024 Apple Inc.
from dataclasses import dataclass
from typing import Any, Optional, Tuple
from typing import Any, Optional
import mlx.core as mx
import mlx.nn as nn
@@ -105,10 +105,9 @@ class MLP(nn.Module):
self.v1 = nn.Linear(d_model, ffn_dim, bias=False)
self.w1 = nn.Linear(d_model, ffn_dim, bias=False)
self.w2 = nn.Linear(ffn_dim, d_model, bias=False)
self.act_fn = nn.silu
def __call__(self, x: mx.array) -> mx.array:
current_hidden_states = self.act_fn(self.w1(x)) * self.v1(x)
current_hidden_states = nn.silu(self.w1(x)) * self.v1(x)
current_hidden_states = self.w2(current_hidden_states)
return current_hidden_states
+1 -2
View File
@@ -118,10 +118,9 @@ class DeepseekMLP(nn.Module):
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)
self.act_fn = nn.silu
def __call__(self, x: mx.array) -> mx.array:
return self.down_proj(self.act_fn(self.gate_proj(x)) * self.up_proj(x))
return self.down_proj(nn.silu(self.gate_proj(x)) * self.up_proj(x))
class MoEGate(nn.Module):
+6 -10
View File
@@ -2,7 +2,7 @@
import math
from dataclasses import dataclass
from typing import Any, Dict, Optional, Tuple
from typing import Any, Dict, Optional
import mlx.core as mx
import mlx.nn as nn
@@ -148,7 +148,7 @@ class DeepseekV2Attention(nn.Module):
self.q_a_proj = nn.Linear(
self.hidden_size, self.q_lora_rank, bias=config.attention_bias
)
self.q_a_layernorm = nn.RMSNorm(self.q_lora_rank)
self.q_a_layernorm = nn.RMSNorm(self.q_lora_rank, eps=1e-6)
self.q_b_proj = nn.Linear(
self.q_lora_rank, self.num_heads * self.q_head_dim, bias=False
)
@@ -158,7 +158,7 @@ class DeepseekV2Attention(nn.Module):
self.kv_lora_rank + self.qk_rope_head_dim,
bias=config.attention_bias,
)
self.kv_a_layernorm = nn.RMSNorm(self.kv_lora_rank)
self.kv_a_layernorm = nn.RMSNorm(self.kv_lora_rank, eps=1e-6)
self.kv_b_proj = nn.Linear(
self.kv_lora_rank,
self.num_heads
@@ -400,8 +400,6 @@ class DeepseekV2Model(nn.Module):
pipeline_rank = self.pipeline_rank
pipeline_size = self.pipeline_size
# Hack to avoid time-outs during prompt-processing
dist_stream = mx.cpu if h.shape[1] > 1 else mx.gpu
if mask is None:
mask = create_attention_mask(h, cache)
@@ -410,19 +408,17 @@ class DeepseekV2Model(nn.Module):
# Receive from the previous process in the pipeline
if pipeline_rank < pipeline_size - 1:
h = mx.distributed.recv_like(h, (pipeline_rank + 1), stream=dist_stream)
h = mx.distributed.recv_like(h, (pipeline_rank + 1))
for i in range(self.num_layers):
h = self.layers[self.start_idx + i](h, mask, cache[i])
# Send to the next process in the pipeline
if pipeline_rank != 0:
h = mx.distributed.send(
h, (pipeline_rank - 1) % pipeline_size, stream=dist_stream
)
h = mx.distributed.send(h, (pipeline_rank - 1) % pipeline_size)
# Broadcast h while keeping it in the graph
h = mx.distributed.all_gather(h, stream=dist_stream)[: h.shape[0]]
h = mx.distributed.all_gather(h)[: h.shape[0]]
return self.norm(h)
+55 -16
View File
@@ -3,7 +3,7 @@
import math
from dataclasses import dataclass
from functools import partial
from typing import Any, Dict, Optional, Tuple
from typing import Any, Dict, Optional
import mlx.core as mx
import mlx.nn as nn
@@ -97,9 +97,7 @@ class DeepseekV3YarnRotaryEmbedding(nn.Module):
scaling_factor, mscale_all_dim
)
freq_extra = base ** (mx.arange(0, dim, 2, dtype=mx.float32) / dim)
freq_inter = scaling_factor * base ** (
mx.arange(0, dim, 2, dtype=mx.float32) / dim
)
freq_inter = scaling_factor * freq_extra
low, high = yarn_find_correction_range(
beta_fast,
beta_slow,
@@ -132,6 +130,14 @@ def clipped_silu(x):
return mx.clip(x * mx.sigmoid(x), a_min=-100, a_max=100)
class ClippedSilu(nn.Module):
def __init__(self):
super().__init__()
def __call__(self, x):
return clipped_silu(x)
class DeepseekV3Attention(nn.Module):
def __init__(self, config: ModelArgs):
super().__init__()
@@ -157,7 +163,7 @@ class DeepseekV3Attention(nn.Module):
self.q_a_proj = nn.Linear(
self.hidden_size, self.q_lora_rank, bias=config.attention_bias
)
self.q_a_layernorm = nn.RMSNorm(self.q_lora_rank)
self.q_a_layernorm = nn.RMSNorm(self.q_lora_rank, eps=1e-6)
self.q_b_proj = nn.Linear(
self.q_lora_rank, self.num_heads * self.q_head_dim, bias=False
)
@@ -167,7 +173,7 @@ class DeepseekV3Attention(nn.Module):
self.kv_lora_rank + self.qk_rope_head_dim,
bias=config.attention_bias,
)
self.kv_a_layernorm = nn.RMSNorm(self.kv_lora_rank)
self.kv_a_layernorm = nn.RMSNorm(self.kv_lora_rank, eps=1e-6)
self.kv_b_proj = nn.Linear(
self.kv_lora_rank,
self.num_heads
@@ -291,6 +297,7 @@ def group_expert_select(
k = top_k
scores = mx.sigmoid(gates.astype(mx.float32))
orig_scores = scores
scores = scores + e_score_correction_bias
scores = mx.unflatten(scores, axis=-1, shape=(n_group, -1))
group_scores = mx.topk(scores, 2, axis=-1).sum(axis=-1, keepdims=True)
@@ -301,9 +308,9 @@ def group_expert_select(
k = top_k
inds = mx.argpartition(-scores, kth=k - 1, axis=-1)[..., :k]
scores = mx.take_along_axis(scores, inds, axis=-1)
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) + 1e-20
denominator = scores.sum(axis=-1, keepdims=True)
scores = scores / denominator
scores = scores * routed_scaling_factor
@@ -345,7 +352,7 @@ class DeepseekV3MoE(nn.Module):
config.hidden_size,
config.moe_intermediate_size,
config.n_routed_experts,
activation=clipped_silu,
activation=ClippedSilu(),
)
self.gate = MoEGate(config)
@@ -437,8 +444,6 @@ class DeepseekV3Model(nn.Module):
pipeline_rank = self.pipeline_rank
pipeline_size = self.pipeline_size
# Hack to avoid time-outs during prompt-processing
dist_stream = mx.cpu if h.shape[1] > 1 else mx.gpu
if mask is None:
mask = create_attention_mask(h, cache)
@@ -448,19 +453,17 @@ class DeepseekV3Model(nn.Module):
# Receive from the previous process in the pipeline
if pipeline_rank < pipeline_size - 1:
h = mx.distributed.recv_like(h, (pipeline_rank + 1), stream=dist_stream)
h = mx.distributed.recv_like(h, (pipeline_rank + 1))
for i in range(self.num_layers):
h = self.layers[self.start_idx + i](h, mask, cache[i])
# Send to the next process in the pipeline
if pipeline_rank != 0:
h = mx.distributed.send(
h, (pipeline_rank - 1) % pipeline_size, stream=dist_stream
)
h = mx.distributed.send(h, (pipeline_rank - 1) % pipeline_size)
# Broadcast h while keeping it in the graph
h = mx.distributed.all_gather(h, stream=dist_stream)[: h.shape[0]]
h = mx.distributed.all_gather(h)[: h.shape[0]]
return self.norm(h)
@@ -483,6 +486,35 @@ class Model(nn.Module):
return self.lm_head(out)
def sanitize(self, weights):
def dequant(weight, scale_inv):
dtype = weight.dtype
bs = 128 # block size
m, n = weight.shape
pad_bottom = (-m) % bs
pad_side = (-n) % bs
weight = mx.pad(weight, ((0, pad_bottom), (0, pad_side)))
weight = weight.reshape(
((m + pad_bottom) // bs, bs, (n + pad_side) // bs, bs)
)
weight = (weight * scale_inv[:, None, :, None]).reshape(
m + pad_bottom, n + pad_side
)
return weight[:m, :n].astype(dtype)
# Dequantize
new_weights = {}
for k, v in weights.items():
if "weight_scale_inv" in k:
scale_inv = v
wk = k.replace("_scale_inv", "")
weight = weights[wk]
weight = dequant(weight, scale_inv)
new_weights[wk] = weight
elif k not in new_weights:
new_weights[k] = v
weights = new_weights
# Stack experts
for l in range(self.args.num_hidden_layers):
prefix = f"model.layers.{l}"
for n, m in [("w1", "gate_proj"), ("w2", "down_proj"), ("w3", "up_proj")]:
@@ -504,3 +536,10 @@ class Model(nn.Module):
@property
def layers(self):
return self.model.layers[self.model.start_idx : self.model.end_idx]
@property
def cast_predicate(self):
def predicate(k):
return "e_score_correction_bias" not in k
return predicate
+320
View File
@@ -0,0 +1,320 @@
# Copyright © 2023-2024 Apple Inc.
from dataclasses import dataclass
from functools import partial
from typing import Any, Dict, 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
from .switch_layers import SwitchGLU
@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: Optional[int]
num_key_value_heads: Optional[int]
first_k_dense_replace: int
moe_intermediate_size: int
moe_layer_freq: int
n_routed_experts: int
n_shared_experts: int
norm_topk_prob: bool
num_experts_per_tok: int
rope_theta: float
routed_scaling_factor: float
head_dim: Optional[int] = None
scoring_func: str = ("noaux_tc",)
n_group: Optional[int] = 1
topk_group: Optional[int] = 1
attention_bias: bool = False
mlp_bias: bool = False
rope_scaling: Optional[Dict[str, Union[float, str]]] = None
tie_word_embeddings: bool = False
class Dots1Attention(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
dim = args.hidden_size
self.n_heads = n_heads = args.num_attention_heads
assert args.num_key_value_heads is not None
self.n_kv_heads = n_kv_heads = args.num_key_value_heads
head_dim = args.head_dim or args.hidden_size // n_heads
self.scale = head_dim**-0.5
self.q_proj = nn.Linear(dim, n_heads * head_dim, bias=False)
self.k_proj = nn.Linear(dim, n_kv_heads * head_dim, bias=False)
self.v_proj = nn.Linear(dim, n_kv_heads * head_dim, bias=False)
self.o_proj = nn.Linear(n_heads * head_dim, dim, bias=False)
self.q_norm = nn.RMSNorm(head_dim, eps=args.rms_norm_eps)
self.k_norm = nn.RMSNorm(head_dim, eps=args.rms_norm_eps)
self.rope = initialize_rope(
head_dim,
base=args.rope_theta,
traditional=False,
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, D = x.shape
queries, keys, values = self.q_proj(x), self.k_proj(x), self.v_proj(x)
queries = self.q_norm(queries.reshape(B, L, self.n_heads, -1)).transpose(
0, 2, 1, 3
)
keys = self.k_norm(keys.reshape(B, L, self.n_kv_heads, -1)).transpose(
0, 2, 1, 3
)
values = values.reshape(B, L, self.n_kv_heads, -1).transpose(0, 2, 1, 3)
if cache is not None:
queries = self.rope(queries, offset=cache.offset)
keys = self.rope(keys, offset=cache.offset)
keys, values = cache.update_and_fetch(keys, values)
else:
queries = self.rope(queries)
keys = self.rope(keys)
output = scaled_dot_product_attention(
queries, keys, values, cache=cache, scale=self.scale, mask=mask
)
output = output.transpose(0, 2, 1, 3).reshape(B, L, -1)
return self.o_proj(output)
@mx.compile
def group_expert_select(
gates,
e_score_correction_bias,
top_k,
n_group,
topk_group,
routed_scaling_factor,
norm_topk_prob,
):
k = top_k
scores = mx.sigmoid(gates.astype(mx.float32))
orig_scores = scores
scores = scores + e_score_correction_bias
k = n_group - topk_group
if k != 0:
scores = mx.unflatten(scores, axis=-1, shape=(n_group, -1))
group_scores = mx.topk(scores, 2, axis=-1).sum(axis=-1, keepdims=True)
group_idx = mx.argpartition(group_scores, kth=k - 1, axis=-2)[..., :k, :]
scores = mx.put_along_axis(scores, group_idx, mx.array(0.0), axis=-2)
scores = mx.flatten(scores, -2, -1)
k = top_k
inds = mx.argpartition(-scores, kth=k - 1, axis=-1)[..., :k]
scores = mx.take_along_axis(orig_scores, inds, axis=-1)
if top_k > 1 and norm_topk_prob:
denominator = scores.sum(axis=-1, keepdims=True)
scores = scores / denominator
scores = scores * routed_scaling_factor
return inds, scores
class Dots1TopkRouter(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
self.top_k = args.num_experts_per_tok
self.norm_topk_prob = args.norm_topk_prob
self.n_routed_experts = args.n_routed_experts
self.routed_scaling_factor = args.routed_scaling_factor
self.n_group = args.n_group
self.topk_group = args.topk_group
self.weight = mx.zeros((self.n_routed_experts, args.hidden_size))
self.e_score_correction_bias = mx.zeros((self.n_routed_experts,))
def __call__(self, x):
return group_expert_select(
x @ self.weight.T,
self.e_score_correction_bias,
self.top_k,
self.n_group,
self.topk_group,
self.routed_scaling_factor,
self.norm_topk_prob,
)
class Dots1MLP(nn.Module):
def __init__(
self, args: ModelArgs, hidden_size: int = None, intermediate_size: int = None
):
super().__init__()
self.hidden_size = args.hidden_size if hidden_size is None else hidden_size
self.intermediate_size = (
args.intermediate_size if intermediate_size is None else intermediate_size
)
self.gate_proj = nn.Linear(
self.hidden_size, self.intermediate_size, bias=args.mlp_bias
)
self.up_proj = nn.Linear(
self.hidden_size, self.intermediate_size, bias=args.mlp_bias
)
self.down_proj = nn.Linear(
self.intermediate_size, self.hidden_size, bias=args.mlp_bias
)
def __call__(self, x) -> mx.array:
return self.down_proj(nn.silu(self.gate_proj(x)) * self.up_proj(x))
class Dots1MoE(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
self.num_experts_per_tok = args.num_experts_per_tok
self.n_shared_experts = args.n_shared_experts
self.experts = SwitchGLU(
args.hidden_size,
args.moe_intermediate_size,
args.n_routed_experts,
)
self.gate = Dots1TopkRouter(args)
self.shared_experts = Dots1MLP(
args=args,
intermediate_size=args.moe_intermediate_size * args.n_shared_experts,
)
def __call__(self, x):
inds, scores = self.gate(x)
y = self.experts(x, inds)
y = (y * scores[..., None]).sum(axis=-2).astype(y.dtype)
if self.n_shared_experts is not None:
y = y + self.shared_experts(x)
return y
class Dots1DecoderLayer(nn.Module):
def __init__(self, args: ModelArgs, layer_idx: int):
super().__init__()
self.self_attn = Dots1Attention(args)
if layer_idx >= args.first_k_dense_replace:
self.mlp = Dots1MoE(args)
else:
self.mlp = Dots1MLP(args)
self.input_layernorm = nn.RMSNorm(args.hidden_size, eps=args.rms_norm_eps)
self.post_attention_layernorm = nn.RMSNorm(
args.hidden_size, eps=args.rms_norm_eps
)
def __call__(
self,
x: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[Any] = None,
) -> mx.array:
r = self.self_attn(self.input_layernorm(x), mask, cache)
h = x + r
r = self.mlp(self.post_attention_layernorm(h))
return h + r
class Dots1Model(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
self.embed_tokens = nn.Embedding(args.vocab_size, args.hidden_size)
self.layers = [
Dots1DecoderLayer(args, layer_idx)
for layer_idx in range(args.num_hidden_layers)
]
self.norm = nn.RMSNorm(args.hidden_size, eps=args.rms_norm_eps)
def __call__(
self,
inputs: mx.array,
mask: mx.array = None,
cache=None,
) -> mx.array:
h = self.embed_tokens(inputs)
if mask is None:
mask = create_attention_mask(h, cache)
if cache is None:
cache = [None] * len(self.layers)
for layer, c in zip(self.layers, cache):
h = layer(h, mask, 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 = Dots1Model(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,
mask: mx.array = None,
cache=None,
):
out = self.model(inputs, mask, cache)
if self.args.tie_word_embeddings:
out = self.model.embed_tokens.as_linear(out)
else:
out = self.lm_head(out)
return out
def sanitize(self, weights):
if self.args.tie_word_embeddings:
weights.pop("lm_head.weight", None)
for l in range(self.args.num_hidden_layers):
prefix = f"model.layers.{l}"
if l >= self.args.first_k_dense_replace:
for n, m in [
("w1", "gate_proj"),
("w2", "down_proj"),
("w3", "up_proj"),
]:
for k in ["weight", "scales", "biases"]:
if f"{prefix}.mlp.experts.0.{m}.{k}" in weights:
to_join = [
weights.pop(f"{prefix}.mlp.experts.{e}.{m}.{k}")
for e in range(self.args.n_routed_experts)
]
weights[f"{prefix}.mlp.experts.{m}.{k}"] = mx.stack(to_join)
return {k: v for k, v in weights.items() if "rotary_emb.inv_freq" not in k}
@property
def layers(self):
return self.model.layers
+167
View File
@@ -0,0 +1,167 @@
# Copyright © 2023-2024 Apple Inc.
from dataclasses import dataclass
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
from .rope_utils import initialize_rope
@dataclass
class ModelArgs(BaseModelArgs):
hidden_size: int
intermediate_size: int
model_type: str
max_position_embeddings: int
num_attention_heads: int
num_key_value_heads: int
head_dim: Optional[int]
num_hidden_layers: int
rms_norm_eps: float
vocab_size: int
rope_theta: float
use_bias: bool
tie_word_embeddings: bool
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.head_dim or dim // n_heads
self.scale = head_dim**-0.5
self.q_proj = nn.Linear(dim, n_heads * head_dim, bias=args.use_bias)
self.k_proj = nn.Linear(dim, n_kv_heads * head_dim, bias=args.use_bias)
self.v_proj = nn.Linear(dim, n_kv_heads * head_dim, bias=args.use_bias)
self.o_proj = nn.Linear(n_heads * head_dim, dim, bias=args.use_bias)
self.rope = initialize_rope(
head_dim,
base=args.rope_theta,
traditional=True,
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, D = x.shape
queries, keys, values = self.q_proj(x), self.k_proj(x), self.v_proj(x)
queries = queries.reshape(B, L, self.n_heads, -1).transpose(0, 2, 1, 3)
keys = keys.reshape(B, L, self.n_kv_heads, -1).transpose(0, 2, 1, 3)
values = values.reshape(B, L, self.n_kv_heads, -1).transpose(0, 2, 1, 3)
if cache is not None:
queries = self.rope(queries, offset=cache.offset)
keys = self.rope(keys, offset=cache.offset)
keys, values = cache.update_and_fetch(keys, values)
else:
queries = self.rope(queries)
keys = self.rope(keys)
output = scaled_dot_product_attention(
queries, keys, values, cache=cache, scale=self.scale, mask=mask
)
output = output.transpose(0, 2, 1, 3).reshape(B, L, -1)
return self.o_proj(output)
class MLP(nn.Module):
def __init__(self, dim, hidden_dim, use_bias=False):
super().__init__()
self.gate_proj = nn.Linear(dim, hidden_dim, bias=use_bias)
self.down_proj = nn.Linear(hidden_dim, dim, bias=use_bias)
self.up_proj = nn.Linear(dim, hidden_dim, bias=use_bias)
def __call__(self, x) -> mx.array:
return self.down_proj(nn.silu(self.gate_proj(x)) * self.up_proj(x))
class DecoderLayer(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
self.self_attn = Attention(args)
self.mlp = MLP(args.hidden_size, args.intermediate_size, args.use_bias)
self.input_layernorm = nn.RMSNorm(args.hidden_size, eps=args.rms_norm_eps)
self.post_attention_layernorm = nn.RMSNorm(
args.hidden_size, eps=args.rms_norm_eps
)
def __call__(
self,
x: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[Any] = None,
) -> mx.array:
r = self.self_attn(self.input_layernorm(x), mask, cache)
h = x + r
r = self.mlp(self.post_attention_layernorm(h))
return h + r
class Ernie45Model(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
self.embed_tokens = nn.Embedding(args.vocab_size, args.hidden_size)
self.layers = [DecoderLayer(args) for _ in range(args.num_hidden_layers)]
self.norm = nn.RMSNorm(args.hidden_size, eps=args.rms_norm_eps)
def __call__(
self,
inputs: mx.array,
mask: mx.array = None,
cache=None,
):
h = self.embed_tokens(inputs)
if mask is None:
mask = create_attention_mask(h, cache)
if cache is None:
cache = [None] * len(self.layers)
for layer, c in zip(self.layers, cache):
h = layer(h, mask, 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 = Ernie45Model(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,
mask: mx.array = None,
cache=None,
):
out = self.model(inputs, mask, 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
@@ -1,7 +1,7 @@
# Copyright © 2023-2024 Apple Inc.
from dataclasses import dataclass
from typing import Any, Optional, Tuple
from typing import Any, Optional
import mlx.core as mx
import mlx.nn as nn
+8 -3
View File
@@ -1,7 +1,7 @@
# Copyright © 2023-2024 Apple Inc.
from dataclasses import dataclass
from typing import Any, Optional, Tuple
from typing import Any, Optional
import mlx.core as mx
import mlx.nn as nn
@@ -94,7 +94,12 @@ class Attention(nn.Module):
scores *= self.attn_logit_softcapping
if mask is not None:
scores = scores + mask
if mask.dtype == mx.bool_:
scores = mx.where(
mask, scores, mx.array(mx.finfo(scores.dtype).min, scores.dtype)
)
else:
scores = scores + mask
scores = mx.softmax(scores, precise=True, axis=-1)
output = scores @ values
if self.repeats > 1:
@@ -167,7 +172,7 @@ class GemmaModel(nn.Module):
h = h * (self.args.hidden_size**0.5)
if mask is None:
mask = create_attention_mask(h, cache)
mask = create_attention_mask(h, cache, return_array=True)
if cache is None:
cache = [None] * len(self.layers)
+64
View File
@@ -0,0 +1,64 @@
# 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 gemma3_text
from .base import BaseModelArgs
@dataclass
class ModelArgs(BaseModelArgs):
model_type: str
text_config: dict
vocab_size: int = 262208
def __post_init__(self):
self.text_config["vocab_size"] = self.vocab_size
self.text_config["num_attention_heads"] = self.text_config.get(
"num_attention_heads", 8
)
self.text_config["num_key_value_heads"] = self.text_config.get(
"num_key_value_heads", 4
)
class Model(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
self.args = args
self.model_type = args.model_type
self.language_model = gemma3_text.Model(
gemma3_text.ModelArgs.from_dict(args.text_config)
)
def __call__(
self,
inputs: mx.array,
cache=None,
mask: Optional[mx.array] = None,
input_embeddings: Optional[mx.array] = None,
):
return self.language_model(
inputs, cache=cache, mask=mask, input_embeddings=input_embeddings
)
def sanitize(self, weights):
weights = tree_unflatten(list(weights.items()))
weights.pop("vision_tower", None)
weights.pop("multi_modal_projector", None)
lm_weights = dict(tree_flatten(weights["language_model"]))
lm_weights = self.language_model.sanitize(lm_weights)
weights["language_model"] = tree_unflatten(list(lm_weights.items()))
return dict(tree_flatten(weights))
@property
def layers(self):
return self.language_model.layers
def make_cache(self):
return self.language_model.make_cache()
+32 -18
View File
@@ -1,12 +1,13 @@
# Copyright © 2025 Apple Inc.
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
from .base import BaseModelArgs, create_attention_mask, scaled_dot_product_attention
from .cache import KVCache, RotatingKVCache
@@ -86,11 +87,10 @@ class Attention(nn.Module):
keys = self.rope(keys)
# Sliding window
if mask is not None and mask.shape[-1] != keys.shape[-2]:
if isinstance(mask, mx.array) and mask.shape[-1] != keys.shape[-2]:
mask = mask[..., -keys.shape[-2] :]
output = mx.fast.scaled_dot_product_attention(
queries, keys, values, scale=self.scale, mask=mask
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)
@@ -117,6 +117,16 @@ class MLP(nn.Module):
return self.down_proj(nn.gelu_approx(self.gate_proj(x)) * self.up_proj(x))
@partial(mx.compile, shapeless=True)
def clip_residual(x, y):
if x.dtype != mx.float16:
return x + y
bound = mx.finfo(mx.float16).max
return mx.clip(x.astype(mx.float32) + y.astype(mx.float32), -bound, bound).astype(
mx.float16
)
class TransformerBlock(nn.Module):
def __init__(self, args: ModelArgs, layer_idx: int):
super().__init__()
@@ -140,9 +150,9 @@ class TransformerBlock(nn.Module):
cache: Optional[Any] = None,
) -> mx.array:
r = self.self_attn(self.input_layernorm(x), mask, cache)
h = x + self.post_attention_layernorm(r)
h = clip_residual(x, self.post_attention_layernorm(r))
r = self.mlp(self.pre_feedforward_layernorm(h))
out = h + self.post_feedforward_layernorm(r)
out = clip_residual(h, self.post_feedforward_layernorm(r))
return out
@@ -165,9 +175,12 @@ class Gemma3Model(nn.Module):
inputs: mx.array,
mask: mx.array = None,
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)
h *= mx.array(self.args.hidden_size**0.5, mx.bfloat16).astype(h.dtype)
if cache is None:
@@ -179,17 +192,18 @@ class Gemma3Model(nn.Module):
sliding_window_mask = create_attention_mask(h, cache)
for i, (layer, c) in enumerate(zip(self.layers, cache)):
is_sliding = (
is_global = (
i % self.args.sliding_window_pattern
== self.args.sliding_window_pattern - 1
)
if mask is None and is_sliding:
mask = sliding_window_mask
local_mask = mask
if mask is None and is_global:
local_mask = full_mask
elif mask is None:
mask = full_mask
local_mask = sliding_window_mask
h = layer(h, mask, c)
h = layer(h, local_mask, c)
return self.norm(h)
@@ -207,17 +221,17 @@ class Model(nn.Module):
inputs: mx.array,
cache=None,
mask: Optional[mx.array] = None,
input_embeddings: Optional[mx.array] = None,
):
out = self.model(inputs, mask, cache)
out = self.model(inputs, mask, cache, input_embeddings)
out = self.lm_head(out)
return out
def sanitize(self, weights):
weights = dict(weights)
if "lm_head.weight" not in weights:
weights["lm_head.weight"] = weights["model.embed_tokens.weight"]
return {
k: v for k, v in weights.items() if "self_attn.rotary_emb.inv_freq" not in k
}
return weights
@property
def layers(self):
+621
View File
@@ -0,0 +1,621 @@
# Copyright © 2025 Apple Inc.
import math
from dataclasses import dataclass
from functools import partial
from typing import Any, Dict, List, Optional
import mlx.core as mx
import mlx.nn as nn
from mlx.utils import tree_flatten, tree_unflatten
from .base import BaseModelArgs, create_attention_mask, scaled_dot_product_attention
from .cache import KVCache, RotatingKVCache
@dataclass
class TextConfig(BaseModelArgs):
model_type: str
hidden_size: int
num_hidden_layers: int
intermediate_size: int
num_attention_heads: int
head_dim: int
rms_norm_eps: float
vocab_size: int
num_key_value_heads: int
num_kv_shared_layers: int
query_pre_attn_scalar: float
vocab_size_per_layer_input: int
sliding_window: int
max_position_embeddings: int
rope_local_base_freq: float
rope_theta: float
final_logit_softcapping: float
layer_types: List[str]
activation_sparsity_pattern: List[float]
hidden_size_per_layer_input: int
altup_num_inputs: int
altup_coef_clip: float
altup_correct_scale: bool
altup_active_idx: int
laurel_rank: int
rope_scaling: Optional[Dict] = None
@dataclass
class ModelArgs(BaseModelArgs):
model_type: str
text_config: dict
class RMSNoScale(nn.Module):
def __init__(self, eps: float = 1e-5):
super().__init__()
self.eps = eps
def __call__(self, x):
return mx.fast.rms_norm(x, None, self.eps)
class Gemma3nLaurelBlock(nn.Module):
"""Learned Augmented Residual Layer"""
def __init__(self, config: TextConfig):
super().__init__()
self.config = config
self.linear_left = nn.Linear(
self.config.hidden_size, self.config.laurel_rank, bias=False
)
self.linear_right = nn.Linear(
self.config.laurel_rank, self.config.hidden_size, bias=False
)
self.post_laurel_norm = nn.RMSNorm(
dims=self.config.hidden_size,
eps=self.config.rms_norm_eps,
)
def __call__(self, x: mx.array) -> mx.array:
laurel_x = self.linear_left(x)
laurel_x = self.linear_right(laurel_x)
normed_laurel_x = self.post_laurel_norm(laurel_x)
return x + normed_laurel_x
class Gemma3nAttention(nn.Module):
def __init__(self, config: TextConfig, layer_idx: int, is_kv_shared_layer: bool):
super().__init__()
self.is_sliding = config.layer_types[layer_idx] == "sliding_attention"
dim = config.hidden_size
self.n_heads = n_heads = config.num_attention_heads
self.n_kv_heads = n_kv_heads = config.num_key_value_heads
self.repeats = n_heads // n_kv_heads
self.head_dim = head_dim = config.head_dim
self.layer_idx = layer_idx
self.scale = 1.0
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.o_proj = nn.Linear(n_heads * head_dim, dim, bias=False)
self.q_norm = nn.RMSNorm(dims=config.head_dim, eps=config.rms_norm_eps)
self.k_norm = nn.RMSNorm(dims=config.head_dim, eps=config.rms_norm_eps)
self.v_norm = RMSNoScale(eps=config.rms_norm_eps)
self.is_kv_shared_layer = is_kv_shared_layer
self.rope = nn.RoPE(
head_dim,
traditional=False,
base=(
config.rope_local_base_freq if self.is_sliding else config.rope_theta
),
)
def __call__(
self,
x: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[Any] = None,
) -> mx.array:
B, L, _ = x.shape
queries = self.q_proj(x)
queries = queries.reshape(B, L, -1, self.head_dim)
queries = self.q_norm(queries)
offset = 0
if self.is_kv_shared_layer and cache is not None:
# For shared layers, retrieve KV from the designated cache layer
keys, values = cache.state
offset = cache.offset
else:
if cache is not None:
offset = cache.offset
keys = self.k_proj(x).reshape(B, L, -1, self.head_dim)
keys = self.k_norm(keys)
keys = keys.transpose(0, 2, 1, 3)
keys = self.rope(keys, offset=offset)
values = self.v_proj(x).reshape(B, L, -1, self.head_dim)
values = self.v_norm(values)
values = values.transpose(0, 2, 1, 3)
if cache is not None:
keys, values = cache.update_and_fetch(keys, values)
queries = queries.transpose(0, 2, 1, 3)
queries = self.rope(queries, offset=offset)
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
)
output = output.transpose(0, 2, 1, 3).reshape(B, L, -1)
return self.o_proj(output)
@partial(mx.compile, shapeless=True)
def gelu_topk(inputs, std_multiplier):
inputs_mean = mx.mean(inputs, axis=-1, keepdims=True)
inputs_std = mx.std(inputs, axis=-1, keepdims=True)
cutoff_x = inputs_mean + inputs_std * std_multiplier.astype(inputs_std.dtype)
return nn.gelu_approx(mx.maximum(0, inputs - cutoff_x))
class MLP(nn.Module):
def __init__(self, config: TextConfig, layer_idx: int = 0):
super().__init__()
self.config = config
self.hidden_size = config.hidden_size
self.intermediate_size = config.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)
if config.activation_sparsity_pattern is not None:
self.activation_sparsity = config.activation_sparsity_pattern[layer_idx]
else:
self.activation_sparsity = 0.0
if self.activation_sparsity > 0:
self._std_multiplier = math.sqrt(2.0) * mx.erfinv(
2 * self.activation_sparsity - 1
)
def __call__(self, x: mx.array):
gate_proj = self.gate_proj(x)
if self.activation_sparsity > 0.0:
activations = gelu_topk(gate_proj, self._std_multiplier)
else:
activations = nn.gelu_approx(gate_proj)
up_proj = self.up_proj(x)
down_proj = self.down_proj(activations * up_proj)
return down_proj
class Gemma3nAltUp(nn.Module):
"""Alternating Updates (AltUp)"""
def __init__(self, config: TextConfig):
super().__init__()
self.config = config
self.correct_output_scale = mx.zeros((self.config.hidden_size,))
self.correction_coefs = nn.Linear(
self.config.altup_num_inputs, self.config.altup_num_inputs, bias=False
)
self.prediction_coefs = nn.Linear(
self.config.altup_num_inputs, self.config.altup_num_inputs**2, bias=False
)
self.modality_router = nn.Linear(
self.config.hidden_size, self.config.altup_num_inputs, bias=False
)
self.router_norm = nn.RMSNorm(
dims=self.config.hidden_size,
eps=self.config.rms_norm_eps,
)
def compute_router_modalities(self, x: mx.array) -> mx.array:
router_inputs = self.router_norm(x) * (self.config.hidden_size**-1.0)
routed = self.modality_router(router_inputs).astype(mx.float32)
return mx.tanh(routed)
def predict(self, x: mx.array) -> mx.array:
modalities = self.compute_router_modalities(x[self.config.altup_active_idx])
self.prediction_coefs.weight = self.prediction_coefs.weight.astype(mx.float32)
if self.config.altup_coef_clip is not None:
self.prediction_coefs.weight = mx.clip(
self.prediction_coefs.weight,
-self.config.altup_coef_clip,
self.config.altup_coef_clip,
)
all_coefs = (
self.prediction_coefs(modalities)
.reshape(
*modalities.shape[:-1],
self.config.altup_num_inputs,
self.config.altup_num_inputs,
)
.transpose(0, 1, 3, 2)
)
x_up = x.astype(mx.float32)
x_permuted = x_up.transpose(1, 2, 3, 0)
predictions = mx.matmul(x_permuted, all_coefs)
predictions = predictions.transpose(3, 0, 1, 2)
predictions += x_up
return predictions.astype(x.dtype)
def correct(self, predictions: mx.array, activated: mx.array):
modalities = self.compute_router_modalities(activated)
self.correction_coefs.weight = self.correction_coefs.weight.astype(mx.float32)
if self.config.altup_coef_clip is not None:
self.correction_coefs.weight = mx.clip(
self.correction_coefs.weight,
-self.config.altup_coef_clip,
self.config.altup_coef_clip,
)
all_coefs = self.correction_coefs(modalities) + 1.0
active_x = predictions[self.config.altup_active_idx]
innovation = activated - active_x
all_coefs = all_coefs.transpose(2, 1, 0)
corrected = innovation[None] * all_coefs[:, None]
corrected += predictions
return corrected.astype(activated.dtype)
class Gemma3nDecoderLayer(nn.Module):
def __init__(self, config: TextConfig, layer_idx: int, is_kv_shared_layer: bool):
super().__init__()
self.config = config
self.hidden_size = config.hidden_size
self.layer_idx = layer_idx
self.self_attn = Gemma3nAttention(config, layer_idx, is_kv_shared_layer)
self.mlp = MLP(config, layer_idx=layer_idx)
self.input_layernorm = nn.RMSNorm(
self.hidden_size,
eps=config.rms_norm_eps,
)
self.post_attention_layernorm = nn.RMSNorm(
self.hidden_size,
eps=config.rms_norm_eps,
)
self.pre_feedforward_layernorm = nn.RMSNorm(
self.hidden_size,
eps=config.rms_norm_eps,
)
self.post_feedforward_layernorm = nn.RMSNorm(
self.hidden_size,
eps=config.rms_norm_eps,
)
self.is_sliding = self.self_attn.is_sliding
self.sliding_window = config.sliding_window
self.hidden_size_per_layer_input = config.hidden_size_per_layer_input
self.altup = Gemma3nAltUp(config)
self.laurel = Gemma3nLaurelBlock(config)
self.per_layer_input_gate = nn.Linear(
self.hidden_size, self.hidden_size_per_layer_input, bias=False
)
self.per_layer_projection = nn.Linear(
self.hidden_size_per_layer_input, self.hidden_size, bias=False
)
self.post_per_layer_input_norm = nn.RMSNorm(
self.hidden_size,
eps=config.rms_norm_eps,
)
def __call__(
self,
x: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[Any] = None,
per_layer_input: Optional[mx.array] = None,
):
predictions = self.altup.predict(x)
active_prediction = predictions[self.config.altup_active_idx]
active_prediction_normed = self.input_layernorm(active_prediction)
laurel_output = self.laurel(active_prediction_normed)
attn = self.self_attn(
active_prediction_normed,
mask,
cache,
)
attn = self.post_attention_layernorm(attn)
attn_gated = active_prediction + attn
attn_laurel = (attn_gated + laurel_output) * (2.0**-0.5)
attn_norm = self.pre_feedforward_layernorm(attn_laurel)
attn_ffw = self.mlp(attn_norm)
attn_ffw_norm = self.post_feedforward_layernorm(attn_ffw)
attn_ffw_laurel_gated = attn_laurel + attn_ffw_norm
corrected_predictions = self.altup.correct(predictions, attn_ffw_laurel_gated)
first_prediction = corrected_predictions[self.config.altup_active_idx]
if self.config.altup_correct_scale:
first_prediction = first_prediction * self.altup.correct_output_scale
first_prediction = self.per_layer_input_gate(first_prediction)
first_prediction = nn.gelu_approx(first_prediction)
first_prediction = mx.multiply(first_prediction, per_layer_input)
first_prediction = self.per_layer_projection(first_prediction)
first_prediction = self.post_per_layer_input_norm(first_prediction)
corrected_predictions[1:] = corrected_predictions[1:] + first_prediction
return corrected_predictions
@partial(mx.compile, shapeless=True)
def logit_softcap(softcap, x):
out = mx.tanh(x / softcap)
out = out * softcap
return out
class LanguageModel(nn.Module):
def __init__(self, config: TextConfig):
super().__init__()
self.config = config
self.hidden_size = config.hidden_size
self.hidden_size_per_layer_input = config.hidden_size_per_layer_input
self.vocab_size = config.vocab_size
self.vocab_size_per_layer_input = config.vocab_size_per_layer_input
self.num_hidden_layers = config.num_hidden_layers
self.final_logit_softcapping = config.final_logit_softcapping
self.first_kv_shared_layer_idx = (
config.num_hidden_layers - config.num_kv_shared_layers
)
self.embed_tokens = nn.Embedding(config.vocab_size, config.hidden_size)
self.layers = [
Gemma3nDecoderLayer(
config=config,
layer_idx=layer_idx,
is_kv_shared_layer=layer_idx >= self.first_kv_shared_layer_idx,
)
for layer_idx in range(config.num_hidden_layers)
]
self.embed_tokens_per_layer = nn.Embedding(
config.vocab_size_per_layer_input,
config.num_hidden_layers * config.hidden_size_per_layer_input,
)
self.per_layer_model_projection = nn.Linear(
config.hidden_size,
config.num_hidden_layers * config.hidden_size_per_layer_input,
bias=False,
)
self.per_layer_projection_norm = nn.RMSNorm(
dims=config.hidden_size_per_layer_input,
eps=config.rms_norm_eps,
)
self.altup_projections = [
nn.Linear(config.hidden_size, config.hidden_size, bias=False)
for _ in range(1, self.config.altup_num_inputs)
]
self.altup_unembed_projections = [
nn.Linear(config.hidden_size, config.hidden_size, bias=False)
for _ in range(1, self.config.altup_num_inputs)
]
self.norm = nn.RMSNorm(
config.hidden_size,
eps=config.rms_norm_eps,
)
self.first_sliding_idx = self.config.layer_types.index("sliding_attention")
self.first_full_idx = self.config.layer_types.index("full_attention")
concrete_layers = self.config.layer_types[: self.first_kv_shared_layer_idx]
shared_full_idx = (
len(concrete_layers) - 1 - concrete_layers[::-1].index("full_attention")
)
shared_sliding_idx = (
len(concrete_layers) - 1 - concrete_layers[::-1].index("sliding_attention")
)
self.layer_idx_to_cache_idx = []
for i, layer_type in enumerate(self.config.layer_types):
if i < self.first_kv_shared_layer_idx:
self.layer_idx_to_cache_idx.append(i)
else:
if layer_type == "full_attention":
self.layer_idx_to_cache_idx.append(shared_full_idx)
elif layer_type == "sliding_attention":
self.layer_idx_to_cache_idx.append(shared_sliding_idx)
else:
raise NotImplementedError(f"Unknown layer type: {layer_type}")
def __call__(
self,
inputs: mx.array = None,
mask: mx.array = None,
cache=None,
input_embeddings: mx.array = None,
):
if input_embeddings is None:
h = self.embed_tokens(inputs) * (self.hidden_size**0.5)
else:
h = input_embeddings
per_layer_inputs = self.get_per_layer_inputs(inputs)
per_layer_inputs = self.project_per_layer_inputs(h, per_layer_inputs)
if cache is None:
cache = [None] * len(self.layers)
if mask is None:
full_mask = create_attention_mask(
h,
cache[self.first_full_idx :],
)
sliding_window_mask = create_attention_mask(
h,
cache[self.first_sliding_idx :],
)
h0 = h
# Expand hidden_states to support per-layer inputs
target_magnitude = mx.mean(h0**2, axis=-1, keepdims=True) ** 0.5
h_list = [h0]
h_list.extend([proj(h0) for proj in self.altup_projections])
h = mx.stack(h_list, axis=0)
mags = mx.mean(h[1:] ** 2, axis=-1, keepdims=True) ** 0.5
h[1:] = h[1:] * (target_magnitude / mx.maximum(mags, mx.finfo(h0.dtype).min))
for i, layer in enumerate(self.layers):
per_layer_input = per_layer_inputs[:, :, i, :]
is_global = self.config.layer_types[i] == "full_attention"
local_mask = mask
if mask is None and is_global:
local_mask = full_mask
elif mask is None:
local_mask = sliding_window_mask
h = layer(
h,
local_mask,
cache[self.layer_idx_to_cache_idx[i]],
per_layer_input,
)
# Per-layer inputs to single output
target_magnitude = mx.mean(h[0] ** 2, axis=-1, keepdims=True) ** 0.5
for i, proj in enumerate(self.altup_unembed_projections):
h[i + 1] = proj(h[i + 1])
mags = mx.mean(h[1:] ** 2, axis=-1, keepdims=True) ** 0.5
h[1:] = h[1:] * (target_magnitude / mx.maximum(mags, mx.finfo(h0.dtype).min))
h = mx.mean(h, axis=0)
out = self.norm(h)
out = self.embed_tokens.as_linear(out)
if self.final_logit_softcapping is not None:
out = logit_softcap(self.final_logit_softcapping, out)
return out
def get_per_layer_inputs(self, input_ids: mx.array) -> mx.array:
per_layer_inputs_mask = input_ids < self.vocab_size_per_layer_input
tokens = mx.where(per_layer_inputs_mask, input_ids, mx.zeros_like(input_ids))
result = self.embed_tokens_per_layer(tokens) * (
self.hidden_size_per_layer_input**0.5
)
return result.reshape(
*input_ids.shape,
self.num_hidden_layers,
self.hidden_size_per_layer_input,
)
def project_per_layer_inputs(
self,
inputs_embeds: mx.array,
per_layer_inputs: mx.array,
) -> mx.array:
per_layer_projection = self.per_layer_model_projection(inputs_embeds) * (
self.hidden_size**-0.5
)
per_layer_projection = per_layer_projection.reshape(
*inputs_embeds.shape[:-1],
self.config.num_hidden_layers,
self.config.hidden_size_per_layer_input,
)
per_layer_projection = self.per_layer_projection_norm(per_layer_projection)
return (per_layer_projection + per_layer_inputs) * (2.0**-0.5)
def make_cache(self):
caches = []
for layer_type in self.config.layer_types[: self.first_kv_shared_layer_idx]:
if layer_type == "full_attention":
caches.append(KVCache())
elif layer_type == "sliding_attention":
caches.append(
RotatingKVCache(max_size=self.config.sliding_window, keep=0)
)
else:
raise NotImplementedError(f"Unknown layer type: {layer_type}")
return caches
class Gemma3n(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
self.language_model = LanguageModel(TextConfig.from_dict(args.text_config))
def __call__(
self,
inputs: mx.array,
cache=None,
mask: Optional[mx.array] = None,
input_embeddings: Optional[mx.array] = None,
):
return self.language_model(
inputs, cache=cache, mask=mask, input_embeddings=input_embeddings
)
def make_cache(self):
return self.language_model.make_cache()
class Model(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
self.args = args
self.model = Gemma3n(args)
def __call__(
self,
inputs: mx.array,
cache=None,
mask: Optional[mx.array] = None,
input_embeddings: Optional[mx.array] = None,
):
return self.model(
inputs, cache=cache, mask=mask, input_embeddings=input_embeddings
)
def sanitize(self, weights):
weights = tree_unflatten(list(weights.items()))
for k in ["vision_tower", "audio_tower", "embed_audio", "embed_vision"]:
weights["model"].pop(k, None)
return dict(tree_flatten(weights))
@property
def layers(self):
return self.model.language_model.layers
def make_cache(self):
return self.model.make_cache()
+183
View File
@@ -0,0 +1,183 @@
# Copyright © 2025 Apple Inc.
from dataclasses import dataclass
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
hidden_size: int
num_hidden_layers: int
intermediate_size: int
num_attention_heads: int
attention_bias: bool
head_dim: int
rms_norm_eps: float
vocab_size: int
num_key_value_heads: int
partial_rotary_factor: float
rope_theta: float
rope_traditional: bool = True
max_position_embeddings: int = 32768
class Glm4MLP(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
self.gate_up_proj = nn.Linear(
args.hidden_size, 2 * args.intermediate_size, bias=False
)
self.down_proj = nn.Linear(args.intermediate_size, args.hidden_size, bias=False)
def __call__(self, x) -> mx.array:
x = self.gate_up_proj(x)
gate, up_states = mx.split(x, 2, axis=-1)
return self.down_proj(nn.silu(gate) * up_states)
class Glm4Attention(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
self.head_dim = getattr(
args, "head_dim", args.hidden_size // args.num_attention_heads
)
self.n_heads = args.num_attention_heads
self.n_kv_heads = args.num_key_value_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=False
)
self.rope = nn.RoPE(
dims=int(self.head_dim * args.partial_rotary_factor),
base=args.rope_theta,
traditional=args.rope_traditional,
)
def __call__(
self, x: mx.array, mask: Optional[mx.array] = None, cache: Optional[Any] = None
) -> mx.array:
B, L, D = x.shape
queries, keys, values = self.q_proj(x), self.k_proj(x), self.v_proj(x)
queries = queries.reshape(B, L, self.n_heads, -1).transpose(0, 2, 1, 3)
keys = keys.reshape(B, L, self.n_kv_heads, -1).transpose(0, 2, 1, 3)
values = values.reshape(B, L, self.n_kv_heads, -1).transpose(0, 2, 1, 3)
if cache is not None:
queries = self.rope(queries, offset=cache.offset)
keys = self.rope(keys, offset=cache.offset)
keys, values = cache.update_and_fetch(keys, values)
else:
queries = self.rope(queries)
keys = self.rope(keys)
output = scaled_dot_product_attention(
queries, keys, values, cache=cache, scale=self.scale, mask=mask
)
output = output.transpose(0, 2, 1, 3).reshape(B, L, -1)
return self.o_proj(output)
class Glm4DecoderLayer(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
self.self_attn = Glm4Attention(args=args)
self.mlp = Glm4MLP(args)
self.input_layernorm = nn.RMSNorm(args.hidden_size, eps=args.rms_norm_eps)
self.post_attention_layernorm = nn.RMSNorm(
args.hidden_size, eps=args.rms_norm_eps
)
self.post_self_attn_layernorm = nn.RMSNorm(
args.hidden_size, eps=args.rms_norm_eps
)
self.post_mlp_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:
x = x + self.post_self_attn_layernorm(
self.self_attn(self.input_layernorm(x), mask, cache)
)
residual = x
x = (
self.post_mlp_layernorm(self.mlp(self.post_attention_layernorm(x)))
+ residual
)
return x
class Glm4Model(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
self.embed_tokens = nn.Embedding(args.vocab_size, args.hidden_size)
self.layers = [
Glm4DecoderLayer(args=args) for _ in range(args.num_hidden_layers)
]
self.norm = nn.RMSNorm(args.hidden_size, eps=args.rms_norm_eps)
def __call__(
self,
inputs: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[Any] = None,
):
h = self.embed_tokens(inputs)
if mask is None:
mask = create_attention_mask(h, cache)
if cache is None:
cache = [None] * len(self.layers)
for layer, c in zip(self.layers, cache):
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 = Glm4Model(args)
self.lm_head = nn.Linear(args.hidden_size, args.vocab_size, bias=False)
def __call__(
self,
inputs: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[Any] = None,
):
out = self.model(inputs, mask, cache)
return self.lm_head(out)
@property
def layers(self):
return self.model.layers
+8 -8
View File
@@ -1,11 +1,10 @@
# Copyright © 2023-2024 Apple Inc.
from dataclasses import dataclass
from typing import Any, Dict, Optional, Tuple, Union
from typing import Any, Optional
import mlx.core as mx
import mlx.nn as nn
import numpy as np
from .base import BaseModelArgs, create_attention_mask, scaled_dot_product_attention
@@ -133,14 +132,15 @@ class GPT2Model(nn.Module):
hidden_states = self.wte(inputs)
mask = None
if hidden_states.shape[1] > 1:
offset = 0
if cache is not None and len(cache) > 0 and cache[0] is not None:
offset = cache[0].offset
position_ids = mx.array(np.arange(L))
hidden_states += self.wpe(position_ids)
position_ids = mx.arange(offset, offset + L)
hidden_states += self.wpe(position_ids)
if mask is None:
mask = create_attention_mask(hidden_states, cache)
if mask is None:
mask = create_attention_mask(hidden_states, cache)
if cache is None:
cache = [None] * len(self.h)
+1 -1
View File
@@ -1,7 +1,7 @@
# Copyright © 2023-2024 Apple Inc.
from dataclasses import dataclass
from typing import Any, Dict, Optional, Tuple, Union
from typing import Any, Optional
import mlx.core as mx
import mlx.nn as nn
+1 -2
View File
@@ -1,11 +1,10 @@
# Copyright © 2023-2024 Apple Inc.
from dataclasses import dataclass
from typing import Any, Dict, Optional, Tuple, Union
from typing import Any, Optional
import mlx.core as mx
import mlx.nn as nn
import numpy as np
from .base import BaseModelArgs, create_attention_mask, scaled_dot_product_attention
+1 -1
View File
@@ -1,7 +1,7 @@
# Copyright © 2025 Apple Inc.
from dataclasses import dataclass
from typing import Any, Optional, Tuple
from typing import Any, Optional
import mlx.core as mx
import mlx.nn as nn
-1
View File
@@ -1,6 +1,5 @@
# Copyright © 2023-2024 Apple Inc.
import math
from dataclasses import dataclass
from typing import Any, Dict, Optional, Tuple, Union
+1 -1
View File
@@ -1,7 +1,7 @@
# Copyright © 2023-2024 Apple Inc.
from dataclasses import dataclass
from typing import Any, Dict, Optional, Tuple, Union
from typing import Any, Dict, Optional, Union
import mlx.core as mx
import mlx.nn as nn
+1 -1
View File
@@ -1,7 +1,7 @@
# Copyright © 2023-2024 Apple Inc.
from dataclasses import dataclass
from typing import Any, Dict, Optional, Tuple, Union
from typing import Any, Dict, Optional, Union
import mlx.core as mx
import mlx.nn as nn
+118
View File
@@ -0,0 +1,118 @@
# Copyright © 2024 Apple Inc.
from dataclasses import dataclass
from typing import Any, Dict, Optional, Union
import mlx.core as mx
import mlx.nn as nn
from .base import BaseModelArgs
from .deepseek_v3 import DeepseekV3Model
@dataclass
class TextArgs(BaseModelArgs):
vocab_size: int = 102400
hidden_size: int = 4096
intermediate_size: int = 11008
moe_intermediate_size: int = 1407
num_hidden_layers: int = 30
num_attention_heads: int = 32
num_key_value_heads: int = 32
n_shared_experts: Optional[int] = None
n_routed_experts: Optional[int] = None
routed_scaling_factor: float = 1.0
kv_lora_rank: int = 512
q_lora_rank: int = 1536
qk_rope_head_dim: int = 64
v_head_dim: int = 128
qk_nope_head_dim: int = 128
topk_method: str = "noaux_tc"
scoring_func: str = "sigmoid"
norm_topk_prob: bool = True
n_group: Optional[int] = None
topk_group: Optional[int] = None
num_experts_per_tok: Optional[int] = None
moe_layer_freq: int = 1
first_k_dense_replace: int = 0
max_position_embeddings: int = 2048
rms_norm_eps: float = 1e-6
rope_theta: float = 10000.0
rope_scaling: Dict = None
attention_bias: bool = False
@dataclass
class ModelArgs(BaseModelArgs):
text_config: Union[TextArgs, dict]
model_type: str
def __post_init__(self):
self.text_config = TextArgs.from_dict(self.text_config)
class LanguageModel(nn.Module):
def __init__(self, config: TextArgs):
super().__init__()
self.args = config
self.model = DeepseekV3Model(config)
self.lm_head = nn.Linear(config.hidden_size, config.vocab_size, bias=False)
def __call__(
self,
inputs: mx.array,
cache: Optional[Any] = None,
mask: Optional[mx.array] = None,
):
out = self.model(inputs, cache, mask)
return self.lm_head(out)
class Model(nn.Module):
def __init__(self, config: ModelArgs):
super().__init__()
self.args = config
self.model_type = config.model_type
self.language_model = LanguageModel(config.text_config)
def __call__(
self,
inputs: mx.array,
cache: Optional[Any] = None,
mask: Optional[mx.array] = None,
):
return self.language_model(inputs, cache, mask)
def sanitize(self, weights):
def keep(key):
return (
"vision_tower" not in key
and "rotary_emb" not in key
and "multi_modal_projector" not in key
)
weights = {k: v for k, v in weights.items() if keep(k)}
# Stack experts
for l in range(self.args.text_config.num_hidden_layers):
prefix = f"language_model.model.layers.{l}"
for m in [("gate_proj"), ("down_proj"), ("up_proj")]:
for k in ["weight", "scales", "biases"]:
if f"{prefix}.mlp.experts.0.{m}.{k}" in weights:
to_join = [
weights.pop(f"{prefix}.mlp.experts.{e}.{m}.{k}")
for e in range(self.args.text_config.n_routed_experts)
]
weights[f"{prefix}.mlp.switch_mlp.{m}.{k}"] = mx.stack(to_join)
return weights
@property
def layers(self):
return self.language_model.model.layers
@property
def cast_predicate(self):
def predicate(k):
return "e_score_correction_bias" not in k
return predicate
+7 -2
View File
@@ -157,8 +157,12 @@ class LlamaModel(nn.Module):
inputs: mx.array,
mask: mx.array = None,
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 mask is None:
mask = create_attention_mask(h, cache)
@@ -186,8 +190,9 @@ class Model(nn.Module):
inputs: mx.array,
mask: mx.array = None,
cache=None,
input_embeddings: Optional[mx.array] = None,
):
out = self.model(inputs, mask, cache)
out = self.model(inputs, mask, cache, input_embeddings)
if self.args.tie_word_embeddings:
out = self.model.embed_tokens.as_linear(out)
else:
+333
View File
@@ -0,0 +1,333 @@
# Copyright © 2023-2024 Apple Inc.
from dataclasses import dataclass
from typing import Any, 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 ChunkedKVCache, KVCache
from .rope_utils import initialize_rope
from .switch_layers import SwitchGLU
@dataclass
class TextArgs(BaseModelArgs):
attention_bias: bool
attention_chunk_size: int
head_dim: int
hidden_act: str
hidden_size: int
interleave_moe_layer_step: int
intermediate_size: int
intermediate_size_mlp: int
max_position_embeddings: int
model_type: str
num_attention_heads: int
num_experts_per_tok: int
num_hidden_layers: int
num_key_value_heads: int
num_local_experts: int
rms_norm_eps: float
rope_scaling: Any
rope_theta: float
use_qk_norm: bool
vocab_size: int
attn_temperature_tuning: int = 4
floor_scale: int = 8192
attn_scale: float = 0.1
@dataclass
class ModelArgs(BaseModelArgs):
text_config: Union[TextArgs, dict]
model_type: str
def __post_init__(self):
self.text_config = TextArgs.from_dict(self.text_config)
class Attention(nn.Module):
def __init__(self, args: TextArgs, layer_idx: int):
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.use_rope = int((layer_idx + 1) % 4 != 0) # rope unused for dense layers
self.attn_temperature_tuning = args.attn_temperature_tuning
self.floor_scale = args.floor_scale
self.attn_scale = args.attn_scale
self.head_dim = head_dim = args.head_dim or args.hidden_size // n_heads
self.scale = head_dim**-0.5
if hasattr(args, "attention_bias"):
attention_bias = args.attention_bias
else:
attention_bias = False
self.q_proj = nn.Linear(dim, n_heads * head_dim, bias=attention_bias)
self.k_proj = nn.Linear(dim, n_kv_heads * head_dim, bias=attention_bias)
self.v_proj = nn.Linear(dim, n_kv_heads * head_dim, bias=attention_bias)
self.o_proj = nn.Linear(n_heads * head_dim, dim, bias=attention_bias)
self.use_qk_norm = args.use_qk_norm and self.use_rope
if self.use_rope:
self.rope = initialize_rope(
head_dim,
args.rope_theta,
traditional=True,
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, D = x.shape
queries, keys, values = self.q_proj(x), self.k_proj(x), self.v_proj(x)
queries = queries.reshape(B, L, self.n_heads, -1).transpose(0, 2, 1, 3)
keys = keys.reshape(B, L, self.n_kv_heads, -1).transpose(0, 2, 1, 3)
values = values.reshape(B, L, self.n_kv_heads, -1).transpose(0, 2, 1, 3)
if cache is not None:
offset = cache.offset
else:
offset = 0
if self.use_rope:
queries = self.rope(queries, offset=offset)
keys = self.rope(keys, offset=offset)
if self.use_qk_norm:
queries = mx.fast.rms_norm(queries, weight=None, eps=1e-6)
keys = mx.fast.rms_norm(keys, weight=None, eps=1e-6)
if self.attn_temperature_tuning and not self.use_rope:
attn_scales = (
mx.log(
mx.floor(mx.arange(offset + 1, offset + L + 1) / self.floor_scale)
+ 1.0
)
* self.attn_scale
+ 1.0
)
attn_scales = attn_scales[:, None]
queries = (queries * attn_scales).astype(queries.dtype)
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)
class MLP(nn.Module):
def __init__(self, args: ModelArgs, intermediate_size: int = None):
super().__init__()
dim = args.hidden_size
hidden_dim = intermediate_size or args.intermediate_size
self.gate_proj = nn.Linear(dim, hidden_dim, bias=False)
self.down_proj = nn.Linear(hidden_dim, dim, bias=False)
self.up_proj = nn.Linear(dim, hidden_dim, bias=False)
def __call__(self, x) -> mx.array:
return self.down_proj(nn.silu(self.gate_proj(x)) * self.up_proj(x))
class MoE(nn.Module):
def __init__(self, args):
super().__init__()
self.top_k = args.num_experts_per_tok
self.num_experts = args.num_local_experts
self.experts = SwitchGLU(
args.hidden_size, args.intermediate_size, self.num_experts
)
self.router = nn.Linear(args.hidden_size, args.num_local_experts, bias=False)
self.shared_expert = MLP(args)
def __call__(self, x) -> mx.array:
logits = self.router(x)
k = self.top_k
indices = mx.argpartition(-logits, kth=k - 1, axis=-1)[..., :k]
scores = mx.take_along_axis(logits, indices, axis=-1)
scores = mx.sigmoid(scores.astype(mx.float32)).astype(x.dtype)
out = self.experts(x * scores, indices).squeeze(2)
return out + self.shared_expert(x)
class TransformerBlock(nn.Module):
def __init__(self, args: TextArgs, layer_idx: int):
super().__init__()
self.num_attention_heads = args.num_attention_heads
self.hidden_size = args.hidden_size
self.self_attn = Attention(args, layer_idx)
self.is_moe_layer = (layer_idx % args.interleave_moe_layer_step) == (
args.interleave_moe_layer_step - 1
)
if self.is_moe_layer:
self.feed_forward = MoE(args)
else:
self.feed_forward = MLP(args, args.intermediate_size_mlp)
self.input_layernorm = nn.RMSNorm(args.hidden_size, eps=args.rms_norm_eps)
self.post_attention_layernorm = nn.RMSNorm(
args.hidden_size, eps=args.rms_norm_eps
)
self.args = args
def __call__(
self,
x: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[Any] = None,
) -> mx.array:
r = self.self_attn(self.input_layernorm(x), mask, cache)
h = x + r
r = self.feed_forward(self.post_attention_layernorm(h))
out = h + r
return out
class LlamaModel(nn.Module):
def __init__(self, args: TextArgs):
super().__init__()
self.args = args
self.vocab_size = args.vocab_size
self.num_hidden_layers = args.num_hidden_layers
assert self.vocab_size > 0
self.embed_tokens = nn.Embedding(args.vocab_size, args.hidden_size)
self.layers = [TransformerBlock(args, i) for i in range(args.num_hidden_layers)]
self.norm = nn.RMSNorm(args.hidden_size, eps=args.rms_norm_eps)
self.attention_chunk_size = args.attention_chunk_size
def __call__(
self,
inputs: mx.array,
mask: mx.array = None,
cache=None,
):
h = self.embed_tokens(inputs)
if cache is not None:
for idx, c in enumerate(cache):
if (idx + 1) % 4 != 0:
c.maybe_trim_front()
start = cache[0].start_position
offset = cache[0].offset
else:
start = 0
offset = 0
end = offset + h.shape[1]
linds = mx.arange(start, end)
rinds = mx.arange(offset, end)[:, None]
block_pos = mx.abs(
(linds // self.attention_chunk_size) - (rinds // self.attention_chunk_size)
)
token_pos = linds <= rinds
chunk_mask = (block_pos == 0) & token_pos
if mask is None:
mask = create_attention_mask(h, cache)
else:
chunk_mask &= mask
if cache is None:
cache = [None] * len(self.layers)
for idx, (layer, c) in enumerate(zip(self.layers, cache)):
use_chunked_attention = (idx + 1) % 4 != 0
if use_chunked_attention:
local_mask = chunk_mask
else:
local_mask = mask
h = layer(h, local_mask, cache=c)
return self.norm(h)
class LanguageModel(nn.Module):
def __init__(self, args: TextArgs):
super().__init__()
self.args = args
self.model_type = args.model_type
self.model = LlamaModel(self.args)
self.lm_head = nn.Linear(
self.args.hidden_size, self.args.vocab_size, bias=False
)
def __call__(
self,
inputs: mx.array,
mask: mx.array = None,
cache=None,
):
out = self.model(inputs, mask, cache)
return self.lm_head(out)
class Model(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
self.args = args
self.model_type = args.model_type
self.language_model = LanguageModel(args.text_config)
def __call__(
self,
inputs: mx.array,
mask: mx.array = None,
cache=None,
):
return self.language_model(inputs, mask, cache)
def sanitize(self, weights):
def to_remove(k):
return "vision_model" in k or "multi_modal_projector" in k
# Remove vision weights
weights = {k: v for k, v in weights.items() if not to_remove(k)}
# Rename expert weights for SwitchGLU
for l in range(self.args.text_config.num_hidden_layers):
prefix = f"language_model.model.layers.{l}.feed_forward.experts"
if f"{prefix}.gate_up_proj" in weights:
v = weights.pop(f"{prefix}.gate_up_proj")
gate_k = f"{prefix}.gate_proj.weight"
up_k = f"{prefix}.up_proj.weight"
gate_proj, up_proj = mx.split(v, 2, axis=-1)
weights[gate_k] = mx.swapaxes(gate_proj, 1, 2)
weights[up_k] = mx.swapaxes(up_proj, 1, 2)
if f"{prefix}.down_proj" in weights:
down_proj = weights.pop(f"{prefix}.down_proj")
weights[f"{prefix}.down_proj.weight"] = mx.swapaxes(down_proj, 1, 2)
return weights
@property
def layers(self):
return self.language_model.model.layers
def make_cache(self):
chunk_size = self.args.text_config.attention_chunk_size
caches = []
for i in range(len(self.layers)):
if (i + 1) % 4 != 0:
caches.append(ChunkedKVCache(chunk_size))
else:
caches.append(KVCache())
return caches
+196
View File
@@ -0,0 +1,196 @@
# Copyright © 2023-2025 Apple Inc.
from dataclasses import dataclass
from typing import Any, Dict, 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
num_key_value_heads: int
max_position_embeddings: int = 32768
rope_theta: float = 10000.0
rope_traditional: bool = False
rope_scaling: Optional[Dict[str, Union[float, str]]] = None
tie_word_embeddings: bool = False
num_nextn_predict_layers: int = 2
class Attention(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
dim = args.hidden_size
self.n_heads = n_heads = args.num_attention_heads
assert args.num_key_value_heads is not None
self.n_kv_heads = n_kv_heads = args.num_key_value_heads
head_dim = args.hidden_size // n_heads
self.scale = head_dim**-0.5
self.q_proj = nn.Linear(dim, n_heads * head_dim, bias=True)
self.k_proj = nn.Linear(dim, n_kv_heads * head_dim, bias=True)
self.v_proj = nn.Linear(dim, n_kv_heads * head_dim, bias=True)
self.o_proj = nn.Linear(n_heads * head_dim, dim, bias=False)
self.rope = initialize_rope(
head_dim,
base=args.rope_theta,
traditional=args.rope_traditional,
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, D = x.shape
queries, keys, values = self.q_proj(x), self.k_proj(x), self.v_proj(x)
queries = queries.reshape(B, L, self.n_heads, -1).transpose(0, 2, 1, 3)
keys = keys.reshape(B, L, self.n_kv_heads, -1).transpose(0, 2, 1, 3)
values = values.reshape(B, L, self.n_kv_heads, -1).transpose(0, 2, 1, 3)
if cache is not None:
queries = self.rope(queries, offset=cache.offset)
keys = self.rope(keys, offset=cache.offset)
keys, values = cache.update_and_fetch(keys, values)
else:
queries = self.rope(queries)
keys = self.rope(keys)
output = scaled_dot_product_attention(
queries, keys, values, cache=cache, scale=self.scale, mask=mask
)
output = output.transpose(0, 2, 1, 3).reshape(B, L, -1)
return self.o_proj(output)
class MLP(nn.Module):
def __init__(self, dim, hidden_dim):
super().__init__()
self.gate_proj = nn.Linear(dim, hidden_dim, bias=False)
self.down_proj = nn.Linear(hidden_dim, dim, bias=False)
self.up_proj = nn.Linear(dim, hidden_dim, bias=False)
def __call__(self, x) -> mx.array:
return self.down_proj(nn.silu(self.gate_proj(x)) * self.up_proj(x))
class TransformerBlock(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
self.num_attention_heads = args.num_attention_heads
self.hidden_size = args.hidden_size
self.self_attn = Attention(args)
self.mlp = MLP(args.hidden_size, args.intermediate_size)
self.input_layernorm = nn.RMSNorm(args.hidden_size, eps=args.rms_norm_eps)
self.post_attention_layernorm = nn.RMSNorm(
args.hidden_size, eps=args.rms_norm_eps
)
self.args = args
def __call__(
self,
x: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[Any] = None,
) -> mx.array:
r = self.self_attn(self.input_layernorm(x), mask, cache)
h = x + r
r = self.mlp(self.post_attention_layernorm(h))
out = h + r
return out
class MiMoModel(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.num_nextn_predict_layers = args.num_nextn_predict_layers
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)
]
self.norm = nn.RMSNorm(args.hidden_size, eps=args.rms_norm_eps)
def __call__(
self,
inputs: mx.array,
mask: mx.array = None,
cache=None,
):
h = self.embed_tokens(inputs)
if mask is None:
mask = create_attention_mask(h, cache)
if cache is None:
cache = [None] * len(self.layers)
for layer, c in zip(self.layers, cache):
h = layer(h, mask, c)
h = self.norm(h)
return h
class Model(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
self.args = args
self.model_type = args.model_type
self.model = MiMoModel(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,
mask: mx.array = None,
cache=None,
):
out = self.model(inputs, mask, cache)
if self.args.tie_word_embeddings:
out = self.model.embed_tokens.as_linear(out)
else:
out = self.lm_head(out)
return out
def sanitize(self, weights):
if self.args.tie_word_embeddings:
weights.pop("lm_head.weight", None)
return {
k: v
for k, v in weights.items()
if "self_attn.rotary_emb.inv_freq" not in k
and not k.startswith("model.mtp_layers.")
}
@property
def layers(self):
return self.model.layers
+11 -15
View File
@@ -1,13 +1,13 @@
# Copyright © 2023-2025 Apple Inc.
from dataclasses import dataclass
from typing import Any, Dict, Optional, Tuple, Union
from typing import Any, Dict, Optional, Union
import mlx.core as mx
import mlx.nn as nn
import numpy as np
from .base import BaseModelArgs, create_attention_mask, scaled_dot_product_attention
from .rope_utils import initialize_rope
@dataclass
@@ -23,6 +23,7 @@ class ModelArgs(BaseModelArgs):
num_key_value_heads: int
scale_depth: float
scale_emb: float
max_position_embeddings: Optional[int] = None
rope_theta: float = 1000000.0
rope_traditional: bool = False
rope_scaling: Optional[Dict[str, Union[str, float]]] = None
@@ -68,17 +69,12 @@ class Attention(nn.Module):
self.num_heads * self.head_dim, self.hidden_size, bias=False
)
rope_scale = (
1 / args.rope_scaling["factor"]
if args.rope_scaling is not None and args.rope_scaling["type"] == "linear"
else 1
)
self.rope = nn.RoPE(
dims=self.head_dim,
traditional=args.rope_traditional,
base=self.rope_theta,
scale=rope_scale,
self.rope = initialize_rope(
self.head_dim,
args.rope_theta,
args.rope_traditional,
args.rope_scaling,
args.max_position_embeddings,
)
def __call__(
@@ -138,9 +134,9 @@ class DecoderLayer(nn.Module):
cache: Optional[Any] = None,
) -> mx.array:
r = self.self_attn(self.input_layernorm(x), mask, cache)
h = x + r * (self.scale_depth / np.sqrt(self.num_hidden_layers))
h = x + r * (self.scale_depth / self.num_hidden_layers**0.5)
r = self.mlp(self.post_attention_layernorm(h))
out = h + r * (self.scale_depth / np.sqrt(self.num_hidden_layers))
out = h + r * (self.scale_depth / self.num_hidden_layers**0.5)
return out
+250
View File
@@ -0,0 +1,250 @@
# Copyright © 2023-2025 Apple Inc.
from dataclasses import dataclass
from typing import Any, Dict, 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 SuScaledRoPE
@dataclass
class ModelArgs(BaseModelArgs):
model_type: str
hidden_size: int
dim_model_base: int
num_hidden_layers: int
intermediate_size: int
num_attention_heads: int
rms_norm_eps: float
vocab_size: int
num_key_value_heads: int
q_lora_rank: int
qk_nope_head_dim: int
qk_rope_head_dim: int
kv_lora_rank: int
scale_depth: float
scale_emb: float
max_position_embeddings: int
attention_bias: bool = False
rope_theta: float = 1000000.0
rope_traditional: bool = False
rope_scaling: Optional[Dict[str, Union[str, float]]] = None
tie_word_embeddings: bool = False
class Attention(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
self.args = args
self.qk_rope_head_dim = self.args.qk_rope_head_dim
self.qk_nope_head_dim = self.args.qk_nope_head_dim
self.attention_bias = self.args.attention_bias
self.kv_lora_rank = self.args.kv_lora_rank
self.num_heads = self.args.num_attention_heads
self.q_lora_rank = self.args.q_lora_rank
self.hidden_size = self.args.hidden_size
self.v_head_dim = self.hidden_size // self.args.num_attention_heads
self.q_head_dim = self.qk_nope_head_dim + self.qk_rope_head_dim
self.softmax_scale = self.q_head_dim ** (-0.5)
self.q_a_proj = nn.Linear(
self.hidden_size, self.q_lora_rank, bias=self.attention_bias
)
self.q_a_layernorm = nn.RMSNorm(self.q_lora_rank)
self.q_b_proj = nn.Linear(
self.q_lora_rank, self.num_heads * self.q_head_dim, bias=False
)
self.kv_a_proj_with_mqa = nn.Linear(
self.hidden_size,
self.kv_lora_rank + self.qk_rope_head_dim,
bias=self.attention_bias,
)
self.kv_a_layernorm = nn.RMSNorm(self.kv_lora_rank)
self.kv_b_proj = nn.Linear(
self.kv_lora_rank,
self.num_heads
* (self.q_head_dim - self.qk_rope_head_dim + self.v_head_dim),
bias=False,
)
self.o_proj = nn.Linear(
self.num_heads * self.v_head_dim,
self.hidden_size,
bias=self.attention_bias,
)
self.rope = SuScaledRoPE(
dims=args.qk_rope_head_dim,
base=args.rope_theta,
max_position_embeddings=args.max_position_embeddings,
original_max_position_embeddings=args.rope_scaling.get(
"original_max_position_embeddings", 4096
),
short_factor=args.rope_scaling.get("short_factor", 1.0),
long_factor=args.rope_scaling.get("long_factor", 1.0),
)
def __call__(
self,
x: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[Dict[str, mx.array]] = None,
):
B, L, _ = x.shape
# Project query
q = self.q_b_proj(self.q_a_layernorm(self.q_a_proj(x)))
q = q.reshape(B, L, self.num_heads, -1).transpose(0, 2, 1, 3)
q_nope, q_pe = mx.split(q, [self.qk_nope_head_dim], axis=-1)
# Project key and value
compressed_kv = self.kv_a_proj_with_mqa(x)
compressed_kv, k_pe = mx.split(compressed_kv, [self.kv_lora_rank], axis=-1)
k_pe = k_pe.reshape(B, L, 1, self.qk_rope_head_dim).transpose(0, 2, 1, 3)
kv = self.kv_b_proj(self.kv_a_layernorm(compressed_kv))
kv = kv.reshape(B, L, self.num_heads, -1).transpose(0, 2, 1, 3)
k_nope, values = mx.split(kv, [self.qk_nope_head_dim], axis=-1)
# Apply RoPE to the query and key parts that need position embedding
if cache is not None:
q_pe = self.rope(q_pe, offset=cache.offset)
k_pe = self.rope(k_pe, offset=cache.offset)
else:
q_pe = self.rope(q_pe)
k_pe = self.rope(k_pe)
# Create the full query and key tensors by combining the parts
# Broadcast k_pe to all heads
k_pe_broadcasted = mx.broadcast_to(
k_pe, (B, self.num_heads, L, self.qk_rope_head_dim)
)
# Use concatenate for queries
queries = mx.concatenate([q_nope, q_pe], axis=-1)
# Use concatenate for keys
keys = mx.concatenate([k_nope, k_pe_broadcasted], axis=-1)
# Update cache if needed
if cache is not None:
keys, values = cache.update_and_fetch(keys, values)
# Perform attention
output = scaled_dot_product_attention(
queries, keys, values, cache=cache, scale=self.softmax_scale, mask=mask
)
output = output.transpose(0, 2, 1, 3).reshape(B, L, -1)
return self.o_proj(output)
class MLP(nn.Module):
def __init__(self, args):
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):
return self.down_proj(nn.silu(self.gate_proj(x)) * self.up_proj(x))
class DecoderLayer(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
self.args = args
self.hidden_size = args.hidden_size
self.num_hidden_layers = args.num_hidden_layers
self.self_attn = Attention(args)
self.mlp = MLP(args)
self.input_layernorm = nn.RMSNorm(args.hidden_size, eps=args.rms_norm_eps)
self.post_attention_layernorm = nn.RMSNorm(
args.hidden_size, eps=args.rms_norm_eps
)
self.scale_depth = args.scale_depth
self.num_hidden_layers = args.num_hidden_layers
def __call__(
self,
x: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[Any] = None,
) -> mx.array:
r = self.self_attn(self.input_layernorm(x), mask, cache)
h = x + r * (self.scale_depth / (self.num_hidden_layers**0.5))
r = self.mlp(self.post_attention_layernorm(h))
out = h + r * (self.scale_depth / (self.num_hidden_layers**0.5))
return out
class MiniCPM3Model(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
self.args = args
self.vocab_size = args.vocab_size
assert self.vocab_size > 0
self.embed_tokens = nn.Embedding(args.vocab_size, args.hidden_size)
self.layers = [DecoderLayer(args) for _ in range(args.num_hidden_layers)]
self.norm = nn.RMSNorm(args.hidden_size, eps=args.rms_norm_eps)
def __call__(
self,
inputs: mx.array,
mask: mx.array = None,
cache=None,
):
h = self.embed_tokens(inputs) * self.args.scale_emb
if mask is None:
mask = create_attention_mask(h, cache)
if cache is None:
cache = [None] * len(self.layers)
for layer, c in zip(self.layers, cache):
h = layer(h, mask, 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 = MiniCPM3Model(args)
if not self.args.tie_word_embeddings:
self.lm_head = nn.Linear(args.hidden_size, args.vocab_size, bias=False)
def __call__(
self,
inputs: mx.array,
mask: mx.array = None,
cache=None,
):
out = self.model(inputs, mask, cache)
if not self.args.tie_word_embeddings:
out = self.lm_head(out / (self.args.hidden_size / self.args.dim_model_base))
else:
out = self.model.embed_tokens.as_linear(out)
return out
@property
def layers(self):
return self.model.layers
+49
View File
@@ -0,0 +1,49 @@
# 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 llama
from .base import BaseModelArgs
@dataclass
class ModelArgs(BaseModelArgs):
model_type: str
text_config: dict
def __post_init__(self):
self.text_config["tie_word_embeddings"] = False
class Model(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
self.args = args
self.model_type = args.model_type
self.language_model = llama.Model(llama.ModelArgs.from_dict(args.text_config))
def __call__(
self,
inputs: mx.array,
cache=None,
mask: Optional[mx.array] = None,
input_embeddings: Optional[mx.array] = None,
):
return self.language_model(
inputs, cache=cache, mask=mask, input_embeddings=input_embeddings
)
def sanitize(self, weights):
weights = tree_unflatten(list(weights.items()))
weights.pop("vision_tower", None)
weights.pop("multi_modal_projector", None)
return dict(tree_flatten(weights))
@property
def layers(self):
return self.language_model.model.layers
+1 -2
View File
@@ -1,8 +1,7 @@
# Copyright © 2023-2024 Apple Inc.
import math
from dataclasses import dataclass
from typing import Any, Dict, Optional, Tuple, Union
from typing import Any, Dict, Optional, Union
import mlx.core as mx
import mlx.nn as nn
+385
View File
@@ -0,0 +1,385 @@
# Copyright © 2025 Apple Inc.
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Union
import mlx.core as mx
import mlx.nn as nn
from .base import BaseModelArgs, create_attention_mask, scaled_dot_product_attention
from .rope_utils import initialize_rope
@dataclass(frozen=True)
class AttentionConfig:
no_op: bool = False
replace_with_linear: bool = False
sparsify: Optional[list[str]] = None
n_heads_in_group: Optional[int] = None # GQA group size
window_length: Optional[int] = None # Not directly used here, placeholder
num_sink_tokens: Optional[int] = None # Not directly used here, placeholder
use_prefill_window_in_sink_attention: bool = (
False # Not directly used here, placeholder
)
unshifted_sink: bool = False # Not directly used here, placeholder
def __post_init__(self):
# Ensure consistency: If no-op or linear, other attn params are irrelevant
if self.no_op or self.replace_with_linear:
# Use object.__setattr__ because the dataclass is frozen
object.__setattr__(self, "n_heads_in_group", None)
object.__setattr__(self, "window_length", None)
object.__setattr__(self, "num_sink_tokens", None)
# If it's a standard attention block, n_heads_in_group must be provided
elif not self.no_op:
if self.n_heads_in_group is None:
raise ValueError(
"n_heads_in_group must be specified for active attention blocks"
)
if self.n_heads_in_group <= 0:
raise ValueError(
f"n_heads_in_group must be positive, got {self.n_heads_in_group}"
)
@dataclass(frozen=True)
class FFNConfig:
no_op: bool = False
replace_with_linear: bool = False
sparsify: Optional[list[str]] = None
ffn_mult: Optional[float] = None
def __post_init__(self):
# Ensure consistency: If no-op or linear, ffn_mult is irrelevant
if self.no_op or self.replace_with_linear:
object.__setattr__(self, "ffn_mult", None)
# If it's a standard FFN block, ffn_mult must be provided
elif not self.no_op:
if self.ffn_mult is None:
raise ValueError("ffn_mult must be specified for active FFN blocks")
# Round to prevent potential floating point inconsistencies if needed
object.__setattr__(self, "ffn_mult", round(self.ffn_mult, 6))
@dataclass(frozen=True)
class BlockConfig:
attention: AttentionConfig
ffn: FFNConfig
@classmethod
def from_dict(cls, data: dict):
# Helper to create BlockConfig from a dictionary (e.g., loaded from JSON)
attn_conf = AttentionConfig(**data.get("attention", {}))
ffn_conf = FFNConfig(**data.get("ffn", {}))
return cls(attention=attn_conf, ffn=ffn_conf)
def _find_multiple(n: int, k: int) -> int:
"""Finds the smallest multiple of k greater than or equal to n."""
if n % k == 0:
return n
return n + k - (n % k)
def _ffn_mult_to_intermediate_size(ffn_mult: float, n_embd: int) -> int:
"""Calculates intermediate size based on multiplier, rounding up to multiple of 256."""
intermediate_size = int(2 * ffn_mult * n_embd / 3)
return _find_multiple(intermediate_size, 256)
# Activation function mapping
_ACT2FN = {
"silu": nn.silu,
"relu": nn.relu,
"gelu": nn.gelu,
"gelu_new": nn.gelu_approx,
"gelu_fast": nn.gelu_approx,
}
@dataclass
class ModelArgs(BaseModelArgs):
model_type: str = "nemotron-nas"
hidden_size: int = 8192
num_hidden_layers: int = 80
num_attention_heads: int = 64
rms_norm_eps: float = 1e-5
vocab_size: int = 128256
block_configs: list = field(default_factory=list) # List of BlockConfig or dicts
hidden_act: str = "silu"
attention_bias: bool = False
mlp_bias: bool = False
rope_theta: float = 500000.0
rope_scaling: Optional[Dict[str, Union[float, str]]] = None
max_position_embeddings: int = 131072
tie_word_embeddings: bool = False
def __post_init__(self):
# Automatically parse block_configs if they are loaded as dicts
if self.block_configs and isinstance(self.block_configs[0], dict):
self.block_configs = [
BlockConfig.from_dict(conf) for conf in self.block_configs
]
if len(self.block_configs) != self.num_hidden_layers:
raise ValueError(
f"Number of block_configs ({len(self.block_configs)}) must match "
f"num_hidden_layers ({self.num_hidden_layers})"
)
# Basic validation for RoPE scaling if provided
if self.rope_scaling:
if "factor" not in self.rope_scaling:
raise ValueError("rope_scaling must contain 'factor'")
rope_type = self.rope_scaling.get("rope_type")
if rope_type is None:
raise ValueError("rope_scaling must contain 'rope_type'")
# Validate individual block configs (post_init in dataclasses already does some)
for i, block_conf in enumerate(self.block_configs):
attn_conf = block_conf.attention
if not attn_conf.no_op and not attn_conf.replace_with_linear:
if self.num_attention_heads % attn_conf.n_heads_in_group != 0:
raise ValueError(
f"Layer {i}: num_attention_heads ({self.num_attention_heads}) "
f"must be divisible by n_heads_in_group ({attn_conf.n_heads_in_group})"
)
class Attention(nn.Module):
"""Standard GQA Attention mechanism for layers that use it."""
def __init__(self, args: ModelArgs, attention_config: AttentionConfig):
super().__init__()
dim = args.hidden_size
self.n_heads = n_heads = args.num_attention_heads
self.n_kv_heads = n_kv_heads = n_heads // attention_config.n_heads_in_group
self.head_dim = head_dim = args.hidden_size // n_heads
if (self.head_dim * n_heads) != dim:
raise ValueError(
f"hidden_size ({dim}) must be divisible by num_attention_heads ({n_heads})"
)
self.scale = head_dim**-0.5
self.q_proj = nn.Linear(dim, n_heads * head_dim, bias=args.attention_bias)
self.k_proj = nn.Linear(dim, n_kv_heads * head_dim, bias=args.attention_bias)
self.v_proj = nn.Linear(dim, n_kv_heads * head_dim, bias=args.attention_bias)
self.o_proj = nn.Linear(n_heads * head_dim, dim, bias=args.attention_bias)
# Initialize RoPE based on global config
self.rope = initialize_rope(
self.head_dim,
args.rope_theta,
False, # Llama uses traditional=False
args.rope_scaling,
args.max_position_embeddings,
)
def __call__(
self,
x: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[Any] = None,
) -> mx.array:
B, L, D = x.shape
queries, keys, values = self.q_proj(x), self.k_proj(x), self.v_proj(x)
queries = queries.reshape(B, L, self.n_heads, self.head_dim).transpose(
0, 2, 1, 3
)
keys = keys.reshape(B, L, self.n_kv_heads, self.head_dim).transpose(0, 2, 1, 3)
values = values.reshape(B, L, self.n_kv_heads, self.head_dim).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 MLP(nn.Module):
"""Standard Feed-Forward Network for layers that use it."""
def __init__(self, args: ModelArgs, ffn_config: FFNConfig):
super().__init__()
dim = args.hidden_size
# Calculate intermediate dim based on layer's specific config
hidden_dim = _ffn_mult_to_intermediate_size(ffn_config.ffn_mult, dim)
self.gate_proj = nn.Linear(dim, hidden_dim, bias=args.mlp_bias)
self.down_proj = nn.Linear(hidden_dim, dim, bias=args.mlp_bias)
self.up_proj = nn.Linear(dim, hidden_dim, bias=args.mlp_bias)
try:
self.act_fn = _ACT2FN[args.hidden_act]
except KeyError:
raise ValueError(f"Unknown activation function: {args.hidden_act}")
def __call__(self, x) -> mx.array:
return self.down_proj(self.act_fn(self.gate_proj(x)) * self.up_proj(x))
class LinearSubblockReplacement(nn.Module):
"""A simple linear layer used to replace Attention or MLP blocks."""
def __init__(self, hidden_size: int, bias: bool):
super().__init__()
self.linear = nn.Linear(hidden_size, hidden_size, bias=bias)
def __call__(self, x: mx.array, *args, **kwargs) -> mx.array:
# Accepts potential extra args (like mask, cache) but ignores them
return self.linear(x)
class TransformerBlock(nn.Module):
"""A single transformer block, potentially heterogeneous based on config."""
def __init__(self, args: ModelArgs, layer_idx: int):
super().__init__()
self.hidden_size = args.hidden_size
# Get the specific configuration for this layer
block_config = args.block_configs[layer_idx]
self.attention_config = block_config.attention
self.ffn_config = block_config.ffn
# Conditionally initialize Input LayerNorm (needed unless Attention is no-op)
if not self.attention_config.no_op:
self.input_layernorm = nn.RMSNorm(args.hidden_size, eps=args.rms_norm_eps)
else:
self.input_layernorm = None
# Conditionally initialize Attention block
if self.attention_config.no_op:
self.self_attn = None
elif self.attention_config.replace_with_linear:
self.self_attn = LinearSubblockReplacement(
args.hidden_size, args.attention_bias
)
else:
# Standard attention for this layer
self.self_attn = Attention(args, self.attention_config)
# Conditionally initialize Post-Attention LayerNorm (needed unless FFN is no-op)
if not self.ffn_config.no_op:
self.post_attention_layernorm = nn.RMSNorm(
args.hidden_size, eps=args.rms_norm_eps
)
else:
self.post_attention_layernorm = None
# Conditionally initialize MLP block
if self.ffn_config.no_op:
self.mlp = None
elif self.ffn_config.replace_with_linear:
self.mlp = LinearSubblockReplacement(args.hidden_size, args.mlp_bias)
else:
# Standard MLP for this layer
self.mlp = MLP(args, self.ffn_config)
def __call__(
self,
x: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[Any] = None,
) -> mx.array:
# Attention part (Input Norm -> Attention -> Residual)
if self.self_attn is not None:
residual = x
h = self.input_layernorm(x)
attn_out = self.self_attn(h, mask=mask, cache=cache)
x = residual + attn_out
# MLP part (Post-Attention Norm -> MLP -> Residual)
if self.mlp is not None:
residual = x
h = self.post_attention_layernorm(x)
mlp_out = self.mlp(h)
x = residual + mlp_out
return x
class NemotronNASModel(nn.Module):
"""The core Nemotron-NAS style transformer model."""
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 = [
TransformerBlock(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)
def __call__(
self,
inputs: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[List[Any]] = None,
):
h = self.embed_tokens(inputs)
if mask is None:
mask = create_attention_mask(h, cache)
if cache is None:
cache = [None] * len(self.layers)
for i, layer in enumerate(self.layers):
h = layer(h, mask, cache=cache[i])
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 = NemotronNASModel(args)
if not args.tie_word_embeddings:
self.lm_head = nn.Linear(args.hidden_size, args.vocab_size, bias=False)
else:
self.lm_head = None
def __call__(
self,
inputs: mx.array,
mask=None,
cache=None,
):
out = self.model(inputs, mask=mask, cache=cache)
if self.args.tie_word_embeddings:
out = self.model.embed_tokens.as_linear(out)
else:
out = self.lm_head(out)
return out
def sanitize(self, weights):
if self.args.tie_word_embeddings:
weights.pop("lm_head.weight", None)
return weights
@property
def layers(self):
return self.model.layers
+1 -1
View File
@@ -2,7 +2,7 @@
import sys
from dataclasses import dataclass
from typing import Any, Optional, Tuple
from typing import Any, Optional
import mlx.core as mx
import mlx.nn as nn
+1 -1
View File
@@ -1,7 +1,7 @@
# Copyright © 2023-2024 Apple Inc.
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple, Union
from typing import Any, Dict, List, Optional, Union
import mlx.core as mx
import mlx.nn as nn
+1 -3
View File
@@ -2,7 +2,6 @@
import math
from dataclasses import dataclass
from typing import Tuple
import mlx.core as mx
import mlx.nn as nn
@@ -112,10 +111,9 @@ class PhiMLP(nn.Module):
super().__init__()
self.fc1 = nn.Linear(config.hidden_size, config.intermediate_size)
self.fc2 = nn.Linear(config.intermediate_size, config.hidden_size)
self.act = nn.GELU(approx="precise")
def __call__(self, x) -> mx.array:
return self.fc2(self.act(self.fc1(x)))
return self.fc2(nn.gelu_approx(self.fc1(x)))
class PhiDecoderLayer(nn.Module):
+2 -2
View File
@@ -7,7 +7,7 @@ import mlx.core as mx
import mlx.nn as nn
from .base import BaseModelArgs, create_attention_mask, scaled_dot_product_attention
from .su_rope import SuScaledRotaryEmbedding
from .rope_utils import SuScaledRoPE
@dataclass
@@ -63,7 +63,7 @@ class Attention(nn.Module):
rope_dim = int(head_dim * args.partial_rotary_factor)
if args.rope_scaling and args.rope_scaling["type"] in ["longrope", "su"]:
self.rope = SuScaledRotaryEmbedding(
self.rope = SuScaledRoPE(
rope_dim,
base=args.rope_theta,
max_position_embeddings=args.max_position_embeddings,
+1 -1
View File
@@ -266,7 +266,7 @@ class Phi3Model(nn.Module):
h = self.mup_embedding_multiplier * h
if mask is None:
mask = create_attention_mask(h, cache)
mask = create_attention_mask(h, cache, return_array=True)
if cache is None:
cache = [None] * len(self.layers)
+2 -2
View File
@@ -7,7 +7,7 @@ import mlx.core as mx
import mlx.nn as nn
from .base import BaseModelArgs, create_attention_mask, scaled_dot_product_attention
from .su_rope import SuScaledRotaryEmbedding
from .rope_utils import SuScaledRoPE
from .switch_layers import SwitchGLU
@@ -45,7 +45,7 @@ class Attention(nn.Module):
self.v_proj = nn.Linear(dim, n_kv_heads * head_dim, bias=True)
self.o_proj = nn.Linear(n_heads * head_dim, dim, bias=True)
self.rope = SuScaledRotaryEmbedding(
self.rope = SuScaledRoPE(
head_dim,
base=args.rope_theta,
max_position_embeddings=args.max_position_embeddings,
+52
View File
@@ -0,0 +1,52 @@
# 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 llama
from .base import BaseModelArgs
@dataclass
class ModelArgs(BaseModelArgs):
model_type: str
text_config: dict
def __post_init__(self):
self.text_config["tie_word_embeddings"] = False
self.text_config["num_attention_heads"] = self.text_config.get(
"num_attention_heads", 32
)
class Model(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
self.args = args
self.model_type = args.model_type
self.language_model = llama.Model(llama.ModelArgs.from_dict(args.text_config))
def __call__(
self,
inputs: mx.array,
cache=None,
mask: Optional[mx.array] = None,
input_embeddings: Optional[mx.array] = None,
):
return self.language_model(
inputs, cache=cache, mask=mask, input_embeddings=input_embeddings
)
def sanitize(self, weights):
weights = tree_unflatten(list(weights.items()))
weights.pop("vision_tower", None)
weights.pop("multi_modal_projector", None)
return dict(tree_flatten(weights))
@property
def layers(self):
return self.language_model.model.layers
+3 -12
View File
@@ -6,6 +6,7 @@ from typing import Any, Optional
import mlx.core as mx
import mlx.nn as nn
from mlx_lm.models.base import BaseModelArgs, create_attention_mask
from .cache import KVCache, MambaCache
@@ -52,16 +53,6 @@ class RMSNorm(nn.Module):
)
def _rms_norm(hidden_states: mx.array, eps: float) -> mx.array:
input_dtype = hidden_states.dtype
hidden_states = hidden_states.astype(mx.float32)
variance = mx.power(hidden_states, 2).mean(-1, keepdims=True)
hidden_states = hidden_states * mx.rsqrt(variance + eps)
hidden_states = hidden_states.astype(input_dtype)
return hidden_states
def get_initial_dt_bias(num_heads: int) -> mx.array:
dt_min = 0.001
dt_max = 0.1
@@ -400,8 +391,8 @@ class Attention(nn.Module):
k = k.reshape(B, T, self.k_num_heads, self.qk_dim).transpose(0, 2, 1, 3)
v = v.reshape(B, T, self.v_num_heads, self.v_dim).transpose(0, 2, 1, 3)
q = _rms_norm(q, 1e-6) * self.q_weight[:, None]
k = _rms_norm(k, 1e-6) * self.k_weight[:, None]
q = mx.fast.rms_norm(q, weight=None, eps=1e-6) * self.q_weight[:, None]
k = mx.fast.rms_norm(k, weight=None, eps=1e-6) * self.k_weight[:, None]
if cache is not None:
q = self.rope(q, offset=cache.offset)
+14 -23
View File
@@ -7,6 +7,7 @@ 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
@@ -18,24 +19,13 @@ class ModelArgs(BaseModelArgs):
num_attention_heads: int
rms_norm_eps: float
vocab_size: int
num_key_value_heads: Optional[int] = None
num_key_value_heads: int
max_position_embeddings: int = 32768
rope_theta: float = 1000000
rope_traditional: bool = False
rope_scaling: Optional[Dict[str, Union[float, str]]] = None
tie_word_embeddings: bool = True
def __post_init__(self):
if self.num_key_value_heads is None:
self.num_key_value_heads = self.num_attention_heads
if self.rope_scaling:
required_keys = {"factor", "type"}
if not all(key in self.rope_scaling for key in required_keys):
raise ValueError(f"rope_scaling must contain keys {required_keys}")
if self.rope_scaling["type"] != "linear":
raise ValueError("rope_scaling 'type' currently only supports 'linear'")
class Attention(nn.Module):
def __init__(self, args: ModelArgs):
@@ -54,16 +44,12 @@ class Attention(nn.Module):
self.v_proj = nn.Linear(dim, n_kv_heads * head_dim, bias=True)
self.o_proj = nn.Linear(n_heads * head_dim, dim, bias=False)
rope_scale = (
1 / args.rope_scaling["factor"]
if args.rope_scaling is not None and args.rope_scaling["type"] == "linear"
else 1
)
self.rope = nn.RoPE(
self.rope = initialize_rope(
head_dim,
traditional=args.rope_traditional,
base=args.rope_theta,
scale=rope_scale,
traditional=args.rope_traditional,
scaling_config=args.rope_scaling,
max_position_embeddings=args.max_position_embeddings,
)
def __call__(
@@ -151,8 +137,12 @@ class Qwen2Model(nn.Module):
inputs: mx.array,
mask: mx.array = None,
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 mask is None:
mask = create_attention_mask(h, cache)
@@ -180,8 +170,9 @@ class Model(nn.Module):
inputs: mx.array,
mask: mx.array = None,
cache=None,
input_embeddings: Optional[mx.array] = None,
):
out = self.model(inputs, mask, cache)
out = self.model(inputs, mask, cache, input_embeddings)
if self.args.tie_word_embeddings:
out = self.model.embed_tokens.as_linear(out)
else:
-1
View File
@@ -1,6 +1,5 @@
# Copyright © 2023-2024 Apple Inc.
import math
from dataclasses import dataclass
from typing import Any, Dict, Optional, Union
+189
View File
@@ -0,0 +1,189 @@
# Copyright © 2023-2024 Apple Inc.
from dataclasses import dataclass
from typing import Any, Dict, 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
num_key_value_heads: int
max_position_embeddings: int
rope_theta: float
head_dim: int
tie_word_embeddings: bool
rope_scaling: Optional[Dict[str, Union[float, str]]] = None
class Attention(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
dim = args.hidden_size
self.n_heads = n_heads = args.num_attention_heads
assert args.num_key_value_heads is not None
self.n_kv_heads = n_kv_heads = args.num_key_value_heads
head_dim = args.head_dim
self.scale = head_dim**-0.5
self.q_proj = nn.Linear(dim, n_heads * head_dim, bias=False)
self.k_proj = nn.Linear(dim, n_kv_heads * head_dim, bias=False)
self.v_proj = nn.Linear(dim, n_kv_heads * head_dim, bias=False)
self.o_proj = nn.Linear(n_heads * head_dim, dim, bias=False)
self.q_norm = nn.RMSNorm(head_dim, eps=args.rms_norm_eps)
self.k_norm = nn.RMSNorm(head_dim, eps=args.rms_norm_eps)
self.rope = initialize_rope(
head_dim,
base=args.rope_theta,
traditional=False,
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, D = x.shape
queries, keys, values = self.q_proj(x), self.k_proj(x), self.v_proj(x)
queries = self.q_norm(queries.reshape(B, L, self.n_heads, -1)).transpose(
0, 2, 1, 3
)
keys = self.k_norm(keys.reshape(B, L, self.n_kv_heads, -1)).transpose(
0, 2, 1, 3
)
values = values.reshape(B, L, self.n_kv_heads, -1).transpose(0, 2, 1, 3)
if cache is not None:
queries = self.rope(queries, offset=cache.offset)
keys = self.rope(keys, offset=cache.offset)
keys, values = cache.update_and_fetch(keys, values)
else:
queries = self.rope(queries)
keys = self.rope(keys)
output = scaled_dot_product_attention(
queries, keys, values, cache=cache, scale=self.scale, mask=mask
)
output = output.transpose(0, 2, 1, 3).reshape(B, L, -1)
return self.o_proj(output)
class MLP(nn.Module):
def __init__(self, dim, hidden_dim):
super().__init__()
self.gate_proj = nn.Linear(dim, hidden_dim, bias=False)
self.down_proj = nn.Linear(hidden_dim, dim, bias=False)
self.up_proj = nn.Linear(dim, hidden_dim, bias=False)
def __call__(self, x) -> mx.array:
return self.down_proj(nn.silu(self.gate_proj(x)) * self.up_proj(x))
class TransformerBlock(nn.Module):
def __init__(self, args: ModelArgs):
super().__init__()
self.num_attention_heads = args.num_attention_heads
self.hidden_size = args.hidden_size
self.self_attn = Attention(args)
self.mlp = MLP(args.hidden_size, args.intermediate_size)
self.input_layernorm = nn.RMSNorm(args.hidden_size, eps=args.rms_norm_eps)
self.post_attention_layernorm = nn.RMSNorm(
args.hidden_size, eps=args.rms_norm_eps
)
self.args = args
def __call__(
self,
x: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[Any] = None,
) -> mx.array:
r = self.self_attn(self.input_layernorm(x), mask, cache)
h = x + r
r = self.mlp(self.post_attention_layernorm(h))
out = h + r
return out
class Qwen3Model(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
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)
]
self.norm = nn.RMSNorm(args.hidden_size, eps=args.rms_norm_eps)
def __call__(
self,
inputs: mx.array,
mask: mx.array = None,
cache=None,
):
h = self.embed_tokens(inputs)
if mask is None:
mask = create_attention_mask(h, cache)
if cache is None:
cache = [None] * len(self.layers)
for layer, c in zip(self.layers, cache):
h = layer(h, mask, 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 = Qwen3Model(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,
mask: mx.array = None,
cache=None,
):
out = self.model(inputs, mask, cache)
if self.args.tie_word_embeddings:
out = self.model.embed_tokens.as_linear(out)
else:
out = self.lm_head(out)
return out
def sanitize(self, weights):
if self.args.tie_word_embeddings:
weights.pop("lm_head.weight", None)
return weights
@property
def layers(self):
return self.model.layers
+240
View File
@@ -0,0 +1,240 @@
# 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 .switch_layers import SwitchGLU
@dataclass
class ModelArgs(BaseModelArgs):
model_type: str
hidden_size: int
num_hidden_layers: int
intermediate_size: int
num_attention_heads: int
num_experts: int
num_experts_per_tok: int
decoder_sparse_step: int
mlp_only_layers: List[int]
moe_intermediate_size: int
rms_norm_eps: float
vocab_size: int
num_key_value_heads: int
head_dim: int
rope_theta: float
tie_word_embeddings: bool
max_position_embeddings: int
norm_topk_prob: bool
rope_scaling: Optional[Dict[str, Union[float, str]]] = None
class Attention(nn.Module):
def __init__(self, args: ModelArgs, layer_idx: int):
super().__init__()
dim = args.hidden_size
self.n_heads = n_heads = args.num_attention_heads
assert args.num_key_value_heads is not None
self.n_kv_heads = n_kv_heads = args.num_key_value_heads
head_dim = getattr(
args, "head_dim", args.hidden_size // args.num_attention_heads
)
self.scale = head_dim**-0.5
self.q_proj = nn.Linear(dim, n_heads * head_dim, bias=False)
self.k_proj = nn.Linear(dim, n_kv_heads * head_dim, bias=False)
self.v_proj = nn.Linear(dim, n_kv_heads * head_dim, bias=False)
self.o_proj = nn.Linear(n_heads * head_dim, dim, bias=False)
self.q_norm = nn.RMSNorm(head_dim, eps=args.rms_norm_eps)
self.k_norm = nn.RMSNorm(head_dim, eps=args.rms_norm_eps)
self.rope = nn.RoPE(
head_dim,
traditional=False,
base=args.rope_theta,
)
def __call__(
self,
x: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[Any] = None,
) -> mx.array:
B, L, D = x.shape
queries, keys, values = self.q_proj(x), self.k_proj(x), self.v_proj(x)
# Prepare the queries, keys and values for the attention computation
queries = self.q_norm(queries.reshape(B, L, self.n_heads, -1)).transpose(
0, 2, 1, 3
)
keys = self.k_norm(keys.reshape(B, L, self.n_kv_heads, -1)).transpose(
0, 2, 1, 3
)
values = values.reshape(B, L, self.n_kv_heads, -1).transpose(0, 2, 1, 3)
if cache is not None:
queries = self.rope(queries, offset=cache.offset)
keys = self.rope(keys, offset=cache.offset)
keys, values = cache.update_and_fetch(keys, values)
else:
queries = self.rope(queries)
keys = self.rope(keys)
output = scaled_dot_product_attention(
queries, keys, values, cache=cache, scale=self.scale, mask=mask
)
output = output.transpose(0, 2, 1, 3).reshape(B, L, -1)
return self.o_proj(output)
class MLP(nn.Module):
def __init__(self, dim, hidden_dim):
super().__init__()
self.gate_proj = nn.Linear(dim, hidden_dim, bias=False)
self.down_proj = nn.Linear(hidden_dim, dim, bias=False)
self.up_proj = nn.Linear(dim, hidden_dim, bias=False)
def __call__(self, x) -> mx.array:
return self.down_proj(nn.silu(self.gate_proj(x)) * self.up_proj(x))
class Qwen3MoeSparseMoeBlock(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.gate = nn.Linear(dim, num_experts, bias=False)
self.switch_mlp = SwitchGLU(dim, intermediate_size, num_experts)
def __call__(
self,
x: mx.array,
):
gates = self.gate(x)
gates = mx.softmax(gates, axis=-1, precise=True)
k = self.top_k
inds = mx.stop_gradient(mx.argpartition(-gates, kth=k - 1, 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)
y = self.switch_mlp(x, inds)
y = (y * scores[..., None]).sum(axis=-2)
return y
class Qwen3MoeDecoderLayer(nn.Module):
def __init__(self, args: ModelArgs, layer_idx: int):
super().__init__()
self.hidden_size = args.hidden_size
self.self_attn = Attention(args, layer_idx)
self.input_layernorm = nn.RMSNorm(args.hidden_size, eps=args.rms_norm_eps)
self.post_attention_layernorm = nn.RMSNorm(
args.hidden_size, eps=args.rms_norm_eps
)
self.args = args
if (layer_idx not in args.mlp_only_layers) and (
args.num_experts > 0 and (layer_idx + 1) % args.decoder_sparse_step == 0
):
self.mlp = Qwen3MoeSparseMoeBlock(args)
else:
self.mlp = MLP(args.hidden_size, args.intermediate_size)
def __call__(
self,
x: mx.array,
mask: Optional[mx.array] = None,
cache: Optional[Any] = None,
) -> mx.array:
r = self.self_attn(self.input_layernorm(x), mask, cache)
h = x + r
r = self.mlp(self.post_attention_layernorm(h))
out = h + r
return out
class Qwen3MoeModel(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
assert self.vocab_size > 0
self.embed_tokens = nn.Embedding(args.vocab_size, args.hidden_size)
self.layers = [
Qwen3MoeDecoderLayer(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)
def __call__(
self,
inputs: mx.array,
mask: mx.array = None,
cache=None,
):
h = self.embed_tokens(inputs)
if mask is None:
mask = create_attention_mask(h, cache)
if cache is None:
cache = [None] * len(self.layers)
for layer, c in zip(self.layers, cache):
h = layer(h, mask, 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 = Qwen3MoeModel(args)
self.lm_head = nn.Linear(args.hidden_size, args.vocab_size, bias=False)
def __call__(
self,
inputs: mx.array,
mask: mx.array = None,
cache=None,
):
out = self.model(inputs, mask, cache)
return self.lm_head(out)
def sanitize(self, weights):
if "model.layers.0.mlp.experts.0.up_proj.weight" not in weights:
return weights
for l in range(self.args.num_hidden_layers):
prefix = f"model.layers.{l}"
for n in ["up_proj", "down_proj", "gate_proj"]:
if f"{prefix}.mlp.experts.0.{n}.weight" in weights:
to_join = [
weights.pop(f"{prefix}.mlp.experts.{e}.{n}.weight")
for e in range(self.args.num_experts)
]
weights[f"{prefix}.mlp.switch_mlp.{n}.weight"] = mx.stack(to_join)
return weights
@property
def layers(self):
return self.model.layers
+1 -1
View File
@@ -2,7 +2,7 @@
import math
from dataclasses import dataclass
from typing import List, Literal, Optional
from typing import List, Optional
import mlx.core as mx
import mlx.nn as nn
+165 -1
View File
@@ -1,11 +1,71 @@
# Copyright © 2023-2024 Apple Inc.
from typing import Optional
import math
from typing import List, Optional, Union
import mlx.core as mx
import mlx.nn as nn
class SuScaledRoPE(nn.Module):
def __init__(
self,
dims: int,
base: float = 10000.0,
max_position_embeddings: int = 131072,
original_max_position_embeddings: int = 4096,
short_factor: Union[List[float], float] = 1.0,
long_factor: Union[List[float], float] = 1.0,
short_mscale: float = None,
long_mscale: float = None,
):
"""
Su Scaled Rotary Embedding layer.
Args:
dims (int): The feature dimensions to be rotated.
base (int, optional): Base for the exponential scaling.
max_position_embeddings (int, optional): The maximum sequence
length that this model was trained with. This is used to determine
the size of the original RoPE embeddings when using long scaling.
Default: ``131072``.
original_max_position_embeddings (int, optional): The maximum
sequence length that this model was trained with. This is used to
determine the size of the original RoPE embeddings when using long
scaling. Default: ``4096``.
short_factor (float or list[float], optional): List of scaling
factors for sequences of length lesser than
``original_max_position_embeddings``. Default: ``1.0``.
long_factor (float or list[float], optional): List of scaling
factors for sequences of length greater than
``original_max_position_embeddings``. Default: ``1.0``.
short_mscale (float, optional): Scale the input prior to embedding.
long_mscale (float, optional): Scale the input prior to embedding.
"""
super().__init__()
freqs = base ** (mx.arange(0, dims, 2, dtype=mx.float32) / dims)
self._freqs = mx.array(long_factor, dtype=mx.float32) * freqs
self.original_max_position_embeddings = original_max_position_embeddings
self.scale = long_mscale or math.sqrt(
1
+ math.log(max_position_embeddings / original_max_position_embeddings)
/ math.log(original_max_position_embeddings)
)
self.dim = dims
def __call__(self, x, offset: int = 0):
x[..., : self.dim] = self.scale * x[..., : self.dim]
return mx.fast.rope(
x,
self.dim,
traditional=False,
base=None,
scale=1.0,
offset=offset,
freqs=self._freqs,
)
class Llama3RoPE(nn.Module):
def __init__(
@@ -61,6 +121,78 @@ class Llama3RoPE(nn.Module):
)
class YarnRoPE(nn.Module):
def __init__(
self,
dims,
traditional=False,
max_position_embeddings=2048,
base=10000,
scaling_factor=1.0,
original_max_position_embeddings=4096,
beta_fast=32,
beta_slow=1,
mscale=1,
mscale_all_dim=0,
):
super().__init__()
def yarn_find_correction_dim(num_rotations):
return (
dims
* math.log(
original_max_position_embeddings / (num_rotations * 2 * math.pi)
)
) / (2 * math.log(base))
def yarn_find_correction_range():
low = math.floor(yarn_find_correction_dim(beta_fast))
high = math.ceil(yarn_find_correction_dim(beta_slow))
return max(low, 0), min(high, dims - 1)
def yarn_get_mscale(scale=1, mscale=1):
if scale <= 1:
return 1.0
return 0.1 * mscale * math.log(scale) + 1.0
def yarn_linear_ramp_mask(min_val, max_val, dim):
if min_val == max_val:
max_val += 0.001 # Prevent singularity
linear_func = (mx.arange(dim, dtype=mx.float32) - min_val) / (
max_val - min_val
)
return mx.clip(linear_func, 0, 1)
self.mscale = yarn_get_mscale(scaling_factor, mscale) / yarn_get_mscale(
scaling_factor, mscale_all_dim
)
freq_extra = base ** (mx.arange(0, dims, 2, dtype=mx.float32) / dims)
freq_inter = scaling_factor * base ** (
mx.arange(0, dims, 2, dtype=mx.float32) / dims
)
low, high = yarn_find_correction_range()
freq_mask = 1.0 - yarn_linear_ramp_mask(low, high, dims // 2)
self._freqs = (freq_inter * freq_extra) / (
freq_inter * freq_mask + freq_extra * (1 - freq_mask)
)
self.dims = dims
self.traditional = traditional
def __call__(self, x, offset=0):
if self.mscale != 1.0:
x[..., : self.dims] = self.mscale * x[..., : self.dims]
return mx.fast.rope(
x,
self.dims,
traditional=self.traditional,
base=None,
scale=1.0,
offset=offset,
freqs=self._freqs,
)
def initialize_rope(
dims,
base,
@@ -87,5 +219,37 @@ def initialize_rope(
base=base,
scaling_config=scaling_config,
)
elif rope_type == "yarn":
scaling_factor = scaling_config["factor"]
rope_kwargs = {
key: scaling_config[key]
for key in [
"original_max_position_embeddings",
"beta_fast",
"beta_slow",
"mscale",
"mscale_all_dim",
]
if key in scaling_config
}
return YarnRoPE(
dims=dims,
max_position_embeddings=max_position_embeddings,
traditional=traditional,
base=base,
**rope_kwargs,
)
elif rope_type == "longrope":
return SuScaledRoPE(
dims=dims,
base=base,
max_position_embeddings=max_position_embeddings,
original_max_position_embeddings=scaling_config[
"original_max_position_embeddings"
],
short_factor=scaling_config["short_factor"],
long_factor=scaling_config["long_factor"],
)
else:
raise ValueError(f"Unsupported RoPE type {rope_type}")
-66
View File
@@ -1,66 +0,0 @@
# Copyright © 2023-2024 Apple Inc.
import math
from typing import List, Union
import mlx.core as mx
import mlx.nn as nn
class SuScaledRotaryEmbedding(nn.Module):
def __init__(
self,
dims: int,
base: float = 10000.0,
max_position_embeddings: int = 131072,
original_max_position_embeddings: int = 4096,
short_factor: Union[List[float], float] = 1.0,
long_factor: Union[List[float], float] = 1.0,
short_mscale: float = None,
long_mscale: float = None,
):
"""
Phi3Su Scaled Rotary Embedding layer for Phi-3 models.
Args:
dims (int): The feature dimensions to be rotated.
base (int, optional): Base for the exponential scaling.
max_position_embeddings (int, optional): The maximum sequence
length that this model was trained with. This is used to determine
the size of the original RoPE embeddings when using long scaling.
Default: ``131072``.
original_max_position_embeddings (int, optional): The maximum
sequence length that this model was trained with. This is used to
determine the size of the original RoPE embeddings when using long
scaling. Default: ``4096``.
short_factor (float or list[float], optional): List of scaling
factors for sequences of length lesser than
``original_max_position_embeddings``. Default: ``1.0``.
long_factor (float or list[float], optional): List of scaling
factors for sequences of length greater than
``original_max_position_embeddings``. Default: ``1.0``.
short_mscale (float, optional): Scale the input prior to embedding.
long_mscale (float, optional): Scale the input prior to embedding.
"""
super().__init__()
freqs = base ** (mx.arange(0, dims, 2, dtype=mx.float32) / dims)
self._freqs = mx.array(long_factor, dtype=mx.float32) * freqs
self.original_max_position_embeddings = original_max_position_embeddings
self.scale = long_mscale or math.sqrt(
1
+ math.log(max_position_embeddings / original_max_position_embeddings)
/ math.log(original_max_position_embeddings)
)
self.dim = dims
def __call__(self, x, offset: int = 0):
x[..., : self.dim] = self.scale * x[..., : self.dim]
return mx.fast.rope(
x,
self.dim,
traditional=False,
base=None,
scale=1.0,
offset=offset,
freqs=self._freqs,
)
+57 -16
View File
@@ -6,6 +6,21 @@ import mlx.core as mx
import mlx.nn as nn
def _gather_sort(x, indices):
*_, M = indices.shape
indices = indices.flatten()
order = mx.argsort(indices)
inv_order = mx.argsort(order)
return x.flatten(0, -3)[order // M], indices[order], inv_order
def _scatter_unsort(x, inv_order, shape=None):
x = x[inv_order]
if shape is not None:
x = mx.unflatten(x, 0, shape)
return x
class QuantizedSwitchLinear(nn.Module):
def __init__(
self,
@@ -38,12 +53,6 @@ class QuantizedSwitchLinear(nn.Module):
# Freeze this model's parameters
self.freeze()
def unfreeze(self, *args, **kwargs):
"""Wrap unfreeze so that we unfreeze any layers we might contain but
our parameters will remain frozen."""
super().unfreeze(*args, **kwargs)
self.freeze(recurse=False)
@property
def input_dims(self):
return self.scales.shape[2] * self.group_size
@@ -56,7 +65,7 @@ class QuantizedSwitchLinear(nn.Module):
def num_experts(self):
return self.weight.shape[0]
def __call__(self, x, indices):
def __call__(self, x, indices, sorted_indices=False):
x = mx.gather_qmm(
x,
self["weight"],
@@ -66,6 +75,7 @@ class QuantizedSwitchLinear(nn.Module):
transpose=True,
group_size=self.group_size,
bits=self.bits,
sorted_indices=sorted_indices,
)
if "bias" in self:
x = x + mx.expand_dims(self["bias"][indices], -2)
@@ -99,8 +109,13 @@ class SwitchLinear(nn.Module):
def num_experts(self):
return self.weight.shape[0]
def __call__(self, x, indices):
x = mx.gather_mm(x, self["weight"].swapaxes(-1, -2), rhs_indices=indices)
def __call__(self, x, indices, sorted_indices=False):
x = mx.gather_mm(
x,
self["weight"].swapaxes(-1, -2),
rhs_indices=indices,
sorted_indices=sorted_indices,
)
if "bias" in self:
x = x + mx.expand_dims(self["bias"][indices], -2)
return x
@@ -122,7 +137,7 @@ class SwitchGLU(nn.Module):
input_dims: int,
hidden_dims: int,
num_experts: int,
activation=nn.silu,
activation=nn.SiLU(),
bias: bool = False,
):
super().__init__()
@@ -135,9 +150,24 @@ class SwitchGLU(nn.Module):
def __call__(self, x, indices) -> mx.array:
x = mx.expand_dims(x, (-2, -3))
x_up = self.up_proj(x, indices)
x_gate = self.gate_proj(x, indices)
x = self.down_proj(self.activation(x_gate) * x_up, indices)
# When we have many tokens, then sort them to make sure that the access
# of different experts is in order.
do_sort = indices.size >= 64
idx = indices
inv_order = None
if do_sort:
x, idx, inv_order = _gather_sort(x, indices)
x_up = self.up_proj(x, idx, sorted_indices=do_sort)
x_gate = self.gate_proj(x, idx, sorted_indices=do_sort)
x = self.down_proj(
self.activation(x_gate) * x_up,
idx,
sorted_indices=do_sort,
)
if do_sort:
x = _scatter_unsort(x, inv_order, indices.shape)
return x.squeeze(-2)
@@ -148,7 +178,7 @@ class SwitchMLP(nn.Module):
input_dims: int,
hidden_dims: int,
num_experts: int,
activation=nn.gelu_approx,
activation=nn.GELU(approx="precise"),
bias: bool = False,
):
super().__init__()
@@ -160,8 +190,19 @@ class SwitchMLP(nn.Module):
def __call__(self, x, indices) -> mx.array:
x = mx.expand_dims(x, (-2, -3))
x = self.fc1(x, indices)
# When we have many tokens, then sort them to make sure that the access
# of different experts is in order.
do_sort = indices.size >= 64
idx = indices
inv_order = None
if do_sort:
x, idx, inv_order = _gather_sort(x, indices)
x = self.fc1(x, idx, sorted_indices=do_sort)
x = self.activation(x)
x = self.fc2(x, indices)
x = self.fc2(x, idx, sorted_indices=do_sort)
if do_sort:
x = _scatter_unsort(x, inv_order, indices.shape)
return x.squeeze(-2)
+588
View File
@@ -0,0 +1,588 @@
# Copyright © 2025 Apple Inc.
import argparse
import copy
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable, Dict
from urllib import request
import mlx.core as mx
import mlx.nn as nn
from mlx.utils import tree_flatten, tree_map, tree_map_with_path
from tqdm import tqdm
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,
save,
)
@dataclass
class ScaleConfig:
prev: nn.Module
layers: list[nn.Module]
block: nn.Module | None = None
kwargs: list = field(default_factory=list)
use_config: Callable[[nn.Module], bool] | None = None
@dataclass
class AWQConfig:
embed: str
lm_head: str
no_clip: list[str]
scale_configs: list[ScaleConfig]
lm_key: str | None = None
def update(cfg, **kwargs):
cfg = copy.deepcopy(cfg)
for k, v in kwargs.items():
setattr(cfg, k, v)
return cfg
llama_awq = AWQConfig(
embed="embed_tokens",
lm_head="lm_head",
no_clip=["q_proj", "k_proj"],
scale_configs=[
ScaleConfig(
block="self_attn",
prev="input_layernorm",
layers=["q_proj", "k_proj", "v_proj"],
kwargs=["mask"],
),
ScaleConfig(prev="mlp.up_proj", layers=["mlp.down_proj"]),
ScaleConfig(
block="mlp",
prev="post_attention_layernorm",
layers=["gate_proj", "up_proj"],
),
],
)
gemma3_text_awq = AWQConfig(
embed="embed_tokens",
lm_head="lm_head",
no_clip=["q_proj", "k_proj"],
scale_configs=[
ScaleConfig(
block="self_attn",
prev="input_layernorm",
layers=["q_proj", "k_proj", "v_proj"],
kwargs=["mask"],
),
ScaleConfig(prev="mlp.up_proj", layers=["mlp.down_proj"]),
ScaleConfig(
block="mlp",
prev="pre_feedforward_layernorm",
layers=["gate_proj", "up_proj"],
),
],
)
gemma3_awq = update(gemma3_text_awq, lm_key="language_model")
deepseek_v2_awq = AWQConfig(
embed="embed_tokens",
lm_head="lm_head",
no_clip=["q_proj", "q_a_proj", "q_b_proj", "kv_a_proj_with_mqa", "kv_b_proj"],
scale_configs=[
ScaleConfig(
block="self_attn",
prev="input_layernorm",
layers=["q_proj", "kv_a_proj_with_mqa"],
kwargs=["mask"],
),
ScaleConfig(
prev="self_attn.kv_a_layernorm",
layers=["self_attn.kv_b_proj"],
),
ScaleConfig(
prev="mlp.up_proj",
layers=["mlp.down_proj"],
use_config=lambda block: not "switch_mlp" in block.mlp,
),
ScaleConfig(
prev="mlp.shared_experts.up_proj",
layers=["mlp.shared_experts.down_proj"],
use_config=lambda block: "switch_mlp" in block.mlp,
),
ScaleConfig(
prev="mlp.switch_mlp.up_proj",
layers=["mlp.switch_mlp.down_proj"],
use_config=lambda block: "switch_mlp" in block.mlp,
kwargs=["indices"],
),
ScaleConfig(
block="mlp",
prev="post_attention_layernorm",
layers=["gate_proj", "up_proj"],
use_config=lambda block: not "switch_mlp" in block.mlp,
),
ScaleConfig(
block="mlp",
prev="post_attention_layernorm",
layers=[
"switch_mlp.gate_proj",
"switch_mlp.up_proj",
"shared_experts.gate_proj",
"shared_experts.up_proj",
"gate", # not quantized, just scaled
],
use_config=lambda block: "switch_mlp" in block.mlp,
),
],
)
AWQ_MODEL_CONFIGS = {
"llama": llama_awq,
"mistral": llama_awq,
"qwen2": llama_awq,
"qwen3": llama_awq,
"gemma3_text": gemma3_text_awq,
"gemma3": update(gemma3_text_awq, lm_key="language_model"),
"deepseek_v2": deepseek_v2_awq,
}
def mse(x, y):
return ((x - y).astype(mx.float32)) ** 2
def submodule_from_key(module, key):
keys = key.split(".")
for k in keys:
module = module[k]
return module
def run_layer(
layer: nn.Module,
x: mx.array,
indices: mx.array | None = None,
batch_size: int = 32,
**kwargs,
):
y = []
for i in range(0, x.shape[0], batch_size):
if indices is not None:
y.append(
layer(x[i : i + batch_size], indices[i : i + batch_size], **kwargs)
)
else:
y.append(layer(x[i : i + batch_size], **kwargs))
mx.eval(y)
y = mx.concatenate(y, axis=0)
return y
def dist_split(x: mx.array, group: mx.distributed.Group):
N = group.size()
if N == 1:
return x
B = x.shape[0]
assert B % N == 0
r = group.rank()
local_B = (B + N - 1) // N
return x[r * local_B : (r + 1) * local_B]
def search_best_scale(
layers: list[nn.Module],
quantize_func: Callable,
block: nn.Module | None,
layer_kwargs: dict,
n_grid: int,
):
group = mx.distributed.init()
layer_kwargs = layer_kwargs or {}
x = layers[0].input_feat
block = block or layers[0]
out = block(x, **layer_kwargs)
x_max = x.abs().mean(axis=(0, 1))
best_error = float("inf")
best_scales = None
weights = tree_flatten(block.parameters())
# Search across different scaling ratios
# and take the best loss.
for ratio in range(n_grid):
ratio = ratio / n_grid
scales = mx.maximum(x_max**ratio, 1e-4).reshape(-1)
scales = scales / (scales.max() * scales.min()).sqrt()
for layer in layers:
if isinstance(layer, (nn.Linear, SwitchLinear)):
layer.weight = quantize_func(layer.weight * scales) / scales
out_q = run_layer(block, x, **layer_kwargs)
loss = mse(out, out_q).sum()
if group is not None:
loss = mx.distributed.all_sum(loss) / group.size()
loss /= out.size
mx.eval(loss)
if loss.item() < best_error:
best_error = loss.item()
best_scales = scales
# reload the original weights
block.load_weights(weights)
best_scales = best_scales.reshape(-1)
mx.eval(best_scales)
return best_scales
def apply_scale(prev_op, layers, scales):
# Fuse the scales into the previous op
if isinstance(prev_op, (nn.Linear, SwitchLinear)):
assert len(layers) == 1
prev_op.weight = prev_op.weight / scales[:, mx.newaxis]
if hasattr(prev_op, "bias"):
prev_op.bias = prev_op.bias / scales
layers[0].weight = layers[0].weight * scales
elif isinstance(prev_op, (nn.LayerNorm, nn.RMSNorm)):
prev_op.weight = prev_op.weight / scales
if hasattr(prev_op, "bias"):
prev_op.bias = prev_op.bias / scales
for layer in layers:
layer.weight = layer.weight * scales
elif prev_op.__class__.__name__ == "RMSNorm": # For gemma models
dt = prev_op.weight.dtype
prev_op.weight = (
(1.0 + prev_op.weight.astype(mx.float32)) / scales - 1.0
).astype(dt)
for layer in layers:
layer.weight = layer.weight * scales
else:
raise NotImplementedError(f"Could not apply scale to prev_op: {prev_op}")
for layer in layers:
if hasattr(layer, "input_feat"):
layer.input_feat = layer.input_feat / scales
def scale_block(
block: nn.Module,
configs: list[ScaleConfig],
quantize_func: Callable,
layer_kwargs: dict,
n_grid: int,
):
for conf in configs:
if conf.use_config is not None and not conf.use_config(block):
continue
if conf.block is not None:
local_block = block[conf.block]
layers = [submodule_from_key(local_block, l) for l in conf.layers]
else:
local_block = None
layers = [submodule_from_key(block, l) for l in conf.layers]
local_kwargs = {k: layer_kwargs[k] for k in conf.kwargs if k in layer_kwargs}
for k in conf.kwargs:
if hasattr(layers[0], k):
local_kwargs[k] = getattr(layers[0], k)
scales = search_best_scale(
layers=layers,
block=local_block,
layer_kwargs=local_kwargs,
quantize_func=quantize_func,
n_grid=n_grid,
)
apply_scale(submodule_from_key(block, conf.prev), layers, scales)
def search_best_clip(
module: nn.Module,
quantize_func: Callable,
group_size: int,
n_grid: int,
max_shrink: float = 0.5,
batch_size: int = 64,
n_frames: int = 512,
):
group = mx.distributed.init()
# subsample the input features
x = module.input_feat.flatten(0, 1)
stride = (x.shape[0] + n_frames - 1) // n_frames
x = x[::stride]
w = module.weight
x = x.reshape(x.shape[0], -1, group_size)
w_init_shape = w.shape
w_all = mx.flatten(w, 0, w.ndim - 2)
w_max_all = []
# batch across W to save memory
for b in range(0, w_all.shape[0], batch_size):
w = w_all[b : b + batch_size]
group_shape = (w.shape[0], w.shape[-1] // group_size)
best_error = mx.full(group_shape, float("inf"))
best_w_max = mx.zeros((*group_shape, 1), dtype=x.dtype)
w_shape = w.shape
w = w.reshape(*w.shape[:-1], -1, group_size)
out = mx.einsum("bdg,odg->bod", x, w)
init_max = w.abs().max(axis=-1, keepdims=True)
# try a range of clips and pick the one with the smallest loss
for i in range(int(max_shrink * n_grid)):
p = 1 - i / n_grid
w_max = p * init_max
w_m = mx.clip(w, -w_max, w_max).reshape(w_shape)
w_q = quantize_func(w_m)
w_q = w_q.reshape(*w_q.shape[:-1], -1, group_size)
out_q = mx.einsum("bdg,odg->bod", x, w_q)
# Take the mean across the input batch
loss = mse(out, out_q).sum(axis=0)
if group is not None:
loss = mx.distributed.all_sum(loss) / group.size()
loss /= out.shape[0]
best_indices = loss < best_error
best_error = mx.where(best_indices, loss, best_error)
best_w_max = mx.where(best_indices[..., mx.newaxis], w_max, best_w_max)
mx.eval(best_w_max, best_error)
w_max_all.append(best_w_max)
best_w_max = mx.concatenate(w_max_all, axis=0)
w_r = w_all.reshape(*w_all.shape[:-1], -1, group_size)
best_w = mx.clip(w_r, -best_w_max, best_w_max)
best_w = best_w.reshape(w_init_shape)
mx.eval(best_w)
return best_w
def clip_block(
block: nn.Module,
no_clip_keys: list[str],
quantize_func: Callable,
group_size: int,
n_grid: int = 20,
):
def apply_clip(path, module):
if isinstance(module, (nn.Linear, SwitchLinear)) and all(
k not in path for k in no_clip_keys
):
best_weight = search_best_clip(
module,
quantize_func=quantize_func,
group_size=group_size,
n_grid=n_grid,
)
module.weight = best_weight
tree_map_with_path(apply_clip, block.leaf_modules(), is_leaf=nn.Module.is_module)
def awq_quantize(
model,
inputs: mx.array,
awq_config: AWQConfig,
group_size: int = 64,
bits: int = 3,
embed_group_size: int = 32,
embed_bits: int = 4,
n_grid: int = 20,
):
if awq_config.lm_key is not None:
model = model[awq_config.lm_key]
group = mx.distributed.init()
def quantize_func(w):
wq = mx.quantize(w, bits=bits, group_size=group_size)
return mx.dequantize(*wq, bits=bits, group_size=group_size)
mask = create_attention_mask(inputs)
embed_key = awq_config.embed
model.model[embed_key] = model.model[embed_key].to_quantized(
group_size=embed_group_size, bits=embed_bits
)
inputs = model.model[embed_key](inputs)
def capture(module):
if not isinstance(module, (nn.Linear, SwitchLinear)):
return module
class Catcher(nn.Module):
def __call__(self, x: mx.array, *args, **kwargs):
# Store the input features on the original modules.
if hasattr(module, "input_feat"):
module.input_feat = mx.concatenate([module.input_feat, x], axis=0)
else:
module.input_feat = x
# Also store the MOE indices if applicabale
if isinstance(module, SwitchLinear):
indices = args[0]
if hasattr(module, "indices"):
module.indices = mx.concatenate(
[module.indices, indices], axis=0
)
else:
module.indices = indices
return module(x, *args, **kwargs)
return Catcher()
for e, block in enumerate(tqdm(model.layers)):
# Capture the input features for each of the layers in the transformer block
orig_leaves = block.leaf_modules()
capture_leaves = tree_map(capture, orig_leaves, is_leaf=nn.Module.is_module)
block.update_modules(capture_leaves)
outputs = run_layer(block, inputs, mask=mask)
block.update_modules(orig_leaves)
del capture_leaves
# Quantize the block without AWQ to obtain a reference loss
nn.quantize(block, group_size=group_size, bits=bits)
outputs_q = run_layer(block, inputs, mask=mask)
before_loss = mse(outputs, outputs_q).sum()
if group is not None:
before_loss = mx.distributed.all_sum(before_loss) / group.size()
before_loss /= outputs.size
block.update_modules(orig_leaves)
orig_params = block.parameters()
scale_block(
block=block,
configs=awq_config.scale_configs,
quantize_func=quantize_func,
n_grid=n_grid,
layer_kwargs={"mask": mask},
)
clip_block(
block=block,
no_clip_keys=awq_config.no_clip,
quantize_func=quantize_func,
group_size=group_size,
n_grid=n_grid,
)
# Quantize the scaled and clipped block
nn.quantize(block, group_size=group_size, bits=bits)
outputs_q = run_layer(block, inputs, mask=mask)
after_loss = mse(outputs, outputs_q).sum()
if group is not None:
after_loss = mx.distributed.all_sum(after_loss) / group.size()
after_loss /= outputs.size
tqdm.write(f"Loss reduction: {after_loss / before_loss}")
if after_loss > before_loss:
# Reload original weights and quantize
block.update_modules(orig_leaves)
block.update(orig_params)
nn.quantize(block, group_size=group_size, bits=bits)
tqdm.write("Loss is not reduced, falling back to original weights.")
inputs = outputs
mx.eval(block)
mx.clear_cache()
if (lm_head := awq_config.lm_head) in model:
model[lm_head] = model[lm_head].to_quantized(
group_size=embed_group_size, bits=embed_bits
)
def update_config(
model: nn.Module,
config: Dict[str, Any],
):
# dummy
config["quantization"] = {"group_size": 64, "bits": 4}
def update_config(path, module):
if hasattr(module, "bits"):
config["quantization"][path] = {
"group_size": module.group_size,
"bits": module.bits,
}
else:
config["quantization"][path] = False
tree_map_with_path(update_config, model.leaf_modules(), is_leaf=nn.Module.is_module)
return config
def main():
parser = argparse.ArgumentParser()
parser.add_argument(
"--model", "-m", default="mlx-community/Qwen2.5-7B-Instruct-bf16"
)
parser.add_argument("--mlx-path", default="mlx_model")
parser.add_argument("--bits", type=int, default=4)
parser.add_argument("--group-size", type=int, default=64)
parser.add_argument("--embed-bits", type=int, default=4)
parser.add_argument("--embed-group-size", type=int, default=32)
parser.add_argument("--num-samples", type=int, default=128)
parser.add_argument("--sequence-length", type=int, default=512)
parser.add_argument("--n-grid", type=int, default=20)
parser.add_argument("--seed", type=int, default=123)
args = parser.parse_args()
group = mx.distributed.init()
num_samples = args.num_samples
if group is not None and num_samples % group.size() > 0:
num_samples += group.size() - num_samples % group.size()
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_type = config["model_type"]
if (awq_config := AWQ_MODEL_CONFIGS.get(model_type, None)) is None:
raise NotImplementedError(f"AWQ support for {model_type} models NYI.")
calibration_data = load_data(tokenizer, args.num_samples, args.sequence_length)
calibration_data = dist_split(calibration_data, group)
awq_quantize(
model,
calibration_data,
awq_config,
bits=args.bits,
group_size=args.group_size,
embed_bits=args.embed_bits,
embed_group_size=args.embed_group_size,
n_grid=args.n_grid,
)
config = update_config(model, config)
save(
args.mlx_path,
model_path,
model,
tokenizer,
config,
hf_repo=hf_repo,
)
+251
View File
@@ -0,0 +1,251 @@
# Copyright © 2025 Apple Inc.
import argparse
import copy
import time
import types
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 tqdm import tqdm
from mlx_lm.tuner.datasets import load_dataset
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,
quantize_model,
save,
)
class Catcher(nn.Module):
def __init__(self, module):
super().__init__()
self.module = module
def __call__(self, *args, **kwargs):
self.outputs = self.module(*args, **kwargs)
return self.outputs
def dwq_quantize(
model,
q_model,
opt,
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,
):
group = mx.distributed.init()
world_size = group.size()
rank = group.rank()
def unfreeze(_, m):
if hasattr(m, "bits") and hasattr(m, "group_size"):
m.unfreeze(keys=["scales", "biases"], recurse=False)
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
def loss_fn(params, x, targets, extra_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)
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)
]
)
loss = kl_loss + activation_loss_weight * act_loss.mean()
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
)
grads = nn.average_gradients(grads)
params = opt.apply_gradients(grads, params)
return loss, ntoks, params
# Accumulate learned weights in higher precision
params = tree_map(
lambda x: x.astype(mx.float32),
q_model.trainable_parameters(),
)
total_loss = 0.0
total_tokens = 0
tokens = 0
tic = time.time()
for it, (batch, lengths) in (
pbar := tqdm(
enumerate(iterate_batches(data, batch_size, max_seq_length)),
total=len(data) // batch_size,
)
):
batch = batch[:, :-1]
targets, extra_targets = forward(model, batch)
mx.eval(targets, extra_targets)
loss, ntoks, params = step(batch, targets, extra_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()
tokens += ntoks
total_loss += loss * ntoks
if rank == 0:
pbar.set_description(desc=f"{loss=:.4f}")
if (it + 1) % 20 == 0:
toks_per_sec = tokens / (time.time() - tic)
peak_memory_gb = mx.get_peak_memory() / 1e9
avg_loss = total_loss / tokens
total_tokens += tokens
tqdm.write(
f"{it=}, {avg_loss=:.4f}, {total_tokens=},"
f" {toks_per_sec=:.3f}, {peak_memory_gb=:.3f}",
)
tic = time.time()
tokens = 0
total_loss = 0
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(tokenizer, data_path: str, num_samples: int, max_seq_length: int):
args = types.SimpleNamespace(
hf_dataset={
"path": data_path,
"train_split": f"train",
"valid_split": "train[:1]",
},
train=True,
test=False,
)
dataset = load_dataset(args, tokenizer)[0]
perm = np.random.permutation(len(dataset))[:num_samples].tolist()
def process(idx):
tokens, offset = dataset.process(dataset[idx])
return (tokens[:max_seq_length], offset)
return [process(i) for i in perm]
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--model", "-m", default="Qwen/Qwen3-4B")
parser.add_argument("--quantized-model", default=None)
parser.add_argument(
"--mlx-path", default="mlx_model", help="Path to save the quantized model."
)
parser.add_argument(
"--bits",
type=int,
default=4,
help="Bits per weight for quantization.",
)
parser.add_argument(
"--group-size", type=int, default=64, help="Group size for quantization."
)
parser.add_argument(
"--num-samples",
type=int,
default=2048,
help="Number of samples to use for training.",
)
parser.add_argument("--max-seq-length", type=int, default=2049)
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)
parser.add_argument(
"--data-path",
type=str,
default="allenai/tulu-3-sft-mixture",
help="A Hugging Face dataset which is compatible with an mlx-lm dataset format.",
)
parser.add_argument(
"--grad-checkpoint",
action="store_true",
help="Use gradient checkpointing to reduce memory use.",
)
args = parser.parse_args()
group = mx.distributed.init()
num_samples = args.num_samples
if num_samples % group.size() > 0:
num_samples += group.size() - num_samples % group.size()
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)
calibration_data = load_data(
tokenizer, args.data_path, args.num_samples, args.max_seq_length
)
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)
else:
q_model = copy.deepcopy(model)
_, config = quantize_model(
q_model,
config,
q_group_size=args.group_size,
q_bits=args.bits,
)
opt = optimizers.Adam(learning_rate=args.learning_rate, bias_correction=True)
dwq_quantize(
model,
q_model,
opt,
calibration_data,
batch_size=args.batch_size,
max_seq_length=args.max_seq_length,
gradient_checkpoint=args.grad_checkpoint,
)
save(
args.mlx_path,
model_path,
q_model,
tokenizer,
config,
hf_repo=hf_repo,
)
+349
View File
@@ -0,0 +1,349 @@
# Copyright © 2025 Apple Inc.
import argparse
import copy
import json
import math
import mlx.core as mx
import mlx.nn as nn
import numpy as np
from mlx.utils import tree_flatten, tree_map, tree_reduce, tree_unflatten
from tqdm import tqdm
from mlx_lm.quant.utils import load_data
from mlx_lm.tuner.losses import kl_div_loss
from mlx_lm.tuner.trainer import grad_checkpoint
from mlx_lm.tuner.utils import get_total_parameters
from mlx_lm.utils import (
compute_bits_per_weight,
fetch_from_hub,
get_model_path,
load,
quantize_model,
save,
)
def make_quant_predicate(config):
def quant_predicate(p, m, _):
if not hasattr(m, "to_quantized"):
return False
return config.get(p, True)
return quant_predicate
def eval_ppl(model, data, batch_size=8):
all_loss = 0.0
ntoks = 0
for s in range(0, len(data), batch_size):
batch = data[s : s + batch_size]
logits = model(batch[:, :-1]).astype(mx.float32)
losses = nn.losses.cross_entropy(logits, batch[:, 1:])
all_loss += losses.sum().item()
ntoks += losses.size
ppl = math.exp(all_loss / ntoks)
return ppl
def make_options(
low_bits, low_group_size, high_bits, high_group_size, include_bpw=True
):
options = []
min_bpw = low_bits + 32 / low_group_size
max_bpw = high_bits + 32 / high_group_size
for b in range(low_bits, high_bits + 1):
for g in [32, 64, 128]:
cbpw = b + 32 / g
if b == 7 or not (min_bpw <= cbpw <= max_bpw):
continue
options.append({"bits": b, "group_size": g, "bpw": cbpw})
options.sort(key=lambda x: x["bpw"])
if not include_bpw:
for o in options:
o.pop("bpw")
return options
def estimate_sensitivities(
model,
data,
low_bits,
low_group_size,
high_bits,
high_group_size,
batch_size: int = 4,
gradient_accum_dtype: mx.Dtype = mx.float32,
gradient_checkpoint: bool = False,
):
def qdq(w, bits, group_size):
w, s, b = mx.quantize(w, bits=bits, group_size=group_size)
return mx.dequantize(w, scales=s, biases=b, bits=bits, group_size=group_size)
layers = tree_flatten(model.leaf_modules(), is_leaf=nn.Module.is_module)
layers = {k: l for k, l in layers if hasattr(l, "to_quantized")}
q_model = copy.deepcopy(model)
q_layers = copy.deepcopy(layers)
for l in q_layers.values():
l.weight = qdq(l.weight, low_bits, low_group_size)
# Freeze everything but the quantizable weight
l.freeze()
l.unfreeze(keys=["weight"])
q_model.freeze()
q_model.update_modules(tree_unflatten(list(q_layers.items())))
def loss_fn(batch, targets):
return kl_div_loss(q_model(batch), targets).mean()
if gradient_checkpoint:
grad_checkpoint(q_model.layers[0])
grad_accum = tree_map(
lambda x: mx.zeros(x.shape, dtype=gradient_accum_dtype),
q_model.trainable_parameters(),
)
for e, s in tqdm(
enumerate(range(0, len(data), batch_size)),
total=len(data) // batch_size,
desc="Estimating sensitivities",
):
batch = data[s : s + batch_size]
targets = model(batch)
mx.eval(targets)
_, grads = nn.value_and_grad(q_model, loss_fn)(batch, targets)
grad_accum = tree_map(lambda x, y: x + y, grad_accum, grads)
del grads
mx.eval(grad_accum)
options = make_options(low_bits, low_group_size, high_bits, high_group_size)
current_bpw = options[0]["bpw"]
def compute_sensitivity(gradient, low_q_weight, original_weight):
n_batches = (len(data) + batch_size - 1) // batch_size
gradient = gradient / n_batches
scores = [{"loss_change": 0, "extra_bits": 0}]
for opt in options[1:]:
extra_bits = (opt["bpw"] - current_bpw) * original_weight.size
other_weight = qdq(original_weight, opt["bits"], opt["group_size"])
loss_change = (gradient * (low_q_weight - other_weight)).sum()
scores.append({"loss_change": loss_change, "extra_bits": extra_bits})
return scores
sensitivities = tree_map(
compute_sensitivity,
grad_accum,
q_model.parameters(),
model.parameters(),
)
mx.eval(sensitivities)
sensitivities = [
(k.replace(".weight", ""), s.item() if isinstance(s, mx.array) else s)
for k, s in tree_flatten(sensitivities)
]
return sensitivities
def compute_bit_budget(model, target_bpw):
model_bytes = tree_reduce(
lambda acc, x: acc + x.nbytes if isinstance(x, mx.array) else acc, model, 0
)
model_params = get_total_parameters(model)
return model_params * target_bpw - model_bytes * 8
def estimate_threshold(
model,
sensitivities,
target_bpw,
low_bits,
low_group_size,
high_bits,
high_group_size,
):
options = make_options(
low_bits, low_group_size, high_bits, high_group_size, include_bpw=False
)
sensitivities = tree_flatten(
tree_unflatten(list(sensitivities.items())),
is_leaf=lambda x: isinstance(x, list) and "loss_change" in x[0],
)
q_model = copy.deepcopy(model)
nn.quantize(q_model, group_size=low_group_size, bits=low_bits)
budget = int(compute_bit_budget(q_model, target_bpw))
benefit_map = {}
def benefit(layer, option, budget):
if (layer, option, budget) in benefit_map:
return benefit_map[layer, option, budget]
stack = [(layer, option, budget)]
while stack:
layer, option, budget = stack[-1]
if budget <= 0 or layer < 0 or option < 0:
benefit_map[layer, option, budget] = 0
stack.pop()
continue
# We either not use this option
prev_layer = layer if option > 0 else layer - 1
prev_option = (option if option > 0 else len(options)) - 1
if (prev_layer, prev_option, budget) not in benefit_map:
stack.append((prev_layer, prev_option, budget))
continue
a = benefit_map[prev_layer, prev_option, budget]
# Or we use it so we have less budget for before
b = float("-inf")
info = sensitivities[layer][1][option]
prev_layer = layer - 1
prev_option = len(options) - 1
prev_budget = budget - info["extra_bits"]
if (
prev_layer,
prev_option,
prev_budget,
) not in benefit_map and prev_budget >= 0:
stack.append((prev_layer, prev_option, prev_budget))
continue
if prev_budget >= 0:
b = benefit_map[prev_layer, prev_option, prev_budget]
b += info["loss_change"]
benefit_map[layer, option, budget] = max(a, b)
stack.pop()
return benefit_map[layer, option, budget]
def backtrack(layer, budget):
selected = []
while layer >= 0:
prev_benefit = benefit(layer - 1, len(options) - 1, budget)
option_benefits = [benefit(layer, i, budget) for i in range(len(options))]
idx, v = max(enumerate(option_benefits), key=lambda x: x[1] - prev_benefit)
info = sensitivities[layer][1][idx]
if v != 0:
budget -= info["extra_bits"]
selected.append((layer, idx))
layer -= 1
return selected[::-1]
selected = backtrack(len(sensitivities) - 1, budget)
config = {sensitivities[l][0]: options[i] for l, i in selected}
return config
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--model", "-m", default="Qwen/Qwen3-0.6B-base")
parser.add_argument(
"--mlx-path", default="mlx_model", help="Path to save the model"
)
parser.add_argument("--seed", type=int, default=123)
parser.add_argument(
"--sensitivities",
type=str,
default=None,
help="Path to a pre-computed sensitivity JSON file.",
)
parser.add_argument(
"--target-bpw", type=float, default=5.0, help="Target bits per weight."
)
parser.add_argument("--low-bits", type=int, default=4)
parser.add_argument("--low-group-size", type=int, default=128)
parser.add_argument("--high-bits", type=int, default=5)
parser.add_argument("--high-group-size", type=int, default=32)
parser.add_argument(
"--report-ppl",
action="store_true",
help="Compute the perplexity of the base and quantized models.",
)
parser.add_argument(
"--grad-checkpoint",
action="store_true",
help="Use gradient checkpointing to reduce memory use.",
)
parser.add_argument(
"--accumulation-dtype",
default="float32",
choices=["float32", "bfloat16"],
help="What type to use to accumulate the gradients for the sensitivities",
)
args = parser.parse_args()
group = mx.distributed.init()
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)
sensitivities = estimate_sensitivities(
model,
data,
args.low_bits,
args.low_group_size,
args.high_bits,
args.high_group_size,
gradient_accum_dtype=getattr(mx, args.accumulation_dtype),
gradient_checkpoint=args.grad_checkpoint,
)
model_name = args.model.replace("/", "_")
with open(f"{model_name}_sensitivities.json", "w") as fid:
json.dump(sensitivities, fid)
else:
with open(args.sensitivities, "r") as fid:
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)
if args.report_ppl:
ppl = eval_ppl(model, data)
print(f"Original PPL: {ppl:.3f}")
quant_config = estimate_threshold(
model,
sensitivities,
target_bpw=args.target_bpw,
low_bits=args.low_bits,
low_group_size=args.low_group_size,
high_bits=args.high_bits,
high_group_size=args.high_group_size,
)
model, config = quantize_model(
model,
config,
q_group_size=args.low_group_size,
q_bits=args.low_bits,
quant_predicate=make_quant_predicate(quant_config),
)
if args.report_ppl:
ppl = eval_ppl(model, data)
print(f"Quantized PPL: {ppl:.3f}")
save(
args.mlx_path,
model_path,
model,
tokenizer,
config,
hf_repo=hf_repo,
)
print(f"Peak memory used: {mx.get_peak_memory() / 1000**3:.3f}GB")
if __name__ == "__main__":
main()
+26
View File
@@ -0,0 +1,26 @@
# Copyright © 2025 Apple Inc.
from pathlib import Path
import mlx.core as mx
def load_data(tokenizer, num_samples: int, sequence_length: int) -> mx.array:
save_dir = Path.home() / ".cache/mlx-lm/calibration_v5.txt"
if not save_dir.exists():
from urllib import request
save_dir.parent.mkdir(parents=True, exist_ok=True)
url = "https://gist.githubusercontent.com/tristandruyen/9e207a95c7d75ddf37525d353e00659c/raw/571fda718462de863e5a0171078c175420c7649a/calibration_data_v5_rc.txt"
request.urlretrieve(url, save_dir)
with open(save_dir) as fid:
texts = fid.read()
tokens = tokenizer.encode(texts, return_tensors="mlx")[0]
# select random non-overlapping chunks
tokens = tokens[: (tokens.size // sequence_length) * sequence_length]
tokens = tokens.reshape(-1, sequence_length)
segments = mx.random.permutation(tokens.shape[0])
if num_samples > 0:
segments = segments[:num_samples]
return tokens[segments]
+73 -20
View File
@@ -2,7 +2,7 @@
import math
from functools import partial
from typing import Callable, Dict, Optional
from typing import Callable, Dict, List, Optional
import mlx.core as mx
@@ -12,7 +12,10 @@ def make_sampler(
top_p: float = 0.0,
min_p: float = 0.0,
min_tokens_to_keep: int = 1,
top_k: int = -1,
top_k: int = 0,
xtc_probability: float = 0.0,
xtc_threshold: float = 0.0,
xtc_special_tokens: List[int] = [],
) -> Callable[mx.array, mx.array]:
"""
Make a sampler function for use with ``generate_step``.
@@ -28,6 +31,13 @@ def make_sampler(
be filtered by min_p sampling.
top_k (int, optional): The top k tokens ranked by probability to constrain
the sampling to.
xtc_probability (float, optional): The probability of applying XTC
sampling.
xtc_threshold (float, optional): The threshold the probs need to reach
for being sampled.
xtc_special_tokens (list(int), optional): List of special tokens IDs to
be excluded from XTC sampling.
Returns:
Callable[mx.array, mx.array]:
@@ -44,6 +54,10 @@ def make_sampler(
sampling_methods.append(lambda x: apply_top_p(x, top_p))
if min_p != 0.0:
sampling_methods.append(lambda x: apply_min_p(x, min_p, min_tokens_to_keep))
if xtc_probability > 0.0:
sampling_methods.append(
lambda x: apply_xtc(x, xtc_probability, xtc_threshold, xtc_special_tokens)
)
# Apply the sampling methods
def sampler(logits):
@@ -170,8 +184,12 @@ def apply_min_p(
selected_logprobs = mx.where(tokens_to_remove, -float("inf"), sorted_logprobs)
# Create a mapping to rearrange back to original indices
# Use argsort of sorted_indices to get the inverse permutation
inverse_indices = mx.argsort(sorted_indices, axis=-1)
inverse_indices = mx.put_along_axis(
mx.zeros_like(sorted_indices),
sorted_indices,
mx.arange(sorted_indices.shape[-1], dtype=sorted_indices.dtype),
axis=-1,
)
# Rearrange selected_logprobs back to original order
original_order_logprobs = mx.take_along_axis(
@@ -182,41 +200,76 @@ def apply_min_p(
@partial(mx.compile, inputs=mx.random.state, outputs=mx.random.state)
def apply_top_p(logits: mx.array, top_p: float) -> mx.array:
def apply_top_p(logprobs: mx.array, top_p: float) -> mx.array:
"""
Apply top-p (nucleus) sampling to logits.
Args:
logits: The logits from the model's output.
logprobs: A vector of log probabilities.
top_p: The cumulative probability threshold for top-p filtering.
Returns:
token selected based on the top-p criterion.
"""
# referenced implementation from https://github.com/huggingface/transformers/blob/main/src/transformers/generation/logits_process.py#L449-L460
probs = mx.softmax(logits, axis=-1)
# sort probs in ascending order
sorted_indices = mx.argsort(probs, axis=-1)
probs = mx.exp(logprobs)
# sort in ascending order
sorted_indices = mx.argsort(logprobs, axis=-1)
sorted_probs = mx.take_along_axis(probs, sorted_indices, axis=-1)
cumulative_probs = mx.cumsum(sorted_probs, axis=-1)
# Rearrange cumulative probs back to original order
inverse_indices = mx.put_along_axis(
mx.zeros_like(sorted_indices),
sorted_indices,
mx.arange(sorted_indices.shape[-1], dtype=sorted_indices.dtype),
axis=-1,
)
cumulative_probs = mx.take_along_axis(cumulative_probs, inverse_indices, axis=-1)
# select tokens with cumulative probs below threshold
top_probs = mx.where(
return mx.where(
cumulative_probs > 1 - top_p,
sorted_probs,
0,
logprobs,
-float("inf"),
)
# Create a mapping to rearrange back to original indices
# Use argsort of sorted_indices to get the inverse permutation
inverse_indices = mx.argsort(sorted_indices, axis=-1)
# Rearrange top_probs back to original order
original_order_probs = mx.take_along_axis(top_probs, inverse_indices, axis=-1)
@partial(mx.compile, inputs=mx.random.state, outputs=mx.random.state)
def apply_xtc(
logits: mx.array,
xtc_probability: float,
xtc_threshold: float,
xtc_special_tokens: List[int],
) -> mx.array:
"""
Apply XTC sampling to the logits.
# Convert back to logits and return
return mx.log(original_order_probs)
Args:
logits: The logits from the model's output.
xtc_probability (float): Probability of XTC sampling to happen for each token
xtc_threshold (float): The threshold the probs need to reach for being sampled.
special_tokens_ids (list(int)): List of special tokens IDs to be excluded from XTC sampling.
"""
if not (0 <= xtc_threshold <= 0.5):
raise ValueError(
f"`threshold` has to be a float in the [0, 0.5] interval, but is {xtc_threshold}"
)
if not (0 <= xtc_probability <= 1.0):
raise ValueError(
f"`probability` has to be a float in the [0, 1] interval, but is {xtc_probability}"
)
probs = mx.softmax(logits, -1)
mask = probs > mx.where(probs > xtc_threshold, probs, mx.inf).min()
if xtc_special_tokens:
mask[..., xtc_special_tokens] = False
return mx.where(
mx.random.uniform(0, 1) > xtc_probability,
logits,
mx.where(mask, -mx.inf, logits),
)
@partial(mx.compile, inputs=mx.random.state, outputs=mx.random.state)
+305 -48
View File
@@ -4,6 +4,7 @@ import argparse
import json
import logging
import platform
import socket
import time
import uuid
import warnings
@@ -26,9 +27,10 @@ import mlx.core as mx
from huggingface_hub import scan_cache_dir
from ._version import __version__
from .models.cache import make_prompt_cache
from .generate import stream_generate
from .models.cache import can_trim_prompt_cache, make_prompt_cache, trim_prompt_cache
from .sample_utils import make_logits_processors, make_sampler
from .utils import load, stream_generate
from .utils import common_prefix_len, load
def get_system_fingerprint():
@@ -139,12 +141,14 @@ def process_message_content(messages):
if len(text_fragments) != len(content):
raise ValueError("Only 'text' content type is supported.")
message["content"] = "".join(text_fragments)
elif content is None:
message["content"] = ""
@dataclass
class PromptCache:
cache: List[Any] = field(default_factory=list)
model_key: Tuple[str, Optional[str]] = ("", None)
model_key: Tuple[str, Optional[str]] = ("", None, None)
tokens: List[int] = field(default_factory=list)
@@ -155,10 +159,11 @@ class ModelProvider:
self.model_key = None
self.model = None
self.tokenizer = None
self.draft_model = None
# Preload the default model if it is provided
if self.cli_args.model is not None:
self.load("default_model")
self.load("default_model", draft_model_path="default_model")
def _validate_model_path(self, model_path: str):
model_path = Path(model_path)
@@ -168,14 +173,15 @@ class ModelProvider:
)
# Added in adapter_path to load dynamically
def load(self, model_path, adapter_path=None):
if self.model_key == (model_path, adapter_path):
def load(self, model_path, adapter_path=None, draft_model_path=None):
if self.model_key == (model_path, adapter_path, draft_model_path):
return self.model, self.tokenizer
# Remove the old model if it exists.
self.model = None
self.tokenizer = None
self.model_key = None
self.draft_model = None
# Building tokenizer_config
tokenizer_config = {
@@ -184,7 +190,12 @@ class ModelProvider:
if self.cli_args.chat_template:
tokenizer_config["chat_template"] = self.cli_args.chat_template
if model_path == "default_model" and self.cli_args.model is not None:
if model_path == "default_model":
if self.cli_args.model is None:
raise ValueError(
"A model path has to be given as a CLI "
"argument or in the HTTP request"
)
model, tokenizer = load(
self.cli_args.model,
adapter_path=(
@@ -202,10 +213,30 @@ class ModelProvider:
if tokenizer.chat_template is None:
tokenizer.chat_template = tokenizer.default_chat_template
self.model_key = (model_path, adapter_path)
self.model_key = (model_path, adapter_path, draft_model_path)
self.model = model
self.tokenizer = tokenizer
def validate_draft_tokenizer(draft_tokenizer):
# Check if tokenizers are compatible
if draft_tokenizer.vocab_size != tokenizer.vocab_size:
logging.warning(
"Draft model tokenizer does not match model tokenizer. "
"Speculative decoding may not work as expected."
)
# Load draft model if specified
if (
draft_model_path == "default_model"
and self.cli_args.draft_model is not None
):
self.draft_model, draft_tokenizer = load(self.cli_args.draft_model)
validate_draft_tokenizer(draft_tokenizer)
elif draft_model_path is not None and draft_model_path != "default_model":
self._validate_model_path(draft_model_path)
self.draft_model, draft_tokenizer = load(draft_model_path)
validate_draft_tokenizer(draft_tokenizer)
return self.model, self.tokenizer
@@ -277,22 +308,35 @@ class APIHandler(BaseHTTPRequestHandler):
self.stream = self.body.get("stream", False)
self.stream_options = self.body.get("stream_options", None)
self.requested_model = self.body.get("model", "default_model")
self.requested_draft_model = self.body.get("draft_model", "default_model")
self.num_draft_tokens = self.body.get(
"num_draft_tokens", self.model_provider.cli_args.num_draft_tokens
)
self.adapter = self.body.get("adapters", None)
self.max_tokens = self.body.get("max_completion_tokens", None)
if self.max_tokens is None:
self.max_tokens = self.body.get("max_tokens", 512)
self.temperature = self.body.get("temperature", 0.0)
self.top_p = self.body.get("top_p", 1.0)
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
)
self.top_p = self.body.get("top_p", self.model_provider.cli_args.top_p)
self.top_k = self.body.get("top_k", self.model_provider.cli_args.top_k)
self.min_p = self.body.get("min_p", self.model_provider.cli_args.min_p)
self.repetition_penalty = self.body.get("repetition_penalty", 1.0)
self.repetition_context_size = self.body.get("repetition_context_size", 20)
self.xtc_probability = self.body.get("xtc_probability", 0.0)
self.xtc_threshold = self.body.get("xtc_threshold", 0.0)
self.logit_bias = self.body.get("logit_bias", None)
self.logprobs = self.body.get("logprobs", -1)
self.validate_model_parameters()
# Load the model if needed
try:
self.model, self.tokenizer = self.model_provider.load(
self.requested_model, self.adapter
self.requested_model,
self.adapter,
self.requested_draft_model,
)
except:
self._set_completion_headers(404)
@@ -336,6 +380,15 @@ class APIHandler(BaseHTTPRequestHandler):
if not isinstance(self.top_p, (float, int)) or self.top_p < 0 or self.top_p > 1:
raise ValueError("top_p must be a float between 0 and 1")
if not isinstance(self.top_k, int) or self.top_k < 0:
raise ValueError("top_k must be a non-negative integer")
if not isinstance(self.min_p, (float, int)) or self.min_p < 0 or self.min_p > 1:
raise ValueError("min_p must be a float between 0 and 1")
if not isinstance(self.num_draft_tokens, int) or self.num_draft_tokens < 0:
raise ValueError("num_draft_tokens must be a non-negative integer")
if (
not isinstance(self.repetition_penalty, (float, int))
or self.repetition_penalty < 0
@@ -361,7 +414,15 @@ class APIHandler(BaseHTTPRequestHandler):
self.logit_bias = {int(k): v for k, v in self.logit_bias.items()}
except ValueError:
raise ValueError("logit_bias must be a dict of int to float")
if not (
isinstance(self.xtc_probability, float)
and 0.00 <= self.xtc_probability <= 1.00
):
raise ValueError(f"xtc_probability must be a float between 0.00 and 1.00")
if not (
isinstance(self.xtc_threshold, float) and 0.00 <= self.xtc_threshold <= 0.50
):
raise ValueError(f"xtc_threshold must be a float between 0.00 and 0.5")
if not isinstance(self.requested_model, str):
raise ValueError("model must be a string")
if self.adapter is not None and not isinstance(self.adapter, str):
@@ -376,6 +437,7 @@ class APIHandler(BaseHTTPRequestHandler):
token_logprobs: Optional[List[float]] = None,
top_tokens: Optional[List[Dict[int, float]]] = None,
tokens: Optional[List[int]] = None,
tool_calls: Optional[List[str]] = None,
) -> dict:
"""
Generate a single response packet based on response type (stream or
@@ -394,13 +456,26 @@ class APIHandler(BaseHTTPRequestHandler):
top_tokens (Optional[List[Dict[int, float]]]): List of dictionaries mapping
tokens to logprobs for the top N tokens at each token position.
tokens (Optional[List[int]]): List of tokens to return with logprobs structure
tool_calls (Optional[List[str]]): List of tool calls.
Returns:
dict: A dictionary containing the response, in the same format as
OpenAI's API.
"""
token_logprobs = token_logprobs if token_logprobs else []
top_logprobs = top_tokens if top_tokens else []
token_logprobs = token_logprobs or []
top_logprobs = top_tokens or []
tool_calls = tool_calls or []
def parse_function(tool_text):
tool_call = json.loads(tool_text.strip())
return {
"function": {
"name": tool_call.get("name", None),
"arguments": json.dumps(tool_call.get("arguments", "")),
},
"type": "function",
"id": None,
}
# Static response
response = {
@@ -418,7 +493,7 @@ class APIHandler(BaseHTTPRequestHandler):
"tokens": tokens,
},
"finish_reason": finish_reason,
}
},
],
}
@@ -442,7 +517,11 @@ class APIHandler(BaseHTTPRequestHandler):
# Add dynamic response
if self.object_type.startswith("chat.completion"):
key_name = "delta" if self.stream else "message"
choice[key_name] = {"role": "assistant", "content": text}
choice[key_name] = {
"role": "assistant",
"content": text,
"tool_calls": [parse_function(tool_text) for tool_text in tool_calls],
}
elif self.object_type == "text_completion":
choice.update(text=text)
else:
@@ -450,18 +529,87 @@ class APIHandler(BaseHTTPRequestHandler):
return response
def reset_prompt_cache(self, prompt):
"""Resets the prompt cache and associated state.
Args:
prompt (List[int]): The tokenized new prompt which will populate the
reset cache.
"""
logging.debug(f"*** Resetting cache. ***")
self.prompt_cache.model_key = self.model_provider.model_key
self.prompt_cache.cache = make_prompt_cache(self.model_provider.model)
if self.model_provider.draft_model is not None:
self.prompt_cache.cache += make_prompt_cache(
self.model_provider.draft_model
)
self.prompt_cache.tokens = list(prompt) # Cache the new prompt fully
def get_prompt_cache(self, prompt):
"""
Determines the portion of the prompt that needs processing by comparing
it to the cached prompt and attempting to reuse the common prefix.
This function updates the internal prompt cache state (tokens and model cache)
based on the comparison. If a common prefix exists, it attempts to trim
the model cache (if supported) to match the common prefix length, avoiding
recomputation.
Args:
prompt (List[int]): The tokenized new prompt.
Returns:
List[int]: The suffix of the prompt that actually needs to be processed
by the model. This will be the full prompt if the cache is
reset or cannot be effectively used.
"""
cache_len = len(self.prompt_cache.tokens)
prompt_len = len(prompt)
com_prefix_len = common_prefix_len(self.prompt_cache.tokens, prompt)
# Leave at least one token in the prompt
com_prefix_len = min(com_prefix_len, len(prompt) - 1)
# Condition 1: Model changed or no common prefix at all. Reset cache.
if (
self.prompt_cache.model_key != self.model_provider.model_key
or cache_len >= len(prompt)
or self.prompt_cache.tokens != prompt[:cache_len]
or com_prefix_len == 0
):
self.prompt_cache.model_key = self.model_provider.model_key
self.prompt_cache.cache = make_prompt_cache(self.model_provider.model)
self.reset_prompt_cache(prompt)
# Condition 2: Common prefix exists and matches cache length. Process suffix.
elif com_prefix_len == cache_len:
logging.debug(
f"*** Cache is prefix of prompt (cache_len: {cache_len}, prompt_len: {prompt_len}). Processing suffix. ***"
)
prompt = prompt[com_prefix_len:]
self.prompt_cache.tokens.extend(prompt)
# Condition 3: Common prefix exists but is shorter than cache length. Attempt trim.
elif com_prefix_len < cache_len:
logging.debug(
f"*** Common prefix ({com_prefix_len}) shorter than cache ({cache_len}). Attempting trim. ***"
)
if can_trim_prompt_cache(self.prompt_cache.cache):
num_to_trim = cache_len - com_prefix_len
logging.debug(f" Trimming {num_to_trim} tokens from cache.")
trim_prompt_cache(self.prompt_cache.cache, num_to_trim)
self.prompt_cache.tokens = self.prompt_cache.tokens[:com_prefix_len]
prompt = prompt[com_prefix_len:]
self.prompt_cache.tokens.extend(prompt)
else:
logging.debug(f" Cache cannot be trimmed. Resetting cache.")
self.reset_prompt_cache(prompt)
# This case should logically not be reached if com_prefix_len <= cache_len
else:
prompt = prompt[cache_len:]
self.prompt_cache.tokens.extend(prompt)
logging.error(
f"Unexpected cache state: com_prefix_len ({com_prefix_len}) > cache_len ({cache_len}). Resetting cache."
)
self.reset_prompt_cache(prompt)
logging.debug(f"Returning {len(prompt)} tokens for processing.")
return prompt
def handle_completion(
@@ -492,10 +640,28 @@ class APIHandler(BaseHTTPRequestHandler):
text = ""
tic = time.perf_counter()
sampler = make_sampler(self.temperature, top_p=self.top_p)
logits_processors = make_logits_processors(
self.logit_bias, self.repetition_penalty, self.repetition_context_size
sampler = make_sampler(
self.temperature,
top_p=self.top_p,
top_k=self.top_k,
min_p=self.min_p,
xtc_probability=self.xtc_probability,
xtc_threshold=self.xtc_threshold,
xtc_special_tokens=[
self.tokenizer.eos_token_id,
self.tokenizer.encode("\n"),
],
)
logits_processors = make_logits_processors(
self.logit_bias,
self.repetition_penalty,
self.repetition_context_size,
)
tool_calls = []
tool_text = ""
in_tool_call = False
segment = ""
for gen_response in stream_generate(
model=self.model,
tokenizer=self.tokenizer,
@@ -504,10 +670,26 @@ class APIHandler(BaseHTTPRequestHandler):
sampler=sampler,
logits_processors=logits_processors,
prompt_cache=self.prompt_cache.cache,
draft_model=self.model_provider.draft_model,
num_draft_tokens=self.num_draft_tokens,
):
segment = gen_response.text
text += segment
logging.debug(text)
logging.debug(gen_response.text)
if (
self.tokenizer.has_tool_calling
and gen_response.text == self.tokenizer.tool_call_start
):
in_tool_call = True
elif in_tool_call:
if gen_response.text == self.tokenizer.tool_call_end:
tool_calls.append(tool_text)
tool_text = ""
in_tool_call = False
else:
tool_text += gen_response.text
else:
text += gen_response.text
segment += gen_response.text
token = gen_response.token
logprobs = gen_response.logprobs
tokens.append(token)
@@ -531,9 +713,10 @@ class APIHandler(BaseHTTPRequestHandler):
tokens[-stop_condition.trim_length :]
)
text = text[: -len(stop_sequence_suffix)]
segment = ""
break
if self.stream:
if self.stream and not in_tool_call:
# If the end of tokens overlaps with a stop sequence, generate new
# tokens until we know if the stop sequence is hit or not
if any(
@@ -543,10 +726,14 @@ class APIHandler(BaseHTTPRequestHandler):
)
):
continue
elif segment:
response = self.generate_response(segment, None)
elif segment or tool_calls:
response = self.generate_response(
segment, None, tool_calls=tool_calls
)
self.wfile.write(f"data: {json.dumps(response)}\n\n".encode())
self.wfile.flush()
segment = ""
tool_calls = []
self.prompt_cache.tokens.extend(tokens)
@@ -555,7 +742,9 @@ class APIHandler(BaseHTTPRequestHandler):
logging.debug(f"Peak memory: {gen_response.peak_memory:.3f} GB")
if self.stream:
response = self.generate_response(segment, finish_reason)
response = self.generate_response(
segment, finish_reason, tool_calls=tool_calls
)
self.wfile.write(f"data: {json.dumps(response)}\n\n".encode())
self.wfile.flush()
if self.stream_options is not None and self.stream_options["include_usage"]:
@@ -573,6 +762,7 @@ class APIHandler(BaseHTTPRequestHandler):
token_logprobs=token_logprobs,
top_tokens=top_tokens,
tokens=tokens,
tool_calls=tool_calls,
)
response_json = json.dumps(response).encode()
indent = "\t" # Backslashes can't be inside of f-strings
@@ -622,8 +812,9 @@ class APIHandler(BaseHTTPRequestHandler):
process_message_content(messages)
prompt = self.tokenizer.apply_chat_template(
messages,
body.get("tools", None),
body.get("tools") or None,
add_generation_prompt=True,
**self.model_provider.cli_args.chat_template_args,
)
else:
prompt = convert_chat(body["messages"], body.get("role_mapping"))
@@ -650,11 +841,23 @@ class APIHandler(BaseHTTPRequestHandler):
"""
if self.path == "/v1/models":
self.handle_models_request()
elif self.path == "/health":
self.handle_health_check()
else:
self._set_completion_headers(404)
self.end_headers()
self.wfile.write(b"Not Found")
def handle_health_check(self):
"""
Handle a GET request for the /health endpoint.
"""
self._set_completion_headers(200)
self.end_headers()
self.wfile.write('{"status": "ok"}'.encode())
self.wfile.flush()
def handle_models_request(self):
"""
Handle a GET request for the /v1/models endpoint.
@@ -662,10 +865,20 @@ class APIHandler(BaseHTTPRequestHandler):
self._set_completion_headers(200)
self.end_headers()
files = ["config.json", "model.safetensors.index.json", "tokenizer_config.json"]
def probably_mlx_lm(repo):
if repo.repo_type != "model":
return False
if "main" not in repo.refs:
return False
file_names = {f.file_path.name for f in repo.refs["main"].files}
return all(f in file_names for f in files)
# Scan the cache directory for downloaded mlx models
hf_cache_info = scan_cache_dir()
downloaded_models = [
repo for repo in hf_cache_info.repos if "mlx" in repo.repo_id
repo for repo in hf_cache_info.repos if probably_mlx_lm(repo)
]
# Create a list of available models
@@ -694,6 +907,10 @@ def run(
):
server_address = (host, port)
prompt_cache = PromptCache()
infos = socket.getaddrinfo(
*server_address, type=socket.SOCK_STREAM, flags=socket.AI_PASSIVE
)
server_class.address_family, _, _, _, server_address = next(iter(infos))
httpd = server_class(
server_address,
lambda *args, **kwargs: handler_class(
@@ -736,6 +953,18 @@ def main():
default=8080,
help="Port for the HTTP server (default: 8080)",
)
parser.add_argument(
"--draft-model",
type=str,
help="A model to be used for speculative decoding.",
default=None,
)
parser.add_argument(
"--num-draft-tokens",
type=int,
help="Number of tokens to draft when using speculative decoding.",
default=3,
)
parser.add_argument(
"--trust-remote-code",
action="store_true",
@@ -748,13 +977,6 @@ def main():
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
help="Set the logging level (default: INFO)",
)
parser.add_argument(
"--cache-limit-gb",
type=int,
default=None,
help="Set the MLX cache limit in GB",
required=False,
)
parser.add_argument(
"--chat-template",
type=str,
@@ -767,19 +989,54 @@ def main():
action="store_true",
help="Use the default chat template",
)
parser.add_argument(
"--temp",
type=float,
default=0.0,
help="Default sampling temperature (default: 0.0)",
)
parser.add_argument(
"--top-p",
type=float,
default=1.0,
help="Default nucleus sampling top-p (default: 1.0)",
)
parser.add_argument(
"--top-k",
type=int,
default=0,
help="Default top-k sampling (default: 0, disables top-k)",
)
parser.add_argument(
"--min-p",
type=float,
default=0.0,
help="Default min-p sampling (default: 0.0, disables min-p)",
)
parser.add_argument(
"--max-tokens",
type=int,
default=512,
help="Default maximum number of tokens to generate (default: 512)",
)
parser.add_argument(
"--chat-template-args",
type=json.loads,
help="""A JSON formatted string of arguments for the tokenizer's apply_chat_template, e.g. '{"enable_thinking":false}'""",
default="{}",
)
args = parser.parse_args()
logging.basicConfig(
level=getattr(logging, args.log_level.upper(), None),
format="%(asctime)s - %(levelname)s - %(message)s",
)
if args.cache_limit_gb is not None:
logging.debug(f"Setting cache limit to {args.cache_limit_gb} GB")
mx.metal.set_cache_limit(args.cache_limit_gb * 1024 * 1024 * 1024)
run(args.host, args.port, ModelProvider(args))
if __name__ == "__main__":
print(
"Calling `python -m mlx_lm.server...` directly is deprecated."
" Use `mlx_lm.server...` or `python -m mlx_lm server ...` instead."
)
main()
+95 -8
View File
@@ -1,8 +1,9 @@
import json
from functools import partial
from json import JSONDecodeError
from typing import List
from transformers import AutoTokenizer
from transformers import AutoTokenizer, PreTrainedTokenizerFast
class StreamingDetokenizer:
@@ -90,6 +91,7 @@ class NaiveStreamingDetokenizer(StreamingDetokenizer):
self._current_text = self._tokenizer.decode(self._current_tokens)
if (
self._tokenizer.clean_up_tokenization_spaces
and len(self._current_text) > 0
and self._current_text[-1] == " "
):
self._current_text = self._current_text[:-1]
@@ -266,6 +268,28 @@ class TokenizerWrapper:
if eos_token_ids is not None
else {tokenizer.eos_token_id}
)
self._think_start = None
self._think_end = None
self._tool_call_start = None
self._tool_call_end = None
THINK_TOKENS = [("<think>", "</think>")]
TOOL_CALL_TOKENS = [("<tool_call>", "</tool_call>")]
vocab = tokenizer.get_vocab()
for think_start, think_end in THINK_TOKENS:
if think_start in vocab and think_end in vocab:
self._think_start = think_start
self._think_end = think_end
break
if tokenizer.chat_template and '"tool"' in tokenizer.chat_template:
self._tool_call_start = ""
self._tool_call_end = ""
for tool_call_start, tool_call_end in TOOL_CALL_TOKENS:
if tool_call_start in vocab and tool_call_end in vocab:
self._tool_call_start = tool_call_start
self._tool_call_end = tool_call_end
break
def add_eos_token(self, token: str):
token_id = None
@@ -279,6 +303,30 @@ class TokenizerWrapper:
self._eos_token_ids.add(token_id)
@property
def has_thinking(self):
return self._think_start is not None
@property
def think_start(self):
return self._think_start
@property
def think_end(self):
return self._think_end
@property
def has_tool_calling(self):
return self._tool_call_start is not None
@property
def tool_call_start(self):
return self._tool_call_start
@property
def tool_call_end(self):
return self._tool_call_end
def __getattr__(self, attr):
if attr == "detokenizer":
return self._detokenizer
@@ -301,6 +349,35 @@ class TokenizerWrapper:
setattr(self._tokenizer, attr, value)
class NewlineTokenizer(PreTrainedTokenizerFast):
"""A tokenizer that replaces newlines with <n> and <n> with new line."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def _preprocess_text(self, text):
return text.replace("\n", "<n>")
def _postprocess_text(self, text):
return text.replace("<n>", "\n")
def encode(self, text, **kwargs):
return super().encode(self._preprocess_text(text), **kwargs)
def encode_batch(self, texts, **kwargs):
return super().encode_batch([self._preprocess_text(t) for t in texts], **kwargs)
def decode(self, *args, **kwargs):
return self._postprocess_text(super().decode(*args, **kwargs))
def batch_decode(self, *args, **kwargs):
decoded = super().batch_decode(*args, **kwargs)
return [self._postprocess_text(d) for d in decoded]
AutoTokenizer.register("NewlineTokenizer", fast_tokenizer_class=NewlineTokenizer)
def _match(a, b):
if type(a) != type(b):
return False
@@ -341,7 +418,9 @@ def _is_bpe_decoder(decoder):
return isinstance(decoder, dict) and decoder.get("type", None) == "ByteLevel"
def load_tokenizer(model_path, tokenizer_config_extra={}, eos_token_ids=None):
def load_tokenizer(
model_path, tokenizer_config_extra={}, return_tokenizer=True, eos_token_ids=None
):
"""Load a huggingface tokenizer and try to infer the type of streaming
detokenizer to use.
@@ -353,7 +432,11 @@ def load_tokenizer(model_path, tokenizer_config_extra={}, eos_token_ids=None):
tokenizer_file = model_path / "tokenizer.json"
if tokenizer_file.exists():
with open(tokenizer_file, "r", encoding="utf-8") as fid:
tokenizer_content = json.load(fid)
try:
tokenizer_content = json.load(fid)
except JSONDecodeError as e:
raise JSONDecodeError("Failed to parse tokenizer.json", e.doc, e.pos)
if "decoder" in tokenizer_content:
if _is_spm_decoder(tokenizer_content["decoder"]):
detokenizer_class = SPMStreamingDetokenizer
@@ -364,11 +447,15 @@ def load_tokenizer(model_path, tokenizer_config_extra={}, eos_token_ids=None):
if isinstance(eos_token_ids, int):
eos_token_ids = [eos_token_ids]
return TokenizerWrapper(
AutoTokenizer.from_pretrained(model_path, **tokenizer_config_extra),
detokenizer_class,
eos_token_ids=eos_token_ids,
)
if return_tokenizer:
return TokenizerWrapper(
AutoTokenizer.from_pretrained(model_path, **tokenizer_config_extra),
detokenizer_class,
eos_token_ids=eos_token_ids,
)
else:
return detokenizer_class
def no_bos_or_eos(sequence: List, bos: int, eos: int) -> List:
+43
View File
@@ -0,0 +1,43 @@
# Copyright © 2024 Apple Inc.
try:
import wandb
except ImportError:
wandb = None
class TrainingCallback:
def on_train_loss_report(self, train_info: dict):
"""Called to report training loss at specified intervals."""
pass
def on_val_loss_report(self, val_info: dict):
"""Called to report validation loss at specified intervals or the beginning."""
pass
class WandBCallback(TrainingCallback):
def __init__(
self,
project_name: str,
log_dir: str,
config: dict,
wrapped_callback: TrainingCallback = None,
):
if wandb is None:
raise ImportError(
"wandb is not installed. Please install it to use WandBCallback."
)
self.wrapped_callback = wrapped_callback
wandb.init(project=project_name, dir=log_dir, config=config)
def on_train_loss_report(self, train_info: dict):
wandb.log(train_info, step=train_info.get("iteration"))
if self.wrapped_callback:
self.wrapped_callback.on_train_loss_report(train_info)
def on_val_loss_report(self, val_info: dict):
wandb.log(val_info, step=val_info.get("iteration"))
if self.wrapped_callback:
self.wrapped_callback.on_val_loss_report(val_info)
+86 -43
View File
@@ -1,13 +1,14 @@
import itertools
# Copyright © 2024 Apple Inc.
import json
import types
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
from transformers import PreTrainedTokenizer
class Dataset:
class TextDataset:
"""
Light-weight wrapper to hold a dataset.
"""
@@ -18,10 +19,15 @@ class Dataset:
tokenizer: PreTrainedTokenizer,
text_key: str = "text",
):
self._data = [tokenizer.encode(d[text_key]) for d in data]
for d in self._data:
if d[-1] != tokenizer.eos_token_id:
d.append(tokenizer.eos_token_id)
self._data = data
self.tokenizer = tokenizer
self.text_key = text_key
def process(self, d):
d = self.tokenizer.encode(d[self.text_key])
if d[-1] != self.tokenizer.eos_token_id:
d.append(self.tokenizer.eos_token_id)
return (d, 0)
def __getitem__(self, idx: int):
return self._data[idx]
@@ -43,17 +49,21 @@ class ChatDataset:
chat_key: str = "messages",
mask_prompt: bool = False,
):
self._data = []
for d in data:
messages = d[chat_key]
tools = d.get("tools", None)
tokens = tokenizer.apply_chat_template(messages, tools=tools)
if mask_prompt:
messages = messages[:-1]
offset = len(tokenizer.apply_chat_template(messages, tools=tools))
self._data.append((tokens, offset))
else:
self._data.append(tokens)
self._data = data
self.chat_key = chat_key
self.mask_prompt = mask_prompt
self.tokenizer = tokenizer
def process(self, d):
messages = d[self.chat_key]
tools = d.get("tools", None)
tokens = self.tokenizer.apply_chat_template(messages, tools=tools)
if self.mask_prompt:
messages = messages[:-1]
offset = len(self.tokenizer.apply_chat_template(messages, tools=tools))
return (tokens, offset)
else:
return (tokens, 0)
def __getitem__(self, idx: int):
return self._data[idx]
@@ -77,23 +87,28 @@ class CompletionsDataset:
completion_key: str,
mask_prompt: bool,
):
self._data = []
for d in data:
tokens = tokenizer.apply_chat_template(
[
{"role": "user", "content": d[prompt_key]},
{"role": "assistant", "content": d[completion_key]},
],
)
if mask_prompt:
offset = len(
tokenizer.apply_chat_template(
[{"role": "user", "content": d[prompt_key]}]
)
self._data = data
self.prompt_key = prompt_key
self.completion_key = completion_key
self.mask_prompt = mask_prompt
self.tokenizer = tokenizer
def process(self, d):
tokens = self.tokenizer.apply_chat_template(
[
{"role": "user", "content": d[self.prompt_key]},
{"role": "assistant", "content": d[self.completion_key]},
],
)
if self.mask_prompt:
offset = len(
self.tokenizer.apply_chat_template(
[{"role": "user", "content": d[self.prompt_key]}]
)
self._data.append((tokens, offset))
else:
self._data.append(tokens)
)
return (tokens, offset)
return (tokens, 0)
def __getitem__(self, idx: int):
return self._data[idx]
@@ -104,10 +119,38 @@ class CompletionsDataset:
class ConcatenatedDataset:
def __init__(self, data: List[Any]):
self._data = list(itertools.chain(*data))
self._data = data
self._len = sum(len(d) for d in self._data)
def __getitem__(self, idx: int):
return self._data[idx]
for data_idx, data in enumerate(self._data):
j = idx - len(data)
if j < 0:
break
idx = j
datum = data[idx]
datum["_dataset"] = data_idx
return datum
def process(self, d):
return self._data[d["_dataset"]].process(d)
def __len__(self):
return self._len
class CacheDataset:
def __init__(self, data: Any):
self._data = data
self._proc_data = [None] * len(data)
def itemlen(self, idx: int):
return len(self._data[idx])
def __getitem__(self, idx: int):
if self._proc_data[idx] is None:
self._proc_data[idx] = self._data.process(self._data[idx])
return self._proc_data[idx]
def __len__(self):
return len(self._data)
@@ -135,11 +178,11 @@ def create_dataset(
elif text_feature in sample:
if mask_prompt:
raise ValueError("Prompt masking not supported for text dataset.")
return Dataset(data, tokenizer, text_key=text_feature)
return TextDataset(data, tokenizer, text_key=text_feature)
else:
raise ValueError(
"Unsupported data format, check the supported formats here:\n"
"https://github.com/ml-explore/mlx-examples/blob/main/llms/mlx_lm/LORA.md#data."
"https://github.com/ml-explore/mlx-lm/blob/main/mlx_lm/LORA.md#Data."
)
@@ -204,8 +247,8 @@ def load_custom_hf_dataset(args, tokenizer: PreTrainedTokenizer):
collection = []
for ds in dataset_collection:
ds_name = ds["name"]
print(f"Loading Hugging Face dataset {ds_name}.")
ds_path = ds["path"]
print(f"Loading Hugging Face dataset {ds_path}.")
ds["mask_prompt"] = getattr(args, "mask_prompt", False)
config = types.SimpleNamespace(**ds)
hf_config = ds.get("config", {})
@@ -213,13 +256,13 @@ def load_custom_hf_dataset(args, tokenizer: PreTrainedTokenizer):
train_split = ds.get("train_split", "train[:80%]")
valid_split = ds.get("valid_split", "train[-10%:]")
train = create_hf_dataset(
ds_name,
ds_path,
config,
train_split,
hf_config,
)
valid = create_hf_dataset(
ds_name,
ds_path,
config,
valid_split,
hf_config,
@@ -230,7 +273,7 @@ def load_custom_hf_dataset(args, tokenizer: PreTrainedTokenizer):
if args.test:
test_split = ds.get("test_split")
test = create_hf_dataset(
ds_name,
ds_path,
config,
test_split,
hf_config,
+4 -5
View File
@@ -20,7 +20,7 @@ class LoRALinear(nn.Module):
# on linear and quantized linear
output_dims, input_dims = linear.weight.shape
if isinstance(linear, nn.QuantizedLinear):
input_dims *= 32 // linear.bits
input_dims = input_dims * 32 // linear.bits
lora_lin = LoRALinear(
input_dims=input_dims,
output_dims=output_dims,
@@ -52,9 +52,8 @@ class LoRALinear(nn.Module):
output_dims, input_dims = weight.shape
fused_linear = nn.Linear(input_dims, output_dims, bias=bias)
lora_b = (self.scale * self.lora_b.T).astype(dtype)
lora_a = self.lora_a.T.astype(dtype)
fused_linear.weight = weight + lora_b @ lora_a
delta = ((self.scale * self.lora_b.T) @ self.lora_a.T).astype(dtype)
fused_linear.weight = weight + delta
if bias:
fused_linear.bias = linear.bias
@@ -203,7 +202,7 @@ class LoRAEmbedding(nn.Module):
):
num_embeddings, dims = embedding.weight.shape
if isinstance(embedding, nn.QuantizedEmbedding):
dims *= 32 // embedding.bits
dims = dims * 32 // embedding.bits
lora_embedding = LoRAEmbedding(
num_embeddings=num_embeddings,
dims=dims,
+378
View File
@@ -0,0 +1,378 @@
# Copyright © 2025 Apple Inc.
import mlx.core as mx
import mlx.nn as nn
def _make_kl_forward_kernel():
source = """
constexpr int M = 4;
constexpr int block = 1024 * M;
constexpr int full_blocks = V / block;
constexpr int extra = V - full_blocks * block;
threadgroup float shared[32 * 2];
uint out_idx = threadgroup_position_in_grid.y;
uint simd_lane_id = thread_index_in_simdgroup;
uint simd_group_id = simdgroup_index_in_threadgroup;
logits_q += out_idx * V;
logits_p += out_idx * V;
out += out_idx;
float lse_q_minus_p;
float lse_p;
{
float max_q = -1e30;
float max_p = -1e30;
float sum_exp_q = 0;
float sum_exp_p = 0;
int offset = thread_index_in_threadgroup * M;
for (int i = 0; i < full_blocks; i++) {
// Read and update q and p
float vals_q[M];
float vals_p[M];
for (int j=0; j<M; j++) {
vals_q[j] = logits_q[offset + j];
vals_p[j] = logits_p[offset + j];
}
float prev_max_q = max_q;
float prev_max_p = max_p;
for (int j=0; j<M; j++) {
max_q = max(max_q, vals_q[j]);
max_p = max(max_p, vals_p[j]);
}
sum_exp_q *= metal::fast::exp(prev_max_q - max_q);
sum_exp_p *= metal::fast::exp(prev_max_p - max_p);
for (int j=0; j<M; j++) {
sum_exp_q += metal::fast::exp(vals_q[j] - max_q);
sum_exp_p += metal::fast::exp(vals_p[j] - max_p);
}
// Move to the next block
offset += block;
}
if (extra > 0) {
// Read and update q and p
float vals_q[M];
float vals_p[M];
for (int j=0; j < M; j++) {
vals_q[j] = (offset + j < V) ? logits_q[offset + j] : -1e30;
vals_p[j] = (offset + j < V) ? logits_p[offset + j] : -1e30;
}
float prev_max_q = max_q;
float prev_max_p = max_p;
for (int j=0; j<M; j++) {
max_q = max(max_q, vals_q[j]);
max_p = max(max_p, vals_p[j]);
}
sum_exp_q *= metal::fast::exp(prev_max_q - max_q);
sum_exp_p *= metal::fast::exp(prev_max_p - max_p);
for (int j=0; j<M; j++) {
sum_exp_q += metal::fast::exp(vals_q[j] - max_q);
sum_exp_p += metal::fast::exp(vals_p[j] - max_p);
}
}
// Share the maxs across the threadgroup
float prev_max_q = max_q;
float prev_max_p = max_p;
max_q = simd_max(max_q);
max_p = simd_max(max_p);
if (simd_lane_id == 0) {
shared[simd_group_id * 2 + 0] = max_q;
shared[simd_group_id * 2 + 1] = max_p;
}
threadgroup_barrier(mem_flags::mem_threadgroup);
max_q = shared[simd_lane_id * 2 + 0];
max_p = shared[simd_lane_id * 2 + 1];
max_q = simd_max(max_q);
max_p = simd_max(max_p);
// Share the sum_exp across the threadgroup
sum_exp_q *= metal::fast::exp(prev_max_q - max_q);
sum_exp_p *= metal::fast::exp(prev_max_p - max_p);
sum_exp_q = simd_sum(sum_exp_q);
sum_exp_p = simd_sum(sum_exp_p);
if (simd_lane_id == 0) {
shared[simd_group_id * 2 + 0] = sum_exp_q;
shared[simd_group_id * 2 + 1] = sum_exp_p;
}
threadgroup_barrier(mem_flags::mem_threadgroup);
sum_exp_q = shared[simd_lane_id * 2 + 0];
sum_exp_p = shared[simd_lane_id * 2 + 1];
sum_exp_q = simd_sum(sum_exp_q);
sum_exp_p = simd_sum(sum_exp_p);
lse_p = max_p + metal::fast::log(sum_exp_p);
lse_q_minus_p = max_q + metal::fast::log(sum_exp_q) - lse_p;
}
threadgroup_barrier(mem_flags::mem_none);
{
float kl = 0;
int offset = thread_index_in_threadgroup * M;
for (int i = 0; i < full_blocks; i++) {
// Read and add to the kl
float vals_q[M];
float vals_p[M];
for (int j=0; j<M; j++) {
vals_q[j] = logits_q[offset + j];
vals_p[j] = logits_p[offset + j];
}
for (int j=0; j<M; j++) {
kl += metal::fast::exp(vals_p[j] - lse_p) * (vals_p[j] - vals_q[j] + lse_q_minus_p);
}
// Move to the next block
offset += block;
}
if (extra > 0) {
float vals_q[M];
float vals_p[M];
for (int j=0; j<M; j++) {
vals_q[j] = (offset + j < V) ? logits_q[offset + j] : -1e30;
vals_p[j] = (offset + j < V) ? logits_p[offset + j] : -1e30;
}
for (int j=0; j<M; j++) {
kl += metal::fast::exp(vals_p[j] - lse_p) * (vals_p[j] - vals_q[j] + lse_q_minus_p);
}
}
// Add the kl across the threadgroup
kl = simd_sum(kl);
if (simd_lane_id == 0) {
shared[simd_group_id] = kl;
}
threadgroup_barrier(mem_flags::mem_none);
kl = shared[simd_lane_id];
kl = simd_sum(kl);
if (thread_index_in_threadgroup == 0) {
out[0] = static_cast<T>(kl);
}
}
"""
return mx.fast.metal_kernel(
name="kl_forward",
input_names=["logits_q", "logits_p"],
output_names=["out"],
source=source,
ensure_row_contiguous=True,
)
def _make_kl_backward_kernel():
source = """
constexpr int M = 4;
constexpr int block = 1024 * M;
constexpr int full_blocks = V / block;
constexpr int extra = V - full_blocks * block;
threadgroup float shared[32 * 2];
uint out_idx = threadgroup_position_in_grid.y;
uint simd_lane_id = thread_index_in_simdgroup;
uint simd_group_id = simdgroup_index_in_threadgroup;
logits_q += out_idx * V;
logits_p += out_idx * V;
out += out_idx * V;
cotan += out_idx;
float lse_q;
float lse_p;
{
float max_q = -1e30;
float max_p = -1e30;
float sum_exp_q = 0;
float sum_exp_p = 0;
int offset = thread_index_in_threadgroup * M;
for (int i = 0; i < full_blocks; i++) {
// Read and update q and p
float vals_q[M];
float vals_p[M];
for (int j=0; j<M; j++) {
vals_q[j] = logits_q[offset + j];
vals_p[j] = logits_p[offset + j];
}
float prev_max_q = max_q;
float prev_max_p = max_p;
for (int j=0; j<M; j++) {
max_q = max(max_q, vals_q[j]);
max_p = max(max_p, vals_p[j]);
}
sum_exp_q *= metal::fast::exp(prev_max_q - max_q);
sum_exp_p *= metal::fast::exp(prev_max_p - max_p);
for (int j=0; j<M; j++) {
sum_exp_q += metal::fast::exp(vals_q[j] - max_q);
sum_exp_p += metal::fast::exp(vals_p[j] - max_p);
}
// Move to the next block
offset += block;
}
if (extra > 0) {
// Read and update q and p
float vals_q[M];
float vals_p[M];
for (int j=0; j < M; j++) {
vals_q[j] = (offset + j < V) ? logits_q[offset + j] : -1e30;
vals_p[j] = (offset + j < V) ? logits_p[offset + j] : -1e30;
}
float prev_max_q = max_q;
float prev_max_p = max_p;
for (int j=0; j<M; j++) {
max_q = max(max_q, vals_q[j]);
max_p = max(max_p, vals_p[j]);
}
sum_exp_q *= metal::fast::exp(prev_max_q - max_q);
sum_exp_p *= metal::fast::exp(prev_max_p - max_p);
for (int j=0; j<M; j++) {
sum_exp_q += metal::fast::exp(vals_q[j] - max_q);
sum_exp_p += metal::fast::exp(vals_p[j] - max_p);
}
}
// Share the maxs across the threadgroup
float prev_max_q = max_q;
float prev_max_p = max_p;
max_q = simd_max(max_q);
max_p = simd_max(max_p);
if (simd_lane_id == 0) {
shared[simd_group_id * 2 + 0] = max_q;
shared[simd_group_id * 2 + 1] = max_p;
}
threadgroup_barrier(mem_flags::mem_threadgroup);
max_q = shared[simd_lane_id * 2 + 0];
max_p = shared[simd_lane_id * 2 + 1];
max_q = simd_max(max_q);
max_p = simd_max(max_p);
// Share the sum_exp across the threadgroup
sum_exp_q *= metal::fast::exp(prev_max_q - max_q);
sum_exp_p *= metal::fast::exp(prev_max_p - max_p);
sum_exp_q = simd_sum(sum_exp_q);
sum_exp_p = simd_sum(sum_exp_p);
if (simd_lane_id == 0) {
shared[simd_group_id * 2 + 0] = sum_exp_q;
shared[simd_group_id * 2 + 1] = sum_exp_p;
}
threadgroup_barrier(mem_flags::mem_threadgroup);
sum_exp_q = shared[simd_lane_id * 2 + 0];
sum_exp_p = shared[simd_lane_id * 2 + 1];
sum_exp_q = simd_sum(sum_exp_q);
sum_exp_p = simd_sum(sum_exp_p);
lse_p = max_p + metal::fast::log(sum_exp_p);
lse_q = max_q + metal::fast::log(sum_exp_q);
}
threadgroup_barrier(mem_flags::mem_none);
{
float kl = 0;
float c = cotan[0];
int offset = thread_index_in_threadgroup * M;
for (int i = 0; i < full_blocks; i++) {
// Read and add to the kl
float vals_q[M];
float vals_p[M];
for (int j=0; j<M; j++) {
vals_q[j] = logits_q[offset + j];
vals_p[j] = logits_p[offset + j];
}
for (int j=0; j<M; j++) {
out[offset + j] = static_cast<T>(
c * (metal::fast::exp(vals_q[j] - lse_q) - metal::fast::exp(vals_p[j] - lse_p)));
}
// Move to the next block
offset += block;
}
if (extra > 0) {
float vals_q[M];
float vals_p[M];
for (int j=0; j<M; j++) {
vals_q[j] = (offset + j < V) ? logits_q[offset + j] : -1e30;
vals_p[j] = (offset + j < V) ? logits_p[offset + j] : -1e30;
}
for (int j=0; j<M; j++) {
if (offset + j < V) {
out[offset + j] = static_cast<T>(
c * (metal::fast::exp(vals_q[j] - lse_q) - metal::fast::exp(vals_p[j] - lse_p)));
}
}
}
}
"""
return mx.fast.metal_kernel(
name="kl_backward",
input_names=["logits_q", "logits_p", "cotan"],
output_names=["out"],
source=source,
ensure_row_contiguous=True,
)
_kl_forward_kernel = _make_kl_forward_kernel()
_kl_backward_kernel = _make_kl_backward_kernel()
@mx.custom_function
def _kl_div_loss(logits_q, logits_p):
n_outs = logits_q.size // logits_q.shape[-1]
dt = logits_q.dtype
return _kl_forward_kernel(
inputs=[logits_q, logits_p],
output_shapes=[logits_q.shape[:-1]],
output_dtypes=[dt],
template=[("T", dt), ("V", logits_q.shape[-1])],
grid=(1024, n_outs, 1),
threadgroup=(1024, 1, 1),
)[0]
@_kl_div_loss.vjp
def _kl_div_loss(primals, cotangent, output):
logits_q, logits_p = primals
dt = logits_q.dtype
dp = mx.zeros_like(logits_p)
dq = _kl_backward_kernel(
inputs=[logits_q, logits_p, cotangent],
output_shapes=[logits_q.shape],
output_dtypes=[dt],
template=[("T", dt), ("V", logits_q.shape[-1])],
grid=(1024, cotangent.size, 1),
threadgroup=(1024, 1, 1),
)[0]
return dq, dp
def kl_div_loss(logits_q, logits_p):
if mx.metal.is_available():
return _kl_div_loss(logits_q, logits_p)
else:
return nn.losses.kl_div_loss(
logits_q - mx.logsumexp(logits_q, axis=-1, keepdims=True),
logits_p - mx.logsumexp(logits_p, axis=-1, keepdims=True),
axis=-1,
reduction="none",
)
+38 -42
View File
@@ -1,20 +1,20 @@
# Copyright © 2024 Apple Inc.
import glob
import shutil
import time
from dataclasses import dataclass, field
from functools import partial
from pathlib import Path
from typing import List, Optional, Tuple
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 transformers import PreTrainedTokenizer
from tqdm import tqdm
from .datasets import CompletionsDataset
from .callbacks import TrainingCallback
from .datasets import CacheDataset
def grad_checkpoint(layer):
@@ -71,27 +71,29 @@ def default_loss(model, batch, lengths):
targets = batch[:, 1:]
logits = model(inputs)
logits = logits.astype(mx.float32)
steps = mx.arange(1, targets.shape[1] + 1)
mask = mx.logical_and(steps >= lengths[:, 0:1], steps <= lengths[:, 1:])
ce = nn.losses.cross_entropy(logits, targets) * mask
ntoks = mask.sum()
ce = ce.sum() / ntoks
ce = ce.astype(mx.float32).sum() / ntoks
return ce, ntoks
def iterate_batches(
dataset,
tokenizer,
batch_size,
max_seq_length,
train=False,
):
# Sort by length:
idx = sorted(range(len(dataset)), key=lambda idx: len(dataset[idx]))
if isinstance(dataset, CacheDataset):
len_fn = lambda idx: dataset.itemlen(idx)
else:
len_fn = lambda idx: len(dataset[idx][0])
idx = sorted(range(len(dataset)), key=len_fn)
if len(dataset) < batch_size:
raise ValueError(
f"Dataset must have at least batch_size={batch_size}"
@@ -126,9 +128,9 @@ def iterate_batches(
"Consider pre-splitting your data to save memory."
)
# Pad to the nearest multiple of 8 or the maximum length
pad_to = 8
max_length_in_batch = pad_to * ((max(lengths) + pad_to - 1) // pad_to)
# Pad to one plus nearest multiple of pad_to or the maximum length
pad_to = 32
max_length_in_batch = 1 + pad_to * ((max(lengths) + pad_to - 1) // pad_to)
max_length_in_batch = min(max_length_in_batch, max_seq_length)
batch_arr = np.zeros((batch_size // step, max_length_in_batch), np.int32)
@@ -149,26 +151,29 @@ def iterate_batches(
def evaluate(
model,
dataset,
tokenizer,
batch_size,
num_batches,
max_seq_length=2048,
loss: callable = default_loss,
iterate_batches: callable = iterate_batches,
):
model.eval()
all_losses = mx.array(0.0)
ntokens = mx.array(0)
index_iterator = iter(range(num_batches)) if num_batches != -1 else iter(int, 1)
for _, batch in zip(
index_iterator,
iterate_batches(
dataset=dataset,
tokenizer=tokenizer,
batch_size=batch_size,
max_seq_length=max_seq_length,
for _, batch in tqdm(
zip(
index_iterator,
iterate_batches(
dataset=dataset,
batch_size=batch_size,
max_seq_length=max_seq_length,
),
),
desc="Calculating loss...",
total=min(len(dataset) // batch_size, num_batches),
):
losses, toks = loss(model, *batch)
all_losses += losses * toks
@@ -181,20 +186,8 @@ def evaluate(
return (all_losses / ntokens).item()
class TrainingCallback:
def on_train_loss_report(self, train_info: dict):
"""Called to report training loss at specified intervals."""
pass
def on_val_loss_report(self, val_info: dict):
"""Called to report validation loss at specified intervals or the beginning."""
pass
def train(
model,
tokenizer,
optimizer,
train_dataset,
val_dataset,
@@ -203,6 +196,7 @@ def train(
iterate_batches: callable = iterate_batches,
training_callback: TrainingCallback = None,
):
mx.set_wired_limit(mx.metal.device_info()["max_recommended_working_set_size"])
print(f"Starting training..., iters: {args.iters}")
world = mx.distributed.init()
world_size = world.size()
@@ -213,8 +207,9 @@ def train(
if args.grad_checkpoint:
grad_checkpoint(model.layers[0])
state = [model.state, optimizer.state]
state = [model.state, optimizer.state, mx.random.state]
@partial(mx.compile, inputs=state, outputs=state)
def step(batch):
# Forward and backward pass
(lvalue, toks), grad = loss_value_and_grad(model, *batch)
@@ -229,6 +224,7 @@ def train(
loss_value_and_grad = nn.value_and_grad(model, loss)
model.train()
losses = 0
n_tokens = 0
steps = 0
@@ -239,7 +235,6 @@ def train(
range(1, args.iters + 1),
iterate_batches(
dataset=train_dataset,
tokenizer=tokenizer,
batch_size=args.batch_size,
max_seq_length=args.max_seq_length,
train=True,
@@ -254,12 +249,12 @@ def train(
model=model,
dataset=val_dataset,
loss=loss,
tokenizer=tokenizer,
batch_size=args.batch_size,
num_batches=args.val_batches,
max_seq_length=args.max_seq_length,
iterate_batches=iterate_batches,
)
model.train()
val_time = time.perf_counter() - tic
if rank == 0:
print(
@@ -271,7 +266,7 @@ def train(
if training_callback is not None:
val_info = {
"iteration": it,
"iteration": it - 1,
"val_loss": val_loss,
"val_time": val_time,
}
@@ -289,13 +284,13 @@ def train(
# Report training loss if needed
if it % args.steps_per_report == 0 or it == args.iters:
train_loss = mx.distributed.all_sum(losses, stream=mx.cpu).item()
train_loss /= steps * mx.distributed.init().size()
train_loss /= steps * world_size
n_tokens = mx.distributed.all_sum(n_tokens, stream=mx.cpu).item()
learning_rate = optimizer.learning_rate.item()
it_sec = args.steps_per_report / train_time
tokens_sec = float(n_tokens) / train_time
trained_tokens += n_tokens
peak_mem = mx.metal.get_peak_memory() / 1e9
peak_mem = mx.get_peak_memory() / 1e9
if rank == 0:
print(
f"Iter {it}: Train loss {train_loss:.3f}, "
@@ -325,7 +320,7 @@ def train(
train_time = 0
# Save adapter weights
if it % args.steps_per_save == 0:
if it % args.steps_per_save == 0 and rank == 0:
adapter_weights = dict(tree_flatten(model.trainable_parameters()))
mx.save_safetensors(str(args.adapter_file), adapter_weights)
checkpoint = (
@@ -338,6 +333,7 @@ def train(
)
# Save final weights
adapter_weights = dict(tree_flatten(model.trainable_parameters()))
mx.save_safetensors(str(args.adapter_file), adapter_weights)
print(f"Saved final weights to {args.adapter_file}.")
if rank == 0:
adapter_weights = dict(tree_flatten(model.trainable_parameters()))
mx.save_safetensors(str(args.adapter_file), adapter_weights)
print(f"Saved final weights to {args.adapter_file}.")
+61 -42
View File
@@ -54,6 +54,13 @@ def linear_to_lora_layers(
"""
def to_lora(layer):
if not use_dora and hasattr(layer, "to_lora"):
return layer.to_lora(
r=config["rank"],
scale=config["scale"],
dropout=config["dropout"],
)
if isinstance(layer, (nn.Linear, nn.QuantizedLinear)):
LoRALayer = DoRALinear if use_dora else LoRALinear
elif isinstance(layer, (SwitchLinear, QuantizedSwitchLinear)):
@@ -79,6 +86,7 @@ def linear_to_lora_layers(
keys = set(keys)
elif model.model_type in [
"mistral",
"mistral3",
"llama",
"phi",
"mixtral",
@@ -87,19 +95,29 @@ def linear_to_lora_layers(
"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",
"mimo",
"ernie4_5",
"dots1",
]:
keys = set(["self_attn.q_proj", "self_attn.v_proj"])
if model.model_type in ["mixtral", "phimoe"]:
@@ -107,7 +125,7 @@ def linear_to_lora_layers(
if model.model_type == "qwen2_moe":
keys.add("mlp.gate")
keys.add("mlp.shared_expert_gate")
if model.model_type == "olmoe":
if model.model_type in ["olmoe", "qwen3_moe", "dots1"]:
keys.add("mlp.gate")
elif model.model_type == "gpt_bigcode":
@@ -128,7 +146,7 @@ def linear_to_lora_layers(
keys = set(["norm_attn_norm.attn.Wqkv", "ffn.router.layer"])
elif model.model_type == "internlm2":
keys = set(["attention.wqkv", "attention.wo"])
elif model.model_type == "deepseek_v2":
elif model.model_type == "deepseek_v2" or model.model_type == "minicpm3":
keys = set(
[
"self_attn.q_proj",
@@ -200,39 +218,36 @@ def dequantize(model: nn.Module) -> nn.Module:
Returns:
nn.Module: The model with dequantized layers.
"""
de_quantize_layers = []
dequantize_layers = []
for name, module in model.named_modules():
bias = "bias" in module
if isinstance(module, nn.QuantizedLinear):
bias = "bias" in module
weight = module.weight
weight = mx.dequantize(
weight,
module.scales,
module.biases,
module.group_size,
module.bits,
).astype(mx.float16)
output_dims, input_dims = weight.shape
linear = nn.Linear(input_dims, output_dims, bias=bias)
linear.weight = weight
if bias:
linear.bias = module.bias
de_quantize_layers.append((name, linear))
if isinstance(module, nn.QuantizedEmbedding):
weight = mx.dequantize(
module.weight,
module.scales,
module.biases,
module.group_size,
module.bits,
).astype(mx.float16)
num_embeddings, dims = weight.shape
emb = nn.Embedding(num_embeddings, dims)
emb.weight = weight
de_quantize_layers.append((name, emb))
cls = nn.Linear
kwargs = {"bias": bias}
elif isinstance(module, nn.QuantizedEmbedding):
kwargs = {}
cls = nn.Embedding
elif isinstance(module, QuantizedSwitchLinear):
kwargs = {"bias": bias}
cls = SwitchLinear
else:
continue
weight = mx.dequantize(
module.weight,
module.scales,
module.biases,
module.group_size,
module.bits,
)
args = weight.shape[::-1]
m = cls(*args, **kwargs)
if bias:
m.bias = module.bias
m.weight = weight
dequantize_layers.append((name, m))
if len(de_quantize_layers) > 0:
model.update_modules(tree_unflatten(de_quantize_layers))
if len(dequantize_layers) > 0:
model.update_modules(tree_unflatten(dequantize_layers))
return model
@@ -255,20 +270,24 @@ def remove_lora_layers(model: nn.Module) -> nn.Module:
return model
def nparams(module):
if hasattr(module, "bits"):
n = 0 if not hasattr(module, "bias") else module.bias.size
return n + module.weight.size * 32 // module.bits
return sum(v.size for _, v in tree_flatten(module.parameters()))
def print_trainable_parameters(model):
def get_total_parameters(model):
leaf_modules = tree_flatten(
model.leaf_modules(), is_leaf=lambda m: isinstance(m, nn.Module)
)
total_p = sum(nparams(m) for _, m in leaf_modules) / 10**6
def nparams(m):
if hasattr(m, "bits"):
n = 0 if not hasattr(m, "bias") else m.bias.size
return n + m.weight.size * 32 // m.bits
return sum(v.size for _, v in tree_flatten(m.parameters()))
return sum(nparams(m) for _, m in leaf_modules)
def print_trainable_parameters(model):
total_p = get_total_parameters(model) / 1e6
trainable_p = (
sum(v.size for _, v in tree_flatten(model.trainable_parameters())) / 10**6
sum(v.size for _, v in tree_flatten(model.trainable_parameters())) / 1e6
)
print(
f"Trainable parameters: {(trainable_p * 100 / total_p):.3f}% "
+22
View File
@@ -0,0 +1,22 @@
# Copyright © 2025 Apple Inc.
import argparse
from .utils import upload_to_hub
def main():
parser = argparse.ArgumentParser(
description="Upload a model to the Hugging Face Hub"
)
parser.add_argument(
"--path", type=str, default="mlx_model", help="Path to the MLX model."
)
parser.add_argument(
"--upload-repo",
help="The Hugging Face repo to upload the model to.",
type=str,
)
args = parser.parse_args()
upload_to_hub(args.path, args.upload_repo)
+128 -677
View File
@@ -1,25 +1,19 @@
# Copyright © 2023-2024 Apple Inc.
import contextlib
import copy
import functools
import glob
import importlib
import inspect
import json
import logging
import os
import shutil
import time
from dataclasses import dataclass
from pathlib import Path
from textwrap import dedent
from typing import (
Any,
Callable,
Dict,
Generator,
List,
NamedTuple,
Optional,
Tuple,
Type,
@@ -33,21 +27,17 @@ if os.getenv("MLXLM_USE_MODELSCOPE", "False").lower() == "true":
try:
from modelscope import snapshot_download
except ImportError:
raise ImportError(
"Please run `pip install modelscope` to activate the ModelScope."
)
raise ImportError("Run `pip install modelscope` to use ModelScope.")
else:
from huggingface_hub import snapshot_download
from mlx.utils import tree_flatten, tree_reduce
from mlx.utils import tree_flatten, tree_map, tree_reduce
from transformers import PreTrainedTokenizer
# Local imports
from .models import cache
from .sample_utils import make_logits_processors, make_sampler
from .tokenizer_utils import TokenizerWrapper, load_tokenizer
from .tuner.utils import dequantize as dequantize_model
from .tuner.utils import load_adapters, nparams
from .tuner.utils import get_total_parameters, load_adapters
# Constants
MODEL_REMAPPING = {
@@ -58,79 +48,6 @@ MODEL_REMAPPING = {
MAX_FILE_SIZE_GB = 5
# A stream on the default device just for generation
generation_stream = mx.new_stream(mx.default_device())
class ModelNotFoundError(Exception):
def __init__(self, message):
self.message = message
super().__init__(self.message)
@dataclass
class GenerationResponse:
"""
The output of :func:`stream_generate`.
Args:
text (str): The next segment of decoded text. This can be an empty string.
token (int): The next token.
from_draft (bool): Whether the token was generated by the draft model.
logprobs (mx.array): A vector of log probabilities.
prompt_tokens (int): The number of tokens in the prompt.
prompt_tps (float): The prompt processing tokens-per-second.
generation_tokens (int): The number of generated tokens.
generation_tps (float): The tokens-per-second for generation.
peak_memory (float): The peak memory used so far in GB.
finish_reason (str): The reason the response is being sent: "length", "stop" or `None`
"""
text: str
token: int
logprobs: mx.array
from_draft: bool
prompt_tokens: int
prompt_tps: float
generation_tokens: int
generation_tps: float
peak_memory: float
finish_reason: Optional[str] = None
@contextlib.contextmanager
def wired_limit(model: nn.Module, streams: Optional[List[mx.Stream]] = None):
"""
A context manager to temporarily change the wired limit.
Note, the wired limit should not be changed during an async eval. If an
async eval could be running pass in the streams to synchronize with prior
to exiting the context manager.
"""
model_bytes = tree_reduce(
lambda acc, x: acc + x.nbytes if isinstance(x, mx.array) else acc, model, 0
)
max_rec_size = mx.metal.device_info()["max_recommended_working_set_size"]
if model_bytes > 0.9 * max_rec_size:
model_mb = model_bytes // 2**20
max_rec_mb = max_rec_size // 2**20
print(
f"[WARNING] Generating with a model that requires {model_mb} MB "
f"which is close to the maximum recommended size of {max_rec_mb} "
"MB. This can be slow. See the documentation for possible work-arounds: "
"https://github.com/ml-explore/mlx-lm/tree/main/llms#large-models"
)
old_limit = mx.metal.set_wired_limit(max_rec_size)
try:
yield None
finally:
if streams is not None:
for s in streams:
mx.synchronize(s)
else:
mx.synchronize()
mx.metal.set_wired_limit(old_limit)
def _get_classes(config: dict):
"""
@@ -158,10 +75,7 @@ def compute_bits_per_weight(model):
model_bytes = tree_reduce(
lambda acc, x: acc + x.nbytes if isinstance(x, mx.array) else acc, model, 0
)
leaf_modules = tree_flatten(
model.leaf_modules(), is_leaf=lambda m: isinstance(m, nn.Module)
)
model_params = sum(nparams(m) for _, m in leaf_modules)
model_params = get_total_parameters(model)
return model_bytes * 8 / model_params
@@ -175,489 +89,36 @@ def get_model_path(path_or_hf_repo: str, revision: Optional[str] = None) -> Path
revision (str, optional): A revision id which can be a branch name, a tag, or a commit hash.
Returns:
Path: The path to the model.
Tuple[Path, str]: A tuple containing the local file path and the Hugging Face repo ID.
"""
model_path = Path(path_or_hf_repo)
if not model_path.exists():
try:
model_path = Path(
snapshot_download(
path_or_hf_repo,
revision=revision,
allow_patterns=[
"*.json",
"*.safetensors",
"*.py",
"tokenizer.model",
"*.tiktoken",
"tiktoken.model",
"*.txt",
"*.jsonl",
],
)
hf_path = path_or_hf_repo
model_path = Path(
snapshot_download(
path_or_hf_repo,
revision=revision,
allow_patterns=[
"*.json",
"*.safetensors",
"*.py",
"tokenizer.model",
"*.tiktoken",
"tiktoken.model",
"*.txt",
"*.jsonl",
"*.jinja",
],
)
except:
raise ModelNotFoundError(
f"Model not found for path or HF repo: {path_or_hf_repo}.\n"
"Please make sure you specified the local path or Hugging Face"
" repo id correctly.\nIf you are trying to access a private or"
" gated Hugging Face repo, make sure you are authenticated:\n"
"https://huggingface.co/docs/huggingface_hub/en/guides/cli#huggingface-cli-login"
) from None
return model_path
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
)
def generate_step(
prompt: mx.array,
model: nn.Module,
*,
max_tokens: int = 256,
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,
prefill_step_size: int = 512,
kv_bits: Optional[int] = None,
kv_group_size: int = 64,
quantized_kv_start: int = 0,
prompt_progress_callback: Optional[Callable[int, int]] = None,
) -> Generator[Tuple[mx.array, mx.array], None, None]:
"""
A generator producing token ids based on the given prompt from the model.
Args:
prompt (mx.array): The input prompt.
model (nn.Module): The model to use for generation.
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
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
logits. Default: ``None``.
max_kv_size (int, optional): Maximum size of the key-value cache. Old
entries (except the first 4 tokens) will be overwritten.
prompt_cache (List[Any], optional): A pre-computed prompt cache. Note, if
provided, the cache will be updated in place.
prefill_step_size (int): Step size for processing the prompt.
kv_bits (int, optional): Number of bits to use for KV cache quantization.
None implies no cache quantization. Default: ``None``.
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_prorgress_callback (Callable[int, int]): A call-back which takes the
prompt tokens processed so far and the total number of prompt tokens.
Yields:
Tuple[mx.array, mx.array]: One token and a vector of log probabilities.
"""
y = prompt
tokens = None
# Create the KV cache for generation
if prompt_cache is None:
prompt_cache = cache.make_prompt_cache(
model,
max_kv_size=max_kv_size,
)
elif len(prompt_cache) != len(model.layers):
raise ValueError("Wrong number of layers in the prompt cache.")
prompt_progress_callback = prompt_progress_callback or (lambda *_: None)
quantize_cache_fn = functools.partial(
maybe_quantize_kv_cache,
quantized_kv_start=quantized_kv_start,
kv_group_size=kv_group_size,
kv_bits=kv_bits,
)
sampler = sampler or (lambda x: mx.argmax(x, axis=-1))
def _step(y):
with mx.stream(generation_stream):
logits = model(y[None], cache=prompt_cache)
logits = logits[:, -1, :]
if logits_processors:
nonlocal tokens
tokens = mx.concat([tokens, y]) if tokens is not None else y
for processor in logits_processors:
logits = processor(tokens, logits)
quantize_cache_fn(prompt_cache)
logprobs = logits - mx.logsumexp(logits, keepdims=True)
y = sampler(logprobs)
return y, logprobs.squeeze(0)
with mx.stream(generation_stream):
total_prompt_tokens = y.size
prompt_processed_tokens = 0
while y.size > prefill_step_size:
model(y[:prefill_step_size][None], cache=prompt_cache)
quantize_cache_fn(prompt_cache)
mx.eval([c.state for c in prompt_cache])
prompt_progress_callback(prompt_processed_tokens, total_prompt_tokens)
prompt_processed_tokens += prefill_step_size
y = y[prefill_step_size:]
mx.metal.clear_cache()
y, logprobs = _step(y)
mx.async_eval(y, logprobs)
n = 0
while True:
if n != max_tokens:
next_y, next_logprobs = _step(y)
mx.async_eval(next_y, next_logprobs)
if n == 0:
mx.eval(y)
prompt_progress_callback(total_prompt_tokens, total_prompt_tokens)
if n == max_tokens:
break
yield y.item(), logprobs
if n % 256 == 0:
mx.metal.clear_cache()
y, logprobs = next_y, next_logprobs
n += 1
def speculative_generate_step(
prompt: mx.array,
model: nn.Module,
draft_model: nn.Module,
*,
num_draft_tokens=2,
max_tokens: int = 256,
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,
kv_bits: Optional[int] = None,
kv_group_size: int = 64,
quantized_kv_start: int = 0,
) -> Generator[Tuple[mx.array, mx.array, bool], None, None]:
"""
A generator producing token ids based on the given prompt from the model.
Args:
prompt (mx.array): The input prompt.
model (nn.Module): The model to use for generation.
draft_model (nn.Module): The draft model for speculative decoding.
num_draft_tokens (int, optional): The number of draft tokens for
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
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
logits. Default: ``None``.
prompt_cache (List[Any], optional): A pre-computed prompt cache. Note, if
provided, the cache will be updated in place. The cache must be trimmable.
prefill_step_size (int): Step size for processing the prompt.
kv_bits (int, optional): Number of bits to use for KV cache quantization.
None implies no cache quantization. Default: ``None``.
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``.
Yields:
Tuple[mx.array, mx.array, bool]: One token, a vector of log probabilities,
and a bool indicating if the token was generated by the draft model
"""
y = prompt.astype(mx.uint32)
prev_tokens = None
# Create the KV cache for generation
if prompt_cache is None:
model_cache = cache.make_prompt_cache(model)
draft_cache = cache.make_prompt_cache(draft_model)
elif len(prompt_cache) != (len(model.layers) + len(draft_model.layers)):
raise ValueError("Wrong number of layers in the prompt cache.")
else:
model_cache = prompt_cache[: len(model.layers)]
draft_cache = prompt_cache[len(model.layers) :]
sampler = sampler or (lambda x: mx.argmax(x, axis=-1))
quantize_cache_fn = functools.partial(
maybe_quantize_kv_cache,
quantized_kv_start=quantized_kv_start,
kv_group_size=kv_group_size,
kv_bits=kv_bits,
)
def _process_and_sample(tokens, logits):
if logits_processors:
for processor in logits_processors:
logits = processor(tokens, logits)
logprobs = logits - mx.logsumexp(logits, axis=-1, keepdims=True)
y = sampler(logprobs)
return y, logprobs
def _step(model, cache, y, n_predict=1):
with mx.stream(generation_stream):
logits = model(y[None], cache=cache)
logits = logits[:, -n_predict:, :]
quantize_cache_fn(cache)
if logits_processors:
nonlocal prev_tokens
out_y, out_logprobs = [], []
if n_predict > 1:
y = y[: -(n_predict - 1)]
for i in range(n_predict):
prev_tokens = (
mx.concat([prev_tokens, y]) if prev_tokens is not None else y
)
y, logprobs = _process_and_sample(prev_tokens, logits[:, i, :])
out_y.append(y)
out_logprobs.append(logprobs)
return mx.concatenate(out_y, axis=0), mx.concatenate(
out_logprobs, axis=0
)
else:
return _process_and_sample(None, logits.squeeze(0))
def _prefill(model, cache, y):
while y.size > prefill_step_size:
model(y[:prefill_step_size][None], cache=cache)
quantize_cache_fn(cache)
mx.eval([c.state for c in cache])
y = y[prefill_step_size:]
mx.metal.clear_cache()
return y
def _rewind_cache(num_draft, num_accept):
cache.trim_prompt_cache(model_cache, num_draft - num_accept)
cache.trim_prompt_cache(draft_cache, max(num_draft - num_accept - 1, 0))
def _draft_generate(y, num_draft):
if num_draft == 0:
return mx.array([], mx.uint32)
ys = []
for _ in range(num_draft):
y, _ = _step(draft_model, draft_cache, y)
mx.async_eval(y)
ys.append(y)
return mx.concatenate(ys)
with mx.stream(generation_stream):
draft_y = _prefill(draft_model, draft_cache, y)
y = _prefill(model, model_cache, y)
ntoks = 0
# Set these so the finally block doesn't raise
num_draft = 0
n = 0
try:
while True:
num_draft = min(max_tokens - ntoks, num_draft_tokens)
draft_tokens = _draft_generate(draft_y, num_draft)
if prev_tokens is not None:
prev_tokens = prev_tokens[: prev_tokens.size - y.size - num_draft + 1]
y = mx.concatenate([y, draft_tokens])
tokens, logprobs = _step(model, model_cache, y, num_draft + 1)
mx.eval(tokens, draft_tokens)
draft_tokens = draft_tokens.tolist()
tokens = tokens.tolist()
n = 0
while n < num_draft:
tn, dtn, lpn = tokens[n], draft_tokens[n], logprobs[n]
if tn != dtn:
break
n += 1
ntoks += 1
yield tn, lpn, True
if ntoks == max_tokens:
break
if ntoks < max_tokens:
ntoks += 1
yield tokens[n], logprobs[n], False
if ntoks == max_tokens:
break
y = mx.array([tokens[n]], mx.uint32)
draft_y = y
# If we accepted all the draft tokens, include the last
# draft token in the next draft step since it hasn't been
# processed yet by the draft model
if n == num_draft:
draft_y = mx.concatenate(
[mx.array(draft_tokens[-1:], mx.uint32), draft_y]
)
if prev_tokens is not None:
prev_tokens = prev_tokens[: -max(num_draft - n, 1)]
_rewind_cache(num_draft, n)
finally:
_rewind_cache(num_draft, n)
def stream_generate(
model: nn.Module,
tokenizer: Union[PreTrainedTokenizer, TokenizerWrapper],
prompt: Union[str, mx.array, List[int]],
draft_model: Optional[nn.Module] = None,
**kwargs,
) -> Generator[GenerationResponse, None, None]:
"""
A generator producing text based on the given prompt from the model.
Args:
model (nn.Module): The model to use for generation.
tokenizer (PreTrainedTokenizer): The tokenizer.
prompt (Union[str, mx.array, List[int]]): The input prompt string or
integer tokens.
draft_model (Optional[nn.Module]): An optional draft model. If provided
then speculative decoding is used. The draft model must use the same
tokenizer as the main model. Default: ``None``.
kwargs: The remaining options get passed to :func:`generate_step`.
See :func:`generate_step` for more details.
Yields:
GenerationResponse: An instance containing the generated text segment and
associated metadata. See :class:`GenerationResponse` for details.
"""
if not isinstance(tokenizer, TokenizerWrapper):
tokenizer = TokenizerWrapper(tokenizer)
if not isinstance(prompt, mx.array):
if isinstance(prompt, str):
# Try to infer if special tokens are needed
add_special_tokens = tokenizer.bos_token is None or not prompt.startswith(
tokenizer.bos_token
)
prompt = tokenizer.encode(prompt, add_special_tokens=add_special_tokens)
prompt = mx.array(prompt)
detokenizer = tokenizer.detokenizer
if draft_model is None:
kwargs.pop("num_draft_tokens", None)
token_generator = generate_step(prompt, model, **kwargs)
# from_draft always false for non-speculative generation
token_generator = (
(token, logprobs, False) for token, logprobs in token_generator
)
else:
kwargs.pop("max_kv_size", None)
token_generator = speculative_generate_step(
prompt, model, draft_model, **kwargs
)
with wired_limit(model, [generation_stream]):
detokenizer.reset()
tic = time.perf_counter()
for n, (token, logprobs, from_draft) in enumerate(token_generator):
if n == 0:
prompt_time = time.perf_counter() - tic
prompt_tps = prompt.size / prompt_time
tic = time.perf_counter()
if token in tokenizer.eos_token_ids:
break
detokenizer.add_token(token)
from huggingface_hub import ModelCard
yield GenerationResponse(
text=detokenizer.last_segment,
token=token,
logprobs=logprobs,
from_draft=from_draft,
prompt_tokens=prompt.size,
prompt_tps=prompt_tps,
generation_tokens=n + 1,
generation_tps=(n + 1) / (time.perf_counter() - tic),
peak_memory=mx.metal.get_peak_memory() / 1e9,
finish_reason=None,
)
detokenizer.finalize()
yield GenerationResponse(
text=detokenizer.last_segment,
token=token,
logprobs=logprobs,
from_draft=from_draft,
prompt_tokens=prompt.size,
prompt_tps=prompt_tps,
generation_tokens=n + 1,
generation_tps=(n + 1) / (time.perf_counter() - tic),
peak_memory=mx.metal.get_peak_memory() / 1e9,
finish_reason="stop" if token in tokenizer.eos_token_ids else "length",
)
def generate(
model: nn.Module,
tokenizer: Union[PreTrainedTokenizer, TokenizerWrapper],
prompt: Union[str, List[int]],
verbose: bool = False,
formatter: Optional[Callable] = None,
**kwargs,
) -> str:
"""
Generate a complete response from the model.
Args:
model (nn.Module): The language model.
tokenizer (PreTrainedTokenizer): The tokenizer.
prompt (Union[str, List[int]]): The input prompt string or integer tokens.
verbose (bool): If ``True``, print tokens and timing information.
Default: ``False``.
kwargs: The remaining options get passed to :func:`stream_generate`.
See :func:`stream_generate` for more details.
"""
if formatter is not None:
print(
"[Warning] Text formatting is deprecated and no longer used. "
"The argument will be removed in a future version."
)
if verbose:
print("=" * 10)
text = ""
for response in stream_generate(model, tokenizer, prompt, **kwargs):
if verbose:
print(response.text, end="", flush=True)
text += response.text
if verbose:
print()
print("=" * 10)
if len(text) == 0:
print("No text generated for this prompt")
return
print(
f"Prompt: {response.prompt_tokens} tokens, "
f"{response.prompt_tps:.3f} tokens-per-sec"
)
print(
f"Generation: {response.generation_tokens} tokens, "
f"{response.generation_tps:.3f} tokens-per-sec"
)
print(f"Peak memory: {response.peak_memory:.3f} GB")
return text
card = ModelCard.load(model_path / "README.md")
hf_path = card.data.base_model
return model_path, hf_path
def load_config(model_path: Path) -> dict:
@@ -780,7 +241,7 @@ def load(
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, _ = get_model_path(path_or_hf_repo)
model, config = load_model(model_path, lazy)
if adapter_path is not None:
@@ -827,29 +288,49 @@ def make_shards(weights: dict, max_file_size_gb: int = MAX_FILE_SIZE_GB) -> list
return shards
def upload_to_hub(path: str, upload_repo: str, hf_path: str):
def create_model_card(path: Union[str, Path], hf_path: Union[str, Path]):
"""
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.
"""
from huggingface_hub import ModelCard
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)
card.text = ""
card.save(os.path.join(path, "README.md"))
def upload_to_hub(path: str, upload_repo: str):
"""
Uploads the model to Hugging Face hub.
Args:
path (str): Local path to the model.
upload_repo (str): Name of the HF repo to upload to.
hf_path (str): Path to the original Hugging Face model.
"""
import os
from huggingface_hub import HfApi, ModelCard, logging
from . import __version__
card = ModelCard.load(hf_path)
card.data.tags = ["mlx"] if card.data.tags is None else card.data.tags + ["mlx"]
card.data.base_model = hf_path
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}
The Model [{upload_repo}](https://huggingface.co/{upload_repo}) was
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__}**.
@@ -876,9 +357,7 @@ def upload_to_hub(path: str, upload_repo: str, hf_path: str):
```
"""
)
card.save(os.path.join(path, "README.md"))
logging.set_verbosity_info()
card.save(card_path)
api = HfApi()
api.create_repo(repo_id=upload_repo, exist_ok=True)
@@ -890,17 +369,18 @@ def upload_to_hub(path: str, upload_repo: str, hf_path: str):
print(f"Upload successful, go to https://huggingface.co/{upload_repo} for details.")
def save_weights(
def save_model(
save_path: Union[str, Path],
weights: Dict[str, Any],
model: nn.Module,
*,
donate_weights: bool = False,
donate_model: bool = False,
) -> None:
"""Save model weights into specified directory."""
"""Save model weights and metadata index into specified directory."""
if isinstance(save_path, str):
save_path = Path(save_path)
save_path.mkdir(parents=True, exist_ok=True)
weights = dict(tree_flatten(model.parameters()))
shards = make_shards(weights)
shards_count = len(shards)
shard_file_format = (
@@ -910,13 +390,20 @@ def save_weights(
)
total_size = sum(v.nbytes for v in weights.values())
index_data = {"metadata": {"total_size": total_size}, "weight_map": {}}
index_data = {
"metadata": {
"total_size": total_size,
"total_parameters": get_total_parameters(model),
},
"weight_map": {},
}
if donate_model:
model.update(tree_map(lambda _: mx.array([]), model.parameters()))
# Write the weights and make sure no references are kept other than the
# necessary ones
if donate_weights:
weights.clear()
del weights
weights.clear()
del weights
for i in range(len(shards)):
shard = shards[i]
@@ -966,8 +453,10 @@ def quantize_model(
a dict of quantization parameters to pass to `to_quantized`.
Returns:
Tuple: Tuple containing quantized weights and config.
Tuple: Tuple containing quantized model and config.
"""
if "quantization" in config:
raise ValueError("Cannot quantize already quantized model")
quantized_config = copy.deepcopy(config)
quantized_config["quantization"] = {"group_size": q_group_size, "bits": q_bits}
@@ -985,12 +474,11 @@ def quantize_model(
)
# support hf model tree #957
quantized_config["quantization_config"] = quantized_config["quantization"]
quantized_weights = dict(tree_flatten(model.parameters()))
bpw = compute_bits_per_weight(model)
print(f"[INFO] Quantized model with {bpw:.3f} bits per weight.")
return quantized_weights, quantized_config
return model, quantized_config
def save_config(
@@ -1007,6 +495,7 @@ def save_config(
"""
# Clean unused keys
config.pop("_name_or_path", None)
config.pop("vision_config", None)
# sort the config for better readability
config = dict(sorted(config.items()))
@@ -1016,103 +505,65 @@ def save_config(
json.dump(config, fid, indent=4)
def mixed_quant_predicate_builder(
low_bits: int = 4, high_bits: int = 4, group_size: int = 64
) -> Callable[[str, nn.Module, dict], Union[bool, dict]]:
def mixed_quant_predicate(
path: str,
module: nn.Module,
config: dict,
) -> Union[bool, dict]:
"""Implements mixed quantization predicates with similar choices to, for example, llama.cpp's Q4_K_M.
Ref: https://github.com/ggerganov/llama.cpp/blob/917786f43d0f29b7c77a0c56767c0fa4df68b1c5/src/llama.cpp#L5265
By Alex Barron: https://gist.github.com/barronalex/84addb8078be21969f1690c1454855f3
"""
if not hasattr(module, "to_quantized"):
return False
index = int(path.split(".")[2]) if len(path.split(".")) > 2 else 0
num_layers = config["num_hidden_layers"]
use_more_bits = (
index < num_layers // 8
or index >= 7 * num_layers // 8
or (index - num_layers // 8) % 3 == 2
)
if "v_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}
if "lm_head" in path:
return {"group_size": group_size, "bits": high_bits}
return {"group_size": group_size, "bits": low_bits}
return mixed_quant_predicate
mixed_3_6 = mixed_quant_predicate_builder(low_bits=3)
mixed_2_6 = mixed_quant_predicate_builder(low_bits=2)
def convert(
hf_path: str,
mlx_path: str = "mlx_model",
quantize: bool = False,
q_group_size: int = 64,
q_bits: int = 4,
dtype: str = "float16",
upload_repo: str = None,
revision: Optional[str] = None,
dequantize: bool = False,
quant_predicate: Optional[
Callable[[str, nn.Module, dict], Union[bool, dict]]
] = None,
def save(
dst_path: Union[str, Path],
src_path: Union[str, Path],
model: nn.Module,
tokenizer: TokenizerWrapper,
config: Dict[str, Any],
hf_repo: Optional[str] = None,
donate_model: bool = True,
):
# Check the save path is empty
if isinstance(mlx_path, str):
mlx_path = Path(mlx_path)
src_path = Path(src_path)
dst_path = Path(dst_path)
save_model(dst_path, model, donate_model=True)
save_config(config, config_path=dst_path / "config.json")
tokenizer.save_pretrained(dst_path)
if mlx_path.exists():
raise ValueError(
f"Cannot save to the path {mlx_path} as it already exists."
" Please delete the file/directory or specify a new path to save to."
)
for p in ["*.py", "generation_config.json"]:
for file in glob.glob(str(src_path / p)):
shutil.copy(file, dst_path)
print("[INFO] Loading")
model_path = get_model_path(hf_path, revision=revision)
model, config, tokenizer = fetch_from_hub(model_path, lazy=True)
if hf_repo is not None:
create_model_card(dst_path, hf_repo)
weights = dict(tree_flatten(model.parameters()))
dtype = getattr(mx, dtype)
weights = {k: v.astype(dtype) for k, v in weights.items()}
if quantize and dequantize:
raise ValueError("Choose either quantize or dequantize, not both.")
def common_prefix_len(list1, list2):
"""
Calculates the length of the common prefix of two lists.
if quantize:
print("[INFO] Quantizing")
model.load_weights(list(weights.items()))
weights, config = quantize_model(
model, config, q_group_size, q_bits, quant_predicate=quant_predicate
)
Args:
list1: The first list of strings.
list2: The second list of strings.
if dequantize:
print("[INFO] Dequantizing")
model = dequantize_model(model)
weights = dict(tree_flatten(model.parameters()))
Returns:
The length of the common prefix. Returns 0 if lists are empty
or do not match at the first element.
"""
# Determine the maximum possible length of the common prefix
min_len = min(len(list1), len(list2))
del model
save_weights(mlx_path, weights, donate_weights=True)
# Iterate up to the length of the shorter list
for i in range(min_len):
if list1[i] != list2[i]:
# Mismatch found, the common prefix length is the current index
return i
py_files = glob.glob(str(model_path / "*.py"))
for file in py_files:
shutil.copy(file, mlx_path)
# No mismatch found within the bounds of the shorter list,
# so the common prefix length is the length of the shorter list.
return min_len
tokenizer.save_pretrained(mlx_path)
save_config(config, config_path=mlx_path / "config.json")
if upload_repo is not None:
upload_to_hub(mlx_path, upload_repo, hf_path)
def does_model_support_input_embeddings(model: nn.Module) -> bool:
"""
Check if the model supports input_embeddings in its call signature.
Args:
model (nn.Module): The model to check.
Returns:
bool: True if the model supports input_embeddings, False otherwise.
"""
try:
signature = inspect.signature(model.__call__)
return "input_embeddings" in signature.parameters
except (ValueError, TypeError):
return False
+1 -1
View File
@@ -1,4 +1,4 @@
mlx>=0.22.0
mlx>=0.25.0
numpy
transformers[sentencepiece]>=4.39.3
protobuf
+7 -3
View File
@@ -6,7 +6,7 @@ from pathlib import Path
from setuptools import setup
package_dir = Path(__file__).parent / "mlx_lm"
with open(package_dir / "requirements.txt") as fid:
with open("requirements.txt") as fid:
requirements = [l.strip() for l in fid.readlines()]
sys.path.append(str(package_dir))
@@ -24,14 +24,18 @@ setup(
url="https://github.com/ml-explore/mlx-lm",
license="MIT",
install_requires=requirements,
packages=["mlx_lm", "mlx_lm.models", "mlx_lm.tuner"],
packages=["mlx_lm", "mlx_lm.models", "mlx_lm.quant", "mlx_lm.tuner"],
python_requires=">=3.8",
extras_require={
"test": ["datasets"],
"evaluate": ["lm-eval", "tqdm"],
"quant": ["datasets", "tqdm"],
},
entry_points={
"console_scripts": [
"mlx_lm.awq = mlx_lm.quant.awq:main",
"mlx_lm.dwq = mlx_lm.quant.dwq:main",
"mlx_lm.dynamic_quant = mlx_lm.quant.dynamic_quant:main",
"mlx_lm.cache_prompt = mlx_lm.cache_prompt:main",
"mlx_lm.chat = mlx_lm.chat:main",
"mlx_lm.convert = mlx_lm.convert:main",
@@ -39,9 +43,9 @@ setup(
"mlx_lm.fuse = mlx_lm.fuse:main",
"mlx_lm.generate = mlx_lm.generate:main",
"mlx_lm.lora = mlx_lm.lora:main",
"mlx_lm.merge = mlx_lm.merge:main",
"mlx_lm.server = mlx_lm.server:main",
"mlx_lm.manage = mlx_lm.manage:main",
"mlx_lm.upload = mlx_lm.upload:main",
]
},
)
+4 -3
View File
@@ -6,9 +6,10 @@ import tempfile
import types
import unittest
from mlx_lm.tuner import datasets
from transformers import AutoTokenizer
from mlx_lm.tuner import datasets
HF_MODEL_PATH = "mlx-community/Qwen1.5-0.5B-Chat-4bit"
@@ -43,7 +44,7 @@ class TestDatasets(unittest.TestCase):
self.assertEqual(len(test), 0)
self.assertTrue(len(train[0]) > 0)
self.assertTrue(len(valid[0]) > 0)
self.assertTrue(isinstance(train, datasets.Dataset))
self.assertTrue(isinstance(train, datasets.TextDataset))
def test_completions(self):
data = {"prompt": "What is the capital of France?", "completion": "Paris."}
@@ -79,7 +80,7 @@ class TestDatasets(unittest.TestCase):
def test_hf(self):
hf_args = {
"name": "billsum",
"path": "billsum",
"prompt_feature": "text",
"completion_feature": "summary",
"train_split": "train[:2%]",
+3 -8
View File
@@ -11,6 +11,7 @@ import mlx.core as mx
import mlx.nn as nn
import mlx.optimizers as opt
from mlx.utils import tree_flatten
from mlx_lm import lora, tuner
from mlx_lm.tuner.dora import DoRAEmbedding, DoRALinear
from mlx_lm.tuner.lora import LoRAEmbedding, LoRALinear
@@ -66,7 +67,7 @@ class TestLora(unittest.TestCase):
)
self.assertEqual(trainable_params, expected_trainable_parameters)
params = {"rank": 8, "alpha": 16, "dropout": 0.0, "scale": 10.0}
params = {"rank": 8, "dropout": 0.0, "scale": 10.0}
check_config(params)
params["rank"] = 1
@@ -107,7 +108,7 @@ class TestLora(unittest.TestCase):
)
num_lora_layers = 4
params = {"rank": 8, "alpha": 16, "dropout": 0.0, "scale": 10.0}
params = {"rank": 8, "dropout": 0.0, "scale": 10.0}
model = gpt_neox.Model(args)
model.freeze()
@@ -364,7 +365,6 @@ class TestScheduleConfig(unittest.TestCase):
def test_evaluate_calls(self):
mock_model = MagicMock()
mock_dataset = MagicMock()
mock_tokenizer = MagicMock()
mock_default_loss = MagicMock()
mock_iterate_batches = MagicMock()
@@ -387,7 +387,6 @@ class TestScheduleConfig(unittest.TestCase):
evaluate(
model=mock_model,
dataset=mock_dataset,
tokenizer=mock_tokenizer,
batch_size=2,
num_batches=2,
max_seq_length=2048,
@@ -397,7 +396,6 @@ class TestScheduleConfig(unittest.TestCase):
mock_iterate_batches.assert_called_once_with(
dataset=mock_dataset,
tokenizer=mock_tokenizer,
batch_size=2,
max_seq_length=2048,
)
@@ -406,7 +404,6 @@ class TestScheduleConfig(unittest.TestCase):
def test_evaluate_infinite_batches(self):
mock_model = MagicMock()
mock_dataset = MagicMock()
mock_tokenizer = MagicMock()
mock_default_loss = MagicMock()
mock_iterate_batches = MagicMock()
@@ -426,7 +423,6 @@ class TestScheduleConfig(unittest.TestCase):
evaluate(
model=mock_model,
dataset=mock_dataset,
tokenizer=mock_tokenizer,
batch_size=2,
num_batches=-1,
max_seq_length=2048,
@@ -436,7 +432,6 @@ class TestScheduleConfig(unittest.TestCase):
mock_iterate_batches.assert_called_once_with(
dataset=mock_dataset,
tokenizer=mock_tokenizer,
batch_size=2,
max_seq_length=2048,
)
+71 -6
View File
@@ -3,14 +3,13 @@
import unittest
from typing import List
from mlx_lm.sample_utils import make_logits_processors
from mlx_lm.utils import (
from mlx_lm.generate import (
GenerationResponse,
generate,
load,
make_sampler,
stream_generate,
)
from mlx_lm.sample_utils import make_logits_processors, make_sampler
from mlx_lm.utils import load
class TestGenerate(unittest.TestCase):
@@ -67,11 +66,15 @@ class TestGenerate(unittest.TestCase):
# make a determinate sampler
sampler = make_sampler(temp=0.0)
messages = [{"role": "user", "content": "hello"}]
prompt = self.tokenizer.apply_chat_template(
messages, add_generation_prompt=True
)
for generation_result in stream_generate(
model=self.model,
tokenizer=self.tokenizer,
prompt="hello",
prompt=prompt,
max_tokens=5,
draft_model=draft_model,
num_draft_tokens=2,
@@ -80,12 +83,74 @@ class TestGenerate(unittest.TestCase):
drafted.append(generation_result.from_draft)
results.append(generation_result)
self.assertEqual(len(results), 5)
self.assertEqual(len(results), 6)
drafted.pop()
# since num_draft_tokens is 2 and draft model is the same, the
# first 2 generations should be drafts, the third should come
# from the target model, and last two should be drafts
self.assertEqual(drafted, [True, True, False, True, True])
def test_stream_generate_input_embeddings(self):
sampler = make_sampler(temp=0.0) # determinate sampler
# get prompt embeddings
messages = [{"role": "user", "content": "Say 'TEST' and nothing else"}]
prompt = self.tokenizer.apply_chat_template(
messages, add_generation_prompt=True
)
prompt_embeddings = self.model.model.embed_tokens(prompt)
response = ""
for generation_result in stream_generate(
model=self.model,
tokenizer=self.tokenizer,
prompt=[], # no prompt tokens passed
max_tokens=5,
sampler=sampler,
input_embeddings=prompt_embeddings,
):
response += generation_result.text
self.assertEqual("TEST", response)
def test_stream_generate_input_embeddings_prefill(self):
sampler = make_sampler(temp=0.0) # determinate sampler
# get prompt embeddings
messages = [{"role": "user", "content": "Say 'TEST' and nothing else"}]
prompt = self.tokenizer.apply_chat_template(
messages, add_generation_prompt=True
)
prompt_embeddings = self.model.model.embed_tokens(prompt)
# setup prompt progress callback to track batched prefill
num_prompt_processing_callbacks = 0
def progress_callback(processed: int, total: int) -> None:
nonlocal num_prompt_processing_callbacks
num_prompt_processing_callbacks += 1
# generate
prefill_step_size = 5
response = ""
for generation_result in stream_generate(
model=self.model,
tokenizer=self.tokenizer,
prompt=[], # no prompt tokens passed
max_tokens=5,
sampler=sampler,
input_embeddings=prompt_embeddings,
prefill_step_size=prefill_step_size,
prompt_progress_callback=progress_callback,
):
response += generation_result.text
self.assertEqual("TEST", response)
num_embeddings = prompt_embeddings.shape[0]
self.assertEqual(
num_embeddings / prefill_step_size, num_prompt_processing_callbacks
)
if __name__ == "__main__":
unittest.main()
+1
View File
@@ -5,6 +5,7 @@ from pathlib import Path
from unittest.mock import MagicMock, patch
import mlx.core as mx
from mlx_lm.gguf import convert_to_gguf
+111 -10
View File
@@ -1,11 +1,13 @@
# Copyright © 2024 Apple Inc.
import copy
import unittest
import mlx.core as mx
import mlx.nn as nn
from mlx.utils import tree_map
from mlx_lm.models import rope_utils
from mlx_lm.models.base import create_causal_mask
from mlx_lm.models.base import create_causal_mask, scaled_dot_product_attention
from mlx_lm.models.cache import KVCache, RotatingKVCache, make_prompt_cache
@@ -165,6 +167,42 @@ class TestModels(unittest.TestCase):
)
self.assertTrue(isinstance(rope, rope_utils.Llama3RoPE))
def test_quantized_sdpa(self):
cache = KVCache()
k = 1e-1 * mx.random.normal(shape=(1, 1, 256, 32))
v = 1e-1 * mx.random.normal(shape=(1, 1, 256, 32))
cache.update_and_fetch(k, v)
quant_cache = cache.to_quantized(group_size=32, bits=8)
k = 1e-1 * mx.random.normal(shape=(1, 1, 1, 32))
v = 1e-1 * mx.random.normal(shape=(1, 1, 1, 32))
k_up, v_up = cache.update_and_fetch(k, v)
qk_up, qv_up = quant_cache.update_and_fetch(k, v)
q = 1e-1 * mx.random.normal(shape=(1, 4, 257, 32))
mask = "causal"
out = scaled_dot_product_attention(
q,
k_up,
v_up,
cache=cache,
mask=mask,
scale=1.0,
)
qout = scaled_dot_product_attention(
q,
qk_up,
qv_up,
cache=quant_cache,
mask=mask,
scale=1.0,
)
self.assertTrue(mx.allclose(out, qout, rtol=1e-2, atol=1e-2))
def model_test_runner(self, model, model_type, vocab_size, num_layers):
self.assertEqual(len(model.layers), num_layers)
@@ -193,6 +231,9 @@ class TestModels(unittest.TestCase):
self.assertEqual(outputs.shape, (1, 1, vocab_size))
self.assertEqual(outputs.dtype, t)
# Make sure the model can be copied / pickled
copy.deepcopy(model)
def test_llama(self):
from mlx_lm.models import llama
@@ -210,6 +251,24 @@ class TestModels(unittest.TestCase):
model, args.model_type, args.vocab_size, args.num_hidden_layers
)
def test_bitnet(self):
from mlx_lm.models import bitnet
args = bitnet.ModelArgs(
model_type="bitnet",
hidden_size=1024,
num_hidden_layers=4,
intermediate_size=2048,
num_attention_heads=4,
num_key_value_heads=4,
rms_norm_eps=1e-5,
vocab_size=10_000,
)
model = bitnet.Model(args)
self.model_test_runner(
model, args.model_type, args.vocab_size, args.num_hidden_layers
)
def test_phi2(self):
from mlx_lm.models import phi
@@ -219,15 +278,6 @@ class TestModels(unittest.TestCase):
model, args.model_type, args.vocab_size, args.num_hidden_layers
)
def test_phixtral(self):
from mlx_lm.models import phixtral
args = phixtral.ModelArgs(
"phixtral", num_vocab=1000, num_layers=4, model_dim=1024
)
model = phixtral.Model(args)
self.model_test_runner(model, args.model_type, args.num_vocab, args.num_layers)
def test_phi3(self):
from mlx_lm.models import phi3
@@ -306,6 +356,56 @@ class TestModels(unittest.TestCase):
args.n_layers,
)
def test_qwen3_moe(self):
from mlx_lm.models import qwen3_moe
args = qwen3_moe.ModelArgs(
model_type="qwen3_moe",
hidden_size=1024,
num_hidden_layers=4,
intermediate_size=2048,
num_attention_heads=4,
num_key_value_heads=4,
rms_norm_eps=1e-5,
head_dim=128,
vocab_size=10_000,
decoder_sparse_step=1,
mlp_only_layers=[],
num_experts_per_tok=4,
num_experts=16,
moe_intermediate_size=1024,
rope_theta=1000,
max_position_embeddings=4096,
tie_word_embeddings=False,
norm_topk_prob=True,
)
model = qwen3_moe.Model(args)
self.model_test_runner(
model, args.model_type, args.vocab_size, args.num_hidden_layers
)
def test_qwen3(self):
from mlx_lm.models import qwen3
args = qwen3.ModelArgs(
model_type="qwen3",
hidden_size=1024,
num_hidden_layers=4,
intermediate_size=2048,
num_attention_heads=4,
num_key_value_heads=4,
rms_norm_eps=1e-5,
vocab_size=10_000,
head_dim=128,
max_position_embeddings=4096,
tie_word_embeddings=False,
rope_theta=1000,
)
model = qwen3.Model(args)
self.model_test_runner(
model, args.model_type, args.vocab_size, args.num_hidden_layers
)
def test_qwen2_moe(self):
from mlx_lm.models import qwen2_moe
@@ -336,6 +436,7 @@ class TestModels(unittest.TestCase):
num_hidden_layers=4,
intermediate_size=2048,
num_attention_heads=4,
num_key_value_heads=4,
rms_norm_eps=1e-5,
vocab_size=10_000,
)
+12 -3
View File
@@ -6,7 +6,10 @@ import tempfile
import unittest
import mlx.core as mx
from mlx_lm.generate import generate_step
from mlx_lm.models.cache import (
ChunkedKVCache,
KVCache,
MambaCache,
QuantizedKVCache,
@@ -16,7 +19,7 @@ from mlx_lm.models.cache import (
save_prompt_cache,
trim_prompt_cache,
)
from mlx_lm.utils import generate_step, load
from mlx_lm.utils import load
HF_MODEL_PATH = "mlx-community/Qwen1.5-0.5B-Chat-4bit"
@@ -93,7 +96,13 @@ class TestPromptCache(unittest.TestCase):
def test_save_load_mixed_cache(self):
cache_file = os.path.join(self.test_dir, "prompt_cache.safetensors")
cache = [MambaCache(), KVCache(), RotatingKVCache(8), MambaCache()]
cache = [
MambaCache(),
KVCache(),
RotatingKVCache(8),
MambaCache(),
ChunkedKVCache(256),
]
for c in cache:
if isinstance(c, MambaCache):
c[0] = mx.random.uniform(shape=(4, 4, 4))
@@ -298,7 +307,7 @@ class TestPromptCache(unittest.TestCase):
):
i += 1
self.assertEqual(tok, toks[i])
self.assertTrue(mx.allclose(logits, all_logits[i], rtol=3e-2))
self.assertTrue(mx.allclose(logits, all_logits[i], rtol=4e-2))
if __name__ == "__main__":
+24 -1
View File
@@ -1,7 +1,8 @@
import unittest
import mlx.core as mx
from mlx_lm.sample_utils import apply_min_p, apply_top_k, apply_top_p
from mlx_lm.sample_utils import apply_min_p, apply_top_k, apply_top_p, apply_xtc
class TestSampleUtils(unittest.TestCase):
@@ -93,6 +94,28 @@ class TestSampleUtils(unittest.TestCase):
actual_probs.tolist(), [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0]]
)
def test_apply_xtc(self):
# Test the threshold
probs = mx.array([[0.4, 0.3, 0.15, 0.15]])
new_probs = mx.softmax(apply_xtc(mx.log(probs), 1, 0.2, []), -1)
expected = mx.array([[0, 0.5, 0.25, 0.25]])
self.assertTrue(mx.allclose(new_probs, expected))
probs = mx.array([[0.4, 0.3, 0.15, 0.15]])
new_probs = mx.softmax(apply_xtc(mx.log(probs), 1, 0.1, []), -1)
expected = mx.array([[0, 0.0, 0.5, 0.5]])
self.assertTrue(mx.allclose(new_probs, expected))
# Test the special tokens
probs = mx.array([[0.4, 0.3, 0.15, 0.15]])
new_probs = mx.softmax(apply_xtc(mx.log(probs), 1, 0.1, [0]), -1)
expected = mx.array([[4 / 7, 0.0, 1.5 / 7, 1.5 / 7]])
self.assertTrue(mx.allclose(new_probs, expected))
# Test that with probability 0 the probs don't change
probs = mx.array([[0.4, 0.3, 0.15, 0.15]])
new_probs = mx.softmax(apply_xtc(mx.log(probs), 0, 0.1, [0]), -1)
self.assertTrue(mx.allclose(new_probs, probs))
if __name__ == "__main__":
unittest.main()
+381 -2
View File
@@ -6,17 +6,44 @@ import threading
import unittest
import requests
from mlx_lm.server import APIHandler
from mlx_lm.utils import load
class DummyModelProvider:
def __init__(self):
def __init__(self, with_draft=False):
HF_MODEL_PATH = "mlx-community/Qwen1.5-0.5B-Chat-4bit"
self.model, self.tokenizer = load(HF_MODEL_PATH)
self.model_key = (HF_MODEL_PATH, None)
def load(self, model, adapter=None):
# Add draft model support
self.draft_model = None
self.draft_model_key = None
self.cli_args = type(
"obj",
(object,),
{
"adapter_path": None,
"chat_template": None,
"use_default_chat_template": False,
"trust_remote_code": False,
"num_draft_tokens": 3,
"temp": 0.0,
"top_p": 1.0,
"top_k": 0,
"min_p": 0.0,
"max_tokens": 512,
"chat_template_args": {},
},
)
if with_draft:
# Use the same model as the draft model for testing
self.draft_model, _ = load(HF_MODEL_PATH)
self.draft_model_key = HF_MODEL_PATH
def load(self, model, adapter=None, draft_model=None):
assert model in ["default_model", "chat_model"]
return self.model, self.tokenizer
@@ -103,6 +130,38 @@ class TestServer(unittest.TestCase):
self.assertIn("id", response_body)
self.assertIn("choices", response_body)
def test_handle_chat_completions_with_null_tool_content(self):
url = f"http://localhost:{self.port}/v1/chat/completions"
chat_post_data = {
"model": "chat_model",
"max_tokens": 10,
"temperature": 0.7,
"top_p": 0.85,
"repetition_penalty": 1.2,
"messages": [
{"role": "user", "content": "what is 2+3?"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"type": "function",
"id": "123",
"function": {
"name": "add",
"arguments": '{"a": 2, "b": 3}',
},
}
],
},
{"role": "tool", "content": "5", "tool_call_id": "123"},
],
}
response = requests.post(url, json=chat_post_data)
response_body = response.text
self.assertIn("id", response_body)
self.assertIn("choices", response_body)
def test_handle_models(self):
url = f"http://localhost:{self.port}/v1/models"
response = requests.get(url)
@@ -129,5 +188,325 @@ class TestServer(unittest.TestCase):
self.assertFalse(sequence_overlap([1, 2, 3], [4, 1, 2, 3]))
class TestServerWithDraftModel(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.model_provider = DummyModelProvider(with_draft=True)
cls.server_address = ("localhost", 0)
cls.httpd = http.server.HTTPServer(
cls.server_address,
lambda *args, **kwargs: APIHandler(cls.model_provider, *args, **kwargs),
)
cls.port = cls.httpd.server_port
cls.server_thread = threading.Thread(target=cls.httpd.serve_forever)
cls.server_thread.daemon = True
cls.server_thread.start()
@classmethod
def tearDownClass(cls):
cls.httpd.shutdown()
cls.httpd.server_close()
cls.server_thread.join()
def test_handle_completions_with_draft_model(self):
url = f"http://localhost:{self.port}/v1/completions"
post_data = {
"model": "default_model",
"prompt": "Once upon a time",
"max_tokens": 10,
"temperature": 0.0,
"top_p": 1.0,
}
response = requests.post(url, json=post_data)
self.assertEqual(response.status_code, 200)
response_body = json.loads(response.text)
self.assertIn("id", response_body)
self.assertIn("choices", response_body)
self.assertIn("usage", response_body)
# Check that tokens were generated
self.assertTrue(response_body["usage"]["completion_tokens"] > 0)
def test_handle_chat_completions_with_draft_model(self):
url = f"http://localhost:{self.port}/v1/chat/completions"
chat_post_data = {
"model": "chat_model",
"max_tokens": 10,
"temperature": 0.0,
"messages": [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Hello!"},
],
}
response = requests.post(url, json=chat_post_data)
self.assertEqual(response.status_code, 200)
response_body = json.loads(response.text)
self.assertIn("id", response_body)
self.assertIn("choices", response_body)
self.assertIn("usage", response_body)
# Check that tokens were generated
self.assertTrue(response_body["usage"]["completion_tokens"] > 0)
def test_streaming_with_draft_model(self):
url = f"http://localhost:{self.port}/v1/chat/completions"
chat_post_data = {
"model": "chat_model",
"max_tokens": 10,
"temperature": 0.0,
"stream": True,
"messages": [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Hello!"},
],
}
response = requests.post(url, json=chat_post_data, stream=True)
self.assertEqual(response.status_code, 200)
chunk_count = 0
for chunk in response.iter_lines():
if chunk:
data = chunk.decode("utf-8")
if data.startswith("data: ") and data != "data: [DONE]":
chunk_data = json.loads(data[6:]) # Skip the "data: " prefix
self.assertIn("choices", chunk_data)
self.assertEqual(len(chunk_data["choices"]), 1)
self.assertIn("delta", chunk_data["choices"][0])
chunk_count += 1
# Make sure we got some streaming chunks
self.assertGreater(chunk_count, 0)
def test_prompt_cache_with_draft_model(self):
url = f"http://localhost:{self.port}/v1/chat/completions"
# First request to initialize cache
chat_post_data = {
"model": "chat_model",
"max_tokens": 5,
"temperature": 0.0,
"messages": [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Tell me a story about"},
],
}
first_response = requests.post(url, json=chat_post_data)
self.assertEqual(first_response.status_code, 200)
# Second request with same prefix should use cache
chat_post_data = {
"model": "chat_model",
"max_tokens": 5,
"temperature": 0.0,
"messages": [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Tell me a story about dragons."},
],
}
second_response = requests.post(url, json=chat_post_data)
self.assertEqual(second_response.status_code, 200)
# Both responses should have content
first_response_body = json.loads(first_response.text)
second_response_body = json.loads(second_response.text)
self.assertIn("choices", first_response_body)
self.assertIn("choices", second_response_body)
self.assertIn("message", first_response_body["choices"][0])
self.assertIn("message", second_response_body["choices"][0])
self.assertIn("content", first_response_body["choices"][0]["message"])
self.assertIn("content", second_response_body["choices"][0]["message"])
# Ensure both generated content
self.assertIsNotNone(first_response_body["choices"][0]["message"]["content"])
self.assertIsNotNone(second_response_body["choices"][0]["message"]["content"])
# --- Tests for get_prompt_cache ---
from unittest.mock import MagicMock, patch
from mlx_lm.server import PromptCache
class TestGetPromptCache(unittest.TestCase):
def setUp(self):
"""Set up mocks and a handler instance for each test."""
self.mock_model_provider = MagicMock()
# Simulate tokenizer needed for decoding in original debug logs (though not strictly needed for cache logic)
self.mock_model_provider.tokenizer = MagicMock()
self.mock_model_provider.tokenizer.decode = lambda x: f"decoded({x})"
self.mock_model_provider.model_key = ("model_v1", None, None)
self.mock_model_provider.draft_model = None # Start without draft model
# --- Prevent BaseHTTPRequestHandler.__init__ from running ---
# It tries to handle a request immediately, which fails with mocks.
# We only need the APIHandler instance with its attributes set.
with patch(
"http.server.BaseHTTPRequestHandler.__init__", lambda *args, **kwargs: None
):
# APIHandler init still requires args for BaseHTTPRequestHandler signature,
# but they won't be used by the patched __init__.
mock_request = MagicMock()
mock_client_address = ("127.0.0.1", 8080)
mock_server = MagicMock()
self.prompt_cache_instance = PromptCache()
self.handler = APIHandler(
self.mock_model_provider,
mock_request,
mock_client_address,
mock_server,
prompt_cache=self.prompt_cache_instance, # Inject our cache instance
)
# Manually set attributes usually set by APIHandler.__init__ if needed
# self.handler.created = MagicMock()
# self.handler.system_fingerprint = MagicMock()
# (Not strictly necessary for get_prompt_cache testing)
@patch("mlx_lm.server.make_prompt_cache")
def test_initial_request_empty_cache(self, mock_make_cache):
"""Test first request when the cache is empty."""
mock_make_cache.return_value = "new_cache_obj"
prompt = [1, 2, 3]
processed_prompt = self.handler.get_prompt_cache(prompt)
self.assertEqual(processed_prompt, [1, 2, 3])
self.assertEqual(self.handler.prompt_cache.tokens, [1, 2, 3])
self.assertEqual(self.handler.prompt_cache.cache, "new_cache_obj")
self.assertEqual(self.handler.prompt_cache.model_key, ("model_v1", None, None))
mock_make_cache.assert_called_once()
@patch("mlx_lm.server.trim_prompt_cache")
@patch("mlx_lm.server.can_trim_prompt_cache", return_value=True)
def test_identical_request_full_hit(self, mock_can_trim, mock_trim_cache):
"""Test when the new prompt is identical to the cached one."""
self.handler.prompt_cache.tokens = [1, 2, 3]
self.handler.prompt_cache.model_key = ("model_v1", None, None)
self.handler.prompt_cache.cache = "existing_cache_obj"
prompt = [1, 2, 3]
# Mock common_prefix_len to return the full length
with patch("mlx_lm.server.common_prefix_len", return_value=3):
processed_prompt = self.handler.get_prompt_cache(prompt)
mock_trim_cache.assert_called_once_with("existing_cache_obj", 1)
self.assertEqual(processed_prompt, [3])
self.assertEqual(self.handler.prompt_cache.tokens, [1, 2, 3])
def test_cache_is_prefix(self):
"""Test when the cached prompt is a prefix of the new prompt."""
self.handler.prompt_cache.tokens = [1, 2, 3]
self.handler.prompt_cache.model_key = ("model_v1", None, None)
self.handler.prompt_cache.cache = "existing_cache_obj"
prompt = [1, 2, 3, 4, 5]
with patch("mlx_lm.server.common_prefix_len", return_value=3):
processed_prompt = self.handler.get_prompt_cache(prompt)
# Should process the suffix, cache tokens updated
self.assertEqual(processed_prompt, [4, 5])
self.assertEqual(self.handler.prompt_cache.tokens, [1, 2, 3, 4, 5])
self.assertEqual(self.handler.prompt_cache.cache, "existing_cache_obj")
@patch("mlx_lm.server.trim_prompt_cache")
@patch("mlx_lm.server.can_trim_prompt_cache", return_value=True)
def test_partial_match_trim_success(self, mock_can_trim, mock_trim_cache):
"""Test partial match where cache is longer and trimming succeeds."""
self.handler.prompt_cache.tokens = [1, 2, 3, 4, 5]
self.handler.prompt_cache.model_key = ("model_v1", None, None)
self.handler.prompt_cache.cache = "existing_cache_obj"
prompt = [1, 2, 3, 6, 7] # Diverges after token 3
with patch("mlx_lm.server.common_prefix_len", return_value=3):
processed_prompt = self.handler.get_prompt_cache(prompt)
# Should process the new suffix, cache trimmed and updated
self.assertEqual(processed_prompt, [6, 7])
self.assertEqual(self.handler.prompt_cache.tokens, [1, 2, 3, 6, 7])
mock_can_trim.assert_called_once_with("existing_cache_obj")
# Called with cache object and num_to_trim (5 - 3 = 2)
mock_trim_cache.assert_called_once_with("existing_cache_obj", 2)
self.assertEqual(
self.handler.prompt_cache.cache, "existing_cache_obj"
) # Cache obj itself isn't changed by mock
@patch("mlx_lm.server.make_prompt_cache")
@patch("mlx_lm.server.trim_prompt_cache")
@patch("mlx_lm.server.can_trim_prompt_cache", return_value=False)
def test_partial_match_trim_fail(
self, mock_can_trim, mock_trim_cache, mock_make_cache
):
"""Test partial match where cache is longer but trimming fails."""
mock_make_cache.return_value = "new_cache_obj_on_reset"
self.handler.prompt_cache.tokens = [1, 2, 3, 4, 5]
self.handler.prompt_cache.model_key = ("model_v1", None, None)
self.handler.prompt_cache.cache = "existing_cache_obj"
prompt = [1, 2, 3, 6, 7] # Diverges after token 3
with patch("mlx_lm.server.common_prefix_len", return_value=3):
processed_prompt = self.handler.get_prompt_cache(prompt)
# Should process the full prompt, cache reset
self.assertEqual(processed_prompt, [1, 2, 3, 6, 7])
self.assertEqual(self.handler.prompt_cache.tokens, [1, 2, 3, 6, 7])
mock_can_trim.assert_called_once_with("existing_cache_obj")
mock_trim_cache.assert_not_called()
mock_make_cache.assert_called_once() # Cache was reset
self.assertEqual(self.handler.prompt_cache.cache, "new_cache_obj_on_reset")
@patch("mlx_lm.server.make_prompt_cache")
def test_no_common_prefix(self, mock_make_cache):
"""Test when there is no common prefix between cache and prompt."""
mock_make_cache.return_value = "new_cache_obj"
self.handler.prompt_cache.tokens = [1, 2, 3]
self.handler.prompt_cache.model_key = ("model_v1", None, None)
self.handler.prompt_cache.cache = "existing_cache_obj"
prompt = [4, 5, 6]
with patch("mlx_lm.server.common_prefix_len", return_value=0):
processed_prompt = self.handler.get_prompt_cache(prompt)
# Should process the full prompt, cache reset
self.assertEqual(processed_prompt, [4, 5, 6])
self.assertEqual(self.handler.prompt_cache.tokens, [4, 5, 6])
mock_make_cache.assert_called_once()
self.assertEqual(self.handler.prompt_cache.cache, "new_cache_obj")
@patch("mlx_lm.server.make_prompt_cache")
def test_model_changed(self, mock_make_cache):
"""Test cache reset when the model key changes."""
mock_make_cache.return_value = "new_cache_obj_model_change"
self.handler.prompt_cache.tokens = [1, 2, 3]
self.handler.prompt_cache.model_key = ("model_v1", None, None) # Original key
self.handler.prompt_cache.cache = "existing_cache_obj"
# Simulate model provider having a new key
self.mock_model_provider.model_key = ("model_v2", None, None)
prompt = [1, 2, 3, 4]
# No need to mock common_prefix_len, model check happens first
processed_prompt = self.handler.get_prompt_cache(prompt)
# Should process the full prompt, cache reset
self.assertEqual(processed_prompt, [1, 2, 3, 4])
self.assertEqual(self.handler.prompt_cache.tokens, [1, 2, 3, 4])
mock_make_cache.assert_called_once()
self.assertEqual(self.handler.prompt_cache.cache, "new_cache_obj_model_change")
self.assertEqual(self.handler.prompt_cache.model_key, ("model_v2", None, None))
if __name__ == "__main__":
unittest.main()

Some files were not shown because too many files have changed in this diff Show More