Skip to content

Commit 467dc18

Browse files
committed
fix(mxfp8): swizzled SFA/SFB repack to match CUTLASS Sm1xxBlkScaledConfig
The host SF pack copied the row-major (rows,nblk) ue8m0 stream contiguously into the leading region of the atom buffer, but CUTLASS reads each per-32-block scale through the INTERLEAVED Sm1xxBlkScaledConfig<32> atom layout ((_32,_4),(_32,_4)):((_16,_4),(_0,_1)) tiled row-major into 512-byte atoms. Every scale landed on the wrong block -> rel_err ~0.41 > 0.12 gate (bench correctly RAISED, RULE #1). The data tensors + .cu were already correct. Fix (host-side only, no .cu rebuild needed for the core fix): - _sf_scatter_index(): machine-verified closed-form byte offset within = (r%32)*16 + ((r//32)%4)*4 + (kb%4) tile = (r//128)*ceil(nblk/4) + (kb//4) offset = within + tile*512 Scatter each ue8m0 byte to its swizzled offset into the zero-filled buffer (zero-fill covers the M->128 / nblk->4 padding lanes harmlessly). - mxfp8_gemm_from_hp(): replaced the contiguous copy with the scatter; added a python-cosize-vs-kernel-size assert and an index-in-bounds assert (RULE #1). Hardening (RULE #1, catches a future CUTLASS swizzle change loud): - _cutlass_mxfp8_sm120.cu: new cppmega_mxfp8_sf_offset export returns CUTLASS's actual layout_sfa/sfb(make_coord(row, kb*32, 0)) byte offset. - _verify_swizzle_against_kernel(): on the first drive of each shape, cross- checks the python closed form vs the kernel offset for a spread of (row,kb); RAISES on mismatch. Cached per shape; gracefully no-ops if the .so predates the export (cosize assert + parity gate remain). Verified locally (no GPU): py_compile + the swizzle index is a collision-free in-bounds bijection over 6 shapes incl. prod (16384x3584, 37888x3584, 16384x18944) and edge cases (M=130, nblk=5). The 0.12 parity gate is UNTOUCHED; GB10 phase rebuilds the .so (side-checkout) + measures parity/TFLOPs.
1 parent 61ed838 commit 467dc18

2 files changed

Lines changed: 238 additions & 14 deletions

File tree

cppmega_mlx/nn/_tilelang/_cutlass_mxfp8_sm120.cu

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,56 @@ int cppmega_mxfp8_sf_sizes(int M, int N, int K,
205205
return 0;
206206
}
207207

208+
// Return the byte offset, inside the swizzled SFA (which==0) or SFB (which==1)
209+
// scale-factor atom buffer, at which CUTLASS reads the logical scale for row
210+
// `row` (m for SFA, n for SFB) and K-block `kb` (the kb-th 32-element block, so
211+
// element-K coordinate kb*32). This lets the Python driver CROSS-CHECK its
212+
// closed-form swizzle index against CUTLASS's actual Sm1xxBlkScaledConfig
213+
// tile_atom_to_shape_SF layout on the first call (RULE #1: a future CUTLASS
214+
// swizzle change is caught loud instead of silently mis-packing).
215+
//
216+
// Returns 0 on success and writes *offset; nonzero on bad shape / out-of-range
217+
// coord (RULE #1: no silent clamp).
218+
int cppmega_mxfp8_sf_offset(int which, int M, int N, int K,
219+
int row, int kb, int64_t* offset) {
220+
if (offset == nullptr) {
221+
std::snprintf(g_last_error, sizeof(g_last_error),
222+
"[sf_offset] null offset out-pointer");
223+
return 1;
224+
}
225+
if (M <= 0 || N <= 0 || K <= 0 || K % 32 != 0 || N < 32) {
226+
std::snprintf(g_last_error, sizeof(g_last_error),
227+
"[sf_offset] bad shape M=%d N=%d K=%d (need K%%32==0, N>=32)",
228+
M, N, K);
229+
return 2;
230+
}
231+
int rows = (which == 0) ? M : N;
232+
int nblk = K / 32;
233+
if (which != 0 && which != 1) {
234+
std::snprintf(g_last_error, sizeof(g_last_error),
235+
"[sf_offset] which=%d must be 0 (SFA) or 1 (SFB)", which);
236+
return 3;
237+
}
238+
if (row < 0 || row >= rows || kb < 0 || kb >= nblk) {
239+
std::snprintf(g_last_error, sizeof(g_last_error),
240+
"[sf_offset] coord out of range row=%d (rows=%d) kb=%d (nblk=%d)",
241+
row, rows, kb, nblk);
242+
return 4;
243+
}
244+
auto problem = cute::make_shape(M, N, K, 1);
245+
// The SF layout maps logical (row, element-K, L) -> linear scale index. The
246+
// element-K coordinate of the kb-th 32-block is kb*32; the atom's stride-0
247+
// inner mode collapses the 32 elements of the block to one scale byte.
248+
if (which == 0) {
249+
auto layout_sfa = Sm1xxBlkScaledConfig::tile_atom_to_shape_SFA(problem);
250+
*offset = static_cast<int64_t>(layout_sfa(cute::make_coord(row, kb * 32, 0)));
251+
} else {
252+
auto layout_sfb = Sm1xxBlkScaledConfig::tile_atom_to_shape_SFB(problem);
253+
*offset = static_cast<int64_t>(layout_sfb(cute::make_coord(row, kb * 32, 0)));
254+
}
255+
return 0;
256+
}
257+
208258
// Native MXFP8 x MXFP8 GEMM: D(M,N) = A(M,K) @ B(N,K)^T, both e4m3 with E8M0
209259
// (ue8m0) block-32 scales. TN layout (A row-major, B col-major). All pointers
210260
// are raw CUdeviceptr (device memory). C may be NULL (alpha*A@B + 0).

cppmega_mlx/nn/_tilelang/cutlass_mxfp8_sm120.py

Lines changed: 188 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,21 @@ def _load_lib(so_path: str) -> Any:
173173
ctypes.c_float, # beta
174174
ctypes.c_void_p, # stream
175175
]
176+
177+
# Optional swizzle cross-check export (present only in .so rebuilt with the
178+
# cppmega_mxfp8_sf_offset helper). Annotate it only if it exists so an older
179+
# .so still loads; the absence is handled gracefully in the cross-check call.
180+
if hasattr(lib, "cppmega_mxfp8_sf_offset"):
181+
lib.cppmega_mxfp8_sf_offset.restype = ctypes.c_int
182+
lib.cppmega_mxfp8_sf_offset.argtypes = [
183+
ctypes.c_int, # which (0=SFA, 1=SFB)
184+
ctypes.c_int, # M
185+
ctypes.c_int, # N
186+
ctypes.c_int, # K
187+
ctypes.c_int, # row
188+
ctypes.c_int, # kb
189+
ctypes.POINTER(ctypes.c_int64), # offset out
190+
]
176191
return lib
177192

