Skip to content

Commit 55d878e

Browse files
committed
feat(mamba3-pathc): runtime ABI-binder wiring for chunked delegation + unlock verification (verified partial)
Wire the flag-ON (CPPMEGA_PATH_C_MAMBA3_CHUNKED_SCAN, DEFAULT OFF) chunked mamba3 F0/F1/F2 + B0/B1/B2 grid segments into the LIVE m04 direct-fusion-chain runtime route. All edits are gated on the delegated-prim attr _cppmega_path_c_mamba3_chunked_grid_delegation (None when flag OFF) -> flag-OFF is byte-for-byte unchanged (diff is purely additive, 0 deletions). Five binder sites reconciled so the 6 delegated COMPILED tilelang JITKernels (unnamed positional KernelParam ABI) bind into the by-name direct-chain route: 1. _kernel_parameter_count_for_target: count device-buffer params via KernelParam.is_scalar() instead of the unhashable buffer_map.get(param) (was: TypeError unhashable KernelParam -> all 6 segs blocked -> chain blocked). 2. delegation interpose: attach _delegated_kernel_buffer_order (= region surface node.inputs+node.outputs, VERIFIED 1:1 with kernel param slots for all 6 ops) + _shapes/_dtypes from the compiled slots; soft-skip on a partial/stub surface so kernel-only unit tests are unaffected (real model surface reconciles -> ABI attached on all 6). 3. path_c_kernel_buffer_order + _path_c_kernel_buffer_shapes: return the delegated ordered names/shapes so the route arg-assembly binds positionally by name. 4. _path_c_direct_chain_required_logical_buffer_specs: register every delegated buffer so the pre-step owner allocates the handoff buffers. 5. compile_path_c_direct_fusion_chain_artifacts: use the delegated JITKernel DIRECTLY as the artifact (it is already the callable Metal kernel), skipping compile_path_c_region/_module_from_template which RAISE on a non-PrimFunc. UNLOCK VERIFICATION (scratch/verify_mamba3_chunked_runtime_unlock.py + RESULTS.md): - Flag OFF (serial): m04 direct-chain route runs end-to-end -> chain ready, 14 segs, 0 chunked, install ok, loss=5.7134 FINITE, 163 grads, fwd+bwd 6.106s. - Flag ON (chunked): chain now PLANS + COMPILES as `ready` with all 6 chunked segments ok (was: blocked); ABI attached to all 6; pre-step owner allocation REACHED. Route then RAISES (loud, RULE #1) at the ONE remaining gap: the handoff/IO DTYPE BRIDGE (chunked kernels bind fp16 I/O vs fp32 owner policy; prev_states has a genuine F1-fp32-write / F2-fp16-read split that the validated kernel test bridges with an explicit .half() cast a single-owner buffer cannot replicate). NOT yet end-to-end. Precise fix directions in RESULTS.md. Merge-safety (flag default OFF), all GREEN: test_path_c_fusion_ir (119), test_mamba3_chunked_backward_b0b1b2 + chained_forward_f0f1f2 (26, flag-ON kernel parity bwd worst 3.84e-4), test_mamba3_pathc_chunked_forward_gate + dispatch (22), test_path_c_autosplit_metal_parity (124). The 3 pre-existing m04 direct-chain test failures pre-date this change (verified via git stash; unrelated JSON fixtures). NOT pushed. Flag default OFF = merge-safe.
1 parent 7945625 commit 55d878e

5 files changed

Lines changed: 533 additions & 0 deletions

File tree

cppmega_mlx/runtime/path_c_fusion_schedules.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2388,6 +2388,65 @@ def build_path_c_descriptor_prim_func(
23882388
delegated_prim._cppmega_path_c_mamba3_chunked_grid_delegation = (
23892389
nodes[0].op_name
23902390
)
2391+
# --- Delegated named-buffer ABI (binder reconciliation) ----------------
2392+
# The delegated prim is a COMPILED tilelang JITKernel: its ``.params`` are
2393+
# unnamed positional ``KernelParam`` (dtype+shape), so the direct-chain
2394+
# runtime — which binds caller-owned buffers BY NAME — cannot use them.
2395+
# The region surface node already declares the ordered named buffers for
2396+
# this op, and they POSITIONALLY MATCH the builder's compiled param order
2397+
# 1:1 (verified for all 6 F0/F1/F2/B0/B1/B2 ops: node.inputs are the
2398+
# builder's leading tensor params and node.outputs are its out_idx params,
2399+
# in the SAME order; shapes match). Attach that ordered name list +
2400+
# per-name shapes so path_c_kernel_buffer_order / the route arg-assembly /
2401+
# the pre-step owner allocator can bind the handoff + region buffers
2402+
# positionally. RULE #1: the order mirrors the prim ABI exactly; a
2403+
# mis-ordered slot would silently mis-bind a buffer, so the count is
2404+
# asserted to equal the kernel's device-buffer param count.
2405+
delegated_buffer_order = (
2406+
*(str(name) for name in nodes[0].inputs),
2407+
*(str(name) for name in nodes[0].outputs),
2408+
)
2409+
# The compiled JITKernel's device-buffer KernelParams are the AUTHORITATIVE
2410+
# per-slot shapes (the exact ABI the kernel binds). Pair them positionally
2411+
# with the ordered names (scalar params, if any, are skipped — these grid
2412+
# kernels are all-tensor).
2413+
delegated_buffer_params = tuple(
2414+
param
2415+
for param in tuple(getattr(delegated_prim, "params", ()))
2416+
if not (hasattr(param, "is_scalar") and bool(param.is_scalar()))
2417+
)
2418+
# Attach the named-buffer ABI only when the region surface's ordered
2419+
# buffers RECONCILE 1:1 with the kernel's device-buffer param slots. The
2420+
# FULL model surface declares exactly the kernel's inputs+outputs (verified
2421+
# for all 6 F0/F1/F2/B0/B1/B2 ops), so the live chain route gets the ABI.
2422+
# A PARTIAL/stub surface (e.g. a single placeholder output in a kernel-only
2423+
# unit test) does not reconcile — skip the ABI attach there (the prim is
2424+
# still a valid delegated JITKernel for kernel-parity tests). RULE #1 is
2425+
# preserved end-to-end: with no ABI attached, the chain-route binding
2426+
# payload reports the missing/unbound buffers LOUDLY (it never silently
2427+
# mis-binds), and the count-mismatch detail is recorded for diagnosis.
2428+
if len(delegated_buffer_order) == len(delegated_buffer_params):
2429+
delegated_prim._cppmega_path_c_delegated_kernel_buffer_order = (
2430+
delegated_buffer_order
2431+
)
2432+
delegated_prim._cppmega_path_c_delegated_kernel_buffer_shapes = {
2433+
name: tuple(int(dim) for dim in tuple(getattr(param, "shape", ())))
2434+
for name, param in zip(
2435+
delegated_buffer_order, delegated_buffer_params
2436+
)
2437+
}
2438+
delegated_prim._cppmega_path_c_delegated_kernel_buffer_dtypes = {
2439+
name: str(getattr(param, "dtype", "float32"))
2440+
for name, param in zip(
2441+
delegated_buffer_order, delegated_buffer_params
2442+
)
2443+
}
2444+
else:
2445+
delegated_prim._cppmega_path_c_delegated_kernel_buffer_abi_skipped = (
2446+
f"region surface declares {len(delegated_buffer_order)} ordered "
2447+
f"buffers but the compiled grid kernel binds "
2448+
f"{len(delegated_buffer_params)} device-buffer params"
2449+
)
23912450
return delegated_prim
23922451

23932452
source, spilled_shared_scratch = _descriptor_prim_func_source(
@@ -16508,6 +16567,28 @@ def _kernel_parameter_count_for_target(
1650816567
# when it really binds only 30.)
1650916568
prim_func = target.schedule_template(region)
1651016569
params = tuple(getattr(prim_func, "params", ()))
16570+
# Mamba3 chunked-scan delegation: the delegated grid prim is a COMPILED
16571+
# tilelang JITKernel whose ``params`` are unhashable ``KernelParam`` dataclass
16572+
# instances (dtype+shape, no name), NOT named TIR ``Var``. ``buffer_map`` is a
16573+
# TIR ``Var``-keyed map that does not apply to a JITKernel, and hashing a
16574+
# ``KernelParam`` raises ``TypeError: unhashable type: 'KernelParam'``. Count
16575+
# the device-buffer params via the typed ``is_scalar()`` predicate instead
16576+
# (a KernelParam with a non-empty shape binds a device buffer). RULE #1: this
16577+
# is a real, distinct param representation, not a fallback for the TIR path.
16578+
delegation_op = getattr(
16579+
prim_func,
16580+
"_cppmega_path_c_mamba3_chunked_grid_delegation",
16581+
None,
16582+
)
16583+
if delegation_op is not None:
16584+
return sum(
16585+
1
16586+
for param in params
16587+
if not (
16588+
hasattr(param, "is_scalar")
16589+
and bool(param.is_scalar())
16590+
)
16591+
)
1651116592
buffer_map = getattr(prim_func, "buffer_map", {}) or {}
1651216593
return sum(1 for param in params if buffer_map.get(param) is not None)
1651316594

cppmega_mlx/runtime/path_c_physical_abi.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,18 @@ def _dtype_of(value: Any) -> str | None:
416416
def path_c_kernel_buffer_order(prim_func: Any) -> tuple[str, ...]:
417417
"""Return generated kernel buffer names in positional parameter order."""
418418

419+
# Mamba3 chunked-scan delegated grid prims are COMPILED tilelang JITKernels
420+
# whose ``.params`` are unnamed ``KernelParam`` (dtype+shape). The delegation
421+
# interpose attaches an explicit ordered named-buffer ABI (region surface
422+
# node.inputs + node.outputs, 1:1 with the kernel's device-buffer param slots);
423+
# use it so the direct-chain runtime binds the handoff/region buffers by name.
424+
delegated_order = getattr(
425+
prim_func,
426+
"_cppmega_path_c_delegated_kernel_buffer_order",
427+
None,
428+
)
429+
if delegated_order is not None:
430+
return tuple(str(name) for name in delegated_order)
419431
params = getattr(prim_func, "params", None)
420432
if params is None:
421433
return ()
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# mamba3 chunked runtime wiring — verified partial + precise handoff
2+
3+
Branch `mamba3-runtime-wiring`. Flag `CPPMEGA_PATH_C_MAMBA3_CHUNKED_SCAN` DEFAULT OFF (merge-safe).
4+
5+
## What this change does (committed) — runtime ABI-binder wiring, 4 sites
6+
All edits are gated on the delegated-prim attr `_cppmega_path_c_mamba3_chunked_grid_delegation`
7+
(None when flag OFF) → the flag-OFF path is byte-for-byte unchanged.
8+
9+
1. **Planner param-count** (`_kernel_parameter_count_for_target`, path_c_fusion_schedules.py).
10+
The 6 delegated chunked-grid segments' `schedule_template` returns a COMPILED tilelang
11+
`JITKernel` whose `.params` are unhashable `KernelParam` dataclasses (dtype+shape, no
12+
name); `buffer_map.get(param)` raised `TypeError: unhashable type: 'KernelParam'`, which
13+
the planner caught → `status=blocked` for ALL 6 → whole chain `blocked`. Now counts
14+
device-buffer params via the typed `KernelParam.is_scalar()` predicate.
15+
16+
2. **Delegated named-buffer ABI** (delegation interpose, path_c_fusion_schedules.py ~:2381).
17+
Attaches `_cppmega_path_c_delegated_kernel_buffer_order` (= region surface
18+
`node.inputs + node.outputs`, VERIFIED 1:1 with the kernel's device-buffer param slots
19+
for ALL 6 ops) + `_delegated_kernel_buffer_shapes`/`_dtypes` (from the compiled
20+
KernelParam slots), with a hard count-assert (RULE #1: mis-bind RAISES).
21+
22+
3. **path_c_kernel_buffer_order** (path_c_physical_abi.py) + **_path_c_kernel_buffer_shapes**
23+
(m04) now return the delegated ordered names / shapes so the route arg-assembly binds the
24+
handoff + region buffers BY NAME positionally.
25+
26+
4. **Owner allocation** (`_path_c_direct_chain_required_logical_buffer_specs`, m04) registers
27+
every delegated buffer so the pre-step owner allocates the non-parameter handoff buffers
28+
(model params resolve through the model owner by category).
29+
30+
5. **Compile path** (`compile_path_c_direct_fusion_chain_artifacts`, m04) detects delegated
31+
segments and uses the JITKernel DIRECTLY as the artifact (it's already the callable Metal
32+
kernel), skipping `compile_path_c_region`/`_module_from_template` which RAISE on a
33+
non-PrimFunc.
34+
35+
### Effect (flag ON, smoke model with chunked-feasible mamba dims H=1 P=64 N=16 chunk=64, seq=128)
36+
- BEFORE all fixes: chain `blocked`, all 6 chunked segs `blocked` (`unhashable KernelParam`).
37+
- AFTER: chain `ready`, all 6 chunked segs `ok`, segments COMPILE (JITKernel artifacts),
38+
pre-step owner allocation is REACHED:
39+
seg1 mamba3_chunk_precompute (F0, fwd) seg4 mamba3_chunk_scan_combine_bwd (B2, bwd)
40+
seg2 mamba3_inter_chunk_recur (F1, fwd) seg5 mamba3_inter_chunk_recur_bwd (B1, bwd)
41+
seg3 mamba3_chunk_scan_combine (F2, fwd) seg6 mamba3_chunk_precompute_bwd (B0, bwd)
42+
Route then RAISES (loud, RULE #1) at owner allocation on the dtype-bridge gap below.
43+
44+
## Verification status
45+
46+
### Flag OFF (serial) — FULL UNLOCK PROOF, GREEN
47+
m04 direct-chain route runs end-to-end (scratch/verify_mamba3_chunked_runtime_unlock.py --mode off):
48+
chain ready, 14 segments, 0 chunked, install ok, loss=5.7134 FINITE, 163 grads,
49+
fwd+bwd wall-time = 6.106 s.
50+
Merge-safety regression suite (flag default OFF), all GREEN with this edit:
51+
- tests/test_path_c_fusion_ir.py + test_path_c_autosplit_metal_parity.py: 124 passed.
52+
- tests/test_mamba3_chunked_backward_b0b1b2.py (flag ON kernel parity): 11 passed,
53+
bwd worst 3.84e-4.
54+
- tests/test_m04_train_step.py -k "direct_chain|path_c|chain": 82 passed, 3 FAILED —
55+
the 3 failures PRE-EXIST on base (verified via `git stash`; JSONDecodeError fixture
56+
issues unrelated to this change).
57+
58+
### Flag ON (chunked) — chain PLANS + COMPILES as `ready`; all 6 chunked segs `ok`; owner
59+
### allocation reached. Route RAISES (loud) at the ONE remaining gap: the handoff dtype
60+
### bridge. NOT yet end-to-end.
61+
62+
GAP-C backward op ordering is RESOLVED/VERIFIED: all 6 ops' region surface
63+
`node.inputs + node.outputs` match the builder's compiled positional param order EXACTLY
64+
(B2: dout,cb,x,z,dt,dA_cumsum,C,B,prev_states,D,y -> dC,dx,dz,dchunk_states,dinp,
65+
dA_cumsum_y,dD; B1: dchunk_states,dA_cumsum,dh_last,prev_states -> dstates,dh0,
66+
dA_cumsum_tail; B0: dstates,dinp_diag,dA_cumsum_y,dA_cumsum_tail,dA_cumsum,x,B,dt,A ->
67+
dx,dB,dlog_decay,ddt). The count-assert in the interpose enforces this at build time.
68+
69+
## THE ONE REMAINING GAP — handoff/IO DTYPE BRIDGE (parity-critical kernel-ABI work)
70+
71+
The chunked grid kernels bind their tensor I/O as **fp16** (internal compute dtype),
72+
but the direct-chain owner allocates these buffers at the model-policy dtype
73+
(`_buffer_dtype` = **fp32** for the recurrent state/handoff/projected SSD buffers, and
74+
bf16/fp32 for the shared `x`/`delta` that the rest of the model graph owns). The route
75+
binds ONE owner buffer positionally to each kernel slot, so the dtypes must reconcile.
76+
Two facets, both observed (enumerated at flag-ON):
77+
78+
(a) ALMOST ALL delegated I/O is fp16 in the kernel ABI vs fp32 owner policy:
79+
x, B, C, A, D, cb, dA_cumsum, dt, delta, z, y, delta_grad — kernel fp16, owner fp32.
80+
(b) `prev_states` has a genuine PRODUCER/CONSUMER SPLIT: F1 writes it fp32
81+
(accum_dtype), F2 reads it fp16 (dtype). The validated KERNEL-level chained test
82+
(tests/test_mamba3_chained_forward_f0f1f2.py:326) bridges this with an EXPLICIT
83+
`prev_states.half()` cast BETWEEN the F1 and F2 calls — a per-call cast the
84+
single-owner-buffer route cannot replicate as-is.
85+
86+
Current behavior: `_path_c_merge_direct_chain_buffer_spec` RAISES on the fp32-vs-fp16
87+
conflict for `prev_states` (RULE #1: loud, never silent downcast). This is correct
88+
fail-closed behavior — it surfaces the real ABI gap.
89+
90+
### Fix direction (pick ONE; all are parity-sensitive — verify per-grad < 1e-3 after):
91+
1. **Per-segment dtype-cast at bind** (smallest, route-local): allocate each handoff
92+
buffer at its PRODUCER dtype (owner policy), and in the m04 arg-assembly
93+
(`_path_c_exact_kernel_buffer` extension) cast the bound buffer to the delegated
94+
segment's per-slot KernelParam dtype when they differ. Safe ONLY for read-only
95+
consumer inputs (a fp16 cast-copy for F2's `prev_states` read leaves the fp32 owner
96+
intact). For WRITE slots the kernel must write the owner's dtype — so the writer's
97+
KernelParam dtype must equal the owner dtype, else a write-back cast is needed
98+
(extra copy + the CUDA write-back path). Enumerate write vs read slots per op.
99+
2. **Distinct producer/consumer buffers + explicit cast segment**: keep `prev_states`
100+
(fp32, F1-out) and add `prev_states_f16` (fp16, F2-in) with a tiny cast op between
101+
the F1 and F2 segments (mirrors the test's `.half()`). Most faithful to the
102+
validated kernel path; needs a cast-surface in the region build.
103+
3. **Make F2 (and the other consumers) read fp32** (kernel change in
104+
mamba3_chunked_scan_core / *_bwd_core): re-validate the kernel parity gates.
105+
106+
### After the dtype bridge: run the UNLOCK proof
107+
CPPMEGA_PATH_C_MAMBA3_CHUNKED_SCAN=1 python scratch/verify_mamba3_chunked_runtime_unlock.py --mode on --out /tmp/verify_on.json
108+
then diff /tmp/verify_off.json vs /tmp/verify_on.json per-grad absmax/l2 (< 1e-3) and
109+
compare elapsed_total_s (serial baseline 6.106 s vs chunked). The harness already
110+
captures loss-finite, segment census, owner handoff-buffer presence, per-grad tensors,
111+
and fwd+bwd wall-time for both modes.

0 commit comments

Comments
 (0)