lenatriestounderstand

Lab · runnable experiments

Matrix Multiplication: The Operation Behind Modern AI

Created Sep 12, 2026 Updated Sep 12, 2026

Read the parent note

One operation, ten rungs, and a stopwatch. Every rung here computes the same C = AB with the same arithmetic — 2MNK floating-point operations, not one more — and they differ by two orders of magnitude in speed. This lab measures the whole ladder on one GPU, and checks every rung against the roofs the hardware actually has.

Part 1 measures this card’s two roofs again, so the lab stands on its own: the FP32 compute roof and the memory roof. Part 2 climbs the ladder — naive, uncoalesced, shared-memory tiles, register tiles, tensor cores — with every result checked for correctness against a float64 reference. Part 3 varies the tile size, which is the same thing as varying arithmetic intensity, and places each kernel on the roofline. Part 4 is precision: what FP16 and the tensor cores buy in speed and cost in accuracy. Part 5 is cuBLAS itself — how its speed depends on shape, alignment and layout, which kernel it picks, and where the ladder ends up against it.

Setup

Run on Kaggle with Settings → Accelerator → GPU T4 x2 (the lab uses one card) and Internet on, which is only needed if the image has no CuPy.

import os
os.environ.setdefault("CUDA_DEVICE_ORDER", "PCI_BUS_ID")

import sys, json, time, datetime, platform, subprocess, threading, traceback, contextlib
import numpy as np, pandas as pd, torch
import matplotlib.pyplot as plt

assert torch.cuda.is_available(), "No GPU: Settings → Accelerator → GPU T4 x2"
DEV = torch.device("cuda:0")
torch.cuda.set_device(DEV)
torch.backends.cuda.matmul.allow_tf32 = False     # Turing has no TF32; keep fp32 meaning fp32
torch.backends.cudnn.allow_tf32 = False

try:
    import cupy as cp
except ImportError:
    subprocess.run([sys.executable, "-m", "pip", "install", "-q", "cupy-cuda12x"], check=True)
    import cupy as cp
cp.cuda.Device(0).use()
assert cp.cuda.get_current_stream().ptr == 0 and torch.cuda.current_stream().cuda_stream == 0

INK, LINE, CREAM = "#2a2a2a", "#d9d3c7", "#f6f2e9"
BLUE, EMBER, FOREST, GRAY, PLUM = "#3b6ea5", "#c4521e", "#2e7d5b", "#9a9384", "#7a4f8c"
plt.rcParams.update({"font.size": 9, "axes.edgecolor": LINE, "axes.spines.top": False,
                     "axes.spines.right": False, "figure.dpi": 120})

RESULTS = {"meta": {}, "errors": {}}


@contextlib.contextmanager
def experiment(name):
    """One experiment per cell. A failure is printed and recorded, and the run moves on."""
    t0 = time.time()
    try:
        yield
    except Exception:
        RESULTS["errors"][name] = traceback.format_exc()
        print(f"!! {name} failed:\n{traceback.format_exc()}")
    finally:
        print(f"[{name}: {time.time() - t0:.1f} s]")


print("executed:", datetime.date.today().isoformat())
print("torch", torch.__version__, "| CUDA", torch.version.cuda, "| CuPy", cp.__version__)
executed: 2026-09-12
torch 2.10.0+cu128 | CUDA 12.8 | CuPy 14.0.1

The stopwatches. CUDA events time the GPU; a sampler polls the card’s sensors while a workload runs; and one more, steady, exists because of what the GPU note measured: on this card a short burst of tensor-core work runs at more than twice the clock of sustained work. Comparing a kernel timed in a burst against one timed after the power limit has bitten would measure the card’s mood, not the code. So every number in this lab that is compared with another number is taken after half a second of the same work, with the clock already settled.

def gpu_time(fn, iters=15, warmup=3, budget=1.0):
    """Median seconds of fn(), measured on the GPU with CUDA events."""
    for _ in range(warmup):
        fn()
    torch.cuda.synchronize()
    s, e = torch.cuda.Event(enable_timing=True), torch.cuda.Event(enable_timing=True)
    s.record(); fn(); e.record(); e.synchronize()
    n = int(min(iters, max(3, budget / max(s.elapsed_time(e) / 1e3, 1e-6))))
    ts = []
    for _ in range(n):
        s.record(); fn(); e.record(); e.synchronize()
        ts.append(s.elapsed_time(e) / 1e3)
    return float(np.median(ts))


def smi(fields="clocks.sm,power.draw,temperature.gpu"):
    out = subprocess.run(["nvidia-smi", "-i", "0", f"--query-gpu={fields}",
                          "--format=csv,noheader,nounits"], capture_output=True, text=True).stdout
    res = {}
    for key, val in zip(fields.split(","), (v.strip() for v in out.strip().split(","))):
        try:
            res[key] = float(val)
        except ValueError:
            res[key] = val
    return res


class Sampler:
    def __init__(self, period=0.2):
        self.period, self.samples, self._stop = period, [], threading.Event()

    def _loop(self):
        while not self._stop.is_set():
            self.samples.append(smi())
            self._stop.wait(self.period)

    def __enter__(self):
        self._thread = threading.Thread(target=self._loop, daemon=True)
        self._thread.start()
        return self

    def __exit__(self, *exc):
        self._stop.set(); self._thread.join()

    def summary(self):
        df = pd.DataFrame(self.samples[1:] or self.samples)
        return {k: float(df[k].median()) for k in df.columns if pd.api.types.is_numeric_dtype(df[k])}


def sustained(fn, seconds=2.0):
    """Seconds per call under continuous load, plus the clocks it ran at."""
    fn(); torch.cuda.synchronize()
    s, e = torch.cuda.Event(enable_timing=True), torch.cuda.Event(enable_timing=True)
    with Sampler() as smp:
        s.record()
        n, t0 = 0, time.perf_counter()
        while time.perf_counter() - t0 < seconds:
            fn(); n += 1
            if n % 4 == 0:
                torch.cuda.synchronize()
        e.record(); e.synchronize()
    return s.elapsed_time(e) / 1e3 / n, smp.summary()


def steady(fn, seconds=0.5, min_calls=3):
    """Median seconds per call once the card has settled into its power-capped state.

    Short bursts on this card run at a much higher clock than sustained work (the GPU note
    measures the effect), so any comparison between shapes or kernels has to be made in the
    same state. Every number in this lab that is compared with another comes from here.

    CUDA launches are asynchronous, so both phases synchronise after every call: otherwise
    the host could queue thousands of calls in a quarter of a second and the GPU would spend
    minutes — and degrees — draining them before the measurement even starts."""
    t0 = time.perf_counter()
    while time.perf_counter() - t0 < seconds / 2:          # settle the clock, one call at a time
        fn()
        torch.cuda.synchronize()
    s_ev, e_ev = torch.cuda.Event(enable_timing=True), torch.cuda.Event(enable_timing=True)
    ts, t0 = [], time.perf_counter()
    while time.perf_counter() - t0 < seconds or len(ts) < min_calls:
        s_ev.record(); fn(); e_ev.record(); e_ev.synchronize()
        ts.append(s_ev.elapsed_time(e_ev) / 1e3)
    return float(np.median(ts))


