Skip to content

Commit ca5fec8

Browse files
committed
refactor: update Path C API to use asynchronous owner-outputs and optimize Metal kernel configuration
1 parent 6691cba commit ca5fec8

7 files changed

Lines changed: 153 additions & 153 deletions

cppmega_mlx/nn/_tilelang/fp8_vecmat_path_c.py

Lines changed: 62 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,12 @@
2121
The production owner-output API is
2222
``fp8_scaled_vecmat_path_c(..., out=existing_array)``: it compiles a tvm-ffi
2323
kernel and passes the caller-owned MLX buffers through DLPack without building
24-
``mx.fast.metal_kernel`` or allocating/casting the output in Python. The
25-
historical no-``out`` API is retired because it could only be implemented by
26-
allocating an output through MLX's direct-MSL wrapper.
24+
``mx.fast.metal_kernel`` or allocating/casting the output in Python. It returns
25+
the MLX graph output that aliases the caller-owned buffer; evaluating that
26+
returned value schedules the write, and the caller-owned ``out`` observes the
27+
same storage afterward. The historical no-``out`` API is retired because it
28+
could only be implemented by allocating an output through MLX's direct-MSL
29+
wrapper.
2730
"""
2831

2932
from __future__ import annotations
@@ -61,16 +64,14 @@
6164
# toggled by the env var ``TILELANG_DISABLE_FP8_DOT4_AUTO``; we don't
6265
# override that env var here.
6366
_FP8_VECMAT_PATH_C_CANDIDATE_PASS_CONFIGS: dict[str, Any] = {
64-
# The current Metal scalar/CSE pipeline can produce an invalid one-element
65-
# SeqStmt around the canonical C[col] write in this reducer. This kernel
66-
# does not need CSE: diagnostic source inspection fuses in the canonical
67-
# packed hot loop, and the owner-output route already emits one
68-
# dot4/simd_sum body. Keep the gate per-kernel rather than disabling CSE
69-
# globally.
70-
"tirx.disable_cse_tir": True,
67+
# MLX native TVM-FFI already performs static ABI shape/dtype/layout checks
68+
# when binding DLTensor views. The generated TVM C-host wrapper otherwise
69+
# repeats that validation on every tiny GEMV launch and dominates runtime.
70+
"tirx.disable_assert": True,
7171
# Z3 idea #4 — discharges ``if (i < N)`` guards the analyzer can prove.
72-
# The M=1 vecmat hot loop has tight static extents so the prover tends
73-
# to succeed; if not, the guard stays.
72+
# The M=1 vecmat hot loop has tight static extents. Leave CSE enabled so
73+
# TileLang hoists the global row and dot4 word index instead of repeating
74+
# grid/thread arithmetic in every FP8 dot4 iteration.
7475
"tl.drop_provable_bound_checks": True,
7576
}
7677

@@ -461,15 +462,10 @@ def fp8_vecmat_reduce(
461462
C[col] = reduced[0] * A_scale[0] * B_scale[col]
462463

463464
elif _uses_fp8_dot4_packed_macro(vec=vec, K=K):
464-
# Fix-1 + Fix-A re-application: ensure the Path C Metal FP8 ops
465-
# (notably ``tirx.metal.fp8_e4m3_dot4``) are registered before we
466-
# parse the macro PrimFunc. Without this we get an opaque FFI
467-
# ``AttributeError`` deep in the lowering pipeline; with it we get
468-
# a clear ``RuntimeError`` naming the missing intrinsic.
469-
# grok design P2: cache the result so we run the registration
470-
# scan once per process instead of on every kernel build. Failure
471-
# still raises (the intrinsic is required for correctness); only
472-
# the *successful* check is cached.
465+
# Keep the diagnostic reducer on the public TileLang intrinsic surface.
466+
# The actual dot4/simd_sum shape is now selected by TileLang's
467+
# FP8 marker late-lowerer rather than by spelling Metal intrinsics in
468+
# cppmega's Python IR builder.
473469
_ensure_path_c_metal_fp8_intrinsics_registered()
474470

475471
@T.prim_func
@@ -483,32 +479,18 @@ def fp8_vecmat_reduce(
483479
with T.Kernel(
484480
T.ceildiv(_FP8_VM_N, _FP8_VM_NP), threads=_FP8_VM_RT * _FP8_VM_NP
485481
) as bx:
486-
accum = T.alloc_local((1,), "float32")
487-
lane = T.get_thread_binding(0)
488-
kr = T.floormod(lane, _FP8_VM_RT)
489-
ni = T.floordiv(lane, _FP8_VM_RT)
490-
col = bx * _FP8_VM_NP + ni
491-
T.clear(accum)
492-
for ko in T.unroll(
493-
0,
494-
T.ceildiv(_FP8_VM_K_WORDS, _FP8_VM_RT),
495-
explicit=False,
496-
unroll_factor=4,
497-
):
498-
i = ko * _FP8_VM_RT + kr
499-
if col < _FP8_VM_N and i < _FP8_VM_K_WORDS:
500-
accum[0] += T.metal_fp8_e4m3_dot4(
501-
T.access_ptr(A[0, 0], "r", extent=_FP8_VM_K),
502-
T.access_ptr(B[col, 0], "r", extent=_FP8_VM_K),
503-
i,
504-
i,
505-
)
506-
reduced = T.call_intrin("float32", "tir.metal.simd_sum", accum[0])
507-
if kr == 0 and col < _FP8_VM_N:
508-
if _FP8_VM_SW == 1:
509-
C[0, col] = reduced * A_scale[0] * B_scale[0]
510-
else:
511-
C[0, col] = reduced * A_scale[0] * B_scale[col]
482+
T.fp8_scaled_matmul(
483+
A,
484+
A_scale,
485+
B,
486+
B_scale,
487+
C,
488+
transpose_B=True,
489+
c_col_offset=bx * _FP8_VM_NP,
490+
simd_group_width=_FP8_VM_RT,
491+
outputs_per_block=_FP8_VM_NP,
492+
accumulate=False,
493+
)
512494

513495
else:
514496

@@ -654,15 +636,6 @@ def lower_fp8_vecmat_msl(
654636
pass_configs=pass_configs or None,
655637
),
656638
)
657-
lowering, _output_shape = _fuse_canonical_vecmat_runtime_body(
658-
lowering,
659-
N=N,
660-
K=K,
661-
reduce_threads=reduce_threads,
662-
vec=vec,
663-
scale_w_per_row=scale_w_per_row,
664-
vectorized_loads=vectorized_loads,
665-
)
666639
return lowering.msl_text
667640

668641

@@ -704,7 +677,9 @@ def fp8_vecmat_msl_features(msl: str) -> dict[str, int]:
704677
"uint_pointer": body.count("uint*"),
705678
"uchar4": body_lowered.count("uchar4"),
706679
"fp8_e4m3_lut": body.count("fp8_e4m3fn_lut"),
707-
"metal_fp8_dot4_helper": body.count("__tvm_fp8_e4m3_dot4_packed"),
680+
"metal_fp8_dot4_helper": body.count("__tvm_fp8_e4m3_dot4_packed")
681+
+ body.count("__tvm_fp8_e4m3_dot4_words")
682+
+ body.count("__tvm_fp8_e4m3fn_lut["),
708683
"packed_uint_loads": packed_uint_loads,
709684
"scalar_fp8_byte_decode": scalar_decode_sites,
710685
"scalar_fp8_byte_decode_calls": scalar_decode_sites,
@@ -758,12 +733,13 @@ def _make_fp8_vecmat_direct_kernel(
758733
759734
The ABI is intentionally flat:
760735
761-
* ``A`` is the existing ``(K,)`` MLX uint8/e4m3 buffer.
736+
* ``A`` is the existing ``(K,)`` MLX uint8/e4m3 buffer, viewed by the
737+
TileLang ABI as ``(1, K)``.
762738
* ``B`` is the existing ``(N, K)`` MLX uint8/e4m3 buffer.
763739
* ``C`` is the caller-owned ``(N,)`` output buffer.
764740
765741
No reshape, allocation, or Python-side cast is needed before the DLPack
766-
handoff.
742+
handoff; the tvm-ffi adapter carries the ABI shape separately.
767743
"""
768744

