Skip to content

Commit 3d4dfd0

Browse files
committed
feat(relax): PR3 physical banks as cross-region Relax SSA tensors + real Metal kernel driver
PR-3 of docs/RELAX-GRAPH-MEMORY-PATH.md (the large memory collapse): (2) Bank exposure: thread the REAL 5 physical banks (activation 171.5M, parameter 360.8M, parameter-grad 418.5M, state/checkpoint 965.3M -- 1981 MB/region, parsed from tl.fusion.physical_abi.physical_buffer_shapes) as Relax-level SSA tensors region-to-region (call_dps_packed with multiple bank inputs + multiple bank outputs), so StaticPlanBlockMemory shares the heavy banks ACROSS regions instead of hiding them inside each packed func. MEASURED eager all-live -> planned peak: 1.32x (2 layers) -> 1.67x (28 = the 1.8B step MR blocks), growing with depth. Honest signature: planned-peak slope 0.951 GB/layer == the state/checkpoint bank size, i.e. every bank collapses to a CONSTANT working set EXCEPT the O(N)-live checkpoint bank (fwd-i saves, bwd-i reads). (1) On-device driver: make_real_kernel_driver runs the real tilelang JITKernel THROUGH the call_dps_packed boundary on Metal end-to-end (17-param MR kernel, 168 KB MSL, 14.68M nonzero activation outputs) -- the external-function boundary executes the real kernel, not the numpy stand-in. KEY FINDING / Relax limitation (RULE #1, reported not papered): StaticPlanBlockMemory cannot see THROUGH call_dps_packed -- it prematurely kills externally-written bank-output storage. Plan stays CORRECT (distinct storages kept; numerics max abs diff 0.0) but the PoC peak analyzer under-counts; ships a corrected true_planned_peak. The real path_c kernel can ONLY be call_dps_packed (PR-2 mismatch #3), so the planner is always blind to in-place bank writes -> liveness cannot beat the O(N) checkpoint term; only remat can. With sqrt(N) remat the 28-block projection drops 27.38 GB -> 6.97 GB (activation/state term) -- bank-exposure + remat + in-place optimizer is what projects eager 118 GB toward the Megatron-class 26-40 GB target. Numeric equivalence vs PR-2 per-region-internal banks verified on LLVM VM (the pack/ unpack is just relocated to Relax tensor boundaries). Evidence: scratch/pr3_*.py.
1 parent a00f9fb commit 3d4dfd0

11 files changed

Lines changed: 1326 additions & 1 deletion

cppmega_mlx/runtime/path_c_dps_adapter.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,61 @@ def register_region_dps_packed(leaf: PathCRegionLeaf, packed_name: str) -> None:
253253
tvm_ffi.register_global_func(packed_name, make_region_dps_packed(leaf), override=True)
254254

255255

256+
# --------------------------------------------------------------------------- #
257+
# PR-3 (3): the REAL on-device tilelang-kernel driver.
258+
#
259+
# This is the production region-compute driver: it invokes ``leaf.kernel`` (the
260+
# tilelang.compile'd JITKernel -- the real path_c compute, which can ONLY be lowered
261+
# by tilelang, PR-2 mismatch #3) on a live device, driven through the physical banks.
262+
# Proven end-to-end on Metal (scratch/pr3_real_kernel_driver.py): the real 17-param
263+
# MR kernel runs THROUGH the call_dps_packed boundary, computing 14.68M nonzero
264+
# activation outputs.
265+
# --------------------------------------------------------------------------- #
266+
def make_real_kernel_driver(
267+
leaf: PathCRegionLeaf, device: Any,
268+
) -> Callable[[PathCRegionLeaf, dict[str, np.ndarray]], None]:
269+
"""Build an on-device driver that runs ``leaf.kernel`` (the real tilelang JITKernel)
270+
on ``device`` (e.g. ``tvm.metal(0)`` / ``tvm.cuda(0)``), mapping the 5 physical
271+
banks to the kernel's leading 5 params, the curried ``run_backward`` scalar to the
272+
gate param, and zero-filled scratch to the auxiliary route-buffer params.
273+
274+
RULE #1: shape / param-count mismatches RAISE; no silent fallback."""
275+
276+
if not getattr(device, "exist", False):
277+
raise RuntimeError(
278+
f"FAIL-LOUD: device {device} not present for the real tilelang-kernel driver")
279+
kparams = list(leaf.kernel.params)
280+
out_idx = set(int(x) for x in leaf.kernel.out_idx)
281+
# the leading 5 kernel params are the 5 physical banks, in bank_param_order[:5].
282+
bank_pos = {name: i for i, name in enumerate(leaf.bank_param_order[:5])}
283+
# locate the scalar gate param (zero-dim) -- typically param index 5.
284+
gate_pos = next((i for i, p in enumerate(kparams)
285+
if len(list(p.shape)) == 0), None)
286+
if gate_pos is None:
287+
raise RuntimeError("FAIL-LOUD: no scalar gate param found in the kernel ABI")
288+
289+
def driver(lf: PathCRegionLeaf, banks: dict[str, np.ndarray]) -> None:
290+
args: list[Any] = [None] * len(kparams)
291+
for name, pos in bank_pos.items():
292+
if name not in banks:
293+
raise RuntimeError(f"FAIL-LOUD: bank {name} missing for real driver")
294+
args[pos] = tvm.runtime.tensor(
295+
np.ascontiguousarray(banks[name], np.float32), device=device)
296+
args[gate_pos] = int(lf.run_backward)
297+
for i in range(len(kparams)):
298+
if args[i] is not None or i == gate_pos:
299+
continue
300+
shp = [int(d) for d in kparams[i].shape]
301+
args[i] = tvm.runtime.tensor(np.zeros(shp, np.float32), device=device)
302+
leaf.kernel(*args)
303+
device.sync()
304+
for name, pos in bank_pos.items():
305+
if pos in out_idx:
306+
banks[name] = args[pos].numpy().reshape(-1)
307+
308+
return driver
309+
310+
256311
# --------------------------------------------------------------------------- #
257312
# Relax emission: the region as a call_dps_packed leaf
258313
# --------------------------------------------------------------------------- #

cppmega_mlx/runtime/path_c_relax_step_banks.py

Lines changed: 484 additions & 0 deletions
Large diffs are not rendered by default.

docs/RELAX-GRAPH-MEMORY-PATH.md

Lines changed: 175 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,16 @@ logical-buffer DPS adapter makes a REAL path_c region a VALID, plannable Relax-g
1616
leaf -- but via an **external-function boundary (`R.call_dps_packed`), NOT a
1717
`call_tir` leaf**, because the real path_c kernel can ONLY be lowered by
1818
`tilelang.compile` (mismatch #3 is a hard codegen wall that does NOT close even after
19-
currying the scalar). Section 7. Integration is a multi-quarter effort; this doc
19+
currying the scalar). Section 7. **PR 3 done + measured** (2026-06-02): the physical
20+
banks (activation 171.5M, parameter 360.8M, parameter-grad 418.5M, state/checkpoint
21+
965.3M -- 1981 MB/region) are exposed as cross-region Relax SSA tensors, so
22+
`StaticPlanBlockMemory` shares the heavy banks ACROSS regions. MEASURED collapse on the
23+
real banks: eager all-live -> planned peak **1.32x -> 1.67x, growing with depth** (the
24+
planned-peak slope == the checkpoint-bank size, so every bank collapses to a constant
25+
working set EXCEPT the O(N)-live checkpoint -- the remat target). The real MR JITKernel
26+
also runs on Metal THROUGH the `call_dps_packed` boundary end-to-end. The bound is the
27+
key finding: cross-region liveness reuse cannot beat the checkpoint term; only
28+
rematerialization can. Section 8. Integration is a multi-quarter effort; this doc
2029
specifies the roadmap.
2130

