Skip to content

Commit 237e6b6

Browse files
committed
feat(path_c): device-resident DPS driver — eliminate the per-step host numpy bounce
Rework the physical-bank DPS driver so the banks stay tvm.cuda(0) device nd-arrays end-to-end in the forward, removing the numpy host round-trip that was ~96.9% of the Relax-graph train_step wall (docs/RELAX-GRAPH-VS-MEGATRON §4). path_c_dps_adapter.py: - device_bank_view / device_pack / device_unpack: zero-copy device sub-range slicing via Tensor._create_view(byte_offset) + device->device copyto (no numpy). - alloc_device_banks: banks via tvm.runtime.empty on device, zeroed once, reused. - make_device_resident_kernel_driver: builds the kernel arg list once (5 device banks + curried run_backward + device-resident zero scratch), per call runs kernel(*args)+sync; kernel mutates banks in place at out_idx (nothing to read back). - make_region_dps_packed._packed routes by output DLPack device type (__dlpack_device__()[0] != kDLCPU): device VM -> device-resident path, CPU VM -> numpy reference (clear device-vs-host gate, not a try/except fallback). - make_real_kernel_driver kept as the numpy-staged reference (CPU self-test + equiv). path_c_relax_step_banks.py: - bank_arg_is_device (DLPack-device gate at the VM boundary), bank_copy_prefix_device (device->device prefix copy + device tail-zero), _zero_device_tensor (device-zero buffer cache; only first fill per (shape,dtype,device) touches host). path_c_relax_train_step.py: - make_real_bank_forward_driver(device=dev): device-resident forward — seeds the kernel act/param banks via device->device view copies, runs the device-resident kernel in place, fills act_out/state_out device-side. No .numpy() at the VM boundary in the forward. Validated on gb10 tvm.cuda(0), REAL MR JITKernel (scratch/pr7_device_resident_driver_validate_gb10.py): (A) numeric equivalence device-resident vs numpy-staged: max|dev-numpy|=1.025e-06. (B) whole train_step runs e2e on CUDA, loss finite (5.525e-06, 2L x 3 steps). (C) per-forward-call 8472ms -> 6515ms = 1.30x (matches scout ceiling for this single-launch MR kernel; the larger lever is the removed VM-boundary host round-trip of the full 2028 MB banks per forward+remat region per step). bwd/adam/loss remain the abstract numpy drivers (lever 4); forward+remat is now device-resident. Forward/remat region fusion deferred.
1 parent 04271e3 commit 237e6b6

8 files changed

Lines changed: 1436 additions & 73 deletions

cppmega_mlx/runtime/path_c_dps_adapter.py

Lines changed: 301 additions & 49 deletions
Large diffs are not rendered by default.

cppmega_mlx/runtime/path_c_relax_step_banks.py

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,32 @@ def sinfo(self) -> relax.TensorStructInfo:
141141
# (host copy) on read and tvm.runtime.tensor(...).copyto(out) (host->device) on write.
142142
# RULE #1 (fail loud): if neither route exists we RAISE; no silent fallback.
143143
# --------------------------------------------------------------------------- #
144+
def bank_arg_is_device(arg) -> bool:
145+
"""True iff a call_dps_packed ABI bank tensor lives on a real (non-CPU) device and
146+
supports the zero-copy device view + device->device copy ABI. On a CUDA/Metal Relax
147+
VM the bank tensors arrive as ``tvm.runtime.Tensor`` (with ``_create_view`` and
148+
``copyto``) on the device -- so the DEVICE-RESIDENT path applies and NO ``.numpy()``
149+
host bounce is needed. This is the gate that removes the doc's 96.9% host round-trip
150+
at the VM boundary (a clear device-vs-host gate, NOT a try/except fallback)."""
151+
if not (hasattr(arg, "_create_view") and hasattr(arg, "copyto")):
152+
return False
153+
fn = getattr(arg, "__dlpack_device__", None) # (device_type, device_id)
154+
if fn is None:
155+
return False
156+
try:
157+
dt = int(fn()[0])
158+
except Exception: # noqa: BLE001
159+
return False
160+
return dt != 1 # DLDeviceType.kDLCPU == 1; CUDA=2, Metal=8 -> device-resident path
161+
162+
144163
def bank_arg_to_host(arg) -> np.ndarray:
145-
"""Import a call_dps_packed ABI tensor to host numpy, device-agnostically."""
164+
"""Import a call_dps_packed ABI tensor to host numpy, device-agnostically.
165+
166+
NOTE: this is the HOST path -- it forces a device->host copy on CUDA. The
167+
device-resident drivers route bank tensors through the device-view helpers above
168+
instead and never hit this. It remains for the abstract reference drivers + CPU VM
169+
self-test (and for places where a host numpy view is genuinely required)."""
146170
try:
147171
return np.from_dlpack(arg)
148172
except Exception as dlpack_err: # noqa: BLE001 -- CUDA: Unsupported device in DLTensor
@@ -155,6 +179,51 @@ def bank_arg_to_host(arg) -> np.ndarray:
155179
return np.ascontiguousarray(to_numpy())
156180

