Skip to content

Commit 3a7a3ce

Browse files
committed
feat(relax): PR1 whole-region path_c Relax assembly + planning; validate DPS unknown
Resolves the load-bearing unknown of docs/RELAX-GRAPH-MEMORY-PATH.md section 3: do real path_c PrimFuncs satisfy R.call_tir destination-passing style (DPS)? ANSWER: NO. The physical-ABI prim (mr_path_c joint fwd+bwd) fails DPS for three concrete, measured reasons (scratch/test_call_tir_dps.py, verbatim): 1. scalar path_c_run_backward:int32 sits at param index 8 (middle), not last -> well_formed FALSE ("Argument 5 type mismatch: expected R.Prim(int32), given R.Tensor((60708456,), float32)"). 2. no trailing output buffer -- logical tensors are packed into shared physical dtype banks read+written IN PLACE (every *_abi_bank in BOTH reads and writes). 3. relax.build RAISES "Cannot insert syncs inside condition" (thread_storage_sync.cc:145): the TileLang T.Kernel guards shared-mem in an if(run_backward) -- meant for tilelang.compile, not generic relax/s_tir. PR 1 therefore assembles the whole-region step from DPS-clean LOGICAL-BUFFER region PrimFuncs at REAL path_c shapes (hidden_size=3584, AEMR pattern), as R.call_tir leaves in ONE @R.function, fwd-then-reverse with every forward activation saved and consumed by its matching backward region (the cross-layer liveness eager mx.eval cannot exploit). Runs StaticPlanBlockMemory + KillAfterLastUse + relax.build on the LLVM Relax VM and checks output vs an independent numpy reference (fail-loud). MEASURED whole-region peak reduction (planned vs unplanned), real H=3584: 4 layers: strict peak 0.55 -> 0.33 MB (1.67x); all-live 0.88 -> 0.66 MB (1.33x) 6 layers: strict peak 0.77 -> 0.44 MB (1.75x); all-live 1.31 -> 0.88 MB (1.50x) 8 layers: strict peak 0.98 -> 0.55 MB (1.80x); all-live 1.75 -> 1.09 MB (1.60x) Strict-peak win GROWS with depth (1.67x->1.80x) -- the cross-layer-liveness signature. New: cppmega_mlx/runtime/path_c_relax_step.py (reuses PoC peak analyzers) scratch/test_call_tir_dps.py (the DPS validator -- load-bearing finding) scratch/test_dps_clean.py (proves the DPS-clean leaf shape builds+runs) Doc: RELAX-GRAPH-MEMORY-PATH.md -- DPS unknown resolved, PR1 numbers, PR2 = the physical->logical DPS adapter shim toward the full 1.8B train step. Env note: vendored TVM is mid tir->tirx migration; T.block is T.sblock, and a conditional reading an arg-backed buffer trips a LowerDeviceKernelLaunch ICHECK (copied to a local to sidestep). Neither affects planning (Relax IR, pre-codegen).
1 parent f2c6c1a commit 3a7a3ce

4 files changed

Lines changed: 717 additions & 26 deletions

File tree