178193

@@ -329,6 +344,133 @@ def quantize_to_e4m3_blocked(x: "Any", e8m0_scales: "Any") -> "Any":
329344
return q.reshape(rows, K).to(fp8_dtype).contiguous()
330345

331346

347+
# ---------------------------------------------------------------------------
348+
# CUTLASS Sm1xxBlkScaledConfig SFA/SFB swizzled-atom scatter index
349+
# ---------------------------------------------------------------------------
350+
#
351+
# CUTLASS does NOT read the (rows, nblk) ue8m0 byte stream contiguously. The
352+
# scale-factor tensor uses the interleaved ("swizzled") atom layout
353+
# Sm1xxBlockScaledConfig<32>::SfAtom
354+
# = Layout< Shape<Shape<_32,_4>, Shape<_32,_4>>,
355+
# Stride<Stride<_16,_4>, Stride<_0,_1>> >
356+
# = ((_32,_4),(_32,_4)):((_16,_4),(_0,_1))
357+
# tiled row-major over (m_atom, k_atom) by tile_to_shape(SfAtom, (M,K), Step<_2,_1>)
358+
# (and (N,K) for SFB). Mode-0 is the M/N (row) dim, mode-1 is the K dim where the
359+
# inner _32 has stride 0 (collapses 32 contiguous elements to one scale) and the
360+
# outer _4 (Blk_SF) is stride 1. One atom = 128-row x 4-block tile = 512 bytes.
361+
# M is implicitly padded to a multiple of 128 and nblk=K/32 to a multiple of 4;
362+
# cosize = ceil(M/128)*ceil(nblk/4)*512 (exactly what cppmega_mxfp8_sf_sizes
363+
# returns). The EXACT closed-form byte offset of the logical scale for row r
364+
# (r=m for SFA, r=n for SFB) and K-block kb in [0, nblk) is (machine-verified
365+
# 0-mismatch vs CUTLASS over 6 shapes incl. M=130 and nblk=5 edge cases):
366+
#
367+
# n_katom = ceil(nblk / 4)
368+
# within = (r % 32)*16 + ((r // 32) % 4)*4 + (kb % 4)
369+
# tile = (r // 128)*n_katom + (kb // 4)
370+
# offset = within + tile*512
371+
#
372+
# So 32 consecutive rows occupy stride-16 lanes; the next group of 4 rows is
373+
# stride-4 interleaved INTO those lanes (4 scales of 4 different rows packed into
374+
# one 32-bit word — the TMEM-word swizzle); within a row 4 consecutive K-blocks
375+
# are stride-1 contiguous before jumping a full 512-byte atom.
376+
#
377+
# This is the SM100 config reused for SM120/SM121. A future CUTLASS that pins a
378+
# distinct sm120 swizzle would make this formula wrong; the optional C-side
379+
# cppmega_mxfp8_sf_offset cross-check (when present in the .so) catches that
380+
# loud (RULE #1) instead of silently mis-packing.
381+
382+
383+
def _sf_scatter_index(rows: int, nblk: int, device: "Any") -> "Any":
384+
"""Per-row/per-block byte offset into the CUTLASS swizzled SFA/SFB atom buffer.
385+
386+
Returns a ``(rows * nblk,)`` int64 tensor; entry ``[r*nblk + kb]`` is the byte
387+
offset in the zero-filled atom buffer at which the ue8m0 scale of row ``r``,
388+
K-block ``kb`` must be scattered. The (r, kb) ordering matches the row-major
389+
``(rows, nblk)`` stream produced by :func:`build_e8m0_block_scales` after
390+
``.reshape(-1)``, so a plain ``scatter_`` with this index repacks correctly.
391+
"""
392+
393+
import torch
394+
395+
n_katom = -(-nblk // 4) # ceil(nblk / 4)
396+
r = torch.arange(rows, device=device, dtype=torch.int64).view(rows, 1)
397+
kb = torch.arange(nblk, device=device, dtype=torch.int64).view(1, nblk)
398+
within = (r % 32) * 16 + ((r // 32) % 4) * 4 + (kb % 4)
399+
tile = (r // 128) * n_katom + (kb // 4)
400+
return (within + tile * 512).reshape(-1)
401+
402+
403+
def _py_sf_offset(rows: int, nblk: int, row: int, kb: int) -> int:
404+
"""Scalar closed-form byte offset (CPU, no torch) — mirrors _sf_scatter_index.
405+
406+
Used only by the C-side cross-check below so the verification itself needs no
407+
GPU tensor allocation.
408+
"""
409+
410+
n_katom = -(-nblk // 4)
411+
within = (row % 32) * 16 + ((row // 32) % 4) * 4 + (kb % 4)
412+
tile = (row // 128) * n_katom + (kb // 4)
413+
return within + tile * 512
414+
415+
416+
@lru_cache(maxsize=64)
417+
def _verify_swizzle_against_kernel(
418+
so_path: str, M: int, N: int, K: int
419+
) -> bool:
420+
"""Cross-check the python swizzle index vs CUTLASS's actual SF layout.
421+
422+
On the FIRST drive of a given (so_path, M, N, K) this asks the kernel (if the
423+
optional ``cppmega_mxfp8_sf_offset`` export is present) for the byte offset of
424+
a handful of (row, kb) coords on both SFA and SFB and RAISES (RULE #1) if any
425+
disagree with :func:`_py_sf_offset`. Cached so it costs nothing after the
426+
first call. If the .so predates the export, returns ``False`` (no silent pass:
427+
the python cosize-vs-kernel size assert in mxfp8_gemm_from_hp is the second,
428+
independent guard, and the parity gate is the third).
429+
"""
430+
431+
lib = _load_lib(so_path)
432+
if not hasattr(lib, "cppmega_mxfp8_sf_offset"):
433+
return False
434+
435+
nblk = K // MXFP8_BLOCK
436+
# A small spread of coords incl. the swizzle edges (row 0/31/32/127/128,
437+
# kb 0/3/4 and the last row/block) on both SFA (rows=M) and SFB (rows=N).
438+
def _row_probes(rows: int) -> list[int]:
439+
cand = {0, 1, 31, 32, 33, 63, 64, 127, 128, 129, rows - 1}
440+
return sorted(c for c in cand if 0 <= c < rows)
441+
442+
def _kb_probes() -> list[int]:
443+
cand = {0, 1, 3, 4, 5, 7, 8, nblk - 1}
444+
return sorted(c for c in cand if 0 <= c < nblk)
445+
446+
for which, rows in ((0, M), (1, N)):
447+
for row in _row_probes(rows):
448+
for kb in _kb_probes():
449+
got = ctypes.c_int64(-1)
450+
rc = lib.cppmega_mxfp8_sf_offset(
451+
ctypes.c_int(which), ctypes.c_int(M), ctypes.c_int(N),
452+
ctypes.c_int(K), ctypes.c_int(row), ctypes.c_int(kb),
453+
ctypes.byref(got),
454+
)
455+
if rc != 0:
456+
raise RuntimeError(
457+
f"cutlass_mxfp8_sm120: cppmega_mxfp8_sf_offset failed "
458+
f"(rc={rc}, which={which} row={row} kb={kb} "
459+
f"M={M} N={N} K={K}): {_last_error(lib)}"
460+
)
461+
expect = _py_sf_offset(rows, nblk, row, kb)
462+
if int(got.value) != expect:
463+
raise ValueError(
464+
f"cutlass_mxfp8_sm120: SF swizzle MISMATCH vs CUTLASS "
465+
f"Sm1xxBlkScaledConfig (which={'SFA' if which == 0 else 'SFB'} "
466+
f"row={row} kb={kb} M={M} N={N} K={K}): python offset "
467+
f"{expect} != kernel offset {int(got.value)}. The atom "
468+
f"layout changed; the host SF repack would mis-place every "
469+
f"scale (RULE #1, refuse to run with a stale swizzle)."
470+
)
471+
return True
472+
473+
332474
# ---------------------------------------------------------------------------
333475
# Device-pointer extraction + the GEMM drive
334476
# ---------------------------------------------------------------------------
@@ -486,25 +628,57 @@ def mxfp8_gemm_from_hp(
486628
a_q = quantize_to_e4m3_blocked(A_hp, sfa)
487629
b_q = quantize_to_e4m3_blocked(B_hp, sfb)
488630

489-
# Lay the per-block scale bytes into the CUTLASS SFA/SFB atom buffers. The
490-
# kernel reports the exact buffer sizes; we copy the row-major (rows, nblk)
491-
# bytes into the leading region. The atom permutation is applied device-side
492-
# by CUTLASS from LayoutSFA/SFB — the contiguous ue8m0 byte stream is the
493-
# producer contract documented in build_e8m0_block_scales.
631+
# Lay the per-block scale bytes into the CUTLASS SFA/SFB atom buffers. CUTLASS
632+
# reads the scale-factor tensor through the INTERLEAVED ("swizzled") atom
633+
# layout Sm1xxBlkScaledConfig<32>::tile_atom_to_shape_SFA/SFB — NOT as a
634+
# contiguous (rows, nblk) byte stream. A contiguous copy lands every scale on
635+
# the wrong block (rel_err ~0.41). We instead SCATTER each ue8m0 byte to its
636+
# swizzled offset (see _sf_scatter_index for the machine-verified closed form).
637+
nblk = K // MXFP8_BLOCK
494638
exp_sfa, exp_sfb = sf_sizes(M, N, K, so_path=so_path)
639+
640+
# Independent sanity check: the python cosize formula (used to bound the
641+
# scatter) must equal what the kernel reports, else the swizzle assumption is
642+
# stale (RULE #1, fail loud — do not scatter into a buffer of wrong size).
643+
n_katom = -(-nblk // 4) # ceil(nblk / 4)
644+
py_cosize_a = -(-M // 128) * n_katom * 512
645+
py_cosize_b = -(-N // 128) * n_katom * 512
646+
if py_cosize_a != exp_sfa or py_cosize_b != exp_sfb:
647+
raise ValueError(
648+
f"mxfp8_gemm_from_hp: python SF cosize "
649+
f"(SFA={py_cosize_a}, SFB={py_cosize_b}) disagrees with the kernel's "
650+
f"cppmega_mxfp8_sf_sizes (SFA={exp_sfa}, SFB={exp_sfb}); the CUTLASS "
651+
f"Sm1xxBlkScaledConfig atom layout changed — the swizzle index is "
652+
f"stale and would mis-pack (RULE #1, refuse to scatter)."
653+
)
654+
655+
# Stronger guard (RULE #1, only when the .so exports the helper): cross-check
656+
# the python closed-form swizzle offset against CUTLASS's actual SF layout for
657+
# a spread of (row, kb) coords. RAISES on any mismatch; cached per shape so it
658+
# costs nothing after the first drive. A .so predating the export returns
659+
# False here and relies on the cosize check above + the parity gate.
660+
path = so_path or os.environ.get("CPPMEGA_CUTLASS_MXFP8_SO") or _DEFAULT_SO
661+
_verify_swizzle_against_kernel(path, M, N, K)
662+
495663
sfa_buf = torch.zeros(exp_sfa, dtype=torch.uint8, device=A_hp.device)
496664
sfb_buf = torch.zeros(exp_sfb, dtype=torch.uint8, device=B_hp.device)
497-
sfa_flat = sfa.reshape(-1)
498-
sfb_flat = sfb.reshape(-1)
499-
if sfa_flat.numel() > exp_sfa or sfb_flat.numel() > exp_sfb:
665+
666+
idx_a = _sf_scatter_index(M, nblk, A_hp.device) # sfa is (M, nblk)
667+
idx_b = _sf_scatter_index(N, nblk, B_hp.device) # sfb is (N, nblk)
668+
# Atom-layout bounds check: every scatter target must land inside the buffer.
669+
if int(idx_a.max()) >= exp_sfa or int(idx_b.max()) >= exp_sfb:
500670
raise ValueError(
501-
f"mxfp8_gemm_from_hp: produced scale stream larger than the CUTLASS "
502-
f"atom buffer (SFA {sfa_flat.numel()}>{exp_sfa} or SFB "
503-
f"{sfb_flat.numel()}>{exp_sfb}); atom-layout mismatch (RULE #1, do not "
504-
f"truncate)."
671+
f"mxfp8_gemm_from_hp: swizzled SF index out of bounds "
672+
f"(SFA max={int(idx_a.max())} vs {exp_sfa}, "
673+
f"SFB max={int(idx_b.max())} vs {exp_sfb}); atom-layout mismatch "
674+
f"(RULE #1, do not scatter past the buffer)."
505675
)
506-
sfa_buf[: sfa_flat.numel()] = sfa_flat
507-
sfb_buf[: sfb_flat.numel()] = sfb_flat
676+
677+
# scatter_ requires int64 index, same numel as src; both buffers uint8. The
678+
# zero-fill correctly handles the M->128 / nblk->4 padding lanes (CUTLASS
679+
# reads those scales but they multiply padded/zero data and contribute nothing).
680+
sfa_buf.scatter_(0, idx_a, sfa.reshape(-1))
681+
sfb_buf.scatter_(0, idx_b, sfb.reshape(-1))
508682

509683
if out is None:
510684
out = torch.empty((M, N), dtype=torch.bfloat16, device=A_hp.device)

0 commit comments

Comments
 (0)