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,1988 @@
|
|
|
1
|
+
"""The device allocator: allocation, the pool and its parked blocks, the free queues, peer copies and the allocator's
|
|
2
|
+
own accounting (live bytes, peaks), with the malloc call-site trace (NBX_MALLOC_TRACE). Cut out of tensor.py (M2, 2026-10-29),
|
|
3
|
+
unchanged."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
from nbxtensor.backend import faults as _faults
|
|
7
|
+
|
|
8
|
+
import bisect
|
|
9
|
+
import ctypes
|
|
10
|
+
import os
|
|
11
|
+
import time
|
|
12
|
+
import sys
|
|
13
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
14
|
+
|
|
15
|
+
from nbxtensor.backend import runtime as _rt
|
|
16
|
+
from nbxtensor.memory.pool import (SegmentPool, LARGE, range_add, range_extend, range_unmapped, range_reclaimable)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ============================================================================
|
|
21
|
+
# MALLOC CALL-SITE LOGGER — env-gated, zero cost when disabled
|
|
22
|
+
# ============================================================================
|
|
23
|
+
#
|
|
24
|
+
# When NBX_MALLOC_TRACE=<path> is set, every DeviceAllocator.malloc_cuda
|
|
25
|
+
# records one row and every free_cuda records the matching "F" row. The
|
|
26
|
+
# buffer is flushed to <path> at interpreter exit via atexit. When unset,
|
|
27
|
+
# the allocator pays a single module-level `is None` check per call — no
|
|
28
|
+
# frame walk, no list append.
|
|
29
|
+
#
|
|
30
|
+
# How to use:
|
|
31
|
+
# NBX_MALLOC_TRACE=/tmp/run.tsv neurobrix run --model ... --triton
|
|
32
|
+
#
|
|
33
|
+
# Row format (tab-separated):
|
|
34
|
+
# event_id \t M|F \t ptr \t nbytes \t <file:line func> (an F row carries the
|
|
35
|
+
# engine frames that freed it, innermost first)
|
|
36
|
+
#
|
|
37
|
+
# Recipes:
|
|
38
|
+
# - Total allocation volume by site:
|
|
39
|
+
# awk '$2=="M"{by[$5]+=$4} END{for(k in by)print by[k],k}' run.tsv \
|
|
40
|
+
# | sort -rn | head
|
|
41
|
+
# - Live-set composition at peak: walk events, track ptr→nbytes live
|
|
42
|
+
# map, record the set at each new peak, then aggregate by site.
|
|
43
|
+
# This is the discriminator between "leak" (unfreed allocs), "peak
|
|
44
|
+
# concurrency" (tensors retained by a deferred-free mechanism), and
|
|
45
|
+
# "fragmentation" (total freed > 80% but driver rejects next alloc).
|
|
46
|
+
# In April 2026 this tool killed a wrong hypothesis in ~30 min on
|
|
47
|
+
# PixArt triton (confirmed _deferred accumulation as root cause,
|
|
48
|
+
# not workspace leak as initially hypothesised). Kept permanent —
|
|
49
|
+
# same discriminator work will be needed for Qwen3 long-context,
|
|
50
|
+
# Sana 4Kpx, video upscalers.
|
|
51
|
+
# ============================================================================
|
|
52
|
+
|
|
53
|
+
_MALLOC_TRACE_FILE: Optional[str] = os.environ.get("NBX_MALLOC_TRACE") or None
|
|
54
|
+
_MALLOC_TRACE_EVENTS: List[Tuple[int, str, int, int, str]] = [] # (eid, kind, ptr, nbytes, site)
|
|
55
|
+
_MALLOC_TRACE_COUNTER: List[int] = [0]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _extract_neurobrix_site(max_frames: int = 12) -> str:
|
|
59
|
+
"""Return nearest neurobrix/ frame as 'file:line func', skipping this file.
|
|
60
|
+
|
|
61
|
+
The logger only fires inside DeviceAllocator — we want the CALLER of the
|
|
62
|
+
allocator (a wrapper, the dispatch, the engine's sequence, ...) not the allocator
|
|
63
|
+
itself. Walk the stack until the first frame of the library or the engine
|
|
64
|
+
(`_is_caller_frame`) that is not the allocator's or the tensor's own.
|
|
65
|
+
"""
|
|
66
|
+
f = sys._getframe(2) # skip this fn + malloc_cuda/free_cuda
|
|
67
|
+
n = 0
|
|
68
|
+
while f is not None and n < max_frames:
|
|
69
|
+
fname = f.f_code.co_filename
|
|
70
|
+
if _is_caller_frame(fname):
|
|
71
|
+
return f"{_rel_site(fname)}:{f.f_lineno} {f.f_code.co_name}"
|
|
72
|
+
f = f.f_back
|
|
73
|
+
n += 1
|
|
74
|
+
return "<unknown>"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
#: the allocator's and the tensor's own files: never the site a trace names
|
|
78
|
+
_NOT_A_CALLER = ("/nbxtensor/memory/allocator.py", "/nbxtensor/memory/memory_pool.py", "/nbxtensor/memory/slab.py",
|
|
79
|
+
"/nbxtensor/tensor.py")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _is_caller_frame(fname: str) -> bool:
|
|
83
|
+
"""A frame of the library or of the engine that calls it (the engine's tree under `neurobrix/`). The trace keyed on
|
|
84
|
+
'neurobrix' alone until the library moved out (M1, 2026-10-28): every wrapper's frame was then skipped and a site
|
|
85
|
+
named only the engine loop that dispatched it."""
|
|
86
|
+
return ("/nbxtensor/" in fname or "/neurobrix/" in fname) and not fname.endswith(_NOT_A_CALLER)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _rel_site(fname: str) -> str:
|
|
90
|
+
"""`nbxtensor/ops/conv_wrappers.py` or `neurobrix/triton/sequence.py`: the path from the package."""
|
|
91
|
+
for pkg in ("/neurobrix/", "/nbxtensor/"): # the engine first: a tree may be named nbxtensor
|
|
92
|
+
if pkg in fname:
|
|
93
|
+
return pkg.strip("/") + "/" + fname.rsplit(pkg, 1)[1]
|
|
94
|
+
return fname
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _record_malloc_site(ptr: int, nbytes: int, dev: int, pooled: bool = True) -> None:
|
|
98
|
+
"""One allocation of the trace: kind "M" for a pooled block, "A" for one outside the pool (a weight arena, handed back
|
|
99
|
+
to the driver whole) — the two the pool's replay (`memory.pool.simulate`) treats apart."""
|
|
100
|
+
eid = _MALLOC_TRACE_COUNTER[0]
|
|
101
|
+
_MALLOC_TRACE_COUNTER[0] = eid + 1
|
|
102
|
+
site = _extract_neurobrix_site()
|
|
103
|
+
_MALLOC_TRACE_EVENTS.append((eid, "M" if pooled else "A", ptr, nbytes, site))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _extract_neurobrix_chain(max_frames: int = 40, keep: int = 8) -> str:
|
|
107
|
+
"""The engine frames of the caller, innermost first, ' < '-joined — for
|
|
108
|
+
a FREE row. A block freed by a finalizer names `free()` as its site,
|
|
109
|
+
which says nothing; the frames above it name what dropped the last
|
|
110
|
+
reference (Ming's arenas freed under live weights, 2026-09-16)."""
|
|
111
|
+
f = sys._getframe(2)
|
|
112
|
+
n = 0
|
|
113
|
+
out = []
|
|
114
|
+
while f is not None and n < max_frames and len(out) < keep:
|
|
115
|
+
fname = f.f_code.co_filename
|
|
116
|
+
if _is_caller_frame(fname):
|
|
117
|
+
out.append(f"{_rel_site(fname)}:{f.f_lineno} {f.f_code.co_name}")
|
|
118
|
+
f = f.f_back
|
|
119
|
+
n += 1
|
|
120
|
+
return " < ".join(out) or "<unknown>"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _record_free_site(ptr: int, nbytes: int) -> None:
|
|
124
|
+
eid = _MALLOC_TRACE_COUNTER[0]
|
|
125
|
+
_MALLOC_TRACE_COUNTER[0] = eid + 1
|
|
126
|
+
_MALLOC_TRACE_EVENTS.append((eid, "F", ptr, nbytes, _extract_neurobrix_chain()))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _flush_malloc_trace() -> None:
|
|
130
|
+
path = _MALLOC_TRACE_FILE
|
|
131
|
+
if path is None or not _MALLOC_TRACE_EVENTS:
|
|
132
|
+
return
|
|
133
|
+
try:
|
|
134
|
+
with open(path, "w") as fh:
|
|
135
|
+
for eid, kind, ptr, nbytes, site in _MALLOC_TRACE_EVENTS:
|
|
136
|
+
fh.write(f"{eid}\t{kind}\t{ptr}\t{nbytes}\t{site}\n")
|
|
137
|
+
except Exception:
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
import atexit as _atexit
|
|
142
|
+
_atexit.register(_flush_malloc_trace)
|
|
143
|
+
|
|
144
|
+
# The floor watchdog stops a run with SIGTERM (5 s grace before SIGKILL),
|
|
145
|
+
# and Python's default SIGTERM action skips atexit — so a floor-stopped
|
|
146
|
+
# traced run died with an EMPTY trace (measured: x8@1024, 2026-09-21).
|
|
147
|
+
# When, and only when, the trace is armed, convert SIGTERM to SystemExit
|
|
148
|
+
# so the flush runs inside the grace window. Unarmed runs keep the
|
|
149
|
+
# default signal behavior untouched.
|
|
150
|
+
if _MALLOC_TRACE_FILE:
|
|
151
|
+
import signal as _signal_mt
|
|
152
|
+
|
|
153
|
+
def _flush_on_term(_signum, _frame):
|
|
154
|
+
raise SystemExit(143)
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
_signal_mt.signal(_signal_mt.SIGTERM, _flush_on_term)
|
|
158
|
+
except (ValueError, OSError):
|
|
159
|
+
pass # non-main thread or exotic host: trace
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class ImpossibleExtentError(RuntimeError):
|
|
164
|
+
"""An op would produce a tensor with an extent that cannot exist (<= 0 where the op defines
|
|
165
|
+
a length, or a rank the op does not produce). Raised at the op, with its input shapes and its
|
|
166
|
+
arithmetic, instead of letting an impossible tensor travel on and fail far away. The graph
|
|
167
|
+
executors add the op uid and the bound symbol values."""
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class DeviceOOMError(RuntimeError):
|
|
171
|
+
"""The device allocator could not honor a request: the driver refused
|
|
172
|
+
the allocation after the deferred-free drain and the single retry.
|
|
173
|
+
Typed so a caller that names its own key in the refusal (the chunked
|
|
174
|
+
SDPA prefill re-raises it with op, shape, rows and bytes asked) catches
|
|
175
|
+
exactly this and nothing else — every other RuntimeError keeps propagating.
|
|
176
|
+
|
|
177
|
+
It also CARRIES the figures it prints, as attributes. The refusal already
|
|
178
|
+
computes requested / live / pool-cached / driver-free in order to write the
|
|
179
|
+
message; a caller that wants to reshape the work rather than give up then had
|
|
180
|
+
to parse them back out of English. They are the input a controller needs to
|
|
181
|
+
re-enter the placement cascade at the op-level tiling rung with the real free
|
|
182
|
+
figure at the moment of failure instead of the estimate made before the run
|
|
183
|
+
(`docs/reference/adaptive-memory-a-runtime-controller.md`, addition 3).
|
|
184
|
+
|
|
185
|
+
Every field is bytes, and `None` where the runtime could not answer — never 0,
|
|
186
|
+
which is a legitimate reading and would be acted on as one.
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
def __init__(self, message: str, *, requested: Optional[int] = None,
|
|
190
|
+
device_idx: Optional[int] = None, live: Optional[int] = None,
|
|
191
|
+
pool_cached: Optional[int] = None, pool_blocks: Optional[int] = None,
|
|
192
|
+
driver_free: Optional[int] = None, driver_total: Optional[int] = None):
|
|
193
|
+
super().__init__(message)
|
|
194
|
+
self.requested = requested
|
|
195
|
+
self.device_idx = device_idx
|
|
196
|
+
self.live = live
|
|
197
|
+
self.pool_cached = pool_cached
|
|
198
|
+
self.pool_blocks = pool_blocks
|
|
199
|
+
self.driver_free = driver_free
|
|
200
|
+
self.driver_total = driver_total
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def shortfall(self) -> Optional[int]:
|
|
204
|
+
"""How many bytes the request was short by, or None if the driver did
|
|
205
|
+
not report its free figure. This is the number a reshape has to close."""
|
|
206
|
+
if self.requested is None or self.driver_free is None:
|
|
207
|
+
return None
|
|
208
|
+
return max(0, self.requested - self.driver_free)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class AllocatorInterceptor:
|
|
212
|
+
"""What stands in front of the allocator's five device operations (allocate, free, copy, set, synchronise) while
|
|
213
|
+
it is installed (`DeviceAllocator.install_interceptor`): each method receives the DIRECT operation it stands in
|
|
214
|
+
front of (`DeviceAllocator.malloc_direct`, ...) and decides whether and how to call it. A replay recording carves
|
|
215
|
+
its run's allocations from a slab and records the copies it must reproduce (`memory.slab.SlabInterceptor`).
|
|
216
|
+
|
|
217
|
+
The defaults pass straight through; a subclass overrides only what it changes. One interceptor at a time: two
|
|
218
|
+
would each believe it sees every allocation."""
|
|
219
|
+
|
|
220
|
+
def malloc(self, direct, nbytes: int, dev_idx: Optional[int], pooled: bool) -> int:
|
|
221
|
+
return direct(nbytes, dev_idx, pooled)
|
|
222
|
+
|
|
223
|
+
def free(self, direct, ptr: int):
|
|
224
|
+
return direct(ptr)
|
|
225
|
+
|
|
226
|
+
def memcpy(self, direct, dst: int, src: int, nbytes: int, kind: int):
|
|
227
|
+
return direct(dst, src, nbytes, kind)
|
|
228
|
+
|
|
229
|
+
def memset(self, direct, ptr: int, value: int, nbytes: int):
|
|
230
|
+
return direct(ptr, value, nbytes)
|
|
231
|
+
|
|
232
|
+
def sync(self, direct):
|
|
233
|
+
return direct()
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class DeviceAllocator:
|
|
237
|
+
"""GPU + pinned-host memory allocator via raw runtime API.
|
|
238
|
+
|
|
239
|
+
Tracks running byte counters per device and for pinned host memory
|
|
240
|
+
so diagnostics (especially for zero3 CPU offload) don't need to
|
|
241
|
+
round-trip through cudaMemGetInfo or torch.cuda.memory_allocated —
|
|
242
|
+
both of which are unavailable or incorrect for NBXTensor.
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
# Running counters — populated by every malloc_cuda/free_cuda and
|
|
246
|
+
# malloc_host_pinned/free_host_pinned. Class-level so the accounting
|
|
247
|
+
# is process-wide. Not thread-safe; NeuroBrix runtime is
|
|
248
|
+
# single-threaded per component by convention.
|
|
249
|
+
_cuda_live_bytes: Dict[int, int] = {} # device_idx -> live
|
|
250
|
+
_cuda_peak_bytes: Dict[int, int] = {} # device_idx -> peak
|
|
251
|
+
_cuda_ptr_size: Dict[int, int] = {} # ptr -> nbytes (for free accounting)
|
|
252
|
+
_range_bases: list = [] # live allocation bases, sorted (holds())
|
|
253
|
+
_range_size: dict = {} # base -> bytes, mirrors the two size tables
|
|
254
|
+
_cuda_ptr_device: Dict[int, int] = {} # ptr -> device_idx (REQUIRED for cudaFreeAsync correctness)
|
|
255
|
+
|
|
256
|
+
# Which generation of the device runtime issued a pointer.
|
|
257
|
+
#
|
|
258
|
+
# A pointer is only meaningful to the runtime that issued it. Normally
|
|
259
|
+
# there is exactly one for the life of the process and this is a constant
|
|
260
|
+
# 0 that never costs a thought. When a runtime IS replaced, every address
|
|
261
|
+
# it issued dies with it — and the free-list must not outlive it, or a
|
|
262
|
+
# later allocation is served an address the current runtime never issued
|
|
263
|
+
# and the first launch that touches it refuses a perfectly good tensor.
|
|
264
|
+
#
|
|
265
|
+
# Flushing the pool at the swap is not enough: a tensor still ALIVE at
|
|
266
|
+
# that moment is freed afterwards, and that free parks its dead address
|
|
267
|
+
# for reuse. The epoch is what the flush cannot see.
|
|
268
|
+
_alloc_epoch: int = 0
|
|
269
|
+
_cuda_ptr_epoch: Dict[int, int] = {} # ptr -> the epoch that issued it
|
|
270
|
+
_host_pinned_live_bytes: int = 0
|
|
271
|
+
_host_pinned_peak_bytes: int = 0
|
|
272
|
+
_host_pinned_ptr_size: Dict[int, int] = {} # ptr -> nbytes
|
|
273
|
+
|
|
274
|
+
# Phase 2 — caching free-list pool. Per-device map of nbytes → list of
|
|
275
|
+
# cached free pointers. On free, the pointer is returned to the pool
|
|
276
|
+
# instead of cudaFree (which would re-fragment the driver heap). On
|
|
277
|
+
# malloc, the pool is checked first (exact size match, then smallest-
|
|
278
|
+
# fit ≥ requested). On cudaMalloc OOM, the entire pool is flushed back
|
|
279
|
+
# to the driver via cudaFree and the malloc is retried — this gives
|
|
280
|
+
# the driver a chance to coalesce its internal heap before declaring
|
|
281
|
+
# failure (analog to torch CachingAllocator's release-cached-blocks
|
|
282
|
+
# path on OOM). Default ON since 2026-09-02 (P-PREFILL-TRITON lever 1:
|
|
283
|
+
# the xlong triton prefill paid one cudaMalloc + one cudaFree per op
|
|
284
|
+
# output, 82 s of host API time on a 172 s run; the pool cut the cold
|
|
285
|
+
# run by 41 % with byte-identical output, gated by the full-zoo
|
|
286
|
+
# battery + the warm-serve sweep). `NBX_ALLOC_POOL=0` disables it
|
|
287
|
+
# (the differential for any allocator-suspected divergence).
|
|
288
|
+
# P-SANA-4KPX-RUNTIME Phase 2 origin.
|
|
289
|
+
# The pool SPLITS and COALESCES (S4a, 2026-10-27; the method of torch's caching allocator): every driver allocation
|
|
290
|
+
# is a SEGMENT made of BLOCKS chained in address order, in one of two SIZE CLASSES (small requests carve small
|
|
291
|
+
# segments, large ones never share a segment with them); a request takes the smallest free block of its class that
|
|
292
|
+
# holds it and splits off the rest when it is at least `memory.pool.POOL_SPLIT_MIN` — a large block only when the
|
|
293
|
+
# rest is at most the request (the half rule, 2026-11-01); a freed block merges with its free neighbours; only a
|
|
294
|
+
# segment wholly free returns to the driver (over the parked cap, on a refused malloc, on a flush). Exact-size
|
|
295
|
+
# parking kept a freed 3 GB block from ever serving a 2 GB request: 2.6-3.7 GB parked beside 6-7 GB live on
|
|
296
|
+
# mochi-1-preview triton at the 8 GB rung.
|
|
297
|
+
# The segments, their blocks and the free index live in ONE `memory.pool.SegmentPool` (`_POOL`, set below the
|
|
298
|
+
# class): the same object Prism's pricing replays (`memory.pool.simulate`).
|
|
299
|
+
_pool_enabled: bool = False # set by _maybe_init_pool() from env
|
|
300
|
+
#: Callables `(dev) -> bytes released` the engine registers for memory it caches outside the pool (a recorded
|
|
301
|
+
#: replay plan's slab, `triton/replay.release_idle_slabs`), called on a malloc refused after the pool's flush.
|
|
302
|
+
_idle_reclaimers: list = []
|
|
303
|
+
#: The `AllocatorInterceptor` in force, or None: the five public device operations go through it.
|
|
304
|
+
_interceptor: Optional[AllocatorInterceptor] = None
|
|
305
|
+
# Pool observability (NBX_ALLOC_STATS=1 prints it at exit): bytes
|
|
306
|
+
# parked in the free-list per device (+ peak), hit/miss counters,
|
|
307
|
+
# and the smallest-fit SLACK (allocated − requested) — the pool's
|
|
308
|
+
# own contribution to the driver-level watermark.
|
|
309
|
+
# (`_POOL.cached`, `_POOL.cached_peak`, `_POOL.stats`)
|
|
310
|
+
# Parked-bytes cap per device (bytes; 0 = unbounded), resolved once per
|
|
311
|
+
# device from the arch yml `memory.alloc_pool_parked_cap_fraction` ×
|
|
312
|
+
# the device's total memory (ONE cudaMemGetInfo at the first free on
|
|
313
|
+
# that device, never in the hot path afterwards).
|
|
314
|
+
_pool_cap_bytes: Dict[int, int] = {}
|
|
315
|
+
# A limit imposed on the process (`set_limit`; an application's rung, a census) is the card the plan was sized
|
|
316
|
+
# for, and the allocator holds to it as to a card of that size: a malloc that would carry the bytes it holds from
|
|
317
|
+
# the driver (live and pooled) past the rung less the runtime's own context is refused as such a card refuses it,
|
|
318
|
+
# and takes the same path (the pool flushed, the malloc retried, then a named OOM). device -> bytes, None: no rung.
|
|
319
|
+
# Measured 2026-10-28: mochi-1-preview triton at the 8 GB rung on a 32 GB card held 10.8 GB — 5.5-6.2 GB live and
|
|
320
|
+
# 3.0-3.7 GB of pooled free blocks no refused malloc ever asked back.
|
|
321
|
+
_rung_limit_bytes: Dict[int, Optional[int]] = {}
|
|
322
|
+
# The memory limit an application imposes (`set_limit`): device -> the bytes of the card this process holds to, the
|
|
323
|
+
# key None for every device; absent, the physical card. Generic: the library knows no rung, no plan, no variable.
|
|
324
|
+
_limit_bytes: Dict[Optional[int], Optional[int]] = {}
|
|
325
|
+
#: device -> {token: the most held from the driver since the window opened} (`open_window`)
|
|
326
|
+
_windows: Dict[int, Dict[int, int]] = {}
|
|
327
|
+
_window_starts: Dict[int, int] = {} # token -> held when it opened
|
|
328
|
+
_window_seq: int = 0
|
|
329
|
+
|
|
330
|
+
# Deferred-queue reclaimers (P-TRITON-LIVE-SET). The triton hot loops
|
|
331
|
+
# register their deferred dead-tensor queues here. Since 2026-10-11 the
|
|
332
|
+
# allocator's OWN malloc never calls them: Prism prices the queue
|
|
333
|
+
# (`PrismSolver._deferred_drain_mb`), so a refused malloc is raised, not
|
|
334
|
+
# rescued. The one reader left is `wrappers._autotune_headroom_guard`,
|
|
335
|
+
# for the single device allocation the engine does not make and the plan
|
|
336
|
+
# does not price — the Triton autotuner's benchmark buffer. A hook takes
|
|
337
|
+
# (device_idx, nbytes requested) and returns the bytes it freed.
|
|
338
|
+
_oom_reclaim_hooks: list = []
|
|
339
|
+
|
|
340
|
+
@classmethod
|
|
341
|
+
def register_oom_reclaim_hook(cls, fn) -> None:
|
|
342
|
+
if fn not in cls._oom_reclaim_hooks:
|
|
343
|
+
cls._oom_reclaim_hooks.append(fn)
|
|
344
|
+
|
|
345
|
+
@classmethod
|
|
346
|
+
def unregister_oom_reclaim_hook(cls, fn) -> None:
|
|
347
|
+
try:
|
|
348
|
+
cls._oom_reclaim_hooks.remove(fn)
|
|
349
|
+
except ValueError:
|
|
350
|
+
pass
|
|
351
|
+
|
|
352
|
+
@staticmethod
|
|
353
|
+
def set_device(device_id: int):
|
|
354
|
+
"""Set current GPU device for allocations (runtime API only).
|
|
355
|
+
|
|
356
|
+
Uses only cudaSetDevice/hipSetDevice — does NOT touch the driver
|
|
357
|
+
context. The driver context is managed solely by ensure_triton_device
|
|
358
|
+
which is called just before Triton kernel launches.
|
|
359
|
+
"""
|
|
360
|
+
ret = _rt.device_memory().set_device(device_id)
|
|
361
|
+
if ret != 0:
|
|
362
|
+
# An invalid ordinal (a device the process cannot see) must be
|
|
363
|
+
# loud here, not reported by the next unrelated launch check in
|
|
364
|
+
# the process as its own (2026-09-05: torch's, in the compiled
|
|
365
|
+
# branch — "invalid device ordinal" on an fp16 op).
|
|
366
|
+
DeviceAllocator.clear_last_error()
|
|
367
|
+
raise RuntimeError(f"set_device({device_id}) refused by the runtime (error {ret}) — "
|
|
368
|
+
f"the process sees {DeviceAllocator.device_count()} device(s)")
|
|
369
|
+
# C2 cache maintenance: a runtime-only switch to a device other
|
|
370
|
+
# than the cached one breaks the "runtime AND Triton both point
|
|
371
|
+
# at the cached idx" invariant — invalidate so the next
|
|
372
|
+
# ensure_triton_device performs the full sync. Switching to the
|
|
373
|
+
# cached device itself keeps the pair coherent (Triton's driver
|
|
374
|
+
# device is untouched here).
|
|
375
|
+
if device_id != _rt._cached_device_idx():
|
|
376
|
+
_rt._DEVICE_CACHE.idx = None
|
|
377
|
+
|
|
378
|
+
@staticmethod
|
|
379
|
+
def get_device() -> int:
|
|
380
|
+
"""Get current GPU device."""
|
|
381
|
+
return _rt.device_memory().get_device()[1]
|
|
382
|
+
|
|
383
|
+
@staticmethod
|
|
384
|
+
def peak_device_bytes():
|
|
385
|
+
"""The most one device held at once since the process started, as this allocator counted it from the driver
|
|
386
|
+
(live driver bytes, the pool's cache included); None where the backend keeps no such count."""
|
|
387
|
+
return max(DeviceAllocator._cuda_peak_bytes.values()) if DeviceAllocator._cuda_peak_bytes else None
|
|
388
|
+
|
|
389
|
+
@staticmethod
|
|
390
|
+
def device_count() -> int:
|
|
391
|
+
"""How many GPUs this process can see. 0 if there is no runtime.
|
|
392
|
+
|
|
393
|
+
The backend table has declared `device_count` since the seam was
|
|
394
|
+
written, but nothing exposed it: `most_free_device` counted inline and
|
|
395
|
+
every other caller reached for torch. Callers that need to know
|
|
396
|
+
whether multi-GPU behaviour is even testable need it too.
|
|
397
|
+
"""
|
|
398
|
+
try:
|
|
399
|
+
return max(0, _rt.device_memory().device_count()[1])
|
|
400
|
+
except Exception:
|
|
401
|
+
return 0
|
|
402
|
+
|
|
403
|
+
@staticmethod
|
|
404
|
+
def free_memory_mb(index: int) -> "Optional[float]":
|
|
405
|
+
"""The driver's FREE memory on device `index`, in MB, or None when the
|
|
406
|
+
runtime cannot answer.
|
|
407
|
+
|
|
408
|
+
The planner's door to the machine as it IS. `DeviceState.free_mb` was
|
|
409
|
+
`capacity - used_mb` with `used_mb` the plan's own accounting — on a
|
|
410
|
+
discrete card nothing ever read what another process holds, so a plan
|
|
411
|
+
that fitted the card whole was accepted while a neighbour held 18 GB
|
|
412
|
+
of it, and died at its first allocation (five of five runs, V100-32GB,
|
|
413
|
+
2026-09-20). Same query as `most_free_device`, for ONE card, the
|
|
414
|
+
previously-current device restored: cudaMemGetInfo / hipMemGetInfo via
|
|
415
|
+
ctypes, no torch (R33).
|
|
416
|
+
"""
|
|
417
|
+
try:
|
|
418
|
+
if int(index) < 0 or int(index) >= DeviceAllocator.device_count():
|
|
419
|
+
return None # cudaSetDevice REPORTS a bad ordinal; it does not raise
|
|
420
|
+
dm = _rt.device_memory()
|
|
421
|
+
prev = DeviceAllocator.get_device()
|
|
422
|
+
dm.set_device(int(index))
|
|
423
|
+
_rc, free_b, _total_b = dm.mem_get_info()
|
|
424
|
+
try:
|
|
425
|
+
dm.set_device(prev)
|
|
426
|
+
except Exception:
|
|
427
|
+
pass
|
|
428
|
+
return free_b / (1024 * 1024)
|
|
429
|
+
except Exception:
|
|
430
|
+
return None
|
|
431
|
+
|
|
432
|
+
@staticmethod
|
|
433
|
+
def most_free_device() -> int:
|
|
434
|
+
"""Return the GPU index with the most driver-free VRAM.
|
|
435
|
+
|
|
436
|
+
Used by the triton weight loader to place a CPU-staged lazy
|
|
437
|
+
component on the emptiest card (the loop GPU may have just
|
|
438
|
+
unloaded). Pure runtime-API — cudaGetDeviceCount + cudaSetDevice +
|
|
439
|
+
cudaMemGetInfo via ctypes; no torch (R33). The previously-current
|
|
440
|
+
device is restored before returning. Falls back to device 0 on any
|
|
441
|
+
query failure or a single-GPU host.
|
|
442
|
+
"""
|
|
443
|
+
dm = _rt.device_memory()
|
|
444
|
+
ndev = DeviceAllocator.device_count()
|
|
445
|
+
if ndev <= 1:
|
|
446
|
+
return 0
|
|
447
|
+
prev = DeviceAllocator.get_device()
|
|
448
|
+
best_idx, best_free = 0, -1
|
|
449
|
+
for i in range(ndev):
|
|
450
|
+
try:
|
|
451
|
+
dm.set_device(i)
|
|
452
|
+
_rc, free_b, _total_b = dm.mem_get_info()
|
|
453
|
+
if free_b > best_free:
|
|
454
|
+
best_free, best_idx = free_b, i
|
|
455
|
+
except Exception:
|
|
456
|
+
continue
|
|
457
|
+
try:
|
|
458
|
+
dm.set_device(prev)
|
|
459
|
+
except Exception:
|
|
460
|
+
pass
|
|
461
|
+
# C2: the probe loop above issued raw cudaSetDevice calls (and
|
|
462
|
+
# the restore may have failed silently) — drop the fast-path
|
|
463
|
+
# cache so the next ensure_triton_device re-syncs fully.
|
|
464
|
+
_rt.invalidate_current_device_cache()
|
|
465
|
+
return best_idx
|
|
466
|
+
|
|
467
|
+
@staticmethod
|
|
468
|
+
def visible_device_memory() -> list[tuple[int, int]]:
|
|
469
|
+
"""`[(ordinal, total_bytes)]` for every device THIS PROCESS can see.
|
|
470
|
+
|
|
471
|
+
The ordinals come back usable as-is in `cuda:<i>`, because the whole
|
|
472
|
+
walk goes through the GPU RUNTIME — so `CUDA_VISIBLE_DEVICES` applies
|
|
473
|
+
to it, exactly as it applies to every allocation made afterwards.
|
|
474
|
+
|
|
475
|
+
That is the entire point of the method. `nvidia-smi` answers the same
|
|
476
|
+
question from NVML, which sits OUTSIDE the mask and always reports the
|
|
477
|
+
whole board: a caller that picks an index from `nvidia-smi` and then
|
|
478
|
+
allocates on it is reading one namespace and writing another, and the
|
|
479
|
+
two agree only on an unpinned host. `tests/unit/kernels/
|
|
480
|
+
test_prefill_determinism.py` did exactly that and asked for `cuda:2`
|
|
481
|
+
under `CUDA_VISIBLE_DEVICES=0` (2026-09-17) — a red that had nothing
|
|
482
|
+
to do with its subject, on a card that was simply not there.
|
|
483
|
+
|
|
484
|
+
Pure runtime API through ctypes — cudaGetDeviceCount + cudaSetDevice +
|
|
485
|
+
cudaMemGetInfo, no torch (R33). The previously-current device is
|
|
486
|
+
restored and the fast-path cache invalidated, as in
|
|
487
|
+
`most_free_device`, whose inline copy of this loop this replaces.
|
|
488
|
+
Returns `[]` when there is no runtime or no device.
|
|
489
|
+
"""
|
|
490
|
+
try:
|
|
491
|
+
dm = _rt.device_memory()
|
|
492
|
+
except Exception:
|
|
493
|
+
return []
|
|
494
|
+
ndev = DeviceAllocator.device_count()
|
|
495
|
+
if ndev <= 0:
|
|
496
|
+
return []
|
|
497
|
+
prev = DeviceAllocator.get_device()
|
|
498
|
+
out: list[tuple[int, int]] = []
|
|
499
|
+
for i in range(ndev):
|
|
500
|
+
try:
|
|
501
|
+
dm.set_device(i)
|
|
502
|
+
_rc, _free_b, total_b = dm.mem_get_info()
|
|
503
|
+
out.append((i, total_b))
|
|
504
|
+
except Exception:
|
|
505
|
+
continue
|
|
506
|
+
try:
|
|
507
|
+
dm.set_device(prev)
|
|
508
|
+
except Exception:
|
|
509
|
+
pass
|
|
510
|
+
_rt.invalidate_current_device_cache()
|
|
511
|
+
return out
|
|
512
|
+
|
|
513
|
+
@staticmethod
|
|
514
|
+
def _maybe_init_pool() -> None:
|
|
515
|
+
"""One-shot read of the NBX_ALLOC_POOL env var (default on;
|
|
516
|
+
`0` disables the pool). Cached on the class."""
|
|
517
|
+
if not hasattr(DeviceAllocator, '_pool_enabled_init'):
|
|
518
|
+
DeviceAllocator._pool_enabled = os.environ.get("NBX_ALLOC_POOL", "1") != "0"
|
|
519
|
+
DeviceAllocator._pool_enabled_init = True
|
|
520
|
+
if os.environ.get("NBX_ALLOC_STATS", "0") == "1":
|
|
521
|
+
_atexit.register(DeviceAllocator.print_alloc_stats)
|
|
522
|
+
|
|
523
|
+
@staticmethod
|
|
524
|
+
def print_alloc_stats() -> None:
|
|
525
|
+
"""Driver-level watermark per device + the pool's own numbers
|
|
526
|
+
(NBX_ALLOC_STATS=1, printed at exit). `peak_driver` is the peak of
|
|
527
|
+
bytes held from the driver (pool-parked blocks included);
|
|
528
|
+
`pool_peak` is the most the free-list ever parked; `slack` is the
|
|
529
|
+
smallest-fit over-allocation (allocated − requested) summed over
|
|
530
|
+
every fit hit, and its maximum for one hit."""
|
|
531
|
+
st = DeviceAllocator._POOL.stats
|
|
532
|
+
devs = sorted(set(DeviceAllocator._cuda_peak_bytes) | set(DeviceAllocator._POOL.cached_peak))
|
|
533
|
+
for d in devs:
|
|
534
|
+
print(f"[NBX_ALLOC_STATS] cuda:{d} peak_driver={DeviceAllocator._cuda_peak_bytes.get(d, 0) / 2**20:.0f}MB "
|
|
535
|
+
f"pool_peak={DeviceAllocator._POOL.cached_peak.get(d, 0) / 2**20:.0f}MB "
|
|
536
|
+
f"pool_now={DeviceAllocator._POOL.cached.get(d, 0) / 2**20:.0f}MB", flush=True)
|
|
537
|
+
print(f"[NBX_ALLOC_STATS] pool={'on' if DeviceAllocator._pool_enabled else 'off'} "
|
|
538
|
+
f"hits exact={st['exact']} fit={st['fit']} miss={st['miss']} "
|
|
539
|
+
f"slack_total={st['slack_total'] / 2**20:.0f}MB slack_max={st['slack_max'] / 2**20:.0f}MB "
|
|
540
|
+
f"flushes={st['flushes']} flushed={st['flushed_bytes'] / 2**20:.0f}MB "
|
|
541
|
+
f"evictions={st['evictions']} evicted={st['evicted_bytes'] / 2**20:.0f}MB "
|
|
542
|
+
f"range maps={st.get('range_maps', 0)} ({st.get('range_map_us', 0) / 1e6:.2f} s) "
|
|
543
|
+
f"reclaims={st.get('range_reclaims', 0)} unmaps={st.get('range_unmaps', 0)} "
|
|
544
|
+
f"ranges={ {d: len(rs) for d, rs in DeviceAllocator._POOL.ranges.items()} } "
|
|
545
|
+
f"cap={ {d: round(c / 2**20) for d, c in DeviceAllocator._pool_cap_bytes.items()} }MB", flush=True)
|
|
546
|
+
|
|
547
|
+
@staticmethod
|
|
548
|
+
def _block_reported(ptr: int, size: Optional[int], dev: int) -> None:
|
|
549
|
+
"""The pool's report of a block's size (`SegmentPool.on_block`): the per-pointer tables follow it."""
|
|
550
|
+
if size is None:
|
|
551
|
+
DeviceAllocator._cuda_ptr_size.pop(ptr, None)
|
|
552
|
+
DeviceAllocator._cuda_ptr_device.pop(ptr, None)
|
|
553
|
+
else:
|
|
554
|
+
DeviceAllocator._cuda_ptr_size[ptr] = size
|
|
555
|
+
DeviceAllocator._cuda_ptr_device[ptr] = dev
|
|
556
|
+
|
|
557
|
+
@staticmethod
|
|
558
|
+
def _segment_to_driver(dev: int, ptr: int) -> int:
|
|
559
|
+
"""Return a WHOLLY free segment (its one block, free, unchained) to the driver; its bytes."""
|
|
560
|
+
n = DeviceAllocator._POOL.drop_segment(dev, ptr)
|
|
561
|
+
_rt.device_memory().free(ptr)
|
|
562
|
+
DeviceAllocator._cuda_ptr_size.pop(ptr, None)
|
|
563
|
+
DeviceAllocator._range_del(ptr)
|
|
564
|
+
DeviceAllocator._cuda_ptr_device.pop(ptr, None)
|
|
565
|
+
DeviceAllocator._cuda_ptr_epoch.pop(ptr, None)
|
|
566
|
+
DeviceAllocator._cuda_live_bytes[dev] = max(0, DeviceAllocator._cuda_live_bytes.get(dev, 0) - n)
|
|
567
|
+
return n
|
|
568
|
+
|
|
569
|
+
@staticmethod
|
|
570
|
+
def _pool_parked_cap(dev: int) -> int:
|
|
571
|
+
"""Bytes the free-list may keep parked on `dev` (0 = unbounded):
|
|
572
|
+
`memory.alloc_pool_parked_cap_fraction` (arch yml) × the device's
|
|
573
|
+
total memory, resolved once per device. No profile / no key = 0."""
|
|
574
|
+
cap = DeviceAllocator._pool_cap_bytes.get(dev)
|
|
575
|
+
if cap is not None:
|
|
576
|
+
return cap
|
|
577
|
+
# "No hardware profile yet" (Prism has not stashed it) is NOT
|
|
578
|
+
# "explicitly unbounded": return 0 for this call without caching, so
|
|
579
|
+
# the cap resolves once the profile exists. The readers live one
|
|
580
|
+
# layer up (kernels/wrappers.py); the import is function-scoped —
|
|
581
|
+
# no import cycle at load — and the profile global is Prism's.
|
|
582
|
+
try:
|
|
583
|
+
from nbxtensor.ops.wrappers import get_hardware_profile, _arch_memory_param
|
|
584
|
+
prof = get_hardware_profile()
|
|
585
|
+
if prof is None or not getattr(prof, "devices", None):
|
|
586
|
+
return 0
|
|
587
|
+
frac = float(_arch_memory_param("alloc_pool_parked_cap_fraction", 0.0))
|
|
588
|
+
cap = 0
|
|
589
|
+
if frac > 0:
|
|
590
|
+
total = DeviceAllocator.device_total_bytes(dev)
|
|
591
|
+
# A limit imposed on the process (`set_limit`: an application's rung, a census) is the device the
|
|
592
|
+
# plan was sized for: the pool parks a fraction of IT, as it would on a card of that size — measured
|
|
593
|
+
# 2026-10-27, mochi-1-preview triton at the 8 GB rung on a 32 GB card parked its 9 GB text encoder
|
|
594
|
+
# arena (cap 16 GB) and peaked at 22.6 GB where a card of 8 GB would have freed it
|
|
595
|
+
limit = DeviceAllocator._imposed_bytes(dev) # the card the application holds this process to
|
|
596
|
+
if limit is not None and total > 0:
|
|
597
|
+
total = min(total, int(limit))
|
|
598
|
+
if total > 0:
|
|
599
|
+
cap = int(total * frac)
|
|
600
|
+
except Exception:
|
|
601
|
+
return 0
|
|
602
|
+
DeviceAllocator._pool_cap_bytes[dev] = cap
|
|
603
|
+
return cap
|
|
604
|
+
|
|
605
|
+
@staticmethod
|
|
606
|
+
def _pool_flush(dev: Optional[int] = None) -> int:
|
|
607
|
+
"""Return every wholly free segment to the driver (a segment still holding a live block stays). Returns the
|
|
608
|
+
bytes freed. Called on a refused malloc and from empty_cache_pool()."""
|
|
609
|
+
devs = [dev] if dev is not None else list(DeviceAllocator._POOL.free.keys())
|
|
610
|
+
return DeviceAllocator._POOL.flush(devs, DeviceAllocator._segment_to_driver)
|
|
611
|
+
|
|
612
|
+
@staticmethod
|
|
613
|
+
def empty_cache_pool() -> int:
|
|
614
|
+
"""Public API: release all pool-cached blocks back to the driver.
|
|
615
|
+
Returns total bytes freed. Safe to call when pool is disabled
|
|
616
|
+
(no-op). Useful between phases to defragment proactively."""
|
|
617
|
+
DeviceAllocator._maybe_init_pool()
|
|
618
|
+
if not DeviceAllocator._pool_enabled:
|
|
619
|
+
return 0
|
|
620
|
+
# an expandable range's free chunks stay mapped: the next component's tensors are their pages, and a driver
|
|
621
|
+
# allocation that needs them (a weight arena, a refused segment) reclaims what it needs at its refusal
|
|
622
|
+
# (`malloc_direct`). Unmapped here, they were mapped back at once — SANA-Video 16g: 65 calls between its
|
|
623
|
+
# phases, 47 737 unmaps and as many maps, +26 s (2026-11-02)
|
|
624
|
+
return DeviceAllocator._pool_flush()
|
|
625
|
+
|
|
626
|
+
@staticmethod
|
|
627
|
+
def new_allocation_epoch() -> int:
|
|
628
|
+
"""Declare that every address issued so far belongs to a runtime that
|
|
629
|
+
is being replaced. Returns the new epoch.
|
|
630
|
+
|
|
631
|
+
Call it AFTER releasing what can still be released (the parked pool)
|
|
632
|
+
and BEFORE the new runtime exists. From here on, a free of an older
|
|
633
|
+
pointer is a no-op rather than a park or a driver call.
|
|
634
|
+
|
|
635
|
+
The engine proper never calls this: a process has one device runtime
|
|
636
|
+
for its whole life. It exists because test processes legitimately
|
|
637
|
+
replace one, and because the alternative — a free-list that outlives
|
|
638
|
+
the runtime it belongs to — surfaced as an order-dependent numeric
|
|
639
|
+
failure in an unrelated kernel, which is the worst way for it to be
|
|
640
|
+
found.
|
|
641
|
+
"""
|
|
642
|
+
DeviceAllocator._alloc_epoch += 1
|
|
643
|
+
return DeviceAllocator._alloc_epoch
|
|
644
|
+
|
|
645
|
+
@staticmethod
|
|
646
|
+
def set_limit(nbytes: Optional[int], dev: Optional[int] = None) -> None:
|
|
647
|
+
"""Hold this process to a card of `nbytes` on device `dev` (None: every device), or lift the limit (`nbytes`
|
|
648
|
+
None). The allocator then behaves as such a card does: a malloc that would carry what it holds from the driver
|
|
649
|
+
(live and pooled) past the limit less the runtime's own context is refused as that card refuses it — the pool
|
|
650
|
+
flushed, the malloc retried, then a named DeviceOOMError — and the pool parks its fraction of the limit, not of
|
|
651
|
+
the physical card."""
|
|
652
|
+
if nbytes is not None and int(nbytes) < 0:
|
|
653
|
+
raise ValueError(f"set_limit: a memory limit is a byte count >= 0 or None, not {nbytes!r}")
|
|
654
|
+
DeviceAllocator._limit_bytes[dev] = None if nbytes is None else int(nbytes)
|
|
655
|
+
for table in (DeviceAllocator._rung_limit_bytes, DeviceAllocator._pool_cap_bytes):
|
|
656
|
+
if dev is None:
|
|
657
|
+
table.clear()
|
|
658
|
+
else:
|
|
659
|
+
table.pop(dev, None)
|
|
660
|
+
|
|
661
|
+
@staticmethod
|
|
662
|
+
def _imposed_bytes(dev: int) -> Optional[int]:
|
|
663
|
+
"""The card `set_limit` holds device `dev` to, in bytes; None: the physical card."""
|
|
664
|
+
lim = DeviceAllocator._limit_bytes
|
|
665
|
+
return lim[dev] if dev in lim else lim.get(None)
|
|
666
|
+
|
|
667
|
+
@staticmethod
|
|
668
|
+
def _rung_limit(dev: int) -> Optional[int]:
|
|
669
|
+
"""The bytes this process may hold from the driver on `dev` under a limit (`set_limit`): the limit less what
|
|
670
|
+
the runtime holds outside the allocator's own blocks (its context), read once from the driver; None: none."""
|
|
671
|
+
if dev in DeviceAllocator._rung_limit_bytes:
|
|
672
|
+
return DeviceAllocator._rung_limit_bytes[dev]
|
|
673
|
+
imposed = DeviceAllocator._imposed_bytes(dev)
|
|
674
|
+
limit = None
|
|
675
|
+
if imposed is not None:
|
|
676
|
+
free_b, total_b = DeviceAllocator.mem_get_info(dev)
|
|
677
|
+
context = max(0, int(total_b) - int(free_b) - DeviceAllocator._cuda_live_bytes.get(dev, 0))
|
|
678
|
+
limit = max(0, int(imposed) - context)
|
|
679
|
+
DeviceAllocator._rung_limit_bytes[dev] = limit
|
|
680
|
+
return limit
|
|
681
|
+
|
|
682
|
+
@staticmethod
|
|
683
|
+
def _past_budget(dm, dev: int, nbytes: int):
|
|
684
|
+
"""The backend's allocation-refused status when `nbytes` more would take this process past the budget its
|
|
685
|
+
device grants (`DeviceMemory.budget`), for a backend whose driver does not refuse there itself
|
|
686
|
+
(`supports("budget_refusal")`: Metal, whose buffers draw on the memory the whole machine shares; its host
|
|
687
|
+
buffers draw on the same budget); None otherwise. The ONE account decides — the device keeps none."""
|
|
688
|
+
if not dm.supports("budget_refusal"):
|
|
689
|
+
return None
|
|
690
|
+
rc, cap = dm.budget()
|
|
691
|
+
if rc != 0:
|
|
692
|
+
return rc
|
|
693
|
+
held = DeviceAllocator._cuda_live_bytes.get(dev, 0) + DeviceAllocator._host_pinned_live_bytes
|
|
694
|
+
return dm.ALLOC_REFUSED if held + int(nbytes) > int(cap) else None
|
|
695
|
+
|
|
696
|
+
#: device -> its VMM (`memory.vmm.Vmm`, with the profile's chunk) when the large class grows in an expandable
|
|
697
|
+
#: range; False: the device keeps driver segments (no VMM, or the profile measured none: vmm_chunk_bytes 0)
|
|
698
|
+
_VMM: Dict[int, Any] = {}
|
|
699
|
+
|
|
700
|
+
@staticmethod
|
|
701
|
+
def _vmm_chunk_fact(dev: int) -> int:
|
|
702
|
+
"""The expandable pool's chunk on `dev`: the hardware profile's measured `vmm_chunk_bytes` (first launch,
|
|
703
|
+
`facts_probe.vmm_chunk_bytes`; 0 = the device has no VMM). No profile installed (a tool, a test): 0. A profile
|
|
704
|
+
whose device states no measurement: refused by name — never a chunk the code chose."""
|
|
705
|
+
from nbxtensor.ops.wrappers import get_hardware_profile
|
|
706
|
+
prof = get_hardware_profile()
|
|
707
|
+
if prof is None or not getattr(prof, "devices", None):
|
|
708
|
+
return 0
|
|
709
|
+
spec = next((d for d in prof.devices if getattr(d, "index", None) == dev), None)
|
|
710
|
+
measured = (getattr(spec, "measured", None) or {}) if spec is not None else {}
|
|
711
|
+
if "vmm_chunk_bytes" not in measured:
|
|
712
|
+
raise RuntimeError(
|
|
713
|
+
f"ZERO FALLBACK: the hardware profile states no measured `vmm_chunk_bytes` for device {dev} — the "
|
|
714
|
+
f"expandable pool's chunk is measured at the first launch on this machine (facts_probe."
|
|
715
|
+
f"vmm_chunk_bytes, written into the generated profile); a committed profile records it from that tool")
|
|
716
|
+
return int(measured["vmm_chunk_bytes"] or 0)
|
|
717
|
+
|
|
718
|
+
@staticmethod
|
|
719
|
+
def use_expandable(dev: int, chunk: int, range_bytes: Optional[int] = None) -> None:
|
|
720
|
+
"""Set `dev`'s large class before its first allocation: an expandable range of `chunk` bytes, or the driver
|
|
721
|
+
segments (`chunk` 0) — the chunk probe's replay (`memory.trace_replay`), which measures each candidate in its
|
|
722
|
+
own process; a run reads the profile's measured fact instead (`_range_vmm`). `range_bytes`: the range's size
|
|
723
|
+
(the replayed card's; a run's is the profile's card). A chunk on a device that cannot map is refused by name."""
|
|
724
|
+
if chunk <= 0:
|
|
725
|
+
DeviceAllocator._VMM[dev] = False
|
|
726
|
+
return
|
|
727
|
+
libs = _rt.backend_table().get("driver_libs")
|
|
728
|
+
from nbxtensor.memory.vmm import Vmm
|
|
729
|
+
lib = Vmm.load(libs) if libs else None
|
|
730
|
+
vv = Vmm(lib, dev) if lib is not None else None
|
|
731
|
+
if vv is None or not vv.supported():
|
|
732
|
+
raise RuntimeError(f"ZERO FALLBACK: device {dev} cannot map an expandable range (no driver VMM): a chunk "
|
|
733
|
+
f"of {chunk} bytes is no measurement there")
|
|
734
|
+
vv.chunk = int(chunk)
|
|
735
|
+
vv.range_bytes = range_bytes
|
|
736
|
+
DeviceAllocator._VMM[dev] = vv
|
|
737
|
+
|
|
738
|
+
@staticmethod
|
|
739
|
+
def _range_vmm(dev: int):
|
|
740
|
+
"""The device's VMM when its large class grows in an expandable range, else None (resolved once)."""
|
|
741
|
+
v = DeviceAllocator._VMM.get(dev)
|
|
742
|
+
if v is None:
|
|
743
|
+
v = False
|
|
744
|
+
# the capability first, the fact after: a backend without the driver API (HIP, Metal), a process that sees
|
|
745
|
+
# no card, or a device without the VMM attribute keeps its driver segments and needs no chunk fact; only a
|
|
746
|
+
# device that CAN map reads the measured chunk, refused by name when the profile states none
|
|
747
|
+
libs = _rt.backend_table().get("driver_libs")
|
|
748
|
+
if libs:
|
|
749
|
+
from nbxtensor.memory.vmm import Vmm
|
|
750
|
+
lib = Vmm.load(libs)
|
|
751
|
+
if lib is not None:
|
|
752
|
+
vv = Vmm(lib, dev)
|
|
753
|
+
if vv.supported():
|
|
754
|
+
chunk = DeviceAllocator._vmm_chunk_fact(dev)
|
|
755
|
+
if chunk:
|
|
756
|
+
vv.chunk = chunk
|
|
757
|
+
v = vv
|
|
758
|
+
DeviceAllocator._VMM[dev] = v
|
|
759
|
+
return v or None
|
|
760
|
+
|
|
761
|
+
@staticmethod
|
|
762
|
+
def _range_reserve(dev: int, v) -> None:
|
|
763
|
+
"""Reserve one more address range for `dev`'s large class: the card's memory as the hardware profile declares
|
|
764
|
+
it (rule 1), whole chunks; registered as device memory for the pointer checks (`holds`)."""
|
|
765
|
+
from nbxtensor.ops.wrappers import get_hardware_profile
|
|
766
|
+
given = getattr(v, "range_bytes", None) # a replay names its card's size (use_expandable)
|
|
767
|
+
prof = get_hardware_profile() if given is None else None
|
|
768
|
+
spec = next((d for d in (getattr(prof, "devices", None) or []) if getattr(d, "index", None) == dev), None)
|
|
769
|
+
mb = (int(given) >> 20) if given is not None else int(getattr(spec, "memory_mb", 0) or 0)
|
|
770
|
+
if mb <= 0:
|
|
771
|
+
raise RuntimeError(f"ZERO FALLBACK: the hardware profile declares no memory for device {dev}: its "
|
|
772
|
+
f"expandable range has no size")
|
|
773
|
+
size = -(-(mb << 20) // v.chunk) * v.chunk
|
|
774
|
+
rc, base = v.reserve(size)
|
|
775
|
+
if rc != 0:
|
|
776
|
+
raise RuntimeError(f"cuMemAddressReserve of {size} bytes on device {dev} failed: CUresult={rc}")
|
|
777
|
+
r = range_add(DeviceAllocator._POOL, dev, base, size, v.chunk)
|
|
778
|
+
r.handles = {}
|
|
779
|
+
DeviceAllocator._range_add(base, size)
|
|
780
|
+
|
|
781
|
+
@staticmethod
|
|
782
|
+
def _range_alloc(dev: int, nbytes: int) -> Optional[int]:
|
|
783
|
+
"""A large block from `dev`'s expandable range: the best free block, else the range extended at its top (a
|
|
784
|
+
new range when the last is full); every chunk it touches mapped. None when the card refuses the mapping even
|
|
785
|
+
after the reclaim (the block goes back to the pool)."""
|
|
786
|
+
pool = DeviceAllocator._POOL
|
|
787
|
+
v = DeviceAllocator._range_vmm(dev)
|
|
788
|
+
ptr = pool.take(dev, nbytes)
|
|
789
|
+
if ptr is None:
|
|
790
|
+
if range_extend(pool, dev, pool.grow_size(nbytes)) is None:
|
|
791
|
+
DeviceAllocator._range_reserve(dev, v)
|
|
792
|
+
if range_extend(pool, dev, pool.grow_size(nbytes)) is None:
|
|
793
|
+
# larger than a whole range: the card's refusal, typed and carrying its figures as the driver
|
|
794
|
+
# path's (a plain RuntimeError here escaped every caller that reshapes the work on a refusal)
|
|
795
|
+
raise DeviceAllocator._oom(dev, nbytes, 2, _rt.device_memory())
|
|
796
|
+
ptr = pool.take(dev, nbytes)
|
|
797
|
+
actual = pool.alloc_size.pop(ptr, nbytes)
|
|
798
|
+
if not DeviceAllocator._range_map(dev, v, range_unmapped(pool, dev, ptr, actual)):
|
|
799
|
+
pool.park(dev, pool.release(ptr))
|
|
800
|
+
return None
|
|
801
|
+
DeviceAllocator._cuda_ptr_size[ptr] = actual
|
|
802
|
+
DeviceAllocator._cuda_ptr_device[ptr] = dev
|
|
803
|
+
DeviceAllocator._cuda_ptr_epoch[ptr] = DeviceAllocator._alloc_epoch
|
|
804
|
+
if _MALLOC_TRACE_FILE is not None:
|
|
805
|
+
_record_malloc_site(ptr, actual, dev)
|
|
806
|
+
return ptr
|
|
807
|
+
|
|
808
|
+
@staticmethod
|
|
809
|
+
def _range_map(dev: int, v, pairs) -> bool:
|
|
810
|
+
"""Map the (range, chunk) pairs: a physical chunk created and mapped for each, counted live. A card that
|
|
811
|
+
refuses (the imposed rung, the device budget, the driver) gets ONE reclaim — a synchronisation, then every
|
|
812
|
+
mapped chunk lying whole inside a free block unmapped and released, and the driver segments flushed — and the
|
|
813
|
+
mapping is retried; False when it still refuses."""
|
|
814
|
+
reclaimed = False
|
|
815
|
+
dm = _rt.device_memory()
|
|
816
|
+
_t0 = time.perf_counter() if pairs else 0.0
|
|
817
|
+
# the physical chunks this mapping's reclaim took back, KEPT to be mapped again where this mapping needs them: a
|
|
818
|
+
# remap is a map + an access grant, where a release then a create paid the driver's allocation of the bytes
|
|
819
|
+
# again (SANA-Video 16g: 2 852 chunks of 32 MiB created over a run, 10.6 s of its mapping — vmm_v100.md);
|
|
820
|
+
# whatever this mapping leaves unused goes back to the driver before it returns
|
|
821
|
+
spare: list = []
|
|
822
|
+
try:
|
|
823
|
+
for r, i in pairs:
|
|
824
|
+
while True:
|
|
825
|
+
rc = 2
|
|
826
|
+
if spare:
|
|
827
|
+
h = spare.pop()
|
|
828
|
+
rc = v.map(r.chunk_va(i), r.chunk, h)
|
|
829
|
+
if rc != 0:
|
|
830
|
+
v.release(h)
|
|
831
|
+
else:
|
|
832
|
+
limit = DeviceAllocator._rung_limit(dev)
|
|
833
|
+
if not (limit is not None and DeviceAllocator._cuda_live_bytes.get(dev, 0) + r.chunk > limit) \
|
|
834
|
+
and DeviceAllocator._past_budget(dm, dev, r.chunk) is None:
|
|
835
|
+
rc, h = v.create(r.chunk)
|
|
836
|
+
if rc == 0:
|
|
837
|
+
rc = v.map(r.chunk_va(i), r.chunk, h)
|
|
838
|
+
if rc != 0:
|
|
839
|
+
v.release(h)
|
|
840
|
+
if rc == 0:
|
|
841
|
+
break
|
|
842
|
+
if reclaimed:
|
|
843
|
+
DeviceAllocator.clear_last_error()
|
|
844
|
+
return False
|
|
845
|
+
# what this mapping still needs, never every free chunk: unmapping them all and mapping them back at
|
|
846
|
+
# the next growths cost HAT-L x4 4 143 unmaps + remaps per run, 2 s of its 12 (the supervisor 10:55)
|
|
847
|
+
DeviceAllocator._range_reclaim(dev, need=(len(pairs) - pairs.index((r, i))) * r.chunk, keep=spare)
|
|
848
|
+
reclaimed = True
|
|
849
|
+
r.mapped.add(i)
|
|
850
|
+
r.handles[i] = h
|
|
851
|
+
DeviceAllocator._POOL.stats["range_maps"] = DeviceAllocator._POOL.stats.get("range_maps", 0) + 1
|
|
852
|
+
live = DeviceAllocator._cuda_live_bytes.get(dev, 0) + r.chunk
|
|
853
|
+
DeviceAllocator._cuda_live_bytes[dev] = live
|
|
854
|
+
if live > DeviceAllocator._cuda_peak_bytes.get(dev, 0):
|
|
855
|
+
DeviceAllocator._cuda_peak_bytes[dev] = live
|
|
856
|
+
for _t, _p in (DeviceAllocator._windows.get(dev) or {}).items():
|
|
857
|
+
if live > _p:
|
|
858
|
+
DeviceAllocator._windows[dev][_t] = live
|
|
859
|
+
finally:
|
|
860
|
+
for h in spare:
|
|
861
|
+
v.release(h)
|
|
862
|
+
if pairs:
|
|
863
|
+
st = DeviceAllocator._POOL.stats
|
|
864
|
+
st["range_map_us"] = st.get("range_map_us", 0) + int((time.perf_counter() - _t0) * 1e6)
|
|
865
|
+
return True
|
|
866
|
+
|
|
867
|
+
@staticmethod
|
|
868
|
+
def _range_unmap(dev: int, r, i: int, keep: Optional[list] = None) -> None:
|
|
869
|
+
"""Unmap chunk `i` of `r`; its physical handle released to the driver, or appended to `keep` (a mapping's own
|
|
870
|
+
reclaim, which maps it again elsewhere in the range)."""
|
|
871
|
+
v = DeviceAllocator._VMM.get(dev)
|
|
872
|
+
v.unmap(r.chunk_va(i), r.chunk)
|
|
873
|
+
h = r.handles.pop(i)
|
|
874
|
+
if keep is None:
|
|
875
|
+
v.release(h)
|
|
876
|
+
else:
|
|
877
|
+
keep.append(h)
|
|
878
|
+
DeviceAllocator._cuda_live_bytes[dev] = max(0, DeviceAllocator._cuda_live_bytes.get(dev, 0) - r.chunk)
|
|
879
|
+
|
|
880
|
+
@staticmethod
|
|
881
|
+
def _range_reclaim(dev: int, need: Optional[int] = None, keep: Optional[list] = None) -> int:
|
|
882
|
+
"""ONE synchronisation of `dev`, then the mapped chunks lying whole inside free blocks of its ranges unmapped and
|
|
883
|
+
released (nothing a kernel may still read: the work is drained) — the highest addresses first, until `need`
|
|
884
|
+
bytes are given back (None: all of them), the driver segments flushed when the chunks were not enough. The
|
|
885
|
+
bytes given back. `keep`: the handles are appended there instead of released (a range mapping's own reclaim)."""
|
|
886
|
+
DeviceAllocator.device_synchronize(dev)
|
|
887
|
+
st = DeviceAllocator._POOL.stats
|
|
888
|
+
st["range_reclaims"] = st.get("range_reclaims", 0) + 1
|
|
889
|
+
out = 0
|
|
890
|
+
pairs = sorted(range_reclaimable(DeviceAllocator._POOL, dev), key=lambda ri: -ri[0].chunk_va(ri[1]))
|
|
891
|
+
for r, i in pairs:
|
|
892
|
+
if need is not None and out >= need:
|
|
893
|
+
return out
|
|
894
|
+
st["range_unmaps"] = st.get("range_unmaps", 0) + 1
|
|
895
|
+
DeviceAllocator._range_unmap(dev, r, i, keep=keep)
|
|
896
|
+
r.mapped.discard(i)
|
|
897
|
+
out += r.chunk
|
|
898
|
+
return out + DeviceAllocator._pool_flush(dev)
|
|
899
|
+
|
|
900
|
+
@staticmethod
|
|
901
|
+
def _oom(dev: int, nbytes: int, ret: int, dm) -> "DeviceOOMError":
|
|
902
|
+
"""The named refusal of `nbytes` on `dev` (the driver's code `ret`): what was live, what the pool held, what the
|
|
903
|
+
driver had free — the text every log reader keys on."""
|
|
904
|
+
DeviceAllocator.clear_last_error() # a refused call must not poison the next one
|
|
905
|
+
# Diagnostic: how much live + how big the pool was at OOM time.
|
|
906
|
+
live_now = DeviceAllocator._cuda_live_bytes.get(dev, 0)
|
|
907
|
+
pool_total = 0
|
|
908
|
+
pool_count = 0
|
|
909
|
+
per_dev = DeviceAllocator._POOL.free.get(dev, {})
|
|
910
|
+
for sz, ptrs in per_dev.items():
|
|
911
|
+
pool_total += sz * len(ptrs)
|
|
912
|
+
pool_count += len(ptrs)
|
|
913
|
+
try:
|
|
914
|
+
_rc, driver_free, driver_total = dm.mem_get_info() # a runtime without the symbol raises: 0 below
|
|
915
|
+
except Exception:
|
|
916
|
+
driver_free = driver_total = 0
|
|
917
|
+
# The message is unchanged to the byte: the queue runner's error
|
|
918
|
+
# extractor and every log-reading tool key on this text.
|
|
919
|
+
return DeviceOOMError(
|
|
920
|
+
f"GPU malloc failed (error {ret}) for {nbytes} bytes "
|
|
921
|
+
f"[device cuda:{dev} live_tracked={live_now/1024/1024:.0f}MB "
|
|
922
|
+
f"pool_cached={pool_total/1024/1024:.0f}MB ({pool_count} blocks) "
|
|
923
|
+
f"driver_free={driver_free/1024/1024:.0f}MB / "
|
|
924
|
+
f"driver_total={driver_total/1024/1024:.0f}MB]"
|
|
925
|
+
+ (f" under the imposed limit: {DeviceAllocator._rung_limit_bytes[dev]/1024/1024:.0f}MB this process may "
|
|
926
|
+
f"hold (memory.set_limit less the runtime's context)"
|
|
927
|
+
if DeviceAllocator._rung_limit_bytes.get(dev) is not None else ""),
|
|
928
|
+
requested=nbytes, device_idx=dev, live=live_now,
|
|
929
|
+
pool_cached=pool_total, pool_blocks=pool_count,
|
|
930
|
+
driver_free=driver_free if driver_total else None,
|
|
931
|
+
driver_total=driver_total or None)
|
|
932
|
+
|
|
933
|
+
@staticmethod
|
|
934
|
+
def _driver_malloc(ptr_obj, nbytes: int, dev: int) -> int:
|
|
935
|
+
"""The runtime's malloc, or the refusal a card of the imposed rung gives (cudaErrorMemoryAllocation, 2)."""
|
|
936
|
+
limit = DeviceAllocator._rung_limit(dev)
|
|
937
|
+
if limit is not None and DeviceAllocator._cuda_live_bytes.get(dev, 0) + int(nbytes) > limit:
|
|
938
|
+
return 2
|
|
939
|
+
dm = _rt.device_memory()
|
|
940
|
+
refused = DeviceAllocator._past_budget(dm, dev, nbytes)
|
|
941
|
+
if refused is not None:
|
|
942
|
+
ptr_obj.value = None
|
|
943
|
+
return refused
|
|
944
|
+
rc, ptr = dm.malloc(nbytes)
|
|
945
|
+
ptr_obj.value = ptr or None
|
|
946
|
+
return rc
|
|
947
|
+
|
|
948
|
+
@staticmethod
|
|
949
|
+
def install_interceptor(interceptor: AllocatorInterceptor) -> None:
|
|
950
|
+
"""Put `interceptor` in front of the five device operations. Installing the one in force again is a no-op; a
|
|
951
|
+
second, different one is refused by name (each would believe it sees every allocation)."""
|
|
952
|
+
cur = DeviceAllocator._interceptor
|
|
953
|
+
if cur is not None and cur is not interceptor:
|
|
954
|
+
raise RuntimeError(
|
|
955
|
+
f"an allocator interceptor is already installed ({type(cur).__module__}.{type(cur).__qualname__}); "
|
|
956
|
+
f"{type(interceptor).__module__}.{type(interceptor).__qualname__} cannot stand beside it")
|
|
957
|
+
DeviceAllocator._interceptor = interceptor
|
|
958
|
+
|
|
959
|
+
@staticmethod
|
|
960
|
+
def remove_interceptor(interceptor: AllocatorInterceptor) -> None:
|
|
961
|
+
"""Take `interceptor` away; refused by name when it is not the one in force."""
|
|
962
|
+
if DeviceAllocator._interceptor is not interceptor:
|
|
963
|
+
raise RuntimeError(f"{type(interceptor).__qualname__} is not the allocator interceptor in force")
|
|
964
|
+
DeviceAllocator._interceptor = None
|
|
965
|
+
|
|
966
|
+
@staticmethod
|
|
967
|
+
def malloc_cuda(nbytes: int, dev_idx: Optional[int] = None, pooled: bool = True) -> int:
|
|
968
|
+
"""Allocate GPU memory: through the interceptor in force, else directly (`malloc_direct`)."""
|
|
969
|
+
i = DeviceAllocator._interceptor
|
|
970
|
+
if i is None:
|
|
971
|
+
return DeviceAllocator.malloc_direct(nbytes, dev_idx, pooled)
|
|
972
|
+
return i.malloc(DeviceAllocator.malloc_direct, nbytes, dev_idx, pooled)
|
|
973
|
+
|
|
974
|
+
@staticmethod
|
|
975
|
+
def malloc_direct(nbytes: int, dev_idx: Optional[int] = None, pooled: bool = True) -> int:
|
|
976
|
+
"""Allocate GPU memory.
|
|
977
|
+
|
|
978
|
+
Default path: synchronous cudaMalloc. The stream-ordered allocator
|
|
979
|
+
(cudaMallocAsync) was prototyped but disabled because its per-
|
|
980
|
+
device memory pools don't grant cross-device peer access by
|
|
981
|
+
default — freeing a cuda:N pointer from a Python-GC site while
|
|
982
|
+
cuda:M is current produced illegal memory accesses on the multi-
|
|
983
|
+
GPU pipeline_parallel path (Qwen3-30B --triton). The synchronous
|
|
984
|
+
path combined with TritonSequence's _deferred batch-release is
|
|
985
|
+
stable across single-GPU, zero3, and pipeline_parallel.
|
|
986
|
+
|
|
987
|
+
Phase 2 path (NBX_ALLOC_POOL=1): satisfies requests from a per-
|
|
988
|
+
device pool first (`memory.pool.SegmentPool`: two size classes, the
|
|
989
|
+
half rule); on a miss the device grows by `grow_size` and the request
|
|
990
|
+
is taken from the new segment; on cudaMalloc OOM, flushes the pool's
|
|
991
|
+
wholly free segments back to the driver and retries (analog to torch
|
|
992
|
+
CachingAllocator release_cached_blocks → retry).
|
|
993
|
+
|
|
994
|
+
dev_idx (C3): the CURRENT device index, when the caller already
|
|
995
|
+
knows it — skips the cudaGetDevice round-trip used purely for
|
|
996
|
+
pool/accounting bookkeeping. The caller MUST guarantee the
|
|
997
|
+
runtime current device equals dev_idx (cudaMalloc allocates on
|
|
998
|
+
the current device). None (default) keeps the internal query.
|
|
999
|
+
|
|
1000
|
+
pooled=False: a block that never enters the pool — taken from the driver (a refusal still flushes the pool and
|
|
1001
|
+
retries) and handed straight back to it when freed. A component's weight arena (`ComponentArena`): large and
|
|
1002
|
+
held for a whole component or segment; pooled, the small tensors that outlive it split it once freed and pin
|
|
1003
|
+
it whole (mochi-1-preview triton at the 8 GB rung, 2026-10-28: 3 553 MB of freed blocks held by ~19 MB of
|
|
1004
|
+
live tensors refused the transformer's 5.2 GB segment).
|
|
1005
|
+
"""
|
|
1006
|
+
if nbytes == 0:
|
|
1007
|
+
return 0
|
|
1008
|
+
DeviceAllocator._maybe_init_pool()
|
|
1009
|
+
dev = dev_idx if dev_idx is not None else DeviceAllocator.get_device()
|
|
1010
|
+
|
|
1011
|
+
# The large class in the device's expandable range (R16, `memory.vmm`): blocks of one reserved address range,
|
|
1012
|
+
# chunks mapped as it grows, whole free chunks unmapped at a refusal — no driver segment, so no remainder a
|
|
1013
|
+
# live neighbour pins keeps its pages (Wan2.1-I2V triton 16g, 2026-11-01).
|
|
1014
|
+
if DeviceAllocator._pool_enabled and pooled and DeviceAllocator._POOL.cls(nbytes) == LARGE \
|
|
1015
|
+
and DeviceAllocator._range_vmm(dev) is not None:
|
|
1016
|
+
ptr = DeviceAllocator._range_alloc(dev, nbytes)
|
|
1017
|
+
if ptr is None:
|
|
1018
|
+
raise DeviceAllocator._oom(dev, nbytes, 2, _rt.device_memory())
|
|
1019
|
+
return ptr
|
|
1020
|
+
|
|
1021
|
+
# Pool fast path
|
|
1022
|
+
if DeviceAllocator._pool_enabled and pooled:
|
|
1023
|
+
ptr = DeviceAllocator._POOL.take(dev, nbytes)
|
|
1024
|
+
if ptr is not None:
|
|
1025
|
+
# alloc size: exact-size hit reuses nbytes; smallest-fit
|
|
1026
|
+
# populated the pool's alloc_size with the bigger size.
|
|
1027
|
+
actual = DeviceAllocator._POOL.alloc_size.pop(ptr, nbytes)
|
|
1028
|
+
DeviceAllocator._cuda_ptr_size[ptr] = actual
|
|
1029
|
+
DeviceAllocator._cuda_ptr_device[ptr] = dev # its segment's range stays registered
|
|
1030
|
+
DeviceAllocator._cuda_ptr_epoch[ptr] = DeviceAllocator._alloc_epoch
|
|
1031
|
+
if _MALLOC_TRACE_FILE is not None:
|
|
1032
|
+
_record_malloc_site(ptr, actual, dev)
|
|
1033
|
+
# No live-byte change: pool blocks stay counted as live
|
|
1034
|
+
# for the lifetime of the segment (pool == still allocated
|
|
1035
|
+
# from the driver's POV). Peak unchanged.
|
|
1036
|
+
return ptr
|
|
1037
|
+
|
|
1038
|
+
# The device grows by what the pool asks for the request (`grow_size`: a small segment for a small request,
|
|
1039
|
+
# the request for a large one) while its wholly free segments stay parked: with the pool's two size classes and
|
|
1040
|
+
# its half rule (memory/pool.py), no segment is pinned by a block smaller than its free remainder, so what a
|
|
1041
|
+
# refused malloc flushes is whole. 164e89dc flushed the pool before every growth instead (Sana_1600M_4Kpx
|
|
1042
|
+
# triton on a V100-16GB, lot 2026-10-28: 4.5 GB parked in 12 split segments beside 9.4 GB alive refused a
|
|
1043
|
+
# 2.96 GB convolution) — and emptied the small free segments the scalars then carved large blocks for: Allegro
|
|
1044
|
+
# triton 16g, gate 2026-11-01, 4.4 GB of free blocks in pinned segments refused a 2.5 GB gelu.
|
|
1045
|
+
grown = DeviceAllocator._POOL.grow_size(nbytes) if DeviceAllocator._pool_enabled and pooled else nbytes
|
|
1046
|
+
ptr_obj = ctypes.c_void_p()
|
|
1047
|
+
dm = _rt.device_memory()
|
|
1048
|
+
ret = DeviceAllocator._driver_malloc(ptr_obj, grown, dev)
|
|
1049
|
+
|
|
1050
|
+
# No reclaim-and-retry of the hot loops' deferred dead-tensor queues here
|
|
1051
|
+
# (ZERO FALLBACK, 2026-10-11). That rescue drained the queue on a refused
|
|
1052
|
+
# malloc and retried — `[NBX_OOM_RECLAIM] freed ...` 1 279 times in one
|
|
1053
|
+
# 16 GB Allegro run — so a plan that did not leave room for the queue ran
|
|
1054
|
+
# anyway. Prism now PRICES the queue (`PrismSolver._deferred_drain_mb`, the
|
|
1055
|
+
# vendor profile's `memory.deferred_drain_cliff_bytes`), and a malloc refused
|
|
1056
|
+
# with the queue inside its price is a plan defect, raised below and named
|
|
1057
|
+
# by the hot loop with the op, the queue it held and the price it had.
|
|
1058
|
+
|
|
1059
|
+
# Phase 2 OOM-retry: if cudaMalloc fails and the pool holds cached
|
|
1060
|
+
# blocks, flush them back to the driver and retry. Defragments the
|
|
1061
|
+
# driver's internal heap before declaring failure.
|
|
1062
|
+
if ret != 0 and DeviceAllocator._pool_enabled:
|
|
1063
|
+
DeviceAllocator.clear_last_error() # a refused call must not poison the next one
|
|
1064
|
+
freed = DeviceAllocator._pool_flush(dev)
|
|
1065
|
+
if DeviceAllocator._POOL.ranges.get(dev):
|
|
1066
|
+
freed += DeviceAllocator._range_reclaim(dev, need=grown) # the range's free chunks, what it needs
|
|
1067
|
+
if freed > 0:
|
|
1068
|
+
ret = DeviceAllocator._driver_malloc(ptr_obj, grown, dev) # what the pool records, never the request
|
|
1069
|
+
|
|
1070
|
+
# The engine's other cached memory, released the same way: a recorded replay plan's slab between two runs of
|
|
1071
|
+
# its sequence (`triton/replay.release_idle_slabs`), freed through the pool, flushed, retried once.
|
|
1072
|
+
if ret != 0 and DeviceAllocator._idle_reclaimers:
|
|
1073
|
+
DeviceAllocator.clear_last_error() # a refused call must not poison the next one
|
|
1074
|
+
got = sum(int(r(dev) or 0) for r in list(DeviceAllocator._idle_reclaimers))
|
|
1075
|
+
if got > 0:
|
|
1076
|
+
if DeviceAllocator._pool_enabled:
|
|
1077
|
+
DeviceAllocator._pool_flush(dev)
|
|
1078
|
+
ret, _p = dm.malloc(grown) # the runtime's malloc itself, as before (no rung check here)
|
|
1079
|
+
ptr_obj.value = _p or None
|
|
1080
|
+
|
|
1081
|
+
# Pre-allocation OOM-recovery (independent of pool): trigger a
|
|
1082
|
+
# cycle-collecting gc.collect() then retry. Sana 4Kpx triton
|
|
1083
|
+
# showed ~25 GB of NBXTensor instances pinned alive at conv::62
|
|
1084
|
+
# OOM by Python frame-args lists from recursive conv2d_wrapper
|
|
1085
|
+
# band loops; gc.collect() picks them up if they form a cycle.
|
|
1086
|
+
# Gated by NBX_GC_ON_OOM=1 (opt-in until validated cross-model).
|
|
1087
|
+
if ret != 0 and os.environ.get("NBX_GC_ON_OOM", "0") == "1":
|
|
1088
|
+
DeviceAllocator.clear_last_error() # a refused call must not poison the next one
|
|
1089
|
+
import gc as _gc_oom
|
|
1090
|
+
_gc_oom.collect()
|
|
1091
|
+
ret = DeviceAllocator._driver_malloc(ptr_obj, grown, dev)
|
|
1092
|
+
|
|
1093
|
+
if ret != 0:
|
|
1094
|
+
raise DeviceAllocator._oom(dev, nbytes, ret, dm)
|
|
1095
|
+
p = ptr_obj.value or 0
|
|
1096
|
+
DeviceAllocator._cuda_ptr_size[p] = grown
|
|
1097
|
+
DeviceAllocator._range_add(p, grown)
|
|
1098
|
+
DeviceAllocator._cuda_ptr_device[p] = dev
|
|
1099
|
+
if DeviceAllocator._pool_enabled and pooled:
|
|
1100
|
+
# the new segment joins the pool free, and the request is taken from it (carved, for a small one)
|
|
1101
|
+
DeviceAllocator._POOL.add_free_segment(dev, p, grown, nbytes)
|
|
1102
|
+
taken = DeviceAllocator._POOL.take(dev, nbytes)
|
|
1103
|
+
assert taken == p, f"the pool did not hand back the segment it was just given: {taken} for {p:#x}"
|
|
1104
|
+
DeviceAllocator._cuda_ptr_size[p] = DeviceAllocator._POOL.alloc_size.pop(p, grown)
|
|
1105
|
+
DeviceAllocator._cuda_ptr_epoch[p] = DeviceAllocator._alloc_epoch
|
|
1106
|
+
live = DeviceAllocator._cuda_live_bytes.get(dev, 0) + grown
|
|
1107
|
+
DeviceAllocator._cuda_live_bytes[dev] = live
|
|
1108
|
+
peak = DeviceAllocator._cuda_peak_bytes.get(dev, 0)
|
|
1109
|
+
if live > peak:
|
|
1110
|
+
DeviceAllocator._cuda_peak_bytes[dev] = live
|
|
1111
|
+
_win = DeviceAllocator._windows.get(dev)
|
|
1112
|
+
if _win:
|
|
1113
|
+
for _t, _p in _win.items():
|
|
1114
|
+
if live > _p:
|
|
1115
|
+
_win[_t] = live
|
|
1116
|
+
if _MALLOC_TRACE_FILE is not None:
|
|
1117
|
+
_record_malloc_site(p, nbytes, dev, pooled=DeviceAllocator._pool_enabled and pooled)
|
|
1118
|
+
return p
|
|
1119
|
+
|
|
1120
|
+
@staticmethod
|
|
1121
|
+
def free_cuda(ptr: int):
|
|
1122
|
+
"""Free GPU memory: through the interceptor in force, else directly (`free_direct`)."""
|
|
1123
|
+
i = DeviceAllocator._interceptor
|
|
1124
|
+
if i is None:
|
|
1125
|
+
return DeviceAllocator.free_direct(ptr)
|
|
1126
|
+
return i.free(DeviceAllocator.free_direct, ptr)
|
|
1127
|
+
|
|
1128
|
+
@staticmethod
|
|
1129
|
+
def free_direct(ptr: int):
|
|
1130
|
+
"""Free GPU memory.
|
|
1131
|
+
|
|
1132
|
+
Default path: cudaFree. Blocks the calling thread until the
|
|
1133
|
+
device is idle for the relevant stream, so TritonSequence
|
|
1134
|
+
batches freeable tensors into _deferred and releases them
|
|
1135
|
+
after a sync at the end of each forward pass.
|
|
1136
|
+
|
|
1137
|
+
Phase 2 path (NBX_ALLOC_POOL=1): the pointer is returned to the
|
|
1138
|
+
per-device free-list pool instead of cudaFree, keeping the
|
|
1139
|
+
driver's internal heap intact (avoids fragmenting it with the
|
|
1140
|
+
churn of small/medium tensors typical of a diffusion forward
|
|
1141
|
+
pass). Live byte counters stay unchanged because the block is
|
|
1142
|
+
STILL allocated from the driver's POV — it just sits in the
|
|
1143
|
+
pool waiting to satisfy the next compatible malloc. cudaFree
|
|
1144
|
+
eventually fires from _pool_flush() on OOM-retry or from
|
|
1145
|
+
empty_cache_pool().
|
|
1146
|
+
"""
|
|
1147
|
+
if not ptr:
|
|
1148
|
+
return
|
|
1149
|
+
DeviceAllocator._maybe_init_pool()
|
|
1150
|
+
|
|
1151
|
+
# A pointer issued by a runtime that no longer exists.
|
|
1152
|
+
#
|
|
1153
|
+
# Its memory went when that runtime did, so there is nothing to give
|
|
1154
|
+
# back — and both of the things this function otherwise does would be
|
|
1155
|
+
# wrong: parking it hands a dead address to the next allocation, and
|
|
1156
|
+
# freeing it asks the CURRENT runtime to release something it never
|
|
1157
|
+
# issued, which it correctly refuses and loudly. Drop the bookkeeping
|
|
1158
|
+
# and say nothing. Only a process that replaces its runtime ever
|
|
1159
|
+
# reaches this; with one runtime the epoch never moves.
|
|
1160
|
+
_epoch = DeviceAllocator._cuda_ptr_epoch.pop(ptr, DeviceAllocator._alloc_epoch)
|
|
1161
|
+
if _epoch != DeviceAllocator._alloc_epoch:
|
|
1162
|
+
_dev = DeviceAllocator._cuda_ptr_device.pop(ptr, None)
|
|
1163
|
+
_sz = DeviceAllocator._cuda_ptr_size.pop(ptr, None)
|
|
1164
|
+
DeviceAllocator._POOL.alloc_size.pop(ptr, None)
|
|
1165
|
+
if _dev is not None and _sz:
|
|
1166
|
+
_live = DeviceAllocator._cuda_live_bytes.get(_dev, 0) - _sz
|
|
1167
|
+
DeviceAllocator._cuda_live_bytes[_dev] = max(0, _live)
|
|
1168
|
+
return
|
|
1169
|
+
|
|
1170
|
+
# The pool: the block goes back to its segment, merged with its free neighbours; a segment wholly free over
|
|
1171
|
+
# the parked cap returns to the driver. A block of a segment is never handed to the driver alone.
|
|
1172
|
+
if DeviceAllocator._pool_enabled and ptr in DeviceAllocator._POOL.blocks:
|
|
1173
|
+
if DeviceAllocator._POOL.blocks[ptr][3]:
|
|
1174
|
+
print(f"[NBX-ALLOC-ERROR] free of a block already free: {ptr:#x}", flush=True)
|
|
1175
|
+
return
|
|
1176
|
+
dev = DeviceAllocator._POOL.blocks[ptr][4]
|
|
1177
|
+
nbytes = DeviceAllocator._POOL.blocks[ptr][0]
|
|
1178
|
+
cached = DeviceAllocator._POOL.park(dev, DeviceAllocator._POOL.release(ptr))
|
|
1179
|
+
cap = DeviceAllocator._pool_parked_cap(dev)
|
|
1180
|
+
if cap and cached > cap:
|
|
1181
|
+
# a range's free chunks stay mapped (they are the next request's pages): they are unmapped only at a
|
|
1182
|
+
# refusal (`_range_reclaim`), as torch's expandable segments release only on an out-of-memory — an unmap
|
|
1183
|
+
# here needed a device synchronisation at every free over the cap (Allegro triton 16g's VAE: 3x slower)
|
|
1184
|
+
DeviceAllocator._POOL.evict(dev, cached - cap, DeviceAllocator._segment_to_driver)
|
|
1185
|
+
if _MALLOC_TRACE_FILE is not None:
|
|
1186
|
+
_record_free_site(ptr, nbytes)
|
|
1187
|
+
return
|
|
1188
|
+
|
|
1189
|
+
alloc_dev = DeviceAllocator._cuda_ptr_device.pop(ptr, None)
|
|
1190
|
+
_rc = _rt.device_memory().free(ptr)
|
|
1191
|
+
if _rc not in (None, 0):
|
|
1192
|
+
# Finalizer context — raising here cascades through GC; a
|
|
1193
|
+
# LOUD print preserves the sticky-error surfacing site.
|
|
1194
|
+
print(f"[NBX-CUDA-ERROR] cudaFree rc={_rc} ptr={ptr:#x} "
|
|
1195
|
+
f"dev={alloc_dev} — sticky async error surfaced at "
|
|
1196
|
+
f"this free; the fault is at or before it.", flush=True)
|
|
1197
|
+
nbytes = DeviceAllocator._cuda_ptr_size.pop(ptr, None)
|
|
1198
|
+
DeviceAllocator._range_del(ptr)
|
|
1199
|
+
if _MALLOC_TRACE_FILE is not None and nbytes is not None:
|
|
1200
|
+
_record_free_site(ptr, nbytes)
|
|
1201
|
+
if nbytes is not None:
|
|
1202
|
+
if alloc_dev is not None:
|
|
1203
|
+
live = DeviceAllocator._cuda_live_bytes.get(alloc_dev, 0) - nbytes
|
|
1204
|
+
DeviceAllocator._cuda_live_bytes[alloc_dev] = live
|
|
1205
|
+
else:
|
|
1206
|
+
dev = DeviceAllocator.get_device()
|
|
1207
|
+
live = DeviceAllocator._cuda_live_bytes.get(dev, 0) - nbytes
|
|
1208
|
+
if live < 0:
|
|
1209
|
+
for d in DeviceAllocator._cuda_live_bytes:
|
|
1210
|
+
if DeviceAllocator._cuda_live_bytes[d] >= nbytes:
|
|
1211
|
+
DeviceAllocator._cuda_live_bytes[d] -= nbytes
|
|
1212
|
+
break
|
|
1213
|
+
else:
|
|
1214
|
+
DeviceAllocator._cuda_live_bytes[dev] = live
|
|
1215
|
+
|
|
1216
|
+
@staticmethod
|
|
1217
|
+
def bind_mapped(ptr: int, nbytes: int) -> None:
|
|
1218
|
+
"""Make `nbytes` of a file mapping at host address `ptr` readable by kernels IN PLACE — on a unified
|
|
1219
|
+
device only (one pool: the GPU reads the mapping's pages, nothing is copied). A discrete device has no
|
|
1220
|
+
such address space: refused by name. Registered in the range table, so `holds()` knows the range."""
|
|
1221
|
+
if _rt.gpu_backend() != "metal":
|
|
1222
|
+
raise RuntimeError(f"ZERO FALLBACK: binding a file mapping in place needs unified memory; backend "
|
|
1223
|
+
f"{_rt.gpu_backend()!r} copies host experts into device slots")
|
|
1224
|
+
start = _rt.device_memory().map_host(int(ptr), int(nbytes))
|
|
1225
|
+
page = int(os.sysconf("SC_PAGESIZE"))
|
|
1226
|
+
end = -(-(int(ptr) + int(nbytes)) // page) * page
|
|
1227
|
+
DeviceAllocator._range_add(start, max(DeviceAllocator._range_size.get(start, 0), end - start))
|
|
1228
|
+
|
|
1229
|
+
@staticmethod
|
|
1230
|
+
def unbind_mapped(ptr: int) -> None:
|
|
1231
|
+
"""Undo `bind_mapped` for the range starting at `ptr`'s page."""
|
|
1232
|
+
page = int(os.sysconf("SC_PAGESIZE"))
|
|
1233
|
+
start = int(ptr) // page * page
|
|
1234
|
+
_rt.device_memory().unmap_host(start)
|
|
1235
|
+
DeviceAllocator._range_del(start)
|
|
1236
|
+
|
|
1237
|
+
@staticmethod
|
|
1238
|
+
def malloc_host_pinned(nbytes: int) -> int:
|
|
1239
|
+
"""Allocate page-locked host memory. Enables non_blocking H2D DMA.
|
|
1240
|
+
|
|
1241
|
+
Uses cudaMallocHost (or hipHostMalloc on ROCm). The returned
|
|
1242
|
+
pointer is a HOST address — numpy can wrap it via
|
|
1243
|
+
np.frombuffer(ctypes.string_at(ptr, nbytes), ...) or ctypes
|
|
1244
|
+
casting, and cudaMemcpy kind=1 (H2D) uses it as src.
|
|
1245
|
+
"""
|
|
1246
|
+
if nbytes == 0:
|
|
1247
|
+
return 0
|
|
1248
|
+
dm = _rt.device_memory()
|
|
1249
|
+
if not dm.supports("host_alloc"):
|
|
1250
|
+
raise RuntimeError(
|
|
1251
|
+
f"Pinned host allocation unsupported on backend "
|
|
1252
|
+
f"{_rt.gpu_backend()!r}")
|
|
1253
|
+
ret = DeviceAllocator._past_budget(dm, DeviceAllocator.get_device(), nbytes)
|
|
1254
|
+
if ret is None:
|
|
1255
|
+
ret, p = dm.host_alloc(nbytes)
|
|
1256
|
+
if ret != 0:
|
|
1257
|
+
DeviceAllocator.clear_last_error() # a refused call must not poison the next one
|
|
1258
|
+
raise RuntimeError(
|
|
1259
|
+
f"Host pinned malloc failed (error {ret}) for {nbytes} bytes")
|
|
1260
|
+
DeviceAllocator._host_pinned_ptr_size[p] = nbytes
|
|
1261
|
+
DeviceAllocator._range_add(p, nbytes) # device-mapped (UVA): a kernel may read it, so holds() knows it
|
|
1262
|
+
DeviceAllocator._host_pinned_live_bytes += nbytes
|
|
1263
|
+
if DeviceAllocator._host_pinned_live_bytes > DeviceAllocator._host_pinned_peak_bytes:
|
|
1264
|
+
DeviceAllocator._host_pinned_peak_bytes = DeviceAllocator._host_pinned_live_bytes
|
|
1265
|
+
return p
|
|
1266
|
+
|
|
1267
|
+
@staticmethod
|
|
1268
|
+
def register_host_pinned(ptr: int, nbytes: int) -> None:
|
|
1269
|
+
"""Page-lock `nbytes` of existing host memory at `ptr` (the backend's `host_register`), accounted as a pinned
|
|
1270
|
+
allocation is: in `holds()`'s ranges (device-mapped under UVA) and in the pinned live / peak bytes.
|
|
1271
|
+
`unregister_host_pinned` undoes it. A backend without the primitive is refused by name — the caller asks
|
|
1272
|
+
`supports("host_register")` first."""
|
|
1273
|
+
dm = _rt.device_memory()
|
|
1274
|
+
if not dm.supports("host_register"):
|
|
1275
|
+
raise RuntimeError(f"host memory registration unsupported on backend {_rt.gpu_backend()!r}")
|
|
1276
|
+
ret = dm.host_register(int(ptr), int(nbytes))
|
|
1277
|
+
if ret != 0:
|
|
1278
|
+
DeviceAllocator.clear_last_error()
|
|
1279
|
+
raise RuntimeError(f"Host memory registration failed (error {ret}) for {nbytes} bytes")
|
|
1280
|
+
DeviceAllocator._host_pinned_ptr_size[ptr] = nbytes
|
|
1281
|
+
DeviceAllocator._range_add(ptr, nbytes)
|
|
1282
|
+
DeviceAllocator._host_pinned_live_bytes += nbytes
|
|
1283
|
+
if DeviceAllocator._host_pinned_live_bytes > DeviceAllocator._host_pinned_peak_bytes:
|
|
1284
|
+
DeviceAllocator._host_pinned_peak_bytes = DeviceAllocator._host_pinned_live_bytes
|
|
1285
|
+
|
|
1286
|
+
@staticmethod
|
|
1287
|
+
def unregister_host_pinned(ptr: int) -> None:
|
|
1288
|
+
if ptr:
|
|
1289
|
+
_rt.device_memory().host_unregister(int(ptr))
|
|
1290
|
+
nbytes = DeviceAllocator._host_pinned_ptr_size.pop(ptr, None)
|
|
1291
|
+
DeviceAllocator._range_del(ptr)
|
|
1292
|
+
if nbytes is not None:
|
|
1293
|
+
DeviceAllocator._host_pinned_live_bytes -= nbytes
|
|
1294
|
+
|
|
1295
|
+
@staticmethod
|
|
1296
|
+
def free_host_pinned(ptr: int):
|
|
1297
|
+
if ptr:
|
|
1298
|
+
dm = _rt.device_memory()
|
|
1299
|
+
if not dm.supports("host_free"):
|
|
1300
|
+
return
|
|
1301
|
+
dm.host_free(ptr)
|
|
1302
|
+
nbytes = DeviceAllocator._host_pinned_ptr_size.pop(ptr, None)
|
|
1303
|
+
DeviceAllocator._range_del(ptr)
|
|
1304
|
+
if nbytes is not None:
|
|
1305
|
+
DeviceAllocator._host_pinned_live_bytes -= nbytes
|
|
1306
|
+
|
|
1307
|
+
# ------------------------------------------------------------------
|
|
1308
|
+
# Accounting API — use instead of cudaMemGetInfo for per-NeuroBrix
|
|
1309
|
+
# observability (cudaMemGetInfo returns device-wide numbers
|
|
1310
|
+
# including other processes, which is useless for debugging a
|
|
1311
|
+
# specific zero3 leak).
|
|
1312
|
+
# ------------------------------------------------------------------
|
|
1313
|
+
|
|
1314
|
+
@staticmethod
|
|
1315
|
+
def memory_allocated(device_idx: Optional[int] = None) -> int:
|
|
1316
|
+
"""Live bytes allocated via malloc_cuda on the given device.
|
|
1317
|
+
With device_idx=None, returns the total across all devices."""
|
|
1318
|
+
if device_idx is None:
|
|
1319
|
+
return sum(DeviceAllocator._cuda_live_bytes.values())
|
|
1320
|
+
return DeviceAllocator._cuda_live_bytes.get(device_idx, 0)
|
|
1321
|
+
|
|
1322
|
+
@staticmethod
|
|
1323
|
+
def peak_memory_allocated(device_idx: Optional[int] = None) -> int:
|
|
1324
|
+
"""Highest live-bytes watermark observed for the device.
|
|
1325
|
+
With device_idx=None, returns the total peak across all devices."""
|
|
1326
|
+
if device_idx is None:
|
|
1327
|
+
return sum(DeviceAllocator._cuda_peak_bytes.values())
|
|
1328
|
+
return DeviceAllocator._cuda_peak_bytes.get(device_idx, 0)
|
|
1329
|
+
|
|
1330
|
+
@staticmethod
|
|
1331
|
+
def device_free_bytes(device_idx: Optional[int] = None) -> int:
|
|
1332
|
+
"""Driver-free bytes on `device_idx` (None = current device), via
|
|
1333
|
+
cudaMemGetInfo/hipMemGetInfo through ctypes (R33: no torch).
|
|
1334
|
+
|
|
1335
|
+
Returns -1 when the query is unavailable (CPU-only host, runtime
|
|
1336
|
+
load failure) so callers can distinguish "unknown" from "0 bytes
|
|
1337
|
+
free". Read-only feasibility probe for wrapper-internal memory
|
|
1338
|
+
streaming gates (conv3d temporal chunk-streaming) — deliberately
|
|
1339
|
+
NOT an accounting API (device-wide, includes other processes) and
|
|
1340
|
+
NOT a per-launch kernel-config source (hardware params stay in the
|
|
1341
|
+
vendor YAMLs)."""
|
|
1342
|
+
prev_dev = None
|
|
1343
|
+
try:
|
|
1344
|
+
dm = _rt.device_memory()
|
|
1345
|
+
if (device_idx is not None
|
|
1346
|
+
and DeviceAllocator.get_device() != device_idx):
|
|
1347
|
+
# Probe on another device: switch, query, RESTORE (the
|
|
1348
|
+
# allocator's own doctrine — a GC-site free of a cuda:N
|
|
1349
|
+
# pointer must never leave cuda:N current; the C3 fast path
|
|
1350
|
+
# contracts that the runtime current device is the caller's).
|
|
1351
|
+
prev_dev = DeviceAllocator.get_device()
|
|
1352
|
+
DeviceAllocator.set_device(device_idx)
|
|
1353
|
+
_rc, free_b, _total_b = dm.mem_get_info()
|
|
1354
|
+
free = int(free_b)
|
|
1355
|
+
# Pool-aware (2026-09-02): bytes parked in the free-list are
|
|
1356
|
+
# reclaimable on demand (the malloc OOM path flushes them, the
|
|
1357
|
+
# autotune guard flushes them), so every feasibility probe —
|
|
1358
|
+
# the SDPA chunk decision, the drain pressure gate, the conv3d
|
|
1359
|
+
# chunk-stream gate — reads the same headroom it read with the
|
|
1360
|
+
# pool off. Without this the probes under-read free memory by
|
|
1361
|
+
# the parked bytes and shrank their chunks on edge rows.
|
|
1362
|
+
if DeviceAllocator._pool_enabled:
|
|
1363
|
+
d = device_idx if device_idx is not None else DeviceAllocator.get_device()
|
|
1364
|
+
free += DeviceAllocator._POOL.cached.get(d, 0)
|
|
1365
|
+
return free
|
|
1366
|
+
except Exception:
|
|
1367
|
+
return -1
|
|
1368
|
+
finally:
|
|
1369
|
+
if prev_dev is not None:
|
|
1370
|
+
try:
|
|
1371
|
+
DeviceAllocator.set_device(prev_dev)
|
|
1372
|
+
except Exception:
|
|
1373
|
+
pass
|
|
1374
|
+
|
|
1375
|
+
@staticmethod
|
|
1376
|
+
def device_total_bytes(device_idx: Optional[int] = None) -> int:
|
|
1377
|
+
"""Total device memory on `device_idx` (None = current device) via
|
|
1378
|
+
cudaMemGetInfo/hipMemGetInfo through ctypes; -1 when unavailable.
|
|
1379
|
+
Same contract and caveats as `device_free_bytes`."""
|
|
1380
|
+
prev_dev = None
|
|
1381
|
+
try:
|
|
1382
|
+
dm = _rt.device_memory()
|
|
1383
|
+
if (device_idx is not None
|
|
1384
|
+
and DeviceAllocator.get_device() != device_idx):
|
|
1385
|
+
# Probe on another device: switch, query, RESTORE (the
|
|
1386
|
+
# allocator's own doctrine — a GC-site free of a cuda:N
|
|
1387
|
+
# pointer must never leave cuda:N current; the C3 fast path
|
|
1388
|
+
# contracts that the runtime current device is the caller's).
|
|
1389
|
+
prev_dev = DeviceAllocator.get_device()
|
|
1390
|
+
DeviceAllocator.set_device(device_idx)
|
|
1391
|
+
_rc, _free_b, total_b = dm.mem_get_info()
|
|
1392
|
+
return int(total_b)
|
|
1393
|
+
except Exception:
|
|
1394
|
+
return -1
|
|
1395
|
+
finally:
|
|
1396
|
+
if prev_dev is not None:
|
|
1397
|
+
try:
|
|
1398
|
+
DeviceAllocator.set_device(prev_dev)
|
|
1399
|
+
except Exception:
|
|
1400
|
+
pass
|
|
1401
|
+
|
|
1402
|
+
@staticmethod
|
|
1403
|
+
def open_window(device_idx: int) -> int:
|
|
1404
|
+
"""Start watching what this process holds from the driver on `device_idx` (live and pooled bytes, the
|
|
1405
|
+
quantity the peak counts); a token for `close_window`. The process-wide peak is never reset by it: a window
|
|
1406
|
+
measures one stretch (a component from its load to its release) beside it."""
|
|
1407
|
+
DeviceAllocator._window_seq += 1
|
|
1408
|
+
t = DeviceAllocator._window_seq
|
|
1409
|
+
held = DeviceAllocator._cuda_live_bytes.get(int(device_idx), 0)
|
|
1410
|
+
DeviceAllocator._windows.setdefault(int(device_idx), {})[t] = held
|
|
1411
|
+
DeviceAllocator._window_starts[t] = held
|
|
1412
|
+
return t
|
|
1413
|
+
|
|
1414
|
+
@staticmethod
|
|
1415
|
+
def window_start(token: int) -> Optional[int]:
|
|
1416
|
+
"""What this process held from the driver when the window `token` opened (what other stretches already held:
|
|
1417
|
+
the part of the window's peak that is not its own); None for a token never opened."""
|
|
1418
|
+
return DeviceAllocator._window_starts.get(token)
|
|
1419
|
+
|
|
1420
|
+
@staticmethod
|
|
1421
|
+
def close_window(device_idx: int, token: int) -> Optional[int]:
|
|
1422
|
+
"""The most this process held from the driver on `device_idx` since `open_window` returned `token`, and the
|
|
1423
|
+
window closed; None for a token not open there."""
|
|
1424
|
+
DeviceAllocator._window_starts.pop(token, None)
|
|
1425
|
+
return DeviceAllocator._windows.get(int(device_idx), {}).pop(token, None)
|
|
1426
|
+
|
|
1427
|
+
@staticmethod
|
|
1428
|
+
def reset_peak_memory(device_idx: Optional[int] = None) -> None:
|
|
1429
|
+
"""Reset the peak watermark to the current live value.
|
|
1430
|
+
With device_idx=None, resets all devices."""
|
|
1431
|
+
if device_idx is None:
|
|
1432
|
+
for d in list(DeviceAllocator._cuda_live_bytes.keys()):
|
|
1433
|
+
DeviceAllocator._cuda_peak_bytes[d] = (
|
|
1434
|
+
DeviceAllocator._cuda_live_bytes[d])
|
|
1435
|
+
else:
|
|
1436
|
+
DeviceAllocator._cuda_peak_bytes[device_idx] = (
|
|
1437
|
+
DeviceAllocator._cuda_live_bytes.get(device_idx, 0))
|
|
1438
|
+
|
|
1439
|
+
@staticmethod
|
|
1440
|
+
def host_pinned_allocated() -> int:
|
|
1441
|
+
"""Live bytes allocated via malloc_host_pinned."""
|
|
1442
|
+
return DeviceAllocator._host_pinned_live_bytes
|
|
1443
|
+
|
|
1444
|
+
@staticmethod
|
|
1445
|
+
def host_pinned_peak() -> int:
|
|
1446
|
+
"""Peak pinned host bytes observed."""
|
|
1447
|
+
return DeviceAllocator._host_pinned_peak_bytes
|
|
1448
|
+
|
|
1449
|
+
@staticmethod
|
|
1450
|
+
def memset_cuda(ptr: int, value: int, nbytes: int):
|
|
1451
|
+
"""Set device bytes: through the interceptor in force, else directly (`memset_direct`)."""
|
|
1452
|
+
i = DeviceAllocator._interceptor
|
|
1453
|
+
if i is None:
|
|
1454
|
+
return DeviceAllocator.memset_direct(ptr, value, nbytes)
|
|
1455
|
+
return i.memset(DeviceAllocator.memset_direct, ptr, value, nbytes)
|
|
1456
|
+
|
|
1457
|
+
@staticmethod
|
|
1458
|
+
def memset_direct(ptr: int, value: int, nbytes: int):
|
|
1459
|
+
if nbytes > 0 and ptr:
|
|
1460
|
+
_rt.device_memory().memset(ptr, value, nbytes)
|
|
1461
|
+
|
|
1462
|
+
@staticmethod
|
|
1463
|
+
def memcpy(dst: int, src: int, nbytes: int, kind: int = 3):
|
|
1464
|
+
"""Copy memory (kind: 0=H2H, 1=H2D, 2=D2H, 3=D2D): through the interceptor in force, else directly
|
|
1465
|
+
(`memcpy_direct`)."""
|
|
1466
|
+
i = DeviceAllocator._interceptor
|
|
1467
|
+
if i is None:
|
|
1468
|
+
return DeviceAllocator.memcpy_direct(dst, src, nbytes, kind)
|
|
1469
|
+
return i.memcpy(DeviceAllocator.memcpy_direct, dst, src, nbytes, kind)
|
|
1470
|
+
|
|
1471
|
+
@staticmethod
|
|
1472
|
+
def memcpy_direct(dst: int, src: int, nbytes: int, kind: int = 3):
|
|
1473
|
+
"""Copy memory. kind: 0=H2H, 1=H2D, 2=D2H, 3=D2D.
|
|
1474
|
+
|
|
1475
|
+
NOTE (NVIDIA API-sync-behavior spec): for D2D the "synchronous"
|
|
1476
|
+
cudaMemcpy performs NO host-side synchronization — the copy is
|
|
1477
|
+
enqueued on the calling device's legacy stream and the host
|
|
1478
|
+
returns immediately. Lifetime of src/dst must be guaranteed by
|
|
1479
|
+
stream ordering, never by host program order.
|
|
1480
|
+
"""
|
|
1481
|
+
if nbytes > 0:
|
|
1482
|
+
_rc = _rt.device_memory().memcpy(dst, src, nbytes, kind)
|
|
1483
|
+
if _rc not in (None, 0):
|
|
1484
|
+
raise RuntimeError(
|
|
1485
|
+
f"cudaMemcpy failed rc={_rc} (kind={kind}, "
|
|
1486
|
+
f"nbytes={nbytes}) — CUDA errors are STICKY: this is "
|
|
1487
|
+
f"the first CHECKED call after the poisoning site, "
|
|
1488
|
+
f"not necessarily the fault itself.")
|
|
1489
|
+
|
|
1490
|
+
@staticmethod
|
|
1491
|
+
def sync_device():
|
|
1492
|
+
"""Synchronize the current device: through the interceptor in force, else directly (`sync_direct`)."""
|
|
1493
|
+
i = DeviceAllocator._interceptor
|
|
1494
|
+
if i is None:
|
|
1495
|
+
return DeviceAllocator.sync_direct()
|
|
1496
|
+
return i.sync(DeviceAllocator.sync_direct)
|
|
1497
|
+
|
|
1498
|
+
@staticmethod
|
|
1499
|
+
def sync_direct():
|
|
1500
|
+
"""Synchronize current GPU device. Waits for all pending GPU ops.
|
|
1501
|
+
|
|
1502
|
+
RAISES on a nonzero return: cudaDeviceSynchronize returns any
|
|
1503
|
+
prior async error (sticky semantics) — discarding it hid the
|
|
1504
|
+
true poisoning site of the multi-GPU error-700 class behind the
|
|
1505
|
+
next checked malloc (R16 research, 2026-07-24)."""
|
|
1506
|
+
rt = _rt.gpu_runtime()
|
|
1507
|
+
backend = _rt.backend_table()
|
|
1508
|
+
sync_name = backend.get("sync")
|
|
1509
|
+
if sync_name:
|
|
1510
|
+
_rc = getattr(rt, sync_name)()
|
|
1511
|
+
if _rc not in (None, 0):
|
|
1512
|
+
raise RuntimeError(
|
|
1513
|
+
f"deviceSynchronize failed rc={_rc} on current device "
|
|
1514
|
+
f"{DeviceAllocator.get_device()} — sticky async error "
|
|
1515
|
+
f"surfaced at this sync; the fault is at or before it.")
|
|
1516
|
+
# The portable half of the same contract: a backend that does not
|
|
1517
|
+
# honour `tl.device_assert` reports through the fault channel, and
|
|
1518
|
+
# this is where it is observed. The sync above has retired the work
|
|
1519
|
+
# that would have written it.
|
|
1520
|
+
_faults.check_device_faults()
|
|
1521
|
+
|
|
1522
|
+
# ------------------------------------------------------------------
|
|
1523
|
+
# Async stream + event primitives (zero torch).
|
|
1524
|
+
# Zero3 pipelining opens a dedicated transfer stream so that the H2D
|
|
1525
|
+
# copy of block N+1 overlaps with the compute on block N which runs
|
|
1526
|
+
# on the default stream. Events synchronize the two streams so we
|
|
1527
|
+
# never read a weight that's still being copied.
|
|
1528
|
+
#
|
|
1529
|
+
# Handles are opaque ctypes.c_void_p values surfaced as Python ints
|
|
1530
|
+
# (0 is the default/NULL stream). Keep them in Python state; do not
|
|
1531
|
+
# re-wrap them on each use — ctypes creates a fresh opaque object
|
|
1532
|
+
# each call.
|
|
1533
|
+
# ------------------------------------------------------------------
|
|
1534
|
+
|
|
1535
|
+
@staticmethod
|
|
1536
|
+
def create_stream() -> int:
|
|
1537
|
+
"""Create a new GPU stream (`cudaStreamCreate` / `hipStreamCreate`:
|
|
1538
|
+
a BLOCKING stream, i.e. one that implicitly orders against the
|
|
1539
|
+
legacy default stream). Returns an opaque handle.
|
|
1540
|
+
|
|
1541
|
+
This ordering is load-bearing: the deferred-free drain skips its
|
|
1542
|
+
host sync under the allocator pool on the premise that every
|
|
1543
|
+
NeuroBrix triton stream orders against the legacy stream (kernel
|
|
1544
|
+
launches and D2D copies on the legacy stream; the zero3 transfer
|
|
1545
|
+
stream created here). The only non-blocking stream in the engine
|
|
1546
|
+
is the replay-graph capture stream, bracketed by context syncs."""
|
|
1547
|
+
rt = _rt.gpu_runtime()
|
|
1548
|
+
backend = _rt.backend_table()
|
|
1549
|
+
fn_name = backend.get("stream_create")
|
|
1550
|
+
if fn_name is None:
|
|
1551
|
+
raise RuntimeError(
|
|
1552
|
+
f"Stream create unsupported on backend {_rt.gpu_backend()!r}")
|
|
1553
|
+
h = ctypes.c_void_p()
|
|
1554
|
+
ret = getattr(rt, fn_name)(ctypes.byref(h))
|
|
1555
|
+
if ret != 0:
|
|
1556
|
+
DeviceAllocator.clear_last_error() # a refused call must not poison the next one
|
|
1557
|
+
raise RuntimeError(f"stream create failed (error {ret})")
|
|
1558
|
+
return h.value or 0
|
|
1559
|
+
|
|
1560
|
+
@staticmethod
|
|
1561
|
+
def clear_last_error() -> int:
|
|
1562
|
+
"""Read-and-reset the runtime's sticky "last error". A refused call
|
|
1563
|
+
(an ordering event asked for an interval, a peer pair that cannot be
|
|
1564
|
+
enabled) leaves its code there, and the next unrelated launch check
|
|
1565
|
+
in the process — torch's, in the compiled branch — reports it as its
|
|
1566
|
+
own (found 2026-09-05: `invalid device ordinal` on an fp16 op after
|
|
1567
|
+
the stopwatch refusal test). Every expected refusal clears it."""
|
|
1568
|
+
rt = _rt.gpu_runtime()
|
|
1569
|
+
fn_name = _rt.backend_table().get("get_last_error")
|
|
1570
|
+
return int(getattr(rt, fn_name)()) if fn_name else 0
|
|
1571
|
+
|
|
1572
|
+
@staticmethod
|
|
1573
|
+
def _rt_call(key: str, *args):
|
|
1574
|
+
rt = _rt.gpu_runtime()
|
|
1575
|
+
fn_name = _rt.backend_table().get(key)
|
|
1576
|
+
if fn_name is None:
|
|
1577
|
+
raise RuntimeError(f"{key} unsupported on backend {_rt.gpu_backend()!r}")
|
|
1578
|
+
ret = getattr(rt, fn_name)(*args)
|
|
1579
|
+
if ret != 0:
|
|
1580
|
+
DeviceAllocator.clear_last_error()
|
|
1581
|
+
raise RuntimeError(f"{fn_name} failed (error {ret})")
|
|
1582
|
+
|
|
1583
|
+
@staticmethod
|
|
1584
|
+
def destroy_stream(stream: int):
|
|
1585
|
+
"""Destroy a stream created by create_stream. No-op for default (0)."""
|
|
1586
|
+
if not stream:
|
|
1587
|
+
return
|
|
1588
|
+
rt = _rt.gpu_runtime()
|
|
1589
|
+
backend = _rt.backend_table()
|
|
1590
|
+
fn_name = backend.get("stream_destroy")
|
|
1591
|
+
if fn_name is None:
|
|
1592
|
+
return
|
|
1593
|
+
getattr(rt, fn_name)(ctypes.c_void_p(stream))
|
|
1594
|
+
|
|
1595
|
+
@staticmethod
|
|
1596
|
+
def holds(ptr: int) -> bool:
|
|
1597
|
+
"""True when `ptr` lies inside a live device allocation this allocator
|
|
1598
|
+
handed out (a block's base, or a view inside it). The launcher asks
|
|
1599
|
+
before dispatching a kernel on an address: a foreign pointer is
|
|
1600
|
+
refused, never launched (launcher contract, Metal workstream 2026-09-05).
|
|
1601
|
+
O(log n): the live ranges are re-sorted only when a registration
|
|
1602
|
+
changed since the last call."""
|
|
1603
|
+
ptr = int(ptr)
|
|
1604
|
+
size = DeviceAllocator._range_size.get(ptr)
|
|
1605
|
+
if size is not None:
|
|
1606
|
+
return True
|
|
1607
|
+
bases = DeviceAllocator._range_bases
|
|
1608
|
+
i = bisect.bisect_right(bases, ptr) - 1
|
|
1609
|
+
return i >= 0 and bases[i] <= ptr < bases[i] + DeviceAllocator._range_size[bases[i]]
|
|
1610
|
+
|
|
1611
|
+
@staticmethod
|
|
1612
|
+
def containing(ptr: int):
|
|
1613
|
+
"""(base, size) of the live range `ptr` lies in, the NEAREST one: the greatest base at or below `ptr` whose range
|
|
1614
|
+
holds it. In-place expert bindings overlap where two projections share a page; the first range in insertion
|
|
1615
|
+
order resolved a projection to its neighbour's shorter range and a launch read past it (DSV2 triton printed
|
|
1616
|
+
garbage, 2026-10-20). The nearest base is the projection's own binding, which runs to its end. None when no
|
|
1617
|
+
range holds `ptr`. O(log n) plus the ranges walked down past."""
|
|
1618
|
+
ptr = int(ptr)
|
|
1619
|
+
bases = DeviceAllocator._range_bases
|
|
1620
|
+
sizes = DeviceAllocator._range_size
|
|
1621
|
+
i = bisect.bisect_right(bases, ptr) - 1
|
|
1622
|
+
while i >= 0:
|
|
1623
|
+
b = bases[i]
|
|
1624
|
+
if ptr < b + sizes[b]:
|
|
1625
|
+
return b, sizes[b]
|
|
1626
|
+
i -= 1
|
|
1627
|
+
return None
|
|
1628
|
+
|
|
1629
|
+
@staticmethod
|
|
1630
|
+
def _range_add(base: int, nbytes: int) -> None:
|
|
1631
|
+
"""Register a live range for `holds()` — O(log n) insert, kept sorted
|
|
1632
|
+
so a launch pays one bisect and never a re-sort."""
|
|
1633
|
+
if base not in DeviceAllocator._range_size:
|
|
1634
|
+
bisect.insort(DeviceAllocator._range_bases, base)
|
|
1635
|
+
DeviceAllocator._range_size[base] = int(nbytes)
|
|
1636
|
+
|
|
1637
|
+
@staticmethod
|
|
1638
|
+
def _range_del(base: int) -> None:
|
|
1639
|
+
if DeviceAllocator._range_size.pop(base, None) is not None:
|
|
1640
|
+
bases = DeviceAllocator._range_bases
|
|
1641
|
+
i = bisect.bisect_left(bases, base)
|
|
1642
|
+
if i < len(bases) and bases[i] == base:
|
|
1643
|
+
bases.pop(i)
|
|
1644
|
+
|
|
1645
|
+
@staticmethod
|
|
1646
|
+
def device_synchronize(device_idx: Optional[int] = None):
|
|
1647
|
+
"""Block the caller until every stream of the device drains.
|
|
1648
|
+
|
|
1649
|
+
Selects the device first when one is given (the runtime's device
|
|
1650
|
+
synchronize is per current device) and restores the previous one."""
|
|
1651
|
+
rt = _rt.gpu_runtime()
|
|
1652
|
+
backend = _rt.backend_table()
|
|
1653
|
+
fn_name = backend.get("sync")
|
|
1654
|
+
if fn_name is None:
|
|
1655
|
+
return
|
|
1656
|
+
prev = None
|
|
1657
|
+
if device_idx is not None:
|
|
1658
|
+
prev = DeviceAllocator.get_device()
|
|
1659
|
+
if prev != device_idx:
|
|
1660
|
+
DeviceAllocator.set_device(device_idx)
|
|
1661
|
+
try:
|
|
1662
|
+
ret = getattr(rt, fn_name)()
|
|
1663
|
+
if ret != 0:
|
|
1664
|
+
DeviceAllocator.clear_last_error()
|
|
1665
|
+
raise RuntimeError(f"{fn_name} failed with error {ret}")
|
|
1666
|
+
finally:
|
|
1667
|
+
if prev is not None and prev != device_idx:
|
|
1668
|
+
DeviceAllocator.set_device(prev)
|
|
1669
|
+
|
|
1670
|
+
@staticmethod
|
|
1671
|
+
def mem_get_info(device_idx: Optional[int] = None) -> Tuple[int, int]:
|
|
1672
|
+
"""(free_bytes, total_bytes) of a device, from the runtime."""
|
|
1673
|
+
dm = _rt.device_memory()
|
|
1674
|
+
mem_fn = _rt.backend_table().get("mem_get_info", "cudaMemGetInfo") # named in the refusal, as before
|
|
1675
|
+
prev = None
|
|
1676
|
+
if device_idx is not None:
|
|
1677
|
+
prev = DeviceAllocator.get_device()
|
|
1678
|
+
if prev != device_idx:
|
|
1679
|
+
DeviceAllocator.set_device(device_idx)
|
|
1680
|
+
try:
|
|
1681
|
+
ret, free_b, total_b = dm.mem_get_info()
|
|
1682
|
+
if ret != 0:
|
|
1683
|
+
DeviceAllocator.clear_last_error()
|
|
1684
|
+
raise RuntimeError(f"{mem_fn} failed with error {ret}")
|
|
1685
|
+
return int(free_b), int(total_b)
|
|
1686
|
+
finally:
|
|
1687
|
+
if prev is not None and prev != device_idx:
|
|
1688
|
+
DeviceAllocator.set_device(prev)
|
|
1689
|
+
|
|
1690
|
+
@staticmethod
|
|
1691
|
+
def stream_synchronize(stream: int):
|
|
1692
|
+
"""Block the caller until the given stream drains."""
|
|
1693
|
+
rt = _rt.gpu_runtime()
|
|
1694
|
+
backend = _rt.backend_table()
|
|
1695
|
+
fn_name = backend.get("stream_sync")
|
|
1696
|
+
if fn_name is None:
|
|
1697
|
+
return
|
|
1698
|
+
getattr(rt, fn_name)(ctypes.c_void_p(stream))
|
|
1699
|
+
|
|
1700
|
+
@staticmethod
|
|
1701
|
+
def create_event(timing: bool = True) -> int:
|
|
1702
|
+
"""Create a new event. Returns opaque handle.
|
|
1703
|
+
|
|
1704
|
+
`timing=False` asks for an event that can order streams but cannot be
|
|
1705
|
+
used to measure an interval, and that is what almost every event in
|
|
1706
|
+
this engine actually wants. It is not a micro-optimisation: recording
|
|
1707
|
+
a timing-enabled event costs **9.5 us** on this rig against ~1 us for
|
|
1708
|
+
an ordering-only one, because the runtime has to place a clock read
|
|
1709
|
+
in the stream and keep the result addressable. Measured 2026-09-03 —
|
|
1710
|
+
it was 28 % of a cross-GPU collective's entire host cost.
|
|
1711
|
+
|
|
1712
|
+
Pass timing=True (the default, kept for compatibility) only when the
|
|
1713
|
+
handle is going to reach `event_elapsed_ms`.
|
|
1714
|
+
"""
|
|
1715
|
+
rt = _rt.gpu_runtime()
|
|
1716
|
+
backend = _rt.backend_table()
|
|
1717
|
+
h = ctypes.c_void_p()
|
|
1718
|
+
flags_fn = backend.get("event_create_flags")
|
|
1719
|
+
if not timing and flags_fn is not None:
|
|
1720
|
+
# 0x02 = cudaEventDisableTiming / hipEventDisableTiming.
|
|
1721
|
+
ret = getattr(rt, flags_fn)(ctypes.byref(h), ctypes.c_uint(0x02))
|
|
1722
|
+
else:
|
|
1723
|
+
fn_name = backend.get("event_create")
|
|
1724
|
+
if fn_name is None:
|
|
1725
|
+
raise RuntimeError(
|
|
1726
|
+
f"Event create unsupported on backend "
|
|
1727
|
+
f"{_rt.gpu_backend()!r}")
|
|
1728
|
+
ret = getattr(rt, fn_name)(ctypes.byref(h))
|
|
1729
|
+
if ret != 0:
|
|
1730
|
+
DeviceAllocator.clear_last_error() # a refused call must not poison the next one
|
|
1731
|
+
raise RuntimeError(f"event create failed (error {ret})")
|
|
1732
|
+
return h.value or 0
|
|
1733
|
+
|
|
1734
|
+
@staticmethod
|
|
1735
|
+
def destroy_event(event: int):
|
|
1736
|
+
"""Destroy an event created by create_event."""
|
|
1737
|
+
if not event:
|
|
1738
|
+
return
|
|
1739
|
+
rt = _rt.gpu_runtime()
|
|
1740
|
+
backend = _rt.backend_table()
|
|
1741
|
+
fn_name = backend.get("event_destroy")
|
|
1742
|
+
if fn_name is None:
|
|
1743
|
+
return
|
|
1744
|
+
getattr(rt, fn_name)(ctypes.c_void_p(event))
|
|
1745
|
+
|
|
1746
|
+
@staticmethod
|
|
1747
|
+
def record_event(event: int, stream: int = 0):
|
|
1748
|
+
"""Capture the current state of `stream` into `event`."""
|
|
1749
|
+
rt = _rt.gpu_runtime()
|
|
1750
|
+
backend = _rt.backend_table()
|
|
1751
|
+
fn_name = backend.get("event_record")
|
|
1752
|
+
if fn_name is None:
|
|
1753
|
+
return
|
|
1754
|
+
getattr(rt, fn_name)(
|
|
1755
|
+
ctypes.c_void_p(event), ctypes.c_void_p(stream))
|
|
1756
|
+
|
|
1757
|
+
@staticmethod
|
|
1758
|
+
def stream_wait_event(stream: int, event: int, flags: int = 0):
|
|
1759
|
+
"""Make `stream` wait for `event` without blocking the host."""
|
|
1760
|
+
rt = _rt.gpu_runtime()
|
|
1761
|
+
backend = _rt.backend_table()
|
|
1762
|
+
fn_name = backend.get("stream_wait_event")
|
|
1763
|
+
if fn_name is None:
|
|
1764
|
+
return
|
|
1765
|
+
getattr(rt, fn_name)(
|
|
1766
|
+
ctypes.c_void_p(stream), ctypes.c_void_p(event),
|
|
1767
|
+
ctypes.c_uint(flags))
|
|
1768
|
+
|
|
1769
|
+
@staticmethod
|
|
1770
|
+
def memcpy_async(dst: int, src: int, nbytes: int,
|
|
1771
|
+
kind: int = 1, stream: int = 0):
|
|
1772
|
+
"""Asynchronous memcpy on a specific stream.
|
|
1773
|
+
|
|
1774
|
+
kind: 0=H2H, 1=H2D, 2=D2H, 3=D2D (matches cudaMemcpyKind).
|
|
1775
|
+
For H2D to actually overlap with compute, `src` must be pinned.
|
|
1776
|
+
"""
|
|
1777
|
+
if nbytes <= 0:
|
|
1778
|
+
return
|
|
1779
|
+
rt = _rt.gpu_runtime()
|
|
1780
|
+
backend = _rt.backend_table()
|
|
1781
|
+
fn_name = backend.get("memcpy_async")
|
|
1782
|
+
if fn_name is None:
|
|
1783
|
+
# Fallback to synchronous memcpy if async is unsupported.
|
|
1784
|
+
DeviceAllocator.memcpy(dst, src, nbytes, kind)
|
|
1785
|
+
return
|
|
1786
|
+
getattr(rt, fn_name)(
|
|
1787
|
+
ctypes.c_void_p(dst), ctypes.c_void_p(src),
|
|
1788
|
+
ctypes.c_size_t(nbytes), ctypes.c_int(kind),
|
|
1789
|
+
ctypes.c_void_p(stream))
|
|
1790
|
+
|
|
1791
|
+
@staticmethod
|
|
1792
|
+
def event_synchronize(event: int):
|
|
1793
|
+
"""Block the host until `event` has been reached."""
|
|
1794
|
+
if not event:
|
|
1795
|
+
return
|
|
1796
|
+
rt = _rt.gpu_runtime()
|
|
1797
|
+
fn_name = _rt.backend_table().get("event_sync")
|
|
1798
|
+
if fn_name is None:
|
|
1799
|
+
DeviceAllocator.sync_device()
|
|
1800
|
+
return
|
|
1801
|
+
ret = getattr(rt, fn_name)(ctypes.c_void_p(event))
|
|
1802
|
+
if ret != 0:
|
|
1803
|
+
DeviceAllocator.clear_last_error() # a refused call must not poison the next one
|
|
1804
|
+
raise RuntimeError(f"event synchronize failed (error {ret})")
|
|
1805
|
+
|
|
1806
|
+
@staticmethod
|
|
1807
|
+
def event_elapsed_ms(start: int, end: int) -> float:
|
|
1808
|
+
"""Milliseconds between two recorded events, measured ON THE DEVICE.
|
|
1809
|
+
|
|
1810
|
+
Both events MUST have been recorded on streams of the SAME device:
|
|
1811
|
+
the runtime's elapsed-time query is undefined across devices because
|
|
1812
|
+
each device has its own clock domain. To time a multi-device
|
|
1813
|
+
collective, record both events on one device's stream and make that
|
|
1814
|
+
stream `stream_wait_event` on the other devices' completion events —
|
|
1815
|
+
then the interval covers the critical path without ever comparing
|
|
1816
|
+
two clocks.
|
|
1817
|
+
|
|
1818
|
+
The caller is responsible for having synchronized `end`.
|
|
1819
|
+
"""
|
|
1820
|
+
rt = _rt.gpu_runtime()
|
|
1821
|
+
fn_name = _rt.backend_table().get("event_elapsed")
|
|
1822
|
+
if fn_name is None:
|
|
1823
|
+
raise RuntimeError(
|
|
1824
|
+
f"event timing unsupported on backend "
|
|
1825
|
+
f"{_rt.gpu_backend()!r}")
|
|
1826
|
+
fn = getattr(rt, fn_name)
|
|
1827
|
+
fn.restype = ctypes.c_int
|
|
1828
|
+
out = ctypes.c_float()
|
|
1829
|
+
ret = fn(ctypes.byref(out), ctypes.c_void_p(start), ctypes.c_void_p(end))
|
|
1830
|
+
if ret != 0:
|
|
1831
|
+
DeviceAllocator.clear_last_error() # a refusal must not poison the next call
|
|
1832
|
+
raise RuntimeError(
|
|
1833
|
+
f"event elapsed failed (error {ret}) — the usual cause is "
|
|
1834
|
+
f"two events recorded on DIFFERENT devices, which has no "
|
|
1835
|
+
f"defined answer.")
|
|
1836
|
+
return float(out.value)
|
|
1837
|
+
|
|
1838
|
+
@staticmethod
|
|
1839
|
+
def can_access_peer(src_dev: int, dst_dev: int) -> bool:
|
|
1840
|
+
"""Whether `src_dev` can address `dst_dev`'s memory directly.
|
|
1841
|
+
|
|
1842
|
+
False does not mean a copy will fail — it means the driver will
|
|
1843
|
+
stage it through host memory, at host-link speed rather than
|
|
1844
|
+
interconnect speed.
|
|
1845
|
+
"""
|
|
1846
|
+
if src_dev == dst_dev:
|
|
1847
|
+
return True
|
|
1848
|
+
rt = _rt.gpu_runtime()
|
|
1849
|
+
fn_name = _rt.backend_table().get("device_can_access_peer")
|
|
1850
|
+
if fn_name is None:
|
|
1851
|
+
return False
|
|
1852
|
+
out = ctypes.c_int()
|
|
1853
|
+
ret = getattr(rt, fn_name)(
|
|
1854
|
+
ctypes.byref(out), ctypes.c_int(src_dev), ctypes.c_int(dst_dev))
|
|
1855
|
+
if ret != 0:
|
|
1856
|
+
DeviceAllocator.clear_last_error()
|
|
1857
|
+
return ret == 0 and bool(out.value)
|
|
1858
|
+
|
|
1859
|
+
# Pairs already granted, so the hot transfer path pays the query once.
|
|
1860
|
+
_peer_enabled: set = set()
|
|
1861
|
+
# Pairs the hardware refused, so a rig without peer links does not retry
|
|
1862
|
+
# on every single transfer.
|
|
1863
|
+
_peer_refused: set = set()
|
|
1864
|
+
|
|
1865
|
+
@staticmethod
|
|
1866
|
+
def ensure_peer_access(src_dev: int, dst_dev: int) -> bool:
|
|
1867
|
+
"""Grant src -> dst once, then remember the answer.
|
|
1868
|
+
|
|
1869
|
+
Called on the cross-device transfer path, which is hot enough that
|
|
1870
|
+
asking the driver every time would cost more than it saves. Both
|
|
1871
|
+
outcomes are memoised: a rig whose cards have no direct links must
|
|
1872
|
+
not re-ask on every hand-off.
|
|
1873
|
+
|
|
1874
|
+
A `False` here is not an error — the copy still works, the driver
|
|
1875
|
+
just routes it through host memory. It is 2.4x slower at 4 KB and
|
|
1876
|
+
8.7x slower at 34 MB on this rig (measured 2026-09-03).
|
|
1877
|
+
"""
|
|
1878
|
+
if src_dev == dst_dev:
|
|
1879
|
+
return True
|
|
1880
|
+
key = (src_dev, dst_dev)
|
|
1881
|
+
if key in DeviceAllocator._peer_enabled:
|
|
1882
|
+
return True
|
|
1883
|
+
if key in DeviceAllocator._peer_refused:
|
|
1884
|
+
return False
|
|
1885
|
+
if os.environ.get("NBX_DISABLE_PEER_ACCESS") == "1":
|
|
1886
|
+
# Escape hatch and A/B control. Direct card-to-card access is a
|
|
1887
|
+
# driver-level grant; if a rig ever misbehaves under it, this
|
|
1888
|
+
# restores the host-staged route without a code change, and it is
|
|
1889
|
+
# how the speedup was measured against the old behaviour.
|
|
1890
|
+
DeviceAllocator._peer_refused.add(key)
|
|
1891
|
+
return False
|
|
1892
|
+
try:
|
|
1893
|
+
granted = DeviceAllocator.enable_peer_access(src_dev, dst_dev)
|
|
1894
|
+
except RuntimeError:
|
|
1895
|
+
granted = False
|
|
1896
|
+
(DeviceAllocator._peer_enabled if granted
|
|
1897
|
+
else DeviceAllocator._peer_refused).add(key)
|
|
1898
|
+
return granted
|
|
1899
|
+
|
|
1900
|
+
@staticmethod
|
|
1901
|
+
def enable_peer_access(src_dev: int, dst_dev: int) -> bool:
|
|
1902
|
+
"""Let `src_dev` reach `dst_dev` over the interconnect directly.
|
|
1903
|
+
|
|
1904
|
+
Direction matters: this grants src -> dst only, so a bidirectional
|
|
1905
|
+
pair needs both calls. Returns True if access is in force afterwards
|
|
1906
|
+
(including when it already was), False if the hardware cannot do it.
|
|
1907
|
+
|
|
1908
|
+
Why this exists: a cross-device `memcpy(kind=3)` with peer access
|
|
1909
|
+
OFF is bounced through host memory by the driver. On this rig every
|
|
1910
|
+
GPU pair reports NV2 (two bonded NVLinks) and the copy still would
|
|
1911
|
+
not use them. Measured cost of the difference:
|
|
1912
|
+
`validation_outputs/tp_collective_latency_2026_09_03/`.
|
|
1913
|
+
"""
|
|
1914
|
+
if src_dev == dst_dev:
|
|
1915
|
+
return True
|
|
1916
|
+
if not DeviceAllocator.can_access_peer(src_dev, dst_dev):
|
|
1917
|
+
return False
|
|
1918
|
+
rt = _rt.gpu_runtime()
|
|
1919
|
+
fn_name = _rt.backend_table().get("device_enable_peer_access")
|
|
1920
|
+
if fn_name is None:
|
|
1921
|
+
return False
|
|
1922
|
+
previous = DeviceAllocator.get_device()
|
|
1923
|
+
try:
|
|
1924
|
+
DeviceAllocator.set_device(src_dev)
|
|
1925
|
+
ret = getattr(rt, fn_name)(ctypes.c_int(dst_dev), ctypes.c_uint(0))
|
|
1926
|
+
finally:
|
|
1927
|
+
DeviceAllocator.set_device(previous)
|
|
1928
|
+
# 704 = cudaErrorPeerAccessAlreadyEnabled. Idempotence is the point:
|
|
1929
|
+
# callers enable defensively and must not have to track state.
|
|
1930
|
+
if ret in (0, 704):
|
|
1931
|
+
return True
|
|
1932
|
+
raise RuntimeError(
|
|
1933
|
+
f"enable peer access {src_dev}->{dst_dev} failed (error {ret})")
|
|
1934
|
+
|
|
1935
|
+
@staticmethod
|
|
1936
|
+
def ensure_triton_device(device_idx: int):
|
|
1937
|
+
"""Set GPU device for Triton JIT context. Zero torch. Universal.
|
|
1938
|
+
|
|
1939
|
+
Uses cudaSetDevice (runtime) + Triton's own driver API.
|
|
1940
|
+
Does NOT call cuCtxSetCurrent — that conflicts with the runtime
|
|
1941
|
+
context on multi-GPU systems.
|
|
1942
|
+
|
|
1943
|
+
C2 fast path: called before every kernel launch, so when the
|
|
1944
|
+
requested device is already fully current (see the
|
|
1945
|
+
per-thread device-cache invariant) the pair of
|
|
1946
|
+
driver calls is skipped. Any device change outside this
|
|
1947
|
+
function must invalidate the cache (set_device does so
|
|
1948
|
+
automatically; raw switches call
|
|
1949
|
+
invalidate_current_device_cache()).
|
|
1950
|
+
"""
|
|
1951
|
+
if device_idx == _rt._cached_device_idx():
|
|
1952
|
+
return
|
|
1953
|
+
# 1. Set runtime device (cudaSetDevice / hipSetDevice)
|
|
1954
|
+
_rt.device_memory().set_device(device_idx)
|
|
1955
|
+
# 2. Tell the launcher which device to target. The NeuroBrix launcher
|
|
1956
|
+
# binds the runtime's primary context of the current device at each
|
|
1957
|
+
# launch; only the differential arm (NBX_LAUNCHER=triton) still
|
|
1958
|
+
# drives upstream's launcher, whose driver must be told (and whose
|
|
1959
|
+
# backend probes import torch — R33, universal since 2026-09-05).
|
|
1960
|
+
if os.environ.get("NBX_LAUNCHER", "nbx").lower() == "triton":
|
|
1961
|
+
import triton.runtime.driver
|
|
1962
|
+
active = triton.runtime.driver.active
|
|
1963
|
+
# A single-device backend has nothing to set and need not
|
|
1964
|
+
# implement the setter: triton-msl's MetalDriver provides
|
|
1965
|
+
# `get_current_device` and no `set_current_device` (measured
|
|
1966
|
+
# 2026-09-05), and this arm is exactly how the Metal A/B is run.
|
|
1967
|
+
# Falling through would raise AttributeError at the first launch;
|
|
1968
|
+
# assuming it worked would risk launching on a device nobody
|
|
1969
|
+
# selected. So the absence is handled by VERIFYING instead.
|
|
1970
|
+
set_current = getattr(active, "set_current_device", None)
|
|
1971
|
+
if set_current is not None:
|
|
1972
|
+
set_current(device_idx)
|
|
1973
|
+
else:
|
|
1974
|
+
current = active.get_current_device()
|
|
1975
|
+
if current != device_idx:
|
|
1976
|
+
raise RuntimeError(
|
|
1977
|
+
f"Triton backend "
|
|
1978
|
+
f"{active.get_current_target().backend!r} exposes no "
|
|
1979
|
+
f"set_current_device and reports device {current}, "
|
|
1980
|
+
f"but device {device_idx} was requested. The kernel "
|
|
1981
|
+
f"would launch on the wrong device, so this refuses "
|
|
1982
|
+
f"instead.")
|
|
1983
|
+
# Cache only after BOTH calls succeeded (this thread's state).
|
|
1984
|
+
_rt._DEVICE_CACHE.idx = device_idx
|
|
1985
|
+
|
|
1986
|
+
|
|
1987
|
+
#: the pool of every device's segments (memory/pool.py), its block sizes mirrored in the per-pointer tables
|
|
1988
|
+
DeviceAllocator._POOL = SegmentPool(on_block=DeviceAllocator._block_reported)
|