lesiondiffusion-mlx 0.2.2__tar.gz → 0.2.4__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 (27) hide show
  1. {lesiondiffusion_mlx-0.2.2 → lesiondiffusion_mlx-0.2.4}/PKG-INFO +11 -32
  2. lesiondiffusion_mlx-0.2.4/README.md +46 -0
  3. {lesiondiffusion_mlx-0.2.2 → lesiondiffusion_mlx-0.2.4}/pyproject.toml +5 -9
  4. {lesiondiffusion_mlx-0.2.2 → lesiondiffusion_mlx-0.2.4}/src/lesiondiffusion_mlx/__init__.py +2 -5
  5. lesiondiffusion_mlx-0.2.4/src/lesiondiffusion_mlx/_attention.py +363 -0
  6. lesiondiffusion_mlx-0.2.4/src/lesiondiffusion_mlx/_group_norm.py +218 -0
  7. lesiondiffusion_mlx-0.2.4/src/lesiondiffusion_mlx/_nax_conv.py +591 -0
  8. lesiondiffusion_mlx-0.2.4/src/lesiondiffusion_mlx/_nifti.py +111 -0
  9. lesiondiffusion_mlx-0.2.4/src/lesiondiffusion_mlx/_reference.py +58 -0
  10. {lesiondiffusion_mlx-0.2.2 → lesiondiffusion_mlx-0.2.4}/src/lesiondiffusion_mlx/_schedules.py +1 -0
  11. {lesiondiffusion_mlx-0.2.2 → lesiondiffusion_mlx-0.2.4}/src/lesiondiffusion_mlx/_source_half.py +2 -0
  12. lesiondiffusion_mlx-0.2.4/src/lesiondiffusion_mlx/_typing.py +178 -0
  13. {lesiondiffusion_mlx-0.2.2 → lesiondiffusion_mlx-0.2.4}/src/lesiondiffusion_mlx/medmlx.py +654 -239
  14. {lesiondiffusion_mlx-0.2.2 → lesiondiffusion_mlx-0.2.4}/src/lesiondiffusion_mlx/mlx_model.py +269 -43
  15. {lesiondiffusion_mlx-0.2.2 → lesiondiffusion_mlx-0.2.4}/src/lesiondiffusion_mlx/mlx_pipeline.py +4 -1
  16. {lesiondiffusion_mlx-0.2.2 → lesiondiffusion_mlx-0.2.4}/src/lesiondiffusion_mlx/mlx_sampling.py +5 -2
  17. lesiondiffusion_mlx-0.2.2/README.md +0 -66
  18. lesiondiffusion_mlx-0.2.2/src/lesiondiffusion_mlx/_nifti.py +0 -73
  19. lesiondiffusion_mlx-0.2.2/src/lesiondiffusion_mlx/_typing.py +0 -110
  20. lesiondiffusion_mlx-0.2.2/src/lesiondiffusion_mlx/mlx_weights.py +0 -216
  21. {lesiondiffusion_mlx-0.2.2 → lesiondiffusion_mlx-0.2.4}/.gitignore +0 -0
  22. {lesiondiffusion_mlx-0.2.2 → lesiondiffusion_mlx-0.2.4}/LICENSE +0 -0
  23. {lesiondiffusion_mlx-0.2.2 → lesiondiffusion_mlx-0.2.4}/THIRD_PARTY_NOTICES.md +0 -0
  24. {lesiondiffusion_mlx-0.2.2 → lesiondiffusion_mlx-0.2.4}/src/lesiondiffusion_mlx/_arrays.py +0 -0
  25. {lesiondiffusion_mlx-0.2.2 → lesiondiffusion_mlx-0.2.4}/src/lesiondiffusion_mlx/_spatial.py +0 -0
  26. {lesiondiffusion_mlx-0.2.2 → lesiondiffusion_mlx-0.2.4}/src/lesiondiffusion_mlx/py.typed +0 -0
  27. {lesiondiffusion_mlx-0.2.2 → lesiondiffusion_mlx-0.2.4}/src/lesiondiffusion_mlx/reference.json +0 -0
@@ -1,21 +1,20 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lesiondiffusion-mlx
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: Native MLX implementation of LesionDiffusion
5
+ Project-URL: Homepage, https://huggingface.co/MedMLX/lesiondiffusion-mlx
5
6
  Author: Joseph Sandoval, Alfredo Sandoval Jr.
6
7
  License-Expression: Apache-2.0 AND MIT AND BSD-3-Clause
7
8
  License-File: LICENSE
8
9
  License-File: THIRD_PARTY_NOTICES.md
9
10
  Classifier: Operating System :: MacOS :: MacOS X
10
11
  Requires-Python: <3.15,>=3.13