769745
_validate_shape(
@@ -798,42 +774,28 @@ def _make_fp8_vecmat_direct_kernel(
798774

799775
@T.prim_func
800776
def fp8_vecmat_reduce_direct(
801-
A: T.Tensor((_FP8_VM_K,), "float8_e4m3"),
777+
A: T.Tensor((1, _FP8_VM_K), "float8_e4m3"),
802778
A_scale: T.Tensor((1,), "float32"),
803779
B: T.Tensor((_FP8_VM_N, _FP8_VM_K), "float8_e4m3"),
804780
B_scale: T.Tensor((_FP8_VM_SW,), "float32"),
805-
C: T.Tensor((_FP8_VM_N,), _FP8_VM_C_DTYPE),
781+
C: T.Tensor((1, _FP8_VM_N), _FP8_VM_C_DTYPE),
806782
):
807783
with T.Kernel(
808784
T.ceildiv(_FP8_VM_N, _FP8_VM_NP),
809785
threads=_FP8_VM_RT * _FP8_VM_NP,
810786
) as bx:
811-
accum = T.alloc_local((1,), "float32")
812-
lane = T.get_thread_binding(0)
813-
kr = T.floormod(lane, _FP8_VM_RT)
814-
ni = T.floordiv(lane, _FP8_VM_RT)
815-
col = bx * _FP8_VM_NP + ni
816-
T.clear(accum)
817-
for ko in T.unroll(
818-
0,
819-
T.ceildiv(_FP8_VM_K_WORDS, _FP8_VM_RT),
820-
explicit=False,
821-
unroll_factor=4,
822-
):
823-
word_i = ko * _FP8_VM_RT + kr
824-
if col < _FP8_VM_N and word_i < _FP8_VM_K_WORDS:
825-
accum[0] += T.metal_fp8_e4m3_dot4(
826-
T.access_ptr(A[0], "r", extent=_FP8_VM_K),
827-
T.access_ptr(B[col, 0], "r", extent=_FP8_VM_K),
828-
word_i,
829-
word_i,
830-
)
831-
reduced = T.call_intrin("float32", "tir.metal.simd_sum", accum[0])
832-
if kr == 0 and col < _FP8_VM_N:
833-
if _FP8_VM_SW == 1:
834-
C[col] = reduced * A_scale[0] * B_scale[0]
835-
else:
836-
C[col] = reduced * A_scale[0] * B_scale[col]
787+
T.fp8_scaled_matmul(
788+
A,
789+
A_scale,
790+
B,
791+
B_scale,
792+
C,
793+
transpose_B=True,
794+
c_col_offset=bx * _FP8_VM_NP,
795+
simd_group_width=_FP8_VM_RT,
796+
outputs_per_block=_FP8_VM_NP,
797+
accumulate=False,
798+
)
837799

838800
try:
839801
from tilelang.transform.simplify import apply_simplify
@@ -1120,7 +1082,14 @@ def fp8_scaled_vecmat_path_c_direct(
11201082
) from exc
11211083

11221084
try:
1123-
returned = kernel(A, A_scale, B, B_scale, C)
1085+
returned = kernel(
1086+
A,
1087+
A_scale,
1088+
B,
1089+
B_scale,
1090+
C,
1091+
_tilelang_mlx_async_owner_outputs=True,
1092+
)
11241093
except Exception as exc:
11251094
try:
11261095
from tilelang.contrib.mlx_interop import DLPackInteropError
@@ -1131,11 +1100,7 @@ def fp8_scaled_vecmat_path_c_direct(
11311100
raise FP8VecmatPathCDirectError(
11321101
f"direct tvm-ffi FP8 vecmat dispatch failed: {type(exc).__name__}: {exc}"
11331102
) from exc
1134-
if returned is not C:
1135-
raise FP8VecmatPathCDirectError(
1136-
"direct tvm-ffi FP8 vecmat did not return the caller-owned output"
1137-
)
1138-
return C
1103+
return returned
11391104

11401105

11411106
def fp8_scaled_vecmat_path_c(
@@ -1154,9 +1119,10 @@ def fp8_scaled_vecmat_path_c(
11541119
``x_fp8`` is ``(K,)`` uint8 e4m3 storage and ``W_fp8`` is transposed
11551120
``(N, K)`` storage, matching Path B. ``scale_x`` is scalar; ``scale_w`` may
11561121
be scalar or per-output ``(N,)``. When ``out`` is provided, dispatches via
1157-
tvm-ffi into that caller-owned output and returns the same object. Without
1158-
``out``, this function fails explicitly: there is no non-owner-output Path C
1159-
dispatch surface.
1122+
tvm-ffi into that caller-owned output and returns the MLX graph output that
1123+
aliases ``out``. Evaluating the returned value schedules the write; reading
1124+
``out`` after that observes the same storage. Without ``out``, this function
1125+
fails explicitly: there is no non-owner-output Path C dispatch surface.
11601126
"""
11611127

11621128
if out is not None:

cppmega_mlx/nn/_tilelang/sparse_mla_blockscaled_path_c.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1497,8 +1497,8 @@ def sparse_mla_blockscaled_path_c_apply(
14971497
"tvm-ffi graph-output dispatch did not return out/lse"
14981498
)
14991499
return None
1500-
out = cast(mx.array, returned[0])
1501-
lse = cast(mx.array, returned[1])
1500+
out = cast(mx.array, returned[0]).reshape((batch, seq_len, heads, d_v_resolved))
1501+
lse = cast(mx.array, returned[1]).reshape((batch, seq_len, heads))
15021502
if return_lse:
15031503
return out, lse
15041504
return out

cppmega_mlx/nn/_tilelang/sparse_mla_path_c.py

Lines changed: 29 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3548,6 +3548,21 @@ def _validate_fwd_owner_output_buffers(
35483548
return shapes
35493549

35503550

3551+
def _fp16_public_carrier(value: mx.array) -> mx.array | None:
3552+
"""Return a public-wrapper fp16 carrier, or None for unsupported dtypes.
3553+
3554+
The owner-output tvm-ffi ABI stays strict and accepts only already-fp16
3555+
buffers. The higher-level differentiable wrapper is allowed to adapt fp32
3556+
model tensors into that ABI and cast the result/grads back at the API edge.
3557+
"""
3558+
3559+
if value.dtype == mx.float16:
3560+
return value
3561+
if value.dtype == mx.float32:
3562+
return value.astype(mx.float16)
3563+
return None
3564+
3565+
35513566
@lru_cache(maxsize=128)
35523567
def _fwd_direct_tvm_ffi_kernel_for(
35533568
BATCH: int,
@@ -3924,7 +3939,9 @@ def sparse_mla_fwd_path_c(
39243939
indices,
39253940
op_name="sparse_mla_fwd_path_c",
39263941
)
3927-
if q.dtype != mx.float16 or kv.dtype != mx.float16:
3942+
q_carrier = _fp16_public_carrier(q)
3943+
kv_carrier = _fp16_public_carrier(kv)
3944+
if q_carrier is None or kv_carrier is None:
39283945
return None
39293946
sm_scale_buf = mx.array([float(sm_scale_value)], dtype=mx.float32)
39303947
try:
@@ -3944,7 +3961,7 @@ def sparse_mla_fwd_path_c(
39443961
op_name="Sparse-MLA Path C native forward",
39453962
),
39463963
)
3947-
returned = kernel(indices_supported, kv, q, sm_scale_buf)
3964+
returned = kernel(indices_supported, kv_carrier, q_carrier, sm_scale_buf)
39483965
except Exception:
39493966
return None
39503967
if not isinstance(returned, (list, tuple)) or len(returned) != 2:
@@ -3982,14 +3999,17 @@ def _sparse_mla_bwd_path_c_partial(
39823999
indices,
39834000
op_name="_sparse_mla_bwd_path_c_partial",
39844001
)
3985-
if q.dtype != mx.float16 or kv.dtype != mx.float16 or d_out.dtype != mx.float16:
4002+
q_carrier = _fp16_public_carrier(q)
4003+
kv_carrier = _fp16_public_carrier(kv)
4004+
d_out_carrier = _fp16_public_carrier(d_out)
4005+
if q_carrier is None or kv_carrier is None or d_out_carrier is None:
39864006
return None
39874007
sm_scale_buf = mx.array([float(sm_scale_value)], dtype=mx.float32)
39884008
try:
39894009
return _sparse_mla_bwd_path_c_partial_direct(
3990-
q,
3991-
kv,
3992-
d_out,
4010+
q_carrier,
4011+
kv_carrier,
4012+
d_out_carrier,
39934013
indices_supported,
39944014
sm_scale_buf=sm_scale_buf,
39954015
d_v=shapes.d_v,
@@ -4124,8 +4144,9 @@ def sparse_mla_path_c_apply(
41244144
41254145
The default ``sm_scale``/``d_v`` path is wrapped in ``mx.custom_function``
41264146
and uses the Path C backward kernel for VJP coverage. Forced Path C
4127-
non-default ``d_v``/``sm_scale`` dispatch uses a shape-parameterized custom
4128-
VJP wrapper over the same forward/backward kernels.
4147+
validates Path C availability but still goes through that differentiable
4148+
wrapper; the raw forward-only tvm-ffi entrypoint is kept for explicit
4149+
owner-output callers.
41294150
41304151
Note (kwarg rename from Path B):
41314152
This entrypoint accepts ``force_path_c`` (raise instead of falling
@@ -4178,29 +4199,11 @@ def sparse_mla_path_c_apply(
41784199
d_v=d_v,
41794200
return_lse=False,
41804201
)
4181-
if force_path_c:
4182-
result = sparse_mla_fwd_path_c(q, kv, indices, sm_scale=sm_scale, d_v=d_v)
4183-
if result is None:
4184-
raise RuntimeError(
4185-
"sparse_mla_path_c_apply: Path C direct tvm-ffi dispatch "
4186-
"is unavailable for these buffers"
4187-
)
4188-
out, _lse = result
4189-
return out.astype(q.dtype)
41904202
out = sparse_mla_path_c_metal_apply(q, kv, indices)
41914203
return cast(mx.array, out).astype(q.dtype)
41924204

41934205
status = sparse_mla_path_c_status()
41944206
if status.available:
4195-
if force_path_c:
4196-
result = sparse_mla_fwd_path_c(q, kv, indices, sm_scale=sm_scale, d_v=d_v)
4197-
if result is None:
4198-
raise RuntimeError(
4199-
"sparse_mla_path_c_apply: Path C direct tvm-ffi dispatch "
4200-
"is unavailable for these buffers"
4201-
)
4202-
out, _lse = result
4203-
return out.astype(q.dtype)
42044207
apply = _sparse_mla_path_c_apply_for_params(float(sm_scale), shapes.d_v)
42054208
out = apply(q, kv, indices)
42064209
return cast(mx.array, out).astype(q.dtype)

cppmega_mlx/nn/_tilelang/topk_selector.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1309,9 +1309,10 @@ def topk_selector(
13091309
k: number of top entries to select.
13101310
starts: optional (B,) int32 starts for per-row mask.
13111311
ends: optional (B,) int32 ends for per-row mask.
1312-
backend: ``"auto"`` (default) tries direct-MSL Path B before the
1313-
pure-MLX reference; it does not route through Path C because this
1314-
public entry point has no owner-output buffer;
1312+
backend: ``"auto"`` (default) tries TileLang Path C first, allocating
1313+
the public result buffer itself when the no-output compatibility
1314+
route is disabled, then falls back to direct-MSL Path B and the
1315+
pure-MLX reference;
13151316
``"metal"`` requires the Path B kernel and raises on fallback;
13161317
``"tilelang"`` / ``"path_c"`` require the owner-output Path C
13171318
helper and therefore fail closed from this no-``out`` API;
@@ -1332,8 +1333,15 @@ def topk_selector(
13321333
if out is None:
13331334
raise RuntimeError("topk_selector: TileLang Path C path unavailable")
13341335
return out
1335-
# auto has no owner-output parameter, so it cannot honestly use the direct
1336-
# Path C tvm-ffi route. Keep no-out AUTO on Path B or the pure-MLX reference.
1336+
out = topk_selector_tilelang(scores, k, starts=starts, ends=ends)
1337+
if out is not None:
1338+
return out
1339+
if starts is None and ends is None:
1340+
try:
1341+
owner_out = mx.zeros((int(scores.shape[0]), int(k)), dtype=mx.int32)
1342+
return topk_selector_tilelang_direct(scores, k, out=owner_out)
1343+
except TopKPathCDirectError:
1344+
pass
13371345
out = topk_selector_metal(scores, k, starts=starts, ends=ends)
13381346
if out is not None:
13391347
return out

0 commit comments

Comments
 (0)