157181

182+
def bank_copy_prefix_device(dst, src, n: int) -> None:
183+
"""Device->device copy of the first ``n`` elements of ``src`` into ``dst`` (both
184+
DEVICE tvm.runtime.Tensor of dtype float32), then ZERO the tail of ``dst`` if it is
185+
longer. ZERO host traffic: a device view copy + (when needed) one device zero-fill.
186+
Mirrors the numpy ``dst[:n] = src[:n]; dst[n:] = 0`` used by the abstract drivers,
187+
but device-resident. RULE #1: out-of-range RAISES."""
188+
src_n = int(np.prod([int(d) for d in src.shape]))
189+
dst_n = int(np.prod([int(d) for d in dst.shape]))
190+
if n > src_n or n > dst_n:
191+
raise RuntimeError(
192+
f"FAIL-LOUD: bank_copy_prefix_device n={n} exceeds src={src_n}/dst={dst_n}")
193+
if dst_n > n:
194+
_zero_device_tensor(dst) # zero the tail; the prefix is overwritten next
195+
src_v = src._create_view((n,), "float32", relative_byte_offset=0)
196+
dst_v = dst._create_view((n,), "float32", relative_byte_offset=0)
197+
src_v.copyto(dst_v)
198+
199+
200+
_BANK_ZERO_HOST_CACHE: dict[tuple, np.ndarray] = {}
201+
_BANK_ZERO_DEVICE_CACHE: dict[tuple, object] = {}
202+
203+
204+
def _zero_device_tensor(t) -> None:
205+
"""Zero a DEVICE tensor in place WITHOUT a per-call host->device transfer: copy from
206+
a cached DEVICE-resident zero buffer (one per (shape,dtype,device), created once via
207+
a single host->device fill). Subsequent zeroings are device->device copies, so the
208+
inner loop has NO host traffic. Falls back to a host-zero copyfrom only the FIRST
209+
time a given (shape,dtype,device) is seen (to materialise the device-zero buffer)."""
210+
shp = tuple(int(d) for d in t.shape)
211+
dtype = str(t.dtype)
212+
dev = t.device
213+
dkey = (shp, dtype, repr(dev)) # repr stable: device(type='cuda', index=0)
214+
zdev = _BANK_ZERO_DEVICE_CACHE.get(dkey)
215+
if zdev is None:
216+
hkey = (shp, dtype)
217+
zh = _BANK_ZERO_HOST_CACHE.get(hkey)
218+
if zh is None:
219+
zh = np.zeros(shp, dtype=np.dtype(dtype))
220+
_BANK_ZERO_HOST_CACHE[hkey] = zh
221+
zdev = tvm.runtime.empty(shp, dtype, device=dev)
222+
zdev.copyfrom(zh) # ONE host->device fill per (shape,dtype,device)
223+
_BANK_ZERO_DEVICE_CACHE[dkey] = zdev
224+
zdev.copyto(t) # device->device zero (no host traffic)
225+
226+
158227
def bank_writeback(out_tensor, host_result: np.ndarray) -> None:
159228
"""Write a host numpy result into a call_dps_packed output tensor, device-agnostic."""
160229
host_result = np.ascontiguousarray(host_result, np.float32)

cppmega_mlx/runtime/path_c_relax_train_step.py

