Skip to content

Commit 5632a28

Browse files
committed
feat(fp8): §19 MEASURED on gb10 sm_121 — fp8 GEMM RUNS; TE per-tensor e4m3 = real 1.6-1.8x tensor-core win; in-house cuda twin correct but unoptimized
Closes the §18 "fp8 UNRUNNABLE on sm_121" gap with MEASURED numbers at the four prod bs4 transformer-block GEMM shapes (M=16384). Two distinct fp8 paths now run: R1 TE per-tensor E4M3 (DelayedScaling -> cuBLASLt fp8 MMA): the REAL fp8 tensor-core win — 1.57-1.84x over bf16 MEASURED (52.1-61.8 fp8 TFLOPs vs 33.3-34.3 bf16), rel-err 0.0375 honest over ALL elements (<=0.10 ceiling). Only MXFP8 block-scaling stays upstream-blocked ("not supported on 12.0+"); per-tensor works once libnvrtc-builtins.so.13.3 is on the loader path. R2 in-house dequant->half->T.gemm CUDA fragment-C twin (fp8_scaled_matmul_path_c_cuda_prim): COMPILES + RUNS + parity-passes (0.0375, native fp8_e4_t->half_t decode verified in emitted CUDA) — but 0.18-0.42x as-is: its MMA runs on the decoded fp16 values (mma_sync<kFloat16,...>, not an fp8-input MMA) on an untuned 32x64x32 single-128-thread tile. It is the proven MEMORY lever (2.0x operand-byte halving MEASURED) + the on-CUDA optimization target, NOT a speed lever as-is. Attribution explicit: throughput belongs to TE-cuBLASLt fp8. Two port fixes were needed for R2 to run on CUDA (RULE #1, no fallback): - fp8_matmul_path_c.py: the Metal T.alloc_var("float32") decode scratch lowered to a non-assignable float[1] on codegen_cuda ("expression must be a modifiable lvalue"). Replaced with a register-fragment stage (T.copy e4m3 global->fragment with disable_tma=True, then per-element T.cast->fp16) — keeps the native per-element decode, emits a valid CUDA lvalue (F2-forward CUDA-twin pattern). - fp8_gemm_microbench.py: the prim's operand param is typed float8_e4m3; feeding a .view(torch.uint8) tensor segfaults the tvm_ffi adapter on the dtype mismatch. Fixed _quantize_pertensor_fp8 to pass native torch.float8_e4m3fn (the 1-byte storage IS the 2.0x halving regardless of the dtype tag). cppmega_mlx/_gb10_nvrtc_env.py (NEW, stdlib-only): self-healing NVRTC loader-path guard — prepends /usr/local/cuda-13.3/.../lib and re-execs once so TE's NVRTC can dlopen libnvrtc-builtins.so.13.3 (the R1 tensorwise route's only loader blocker); no-op on non-gb10. Validated end-to-end on gb10 with env -u LD_LIBRARY_PATH. docs: RELAX-GRAPH-VS-MEGATRON.md §19 (full table + GO verdict + e2e EXTRAPOLATION + corrected memory synergy bs4-fp8 ~= 2x bs1-fp16); FP8-ACTIVATIONS-PATHC.md TL;DR/status updated with the MEASURED fp8 GEMM numbers. GO: a REAL fp8 e4m3 tensor-core GEMM runs on sm_121 with a measured ~1.8x speedup and honest 0.0375 parity. Measured 2026-06-04 gb10 tvm.cuda(0).
1 parent cb94526 commit 5632a28

5 files changed

Lines changed: 313 additions & 16 deletions

File tree

cppmega_mlx/_gb10_nvrtc_env.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"""gb10 NVRTC loader-path guard — persist the CUDA 13.3 builtins dir for EVERY run.
2+
3+
WHY (the measured gb10 sm_121 bug, 2026-06-04). TransformerEngine 2.16 (source
4+
build at /home/dave/TransformerEngine) links ``libnvrtc.so.13`` built against
5+
CUDA 13.3. At JIT-compile time that NVRTC dlopen's a companion
6+
``libnvrtc-builtins.so.<MAJOR>.<MINOR>`` whose MINOR must match EXACTLY — i.e.
7+
``libnvrtc-builtins.so.13.3``. That file exists
8+
(``/usr/local/cuda-13.3/targets/sbsa-linux/lib/libnvrtc-builtins.so.13.3``) but
9+
its directory is not on the login-shell ``LD_LIBRARY_PATH`` (empty) and was
10+
historically absent from the ldconfig cache, so the TE tensorwise (Float8
11+
delayed-scaling) route failed with::
12+
13+
failed to open libnvrtc-builtins.so.13.3
14+
15+
This is a TOOLCHAIN/loader-path problem, NOT a code bug — fixed purely by making
16+
that directory findable. The system-level real fix is a one-time
17+
``sudo -n ldconfig`` (the cache now resolves 13.3). This module is the COMMITTED,
18+
self-healing, per-run belt-and-suspenders that makes the fix survive a future
19+
ldconfig regression WITHOUT relying on a transient one-shell ``export`` (RULE #1:
20+
no silent dependence on a shell var that a future operator might forget).
21+
22+
HOW. ``LD_LIBRARY_PATH`` is parsed by glibc ONCE at process startup; a late
23+
``os.environ`` mutation is NOT honored by later ``dlopen`` calls for the dynamic
24+
search path. So this helper, when the 13.3 dir is missing from the *startup*
25+
``LD_LIBRARY_PATH``, prepends it and RE-EXECS the interpreter so the loader picks
26+
it up at startup. The re-exec is idempotent (guarded by an env sentinel) and runs
27+
at most once. RULE #1: if the dir does not exist or the re-exec cannot be
28+
performed, this RAISES with the precise where+what — it never silently proceeds
29+
with a half-applied path that would later fail deep inside an NVRTC dlopen.
30+
31+
NON-gb10 hosts (no such directory) are a no-op: the helper only acts when the
32+
13.3 builtins dir is actually present, so importing it on a Mac/M4 is harmless.
33+
34+
This module is deliberately a TOP-LEVEL ``cppmega_mlx`` module (NOT under
35+
``cppmega_mlx.runtime``) and imports ONLY the stdlib, so importing it does not
36+
pull torch / mlx / TransformerEngine — the re-exec MUST happen before any of
37+
those build their loader link map.
38+
"""
39+
40+
from __future__ import annotations
41+
42+
import os
43+
import sys
44+
45+
# The directory that holds libnvrtc-builtins.so.13.3 on gb10 (CUDA 13.3 toolkit,
46+
# sbsa-linux / aarch64). This is the loader path TE's NVRTC needs.
47+
GB10_CUDA133_LIB = "/usr/local/cuda-13.3/targets/sbsa-linux/lib"
48+
49+
# Sentinel so the re-exec happens at most once (guards against an exec loop).
50+
_REEXEC_SENTINEL = "CPPMEGA_GB10_NVRTC_PATH_APPLIED"
51+
52+
53+
def _dir_has_builtins(path: str) -> bool:
54+
"""True iff *path* exists and contains the libnvrtc-builtins.so.13.3 SONAME."""
55+
soname = os.path.join(path, "libnvrtc-builtins.so.13.3")
56+
return os.path.isdir(path) and os.path.exists(soname)
57+
58+
59+
def ensure_nvrtc_builtins_path(*, lib_dir: str = GB10_CUDA133_LIB) -> str:
60+
"""Guarantee *lib_dir* is on the STARTUP LD_LIBRARY_PATH for this process.
61+
62+
Returns one of: ``"noop-not-gb10"`` (dir absent — not this host, harmless),
63+
``"already-present"`` (startup env already had it), or it RE-EXECS the
64+
interpreter (does not return in that case). RULE #1: raises RuntimeError if
65+
the 13.3 builtins dir exists but the re-exec cannot be performed — never a
66+
silent half-fix.
67+
68+
MUST be called BEFORE importing torch / transformer_engine (i.e. at the very
69+
top of any gb10 run entrypoint) so the corrected env is in place when NVRTC
70+
is later dlopen'd.
71+
"""
72+
if not _dir_has_builtins(lib_dir):
73+
# Not gb10 (or the 13.3 toolkit is not installed here). Nothing to do —
74+
# this is the correct no-op on a Mac/M4 dev box. We do NOT fabricate the
75+
# path on a host that lacks it.
76+
return "noop-not-gb10"
77+
78+
current = os.environ.get("LD_LIBRARY_PATH", "")
79+
entries = [p for p in current.split(os.pathsep) if p]
80+
if lib_dir in entries:
81+
# Startup env already carries it (e.g. the run command's LD_LIBRARY_PATH
82+
# prefix, or a prior re-exec). The loader honors it — done.
83+
return "already-present"
84+
85+
if os.environ.get(_REEXEC_SENTINEL) == "1":
86+
# We already re-exec'd once yet the dir is still not on LD_LIBRARY_PATH.
87+
# That should be impossible; fail loud rather than loop or limp on.
88+
raise RuntimeError(
89+
"gb10_nvrtc_env: re-exec sentinel set but "
90+
f"{lib_dir!r} still absent from LD_LIBRARY_PATH={current!r}; refusing "
91+
"to proceed — TE's NVRTC would later fail to open "
92+
"libnvrtc-builtins.so.13.3 (RULE #1: no silent half-applied path)."
93+
)
94+
95+
# Prepend the 13.3 dir and re-exec so the loader parses it at startup.
96+
new_ldlp = os.pathsep.join([lib_dir, *entries]) if entries else lib_dir
97+
new_env = dict(os.environ)
98+
new_env["LD_LIBRARY_PATH"] = new_ldlp
99+
new_env[_REEXEC_SENTINEL] = "1"
100+
try:
101+
os.execve(sys.executable, [sys.executable, *sys.argv], new_env)
102+
except OSError as exc: # pragma: no cover - exec almost never fails
103+
raise RuntimeError(
104+
"gb10_nvrtc_env: failed to re-exec the interpreter to apply "
105+
f"LD_LIBRARY_PATH={new_ldlp!r} (needed so TE's NVRTC can dlopen "
106+
f"libnvrtc-builtins.so.13.3): {exc!r}. RULE #1: not falling through "
107+
"with a path that would crash inside NVRTC later — fix the run env."
108+
) from exc
109+
# os.execve does not return on success.
110+
raise AssertionError("unreachable: os.execve returned without raising") # pragma: no cover

cppmega_mlx/nn/_tilelang/fp8_matmul_path_c.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -259,15 +259,35 @@ def _fp8_scaled_matmul2d_cuda_kernel_template(
259259
C_frag = T.alloc_fragment((_FP8_MM_BM, _FP8_MM_BN), _FP8_MM_C_DTYPE) # type: ignore[name-defined] # noqa: F821
260260
C_shared = T.alloc_shared((_FP8_MM_BM, _FP8_MM_BN), _FP8_MM_C_DTYPE, scope="shared") # type: ignore[name-defined] # noqa: F821
261261
T.clear(C_frag)
262+
A_fp8_local = T.alloc_fragment((_FP8_MM_BM, _FP8_MM_BK), "float8_e4m3") # type: ignore[name-defined] # noqa: F821
263+
B_fp8_local = T.alloc_fragment((_FP8_MM_BN, _FP8_MM_BK), "float8_e4m3") # type: ignore[name-defined] # noqa: F821
262264
for ko in T.serial(T.ceildiv(_FP8_MM_K, _FP8_MM_BK)): # type: ignore[name-defined] # noqa: F821
265+
# Stage the e4m3 bytes into register fragments first (disable_tma on
266+
# the global load), then cast element-wise. On CUDA codegen the prior
267+
# ``T.alloc_var("float32")`` scratch lowered to a non-assignable
268+
# ``float[1]`` array (lvalue error); the per-element fragment->fragment
269+
# ``T.cast(e4m3 -> float16)`` here lowers to the NATIVE
270+
# ``__nv_fp8_e4m3 -> half`` decode (codegen_cuda ``GetTileLangFP8Type``
271+
# -> ``fp8_e4*_t``), NOT a vectorized ``(half4)(uchar4)`` reinterpret —
272+
# the per-element scalar cast keeps the decode honest (RULE #1) while
273+
# producing a valid CUDA lvalue. (F2 forward CUDA twin pattern: copy
274+
# global->local, then operate on the fragment under T.Parallel.)
275+
T.copy( # type: ignore[name-defined] # noqa: F821
276+
A_fp8[by * _FP8_MM_BM : by * _FP8_MM_BM + _FP8_MM_BM,
277+
ko * _FP8_MM_BK : ko * _FP8_MM_BK + _FP8_MM_BK],
278+
A_fp8_local,
279+
disable_tma=True,
280+
)
281+
T.copy( # type: ignore[name-defined] # noqa: F821
282+
B_fp8[bx * _FP8_MM_BN : bx * _FP8_MM_BN + _FP8_MM_BN,
283+
ko * _FP8_MM_BK : ko * _FP8_MM_BK + _FP8_MM_BK],
284+
B_fp8_local,
285+
disable_tma=True,
286+
)
263287
for i, kk in T.Parallel(_FP8_MM_BM, _FP8_MM_BK): # type: ignore[name-defined] # noqa: F821
264-
a_val = T.alloc_var("float32") # type: ignore[name-defined] # noqa: F821
265-
a_val = T.cast(A_fp8[by * _FP8_MM_BM + i, ko * _FP8_MM_BK + kk], "float32") # type: ignore[name-defined] # noqa: F821
266-
A_shared[i, kk] = T.cast(a_val, "float16") # type: ignore[name-defined] # noqa: F821
288+
A_shared[i, kk] = T.cast(A_fp8_local[i, kk], "float16") # type: ignore[name-defined] # noqa: F821
267289
for j, kk in T.Parallel(_FP8_MM_BN, _FP8_MM_BK): # type: ignore[name-defined] # noqa: F821
268-
b_val = T.alloc_var("float32") # type: ignore[name-defined] # noqa: F821
269-
b_val = T.cast(B_fp8[bx * _FP8_MM_BN + j, ko * _FP8_MM_BK + kk], "float32") # type: ignore[name-defined] # noqa: F821
270-
B_shared[j, kk] = T.cast(b_val, "float16") # type: ignore[name-defined] # noqa: F821
290+
B_shared[j, kk] = T.cast(B_fp8_local[j, kk], "float16") # type: ignore[name-defined] # noqa: F821
271291
T.gemm(A_shared, B_shared, C_frag, transpose_B=True) # type: ignore[name-defined] # noqa: F821
272292
sa = A_scale[0]
273293
sb = B_scale[0]

docs/FP8-ACTIVATIONS-PATHC.md

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,26 @@
11
# FP8 ACTIVATIONS END-TO-END for the path_c pipeline (design + microbench spec)
22

3-
**Status: DESIGN (read-only round), 2026-06-04. Target: gb10 sm_121 (Grace-Blackwell, `tvm.cuda(0)`).**
3+
**§19 MEASURED UPDATE (gb10 sm_121, 2026-06-04) — fp8 GEMM now RUNS; the SPEED is MEASURED.**
4+
The §18 "fp8 UNRUNNABLE on sm_121" gap is CLOSED on two fronts. (1) **TE per-tensor E4M3
5+
(`DelayedScaling`, cuBLASLt fp8 MMA) is the REAL fp8 tensor-core win: 1.57–1.84× over bf16
6+
MEASURED** at the four prod bs4 GEMM shapes (52.1–61.8 fp8 TFLOPs vs 33.3–34.3 bf16), rel-err
7+
**0.0375 honest over ALL elements** (≤0.10 ceiling; only the MXFP8 *block-scaling* recipe stays
8+
upstream-blocked "not supported on 12.0+"; the per-tensor NVRTC-builtins loader gap is fixed by the
9+
committed self-healing `cppmega_mlx/_gb10_nvrtc_env.py`). (2) **Our in-house Metal dequant→half→
10+
`T.gemm` route is ported to a `target="cuda"` fragment-C twin** (`fp8_scaled_matmul_path_c_cuda_prim`)
11+
that COMPILES + RUNS + passes parity (0.0375, native `fp8_e4_t``half_t` decode verified in the
12+
emitted CUDA) — **but it is NOT yet fast (0.18–0.42×): its MMA runs on the decoded fp16 values
13+
(`mma_sync<kFloat16,…>`, not an fp8-input MMA) on an untuned 32×64×32 single-128-thread tile**, so it
14+
is the proven *memory* lever (2.0× operand-byte halving MEASURED) and the on-CUDA optimization target,
15+
NOT a speed lever as-is. Attribution is explicit: the throughput belongs to TE-cuBLASLt fp8; our
16+
kernel gives the memory halving + a correct on-CUDA fp8 path. Full numbers + reproduce: RELAX §19.
17+
(Two port edits were required: the Metal `T.alloc_var` decode scratch lowered to a non-assignable
18+
`float[1]` on codegen_cuda → replaced with a register-fragment stage; and the microbench must pass the
19+
native `torch.float8_e4m3fn` not a `.view(uint8)` reinterpret, which segfaults the tvm_ffi adapter.)
20+
21+
---
22+
23+
**Status: DESIGN (read-only round) + §19 MEASURED microbench, 2026-06-04. Target: gb10 sm_121 (Grace-Blackwell, `tvm.cuda(0)`).**
424
This is Track 2 of the 3-lever Megatron-gap workflow (lever 1 = bs1→bs4; lever 2 = THIS doc, fp8
525
activations end-to-end; lever 3 = B2 grid-restructure). It is the user's thesis verbatim: *"why hold
626
bf16 activations at all if every kernel is fp8? that is BOTH speed (fp8 tensor cores) AND memory
@@ -22,11 +42,15 @@ any failure it RAISES with where+what — never an fp8→bf16 or bs4→bs1 silen
2242
(4 tokens × 1 byte = 2× of 1 token × 2 bytes). Concretely ~12.8 GB @8L / ~26 GB @28L for bs4-fp8
2343
vs the naive ~25.6/~52 GB bs4-fp16 — back to Megatron-class (~26 GB) at 8L. PROJECTION. (Correction
2444
per §18 adversarial verify: the earlier "bs4-fp8 ≈ bs1-fp16" headline was arithmetically wrong.)
25-
* **Speed (the de-risk target).** The dominant GEMMs at bs4 are the transformer-block MLP/attention
45+
* **Speed (NOW MEASURED, §19).** The dominant GEMMs at bs4 are the transformer-block MLP/attention
2646
GEMMs (M = bs·seq = 16384), NOT the SSD scan. On Blackwell these run on the **regular FP8 Tensor
27-
Cores** (MXFP8 / per-tensor cuBLASLt e4m3), which — unlike the FP4 SR/RHT path — DO exist on
28-
sm_121 (no tcgen05/TMEM dependency). The microbench measures the realized fp8-vs-bf16 TFLOPs at
29-
those exact shapes before any rewrite.
47+
Cores** (per-tensor cuBLASLt e4m3), which — unlike the FP4 SR/RHT path — DO exist on sm_121 (no
48+
tcgen05/TMEM dependency): **MEASURED 1.57–1.84× over bf16** (TE `DelayedScaling` E4M3, 52.1–61.8
49+
fp8 TFLOPs vs 33.3–34.3 bf16, rel-err 0.0375 ≤ 0.10). MXFP8 block-scaling stays upstream-blocked on
50+
12.0+. Our in-house `target="cuda"` dequant→`T.gemm` twin RUNS + parity-passes (0.0375) but is
51+
**0.18–0.42× as-is** (decoded-fp16 MMA on an untuned tile) — a memory lever (2.0× MEASURED) + the
52+
optimization target, not yet a speed lever. (Was: "the microbench measures … before any rewrite" —
53+
now measured; full table in RELAX §19.)
3054
* **Scaling recommendation.** Use **MXFP8 (E4M3, 32-element block / E8M0 scale) for ALL operands
3155
incl. activation gradients** as the primary recipe, with **per-tensor delayed-amax E4M3** (reusing
3256
`fp8_amax.py`) as the simpler fallback-FREE alternative for the in-house TileLang kernels that do
@@ -79,6 +103,13 @@ target for the SSD GEMMs that live inside our own grid kernels.
79103

80104
### 2b. Scaling strategy — RECOMMENDATION: MXFP8 (E4M3, block-32) primary; per-tensor delayed-amax secondary
81105

106+
> **§2b-status (MEASURED 2026-06-04, gb10 sm_121 / CC 12.1) — MXFP8 is UPSTREAM-BLOCKED here today; per-tensor delayed-amax (R1 tensorwise) is the route that RUNS.**
107+
> The MXFP8 "primary" recommendation below is the *target* recipe for Blackwell, but it does **not** run on gb10 (GB10, sm_121) this round, and we do **not** fake it (RULE #1). NVIDIA TransformerEngine's own Python gate `transformer_engine/pytorch/quantization.py::_compute_mxfp8_support()` (line 69) hard-raises `"MXFP8 (for all gemm layouts) is not supported on 12.0+ architectures yet."` for compute capability ≥ (12,0). Root cause (TE tech lead *ptrendx*, issue [#2668](https://github.com/NVIDIA/TransformerEngine/issues/2668), 2026-02-11): cuBLAS lacks the **non-TN GEMM layouts** MXFP8 backward needs on SM120/SM121 — a cuBLAS gap, **not** hardware/CUTLASS and **not** our code. The fix, PR [#3050](https://github.com/NVIDIA/TransformerEngine/pull/3050) (still an **OPEN DRAFT** as of 2026-05-29, complemented by PR [#2833](https://github.com/NVIDIA/TransformerEngine/pull/2833)), relaxes the gate **only** when `tex.get_cublasLt_version() >= 130600` (**cuBLASLt 13.6.0.2**) — and that cuBLASLt does **not exist anywhere yet** (PyPI max + box max are both 13.5.1.27 = `130501 < 130600`). So even a perfect cherry-pick of #3050 today would still `fail-closed` (`is_mxfp8_available()` → False), which is correct: forcing the gate lower would silently miscompute the MXFP8 backward (a RULE #1 violation). **VERDICT: MXFP8 on sm_121 is genuinely upstream-blocked on (a) an unmerged draft PR and (b) an unreleased cuBLASLt 13.6.0.2 dependency. Recorded as a measured FAIL, never silently degraded to bf16 or tensorwise.**
108+
>
109+
> **Forward plan (when NVIDIA ships cuBLASLt 13.6.0.2):** `pip install 'nvidia-cublas>=13.6.0.2'` into `cppmega-venv`, then update `/home/dave/TransformerEngine` to a SHA carrying BOTH PR #2833 and PR #3050 once merged (or cherry-pick `git fetch origin pull/2833/head:pr2833 && pull/3050/head:pr3050` onto a throwaway branch off local `main`, rebuild with `NVTE_CUDA_ARCHS=121a pip install -e . --no-build-isolation`). Expect conflicts: the installed TE is a fork-tainted build (HEAD `8d1d79bf`, v2.16.0.dev0, jewelmusicee remote + local cppmega MXFP8 transpose commits, 31 behind origin) — merge origin/main first. Re-verify forward **and** backward numerically before labeling the route fp8-pass; the drafts are unfinished ("TODO: code/git history clean up", tests box unchecked).
110+
>
111+
> **What runs on gb10 today:** **R1 tensorwise** (per-tensor Float8 delayed-amax, the "secondary" below) — TE supports it on sm_121 (NVIDIA forum: "FP8 with delayed scaling works correctly on these architectures as a workaround"); its only blocker was the NVRTC `libnvrtc-builtins.so.13.3` loader path, now fixed durably (system `ldconfig` cache + the committed `cppmega_mlx/_gb10_nvrtc_env.py` re-exec guard + the run-command `LD_LIBRARY_PATH` prefix). And **R2** (our CUDA `T.gemm` e4m3 dequant twin). §19 MEASURED: R1 tensorwise = real fp8 cuBLASLt MMA, 1.58–1.83× over bf16, rel_err 0.0375; R2 compiles+runs+parity 0.0375 but 0.18–0.42× (untuned tile + fp16-MMA).
112+
82113
* **Primary: MXFP8 block-32 E4M3 (E8M0 power-of-2 scale).** On Blackwell this is the native recipe.
83114
Decisive grounding from the 2025 literature + NVIDIA docs: **E4M3 for ALL tensors including
84115
activation gradients** (E5M2-for-gradients measurably regresses perplexity on Blackwell; the

0 commit comments

Comments
 (0)