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.
- nbxtensor/THIRD_PARTY_NOTICES.md +287 -0
- nbxtensor/__init__.py +17 -0
- nbxtensor/backend/__init__.py +1 -0
- nbxtensor/backend/capabilities.py +207 -0
- nbxtensor/backend/cpu/__init__.py +1 -0
- nbxtensor/backend/cpu/blas.py +102 -0
- nbxtensor/backend/cpu/pool.py +204 -0
- nbxtensor/backend/cpu_backend.py +162 -0
- nbxtensor/backend/device_facts.py +105 -0
- nbxtensor/backend/device_files.py +129 -0
- nbxtensor/backend/device_names.py +18 -0
- nbxtensor/backend/devices/amd/cdna.yml +228 -0
- nbxtensor/backend/devices/amd/cdna2.yml +228 -0
- nbxtensor/backend/devices/amd/cdna3.yml +227 -0
- nbxtensor/backend/devices/apple/apple_m1.yml +266 -0
- nbxtensor/backend/devices/apple/apple_m1_max.yml +266 -0
- nbxtensor/backend/devices/apple/apple_m1_pro.yml +266 -0
- nbxtensor/backend/devices/apple/apple_m1_ultra.yml +266 -0
- nbxtensor/backend/devices/apple/apple_m2.yml +266 -0
- nbxtensor/backend/devices/apple/apple_m2_max.yml +266 -0
- nbxtensor/backend/devices/apple/apple_m2_pro.yml +266 -0
- nbxtensor/backend/devices/apple/apple_m2_ultra.yml +266 -0
- nbxtensor/backend/devices/apple/apple_m3.yml +266 -0
- nbxtensor/backend/devices/apple/apple_m3_max.yml +266 -0
- nbxtensor/backend/devices/apple/apple_m3_pro.yml +266 -0
- nbxtensor/backend/devices/apple/apple_m3_ultra.yml +266 -0
- nbxtensor/backend/devices/apple/apple_m4.yml +266 -0
- nbxtensor/backend/devices/apple/apple_m4_max.yml +266 -0
- nbxtensor/backend/devices/apple/apple_m4_pro.yml +308 -0
- nbxtensor/backend/devices/apple/apple_m5.yml +269 -0
- nbxtensor/backend/devices/apple/apple_m5_max.yml +269 -0
- nbxtensor/backend/devices/apple/apple_m5_pro.yml +269 -0
- nbxtensor/backend/devices/apple/apple_m5_ultra.yml +269 -0
- nbxtensor/backend/devices/apple/apple_m6.yml +269 -0
- nbxtensor/backend/devices/apple/apple_silicon.yml +263 -0
- nbxtensor/backend/devices/nvidia/ampere.yml +214 -0
- nbxtensor/backend/devices/nvidia/hopper.yml +213 -0
- nbxtensor/backend/devices/nvidia/volta.yml +411 -0
- nbxtensor/backend/faults.py +151 -0
- nbxtensor/backend/host_memory.py +185 -0
- nbxtensor/backend/launcher.py +2350 -0
- nbxtensor/backend/launcher_contract.py +429 -0
- nbxtensor/backend/metal_backend.py +661 -0
- nbxtensor/backend/metal_device.py +1274 -0
- nbxtensor/backend/process_memory.py +73 -0
- nbxtensor/backend/replay_graph.py +322 -0
- nbxtensor/backend/runtime.py +409 -0
- nbxtensor/backend/triton_ext_driver.py +735 -0
- nbxtensor/dtype/__init__.py +1 -0
- nbxtensor/dtype/convert.py +107 -0
- nbxtensor/dtype/dtypes.yml +70 -0
- nbxtensor/dtype/itemsize.py +166 -0
- nbxtensor/dtype/nbx_dtype.py +72 -0
- nbxtensor/dtype/operands.py +158 -0
- nbxtensor/dtype/outputs.py +64 -0
- nbxtensor/dtype/policy.py +155 -0
- nbxtensor/dtype/policy.yml +51 -0
- nbxtensor/dtype/promotion.py +112 -0
- nbxtensor/host.py +55 -0
- nbxtensor/io/__init__.py +1 -0
- nbxtensor/io/read_rate.py +197 -0
- nbxtensor/io/safetensors.py +314 -0
- nbxtensor/memory/__init__.py +9 -0
- nbxtensor/memory/allocator.py +1988 -0
- nbxtensor/memory/arena.py +75 -0
- nbxtensor/memory/device_memory.py +150 -0
- nbxtensor/memory/device_transfer.py +154 -0
- nbxtensor/memory/layout.py +43 -0
- nbxtensor/memory/memory_pool.py +191 -0
- nbxtensor/memory/metal.py +96 -0
- nbxtensor/memory/oom_advice.py +91 -0
- nbxtensor/memory/pointer_tables.py +323 -0
- nbxtensor/memory/pool.py +507 -0
- nbxtensor/memory/shadow.py +43 -0
- nbxtensor/memory/slab.py +273 -0
- nbxtensor/memory/trace_replay.py +122 -0
- nbxtensor/memory/traces/sana-video-2b-720p.16g.2026-11-02.tsv +13610 -0
- nbxtensor/memory/vmm.py +118 -0
- nbxtensor/ops/__init__.py +1 -0
- nbxtensor/ops/_autotune_policy.py +90 -0
- nbxtensor/ops/_common.py +32 -0
- nbxtensor/ops/_configs.py +1041 -0
- nbxtensor/ops/_cost.py +231 -0
- nbxtensor/ops/abs.py +17 -0
- nbxtensor/ops/adaptive_avg_pool2d.py +161 -0
- nbxtensor/ops/add.py +96 -0
- nbxtensor/ops/addcdiv.py +29 -0
- nbxtensor/ops/addcmul.py +29 -0
- nbxtensor/ops/addmv_op.py +64 -0
- nbxtensor/ops/addr.py +68 -0
- nbxtensor/ops/all_reduce.py +83 -0
- nbxtensor/ops/amax.py +40 -0
- nbxtensor/ops/any_reduce.py +83 -0
- nbxtensor/ops/arange_op.py +27 -0
- nbxtensor/ops/argmax.py +107 -0
- nbxtensor/ops/argmin.py +93 -0
- nbxtensor/ops/atan2.py +71 -0
- nbxtensor/ops/attention_wrappers.py +1648 -0
- nbxtensor/ops/autotune_bucket.py +137 -0
- nbxtensor/ops/autotune_cache.py +430 -0
- nbxtensor/ops/autotune_certified.py +1262 -0
- nbxtensor/ops/autotune_refusals.py +380 -0
- nbxtensor/ops/autotune_settings.py +58 -0
- nbxtensor/ops/avg_pool2d.py +73 -0
- nbxtensor/ops/baddbmm_op.py +132 -0
- nbxtensor/ops/batch_norm.py +163 -0
- nbxtensor/ops/binary_strided.py +196 -0
- nbxtensor/ops/bitwise_and.py +26 -0
- nbxtensor/ops/bitwise_left_shift.py +47 -0
- nbxtensor/ops/bitwise_not.py +24 -0
- nbxtensor/ops/bitwise_or.py +26 -0
- nbxtensor/ops/bitwise_right_shift.py +47 -0
- nbxtensor/ops/bucketize.py +37 -0
- nbxtensor/ops/cat_op.py +87 -0
- nbxtensor/ops/celu.py +20 -0
- nbxtensor/ops/clamp.py +24 -0
- nbxtensor/ops/clamp_min.py +22 -0
- nbxtensor/ops/conv2d.py +265 -0
- nbxtensor/ops/conv2d_m8n8k4.py +165 -0
- nbxtensor/ops/conv_depthwise2d.py +154 -0
- nbxtensor/ops/conv_transpose2d.py +148 -0
- nbxtensor/ops/conv_wrappers.py +1482 -0
- nbxtensor/ops/copy.py +18 -0
- nbxtensor/ops/copy_op.py +39 -0
- nbxtensor/ops/cos.py +17 -0
- nbxtensor/ops/count_nonzero.py +64 -0
- nbxtensor/ops/cross_entropy.py +67 -0
- nbxtensor/ops/cummax.py +109 -0
- nbxtensor/ops/cummin.py +105 -0
- nbxtensor/ops/cumsum.py +244 -0
- nbxtensor/ops/decode_attn_vec.py +162 -0
- nbxtensor/ops/depthwise_conv2d.py +132 -0
- nbxtensor/ops/dequant_gemv.py +190 -0
- nbxtensor/ops/dft.py +67 -0
- nbxtensor/ops/dispatch.py +1228 -0
- nbxtensor/ops/div.py +75 -0
- nbxtensor/ops/dot_op.py +67 -0
- nbxtensor/ops/dropout.py +23 -0
- nbxtensor/ops/dtype_convert.py +42 -0
- nbxtensor/ops/elementwise_launch.py +81 -0
- nbxtensor/ops/elementwise_wrappers.py +2315 -0
- nbxtensor/ops/elu.py +20 -0
- nbxtensor/ops/embedding.py +69 -0
- nbxtensor/ops/eq.py +32 -0
- nbxtensor/ops/erf.py +18 -0
- nbxtensor/ops/exp.py +17 -0
- nbxtensor/ops/exp2.py +18 -0
- nbxtensor/ops/fft_op.py +125 -0
- nbxtensor/ops/fill_op.py +20 -0
- nbxtensor/ops/flash_attention.py +307 -0
- nbxtensor/ops/flash_attention_m8n8k4.py +384 -0
- nbxtensor/ops/flash_decode.py +78 -0
- nbxtensor/ops/flip_op.py +60 -0
- nbxtensor/ops/floor.py +90 -0
- nbxtensor/ops/full_op.py +24 -0
- nbxtensor/ops/fused_moe.py +417 -0
- nbxtensor/ops/fused_moe_m8n8k4.py +99 -0
- nbxtensor/ops/fused_upsample_conv.py +413 -0
- nbxtensor/ops/gather_op.py +52 -0
- nbxtensor/ops/ge.py +32 -0
- nbxtensor/ops/gelu.py +31 -0
- nbxtensor/ops/gemv_vec.py +80 -0
- nbxtensor/ops/glu.py +43 -0
- nbxtensor/ops/grid_sampler.py +145 -0
- nbxtensor/ops/group_norm_tiled.py +67 -0
- nbxtensor/ops/groupnorm.py +123 -0
- nbxtensor/ops/gt.py +32 -0
- nbxtensor/ops/hardsigmoid.py +18 -0
- nbxtensor/ops/hardswish.py +19 -0
- nbxtensor/ops/im2col.py +57 -0
- nbxtensor/ops/index_add.py +95 -0
- nbxtensor/ops/index_put_op.py +92 -0
- nbxtensor/ops/index_select.py +121 -0
- nbxtensor/ops/index_wrappers.py +770 -0
- nbxtensor/ops/isfinite.py +20 -0
- nbxtensor/ops/isinf.py +20 -0
- nbxtensor/ops/isnan.py +18 -0
- nbxtensor/ops/launch_keys.py +1054 -0
- nbxtensor/ops/launch_observer.py +54 -0
- nbxtensor/ops/layernorm.py +71 -0
- nbxtensor/ops/le.py +35 -0
- nbxtensor/ops/leaky_relu.py +19 -0
- nbxtensor/ops/lerp.py +76 -0
- nbxtensor/ops/linalg_wrappers.py +1370 -0
- nbxtensor/ops/linspace_op.py +47 -0
- nbxtensor/ops/log.py +17 -0
- nbxtensor/ops/log_sigmoid.py +23 -0
- nbxtensor/ops/logical_and.py +26 -0
- nbxtensor/ops/logical_not.py +24 -0
- nbxtensor/ops/logical_or.py +26 -0
- nbxtensor/ops/logical_xor.py +37 -0
- nbxtensor/ops/loss_wrappers.py +282 -0
- nbxtensor/ops/lt.py +32 -0
- nbxtensor/ops/masked_fill.py +21 -0
- nbxtensor/ops/matmul.py +377 -0
- nbxtensor/ops/matmul_m8n8k4.py +235 -0
- nbxtensor/ops/max_pool2d.py +74 -0
- nbxtensor/ops/max_pool3d.py +93 -0
- nbxtensor/ops/max_reduce.py +91 -0
- nbxtensor/ops/maximum.py +18 -0
- nbxtensor/ops/mean.py +40 -0
- nbxtensor/ops/min_reduce.py +91 -0
- nbxtensor/ops/minimum.py +18 -0
- nbxtensor/ops/mish.py +22 -0
- nbxtensor/ops/mma_m8n8k4.py +152 -0
- nbxtensor/ops/moe_align.py +195 -0
- nbxtensor/ops/moe_decode_vec.py +374 -0
- nbxtensor/ops/moe_device_residency.py +141 -0
- nbxtensor/ops/moe_wrappers.py +345 -0
- nbxtensor/ops/mse_loss.py +79 -0
- nbxtensor/ops/mul.py +51 -0
- nbxtensor/ops/nan_to_num.py +23 -0
- nbxtensor/ops/ne.py +32 -0
- nbxtensor/ops/neg.py +17 -0
- nbxtensor/ops/nllloss.py +124 -0
- nbxtensor/ops/nonzero.py +33 -0
- nbxtensor/ops/nonzero_op.py +62 -0
- nbxtensor/ops/norm_wrappers.py +579 -0
- nbxtensor/ops/ones_op.py +20 -0
- nbxtensor/ops/oracles/__init__.py +6 -0
- nbxtensor/ops/oracles/conv2d_fp64.py +450 -0
- nbxtensor/ops/pad_op.py +159 -0
- nbxtensor/ops/paged_decode_attn.py +215 -0
- nbxtensor/ops/pixel_shuffle.py +144 -0
- nbxtensor/ops/pixel_unshuffle.py +72 -0
- nbxtensor/ops/pow.py +137 -0
- nbxtensor/ops/prod.py +81 -0
- nbxtensor/ops/rand_op.py +52 -0
- nbxtensor/ops/random_wrappers.py +366 -0
- nbxtensor/ops/reciprocal.py +17 -0
- nbxtensor/ops/reduction_wrappers.py +845 -0
- nbxtensor/ops/relu.py +18 -0
- nbxtensor/ops/remainder.py +46 -0
- nbxtensor/ops/repeat_interleave.py +48 -0
- nbxtensor/ops/repeat_op.py +132 -0
- nbxtensor/ops/residual_chain.py +374 -0
- nbxtensor/ops/rmsnorm.py +68 -0
- nbxtensor/ops/rope.py +142 -0
- nbxtensor/ops/rsqrt.py +17 -0
- nbxtensor/ops/scaled_softmax.py +96 -0
- nbxtensor/ops/scatter_op.py +139 -0
- nbxtensor/ops/screen_oracle.py +479 -0
- nbxtensor/ops/selu.py +22 -0
- nbxtensor/ops/shape_wrappers.py +1032 -0
- nbxtensor/ops/sigmoid.py +19 -0
- nbxtensor/ops/silu.py +27 -0
- nbxtensor/ops/sin.py +17 -0
- nbxtensor/ops/softmax.py +169 -0
- nbxtensor/ops/softplus.py +27 -0
- nbxtensor/ops/sort_op.py +304 -0
- nbxtensor/ops/spectral_wrappers.py +481 -0
- nbxtensor/ops/sqrt.py +17 -0
- nbxtensor/ops/stack_op.py +84 -0
- nbxtensor/ops/std.py +113 -0
- nbxtensor/ops/strided_copy.py +200 -0
- nbxtensor/ops/sub.py +35 -0
- nbxtensor/ops/sum.py +40 -0
- nbxtensor/ops/tan.py +19 -0
- nbxtensor/ops/tanh.py +21 -0
- nbxtensor/ops/threshold.py +19 -0
- nbxtensor/ops/tiling.yml +91 -0
- nbxtensor/ops/tiling_policy.py +43 -0
- nbxtensor/ops/tiling_sizes.py +370 -0
- nbxtensor/ops/topk.py +267 -0
- nbxtensor/ops/trace_op.py +64 -0
- nbxtensor/ops/tril.py +64 -0
- nbxtensor/ops/triu.py +62 -0
- nbxtensor/ops/unfold_op.py +37 -0
- nbxtensor/ops/unique_op.py +97 -0
- nbxtensor/ops/upsample_bilinear2d.py +106 -0
- nbxtensor/ops/upsample_nearest2d.py +52 -0
- nbxtensor/ops/upsample_nearest3d.py +61 -0
- nbxtensor/ops/var.py +136 -0
- nbxtensor/ops/vector_norm.py +216 -0
- nbxtensor/ops/weight_norm.py +104 -0
- nbxtensor/ops/where.py +39 -0
- nbxtensor/ops/wrappers.py +825 -0
- nbxtensor/ops/zeros_op.py +20 -0
- nbxtensor/quant/__init__.py +1 -0
- nbxtensor/quant/quantized_tensor.py +187 -0
- nbxtensor/random/__init__.py +1 -0
- nbxtensor/random/mt_randn.py +154 -0
- nbxtensor/random/rng_pin.py +59 -0
- nbxtensor/random/rng_stream.py +118 -0
- nbxtensor/random/seeded_draw.py +223 -0
- nbxtensor/shape/__init__.py +157 -0
- nbxtensor/tensor.py +1867 -0
- nbxtensor-0.0.1.dist-info/METADATA +379 -0
- nbxtensor-0.0.1.dist-info/RECORD +291 -0
- nbxtensor-0.0.1.dist-info/WHEEL +4 -0
- nbxtensor-0.0.1.dist-info/licenses/LICENSE +189 -0
|
@@ -0,0 +1,735 @@
|
|
|
1
|
+
"""NeuroBrix's launcher driver for triton-lang/triton-ext's AppleGPU backend.
|
|
2
|
+
|
|
3
|
+
Why this file exists, in one sentence: a driver implements ONE backend's launch
|
|
4
|
+
ABI, and triton-ext's is not the fork's.
|
|
5
|
+
|
|
6
|
+
The fork binds each scalar to its own Metal argument slot. triton-ext packs every
|
|
7
|
+
scalar into a SINGLE device buffer and binds it after the pointers — its own
|
|
8
|
+
`MetalLauncher` says so:
|
|
9
|
+
|
|
10
|
+
# Scalars pack into one device buffer, so IR param order is
|
|
11
|
+
# [pointers, packed_scalar_buf, system_values].
|
|
12
|
+
|
|
13
|
+
Launching one backend's kernel through the other's driver therefore lands the
|
|
14
|
+
first scalar and zeroes the rest, with nothing raised: every mask goes false,
|
|
15
|
+
every `tl.load` takes its `other`, and the `tl.store` is fully masked, so the
|
|
16
|
+
output buffer keeps the zeros it was allocated with. That is the exact-zero
|
|
17
|
+
signature measured on conv2d 1x1 across all 18 configs (see the campaign ledger,
|
|
18
|
+
`abi_scalars.py`: the kernel saw `HW,C,K = [4096, 0, 0]` for `[4096, 180, 180]`).
|
|
19
|
+
|
|
20
|
+
Two rules this file keeps:
|
|
21
|
+
|
|
22
|
+
* **We do not re-derive their ABI.** The scalar layout comes from triton-ext's own
|
|
23
|
+
`_compute_scalar_layout`/`_SCALAR_PACK_INFO`, so a change on their side is a
|
|
24
|
+
change here, not a silent divergence. We follow the line of Triton.
|
|
25
|
+
* **No torch.** triton-ext ships two dispatch runtimes and prefers the libtorch
|
|
26
|
+
one whenever torch is merely installed; R33 forbids that, so this driver pins
|
|
27
|
+
the torch-free `_NativeRuntime`. Upstream has no supported switch for it —
|
|
28
|
+
that is an owed upstream contribution, with `spike_native_runtime.py` as its
|
|
29
|
+
reproducer.
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import ctypes
|
|
34
|
+
import struct
|
|
35
|
+
from typing import Any, List, NamedTuple, Optional, Sequence, Tuple
|
|
36
|
+
|
|
37
|
+
from nbxtensor.backend.launcher import Driver
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _ext():
|
|
41
|
+
"""triton-ext's driver module, with the torch-free runtime pinned.
|
|
42
|
+
|
|
43
|
+
`_runtime()` chooses `_TorchRuntime` whenever torch can be imported and
|
|
44
|
+
offers no override, so the choice is made here before anything builds one.
|
|
45
|
+
"""
|
|
46
|
+
from triton_apple_backend import driver as D
|
|
47
|
+
if not isinstance(getattr(D, "_RUNTIME", None), D._NativeRuntime):
|
|
48
|
+
D._RUNTIME = D._NativeRuntime()
|
|
49
|
+
return D
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_SYNC_TIMING = __import__("os").environ.get("NBX_SYNC_TIMING", "0") == "1"
|
|
53
|
+
_SYNC_ACC = [0.0, 0.0, 0.0, 0] # drain, submit, synchronize, count
|
|
54
|
+
_SYNC_PER_KERNEL = {} # name -> [calls, seconds inside synchronize]
|
|
55
|
+
if _SYNC_TIMING: # pragma: no cover
|
|
56
|
+
import atexit as _ax
|
|
57
|
+
|
|
58
|
+
@_ax.register
|
|
59
|
+
def _report_sync():
|
|
60
|
+
d, s_, y, n = _SYNC_ACC
|
|
61
|
+
if not n:
|
|
62
|
+
return
|
|
63
|
+
print(f"[sync-timing] launches={n} drain={d:.3f}s submit={s_:.3f}s "
|
|
64
|
+
f"synchronize={y:.3f}s (per launch: drain {1000*d/n:.3f}ms "
|
|
65
|
+
f"submit {1000*s_/n:.3f}ms sync {1000*y/n:.3f}ms)", flush=True)
|
|
66
|
+
if not _SYNC_PER_KERNEL:
|
|
67
|
+
return
|
|
68
|
+
rows = sorted(_SYNC_PER_KERNEL.items(), key=lambda kv: -kv[1][1])
|
|
69
|
+
tot = sum(v[1] for v in _SYNC_PER_KERNEL.values()) or 1.0
|
|
70
|
+
print(f"[sync-timing] per operator, by time inside synchronize:", flush=True)
|
|
71
|
+
print(f"[sync-timing] {'kernel':<44} {'calls':>7} {'sync s':>9} "
|
|
72
|
+
f"{'ms/call':>9} {'share':>7}", flush=True)
|
|
73
|
+
for name, (c, t) in rows[:18]:
|
|
74
|
+
print(f"[sync-timing] {name[:44]:<44} {c:>7} {t:>9.3f} "
|
|
75
|
+
f"{1000*t/max(c,1):>9.3f} {100*t/tot:>6.1f}%", flush=True)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _native():
|
|
79
|
+
from triton_apple_backend import metal_native
|
|
80
|
+
return metal_native
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
#: Our `_pack_param` kinds -> the width, in bytes, of the value it produced.
|
|
84
|
+
#: A float arrives as the bit pattern of its storage type and an int as itself,
|
|
85
|
+
#: so both are written as an unsigned field of this width. Re-packing through
|
|
86
|
+
#: `struct.pack('<f', ...)` would treat a bit pattern as a number.
|
|
87
|
+
_KIND_WIDTH = {
|
|
88
|
+
"bits16": 2, "bits32": 4, "bits64": 8,
|
|
89
|
+
"i1": 1, "i8": 1, "i16": 2, "i32": 4, "i64": 8,
|
|
90
|
+
"u1": 1, "u8": 1, "u16": 2, "u32": 4, "u64": 8,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class TritonExtDriver(Driver):
|
|
95
|
+
"""Loads a metallib and launches it the way triton-ext's own launcher does."""
|
|
96
|
+
|
|
97
|
+
#: triton-ext emits a compiled `.metallib`, not text MSL (`compiler.py`
|
|
98
|
+
#: stages: "msl" then "metallib"). The fork emits the text.
|
|
99
|
+
artifact_kind = "metallib"
|
|
100
|
+
|
|
101
|
+
#: Their launcher builds its argument mask from the kernel SIGNATURE, so two
|
|
102
|
+
#: extra scratch pointers would trip its own arity check:
|
|
103
|
+
#: "flat arg count does not match signature keep-mask length".
|
|
104
|
+
wants_scratch_params = False
|
|
105
|
+
|
|
106
|
+
#: This driver binds a pointer by wrapping memory, and a wrap costs what it maps (~11.5 ms per GiB on M4 Pro,
|
|
107
|
+
#: 2026-10-04): it asks the launcher for each tensor's addressable extent so it wraps that, not the rest of the
|
|
108
|
+
#: allocation the tensor lives in.
|
|
109
|
+
wants_extents = True
|
|
110
|
+
|
|
111
|
+
_instance: Optional["TritonExtDriver"] = None
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def instance(cls) -> "TritonExtDriver":
|
|
115
|
+
if cls._instance is None:
|
|
116
|
+
cls._instance = cls()
|
|
117
|
+
return cls._instance
|
|
118
|
+
|
|
119
|
+
# -- compile target ----------------------------------------------------
|
|
120
|
+
def target(self):
|
|
121
|
+
# The same target the fork's driver builds: the seam owns the NAME
|
|
122
|
+
# ("metal" for the fork, "mps" for triton-ext) and the device owns the
|
|
123
|
+
# arch. Building it here again would be a second answer to one question.
|
|
124
|
+
from nbxtensor.backend.metal_backend import metal_target
|
|
125
|
+
return metal_target()
|
|
126
|
+
|
|
127
|
+
def max_shared_memory_per_block(self) -> int:
|
|
128
|
+
"""triton-ext's own threadgroup budget, by the name it actually uses.
|
|
129
|
+
|
|
130
|
+
This read `getattr(hw_constants, "MAX_THREADGROUP_MEMORY", 32768)` until
|
|
131
|
+
2026-09-17. **That name does not exist in triton-ext** — the module
|
|
132
|
+
declares `TG_BUDGET_BYTES`, `WARP_SIZE`, `SG_FRAG_DIM`, `TARGET`,
|
|
133
|
+
`target_arch` and nothing else — so every call took the literal, and the
|
|
134
|
+
launcher pruned configurations against a number no backend had said.
|
|
135
|
+
The same class as a literal standing in for a runtime value, in the one
|
|
136
|
+
shape that hides best: a `getattr` default that is never not taken.
|
|
137
|
+
|
|
138
|
+
It was right by luck. Measured the day it was found, M4 Pro:
|
|
139
|
+
|
|
140
|
+
hw_constants.TG_BUDGET_BYTES 32768
|
|
141
|
+
MTLDevice.maxThreadgroupMemoryLength 32768
|
|
142
|
+
|
|
143
|
+
so the number does not move; only the authority for it does. The
|
|
144
|
+
backend binds the same constant into its own launch metadata
|
|
145
|
+
(`driver.py:238`, `"max_shared_mem": _TG_BUDGET_BYTES`), which is what
|
|
146
|
+
makes it the right thing to ask.
|
|
147
|
+
|
|
148
|
+
A backend that stops declaring it refuses here, and
|
|
149
|
+
`max_shared_memory_per_block()` in the launcher turns that into None —
|
|
150
|
+
"do not prune" — which is the safe answer. Inventing one is not.
|
|
151
|
+
"""
|
|
152
|
+
from triton_apple_backend import hw_constants
|
|
153
|
+
budget = getattr(hw_constants, "TG_BUDGET_BYTES", None)
|
|
154
|
+
if budget is None:
|
|
155
|
+
raise RuntimeError(
|
|
156
|
+
"triton_apple_backend.hw_constants declares no TG_BUDGET_BYTES; "
|
|
157
|
+
"the threadgroup-memory ceiling is the backend's to state and "
|
|
158
|
+
"this driver will not invent one. Declared names: "
|
|
159
|
+
f"{sorted(n for n in dir(hw_constants) if not n.startswith('_'))}")
|
|
160
|
+
return int(budget)
|
|
161
|
+
|
|
162
|
+
def block_for(self, metadata):
|
|
163
|
+
"""Threads per threadgroup, as their launcher computes it: `lx` is
|
|
164
|
+
`num_warps * 32`, and ly/lz are 1 — Metal has no second launch axis
|
|
165
|
+
here."""
|
|
166
|
+
return (32 * int(getattr(metadata, "num_warps", 4)), 1, 1)
|
|
167
|
+
|
|
168
|
+
# -- load --------------------------------------------------------------
|
|
169
|
+
def load(self, binary: bytes, name: str, shared: int):
|
|
170
|
+
module = _native().load_metallib(bytes(binary))
|
|
171
|
+
fn = module.get_function(name)
|
|
172
|
+
import os as _os
|
|
173
|
+
if _os.environ.get("NBX_EXT_DEBUG"):
|
|
174
|
+
print(f"[ext] load name={name!r} metallib={len(bytes(binary))}B "
|
|
175
|
+
f"fn={fn} max_threads="
|
|
176
|
+
f"{getattr(fn, 'max_total_threads_per_threadgroup', None)}", flush=True)
|
|
177
|
+
return fn
|
|
178
|
+
|
|
179
|
+
# -- launch ------------------------------------------------------------
|
|
180
|
+
def trailing_buffers(self, metadata):
|
|
181
|
+
"""What triton-ext's emitter appends to a kernel's own parameters.
|
|
182
|
+
|
|
183
|
+
Its compiler writes two descriptor blocks into the emitted MSL and
|
|
184
|
+
carries them in the metadata (`compiler.py`: `metadata["print_layout"]`,
|
|
185
|
+
`metadata["assert_layout"]`, from `AGPU-PRINT-LAYOUT` / `AGPU-ASSERT-LAYOUT`).
|
|
186
|
+
When a block is present the kernel has ONE MORE buffer parameter than
|
|
187
|
+
its Triton signature declares, and triton-ext's own driver binds it
|
|
188
|
+
last, print before assert.
|
|
189
|
+
|
|
190
|
+
We bound neither until 2026-09-17, and `metal_native.packArguments`
|
|
191
|
+
binds the tuple it is handed at indices 0..n-1 without ever comparing
|
|
192
|
+
the count to what the kernel declares — so the slot simply stayed
|
|
193
|
+
unbound. Measured that day (`probe_ki19_driver_protections.py`): a
|
|
194
|
+
`tl.device_assert` that FAILS on every lane ran to completion and raised
|
|
195
|
+
nothing, while the same kernel's ordinary output was correct. A lost
|
|
196
|
+
assert is the worst shape of that: the guard is paid for and not
|
|
197
|
+
delivered, which is the same fault class as the conv-1x1 zeros.
|
|
198
|
+
|
|
199
|
+
Four of NeuroBrix's own kernels are compiled `@triton.jit(debug=True)`
|
|
200
|
+
precisely to keep such an assert (`embedding`, `index_select` x2,
|
|
201
|
+
`index_put`), so this is not a hypothetical parameter.
|
|
202
|
+
|
|
203
|
+
Returns None when the kernel declares neither block — the common case,
|
|
204
|
+
and then `launch` does exactly what it did before.
|
|
205
|
+
"""
|
|
206
|
+
from triton_apple_backend.device_assert import parse_assert_layout
|
|
207
|
+
from triton_apple_backend.device_print import parse_print_layout
|
|
208
|
+
pl = parse_print_layout(getattr(metadata, "print_layout", None))
|
|
209
|
+
al = parse_assert_layout(getattr(metadata, "assert_layout", None))
|
|
210
|
+
# The two address flags triton-ext's compiler records (`compiler.py`:
|
|
211
|
+
# `metadata["exposes_addresses"]`, `metadata["reads_addresses"]`) and its
|
|
212
|
+
# own launcher passes to every call: a kernel that stores a pointer's
|
|
213
|
+
# bits exposes its buffers, and one that reads through such bits must
|
|
214
|
+
# name the exposed buffers to Metal, which keeps resident only what the
|
|
215
|
+
# encoder names. We passed neither until 2026-10-04.
|
|
216
|
+
exposes = bool(getattr(metadata, "exposes_addresses", False))
|
|
217
|
+
reads = bool(getattr(metadata, "reads_addresses", False))
|
|
218
|
+
if pl is None and al is None and not exposes and not reads:
|
|
219
|
+
return None
|
|
220
|
+
return _Trailing(pl, al, exposes, reads)
|
|
221
|
+
|
|
222
|
+
def launch(self, function, grid, block, shared: int, stream: int,
|
|
223
|
+
params: Sequence[Tuple[str, Any]], names=None, types=None,
|
|
224
|
+
trailing=None, extents=None) -> None:
|
|
225
|
+
if types is None:
|
|
226
|
+
raise RuntimeError(
|
|
227
|
+
"the triton-ext driver needs the Triton type of every launch "
|
|
228
|
+
"parameter: its scalars share one packed buffer whose field "
|
|
229
|
+
"offsets come from those types. Launching without them would "
|
|
230
|
+
"pack to the wrong offsets WITHOUT failing, which is the exact "
|
|
231
|
+
"silent-zero this driver exists to end.")
|
|
232
|
+
|
|
233
|
+
# The argument list must be exactly what the kernel declares. `zip`
|
|
234
|
+
# below stops at the shorter of the two, so a short list would bind
|
|
235
|
+
# what it could and launch — silently, with the tail unbound. CUDA has
|
|
236
|
+
# refused this since the launcher existed (`CudaDriver.launch` compares
|
|
237
|
+
# against the cubin's own count); this driver did not, and the
|
|
238
|
+
# launcher contract caught it the first time it was run against a
|
|
239
|
+
# driver that is not the archived fork's (2026-09-17).
|
|
240
|
+
if len(params) != len(types):
|
|
241
|
+
raise RuntimeError(
|
|
242
|
+
f"NeuroBrix triton-ext driver: {len(params)} launch parameters "
|
|
243
|
+
f"against {len(types)} declared types — refused, not launched. "
|
|
244
|
+
f"Binding the shorter of the two leaves the rest unbound and "
|
|
245
|
+
f"says nothing.")
|
|
246
|
+
|
|
247
|
+
D = _ext()
|
|
248
|
+
ptr_args: List[Any] = []
|
|
249
|
+
scalar_types: List[str] = []
|
|
250
|
+
scalar_vals: List[Tuple[str, int]] = []
|
|
251
|
+
|
|
252
|
+
extents = list(extents) if extents is not None else [None] * len(params)
|
|
253
|
+
for ((kind, value), ty), extent in zip(zip(params, types), extents):
|
|
254
|
+
if kind == "ptr":
|
|
255
|
+
ptr_args.append(_buffer_for(int(value), ty, extent))
|
|
256
|
+
else:
|
|
257
|
+
scalar_types.append(ty)
|
|
258
|
+
scalar_vals.append((kind, int(value)))
|
|
259
|
+
|
|
260
|
+
args: Tuple[Any, ...] = tuple(ptr_args)
|
|
261
|
+
if scalar_types:
|
|
262
|
+
total, offsets = D._compute_scalar_layout(scalar_types)
|
|
263
|
+
limit = getattr(D, "_SETBYTES_LIMIT", 4096)
|
|
264
|
+
if total > limit:
|
|
265
|
+
raise RuntimeError(
|
|
266
|
+
f"packed scalar args are {total} bytes, over Metal's "
|
|
267
|
+
f"{limit}-byte setBytes limit")
|
|
268
|
+
buf = bytearray(total)
|
|
269
|
+
for (kind, val), ty, off in zip(scalar_vals, scalar_types, offsets):
|
|
270
|
+
width = _KIND_WIDTH.get(kind)
|
|
271
|
+
if width is None:
|
|
272
|
+
raise RuntimeError(
|
|
273
|
+
f"the triton-ext driver cannot pack a parameter of kind "
|
|
274
|
+
f"{kind!r} (Triton type {ty!r})")
|
|
275
|
+
fmt = {1: "<B", 2: "<H", 4: "<I", 8: "<Q"}[width]
|
|
276
|
+
struct.pack_into(fmt, buf, off, val & ((1 << (8 * width)) - 1))
|
|
277
|
+
args = args + (bytes(buf),)
|
|
278
|
+
|
|
279
|
+
gx, gy, gz = grid
|
|
280
|
+
lx, ly, lz = block
|
|
281
|
+
import os as _os
|
|
282
|
+
if _os.environ.get("NBX_EXT_DEBUG"):
|
|
283
|
+
print(f"[ext] ptrs={len(ptr_args)} scalars={list(zip(scalar_types, scalar_vals))} "
|
|
284
|
+
f"packed={(args[-1].hex() if scalar_types else None)} "
|
|
285
|
+
f"threads={[gx*lx, gy*ly, gz*lz]} group_size={[lx, ly, lz]} "
|
|
286
|
+
f"grid={grid} block={block} shared={shared}", flush=True)
|
|
287
|
+
# The emitter's own trailing parameters, bound LAST and in its order
|
|
288
|
+
# (print, then assert), which is what `planKernelAbi` fixed. Both must
|
|
289
|
+
# start zeroed: each block's head word is a running count the kernel
|
|
290
|
+
# bumps.
|
|
291
|
+
print_buf = assert_buf = None
|
|
292
|
+
addr_flags = {}
|
|
293
|
+
if trailing is not None:
|
|
294
|
+
if trailing.exposes_addresses:
|
|
295
|
+
addr_flags["exposes_addresses"] = True
|
|
296
|
+
if trailing.reads_addresses:
|
|
297
|
+
addr_flags["reads_addresses"] = True
|
|
298
|
+
if trailing is not None:
|
|
299
|
+
rt = D._runtime() # D is the pinned torch-free runtime's module
|
|
300
|
+
if trailing.print_layout is not None:
|
|
301
|
+
print_buf = rt.zeros_i32(trailing.print_layout.nbytes // 4)
|
|
302
|
+
args = args + (print_buf,)
|
|
303
|
+
if trailing.assert_layout is not None:
|
|
304
|
+
assert_buf = rt.zeros_i32(trailing.assert_layout.nbytes // 4)
|
|
305
|
+
args = args + (assert_buf,)
|
|
306
|
+
|
|
307
|
+
# Two queues, one device, shared buffers. Metal orders command buffers
|
|
308
|
+
# within a queue; ACROSS queues nothing is ordered without an explicit
|
|
309
|
+
# event (Apple: a fence cannot synchronize untracked resources accessed
|
|
310
|
+
# from separate queues; MTLEvent/MTLSharedEvent is the mechanism).
|
|
311
|
+
#
|
|
312
|
+
# NeuroBrix enqueues its device-to-device copies as blits on ITS queue
|
|
313
|
+
# and returns to the host immediately — correct while the kernels shared
|
|
314
|
+
# that queue, which is what `metal_device._blit` says in as many words:
|
|
315
|
+
# "a blit on the same queue as the kernels is ordered by the GPU and
|
|
316
|
+
# waits for nothing". triton-ext breaks that premise: its kernels run on
|
|
317
|
+
# its own queue, so a blit that has not landed is invisible to them.
|
|
318
|
+
#
|
|
319
|
+
# Measured on swin2SR (M4 Pro, 2026-09-17): without this drain, the conv
|
|
320
|
+
# at (1,180,448,448)->64 read 63181 NaN / 356 Inf / absmax 3.39e+38 from
|
|
321
|
+
# an input that is `absmax 1.492` and finite in every ordered run, and
|
|
322
|
+
# the fp64 screen refused all 18 candidates. A host round trip inserted
|
|
323
|
+
# before the launch made the same run clean, which is what identified
|
|
324
|
+
# the ordering rather than the arithmetic.
|
|
325
|
+
with _launch_pool(addr_flags):
|
|
326
|
+
if _SYNC_TIMING:
|
|
327
|
+
import time as _t
|
|
328
|
+
_a = _t.perf_counter(); _nbx_queue_drain()
|
|
329
|
+
_b = _t.perf_counter()
|
|
330
|
+
function(*args, threads=[gx * lx, gy * ly, gz * lz], group_size=[lx, ly, lz], **addr_flags)
|
|
331
|
+
_c = _t.perf_counter(); _native().synchronize(); _d = _t.perf_counter()
|
|
332
|
+
_SYNC_ACC[0] += _b - _a; _SYNC_ACC[1] += _c - _b
|
|
333
|
+
_SYNC_ACC[2] += _d - _c; _SYNC_ACC[3] += 1
|
|
334
|
+
_kn = getattr(function, "name", None) or type(function).__name__
|
|
335
|
+
_row = _SYNC_PER_KERNEL.get(_kn)
|
|
336
|
+
if _row is None:
|
|
337
|
+
_SYNC_PER_KERNEL[_kn] = [1, _d - _c]
|
|
338
|
+
else:
|
|
339
|
+
_row[0] += 1; _row[1] += _d - _c
|
|
340
|
+
else:
|
|
341
|
+
_nbx_queue_drain()
|
|
342
|
+
function(*args, threads=[gx * lx, gy * ly, gz * lz], group_size=[lx, ly, lz], **addr_flags)
|
|
343
|
+
# And the other direction: the host (and NeuroBrix's own blits) must
|
|
344
|
+
# see what this kernel wrote.
|
|
345
|
+
_native().synchronize()
|
|
346
|
+
|
|
347
|
+
# Read what the kernel recorded. Prints first, so anything it printed is
|
|
348
|
+
# already out when a failed assert raises.
|
|
349
|
+
if print_buf is not None:
|
|
350
|
+
from triton_apple_backend.device_print import format_records
|
|
351
|
+
rt = D._runtime()
|
|
352
|
+
for line in format_records(trailing.print_layout, rt.as_u32(print_buf)):
|
|
353
|
+
print(line, flush=True)
|
|
354
|
+
if assert_buf is not None:
|
|
355
|
+
from triton_apple_backend.device_assert import check as _check_asserts
|
|
356
|
+
rt = D._runtime()
|
|
357
|
+
_check_asserts(trailing.assert_layout, rt.as_u32(assert_buf))
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _launch_pool(addr_flags):
|
|
361
|
+
"""The scope a kernel call and its drain run in: an autorelease pool when the kernel reads addresses.
|
|
362
|
+
|
|
363
|
+
Such a kernel is launched with the exposed buffers named to Metal: triton-ext builds that list as
|
|
364
|
+
`[exposedBuffers() allObjects]`, an AUTORELEASED array, and a Python thread has no pool around the call, so
|
|
365
|
+
it is never released and it holds every exposed buffer. Ours is the pinned scope's wrap of a whole
|
|
366
|
+
allocation: one such launch kept the arena in the process for good (2 116 MB of a freed 2 GiB arena; with
|
|
367
|
+
the launch inside a pool, 69-78 MB; deepseek-moe streamed went 11 -> 20 GB of footprint with one segment in
|
|
368
|
+
the allocator's books, 2026-10-04). Every other launch keeps the path it had.
|
|
369
|
+
"""
|
|
370
|
+
if addr_flags.get("reads_addresses"):
|
|
371
|
+
import objc
|
|
372
|
+
return objc.autorelease_pool()
|
|
373
|
+
import contextlib
|
|
374
|
+
return contextlib.nullcontext()
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
class _Trailing(NamedTuple):
|
|
378
|
+
"""The emitter-declared buffers that follow a kernel's own parameters.
|
|
379
|
+
|
|
380
|
+
Held as the PARSED layouts rather than the raw text: parsing is a regex
|
|
381
|
+
walk over the whole emitted module, and it is a property of the
|
|
382
|
+
compilation, so it happens once in `trailing_buffers` and never on the
|
|
383
|
+
launch path.
|
|
384
|
+
"""
|
|
385
|
+
print_layout: Any
|
|
386
|
+
assert_layout: Any
|
|
387
|
+
#: triton-ext's two address flags for this compilation, passed to the kernel call as its own launcher does.
|
|
388
|
+
exposes_addresses: bool = False
|
|
389
|
+
reads_addresses: bool = False
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
#: Binding census, printed at exit under NBX_EXT_STATS=1. An interior binding
|
|
393
|
+
#: takes its LENGTH from the allocator's range table rather than from an exact
|
|
394
|
+
#: pointer hit, which is the one place a stale entry could hand back a buffer
|
|
395
|
+
#: shorter than the tensor.
|
|
396
|
+
_STATS = {"exact": 0, "interior": 0, "extent_bound": 0}
|
|
397
|
+
|
|
398
|
+
import os as _os_stats
|
|
399
|
+
if _os_stats.environ.get("NBX_EXT_STATS"):
|
|
400
|
+
import atexit as _atexit
|
|
401
|
+
_atexit.register(lambda: print(
|
|
402
|
+
f"[ext-stats] pointer bindings: exact={_STATS['exact']} "
|
|
403
|
+
f"interior={_STATS['interior']}", flush=True))
|
|
404
|
+
|
|
405
|
+
#: Metal's zero-copy wrap requires page-aligned memory; Apple Silicon pages
|
|
406
|
+
#: are 16 KiB.
|
|
407
|
+
_PAGE = 16384
|
|
408
|
+
|
|
409
|
+
#: Triton pointer type -> numpy dtype. `wrap` reads the element type off the
|
|
410
|
+
#: object it is given; a bare memoryview arrives as dtype None, which is not a
|
|
411
|
+
#: buffer triton-ext can bind.
|
|
412
|
+
_PTR_NP = {
|
|
413
|
+
"fp16": "float16", "bf16": "uint16", "fp32": "float32", "fp64": "float64",
|
|
414
|
+
"i1": "bool", "i8": "int8", "i16": "int16", "i32": "int32", "i64": "int64",
|
|
415
|
+
"u8": "uint8", "u16": "uint16", "u32": "uint32", "u64": "uint64",
|
|
416
|
+
"fp8e4nv": "uint8", "fp8e5": "uint8", "fp8e4b15": "uint8",
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
#: Wraps pinned by an open `pinned_addresses` scope: (addr, np_dtype) -> the
|
|
421
|
+
#: MetalBuffer every launch inside the scope reuses for that pointer. Keeping
|
|
422
|
+
#: ONE wrap per (address, element type) is what keeps a captured GPU address
|
|
423
|
+
#: meaning the same storage from one launch to the next.
|
|
424
|
+
_PINNED_WRAPS: dict = {}
|
|
425
|
+
|
|
426
|
+
# Pinned wraps: ONE uint8 wrap of each WHOLE allocation containing a pinned
|
|
427
|
+
# tensor, held HERE for the scope's lifetime, its address read through the
|
|
428
|
+
# served triton-ext API (`MetalBuffer.gpu_address()`). What that buys is a
|
|
429
|
+
# PINNED LIFETIME: the wrap, and so its GPU virtual address,
|
|
430
|
+
# outlives the launch that made it. A kernel that reads through a
|
|
431
|
+
# pointer-table entry needs the covering wrap ALIVE, and this driver otherwise
|
|
432
|
+
# makes a fresh wrap per launch. Once that wrap is dropped, its captured
|
|
433
|
+
# address reads wrong bytes with nothing raised. Upstream documents exactly
|
|
434
|
+
# this: an address is valid only while its buffer lives (triton-ext
|
|
435
|
+
# address.gpu_address). It is not a residency fix. Re-measured 2026-09-24
|
|
436
|
+
# against PR #126's head 8b45b9aa: an ALIVE wrap left unbound reads correctly
|
|
437
|
+
# from a later command buffer at 1 KiB, at 64 MiB and after churn, with or
|
|
438
|
+
# without a useResource; a DROPPED wrap reads 0/256
|
|
439
|
+
# (docs/reference/owed-proofs.md, 2026-09-24). Until 2026-10-04 this called
|
|
440
|
+
# `metal_native.retain_resident`, a function of the retired residency commit
|
|
441
|
+
# that the served build never had: every mixture-of-experts model failed at
|
|
442
|
+
# its first band on Apple (granite-3.1-1b-a400m, "has no attribute
|
|
443
|
+
# 'retain_resident'"). Upstream's contract is the one followed now:
|
|
444
|
+
# `gpu_address()` records the buffer as exposed (weakly), and a kernel compiled
|
|
445
|
+
# as reading addresses names the exposed buffers to Metal at its launch
|
|
446
|
+
# (`reads_addresses`, carried by `_Trailing`). Measured 2026-09-21
|
|
447
|
+
# (granite): per-expert kept wraps also worked but Metal declined the ~480th
|
|
448
|
+
# GB-scale alias, so one wrap per allocation is the shape that scales.
|
|
449
|
+
# base -> [wrap, refcount, gpu_va].
|
|
450
|
+
_RESIDENT_WRAPS: dict = {}
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _resident_acquire(addr: int) -> int:
|
|
454
|
+
"""Pin one wrap of the whole allocation containing `addr` for the scope's
|
|
455
|
+
lifetime (`_RESIDENT_WRAPS` holds it); returns base."""
|
|
456
|
+
import numpy as np
|
|
457
|
+
from nbxtensor.memory.allocator import DeviceAllocator
|
|
458
|
+
|
|
459
|
+
size = DeviceAllocator._cuda_ptr_size.get(addr)
|
|
460
|
+
base = addr
|
|
461
|
+
if size is None:
|
|
462
|
+
if _recorded_shadow(addr) is not None:
|
|
463
|
+
# A census shadow allocation, recorded as one: memory that exists nowhere, so
|
|
464
|
+
# nothing to wrap and nothing to hold. Recognised, not skipped: an address that
|
|
465
|
+
# nobody recorded still fails below, in a shadow as in a real run.
|
|
466
|
+
return None
|
|
467
|
+
base, size = _containing_allocation(addr)
|
|
468
|
+
ent = _RESIDENT_WRAPS.get(base)
|
|
469
|
+
if ent is not None:
|
|
470
|
+
ent[1] += 1
|
|
471
|
+
return base
|
|
472
|
+
raw = (ctypes.c_byte * size).from_address(base)
|
|
473
|
+
view = np.frombuffer(memoryview(raw), dtype=np.uint8)
|
|
474
|
+
# Inside a pool: `gpu_address()` records the buffer in triton-ext's weak table of exposed buffers, and an
|
|
475
|
+
# insert into a weak NSHashTable can load its live members, each load a retain + AUTORELEASE. A Python
|
|
476
|
+
# thread has no pool, so that reference was never dropped: about one pinned scope in fifteen kept its whole
|
|
477
|
+
# arena for good (48 rounds on a fresh 128 MiB arena, no kernel: +128 MB at rounds 15, 30 and 45;
|
|
478
|
+
# deepseek-moe streamed kept one 8.5 GB segment at its third pass, 2026-10-04).
|
|
479
|
+
import objc
|
|
480
|
+
with objc.autorelease_pool():
|
|
481
|
+
buf = _native().wrap(view)
|
|
482
|
+
gpu_va = int(buf.gpu_address()) # also records it as exposed, weakly
|
|
483
|
+
_RESIDENT_WRAPS[base] = [buf, 1, gpu_va]
|
|
484
|
+
return base
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def pinned_gpu_address(addr: int) -> int:
|
|
488
|
+
"""The address a SHADER may read at `addr`, valid while its pin is held.
|
|
489
|
+
|
|
490
|
+
Every MTLBuffer has its own GPU virtual address — a no-copy wrap of the
|
|
491
|
+
same memory is a DIFFERENT VA, which is why an address captured through a
|
|
492
|
+
transient launch wrap reads zeros once that wrap dies (measured,
|
|
493
|
+
probe_read_through 2026-09-21). The pin's whole-allocation kept wrap
|
|
494
|
+
is therefore the one address authority: its gpu_address plus the CPU
|
|
495
|
+
offset inside the allocation, computable on the host with no launch.
|
|
496
|
+
"""
|
|
497
|
+
from nbxtensor.memory.allocator import DeviceAllocator
|
|
498
|
+
size = DeviceAllocator._cuda_ptr_size.get(addr)
|
|
499
|
+
base = addr
|
|
500
|
+
if size is None:
|
|
501
|
+
if _recorded_shadow(addr) is not None:
|
|
502
|
+
# A recorded shadow has no GPU address to give; the shadow's own address stands
|
|
503
|
+
# for it, exactly as every other address in a shadow addresses nothing.
|
|
504
|
+
return int(addr)
|
|
505
|
+
base, size = _containing_allocation(addr)
|
|
506
|
+
ent = _RESIDENT_WRAPS.get(base)
|
|
507
|
+
if ent is None:
|
|
508
|
+
raise RuntimeError(
|
|
509
|
+
f"pinned_gpu_address(0x{addr:x}): its allocation is not under any "
|
|
510
|
+
f"pinned_addresses scope — an address handed out without a pin "
|
|
511
|
+
f"would go stale silently, which is the defect this refuses")
|
|
512
|
+
return ent[2] + (addr - base)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _recorded_shadow(addr: int):
|
|
516
|
+
"""(base, nbytes) when `addr` is a shadow allocation RECORDED as one (`memory.shadow`), else None.
|
|
517
|
+
Never true in a real run: nothing is recorded there unless an application installed the shadow."""
|
|
518
|
+
from nbxtensor.memory.shadow import shadow_range
|
|
519
|
+
return shadow_range(addr)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _resident_release(base) -> None:
|
|
523
|
+
if base is None: # a recorded shadow held nothing
|
|
524
|
+
return
|
|
525
|
+
ent = _RESIDENT_WRAPS.get(base)
|
|
526
|
+
if ent is None:
|
|
527
|
+
return
|
|
528
|
+
ent[1] -= 1
|
|
529
|
+
if ent[1] <= 0:
|
|
530
|
+
_RESIDENT_WRAPS.pop(base, None) # the wrap dies here; the weak exposed table drops it
|
|
531
|
+
#: How many open scopes pin each address. A wrap is dropped only when the last
|
|
532
|
+
#: scope holding its address exits.
|
|
533
|
+
_PIN_COUNTS: dict = {}
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
class pinned_addresses:
|
|
537
|
+
"""Pin the Metal wraps behind these tensors, so an address a kernel CAPTURES
|
|
538
|
+
stays valid for every launch inside the scope.
|
|
539
|
+
|
|
540
|
+
The contract this makes enforceable: a kernel may store the bitcast of a
|
|
541
|
+
pointer it was handed (``tl.cast(ptr, tl.int64, bitcast=True)``) into a
|
|
542
|
+
table, and a later kernel inside the same scope may load that table entry
|
|
543
|
+
and read through it. That is how a mixture-of-experts band addresses its
|
|
544
|
+
per-expert weights.
|
|
545
|
+
|
|
546
|
+
Why a scope and not a convention. ``_buffer_for`` builds a NEW
|
|
547
|
+
``metal_native.wrap`` for every launch, so without this an address captured
|
|
548
|
+
in one launch names a buffer that is RELEASED before the next launch runs.
|
|
549
|
+
What the read then returns is UNDEFINED, and both outcomes were measured:
|
|
550
|
+
0/256 in one ordering (verify_wrap_lifetime.py, 2026-09-19) and, when Metal
|
|
551
|
+
happened to hand a re-wrap of the same region the same GPU address,
|
|
552
|
+
256/256 that LOOK correct (2026-09-20, in test ordering). With one wrap
|
|
553
|
+
kept alive it is 256/256 by contract rather than by accident:
|
|
554
|
+
|
|
555
|
+
wrap created fresh per launch (as shipped) undefined — an accident
|
|
556
|
+
the same wrap kept alive 256/256, by contract
|
|
557
|
+
|
|
558
|
+
The scope holds two things, because the lifetime has two halves and both
|
|
559
|
+
were measured:
|
|
560
|
+
|
|
561
|
+
* **the wraps** — Metal keeps the GPU address mapped to this buffer for as
|
|
562
|
+
long as the MetalBuffer object lives;
|
|
563
|
+
* **the tensors** — so the engine's allocator cannot free and reissue the
|
|
564
|
+
storage underneath. On MPS the FIRST same-size allocation after a free
|
|
565
|
+
reissued the exact address, and the stale table then read the newcomer's
|
|
566
|
+
bytes with nothing raised (repro_130_mps_reissue.py). A stale table does
|
|
567
|
+
not fail loudly in either direction, which is why this is a scope in code
|
|
568
|
+
and not a caution in a comment.
|
|
569
|
+
|
|
570
|
+
Nests: an address pinned by two scopes stays pinned until both exit.
|
|
571
|
+
"""
|
|
572
|
+
|
|
573
|
+
def __init__(self, *tensors):
|
|
574
|
+
if not tensors:
|
|
575
|
+
raise RuntimeError(
|
|
576
|
+
"pinned_addresses with nothing to pin would promise a lifetime "
|
|
577
|
+
"it does not hold; pass the tensors whose addresses kernels "
|
|
578
|
+
"will capture")
|
|
579
|
+
self._tensors = tuple(tensors) # the allocator half of the lifetime
|
|
580
|
+
self._addrs = []
|
|
581
|
+
|
|
582
|
+
def __enter__(self):
|
|
583
|
+
self._resident_bases = []
|
|
584
|
+
for t in self._tensors:
|
|
585
|
+
addr = int(t.data_ptr())
|
|
586
|
+
_PIN_COUNTS[addr] = _PIN_COUNTS.get(addr, 0) + 1
|
|
587
|
+
self._addrs.append(addr)
|
|
588
|
+
self._resident_bases.append(_resident_acquire(addr))
|
|
589
|
+
return self
|
|
590
|
+
|
|
591
|
+
def __exit__(self, exc_type, exc, tb):
|
|
592
|
+
for addr in self._addrs:
|
|
593
|
+
n = _PIN_COUNTS.get(addr, 0) - 1
|
|
594
|
+
if n > 0:
|
|
595
|
+
_PIN_COUNTS[addr] = n
|
|
596
|
+
continue
|
|
597
|
+
_PIN_COUNTS.pop(addr, None)
|
|
598
|
+
for key in [k for k in _PINNED_WRAPS if k[0] == addr]:
|
|
599
|
+
_PINNED_WRAPS.pop(key, None)
|
|
600
|
+
for base in getattr(self, "_resident_bases", ()):
|
|
601
|
+
_resident_release(base)
|
|
602
|
+
self._resident_bases = []
|
|
603
|
+
self._addrs.clear()
|
|
604
|
+
return False
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _buffer_for(addr: int, ty: str, extent: Optional[int] = None):
|
|
608
|
+
"""Alias the NBXTensor allocation containing `addr` as a MetalBuffer.
|
|
609
|
+
|
|
610
|
+
`extent`: the bytes the launch's tensor can address from `addr` (the launcher's `_addressable_extent`), or None.
|
|
611
|
+
Given, the wrap covers that, rounded up to the page and never past the allocation — not the rest of the
|
|
612
|
+
allocation: a no-copy wrap costs what it maps, and a weight at the start of its multi-GB arena made every launch
|
|
613
|
+
on it map the whole remaining arena (10.9 ms at 1 GiB, 46.7 ms at 4 GiB for a 0.5 ms gemv; orpheus decoded at
|
|
614
|
+
6.3 s per token, 2026-10-04). A pinned address keeps the whole-allocation span its scope's captured table needs.
|
|
615
|
+
|
|
616
|
+
The launcher reduces a pointer parameter to its integer address, so the
|
|
617
|
+
length has to come from the allocator, which already records it. A tensor
|
|
618
|
+
VIEW points into the middle of an allocation, so the base is looked up
|
|
619
|
+
before the size.
|
|
620
|
+
|
|
621
|
+
`ty` is the Triton pointer type ("*fp32"): the element type has to travel
|
|
622
|
+
with the address, because `wrap` takes it from the object it is handed and a
|
|
623
|
+
typeless buffer is not bindable.
|
|
624
|
+
"""
|
|
625
|
+
import numpy as np
|
|
626
|
+
|
|
627
|
+
elem = ty.lstrip("*k") if ty else ""
|
|
628
|
+
np_dtype = _PTR_NP.get(elem)
|
|
629
|
+
if np_dtype is None:
|
|
630
|
+
raise RuntimeError(
|
|
631
|
+
f"the triton-ext driver has no numpy element type for pointer "
|
|
632
|
+
f"parameter of type {ty!r}; binding it typeless would not fail, it "
|
|
633
|
+
f"would bind a buffer the kernel reads as zeros")
|
|
634
|
+
if addr == 0:
|
|
635
|
+
raise RuntimeError(
|
|
636
|
+
"the triton-ext driver was handed a null pointer parameter; its "
|
|
637
|
+
"launcher has no slot for one")
|
|
638
|
+
from nbxtensor.memory.allocator import DeviceAllocator
|
|
639
|
+
|
|
640
|
+
size = DeviceAllocator._cuda_ptr_size.get(addr)
|
|
641
|
+
base = addr
|
|
642
|
+
if size is None:
|
|
643
|
+
base, size = _containing_allocation(addr)
|
|
644
|
+
_STATS["interior"] += 1
|
|
645
|
+
else:
|
|
646
|
+
_STATS["exact"] += 1
|
|
647
|
+
# An interior pointer — a view, or a tensor packed inside a larger buffer —
|
|
648
|
+
# binds by starting the Metal buffer AT that address and running to the end
|
|
649
|
+
# of the allocation. Binding the allocation's base instead would read the
|
|
650
|
+
# wrong elements silently, and `metal_native` has no offset binding
|
|
651
|
+
# (`metal_native.m`: every argument goes in with `setBuffer:...offset:0`).
|
|
652
|
+
#
|
|
653
|
+
# Metal decides whether a given address can be wrapped with no copy. The
|
|
654
|
+
# documented requirement is page alignment, but MEASURED on M4 Pro a
|
|
655
|
+
# non-page-aligned interior address inside an already-mapped region is
|
|
656
|
+
# accepted (repro_ext_no_offset_binding.py, case 3: offset 0x300, wrapped,
|
|
657
|
+
# 64768 bytes). So we do not pre-judge it: we ask, and turn Metal's own
|
|
658
|
+
# refusal into a named one. Guessing a binding is what this driver exists
|
|
659
|
+
# to stop; refusing a binding that would have worked is merely wrong.
|
|
660
|
+
offset = addr - base
|
|
661
|
+
# Floor the span to a whole number of elements. The allocation runs to the
|
|
662
|
+
# end of its block, which need not be a multiple of this tensor's element
|
|
663
|
+
# size — and `np.frombuffer` refuses a remainder with "buffer size must be a
|
|
664
|
+
# multiple of element size". Measured on Kokoro-82M, aten.upsample_linear1d
|
|
665
|
+
# at (1, 9, 76800). Flooring is safe: the kernel addresses its own tensor,
|
|
666
|
+
# which ends at or before the block's end, so no element it reads or writes
|
|
667
|
+
# is cut off.
|
|
668
|
+
itemsize = np.dtype(np_dtype).itemsize
|
|
669
|
+
span = ((size - offset) // itemsize) * itemsize
|
|
670
|
+
if span <= 0:
|
|
671
|
+
raise RuntimeError(
|
|
672
|
+
f"the triton-ext driver cannot bind pointer 0x{addr:x}: the "
|
|
673
|
+
f"allocation leaves {size - offset} bytes from it, less than one "
|
|
674
|
+
f"{np_dtype} element")
|
|
675
|
+
# A launch inside a `pinned_addresses` scope must reuse the scope's wrap:
|
|
676
|
+
# a fresh wrap would be a fresh GPU address, and the table the scope exists
|
|
677
|
+
# for would go stale mid-scope.
|
|
678
|
+
_pin_key = (addr, np_dtype)
|
|
679
|
+
_pinned = _PINNED_WRAPS.get(_pin_key)
|
|
680
|
+
if _pinned is not None:
|
|
681
|
+
return _pinned
|
|
682
|
+
if extent is not None and addr not in _PIN_COUNTS:
|
|
683
|
+
reach = -(-max(int(extent), 1) // _PAGE) * _PAGE # the tensor's extent, rounded up to the page
|
|
684
|
+
span = (min(span, reach) // itemsize) * itemsize
|
|
685
|
+
_STATS["extent_bound"] += 1
|
|
686
|
+
raw = (ctypes.c_byte * span).from_address(addr)
|
|
687
|
+
view = np.frombuffer(memoryview(raw), dtype=np_dtype)
|
|
688
|
+
try:
|
|
689
|
+
_buf = _native().wrap(view)
|
|
690
|
+
if addr in _PIN_COUNTS:
|
|
691
|
+
_PINNED_WRAPS[_pin_key] = _buf
|
|
692
|
+
return _buf
|
|
693
|
+
except Exception as exc:
|
|
694
|
+
raise RuntimeError(
|
|
695
|
+
f"the triton-ext driver could not bind pointer 0x{addr:x} "
|
|
696
|
+
f"(0x{offset:x} into an allocation at 0x{base:x}, {size - offset} "
|
|
697
|
+
f"bytes): Metal declined a zero-copy view of it. `metal_native` "
|
|
698
|
+
f"binds every buffer at offset 0, so there is no other correct "
|
|
699
|
+
f"binding here — binding the allocation base would read the wrong "
|
|
700
|
+
f"elements WITHOUT failing. Metal said: {exc}") from exc
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def _containing_allocation(addr: int) -> Tuple[int, int]:
|
|
704
|
+
from nbxtensor.memory.allocator import DeviceAllocator
|
|
705
|
+
hit = DeviceAllocator.containing(addr)
|
|
706
|
+
if hit is not None:
|
|
707
|
+
return hit
|
|
708
|
+
raise RuntimeError(
|
|
709
|
+
f"the triton-ext driver cannot bind pointer 0x{addr:x}: the allocator "
|
|
710
|
+
f"does not record it, so its length is unknown and it cannot be wrapped "
|
|
711
|
+
f"as a Metal buffer")
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def driver() -> TritonExtDriver:
|
|
715
|
+
return TritonExtDriver.instance()
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _nbx_queue_drain():
|
|
719
|
+
"""Wait for NeuroBrix's own Metal queue before a foreign queue reads its
|
|
720
|
+
buffers. Coarse — a host wait — but correct; an MTLSharedEvent between the
|
|
721
|
+
two queues is the finer instrument and is owed if this costs measurably."""
|
|
722
|
+
from nbxtensor.backend.metal_device import runtime
|
|
723
|
+
rt = runtime()
|
|
724
|
+
# Skip the round trip when this queue has nothing that could be writing.
|
|
725
|
+
# `sync()` does `flush()` and then commits an EMPTY command buffer and waits
|
|
726
|
+
# on it — and that empty buffer queues behind triton-ext's work on a busy
|
|
727
|
+
# GPU, which is why it costs 0.458 ms per launch in a decode against
|
|
728
|
+
# 0.019 ms on an idle queue standalone. Measured over a 120-token TinyLlama
|
|
729
|
+
# decode, 4943 drains: 0 had a committed buffer pending, 1240 (25.1 %) had an
|
|
730
|
+
# open encoder, and 3703 (74.9 %) had NOTHING. Waiting for work that does not
|
|
731
|
+
# exist cannot order anything, so those 3703 are skipped.
|
|
732
|
+
if not rt.has_pending_gpu_writes():
|
|
733
|
+
return
|
|
734
|
+
rt.sync()
|
|
735
|
+
|