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.
Files changed (71) hide show
  1. mlx_train_perf-0.1.0/.github/workflows/ci.yml +48 -0
  2. mlx_train_perf-0.1.0/.github/workflows/publish.yml +34 -0
  3. mlx_train_perf-0.1.0/.gitignore +8 -0
  4. mlx_train_perf-0.1.0/CHANGELOG.md +41 -0
  5. mlx_train_perf-0.1.0/LICENSE +21 -0
  6. mlx_train_perf-0.1.0/PKG-INFO +123 -0
  7. mlx_train_perf-0.1.0/README.md +101 -0
  8. mlx_train_perf-0.1.0/ROADMAP.md +35 -0
  9. mlx_train_perf-0.1.0/pyproject.toml +112 -0
  10. mlx_train_perf-0.1.0/scripts/bench_backward_ladder.py +602 -0
  11. mlx_train_perf-0.1.0/scripts/bench_loss_layer.py +176 -0
  12. mlx_train_perf-0.1.0/scripts/bench_quant_thresholds.py +237 -0
  13. mlx_train_perf-0.1.0/scripts/bench_train_step.py +257 -0
  14. mlx_train_perf-0.1.0/scripts/fit_calibration.py +161 -0
  15. mlx_train_perf-0.1.0/scripts/ground_truth_atomic_outputs.py +385 -0
  16. mlx_train_perf-0.1.0/scripts/northstar_context_sweep.py +264 -0
  17. mlx_train_perf-0.1.0/src/mlx_train_perf/__init__.py +12 -0
  18. mlx_train_perf-0.1.0/src/mlx_train_perf/_compat.py +21 -0
  19. mlx_train_perf-0.1.0/src/mlx_train_perf/adapters/__init__.py +0 -0
  20. mlx_train_perf-0.1.0/src/mlx_train_perf/adapters/mlx_lm.py +214 -0
  21. mlx_train_perf-0.1.0/src/mlx_train_perf/bench/__init__.py +0 -0
  22. mlx_train_perf-0.1.0/src/mlx_train_perf/bench/artifacts.py +153 -0
  23. mlx_train_perf-0.1.0/src/mlx_train_perf/bench/runner.py +161 -0
  24. mlx_train_perf-0.1.0/src/mlx_train_perf/bench/worker.py +450 -0
  25. mlx_train_perf-0.1.0/src/mlx_train_perf/cli.py +265 -0
  26. mlx_train_perf-0.1.0/src/mlx_train_perf/core/__init__.py +0 -0
  27. mlx_train_perf-0.1.0/src/mlx_train_perf/core/chunked.py +183 -0
  28. mlx_train_perf-0.1.0/src/mlx_train_perf/core/guards.py +38 -0
  29. mlx_train_perf-0.1.0/src/mlx_train_perf/core/kernel/__init__.py +0 -0
  30. mlx_train_perf-0.1.0/src/mlx_train_perf/core/kernel/dispatch.py +25 -0
  31. mlx_train_perf-0.1.0/src/mlx_train_perf/core/kernel/launch.py +522 -0
  32. mlx_train_perf-0.1.0/src/mlx_train_perf/core/kernel/source.py +702 -0
  33. mlx_train_perf-0.1.0/src/mlx_train_perf/core/loss.py +392 -0
  34. mlx_train_perf-0.1.0/src/mlx_train_perf/core/naive.py +14 -0
  35. mlx_train_perf-0.1.0/src/mlx_train_perf/devtools/__init__.py +0 -0
  36. mlx_train_perf-0.1.0/src/mlx_train_perf/devtools/regpressure.py +279 -0
  37. mlx_train_perf-0.1.0/src/mlx_train_perf/errors.py +49 -0
  38. mlx_train_perf-0.1.0/src/mlx_train_perf/plan/__init__.py +0 -0
  39. mlx_train_perf-0.1.0/src/mlx_train_perf/plan/calibration.py +69 -0
  40. mlx_train_perf-0.1.0/src/mlx_train_perf/plan/calibration_data.json +16 -0
  41. mlx_train_perf-0.1.0/src/mlx_train_perf/plan/estimate.py +405 -0
  42. mlx_train_perf-0.1.0/src/mlx_train_perf/py.typed +0 -0
  43. mlx_train_perf-0.1.0/tests/conftest.py +37 -0
  44. mlx_train_perf-0.1.0/tests/test_adapter.py +345 -0
  45. mlx_train_perf-0.1.0/tests/test_bench_backward_ladder.py +257 -0
  46. mlx_train_perf-0.1.0/tests/test_bench_loss_layer.py +158 -0
  47. mlx_train_perf-0.1.0/tests/test_bench_resume.py +431 -0
  48. mlx_train_perf-0.1.0/tests/test_bench_train_step.py +303 -0
  49. mlx_train_perf-0.1.0/tests/test_chunked.py +187 -0
  50. mlx_train_perf-0.1.0/tests/test_cli.py +337 -0
  51. mlx_train_perf-0.1.0/tests/test_compat.py +30 -0
  52. mlx_train_perf-0.1.0/tests/test_devtools.py +213 -0
  53. mlx_train_perf-0.1.0/tests/test_fit_calibration.py +268 -0
  54. mlx_train_perf-0.1.0/tests/test_ground_truth_script.py +101 -0
  55. mlx_train_perf-0.1.0/tests/test_guards.py +42 -0
  56. mlx_train_perf-0.1.0/tests/test_kernel_backward_parity.py +320 -0
  57. mlx_train_perf-0.1.0/tests/test_kernel_dispatch.py +33 -0
  58. mlx_train_perf-0.1.0/tests/test_kernel_guard.py +101 -0
  59. mlx_train_perf-0.1.0/tests/test_kernel_launch_calibration.py +114 -0
  60. mlx_train_perf-0.1.0/tests/test_kernel_parity.py +54 -0
  61. mlx_train_perf-0.1.0/tests/test_kernel_quant_parity.py +97 -0
  62. mlx_train_perf-0.1.0/tests/test_kernel_quant_validation.py +22 -0
  63. mlx_train_perf-0.1.0/tests/test_kernel_source.py +131 -0
  64. mlx_train_perf-0.1.0/tests/test_loss_api.py +398 -0
  65. mlx_train_perf-0.1.0/tests/test_loss_compile.py +105 -0
  66. mlx_train_perf-0.1.0/tests/test_markers.py +14 -0
  67. mlx_train_perf-0.1.0/tests/test_naive.py +35 -0
  68. mlx_train_perf-0.1.0/tests/test_northstar_context_sweep.py +308 -0
  69. mlx_train_perf-0.1.0/tests/test_plan.py +481 -0
  70. mlx_train_perf-0.1.0/tests/test_quant_layout.py +16 -0
  71. 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,8 @@
1
+ /CLAUDE.md
2
+ docs/superpowers/
3
+ .venv/
4
+ __pycache__/
5
+ *.egg-info/
6
+ .coverage
7
+ dist/
8
+ _artifacts/
@@ -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