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,1274 @@
1
+ """The Metal device runtime, behind the seam `DeviceAllocator` already owns.
2
+
3
+ Step 5 of the Metal adoption plan. `cuda` and `hip` are two symbol tables over
4
+ one C ABI, so `_GPU_BACKENDS` can hold both as rows that differ only by name.
5
+ **Metal cannot be a third row** — it is an Objective-C API with `MTLDevice`
6
+ and `MTLBuffer` and there is no `metalMalloc` to name — and
7
+ `test_device_backend_seam.py` pins exactly that, both in its contract
8
+ assertion (`set(backends) == {"cuda", "hip"}`) and in its own docstring:
9
+ *"it will be a second implementation behind this class rather than a third
10
+ row in the dict."*
11
+
12
+ This module is that second implementation. It presents the **call contract**
13
+ the seam already uses — a runtime object whose entry points take `ctypes`
14
+ arguments, write results through `ctypes.byref`, and return an integer status
15
+ — so every one of the 49 runtime touches inside `DeviceAllocator` works
16
+ unchanged. The ctypes shape is the seam's existing convention, not a pretence
17
+ that Metal has a C ABI.
18
+
19
+ ## What unified memory deletes
20
+
21
+ Apple Silicon has one physical memory shared by CPU and GPU, so a buffer made
22
+ with `MTLResourceStorageModeShared` has a single address that is valid from
23
+ both. `contents()` hands that address back, and it is the same address a
24
+ kernel receives. Three consequences, and they are simplifications rather than
25
+ work:
26
+
27
+ * **H2D and D2H do not exist.** All four `cudaMemcpy` kinds are one byte copy
28
+ between two addresses in the same address space.
29
+ * **Pinned host memory does not exist either**, because there is no DMA to
30
+ pin for. `malloc_host` returns a shared buffer, which genuinely satisfies
31
+ what `cudaMallocHost` is for: a host address the device can read with no
32
+ copy. That is a correct mapping, not a fallback.
33
+ * **Peer access does not apply.** `MTLCopyAllDevices()` returns one device on
34
+ this hardware, and the key is simply absent, which makes
35
+ `DeviceAllocator.enable_peer_access` answer False — true, not silent.
36
+
37
+ ## Zero fallback
38
+
39
+ Every failure is loud. If Metal is absent, `runtime()` raises. If a buffer
40
+ cannot be allocated, the entry point returns a non-zero status and
41
+ `DeviceAllocator.malloc_cuda` raises its OOM with full diagnostics. Nothing
42
+ here ever hands back host memory pretending it is device memory, and nothing
43
+ degrades to CPU.
44
+
45
+ What Metal has no honest equivalent for is **omitted rather than stubbed**:
46
+ events, `memcpy_async`, the stream-ordered allocator. The seam already turns
47
+ a missing key into an explicit
48
+ `RuntimeError("... unsupported on backend 'metal'")`, so omission is the
49
+ loud refusal and a no-op stub would have been the silent wrong.
50
+
51
+ R33 preserved — no torch, at the boundary included.
52
+ R34 preserved — nothing here is keyed on a model name.
53
+ """
54
+
55
+ from __future__ import annotations
56
+
57
+ import bisect
58
+ import ctypes
59
+ import os
60
+ import threading
61
+ from typing import Dict, Optional, Tuple
62
+ import nbxtensor.memory.allocator as _nbx_allocator # the module the moved names live in (M2 cut of tensor.py)
63
+ import nbxtensor.backend.runtime as _nbx_runtime # the module the moved names live in (M2 cut of tensor.py)
64
+
65
+ # Status codes. 0 is success, mirroring the C runtimes the seam was written
66
+ # against; the non-zero values are ours and appear only in NeuroBrix errors.
67
+ _OK = 0
68
+ _ERR_ALLOC = 1
69
+ _ERR_BAD_DEVICE = 2
70
+ _ERR_UNKNOWN_POINTER = 3
71
+ _ERR_BAD_ARGUMENT = 4
72
+
73
+
74
+
75
+ class _MappedView:
76
+ """The registry's view slot for a buffer over a file mapping: it keeps the ctypes region the buffer was
77
+ made from; nothing to release (the mapping's owner unmaps it; the MTLBuffer is dropped with the entry)."""
78
+
79
+ def __init__(self, region=None):
80
+ self.region = region
81
+
82
+ def release(self):
83
+ pass
84
+
85
+ class MetalUnavailableError(RuntimeError):
86
+ """Metal, or its Python bindings, are not usable on this machine.
87
+
88
+ Raised rather than returned so that no caller can mistake absence for an
89
+ empty device list and quietly continue on the CPU.
90
+ """
91
+
92
+
93
+ def _drop_base(bases: list, address: int) -> None:
94
+ """Remove one base from the sorted index, in log time.
95
+
96
+ A freed base left in the index would let a later interior lookup resolve
97
+ into memory the allocator no longer owns — the exact opposite of what the
98
+ ownership rule is for.
99
+ """
100
+ index = bisect.bisect_left(bases, address)
101
+ if index < len(bases) and bases[index] == address:
102
+ del bases[index]
103
+
104
+
105
+ def _deref(arg):
106
+ """The object behind a `ctypes.byref(...)` out-parameter.
107
+
108
+ The seam passes out-parameters exactly as it does to a C runtime. A
109
+ `CArgObject` carries the object it references as `_obj`; a real pointer
110
+ carries `contents`. Both are accepted so this works whichever the caller
111
+ used.
112
+ """
113
+ if hasattr(arg, "_obj"):
114
+ return arg._obj
115
+ if hasattr(arg, "contents"):
116
+ return arg.contents
117
+ raise TypeError(f"not an out-parameter: {type(arg).__name__}")
118
+
119
+
120
+ def _declared_dispatch_fact(key: str) -> int:
121
+ """A `dispatch.<key>` the device file must state, positive — refused by name otherwise (ZERO FALLBACK: it was 32 / 512
122
+ written here under a bare `except`, the Dell's reading 2026-11-05 01:20; every Apple device file declares both)."""
123
+ from nbxtensor.ops.wrappers import _arch_dispatch_param
124
+ value = _arch_dispatch_param(key, None)
125
+ if value is None or int(value) <= 0:
126
+ raise RuntimeError(f"ZERO FALLBACK: the device file states no positive dispatch.{key} ({value!r}) — the Metal runtime "
127
+ f"does not invent one; declare it in nbxtensor/backend/devices/apple/<arch>.yml")
128
+ return int(value)
129
+
130
+
131
+ def _as_int(value) -> int:
132
+ """A Python int from a ctypes scalar or a plain int."""
133
+ return int(getattr(value, "value", value) or 0)
134
+
135
+
136
+ #: The `new*` selectors this runtime calls through pyobjc. Every one returns an object the caller
137
+ #: OWNS (+1, the Cocoa "new" family — for instance methods too, per clang's ownership rules).
138
+ _NEW_FAMILY_SELECTORS = (b"newBufferWithLength:options:", b"newCommandQueue", b"newSharedEvent")
139
+ _NEW_FAMILY_DECLARED = False
140
+
141
+
142
+ def declare_new_family_ownership() -> None:
143
+ """Tell pyobjc that the `new*` Metal selectors return an already-retained object.
144
+
145
+ pyobjc up to 12.2.2 applies the "new" ownership rule to class methods only, treats the +1
146
+ result of an instance method such as `-[MTLDevice newBufferWithLength:options:]` as
147
+ autoreleased, retains it again, and never releases that retain: EVERY MTLBuffer this runtime
148
+ ever made outlived its free (upstream: ronaldoussoren/pyobjc#690, fixed in pyobjc 12.2.3,
149
+ not yet published when this was written; dipy/dipy#4190 met the same leak). Measured
150
+ 2026-09-26 on an M4 Pro with pyobjc 12.2.2: a new buffer had retainCount 2, and after `del`
151
+ `MTLDevice.currentAllocatedSize()` did not move; declared as below, retainCount 1 and the
152
+ allocation returns to 0 at `del`. The process footprint grew by every freed buffer the GPU
153
+ had written — about 60 MB per key in a certifier sweep, into swap in a long render.
154
+
155
+ The declaration must be made after `import Metal` (loading the framework wrapper installs its
156
+ own metadata) and before the first call of each selector (pyobjc resolves a selector's
157
+ signature once). It is idempotent, and it stays correct under a pyobjc that fixes the rule
158
+ itself: both then say the same thing. `test_metal_new_family_is_not_leaked.py` measures it."""
159
+ global _NEW_FAMILY_DECLARED
160
+ if _NEW_FAMILY_DECLARED:
161
+ return
162
+ import objc
163
+ import Metal # noqa: F401 the wrapper's own metadata first, ours over it
164
+ for selector in _NEW_FAMILY_SELECTORS:
165
+ objc.registerMetaDataForSelector(
166
+ b"NSObject", selector, {"retval": {"already_retained": True}, "arguments": {}})
167
+ _NEW_FAMILY_DECLARED = True
168
+
169
+
170
+ class MetalRuntime:
171
+ """One Metal device, presented as the runtime object the seam expects.
172
+
173
+ Instantiated once per process by `runtime()`. Not thread-safe by
174
+ construction beyond the registry lock, which matches the rest of
175
+ `DeviceAllocator` ("single-threaded per component by convention").
176
+ """
177
+
178
+ def __init__(self) -> None:
179
+ try:
180
+ import Metal # pyobjc-framework-Metal
181
+ except ImportError as exc: # pragma: no cover
182
+ raise MetalUnavailableError(
183
+ "The Metal Python bindings are not installed. NeuroBrix "
184
+ "reaches Apple GPUs through pyobjc:\n"
185
+ " pip install pyobjc-framework-Metal\n"
186
+ "It is a dependency of the Metal Triton backend, so this "
187
+ "usually means the backend itself is missing — see "
188
+ "nbxtensor.backend.metal_backend."
189
+ ) from exc
190
+
191
+ self._Metal = Metal
192
+ declare_new_family_ownership()
193
+ device = Metal.MTLCreateSystemDefaultDevice()
194
+ if device is None:
195
+ raise MetalUnavailableError(
196
+ "No Metal device. `MTLCreateSystemDefaultDevice()` returned "
197
+ "nil, so this process cannot reach a GPU at all — a headless "
198
+ "session or a virtualised host without GPU passthrough will "
199
+ "do this. NeuroBrix refuses rather than computing on the CPU "
200
+ "under a device name."
201
+ )
202
+ if not device.hasUnifiedMemory():
203
+ raise MetalUnavailableError(
204
+ f"Metal device {device.name()!r} does not report unified "
205
+ f"memory. Every address this allocator hands out is a "
206
+ f"shared-storage buffer's `contents()`, which is only the "
207
+ f"SAME address the GPU sees when host and device share the "
208
+ f"memory. On a discrete Metal GPU that assumption is false "
209
+ f"and the pointers would be silently wrong, so this refuses "
210
+ f"instead. Apple Silicon reports unified memory; an Intel "
211
+ f"Mac with a discrete card does not."
212
+ )
213
+ self._device = device
214
+ # One queue owned by the allocator. See `sync` for exactly what it
215
+ # does and does not order against.
216
+ self._queue = device.newCommandQueue()
217
+ if self._queue is None: # pragma: no cover
218
+ raise MetalUnavailableError(
219
+ "Metal device present but `newCommandQueue()` returned nil.")
220
+
221
+ # The VM page size, read from the system. Every allocation is rounded
222
+ # up to a multiple of it, and that is not a micro-optimisation:
223
+ # `MTLDevice.newBufferWithBytesNoCopy:length:options:deallocator:` —
224
+ # which is how the Triton Metal backend turns one of our pointers
225
+ # into a buffer it can bind — requires a PAGE-ALIGNED pointer and a
226
+ # page-multiple length. Metal sub-allocates buffers smaller than a
227
+ # page inside one, so `contents()` for them lands mid-page. Measured
228
+ # 2026-09-05: of twelve sizes from 1 byte to 64 KB, the eight below
229
+ # one page all came back unaligned (offsets 1280, 1536, 1792, 2048,
230
+ # 2304, 3328, 7424, 15616) and every one of them was page-aligned
231
+ # once the length was rounded up. Handing a sub-page pointer to
232
+ # NoCopy is undefined behaviour, and it showed as
233
+ # "Failed to create Metal buffer" under memory pressure and, once, a
234
+ # SIGBUS.
235
+ self._page_size = int(os.sysconf("SC_PAGESIZE"))
236
+
237
+ self._lock = threading.RLock()
238
+ # address -> (MTLBuffer, memoryview, nbytes). The buffer entry is what
239
+ # keeps the allocation alive: dropping it is the free.
240
+ self._buffers: Dict[int, Tuple[object, memoryview, int]] = {}
241
+ self._host_buffers: Dict[int, Tuple[object, memoryview, int]] = {}
242
+ #: Every live base address, sorted, so an interior pointer resolves
243
+ #: by bisection instead of a scan. The engine sub-allocates arenas,
244
+ #: so interior pointers are the common case, not the exception.
245
+ self._bases: list = []
246
+ # No byte is counted here: the one account is the allocator's (`memory.allocator.DeviceAllocator`), and the
247
+ # device's budget is refused there (`memory.metal.MetalMemory`), never a second figure of the same fact.
248
+ # handle -> MTLCommandQueue, for the stream API.
249
+ self._queues: Dict[int, object] = {}
250
+ self._next_queue_handle = 1
251
+ # handle -> event record, for the event API.
252
+ self._events: Dict[int, dict] = {}
253
+ self._next_event_handle = 1
254
+ # Retired shared events, kept for reuse.
255
+ #
256
+ # `newSharedEvent` is not an unlimited resource: an autotune sweep
257
+ # times every candidate config of every op with a fresh pair, and a
258
+ # long run exhausted the device — `newSharedEvent` began returning
259
+ # nil, which surfaced as "event create failed (error 1)" in the middle
260
+ # of a matmul. Destroying them was not enough; the objects are
261
+ # released but a run that never returns to the event loop does not
262
+ # necessarily reclaim them.
263
+ #
264
+ # A shared event is reusable by construction — signalling is a
265
+ # monotonically increasing value on the same object — so a retired one
266
+ # goes back on this list with its counter intact and the next create
267
+ # takes it. The peak count is what a run actually holds at once, which
268
+ # for the timing path is two.
269
+ self._event_pool: list = []
270
+
271
+ # Command buffers committed and NOT yet waited on, oldest first.
272
+ #
273
+ # A launch used to commit its buffer and block on
274
+ # `waitUntilCompleted()` before returning, so every kernel cost a full
275
+ # host round trip. Measured 2026-09-07 on TinyLlama decode: 598 us per
276
+ # launch across 10,695 launches, 1,337 of them per decode step —
277
+ # 799 ms of an 857 ms step, 93% of it, spent waiting for a GPU that
278
+ # had already been given the work.
279
+ #
280
+ # Metal executes the buffers of one queue in the order they were
281
+ # committed, so dropping the wait keeps the ordering that made the
282
+ # engine correct. What it does NOT keep is the accident that made
283
+ # every host read of device memory safe: `memcpy` here is a memmove on
284
+ # shared memory and orders against nothing. So the wait moves to the
285
+ # places where the host actually looks — `memcpy`, `memset`, `sync`,
286
+ # `stream_sync` — and to `free`, because a buffer referenced by
287
+ # pending work must not be released.
288
+ #
289
+ # The list is bounded: Metal blocks in `commandBuffer()` once a queue
290
+ # holds 64 in flight, and blocking there is a stall nobody can see.
291
+ # `_drain_to` keeps it under the cap read from the hardware profile.
292
+ self._pending: Dict[int, list] = {}
293
+ self._pending_cap: Optional[int] = None
294
+
295
+ # The OPEN command buffer of each stream, and the encoder inside it.
296
+ #
297
+ # A NeuroBrix stream means ordered work that synchronises when it is
298
+ # asked to, and on Metal that is one command buffer with one
299
+ # serialised compute encoder — opened at the first launch, committed
300
+ # at a synchronisation point, an event, or a host read. It is the
301
+ # same contract a CUDA stream keeps; this is where it is kept here.
302
+ #
303
+ # It was one command buffer per LAUNCH until 2026-09-07, which put
304
+ # 1,505 of them in a single decode step: 501 ms of the step's 593 ms
305
+ # was draining them, and Metal's own GPUStartTime/GPUEndTime summed to
306
+ # 806 ms across those buffers — more than the step itself, because
307
+ # what that sum measures at this granularity is residency, not work.
308
+ #
309
+ # `_open` holds (command_buffer, encoder, kind, dispatches). A request
310
+ # for a different encoder kind ends the current one and opens the new
311
+ # one in the SAME buffer, so a blit and the kernels around it keep
312
+ # their order without a commit between them.
313
+ self._open: Dict[int, list] = {}
314
+ self._dispatch_cap: Optional[int] = None
315
+
316
+ # An encoder that is FREED while still open aborts the whole process:
317
+ # -[_MTLCommandEncoder dealloc]:134: failed assertion
318
+ # 'Command encoder released without endEncoding'
319
+ # The encode paths already close their stream when encoding raises.
320
+ # What none of them covers is an error that escapes ALL the way out —
321
+ # a compile-time refusal, most of all — leaving a stream open with a
322
+ # live encoder for the interpreter to free at teardown.
323
+ #
324
+ # Measured 2026-09-09 on the whole `stt` family: after a `tt.dot`
325
+ # refusal the three models did not fall back, they ABORTED (rc=-6),
326
+ # and the assertion is what the log showed instead of the refusal that
327
+ # caused it. A loud, recoverable decline became a crash, and it took
328
+ # the family with it.
329
+ #
330
+ # So the encoders are ended at interpreter shutdown too. Ending is all
331
+ # that is needed — the buffers are not committed, because at exit
332
+ # there is nothing left to read their results.
333
+ import atexit as _atexit
334
+ _atexit.register(self._end_open_encoders)
335
+
336
+ # `DeviceAllocator.event_elapsed_ms` sets `.restype` on the entry
337
+ # point before calling it — a ctypes idiom that a bound method
338
+ # rejects. A function object accepts attributes, so this one entry
339
+ # point is exposed as a closure rather than a method. The seam's
340
+ # convention, honoured rather than worked around.
341
+ def event_elapsed(out_ms, start, end, _self=self):
342
+ return _self._event_elapsed(out_ms, start, end)
343
+
344
+ self.event_elapsed = event_elapsed
345
+
346
+ try:
347
+ self._device_count = len(Metal.MTLCopyAllDevices())
348
+ except Exception: # pragma: no cover
349
+ # MTLCopyAllDevices is macOS-only; a device exists, so it is one.
350
+ self._device_count = 1
351
+
352
+ # -- properties read from the device, never assumed ---------------------
353
+
354
+ @property
355
+ def device_name(self) -> str:
356
+ return str(self._device.name())
357
+
358
+ @property
359
+ def arch_name(self) -> str:
360
+ """The Triton target arch string for this GPU.
361
+
362
+ The Metal device name, lowercased and hyphenated — "apple-m4-pro".
363
+ Computed here rather than imported from the Metal backend's driver:
364
+ that module carries torch imports, and R33 does not make an exception
365
+ for Apple. The rule is one line of string handling, and the profile
366
+ matches it by prefix (`config/vendors/apple/apple_silicon.yml`).
367
+ """
368
+ from nbxtensor.backend.device_names import arch_string
369
+ return arch_string(self._device.name())
370
+
371
+ @property
372
+ def has_unified_memory(self) -> bool:
373
+ return bool(self._device.hasUnifiedMemory())
374
+
375
+ @property
376
+ def max_threadgroup_memory(self) -> int:
377
+ """Threadgroup memory per threadgroup, in bytes, from the device.
378
+
379
+ The one number the Apple profile calls its binding constraint, and
380
+ the profile has it `hand_curated_from_docs`. Exposed here so it can
381
+ be measured rather than read.
382
+ """
383
+ return int(self._device.maxThreadgroupMemoryLength())
384
+
385
+ # -- allocation ---------------------------------------------------------
386
+
387
+ def budget_bytes(self) -> int:
388
+ """The device's own working-set budget, read from the device."""
389
+ return int(self._device.recommendedMaxWorkingSetSize())
390
+
391
+ # Why the budget is checked against OUR live count and not against
392
+ # `MTLDevice.currentAllocatedSize()`, which is the obvious candidate:
393
+ # measured 2026-09-05, that counter does not come back down. Four cycles
394
+ # of allocating 2 GiB and releasing it read 2048 / 4096 / 6144 / 8192 MB
395
+ # while the process RSS stayed flat at ~2.3 GB — so the memory really is
396
+ # released and reused, and the counter is cumulative rather than live.
397
+ # Using it as a budget gauge would have produced an allocator that OOMs
398
+ # permanently after enough churn, on a device with nothing allocated.
399
+
400
+ def _new_shared_buffer(self, nbytes: int):
401
+ """A shared-storage MTLBuffer plus the address the CPU and GPU share.
402
+
403
+ The device's working-set budget is refused by the ONE account before it asks here
404
+ (`memory.allocator.DeviceAllocator`, `memory.metal.MetalMemory.budget`), and that is deliberate.
405
+ `recommendedMaxWorkingSetSize` is a recommendation: Metal will keep handing out buffers past it,
406
+ backed by a memory the whole machine is sharing, until the system starts paging or the process
407
+ is killed. An allocator that never says no turns "fill the device until it refuses" — which is
408
+ how the engine's own OOM-reclaim path is exercised — into an unbounded claim on the user's RAM.
409
+ Apple's number is the honest ceiling and it is read from the device, never guessed.
410
+ """
411
+ Metal = self._Metal
412
+ page = self._page_size
413
+ allocated = (nbytes + page - 1) // page * page
414
+ buffer = self._device.newBufferWithLength_options_(
415
+ allocated, Metal.MTLResourceStorageModeShared)
416
+ if buffer is None:
417
+ return None, None, 0, 0
418
+ view = buffer.contents().as_buffer(allocated)
419
+ # `from_buffer` holds an export on the view only for as long as the
420
+ # returned object lives; the address belongs to the MTLBuffer, which
421
+ # the registry keeps alive, so it stays valid once the keeper goes.
422
+ keeper = ctypes.c_char.from_buffer(view)
423
+ address = ctypes.addressof(keeper)
424
+ del keeper
425
+ if address % page: # pragma: no cover
426
+ # Rounding is supposed to guarantee this. If Metal ever stops
427
+ # honouring it, refuse rather than hand out a pointer the
428
+ # backend will wrap into a buffer with undefined behaviour.
429
+ return None, None, 0, 0
430
+ return buffer, view, address, allocated
431
+
432
+ def malloc(self, out_ptr, size) -> int:
433
+ """`cudaMalloc(&ptr, size)`."""
434
+ nbytes = _as_int(size)
435
+ slot = _deref(out_ptr)
436
+ if nbytes <= 0:
437
+ slot.value = 0
438
+ return _ERR_BAD_ARGUMENT if nbytes < 0 else _OK
439
+ buffer, view, address, allocated = self._new_shared_buffer(nbytes)
440
+ if buffer is None or not address:
441
+ slot.value = 0
442
+ return _ERR_ALLOC
443
+ with self._lock:
444
+ self._buffers[address] = (buffer, view, allocated)
445
+ bisect.insort(self._bases, address)
446
+ slot.value = address
447
+ return _OK
448
+
449
+ def free(self, ptr) -> int:
450
+ """`cudaFree(ptr)`. Dropping the MTLBuffer reference IS the free."""
451
+ address = _as_int(ptr)
452
+ if not address:
453
+ return _OK
454
+ # Releasing memory a committed command buffer still references is a
455
+ # use-after-free on the GPU's side of the fence.
456
+ self.flush()
457
+ with self._lock:
458
+ entry = self._buffers.pop(address, None)
459
+ if entry is not None:
460
+ _drop_base(self._bases, address)
461
+ if entry is None:
462
+ # Freeing a pointer this runtime never handed out is a bug in the
463
+ # caller, and it must not pass silently: on CUDA the same call
464
+ # returns cudaErrorInvalidValue.
465
+ return _ERR_UNKNOWN_POINTER
466
+ entry[1].release()
467
+ return _OK
468
+
469
+ def malloc_host(self, out_ptr, size) -> int:
470
+ """`cudaMallocHost(&ptr, size)` — a shared buffer, and that is exact.
471
+
472
+ `cudaMallocHost` exists to page-lock host memory so a DMA engine can
473
+ stream it to the device. Apple Silicon has no such transfer: the
474
+ address a shared buffer hands back is already readable by the GPU
475
+ with no copy, which is the whole property the caller wants.
476
+ """
477
+ nbytes = _as_int(size)
478
+ slot = _deref(out_ptr)
479
+ if nbytes <= 0:
480
+ slot.value = 0
481
+ return _ERR_BAD_ARGUMENT if nbytes < 0 else _OK
482
+ buffer, view, address, allocated = self._new_shared_buffer(nbytes)
483
+ if buffer is None or not address:
484
+ slot.value = 0
485
+ return _ERR_ALLOC
486
+ with self._lock:
487
+ self._host_buffers[address] = (buffer, view, allocated)
488
+ bisect.insort(self._bases, address)
489
+ slot.value = address
490
+ return _OK
491
+
492
+ def free_host(self, ptr) -> int:
493
+ address = _as_int(ptr)
494
+ if not address:
495
+ return _OK
496
+ with self._lock:
497
+ entry = self._host_buffers.pop(address, None)
498
+ if entry is not None:
499
+ _drop_base(self._bases, address)
500
+ if entry is None:
501
+ return _ERR_UNKNOWN_POINTER
502
+ entry[1].release()
503
+ return _OK
504
+
505
+ def bind_mapped(self, address: int, nbytes: int) -> int:
506
+ """Make `nbytes` of a file mapping at `address` bindable in place: a shared buffer over the pages that
507
+ hold them (`newBufferWithBytesNoCopy`, page-aligned as Metal requires), registered so a launch resolves
508
+ any address inside it to (buffer, offset) like every other allocation (`buffer_for_pointer`). No byte
509
+ is copied and none is counted against the working set: the pages are the file's, the page cache's
510
+ to keep or reclaim. The unified expert store reads a routed expert this way, the expert's own pages
511
+ only (one buffer per expert, never a whole shard: a command buffer keeps resident what it binds).
512
+ Returns the page base registered; a range already covered returns its base."""
513
+ page = self._page_size
514
+ start = int(address) // page * page
515
+ end = -(-(int(address) + int(nbytes)) // page) * page
516
+ with self._lock:
517
+ entry = self._host_buffers.get(start)
518
+ if entry is not None and start + entry[2] >= end:
519
+ return start
520
+ if entry is not None: # a neighbour's buffer starts on this page: cover both
521
+ end = max(end, start + entry[2])
522
+ # PyObjC takes the bytes as a buffer object, not an integer: a ctypes array AT the address (no copy —
523
+ # checked: the buffer's contents() is the mapping's own address)
524
+ region = (ctypes.c_char * (end - start)).from_address(start)
525
+ buffer = self._device.newBufferWithBytesNoCopy_length_options_deallocator_(
526
+ region, end - start, self._Metal.MTLResourceStorageModeShared, None)
527
+ if buffer is None:
528
+ raise RuntimeError(f"ZERO FALLBACK: Metal refused to bind {end - start} bytes of a file mapping at "
529
+ f"{start:#x} in place (newBufferWithBytesNoCopy returned nil)")
530
+ with self._lock:
531
+ if start not in self._host_buffers:
532
+ bisect.insort(self._bases, start)
533
+ self._host_buffers[start] = (buffer, _MappedView(region), end - start)
534
+ return start
535
+
536
+ def unbind_mapped(self, start: int) -> bool:
537
+ """Drop the in-place buffer registered at page `start` (`bind_mapped`): its pages are the file's again,
538
+ nothing holds them resident. A command buffer still running retains the MTLBuffer it bound."""
539
+ with self._lock:
540
+ entry = self._host_buffers.pop(int(start), None)
541
+ if entry is not None:
542
+ _drop_base(self._bases, int(start))
543
+ return entry is not None
544
+
545
+ # -- copies -------------------------------------------------------------
546
+
547
+ def memcpy(self, dst, src, size, kind=None) -> int:
548
+ """`cudaMemcpy(dst, src, n, kind)` — one address space, so one copy.
549
+
550
+ `kind` is accepted and ignored on purpose: H2D, D2H, D2D and H2H are
551
+ the same operation when host and device share the memory. `memmove`
552
+ rather than `memcpy` because it is correct for every input the
553
+ caller can produce, including the overlap CUDA leaves undefined.
554
+ """
555
+ nbytes = _as_int(size)
556
+ if nbytes <= 0:
557
+ return _OK if nbytes == 0 else _ERR_BAD_ARGUMENT
558
+ dst_addr, src_addr = _as_int(dst), _as_int(src)
559
+ if not dst_addr or not src_addr:
560
+ return _ERR_BAD_ARGUMENT
561
+ # Device to device is STREAM-ORDERED by contract, not host-ordered:
562
+ # "for D2D the synchronous cudaMemcpy performs NO host-side
563
+ # synchronization — the copy is enqueued on the calling device's
564
+ # legacy stream and the host returns immediately" (the allocator says
565
+ # so in nbx_tensor.memcpy). Doing it as a host memmove made it
566
+ # host-ordered, and once the launch path stopped waiting that turned
567
+ # every one of them into a flush: measured 2026-09-07, 1,086 D2D
568
+ # copies in an 8-token decode went from 0.007 s to 4.08 s, taking back
569
+ # most of what dropping the per-launch wait had given. A blit on this
570
+ # allocator's queue is ordered by the GPU against the work on that
571
+ # queue and waits for nothing; the Triton kernels run on triton-ext's
572
+ # own queue, and the launcher drains this one before each of them.
573
+ if _as_int(kind) == 3:
574
+ blitted = self._blit(dst_addr, src_addr, nbytes)
575
+ if blitted is not None:
576
+ return blitted
577
+ # Host is involved on one side or the other: a memmove on shared
578
+ # memory orders against nothing, so the GPU work that produced these
579
+ # bytes has to have retired before it runs. It always had; it used to
580
+ # be true by accident because every launch waited.
581
+ self.flush()
582
+ ctypes.memmove(ctypes.c_void_p(dst_addr),
583
+ ctypes.c_void_p(src_addr), nbytes)
584
+ return _OK
585
+
586
+ def memset(self, ptr, value, size) -> int:
587
+ nbytes = _as_int(size)
588
+ if nbytes <= 0:
589
+ return _OK if nbytes == 0 else _ERR_BAD_ARGUMENT
590
+ address = _as_int(ptr)
591
+ if not address:
592
+ return _ERR_BAD_ARGUMENT
593
+ self.flush()
594
+ ctypes.memset(ctypes.c_void_p(address),
595
+ _as_int(value) & 0xFF, nbytes)
596
+ return _OK
597
+
598
+ # -- device queries -----------------------------------------------------
599
+
600
+ def set_device(self, index) -> int:
601
+ """Metal exposes one GPU here; anything but 0 is an error, not a
602
+ silent clamp to the only device."""
603
+ return _OK if _as_int(index) == 0 else _ERR_BAD_DEVICE
604
+
605
+ def get_device(self, out_index) -> int:
606
+ _deref(out_index).value = 0
607
+ return _OK
608
+
609
+ def device_count(self, out_count) -> int:
610
+ _deref(out_count).value = self._device_count
611
+ return _OK
612
+
613
+ def mem_get_info(self, out_free, out_total) -> int:
614
+ """`cudaMemGetInfo(&free, &total)`, read from the device.
615
+
616
+ `total` is `recommendedMaxWorkingSetSize()` — Apple's own budget for
617
+ this device, which on unified memory is the meaningful ceiling rather
618
+ than installed RAM, because that RAM is shared with everything else
619
+ running. `free` is that budget minus `currentAllocatedSize()`, so it
620
+ is a budget headroom rather than a physical free-page count. The
621
+ callers of this in NeuroBrix are feasibility probes and the pool's
622
+ parked-bytes cap, both of which want exactly that.
623
+ """
624
+ from nbxtensor.memory.metal import MetalMemory
625
+ rc, free, total = MetalMemory(self).mem_get_info() # headroom against the ONE account
626
+ _deref(out_total).value = total
627
+ _deref(out_free).value = free
628
+ return rc
629
+
630
+ # -- ordering -----------------------------------------------------------
631
+
632
+ # -- deferred completion ------------------------------------------------
633
+
634
+ def _cap(self) -> int:
635
+ """How many committed command buffers may be in flight at once.
636
+
637
+ Read from the hardware profile (`dispatch.max_command_buffers_in_flight`)
638
+ so the number is the device's, not this file's. Metal's own ceiling is
639
+ 64 per queue and it BLOCKS in `commandBuffer()` at that point, which is
640
+ a stall with no name attached; staying under it keeps the backpressure
641
+ ours and visible.
642
+ """
643
+ if self._pending_cap is None:
644
+ self._pending_cap = _declared_dispatch_fact("max_command_buffers_in_flight")
645
+ return self._pending_cap
646
+
647
+ def track_committed(self, queue_handle: int, command_buffer) -> None:
648
+ """Record a buffer committed without a wait, and keep the queue's
649
+ in-flight depth under the cap by waiting on the oldest."""
650
+ with self._lock:
651
+ pending = self._pending.setdefault(int(queue_handle or 0), [])
652
+ pending.append(command_buffer)
653
+ cap = self._cap()
654
+ while len(pending) > cap:
655
+ oldest = pending.pop(0)
656
+ self._await(oldest)
657
+
658
+ def _await(self, command_buffer) -> None:
659
+ command_buffer.waitUntilCompleted()
660
+ error = command_buffer.error()
661
+ if error is not None:
662
+ raise RuntimeError(f"Metal command buffer failed: {error}")
663
+
664
+ def _dispatches_per_buffer(self) -> int:
665
+ """How many dispatches one command buffer may carry before it is
666
+ committed, from `dispatch.max_dispatches_per_command_buffer` in the
667
+ hardware profile. It bounds latency and how much work a single
668
+ failure takes down with it; it is not a correctness knob."""
669
+ if self._dispatch_cap is None:
670
+ self._dispatch_cap = _declared_dispatch_fact("max_dispatches_per_command_buffer")
671
+ return self._dispatch_cap
672
+
673
+ def encoder_for(self, stream: int, kind: str = "compute"):
674
+ """The open encoder of this stream, opening one if there is none.
675
+
676
+ Returns `(command_buffer, encoder)`. The caller encodes and then calls
677
+ `note_dispatch`; it must not commit — the stream owns that.
678
+ """
679
+ key = int(stream or 0)
680
+ with self._lock:
681
+ entry = self._open.get(key)
682
+ if entry is not None and entry[2] != kind:
683
+ # Same buffer, different encoder: end the current one and open
684
+ # the kind asked for, so ordering is kept without a commit.
685
+ entry[1].endEncoding()
686
+ entry[1] = self._new_encoder(entry[0], kind)
687
+ entry[2] = kind
688
+ return entry[0], entry[1]
689
+ if entry is not None:
690
+ return entry[0], entry[1]
691
+ queue = self._resolve_queue(key)
692
+ if queue is None:
693
+ return None, None
694
+ command_buffer = queue.commandBuffer()
695
+ if command_buffer is None: # pragma: no cover
696
+ return None, None
697
+ encoder = self._new_encoder(command_buffer, kind)
698
+ if encoder is None: # pragma: no cover
699
+ command_buffer.commit()
700
+ return None, None
701
+ self._open[key] = [command_buffer, encoder, kind, 0]
702
+ return command_buffer, encoder
703
+
704
+ @staticmethod
705
+ def _new_encoder(command_buffer, kind: str):
706
+ if kind == "blit":
707
+ return command_buffer.blitCommandEncoder()
708
+ # `computeCommandEncoder()` is MTLDispatchTypeSerial: the dispatches
709
+ # inside it run in order with the barriers Metal inserts itself,
710
+ # which is what makes one encoder able to carry a whole graph.
711
+ return command_buffer.computeCommandEncoder()
712
+
713
+ def note_dispatch(self, stream: int) -> None:
714
+ """One more piece of work encoded on this stream; commit if the
715
+ buffer has carried enough of them."""
716
+ key = int(stream or 0)
717
+ with self._lock:
718
+ entry = self._open.get(key)
719
+ if entry is None:
720
+ return
721
+ entry[3] += 1
722
+ if entry[3] < self._dispatches_per_buffer():
723
+ return
724
+ self.close_stream(key)
725
+
726
+ def _end_open_encoders(self) -> None:
727
+ """End every open encoder, without committing. Teardown only.
728
+
729
+ `close_stream` ends AND commits AND tracks, which is right during a
730
+ run and wrong at exit: a commit at teardown queues work whose results
731
+ nobody will read, and tracking it adds a buffer nothing will await.
732
+ Here only the assertion is being avoided, so only `endEncoding` is
733
+ called. Never raises: this runs while the interpreter is going down,
734
+ and an exception there replaces one confusing abort with another.
735
+ """
736
+ try:
737
+ with self._lock:
738
+ entries = list(self._open.values())
739
+ self._open.clear()
740
+ except Exception: # pragma: no cover
741
+ return
742
+ for entry in entries:
743
+ try:
744
+ entry[1].endEncoding()
745
+ except Exception: # pragma: no cover
746
+ pass
747
+
748
+ def close_stream(self, stream: Optional[int] = None) -> None:
749
+ """End the open encoder and commit the buffer, without waiting."""
750
+ keys = [int(stream or 0)] if stream is not None else list(self._open)
751
+ for key in keys:
752
+ with self._lock:
753
+ entry = self._open.pop(key, None)
754
+ if entry is None:
755
+ continue
756
+ command_buffer, encoder = entry[0], entry[1]
757
+ try:
758
+ encoder.endEncoding()
759
+ except Exception: # pragma: no cover
760
+ pass
761
+ command_buffer.commit()
762
+ self.track_committed(key, command_buffer)
763
+
764
+ def abandon_stream(self, stream: int) -> None:
765
+ """Close the stream's buffer after an encoding failure.
766
+
767
+ A command buffer holds its queue's in-flight slot from creation until
768
+ it COMPLETES; one abandoned mid-encode never completes and the slot is
769
+ gone for the life of the process."""
770
+ self.close_stream(int(stream or 0))
771
+
772
+ def _blit(self, dst_addr: int, src_addr: int, nbytes: int):
773
+ """A device-to-device copy as GPU work on this allocator's queue (not the
774
+ Triton kernels' queue: triton-ext runs those on its own, and the launcher
775
+ drains this queue before every kernel).
776
+
777
+ Returns `_OK` when it was enqueued, or None when either end is not
778
+ memory this allocator handed out — in which case the caller falls
779
+ back to the host copy, because a blit can only address MTLBuffers.
780
+ """
781
+ dst = self.buffer_for_pointer(dst_addr)
782
+ src = self.buffer_for_pointer(src_addr)
783
+ if dst is None or src is None or dst[0] is None or src[0] is None:
784
+ return None
785
+ # Into the stream's own buffer, as a blit encoder between the compute
786
+ # encoders around it: one command buffer, order kept by Metal.
787
+ _cb, encoder = self.encoder_for(0, kind="blit")
788
+ if encoder is None: # pragma: no cover
789
+ return None
790
+ encoder.copyFromBuffer_sourceOffset_toBuffer_destinationOffset_size_(
791
+ src[0], src[1], dst[0], dst[1], nbytes)
792
+ self.note_dispatch(0)
793
+ return _OK
794
+
795
+ def flush(self, queue_handle: Optional[int] = None) -> int:
796
+ """Wait for everything committed on this queue (or all of them).
797
+
798
+ This is the seam the launch path traded its per-kernel wait for: the
799
+ host may read device memory only after it, and every entry point that
800
+ does — memcpy, memset, sync, stream_sync, free — goes through it.
801
+ """
802
+ # Work still being encoded has not been committed, so waiting on the
803
+ # pending list alone would return before it ran. The stream is closed
804
+ # first: that is what "synchronise" means for a stream that batches.
805
+ self.close_stream(None if queue_handle is None else int(queue_handle or 0))
806
+ with self._lock:
807
+ if queue_handle is None:
808
+ buffers = [cb for lst in self._pending.values() for cb in lst]
809
+ self._pending.clear()
810
+ else:
811
+ buffers = self._pending.pop(int(queue_handle or 0), [])
812
+ for command_buffer in buffers:
813
+ self._await(command_buffer)
814
+ return _OK
815
+
816
+ def has_pending_gpu_writes(self) -> bool:
817
+ """True when this queue holds work that could still be writing memory.
818
+
819
+ The launch path's drain exists for ONE crossing: our queue writes and
820
+ triton-ext's kernel reads. Only two things on this queue write memory a
821
+ kernel could read — a blit still being encoded (an open stream), and a
822
+ buffer already committed and not yet retired (`_pending`). Both are
823
+ counted here.
824
+
825
+ `event_record` and `stream_wait_event` also commit buffers and are NOT
826
+ counted, deliberately: they encode a signal and a wait, and neither
827
+ writes device memory. They are also not tracked in `_pending`, so a
828
+ caller that needs to wait for them must not use this — which is why this
829
+ is named for GPU WRITES and not for emptiness, and why `sync()` below is
830
+ left alone with its full device-synchronize semantics.
831
+ """
832
+ with self._lock:
833
+ return bool(self._open) or any(self._pending.values())
834
+
835
+ def sync(self) -> int:
836
+ """`cudaDeviceSynchronize()` on the allocator's own queue.
837
+
838
+ A real device round trip: an empty command buffer is committed and
839
+ waited on, so anything already enqueued on this queue has retired.
840
+
841
+ **What it does not cover, named rather than left silent:** the Triton
842
+ Metal backend dispatches kernels on a queue of its own, and this does
843
+ not order against that queue. Today nothing else enqueues work — the
844
+ copies above are CPU `memmove` on shared memory — so there is nothing
845
+ it misses. When the launch path lands it must either share this queue
846
+ or this method must be re-pointed at the backend's, and that is a
847
+ contract for that workstream, not an option.
848
+ """
849
+ # Everything the launch path committed without waiting, first: the
850
+ # contract this docstring used to describe as owed to the launch
851
+ # workstream is now kept here.
852
+ self.flush()
853
+ command_buffer = self._queue.commandBuffer()
854
+ if command_buffer is None: # pragma: no cover
855
+ return _ERR_ALLOC
856
+ command_buffer.commit()
857
+ command_buffer.waitUntilCompleted()
858
+ error = command_buffer.error()
859
+ return _OK if error is None else _ERR_ALLOC
860
+
861
+ def stream_create(self, out_handle) -> int:
862
+ """A Metal command queue, which is what a CUDA stream is here.
863
+
864
+ Handles start at 1: zero is the seam's NULL/default stream and must
865
+ never be produced by a create.
866
+ """
867
+ queue = self._device.newCommandQueue()
868
+ if queue is None: # pragma: no cover
869
+ _deref(out_handle).value = 0
870
+ return _ERR_ALLOC
871
+ with self._lock:
872
+ handle = self._next_queue_handle
873
+ self._next_queue_handle += 1
874
+ self._queues[handle] = queue
875
+ _deref(out_handle).value = handle
876
+ return _OK
877
+
878
+ def stream_destroy(self, handle) -> int:
879
+ key = _as_int(handle)
880
+ if not key:
881
+ return _OK
882
+ with self._lock:
883
+ return _OK if self._queues.pop(key, None) is not None \
884
+ else _ERR_UNKNOWN_POINTER
885
+
886
+ def stream_sync(self, handle) -> int:
887
+ key = _as_int(handle)
888
+ if not key:
889
+ return self.sync()
890
+ with self._lock:
891
+ queue = self._queues.get(key)
892
+ if queue is None:
893
+ return _ERR_UNKNOWN_POINTER
894
+ self.flush(key)
895
+ command_buffer = queue.commandBuffer()
896
+ if command_buffer is None: # pragma: no cover
897
+ return _ERR_ALLOC
898
+ command_buffer.commit()
899
+ command_buffer.waitUntilCompleted()
900
+ return _OK if command_buffer.error() is None else _ERR_ALLOC
901
+
902
+ # -- events -------------------------------------------------------------
903
+ #
904
+ # A CUDA event is a marker placed in a stream. Metal's equivalent is an
905
+ # `MTLSharedEvent` signalled from a command buffer, and the command
906
+ # buffer also carries `GPUEndTime`, which is a GPU-timeline timestamp —
907
+ # so both halves of the CUDA event API map onto real Metal machinery and
908
+ # neither is faked. `timing=False` events skip nothing on Metal (the
909
+ # timestamp comes free with the command buffer) but they still REFUSE to
910
+ # be read as a stopwatch, because the engine's contract is that an
911
+ # ordering handle is not a clock and code relying on that must break
912
+ # here exactly as it breaks on CUDA.
913
+
914
+ def _resolve_queue(self, handle: int):
915
+ """The queue a stream handle names; 0 is the allocator's own."""
916
+ if not handle:
917
+ return self._queue
918
+ with self._lock:
919
+ return self._queues.get(handle)
920
+
921
+ def event_create(self, out_handle) -> int:
922
+ return self._event_create(out_handle, timing=True)
923
+
924
+ def event_create_flags(self, out_handle, flags) -> int:
925
+ # 0x02 == cudaEventDisableTiming.
926
+ return self._event_create(out_handle, timing=not (_as_int(flags) & 0x02))
927
+
928
+ def _event_create(self, out_handle, timing: bool) -> int:
929
+ with self._lock:
930
+ retired = self._event_pool.pop() if self._event_pool else None
931
+ if retired is not None:
932
+ shared, value = retired["shared"], retired["value"]
933
+ else:
934
+ shared, value = self._device.newSharedEvent(), 0
935
+ if shared is None: # pragma: no cover
936
+ _deref(out_handle).value = 0
937
+ return _ERR_ALLOC
938
+ with self._lock:
939
+ handle = self._next_event_handle
940
+ self._next_event_handle += 1
941
+ self._events[handle] = {"shared": shared, "value": value,
942
+ "timing": timing, "buffer": None}
943
+ _deref(out_handle).value = handle
944
+ return _OK
945
+
946
+ #: How many retired shared events to keep for reuse. A run holds a couple
947
+ #: at a time; the rest are dropped so a pathological caller cannot turn
948
+ #: this into a leak of its own.
949
+ _EVENT_POOL_CAP = 64
950
+
951
+ def event_destroy(self, handle) -> int:
952
+ key = _as_int(handle)
953
+ if not key:
954
+ return _OK
955
+ with self._lock:
956
+ entry = self._events.pop(key, None)
957
+ if entry is None:
958
+ return _ERR_UNKNOWN_POINTER
959
+ # The command buffer goes; the shared event and its counter stay,
960
+ # because the next create can take them as they are.
961
+ entry["buffer"] = None
962
+ if len(self._event_pool) < self._EVENT_POOL_CAP:
963
+ self._event_pool.append({"shared": entry["shared"],
964
+ "value": entry["value"]})
965
+ return _OK
966
+
967
+ def event_record(self, handle, stream=None) -> int:
968
+ """Signal the event from a command buffer on `stream`'s queue."""
969
+ key = _as_int(handle)
970
+ with self._lock:
971
+ event = self._events.get(key)
972
+ if event is None:
973
+ return _ERR_UNKNOWN_POINTER
974
+ queue = self._resolve_queue(_as_int(stream))
975
+ if queue is None:
976
+ return _ERR_UNKNOWN_POINTER
977
+ command_buffer = queue.commandBuffer()
978
+ if command_buffer is None: # pragma: no cover
979
+ return _ERR_ALLOC
980
+ with self._lock:
981
+ event["value"] += 1
982
+ value = event["value"]
983
+ command_buffer.encodeSignalEvent_value_(event["shared"], value)
984
+ command_buffer.commit()
985
+ event["buffer"] = command_buffer
986
+ return _OK
987
+
988
+ def event_sync(self, handle) -> int:
989
+ key = _as_int(handle)
990
+ with self._lock:
991
+ event = self._events.get(key)
992
+ if event is None:
993
+ return _ERR_UNKNOWN_POINTER
994
+ command_buffer = event.get("buffer")
995
+ if command_buffer is None:
996
+ # Never recorded: nothing to wait for, exactly as on CUDA.
997
+ return _OK
998
+ command_buffer.waitUntilCompleted()
999
+ return _OK if command_buffer.error() is None else _ERR_ALLOC
1000
+
1001
+ def stream_wait_event(self, stream, handle, flags=None) -> int:
1002
+ """Make a queue wait on the event, without blocking the host."""
1003
+ key = _as_int(handle)
1004
+ with self._lock:
1005
+ event = self._events.get(key)
1006
+ if event is None:
1007
+ return _ERR_UNKNOWN_POINTER
1008
+ queue = self._resolve_queue(_as_int(stream))
1009
+ if queue is None:
1010
+ return _ERR_UNKNOWN_POINTER
1011
+ command_buffer = queue.commandBuffer()
1012
+ if command_buffer is None: # pragma: no cover
1013
+ return _ERR_ALLOC
1014
+ command_buffer.encodeWaitForEvent_value_(event["shared"],
1015
+ event["value"])
1016
+ command_buffer.commit()
1017
+ return _OK
1018
+
1019
+ def _event_elapsed(self, out_ms, start, end) -> int:
1020
+ """Milliseconds between two recorded events, on the GPU timeline."""
1021
+ with self._lock:
1022
+ a = self._events.get(_as_int(start))
1023
+ b = self._events.get(_as_int(end))
1024
+ if a is None or b is None:
1025
+ return _ERR_UNKNOWN_POINTER
1026
+ if not a["timing"] or not b["timing"]:
1027
+ # The whole point of an ordering handle: refuse rather than
1028
+ # return a plausible wrong number.
1029
+ return _ERR_BAD_ARGUMENT
1030
+ buf_a, buf_b = a.get("buffer"), b.get("buffer")
1031
+ if buf_a is None or buf_b is None:
1032
+ return _ERR_BAD_ARGUMENT
1033
+ # GPUEndTime is only defined once the buffer has retired.
1034
+ buf_a.waitUntilCompleted()
1035
+ buf_b.waitUntilCompleted()
1036
+ delta = (float(buf_b.GPUEndTime()) - float(buf_a.GPUEndTime())) * 1000.0
1037
+ _deref(out_ms).value = max(0.0, delta)
1038
+ return _OK
1039
+
1040
+ # -- binding ------------------------------------------------------------
1041
+
1042
+ def buffer_for_pointer(self, address: int):
1043
+ """The `MTLBuffer` backing an address this allocator handed out.
1044
+
1045
+ How a launch path SHOULD bind our memory. The alternative — wrapping
1046
+ the raw address with
1047
+ `newBufferWithBytesNoCopy:length:options:deallocator:`, which is what
1048
+ the Triton Metal driver does — asks Metal to take over pages it
1049
+ already owns, requires page alignment to be defined at all, and was
1050
+ observed returning nil under memory pressure. The buffer already
1051
+ exists; the registry can simply hand it back.
1052
+
1053
+ Returns `(MTLBuffer, offset)`. The offset is not always 0: the engine
1054
+ sub-allocates. `ComponentArena` takes ONE block per component and
1055
+ bump-allocates every weight inside it, so a weight pointer is a base
1056
+ plus an offset, and that is the normal path for every model on every
1057
+ backend — not an Apple special case.
1058
+
1059
+ This looked up the exact address until 2026-09-06, which is the same
1060
+ as answering "no" for every weight tensor in the engine. The first
1061
+ TinyLlama --triton run refused at the embedding kernel: the address
1062
+ was a perfectly good arena interior, and the ownership rule said the
1063
+ allocator had never handed it out.
1064
+
1065
+ Returns `(None, 0)` for an address genuinely outside every live
1066
+ allocation — the caller decides, and is not handed something that
1067
+ merely looks right.
1068
+ """
1069
+ with self._lock:
1070
+ entry = self._buffers.get(address) or self._host_buffers.get(address)
1071
+ if entry is not None:
1072
+ return entry[0], 0
1073
+ # An interior address: the greatest base at or below it, if the
1074
+ # allocation it names is long enough to contain it.
1075
+ index = bisect.bisect_right(self._bases, address) - 1
1076
+ if index < 0:
1077
+ return None, 0
1078
+ base = self._bases[index]
1079
+ entry = self._buffers.get(base) or self._host_buffers.get(base)
1080
+ if entry is None: # pragma: no cover
1081
+ return None, 0
1082
+ if address >= base + entry[2]:
1083
+ return None, 0
1084
+ return entry[0], address - base
1085
+
1086
+ # -- diagnostics --------------------------------------------------------
1087
+
1088
+ def live_allocation_count(self) -> int:
1089
+ """Buffers this runtime is keeping alive. Tests read it; the engine
1090
+ does not."""
1091
+ with self._lock:
1092
+ return len(self._buffers) + len(self._host_buffers)
1093
+
1094
+
1095
+ # The entry-point name table for this implementation. It is deliberately NOT
1096
+ # a row in `_GPU_BACKENDS`: that dict holds symbol tables over one C ABI and
1097
+ # `test_device_backend_seam.py` pins it to exactly `{"cuda", "hip"}`, because
1098
+ # a key present in one row and missing from the other is a crash on that
1099
+ # hardware alone. Metal is a different API, so it gets a different table.
1100
+ #
1101
+ # Keys ABSENT here are absent on purpose — Metal has no honest equivalent —
1102
+ # and the seam turns each into an explicit refusal naming the backend:
1103
+ # memcpy_async
1104
+ # there is no asynchronous copy to make: the copy is a CPU memmove
1105
+ # over memory both processors already share.
1106
+ # malloc_async / free_async
1107
+ # the stream-ordered allocator has no counterpart, and it is disabled
1108
+ # on CUDA here anyway.
1109
+ # device_can_access_peer / device_enable_peer_access
1110
+ # one GPU. `MTLCopyAllDevices()` returns 1, and the seam answers False
1111
+ # for peer access when the key is missing, which is the truth.
1112
+ METAL_BACKEND = {
1113
+ "rt_libs": [], # nothing is dlopen'd: this is not a C library
1114
+ "malloc": "malloc", "free": "free",
1115
+ "memcpy": "memcpy", "memset": "memset",
1116
+ "set_device": "set_device", "get_device": "get_device",
1117
+ "device_count": "device_count",
1118
+ "mem_get_info": "mem_get_info",
1119
+ "sync": "sync",
1120
+ "malloc_host": "malloc_host", "free_host": "free_host",
1121
+ "stream_create": "stream_create",
1122
+ "stream_destroy": "stream_destroy",
1123
+ "stream_sync": "stream_sync",
1124
+ "event_create": "event_create",
1125
+ "event_create_flags": "event_create_flags",
1126
+ "event_destroy": "event_destroy",
1127
+ "event_record": "event_record",
1128
+ "event_sync": "event_sync",
1129
+ "event_elapsed": "event_elapsed",
1130
+ "stream_wait_event": "stream_wait_event",
1131
+ }
1132
+
1133
+
1134
+ _RUNTIME: Optional[MetalRuntime] = None
1135
+ _RUNTIME_LOCK = threading.Lock()
1136
+
1137
+
1138
+ def runtime() -> MetalRuntime:
1139
+ """The process-wide Metal runtime. Raises if Metal is unusable.
1140
+
1141
+ Under NBX_CENSUS=1 the Metal device is deliberately UNREACHABLE — the
1142
+ honest analogue of the census's `CUDA_VISIBLE_DEVICES=` on a discrete
1143
+ card. The census forms the keys the launcher WOULD form for a profile
1144
+ (shapes, dtypes, block sizes, all data), and must touch no device; a
1145
+ path that reaches for the real Metal device inside a shadow is one the
1146
+ census does not cover, and it fails LOUDLY here rather than succeeding in
1147
+ silence on the one card an Apple host always has. `device_count()` reads
1148
+ 0 through this (its own except path), so the census gate passes; the
1149
+ backend NAME still resolves via NBX_GPU_BACKEND=metal, which needs no
1150
+ device (`_pin_triton_backend`).
1151
+ """
1152
+ import os as _os_c
1153
+ if _os_c.environ.get("NBX_CENSUS") == "1":
1154
+ raise RuntimeError(
1155
+ "census shadow: the Metal device is deliberately unreachable "
1156
+ "(NBX_CENSUS=1). Key formation is pure data and needs no device; "
1157
+ "a path that opened the Metal runtime here is uncovered by the "
1158
+ "census and must be made pure.")
1159
+ global _RUNTIME
1160
+ if _RUNTIME is None:
1161
+ with _RUNTIME_LOCK:
1162
+ if _RUNTIME is None:
1163
+ _RUNTIME = MetalRuntime()
1164
+ return _RUNTIME
1165
+
1166
+
1167
+ def metal_device_available() -> bool:
1168
+ """True when a Metal device can actually be opened.
1169
+
1170
+ A probe, so it answers False instead of raising — but it opens the real
1171
+ device rather than checking for the import, because a machine with the
1172
+ bindings and no usable GPU must not be reported as ready.
1173
+
1174
+ Under NBX_CENSUS the device is deliberately UNREACHABLE (`runtime()`
1175
+ refuses), but the backend must still be NAMEABLE — the exact analogue of a
1176
+ CUDA rack where `libcudart` loads and names the backend though
1177
+ `CUDA_VISIBLE_DEVICES=` leaves no device visible. The backend resolver
1178
+ (`nbx_tensor` `_resolve_backend`) calls this to decide whether to name
1179
+ metal; answering False under census made every op that resolves the
1180
+ backend — `aten::embedding` first — fail with "No GPU runtime found",
1181
+ though the same census completes on CUDA (the Dell's 9ea81cd2 covers the
1182
+ rack, not Apple). So answer True here (naming) while `runtime()` stays
1183
+ unreachable (opening): keys are pure data and need no open device.
1184
+ """
1185
+ import os as _os_a
1186
+ if _os_a.environ.get("NBX_CENSUS") == "1":
1187
+ return True
1188
+ try:
1189
+ runtime()
1190
+ return True
1191
+ except Exception:
1192
+ return False
1193
+
1194
+
1195
+ def reset_runtime_for_tests() -> None:
1196
+ """Drop the cached runtime — and everything compiled against it.
1197
+
1198
+ A compiled kernel captures the runtime it was built with: that runtime's
1199
+ device, its pipelines, and its registry of which addresses the allocator
1200
+ handed out. Replacing the runtime while compiled kernels survive leaves
1201
+ those kernels resolving buffer addresses through a registry that never
1202
+ issued them, and the next launch refuses a perfectly good tensor with
1203
+ "which the Metal allocator did not hand out".
1204
+
1205
+ Measured 2026-09-05: this made `test_cumsum_is_correct[128]` fail in the
1206
+ full kernels suite and pass in every smaller selection — the contaminating
1207
+ file was `test_metal_allocator.py`, the only caller of this function.
1208
+ Tests only, but the invariant is general: the caches and the runtime have
1209
+ the same lifetime.
1210
+ """
1211
+ global _RUNTIME
1212
+
1213
+ # BEFORE dropping it, hand everything the old runtime owns back to the
1214
+ # old runtime — while it is still the one a free would reach.
1215
+ #
1216
+ # * the allocator's free pool parks blocks for reuse. A block parked
1217
+ # under the old runtime and handed out after the reset is an address
1218
+ # the new runtime never issued, and the first launch that touches it
1219
+ # refuses a perfectly good tensor.
1220
+ # * `_INT64_ARRAY_CACHE` holds shape/stride tensors forever by design
1221
+ # (a frozen replay plan records their raw device pointers), which
1222
+ # also means forever across a reset.
1223
+ from nbxtensor import tensor as nbx_tensor
1224
+ import nbxtensor.memory.allocator as _nbx_allocator
1225
+ import nbxtensor.backend.runtime as _nbx_runtime
1226
+ try:
1227
+ _nbx_allocator.DeviceAllocator.empty_cache_pool()
1228
+ except Exception: # pragma: no cover
1229
+ pass # nothing to release
1230
+ nbx_tensor._INT64_ARRAY_CACHE.clear()
1231
+
1232
+ # Everything issued so far belongs to the runtime about to be dropped.
1233
+ # The flush above released what was parked; a tensor still ALIVE right
1234
+ # now is freed later, and without this that free would park its dead
1235
+ # address for the next allocation to be served.
1236
+ try:
1237
+ _nbx_allocator.DeviceAllocator.new_allocation_epoch()
1238
+ except Exception: # pragma: no cover
1239
+ pass
1240
+
1241
+ with _RUNTIME_LOCK:
1242
+ _RUNTIME = None
1243
+
1244
+ # Everything that captured the old runtime goes with it. Lazily and
1245
+ # after the lock, because both of these import this module.
1246
+ #
1247
+ # There are three caches, and they must have ONE lifetime:
1248
+ # * `_RUNTIME` here,
1249
+ # * the compiled kernels in `metal_driver`, which hold the device and
1250
+ # the pipelines they were built on,
1251
+ # * `_nbx_runtime.gpu_runtime`, an lru_cache the allocator resolves
1252
+ # every malloc through.
1253
+ #
1254
+ # Clearing any subset is worse than clearing none: the tensors then come
1255
+ # from one runtime and the kernels from another, and a launch refuses a
1256
+ # perfectly good address with "which the Metal allocator did not hand
1257
+ # out". Measured 2026-09-05 as an order-dependent failure of
1258
+ # `test_cumsum_is_correct[128]` in the full kernels suite.
1259
+ # The fork's driver held a kernel/library cache that pinned the device;
1260
+ # it was archived 2026-09-17 and the cache went with it. triton-ext
1261
+ # holds its compiled functions in Triton's own cache, which this
1262
+ # function's callers already clear.
1263
+
1264
+ # The launcher caches a driver-produced handle per specialisation — a
1265
+ # Metal pipeline here — so it holds the runtime as surely as the driver
1266
+ # does, one layer up.
1267
+ from nbxtensor.backend import launcher
1268
+ launcher.reset_caches()
1269
+
1270
+ for cached in (getattr(_nbx_runtime, "gpu_runtime", None),
1271
+ getattr(_nbx_runtime, "_metal_backend_table", None)):
1272
+ clear = getattr(cached, "cache_clear", None)
1273
+ if clear is not None:
1274
+ clear()