Skip to content

Commit a00f9fb

Browse files
committed
feat(relax): PR2 physical-bank -> logical-buffer DPS adapter for real path_c regions
PR 2 of docs/RELAX-GRAPH-MEMORY-PATH.md (goal #4). Makes a REAL path_c region a valid, plannable Relax-graph leaf so the train step can be one @R.function with global StaticPlanBlockMemory. DECISION (measured): the real path_c kernel can ONLY be lowered by tilelang.compile (the TileLang guarded-sync T.Kernel body RAISES under generic relax/s_tir, even after currying run_backward -- mismatch #3 is a hard codegen wall). So the graph boundary is R.call_dps_packed (external function), NOT R.call_tir. The adapter parses the real prim's physical-ABI map (60 logical tensors / 5 banks), presents logical I/O as the DPS surface, and packs/unpacks logical tensors into the physical bank sub-ranges around the kernel. - path_c_dps_adapter.py: ABI introspection + DPS packed-func adapter + Relax emit. - path_c_relax_step_real.py: assemble real-region DPS-adapter leaves as call_dps_packed into one @R.function; plan + build + run + numerics + measure. - Measured real-leaf chain (CPU LLVM VM): strict peak 1.67x->1.75x->1.80x at 4/6/8 layers (grows with depth); both all-live total and strict peak drop. - scratch/pr2_*: evidence -- curry closes #1 not #3; tilelang.compile lowers the prim (168KB Metal, ~2s); call_dps_packed leaf well_formed+plan+build+run+correct; adapter packs/unpacks byte-exact through the REAL 44.9M-f32 bank offsets. Next: on-device kernel driver + expose banks as Relax tensors to co-plan the heavy ~45M/253M-f32 banks across regions; then Gradient/remat/in-place optimizer.
1 parent 5e0704e commit a00f9fb

8 files changed

Lines changed: 1002 additions & 11 deletions
Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
"""PR 2 of docs/RELAX-GRAPH-MEMORY-PATH.md: the physical-bank -> logical-buffer
2+
DPS adapter that makes a REAL path_c PrimFunc usable as a Relax-graph leaf, so the
3+
whole train step can be assembled as ONE Relax @R.function with global memory
4+
planning (StaticPlanBlockMemory).
5+
6+
WHY AN EXTERNAL-FUNCTION (call_dps_packed) BOUNDARY, NOT A call_tir LEAF
7+
-----------------------------------------------------------------------
8+
PR 1 (scratch/test_call_tir_dps.py) found the 3 DPS mismatches that block the real
9+
physical-ABI path_c prim from being an R.call_tir leaf. PR 2 MEASURED whether each
10+
is fixable inside a generic-TIR adapter:
11+
12+
(1) PARAM ORDER -- scalar ``path_c_run_backward: T.int32`` at param index 5 (the
13+
middle), not last. ==> CLOSED by specialization: ``prim.specialize({run_backward:
14+
const})`` yields a 16-param prim with ZERO scalar params (no mid-param scalar).
15+
Verified: scratch/pr2_test_curry.py prints "scalars: []".
16+
17+
(2) NO TRAILING OUTPUT BUFFER -- logical tensors are packed into disjoint RANGES
18+
of a few large shared physical dtype banks, read+written IN PLACE
19+
(``tilelang_out_idx = [0,2,3,4,6,...,16]`` -- nearly every param is an output).
20+
==> CLOSED by the adapter ABI below: it presents logical inputs as read-only
21+
Relax tensors and the logical output as a single trailing Relax tensor; the
22+
physical-bank packing is an INTERNAL detail of the packed function, not the
23+
call ABI.
24+
25+
(3) NOT A GENERIC-TIR KERNEL -- the TileLang ``T.Kernel(64, threads=1024)`` body
26+
guards ``T.alloc_shared`` accesses inside conditionals; generic relax/s_tir
27+
RAISES "Cannot insert syncs inside condition" (thread_storage_sync.cc:145).
28+
==> This is a HARD WALL and it does NOT close by specialization: even after
29+
currying run_backward to a constant the s_tir build STILL raises (the row-chunk
30+
dispatch guards remain). MEASURED: scratch/pr2_test_curry.py.
31+
BUT the SAME prim lowers cleanly through ``tilelang.compile`` (target=metal)
32+
in ~2 s, emitting a 168 KB Metal kernel + a callable JITKernel.
33+
MEASURED: scratch/pr2_compile_full.py.
34+
35+
DECISION (this is the deliverable's pinned outcome): because of (3), the real
36+
path_c kernel can ONLY be lowered via ``tilelang.compile`` -- it can NEVER be
37+
inlined into a generic-TIR ``R.call_tir`` leaf. The correct Relax-graph boundary
38+
is therefore an EXTERNAL FUNCTION: ``R.call_dps_packed("<name>", [logical_inputs],
39+
out_sinfo)``. The named packed function:
40+
* allocates / owns the physical banks,
41+
* packs the logical inputs into their bank sub-ranges (per the prim's
42+
``tl.fusion.physical_abi.logical_to_physical`` map),
43+
* invokes the tilelang.compile'd kernel (the real path_c compute) on the banks,
44+
* unpacks the logical-output sub-range into the trailing DPS output tensor.
45+
This closes (1) (run_backward curried per-specialized-kernel), (2) (logical I/O is
46+
the ABI; banks are internal), and (3) (kernel is the tilelang artifact, not s_tir).
47+
48+
call_dps_packed outputs ARE Relax-level tensors that CallTIRRewrite materialises as
49+
``builtin.alloc_tensor`` and ``StaticPlanBlockMemory`` co-plans -- so the planner
50+
STILL sees and reuses each region's logical working set across the assembled
51+
@R.function, exactly as PR 1's call_tir leaves did. (The internal physical banks
52+
are NOT Relax-visible; co-planning those is the explicit further step noted in the
53+
doc -- expose banks as Relax tensors -- but the logical-output liveness, which is
54+
what drives the cross-layer fwd/bwd concurrency win, is fully planned here.)
55+
56+
This module provides:
57+
* ``PathCRegionLeaf`` -- a curried (fwd-only / bwd-only) real path_c region with
58+
its tilelang-compiled kernel, logical I/O signature, and physical-bank map.
59+
* ``register_region_dps_packed`` -- register the pack/kernel/unpack packed func.
60+
* ``emit_region_call`` -- emit ``R.call_dps_packed`` for the region into a
61+
BlockBuilder, returning the logical output Var.
62+
63+
RULE #1 (fail loud): every stage asserts; mismatched shapes / missing banks RAISE.
64+
65+
Run the self-test:
66+
TVM_LIBRARY_PATH=/Volumes/external/sources/tilelang/build/lib \\
67+
PYTHONPATH=/Volumes/external/sources/cppmega.mlx \\
68+
<python> -m cppmega_mlx.runtime.path_c_dps_adapter
69+
"""
70+
71+
from __future__ import annotations
72+
73+
import json
74+
import sys
75+
from dataclasses import dataclass
76+
from typing import Any, Callable
77+
78+
import numpy as np
79+
80+
import tvm
81+
import tvm_ffi
82+
from tvm import relax, tir
83+
84+
85+
# --------------------------------------------------------------------------- #
86+
# Physical-ABI introspection (reads the REAL prim's metadata -- no fabrication)
87+
# --------------------------------------------------------------------------- #
88+
@dataclass(frozen=True)
89+
class LogicalBufferMap:
90+
"""One logical tensor's placement inside a physical bank sub-range."""
91+
92+
name: str
93+
bank: str
94+
offset: int
95+
size: int
96+
logical_shape: tuple[int, ...]
97+
dtype: str
98+
99+
100+
def parse_logical_to_physical(prim: tir.PrimFunc) -> dict[str, LogicalBufferMap]:
101+
"""Parse the ``tl.fusion.physical_abi.logical_to_physical`` attr off a real
102+
path_c prim into a {logical_name: LogicalBufferMap} dict. RAISES if absent."""
103+
104+
if prim.attrs is None or "tl.fusion.physical_abi.logical_to_physical" not in prim.attrs:
105+
raise RuntimeError(
106+
"FAIL-LOUD: prim carries no tl.fusion.physical_abi.logical_to_physical "
107+
"attr -- not a physical-ABI path_c prim"
108+
)
109+
raw = prim.attrs["tl.fusion.physical_abi.logical_to_physical"]
110+
table = json.loads(str(raw))
111+
out: dict[str, LogicalBufferMap] = {}
112+
for name, spec in table.items():
113+
out[name] = LogicalBufferMap(
114+
name=name,
115+
bank=str(spec["bank"]),
116+
offset=int(spec["offset"]),
117+
size=int(spec["size"]),
118+
logical_shape=tuple(int(d) for d in spec["logical_shape"]),
119+
dtype=str(spec["dtype"]),
120+
)
121+
return out
122+
123+
124+
def parse_physical_bank_shapes(prim: tir.PrimFunc) -> dict[str, int]:
125+
"""Parse ``tl.fusion.physical_abi.physical_buffer_shapes`` -> {bank: numel}."""
126+
127+
raw = prim.attrs["tl.fusion.physical_abi.physical_buffer_shapes"]
128+
table = json.loads(str(raw))
129+
return {str(k): int(v[0]) for k, v in table.items()}
130+
131+
132+
def prim_bank_param_order(prim: tir.PrimFunc) -> list[str]:
133+
"""The bank/handle param names IN ORDER (the physical kernel ABI order),
134+
skipping the scalar gate param. The tilelang kernel is invoked positionally
135+
in this order."""
136+
137+
order = []
138+
for p in prim.params:
139+
if p in prim.buffer_map:
140+
order.append(prim.buffer_map[p].name)
141+
return order
142+
143+
144+
# --------------------------------------------------------------------------- #
145+
# A real path_c region, curried + tilelang-compiled, as a Relax-graph leaf
146+
# --------------------------------------------------------------------------- #
147+
@dataclass
148+
class PathCRegionLeaf:
149+
"""A REAL path_c region specialized to fwd-only or bwd-only, with the real
150+
tilelang-compiled kernel and the logical I/O signature the adapter exposes."""
151+
152+
name: str
153+
run_backward: int
154+
prim: tir.PrimFunc # the specialized (no-scalar) physical-ABI prim
155+
kernel: Any # tilelang JITKernel (real compiled path_c kernel)
156+
logical_map: dict[str, LogicalBufferMap]
157+
bank_shapes: dict[str, int]
158+
bank_param_order: list[str]
159+
logical_inputs: tuple[str, ...] # logical input tensor names (read-only)
160+
logical_output: str # the single logical output tensor name (DPS)
161+
162+
163+
# --------------------------------------------------------------------------- #
164+
# The DPS packed function: logical I/O ABI over the physical banks + real kernel
165+
# --------------------------------------------------------------------------- #
166+
def make_region_dps_packed(leaf: PathCRegionLeaf) -> Callable[..., Any]:
167+
"""Build the packed function implementing the logical->physical DPS adapter for
168+
``leaf``. Signature (call_dps_packed ABI): (logical_in_0, ..., logical_in_k,
169+
logical_out) -- inputs first, single OUTPUT LAST, banks INTERNAL.
170+
171+
Body: allocate the physical banks, pack each logical input into its bank
172+
sub-range, invoke the real tilelang kernel on the banks (in the kernel's
173+
positional bank order), unpack the logical-output sub-range into ``logical_out``.
174+
175+
NOTE on the kernel call: the real tilelang JITKernel takes the physical banks
176+
(and any auxiliary route buffers) positionally. We supply the banks we own; any
177+
auxiliary route-symbol buffers the kernel also takes are zero-filled scratch of
178+
the kernel-declared shape (they are not part of THIS region's logical ABI -- the
179+
adapter's contract is the logical inputs/output; auxiliary kernel args are an
180+
internal kernel detail). For the self-test below we drive a numpy reference
181+
through the SAME pack/unpack to validate the adapter plumbing end to end without
182+
requiring a live Metal device on the measurement host.
183+
"""
184+
185+
lmap = leaf.logical_map
186+
bank_shapes = leaf.bank_shapes
187+
188+
def _packed(*args: Any) -> None:
189+
if len(args) != len(leaf.logical_inputs) + 1:
190+
raise RuntimeError(
191+
f"FAIL-LOUD: {leaf.name} DPS packed expected "
192+
f"{len(leaf.logical_inputs)+1} args "
193+
f"(inputs {len(leaf.logical_inputs)} + 1 output), got {len(args)}"
194+
)
195+
in_arrays = [np.from_dlpack(a) for a in args[:-1]]
196+
out_array = np.from_dlpack(args[-1])
197+
198+
# Allocate the physical banks we own.
199+
banks = {b: np.zeros((n,), dtype=np.float32) for b, n in bank_shapes.items()}
200+
201+
# Pack each logical input into its bank sub-range.
202+
for lname, arr in zip(leaf.logical_inputs, in_arrays):
203+
m = lmap[lname]
204+
flat = np.ascontiguousarray(arr, dtype=np.float32).reshape(-1)
205+
if flat.size != m.size:
206+
raise RuntimeError(
207+
f"FAIL-LOUD: logical input {lname} numel {flat.size} != "
208+
f"ABI sub-range size {m.size}"
209+
)
210+
banks[m.bank][m.offset : m.offset + m.size] = flat
211+
212+
# Invoke the region compute on the packed banks. The real deployment calls
213+
# ``leaf.kernel`` (the tilelang JITKernel) here on a live Metal/CUDA device;
214+
# the adapter's pack/unpack ABI around that call is what this module proves.
215+
_drive_region_compute(leaf, banks)
216+
217+
# Unpack the logical-output sub-range into the trailing DPS output tensor.
218+
m = lmap[leaf.logical_output]
219+
out_flat = banks[m.bank][m.offset : m.offset + m.size]
220+
out_array[...] = out_flat.reshape(out_array.shape)
221+
222+
return _packed
223+
224+
225+
# Hook the region compute. Default = a transparent reference matching the region's
226+
# logical semantics (so the adapter ABI is testable on CPU without a live GPU); the
227+
# real path is set by ``set_region_kernel_driver`` to call ``leaf.kernel`` on device.
228+
_REGION_DRIVER: Callable[[PathCRegionLeaf, dict[str, np.ndarray]], None] | None = None
229+
230+
231+
def set_region_kernel_driver(
232+
fn: Callable[[PathCRegionLeaf, dict[str, np.ndarray]], None] | None,
233+
) -> None:
234+
global _REGION_DRIVER
235+
_REGION_DRIVER = fn
236+
237+
238+
def _drive_region_compute(leaf: PathCRegionLeaf, banks: dict[str, np.ndarray]) -> None:
239+
if _REGION_DRIVER is not None:
240+
_REGION_DRIVER(leaf, banks)
241+
return
242+
raise RuntimeError(
243+
"FAIL-LOUD: no region kernel driver set. Call set_region_kernel_driver(...) "
244+
"with either the on-device tilelang-kernel driver or a reference driver "
245+
"before invoking the DPS packed function."
246+
)
247+
248+
249+
def register_region_dps_packed(leaf: PathCRegionLeaf, packed_name: str) -> None:
250+
"""Register the region's DPS packed function under ``packed_name`` so Relax can
251+
``R.call_dps_packed(packed_name, ...)`` it."""
252+
253+
tvm_ffi.register_global_func(packed_name, make_region_dps_packed(leaf), override=True)
254+
255+
256+
# --------------------------------------------------------------------------- #
257+
# Relax emission: the region as a call_dps_packed leaf
258+
# --------------------------------------------------------------------------- #
259+
def emit_region_call(
260+
bb: relax.BlockBuilder,
261+
packed_name: str,
262+
inputs: list[relax.Expr],
263+
out_sinfo: relax.TensorStructInfo,
264+
) -> relax.Var:
265+
"""Emit ``R.call_dps_packed(packed_name, inputs, out_sinfo)`` and return the
266+
logical-output Var. This is the real path_c region as a Relax-graph leaf."""
267+
268+
return bb.emit(relax.call_dps_packed(packed_name, inputs, out_sinfo))
269+
270+
271+
if __name__ == "__main__":
272+
# Self-test lives in the measurement module (path_c_relax_step) which assembles
273+
# several real region leaves and runs StaticPlanBlockMemory; running this module
274+
# directly just sanity-checks introspection on the real MR prim.
275+
from cppmega_mlx.runtime.path_c_fusion import (
276+
build_path_c_aot_autograd_region,
277+
build_path_c_model_region_from_route_symbols,
278+
)
279+
from cppmega_mlx.runtime.path_c_fusion_schedules import (
280+
path_c_fusion_schedule_template,
281+
)
282+
from cppmega_mlx.recipes.model_factory import local_gb10_quarter_profile
283+
284+
cfg = local_gb10_quarter_profile().hybrid_config()
285+
region = build_path_c_model_region_from_route_symbols(
286+
region_name="mr_path_c", route_symbols=("M", "R"), model_config=cfg,
287+
)
288+
prim = path_c_fusion_schedule_template(build_path_c_aot_autograd_region(region))
289+
lmap = parse_logical_to_physical(prim)
290+
banks = parse_physical_bank_shapes(prim)
291+
order = prim_bank_param_order(prim)
292+
print(f"real MR prim: {len(prim.params)} params, "
293+
f"{len(lmap)} logical tensors, {len(banks)} physical banks")
294+
print("physical banks:", banks)
295+
print("bank param order:", order)
296+
print("sample logical maps:")
297+
for k in list(lmap)[:6]:
298+
m = lmap[k]
299+
print(f" {k:40s} -> {m.bank} [{m.offset}:{m.offset+m.size}] shape={m.logical_shape}")
300+
sys.exit(0)

0 commit comments

Comments
 (0)