nbxtensor 0.0.1__py3-none-any.whl

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 (291) hide show
  1. nbxtensor/THIRD_PARTY_NOTICES.md +287 -0
  2. nbxtensor/__init__.py +17 -0
  3. nbxtensor/backend/__init__.py +1 -0
  4. nbxtensor/backend/capabilities.py +207 -0
  5. nbxtensor/backend/cpu/__init__.py +1 -0
  6. nbxtensor/backend/cpu/blas.py +102 -0
  7. nbxtensor/backend/cpu/pool.py +204 -0
  8. nbxtensor/backend/cpu_backend.py +162 -0
  9. nbxtensor/backend/device_facts.py +105 -0
  10. nbxtensor/backend/device_files.py +129 -0
  11. nbxtensor/backend/device_names.py +18 -0
  12. nbxtensor/backend/devices/amd/cdna.yml +228 -0
  13. nbxtensor/backend/devices/amd/cdna2.yml +228 -0
  14. nbxtensor/backend/devices/amd/cdna3.yml +227 -0
  15. nbxtensor/backend/devices/apple/apple_m1.yml +266 -0
  16. nbxtensor/backend/devices/apple/apple_m1_max.yml +266 -0
  17. nbxtensor/backend/devices/apple/apple_m1_pro.yml +266 -0
  18. nbxtensor/backend/devices/apple/apple_m1_ultra.yml +266 -0
  19. nbxtensor/backend/devices/apple/apple_m2.yml +266 -0
  20. nbxtensor/backend/devices/apple/apple_m2_max.yml +266 -0
  21. nbxtensor/backend/devices/apple/apple_m2_pro.yml +266 -0
  22. nbxtensor/backend/devices/apple/apple_m2_ultra.yml +266 -0
  23. nbxtensor/backend/devices/apple/apple_m3.yml +266 -0
  24. nbxtensor/backend/devices/apple/apple_m3_max.yml +266 -0
  25. nbxtensor/backend/devices/apple/apple_m3_pro.yml +266 -0
  26. nbxtensor/backend/devices/apple/apple_m3_ultra.yml +266 -0
  27. nbxtensor/backend/devices/apple/apple_m4.yml +266 -0
  28. nbxtensor/backend/devices/apple/apple_m4_max.yml +266 -0
  29. nbxtensor/backend/devices/apple/apple_m4_pro.yml +308 -0
  30. nbxtensor/backend/devices/apple/apple_m5.yml +269 -0
  31. nbxtensor/backend/devices/apple/apple_m5_max.yml +269 -0
  32. nbxtensor/backend/devices/apple/apple_m5_pro.yml +269 -0
  33. nbxtensor/backend/devices/apple/apple_m5_ultra.yml +269 -0
  34. nbxtensor/backend/devices/apple/apple_m6.yml +269 -0
  35. nbxtensor/backend/devices/apple/apple_silicon.yml +263 -0
  36. nbxtensor/backend/devices/nvidia/ampere.yml +214 -0
  37. nbxtensor/backend/devices/nvidia/hopper.yml +213 -0
  38. nbxtensor/backend/devices/nvidia/volta.yml +411 -0
  39. nbxtensor/backend/faults.py +151 -0
  40. nbxtensor/backend/host_memory.py +185 -0
  41. nbxtensor/backend/launcher.py +2350 -0
  42. nbxtensor/backend/launcher_contract.py +429 -0
  43. nbxtensor/backend/metal_backend.py +661 -0
  44. nbxtensor/backend/metal_device.py +1274 -0
  45. nbxtensor/backend/process_memory.py +73 -0
  46. nbxtensor/backend/replay_graph.py +322 -0
  47. nbxtensor/backend/runtime.py +409 -0
  48. nbxtensor/backend/triton_ext_driver.py +735 -0
  49. nbxtensor/dtype/__init__.py +1 -0
  50. nbxtensor/dtype/convert.py +107 -0
  51. nbxtensor/dtype/dtypes.yml +70 -0
  52. nbxtensor/dtype/itemsize.py +166 -0
  53. nbxtensor/dtype/nbx_dtype.py +72 -0
  54. nbxtensor/dtype/operands.py +158 -0
  55. nbxtensor/dtype/outputs.py +64 -0
  56. nbxtensor/dtype/policy.py +155 -0
  57. nbxtensor/dtype/policy.yml +51 -0
  58. nbxtensor/dtype/promotion.py +112 -0
  59. nbxtensor/host.py +55 -0
  60. nbxtensor/io/__init__.py +1 -0
  61. nbxtensor/io/read_rate.py +197 -0
  62. nbxtensor/io/safetensors.py +314 -0
  63. nbxtensor/memory/__init__.py +9 -0
  64. nbxtensor/memory/allocator.py +1988 -0
  65. nbxtensor/memory/arena.py +75 -0
  66. nbxtensor/memory/device_memory.py +150 -0
  67. nbxtensor/memory/device_transfer.py +154 -0
  68. nbxtensor/memory/layout.py +43 -0
  69. nbxtensor/memory/memory_pool.py +191 -0
  70. nbxtensor/memory/metal.py +96 -0
  71. nbxtensor/memory/oom_advice.py +91 -0
  72. nbxtensor/memory/pointer_tables.py +323 -0
  73. nbxtensor/memory/pool.py +507 -0
  74. nbxtensor/memory/shadow.py +43 -0
  75. nbxtensor/memory/slab.py +273 -0
  76. nbxtensor/memory/trace_replay.py +122 -0
  77. nbxtensor/memory/traces/sana-video-2b-720p.16g.2026-11-02.tsv +13610 -0
  78. nbxtensor/memory/vmm.py +118 -0
  79. nbxtensor/ops/__init__.py +1 -0
  80. nbxtensor/ops/_autotune_policy.py +90 -0
  81. nbxtensor/ops/_common.py +32 -0
  82. nbxtensor/ops/_configs.py +1041 -0
  83. nbxtensor/ops/_cost.py +231 -0
  84. nbxtensor/ops/abs.py +17 -0
  85. nbxtensor/ops/adaptive_avg_pool2d.py +161 -0
  86. nbxtensor/ops/add.py +96 -0
  87. nbxtensor/ops/addcdiv.py +29 -0
  88. nbxtensor/ops/addcmul.py +29 -0
  89. nbxtensor/ops/addmv_op.py +64 -0
  90. nbxtensor/ops/addr.py +68 -0
  91. nbxtensor/ops/all_reduce.py +83 -0
  92. nbxtensor/ops/amax.py +40 -0
  93. nbxtensor/ops/any_reduce.py +83 -0
  94. nbxtensor/ops/arange_op.py +27 -0
  95. nbxtensor/ops/argmax.py +107 -0
  96. nbxtensor/ops/argmin.py +93 -0
  97. nbxtensor/ops/atan2.py +71 -0
  98. nbxtensor/ops/attention_wrappers.py +1648 -0
  99. nbxtensor/ops/autotune_bucket.py +137 -0
  100. nbxtensor/ops/autotune_cache.py +430 -0
  101. nbxtensor/ops/autotune_certified.py +1262 -0
  102. nbxtensor/ops/autotune_refusals.py +380 -0
  103. nbxtensor/ops/autotune_settings.py +58 -0
  104. nbxtensor/ops/avg_pool2d.py +73 -0
  105. nbxtensor/ops/baddbmm_op.py +132 -0
  106. nbxtensor/ops/batch_norm.py +163 -0
  107. nbxtensor/ops/binary_strided.py +196 -0
  108. nbxtensor/ops/bitwise_and.py +26 -0
  109. nbxtensor/ops/bitwise_left_shift.py +47 -0
  110. nbxtensor/ops/bitwise_not.py +24 -0
  111. nbxtensor/ops/bitwise_or.py +26 -0
  112. nbxtensor/ops/bitwise_right_shift.py +47 -0
  113. nbxtensor/ops/bucketize.py +37 -0
  114. nbxtensor/ops/cat_op.py +87 -0
  115. nbxtensor/ops/celu.py +20 -0
  116. nbxtensor/ops/clamp.py +24 -0
  117. nbxtensor/ops/clamp_min.py +22 -0
  118. nbxtensor/ops/conv2d.py +265 -0
  119. nbxtensor/ops/conv2d_m8n8k4.py +165 -0
  120. nbxtensor/ops/conv_depthwise2d.py +154 -0
  121. nbxtensor/ops/conv_transpose2d.py +148 -0
  122. nbxtensor/ops/conv_wrappers.py +1482 -0
  123. nbxtensor/ops/copy.py +18 -0
  124. nbxtensor/ops/copy_op.py +39 -0
  125. nbxtensor/ops/cos.py +17 -0
  126. nbxtensor/ops/count_nonzero.py +64 -0
  127. nbxtensor/ops/cross_entropy.py +67 -0
  128. nbxtensor/ops/cummax.py +109 -0
  129. nbxtensor/ops/cummin.py +105 -0
  130. nbxtensor/ops/cumsum.py +244 -0
  131. nbxtensor/ops/decode_attn_vec.py +162 -0
  132. nbxtensor/ops/depthwise_conv2d.py +132 -0
  133. nbxtensor/ops/dequant_gemv.py +190 -0
  134. nbxtensor/ops/dft.py +67 -0
  135. nbxtensor/ops/dispatch.py +1228 -0
  136. nbxtensor/ops/div.py +75 -0
  137. nbxtensor/ops/dot_op.py +67 -0
  138. nbxtensor/ops/dropout.py +23 -0
  139. nbxtensor/ops/dtype_convert.py +42 -0
  140. nbxtensor/ops/elementwise_launch.py +81 -0
  141. nbxtensor/ops/elementwise_wrappers.py +2315 -0
  142. nbxtensor/ops/elu.py +20 -0
  143. nbxtensor/ops/embedding.py +69 -0
  144. nbxtensor/ops/eq.py +32 -0
  145. nbxtensor/ops/erf.py +18 -0
  146. nbxtensor/ops/exp.py +17 -0
  147. nbxtensor/ops/exp2.py +18 -0
  148. nbxtensor/ops/fft_op.py +125 -0
  149. nbxtensor/ops/fill_op.py +20 -0
  150. nbxtensor/ops/flash_attention.py +307 -0
  151. nbxtensor/ops/flash_attention_m8n8k4.py +384 -0
  152. nbxtensor/ops/flash_decode.py +78 -0
  153. nbxtensor/ops/flip_op.py +60 -0
  154. nbxtensor/ops/floor.py +90 -0
  155. nbxtensor/ops/full_op.py +24 -0
  156. nbxtensor/ops/fused_moe.py +417 -0
  157. nbxtensor/ops/fused_moe_m8n8k4.py +99 -0
  158. nbxtensor/ops/fused_upsample_conv.py +413 -0
  159. nbxtensor/ops/gather_op.py +52 -0
  160. nbxtensor/ops/ge.py +32 -0
  161. nbxtensor/ops/gelu.py +31 -0
  162. nbxtensor/ops/gemv_vec.py +80 -0
  163. nbxtensor/ops/glu.py +43 -0
  164. nbxtensor/ops/grid_sampler.py +145 -0
  165. nbxtensor/ops/group_norm_tiled.py +67 -0
  166. nbxtensor/ops/groupnorm.py +123 -0
  167. nbxtensor/ops/gt.py +32 -0
  168. nbxtensor/ops/hardsigmoid.py +18 -0
  169. nbxtensor/ops/hardswish.py +19 -0
  170. nbxtensor/ops/im2col.py +57 -0
  171. nbxtensor/ops/index_add.py +95 -0
  172. nbxtensor/ops/index_put_op.py +92 -0
  173. nbxtensor/ops/index_select.py +121 -0
  174. nbxtensor/ops/index_wrappers.py +770 -0
  175. nbxtensor/ops/isfinite.py +20 -0
  176. nbxtensor/ops/isinf.py +20 -0
  177. nbxtensor/ops/isnan.py +18 -0
  178. nbxtensor/ops/launch_keys.py +1054 -0
  179. nbxtensor/ops/launch_observer.py +54 -0
  180. nbxtensor/ops/layernorm.py +71 -0
  181. nbxtensor/ops/le.py +35 -0
  182. nbxtensor/ops/leaky_relu.py +19 -0
  183. nbxtensor/ops/lerp.py +76 -0
  184. nbxtensor/ops/linalg_wrappers.py +1370 -0
  185. nbxtensor/ops/linspace_op.py +47 -0
  186. nbxtensor/ops/log.py +17 -0
  187. nbxtensor/ops/log_sigmoid.py +23 -0
  188. nbxtensor/ops/logical_and.py +26 -0
  189. nbxtensor/ops/logical_not.py +24 -0
  190. nbxtensor/ops/logical_or.py +26 -0
  191. nbxtensor/ops/logical_xor.py +37 -0
  192. nbxtensor/ops/loss_wrappers.py +282 -0
  193. nbxtensor/ops/lt.py +32 -0
  194. nbxtensor/ops/masked_fill.py +21 -0
  195. nbxtensor/ops/matmul.py +377 -0
  196. nbxtensor/ops/matmul_m8n8k4.py +235 -0
  197. nbxtensor/ops/max_pool2d.py +74 -0
  198. nbxtensor/ops/max_pool3d.py +93 -0
  199. nbxtensor/ops/max_reduce.py +91 -0
  200. nbxtensor/ops/maximum.py +18 -0
  201. nbxtensor/ops/mean.py +40 -0
  202. nbxtensor/ops/min_reduce.py +91 -0
  203. nbxtensor/ops/minimum.py +18 -0
  204. nbxtensor/ops/mish.py +22 -0
  205. nbxtensor/ops/mma_m8n8k4.py +152 -0
  206. nbxtensor/ops/moe_align.py +195 -0
  207. nbxtensor/ops/moe_decode_vec.py +374 -0
  208. nbxtensor/ops/moe_device_residency.py +141 -0
  209. nbxtensor/ops/moe_wrappers.py +345 -0
  210. nbxtensor/ops/mse_loss.py +79 -0
  211. nbxtensor/ops/mul.py +51 -0
  212. nbxtensor/ops/nan_to_num.py +23 -0
  213. nbxtensor/ops/ne.py +32 -0
  214. nbxtensor/ops/neg.py +17 -0
  215. nbxtensor/ops/nllloss.py +124 -0
  216. nbxtensor/ops/nonzero.py +33 -0
  217. nbxtensor/ops/nonzero_op.py +62 -0
  218. nbxtensor/ops/norm_wrappers.py +579 -0
  219. nbxtensor/ops/ones_op.py +20 -0
  220. nbxtensor/ops/oracles/__init__.py +6 -0
  221. nbxtensor/ops/oracles/conv2d_fp64.py +450 -0
  222. nbxtensor/ops/pad_op.py +159 -0
  223. nbxtensor/ops/paged_decode_attn.py +215 -0
  224. nbxtensor/ops/pixel_shuffle.py +144 -0
  225. nbxtensor/ops/pixel_unshuffle.py +72 -0
  226. nbxtensor/ops/pow.py +137 -0
  227. nbxtensor/ops/prod.py +81 -0
  228. nbxtensor/ops/rand_op.py +52 -0
  229. nbxtensor/ops/random_wrappers.py +366 -0
  230. nbxtensor/ops/reciprocal.py +17 -0
  231. nbxtensor/ops/reduction_wrappers.py +845 -0
  232. nbxtensor/ops/relu.py +18 -0
  233. nbxtensor/ops/remainder.py +46 -0
  234. nbxtensor/ops/repeat_interleave.py +48 -0
  235. nbxtensor/ops/repeat_op.py +132 -0
  236. nbxtensor/ops/residual_chain.py +374 -0
  237. nbxtensor/ops/rmsnorm.py +68 -0
  238. nbxtensor/ops/rope.py +142 -0
  239. nbxtensor/ops/rsqrt.py +17 -0
  240. nbxtensor/ops/scaled_softmax.py +96 -0
  241. nbxtensor/ops/scatter_op.py +139 -0
  242. nbxtensor/ops/screen_oracle.py +479 -0
  243. nbxtensor/ops/selu.py +22 -0
  244. nbxtensor/ops/shape_wrappers.py +1032 -0
  245. nbxtensor/ops/sigmoid.py +19 -0
  246. nbxtensor/ops/silu.py +27 -0
  247. nbxtensor/ops/sin.py +17 -0
  248. nbxtensor/ops/softmax.py +169 -0
  249. nbxtensor/ops/softplus.py +27 -0
  250. nbxtensor/ops/sort_op.py +304 -0
  251. nbxtensor/ops/spectral_wrappers.py +481 -0
  252. nbxtensor/ops/sqrt.py +17 -0
  253. nbxtensor/ops/stack_op.py +84 -0
  254. nbxtensor/ops/std.py +113 -0
  255. nbxtensor/ops/strided_copy.py +200 -0
  256. nbxtensor/ops/sub.py +35 -0
  257. nbxtensor/ops/sum.py +40 -0
  258. nbxtensor/ops/tan.py +19 -0
  259. nbxtensor/ops/tanh.py +21 -0
  260. nbxtensor/ops/threshold.py +19 -0
  261. nbxtensor/ops/tiling.yml +91 -0
  262. nbxtensor/ops/tiling_policy.py +43 -0
  263. nbxtensor/ops/tiling_sizes.py +370 -0
  264. nbxtensor/ops/topk.py +267 -0
  265. nbxtensor/ops/trace_op.py +64 -0
  266. nbxtensor/ops/tril.py +64 -0
  267. nbxtensor/ops/triu.py +62 -0
  268. nbxtensor/ops/unfold_op.py +37 -0
  269. nbxtensor/ops/unique_op.py +97 -0
  270. nbxtensor/ops/upsample_bilinear2d.py +106 -0
  271. nbxtensor/ops/upsample_nearest2d.py +52 -0
  272. nbxtensor/ops/upsample_nearest3d.py +61 -0
  273. nbxtensor/ops/var.py +136 -0
  274. nbxtensor/ops/vector_norm.py +216 -0
  275. nbxtensor/ops/weight_norm.py +104 -0
  276. nbxtensor/ops/where.py +39 -0
  277. nbxtensor/ops/wrappers.py +825 -0
  278. nbxtensor/ops/zeros_op.py +20 -0
  279. nbxtensor/quant/__init__.py +1 -0
  280. nbxtensor/quant/quantized_tensor.py +187 -0
  281. nbxtensor/random/__init__.py +1 -0
  282. nbxtensor/random/mt_randn.py +154 -0
  283. nbxtensor/random/rng_pin.py +59 -0
  284. nbxtensor/random/rng_stream.py +118 -0
  285. nbxtensor/random/seeded_draw.py +223 -0
  286. nbxtensor/shape/__init__.py +157 -0
  287. nbxtensor/tensor.py +1867 -0
  288. nbxtensor-0.0.1.dist-info/METADATA +379 -0
  289. nbxtensor-0.0.1.dist-info/RECORD +291 -0
  290. nbxtensor-0.0.1.dist-info/WHEEL +4 -0
  291. nbxtensor-0.0.1.dist-info/licenses/LICENSE +189 -0
