@@ -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+
17721835def _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
0 commit comments