@@ -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