@@ -0,0 +1,661 @@
1
+ """Triton-on-Metal backend detection and gate (Apple Silicon).
2
+
3
+ `--triton` / `--triton-sequential` on an Apple GPU require an out-of-tree
4
+ Triton backend that targets Metal. We do NOT bundle, auto-fetch or vendor
5
+ it — the same doctrine `cpu_backend.py` applies to `triton-cpu`, and for
6
+ the same reasons: auto-fetching a wheel is a supply-chain surface the user
7
+ did not consent to, and air-gapped installs fail with a confusing network
8
+ error instead of a clear one.
9
+
10
+ Why an external backend rather than our own (2026-09-03 decision, sourced
11
+ in ``docs/internal/metal_scoping_2026_09_03.md``):
12
+
13
+ * Upstream Triton has no Metal target and nobody is building one there —
14
+ issue #4824 has sat open since 2024-09-28 with no maintainer engagement.
15
+ * Triton 3.7 (2026-07) shipped a plugin-extension system, so a new target
16
+ is an out-of-tree backend loaded at runtime, **not a fork**. R25's
17
+ "no internal fork" reading is satisfied by construction.
18
+ * `bledden/triton-msl` already implements that backend under MIT, aligned
19
+ to Triton 3.7, lowering TTGIR to Metal Shading Language. It reports zero
20
+ silent-wrong results across upstream's own `test_core.py`, and refuses
21
+ what it cannot lower rather than computing something wrong — which is
22
+ this engine's Zero-Fallback doctrine arrived at independently.
23
+
24
+ Our own `@triton.jit` kernels are portable **as source** because R33 kept
25
+ `torch` out of the triton tree; what is not portable is the CUDA runtime
26
+ binding in `kernels/nbx_tensor.py`. That is the one real port, and it is
27
+ tracked as the coupling point in the adoption plan.
28
+
29
+ The corpus is **280 kernels across 160 modules**, counted by parsing
30
+ `kernels/ops/` on 2026-09-05. The 424/161 figure carried by the adoption
31
+ plan and the scoping study is a `grep` count: 144 of those occurrences are
32
+ module docstrings saying "pure `@triton.jit` kernel", and one is a comment
33
+ saying a helper is *never* `@triton.jit`.
34
+
35
+ R33 preserved — nothing here imports torch, even at the boundary.
36
+ R34 preserved — nothing here is keyed on a model name.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import importlib.util
42
+ import os
43
+ import platform
44
+ import shutil
45
+ import subprocess
46
+
47
+
48
+ # --- Known coverage gaps of the Metal backend -------------------------------
49
+ # Marker constants read by the dispatcher, so a future workstream flips them in
50
+ # ONE place when the upstream gap closes. Same pattern as the triton-cpu
51
+ # markers. Sourced from the backend's published refusal list, 2026-09-03;
52
+ # none of them is measured by us — the compile census in the adoption plan is
53
+ # what replaces a README with a fact.
54
+
55
+ # Batched 3-D matmul is not implemented upstream. This is the gap that blocks
56
+ # us: `baddbmm` is the largest single item in our own prefill split (6.2 s of
57
+ # 21 s), so attention prefill does not run without it. It is also the kernel
58
+ # class this project has spent the year on — closing it upstream is the
59
+ # contribution named in the adoption plan.
60
+ #
61
+ # MEASURED 2026-09-05 on an M4 Pro, and the shape is narrower and wider than
62
+ # the README said (validation_outputs/metal_first_light_2026_09_05/):
63
+ # * narrower — plain `matmul.py::matmul_kernel` LOWERS cleanly. It is not
64
+ # "matmul on Metal" that fails.
65
+ # * wider — the refusal is the fused-epilogue template declining a `tt.dot`
66
+ # inside an `scf.for` K-loop that carries a trailing compute epilogue, so
67
+ # it takes `matmul.py::addmm_kernel` as well as `baddbmm_op.py`. addmm was
68
+ # on nobody's gap list.
69
+ # Both refuse LOUDLY (`MetalNonRecoverableError`), which is the contract
70
+ # working.
71
+ TRITON_METAL_BATCHED_MATMUL_BLOCKED: bool = True
72
+
73
+ # bf16 inputs are refused by the backend's FlashAttention path.
74
+ TRITON_METAL_BF16_ATTENTION_BLOCKED: bool = True
75
+
76
+ # Metal GPUs have NO double precision. The prediction written here was that
77
+ # `tl.float64` "cannot be lowered at all" and that the device-scalar kernels
78
+ # in kernels/ops/{add,mul,div}.py — which widen through f64 to stay bit-exact
79
+ # with the host path — would therefore be refused.
80
+ #
81
+ # MEASURED 2026-09-05, and the prediction was wrong in the direction that
82
+ # matters. All three kernels compile through the MSL stage with NO refusal.
83
+ # The f64 survives Triton's own middle end intact (`arith.extf f32 to f64`,
84
+ # `arith.mulf f64`, `arith.truncf f64 to f32` are all present in the TTGIR)
85
+ # and it is the backend's MSL lowering that drops it, emitting
86
+ # `float val = static_cast<float>(s_ptr[0]); float r = val * alpha;`.
87
+ # Silent narrowing, not a loud refusal.
88
+ #
89
+ # For these three kernels that costs nothing: a product of two f32 values is
90
+ # exactly representable in f64, so widening then narrowing round-trips. Checked
91
+ # rather than argued — 20,000,000 random pairs plus subnormals and overflow
92
+ # cases, every one bit-identical. `mul`/`div` are a bare `.to(f64).to(f32)`
93
+ # round trip and are no-ops by inspection.
94
+ #
95
+ # The hardware fact stands and so does this flag. What changed is the failure
96
+ # MODE to expect: any FUTURE f64 expression with more than one rounding —
97
+ # an accumulation, a division, a sum of three terms — would be narrowed just
98
+ # as silently, and there would be no refusal to catch it.
99
+ TRITON_METAL_FP64_UNAVAILABLE: bool = True
100
+
101
+
102
+ # The Metal backend's compile pipeline ends in `xcrun metal` + `xcrun metallib`
103
+ # (MSL -> AIR -> metallib), so it needs Apple's OFFLINE shader compiler. That
104
+ # compiler is not part of the Command Line Tools: on macOS 26 / Xcode 26 it is
105
+ # an on-demand Xcode component installed with
106
+ # `sudo xcodebuild -downloadComponent MetalToolchain`, and `xcodebuild` itself
107
+ # refuses to run under a Command-Line-Tools-only developer directory.
108
+ #
109
+ # MEASURED 2026-09-05 on an M4 Pro with CLT and no Xcode:
110
+ # * the Metal FRAMEWORK compiles MSL at runtime perfectly well —
111
+ # `newLibraryWithSource:` builds a pipeline that dispatches and returns
112
+ # the right numbers. So this is NOT a platform limitation.
113
+ # * triton-msl's driver `is_active()` probes `xcrun --find metal` and
114
+ # returns False without it, so Triton reports ZERO active drivers and
115
+ # cannot name a target at all. Nothing compiles and nothing runs.
116
+ # * compilation with an EXPLICIT target still reaches the `msl` stage and
117
+ # fails only at `metallib` — which is what let the compile census run.
118
+ TRITON_METAL_NEEDS_OFFLINE_SHADER_COMPILER: bool = True
119
+
120
+
121
+ class TritonMetalNotInstalledError(ImportError):
122
+ """Raised when `--triton` is invoked on an Apple GPU and no Metal Triton
123
+ backend is installed.
124
+
125
+ The message is deliberately actionable: what is missing, the install
126
+ command, and the alternative that already works today.
127
+ """
128
+
129
+
130
+ class TritonMetalShaderCompilerMissingError(TritonMetalNotInstalledError):
131
+ """Raised when the Metal backend IS installed but Apple's offline shader
132
+ compiler is not, so nothing it compiles can be built.
133
+
134
+ A subclass, so every existing call site that catches
135
+ `TritonMetalNotInstalledError` keeps working while the message stays
136
+ specific. It is a separate condition because the remedy is completely
137
+ different — a ~700 MB Xcode component, not a pip install — and because
138
+ a package check alone reports "ready" on a machine where nothing runs.
139
+ """
140
+
141
+
142
+ def is_apple_silicon() -> bool:
143
+ """True on an Apple-Silicon Mac."""
144
+ return platform.system() == "Darwin" and platform.machine() == "arm64"
145
+
146
+
147
+ class BackendSelectionRefused(RuntimeError):
148
+ """This seam refused to choose a Metal backend, and said why.
149
+
150
+ A NAMED type because callers must be able to tell this refusal from "this
151
+ machine has no Triton target at all", and telling them apart by message text
152
+ would make every rewording a silent behaviour change.
153
+
154
+ Measured 2026-09-17, which is why it exists: `ops/_configs.arch_smem_budget`
155
+ wrapped its target resolution in `except Exception: return None`. The moment
156
+ the Apple profile declared a backend that venv did not have, this refusal was
157
+ swallowed, the whole hardware profile resolved EMPTY and was cached empty —
158
+ smem budget, screen bytes and the declared backend all silently unread — and
159
+ the next refusal then reported "the profile declares none" about a profile
160
+ that declares one. A refusal that is swallowed is worse than no refusal: it
161
+ turns a loud, correct stop into a quiet wrong answer.
162
+ """
163
+
164
+
165
+ def triton_metal_available() -> bool:
166
+ """Probe for a Metal Triton backend without importing it.
167
+
168
+ Accepts either the standalone package or a backend registered into
169
+ Triton's plugin system, because the plugin path (Triton 3.7+) is how a
170
+ third-party target is expected to arrive and the package name is not
171
+ guaranteed to be the import name. It recognises triton-ext's AppleGPU
172
+ plugin, which registers as the `apple` backend. The archived fork
173
+ (`triton_msl`) was probed here until 2026-09-17; it is no longer
174
+ selectable, so answering True for it would report a Metal path the engine
175
+ cannot take. No engine file names a vendor; this one seam does.
176
+ """
177
+ for module in ("triton.backends.metal",):
178
+ try:
179
+ if importlib.util.find_spec(module) is not None:
180
+ return True
181
+ except (ImportError, ValueError):
182
+ continue
183
+ # triton-ext's AppleGPU backend registers into Triton's plugin registry
184
+ # under the name `apple` rather than as an importable `triton.backends.metal`.
185
+ try:
186
+ from triton.backends import backends as _tb
187
+ if "apple" in (_tb.keys() if hasattr(_tb, "keys") else _tb):
188
+ return True
189
+ except Exception:
190
+ pass
191
+ # A plugin can also be pointed at by the upstream env var.
192
+ plugins = os.environ.get("TRITON_PLUGIN_PATHS", "")
193
+ return any("metal" in p.lower() or "apple" in p.lower()
194
+ for p in plugins.split(os.pathsep) if p)
195
+
196
+
197
+ def backend_refusal_types() -> tuple:
198
+ """The exception types a Metal backend raises to say 'I cannot compile this
199
+ config correctly' — collected here so the SHARED refusal module
200
+ (`autotune_refusals.py`, which the Dell also runs) names no vendor.
201
+
202
+ Empty off Apple / when no Metal backend is installed — so on CUDA the
203
+ shared check is exactly as inert as before this seam existed. A backend
204
+ absent contributes nothing rather than raising.
205
+
206
+ **EMPTY ON APPLE TOO, TODAY, AND THAT IS A MEASUREMENT.** The archived
207
+ fork named `triton_msl.errors.MetalNonRecoverableError`; triton-ext names
208
+ nothing. Checked 2026-09-17 in the installed package: it has no `errors`
209
+ module, and the only exception classes it defines are
210
+ `driver.OutOfResources` (Triton's own) and `device_assert.DeviceAssertError`.
211
+ Its compiler says 'I cannot emit this' with a bare `RuntimeError`.
212
+
213
+ The consequence, stated rather than papered over: `exclude_refused_configs`
214
+ scores a refusal `inf` and keeps sweeping, and RE-RAISES anything else, so
215
+ a config triton-ext refuses ends the sweep instead of being excluded. This
216
+ is NOT new with the archive — the fork's type could never match an
217
+ exception triton-ext raised — and no sweep here has hit it yet. Matching on
218
+ the message text is refused on purpose: it would make every reworded
219
+ refusal a run-ending error, silently. The fix belongs upstream, as a named
220
+ refusal type in triton-ext, and is recorded as an upstream candidate.
221
+ """
222
+ types: list = []
223
+ # triton-ext (AppleGPU) refusal type, if/when it exposes one by name.
224
+ try:
225
+ from triton_apple_backend.errors import AppleGPUNonRecoverableError # type: ignore
226
+ types.append(AppleGPUNonRecoverableError)
227
+ except Exception:
228
+ pass
229
+ return tuple(types)
230
+
231
+
232
+ def is_backend_refusal(exc: BaseException) -> bool:
233
+ """True when `exc` is a Metal backend saying it cannot compile this config —
234
+ asked by class against whichever backend(s) are present, naming none in the
235
+ caller. False (never raising) when no Metal backend is installed."""
236
+ rt = backend_refusal_types()
237
+ return bool(rt) and isinstance(exc, rt)
238
+
239
+
240
+ def metal_target():
241
+ """The Triton target for this machine, without activating a driver.
242
+
243
+ Asking `triton.runtime.driver.active` would make Triton probe every
244
+ registered backend, and upstream's AMD probe does `import torch` in its
245
+ `is_active()` — torch in our process for a card that is not there. The
246
+ target is built from the Metal device instead, which is where the answer
247
+ lives anyway.
248
+ """
249
+ from triton.backends.compiler import GPUTarget
250
+
251
+ from nbxtensor.backend.metal_device import runtime
252
+
253
+ # The target NAME belongs to the selected backend; this module owns
254
+ # which. It lived in the fork's driver until the fork was archived
255
+ # (2026-09-17) — wrongly, because triton-ext's driver calls it too.
256
+ return GPUTarget(backend_target_name(), runtime().arch_name, 32)
257
+
258
+
259
+ #: The Metal backends NeuroBrix can target. The engine targets Triton; WHICH
260
+ #: Metal backend runs is a selection, not a branch, and this table is the only
261
+ #: place either is named. `probe` is the import that proves it is installed;
262
+ #: `compiler` is the dotted path to its Triton backend class (the thing a driver
263
+ #: needs to patch a stage).
264
+ METAL_BACKENDS = {
265
+ # The bledden triton-msl fork was a row here until 2026-09-17. It is
266
+ # ARCHIVED, not deleted: it stays in this repository's history and in
267
+ # ~/Workspace/nbx-atelier/clones/triton-msl-src as reference and
268
+ # inspiration. It is not selectable, so no profile can declare it and no
269
+ # run can reach it. The table keeps its shape because it is the seam:
270
+ # adding a backend is adding a row, and that has not changed.
271
+ "triton_ext": {
272
+ "probe": "triton_apple_backend",
273
+ "compiler": ("triton_apple_backend.compiler", "MetalBackend"),
274
+ "target": "mps",
275
+ # NONE YET. triton-ext emits MSL from C++ MLIR passes with its own
276
+ # argument conventions and ships its own driver; our fork-shaped driver
277
+ # mis-binds its kernels (measured 2026-09-16: scalars after the first
278
+ # arrive as 0, so every mask is false and the output keeps its zeros).
279
+ # Until an adapter exists, selecting this backend REFUSES at the driver
280
+ # rather than launching through an ABI that is not its own.
281
+ "nbx_driver": "nbxtensor.backend.triton_ext_driver",
282
+ "what": "triton-lang/triton-ext AppleGPU (C++ MLIR -> MSL)",
283
+ },
284
+ }
285
+
286
+ #: What a backend says when it CANNOT emit and silently computes elsewhere.
287
+ #: Only the seam names a vendor; the launcher asks for the list.
288
+ _FALLBACK_MARKERS = {
289
+ # EMPTY BY MEASUREMENT, not by omission. Searched 2026-09-17 in the
290
+ # installed triton_apple_backend: its Python sources, its Objective-C
291
+ # (metal_native.m, metal_torch.mm) and the strings of its compiled
292
+ # libapplegpu_backend.dylib carry no "fall back", "falling back" or
293
+ # "on cpu" message of any kind. This backend does not silently compute
294
+ # elsewhere; it refuses. The archived fork did (its marker was
295
+ # "Kernel will fall back to CPU"), which is why this table exists.
296
+ #
297
+ # So `launcher.prepare`'s warning capture currently has nothing to match,
298
+ # and that is a statement about the backend rather than a gate nobody
299
+ # armed. A backend that CAN fall back adds its marker here, and the door
300
+ # arms itself.
301
+ "triton_ext": (),
302
+ }
303
+
304
+
305
+ def backend_fallback_markers():
306
+ """Phrases that mean the selected backend did not emit for the device.
307
+
308
+ A backend that warns and computes on the CPU returns numbers, so nothing
309
+ fails — the caller gets an answer from hardware it did not ask for.
310
+ Measured 2026-09-17: fifteen tests in trunk were red for exactly this, the
311
+ fork refusing batched MMA and falling back, and the engine accepting it.
312
+ """
313
+ try:
314
+ return _FALLBACK_MARKERS.get(selected_metal_backend(), ())
315
+ except Exception: # noqa: BLE001
316
+ return ()
317
+
318
+
319
+ _PROFILE_KEY = "metal_backend"
320
+ _ENV_KEY = "NEUROBRIX_METAL_BACKEND"
321
+
322
+
323
+ def _installed(name: str) -> bool:
324
+ spec = METAL_BACKENDS.get(name)
325
+ if not spec:
326
+ return False
327
+ try:
328
+ return importlib.util.find_spec(spec["probe"]) is not None
329
+ except (ImportError, ValueError):
330
+ return False
331
+
332
+
333
+ def backend_module(name: str) -> str:
334
+ """The top-level module Metal backend `name` is installed as (what an application inspects to judge where the
335
+ installation stands — NeuroBrix refuses one on storage its machine clears, `core.paths`)."""
336
+ return METAL_BACKENDS[name]["probe"].split(".")[0]
337
+
338
+
339
+ def _declare_selected(name: str) -> str:
340
+ """The selection DECLARES to nbx_tensor what the selected launcher proved: on triton_ext a
341
+ loaded address reads correctly through its pinned scope
342
+ (test_the_moe_table_reads_through_a_pinned_scope, 2026-09-20); any other backend answers
343
+ False until it proves its own lifetime contract. nbx_tensor is a library with a boundary and
344
+ asks the engine nothing — until 2026-09-26 it imported this module to ask, and its ratchet
345
+ cell was red on main. Every selection (the launcher's driver seam, the autotune key
346
+ resolution, the refusal reader) passes through `selected_metal_backend` before a kernel is
347
+ launched, so the declaration precedes the first launch."""
348
+ from nbxtensor.backend.capabilities import declare_backend_capability
349
+ declare_backend_capability("metal", loads_pointers_from_memory=(name == "triton_ext"))
350
+ return name
351
+
352
+
353
+ def selected_metal_backend() -> str:
354
+ """WHICH Metal backend this machine runs, decided by the PROFILE.
355
+
356
+ The profile may declare `metal_backend: triton_ext`. A declared backend that
357
+ is not installed is REFUSED BY NAME — silently running a different one would
358
+ make a measurement attribute itself to the wrong implementation, which is the
359
+ whole reason this is a selection and not a branch. The archived fork
360
+ (`triton_msl`) is not a known name any more, so a profile still declaring it
361
+ is refused too, and told so. When the profile declares nothing, the single
362
+ installed backend is
363
+ used; if both are installed and none is declared, that ambiguity is refused
364
+ too — a machine that can run either must say which.
365
+ """
366
+ # An explicit operator override, above the profile and named in the run's
367
+ # own environment. It exists for one real case: evaluating a SECOND backend
368
+ # from a different virtualenv that shares this source tree, so the profile
369
+ # file cannot hold both answers at once. It is still a SELECTION — declared,
370
+ # refused by name when absent — never a silent fallback.
371
+ declared = os.environ.get(_ENV_KEY)
372
+ if not declared:
373
+ # Read the declaration WITHOUT `active_vendor_profile()`, which resolves
374
+ # the profile through `launcher.target()` — and the target's backend NAME
375
+ # comes from this very function. Profile needs target, target needs
376
+ # backend, backend needs profile.
377
+ #
378
+ # Measured 2026-09-17: on a cold process that cycle meant the declaration
379
+ # was never read at all. The inner call came back empty, `declared`
380
+ # became None, and this function fell through to "the single installed
381
+ # backend" — INFERRING what the profile was there to tell it. It agreed
382
+ # with the declaration only by luck, while exactly one backend happened
383
+ # to be installed. With a declared backend absent from the venv it also
384
+ # made this function's own refusal lie: "the profile declares none",
385
+ # about a profile whose line 370 declares one.
386
+ #
387
+ # The cycle is spurious. A profile is matched on the ARCH, and the arch
388
+ # is the DEVICE's, not the backend's, so `vendor_profile_for_arch` takes
389
+ # the Metal device's name and asks for nothing else.
390
+ try:
391
+ from nbxtensor.backend.metal_device import runtime
392
+ from nbxtensor.ops._configs import vendor_profile_for_arch
393
+ declared = (vendor_profile_for_arch(runtime().arch_name) or {}).get(_PROFILE_KEY)
394
+ except Exception:
395
+ declared = None
396
+
397
+ if declared:
398
+ if declared not in METAL_BACKENDS:
399
+ raise BackendSelectionRefused(
400
+ f"the hardware profile declares `{_PROFILE_KEY}: {declared}`, which is "
401
+ f"not a Metal backend this engine knows. Known: "
402
+ f"{', '.join(sorted(METAL_BACKENDS))}.")
403
+ if not _installed(declared):
404
+ raise BackendSelectionRefused(
405
+ f"the hardware profile declares `{_PROFILE_KEY}: {declared}` "
406
+ f"({METAL_BACKENDS[declared]['what']}) and it is NOT installed "
407
+ f"(no module {METAL_BACKENDS[declared]['probe']!r}). Refusing rather "
408
+ f"than running on the other backend and attributing the numbers to "
409
+ f"the declared one. Install it, or change the profile.")
410
+ return _declare_selected(declared)
411
+
412
+ present = [n for n in METAL_BACKENDS if _installed(n)]
413
+ if not present:
414
+ raise BackendSelectionRefused(
415
+ "no Metal backend is installed (looked for "
416
+ + ", ".join(f"{n} ({METAL_BACKENDS[n]['probe']})" for n in METAL_BACKENDS)
417
+ + "), and the profile declares none.")
418
+ if len(present) > 1:
419
+ raise BackendSelectionRefused(
420
+ f"both Metal backends are installed ({', '.join(present)}) and the "
421
+ f"profile declares no `{_PROFILE_KEY}`. A machine that can run either "
422
+ f"must say which, or its measurements cannot name the backend that "
423
+ f"produced them.")
424
+ return _declare_selected(present[0])
425
+
426
+
427
+ def backend_target_name() -> str:
428
+ """The Triton GPUTarget backend NAME the selected implementation answers to.
429
+
430
+ Triton resolves a backend by asking each registered one `supports_target`,
431
+ which compares this string. The two Metal backends do not use the same one —
432
+ the fork answers to `metal`, triton-ext's AppleGPU to `mps` — so a driver
433
+ that hardcodes either can only ever reach one of them ("0 compatible
434
+ backends for target (metal)"). The name is a property of the selected
435
+ backend, so it lives in the table with it.
436
+ """
437
+ return METAL_BACKENDS[selected_metal_backend()]["target"]
438
+
439
+
440
+ def backend_compiler_class():
441
+ """The selected backend's Triton backend class — the object a driver patches.
442
+
443
+ Raises with the backend NAMED when it is selected but its compiler cannot be
444
+ imported, so a driver never silently does nothing.
445
+ """
446
+ name = selected_metal_backend()
447
+ mod_name, attr = METAL_BACKENDS[name]["compiler"]
448
+ try:
449
+ mod = importlib.import_module(mod_name)
450
+ except Exception as exc: # noqa: BLE001
451
+ raise BackendSelectionRefused(
452
+ f"the Metal backend in force is {name!r} ({METAL_BACKENDS[name]['what']}) "
453
+ f"but its compiler module {mod_name!r} could not be imported ({exc}).")
454
+ cls = getattr(mod, attr, None)
455
+ if cls is None:
456
+ raise BackendSelectionRefused(
457
+ f"the Metal backend in force is {name!r} but {mod_name}.{attr} does not "
458
+ f"exist — this engine cannot patch a stage it cannot name.")
459
+ return cls
460
+
461
+
462
+ def metal_shader_compiler_available() -> bool:
463
+ """True when Apple's offline shader compiler (`xcrun metal`) can be run.
464
+
465
+ The SAME probe the backend's own driver uses for `is_active()`, so this
466
+ answers the question that actually decides whether anything runs, rather
467
+ than a question that merely correlates with it.
468
+
469
+ Why `xcrun --find metal` and not `shutil.which("metal")`: the compiler is
470
+ not on `PATH`, it is resolved by `xcrun` inside the active developer
471
+ directory. And why not trust `xcode-select -p`: Command Line Tools give a
472
+ valid developer directory and still have no `metal` in it, which is
473
+ exactly the machine this was written on.
474
+
475
+ False on any non-Darwin host, and False rather than raising if `xcrun` is
476
+ missing entirely — a probe that takes the run down is not a probe.
477
+ """
478
+ if platform.system() != "Darwin":
479
+ return False
480
+ if shutil.which("xcrun") is None:
481
+ return False
482
+ # EXECUTE `metal --version`, not just `--find`: the Xcode default-toolchain
483
+ # shim is FOUND even when it cannot run (Xcode 26/27 delegation defect), so
484
+ # `--find` said yes on a machine where nothing compiled. `_metal_runs`
485
+ # answers the question the compile pipeline actually asks. (Callers that
486
+ # need the delegation fix applied first call `ensure_metal_toolchain_selected`.)
487
+ return _metal_runs()
488
+
489
+
490
+ _DELEGATION_ANNOUNCED = False
491
+
492
+
493
+ def _metal_runs(env_extra: "dict | None" = None) -> bool:
494
+ """True when `xcrun metal` can actually EXECUTE (not merely be found).
495
+
496
+ `xcrun --find metal` resolves the Xcode default-toolchain SHIM, which is
497
+ present even when it cannot run — so the find-probe says yes on a machine
498
+ where nothing compiles. This runs `metal --version`, which is what the
499
+ compile pipeline needs."""
500
+ import os as _os
501
+ env = dict(_os.environ, **(env_extra or {}))
502
+ try:
503
+ return subprocess.run(["xcrun", "metal", "--version"], env=env,
504
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
505
+ timeout=20).returncode == 0
506
+ except (OSError, subprocess.SubprocessError):
507
+ return False
508
+
509
+
510
+ def ensure_metal_toolchain_selected() -> None:
511
+ """Work around Xcode 26/27's broken default-toolchain delegation, once.
512
+
513
+ macOS 26 / Xcode 26+ ship the Metal compiler as a separate on-demand
514
+ toolchain (`com.apple.dt.toolchain.Metal`). Once downloaded it is mounted
515
+ and its `metal` binary runs — but Xcode's DEFAULT-toolchain shim does not
516
+ delegate to it, so a bare `xcrun metal` fails with "missing Metal Toolchain"
517
+ even though the toolchain is installed. Selecting it explicitly
518
+ (`TOOLCHAINS=Metal`) resolves it. This is a recognised Apple defect since
519
+ Xcode 26; every user on 26 or 27 meets it, so the backend detects and fixes
520
+ it here rather than leaving each user to discover `TOOLCHAINS` alone.
521
+
522
+ A no-op off Apple Silicon, when a TOOLCHAINS is already set, or when the
523
+ bare compiler already runs. Announced ONCE per process — a fix repeated on
524
+ every kernel is noise nobody reads."""
525
+ global _DELEGATION_ANNOUNCED
526
+ if not is_apple_silicon():
527
+ return
528
+ import os as _os
529
+ if _os.environ.get("TOOLCHAINS"):
530
+ return # the caller already chose
531
+ if _metal_runs():
532
+ return # delegation is fine here
533
+ if _metal_runs({"TOOLCHAINS": "Metal"}):
534
+ _os.environ["TOOLCHAINS"] = "Metal"
535
+ if not _DELEGATION_ANNOUNCED:
536
+ _DELEGATION_ANNOUNCED = True
537
+ print("[metal] Xcode's default toolchain does not delegate to the "
538
+ "installed Metal compiler (a known Apple defect since Xcode "
539
+ "26); selecting it with TOOLCHAINS=Metal for this process.",
540
+ flush=True)
541
+
542
+
543
+ def ensure_triton_metal_or_raise() -> None:
544
+ """Verify a Metal Triton backend is present before the triton path starts.
545
+
546
+ Call sites mirror `ensure_triton_cpu_or_raise`: the `--triton` /
547
+ `--triton-sequential` entry points, before any triton import that would
548
+ otherwise produce a cryptic `triton.runtime.driver.active` error.
549
+
550
+ A no-op off Apple Silicon.
551
+
552
+ Two conditions, not one. The package being importable was the whole gate
553
+ until 2026-09-05, when the first Apple machine showed that it says "ready"
554
+ on a Mac where nothing can run: with Command Line Tools but no Metal
555
+ Toolchain the backend imports fine, this gate passed, and the run then
556
+ died several steps later inside Triton's driver with "0 active drivers".
557
+ That is precisely the `cpu_backend.py` defect this file was written to
558
+ stop, reappearing one layer down.
559
+
560
+ Raises:
561
+ TritonMetalNotInstalledError: no Metal backend at all — naming the
562
+ install, the doctrine (we never auto-fetch), and the `--compiled`
563
+ path that already runs on Apple GPUs through MPS today.
564
+ TritonMetalShaderCompilerMissingError: the backend is installed but
565
+ Apple's offline shader compiler is not, so it can compile nothing.
566
+ """
567
+ if not is_apple_silicon():
568
+ return
569
+
570
+ # Fix Xcode 26/27's broken toolchain delegation BEFORE probing the compiler,
571
+ # so the probe sees the compiler that will actually run.
572
+ ensure_metal_toolchain_selected()
573
+
574
+ if not triton_metal_available():
575
+ raise TritonMetalNotInstalledError(
576
+ "The --triton engine needs a Triton backend that targets Metal, and "
577
+ "none is installed.\n"
578
+ "\n"
579
+ "Upstream Triton has no Apple GPU target. An out-of-tree backend "
580
+ "exists:\n"
581
+ " pip install triton-msl (MIT; needs Triton 3.7+ built "
582
+ "with TRITON_EXT_ENABLED=1)\n"
583
+ "\n"
584
+ "NeuroBrix does not install it for you: fetching a wheel at runtime "
585
+ "is a\n"
586
+ "supply-chain surface you did not consent to, and it fails "
587
+ "confusingly on\n"
588
+ "air-gapped machines.\n"
589
+ "\n"
590
+ "Working alternative today: drop --triton. The default compiled "
591
+ "engine runs\n"
592
+ "on Apple GPUs through PyTorch MPS with no extra install.\n"
593
+ "\n"
594
+ "Status and known gaps: docs/internal/metal_adoption_plan_2026_09_03.md"
595
+ )
596
+
597
+ if not metal_shader_compiler_available():
598
+ raise TritonMetalShaderCompilerMissingError(
599
+ "A Triton Metal backend is installed, but Apple's offline shader "
600
+ "compiler is not,\n"
601
+ "so nothing it compiles can be built and the Metal target will "
602
+ "not even activate.\n"
603
+ "\n"
604
+ "Measured on an M4 Pro, 2026-09-05: without `xcrun metal` the "
605
+ "backend's driver\n"
606
+ "reports itself inactive, Triton then finds ZERO active drivers, "
607
+ "and the run dies\n"
608
+ "with 'Backend device metal is not active' well after this "
609
+ "point. That late,\n"
610
+ "cryptic death is the exact failure this gate exists to replace.\n"
611
+ "\n"
612
+ "The compiler is NOT part of the Command Line Tools. On macOS 26 "
613
+ "/ Xcode 26 it is\n"
614
+ "a separate on-demand component:\n"
615
+ " sudo xcodebuild -downloadComponent MetalToolchain "
616
+ "(~700 MB)\n"
617
+ "and `xcodebuild` requires a full Xcode: under a Command-Line-"
618
+ "Tools-only developer\n"
619
+ "directory it refuses to run. Apple publishes a standalone Metal "
620
+ "toolchain for\n"
621
+ "Windows only, so on macOS there is no route that avoids Xcode.\n"
622
+ "Verify with `xcrun metal --version`.\n"
623
+ "\n"
624
+ "Note this is a property of THIS backend's compile path, not of "
625
+ "Metal: the Metal\n"
626
+ "framework compiles shader source at runtime with no Xcode "
627
+ "present.\n"
628
+ "\n"
629
+ "Working alternative today: drop --triton. The default compiled "
630
+ "engine runs\n"
631
+ "on Apple GPUs through PyTorch MPS with no extra install.\n"
632
+ "\n"
633
+ "Status and known gaps: docs/internal/metal_adoption_plan_2026_09_03.md"
634
+ )
635
+
636
+
637
+
638
+ def nbx_driver_module() -> str:
639
+ """The library's launcher driver module implementing the SELECTED backend's
640
+ launch ABI, or a refusal naming why there is none.
641
+
642
+ A driver is not interchangeable between Metal backends: ours reads the
643
+ fork's MSL emission conventions to decide how each scalar is bound. Handing
644
+ it a kernel another emitter produced binds the wrong things silently —
645
+ measured, and the failure is not loud: scalars after the first arrive as 0,
646
+ every mask is false, and the kernel writes nothing, so the output keeps the
647
+ zeros it was allocated with.
648
+ """
649
+ name = selected_metal_backend()
650
+ mod = METAL_BACKENDS[name].get("nbx_driver")
651
+ if mod:
652
+ return mod
653
+ raise BackendSelectionRefused(
654
+ f"the Metal backend in force is {name!r} ({METAL_BACKENDS[name]['what']}), "
655
+ f"and NeuroBrix has no launcher driver implementing its launch ABI. "
656
+ f"Refusing to launch through another backend's driver: ours derives its "
657
+ f"argument binding from the fork's MSL conventions, and on a kernel this "
658
+ f"backend compiled that binds scalars to the wrong places WITHOUT failing "
659
+ f"— the kernel reads zeros and writes nothing. Correct-or-refuse: an "
660
+ f"adapter for {name!r} is owed before it can be launched."
661
+ )