mlx-train-perf 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.
- mlx_train_perf-0.1.0/.github/workflows/ci.yml +48 -0
- mlx_train_perf-0.1.0/.github/workflows/publish.yml +34 -0
- mlx_train_perf-0.1.0/.gitignore +8 -0
- mlx_train_perf-0.1.0/CHANGELOG.md +41 -0
- mlx_train_perf-0.1.0/LICENSE +21 -0
- mlx_train_perf-0.1.0/PKG-INFO +123 -0
- mlx_train_perf-0.1.0/README.md +101 -0
- mlx_train_perf-0.1.0/ROADMAP.md +35 -0
- mlx_train_perf-0.1.0/pyproject.toml +112 -0
- mlx_train_perf-0.1.0/scripts/bench_backward_ladder.py +602 -0
- mlx_train_perf-0.1.0/scripts/bench_loss_layer.py +176 -0
- mlx_train_perf-0.1.0/scripts/bench_quant_thresholds.py +237 -0
- mlx_train_perf-0.1.0/scripts/bench_train_step.py +257 -0
- mlx_train_perf-0.1.0/scripts/fit_calibration.py +161 -0
- mlx_train_perf-0.1.0/scripts/ground_truth_atomic_outputs.py +385 -0
- mlx_train_perf-0.1.0/scripts/northstar_context_sweep.py +264 -0
- mlx_train_perf-0.1.0/src/mlx_train_perf/__init__.py +12 -0
- mlx_train_perf-0.1.0/src/mlx_train_perf/_compat.py +21 -0
- mlx_train_perf-0.1.0/src/mlx_train_perf/adapters/__init__.py +0 -0
- mlx_train_perf-0.1.0/src/mlx_train_perf/adapters/mlx_lm.py +214 -0
- mlx_train_perf-0.1.0/src/mlx_train_perf/bench/__init__.py +0 -0
- mlx_train_perf-0.1.0/src/mlx_train_perf/bench/artifacts.py +153 -0
- mlx_train_perf-0.1.0/src/mlx_train_perf/bench/runner.py +161 -0
- mlx_train_perf-0.1.0/src/mlx_train_perf/bench/worker.py +450 -0
- mlx_train_perf-0.1.0/src/mlx_train_perf/cli.py +265 -0
- mlx_train_perf-0.1.0/src/mlx_train_perf/core/__init__.py +0 -0
- mlx_train_perf-0.1.0/src/mlx_train_perf/core/chunked.py +183 -0
- mlx_train_perf-0.1.0/src/mlx_train_perf/core/guards.py +38 -0
- mlx_train_perf-0.1.0/src/mlx_train_perf/core/kernel/__init__.py +0 -0
- mlx_train_perf-0.1.0/src/mlx_train_perf/core/kernel/dispatch.py +25 -0
- mlx_train_perf-0.1.0/src/mlx_train_perf/core/kernel/launch.py +522 -0
- mlx_train_perf-0.1.0/src/mlx_train_perf/core/kernel/source.py +702 -0
- mlx_train_perf-0.1.0/src/mlx_train_perf/core/loss.py +392 -0
- mlx_train_perf-0.1.0/src/mlx_train_perf/core/naive.py +14 -0
- mlx_train_perf-0.1.0/src/mlx_train_perf/devtools/__init__.py +0 -0
- mlx_train_perf-0.1.0/src/mlx_train_perf/devtools/regpressure.py +279 -0
- mlx_train_perf-0.1.0/src/mlx_train_perf/errors.py +49 -0
- mlx_train_perf-0.1.0/src/mlx_train_perf/plan/__init__.py +0 -0
- mlx_train_perf-0.1.0/src/mlx_train_perf/plan/calibration.py +69 -0
- mlx_train_perf-0.1.0/src/mlx_train_perf/plan/calibration_data.json +16 -0
- mlx_train_perf-0.1.0/src/mlx_train_perf/plan/estimate.py +405 -0
- mlx_train_perf-0.1.0/src/mlx_train_perf/py.typed +0 -0
- mlx_train_perf-0.1.0/tests/conftest.py +37 -0
- mlx_train_perf-0.1.0/tests/test_adapter.py +345 -0
- mlx_train_perf-0.1.0/tests/test_bench_backward_ladder.py +257 -0
- mlx_train_perf-0.1.0/tests/test_bench_loss_layer.py +158 -0
- mlx_train_perf-0.1.0/tests/test_bench_resume.py +431 -0
- mlx_train_perf-0.1.0/tests/test_bench_train_step.py +303 -0
- mlx_train_perf-0.1.0/tests/test_chunked.py +187 -0
- mlx_train_perf-0.1.0/tests/test_cli.py +337 -0
- mlx_train_perf-0.1.0/tests/test_compat.py +30 -0
- mlx_train_perf-0.1.0/tests/test_devtools.py +213 -0
- mlx_train_perf-0.1.0/tests/test_fit_calibration.py +268 -0
- mlx_train_perf-0.1.0/tests/test_ground_truth_script.py +101 -0
- mlx_train_perf-0.1.0/tests/test_guards.py +42 -0
- mlx_train_perf-0.1.0/tests/test_kernel_backward_parity.py +320 -0
- mlx_train_perf-0.1.0/tests/test_kernel_dispatch.py +33 -0
- mlx_train_perf-0.1.0/tests/test_kernel_guard.py +101 -0
- mlx_train_perf-0.1.0/tests/test_kernel_launch_calibration.py +114 -0
- mlx_train_perf-0.1.0/tests/test_kernel_parity.py +54 -0
- mlx_train_perf-0.1.0/tests/test_kernel_quant_parity.py +97 -0
- mlx_train_perf-0.1.0/tests/test_kernel_quant_validation.py +22 -0
- mlx_train_perf-0.1.0/tests/test_kernel_source.py +131 -0
- mlx_train_perf-0.1.0/tests/test_loss_api.py +398 -0
- mlx_train_perf-0.1.0/tests/test_loss_compile.py +105 -0
- mlx_train_perf-0.1.0/tests/test_markers.py +14 -0
- mlx_train_perf-0.1.0/tests/test_naive.py +35 -0
- mlx_train_perf-0.1.0/tests/test_northstar_context_sweep.py +308 -0
- mlx_train_perf-0.1.0/tests/test_plan.py +481 -0
- mlx_train_perf-0.1.0/tests/test_quant_layout.py +16 -0
- mlx_train_perf-0.1.0/tests/test_worker_train_step.py +279 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
name: ci
|
|
2
|
+
on: [push, pull_request]
|
|
3
|
+
jobs:
|
|
4
|
+
checks:
|
|
5
|
+
runs-on: macos-15
|
|
6
|
+
steps:
|
|
7
|
+
- uses: actions/checkout@v4
|
|
8
|
+
with: { fetch-depth: 0 }
|
|
9
|
+
- uses: astral-sh/setup-uv@v5
|
|
10
|
+
- run: uv sync --all-extras
|
|
11
|
+
- run: uv run ruff check src/ tests/
|
|
12
|
+
- run: uv run mypy src/
|
|
13
|
+
# coverage REPORTED but not gated here: the metal marker gates CORE code (kernel
|
|
14
|
+
# launch/loss paths), so the default lane can't reach fail_under=85 by construction.
|
|
15
|
+
# The gate lives in the metal-enabled lane (added in Task 7 once the probe verdict is
|
|
16
|
+
# known) and in the Task 18 local pre-release check.
|
|
17
|
+
- run: uv run pytest tests/ --cov=mlx_train_perf --cov-fail-under=0
|
|
18
|
+
metal-probe:
|
|
19
|
+
runs-on: macos-15
|
|
20
|
+
continue-on-error: true
|
|
21
|
+
steps:
|
|
22
|
+
- uses: actions/checkout@v4
|
|
23
|
+
with: { fetch-depth: 0 }
|
|
24
|
+
- uses: astral-sh/setup-uv@v5
|
|
25
|
+
- run: uv sync
|
|
26
|
+
- name: Can this runner JIT + run mx.fast.metal_kernel?
|
|
27
|
+
run: |
|
|
28
|
+
uv run python - <<'EOF'
|
|
29
|
+
import mlx.core as mx
|
|
30
|
+
k = mx.fast.metal_kernel(name="probe", input_names=["inp"], output_names=["out"],
|
|
31
|
+
source="uint e = thread_position_in_grid.x; out[e] = inp[e] * 2.0f;")
|
|
32
|
+
x = mx.arange(16, dtype=mx.float32)
|
|
33
|
+
(y,) = k(inputs=[x], grid=(16, 1, 1), threadgroup=(16, 1, 1),
|
|
34
|
+
output_shapes=[(16,)], output_dtypes=[mx.float32])
|
|
35
|
+
assert (y == x * 2).all().item(), "wrong result"
|
|
36
|
+
print("METAL PROBE: OK")
|
|
37
|
+
EOF
|
|
38
|
+
# Task-2 probe verdict: METAL PROBE: OK on macos-15 — this lane runs the Metal-gated
|
|
39
|
+
# kernel parity suite and carries the fail_under=85 coverage gate (the default `checks`
|
|
40
|
+
# job above can't reach it: the metal marker gates the kernel launch/loss paths).
|
|
41
|
+
metal-tests:
|
|
42
|
+
runs-on: macos-15
|
|
43
|
+
steps:
|
|
44
|
+
- uses: actions/checkout@v4
|
|
45
|
+
with: { fetch-depth: 0 }
|
|
46
|
+
- uses: astral-sh/setup-uv@v5
|
|
47
|
+
- run: uv sync --all-extras
|
|
48
|
+
- run: uv run pytest tests/ --run-metal --cov=mlx_train_perf
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
name: publish
|
|
2
|
+
# Tag-triggered release: build the sdist + wheel and publish to PyPI via Trusted
|
|
3
|
+
# Publishing (OIDC, no stored token). The package is pure Python (the Metal kernel is
|
|
4
|
+
# JIT-compiled at runtime through mx.fast.metal_kernel), so the build is platform-neutral.
|
|
5
|
+
on:
|
|
6
|
+
push:
|
|
7
|
+
tags: ["v*"]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
build:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
with:
|
|
15
|
+
fetch-depth: 0 # hatch-vcs derives the version from the git tag/history
|
|
16
|
+
- uses: astral-sh/setup-uv@v5
|
|
17
|
+
- run: uv build
|
|
18
|
+
- uses: actions/upload-artifact@v4
|
|
19
|
+
with:
|
|
20
|
+
name: dist
|
|
21
|
+
path: dist/
|
|
22
|
+
|
|
23
|
+
publish:
|
|
24
|
+
needs: build
|
|
25
|
+
runs-on: ubuntu-latest
|
|
26
|
+
environment: pypi
|
|
27
|
+
permissions:
|
|
28
|
+
id-token: write # required for PyPI Trusted Publishing
|
|
29
|
+
steps:
|
|
30
|
+
- uses: actions/download-artifact@v4
|
|
31
|
+
with:
|
|
32
|
+
name: dist
|
|
33
|
+
path: dist/
|
|
34
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
|
|
5
|
+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [0.1.0] - 2026-07-08
|
|
8
|
+
|
|
9
|
+
First release. A fused, logit-free linear-cross-entropy loss for MLX training on Apple
|
|
10
|
+
Silicon, with an mlx-lm adapter, a RAM-fit planner, and a benchmark harness.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Fused Metal cross-entropy kernel that never materializes the `(N, V)` logits: about
|
|
14
|
+
3900x less loss-layer memory than materialized logits in isolation, at a 1.64x forward
|
|
15
|
+
cost (n=8192, V=151936, D=4096, bf16; `scripts/bench_loss_layer.py`). Exact value and
|
|
16
|
+
gradient parity against a materialized reference.
|
|
17
|
+
- Three implementations behind one `impl` argument: `kernel` (the fused Metal path),
|
|
18
|
+
`chunked` (a pure-MLX fallback bounded by a fixed vocabulary tile), and `naive` (the
|
|
19
|
+
materialized correctness oracle). `auto` selects the kernel when the mlx version is
|
|
20
|
+
verified and the head and dtype are supported, and otherwise raises a typed error
|
|
21
|
+
naming the reason and the alternatives. It never silently downgrades.
|
|
22
|
+
- Quantized-head support: 4-bit group-size-64 heads (the mlx-community QLoRA default) run
|
|
23
|
+
through the kernel.
|
|
24
|
+
- mlx-lm training adapter (`make_loss_fn`) that plugs the loss into `mlx_lm`'s compiled
|
|
25
|
+
trainer, with per-step loss curves matching the stock trainer to bf16 tolerance
|
|
26
|
+
(about 2e-3). End-to-end throughput is roughly 8-12% slower per step at bf16
|
|
27
|
+
(`scripts/bench_train_step.py`).
|
|
28
|
+
- RAM-fit planner that estimates peak training memory and accounts for the O(N^2)
|
|
29
|
+
attention backward that dominates at long context. Fit to measured Qwen3-8B train-step
|
|
30
|
+
peaks and cross-model validated on Llama-3.2-3B to within about 9%.
|
|
31
|
+
- Benchmark harness and committed scripts for every published number.
|
|
32
|
+
|
|
33
|
+
### Known limits
|
|
34
|
+
- The fused loss frees memory at a given context but does not, on its own, extend the
|
|
35
|
+
maximum trainable context on MLX. `mx.fast.scaled_dot_product_attention` has an O(N^2)
|
|
36
|
+
backward that materializes the `(N, N)` attention matrix and becomes the memory
|
|
37
|
+
bottleneck at long context. A memory-efficient attention backward is the next step (see
|
|
38
|
+
`ROADMAP.md`).
|
|
39
|
+
- Architectures: Llama and Qwen3 only. Training: LoRA / QLoRA. Apple Silicon only.
|
|
40
|
+
|
|
41
|
+
[0.1.0]: https://github.com/IonDen/mlx-train-perf/releases/tag/v0.1.0
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Denis Ineshin
|
|
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.
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mlx-train-perf
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Fused, logit-free linear-cross-entropy loss, RAM-fit planner, and benchmark harness for MLX fine-tuning on Apple Silicon
|
|
5
|
+
Author-email: Denis Ineshin <denis.ineshin@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Operating System :: MacOS
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Requires-Dist: mlx>=0.31.2
|
|
16
|
+
Provides-Extra: mlx-lm
|
|
17
|
+
Requires-Dist: mlx-lm<0.32,>=0.31.3; extra == 'mlx-lm'
|
|
18
|
+
Requires-Dist: transformers<5.13,>=5.0; extra == 'mlx-lm'
|
|
19
|
+
Provides-Extra: probe
|
|
20
|
+
Requires-Dist: pyobjc-framework-metal; extra == 'probe'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# mlx-train-perf
|
|
24
|
+
|
|
25
|
+
A fused, logit-free linear-cross-entropy loss for training on Apple Silicon with [MLX](https://github.com/ml-explore/mlx), plus a RAM-fit planner and an honest benchmark harness. It drops into an `mlx-lm` LoRA/QLoRA fine-tune as the loss function.
|
|
26
|
+
|
|
27
|
+
The idea is the same one behind [Cut Cross-Entropy](https://arxiv.org/abs/2411.09009) and [Liger-Kernel](https://github.com/linkedin/Liger-Kernel) on the CUDA side, ported to a Metal kernel: compute the cross-entropy loss and its gradient without ever building the full `(N, V)` logits tensor. For a large vocabulary that tensor is the single biggest allocation in the training step, and it is pure waste. You only need the per-token loss and a gradient back into the hidden states.
|
|
28
|
+
|
|
29
|
+
Released on PyPI as `mlx-train-perf`. Every number below has a committed script under `scripts/` that reproduces it; all were measured on an M1 Max (32 GB), mlx 0.31.2.
|
|
30
|
+
|
|
31
|
+
## The problem it solves
|
|
32
|
+
|
|
33
|
+
Standard cross-entropy in a trainer materializes logits of shape `(batch·seq, vocab)`. At Qwen3-8B's vocabulary (151,936) and a 2048-token sequence, that is a 0.6 GB tensor in bf16, plus another for the softmax gradient in the backward pass. The fused kernel never allocates it: the forward regenerates logits in registers tile-by-tile over the vocabulary and returns three `N`-length arrays (the per-token NLL, the log-sum-exp, and the target logit); the backward recomputes the needed tiles instead of reading a stored matrix.
|
|
34
|
+
|
|
35
|
+
Measured in isolation, at n=8192, V=151936, D=4096, bf16 (`scripts/bench_loss_layer.py`):
|
|
36
|
+
|
|
37
|
+
| loss layer | peak memory | forward wall |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| naive (materialized logits) | 2.318 GB | 1.0× |
|
|
40
|
+
| kernel (this project) | 0.0006 GB | 1.64× |
|
|
41
|
+
|
|
42
|
+
About 3900× less memory for the loss layer, at a 1.64× cost on the forward pass.
|
|
43
|
+
|
|
44
|
+
## What this does and does not buy you
|
|
45
|
+
|
|
46
|
+
I want to be precise here, because the honest end-to-end story is narrower than the loss-layer number suggests.
|
|
47
|
+
|
|
48
|
+
In a real fine-tune step the fused loss frees the memory the logit tensor would have taken. That headroom goes to a larger batch or a longer sequence at the *same* peak. The loss is exact to bf16 tolerance against the stock trainer (per-step loss curves match to about 2e-3), and the throughput cost is small: roughly 8–12% slower per step at bf16 (`scripts/bench_train_step.py`, Qwen3-8B-4bit, LoRA r=8, gradient checkpointing on).
|
|
49
|
+
|
|
50
|
+
What it does *not* buy you on MLX today is a longer maximum context before you hit an out-of-memory error. We measured this directly (`scripts/northstar_context_sweep.py`): with gradient checkpointing on, ours and the stock trainer hit essentially the same context ceiling for an 8B QLoRA on 32 GB — ours about 8450 tokens, stock about 8700, a gap of one 256-token probe step. The reason is that `mx.fast.scaled_dot_product_attention` has a memory-efficient forward but an O(N²) backward. It materializes the `(N,N)` attention matrix one layer at a time during training, and that term dominates the peak at long context. Once the logits are gone, attention is the bottleneck, not the loss. A memory-efficient attention backward is the piece that would move the context ceiling on MLX, and it is the next thing on the [roadmap](ROADMAP.md).
|
|
51
|
+
|
|
52
|
+
So this frees real memory at a given context, and it is the right building block, but the flagship "train much longer sequences" win needs the attention backward too.
|
|
53
|
+
|
|
54
|
+
## Install
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install mlx-train-perf # the loss kernel + planner
|
|
58
|
+
pip install "mlx-train-perf[mlx-lm]" # plus the mlx-lm training adapter
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Apple Silicon only. Requires mlx (>=0.31.2 recommended; the kernel's JIT contract is verified against it).
|
|
62
|
+
|
|
63
|
+
## Use it in an mlx-lm fine-tune
|
|
64
|
+
|
|
65
|
+
The adapter builds a loss callable with the same signature `mlx_lm`'s trainer expects, so you pass it straight to `train(...)`:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
import mlx.core as mx
|
|
69
|
+
from mlx_lm import load
|
|
70
|
+
from mlx_lm.tuner.trainer import train
|
|
71
|
+
from mlx_train_perf.adapters.mlx_lm import make_loss_fn
|
|
72
|
+
|
|
73
|
+
model, tokenizer = load("mlx-community/Qwen3-8B-4bit")
|
|
74
|
+
model.set_dtype(mx.bfloat16) # 4-bit checkpoints compute in fp16; the kernel needs bf16/fp32
|
|
75
|
+
# ... freeze the base model and apply linear_to_lora_layers as in a normal mlx-lm LoRA run ...
|
|
76
|
+
|
|
77
|
+
loss_fn = make_loss_fn(model, impl="auto")
|
|
78
|
+
train(model=model, optimizer=opt, train_dataset=ds, args=args, loss=loss_fn)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`make_loss_fn` splits the model into its trunk and its output head and routes the loss through the fused kernel.
|
|
82
|
+
|
|
83
|
+
## Implementations
|
|
84
|
+
|
|
85
|
+
`impl` picks how the loss is computed. `"auto"` is the default and the one to use.
|
|
86
|
+
|
|
87
|
+
- `kernel` — the fused Metal kernel. `"auto"` resolves here when the mlx version is verified and the head/dtype are supported (dense or tied fp32/bf16 head; 4-bit group-size-64 quantized head; hidden states in fp32 or bf16). It never materializes `(N, V)`.
|
|
88
|
+
- `chunked` — a pure-MLX fallback that processes the vocabulary in fixed tiles. No Metal kernel, works anywhere MLX does, uses more memory than `kernel` but far less than `naive`. This is also the backward path the kernel forward pairs with today.
|
|
89
|
+
- `naive` — materializes the full logits. It is the correctness oracle the other two are tested against, not something to train with.
|
|
90
|
+
|
|
91
|
+
`"auto"` never silently downgrades. If it cannot use the kernel (unverified mlx, an unsupported head, fp16 hidden states) it raises a typed error naming the reason and the alternatives, so you always know which path ran.
|
|
92
|
+
|
|
93
|
+
## RAM-fit planner
|
|
94
|
+
|
|
95
|
+
Before a run, the planner estimates the peak training memory for a config and tells you whether it fits, or suggests a smaller batch or sequence length that would:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
mlx-train-perf plan --config path/to/config.json --batch 1 --seq-len 4096 --lora-rank 8
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The memory model is fit to measured Qwen3-8B train-step peaks and cross-model validated on Llama-3.2-3B to within about 9%. It accounts for the O(N²) attention backward described above, so it does not under-predict at long context the way a linear model would. It is an estimate, and it errs toward over-predicting, which is the safe direction for a tool whose job is to keep you off the OOM cliff.
|
|
102
|
+
|
|
103
|
+
## Supported models
|
|
104
|
+
|
|
105
|
+
- Architectures: Llama and Qwen3. The adapter's model splitter handles these; others raise a typed error.
|
|
106
|
+
- Quantization: 4-bit group-size-64 (the mlx-community QLoRA default), or a dense fp32/bf16 head.
|
|
107
|
+
- Training: LoRA / QLoRA. Full fine-tuning is estimated by the planner but is not the case this is tuned for.
|
|
108
|
+
- Hardware: Apple Silicon.
|
|
109
|
+
|
|
110
|
+
## Reproducing the numbers
|
|
111
|
+
|
|
112
|
+
Each claim above has one script. They run on the GPU, take real wall-clock time, and print the artifacts they measured:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
python scripts/bench_loss_layer.py # the ~3900x loss-layer memory number
|
|
116
|
+
python scripts/bench_train_step.py --model mlx-community/Qwen3-8B-4bit --seq-len 1024 2048 \
|
|
117
|
+
--impl kernel --compute-dtype bfloat16 --grad-checkpoint # end-to-end tok/s vs stock
|
|
118
|
+
python scripts/northstar_context_sweep.py # the max-context sweep (1-2 h; heavy)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## License
|
|
122
|
+
|
|
123
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# mlx-train-perf
|
|
2
|
+
|
|
3
|
+
A fused, logit-free linear-cross-entropy loss for training on Apple Silicon with [MLX](https://github.com/ml-explore/mlx), plus a RAM-fit planner and an honest benchmark harness. It drops into an `mlx-lm` LoRA/QLoRA fine-tune as the loss function.
|
|
4
|
+
|
|
5
|
+
The idea is the same one behind [Cut Cross-Entropy](https://arxiv.org/abs/2411.09009) and [Liger-Kernel](https://github.com/linkedin/Liger-Kernel) on the CUDA side, ported to a Metal kernel: compute the cross-entropy loss and its gradient without ever building the full `(N, V)` logits tensor. For a large vocabulary that tensor is the single biggest allocation in the training step, and it is pure waste. You only need the per-token loss and a gradient back into the hidden states.
|
|
6
|
+
|
|
7
|
+
Released on PyPI as `mlx-train-perf`. Every number below has a committed script under `scripts/` that reproduces it; all were measured on an M1 Max (32 GB), mlx 0.31.2.
|
|
8
|
+
|
|
9
|
+
## The problem it solves
|
|
10
|
+
|
|
11
|
+
Standard cross-entropy in a trainer materializes logits of shape `(batch·seq, vocab)`. At Qwen3-8B's vocabulary (151,936) and a 2048-token sequence, that is a 0.6 GB tensor in bf16, plus another for the softmax gradient in the backward pass. The fused kernel never allocates it: the forward regenerates logits in registers tile-by-tile over the vocabulary and returns three `N`-length arrays (the per-token NLL, the log-sum-exp, and the target logit); the backward recomputes the needed tiles instead of reading a stored matrix.
|
|
12
|
+
|
|
13
|
+
Measured in isolation, at n=8192, V=151936, D=4096, bf16 (`scripts/bench_loss_layer.py`):
|
|
14
|
+
|
|
15
|
+
| loss layer | peak memory | forward wall |
|
|
16
|
+
|---|---|---|
|
|
17
|
+
| naive (materialized logits) | 2.318 GB | 1.0× |
|
|
18
|
+
| kernel (this project) | 0.0006 GB | 1.64× |
|
|
19
|
+
|
|
20
|
+
About 3900× less memory for the loss layer, at a 1.64× cost on the forward pass.
|
|
21
|
+
|
|
22
|
+
## What this does and does not buy you
|
|
23
|
+
|
|
24
|
+
I want to be precise here, because the honest end-to-end story is narrower than the loss-layer number suggests.
|
|
25
|
+
|
|
26
|
+
In a real fine-tune step the fused loss frees the memory the logit tensor would have taken. That headroom goes to a larger batch or a longer sequence at the *same* peak. The loss is exact to bf16 tolerance against the stock trainer (per-step loss curves match to about 2e-3), and the throughput cost is small: roughly 8–12% slower per step at bf16 (`scripts/bench_train_step.py`, Qwen3-8B-4bit, LoRA r=8, gradient checkpointing on).
|
|
27
|
+
|
|
28
|
+
What it does *not* buy you on MLX today is a longer maximum context before you hit an out-of-memory error. We measured this directly (`scripts/northstar_context_sweep.py`): with gradient checkpointing on, ours and the stock trainer hit essentially the same context ceiling for an 8B QLoRA on 32 GB — ours about 8450 tokens, stock about 8700, a gap of one 256-token probe step. The reason is that `mx.fast.scaled_dot_product_attention` has a memory-efficient forward but an O(N²) backward. It materializes the `(N,N)` attention matrix one layer at a time during training, and that term dominates the peak at long context. Once the logits are gone, attention is the bottleneck, not the loss. A memory-efficient attention backward is the piece that would move the context ceiling on MLX, and it is the next thing on the [roadmap](ROADMAP.md).
|
|
29
|
+
|
|
30
|
+
So this frees real memory at a given context, and it is the right building block, but the flagship "train much longer sequences" win needs the attention backward too.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install mlx-train-perf # the loss kernel + planner
|
|
36
|
+
pip install "mlx-train-perf[mlx-lm]" # plus the mlx-lm training adapter
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Apple Silicon only. Requires mlx (>=0.31.2 recommended; the kernel's JIT contract is verified against it).
|
|
40
|
+
|
|
41
|
+
## Use it in an mlx-lm fine-tune
|
|
42
|
+
|
|
43
|
+
The adapter builds a loss callable with the same signature `mlx_lm`'s trainer expects, so you pass it straight to `train(...)`:
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
import mlx.core as mx
|
|
47
|
+
from mlx_lm import load
|
|
48
|
+
from mlx_lm.tuner.trainer import train
|
|
49
|
+
from mlx_train_perf.adapters.mlx_lm import make_loss_fn
|
|
50
|
+
|
|
51
|
+
model, tokenizer = load("mlx-community/Qwen3-8B-4bit")
|
|
52
|
+
model.set_dtype(mx.bfloat16) # 4-bit checkpoints compute in fp16; the kernel needs bf16/fp32
|
|
53
|
+
# ... freeze the base model and apply linear_to_lora_layers as in a normal mlx-lm LoRA run ...
|
|
54
|
+
|
|
55
|
+
loss_fn = make_loss_fn(model, impl="auto")
|
|
56
|
+
train(model=model, optimizer=opt, train_dataset=ds, args=args, loss=loss_fn)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`make_loss_fn` splits the model into its trunk and its output head and routes the loss through the fused kernel.
|
|
60
|
+
|
|
61
|
+
## Implementations
|
|
62
|
+
|
|
63
|
+
`impl` picks how the loss is computed. `"auto"` is the default and the one to use.
|
|
64
|
+
|
|
65
|
+
- `kernel` — the fused Metal kernel. `"auto"` resolves here when the mlx version is verified and the head/dtype are supported (dense or tied fp32/bf16 head; 4-bit group-size-64 quantized head; hidden states in fp32 or bf16). It never materializes `(N, V)`.
|
|
66
|
+
- `chunked` — a pure-MLX fallback that processes the vocabulary in fixed tiles. No Metal kernel, works anywhere MLX does, uses more memory than `kernel` but far less than `naive`. This is also the backward path the kernel forward pairs with today.
|
|
67
|
+
- `naive` — materializes the full logits. It is the correctness oracle the other two are tested against, not something to train with.
|
|
68
|
+
|
|
69
|
+
`"auto"` never silently downgrades. If it cannot use the kernel (unverified mlx, an unsupported head, fp16 hidden states) it raises a typed error naming the reason and the alternatives, so you always know which path ran.
|
|
70
|
+
|
|
71
|
+
## RAM-fit planner
|
|
72
|
+
|
|
73
|
+
Before a run, the planner estimates the peak training memory for a config and tells you whether it fits, or suggests a smaller batch or sequence length that would:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
mlx-train-perf plan --config path/to/config.json --batch 1 --seq-len 4096 --lora-rank 8
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The memory model is fit to measured Qwen3-8B train-step peaks and cross-model validated on Llama-3.2-3B to within about 9%. It accounts for the O(N²) attention backward described above, so it does not under-predict at long context the way a linear model would. It is an estimate, and it errs toward over-predicting, which is the safe direction for a tool whose job is to keep you off the OOM cliff.
|
|
80
|
+
|
|
81
|
+
## Supported models
|
|
82
|
+
|
|
83
|
+
- Architectures: Llama and Qwen3. The adapter's model splitter handles these; others raise a typed error.
|
|
84
|
+
- Quantization: 4-bit group-size-64 (the mlx-community QLoRA default), or a dense fp32/bf16 head.
|
|
85
|
+
- Training: LoRA / QLoRA. Full fine-tuning is estimated by the planner but is not the case this is tuned for.
|
|
86
|
+
- Hardware: Apple Silicon.
|
|
87
|
+
|
|
88
|
+
## Reproducing the numbers
|
|
89
|
+
|
|
90
|
+
Each claim above has one script. They run on the GPU, take real wall-clock time, and print the artifacts they measured:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
python scripts/bench_loss_layer.py # the ~3900x loss-layer memory number
|
|
94
|
+
python scripts/bench_train_step.py --model mlx-community/Qwen3-8B-4bit --seq-len 1024 2048 \
|
|
95
|
+
--impl kernel --compute-dtype bfloat16 --grad-checkpoint # end-to-end tok/s vs stock
|
|
96
|
+
python scripts/northstar_context_sweep.py # the max-context sweep (1-2 h; heavy)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## License
|
|
100
|
+
|
|
101
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Roadmap
|
|
2
|
+
|
|
3
|
+
## Released
|
|
4
|
+
|
|
5
|
+
### 0.1.0
|
|
6
|
+
- Fused, logit-free linear-cross-entropy Metal kernel (forward), with a chunked pure-MLX
|
|
7
|
+
fallback and a materialized correctness oracle.
|
|
8
|
+
- Quantized (4-bit group-size-64) head support.
|
|
9
|
+
- mlx-lm training adapter, RAM-fit planner, and benchmark harness.
|
|
10
|
+
|
|
11
|
+
## Planned
|
|
12
|
+
|
|
13
|
+
### Memory-efficient attention backward
|
|
14
|
+
This is the highest-leverage item. The measured bottleneck for long-context training on
|
|
15
|
+
MLX is the attention backward, which materializes the `(N, N)` score matrix one layer at
|
|
16
|
+
a time. A tiled, recompute-based attention backward (the FlashAttention backward, written
|
|
17
|
+
as a Metal kernel) is what would actually raise the maximum trainable context on Apple
|
|
18
|
+
Silicon. The fused loss frees the logit memory; this frees the attention memory, and
|
|
19
|
+
together they are what "train much longer sequences" needs.
|
|
20
|
+
|
|
21
|
+
### Fused backward kernel for the loss
|
|
22
|
+
The loss ships with a fused forward and a proven chunked backward. A fully fused backward
|
|
23
|
+
kernel is a further memory reduction for the loss layer itself.
|
|
24
|
+
|
|
25
|
+
### Sequence packing
|
|
26
|
+
Pack variable-length examples into fixed blocks to cut padding waste in SFT training.
|
|
27
|
+
|
|
28
|
+
### Planner inverse queries
|
|
29
|
+
Ask the planner for the largest batch or sequence length that fits a given memory budget,
|
|
30
|
+
instead of checking one config at a time.
|
|
31
|
+
|
|
32
|
+
## Not doing
|
|
33
|
+
- Trainer UX or a training-loop framework. That is the lane of tools like mlx-lm-lora and
|
|
34
|
+
mlx-tune; this project is a layer they can import.
|
|
35
|
+
- Non-Apple-Silicon backends.
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling", "hatch-vcs"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mlx-train-perf"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Fused, logit-free linear-cross-entropy loss, RAM-fit planner, and benchmark harness for MLX fine-tuning on Apple Silicon"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "Denis Ineshin", email = "denis.ineshin@gmail.com" }]
|
|
13
|
+
dependencies = ["mlx>=0.31.2"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Programming Language :: Python :: 3.11",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"Operating System :: MacOS",
|
|
20
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
# transformers>=5.13 breaks `import mlx_lm` outright: mlx_lm/tokenizer_utils.py:505 calls
|
|
25
|
+
# AutoTokenizer.register("NewlineTokenizer", ...) with a string class name, and
|
|
26
|
+
# transformers 5.13's auto_factory.py:680 requires an actual class object there
|
|
27
|
+
# (AttributeError: 'str' object has no attribute '__module__'). Verified clean on
|
|
28
|
+
# 5.0.0/5.4.0/5.8.0/5.11.0/5.12.0; verified broken on 5.13.0.
|
|
29
|
+
mlx-lm = ["mlx-lm>=0.31.3,<0.32", "transformers>=5.0,<5.13"]
|
|
30
|
+
probe = ["pyobjc-framework-Metal"]
|
|
31
|
+
|
|
32
|
+
[project.scripts]
|
|
33
|
+
mlx-train-perf = "mlx_train_perf.cli:main"
|
|
34
|
+
|
|
35
|
+
[tool.hatch.version]
|
|
36
|
+
source = "vcs"
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.sdist]
|
|
39
|
+
# Explicit allowlist: the repo carries gitignored working-state (.superpowers/ SDD
|
|
40
|
+
# ledger, .codegraph/ index, docs/, _artifacts/, CLAUDE.md) that hatchling would
|
|
41
|
+
# otherwise sweep into the sdist. Ship only the source, tests, bench scripts, CI, and
|
|
42
|
+
# the user-facing docs. (The wheel already ships just the `mlx_train_perf` package.)
|
|
43
|
+
include = [
|
|
44
|
+
"/src",
|
|
45
|
+
"/tests",
|
|
46
|
+
"/scripts",
|
|
47
|
+
"/.github",
|
|
48
|
+
"/README.md",
|
|
49
|
+
"/CHANGELOG.md",
|
|
50
|
+
"/ROADMAP.md",
|
|
51
|
+
"/LICENSE",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
[dependency-groups]
|
|
55
|
+
dev = [
|
|
56
|
+
"pytest>=8", "pytest-cov", "hypothesis", "ruff", "mypy", "basedpyright",
|
|
57
|
+
# transformers>=5.13 breaks `import mlx_lm` (see the mlx-lm extra above for the
|
|
58
|
+
# verified-broken/verified-clean version detail); pinned here too since dev installs
|
|
59
|
+
# both groups.
|
|
60
|
+
"mlx-lm>=0.31.3,<0.32", "transformers>=5.0,<5.13", "pyobjc-framework-Metal",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
[tool.ruff]
|
|
64
|
+
line-length = 100
|
|
65
|
+
target-version = "py311"
|
|
66
|
+
|
|
67
|
+
[tool.ruff.lint]
|
|
68
|
+
select = ["E", "F", "W", "I", "N", "UP", "B", "A", "C4", "PT", "RET", "SIM", "ARG", "PTH", "PL", "RUF"]
|
|
69
|
+
ignore = ["PLR0913", "PLR2004"]
|
|
70
|
+
|
|
71
|
+
[tool.ruff.lint.per-file-ignores]
|
|
72
|
+
# Verbatim-ported MSL kernel body (mlx-train-perf-spike/kernel_v2e.py) — line length is
|
|
73
|
+
# inherent to the source text; reformatting would break the byte-for-byte port fidelity
|
|
74
|
+
# the parity cross-check (task-7-report.md) depends on.
|
|
75
|
+
"src/mlx_train_perf/core/kernel/source.py" = ["E501"]
|
|
76
|
+
|
|
77
|
+
[tool.mypy]
|
|
78
|
+
strict = true
|
|
79
|
+
python_version = "3.12"
|
|
80
|
+
|
|
81
|
+
[[tool.mypy.overrides]]
|
|
82
|
+
module = ["mlx_lm.*", "Metal"]
|
|
83
|
+
ignore_missing_imports = true
|
|
84
|
+
|
|
85
|
+
[[tool.mypy.overrides]]
|
|
86
|
+
module = ["numpy", "numpy.*"]
|
|
87
|
+
follow_imports = "skip"
|
|
88
|
+
ignore_missing_imports = true
|
|
89
|
+
|
|
90
|
+
[[tool.mypy.overrides]]
|
|
91
|
+
# `mlx/nn/__init__.py` re-exports its public API via `from .layers import *` with no
|
|
92
|
+
# `__all__` -- under `--strict`'s `no_implicit_reexport`, mypy doesn't treat that as a
|
|
93
|
+
# legitimate re-export, so `nn.Module` / `nn.Linear` / etc. fail as "not defined" / "has
|
|
94
|
+
# no attribute" at every call site (verified: `reveal_type(mlx.nn)` -> a bare
|
|
95
|
+
# `types.ModuleType`, not an error in mlx.nn's own source). `implicit_reexport = true`
|
|
96
|
+
# fixes exactly that resolution gap while still fully attribute-checking `nn.*` (e.g. a
|
|
97
|
+
# typo'd `nn.QuantizedLinaer` is still caught) -- unlike `follow_imports = "skip"`,
|
|
98
|
+
# which would also treat `nn.*` as `Any` and silently swallow that kind of typo.
|
|
99
|
+
module = ["mlx.nn", "mlx.nn.*"]
|
|
100
|
+
implicit_reexport = true
|
|
101
|
+
|
|
102
|
+
[tool.pytest.ini_options]
|
|
103
|
+
addopts = "-ra"
|
|
104
|
+
markers = [
|
|
105
|
+
"metal: needs a Metal GPU + JIT (collection-gated: --run-metal)",
|
|
106
|
+
"smoke: loads a real model (collection-gated: --run-smoke)",
|
|
107
|
+
"benchmark: timing runs (collection-gated: --run-benchmark)",
|
|
108
|
+
"network: touches the network (collection-gated: --run-network)",
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
[tool.coverage.report]
|
|
112
|
+
fail_under = 85
|