Lines changed: 71 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,17 @@
7171
BANK_PARAMG,
7272
BANK_STATE,
7373
BankSinfo,
74+
bank_arg_is_device,
7475
bank_arg_to_host,
76+
bank_copy_prefix_device,
7577
bank_writeback,
7678
real_bank_numels,
7779
register_bank_drivers,
7880
)
81+
from cppmega_mlx.runtime.path_c_dps_adapter import (
82+
alloc_device_banks,
83+
make_device_resident_kernel_driver,
84+
)
7985
from cppmega_mlx.runtime.path_c_relax_step_remat import (
8086
checkpoint_boundaries,
8187
nearest_boundary,
@@ -126,26 +132,78 @@ def _loss_reference(act_bank: np.ndarray) -> float:
126132
# `leaf` is a fwd-only PathCRegionLeaf (run_backward=0) carrying the real kernel;
127133
# `real_driver` is make_real_kernel_driver(leaf, device) from the driver phase.
128134
# --------------------------------------------------------------------------- #
129-
def make_real_bank_forward_driver(leaf, real_driver, bank_numels: dict[str, int]):
135+
def make_real_bank_forward_driver(leaf, real_driver, bank_numels: dict[str, int],
136+
*, device=None):
130137
"""Build a `pathc.bank_fwd_*` packed func that runs the REAL path_c CUDA kernel.
131138
132-
ABI: (act_in, param, state_in, act_out, state_out). It packs the act/param banks
133-
into the real kernel's physical banks, runs the real kernel (real_driver writes
134-
the activation bank back into ``banks``), then writes act_out = activation bank
135-
and state_out = checkpoint snapshot of the activation. The real kernel is the
136-
SAME compiled artifact proven on gb10 (14.68M nonzero)."""
139+
ABI: (act_in, param, state_in, act_out, state_out). It seeds the real kernel's
140+
activation/parameter physical banks from the incoming act/param SSA bank tensors,
141+
runs the real kernel (writes the activation bank in place), then writes
142+
act_out = activation bank and state_out = checkpoint snapshot of the activation.
143+
The real kernel is the SAME compiled artifact proven on gb10 (14.68M nonzero).
144+
145+
TWO PATHS (RULE #1: clear device-vs-host gate, no silent fallback):
146+
* DEVICE-RESIDENT (gb10): when the call_dps_packed bank tensors arrive on a real
147+
device, the act/param banks are seeded into the driver-OWNED device physical
148+
banks via DEVICE->DEVICE view copies, the DEVICE-RESIDENT kernel driver runs
149+
the kernel on those device banks IN PLACE, and act_out/state_out are filled by
150+
DEVICE->DEVICE copies. NO ``.numpy()`` / host staging anywhere -- this removes
151+
the per-region per-step ~2028 MB host round-trip at the VM boundary.
152+
* NUMPY-STAGED (CPU self-test / reference): host numpy banks + the numpy-staged
153+
real_driver. Used only when the ABI tensors are host tensors.
154+
155+
``device`` is required for the device-resident path; the device kernel driver +
156+
device physical banks are built ONCE here and reused across calls (the kernel
157+
mutates them in place)."""
137158

138159
act_n = bank_numels[BANK_ACT]
139160
state_n = bank_numels[BANK_STATE]
140161
param_n = bank_numels[BANK_PARAM]
141162

163+
# Build the device-resident kernel driver + the driver-owned device physical banks
164+
# ONCE (lazily, on first device call). These persist across every forward call.
165+
dev_state: dict[str, object] = {}
166+
167+
def _ensure_device_state(dev):
168+
if "driver" not in dev_state:
169+
dev_state["driver"] = make_device_resident_kernel_driver(leaf, dev)
170+
dev_state["banks"] = alloc_device_banks(leaf.bank_shapes, dev)
171+
return dev_state["driver"], dev_state["banks"]
172+
142173
def packed(act_in, param, state_in, act_out, state_out):
174+
if bank_arg_is_device(act_in):
175+
# ---- DEVICE-RESIDENT PATH (no numpy host bounce) ----
176+
dev = act_in.device
177+
dev_driver, dbanks = _ensure_device_state(dev)
178+
# The non-input banks (ACTG, PARAMG, STATE) are zeroed ONCE at alloc (in
179+
# alloc_device_banks). The scout validated (pr7_device_resident_kernel_feed:
180+
# max|device-numpy|=0) that REUSING the banks across calls -- WITHOUT
181+
# re-zeroing -- is bit-identical to the fresh-zeroed-numpy path for the
182+
# forward kernel (the forward reads ACT+PARAM and overwrites its out banks;
183+
# it does not read stale non-input bank state). So we DO NOT re-zero per
184+
# call -- that would reintroduce a ~1.5 GB host->device bounce we are here
185+
# to eliminate. (Numeric equivalence is asserted on gb10.)
186+
# Seed the kernel's activation/parameter physical banks from the SSA banks
187+
# (device->device view copies; tail-zeroed where the SSA bank is shorter).
188+
bank_copy_prefix_device(dbanks[BANK_ACT], act_in,
189+
min(act_n, int(np.prod([int(d) for d in act_in.shape]))))
190+
bank_copy_prefix_device(dbanks[BANK_PARAM], param,
191+
min(param_n, int(np.prod([int(d) for d in param.shape]))))
192+
# Run the REAL kernel on the device banks (writes BANK_ACT in place).
193+
dev_driver(leaf, dbanks, dev)
194+
# act_out <- new activation bank (device->device).
195+
bank_copy_prefix_device(act_out, dbanks[BANK_ACT],
196+
min(act_n, int(np.prod([int(d) for d in act_out.shape]))))
197+
# state_out <- checkpoint snapshot of the activation (device->device).
198+
so_n = int(np.prod([int(d) for d in state_out.shape]))
199+
bank_copy_prefix_device(state_out, dbanks[BANK_ACT], min(so_n, act_n))
200+
return
201+
202+
# ---- NUMPY-STAGED REFERENCE PATH (CPU self-test) ----
143203
a = bank_arg_to_host(act_in)
144204
p = bank_arg_to_host(param)
145205
ao = act_out
146206
so = state_out
147-
# Physical banks the real kernel operates on. The activation bank is seeded
148-
# with the incoming activation; the parameter bank with the incoming params.
149207
banks = {
150208
BANK_ACT: np.ascontiguousarray(a, np.float32).reshape(-1)[:act_n].copy(),
151209
BANK_ACTG: np.zeros(bank_numels[BANK_ACTG], np.float32),
@@ -161,12 +219,9 @@ def packed(act_in, param, state_in, act_out, state_out):
161219
pad = np.zeros(param_n, np.float32)
162220
pad[: banks[BANK_PARAM].size] = banks[BANK_PARAM]
163221
banks[BANK_PARAM] = pad
164-
# Run the REAL tilelang path_c CUDA kernel on the banks (writes act bank).
165222
real_driver(leaf, banks)
166223
act_new = banks[BANK_ACT].reshape(-1)
167-
# act_out <- new activation bank.
168224
bank_writeback(ao, act_new[:act_n])
169-
# state_out <- checkpoint snapshot of the activation (what backward reads).
170225
ck = np.zeros(state_n, np.float32)
171226
n = min(state_n, act_new.size)
172227
ck[:n] = act_new[:n]
@@ -176,15 +231,17 @@ def packed(act_in, param, state_in, act_out, state_out):
176231

177232

178233
def register_real_forward_driver(leaf, real_driver, bank_numels: dict[str, int],
179-
n_layers: int) -> None:
234+
n_layers: int, *, device=None) -> None:
180235
"""Install the REAL path_c CUDA kernel as EVERY `pathc.bank_fwd_i` region.
181236
182237
All forward regions share the one real MR kernel (the bank ABI is per-block
183238
identical; the bank STORAGE is what the planner threads). The backward + optimizer
184239
+ loss drivers stay the abstract numpy ones (the backward kernel is the s_tir wall
185240
documented in the adapter; the forward proves the real-kernel-through-graph path).
186-
"""
187-
fwd = make_real_bank_forward_driver(leaf, real_driver, bank_numels)
241+
242+
``device`` enables the DEVICE-RESIDENT forward path (banks stay device tensors, no
243+
host bounce). Pass the CUDA device on gb10."""
244+
fwd = make_real_bank_forward_driver(leaf, real_driver, bank_numels, device=device)
188245
for i in range(n_layers):
189246
tvm_ffi.register_global_func(f"pathc.bank_fwd_{i}", fwd, override=True)
190247

0 commit comments

Comments
 (0)