torch-ane 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- torch_ane-0.1.0/LICENSE +21 -0
- torch_ane-0.1.0/PKG-INFO +167 -0
- torch_ane-0.1.0/README.md +134 -0
- torch_ane-0.1.0/pyproject.toml +38 -0
- torch_ane-0.1.0/setup.cfg +4 -0
- torch_ane-0.1.0/tests/test_torch_ane.py +207 -0
- torch_ane-0.1.0/torch_ane/__init__.py +34 -0
- torch_ane-0.1.0/torch_ane/diffusion.py +166 -0
- torch_ane-0.1.0/torch_ane/llm/__init__.py +13 -0
- torch_ane-0.1.0/torch_ane/llm/_kv.py +98 -0
- torch_ane-0.1.0/torch_ane/llm/causal.py +317 -0
- torch_ane-0.1.0/torch_ane/llm/chunked.py +475 -0
- torch_ane-0.1.0/torch_ane/llm/gguf.py +15 -0
- torch_ane-0.1.0/torch_ane/passes/__init__.py +25 -0
- torch_ane-0.1.0/torch_ane/passes/composite.py +104 -0
- torch_ane-0.1.0/torch_ane/passes/export.py +298 -0
- torch_ane-0.1.0/torch_ane/passes/layout.py +309 -0
- torch_ane-0.1.0/torch_ane/passes/quant.py +202 -0
- torch_ane-0.1.0/torch_ane/runtime.py +407 -0
- torch_ane-0.1.0/torch_ane.egg-info/PKG-INFO +167 -0
- torch_ane-0.1.0/torch_ane.egg-info/SOURCES.txt +22 -0
- torch_ane-0.1.0/torch_ane.egg-info/dependency_links.txt +1 -0
- torch_ane-0.1.0/torch_ane.egg-info/requires.txt +16 -0
- torch_ane-0.1.0/torch_ane.egg-info/top_level.txt +1 -0
torch_ane-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ivan Shivalov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
torch_ane-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: torch-ane
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Run PyTorch models on the Apple Neural Engine via Core AI: compile any nn.Module, LLMs with KV cache, Stable Diffusion
|
|
5
|
+
Author: Ivan Shivalov
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/intexcor/torch-ane
|
|
8
|
+
Project-URL: Issues, https://github.com/intexcor/torch-ane/issues
|
|
9
|
+
Keywords: pytorch,apple-silicon,neural-engine,ane,core-ai,coreml,llm,stable-diffusion
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Operating System :: MacOS
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
16
|
+
Requires-Python: >=3.11
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: torch>=2.10
|
|
20
|
+
Requires-Dist: numpy
|
|
21
|
+
Requires-Dist: coreai-torch>=0.4.3
|
|
22
|
+
Requires-Dist: coreai-opt>=0.3
|
|
23
|
+
Provides-Extra: llm
|
|
24
|
+
Requires-Dist: transformers>=5; extra == "llm"
|
|
25
|
+
Requires-Dist: gguf; extra == "llm"
|
|
26
|
+
Provides-Extra: diffusion
|
|
27
|
+
Requires-Dist: diffusers>=0.40; extra == "diffusion"
|
|
28
|
+
Requires-Dist: transformers>=5; extra == "diffusion"
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest; extra == "dev"
|
|
31
|
+
Requires-Dist: pyflakes; extra == "dev"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# torch-ane
|
|
35
|
+
|
|
36
|
+
Run PyTorch models on the **Apple Neural Engine** through Apple's Core AI (macOS 27+).
|
|
37
|
+
|
|
38
|
+
Pass in a plain `nn.Module`, or a model from transformers or diffusers. torch-ane rewrites it
|
|
39
|
+
into a form the ANE runs fast and correctly: fp16, static shapes, and the layout and ops from
|
|
40
|
+
Apple's ANE guidance. It also works around the ANE's numeric quirks. You get back a module
|
|
41
|
+
that behaves like the original.
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
import torch_ane
|
|
45
|
+
|
|
46
|
+
m = torch_ane.compile(model.eval(), example_inputs=(x,)) # any nn.Module -> ANE
|
|
47
|
+
y = m(x) # torch tensors in, torch tensors out
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
On an M4 MacBook, ANE prefill and convolutions are 2–3× faster than the GPU (MLX/MPS).
|
|
51
|
+
Decoding is limited by memory bandwidth, and the ANE reads ~57 GB/s.
|
|
52
|
+
|
|
53
|
+
## Install
|
|
54
|
+
|
|
55
|
+
Requires macOS 27+ on Apple silicon, and Python 3.11+.
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
pip install torch-ane # core: compile()
|
|
59
|
+
pip install "torch-ane[llm]" # + CausalLM / ChunkedCausalLM (transformers, gguf)
|
|
60
|
+
pip install "torch-ane[diffusion]" # + StableDiffusion (diffusers)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Core AI's Python packages (`coreai-core`, `coreai-torch`, `coreai-opt`) come from PyPI
|
|
64
|
+
as dependencies.
|
|
65
|
+
|
|
66
|
+
## What's inside
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
import torch_ane
|
|
70
|
+
|
|
71
|
+
torch_ane.available() # is Core AI usable here
|
|
72
|
+
|
|
73
|
+
# Any module. The first call per input shape builds a program (seconds); then it's cached on disk.
|
|
74
|
+
m = torch_ane.compile(model, example_inputs=(x,), device="ane") # or device="gpu"
|
|
75
|
+
|
|
76
|
+
# A whole LLM on the ANE (up to ~1-3B). The KV cache is Core AI state, so it is never copied.
|
|
77
|
+
lm = torch_ane.CausalLM.from_pretrained("Qwen/Qwen2.5-0.5B-Instruct", batch=True)
|
|
78
|
+
ids = lm.generate(prompt_ids, max_new_tokens=64, eos=tok.eos_token_id)
|
|
79
|
+
outs = lm.generate_batch([ids1, ids2, ids3], max_new_tokens=64) # up to 8 streams per step
|
|
80
|
+
logits = lm.feed(more_ids) # append to the cache -> last logits
|
|
81
|
+
|
|
82
|
+
# A large model from GGUF, in 4-layer chunks with 4-bit palettes (Devstral 24B etc.)
|
|
83
|
+
big = torch_ane.ChunkedCausalLM.from_gguf("model.gguf", "config_dir/")
|
|
84
|
+
|
|
85
|
+
# Stable Diffusion: text encoder, UNet and VAE on the ANE
|
|
86
|
+
sd = torch_ane.StableDiffusion("segmind/tiny-sd")
|
|
87
|
+
image, timings = sd("a cute robot", steps=20, seed=0)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
| | |
|
|
91
|
+
|---|---|
|
|
92
|
+
| `compile(model, example_inputs=None, device="ane", validate=True, compress=None)` | → `CompiledModule`. `validate` checks the first call of each shape against eager. If they disagree, that shape falls back to GPU, then to eager. `compress="int8"` stores Linear/Conv2d weights in int8 |
|
|
93
|
+
| `CompiledModule` | torch module over Core AI programs, one program per input shape (up to 8) |
|
|
94
|
+
| `CausalLM(hf_model, max_len=2048, compress=None, batch=False, ...)` | prefill in chunks of 256, decode in windows of 8, a ladder of context sizes 512…`max_len` in one program |
|
|
95
|
+
| `ChunkedCausalLM.from_gguf(path, config)` | 4-bit palettes, plus int8 where the GGUF is more precise (Q6_K). First build takes ~35 min for 24B |
|
|
96
|
+
| `StableDiffusion(model_id, device="ane", size=512)` | diffusers pipeline with all three parts on the ANE |
|
|
97
|
+
| `passes` | the building blocks: `export_for_ane`, `swap_linear`, `split_wide`, `chunk_mlp`, `palettize_lut`, `quantize_int8_modules`, `calibrate_rescale`, `use_composite_ops`, … |
|
|
98
|
+
| `CACHE_DIR`, `prune_cache()`, `clear_system_cache()` | build cache at `~/.cache/torch_ane` (`TORCH_ANE_CACHE`). It is capped at `TORCH_ANE_CACHE_GB` (40) and least recently used builds are removed. `clear_system_cache()` wipes Core AI's own compile cache, which can only be cleared as a whole |
|
|
99
|
+
|
|
100
|
+
## What it does to your model
|
|
101
|
+
|
|
102
|
+
These passes need no knowledge of the specific model:
|
|
103
|
+
|
|
104
|
+
1. RMSNorm, found by a numerical check rather than by name, becomes a Core AI composite op.
|
|
105
|
+
2. Everything that doesn't depend on the input (masks, RoPE tables) is precomputed in fp32.
|
|
106
|
+
3. A causal mask becomes `is_causal`.
|
|
107
|
+
4. SiLU becomes `x·0.5·(1 + tanh(x/2))`. The native ANE SiLU is off by ~5% near zero, which
|
|
108
|
+
wrecks GroupNorm→SiLU in diffusion UNets.
|
|
109
|
+
5. The model is converted to fp16, the only float type the ANE runs.
|
|
110
|
+
6. MLPs whose projection weight exceeds 4 MB are split along the hidden dimension. Each chunk
|
|
111
|
+
of `up` feeds its own chunk of `down`, and the results are summed. Otherwise the weight plus
|
|
112
|
+
the `[S, hidden]` activation doesn't fit the ANE's on-chip memory (~32 MB on M4), and the
|
|
113
|
+
ANE stalls on the bus at ~50 GB/s. This cuts the Whisper large encoder by 24% and
|
|
114
|
+
Qwen2.5-0.5B prefill by 14%. Disable it with `rules=()`.
|
|
115
|
+
|
|
116
|
+
LLMs get two more passes:
|
|
117
|
+
- Per-projection 2^k scales, calibrated on real text, so small activations don't become fp16
|
|
118
|
+
subnormals while large ones don't overflow.
|
|
119
|
+
- int16 positions, embeddings on the CPU, and logits for one row only.
|
|
120
|
+
|
|
121
|
+
UNet/VAE attention uses Apple's layout: BC1S, heads split, `einsum` with no transposes.
|
|
122
|
+
|
|
123
|
+
## Speed (MacBook M4, 24 GB)
|
|
124
|
+
|
|
125
|
+
| task | ANE | for comparison |
|
|
126
|
+
|---|---|---|
|
|
127
|
+
| ResNet-18, batch 16 | 4.5 ms | MLX 24.5, MPS 32.7 |
|
|
128
|
+
| BERT-base 8×128 | 20 ms | MLX 71, MPS 75 |
|
|
129
|
+
| Whisper large-v3-turbo encoder | 285 ms (int8: 264) | |
|
|
130
|
+
| Qwen2.5-0.5B prefill, 512 tokens | 9065 tok/s | mlx-lm 3279 |
|
|
131
|
+
| Qwen2.5-0.5B decode, fp16 | 54 tok/s; 8 streams: 351 | mlx-lm bf16 70 |
|
|
132
|
+
| Devstral Small 24B decode | 4.8 tok/s | mlx-lm 4-bit 6.7 |
|
|
133
|
+
| tiny-sd 512×512, 20 steps | 4.9 s | VAE 3× faster than GPU |
|
|
134
|
+
|
|
135
|
+
## Examples
|
|
136
|
+
|
|
137
|
+
- [`examples/compile_bert.py`](examples/compile_bert.py): BERT-base, eager vs ANE
|
|
138
|
+
- [`examples/generate.py`](examples/generate.py): chat with Qwen2.5 on the ANE
|
|
139
|
+
- [`examples/chat_gguf.py`](examples/chat_gguf.py): chat with a 24B GGUF model, with the engine in a restartable process
|
|
140
|
+
- [`examples/imagine.py`](examples/imagine.py): Stable Diffusion
|
|
141
|
+
|
|
142
|
+
## Limitations
|
|
143
|
+
|
|
144
|
+
- Shapes are static, so a new input shape means a new build.
|
|
145
|
+
- The ANE computes in fp16 only (weights can be palettized or int8). `-inf` in masks is
|
|
146
|
+
replaced by −40000.
|
|
147
|
+
- int8 weights in Conv1d make the ANE compiler fail silently, and the whole function moves to
|
|
148
|
+
the GPU. `compress="int8"` therefore quantizes only Linear/Conv2d.
|
|
149
|
+
- The coreai-core Python wrapper aborts the process after 16 384 allocated buffers
|
|
150
|
+
([apple/coreai-torch#75](https://github.com/apple/coreai-torch/issues/75)).
|
|
151
|
+
`ChunkedCausalLM.steps_left()` lets you restart ahead of time; see `examples/chat_gguf.py`.
|
|
152
|
+
- The ANE unloads programs after ~10 s idle, which makes the next call ~3× slower. Use
|
|
153
|
+
`ChunkedCausalLM.keep_warm()` to avoid this.
|
|
154
|
+
- Copying an input into Core AI runs at ~0.5 GB/s, about 10 ms per 4 MB input. The public API
|
|
155
|
+
has no way around this.
|
|
156
|
+
- Code comments are in Russian.
|
|
157
|
+
|
|
158
|
+
## Tests
|
|
159
|
+
|
|
160
|
+
```sh
|
|
161
|
+
pip install -e ".[llm,diffusion,dev]"
|
|
162
|
+
pytest
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## License
|
|
166
|
+
|
|
167
|
+
MIT
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# torch-ane
|
|
2
|
+
|
|
3
|
+
Run PyTorch models on the **Apple Neural Engine** through Apple's Core AI (macOS 27+).
|
|
4
|
+
|
|
5
|
+
Pass in a plain `nn.Module`, or a model from transformers or diffusers. torch-ane rewrites it
|
|
6
|
+
into a form the ANE runs fast and correctly: fp16, static shapes, and the layout and ops from
|
|
7
|
+
Apple's ANE guidance. It also works around the ANE's numeric quirks. You get back a module
|
|
8
|
+
that behaves like the original.
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
import torch_ane
|
|
12
|
+
|
|
13
|
+
m = torch_ane.compile(model.eval(), example_inputs=(x,)) # any nn.Module -> ANE
|
|
14
|
+
y = m(x) # torch tensors in, torch tensors out
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
On an M4 MacBook, ANE prefill and convolutions are 2–3× faster than the GPU (MLX/MPS).
|
|
18
|
+
Decoding is limited by memory bandwidth, and the ANE reads ~57 GB/s.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
Requires macOS 27+ on Apple silicon, and Python 3.11+.
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
pip install torch-ane # core: compile()
|
|
26
|
+
pip install "torch-ane[llm]" # + CausalLM / ChunkedCausalLM (transformers, gguf)
|
|
27
|
+
pip install "torch-ane[diffusion]" # + StableDiffusion (diffusers)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Core AI's Python packages (`coreai-core`, `coreai-torch`, `coreai-opt`) come from PyPI
|
|
31
|
+
as dependencies.
|
|
32
|
+
|
|
33
|
+
## What's inside
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
import torch_ane
|
|
37
|
+
|
|
38
|
+
torch_ane.available() # is Core AI usable here
|
|
39
|
+
|
|
40
|
+
# Any module. The first call per input shape builds a program (seconds); then it's cached on disk.
|
|
41
|
+
m = torch_ane.compile(model, example_inputs=(x,), device="ane") # or device="gpu"
|
|
42
|
+
|
|
43
|
+
# A whole LLM on the ANE (up to ~1-3B). The KV cache is Core AI state, so it is never copied.
|
|
44
|
+
lm = torch_ane.CausalLM.from_pretrained("Qwen/Qwen2.5-0.5B-Instruct", batch=True)
|
|
45
|
+
ids = lm.generate(prompt_ids, max_new_tokens=64, eos=tok.eos_token_id)
|
|
46
|
+
outs = lm.generate_batch([ids1, ids2, ids3], max_new_tokens=64) # up to 8 streams per step
|
|
47
|
+
logits = lm.feed(more_ids) # append to the cache -> last logits
|
|
48
|
+
|
|
49
|
+
# A large model from GGUF, in 4-layer chunks with 4-bit palettes (Devstral 24B etc.)
|
|
50
|
+
big = torch_ane.ChunkedCausalLM.from_gguf("model.gguf", "config_dir/")
|
|
51
|
+
|
|
52
|
+
# Stable Diffusion: text encoder, UNet and VAE on the ANE
|
|
53
|
+
sd = torch_ane.StableDiffusion("segmind/tiny-sd")
|
|
54
|
+
image, timings = sd("a cute robot", steps=20, seed=0)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
| | |
|
|
58
|
+
|---|---|
|
|
59
|
+
| `compile(model, example_inputs=None, device="ane", validate=True, compress=None)` | → `CompiledModule`. `validate` checks the first call of each shape against eager. If they disagree, that shape falls back to GPU, then to eager. `compress="int8"` stores Linear/Conv2d weights in int8 |
|
|
60
|
+
| `CompiledModule` | torch module over Core AI programs, one program per input shape (up to 8) |
|
|
61
|
+
| `CausalLM(hf_model, max_len=2048, compress=None, batch=False, ...)` | prefill in chunks of 256, decode in windows of 8, a ladder of context sizes 512…`max_len` in one program |
|
|
62
|
+
| `ChunkedCausalLM.from_gguf(path, config)` | 4-bit palettes, plus int8 where the GGUF is more precise (Q6_K). First build takes ~35 min for 24B |
|
|
63
|
+
| `StableDiffusion(model_id, device="ane", size=512)` | diffusers pipeline with all three parts on the ANE |
|
|
64
|
+
| `passes` | the building blocks: `export_for_ane`, `swap_linear`, `split_wide`, `chunk_mlp`, `palettize_lut`, `quantize_int8_modules`, `calibrate_rescale`, `use_composite_ops`, … |
|
|
65
|
+
| `CACHE_DIR`, `prune_cache()`, `clear_system_cache()` | build cache at `~/.cache/torch_ane` (`TORCH_ANE_CACHE`). It is capped at `TORCH_ANE_CACHE_GB` (40) and least recently used builds are removed. `clear_system_cache()` wipes Core AI's own compile cache, which can only be cleared as a whole |
|
|
66
|
+
|
|
67
|
+
## What it does to your model
|
|
68
|
+
|
|
69
|
+
These passes need no knowledge of the specific model:
|
|
70
|
+
|
|
71
|
+
1. RMSNorm, found by a numerical check rather than by name, becomes a Core AI composite op.
|
|
72
|
+
2. Everything that doesn't depend on the input (masks, RoPE tables) is precomputed in fp32.
|
|
73
|
+
3. A causal mask becomes `is_causal`.
|
|
74
|
+
4. SiLU becomes `x·0.5·(1 + tanh(x/2))`. The native ANE SiLU is off by ~5% near zero, which
|
|
75
|
+
wrecks GroupNorm→SiLU in diffusion UNets.
|
|
76
|
+
5. The model is converted to fp16, the only float type the ANE runs.
|
|
77
|
+
6. MLPs whose projection weight exceeds 4 MB are split along the hidden dimension. Each chunk
|
|
78
|
+
of `up` feeds its own chunk of `down`, and the results are summed. Otherwise the weight plus
|
|
79
|
+
the `[S, hidden]` activation doesn't fit the ANE's on-chip memory (~32 MB on M4), and the
|
|
80
|
+
ANE stalls on the bus at ~50 GB/s. This cuts the Whisper large encoder by 24% and
|
|
81
|
+
Qwen2.5-0.5B prefill by 14%. Disable it with `rules=()`.
|
|
82
|
+
|
|
83
|
+
LLMs get two more passes:
|
|
84
|
+
- Per-projection 2^k scales, calibrated on real text, so small activations don't become fp16
|
|
85
|
+
subnormals while large ones don't overflow.
|
|
86
|
+
- int16 positions, embeddings on the CPU, and logits for one row only.
|
|
87
|
+
|
|
88
|
+
UNet/VAE attention uses Apple's layout: BC1S, heads split, `einsum` with no transposes.
|
|
89
|
+
|
|
90
|
+
## Speed (MacBook M4, 24 GB)
|
|
91
|
+
|
|
92
|
+
| task | ANE | for comparison |
|
|
93
|
+
|---|---|---|
|
|
94
|
+
| ResNet-18, batch 16 | 4.5 ms | MLX 24.5, MPS 32.7 |
|
|
95
|
+
| BERT-base 8×128 | 20 ms | MLX 71, MPS 75 |
|
|
96
|
+
| Whisper large-v3-turbo encoder | 285 ms (int8: 264) | |
|
|
97
|
+
| Qwen2.5-0.5B prefill, 512 tokens | 9065 tok/s | mlx-lm 3279 |
|
|
98
|
+
| Qwen2.5-0.5B decode, fp16 | 54 tok/s; 8 streams: 351 | mlx-lm bf16 70 |
|
|
99
|
+
| Devstral Small 24B decode | 4.8 tok/s | mlx-lm 4-bit 6.7 |
|
|
100
|
+
| tiny-sd 512×512, 20 steps | 4.9 s | VAE 3× faster than GPU |
|
|
101
|
+
|
|
102
|
+
## Examples
|
|
103
|
+
|
|
104
|
+
- [`examples/compile_bert.py`](examples/compile_bert.py): BERT-base, eager vs ANE
|
|
105
|
+
- [`examples/generate.py`](examples/generate.py): chat with Qwen2.5 on the ANE
|
|
106
|
+
- [`examples/chat_gguf.py`](examples/chat_gguf.py): chat with a 24B GGUF model, with the engine in a restartable process
|
|
107
|
+
- [`examples/imagine.py`](examples/imagine.py): Stable Diffusion
|
|
108
|
+
|
|
109
|
+
## Limitations
|
|
110
|
+
|
|
111
|
+
- Shapes are static, so a new input shape means a new build.
|
|
112
|
+
- The ANE computes in fp16 only (weights can be palettized or int8). `-inf` in masks is
|
|
113
|
+
replaced by −40000.
|
|
114
|
+
- int8 weights in Conv1d make the ANE compiler fail silently, and the whole function moves to
|
|
115
|
+
the GPU. `compress="int8"` therefore quantizes only Linear/Conv2d.
|
|
116
|
+
- The coreai-core Python wrapper aborts the process after 16 384 allocated buffers
|
|
117
|
+
([apple/coreai-torch#75](https://github.com/apple/coreai-torch/issues/75)).
|
|
118
|
+
`ChunkedCausalLM.steps_left()` lets you restart ahead of time; see `examples/chat_gguf.py`.
|
|
119
|
+
- The ANE unloads programs after ~10 s idle, which makes the next call ~3× slower. Use
|
|
120
|
+
`ChunkedCausalLM.keep_warm()` to avoid this.
|
|
121
|
+
- Copying an input into Core AI runs at ~0.5 GB/s, about 10 ms per 4 MB input. The public API
|
|
122
|
+
has no way around this.
|
|
123
|
+
- Code comments are in Russian.
|
|
124
|
+
|
|
125
|
+
## Tests
|
|
126
|
+
|
|
127
|
+
```sh
|
|
128
|
+
pip install -e ".[llm,diffusion,dev]"
|
|
129
|
+
pytest
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## License
|
|
133
|
+
|
|
134
|
+
MIT
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "torch-ane"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Run PyTorch models on the Apple Neural Engine via Core AI: compile any nn.Module, LLMs with KV cache, Stable Diffusion"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "Ivan Shivalov" }]
|
|
13
|
+
requires-python = ">=3.11"
|
|
14
|
+
dependencies = ["torch>=2.10", "numpy", "coreai-torch>=0.4.3", "coreai-opt>=0.3"]
|
|
15
|
+
keywords = ["pytorch", "apple-silicon", "neural-engine", "ane", "core-ai", "coreml", "llm", "stable-diffusion"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Operating System :: MacOS",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"Intended Audience :: Science/Research",
|
|
22
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
llm = ["transformers>=5", "gguf"]
|
|
27
|
+
diffusion = ["diffusers>=0.40", "transformers>=5"]
|
|
28
|
+
dev = ["pytest", "pyflakes"]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Repository = "https://github.com/intexcor/torch-ane"
|
|
32
|
+
Issues = "https://github.com/intexcor/torch-ane/issues"
|
|
33
|
+
|
|
34
|
+
[tool.setuptools.packages.find]
|
|
35
|
+
include = ["torch_ane*"]
|
|
36
|
+
|
|
37
|
+
[tool.pytest.ini_options]
|
|
38
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""torch_ane: компиляция на Neural Engine, проходы, LLM с KV-кешем-состоянием."""
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
import torch
|
|
7
|
+
import torch.nn as nn
|
|
8
|
+
|
|
9
|
+
import torch_ane
|
|
10
|
+
|
|
11
|
+
needs_coreai = pytest.mark.skipif(not torch_ane.available(), reason="нет Core AI (macOS 27 + coreai-torch)")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_no_mlx_dependency():
|
|
15
|
+
"""Библиотека самостоятельна: импорт torch_ane и его LLM-части не тянет MLX и torch_mlx."""
|
|
16
|
+
code = ("import sys, torch_ane; torch_ane.CausalLM; torch_ane.passes.swap_linear; "
|
|
17
|
+
"print(any(m == 'mlx' or m.startswith(('mlx.', 'torch_mlx')) for m in sys.modules))")
|
|
18
|
+
out = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, check=True).stdout
|
|
19
|
+
assert out.strip() == "False"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_prune_cache(tmp_path, monkeypatch):
|
|
23
|
+
"""Кеш сборок не больше лимита: удаляются давно не использованные, keep и
|
|
24
|
+
недописанные (.partial) остаются."""
|
|
25
|
+
import os
|
|
26
|
+
|
|
27
|
+
from torch_ane import runtime
|
|
28
|
+
monkeypatch.setattr(runtime, "CACHE_DIR", tmp_path)
|
|
29
|
+
for i, name in enumerate(("a.aimodel", "b.aimodel", "c.aimodel", "d.partial.aimodel")):
|
|
30
|
+
d = tmp_path / name
|
|
31
|
+
d.mkdir()
|
|
32
|
+
(d / "w").write_bytes(b"x" * 1000)
|
|
33
|
+
os.utime(d, (1000 + i, 1000 + i))
|
|
34
|
+
freed = runtime.prune_cache(max_gb=2.5e-6, keep=(tmp_path / "a.aimodel",)) # лимит 2500 байт
|
|
35
|
+
assert freed == 2000 and sorted(p.name for p in tmp_path.iterdir()) == ["a.aimodel", "d.partial.aimodel"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_device_names():
|
|
39
|
+
m = nn.Linear(4, 4)
|
|
40
|
+
if torch_ane.available():
|
|
41
|
+
assert torch_ane.CompiledModule(m, device="ane").device == "neural_engine"
|
|
42
|
+
assert torch_ane.CompiledModule(m, device="gpu").device == "gpu"
|
|
43
|
+
with pytest.raises(ValueError):
|
|
44
|
+
torch_ane.runtime._device("tpu")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@needs_coreai
|
|
48
|
+
def test_compile_matches_eager():
|
|
49
|
+
torch.manual_seed(0)
|
|
50
|
+
m = nn.Sequential(nn.Linear(32, 64), nn.GELU(), nn.Linear(64, 8)).eval()
|
|
51
|
+
x = torch.randn(4, 32)
|
|
52
|
+
cm = torch_ane.compile(m, example_inputs=(x,))
|
|
53
|
+
with torch.no_grad():
|
|
54
|
+
ref = m(x)
|
|
55
|
+
out = cm(x)
|
|
56
|
+
assert out.dtype == torch.float32 and ((out - ref).norm() / ref.norm()) < 2e-2
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@needs_coreai
|
|
60
|
+
def test_compile_int8_with_conv1d():
|
|
61
|
+
"""compress="int8" на ANE: int8 только в Linear/Conv2d. Сжатие готовой программы
|
|
62
|
+
квантовало и Conv1d — компилятор ANE падал, модель уходила на GPU с мусором на выходе."""
|
|
63
|
+
class Net(nn.Module):
|
|
64
|
+
def __init__(self):
|
|
65
|
+
super().__init__()
|
|
66
|
+
self.conv = nn.Conv1d(16, 64, 3, padding=1)
|
|
67
|
+
self.mlp = nn.Sequential(nn.Linear(64, 256), nn.GELU(), nn.Linear(256, 64))
|
|
68
|
+
|
|
69
|
+
def forward(self, x): # [1, 16, T]
|
|
70
|
+
h = torch.nn.functional.gelu(self.conv(x)).transpose(1, 2)
|
|
71
|
+
return h + self.mlp(h)
|
|
72
|
+
|
|
73
|
+
torch.manual_seed(0)
|
|
74
|
+
m = Net().eval()
|
|
75
|
+
x = torch.randn(1, 16, 128)
|
|
76
|
+
cm = torch_ane.compile(m, example_inputs=(x,), validate=False, compress="int8")
|
|
77
|
+
with torch.no_grad():
|
|
78
|
+
ref = m(x)
|
|
79
|
+
out = cm(x)
|
|
80
|
+
if cm.ane_stats:
|
|
81
|
+
assert cm.ane_stats["int8"] == 2 # две Linear, Conv1d не тронута
|
|
82
|
+
assert ((out - ref).norm() / ref.norm()) < 3e-2
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@needs_coreai
|
|
86
|
+
def test_ane_passes_llm():
|
|
87
|
+
"""ANE-проходы на LLM из transformers без правок под модель: RMSNorm -> составная операция,
|
|
88
|
+
свёртка констант (маска, RoPE), причинная маска -> is_causal; следующий токен как у eager."""
|
|
89
|
+
from transformers import Qwen2Config, Qwen2ForCausalLM
|
|
90
|
+
|
|
91
|
+
class Prefill(nn.Module):
|
|
92
|
+
def __init__(self, lm):
|
|
93
|
+
super().__init__()
|
|
94
|
+
self.lm = lm
|
|
95
|
+
|
|
96
|
+
def forward(self, ids):
|
|
97
|
+
return self.lm.lm_head(self.lm.model(input_ids=ids, use_cache=False).last_hidden_state[:, -1])
|
|
98
|
+
|
|
99
|
+
torch.manual_seed(0)
|
|
100
|
+
lm = Qwen2ForCausalLM(Qwen2Config(hidden_size=128, intermediate_size=256, num_hidden_layers=2,
|
|
101
|
+
num_attention_heads=4, num_key_value_heads=2, vocab_size=1000)).eval()
|
|
102
|
+
model = Prefill(lm).eval()
|
|
103
|
+
ids = torch.randint(0, 1000, (1, 64))
|
|
104
|
+
m = torch_ane.compile(model, validate=False)
|
|
105
|
+
out = m(ids)
|
|
106
|
+
with torch.no_grad():
|
|
107
|
+
ref = model(ids)
|
|
108
|
+
if m.ane_stats: # при загрузке из дискового кеша проходы не запускаются
|
|
109
|
+
assert {k: m.ane_stats[k] for k in ("rms_norm", "causal")} == {"rms_norm": 5, "causal": 2}
|
|
110
|
+
assert m.ane_stats["folded"] > 0
|
|
111
|
+
assert int(out.argmax()) == int(ref.argmax())
|
|
112
|
+
assert ((out.float() - ref).norm() / ref.norm()) < 5e-2
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_ane_rules_eager_equivalent():
|
|
116
|
+
"""Правила Apple для ANE (Linear -> свёртка 1x1, внимание по головам) не меняют
|
|
117
|
+
математику модели: проверка в eager, без Core AI."""
|
|
118
|
+
from transformers import Qwen2Config, Qwen2ForCausalLM
|
|
119
|
+
|
|
120
|
+
from torch_ane import passes
|
|
121
|
+
torch.manual_seed(0)
|
|
122
|
+
lm = Qwen2ForCausalLM(Qwen2Config(hidden_size=64, intermediate_size=128, num_hidden_layers=2,
|
|
123
|
+
num_attention_heads=4, num_key_value_heads=2, vocab_size=100)).eval()
|
|
124
|
+
ids = torch.randint(0, 100, (1, 23))
|
|
125
|
+
with torch.no_grad():
|
|
126
|
+
ref = lm(ids).logits
|
|
127
|
+
for rules in (("conv",), ("conv_n111",), ("heads",), ("conv", "heads")):
|
|
128
|
+
m, st = passes.prepare(lm, rules)
|
|
129
|
+
assert st.get("conv", 0) or st.get("heads", 0)
|
|
130
|
+
torch.testing.assert_close(m(ids).logits, ref, rtol=1e-4, atol=1e-4)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_chunk_mlp_eager_equivalent():
|
|
134
|
+
"""MLP кусками скрытого измерения: Sequential (Whisper) и gate/up/down (Qwen) — та же
|
|
135
|
+
математика; маленькие MLP не трогаются."""
|
|
136
|
+
from transformers import Qwen2Config, Qwen2ForCausalLM
|
|
137
|
+
|
|
138
|
+
from torch_ane import passes
|
|
139
|
+
torch.manual_seed(0)
|
|
140
|
+
seq = nn.Sequential(nn.Sequential(nn.Linear(256, 1024), nn.GELU(), nn.Linear(1024, 256))).eval()
|
|
141
|
+
lm = Qwen2ForCausalLM(Qwen2Config(hidden_size=64, intermediate_size=256, num_hidden_layers=2,
|
|
142
|
+
num_attention_heads=4, num_key_value_heads=2, vocab_size=100)).eval()
|
|
143
|
+
x, ids = torch.randn(3, 256), torch.randint(0, 100, (1, 11))
|
|
144
|
+
with torch.no_grad():
|
|
145
|
+
ref_s, ref_l = seq(x), lm(ids).logits
|
|
146
|
+
m, st = passes.prepare(seq, ("mlp_chunk",))
|
|
147
|
+
assert st["mlp_chunk"] == 0 # вес 0.5 МБ — меньше порога
|
|
148
|
+
assert passes.chunk_mlp(m, max_bytes=128 << 10) == 1 and len(m[0].up) == 4
|
|
149
|
+
torch.testing.assert_close(m(x), ref_s, rtol=1e-4, atol=1e-5)
|
|
150
|
+
m, _ = passes.prepare(lm, ())
|
|
151
|
+
assert passes.chunk_mlp(m, max_bytes=8 << 10) == 2 and len(m.model.layers[0].mlp.up) == 4
|
|
152
|
+
torch.testing.assert_close(m(ids).logits, ref_l, rtol=1e-4, atol=1e-4)
|
|
153
|
+
m, _ = passes.prepare(lm, ()) # down_proj в обёртке масштаба (CausalLM)
|
|
154
|
+
passes.rescale_for_fp16(m, k=4)
|
|
155
|
+
assert passes.chunk_mlp(m, max_bytes=8 << 10) == 2 and m.model.layers[0].mlp.scale == 2 ** -4
|
|
156
|
+
torch.testing.assert_close(m(ids).logits, ref_l, rtol=1e-4, atol=1e-4)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@needs_coreai
|
|
160
|
+
def test_causal_lm_matches_eager():
|
|
161
|
+
"""KV-кеш — состояния Core AI (схема Apple): prefill куском + декод окнами по 8
|
|
162
|
+
дают те же логиты, что transformers на всём контексте."""
|
|
163
|
+
from transformers import Qwen2Config, Qwen2ForCausalLM
|
|
164
|
+
|
|
165
|
+
import numpy as np
|
|
166
|
+
|
|
167
|
+
torch.manual_seed(0)
|
|
168
|
+
lm = Qwen2ForCausalLM(Qwen2Config(hidden_size=128, intermediate_size=256, num_hidden_layers=2,
|
|
169
|
+
num_attention_heads=4, num_key_value_heads=2, vocab_size=1000)).eval()
|
|
170
|
+
ids = torch.randint(0, 1000, (100,)).tolist() # 37 + 63: переход со ступени 64 на 128
|
|
171
|
+
st = torch_ane.CausalLM(lm, max_len=256, prefill_q=32, ladder=(64, 128))
|
|
172
|
+
st.reset()
|
|
173
|
+
st._hist = []
|
|
174
|
+
got = [st.feed(ids[:37])] + [st.feed([t]) for t in ids[37:]]
|
|
175
|
+
with torch.no_grad():
|
|
176
|
+
ref = lm(torch.tensor([ids])).logits[0, 36:].numpy()
|
|
177
|
+
assert st.M == 128
|
|
178
|
+
for g, r in zip(got, ref):
|
|
179
|
+
assert np.linalg.norm(g - r) / np.linalg.norm(r) < 0.05
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@needs_coreai
|
|
183
|
+
def test_causal_lm_batch_matches_single():
|
|
184
|
+
"""Потоки в окне декода (generate_batch): у каждого свои позиции и маска, K/V вперемешку
|
|
185
|
+
в общем буфере — токены совпадают с одиночной генерацией каждого промпта."""
|
|
186
|
+
from transformers import Qwen2Config, Qwen2ForCausalLM
|
|
187
|
+
|
|
188
|
+
torch.manual_seed(0)
|
|
189
|
+
lm = Qwen2ForCausalLM(Qwen2Config(hidden_size=128, intermediate_size=256, num_hidden_layers=2,
|
|
190
|
+
num_attention_heads=4, num_key_value_heads=2, vocab_size=1000)).eval()
|
|
191
|
+
prompts = [torch.randint(0, 1000, (n,)).tolist() for n in (5, 40, 17)] # 40 — два куска prefill
|
|
192
|
+
st = torch_ane.CausalLM(lm, max_len=256, prefill_q=32, ladder=(128,), batch=True)
|
|
193
|
+
assert st.generate_batch(prompts, 12) == [st.generate(p, 12) for p in prompts]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@needs_coreai
|
|
197
|
+
def test_silu_after_groupnorm():
|
|
198
|
+
"""Родная SiLU на ANE врёт около нуля (~5% после GroupNorm); export_for_ane заменяет её
|
|
199
|
+
записью через tanh — связка GroupNorm -> SiLU -> свёртка совпадает с eager."""
|
|
200
|
+
torch.manual_seed(0)
|
|
201
|
+
m = nn.Sequential(nn.GroupNorm(8, 64), nn.SiLU(), nn.Conv2d(64, 64, 3, padding=1)).eval()
|
|
202
|
+
x = torch.randn(2, 64, 16, 16) * 3
|
|
203
|
+
with torch.no_grad():
|
|
204
|
+
ref = m(x)
|
|
205
|
+
cm = torch_ane.compile(m, validate=False)
|
|
206
|
+
out = cm(x)
|
|
207
|
+
assert ((out - ref).norm() / ref.norm()) < 5e-3
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""torch_ane — PyTorch-модели на Apple Neural Engine через Core AI (macOS 27+).
|
|
2
|
+
|
|
3
|
+
import torch_ane
|
|
4
|
+
|
|
5
|
+
m = torch_ane.compile(model.eval(), example_inputs=(x,)) # любой nn.Module -> ANE
|
|
6
|
+
y = m(x)
|
|
7
|
+
|
|
8
|
+
lm = torch_ane.CausalLM.from_pretrained("Qwen/Qwen2.5-0.5B-Instruct")
|
|
9
|
+
ids = lm.generate(prompt_ids, max_new_tokens=64)
|
|
10
|
+
|
|
11
|
+
sd = torch_ane.StableDiffusion("segmind/tiny-sd")
|
|
12
|
+
image, timings = sd("a cute robot", steps=20, seed=0)
|
|
13
|
+
|
|
14
|
+
Нижний уровень — torch_ane.passes (проходы экспорта, раскладка Apple, палитры/int8,
|
|
15
|
+
составные операции). Сборки кешируются в torch_ane.CACHE_DIR (~/.cache/torch_ane,
|
|
16
|
+
переменная TORCH_ANE_CACHE). Библиотека не зависит от MLX.
|
|
17
|
+
"""
|
|
18
|
+
from . import passes
|
|
19
|
+
from .runtime import (CACHE_DIR, CompiledModule, available, cleanup_mpsgraph_tmp, clear_system_cache, compile,
|
|
20
|
+
compress_program, model_digest, prune_cache)
|
|
21
|
+
|
|
22
|
+
__all__ = ["compile", "CompiledModule", "available", "compress_program", "model_digest", "cleanup_mpsgraph_tmp",
|
|
23
|
+
"CACHE_DIR", "prune_cache", "clear_system_cache", "passes", "CausalLM", "ChunkedCausalLM", "StableDiffusion"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def __getattr__(name):
|
|
27
|
+
# тяжёлые части (transformers, diffusers) — только по обращению
|
|
28
|
+
if name in ("CausalLM", "ChunkedCausalLM"):
|
|
29
|
+
from . import llm
|
|
30
|
+
return getattr(llm, name)
|
|
31
|
+
if name == "StableDiffusion":
|
|
32
|
+
from .diffusion import StableDiffusion
|
|
33
|
+
return StableDiffusion
|
|
34
|
+
raise AttributeError(f"module 'torch_ane' has no attribute {name!r}")
|