def sweep(configs, measure, repeats=3, seed=0):
    """Measure every config `repeats` times in a freshly shuffled order each round, so that a
    card warming up over the sweep can't masquerade as an effect of the config.
    Returns {config: [seconds, ...]}."""
    rng = np.random.default_rng(seed)
    out = {c: [] for c in configs}
    for _ in range(repeats):
        for i in rng.permutation(len(configs)):
            out[configs[i]].append(measure(configs[i]))
    return out


def jsonable(o):
    if isinstance(o, np.bool_):
        return bool(o)
    if isinstance(o, np.integer):
        return int(o)
    if isinstance(o, np.floating):
        return float(o)
    if isinstance(o, np.ndarray):
        return o.tolist()
    return str(o)


def free_gpu():
    cp.get_default_memory_pool().free_all_blocks()
    torch.cuda.empty_cache()

Part 1 — the two roofs, again

A matmul kernel can only be judged against what the card can do, so the lab re-measures both ceilings rather than importing them: the FP32 arithmetic roof (a kernel of nothing but independent multiply-adds in registers) and the memory roof (a streaming read).

ROOF_SRC = r'''
extern "C" __global__ void fma_peak(float* out, int iters) {
    float a0 = 1e-6f * threadIdx.x, a1 = a0 + 0.1f, a2 = a0 + 0.2f, a3 = a0 + 0.3f,
          a4 = a0 + 0.4f, a5 = a0 + 0.5f, a6 = a0 + 0.6f, a7 = a0 + 0.7f;
    const float b = 0.99999f, c = 1e-5f;
    #pragma unroll 4
    for (int k = 0; k < iters; ++k) {
        a0 = fmaf(a0, b, c); a1 = fmaf(a1, b, c); a2 = fmaf(a2, b, c); a3 = fmaf(a3, b, c);
        a4 = fmaf(a4, b, c); a5 = fmaf(a5, b, c); a6 = fmaf(a6, b, c); a7 = fmaf(a7, b, c);
    }
    out[blockIdx.x * blockDim.x + threadIdx.x] = a0 + a1 + a2 + a3 + a4 + a5 + a6 + a7;
}

extern "C" __global__ void stream_read(const float4* __restrict__ x, long long n4, float* sink) {
    long long tid = blockIdx.x * (long long)blockDim.x + threadIdx.x;
    long long nth = (long long)gridDim.x * blockDim.x;
    float acc = 0.f;
    #pragma unroll 4
    for (long long i = tid; i < n4; i += nth) {
        float4 v = x[i];
        acc += v.x + v.y + v.z + v.w;
    }
    if (acc == 1234.5f) sink[0] = acc;
}
'''
ROOFS = cp.RawModule(code=ROOF_SRC)

