User guide¶
This guide walks through mmaemu by task: installing and checking it, computing a first GEMM, choosing a platform profile, dealing with split-K, picking a backend, putting mmaemu inside a model, and testing with it. The exact arithmetic is specified in semantics.md; every function is listed in api.md.
1. What mmaemu does, in one paragraph¶
An FP8 GEMM on NVIDIA tensor cores is not an IEEE computation. The tensor core adds blocks of products after
aligning them to the block's largest exponent and truncating them, cuBLAS may split the K dimension and add the
partial sums in an order of its own choosing, and the epilogue of torch._scaled_mm rounds twice. Two GPUs with
different tensor cores, or the same GPU with a different cuBLAS, therefore give different bits for the same FP8
GEMM. mmaemu recomputes the GEMM exactly as one reference platform computes it (in v0.1: the H100 PCIe, Hopper,
by default, or the RTX 4060, Ada, both with cuBLASLt 12.8.3.14), on the CPU or on another CUDA GPU, with kernels
that return the reference bits wherever they run: IEEE-exact kernels everywhere, tensor-core kernels only where a
per-device self-test confirms the properties they rely on.
2. Install and check¶
mmaemu needs Python 3.10 or newer, PyTorch 2.6 or newer and NumPy:
pip install mmaemu
The development version installs from the repository: pip install "git+https://github.com/astding/mmaemu".
No CUDA toolkit, nvcc or C++ compiler is needed: the package contains prebuilt CUDA kernels (fatbins) that are
loaded through the NVIDIA driver. If they cannot be used, mmaemu compiles the shipped kernel sources with NVRTC
(which comes with PyTorch's CUDA wheels), and otherwise falls back to the exact reference model, with one
BackendFallbackWarning.
Check the installation:
python -m mmaemu info # versions, device, matching profiles, backend status with reasons, presets
python -m mmaemu selftest # runs the kernel self-tests on the GPU; exit 0 = every required kernel passed
info tells you which backend scaled_mm will use on your GPU and why the others are unavailable, and whether
your GPU is a reference platform (Profile.matches_device). On a GPU that is not one, mmaemu still computes the
reference platform's result. What it computes does not depend on where it runs, with one exception: the split-K
decision of a shape missing from the profile's table is queried live on the reference GPU, and elsewhere inferred
(S = 1 when M*N is above the profile's envelope for the setting) or refused with SplitKUnknownError.
3. A first GEMM¶
mmaemu.scaled_mm takes the arguments of torch 2.7.1 torch._scaled_mm (same names, order and defaults):
import torch
import mmaemu
torch.manual_seed(0)
x = (torch.randn(64, 1920) * 40).clamp(-448, 448).to(torch.float8_e4m3fn) # input [M, K], row-major
w = (torch.randn(5760, 1920) * 40).clamp(-448, 448).to(torch.float8_e4m3fn) # weight [N, K], row-major
scale_x, scale_w = torch.tensor(1 / 64), torch.tensor(1 / 32) # tensor-wise fp32 scales
y = mmaemu.scaled_mm(x, w.t(), scale_x, scale_w, out_dtype=torch.bfloat16, use_fast_accum=True)
mat2is [K, N] column-major, which is whatw.t()gives for a row-major [N, K] weight, as in torch.- The defaults follow torch:
use_fast_accum=False, andout_dtypeis the input dtype. Most FP8 inference code (Transformer Engine, Evo 2) uses fast accumulation with a bf16 output; pass both explicitly. - The scales multiply the result: y = (x @ w.T) * scale_x * scale_w, with the rounding of the hardware epilogue. If your code stores quantisation scales (x_fp8 = x * s), pass their reciprocals, as Transformer Engine does.
The same call on CUDA tensors runs the CUDA kernels and returns the same bits:
yg = mmaemu.scaled_mm(x.cuda(), w.cuda().t(), scale_x.cuda(), scale_w.cuda(),
out_dtype=torch.bfloat16, use_fast_accum=True)
mmaemu.testing.assert_bitwise_equal(yg.cpu(), y)
To see what a call will do without running it:
print(mmaemu.explain(x, w.t(), scale_x, scale_w, out_dtype=torch.bfloat16, use_fast_accum=True))
CallPlan(validation='ok', profile='hopper', preset='hopper', preset_maturity='validated',
split=SplitDecision(S=1, scheme='none', source='table', verified=True, table_id='heur_h100pcie_lt12.8.3.14',
note='table row: algo 66, tile 134, numSplitsK 1, customOption 0'),
backend='reference', fallbacks=['cuda: tensors on cpu'], warnings=[])
The same is available from the shell: python -m mmaemu explain 64 5760 1920 --out bf16 --fast (M N K).
mmaemu.accumulate(x, w, use_fast_accum=True) returns the fp32 accumulator before the scales are applied (note:
w, not w.t(); it takes both operands as [rows, K]). It has no default for use_fast_accum on purpose.
4. Choose a profile¶
A profile names the platform whose results you want: the GPU, its SM count, the cuBLASLt version, and the caller with its cuBLAS workspace size. The caller matters because the workspace size changes which split-K kernel cuBLAS picks.
| Profile | Use it for results produced by |
|---|---|
hopper (default) |
torch._scaled_mm on an H100 PCIe (114 SMs, 1 MiB workspace) |
hopper-te |
Transformer Engine 2.3 on an H100 PCIe (32 MiB workspace), e.g. Evo 2 |
hopper-ws0 |
cuBLAS with CUBLASLT_WORKSPACE_SIZE=0 on an H100 PCIe (never splits) |
ada |
torch._scaled_mm on an RTX 4060 (1 MiB workspace) |
ada-te |
Transformer Engine 2.3 on an RTX 4060 (4 MiB workspace), e.g. Evo 2 |
ada-ws0 |
cuBLAS with CUBLASLT_WORKSPACE_SIZE=0 on an RTX 4060 (never splits) |
Pass profile="hopper-te" per call, or set MMAEMU_PROFILE. On a GPU the H100 profiles run E4M3 x E4M3 on the
CUDA kernel h4 (the hopper preset's 32-product blocks with v4's design, about 4.1 to 4.6 TMAC/s on an RTX 5070 Ti), or
on h1 (v1's design, about 2 TMAC/s on an RTX 5070 Ti) on sm_75 GPUs, as h4 needs sm_80; mixed formats run on the reference backend, as for every profile. The experimental profiles
(hopper-experimental, b200-mma-sync-experimental) evaluate instruction models without a reference platform; they
warn, and have no split-K table. Details: presets_profiles.md.
5. Split-K¶
cuBLAS splits the K dimension of some GEMMs (mostly small M x N with large K) and adds the partial results in an
order that depends on the shape, the formats, the output dtype, the accumulation mode, the workspace and the GPU.
mmaemu reads that decision from a table recorded on the profile's GPU (splitk="auto", the default).
- If the table decides, the call runs.
explainshows the decision and where it came from. - On the profile's GPU itself, a shape missing from the table is decided by a live query of cuBLASLt's heuristic.
- Elsewhere, if the shape's MN is above the profile's envelope for its setting, mmaemu infers S = 1 and says so
in the decision's note (
MMAEMU_SPLITK_STRICT=1turns this inference off). On the RTX 4060 profiles the bound is twice the largest MN that ever split in the table for the setting; cuBLASLt splits shapes up to 1.27 times that M*N there. On the H100 profiles (the default) the bound depends on K and comes from a dense sweep of the H100's heuristic (1,024 <= K <= 4,194,304). It passed a sealed held-out test at torch's 1 MiB workspace and failed it at Transformer Engine's 32 MiB (1 of 77,518 shapes), sohopper-teinferences emitUnvalidatedEnvelopeWarning(splitk_tables.md). - Otherwise the call raises
SplitKUnknownErrorinstead of guessing. Passsplitk=1(no split) or the known split,splitk=(S, "contiguous")on the H100 profiles and(S, "serial")/(S, "pairwise")on the RTX 4060 ones, or record the shape on the profile's GPU withtools/record_heuristics.py.
Typical model projections (large M x N) never split on the reference GPU; all Evo 2 projection shapes are S = 1.
6. Backends and devices¶
| Backend | Where | Speed (RTX 5070 Ti, Evo 2 1B shape) |
|---|---|---|
reference |
the tensors' device, CPU or CUDA: the float64 model | slow; for tests and small problems |
cuda:v1 |
CUDA, sm_75 and newer, IEEE-exact | 1.9 TMAC/s |
cuda:v4 |
CUDA, sm_80 and newer, IEEE-exact | 4.0 TMAC/s |
cuda:v9 |
CUDA, gated: bf16 tensor cores for the exponents | 4.8 TMAC/s (opt-in outside sm_89) |
cuda:tc3 |
CUDA, gated: fp16 tensor cores, sm_80/86/89 binaries only | no sm_120 binary (2.94 TMAC/s on the RTX 4060, T6) |
cuda:h4 |
CUDA, sm_80 and newer, IEEE-exact; the hopper preset (the H100 profiles) |
4.1 TMAC/s |
cuda:h1 |
CUDA, sm_75 and newer, IEEE-exact; the hopper preset (the H100 profiles) |
2.0 TMAC/s |
With backend="auto" mmaemu takes the highest-priority kernel of the profile's preset that loaded and passed its
self-test on the device (ada: tc3 > v9 > v4 > v1 > reference; hopper: h4 > h1 > reference). v1, v4, h1 and h4 use
only IEEE-defined instructions, so their results do not depend on the GPU; v1, v4, h1 and h4 are validated on sm_89
(RTX 4060) and sm_120 (RTX 5070 Ti). v9 and tc3 depend on how a GPU's
tensor cores round, so they are auto-selected only on sm_89, where they were validated; elsewhere
MMAEMU_ALLOW_GATED_KERNELS=1 allows them after their gates pass. E5M2 and mixed-format inputs always use the
reference backend. Force a backend with backend="cuda:v4" or MMAEMU_BACKEND. Details: backends.md.
The CPU is a full platform: every result can be computed with CPU tensors, no GPU needed. On the CPU, mmaemu
refuses to run while flush-to-zero is enabled (torch.set_flush_denormal(True)), because that would change
subnormal arithmetic.
7. Inside a model¶
mmaemu replaces one call: wherever a model computes an FP8 GEMM with torch._scaled_mm (or Transformer Engine's
FP8 Linear), call mmaemu.scaled_mm with the same FP8 operands and scales. A minimal module:
class EmulatedFP8Linear(torch.nn.Module):
"""y = x @ W.T with E4M3 operands, as Transformer Engine 2.3 computes it on an H100 PCIe (no bias)."""
def __init__(self, weight, scale_x, scale_w, profile="hopper-te"):
super().__init__()
self.scale_x, self.scale_w, self.profile = scale_x, scale_w, profile # quantisation scales (fp32 tensors)
self.wq = (weight.detach().float() * scale_w).clamp(-448, 448).to(torch.float8_e4m3fn)
def forward(self, x):
xq = (x.float() * self.scale_x).clamp(-448, 448).to(torch.float8_e4m3fn).reshape(-1, x.shape[-1])
y = mmaemu.scaled_mm(xq, self.wq.t(), 1 / self.scale_x, 1 / self.scale_w, # torch takes the inverse scales
out_dtype=torch.bfloat16, use_fast_accum=True, profile=self.profile)
return y.reshape(*x.shape[:-1], -1)
profile="ada-te" gives the RTX 4060's TE GEMM instead, as in the Evo 2 notebook.
Things to get right, all of which change bits:
- The quantisation. The FP8 operands must be the ones the reference run used: same scales, same saturation
(
clampto +-448 for E4M3 before the cast), same rounding of the cast. Hash them against the reference if you can; the Evo 2 notebook does. - Padding. Some callers pad M (vortex pads the sequence length to a multiple of 16 on its TE path). Padding rows do not change the other rows' results, but a different M can change the split-K decision.
- Inference only. mmaemu refuses inputs that require grad while grad mode is on. Detach the weight, and run
the model under
torch.inference_mode(). - bias. Not supported in v0.1 (raises). Add the bias yourself after the GEMM only if the reference did the same.
What this gives you: each emulated GEMM returns the reference GPU's bits. The rest of the model (bf16 matrix products, convolutions, normalisations, attention) still runs natively on your GPU and is not emulated, so a full model's outputs are close to the reference GPU's, not identical; evo2.md measures how close for Evo 2.
8. Testing with mmaemu¶
mmaemu.testing.assert_bitwise_equal(actual, expected) compares dtypes, shapes and bit patterns, so +0 and -0
differ and NaN payloads count; on failure it reports the mismatch count by class (zero sign, NaN payload, value)
and the first ten mismatching elements. mmaemu.testing.bits(t) gives the integer view of a tensor.
Typical uses:
- Golden outputs without a GPU. Compute expected FP8 GEMM results on a CPU-only CI runner and compare your kernel's output on the GPU later.
- Checking a native result. On an RTX 4060 with cuBLASLt 12.8.3.14 and torch 2.7.1,
torch._scaled_mmandmmaemu.scaled_mm(profileada) return the same bits for the validated scope; a difference there is a bug report. - Attributing a discrepancy. When a model differs between two GPUs, replace its FP8 GEMMs with mmaemu on both: what remains comes from the non-FP8 operations.
9. Errors and warnings¶
| Exception / warning | Meaning | What to do |
|---|---|---|
ReferenceRejectsError |
torch 2.7.1 or the profile's cuBLAS would reject this call (layout, divisibility, dtypes) | fix the call as you would for torch._scaled_mm |
UnsupportedFeatureError |
bias, row-wise scales, grad mode, or a feature of an experimental preset | see limitations.md |
SplitKUnknownError |
the split-K decision is unknown for this shape | pass splitk explicitly (section 5) |
BackendUnavailableError |
an explicitly requested backend cannot run here | use backend="auto" |
MmaemuError |
e.g. CPU flush-to-zero is on | torch.set_flush_denormal(False) |
ExperimentalPresetWarning |
an experimental preset or profile is in use | read its validated scope |
UnverifiedSplitWarning |
a split decision whose reduction was never replayed on hardware | treat the result as experimental |
UnvalidatedEnvelopeWarning |
(an UnverifiedSplitWarning) S = 1 inferred by an envelope that failed its held-out test (H100 at 32 MiB) |
pass splitk if you know the H100's split, or treat the result as experimental |
BackendFallbackWarning |
a faster CUDA kernel failed to load or failed a gate, or a kernel had to be compiled with NVRTC; a slower kernel, the same kernel built by NVRTC, or the reference model runs instead (the message says which) | python -m mmaemu info shows why |
The exception classes above derive from mmaemu.MmaemuError and from the matching built-in class (RuntimeError,
NotImplementedError, LookupError); argument errors raise the built-in TypeError, ValueError or KeyError.
10. Environment variables¶
| Variable | Effect |
|---|---|
MMAEMU_PROFILE |
default profile |
MMAEMU_BACKEND |
default backend (auto, reference, cuda, cuda:v1, cuda:v4, cuda:v9, cuda:tc3, cuda:h1, cuda:h4) |
MMAEMU_SPLITK_STRICT=1 |
no envelope inference of S = 1 |
MMAEMU_DISABLE_CUDA=1 |
never touch CUDA |
MMAEMU_ALLOW_GATED_KERNELS=1 |
allow v9 / tc3 on capabilities they were not validated on, if their gates pass |
MMAEMU_CACHE_DIR |
NVRTC cubins and self-test verdicts (default ${XDG_CACHE_HOME:-~/.cache}/mmaemu) |
MMAEMU_LIBCUDA_PATH, MMAEMU_NVRTC_PATH |
explicit driver / NVRTC library paths |