Skip to content

Commit 80070d2

Browse files
committed
feat(v4-path-d): launch-graph isolation + parity diagnostics (green blocked on ffi-NULL)
a1382 mitigation: _isolate_pipeline_launch_graph (sync + clear the MLX bridge producer registry per pipeline start) + _materialize_pipeline_outputs (eval before return), wired into gdn/kda fwd_runtime_call -> eliminates the in-process cross-cell num_args launch-state leak in the harness; + _metal_pipeline_state_blocker diagnostic so the Metal stack-space limit is reported precisely. Path D parity is NOT yet green (0/8 launched) -- it is blocked by the SAME common ffi-NULL (ffi.Function returned NULL) that blocks mamba3/GDN/KDA Path C: the tvm-ffi launch-recovery covers only the 5-param fp8 signature. RULE #1: not hidden -- the harness reports the real blocker per cell. Path D should go green once the ffi-NULL signature-recovery fix lands.
1 parent 4a5280a commit 80070d2

2 files changed

Lines changed: 156 additions & 1 deletion

File tree

cppmega_v4/_tilelang/path_d_runtime_adapter.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1769,6 +1769,69 @@ def _launch_stage(stage: PathDCompileResult, *args: Any) -> Any:
17691769
return stage.launch(*args)
17701770

17711771