Lines changed: 383 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,383 @@
1+
"""Assemble a REAL-shaped path_c region chain as ONE Relax @R.function and
2+
measure whole-region StaticPlanBlockMemory peak (planned vs unplanned).
3+
4+
This is PR 1 of docs/RELAX-GRAPH-MEMORY-PATH.md (goal #4): take the per-region
5+
fwd+bwd PrimFuncs that path_c already emits and assemble them ONE LEVEL ABOVE the
6+
per-region kernel boundary, in Relax, as ``R.call_tir`` leaves -- so
7+
``StaticPlanBlockMemory`` sees the whole fwd->bwd liveness span end-to-end.
8+
9+
DPS FINDING (the load-bearing unknown of section 3 of the doc, now VALIDATED):
10+
11+
The REAL path_c PrimFunc emitted by ``path_c_fusion_schedule_template`` binds a
12+
PHYSICAL-BANK ABI (path_c_physical_abi.py): many logical tensors are packed into
13+
disjoint RANGES of a few large shared physical dtype banks
14+
(``path_c_float32_activation_abi_bank`` (~45M f32), ``..._parameter_abi_bank``
15+
(~133M f32), ``..._state_abi_bank`` (~253M f32), ...), and the kernel READS AND
16+
WRITES those banks IN PLACE (every ``*_abi_bank`` appears in BOTH ``T.reads`` and
17+
``T.writes``). This does NOT satisfy R.call_tir destination-passing style, for
18+
three concrete, measured reasons (verbatim captured by
19+
scratch/test_call_tir_dps.py against the real ``mr_path_c`` prim):
20+
21+
1. PARAM ORDER. R.call_tir requires tensor args first, scalar (R.Prim) args
22+
last (passed via ``tir_vars``). The physical prim interleaves its scalar
23+
``path_c_run_backward: T.int32`` at param index 8, in the MIDDLE of the
24+
tensor banks. well_formed() is FALSE:
25+
"Argument 5 type mismatch: expected R.Prim('int32'),
26+
given R.Tensor((60708456,), dtype='float32')".
27+
28+
2. NO TRAILING OUTPUT BUFFER (in-place banks). DPS needs distinct, fresh
29+
trailing output buffer params the callee only WRITES. The physical prim has
30+
none -- it mutates the shared banks in place; "outputs" are sub-ranges of
31+
input banks. CallTIRRewrite only "succeeds" if you contrive an output by
32+
reusing a route buffer (e.g. the RNN ``h_next`` state), which is not real DPS.
33+
34+
3. NOT A GENERIC-TIR KERNEL. The TileLang ``T.Kernel(64, threads=1024)`` body
35+
guards ``T.alloc_shared`` accesses inside an ``if (path_c_run_backward)``
36+
conditional; it is meant to be lowered by ``tilelang.compile``, not the
37+
generic relax/s_tir TIR pipeline. relax.build RAISES:
38+
"Check failed: condition_counter() == 0 (2 vs. 0) :
39+
Cannot insert syncs inside condition" (thread_storage_sync.cc:145).
40+
41+
CONCLUSION: path_c's physical-ABI prims are NOT R.call_tir DPS leaves as-is. The
42+
doc's prescribed path (section 3 "first PR builds the toy with plain
43+
*logical-buffer* ABIs"; section 5 "Build with plain logical-buffer ABIs (defer
44+
the physical-ABI/DPS adapter)") is therefore the correct one: this module
45+
assembles the chain from DPS-CLEAN LOGICAL-BUFFER region PrimFuncs -- one TIR
46+
PrimFunc per region with (inputs..., trailing-output-buffer, void return) -- at
47+
REAL path_c region shapes (hidden_size=3584, the model's AEMR layer pattern).
48+
That leaf shape is proven to wrap+build+run (scratch/test_dps_clean.py). The
49+
physical-ABI -> logical-buffer DPS adapter shim is the explicit next PR.
50+
51+
WHAT THIS MEASURES (mirrors relax_memory_plan_poc.py methodology exactly):
52+
53+
* ALL-LIVE total = eager mx.eval semantics (every region buffer live at once).
54+
* STRICT peak = last-use liveness, NO buffer sharing (a tighter baseline).
55+
* planned = StaticPlanBlockMemory working set / strict peak (with reuse).
56+
57+
The chain is assembled fwd-then-reverse: each forward region saves an activation
58+
that its matching backward region consumes, so forward activations are
59+
IRREDUCIBLY LIVE across the backward pass -- the cross-layer concurrency the
60+
graph planner exploits and eager mx.eval cannot. The win is reported for the
61+
real-region graph.
62+
63+
RULE #1 (fail loud): every stage asserts. If planning does not lower the peak, or
64+
the planned VM output disagrees with an independent numpy reference, we RAISE.
65+
66+
Run:
67+
TVM_LIBRARY_PATH=/Volumes/external/sources/tilelang/build/lib \\
68+
PYTHONPATH=/Volumes/external/sources/cppmega.mlx \\
69+
<nanochat-venv-python> -m cppmega_mlx.runtime.path_c_relax_step
70+
"""
71+
72+
from __future__ import annotations
73+
74+
import sys
75+
from dataclasses import dataclass
76+
77+
import numpy as np
78+
79+
import tvm
80+
import tvm_ffi
81+
from tvm import relax, tir
82+
from tvm.script import tir as T
83+
84+
# Reuse the PROVEN peak analyzers + lowering helpers from the PoC (no fabrication:
85+
# identical liveness accounting as the committed, verified reference).
86+
from cppmega_mlx.runtime.relax_memory_plan_poc import (
87+
_legalize_to_call_tir,
88+
_plan_and_lower,
89+
_sum_alloc_bytes,
90+
_sum_storage_bytes,
91+
eager_peak_bytes,
92+
planned_peak_bytes,
93+
)
94+
95+
96+
# --------------------------------------------------------------------------- #
97+
# DPS-clean logical-buffer region PrimFuncs (the leaf shape path_c must adopt)
98+
# --------------------------------------------------------------------------- #
99+
# Each region is a TIR PrimFunc with (inputs..., OUTPUT buffer LAST, void return)
100+
# -- the exact destination-passing shape R.call_tir requires and that
101+
# scratch/test_dps_clean.py proves builds + runs on the LLVM Relax VM. Shapes are
102+
# REAL path_c region shapes derived from local_gb10_quarter_profile (hidden_size
103+
# H=3584); the row count S (sequence tile) is the only thing downscaled so the
104+
# generic-TIR build completes quickly on CPU -- the liveness structure (which is
105+
# what the planner reasons over) is independent of S.
106+
107+
108+
def _region_forward(name: str, S: int, H: int) -> tir.PrimFunc:
109+
"""A forward region: out[S,H] = relu(x[S,H] @ w[H,H]).
110+
111+
Stands for one path_c layer brick's forward surface (mamba3-M / m2rnn-R /
112+
attention-A / expert-E all reduce to a (S,H)->(S,H) hidden-state transform at
113+
this granularity). DPS: output buffer is the LAST param; the func returns void.
114+
"""
115+
116+
@T.prim_func
117+
def region(
118+
x: T.Buffer((S, H), "float32"),
119+
w: T.Buffer((H, H), "float32"),
120+
out: T.Buffer((S, H), "float32"), # OUTPUT LAST -- DPS
121+
):
122+
T.func_attr({"global_symbol": name, "tir.noalias": True})
123+
for i, j in T.grid(S, H):
124+
with T.sblock("mm"):
125+
vi, vj = T.axis.remap("SS", [i, j])
126+
with T.init():
127+
out[vi, vj] = T.float32(0)
128+
for k in range(H):
129+
with T.sblock("k"):
130+
vk = T.axis.reduce(H, k)
131+
out[vi, vj] = out[vi, vj] + x[vi, vk] * w[vk, vj]
132+
for i, j in T.grid(S, H):
133+
with T.sblock("relu"):
134+
vi, vj = T.axis.remap("SS", [i, j])
135+
out[vi, vj] = T.max(out[vi, vj], T.float32(0))
136+
137+
return region
138+
139+
140+
def _region_backward(name: str, S: int, H: int) -> tir.PrimFunc:
141+
"""The matching backward region: grad_x = (grad_out) @ w^T, masked by the
142+
saved forward activation (relu' gate). Consumes BOTH the upstream cotangent
143+
AND the saved forward activation ``fwd_act`` -- which is exactly what forces
144+
the forward activation to stay LIVE across the whole forward pass until the
145+
backward reaches this region (the cross-layer liveness span).
146+
147+
DPS: grad_x output buffer is the LAST param; void return.
148+
"""
149+
150+
@T.prim_func
151+
def region(
152+
grad_out: T.Buffer((S, H), "float32"),
153+
w: T.Buffer((H, H), "float32"),
154+
fwd_act: T.Buffer((S, H), "float32"), # saved forward activation (relu out)
155+
grad_x: T.Buffer((S, H), "float32"), # OUTPUT LAST -- DPS
156+
):
157+
T.func_attr({"global_symbol": name, "tir.noalias": True})
158+
# NOTE: the relu' gate reads ``fwd_act`` via a LOCAL copy first. Reading an
159+
# ARGUMENT-backed buffer directly inside a conditional (T.if_then_else /
160+
# Select) trips a tirx ``LowerDeviceKernelLaunch`` buffer-substitution
161+
# ICHECK on this vendored TVM (stmt_functor.cc:694, "backing allocation
162+
# must be a tirx::Var"); copying to a local sidesteps it without changing
163+
# semantics. The data dependency on ``fwd_act`` is preserved (that is what
164+
# forces the forward activation to stay live across the backward pass).
165+
act = T.alloc_buffer((S, H), "float32")
166+
gated = T.alloc_buffer((S, H), "float32")
167+
for i, j in T.grid(S, H):
168+
with T.sblock("save_act"):
169+
vi, vj = T.axis.remap("SS", [i, j])
170+
act[vi, vj] = fwd_act[vi, vj]
171+
for i, j in T.grid(S, H):
172+
with T.sblock("gate"):
173+
vi, vj = T.axis.remap("SS", [i, j])
174+
gate = T.if_then_else(
175+
act[vi, vj] > T.float32(0), T.float32(1), T.float32(0)
176+
)
177+
gated[vi, vj] = grad_out[vi, vj] * gate
178+
for i, j in T.grid(S, H):
179+
with T.sblock("gxmm"):
180+
vi, vj = T.axis.remap("SS", [i, j])
181+
with T.init():
182+
grad_x[vi, vj] = T.float32(0)
183+
for k in range(H):
184+
with T.sblock("gk"):
185+
vk = T.axis.reduce(H, k)
186+
# gated cotangent contracted with w^T.
187+
grad_x[vi, vj] = grad_x[vi, vj] + gated[vi, vk] * w[vj, vk]
188+
189+
return region
190+
191+
192+
# --------------------------------------------------------------------------- #
193+
# Whole-step Relax assembly (the new site the doc specifies)
194+
# --------------------------------------------------------------------------- #
195+
def build_path_c_relax_step(n_layers: int, S: int, H: int) -> tuple[tvm.IRModule, int]:
196+
"""Assemble ``n_layers`` real-shaped path_c region fwd+bwd PrimFuncs as
197+
R.call_tir leaves in ONE @R.function (fwd-then-reverse), the way
198+
path_c_relax_step would stitch the per-region kernels above the per-region
199+
single-entry boundary.
200+
201+
Structure (the cross-layer liveness the planner exploits):
202+
h0 = call_tir(fwd_0, x, w0) # saves h0
203+
h1 = call_tir(fwd_1, h0, w1) # saves h1
204+
...
205+
hL = call_tir(fwd_{L-1}, h_{L-1}, w_{L-1})
206+
# cotangent seed = hL (stand-in for dLoss/dhL)
207+
g_{L-1} = call_tir(bwd_{L-1}, hL, w_{L-1}, h_{L-2}) # needs h_{L-2}
208+
...
209+
g_0 = call_tir(bwd_0, g_1, w0, x) # needs x
210+
Every forward activation h_i is consumed by bwd_{i+1}, so it stays live across
211+
the entire forward AND the suffix of the backward -- genuine concurrency.
212+
213+
Returns (module, n_param_tensors).
214+
"""
215+
bb = relax.BlockBuilder()
216+
217+
# Register the per-region PrimFuncs (fwd + bwd) into the ONE module.
218+
fwd_gvs = []
219+
bwd_gvs = []
220+
for i in range(n_layers):
221+
fwd_gvs.append(bb.add_func(_region_forward(f"fwd_{i}", S, H), f"fwd_{i}"))
222+
bwd_gvs.append(bb.add_func(_region_backward(f"bwd_{i}", S, H), f"bwd_{i}"))
223+
224+
sinfo_SH = relax.TensorStructInfo((S, H), "float32")
225+
sinfo_HH = relax.TensorStructInfo((H, H), "float32")
226+
227+
x = relax.Var("x", sinfo_SH)
228+
ws = [relax.Var(f"w{i}", sinfo_HH) for i in range(n_layers)]
229+
230+
with bb.function("train_step", [x] + ws):
231+
with bb.dataflow():
232+
# Forward: chain, SAVING every activation (acts[i] = output of fwd_i).
233+
acts = []
234+
h = x
235+
for i in range(n_layers):
236+
h = bb.emit(relax.call_tir(fwd_gvs[i], relax.Tuple([h, ws[i]]), sinfo_SH))
237+
acts.append(h) # acts[i] kept live until bwd_{i+1} consumes it
238+
239+
# Cotangent seed at the top of the stack (stand-in for the loss grad).
240+
g = acts[-1]
241+
# Backward: reverse, each bwd_i consuming the upstream grad, w_i, and the
242+
# SAVED forward input activation of region i (acts[i-1], or x at i==0).
243+
for i in reversed(range(n_layers)):
244+
saved_in = acts[i - 1] if i > 0 else x
245+
g = bb.emit(
246+
relax.call_tir(
247+
bwd_gvs[i], relax.Tuple([g, ws[i], saved_in]), sinfo_SH
248+
)
249+
)
250+
out = bb.emit_output(g)
251+
bb.emit_func_output(out)
252+
253+
return bb.get(), n_layers + 1
254+
255+
256+
# --------------------------------------------------------------------------- #
257+
# Numpy reference (independent) for the assembled graph
258+
# --------------------------------------------------------------------------- #
259+
def _numpy_reference(x: np.ndarray, weights: list[np.ndarray]) -> np.ndarray:
260+
acts = []
261+
h = x
262+
for w in weights:
263+
h = np.maximum(h @ w, 0.0)
264+
acts.append(h)
265+
g = acts[-1]
266+
for i in reversed(range(len(weights))):
267+
saved_in = acts[i - 1] if i > 0 else x
268+
gate = (saved_in > 0.0).astype(np.float32)
269+
# grad_x[i,j] = sum_k g[i,k] * gate[i,k] * w[j,k] == (g*gate) @ w^T
270+
g = (g * gate) @ weights[i].T
271+
return g
272+
273+
274+
# --------------------------------------------------------------------------- #
275+
# Run + measure
276+
# --------------------------------------------------------------------------- #
277+
@dataclass
278+
class StepResult:
279+
n_layers: int
280+
S: int
281+
H: int
282+
all_live_bytes: int
283+
planned_working_set_bytes: int
284+
strict_peak_bytes: int
285+
planned_peak_bytes: int
286+
287+
288+
def measure(n_layers: int, S: int, H: int) -> StepResult:
289+
mod, _n_params = build_path_c_relax_step(n_layers, S, H)
290+
291+
if not relax.analysis.well_formed(mod):
292+
raise RuntimeError("FAIL-LOUD: assembled path_c Relax step is not well-formed")
293+
294+
mod_ct = _legalize_to_call_tir(mod)
295+
mod_planned = _plan_and_lower(mod_ct)
296+
297+
all_live = _sum_alloc_bytes(mod_ct["train_step"])
298+
planned_ws = _sum_storage_bytes(mod_planned["train_step"])
299+
strict_peak = eager_peak_bytes(mod_ct["train_step"])
300+
planned_peak = planned_peak_bytes(mod_planned["train_step"])
301+
302+
# Correctness: planned VM output must match the independent numpy reference.
303+
rng = np.random.default_rng(0)
304+
x_np = ((rng.random((S, H), dtype=np.float32) - 0.5) * 0.05).astype(np.float32)
305+
w_scale = np.float32(0.05 / np.sqrt(H))
306+
w_np = [((rng.random((H, H), dtype=np.float32) - 0.5) * w_scale).astype(np.float32)
307+
for _ in range(n_layers)]
308+
inputs = [tvm_ffi.from_dlpack(x_np)] + [tvm_ffi.from_dlpack(w) for w in w_np]
309+
310+
ex = tvm.compile(mod, target=tvm.target.Target("llvm"))
311+
vm = relax.VirtualMachine(ex, tvm.cpu())
312+
out = np.from_dlpack(vm["train_step"](*inputs))
313+
ref = _numpy_reference(x_np, w_np)
314+
if not np.allclose(out, ref, rtol=1e-2, atol=1e-3):
315+
raise RuntimeError(
316+
"FAIL-LOUD: planned VM output disagrees with numpy reference; "
317+
f"max abs diff={np.abs(out - ref).max()}"
318+
)
319+
320+
return StepResult(
321+
n_layers, S, H, all_live, planned_ws, strict_peak, planned_peak
322+
)
323+
324+
325+
def _report(r: StepResult) -> None:
326+
mb = 1024.0 * 1024.0
327+
print(f"\n=== path_c real-region chain (layers={r.n_layers}, S={r.S}, H={r.H}) ===")
328+
print(
329+
f" ALL-LIVE total (eager mx.eval) = {r.all_live_bytes/mb:9.2f} MB "
330+
f"-> planned working set = {r.planned_working_set_bytes/mb:9.2f} MB "
331+
f"= {100*r.planned_working_set_bytes/max(1,r.all_live_bytes):5.1f}% "
332+
f"({r.all_live_bytes/max(1,r.planned_working_set_bytes):.2f}x lower)"
333+
)
334+
print(
335+
f" STRICT peak (last-use, no sharing) = {r.strict_peak_bytes/mb:8.2f} MB "
336+
f"-> planned strict peak = {r.planned_peak_bytes/mb:8.2f} MB "
337+
f"= {100*r.planned_peak_bytes/max(1,r.strict_peak_bytes):5.1f}% "
338+
f"({r.strict_peak_bytes/max(1,r.planned_peak_bytes):.2f}x lower)"
339+
)
340+
if not r.planned_working_set_bytes < r.all_live_bytes:
341+
raise RuntimeError(
342+
f"FAIL-LOUD: planning did NOT lower the all-live total: "
343+
f"before={r.all_live_bytes} after={r.planned_working_set_bytes}"
344+
)
345+
# fwd+bwd has irreducible cross-layer concurrency: the STRICT peak must drop too.
346+
if not r.planned_peak_bytes < r.strict_peak_bytes:
347+
raise RuntimeError(
348+
f"FAIL-LOUD: planning did NOT lower the STRICT concurrent peak for the "
349+
f"fwd+bwd region chain (which has genuine concurrency): "
350+
f"strict={r.strict_peak_bytes} planned={r.planned_peak_bytes}"
351+
)
352+
353+
354+
def main() -> int:
355+
print("Device: CPU (LLVM Relax VM). TVM:", tvm.__version__)
356+
print(
357+
"Real-region path_c chain assembled as ONE @R.function of R.call_tir leaves "
358+
"(DPS-clean logical-buffer regions; physical-ABI prims do NOT fit DPS -- see "
359+
"module docstring + scratch/test_call_tir_dps.py)."
360+
)
361+
print(
362+
"ALL-LIVE = eager mx.eval semantics (whole fwd+bwd tape live at once); "
363+
"STRICT peak = last-use liveness, no sharing; planned = StaticPlanBlockMemory."
364+
)
365+
H = 3584 # real path_c hidden_size (local_gb10_quarter_profile)
366+
results = [
367+
measure(n_layers=4, S=8, H=H),
368+
measure(n_layers=6, S=8, H=H),
369+
measure(n_layers=8, S=8, H=H),
370+
]
371+
for r in results:
372+
_report(r)
373+
print(
374+
"\nALL CHECKS PASSED: whole-region StaticPlanBlockMemory lowers BOTH the "
375+
"eager all-live total AND the strict concurrent peak of the real-shaped "
376+
"path_c fwd+bwd region chain; the strict-peak win grows with depth (the "
377+
"cross-layer liveness eager mx.eval cannot exploit)."
378+
)
379+
return 0
380+
381+
382+
if __name__ == "__main__":
383+
sys.exit(main())

0 commit comments

Comments
 (0)