Backends: CUDA kernels, loader ladder, gates, cache¶
mmaemu computes the same bits on every backend. A backend only supplies the chain function. That is the block-FMA
chain of the request's preset (ada: 16-term blocks, hopper: 32-term blocks) over a list of K indices, starting from
+0. Everything around it (split-K plans, slow-accumulation chunks, non-finite inputs, epilogue) is shared torch code in
mmaemu.model.
| Backend | What runs | Where | Formats | Needs |
|---|---|---|---|---|
cuda:tc3 |
fp16-HMMA "dummy accumulator" kernel (gated, optional) | CUDA, cc 8.0/8.6/8.9 SASS only | E4M3 x E4M3 | gates smoke + design_a |
cuda:v9 |
bf16 tensor-core exponent pass + FFMA.RZ chain (gated, optional) | CUDA, SASS sm_80/86/89/90/100/120 only (no PTX) | E4M3 x E4M3 | gates smoke + window |
cuda:v4 |
IEEE-only CUDA-core kernel (p2 v4 cfg9) | CUDA sm_80+ | E4M3 x E4M3 | gate smoke |
cuda:v1 |
IEEE-only fp32-pipe kernel (tcemu v1 cfg4, DEC1 shared LUT) | CUDA sm_75+ | E4M3 x E4M3 | gate smoke |
cuda:h4 |
the hopper preset's fast kernel: v4's design with 32-term blocks |
CUDA sm_80+ | E4M3 x E4M3 | gate smoke |
cuda:h1 |
the hopper preset's portable kernel: v1's design with 32-term blocks |
CUDA sm_75+ | E4M3 x E4M3 | gate smoke |
reference |
float64 model (mmaemu.model.chain) |
the tensors' device (CPU or CUDA) | all | nothing |
Every kernel implements one preset (KernelSpec.preset): v1, v4, v9 and tc3 the ada preset, h1 and h4 the hopper
preset. v1, v4, h1 and h4 use only IEEE-defined instructions: fp32 adds and multiplies with explicit rounding modes,
fma.rn.bf16x2, and integer byte SIMD. Their result is therefore the same on every GPU they run on. A failure of their
smoke gate is a bug, not a hardware property. v4's sm_80/86 builds and its compute_80 PTX decode E4M3 in software; they
have been compiled but not run on hardware, and the smoke gate checks them on first use. h4 has run on sm_89 and sm_120 only. v9 and tc3 depend on how a
GPU's tensor cores round, so they are gated: they are auto-selected only on validated capabilities (today sm_89) and
after their gates pass.
Selection (mmaemu.backends.registry)¶
select(req, backend="auto") takes backend from the argument. When the argument is "auto", $MMAEMU_BACKEND
overrides it. Accepted values are "auto", "reference", "cuda" and "cuda:<kernel>".
-
The reference backend is used in these cases:
"reference"was requested;- the tensors are on the CPU;
MMAEMU_DISABLE_CUDA=1is set;- no CUDA device is visible.
In the last three cases an explicit
"cuda..."request raisesBackendUnavailableErrorinstead. These paths never touch the CUDA driver. -
Otherwise the discovered kernels are tried in priority order: tc3 (40), v9 (30), v4 (20), v1 (10) for the
adapreset, h4 (20) and h1 (10) for thehopperpreset. A kernel serves only its own preset, so the two orders never mix.kernels.SPECSis always discovered;mmaemu.backends.cuda.fast.specs.SPECSis added when it is importable. -
Some kernels are skipped by design. This is silent, and
status()/explain_selection()give the reason. It happens when:- the format pair or preset is unsupported: a kernel serves only its own preset, and E5M2, mixed formats and
the presets without a kernel (
b200_mma_sync, custom presets) always run on the reference backend; - the compute capability is below
min_cc; - the architecture is outside the kernel's supported SASS/PTX targets; NVRTC does not extend that support (tc3 therefore stays unavailable on sm_120);
- the device's opt-in shared memory is too small;
- the kernel is gated and the capability is not in
validated_on.MMAEMU_ALLOW_GATED_KERNELS=1lifts this check, and so does an explicit"cuda:<kernel>"; the kernel's gates still run in both cases.
- the format pair or preset is unsupported: a kernel serves only its own preset, and E5M2, mixed formats and
the presets without a kernel (
-
Other skips count as failures: the module cannot be loaded (fatbin and NVRTC both failed), or a gate failed. The next kernel is tried. If none is left,
"auto"uses the reference backend and"cuda"raises. -
One
BackendFallbackWarningis emitted per (device, reason) and process. It is emitted when a kernel failed, or when the selected kernel was compiled with NVRTC because its fatbin was unusable. By-design skips never warn. The failures of one selection form one reason: a single warning names every kernel that failed and the backend that is used instead.
status(device=None) returns {name: BackendStatus} for every discovered kernel and reference. A kernel is
available only after it was loaded and passed its gates on that device. validated_here means the capability is
in validated_on. source is "fatbin" or "nvrtc". reason explains every unavailable kernel.
v1 and v4 have also passed the RTX 5070 Ti validation
on sm_120 and report validated_here=True there. For the ada preset AUTO selects v4 on that device.
h1 and h4 report validated_here=True on sm_89 and sm_120, from their runs on the
RTX 4060 and the RTX 5070 Ti
(h1,
h4).
On fa0fd90, T3 passed on the RTX 5070 Ti with v1, v4, h1 and h4
(T3), and the replay of the H100 probe through h4
passed there and on the RTX 4060 (replay); both again
after the profile rename (RTX 5070 Ti,
RTX 4060).
They are ungated, so validated_on decides only validated_here for them: selection and selftest do not read it.
v9's sm_120 window measurements pass, while its default selection remains gated
until that architecture is added to its validation scope.
On an H100 PCIe (sm_90), v1 and v4 passed T3 with the release fatbins; NVRTC builds of the same kernels (5c15e8a,
sources equal up to comments) matched the RTX 4060 on all 21 Evo 2 projection calls
(H100 report). The full device-independence
suite did not run there, so sm_90 is not in their validated_on. For the ada preset AUTO still selects v4 on
sm_90, but validated_here is False, and a consumer that requires it falls back to the reference backend.
Loader ladder (mmaemu.backends.cuda.modules)¶
Kernel modules are cached per (device ordinal, CUDA context), keyed by cuCtxGetId. A module and its
compile-time __constant__ decode tables belong to the context that loaded it. A second context or a second device
therefore loads its own copy (the prototype's kernels gave zeros there).
-
Fatbin. Release fatbins live in the package's
mmaemu/backends/cuda/bin/together withmanifest.json(formatmmaemu-fatbin/1). The loader uses a module's fatbin only if all of these hold:- the file's sha256 equals the manifest;
- the manifest's
source_sha256equals the hash of the kernel sources shipped next to it (otherwise the fatbin is stale); - the device can run one of its SASS architectures, or its PTX can be JIT-compiled for the device.
Loading a module takes about 2 ms (audit);
tests/test_cuda_runtime.py::test_fatbin_load_time_fresh_processchecks that it stays at or below 20 ms. -
NVRTC. The shipped
.cusource is compiled for the device'ssm_XY. The compile options are fixed:--fmad=false -default-device --std=c++17; callers can add options but cannot change these.- libnvrtc comes from torch's
nvidia-cuda-nvrtc-cu12wheel, so neither nvcc norCUDA_HOMEis needed. - The cubin is cached atomically in
$MMAEMU_CACHE_DIR/nvrtc/<sha256>.cubin. The key covers the source, the headers, the options, the architecture and the NVRTC version. - If the cache directory cannot be written, the cubin is kept in memory.
- NVRTC builds are bitwise equal to the fatbin builds (tested). An internal review of the prototype measured them 4-6% slower; unpaired runs here put v9 and tc3 within 1.5% of the fatbin builds (kernels_fast.md).
- libnvrtc comes from torch's
-
Otherwise
BackendUnavailableError, which names both reasons. The registry then moves to the next backend.
Launches run under torch.cuda.device(device) on torch.cuda.current_stream(device). CUDA graph capture is not
supported in v0.1: the non-finite-code check of the plan synchronises with the host.
Kernel ABI v1 and the ChainFn wrapper (mmaemu.backends.cuda.kernels)¶
extern "C" __global__ void mmaemu_<name>_e4m3e4m3(const uint8_t* A, const uint8_t* B, float* C, int M, int N, int K);
- A is
[M][K]E4M3 codes and B is[N][K], both row-major. C is[M][N]fp32. C[m][n]is the fast chain of the kernel's preset (KernelSpec.preset) over k = 0..K-1 from +0.- Sources are device-only: no torch headers, no host code, no standard headers (NVRTC has none).
- The kernel never faults, whatever the byte values.
chain_fn_for(spec, req) returns the ChainFn that the plan executor calls. It:
- accepts only
torch.uint8codes ortorch.float8_e4m3fnand raisesTypeErrorotherwise (B4: e5m2, fnuz and bf16 never reach a kernel); - raises
ValueErrorfor mixed devices; - makes the operands contiguous, and clones an operand whose address is not aligned to
align_bytes(B12: a misaligned 32-bit load is a sticky error that kills the CUDA context); - raises
ValueErrorfor a request of another preset; - checks
L % 16 == 0andL < k_max(v4 and h4: 2^24, v1 and h1: 2^30); h1 and h4 end a chain with L % 32 == 16 in a half block of zero codes, which the model excludes; - returns empty or +0 results for M, N or L = 0 without a launch;
- launches in row chunks when
ceil(M / tile_m)exceeds 65535.
The chain is given finite codes only: execute_plan sanitises NaN codes and patches the IEEE result afterwards.
The raw v1 and h1 kernels also propagate NaN codes to canonical NaN by themselves (the prototype's v1 decoded them
as 0).
| Kernel | Tile | Threads | Dynamic smem | SASS | PTX | k_max |
|---|---|---|---|---|---|---|
| v1 | 64 x 64 | 256 | 0 (12 KiB static) | sm_75, 80, 86, 89, 90, 100, 120 | compute_75 | 2^30 |
| v4 | 128 x 128 | 256 | 36,864 B | sm_80, 86, 89, 90, 100, 120 | compute_80 | 2^24 |
| h1 | 64 x 64 | 256 | 0 (22 KiB static) | sm_75, 80, 86, 89, 90, 100, 120 | compute_75 | 2^30 |
| h4 | 128 x 128 | 256 | 73,728 B | sm_80, 86, 89, 90, 100, 120 | compute_80 | 2^24 |
Shared memory above 48 KiB is enabled per function up to the device's opt-in limit
(CU_DEVICE_ATTRIBUTE_MAX_SHARED_MEMORY_PER_BLOCK_OPTIN). tc3 (81,920 B) and h4 (73,728 B) need it: modules.launch
opts in, and selection skips a kernel if the device's limit is lower.
v1 decode variants (compile-time switches in v1.cu)¶
| Variant | Switch | Shipped |
|---|---|---|
decode values from a per-block shared-memory copy of the __constant__ table (DEC1) |
MMAEMU_V1_DEC=1 |
see below |
| carry-free biased exponent bytes (exp1b) | MMAEMU_V1_EXP=1 |
see below |
The rule is to ship a variant only if it is bitwise equal and at least 1.2x faster on sm_89. The measured decision and its numbers are recorded in the v1 decision section.
Gates and the verdict cache (mmaemu.backends.cuda.selftest)¶
- smoke (every kernel) compares the kernel's ChainFn bit for bit with the CPU float64 reference on two cases:
- 16x8x64 random codes;
- 64x64x256 adversarial codes: subnormals and ±0, ±448, cancellation inside blocks, one exponent per 16-block, every finite code, and -0 everywhere.
- window (v9) and design_a (tc3) are registered by
mmaemu.backends.cuda.fast.gatesthroughspec.register_gate. A gate function may carry an integerversionattribute. run_gates(spec, device, force=False)runssmokeand thenspec.gates, and stops at the first failure.- Verdicts are cached in memory and in
$MMAEMU_CACHE_DIR/verdicts/<sha256>.json. The cache key is:- GPU UUID, compute capability and driver version;
- mmaemu version;
- sha256 of the loaded module image;
- gate name and gate version.
- A corrupt cache file is ignored and the gate runs again. A gate that raises is reported as failed and is not
cached. The v9 and tc3 gates catch errors inside their stages and return a failed
<gate>.error: ...verdict, which is cached like any other: after a transient error (for example out of memory), runpython -m mmaemu selftest --force.
Environment variables (read at call time)¶
| Variable | Effect |
|---|---|
MMAEMU_BACKEND |
default backend when the call says "auto" |
MMAEMU_DISABLE_CUDA=1 |
never use CUDA kernels; the driver is not touched |
MMAEMU_ALLOW_GATED_KERNELS=1 |
auto-select gated kernels on capabilities outside validated_on (their gates still run) |
MMAEMU_CACHE_DIR |
NVRTC cubins and gate verdicts (default ${XDG_CACHE_HOME:-~/.cache}/mmaemu). mmaemu writes no file anywhere else. It must not be writable by other users: cubins and verdicts are loaded from it without verification |
MMAEMU_LIBCUDA_PATH |
driver library; when set, the kernel loader tries only this path (otherwise libcuda.so.1, then /usr/lib/wsl/lib/libcuda.so.1); the live split-K query tries it first, then those two |
MMAEMU_NVRTC_PATH |
NVRTC library; when set, only this path is tried (otherwise the nvidia.cuda_nvrtc wheel, $CONDA_PREFIX/lib, $CUDA_HOME/lib64, find_library("nvrtc")) |
MMAEMU_FATBIN_DIR |
development/tests only: load fatbins from a local build instead of the package's bin/ |
MMAEMU_TEST_FORCE_FAIL |
tests only: comma list of fatbin, nvrtc and gate names that fail on purpose (forced gate failures are not cached) |
Nothing is initialised at import time. import mmaemu loads neither libcuda nor libnvrtc.
On the reference host, import torch itself maps libcuda: libcublasLt's initialiser dlopens it.
tests/test_cuda_runtime.py::test_no_visible_devices therefore checks two things: that mmaemu never opens the driver
(driver.loaded() is False), and that it adds no mapping.
Building fatbins (tools/build_fatbin.py)¶
python tools/build_fatbin.py --out build/fatbin # development (-lineinfo)
python tools/build_fatbin.py --release --out <dir> # release flags: -O3 --fmad=false, relative paths
python tools/build_fatbin.py --check [--out <dir>] # hash-only staleness check (CI; no nvcc)
- The tool refuses nvcc older than 12.8, which is needed for sm_100 and sm_120. It takes nvcc from
--nvcc,$NVCC,$CUDA_HOME/binorPATH; release fatbins are built with nvcc 12.8.61. A distribution's nvcc 12.0 is refused. - Architectures come from the KernelSpecs. Probe modules (
PROBE_MODULES) get SASS sm_80..sm_120 and no PTX. - Besides the fatbins and
manifest.json, the tool writes:cuobjdump.txt: the--list-elf --list-ptxlisting, which must equal the requested architectures. It uses a cuobjdump of at least 12.8 (a distribution's cuobjdump may be 12.0), so the tool also looks next to nvcc and in triton's wheel.build_report.json: ptxas registers and spills, compiler warnings, and the forbidden-PTX grep.
- It fails when any of these hold:
- a module's PTX for any of its architectures contains
fma.rz.bf16orfma.rz.bf16x2, which ptxas 12.0-13.3 miscompiles; - ptxas reports spill bytes, unless
--allow-spillsis given; - the listing differs from the KernelSpecs.
- a module's PTX for any of its architectures contains
- Release fatbins are
--releasebuilds committed tommaemu/backends/cuda/bin/. A branch that adds or changes a kernel commits that module's build (--release --modules <module>; its entry is added tobin/manifest.json, the other entries unchanged), with its build log underdocs/validation_logs/<date>/, as h1 (build) and h4 (build) were. At the release-candidate freeze the maintainer rebuilds every module with--release, compares it byte for byte with the committed fatbins and runs T3 on them; the log goes underdocs/validation_logs/<date>/(release.md, section 2). What the 0.1.0 freeze found, and why the committed v1 and v4 bytes were kept, is in rc-fatbin.
Benchmarks (tools/bench_kernels.py, tools/hostclock.py)¶
$GPU timeout 600 python tools/bench_kernels.py --kernels v1,v4 --shape 1b --M 2048 --data gauss40 \
--fatbin-dir build/fatbin_rel --out results.json
Method:
- Timing: CUDA events only. The host-clock gate runs first and the
perf_counter / CLOCK_MONOTONIC_RAWratio is recorded; on this WSL2 host perf_counter runs 6-10% fast. - Load source: kernels are loaded as users get them. Performance acceptance needs a fatbin (the package's release
fatbins, or
--fatbin-dir);--allow-nvrtcrecords NVRTC numbers instead, marked as such. The report records each kernel's image sha256. - Exactness gate before timing:
- v1 and h1 are compared with the CPU reference of their own preset on the first min(M, 64) rows x 256 columns;
- every other kernel is compared with the IEEE kernel of its preset (v1 for
ada, h1 forhopper) on the full output; - any mismatch stops the run, which writes its report and exits 1.
- Warm-up and windows: at least 1.5 s of warm-up per kernel, then interleaved windows of at least 1.0 s each. The reported value is the median TMAC/s over the windows.
- Clocks:
nvidia-smi(/usr/lib/wsl/lib/nvidia-smion WSL) samples the SM clock, power and throttle reasons every 200 ms. MAC/clk/SM = TMAC/s / (mean SM clock × SM count). --timer host-raw(CLOCK_MONOTONIC_RAW around synchronised launches) exists for cross-checks. It is refused when the host-clock gate fails.
Platforms¶
- The CUDA kernels are tested on Linux x86_64 only (WSL2 included). mmaemu does not check the platform: they load
wherever
libcuda.so.1is found (Linux aarch64 is untested and self-test gated like any untested device). On native Windows that library is not found, so mmaemu warns once and uses the reference backend. - Multi-GPU is untested. The per-(device, context) cache is exercised with a second context on one GPU.
- Validation status:
- v1 and v4 are validated on sm_89 (RTX 4060) and sm_120 (RTX 5070 Ti). On sm_90 (H100) they passed T3 but are
not in
validated_on(see above). - v9 and tc3 are validated on sm_89 only.
- h1 and h4 are validated on sm_89 (RTX 4060) and sm_120 (RTX 5070 Ti); they have never run on an H100 (sm_90).
- On other capabilities the kernels are unvalidated: the smoke gate runs on first use, and v1/v4/h1/h4 are IEEE-exact by construction.
- v1 and v4 are validated on sm_89 (RTX 4060) and sm_120 (RTX 5070 Ti). On sm_90 (H100) they passed T3 but are
not in
v1 variant decision¶
Measured on the RTX 4060 (sm_89, 24 SMs, driver 572.16, WSL2) on 2026-10-12 under the GPU lock with
tools/bench_kernels.py:
- workload: Evo 2 1B shape (M 2048, N 5760, K 1920), gauss40 data;
- builds:
nvcc 12.8.61 -O3 --fmad=false; - timing: CUDA events, 1.5 s warm-up, 5 interleaved windows of at least 1.0 s each.
Every variant was bitwise equal to the shipped v1 on all 11,796,480 outputs, and v1 itself was equal to the CPU
reference on a 64x256 block. The raw report is build/bench/v1_variants_1b_2048_gauss40.json (not committed).
| v1 variant | TMAC/s (median) | vs base | vs DEC1 |
|---|---|---|---|
base: __constant__ LUT, __vadd4/__vmaxs4 (DEC0, EXP0; = tcemu v1 cfg4 + fixes) |
0.515 | 1.00x | |
| DEC1 shared-memory LUT (DEC1, EXP0) | 0.636 | 1.235x | 1.00x |
| exp1b only (DEC0, EXP1) | 0.652 | 1.265x | |
| DEC1 + exp1b (DEC1, EXP1) | 0.725 | 1.406x | 1.138x |
The decision applies the rule to each change relative to what is already adopted, in the spec's order:
- DEC1 is shipped (
MMAEMU_V1_DEC=1): bitwise equal and 1.235x faster than the base. - exp1b on top of DEC1 adds only 1.138x, below the 1.2x bar, so it is not shipped (
MMAEMU_V1_EXP=0). It stays a compile-time switch, which keeps the measurement reproducible:--v1-variant dec1exp1=MMAEMU_V1_DEC=1:MMAEMU_V1_EXP=1. Adopting it is a maintainer decision: 0.725 vs 0.636 TMAC/s.
The shipped v1 SASS is instruction-for-instruction identical to the audit's DEC1 build of
verify-p0-profile-0/src/variants.cu (cfg4, DEC 1): 2784 instructions, 0 differing lines after normalising.
The shipped v4 SASS is identical to the audit's p2 v4 cfg9 build: 4320 instructions, 0 differing lines.
So the audit's exactness and speed evidence carries over.
Acceptance run (a development tree, before the v0.1 components were integrated):
- fatbin: release-flag fatbin built locally by
tools/build_fatbin.py --release; - workload: 1b shape, M 2048, gauss40;
- tree: the v0.1 scaffold and numerics with the CUDA runtime and kernels v1 / v4;
- report:
build/bench/A7_v1_v4_1b_2048_gauss40.json(not committed).
| Kernel | TMAC/s median (min-max) | Mean SM clock in its windows | MAC/clk/SM | Threshold |
|---|---|---|---|---|
| v1 (DEC1) | 0.637 (0.634-0.638) | 2555 MHz | 10.4 | >= 0.60 |
| v4 cfg9 | 1.354 (1.352-1.356) | 2512 MHz | 22.5 | >= 1.25 |
Conditions during the run:
- Throttle reason 0x4: the software power cap.
nvidia-smi -q -d POWERsamples averaged 110 W against the 115 W limit. WSL reports no instantaneouspower.draw.- The host-clock ratio
perf_counter / CLOCK_MONOTONIC_RAWwas 1.031. This is recorded only; all timing used CUDA events.