11
- Requires-Dist: medmlx-core==0.2.4
12
+ Requires-Dist: medmlx-core==0.2.5
12
13
  Requires-Dist: nibabel<6,>=5.3
13
14
  Requires-Dist: numpy<3,>=2.2
14
15
  Requires-Dist: safetensors<1,>=0.6
15
16
  Requires-Dist: scipy<2,>=1.15
16
17
  Requires-Dist: tokenizers<1,>=0.21
17
- Provides-Extra: conversion
18
- Requires-Dist: torch<3,>=2.13; extra == 'conversion'
19
18
  Provides-Extra: dev
20
19
  Requires-Dist: einops<0.9,>=0.8.1; extra == 'dev'
21
20
  Requires-Dist: hypothesis<7,>=6.100; extra == 'dev'
@@ -35,11 +34,7 @@ Description-Content-Type: text/markdown
35
34
 
36
35
  Add synthetic lesions to CT with native MLX, with controls for organ, size, placement and phenotype.
37
36
 
38
- [Docs](docs/technical.md) · [MedMLX](https://github.com/MedMLX)
39
-
40
- ![Liver CT before and after lesion generation; the contour marks the generated lesion.](https://raw.githubusercontent.com/MedMLX/lesiondiffusion-mlx/main/docs/showcase.png)
41
-
42
- *Liver CT before and after lesion generation; the contour marks the generated lesion.*
37
+ [Model weights](https://huggingface.co/MedMLX/lesiondiffusion-mlx)
43
38
 
44
39
  | Input | Output |
45
40
  | :--- | :--- |
@@ -52,26 +47,7 @@ Add synthetic lesions to CT with native MLX, with controls for organ, size, plac
52
47
  ```bash
53
48
  uv venv --python 3.13
54
49
  source .venv/bin/activate
55
- repo="https://github.com/MedMLX/lesiondiffusion-mlx"
56
- uv pip install \
57
- "lesiondiffusion-mlx[conversion] @ git+$repo" \
58
- huggingface-hub
59
- ```
60
-
61
- Get `LMNet.ckpt`, `LINet.ckpt` and `VQ.ckpt` from the [upstream weights folder](https://drive.google.com/drive/folders/1n6_eAWhsHBFBYtDxPtp_FXp_tNHhOCHi). Convert once, then generate:
62
-
63
- ```python
64
- from pathlib import Path
65
-
66
- from huggingface_hub import hf_hub_download
67
- from lesiondiffusion_mlx import write_conversion
68
-
69
- tokenizer = hf_hub_download("microsoft/BiomedCLIP-PubMedBERT_256-vit_base_patch16_224", "tokenizer.json")
70
- write_conversion(
71
- checkpoints={"lmnet": Path("LMNet.ckpt"), "linet": Path("LINet.ckpt"), "vq": Path("VQ.ckpt")},
72
- tokenizer_path=Path(tokenizer),
73
- output_dir=Path("lesiondiffusion-weights"),
74
- )
50
+ uv pip install "lesiondiffusion-mlx>=0.2.4"
75
51
  ```
76
52
 
77
53
  ```python
@@ -80,7 +56,6 @@ from lesiondiffusion_mlx.medmlx import run
80
56
  result = run(
81
57
  image_path="ct_1mm_ras.nii.gz",
82
58
  organ_mask_path="liver_mask.nii.gz",
83
- weights_path="lesiondiffusion-weights",
84
59
  output_dir="out",
85
60
  organ="liver",
86
61
  lesion_type="tumor",
@@ -90,10 +65,14 @@ result = run(
90
65
  print(result["outputs"]["synthetic_image"])
91
66
  ```
92
67
 
93
- Inputs must support the documented crop sizes. Outputs use a RAS 1 mm grid; voxels outside the lesion can change. Synthetic output is not ground truth.
68
+ Weights download from `MedMLX/lesiondiffusion-mlx` on first use. A local native
69
+ folder can also be passed as `weights_path`; it needs `model.safetensors` and
70
+ `tokenizer.json`. Conversion receipts are not required.
71
+
72
+ Version 0.2.3 accepts `lesion_mask_path` instead of `bbox_size_mm` to inpaint a supplied lesion shape. Inputs must support the documented crop sizes. Outputs use a RAS 1 mm grid; voxels outside the lesion can change. Synthetic output is not ground truth.
94
73
 
95
74
  ## License
96
75
 
97
- [Apache-2.0](LICENSE). See [third-party notices](THIRD_PARTY_NOTICES.md). Model weights retain their publisher terms.
76
+ Apache-2.0; license and third-party notices are included in the distribution. Model weights retain their publisher terms.
98
77
 
99
78
  For research and education. Outputs require human review.
@@ -0,0 +1,46 @@
1
+ # lesiondiffusion-mlx
2
+
3
+ Add synthetic lesions to CT with native MLX, with controls for organ, size, placement and phenotype.
4
+
5
+ [Model weights](https://huggingface.co/MedMLX/lesiondiffusion-mlx)
6
+
7
+ | Input | Output |
8
+ | :--- | :--- |
9
+ | CT + aligned binary organ mask | Synthetic CT and lesion mask |
10
+
11
+ **Apple Silicon · Metal · Python 3.13 / 3.14** · [uv](https://docs.astral.sh/uv/)
12
+
13
+ ## Quick start
14
+
15
+ ```bash
16
+ uv venv --python 3.13
17
+ source .venv/bin/activate
18
+ uv pip install "lesiondiffusion-mlx>=0.2.4"
19
+ ```
20
+
21
+ ```python
22
+ from lesiondiffusion_mlx.medmlx import run
23
+
24
+ result = run(
25
+ image_path="ct_1mm_ras.nii.gz",
26
+ organ_mask_path="liver_mask.nii.gz",
27
+ output_dir="out",
28
+ organ="liver",
29
+ lesion_type="tumor",
30
+ bbox_size_mm=[30, 30, 20],
31
+ placement="inside_parenchyma",
32
+ )
33
+ print(result["outputs"]["synthetic_image"])
34
+ ```
35
+
36
+ Weights download from `MedMLX/lesiondiffusion-mlx` on first use. A local native
37
+ folder can also be passed as `weights_path`; it needs `model.safetensors` and
38
+ `tokenizer.json`. Conversion receipts are not required.
39
+
40
+ Version 0.2.3 accepts `lesion_mask_path` instead of `bbox_size_mm` to inpaint a supplied lesion shape. Inputs must support the documented crop sizes. Outputs use a RAS 1 mm grid; voxels outside the lesion can change. Synthetic output is not ground truth.
41
+
42
+ ## License
43
+
44
+ Apache-2.0; license and third-party notices are included in the distribution. Model weights retain their publisher terms.
45
+
46
+ For research and education. Outputs require human review.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "lesiondiffusion-mlx"
7
- version = "0.2.2"
7
+ version = "0.2.4"
8
8
  description = "Native MLX implementation of LesionDiffusion"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13,<3.15"
@@ -13,7 +13,7 @@ license = "Apache-2.0 AND MIT AND BSD-3-Clause"
13
13
  license-files = ["LICENSE", "THIRD_PARTY_NOTICES.md"]
14
14
  authors = [{ name = "Joseph Sandoval" }, { name = "Alfredo Sandoval Jr." }]
15
15
  dependencies = [
16
- "medmlx-core==0.2.4",
16
+ "medmlx-core==0.2.5",
17
17
  "nibabel>=5.3,<6",
18
18
  "numpy>=2.2,<3",
19
19
  "safetensors>=0.6,<1",
@@ -21,11 +21,13 @@ dependencies = [
21
21
  "tokenizers>=0.21,<1",
22
22
  ]
23
23
 
24
+ [project.urls]
25
+ Homepage = "https://huggingface.co/MedMLX/lesiondiffusion-mlx"
26
+
24
27
  [project.entry-points."medmlx.models"]
25
28
  lesiondiffusion = "lesiondiffusion_mlx.medmlx:MODEL"
26
29
 
27
30
  [project.optional-dependencies]
28
- conversion = ["torch>=2.13,<3"]
29
31
  dev = [
30
32
  "pyright==1.1.414",
31
33
  "pytest>=8.4,<10",
@@ -41,9 +43,6 @@ reference = ["einops==0.8.2", "omegaconf==2.3.1", "transformers==5.19.0"]
41
43
  [tool.lesiondiffusion]
42
44
  reference = "src/lesiondiffusion_mlx/reference.json"
43
45
 
44
- [tool.hatch.metadata]
45
- allow-direct-references = true
46
-
47
46
  [tool.hatch.build.targets.wheel]
48
47
  packages = ["src/lesiondiffusion_mlx"]
49
48
 
@@ -78,9 +77,6 @@ ban-relative-imports = "all"
78
77
  "typing.Any" = {msg = "Use a precise type and parse untyped values at the boundary."}
79
78
  "typing_extensions.Any" = {msg = "Use a precise type and parse untyped values at the boundary."}
80
79
 
81
- [tool.uv.sources]
82
- medmlx-core = { git = "https://github.com/MedMLX/medmlx.git", rev = "f98e0924f50228e2b9509474947b9672054b3217", subdirectory = "packages/core" }
83
-
84
80
  [tool.hatch.build.targets.sdist]
85
81
  only-include = [
86
82
  "src/lesiondiffusion_mlx",
@@ -31,9 +31,8 @@ if TYPE_CHECKING:
31
31
  VqAutoencoder as VqAutoencoder,
32
32
  )
33
33
  from lesiondiffusion_mlx.mlx_sampling import DdimSampler as DdimSampler
34
- from lesiondiffusion_mlx.mlx_weights import write_conversion as write_conversion
35
34
 
36
- __version__ = "0.2.2"
35
+ __version__ = "0.2.4"
37
36
 
38
37
  __all__ = [
39
38
  "LINET_CONFIG",
@@ -45,18 +44,16 @@ __all__ = [
45
44
  "UnetConfig",
46
45
  "VqAutoencoder",
47
46
  "lesion_diffusion_timesteps",
48
- "write_conversion",
49
47
  ]
50
48
 
51
49
 
52
50
  def __getattr__(name: str) -> object:
51
+ """Load a public export from its module on first access."""
53
52
  if name not in __all__:
54
53
  raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
55
54
  module = (
56
55
  "_schedules"
57
56
  if name == "lesion_diffusion_timesteps"
58
- else "mlx_weights"
59
- if name == "write_conversion"
60
57
  else "mlx_sampling"
61
58
  if name == "DdimSampler"
62
59
  else "mlx_model"
@@ -0,0 +1,363 @@
1
+ """FP32 flash attention for 32-wide heads, kept in the projection layout.
2
+
3
+ MLX 0.32 has no fused full-attention kernel for 32-wide FP32 heads. Using its
4
+ 64-wide kernel requires zero-padding heads to 64, doing twice the arithmetic.
5
+ With TF32 disabled (medmlx-core's default, which keeps LMNet in full FP32) that
6
+ padded kernel dominates LMNet's step time. This kernel computes the same
7
+ ``softmax(Q K^T * scale) V`` in FP32 without padding:
8
+
9
+ * Each simdgroup owns 8 query rows; 32-key blocks of K and V are staged in
10
+ threadgroup memory and shared by the threadgroup's simdgroups.
11
+ * ``Q K^T`` and ``P V`` use FP32 ``simdgroup_float8x8`` multiply-accumulate.
12
+ * The online softmax keeps a running row maximum and sum in FP32, using
13
+ ``exp2`` of log2(e)-scaled scores (folded into Q once) with precise exp2.
14
+ Running output rows are rescaled by a diagonal 8x8 matrix multiply.
15
+
16
+ Results agree with MLX's padded FP32 kernel to FP32 rounding; summation order
17
+ differs, so outputs are not bitwise identical.
18
+
19
+ On M5-class GPUs (``nax_available``) a second kernel runs both products on the
20
+ neural accelerators in the exact split-FP16 form of ``_nax_conv``: Q (scaled),
21
+ K, V and each softmax probability ``p`` are split into an FP16 ``hi`` and a
22
+ ``2^11``-scaled FP16 ``lo``; ``hi x hi`` and ``hi x lo + lo x hi`` go to
23
+ separate FP32 accumulators. Only the ``lo x lo`` term is dropped, so errors
24
+ stay at FP32 level (measured below the SIMD kernel's), at three FP16 products
25
+ per FP32 product. Each simdgroup owns 16 query rows; a ``16 x 32`` score tile
26
+ already has the register layout of ``P V``'s left operand, so probabilities
27
+ are split in registers. It runs at about the accelerators' FP16 throughput,
28
+ roughly 1.5x the SIMD kernel on LMNet's 23104-token layers.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import math
34
+ from functools import lru_cache
35
+ from typing import TYPE_CHECKING, cast
36
+
37
+ from lesiondiffusion_mlx._nax_conv import NAX_MMA_HEADER, nax_available
38
+ from lesiondiffusion_mlx._typing import ArrayConstructor
39
+
40
+ if TYPE_CHECKING:
41
+ from lesiondiffusion_mlx._typing import Array, MetalKernel, Mlx
42
+
43
+ _SIMDGROUPS = 4
44
+ _BLOCK = 32 # query rows per threadgroup and keys per staged block
45
+ _NAX_ROWS = 16 # query rows per simdgroup in the NAX kernel
46
+
47
+ _SOURCE = r"""
48
+ const uint lane = thread_index_in_simdgroup;
49
+ const uint sg = simdgroup_index_in_threadgroup;
50
+ const uint tid = sg * 32 + lane;
51
+ const uint b = threadgroup_position_in_grid.y / H, h = threadgroup_position_in_grid.y % H;
52
+ const uint ld = H * 32;
53
+ const uint row0 = threadgroup_position_in_grid.x * (NSG * 8) + sg * 8;
54
+ const device float* Q = q + ulong(b) * L * ld + h * 32;
55
+ const device float* K = k + ulong(b) * LK * ld + h * 32;
56
+ const device float* V = v + ulong(b) * LK * ld + h * 32;
57
+
58
+ threadgroup float Ks[32 * 32];
59
+ threadgroup float Vs[32 * 32];
60
+ threadgroup float Ss[NSG][8 * 32];
61
+ threadgroup float Dg[NSG][64];
62
+
63
+ for (uint i = lane; i < 64; i += 32) Dg[sg][i] = 0.0f;
64
+ const float sc = scale[0] * M_LOG2E_F;
65
+ simdgroup_float8x8 qf[4];
66
+ simdgroup_float8x8 o[4];
67
+ for (short j = 0; j < 4; j++) {
68
+ simdgroup_load(qf[j], Q + ulong(row0) * ld + j * 8, ld);
69
+ qf[j].thread_elements()[0] *= sc;
70
+ qf[j].thread_elements()[1] *= sc;
71
+ o[j] = make_filled_simdgroup_matrix<float, 8, 8>(0.0f);
72
+ }
73
+ // Lane owns row r of the simdgroup's 8 rows and 8 of the block's 32 scores.
74
+ const uint r = lane / 4, c0 = (lane % 4) * 8;
75
+ float m = -INFINITY, l = 0.0f;
76
+
77
+ for (uint kb = 0; kb < LK; kb += 32) {
78
+ threadgroup_barrier(mem_flags::mem_threadgroup);
79
+ for (uint i = tid; i < 32 * 8; i += NSG * 32) {
80
+ const uint kr = i / 8, kc = (i % 8) * 4;
81
+ *reinterpret_cast<threadgroup float4*>(Ks + kr * 32 + kc) =
82
+ *reinterpret_cast<const device float4*>(K + ulong(kb + kr) * ld + kc);
83
+ *reinterpret_cast<threadgroup float4*>(Vs + kr * 32 + kc) =
84
+ *reinterpret_cast<const device float4*>(V + ulong(kb + kr) * ld + kc);
85
+ }
86
+ threadgroup_barrier(mem_flags::mem_threadgroup);
87
+ for (short n = 0; n < 4; n++) {
88
+ simdgroup_float8x8 s = make_filled_simdgroup_matrix<float, 8, 8>(0.0f);
89
+ for (short j = 0; j < 4; j++) {
90
+ simdgroup_float8x8 kt;
91
+ simdgroup_load(kt, Ks + n * 8 * 32 + j * 8, 32, ulong2(0, 0), true);
92
+ simdgroup_multiply_accumulate(s, qf[j], kt, s);
93
+ }
94
+ simdgroup_store(s, Ss[sg] + n * 8, 32);
95
+ }
96
+ simdgroup_barrier(mem_flags::mem_threadgroup);
97
+ threadgroup float* row = Ss[sg] + r * 32 + c0;
98
+ float values[8];
99
+ float local = -INFINITY;
100
+ for (short j = 0; j < 8; j++) { values[j] = row[j]; local = max(local, values[j]); }
101
+ local = max(local, simd_shuffle_xor(local, 1));
102
+ local = max(local, simd_shuffle_xor(local, 2));
103
+ const float m_new = max(m, local);
104
+ const float alpha = metal::precise::exp2(m - m_new);
105
+ float total = 0.0f;
106
+ for (short j = 0; j < 8; j++) {
107
+ const float p = metal::precise::exp2(values[j] - m_new);
108
+ total += p;
109
+ row[j] = p;
110
+ }
111
+ total += simd_shuffle_xor(total, 1);
112
+ total += simd_shuffle_xor(total, 2);
113
+ l = l * alpha + total;
114
+ m = m_new;
115
+ if ((lane % 4) == 0) Dg[sg][r * 9] = alpha;
116
+ simdgroup_barrier(mem_flags::mem_threadgroup);
117
+ simdgroup_float8x8 dg;
118
+ simdgroup_load(dg, Dg[sg], 8);
119
+ for (short d = 0; d < 4; d++) { simdgroup_float8x8 t; simdgroup_multiply(t, dg, o[d]); o[d] = t; }
120
+ for (short kk = 0; kk < 4; kk++) {
121
+ simdgroup_float8x8 pf;
122
+ simdgroup_load(pf, Ss[sg] + kk * 8, 32);
123
+ for (short d = 0; d < 4; d++) {
124
+ simdgroup_float8x8 vf;
125
+ simdgroup_load(vf, Vs + kk * 8 * 32 + d * 8, 32);
126
+ simdgroup_multiply_accumulate(o[d], pf, vf, o[d]);
127
+ }
128
+ }
129
+ }
130
+ simdgroup_barrier(mem_flags::mem_threadgroup);
131
+ if ((lane % 4) == 0) Dg[sg][r * 9] = 1.0f / l;
132
+ simdgroup_barrier(mem_flags::mem_threadgroup);
133
+ simdgroup_float8x8 dg;
134
+ simdgroup_load(dg, Dg[sg], 8);
135
+ for (short d = 0; d < 4; d++) {
136
+ simdgroup_float8x8 t;
137
+ simdgroup_multiply(t, dg, o[d]);
138
+ simdgroup_store(t, out + ulong(b) * L * ld + ulong(row0) * ld + h * 32 + d * 8, ld);
139
+ }
140
+ """
141
+
142
+
143
+ _NAX_SOURCE = r"""
144
+ // Fragment element 4 i + j is row fm + 8 i, column fn + j of a 16x16 tile, for
145
+ // left and right operands and the accumulator alike (see mma_16x32x16).
146
+ const uint lane = thread_index_in_simdgroup;
147
+ const uint sg = simdgroup_index_in_threadgroup;
148
+ const uint tid = sg * 32 + lane;
149
+ const uint qid = lane >> 2;
150
+ const uint fm = (qid & 4) | ((lane >> 1) & 3);
151
+ const uint fn = ((qid & 2) | (lane & 1)) * 4;
152
+ const uint b = threadgroup_position_in_grid.y / H, h = threadgroup_position_in_grid.y % H;
153
+ constexpr uint LD = H * 32;
154
+ const uint row0 = threadgroup_position_in_grid.x * (NSG * 16) + sg * 16;
155
+ const ulong qbase = ulong(b) * L * LD + h * 32;
156
+ const ulong kbase = ulong(b) * LK * LD + h * 32;
157
+
158
+ // Q hi/lo for head dims 0-15 (q*0) and 16-31 (q*1); rows past L repeat the
159
+ // last row and are not stored.
160
+ hfrag qh0, qh1, ql0, ql1;
161
+ for (short i = 0; i < 2; i++) {
162
+ const uint r = min(uint(row0 + fm + 8 * i), uint(L - 1));
163
+ for (short j = 0; j < 4; j++) {
164
+ const ulong at = qbase + ulong(r) * LD + fn + j;
165
+ qh0[4 * i + j] = qh[at]; qh1[4 * i + j] = qh[at + 16];
166
+ ql0[4 * i + j] = ql[at]; ql1[4 * i + j] = ql[at + 16];
167
+ }
168
+ }
169
+ // 32-key blocks: K transposed (head dim major) and V key major, padded rows.
170
+ constexpr uint KP = 40;
171
+ threadgroup half Kh[32 * KP], Kl[32 * KP], Vh[32 * KP], Vl[32 * KP];
172
+ // o: hi x hi output sums; p: 2^11-scaled cross terms.
173
+ facc o0 = facc(0), o1 = facc(0), p0 = facc(0), p1 = facc(0);
174
+ float m[2] = {-INFINITY, -INFINITY}, l[2] = {0.0f, 0.0f};
175
+
176
+ for (uint kb = 0; kb < LK; kb += 32) {
177
+ threadgroup_barrier(mem_flags::mem_threadgroup);
178
+ for (uint e = tid; e < 32 * 8; e += NSG * 32) {
179
+ const uint kr = e / 8, kc = (e % 8) * 4;
180
+ const ulong at = kbase + ulong(kb + kr) * LD + kc;
181
+ const half4 k_h = *reinterpret_cast<const device half4*>(kh + at);
182
+ const half4 k_l = *reinterpret_cast<const device half4*>(kl + at);
183
+ for (short t = 0; t < 4; t++) {
184
+ Kh[(kc + t) * KP + kr] = k_h[t];
185
+ Kl[(kc + t) * KP + kr] = k_l[t];
186
+ }
187
+ *reinterpret_cast<threadgroup half4*>(Vh + kr * KP + kc) =
188
+ *reinterpret_cast<const device half4*>(vh + at);
189
+ *reinterpret_cast<threadgroup half4*>(Vl + kr * KP + kc) =
190
+ *reinterpret_cast<const device half4*>(vl + at);
191
+ }
192
+ threadgroup_barrier(mem_flags::mem_threadgroup);
193
+ // Scores of keys kb + 0-15 (s0) and kb + 16-31 (s1), log2(e)-scaled via Q.
194
+ facc s0 = facc(0), s1 = facc(0), t0 = facc(0), t1 = facc(0);
195
+ for (short dc = 0; dc < 2; dc++) {
196
+ hfrag bh0, bh1, bl0, bl1;
197
+ for (short i = 0; i < 2; i++) {
198
+ const uint d = dc * 16 + fm + 8 * i;
199
+ const half4 x0 = *reinterpret_cast<threadgroup half4*>(Kh + d * KP + fn);
200
+ const half4 x1 = *reinterpret_cast<threadgroup half4*>(Kh + d * KP + 16 + fn);
201
+ const half4 y0 = *reinterpret_cast<threadgroup half4*>(Kl + d * KP + fn);
202
+ const half4 y1 = *reinterpret_cast<threadgroup half4*>(Kl + d * KP + 16 + fn);
203
+ for (short j = 0; j < 4; j++) {
204
+ bh0[4 * i + j] = x0[j]; bh1[4 * i + j] = x1[j];
205
+ bl0[4 * i + j] = y0[j]; bl1[4 * i + j] = y1[j];
206
+ }
207
+ }
208
+ const thread hfrag& a_h = dc == 0 ? qh0 : qh1;
209
+ const thread hfrag& a_l = dc == 0 ? ql0 : ql1;
210
+ mma_16x32x16(s0, s1, a_h, bh0, bh1);
211
+ mma_16x32x16(t0, t1, a_h, bl0, bl1);
212
+ mma_16x32x16(t0, t1, a_l, bh0, bh1);
213
+ }
214
+ s0 += t0 * (1.0f / 2048.0f);
215
+ s1 += t1 * (1.0f / 2048.0f);
216
+ // Online softmax per row fm + 8 i; a row's 32 scores span lanes xor 1 and 8.
217
+ hfrag ph0, ph1, pl0, pl1;
218
+ for (short i = 0; i < 2; i++) {
219
+ float top = -INFINITY;
220
+ for (short j = 0; j < 4; j++) top = max(top, max(s0[4 * i + j], s1[4 * i + j]));
221
+ top = max(top, simd_shuffle_xor(top, 1));
222
+ top = max(top, simd_shuffle_xor(top, 8));
223
+ const float m_new = max(m[i], top);
224
+ const float alpha = metal::precise::exp2(m[i] - m_new);
225
+ float total = 0.0f;
226
+ for (short j = 0; j < 4; j++) {
227
+ const float e0 = metal::precise::exp2(s0[4 * i + j] - m_new);
228
+ const float e1 = metal::precise::exp2(s1[4 * i + j] - m_new);
229
+ total += e0 + e1;
230
+ const half h0 = half(e0), h1 = half(e1);
231
+ ph0[4 * i + j] = h0; ph1[4 * i + j] = h1;
232
+ pl0[4 * i + j] = half((e0 - float(h0)) * 2048.0f);
233
+ pl1[4 * i + j] = half((e1 - float(h1)) * 2048.0f);
234
+ }
235
+ total += simd_shuffle_xor(total, 1);
236
+ total += simd_shuffle_xor(total, 8);
237
+ l[i] = l[i] * alpha + total;
238
+ m[i] = m_new;
239
+ for (short j = 0; j < 4; j++) {
240
+ o0[4 * i + j] *= alpha; o1[4 * i + j] *= alpha;
241
+ p0[4 * i + j] *= alpha; p1[4 * i + j] *= alpha;
242
+ }
243
+ }
244
+ // O += P V over keys kb + 16 kc + 0-15.
245
+ for (short kc = 0; kc < 2; kc++) {
246
+ hfrag vh0, vh1, vl0, vl1;
247
+ for (short i = 0; i < 2; i++) {
248
+ const uint kr = kc * 16 + fm + 8 * i;
249
+ const half4 x0 = *reinterpret_cast<threadgroup half4*>(Vh + kr * KP + fn);
250
+ const half4 x1 = *reinterpret_cast<threadgroup half4*>(Vh + kr * KP + 16 + fn);
251
+ const half4 y0 = *reinterpret_cast<threadgroup half4*>(Vl + kr * KP + fn);
252
+ const half4 y1 = *reinterpret_cast<threadgroup half4*>(Vl + kr * KP + 16 + fn);
253
+ for (short j = 0; j < 4; j++) {
254
+ vh0[4 * i + j] = x0[j]; vh1[4 * i + j] = x1[j];
255
+ vl0[4 * i + j] = y0[j]; vl1[4 * i + j] = y1[j];
256
+ }
257
+ }
258
+ const thread hfrag& a_h = kc == 0 ? ph0 : ph1;
259
+ const thread hfrag& a_l = kc == 0 ? pl0 : pl1;
260
+ mma_16x32x16(o0, o1, a_h, vh0, vh1);
261
+ mma_16x32x16(p0, p1, a_h, vl0, vl1);
262
+ mma_16x32x16(p0, p1, a_l, vh0, vh1);
263
+ }
264
+ }
265
+ for (short i = 0; i < 2; i++) {
266
+ const uint r = row0 + fm + 8 * i;
267
+ if (r >= L) continue;
268
+ const float inv = 1.0f / l[i];
269
+ for (short j = 0; j < 4; j++) {
270
+ const ulong at = qbase + ulong(r) * LD + fn + j;
271
+ out[at] = (o0[4 * i + j] + p0[4 * i + j] * (1.0f / 2048.0f)) * inv;
272
+ out[at + 16] = (o1[4 * i + j] + p1[4 * i + j] * (1.0f / 2048.0f)) * inv;
273
+ }
274
+ }
275
+ """
276
+
277
+
278
+ def flash_eligible(q: Array, k: Array, heads: int, mx: Mlx) -> bool:
279
+ """Whether projections ``(B, L, heads * 32)`` fit the kernel.
280
+
281
+ Requires FP32, 32-wide heads, and query and key lengths divisible by 32.
282
+ """
283
+ return (
284
+ q.dtype == mx.float32
285
+ and k.dtype == mx.float32
286
+ and int(q.shape[-1]) == heads * 32
287
+ and int(k.shape[-1]) == heads * 32
288
+ and int(q.shape[1]) % _BLOCK == 0
289
+ and int(k.shape[1]) % _BLOCK == 0
290
+ )
291
+
292
+
293
+ @lru_cache(maxsize=1)
294
+ def _kernel(mx: Mlx) -> MetalKernel:
295
+ """Build the FP32 SIMD kernel once; used when the NAX kernel is unavailable."""
296
+ return mx.fast.metal_kernel(
297
+ name="lesion_flash_attention_fp32_d32",
298
+ input_names=["q", "k", "v", "scale"],
299
+ output_names=["out"],
300
+ source=_SOURCE,
301
+ )
302
+
303
+
304
+ @lru_cache(maxsize=1)
305
+ def _nax_kernel(mx: Mlx) -> MetalKernel:
306
+ """Build the NAX kernel once; it takes hi/lo FP16 splits of q, k and v (see ``_split``)."""
307
+ return mx.fast.metal_kernel(
308
+ name="lesion_flash_attention_nax_split_d32",
309
+ input_names=["qh", "ql", "kh", "kl", "vh", "vl"],
310
+ output_names=["out"],
311
+ source=_NAX_SOURCE,
312
+ header=NAX_MMA_HEADER,
313
+ )
314
+
315
+
316
+ def _split(x: Array, mx: Mlx) -> tuple[Array, Array]:
317
+ """FP32 ``x`` as FP16 ``hi = half(x)`` and ``lo = half((x - hi) * 2^11)``."""
318
+ hi = x.astype(mx.float16)
319
+ return hi, ((x - hi.astype(mx.float32)) * 2048.0).astype(mx.float16)
320
+
321
+
322
+ def flash_attention(q: Array, k: Array, v: Array, *, heads: int, scale: float, mx: Mlx) -> Array:
323
+ """Multi-head attention on head-interleaved projections.
324
+
325
+ Args:
326
+ q: Queries ``(B, L, heads * 32)``; head ``h`` is columns ``32 h`` to ``32 h + 31``.
327
+ k: Keys ``(B, LK, heads * 32)``.
328
+ v: Values ``(B, LK, heads * 32)``.
329
+ heads: Number of heads.
330
+ scale: Multiplier applied to ``Q K^T`` before the softmax.
331
+ mx: The imported ``mlx.core`` module.
332
+
333
+ Returns:
334
+ ``(B, L, heads * 32)`` FP32 attention output in the same head layout,
335
+ ready for the output projection. On M5-class GPUs the NAX split-FP16
336
+ kernel computes it, which needs ``|q * scale * log2(e)|``, ``|k|`` and
337
+ ``|v|`` below 65504; these ranges are not checked. When ``nax_available``
338
+ is false, the FP32 SIMD kernel computes it.
339
+ """
340
+ batch, length = int(q.shape[0]), int(q.shape[1])
341
+ if nax_available(mx):
342
+ (qh, ql), (kh, kl), (vh, vl) = (
343
+ _split(q * (scale * math.log2(math.e)), mx),
344
+ _split(k, mx),
345
+ _split(v, mx),
346
+ )
347
+ groups = -(-length // (_NAX_ROWS * _SIMDGROUPS))
348
+ return _nax_kernel(mx)(
349
+ inputs=[qh, ql, kh, kl, vh, vl],
350
+ template=[("L", length), ("LK", int(k.shape[1])), ("H", heads), ("NSG", _SIMDGROUPS)],
351
+ grid=(32 * _SIMDGROUPS * groups, batch * heads, 1),
352
+ threadgroup=(32 * _SIMDGROUPS, 1, 1),
353
+ output_shapes=[tuple(q.shape)],
354
+ output_dtypes=[mx.float32],
355
+ )[0]
356
+ return _kernel(mx)(
357
+ inputs=[q, k, v, cast(ArrayConstructor, mx.array)([scale], dtype=mx.float32)],
358
+ template=[("L", length), ("LK", int(k.shape[1])), ("H", heads), ("NSG", _SIMDGROUPS)],
359
+ grid=(32 * _SIMDGROUPS * (length // _BLOCK), batch * heads, 1),
360
+ threadgroup=(32 * _SIMDGROUPS, 1, 1),
361
+ output_shapes=[tuple(q.shape)],
362
+ output_dtypes=[mx.float32],
363
+ )[0]