with experiment("roofs"):
    props = cp.cuda.runtime.getDeviceProperties(0)
    name = props["name"].decode() if isinstance(props["name"], bytes) else props["name"]
    sms = props["multiProcessorCount"]
    stat = smi("clocks.max.sm,power.limit")
    full_blocks = sms * (props["maxThreadsPerMultiProcessor"] // 256)

    out = cp.empty(full_blocks * 8 * 256, dtype=cp.float32)
    nth = out.size
    iters = 30000
    t, clocks = sustained(lambda: ROOFS.get_function("fma_peak")(
        (full_blocks * 8,), (256,), (out, np.int32(iters))))
    fp32_roof = nth * iters * 16 / t
    del out

    buf = cp.random.random(256 * 2**20 // 4, dtype=cp.float32)
    sink = cp.zeros(1, dtype=cp.float32)
    t = gpu_time(lambda: ROOFS.get_function("stream_read")(
        (full_blocks,), (256,), (buf, np.int64(buf.size // 4), sink)), iters=7)
    bw_roof = buf.nbytes / t
    del buf
    free_gpu()

    spec = {"gpu": name, "sms": int(sms), "max_clock_mhz": stat["clocks.max.sm"],
            "power_limit_w": stat["power.limit"], "fp32_roof": fp32_roof, "bw_roof": bw_roof,
            "ridge_fp32": fp32_roof / bw_roof, "clocks_under_fma": clocks}
    RESULTS["spec"] = spec
    print(f"{name}: {sms} SMs")
    print(f"FP32 compute roof : {fp32_roof / 1e12:6.2f} TFLOP/s  (at {clocks.get('clocks.sm', 0):.0f} MHz, "
          f"{clocks.get('power.draw', 0):.0f} W)")
    print(f"memory roof       : {bw_roof / 1e9:6.0f} GB/s")
    print(f"ridge point       : {fp32_roof / bw_roof:6.1f} FLOP per byte")
Tesla T4: 40 SMs
FP32 compute roof :   6.41 TFLOP/s  (at 1335 MHz, 67 W)
memory roof       :    265 GB/s
ridge point       :   24.1 FLOP per byte
[roofs: 2.8 s]

Part 2 — the ladder

Four hand-written kernel designs, in full — one in two thread mappings, one in three tile sizes, one with a register patch from 1×1 to 8×8 — and then cuBLAS in three precision setups. They are the same algorithm — three nested loops — arranged differently in memory. Read them as a sequence: each one changes exactly one thing about where the operands come from, and nothing about the arithmetic.

MM_SRC = r'''
// ── 1. naive: one thread per output element, everything from global memory ────
extern "C" __global__ void mm_naive(const float* __restrict__ A, const float* __restrict__ B,
                                    float* __restrict__ C, int M, int N, int K) {
    int col = blockIdx.x * blockDim.x + threadIdx.x;   // consecutive threads → consecutive columns
    int row = blockIdx.y * blockDim.y + threadIdx.y;
    if (row >= M || col >= N) return;
    float acc = 0.f;
    for (int k = 0; k < K; ++k) acc += A[row * K + k] * B[k * N + col];
    C[row * N + col] = acc;
}

// ── 2. the same kernel with the thread mapping transposed ────────────────────
// Consecutive threads now differ in *row*, so their loads from A are a whole row —
// K floats — apart, and their stores into C are strided by N. (Their loads from B
// coincide instead; it is A's traffic that stops coalescing.)
extern "C" __global__ void mm_naive_uncoalesced(const float* __restrict__ A, const float* __restrict__ B,
                                                float* __restrict__ C, int M, int N, int K) {
    int row = blockIdx.x * blockDim.x + threadIdx.x;
    int col = blockIdx.y * blockDim.y + threadIdx.y;
    if (row >= M || col >= N) return;
    float acc = 0.f;
    for (int k = 0; k < K; ++k) acc += A[row * K + k] * B[k * N + col];
    C[row * N + col] = acc;
}

// ── 3. shared-memory tiling ──────────────────────────────────────────────────
// The block cooperates: it loads a T×T tile of A and of B into shared memory,
// and every thread reads those tiles T times. Each byte fetched from DRAM is
// now used T times instead of once.
template <int T>
__device__ __forceinline__ void mm_tiled_impl(const float* __restrict__ A, const float* __restrict__ B,
                                              float* __restrict__ C, int M, int N, int K) {
    __shared__ float As[T][T], Bs[T][T];
    int tx = threadIdx.x, ty = threadIdx.y;
    int col = blockIdx.x * T + tx, row = blockIdx.y * T + ty;
    float acc = 0.f;
    for (int k0 = 0; k0 < K; k0 += T) {
        As[ty][tx] = (row < M && k0 + tx < K) ? A[row * K + k0 + tx] : 0.f;
        Bs[ty][tx] = (col < N && k0 + ty < K) ? B[(k0 + ty) * N + col] : 0.f;
        __syncthreads();
        #pragma unroll
        for (int k = 0; k < T; ++k) acc += As[ty][k] * Bs[k][tx];
        __syncthreads();
    }
    if (row < M && col < N) C[row * N + col] = acc;
}
extern "C" __global__ void mm_tiled8 (const float* __restrict__ A, const float* __restrict__ B, float* __restrict__ C, int M, int N, int K) { mm_tiled_impl<8>(A, B, C, M, N, K); }
extern "C" __global__ void mm_tiled16(const float* __restrict__ A, const float* __restrict__ B, float* __restrict__ C, int M, int N, int K) { mm_tiled_impl<16>(A, B, C, M, N, K); }
extern "C" __global__ void mm_tiled32(const float* __restrict__ A, const float* __restrict__ B, float* __restrict__ C, int M, int N, int K) { mm_tiled_impl<32>(A, B, C, M, N, K); }

// ── 4. register tiling: 16×16 threads per block, each owning a TT×TT patch of C ──
// Shared memory raised the reuse of DRAM bytes; registers now raise the reuse of
// shared-memory bytes. One load feeds TT multiply-adds instead of one, and every
// thread carries TT² independent accumulators instead of one dependent chain.
// The block tile grows with the patch (16·TT) so only the patch size changes.
template <int TT>
__device__ __forceinline__ void mm_reg_impl(const float* __restrict__ A, const float* __restrict__ B,
                                            float* __restrict__ C, int M, int N, int K) {
    __shared__ float As[16 * TT][16], Bs[16][16 * TT];
    int tx = threadIdx.x, ty = threadIdx.y;          // 16 × 16 threads
    int row0 = blockIdx.y * 16 * TT + ty * TT;
    int col0 = blockIdx.x * 16 * TT + tx * TT;
    float acc[TT][TT] = {{0.f}};
    for (int k0 = 0; k0 < K; k0 += 16) {
        #pragma unroll
        for (int i = 0; i < TT; ++i) {
            int r = row0 + i;
            As[ty * TT + i][tx] = (r < M && k0 + tx < K) ? A[r * K + k0 + tx] : 0.f;
            int c = col0 + i;
            Bs[ty][tx * TT + i] = (c < N && k0 + ty < K) ? B[(k0 + ty) * N + c] : 0.f;
        }
        __syncthreads();
        #pragma unroll
        for (int k = 0; k < 16; ++k) {
            float a[TT], b[TT];
            #pragma unroll
            for (int i = 0; i < TT; ++i) { a[i] = As[ty * TT + i][k]; b[i] = Bs[k][tx * TT + i]; }
            #pragma unroll
            for (int i = 0; i < TT; ++i)
                #pragma unroll
                for (int j = 0; j < TT; ++j) acc[i][j] = fmaf(a[i], b[j], acc[i][j]);
        }
        __syncthreads();
    }
    #pragma unroll
    for (int i = 0; i < TT; ++i) {
        #pragma unroll
        for (int j = 0; j < TT; ++j) {
            int r = row0 + i, c = col0 + j;
            if (r < M && c < N) C[r * N + c] = acc[i][j];
        }
    }
}
extern "C" __global__ void mm_reg1(const float* __restrict__ A, const float* __restrict__ B, float* __restrict__ C, int M, int N, int K) { mm_reg_impl<1>(A, B, C, M, N, K); }
extern "C" __global__ void mm_reg2(const float* __restrict__ A, const float* __restrict__ B, float* __restrict__ C, int M, int N, int K) { mm_reg_impl<2>(A, B, C, M, N, K); }
extern "C" __global__ void mm_reg4(const float* __restrict__ A, const float* __restrict__ B, float* __restrict__ C, int M, int N, int K) { mm_reg_impl<4>(A, B, C, M, N, K); }
extern "C" __global__ void mm_reg8(const float* __restrict__ A, const float* __restrict__ B, float* __restrict__ C, int M, int N, int K) { mm_reg_impl<8>(A, B, C, M, N, K); }
'''
MM = cp.RawModule(code=MM_SRC)
KERNELS = ["mm_naive", "mm_naive_uncoalesced", "mm_tiled8", "mm_tiled16", "mm_tiled32",
           "mm_reg1", "mm_reg2", "mm_reg4", "mm_reg8"]
COMPILE_ERRORS = {}
for k in KERNELS:
    try:
        MM.get_function(k)      # force compilation now, so errors surface here
    except Exception as exc:    # a patch too large for the register budget may not compile at all
        COMPILE_ERRORS[k] = str(exc).splitlines()[0][:200]
RESULTS["compile_errors"] = COMPILE_ERRORS
print("compiled:", ", ".join(k for k in KERNELS if k not in COMPILE_ERRORS), "| failed:", COMPILE_ERRORS or "none")
compiled: mm_naive, mm_naive_uncoalesced, mm_tiled8, mm_tiled16, mm_tiled32, mm_reg1, mm_reg2, mm_reg4, mm_reg8 | failed: none

The tensor-core kernel is separate, because it needs a header (mma.h) and a different way of thinking: the unit of work is no longer a thread but a warp, and the warp issues one matrix-multiply operation for a whole 16×16×16 tile, which the compiler lowers to the tensor-core instructions underneath.

WMMA_SRC = r'''
#include <mma.h>
using namespace nvcuda;

// One warp computes one 16×16 tile of C. Inputs are FP16; the accumulator is FP32.
extern "C" __global__ void mm_wmma(const half* __restrict__ A, const half* __restrict__ B,
                                   float* __restrict__ C, int M, int N, int K) {
    int warp = (blockIdx.x * blockDim.x + threadIdx.x) / 32;
    int tilesN = N / 16;
    int tileRow = warp / tilesN, tileCol = warp % tilesN;
    if (tileRow * 16 >= M) return;

    wmma::fragment<wmma::matrix_a, 16, 16, 16, half, wmma::row_major> a_frag;
    wmma::fragment<wmma::matrix_b, 16, 16, 16, half, wmma::row_major> b_frag;
    wmma::fragment<wmma::accumulator, 16, 16, 16, float> acc;
    wmma::fill_fragment(acc, 0.0f);

    for (int k0 = 0; k0 < K; k0 += 16) {
        wmma::load_matrix_sync(a_frag, A + tileRow * 16 * K + k0, K);
        wmma::load_matrix_sync(b_frag, B + k0 * N + tileCol * 16, N);
        wmma::mma_sync(acc, a_frag, b_frag, acc);
    }
    wmma::store_matrix_sync(C + tileRow * 16 * N + tileCol * 16, acc, N, wmma::mem_row_major);
}
'''
WMMA, WMMA_BACKEND = None, None
with experiment("compile_wmma"):
    import glob, shutil, site

    def cuda_includes():
        """Where mma.h might live: a system CUDA install, or the pip packages torch pulls in."""
        roots = glob.glob("/usr/local/cuda*/include")
        for sp in site.getsitepackages() + [site.getusersitepackages()]:
            roots += glob.glob(os.path.join(sp, "nvidia", "*", "include"))
        return [d for d in roots if os.path.exists(os.path.join(d, "mma.h"))]

    def find_nvcc():
        found = shutil.which("nvcc") or next(iter(glob.glob("/usr/local/cuda*/bin/nvcc")), None)
        if found:
            return found
        for sp in site.getsitepackages() + [site.getusersitepackages()]:
            hit = glob.glob(os.path.join(sp, "nvidia", "cuda_nvcc", "bin", "nvcc"))
            if hit:
                return hit[0]
        return None

    incs, nvcc = cuda_includes(), find_nvcc()
    if not incs or not nvcc:
        print("  no local CUDA toolkit — pulling the compiler and headers from pip")
        subprocess.run([sys.executable, "-m", "pip", "install", "-q",
                        "nvidia-cuda-nvcc-cu12", "nvidia-cuda-runtime-cu12"], check=False)
        incs, nvcc = cuda_includes(), find_nvcc()
    print("  mma.h in:", incs[0] if incs else "nowhere")
    print("  nvcc    :", nvcc or "not found")

    attempts = []
    if incs:
        attempts.append(("nvrtc", dict(options=("-std=c++14", *[f"-I{d}" for d in incs]))))
    if nvcc:
        os.environ["CUDA_PATH"] = os.path.dirname(os.path.dirname(nvcc))
        os.environ["PATH"] = os.path.dirname(nvcc) + os.pathsep + os.environ.get("PATH", "")
        attempts.append(("nvcc", dict(options=("-std=c++14",), backend="nvcc")))
    for label, kwargs in attempts:
        try:
            mod = cp.RawModule(code=WMMA_SRC, **kwargs)
            mod.get_function("mm_wmma")
            WMMA, WMMA_BACKEND = mod, label
            break
        except Exception as exc:
            print(f"  {label}: {str(exc).splitlines()[0][:160]}")
    print("tensor-core kernel:", WMMA_BACKEND or "not compiled — cuBLAS fp16 stands in for this rung")
  mma.h in: /usr/local/cuda-12.8/include
  nvcc    : /usr/local/cuda/bin/nvcc
tensor-core kernel: nvrtc
[compile_wmma: 0.2 s]

Now run them. Every kernel is checked against a float64 reference before it is timed: a fast wrong answer is worth nothing, and the tiling kernels are exactly where an off-by-one hides.

with experiment("ladder"):
    N_LADDER = 1024
    cp.random.seed(0)
    A = cp.random.random((N_LADDER, N_LADDER), dtype=cp.float32)
    B = cp.random.random((N_LADDER, N_LADDER), dtype=cp.float32)
    ref = A.astype(cp.float64) @ B.astype(cp.float64)
    ref_t = torch.as_tensor(ref, device=DEV)
    C = cp.empty((N_LADDER, N_LADDER), dtype=cp.float32)
    n32 = [np.int32(N_LADDER)] * 3
    flops = 2 * N_LADDER ** 3
    rows = []

    def errors(out):
        """Normalized max error (largest error ÷ largest entry) and relative Frobenius error."""
        if isinstance(out, cp.ndarray):
            diff = out.astype(cp.float64) - ref
            return (float(cp.abs(diff).max() / cp.abs(ref).max()), float(cp.linalg.norm(diff) / cp.linalg.norm(ref)))
        diff = out.double() - ref_t
        return (float(diff.abs().max() / ref_t.abs().max()), float(torch.linalg.norm(diff) / torch.linalg.norm(ref_t)))

    def record(label, fn, out, fp32=True):
        out.fill(0) if isinstance(out, cp.ndarray) else out.zero_()
        fn(); torch.cuda.synchronize()
        e_max, e_fro = errors(out)                  # correctness first: a fast wrong answer is worth nothing
        t = steady(fn)
        rows.append(dict(kernel=label, seconds=t, flops_per_s=flops / t, err_max_norm=e_max, err_fro=e_fro,
                         pct_of_roof=100 * (flops / t) / RESULTS["spec"]["fp32_roof"] if fp32 else None))
        print(f"{label:44s} {t * 1e3:8.2f} ms  {flops / t / 1e9:8.1f} GFLOP/s   max {e_max:.1e}  Frobenius {e_fro:.1e}")

    f = MM.get_function("mm_naive")
    record("naive", lambda: f((N_LADDER // 16, N_LADDER // 16), (16, 16), (A, B, C, *n32)), C)
    f = MM.get_function("mm_naive_uncoalesced")
    record("naive, uncoalesced mapping", lambda: f((N_LADDER // 16, N_LADDER // 16), (16, 16), (A, B, C, *n32)), C)
    for T in (8, 16, 32):
        f = MM.get_function(f"mm_tiled{T}")
        record(f"shared-memory tiles {T}×{T}", lambda f=f, T=T: f((N_LADDER // T, N_LADDER // T), (T, T), (A, B, C, *n32)), C)
    f = MM.get_function("mm_reg4")
    record("register tiles, 64×64 block", lambda: f((N_LADDER // 64, N_LADDER // 64), (16, 16), (A, B, C, *n32)), C)

    tA, tB = torch.as_tensor(A, device=DEV), torch.as_tensor(B, device=DEV)
    tC = torch.empty((N_LADDER, N_LADDER), device=DEV, dtype=torch.float32)
    record("cuBLAS fp32 (torch.mm)", lambda: torch.mm(tA, tB, out=tC), tC)

    Ah, Bh = A.astype(cp.float16), B.astype(cp.float16)
    C32 = cp.empty((N_LADDER, N_LADDER), dtype=cp.float32)
    if WMMA is not None:
        warps = (N_LADDER // 16) * (N_LADDER // 16)
        fw = WMMA.get_function("mm_wmma")
        record("hand-written tensor cores (fp16 in, fp32 acc)",
               lambda: fw((warps * 32 // 256,), (256,), (Ah, Bh, C32, *n32)), C32, fp32=False)

    # cuBLAS with exactly the hand-written kernel's formats: FP16 inputs, FP32 accumulator and output.
    # torch.mm can't express that, so this calls cuBLAS GemmEx directly. Row-major C = A·B is the
    # column-major product Cᵀ = Bᵀ·Aᵀ, so B goes in the first operand slot.
    try:
        from cupy_backends.cuda.libs import cublas as cublas_lib
        handle = cp.cuda.device.get_cublas_handle()
        one, zero = np.array(1.0, np.float32), np.array(0.0, np.float32)
        R16F, R32F, TENSOR_OP = 2, 0, 99
        def gemmex(compute):
            cublas_lib.gemmEx(handle, 0, 0, N_LADDER, N_LADDER, N_LADDER, one.ctypes.data,
                              Bh.data.ptr, R16F, N_LADDER, Ah.data.ptr, R16F, N_LADDER,
                              zero.ctypes.data, C32.data.ptr, R32F, N_LADDER, compute, TENSOR_OP)
        compute_type, last_exc = None, None
        for candidate in (68, 0):                   # CUBLAS_COMPUTE_32F, then the legacy CUDA_R_32F
            try:
                gemmex(candidate); cp.cuda.Device().synchronize()
                compute_type = candidate
                break
            except Exception as exc:
                last_exc = exc
        if compute_type is None:
            raise last_exc
        record("cuBLAS fp16 in → fp32 out (GemmEx)", lambda: gemmex(compute_type), C32, fp32=False)
    except Exception as exc:
        RESULTS["errors"]["gemmex"] = traceback.format_exc()
        print("  GemmEx fp16 → fp32 not available here:", str(exc).splitlines()[0][:160])

    tAh, tBh = tA.half(), tB.half()
    tCh = torch.empty((N_LADDER, N_LADDER), device=DEV, dtype=torch.float16)
    record("cuBLAS fp16 tensor cores", lambda: torch.mm(tAh, tBh, out=tCh), tCh, fp32=False)

    ladder = pd.DataFrame(rows)
    ladder["vs_naive"] = ladder.flops_per_s / ladder.flops_per_s.iloc[0]
    RESULTS["ladder"] = {"n": N_LADDER, "rows": ladder.to_dict("records"), "wmma_backend": WMMA_BACKEND}
    del A, B, C, C32, Ah, Bh, ref, ref_t, tA, tB, tC, tAh, tBh, tCh
    free_gpu()

    fig, ax = plt.subplots(figsize=(7.2, 3.6))
    ax.barh(range(len(ladder)), ladder.flops_per_s / 1e12,
            color=[GRAY if "naive" in k else BLUE if "cuBLAS" in k else FOREST for k in ladder.kernel])
    ax.set_yticks(range(len(ladder))); ax.set_yticklabels(ladder.kernel, fontsize=8)
    ax.invert_yaxis(); ax.set_xlabel("TFLOP/s"); ax.set_xscale("log")
    plt.tight_layout(); plt.show()
naive                                            4.65 ms     462.1 GFLOP/s   max 1.9e-06  Frobenius 4.3e-07
naive, uncoalesced mapping                      17.92 ms     119.8 GFLOP/s   max 1.9e-06  Frobenius 4.3e-07
shared-memory tiles 8×8                          4.23 ms     507.3 GFLOP/s   max 1.9e-06  Frobenius 4.3e-07
shared-memory tiles 16×16                        2.93 ms     733.6 GFLOP/s   max 1.9e-06  Frobenius 4.3e-07
shared-memory tiles 32×32                        2.47 ms     870.9 GFLOP/s   max 1.9e-06  Frobenius 4.3e-07
register tiles, 64×64 block                      0.93 ms    2317.7 GFLOP/s   max 1.9e-06  Frobenius 4.3e-07
cuBLAS fp32 (torch.mm)                           0.60 ms    3601.7 GFLOP/s   max 4.5e-07  Frobenius 1.0e-07
hand-written tensor cores (fp16 in, fp32 acc)     0.65 ms    3296.3 GFLOP/s   max 5.7e-05  Frobenius 1.5e-05
cuBLAS fp16 in → fp32 out (GemmEx)               0.10 ms   21938.2 GFLOP/s   max 5.7e-05  Frobenius 1.5e-05
cuBLAS fp16 tensor cores                         0.09 ms   23293.6 GFLOP/s   max 4.7e-04  Frobenius 2.2e-04

[ladder: 9.3 s]

The register patch: reuse against occupancy

The register rung used a 4×4 patch. A bigger patch collects more reuse — one shared-memory read feeds more multiply-adds — but every accumulator is a register, registers are divided among the resident threads, and a thread that needs more than its share means fewer threads fit. So the patch is swept from 1×1 to 8×8, with the block growing alongside (16 threads per side, always) so that nothing but the patch changes. The register count is an estimate from the kernel’s live variables; the compiler’s exact allocation isn’t visible from Python.

with experiment("register_patch"):
    n = 1024
    cp.random.seed(0)
    A = cp.random.random((n, n), dtype=cp.float32)
    B = cp.random.random((n, n), dtype=cp.float32)
    ref = A.astype(cp.float64) @ B.astype(cp.float64)
    C = cp.empty((n, n), dtype=cp.float32)
    flops = 2 * n ** 3
    budget = props["regsPerMultiprocessor"] // props["maxThreadsPerMultiProcessor"]   # registers per thread at full occupancy
    rows = []
    for TT in (1, 2, 4, 8):
        name, BT = f"mm_reg{TT}", 16 * TT
        regs = TT * TT + 2 * TT + 8          # accumulators + operand registers + indices and counters
        row = dict(patch=TT, block=BT, reads_per_fma=2 / TT, registers_est=regs,
                   occupancy_est=min(1.0, budget / regs), seconds=None, flops_per_s=None, err_fro=None, note="")
        if name in COMPILE_ERRORS:
            row["note"] = COMPILE_ERRORS[name]
        else:
            f = MM.get_function(name)
            run = lambda f=f, BT=BT: f((n // BT, n // BT), (16, 16), (A, B, C, np.int32(n), np.int32(n), np.int32(n)))
            C.fill(0); run(); cp.cuda.Device().synchronize()
            row["err_fro"] = float(cp.linalg.norm(C.astype(cp.float64) - ref) / cp.linalg.norm(ref))
            row["seconds"] = steady(run)
            row["flops_per_s"] = flops / row["seconds"]
        rows.append(row)
    patch = pd.DataFrame(rows)
    RESULTS["register_patch"] = {"n": n, "register_budget_per_thread": int(budget), "rows": patch.to_dict("records")}
    del A, B, C, ref
    free_gpu()
    print(f"register budget at full occupancy: {budget} per thread")
    print(patch.to_string(index=False, float_format=lambda v: f"{v:.3g}"))
register budget at full occupancy: 64 per thread
 patch  block  reads_per_fma  registers_est  occupancy_est  seconds  flops_per_s  err_fro note
     1     16              2             11              1    0.003     7.17e+11 4.28e-07     
     2     32              1             16              1  0.00159     1.35e+12 4.28e-07     
     4     64            0.5             32              1 0.000944     2.28e+12 4.28e-07     
     8    128           0.25             88          0.727 0.000857     2.51e+12 4.28e-07     
[register_patch: 3.0 s]

Part 3 — the tile is the intensity

A tile of side T means each byte brought from DRAM is used T times, so the kernel’s arithmetic intensity is T/4 FLOPs per byte in FP32. That is the whole reason tiling works, and it is measurable: the tile sweep of Part 2 becomes a walk along the roofline.

with experiment("tiles_on_roofline"):
    spec = RESULTS["spec"]
    rows = []
    for r in RESULTS["ladder"]["rows"]:
        k = r["kernel"]
        if k.startswith("shared-memory tiles"):
            T = int(k.split()[2].split("×")[0])
            rows.append(dict(kernel=k, tile=T, ai=T / 4, flops_per_s=r["flops_per_s"]))
        elif k.startswith("naive") and "uncoalesced" not in k:
            rows.append(dict(kernel=k, tile=1, ai=0.25, flops_per_s=r["flops_per_s"]))
        elif k.startswith("register"):
            rows.append(dict(kernel=k, tile=64, ai=64 / 4, flops_per_s=r["flops_per_s"]))
    tiles = pd.DataFrame(rows)
    tiles["roof_here"] = np.minimum(spec["fp32_roof"], spec["bw_roof"] * tiles.ai)
    tiles["pct_of_roof_here"] = 100 * tiles.flops_per_s / tiles.roof_here
    RESULTS["tiles"] = tiles.to_dict("records")
    print(tiles.to_string(index=False, float_format=lambda v: f"{v:.3g}"))

    ai = np.logspace(-1.2, 3, 200)
    fig, ax = plt.subplots(figsize=(6.8, 3.8))
    ax.plot(ai, np.minimum(spec["fp32_roof"], spec["bw_roof"] * ai) / 1e12, color=INK, lw=1.4)
    ax.scatter(tiles.ai, tiles.flops_per_s / 1e12, s=30, color=EMBER, zorder=3)
    for _, r in tiles.iterrows():
        ax.annotate(f"tile {r.tile}", (r.ai, r.flops_per_s / 1e12), textcoords="offset points",
                    xytext=(6, -3), fontsize=8, color=INK)
    ax.set_xscale("log"); ax.set_yscale("log")
    ax.set_xlabel("arithmetic intensity (FLOP/byte), from the tile size")
    ax.set_ylabel("TFLOP/s")
    plt.tight_layout(); plt.show()
                     kernel  tile   ai  flops_per_s  roof_here  pct_of_roof_here
                      naive     1 0.25     4.62e+11   6.64e+10               696
    shared-memory tiles 8×8     8    2     5.07e+11   5.31e+11              95.5
  shared-memory tiles 16×16    16    4     7.34e+11   1.06e+12              69.1
  shared-memory tiles 32×32    32    8     8.71e+11   2.12e+12                41
register tiles, 64×64 block    64   16     2.32e+12   4.25e+12              54.6

[tiles_on_roofline: 0.4 s]

Part 4 — precision

The tensor cores only take FP16 (on this card), so the ladder’s last rungs change the arithmetic, not just its arrangement. Two questions, both measurable: how much speed does the lower precision buy, and how much accuracy does it cost against a float64 answer?

with experiment("precision"):
    n = 2048
    cp.random.seed(0)
    A = cp.random.random((n, n), dtype=cp.float32) - 0.5
    B = cp.random.random((n, n), dtype=cp.float32) - 0.5
    ref = A.astype(cp.float64) @ B.astype(cp.float64)
    ref_t = torch.as_tensor(ref, device=DEV)
    flops = 2 * n ** 3
    rows = []
    for label, dt in [("fp32 (CUDA cores)", torch.float32), ("fp16 (tensor cores)", torch.float16)]:
        tA = torch.as_tensor(A, device=DEV).to(dt)
        tB = torch.as_tensor(B, device=DEV).to(dt)
        out = torch.empty((n, n), device=DEV, dtype=dt)
        t = steady(lambda: torch.mm(tA, tB, out=out))
        diff = out.double() - ref_t
        rows.append(dict(precision=label, seconds=t, flops_per_s=flops / t,
                         err_max_norm=float(diff.abs().max() / ref_t.abs().max()),
                         err_fro=float(torch.linalg.norm(diff) / torch.linalg.norm(ref_t)),
                         bytes_per_element=out.element_size()))
        del tA, tB, out, diff
    prec = pd.DataFrame(rows)
    prec["speedup_vs_fp32"] = prec.flops_per_s / prec.flops_per_s.iloc[0]
    RESULTS["precision"] = {"n": n, "rows": prec.to_dict("records")}
    del A, B, ref, ref_t
    free_gpu()
    print(prec.to_string(index=False, float_format=lambda v: f"{v:.4g}"))
          precision   seconds  flops_per_s  err_max_norm   err_fro  bytes_per_element  speedup_vs_fp32
  fp32 (CUDA cores)  0.004657    3.689e+12         1e-06 5.741e-07                  4                1
fp16 (tensor cores) 0.0008585    2.001e+13     0.0004605 0.0003334                  2            5.424
[precision: 2.3 s]

Part 5 — cuBLAS, and what shape does to it

cuBLAS is the ladder’s top rung, but it is not one kernel: it picks a tiling for the shape it is given. Three sweeps show what that means in practice — size, alignment, and layout.

with experiment("cublas_size"):
    DT = {"fp32": torch.float32, "fp16": torch.float16}
    configs = [(n, pr) for n in [128, 256, 512, 1024, 1536, 2048, 3072, 4096, 6144, 8192] for pr in ("fp32", "fp16")]

    def measure(cfg):
        n, pr = cfg
        A = torch.randn(n, n, device=DEV, dtype=DT[pr])
        B = torch.randn(n, n, device=DEV, dtype=DT[pr])
        t = steady(lambda: A @ B, seconds=0.4)
        del A, B
        return t

    res = sweep(configs, measure, repeats=3)
    free_gpu()
    size = pd.DataFrame([dict(n=n, precision=pr, seconds=float(np.median(ts)), seconds_min=min(ts), seconds_max=max(ts),
                              flops_per_s=2 * n ** 3 / float(np.median(ts))) for (n, pr), ts in res.items()])
    RESULTS["cublas_size"] = size.to_dict("records")
    print(size.pivot(index="n", columns="precision", values="flops_per_s").apply(lambda c: c / 1e12).round(1).to_string())

    fig, ax = plt.subplots(figsize=(6.8, 3.4))
    for label, color in [("fp32", GRAY), ("fp16", BLUE)]:
        d = size[size.precision == label].sort_values("n")
        ax.plot(d.n, d.flops_per_s / 1e12, "-o", ms=3, color=color, label=label)
    ax.set_xscale("log", base=2); ax.set_xlabel("n (n×n @ n×n)"); ax.set_ylabel("TFLOP/s")
    ax.legend(frameon=False); plt.tight_layout(); plt.show()
precision  fp16  fp32
n                    
128         0.1   0.1
256         1.1   0.9
512         8.7   3.4
1024       20.5   3.1
1536       21.2   3.3
2048       19.0   3.3
3072       20.0   3.2
4096       18.8   3.4
6144       14.0   3.1
8192       20.4   3.3

[cublas_size: 39.7 s]

Alignment next. A GPU matmul is cut into tiles of a fixed size — commonly 128 or 64 elements on a side — so a matrix whose dimensions are not multiples of that leaves ragged tiles at the edge, and a grid that doesn’t divide evenly across the SMs leaves some of them idle on the last wave. Both show up as a sawtooth around the “nice” sizes — and cuBLAS may also simply pick a different kernel for a slightly different shape. To tell these apart, the same one-element step is measured for a kernel whose tiling can’t change: this lab’s own 32×32 shared-memory tiles. Every size is measured three times in shuffled order, so a card warming up over the sweep can’t pass for an effect of size.

with experiment("cublas_quantization"):
    cu_sizes = list(range(4088, 4105)) + [2048, 2049, 2056, 2064, 2080, 2112, 2176, 2304]

    def measure_cublas(n):
        A = torch.randn(n, n, device=DEV, dtype=torch.float16)
        B = torch.randn(n, n, device=DEV, dtype=torch.float16)
        t = steady(lambda: A @ B, seconds=0.35)
        del A, B
        return t

    res = sweep(cu_sizes, measure_cublas, repeats=3)
    quant = pd.DataFrame([dict(n=n, seconds=float(np.median(ts)), seconds_min=min(ts), seconds_max=max(ts),
                               flops_per_s=2 * n ** 3 / float(np.median(ts))) for n, ts in res.items()]).sort_values("n")

    # The same one-element step for a kernel whose tiling can't change: shared-memory tiles of 32.
    # Its ragged edge costs exactly one more partial tile per dimension, so any extra slowdown
    # cuBLAS shows beyond this is not the edge itself.
    f32 = MM.get_function("mm_tiled32")
    def measure_fixed(n):
        A = cp.random.random((n, n), dtype=cp.float32)
        B = cp.random.random((n, n), dtype=cp.float32)
        C = cp.empty((n, n), dtype=cp.float32)
        g = -(-n // 32)
        t = steady(lambda: f32((g, g), (32, 32), (A, B, C, np.int32(n), np.int32(n), np.int32(n))), seconds=0.4)
        del A, B, C
        return t

    res_fixed = sweep([2048, 2049, 4096, 4097], measure_fixed, repeats=3, seed=1)
    free_gpu()
    fixed = pd.DataFrame([dict(n=n, seconds=float(np.median(ts)), seconds_min=min(ts), seconds_max=max(ts),
                               flops_per_s=2 * n ** 3 / float(np.median(ts))) for n, ts in res_fixed.items()]).sort_values("n")
    RESULTS["cublas_quantization"] = quant.to_dict("records")
    RESULTS["fixed_tiling_alignment"] = fixed.to_dict("records")

    def step(df, a, b):
        ra, rb = df[df.n == a].iloc[0], df[df.n == b].iloc[0]
        return 100 * (rb.seconds / ra.seconds - 1), 100 * (1 - rb.flops_per_s / ra.flops_per_s)
    for a, b in [(2048, 2049), (4096, 4097)]:
        ct, cf = step(quant, a, b); ft, ff = step(fixed, a, b)
        print(f"{a}{b}:  cuBLAS fp16 time +{ct:.0f}% (throughput −{cf:.0f}%)   "
              f"fixed 32×32 tiles time +{ft:.1f}% (throughput −{ff:.1f}%)")
    near = quant[(quant.n >= 4088) & (quant.n <= 4104)]
    print(near.assign(tflops=near.flops_per_s / 1e12)[["n", "tflops"]].to_string(index=False, float_format=lambda v: f"{v:.1f}"))
2048 → 2049:  cuBLAS fp16 time +54% (throughput −35%)   fixed 32×32 tiles time +9.3% (throughput −8.4%)
4096 → 4097:  cuBLAS fp16 time +50% (throughput −33%)   fixed 32×32 tiles time +4.4% (throughput −4.2%)
   n  tflops
4088    17.3
4089    13.8
4090    12.9
4091    14.6
4092    12.7
4093    14.6
4094    13.7
4095    14.0
4096    19.3
4097    12.9
4098    13.0
4099    12.8
4100    12.5
4101    13.2
4102    12.4
4103    12.6
4104    15.8
[cublas_quantization: 49.1 s]

Timing shows the jump but not its cause. PyTorch’s profiler does: it records the name of every kernel launched on the GPU, so the next cell asks which kernel cuBLAS runs at each size, in FP16 and in FP32. The sizes include 4092 and 4100 — multiples of 4 but not of 8 — to see where the fast kernels stop. FP32 gets its own timing, measured the same way as the FP16 sweep above.

with experiment("cublas_kernels"):
    from torch.autograd import DeviceType
    from torch.profiler import ProfilerActivity, profile
    import warnings

    def kernel_names(A, B, calls=3):
        """The GPU kernels one A @ B launches, as named in PyTorch's profiler (fills excluded)."""
        for _ in range(3):
            A @ B
        torch.cuda.synchronize()
        with warnings.catch_warnings():
            warnings.simplefilter("ignore")
            with profile(activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA]) as prof:
                for _ in range(calls):
                    A @ B
                torch.cuda.synchronize()
        return sorted({e.name for e in prof.events() if e.device_type == DeviceType.CUDA and "Memset" not in e.name})

    k_sizes = [2048, 2049, 4088, 4092, 4096, 4097, 4100, 4104]
    DT = {"fp16": torch.float16, "fp32": torch.float32}
    names = {}
    for pr, dt in DT.items():
        for n in k_sizes:
            A = torch.randn(n, n, device=DEV, dtype=dt)
            B = torch.randn(n, n, device=DEV, dtype=dt)
            names[(pr, n)] = kernel_names(A, B)
            del A, B

    def measure_fp32(n):
        A = torch.randn(n, n, device=DEV, dtype=torch.float32)
        B = torch.randn(n, n, device=DEV, dtype=torch.float32)
        t = steady(lambda: A @ B, seconds=0.35)
        del A, B
        return t

    res32 = sweep(k_sizes, measure_fp32, repeats=3, seed=2)
    free_gpu()
    fp16_seconds = dict(zip(quant.n, quant.seconds))
    kernels = pd.DataFrame([dict(n=n, precision=pr, kernels=names[(pr, n)],
                                 seconds=fp16_seconds[n] if pr == "fp16" else float(np.median(res32[n])))
                            for pr in DT for n in k_sizes])
    RESULTS["cublas_kernels"] = kernels.to_dict("records")
    for pr in DT:
        d = kernels[kernels.precision == pr]
        base = d[d.n == 4096].seconds.iloc[0]
        print(f"\n{pr}")
        for r in d.itertuples():
            print(f"  n = {r.n}  {r.seconds * 1e3:7.2f} ms  ({100 * (r.seconds / base - 1):+5.1f}% vs 4096)  {' + '.join(r.kernels)}")

fp16
  n = 2048     0.95 ms  (-86.7% vs 4096)  turing_fp16_s1688gemm_fp16_128x128_ldg8_f2f_nn
  n = 2049     1.46 ms  (-79.5% vs 4096)  void cutlass::Kernel2<cutlass_75_tensorop_f16_s1688gemm_f16_256x128_nn_align1>(cutlass_75_tensorop_f16_s1688gemm_f16_256x128_nn_align1::Params)
  n = 4088     7.89 ms  (+11.0% vs 4096)  turing_fp16_s1688gemm_fp16_256x128_ldg8_f2f_stages_32x1_nn
  n = 4092    10.82 ms  (+52.2% vs 4096)  void cutlass::Kernel2<cutlass_75_tensorop_f16_s1688gemm_f16_256x128_nn_align2>(cutlass_75_tensorop_f16_s1688gemm_f16_256x128_nn_align2::Params)
  n = 4096     7.11 ms  ( +0.0% vs 4096)  turing_fp16_s1688gemm_fp16_256x128_ldg8_f2f_stages_32x1_nn
  n = 4097    10.67 ms  (+50.0% vs 4096)  void cutlass::Kernel2<cutlass_75_tensorop_f16_s1688gemm_f16_256x128_nn_align1>(cutlass_75_tensorop_f16_s1688gemm_f16_256x128_nn_align1::Params)
  n = 4100    11.01 ms  (+54.9% vs 4096)  void cutlass::Kernel2<cutlass_75_tensorop_f16_s1688gemm_f16_256x128_nn_align2>(cutlass_75_tensorop_f16_s1688gemm_f16_256x128_nn_align2::Params)
  n = 4104     8.73 ms  (+22.7% vs 4096)  turing_fp16_s1688gemm_fp16_128x256_ldg8_f2f_stages_32x1_nn

fp32
  n = 2048     5.15 ms  (-87.1% vs 4096)  volta_sgemm_128x64_nn
  n = 2049     5.53 ms  (-86.1% vs 4096)  volta_sgemm_128x64_nn
  n = 4088    42.36 ms  ( +6.1% vs 4096)  volta_sgemm_128x64_nn
  n = 4092    42.53 ms  ( +6.6% vs 4096)  volta_sgemm_128x64_nn
  n = 4096    39.91 ms  ( +0.0% vs 4096)  volta_sgemm_128x64_nn
  n = 4097    44.37 ms  (+11.2% vs 4096)  volta_sgemm_128x64_nn
  n = 4100    44.74 ms  (+12.1% vs 4096)  volta_sgemm_128x64_nn
  n = 4104    44.66 ms  (+11.9% vs 4096)  volta_sgemm_128x64_nn
[cublas_kernels: 20.9 s]

Layout last: the same numbers, multiplied in the same order, but stored transposed. cuBLAS is column-major underneath, so a PyTorch A @ B is already a transposed problem for it; asking for A.T @ B or A @ B.T changes which operand it has to read across its stride.

with experiment("cublas_layout"):
    n = 4096
    A = torch.randn(n, n, device=DEV, dtype=torch.float16)
    B = torch.randn(n, n, device=DEV, dtype=torch.float16)
    At, Bt = A.t().contiguous(), B.t().contiguous()
    flops = 2 * n ** 3
    cases = {
        "A @ B": lambda: A @ B,
        "A.T @ B (A stored transposed)": lambda: At.t() @ B,
        "A @ B.T (B stored transposed)": lambda: A @ Bt.t(),
        "A.T @ B.T": lambda: At.t() @ Bt.t(),
    }
    res = sweep(list(cases), lambda k: steady(cases[k]), repeats=3)
    layout = pd.DataFrame([dict(case=k, seconds=float(np.median(ts)), seconds_min=min(ts), seconds_max=max(ts),
                                flops_per_s=flops / float(np.median(ts))) for k, ts in res.items()])
    layout["vs_plain"] = layout.flops_per_s / layout.flops_per_s.iloc[0]
    RESULTS["cublas_layout"] = layout.to_dict("records")
    del A, B, At, Bt
    free_gpu()
    print(layout.to_string(index=False, float_format=lambda v: f"{v:.4g}"))
                         case  seconds  seconds_min  seconds_max  flops_per_s  vs_plain
                        A @ B 0.007533     0.007319     0.007536    1.825e+13         1
A.T @ B (A stored transposed) 0.007452     0.007451     0.007455    1.844e+13     1.011
A @ B.T (B stored transposed) 0.007756     0.007736     0.007973    1.772e+13    0.9712
                    A.T @ B.T 0.006582     0.006578     0.006748    2.088e+13     1.144
[cublas_layout: 9.1 s]

The last sweep is the one that matters for language models: a matmul where one dimension is tiny. For (M × K)(K × N) with s bytes per number, the arithmetic is 2MKN FLOPs and the least traffic any kernel can have — each input read once, each output written once — is s (MK + KN + MN) bytes, so the best possible intensity is

$$I = \frac{2MKN}{s\,(MK + KN + MN)}$$

With M = 1 — the shape a model runs when it generates a single token — that is about 1, and no amount of tiling can give the operation intensity the formula doesn’t allow. The sweep below computes exactly this quantity for each M, so the table is a check of the formula against the measured bytes per second.

with experiment("skinny"):
    K = N = 4096
    W = torch.randn(K, N, device=DEV, dtype=torch.float16)
    Ms = [1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048]
    X = {M: torch.randn(M, K, device=DEV, dtype=torch.float16) for M in Ms}
    res = sweep(Ms, lambda M: steady(lambda: X[M] @ W, seconds=0.35), repeats=3)
    rows = []
    for M, ts in sorted(res.items()):
        t = float(np.median(ts))
        flops = 2 * M * K * N
        nbytes = 2 * (M * K + K * N + M * N)          # s·(MK + KN + MN), s = 2 bytes in FP16
        rows.append(dict(M=M, seconds=t, seconds_min=min(ts), seconds_max=max(ts), flops_per_s=flops / t,
                         bytes_per_s=nbytes / t, ai=flops / nbytes))
    skinny = pd.DataFrame(rows)
    RESULTS["skinny"] = skinny.to_dict("records")
    del W, X
    free_gpu()
    print(skinny.to_string(index=False, float_format=lambda v: f"{v:.4g}"))
   M   seconds  seconds_min  seconds_max  flops_per_s  bytes_per_s     ai
   1 0.0001771    0.0001763     0.000178    1.895e+11    1.896e+11 0.9995
   2 0.0001737    0.0001736    0.0001739    3.864e+11    1.934e+11  1.998
   4 0.0001754     0.000175     0.000176    7.654e+11    1.917e+11  3.992
   8 0.0001785    0.0001784    0.0001789    1.504e+12    1.888e+11  7.969
  16 0.0001851    0.0001849    0.0001852    2.901e+12    1.827e+11  15.88
  32 0.0001641     0.000164    0.0001643    6.541e+12    2.076e+11  31.51
  64   0.00017    0.0001693    0.0001705    1.263e+13    2.036e+11  62.06
 128 0.0002827    0.0002756    0.0002872     1.52e+13    1.261e+11  120.5
 256 0.0004379    0.0004299    0.0004495    1.962e+13     8.62e+10  227.6
 512  0.001106     0.001074     0.001121    1.553e+13    3.792e+10  409.6
1024   0.00174     0.001702     0.001781    1.974e+13    2.892e+10  682.7
2048  0.003447     0.003362     0.003537    1.993e+13    1.947e+10   1024
[skinny: 18.9 s]

Everything in one place

with experiment("summary"):
    spec = RESULTS["spec"]
    ladder = pd.DataFrame(RESULTS["ladder"]["rows"])
    best = ladder.loc[ladder.flops_per_s.idxmax()]
    naive = ladder.iloc[0]
    print(f"card              : {spec['gpu']}, FP32 roof {spec['fp32_roof'] / 1e12:.2f} TFLOP/s, "
          f"memory roof {spec['bw_roof'] / 1e9:.0f} GB/s, ridge {spec['ridge_fp32']:.0f} FLOP/B")
    print(f"naive kernel      : {naive.flops_per_s / 1e9:.1f} GFLOP/s "
          f"({100 * naive.flops_per_s / spec['fp32_roof']:.1f}% of the FP32 roof)")
    print(f"best of the ladder: {best.kernel}{best.flops_per_s / 1e12:.2f} TFLOP/s "
          f"({best.flops_per_s / naive.flops_per_s:.0f}× the naive kernel)")
    print("\nerrors:", list(RESULTS["errors"]) or "none")
card              : Tesla T4, FP32 roof 6.41 TFLOP/s, memory roof 265 GB/s, ridge 24 FLOP/B
naive kernel      : 462.1 GFLOP/s (7.2% of the FP32 roof)
best of the ladder: cuBLAS fp16 tensor cores — 23.29 TFLOP/s (50× the naive kernel)

errors: none
[summary: 0.0 s]
RESULTS["meta"].update(
    executed=datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"),
    torch=torch.__version__, cuda=torch.version.cuda, cupy=cp.__version__,
    python=platform.python_version(), kaggle=bool(os.environ.get("KAGGLE_KERNEL_RUN_TYPE")),
)
out_dir = "/kaggle/working" if os.path.isdir("/kaggle/working") else "."
with open(os.path.join(out_dir, "matmul_results.json"), "w") as f:
    json.dump(RESULTS, f, default=jsonable, indent=1)
print("saved", os.path.join(out_dir, "matmul_results.json"))
saved /kaggle/working/matmul_results.json
Read the parent note