2231
PoC code: [`cppmega_mlx/runtime/relax_memory_plan_poc.py`](../cppmega_mlx/runtime/relax_memory_plan_poc.py)
@@ -475,3 +484,168 @@ The adapter unblocks putting REAL path_c regions in the graph via the
475484
* `scratch/pr2_test_real_abi_roundtrip.py` -- adapter packs/unpacks through the REAL
476485
bank sub-range offsets byte-exact.
477486
* `scratch/pr2_dump_abi.py` -- dumps the real prim's physical-ABI metadata maps.
487+
488+
---
489+
490+
## 8. PR 3 -- DONE + MEASURED (2026-06-02): physical banks as cross-region Relax tensors (the LARGE collapse) + real on-device kernel driver
491+
492+
**Shipped:**
493+
* [`cppmega_mlx/runtime/path_c_relax_step_banks.py`](../cppmega_mlx/runtime/path_c_relax_step_banks.py)
494+
-- the bank-as-Relax-tensor SSA assembly + the honest peak analyzer + the remat
495+
projection.
496+
* `make_real_kernel_driver` in
497+
[`cppmega_mlx/runtime/path_c_dps_adapter.py`](../cppmega_mlx/runtime/path_c_dps_adapter.py)
498+
-- the first-class on-device driver that runs the real tilelang JITKernel through
499+
the `call_dps_packed` boundary (PR-3 deliverable 3).
500+
501+
### The two PR-3 tasks (from section 7's "remaining step" (1) and (2))
502+
503+
**(2) Expose the physical banks as cross-region Relax tensors.** Until PR-3 each
504+
region's 5 physical banks were INTERNAL to its packed func, so
505+
`StaticPlanBlockMemory` only co-planned the tiny logical-output tensors -> 1.80x.
506+
PR-3 threads the REAL banks (parsed from `tl.fusion.physical_abi.physical_buffer_shapes`)
507+
as Relax-level SSA tensors region-to-region: each region READS bank tensors and WRITES
508+
updated bank tensors (`R.call_dps_packed` with multiple bank inputs and multiple bank
509+
outputs). The SSA thread mirrors the real dataflow:
510+
511+
| bank | per-region | liveness in the SSA thread | collapses? |
512+
|---|---|---|---|
513+
| parameter | 360.8 MB | read-only, shared by EVERY region | YES -> 1x |
514+
| parameter_gradient | 418.5 MB | SSA grad accumulator (read+write per bwd) | YES -> 1x |
515+
| activation | 171.5 MB | forward-flowing (fwd i: act_i -> act_{i+1}) | YES -> ~2 live |
516+
| activation_gradient | 112.0 MB | backward-flowing (bwd i in/out) | YES -> ~2 live |
517+
| **state / checkpoint** | **965.3 MB** | **fwd i SAVES, bwd i READS -> live fwd-i..bwd-i** | **NO -> O(N)** |
518+
519+
**(1) On-device kernel driver.** `make_real_kernel_driver(leaf, tvm.metal(0))` maps the
520+
5 banks to the kernel's leading params, the curried `run_backward` to the gate param,
521+
and zero scratch to the 11 auxiliary route buffers, then invokes `leaf.kernel` (the
522+
tilelang JITKernel) on the device. PROVEN end-to-end on Metal
523+
(`scratch/pr3_real_kernel_driver.py`): the real 17-param MR kernel compiles in ~0.8 s
524+
(168 KB MSL) and runs THROUGH the `call_dps_packed` boundary, computing
525+
**14,680,063 / 14,680,064 nonzero** activation outputs (the kernel genuinely executes,
526+
not the numpy stand-in). This is the external-function boundary executing the real
527+
kernel through the Relax graph -- the proof the task asked for.
528+
529+
### Deliverable (1): MEASURED peak reduction with banks exposed, and how it scales
530+
531+
Real bank numels (5 banks, **1981 MB/region**), assembled as a fwd-then-reverse
532+
`call_dps_packed` SSA chain. Eager all-live = `mx.eval` semantics (every region's bank
533+
outputs live at once). Planned peak = the TRUE concurrent high-water (honest liveness;
534+
see the limitation below). Device: CPU LLVM Relax VM (planning is target-independent).
535+
536+
| layers | eager all-live | planned peak (banks exposed) | reduction |
537+
|---|---|---|---|
538+
| 2 | 3.26 GB | 2.46 GB | **1.32x** |
539+
| 4 | 6.51 GB | 4.76 GB | **1.37x** |
540+
| 8 | 13.03 GB | 8.53 GB | **1.53x** |
541+
| 16 | 26.05 GB | 16.07 GB | **1.62x** |
542+
| **28 (the 1.8B step's MR blocks)** | **45.59 GB** | **27.38 GB** | **1.67x** |
543+
544+
**The reduction GROWS with depth (1.32x -> 1.67x) -- this is the cross-region
545+
bank-sharing collapse.** The honest signature: a linear fit gives **all-live slope =
546+
1.628 GB/layer, planned-peak slope = 0.951 GB/layer**, and **0.951 GB/layer == the
547+
state/checkpoint bank size (0.943 GB)**. So the planner collapsed EVERYTHING that can
548+
collapse -- the forward-flowing activation banks, the backward-flowing
549+
activation-gradient banks, the read-only parameter bank, and the SSA parameter-gradient
550+
accumulator all fold to a CONSTANT working set (0.68 GB/layer saved) -- and the ONLY
551+
term still growing with depth is the **O(N)-live checkpoint/state bank**.
552+
553+
### The honest bound: bank-sharing alone does NOT close the gap -- the checkpoint bank is the remat target
554+
555+
This is larger than PR-2's 1.80x **on the heavy banks** (PR-2's 1.80x was on the tiny
556+
logical outputs only; PR-3 moves the real 45M/253M-f32 banks), but it is **bounded at
557+
~1.67x by the O(N) checkpoint term**, NOT a runaway collapse. That is the real,
558+
load-bearing finding: cross-region liveness reuse collapses every bank EXCEPT the
559+
saved-activation/state checkpoint, because checkpoint i is irreducibly live from fwd-i
560+
to bwd-i. **Liveness planning cannot beat the checkpoint term; only rematerialization
561+
(lever 4) can.** Projected with sqrt(N) gradient checkpointing on the state bank:
562+
563+
| layers | eager all-live | banks-planned | + sqrt(N) remat |
564+
|---|---|---|---|
565+
| 8 | 13.03 GB | 8.53 GB | **4.14 GB** |
566+
| 16 | 26.05 GB | 16.07 GB | **5.09 GB** |
567+
| 28 | 45.59 GB | 27.38 GB | **6.97 GB** |
568+
569+
So the projection to the 1.8B step's 28 MR blocks: the **activation+state+grad working
570+
set** lands at ~27 GB with banks exposed, and ~7 GB with sqrt(N) remat on top. This is
571+
the activation/grad term ONLY (parsed from the quarter-profile region banks); the FULL
572+
118 GB eager figure additionally includes the Adam optimizer state (m, v = 2x params)
573+
at the real bs=4xseq=4096 scale. **Closing-the-gap math, re-grounded on the MEASURED
574+
bank slopes:** the bank-exposed planner takes the activation/grad term from O(N)-all-live
575+
(1.628 GB/layer) to O(N)-checkpoint (0.951 GB/layer) -> sqrt(N) remat takes the
576+
checkpoint term to O(sqrt N) -> an in-place Adam op (lever 5) takes the optimizer-state
577+
term in place. The remat step is the one that turns the O(N) checkpoint into the small
578+
residual; **bank-exposure + remat + in-place optimizer is what projects the eager
579+
118 GB toward the Megatron-class ~26-40 GB target** -- and the measured bank slopes show
580+
bank-exposure alone gets the activation/grad working set to ~27 GB at 28 blocks, with
581+
remat pushing the checkpoint residual to single-digit GB.
582+
583+
### Deliverable (2): numeric equivalence vs the per-region-internal-bank version
584+
585+
The bank-as-SSA-tensor assembly produces the SAME result as PR-2's per-region-internal
586+
version -- the pack/unpack is just relocated from inside each packed func to Relax tensor
587+
boundaries. VERIFIED on the LLVM VM at a ratio-preserving downscale (4 and 8 layers):
588+
the planned VM output (`actg`, `paramg`) matches an independent numpy reference of the
589+
identical SSA dataflow, **max abs diff 0.0**, at both the `/20000` and a stress `/2000`
590+
downscale (8 layers). RULE #1: any mismatch RAISES.
591+
592+
### KEY PR-3 FINDING -- a Relax limitation that BOUNDS the graph path (reported, not papered over)
593+
594+
`StaticPlanBlockMemory` **cannot see THROUGH the `call_dps_packed` external boundary**.
595+
Because the packed func is opaque, the planner does NOT know it WRITES its trailing
596+
bank-output tensors, so it emits a `kill_storage` for each such storage IMMEDIATELY
597+
after `alloc` (dead-on-arrival). Two consequences, both measured
598+
(`scratch/pr3_inspect_planned_ir.py`):
599+
600+
1. **The plan stays CORRECT** -- each checkpoint nonetheless keeps a DISTINCT storage
601+
token (the killed storage is never reused for a conflicting tensor), so numerics are
602+
exact (verified above). The premature kill is benign for correctness.
603+
2. **But the PoC's `planned_peak_bytes` analyzer UNDER-counts** -- it honours the
604+
premature kills and reports a falsely-low peak (e.g. 1.52 GB flat where the true
605+
peak is 8.53 GB at 8 layers). PR-3 therefore ships a corrected `true_planned_peak`
606+
analyzer: a storage is live from its alloc until the LAST textual use of any tensor
607+
viewing it (call_packed args count as uses). The table above uses the honest figure.
608+
609+
The bound this sets: the **real path_c kernel can ONLY be `call_dps_packed`** (PR-2
610+
mismatch #3 -- the TileLang guarded-sync body never lowers under generic s_tir), so the
611+
planner is ALWAYS blind to the in-place bank writes behind it. It can still co-plan the
612+
bank *tensors* (their alloc/last-use liveness IS visible), which is what delivers the
613+
1.67x collapse -- but it cannot do *in-place* bank aliasing across the external call
614+
(the SSA input and output bank are distinct Relax tensors, so a true in-place update is
615+
not planned; this matches the doc's "no in-place planning by default" risk and is the
616+
same reason the in-place Adam op (lever 5) must be an explicit op, not a planner freebie).
617+
A `call_tir` (planner-transparent DPS) variant of the same bank chain confirms the
618+
planner tracks the checkpoint liveness identically when the boundary is NOT opaque
619+
(`scratch/pr3_call_tir_banks.py`), so the limitation is specifically the
620+
external-function opacity, which the real kernel cannot avoid.
621+
622+
### PR 3 evidence scripts (all pass, 2026-06-02)
623+
624+
* `cppmega_mlx/runtime/path_c_relax_step_banks.py` -- the bank-SSA assembly, numeric
625+
validation (max abs diff 0.0), full-scale peak table, and remat projection.
626+
* `scratch/pr3_real_kernel_driver.py` -- the REAL MR JITKernel runs on Metal through the
627+
`call_dps_packed` boundary (14.68M nonzero activation outputs).
628+
* `scratch/pr3_dump_banks.py` -- the real 5-bank sizes + per-bank logical-tensor layout.
629+
* `scratch/pr3_inspect_planned_ir.py` -- shows the premature `kill_storage` of
630+
externally-written bank outputs (the limitation) and that distinct storages are kept.
631+
* `scratch/pr3_call_tir_banks.py` -- the same bank chain with planner-transparent
632+
`call_tir` leaves, isolating the opacity as the cause.
633+
* `scratch/pr3_true_peak.py` / `scratch/pr3_decompose_peak.py` -- the honest peak slope
634+
(== state-bank/layer) and the per-bank-category decomposition.
635+
636+
### Remaining to the full 1.8B step graph
637+
638+
1. **Rematerialization on the checkpoint bank** (the measured O(N) term) -- manual
639+
`relax.grad.start_checkpoint`/`end_checkpoint` per MR block (no auto-selector in this
640+
TVM). This is now the SINGLE highest-leverage remaining step: it is the only lever
641+
that beats the checkpoint term the bank-exposure measurement proved is irreducible
642+
under liveness.
643+
2. **`Gradient` over the WHOLE assembled step** -- validate the `call_dps_packed` bank
644+
leaves against the single-block `Gradient` ICHECK (`gradient.cc:680`) before promising
645+
the optimizer co-plan.
646+
3. **In-place Adam/SGD op** (or ZeRO-1) -- because the planner does NOT do in-place across
647+
the external boundary (the finding above), the optimizer in-place update must be an
648+
explicit op, attacking the optimizer-state term.
649+
4. **CUDA on gb10** -- the Metal driver is proven; re-run `make_real_kernel_driver` with
650+
`tvm.cuda(0)` under the single-run gb10 discipline (poll IDLE + free>105G, SIGTERM,
651+
fuser + drop_caches) to confirm the same boundary on the production device.

scratch/pr3_baselines.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""PR-3: print all four baselines (all-live sum, planned working-set sum, strict
2+
concurrent peak, planned concurrent peak) for the bank-SSA chain at increasing depth,
3+
so we can see which baseline the bank-exposure collapse actually moves and by how
4+
much it scales with #layers."""
5+
from __future__ import annotations
6+
from cppmega_mlx.runtime.path_c_relax_step_banks import real_bank_numels, build_bank_chain
7+
from cppmega_mlx.runtime.relax_memory_plan_poc import (
8+
_legalize_to_call_tir, _plan_and_lower, _sum_alloc_bytes, _sum_storage_bytes,
9+
eager_peak_bytes, planned_peak_bytes,
10+
)
11+
12+
numels = real_bank_numels()
13+
gb = 1024 ** 3
14+
hdr = f"{'L':>3} {'all-live GB':>14} {'planned-WS GB':>14} {'strict-peak GB':>15} {'planned-peak GB':>16}"
15+
print(hdr)
16+
for nl in (1, 2, 4, 8, 16, 28):
17+
mod = build_bank_chain(numels, nl)
18+
ct = _legalize_to_call_tir(mod)
19+
pl = _plan_and_lower(ct)
20+
al = _sum_alloc_bytes(ct["train_step"])
21+
ws = _sum_storage_bytes(pl["train_step"])
22+
sp = eager_peak_bytes(ct["train_step"])
23+
pp = planned_peak_bytes(pl["train_step"])
24+
print(f"{nl:>3} {al/gb:>14.2f} {ws/gb:>14.2f} {sp/gb:>15.2f} {pp/gb:>16.2f}")

0 commit comments

Comments
 (0)