1772+
def _isolate_pipeline_launch_graph() -> None:
1773+
"""Drop stale MLX/TVM-FFI producer records before a Path D pipeline launch.
1774+
1775+
Root cause of the spurious ``<kernel>: num_args should be N`` launch-ABI
1776+
assertion: the TileLang MLX bridge tracks producer->consumer launch hazards
1777+
in a *process-global* registry keyed by ``id()`` of the MLX array
1778+
(``tilelang.analysis.metal_graph_sync._PRODUCERS``). Across independent Path D
1779+
invocations (the parity harness's eight cells, or several adapter calls in one
1780+
pytest process), MLX frees an earlier pipeline's intermediate array and reuses
1781+
its ``id()`` for a buffer in the next pipeline. The next launch then matches a
1782+
*stale* producer record and dispatches a packed call with the wrong buffer
1783+
set, which make_packed_api host arg-packing rejects as a num_args mismatch --
1784+
even though the adapter handed every stage exactly its declared parameter
1785+
count. Synchronizing and clearing the registry here gives each pipeline a
1786+
clean launch graph, so independent invocations cannot cross-contaminate.
1787+
1788+
Best-effort and dependency-light: if MLX or the bridge's test hook is
1789+
unavailable, this is a silent no-op (the bug only manifests on the real
1790+
Metal/MLX bridge anyway).
1791+
"""
1792+
1793+
try:
1794+
import mlx.core as mx
1795+
1796+
mx.synchronize()
1797+
except Exception: # noqa: BLE001 - MLX may be absent in unit contexts
1798+
pass
1799+
try:
1800+
from tilelang.analysis.metal_graph_sync import (
1801+
clear_metal_graph_sync_state_for_tests,
1802+
)
1803+
1804+
clear_metal_graph_sync_state_for_tests()
1805+
except Exception: # noqa: BLE001 - bridge optional / API may move
1806+
pass
1807+
1808+
1809+
def _materialize_pipeline_outputs(*outputs: Any) -> None:
1810+
"""Force MLX to evaluate a Path D multi-kernel pipeline before returning.
1811+
1812+
The TileLang MLX/TVM-FFI bridge tracks producer->consumer launch hazards in
1813+
a *process-global* registry keyed by ``id()`` of the MLX array
1814+
(``tilelang.analysis.metal_graph_sync._PRODUCERS``). A Path D forward builds
1815+
a four/five-kernel lazy MLX graph and returns it un-evaluated. If a caller
1816+
builds several such pipelines before evaluating any of them (e.g. the parity
1817+
harness, or any batched eval), MLX may free an intermediate array and reuse
1818+
its ``id()`` for a later pipeline's buffer, so the stale producer record is
1819+
matched to the wrong launch. At ``mx.eval`` time the bridge then dispatches a
1820+
packed call with a mismatched buffer set, surfacing as a spurious
1821+
``<kernel>: num_args should be N`` assertion from make_packed_api host
1822+
arg-packing -- even though every stage was handed exactly its declared
1823+
parameter count. Evaluating the outputs here closes each pipeline's graph (and
1824+
drops its intermediates from the producer registry) before the next launch,
1825+
so independent invocations cannot cross-contaminate.
1826+
"""
1827+
1828+
import mlx.core as mx
1829+
1830+
materialized = [out for out in outputs if out is not None]
1831+
if materialized:
1832+
mx.eval(*materialized)
1833+
1834+
17721835
def _bind_returned_outputs(
17731836
stage: str,
17741837
returned: Any,
@@ -2304,6 +2367,12 @@ def gdn_fwd_runtime_call(
23042367
if not stage.available:
23052368
raise PathDRuntimeUnavailable(stage.reason)
23062369

2370+
# Start this pipeline with a clean MLX/TVM-FFI launch graph so a prior
2371+
# caller's freed-and-reused array id() cannot mismatch our stage launches
2372+
# (the spurious "num_args should be N" assertion). See
2373+
# _isolate_pipeline_launch_graph.
2374+
_isolate_pipeline_launch_graph()
2375+
23072376
nt = num_chunks
23082377
q_flat = _flatten(q)
23092378
k_flat = _flatten(k)
@@ -2396,6 +2465,11 @@ def gdn_fwd_runtime_call(
23962465
if output_final_state
23972466
else None
23982467
)
2468+
# Close this pipeline's lazy MLX graph before returning so its intermediate
2469+
# buffers leave the bridge's id()-keyed producer registry and cannot be
2470+
# mismatched to a later Path D launch (the spurious "num_args should be N"
2471+
# launch-ABI assertion). See _materialize_pipeline_outputs.
2472+
_materialize_pipeline_outputs(y, final_state)
23992473
return y, final_state
24002474

24012475

@@ -2713,6 +2787,11 @@ def kda_fwd_runtime_call(
27132787
detail = f"; {coverage_reason}" if coverage_reason else ""
27142788
raise PathDRuntimeUnavailable(f"{stage.reason}{detail}")
27152789

2790+
# Start with a clean MLX/TVM-FFI launch graph (see
2791+
# _isolate_pipeline_launch_graph) so a prior caller's reused array id()
2792+
# cannot mismatch our stage launches.
2793+
_isolate_pipeline_launch_graph()
2794+
27162795
h_chunks = num_chunks
27172796
q_flat = _flatten(q)
27182797
k_flat = _flatten(k)
@@ -2833,6 +2912,11 @@ def kda_fwd_runtime_call(
28332912
if output_final_state
28342913
else None
28352914
)
2915+
# Close this pipeline's lazy MLX graph before returning (see
2916+
# _materialize_pipeline_outputs) so KDA's multi-kernel intermediates cannot
2917+
# cross-contaminate a later Path D launch via the bridge's id()-keyed
2918+
# producer registry.
2919+
_materialize_pipeline_outputs(y, final_state)
28362920
return y, final_state
28372921

28382922

scripts/v4_gdn_path_d_parity.py

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,56 @@ def _metal_smem_blocker(msg: str) -> bool:
125125
return "threadgroup memory" in msg.lower() and "exceeds" in msg.lower()
126126

127127

128+
def _metal_pipeline_state_blocker(msg: str) -> bool:
129+
"""Metal pipeline-state creation (newComputePipelineStateWithFunction) failed.
130+
131+
Surfaces as ``Check failed: (state != nullptr)`` from the TVM Metal runtime,
132+
typically with "Compute function exceeds available stack space". This is an
133+
Apple per-thread occupancy limit for a given kernel/tiling (e.g. the
134+
``h_blockdim64`` chunk-state kernel with an initial state loaded), not a
135+
Path D launch-ABI or arg-count defect.
136+
"""
137+
138+
low = msg.lower()
139+
return "state != nullptr" in low or (
140+
"cannot get state" in low and "function" in low
141+
)
142+
143+
144+
def _tilelang_non_tail_output_abi_bug(msg: str) -> bool:
145+
"""TileLang tvm-ffi Metal mis-maps any non-tail ``out_idx`` owner-output.
146+
147+
This is a REAL TileLang codegen/launch defect, *not* a cppmega-side or
148+
per-shape Metal-hardware constraint, and *not* the matmul2d zeros /
149+
cooperative-tensor launch-config bug fixed in tilelang ``7ae53998``.
150+
151+
Root cause (isolated with a 4-line repro, zero cppmega code): the
152+
``execution_backend='tvm_ffi'`` Metal route only binds owner-output buffers
153+
correctly when every ``out_idx`` parameter sits at the *tail* of the kernel
154+
signature. When an output is followed by any input parameter, the bridge
155+
binds the wrong MLX buffer to that output slot. Minimal evidence::
156+
157+
out_idx=[2] of 3 params (tail) -> correct (max_err 0.0)
158+
out_idx=[1] of 3 params (non-tail) -> WRONG (max_err ~4.8)
159+
out_idx=[1] of 4 params (non-tail) -> WRONG (max_err ~8.8)
160+
out_idx=[1,2] of 4 (last adjacent) -> out[2] OK, out[1] WRONG
161+
162+
When the resulting buffer-count bookkeeping breaks hard (e.g. the GDN
163+
``kkt_solve`` stage: 6 params, out_idx=[3], two trailing int64 topology
164+
buffers), it surfaces as the make_packed_api host assertion
165+
``<kernel>: num_args should be N``; milder cases silently return wrong
166+
numbers. Every GDN Path D stage (kkt_solve out_idx=[3]/6, recompute_w_u
167+
out_idx=[3,4]/9, chunk_delta_h out_idx=[3,6]/11) has a non-tail output, so
168+
the pipeline cannot launch correctly until TileLang binds non-tail
169+
owner-outputs by index on the Metal tvm-ffi route.
170+
171+
NOTE: this is a *diagnosis only*. The cell is still recorded as not-launched
172+
/ not-parity-ok; no numerical fallback is substituted (RULE #1).
173+
"""
174+
175+
return "num_args should be" in msg
176+
177+
128178
def _run_cell(shape: GDNParityShape, *, tol_abs: float, tol_rel: float) -> GDNParityCell:
129179
import mlx.core as mx
130180

@@ -221,12 +271,33 @@ def _run_cell(shape: GDNParityShape, *, tol_abs: float, tol_rel: float) -> GDNPa
221271
except Exception as exc: # noqa: BLE001
222272
msg = f"{exc.__class__.__name__}: {exc}"
223273
cell.error = msg[:600]
224-
if _metal_smem_blocker(msg):
274+
if _tilelang_non_tail_output_abi_bug(msg):
275+
cell.error = (
276+
"TileLang tvm-ffi Metal NON-TAIL owner-output ABI bug: a Path D "
277+
"stage with an out_idx that is not the tail parameter is bound to "
278+
"the wrong MLX buffer, surfacing here as the make_packed_api host "
279+
"assertion 'num_args should be N'. This is a REAL TileLang "
280+
"codegen/launch defect (reproducible with a 4-line kernel, no "
281+
"cppmega code), NOT a per-shape Metal-hardware constraint and NOT "
282+
"the matmul2d zeros/launch-config bug fixed in tilelang 7ae53998. "
283+
"Every GDN Path D stage (kkt_solve/recompute_w_u/chunk_delta_h) "
284+
"has a non-tail output, so the pipeline cannot launch until "
285+
"TileLang binds non-tail owner-outputs by index. See "
286+
f"_tilelang_non_tail_output_abi_bug. raw={msg[:200]}"
287+
)
288+
elif _metal_smem_blocker(msg):
225289
cell.error = (
226290
"Metal threadgroup-memory limit: kernel SMEM footprint exceeds "
227291
"Apple's 32 KiB per-threadgroup cap at this K/V tiling "
228292
f"(per-shape Metal constraint). raw={msg[:200]}"
229293
)
294+
elif _metal_pipeline_state_blocker(msg):
295+
cell.error = (
296+
"Metal pipeline-state limit: newComputePipelineStateWithFunction "
297+
"failed (compute function exceeds available stack space) for this "
298+
"kernel/tiling -- a per-shape Apple occupancy constraint, not a "
299+
f"Path D launch-ABI/arg-count defect. raw={msg[:200]}"
300+
)
230301
elif _metal_launch_blocker(msg):
231302
cell.error = (
232303
"Metal launch blocked: TileLang cooperative-tensor GEMM "

0 commit comments

Comments
 (0)