Lab · runnable experiments
How GPUs Actually Run Machine Learning
Created Sep 10, 2026 Updated Sep 11, 2026
Read the parent noteThe note asks one question: why is the same GPU sometimes a hundred times faster than a CPU and sometimes barely faster at all? This lab answers it with measurements on one real card, and every term the note introduces — warps, SIMT, latency hiding, the memory hierarchy, FLOPs, arithmetic intensity, the roofline — is pinned to a number measured here.
Part 1 reproduces the paradox: matrix multiplication and element-wise addition swept from tiny to huge, on the CPU and on the GPU, with and without the copy over PCIe. Part 2 opens the programming model with hand-written CUDA kernels: what a warp is, and what it costs when the 32 threads of a warp disagree. Part 3 is memory: access patterns, the latency of each level of the hierarchy, how the GPU hides that latency, and the bandwidth of each level. Part 4 is compute: the FLOP/s the card delivers against what the datasheet promises, and what the tensor cores add. Part 5 puts both roofs on one chart and walks a single kernel from memory-bound to compute-bound, then places real PyTorch operations on it. Part 6 closes the loop: a three-number model of each machine, asked to predict the speed-ups from Part 1.
Everything runs on one Kaggle Tesla T4. The code is the code that ran; the outputs are the outputs it printed.
Setup
Run on Kaggle with Settings → Accelerator → GPU T4 x2 (the lab uses one of the two cards) and Internet on — CuPy is installed only if the image does not already have it.
import os
os.environ.setdefault("CUDA_DEVICE_ORDER", "PCI_BUS_ID") # torch, CuPy and nvidia-smi agree on "GPU 0"
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 # keep fp32 honest (Turing has no TF32 anyway)
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()
# CuPy kernels and PyTorch ops are timed with the same CUDA events, which is only valid if
# both libraries issue work to the same (legacy default) stream.
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__,
"| Python", platform.python_version())executed: 2026-09-11
torch 2.10.0+cu128 | CUDA 12.8 | CuPy 14.0.1 | Python 3.12.13
Three stopwatches. gpu_time brackets the work with CUDA events, so it measures what the GPU did, not how long Python took to ask. wall_time is an ordinary clock, for the CPU and for anything that includes copies. sustained keeps the GPU busy for a couple of seconds while a background thread polls nvidia-smi, because a card’s clock under load is not the clock on its datasheet. All three report the median of repeated runs.
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 wall_time(fn, iters=15, warmup=1, budget=1.5, sync=False):
"""Median wall-clock seconds of fn(); sync=True waits for the GPU to finish."""
def run():
if sync:
torch.cuda.synchronize()
t0 = time.perf_counter()
fn()
if sync:
torch.cuda.synchronize()
return time.perf_counter() - t0
for _ in range(warmup):
run()
n = int(min(iters, max(3, budget / max(run(), 1e-9))))
return float(np.median([run() for _ in range(n)]))
def smi(fields="clocks.sm,clocks.mem,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:
"""Polls nvidia-smi in a background thread while a workload runs."""
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) # first sample can be pre-load
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 of fn() under continuous load, plus the clocks and power 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() # keep the queue from running far ahead of the clock
e.record(); e.synchronize()
return s.elapsed_time(e) / 1e3 / n, smp.summary()
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)The machine, as it describes itself
Before measuring anything, the card’s own description: how many streaming multiprocessors (SMs), how many FP32 lanes each has, how big the register file, shared memory and L2 are, and the memory bus. From those, two theoretical ceilings: FP32 FLOP/s = SMs × lanes × 2 × clock (a fused multiply-add counts as two FLOPs), and DRAM bandwidth = memory clock × 2 × bus width. Everything later is measured against these.
props = cp.cuda.runtime.getDeviceProperties(0)
tprops = torch.cuda.get_device_properties(0)
def prop(key, torch_attr=None):
v = props.get(key)
if v is None and torch_attr:
v = getattr(tprops, torch_attr, None)
return v.decode() if isinstance(v, (bytes, bytearray)) else v
cc = (prop("major", "major"), prop("minor", "minor"))
FP32_LANES_PER_SM = {(7, 0): 64, (7, 5): 64, (8, 0): 64, (8, 6): 128, (8, 7): 128, (8, 9): 128,
(9, 0): 128, (10, 0): 128, (12, 0): 128}
SPEC_FP16_TENSOR = {"Tesla T4": 65e12} # vendor datasheet, dense FP16 tensor-core peak
stat = smi("name,clocks.max.sm,clocks.max.mem,power.limit,pcie.link.gen.max,"
"pcie.link.width.max,driver_version")
sms = prop("multiProcessorCount", "multi_processor_count")
mem_mhz = (prop("memoryClockRate") or 0) / 1e3 or stat["clocks.max.mem"]
bus_bits = prop("memoryBusWidth")
spec = {
"gpu": prop("name", "name"),
"compute_capability": f"{cc[0]}.{cc[1]}",
"sms": sms,
"fp32_lanes_per_sm": FP32_LANES_PER_SM.get(cc, 64),
"warp_size": prop("warpSize", "warp_size"),
"max_threads_per_sm": prop("maxThreadsPerMultiProcessor", "max_threads_per_multi_processor"),
"regs_per_sm": prop("regsPerMultiprocessor", "regs_per_multiprocessor"),
"shared_mem_per_sm_bytes": prop("sharedMemPerMultiprocessor", "shared_memory_per_multiprocessor"),
"l2_bytes": prop("l2CacheSize", "L2_cache_size"),
"dram_bytes": prop("totalGlobalMem", "total_memory"),
"mem_bus_bits": bus_bits,
"mem_clock_mhz": mem_mhz,
"max_sm_clock_mhz": stat["clocks.max.sm"],
"power_limit_w": stat["power.limit"],
"pcie": f"gen {stat['pcie.link.gen.max']:.0f} x{stat['pcie.link.width.max']:.0f}",
"driver": stat["driver_version"],
}
spec["fp32_lanes_total"] = spec["sms"] * spec["fp32_lanes_per_sm"]
spec["max_warps_per_sm"] = spec["max_threads_per_sm"] // spec["warp_size"]
spec["register_file_total_bytes"] = spec["regs_per_sm"] * 4 * spec["sms"]
spec["peak_fp32_flops_theory"] = spec["fp32_lanes_total"] * 2 * spec["max_sm_clock_mhz"] * 1e6
spec["peak_dram_bw_theory"] = mem_mhz * 1e6 * 2 * bus_bits / 8
spec["peak_fp16_tensor_flops_spec"] = SPEC_FP16_TENSOR.get(spec["gpu"])
lscpu = {}
try:
out = subprocess.run(["lscpu"], capture_output=True, text=True).stdout
lscpu = {k.strip(): v.strip() for k, v in (l.split(":", 1) for l in out.splitlines() if ":" in l)}
except Exception:
pass
cpu = {
"model": lscpu.get("Model name", platform.processor()),
"logical_cpus": os.cpu_count(),
"torch_threads": torch.get_num_threads(),
"flags_avx512": "avx512f" in lscpu.get("Flags", ""),
"flags_avx2": "avx2" in lscpu.get("Flags", ""),
}
RESULTS["spec"], RESULTS["cpu"] = spec, cpu
rows = [
("GPU", spec["gpu"]), ("compute capability", spec["compute_capability"]),
("streaming multiprocessors (SMs)", spec["sms"]),
("FP32 lanes per SM / total", f"{spec['fp32_lanes_per_sm']} / {spec['fp32_lanes_total']}"),
("warp size", spec["warp_size"]),
("max resident threads per SM (warps)", f"{spec['max_threads_per_sm']} ({spec['max_warps_per_sm']})"),
("register file per SM / whole GPU", f"{spec['regs_per_sm'] * 4 / 1024:.0f} KB / {spec['register_file_total_bytes'] / 2**20:.1f} MB"),
("shared memory per SM", f"{spec['shared_mem_per_sm_bytes'] / 1024:.0f} KB"),
("L2 cache", f"{spec['l2_bytes'] / 2**20:.1f} MB"),
("DRAM", f"{spec['dram_bytes'] / 2**30:.1f} GB, {bus_bits}-bit bus @ {mem_mhz:.0f} MHz"),
("max SM clock / power limit", f"{spec['max_sm_clock_mhz']:.0f} MHz / {spec['power_limit_w']:.0f} W"),
("theoretical FP32 peak", f"{spec['peak_fp32_flops_theory'] / 1e12:.2f} TFLOP/s"),
("datasheet FP16 tensor-core peak", f"{(spec['peak_fp16_tensor_flops_spec'] or float('nan')) / 1e12:.0f} TFLOP/s"),
("theoretical DRAM bandwidth", f"{spec['peak_dram_bw_theory'] / 1e9:.0f} GB/s"),
("host link", spec["pcie"]),
("CPU", f"{cpu['model']} · {cpu['logical_cpus']} logical CPUs · torch uses {cpu['torch_threads']} threads"),
]
print(pd.DataFrame(rows, columns=["", "value"]).to_string(index=False)) value
GPU Tesla T4
compute capability 7.5
streaming multiprocessors (SMs) 40
FP32 lanes per SM / total 64 / 2560
warp size 32
max resident threads per SM (warps) 1024 (32)
register file per SM / whole GPU 256 KB / 10.0 MB
shared memory per SM 64 KB
L2 cache 4.0 MB
DRAM 14.6 GB, 256-bit bus @ 5001 MHz
max SM clock / power limit 1590 MHz / 70 W
theoretical FP32 peak 8.14 TFLOP/s
datasheet FP16 tensor-core peak 65 TFLOP/s
theoretical DRAM bandwidth 320 GB/s
host link gen 3 x16
CPU Intel(R) Xeon(R) CPU @ 2.00GHz · 4 logical CPUs · torch uses 2 threads
Part 1 — the paradox
Two operations, every size, two machines
Matrix multiplication (n × n by n × n, FP32) and element-wise addition (x + y, FP32), each swept from tiny to as large as fits. For each size three timings: the CPU; the GPU with data already in its memory; and the GPU as a naive script would use it — copy the inputs over, compute, copy the answer back.
with experiment("paradox"):
torch.manual_seed(0)
rows = []
for n in [16, 24, 32, 48, 64, 96, 128, 192, 256, 384, 512, 768, 1024, 1536, 2048, 3072, 4096]:
a_c, b_c = torch.randn(n, n), torch.randn(n, n)
a_g, b_g = a_c.to(DEV), b_c.to(DEV)
rows.append(dict(
op="matmul", size=n, flops=2 * n**3, bytes=3 * n * n * 4,
cpu_s=wall_time(lambda: a_c @ b_c),
gpu_s=gpu_time(lambda: a_g @ b_g),
gpu_e2e_s=wall_time(lambda: (a_c.to(DEV) @ b_c.to(DEV)).cpu(), sync=True),
))
for k in range(10, 29):
N = 2**k
x_c, y_c = torch.randn(N), torch.randn(N)
x_g, y_g = x_c.to(DEV), y_c.to(DEV)
o_c, o_g = torch.empty(N), torch.empty(N, device=DEV)
rows.append(dict(
op="add", size=N, flops=N, bytes=3 * N * 4,
cpu_s=wall_time(lambda: torch.add(x_c, y_c, out=o_c)),
gpu_s=gpu_time(lambda: torch.add(x_g, y_g, out=o_g)),
gpu_e2e_s=wall_time(lambda: torch.add(x_c.to(DEV), y_c.to(DEV)).cpu(), sync=True),
))
del x_c, y_c, x_g, y_g, o_c, o_g
torch.cuda.empty_cache()
paradox = pd.DataFrame(rows)
paradox["speedup"] = paradox.cpu_s / paradox.gpu_s
paradox["speedup_e2e"] = paradox.cpu_s / paradox.gpu_e2e_s
RESULTS["paradox"] = paradox.to_dict("records")
fmt = lambda v: f"{v:.3g}"
cols = ["size", "cpu_s", "gpu_s", "gpu_e2e_s", "speedup", "speedup_e2e"]
print("matmul (n × n):")
print(paradox[(paradox.op == "matmul") & paradox["size"].isin([32, 256, 1024, 4096])][cols]
.to_string(index=False, float_format=fmt))
print("\nadd (elements):")
print(paradox[(paradox.op == "add") & paradox["size"].isin([2**10, 2**16, 2**22, 2**28])][cols]
.to_string(index=False, float_format=fmt))
fig, axes = plt.subplots(1, 2, figsize=(9, 3.4), sharey=True)
for ax, op, xlabel in zip(axes, ["matmul", "add"],
["matrix side n (n×n @ n×n, fp32)", "elements (x + y, fp32)"]):
d = paradox[paradox.op == op]
ax.plot(d["size"], d.speedup, "-o", color=BLUE, ms=3, label="GPU, data already on the GPU")
ax.plot(d["size"], d.speedup_e2e, "-o", color=EMBER, ms=3, label="GPU, including the copies")
ax.axhline(1, color=GRAY, lw=0.8, ls="--")
ax.set_xscale("log", base=2); ax.set_yscale("log")
ax.set_xlabel(xlabel); ax.set_title(op)
axes[0].set_ylabel("speed-up over the CPU (×)")
axes[0].legend(frameon=False, fontsize=8)
plt.tight_layout(); plt.show()matmul (n × n):
size cpu_s gpu_s gpu_e2e_s speedup speedup_e2e
32 6.47e-06 3.43e-05 0.000119 0.189 0.0545
256 0.000178 7e-05 0.000402 2.54 0.442
1024 0.0106 0.000852 0.00399 12.4 2.66
4096 0.67 0.0354 0.118 18.9 5.7
add (elements):
size cpu_s gpu_s gpu_e2e_s speedup speedup_e2e
1024 2.7e-06 2.05e-05 8.44e-05 0.132 0.032
65536 1.15e-05 2.33e-05 0.000343 0.494 0.0335
4194304 0.00197 0.000219 0.0115 8.98 0.172
268435456 0.143 0.0131 1.5 11 0.0954
[paradox: 48.3 s]
The floor under every tiny operation
At the small end of both sweeps the GPU is not computing — it is waiting to be told what to compute. Each PyTorch call on a CUDA tensor goes through the Python interpreter, the dispatcher and the CUDA driver before the GPU sees a kernel. Here that cost is isolated: a thousand tiny additions issued one by one from Python; the same thousand captured once as a CUDA graph and replayed as a single submission; and the same thousand on the CPU.
with experiment("launch_overhead"):
K = 1000
x_g, x_c = torch.zeros(1024, device=DEV), torch.zeros(1024)
def eager_gpu():
for _ in range(K):
x_g.add_(1.0)
def eager_cpu():
for _ in range(K):
x_c.add_(1.0)
side = torch.cuda.Stream()
side.wait_stream(torch.cuda.current_stream())
with torch.cuda.stream(side):
eager_gpu() # warm-up on a side stream, as capture requires
torch.cuda.current_stream().wait_stream(side)
graph = torch.cuda.CUDAGraph()
with torch.cuda.graph(graph):
eager_gpu()
overhead = {
"gpu_eager_per_op_s": wall_time(eager_gpu, sync=True) / K,
"gpu_graph_per_op_s": wall_time(graph.replay, sync=True) / K,
"cpu_per_op_s": wall_time(eager_cpu) / K,
"gpu_single_roundtrip_s": wall_time(lambda: x_g.add_(1.0), sync=True),
}
RESULTS["launch_overhead"] = overhead
for k, v in overhead.items():
print(f"{k:24s} {v * 1e6:8.2f} µs")gpu_eager_per_op_s 9.30 µs
gpu_graph_per_op_s 1.35 µs
cpu_per_op_s 7.02 µs
gpu_single_roundtrip_s 25.74 µs
[launch_overhead: 0.4 s]
Part 2 — threads, warps, and SIMT
The kernels
From here on the GPU is programmed directly, in CUDA C, compiled at run time by NVRTC through CuPy. Every kernel used in the lab is in this one cell. Each is a function run by every thread of a grid; the grid is cut into blocks; each block is scheduled onto one SM; and the SM executes its threads in groups of 32 — warps — that issue one instruction at a time together.
SRC = r'''
// ── SIMT: what happens when the 32 threads of a warp disagree ─────────────────
extern "C" __global__ void diverge(float* out, int paths, int by_lane, int work) {
int i = blockIdx.x * blockDim.x + threadIdx.x;
int group = by_lane ? (threadIdx.x & 31) : (i >> 5); // lane inside a warp, or the warp itself
int p = group % paths;
float x = 1.0f + 1e-7f * (float)i;
if (p == 0) { for (int k = 0; k < work; ++k) x = fmaf(x, 0.9999f, 1e-4f); }
else if (p == 1) { for (int k = 0; k < work; ++k) x = fmaf(x, 1.0001f, -1e-4f); }
else if (p == 2) { for (int k = 0; k < work; ++k) x = fmaf(x, 0.9998f, 2e-4f); }
else { for (int k = 0; k < work; ++k) x = fmaf(x, 1.0002f, -2e-4f); }
out[i] = x;
}
extern "C" __global__ void straggler(float* out, int every, int work) {
int i = blockIdx.x * blockDim.x + threadIdx.x;
float x = 1.0f + 1e-7f * (float)i;
if (i % every == 0) { for (int k = 0; k < work; ++k) x = fmaf(x, 0.9999f, 1e-4f); }
out[i] = x;
}
// ── access pattern: a warp's 32 loads are served together, in 32-byte sectors ─
extern "C" __global__ void strided_read(const float* __restrict__ x, float* __restrict__ y,
long long n, long long mask, int stride) {
long long i = blockIdx.x * (long long)blockDim.x + threadIdx.x;
if (i < n) y[i] = x[(i * stride) & mask];
}
extern "C" __global__ void gather_read(const float* __restrict__ x, const int* __restrict__ idx,
float* __restrict__ y, long long n) {
long long i = blockIdx.x * (long long)blockDim.x + threadIdx.x;
if (i < n) y[i] = x[idx[i]];
}
// ── latency: one thread, and every load needs the result of the previous one ──
extern "C" __global__ void chase(const unsigned int* __restrict__ next, unsigned int start,
int steps, unsigned int* sink, long long* cycles) {
unsigned int p = start;
long long t0 = clock64();
for (int s = 0; s < steps; ++s) p = next[p];
long long t1 = clock64();
sink[0] = p;
cycles[0] = t1 - t0;
}
// ── bandwidth vs working-set size: the whole GPU re-reads one buffer ──────────
// Every block walks the *whole* buffer (from its own starting point), so the footprint of
// each SM is the full working set — the thing whose size the sweep is about.
extern "C" __global__ void reread(const float4* __restrict__ x, long long mask4, int reps,
float* sink) {
long long start = (long long)blockIdx.x * 7919 * blockDim.x; // spread the blocks out
float acc = 0.f;
#pragma unroll 8
for (int r = 0; r < reps; ++r) {
float4 v = x[(start + threadIdx.x + (long long)r * blockDim.x) & mask4];
acc += v.x + v.y + v.z + v.w;
}
if (acc == 1234.5f) sink[0] = acc; // never true; keeps the loads alive
}
// ── latency hiding: a streaming read with ILP independent loads per thread ────
template <int ILP>
__device__ __forceinline__ void stream_read_impl(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 1
for (long long base = tid; base < n4; base += nth * ILP) {
float4 v[ILP];
#pragma unroll
for (int j = 0; j < ILP; ++j) {
long long i = base + j * nth;
float4 z = {0.f, 0.f, 0.f, 0.f};
v[j] = (i < n4) ? x[i] : z;
}
#pragma unroll
for (int j = 0; j < ILP; ++j) acc += v[j].x + v[j].y + v[j].z + v[j].w;
}
if (acc == 1234.5f) sink[0] = acc;
}
extern "C" __global__ void stream_read_1(const float4* __restrict__ x, long long n4, float* s) { stream_read_impl<1>(x, n4, s); }
extern "C" __global__ void stream_read_2(const float4* __restrict__ x, long long n4, float* s) { stream_read_impl<2>(x, n4, s); }
extern "C" __global__ void stream_read_4(const float4* __restrict__ x, long long n4, float* s) { stream_read_impl<4>(x, n4, s); }
extern "C" __global__ void stream_read_8(const float4* __restrict__ x, long long n4, float* s) { stream_read_impl<8>(x, n4, s); }
// ── the compute roof: eight independent FMA chains per thread, no memory traffic ─
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;
}
// ── the one-knob roofline: read an element, do k FMAs on it, write it back ────
extern "C" __global__ void intensity(const float4* __restrict__ x, float4* __restrict__ y,
long long n4, int k) {
long long tid = blockIdx.x * (long long)blockDim.x + threadIdx.x;
long long nth = (long long)gridDim.x * blockDim.x;
for (long long i = tid; i < n4; i += nth) {
float4 v = x[i];
#pragma unroll 4
for (int j = 0; j < k; ++j) {
v.x = fmaf(v.x, 0.9999f, 1e-4f); v.y = fmaf(v.y, 0.9999f, 1e-4f);
v.z = fmaf(v.z, 0.9999f, 1e-4f); v.w = fmaf(v.w, 0.9999f, 1e-4f);
}
y[i] = v;
}
}
// ── the GPU's own nanosecond clock, independent of the SM clock ───────────────
__device__ __forceinline__ unsigned long long gtimer() {
unsigned long long t;
asm volatile("mov.u64 %0, %%globaltimer;" : "=l"(t));
return t;
}
// ── a clock probe: spin for spin_ns of wall time, count the SM cycles that passed ─
extern "C" __global__ void clock_probe(long long* out, int idx, int spin_ns) {
unsigned long long t0 = gtimer(), t1 = t0;
long long c0 = clock64();
while (t1 - t0 < (unsigned long long)spin_ns) t1 = gtimer();
long long c1 = clock64();
out[3 * idx] = (long long)t0;
out[3 * idx + 1] = (long long)(t1 - t0);
out[3 * idx + 2] = c1 - c0;
}
// ── strided reads with no writes, served from L2 only (ld.global.cg skips L1) ─
extern "C" __global__ void strided_sum_cg(const float* __restrict__ x, long long mask, int stride,
int reps, float* out) {
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 (int r = 0; r < reps; ++r) acc += __ldcg(&x[((tid + (long long)r * nth) * stride) & mask]);
out[tid] = acc;
}
// ── latency under load: block 0 chases pointers while every other block streams ─
extern "C" __global__ void loaded_chase(const unsigned int* __restrict__ next, unsigned int start,
int steps, const float4* __restrict__ bg, long long bg_n4,
volatile int* done, unsigned long long* bg_loads,
long long* out) {
if (blockIdx.x == 0) {
if (threadIdx.x == 0) {
unsigned int p = start;
unsigned long long t0 = gtimer();
for (int s = 0; s < steps; ++s) p = next[p];
unsigned long long t1 = gtimer();
out[0] = (long long)(t1 - t0);
out[1] = p;
__threadfence();
*done = 1;
}
return;
}
long long nth = (long long)(gridDim.x - 1) * blockDim.x;
long long i = (long long)(blockIdx.x - 1) * blockDim.x + threadIdx.x;
float acc = 0.f;
unsigned long long n = 0;
while (*done == 0) {
#pragma unroll 8
for (int j = 0; j < 32; ++j) {
float4 v = bg[i];
acc += v.x;
i += nth;
if (i >= bg_n4) i -= bg_n4;
}
n += 32;
}
atomicAdd(bg_loads, n);
if (acc == 1234.5f) out[2] = 1;
}
'''
KERNEL_NAMES = ["diverge", "straggler", "strided_read", "gather_read", "chase", "reread",
"stream_read_1", "stream_read_2", "stream_read_4", "stream_read_8",
"fma_peak", "intensity", "clock_probe", "strided_sum_cg", "loaded_chase"]
MOD = cp.RawModule(code=SRC)
KERN = {name: MOD.get_function(name) for name in KERNEL_NAMES}
def launch(name, grid, block, *args):
KERN[name]((int(grid),), (int(block),), args)
def free_gpu():
cp.get_default_memory_pool().free_all_blocks()
torch.cuda.empty_cache()
FULL_BLOCKS = spec["sms"] * (spec["max_threads_per_sm"] // 256) # 256-thread blocks that fill every SM once
def build_chain(nbytes, line=128, order="random", seed=0):
"""A pointer chain built on the GPU: one node per `line` bytes, visited in random or
sequential order. Building it on the GPU keeps the card busy (and at working clocks)
instead of idling for seconds while the CPU shuffles a large permutation."""
words, stride = nbytes // 4, line // 4
if order == "random":
cp.random.seed(seed)
idx = cp.random.permutation(words // stride)
else:
idx = cp.arange(words // stride)
idx = (idx * stride).astype(cp.uint32)
nxt = cp.zeros(words, dtype=cp.uint32)
nxt[idx] = cp.roll(idx, -1)
return nxt, int(idx[0].get())
_WARM = cp.empty(64 * 2**20 // 4, dtype=cp.float32)
_WARM_SINK = cp.zeros(1, dtype=cp.float32)
def warm_up(seconds=0.05):
"""Keep the GPU busy for a moment so a light measurement starts at working clocks."""
t0 = time.perf_counter()
while time.perf_counter() - t0 < seconds:
launch("stream_read_4", FULL_BLOCKS, 256, _WARM, np.int64(_WARM.size // 4), _WARM_SINK)
cp.cuda.Device().synchronize()
print(f"compiled {len(KERN)} kernels; one full wave = {FULL_BLOCKS} blocks × 256 threads")compiled 15 kernels; one full wave = 160 blocks × 256 threads
Divergence: one instruction stream per warp
The diverge kernel gives each thread one of paths different loops to run. The only thing that changes between rows is who gets which loop: either whole warps agree (warp 0 takes path 0, warp 1 takes path 1, …) or the split runs through every warp (lane 0 takes path 0, lane 1 path 1, …). The total arithmetic is identical in every row.
Each configuration is measured seven times, in a shuffled order, so that a drifting clock can’t masquerade as an effect: the spread between repeats is the noise floor, measured rather than guessed.
with experiment("simt_divergence"):
n = FULL_BLOCKS * 256 * 4
out = cp.empty(n, dtype=cp.float32)
WORK = 20000
configs = [(by_lane, paths) for by_lane in (0, 1) for paths in (1, 2, 4)]
trials = {c: [] for c in configs}
rng = np.random.default_rng(0)
for rep in range(7):
for j in rng.permutation(len(configs)):
by_lane, paths = configs[j]
trials[(by_lane, paths)].append(gpu_time(
lambda: launch("diverge", n // 256, 256, out, np.int32(paths), np.int32(by_lane),
np.int32(WORK)), iters=5, warmup=1))
rows = []
for (by_lane, paths), ts in trials.items():
ts = np.array(ts) * 1e3
rows.append(dict(split="lanes inside every warp" if by_lane else "whole warps", paths=paths,
ms=float(np.median(ts)), ms_min=float(ts.min()), ms_max=float(ts.max())))
div = pd.DataFrame(rows)
base = div.ms.iloc[0]
for c in ("ms", "ms_min", "ms_max"):
div["vs_uniform" + c[2:]] = div[c] / base
RESULTS["divergence"] = div.to_dict("records")
print(div.to_string(index=False, float_format=lambda v: f"{v:.3f}"))
del out; free_gpu() split paths ms ms_min ms_max vs_uniform vs_uniform_min vs_uniform_max
whole warps 1 0.895 0.877 0.921 1.000 0.979 1.029
whole warps 2 0.885 0.872 0.903 0.989 0.974 1.009
whole warps 4 0.885 0.836 0.901 0.989 0.934 1.006
lanes inside every warp 1 0.893 0.848 0.931 0.998 0.948 1.040
lanes inside every warp 2 1.770 1.688 1.790 1.978 1.886 2.000
lanes inside every warp 4 3.511 3.301 3.594 3.923 3.688 4.016
[simt_divergence: 0.5 s]
The same fact from the other side: one thread in every does a long loop and the rest do nothing. If the warp is the unit of execution, the time depends not on how many threads work but on how many warps contain at least one that does.
with experiment("simt_straggler"):
n, WORK = FULL_BLOCKS * 256 * 4, 20000
out = cp.empty(n, dtype=cp.float32)
rows = []
for every in [1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048]:
t = gpu_time(lambda: launch("straggler", n // 256, 256, out, np.int32(every), np.int32(WORK)))
rows.append(dict(every=every, busy_threads=1 / every,
busy_warps=min(1.0, 32 / every), ms=t * 1e3))
strag = pd.DataFrame(rows)
strag["vs_all_busy"] = strag.ms / strag.ms.iloc[0]
# the floor: one warp alone, nothing to interleave with — the latency of its dependent chain
one_warp_ms = gpu_time(lambda: launch("straggler", 1, 32, out, np.int32(1), np.int32(WORK))) * 1e3
RESULTS["straggler"] = strag.to_dict("records")
RESULTS["straggler_one_warp_ms"] = one_warp_ms
print(strag.to_string(index=False, float_format=lambda v: f"{v:.4g}"))
print(f"\none warp on its own: {one_warp_ms:.4f} ms "
f"(= {one_warp_ms / 1e3 / WORK * 1e9:.2f} ns per dependent FMA)")
fig, ax = plt.subplots(figsize=(6.4, 3.2))
ax.plot(strag.every, strag.vs_all_busy, "-o", color=BLUE, ms=4, label="measured time")
ax.plot(strag.every, strag.busy_threads, ":", color=GRAY, label="fraction of threads that work")
ax.plot(strag.every, strag.busy_warps, "--", color=EMBER, label="fraction of warps with ≥1 working thread")
ax.set_xscale("log", base=2); ax.set_yscale("log")
ax.set_xlabel("one working thread in every …"); ax.set_ylabel("relative to all threads working")
ax.legend(frameon=False, fontsize=8); plt.tight_layout(); plt.show()
del out; free_gpu() every busy_threads busy_warps ms vs_all_busy
1 1 1 0.8466 1
2 0.5 1 0.8451 0.9982
4 0.25 1 0.8519 1.006
8 0.125 1 0.8517 1.006
16 0.0625 1 0.8504 1.004
32 0.03125 1 0.8438 0.9967
64 0.01562 0.5 0.4825 0.57
128 0.007812 0.25 0.2956 0.3491
256 0.003906 0.125 0.241 0.2847
512 0.001953 0.0625 0.1311 0.1548
1024 0.0009766 0.03125 0.1167 0.1379
2048 0.0004883 0.01562 0.07357 0.0869
one warp on its own: 0.0696 ms (= 3.48 ns per dependent FMA)
[simt_straggler: 0.5 s]
Part 3 — memory
Coalescing: a warp reads together
Each thread copies one float, but reads it from x[i * stride]. With stride 1 the 32 threads of a warp touch 128 consecutive bytes; with stride 8 or more every thread lands in its own 32-byte sector, and the memory system moves 32 bytes to deliver 4. The last row is a random gather. The first “predicted” column assumes nothing but that sector size. The second was added after the first run of this lab, when the measurements kept falling past stride 8: it assumes that DRAM is read 64 bytes at a time (two sectors), whatever the caches do.
with experiment("coalescing"):
n = 2**24
x = cp.random.random(2**28, dtype=cp.float32) # 1 GB source, far beyond L2
y = cp.empty(n, dtype=cp.float32)
rows = []
for stride in [1, 2, 4, 8, 16, 32, 64]:
t = gpu_time(lambda: launch("strided_read", n // 256, 256, x, y, np.int64(n),
np.int64(2**28 - 1), np.int32(stride)))
# bytes moved per element: the 4-byte write + whatever the read drags in
rows.append(dict(pattern=f"stride {stride}", stride=stride, useful_gb_s=8 * n / t / 1e9,
predicted_32b=8 / (4 + 4 * min(stride, 8)),
predicted_64b=8 / (4 + 4 * min(stride, 16))))
idx = cp.random.randint(0, 2**28, n, dtype=cp.int32)
t = gpu_time(lambda: launch("gather_read", n // 256, 256, x, idx, y, np.int64(n)))
rows.append(dict(pattern="random gather", stride=None, useful_gb_s=8 * n / t / 1e9,
predicted_32b=8 / (4 + 4 + 32), predicted_64b=8 / (4 + 4 + 64)))
coal = pd.DataFrame(rows)
coal["measured_fraction"] = coal.useful_gb_s / coal.useful_gb_s.iloc[0]
RESULTS["coalescing"] = coal.to_dict("records")
print(coal.to_string(index=False, float_format=lambda v: f"{v:.3f}"))
del x, y, idx; free_gpu() pattern stride useful_gb_s predicted_32b predicted_64b measured_fraction
stride 1 1.000 238.625 1.000 1.000 1.000
stride 2 2.000 167.611 0.667 0.667 0.702
stride 4 4.000 106.059 0.400 0.400 0.444
stride 8 8.000 59.086 0.222 0.222 0.248
stride 16 16.000 29.455 0.222 0.118 0.123
stride 32 32.000 27.211 0.222 0.118 0.114
stride 64 64.000 25.218 0.222 0.118 0.106
random gather NaN 14.181 0.200 0.111 0.059
[coalescing: 1.2 s]
Where does a 64-byte granularity come from — the caches, or DRAM? The same strided read again, this time with no writes at all and with loads that skip L1 (__ldcg), from two sources: a 2 MB buffer that lives in L2, and a 1 GB buffer that has to come from DRAM. If the extra halving between stride 8 and stride 16 belongs to DRAM, it appears only in the second.
with experiment("coalescing_l2_vs_dram"):
nth = FULL_BLOCKS * 256
reps = 3200
out = cp.empty(nth, dtype=cp.float32)
rows = []
for label, nbytes in [("L2 (2 MB source)", 2 * 2**20), ("DRAM (1 GB source)", 2**30)]:
src = cp.random.random(nbytes // 4, dtype=cp.float32)
mask = np.int64(nbytes // 4 - 1)
for stride in [1, 2, 4, 8, 16, 32, 64]:
t = gpu_time(lambda: launch("strided_sum_cg", FULL_BLOCKS, 256, src, mask, np.int32(stride),
np.int32(reps), out), iters=7)
rows.append(dict(source=label, stride=stride, useful_gb_s=nth * reps * 4 / t / 1e9))
del src; free_gpu()
cl2 = pd.DataFrame(rows)
cl2["fraction_of_stride_1"] = cl2.useful_gb_s / cl2.groupby("source").useful_gb_s.transform("first")
cl2["predicted_32b"] = 1 / np.minimum(cl2.stride, 8)
cl2["predicted_64b"] = 1 / np.minimum(cl2.stride, 16)
RESULTS["coalescing_l2_vs_dram"] = cl2.to_dict("records")
print(cl2.to_string(index=False, float_format=lambda v: f"{v:.3f}"))
del out; free_gpu() source stride useful_gb_s fraction_of_stride_1 predicted_32b predicted_64b
L2 (2 MB source) 1 1325.137 1.000 1.000 1.000
L2 (2 MB source) 2 677.585 0.511 0.500 0.500
L2 (2 MB source) 4 330.323 0.249 0.250 0.250
L2 (2 MB source) 8 146.650 0.111 0.125 0.125
L2 (2 MB source) 16 159.970 0.121 0.125 0.062
L2 (2 MB source) 32 109.809 0.083 0.125 0.062
L2 (2 MB source) 64 110.979 0.084 0.125 0.062
DRAM (1 GB source) 1 239.403 1.000 1.000 1.000
DRAM (1 GB source) 2 125.857 0.526 0.500 0.500
DRAM (1 GB source) 4 67.160 0.281 0.250 0.250
DRAM (1 GB source) 8 34.206 0.143 0.125 0.125
DRAM (1 GB source) 16 17.088 0.071 0.125 0.062
DRAM (1 GB source) 32 15.475 0.065 0.125 0.062
DRAM (1 GB source) 64 13.389 0.056 0.125 0.062
[coalescing_l2_vs_dram: 1.7 s]
From L2 the fall stops at stride 8: every thread has its own 32-byte sector and nothing more is wasted, which is the sector model exactly. From DRAM it halves once more at stride 16. The caches work in 32-byte sectors; DRAM is read 64 bytes at a time.
Latency: how long one load takes, level by level
A single thread follows a chain of pointers laid out in random order, one per 128-byte line, so every load depends on the one before it and nothing can be overlapped or prefetched. Grow the chain and it spills out of L1, then out of L2, into DRAM. The time per step is the latency of whichever level the chain fits in. clock64() counts SM cycles inside the kernel, so the ratio of cycles to nanoseconds is the clock the SM actually ran at.
The chains are built on the GPU itself, and the card is kept busy for a moment right before each measurement. (A first version built them on the CPU; for the largest chain that left the GPU idle for seconds, it dropped to idle clocks, and the measurement came out 70% slow for a reason that had nothing to do with memory.) The sweep runs to 8 GB — half the card — to see whether anything lies beyond DRAM’s own latency, such as the cost of address translation.
with experiment("latency"):
sink, cyc = cp.zeros(1, dtype=cp.uint32), cp.zeros(1, dtype=cp.int64)
STEPS = 50_000
rows = []
for k in range(12, 34):
try:
chain, start = build_chain(2**k)
except cp.cuda.memory.OutOfMemoryError:
print(f"{2**k / 2**30:.0f} GB chain does not fit — stopping the sweep here")
break
warm_up()
t = gpu_time(lambda: launch("chase", 1, 1, chain, np.uint32(start), np.int32(STEPS), sink, cyc),
iters=3, warmup=1)
cycles = int(cyc.get()[0]) / STEPS
rows.append(dict(bytes=2**k, ns=t / STEPS * 1e9, cycles=cycles,
sm_ghz=cycles / (t / STEPS * 1e9)))
del chain; free_gpu()
lat = pd.DataFrame(rows)
RESULTS["latency"] = lat.to_dict("records")
print(lat.to_string(index=False, float_format=lambda v: f"{v:.3g}"))
fig, ax = plt.subplots(figsize=(6.4, 3.2))
ax.plot(lat["bytes"], lat.ns, "-o", color=BLUE, ms=4)
ax.axvline(spec["l2_bytes"], color=EMBER, ls="--", lw=0.8)
ax.text(spec["l2_bytes"], ax.get_ylim()[1] * 0.95, " L2 size", color=EMBER, fontsize=8, va="top")
ax.set_xscale("log", base=2)
ax.set_xlabel("working set of the pointer chain (bytes)"); ax.set_ylabel("ns per dependent load")
plt.tight_layout(); plt.show() bytes ns cycles sm_ghz
4096 26.7 40.1 1.5
8192 27 40.2 1.49
16384 27.5 40.5 1.47
32768 27.8 41 1.48
65536 96.4 147 1.52
131072 150 232 1.55
262144 152 232 1.53
524288 150 232 1.55
1048576 151 232 1.54
2097152 150 232 1.55
4194304 162 249 1.53
8388608 314 491 1.56
16777216 314 489 1.56
33554432 311 486 1.56
67108864 316 492 1.56
134217728 316 493 1.56
268435456 316 493 1.56
536870912 317 494 1.56
1073741824 317 493 1.56
2147483648 317 494 1.56
4294967296 317 495 1.56
8589934592 326 491 1.51
[latency: 4.8 s]
Latency hiding: Little’s law on a GPU
The chain above is the worst case: one thread, nothing else to do while it waits. A real kernel has thousands of loads in flight at once, and while one warp waits the scheduler issues another. How many must be in flight? Little’s law: bytes in flight = latency × bandwidth. Here a streaming read is launched with a controlled number of warps, each thread keeping 1, 2, 4 or 8 independent 16-byte loads outstanding (ILP). The warps are spread one per block, so they land on as many different SMs as possible. If Little’s law is right, the four curves are four different functions of warps but one function of bytes in flight.
A second series packs the same warps into a single block, so they all run on one SM. That separates the GPU-wide question (how much must be in flight?) from a per-SM one (how much can one SM keep in flight?).
with experiment("latency_hiding"):
n4 = 256 * 2**20 // 16 # 256 MB, far beyond L2
xs = cp.random.random(n4 * 4, dtype=cp.float32)
sink = cp.zeros(1, dtype=cp.float32)
resident_warps = spec["sms"] * spec["max_warps_per_sm"]
max_blocks_per_sm = prop("maxBlocksPerMultiProcessor") or (16 if cc < (8, 0) else 32)
lat_df = pd.DataFrame(RESULTS.get("latency", []))
past_l2 = lat_df[(lat_df["bytes"] >= 4 * spec["l2_bytes"]) & (lat_df["bytes"] <= 16 * spec["l2_bytes"])] if len(lat_df) else lat_df
dram_latency_s = float(past_l2.ns.median()) * 1e-9 if len(past_l2) else 500e-9
rows = []
for ilp in (1, 2, 4, 8):
for warps in [1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096]:
# one warp per block while blocks can still all be resident; fatter blocks beyond that
per_block = max(1, -(-warps // (spec["sms"] * max_blocks_per_sm)))
per_block = 1 << (per_block - 1).bit_length() # round up to a power of two
block, grid = 32 * per_block, warps // per_block
t = gpu_time(lambda: launch(f"stream_read_{ilp}", grid, block, xs, np.int64(n4), sink),
iters=5, warmup=1)
live = min(warps, resident_warps)
rows.append(dict(layout="spread", ilp=ilp, warps=warps, sms_used=min(grid, spec["sms"]),
resident_warps=live, bytes_in_flight=live * 32 * 16 * ilp,
gb_s=n4 * 16 / t / 1e9))
for warps in [1, 2, 4, 8, 16, 32]: # the same warps, all on one SM
n4_small = 32 * 2**20 // 16 # 32 MB: one SM is slow, keep it short
t = gpu_time(lambda: launch(f"stream_read_{ilp}", 1, 32 * warps, xs, np.int64(n4_small), sink),
iters=5, warmup=1)
rows.append(dict(layout="one SM", ilp=ilp, warps=warps, sms_used=1, resident_warps=warps,
bytes_in_flight=warps * 32 * 16 * ilp, gb_s=n4_small * 16 / t / 1e9))
hide = pd.DataFrame(rows)
spread = hide[hide.layout == "spread"]
one_sm = hide[hide.layout == "one SM"]
bw_max = spread.gb_s.max() * 1e9
hide["littles_law_gb_s"] = np.minimum(bw_max, hide.bytes_in_flight / dram_latency_s) / 1e9
RESULTS["latency_hiding"] = {"rows": hide.to_dict("records"), "dram_latency_s": dram_latency_s,
"bw_max": bw_max, "one_sm_max": one_sm.gb_s.max() * 1e9}
print(f"DRAM latency used for Little's law: {dram_latency_s * 1e9:.0f} ns; "
f"best streaming read {bw_max / 1e9:.0f} GB/s")
print(f"=> bytes that must be in flight to saturate: {bw_max * dram_latency_s / 1024:.0f} KB")
print(f"one SM on its own tops out at {one_sm.gb_s.max():.1f} GB/s "
f"({spec['sms']} of them: {spec['sms'] * one_sm.gb_s.max():.0f} GB/s)")
print("\nspread over the SMs (GB/s):")
print(spread.pivot(index="warps", columns="ilp", values="gb_s").round(1).to_string())
print("\nall on one SM (GB/s):")
print(one_sm.pivot(index="warps", columns="ilp", values="gb_s").round(1).to_string())
fig, axes = plt.subplots(1, 2, figsize=(9, 3.4), sharey=True)
colors = {1: GRAY, 2: BLUE, 4: FOREST, 8: EMBER}
for ilp, d in spread.groupby("ilp"):
axes[0].plot(d.warps, d.gb_s, "-o", ms=3, color=colors[ilp], label=f"ILP {ilp}")
axes[1].plot(d.bytes_in_flight, d.gb_s, "o", ms=3, color=colors[ilp])
ll = hide[hide.layout == "spread"].sort_values("bytes_in_flight")
axes[1].plot(ll.bytes_in_flight, ll.littles_law_gb_s, "--", color=INK, lw=0.9,
label="latency × bandwidth")
axes[0].set_xscale("log", base=2); axes[1].set_xscale("log", base=2); axes[0].set_yscale("log")
axes[0].set_xlabel("warps launched"); axes[1].set_xlabel("bytes in flight")
axes[0].set_ylabel("read bandwidth (GB/s)")
axes[0].legend(frameon=False, fontsize=8); axes[1].legend(frameon=False, fontsize=8)
plt.tight_layout(); plt.show()
del xs; free_gpu()DRAM latency used for Little's law: 314 ns; best streaming read 275 GB/s
=> bytes that must be in flight to saturate: 84 KB
one SM on its own tops out at 48.9 GB/s (40 of them: 1957 GB/s)
spread over the SMs (GB/s):
ilp 1 2 4 8
warps
1 1.5 2.7 5.1 8.6
2 3.0 5.4 10.2 17.0
4 6.0 10.9 20.2 34.0
8 11.9 21.6 39.9 66.2
16 23.8 43.0 78.6 130.2
32 47.3 84.3 149.3 236.7
64 92.8 160.5 251.1 264.1
128 174.3 253.5 255.0 266.1
256 257.0 264.3 258.0 265.5
512 264.0 265.9 253.6 263.7
1024 271.9 265.6 256.0 264.0
2048 275.2 265.5 256.2 264.3
4096 274.7 266.2 259.0 263.9
all on one SM (GB/s):
ilp 1 2 4 8
warps
1 1.5 2.6 4.9 7.2
2 3.0 5.3 9.6 15.5
4 5.9 10.4 18.6 31.3
8 11.7 19.7 35.3 46.4
16 22.7 35.3 48.9 47.8
32 39.6 48.5 48.9 48.0
[latency_hiding: 6.5 s]
Latency under load
Little’s law above used the latency of an idle memory system — one pointer chase with nothing else running. Under load, requests queue, and each one should take longer. That is measured directly here: block 0 of the grid chases pointers through a 256 MB chain while every other block streams through a separate 1 GB buffer, and the number of streaming blocks is swept from none to the whole GPU. For each setting: the latency the chase saw, and the bandwidth the background got.
with experiment("loaded_latency"):
chain, start = build_chain(256 * 2**20)
bg = cp.random.random(2**30 // 4, dtype=cp.float32)
bg_n4 = np.int64(bg.size // 4)
STEPS = 20_000
rows = []
for blocks in [0, 1, 2, 4, 8, 16, 24, 32, 48, 64, 96, 128, FULL_BLOCKS - 1]:
samples = []
for _ in range(3):
done = cp.zeros(1, dtype=cp.int32)
loads = cp.zeros(1, dtype=cp.uint64)
out = cp.zeros(3, dtype=cp.int64)
warm_up()
launch("loaded_chase", 1 + blocks, 256, chain, np.uint32(start), np.int32(STEPS), bg, bg_n4,
done, loads, out)
cp.cuda.Device().synchronize()
chase_ns = int(out.get()[0])
samples.append((chase_ns / STEPS, int(loads.get()[0]) * 16 / chase_ns)) # ns per load, GB/s
lat_ns, bw = np.median([s[0] for s in samples]), np.median([s[1] for s in samples])
rows.append(dict(background_blocks=blocks, latency_ns=float(lat_ns), background_gb_s=float(bw)))
loaded = pd.DataFrame(rows)
RESULTS["loaded_latency"] = loaded.to_dict("records")
del chain, bg; free_gpu()
print(loaded.to_string(index=False, float_format=lambda v: f"{v:.1f}"))
fig, ax = plt.subplots(figsize=(6.4, 3.2))
ax.plot(loaded.background_gb_s, loaded.latency_ns, "-o", color=EMBER, ms=4)
ax.set_xlabel("bandwidth used by everything else (GB/s)"); ax.set_ylabel("latency of one load (ns)")
plt.tight_layout(); plt.show() background_blocks latency_ns background_gb_s
0 328.6 0.0
1 331.6 38.9
2 338.6 76.7
4 357.3 147.4
8 455.2 260.3
16 1165.0 270.3
24 1693.4 270.1
32 2148.1 268.4
48 3631.9 268.1
64 4582.0 267.7
96 7029.4 267.4
128 9541.0 265.4
159 9595.9 264.1
[loaded_latency: 4.6 s]
Up to about 150 GB/s of background traffic the latency barely moves; near saturation it climbs to ~450 ns — the latency the Little’s-law loads actually saw at their knee. Past saturation the background gets no more bandwidth at all, and the latency explodes into microseconds: more requests in flight than Little’s law asks for only lengthen the queue.
Bandwidth: what each level delivers
Latency is the cost of one load; bandwidth is how many bytes per second the level delivers when the whole GPU is asking. A full-occupancy grid re-reads one buffer of a given size, over and over, and every block walks the whole of it. While the buffer fits in an SM’s L1, the reads never leave the SM; up to the L2 size they come from L2; beyond it, from DRAM.
with experiment("hierarchy_bandwidth"):
nth = FULL_BLOCKS * 256
sink = cp.zeros(1, dtype=cp.float32)
reps = int(max(64, 2e9 / (nth * 16)) // 8 * 8) # ~2 GB of loads per launch at every size
rows = []
for k in range(12, 31):
buf = cp.random.random(2**k // 4, dtype=cp.float32)
t = gpu_time(lambda: launch("reread", FULL_BLOCKS, 256, buf, np.int64(2**k // 16 - 1),
np.int32(reps), sink), iters=5)
rows.append(dict(bytes=2**k, gb_s=nth * reps * 16 / t / 1e9))
del buf; free_gpu()
hbw = pd.DataFrame(rows)
RESULTS["hierarchy_bandwidth"] = hbw.to_dict("records")
print(hbw.to_string(index=False, float_format=lambda v: f"{v:.4g}"))
fig, ax = plt.subplots(figsize=(6.4, 3.2))
ax.plot(hbw["bytes"], hbw.gb_s, "-o", color=FOREST, ms=4)
ax.axvline(spec["l2_bytes"], color=EMBER, ls="--", lw=0.8)
ax.axhline(spec["peak_dram_bw_theory"] / 1e9, color=GRAY, ls=":", lw=0.8)
ax.text(hbw["bytes"].iloc[0], spec["peak_dram_bw_theory"] / 1e9, "DRAM datasheet", color=GRAY,
fontsize=8, va="bottom")
ax.set_xscale("log", base=2); ax.set_yscale("log")
ax.set_xlabel("working set (bytes)"); ax.set_ylabel("read bandwidth (GB/s)")
plt.tight_layout(); plt.show() bytes gb_s
4096 3870
8192 3882
16384 3870
32768 3870
65536 3820
131072 1561
262144 1336
524288 1114
1048576 1107
2097152 1094
4194304 1088
8388608 642.5
16777216 430.6
33554432 323.8
67108864 293.8
134217728 271
268435456 268.8
536870912 267.8
1073741824 267.1
[hierarchy_bandwidth: 0.9 s]
How big is L1, exactly?
On Turing, L1 and shared memory are one 96 KB array per SM, and the driver decides per kernel how much of it becomes L1. A kernel can state a preference, the carveout: 0 asks for as much L1 as possible, 100 for as much shared memory as possible. The chase is repeated here in fine steps under each setting, with two chain orders: random (as above) and sequential, which is immune to the unlucky set conflicts a random chain can hit just below capacity. The bandwidth kernel is repeated at the power-of-two sizes it supports.
with experiment("l1_capacity"):
sink, cyc = cp.zeros(1, dtype=cp.uint32), cp.zeros(1, dtype=cp.int64)
bsink = cp.zeros(1, dtype=cp.float32)
nth, reps = FULL_BLOCKS * 256, 3048
rows, notes = [], {}
for label, carveout in [("default", None), ("max L1 (carveout 0)", 0), ("max shared (carveout 100)", 100)]:
if carveout is not None:
try:
for name in ("chase", "reread"):
KERN[name].preferred_shared_memory_carveout = carveout
except Exception as e:
notes[label] = f"could not set carveout: {e}"
continue
for kb in [8, 16, 24, 32, 40, 48, 56, 64, 72, 80, 96, 112, 128, 160, 192]:
for order in ("random", "sequential"):
chain, start = build_chain(kb * 1024, order=order)
t = gpu_time(lambda: launch("chase", 1, 1, chain, np.uint32(start), np.int32(20000), sink, cyc),
iters=3, warmup=1)
rows.append(dict(carveout=label, kb=kb, kind=f"chase, {order}", ns=t / 20000 * 1e9,
cycles=int(cyc.get()[0]) / 20000))
del chain
if kb & (kb - 1) == 0: # the bandwidth kernel needs a power of two
buf = cp.random.random(kb * 1024 // 4, dtype=cp.float32)
t = gpu_time(lambda: launch("reread", FULL_BLOCKS, 256, buf, np.int64(kb * 1024 // 16 - 1),
np.int32(reps), bsink), iters=5)
rows.append(dict(carveout=label, kb=kb, kind="bandwidth", gb_s=nth * reps * 16 / t / 1e9))
del buf
free_gpu()
try:
for name in ("chase", "reread"):
KERN[name].preferred_shared_memory_carveout = -1 # back to the driver's default
except Exception as e:
notes["reset"] = str(e)
l1 = pd.DataFrame(rows)
seq = l1[l1.kind == "chase, sequential"]
caps = {c: int(d[d.cycles <= 1.3 * d.cycles.min()].kb.max()) for c, d in seq.groupby("carveout")}
RESULTS["l1_capacity"] = {"rows": l1.to_dict("records"), "capacity_kb": caps, "notes": notes}
print("L1 capacity seen by a sequential chase (KB):", caps, notes or "")
print(l1.pivot_table(index="kb", columns=["carveout", "kind"], values="cycles").round(0).to_string())
bw = l1[l1.kind == "bandwidth"]
if len(bw):
print("\nbandwidth (GB/s):")
print(bw.pivot_table(index="kb", columns="carveout", values="gb_s").round(0).to_string())L1 capacity seen by a sequential chase (KB): {'default': 56, 'max L1 (carveout 0)': 56, 'max shared (carveout 100)': 24}
carveout default max L1 (carveout 0) max shared (carveout 100)
kind chase, random chase, sequential chase, random chase, sequential chase, random chase, sequential
kb
8 41.0 41.0 41.0 41.0 41.0 41.0
16 41.0 41.0 41.0 41.0 41.0 41.0
24 42.0 42.0 42.0 42.0 42.0 42.0
32 42.0 42.0 42.0 42.0 138.0 138.0
40 43.0 43.0 43.0 43.0 195.0 195.0
48 44.0 44.0 44.0 44.0 227.0 227.0
56 44.0 44.0 44.0 44.0 227.0 232.0
64 115.0 118.0 115.0 128.0 232.0 232.0
72 191.0 198.0 191.0 203.0 233.0 232.0
80 231.0 232.0 231.0 232.0 232.0 232.0
96 232.0 232.0 232.0 232.0 232.0 232.0
112 233.0 232.0 232.0 232.0 232.0 232.0
128 232.0 233.0 232.0 232.0 232.0 232.0
160 232.0 232.0 231.0 232.0 232.0 232.0
192 232.0 232.0 232.0 232.0 232.0 232.0
bandwidth (GB/s):
carveout default max L1 (carveout 0) max shared (carveout 100)
kb
8 3903.0 3928.0 3879.0
16 3907.0 3917.0 3870.0
32 3901.0 3916.0 2713.0
64 3840.0 3841.0 1623.0
128 1775.0 1767.0 1432.0
[l1_capacity: 1.2 s]
A kernel with no shared memory already gets the large L1: “default” and “max L1” coincide, with 56 KB running at full L1 speed out of the 64 KB configuration. Asking for maximum shared memory shrinks L1 to the 32 KB configuration, 24 KB usable, and the bandwidth at 32 KB drops with it. (The coarse chase above, stepping from 32 KB straight to 64 KB, could only say “between the two”.)
The level below DRAM: the host link
The CPU’s memory is one more level of the hierarchy, the slowest and furthest away, reached over PCIe. Ordinary (“pageable”) host memory has to be staged through a pinned buffer by the driver; memory allocated as pinned can be read by the GPU’s copy engine directly.
with experiment("pcie"):
rows = []
for nbytes in [4, 4096, 2**20, 16 * 2**20, 256 * 2**20, 1024 * 2**20]:
h = torch.empty(nbytes, dtype=torch.uint8)
hp = h.pin_memory()
d = torch.empty(nbytes, dtype=torch.uint8, device=DEV)
rows.append(dict(
bytes=nbytes,
h2d_pageable_s=wall_time(lambda: d.copy_(h), sync=True),
h2d_pinned_s=wall_time(lambda: d.copy_(hp, non_blocking=True), sync=True),
d2h_pinned_s=wall_time(lambda: hp.copy_(d, non_blocking=True), sync=True),
))
del h, hp, d
pcie = pd.DataFrame(rows)
for c in ["h2d_pageable", "h2d_pinned", "d2h_pinned"]:
pcie[c + "_gb_s"] = pcie["bytes"] / pcie[c + "_s"] / 1e9
RESULTS["pcie"] = pcie.to_dict("records")
free_gpu()
print(pcie.to_string(index=False, float_format=lambda v: f"{v:.3g}")) bytes h2d_pageable_s h2d_pinned_s d2h_pinned_s h2d_pageable_gb_s h2d_pinned_gb_s d2h_pinned_gb_s
4 2.13e-05 2.12e-05 1.97e-05 0.000188 0.000188 0.000203
4096 2.44e-05 2.11e-05 1.98e-05 0.168 0.194 0.207
1048576 0.00035 0.000107 9.64e-05 2.99 9.79 10.9
16777216 0.00366 0.00138 0.0013 4.59 12.2 12.9
268435456 0.0401 0.0218 0.0206 6.7 12.3 13
1073741824 0.161 0.087 0.0823 6.69 12.3 13
[pcie: 7.3 s]
Part 4 — FLOPs
The CUDA-core roof
fma_peak never touches memory: every thread runs eight independent chains of fused multiply-adds in registers. That is the most arithmetic the FP32 lanes can do. It runs for a couple of seconds under load while nvidia-smi is polled — the T4 is a 70-watt card, and the question is whether it holds its boost clock.
with experiment("compute_roof"):
out = cp.empty(FULL_BLOCKS * 8 * 256, dtype=cp.float32)
nth = out.size
iters = int(0.02 * spec["peak_fp32_flops_theory"] / (nth * 16)) # ~20 ms per launch
t, clocks = sustained(lambda: launch("fma_peak", FULL_BLOCKS * 8, 256, out, np.int32(iters)))
fma = {"flops_per_s": nth * iters * 16 / t, "clocks": clocks}
fma["theory_at_measured_clock"] = spec["fp32_lanes_total"] * 2 * clocks.get("clocks.sm", np.nan) * 1e6
gemm = {}
for label, n, dtype in [("fp32 4096", 4096, torch.float32), ("fp16 4096", 4096, torch.float16),
("fp16 8192", 8192, torch.float16)]:
A = torch.randn(n, n, device=DEV, dtype=dtype); B = torch.randn(n, n, device=DEV, dtype=dtype)
t, clocks = sustained(lambda: A @ B)
gemm[label] = {"flops_per_s": 2 * n**3 / t, "clocks": clocks}
del A, B
RESULTS["compute_roof"] = {"fma_peak": fma, "gemm": gemm}
del out; free_gpu()
rows = [("FMA kernel, CUDA cores, fp32", fma)] + [(f"cuBLAS matmul {k}", v) for k, v in gemm.items()]
print(f"{'':32s} {'TFLOP/s':>8s} {'SM MHz':>7s} {'W':>6s} {'°C':>4s}")
for name, r in rows:
c = r["clocks"]
print(f"{name:32s} {r['flops_per_s'] / 1e12:8.2f} {c.get('clocks.sm', 0):7.0f} "
f"{c.get('power.draw', 0):6.1f} {c.get('temperature.gpu', 0):4.0f}")
print(f"\ntheoretical fp32 at max clock {spec['peak_fp32_flops_theory'] / 1e12:.2f} TFLOP/s; "
f"at the clock measured under load {fma['theory_at_measured_clock'] / 1e12:.2f} TFLOP/s")
if spec["peak_fp16_tensor_flops_spec"]:
print(f"datasheet fp16 tensor-core peak {spec['peak_fp16_tensor_flops_spec'] / 1e12:.0f} TFLOP/s") TFLOP/s SM MHz W °C
FMA kernel, CUDA cores, fp32 6.81 1365 67.1 68
cuBLAS matmul fp32 4096 3.52 705 66.2 69
cuBLAS matmul fp16 4096 20.26 555 66.8 70
cuBLAS matmul fp16 8192 20.81 570 66.5 71
theoretical fp32 at max clock 8.14 TFLOP/s; at the clock measured under load 6.99 TFLOP/s
datasheet fp16 tensor-core peak 65 TFLOP/s
[compute_roof: 8.5 s]
Does the roof sink? A 70-watt card under load
The sustained numbers above were taken after a warm-up. The obvious expectation is that the card boosts at first and sinks as the power limit catches up, so this checks it: the card rests for ten seconds, then multiplies the same pair of 4096×4096 FP16 matrices back to back for five seconds. Every multiplication is timed, and the SM clock and power draw are polled alongside.
with experiment("power_cap"):
n = 4096
A = torch.randn(n, n, device=DEV, dtype=torch.float16)
B = torch.randn(n, n, device=DEV, dtype=torch.float16)
A @ B; torch.cuda.synchronize()
time.sleep(10) # let the card cool and clock back up
polled, stop = [], threading.Event()
t_start = time.perf_counter()
def poll():
while not stop.is_set():
s = smi("clocks.sm,power.draw,temperature.gpu")
s["t"] = time.perf_counter() - t_start
polled.append(s)
stop.wait(0.05)
poller = threading.Thread(target=poll, daemon=True); poller.start()
ev_s, ev_e = torch.cuda.Event(enable_timing=True), torch.cuda.Event(enable_timing=True)
runs = []
while time.perf_counter() - t_start < 5.0:
ev_s.record(); A @ B; ev_e.record(); ev_e.synchronize()
runs.append(dict(t=time.perf_counter() - t_start,
tflops=2 * n**3 / (ev_s.elapsed_time(ev_e) / 1e3) / 1e12))
stop.set(); poller.join()
runs, polled = pd.DataFrame(runs), pd.DataFrame(polled)
RESULTS["power_cap"] = {"runs": runs.to_dict("records"), "polled": polled.to_dict("records")}
del A, B; free_gpu()
first = runs[runs.t < 0.1].tflops.median()
last = runs[runs.t > 4.0].tflops.median()
print(f"first 100 ms: {first:.1f} TFLOP/s · after 4 s: {last:.1f} TFLOP/s "
f"({len(runs)} multiplications)")
print(polled.iloc[[0, len(polled) // 4, len(polled) // 2, -1]].to_string(index=False))
fig, ax = plt.subplots(figsize=(7, 3.2))
ax.plot(runs.t, runs.tflops, color=BLUE, lw=1, label="fp16 matmul, TFLOP/s")
ax.set_xlabel("seconds under load"); ax.set_ylabel("TFLOP/s", color=BLUE)
ax2 = ax.twinx()
ax2.plot(polled.t, polled["clocks.sm"], color=EMBER, lw=1, label="SM clock, MHz")
ax2.set_ylabel("SM clock (MHz)", color=EMBER); ax2.spines["right"].set_visible(True)
plt.tight_layout(); plt.show()first 100 ms: 20.8 TFLOP/s · after 4 s: 19.7 TFLOP/s (715 multiplications)
clocks.sm power.draw temperature.gpu t
585.0 31.49 68.0 0.047024
570.0 66.56 70.0 1.225221
555.0 66.85 71.0 2.505841
540.0 66.42 72.0 4.966242
[power_cap: 15.2 s]
The expectation was wrong: there was no boost to lose. The first multiplication already ran at about 20 TFLOP/s, and within 150 ms the power was at the limit and the clock around 600 MHz. Yet in the operation sweep of Part 5, short FP16 multiplications reach 40–42 TFLOP/s — which at this card’s tensor-core rate needs the SMs above 1 GHz while they run. So something about what ran before a burst decides its clock.
Where the burst clock comes from
nvidia-smi samples the clock ten times a second at best, far too coarsely for a burst that lasts milliseconds. So the GPU measures itself: after every multiplication in a burst, a one-thread clock_probe kernel spins for 20 µs of wall time (read from the GPU’s own nanosecond timer) and counts the SM cycles that passed. Cycles over nanoseconds is the SM clock at that moment. The same burst — 2048³ FP16 multiplications back to back — is run after two different preludes: ten seconds of idling, and three seconds of memory-bound streaming, which keeps the card busy at a high clock while drawing far less power than the tensor cores do.
with experiment("burst_clock"):
n, N = 2048, 1500
A = torch.randn(n, n, device=DEV, dtype=torch.float16)
B = torch.randn(n, n, device=DEV, dtype=torch.float16)
probe = cp.zeros(3 * N, dtype=cp.int64)
stream_buf = cp.random.random(256 * 2**20 // 4, dtype=cp.float32)
stream_sink = cp.zeros(1, dtype=cp.float32)
A @ B; torch.cuda.synchronize()
def burst(prelude):
if prelude == "after 10 s idle":
time.sleep(10)
else:
t0 = time.perf_counter()
while time.perf_counter() - t0 < 3.0:
launch("stream_read_4", FULL_BLOCKS, 256, stream_buf, np.int64(stream_buf.size // 4), stream_sink)
cp.cuda.Device().synchronize()
before = smi("clocks.sm,power.draw")
evs = []
for i in range(N):
s, e = torch.cuda.Event(enable_timing=True), torch.cuda.Event(enable_timing=True)
s.record(); A @ B; e.record()
launch("clock_probe", 1, 1, probe, np.int32(i), np.int32(20_000))
evs.append((s, e))
torch.cuda.synchronize()
pr = probe.get().reshape(N, 3)
df = pd.DataFrame({"t_ms": (pr[:, 0] - pr[0, 0]) / 1e6, "sm_mhz": pr[:, 2] / pr[:, 1] * 1e3,
"tflops": [2 * n**3 / (s.elapsed_time(e) / 1e3) / 1e12 for s, e in evs]})
return df, before
runs = {}
for prelude in ["after 10 s idle", "after 3 s of memory-bound work"]:
df, before = burst(prelude)
runs[prelude] = {"trace": df.to_dict("records"), "smi_before": before}
first, last = df[df.t_ms < 5], df[df.t_ms > df.t_ms.max() - 100]
f_mhz, l_mhz = first.sm_mhz.median(), last.sm_mhz.median()
mid = (f_mhz + l_mhz) / 2
crossed = df[(df.sm_mhz < mid) if f_mhz > l_mhz else (df.sm_mhz > mid)]
runs[prelude]["halfway_ms"] = float(crossed.t_ms.iloc[0]) if len(crossed) else None
print(f"{prelude}: nvidia-smi just before — {before.get('clocks.sm', 0):.0f} MHz, "
f"{before.get('power.draw', 0):.0f} W")
print(f" first 5 ms: {f_mhz:6.0f} MHz, {first.tflops.median():5.1f} TFLOP/s")
print(f" last 100 ms: {l_mhz:6.0f} MHz, {last.tflops.median():5.1f} TFLOP/s")
if len(crossed) and abs(f_mhz - l_mhz) > 100:
print(f" the clock is halfway {'down' if f_mhz > l_mhz else 'up'} after {crossed.t_ms.iloc[0]:.1f} ms")
RESULTS["burst_clock"] = runs
del A, B, stream_buf; free_gpu()
fig, axes = plt.subplots(1, 2, figsize=(9, 3.4), sharey=True)
for ax, (prelude, r) in zip(axes, runs.items()):
d = pd.DataFrame(r["trace"])
ax.plot(d.t_ms, d.sm_mhz, color=EMBER, lw=1)
ax.set_title(prelude, fontsize=9); ax.set_xlabel("ms into the burst")
ax2 = ax.twinx(); ax2.plot(d.t_ms, d.tflops, color=BLUE, lw=0.8, alpha=0.7)
ax2.set_ylim(0, 50); ax2.spines["right"].set_visible(True)
axes[0].set_ylabel("SM clock (MHz)", color=EMBER)
plt.tight_layout(); plt.show()after 10 s idle: nvidia-smi just before — 585 MHz, 32 W
first 5 ms: 580 MHz, 18.1 TFLOP/s
last 100 ms: 600 MHz, 18.5 TFLOP/s
after 3 s of memory-bound work: nvidia-smi just before — 1245 MHz, 67 W
first 5 ms: 1409 MHz, 41.4 TFLOP/s
last 100 ms: 585 MHz, 18.1 TFLOP/s
the clock is halfway down after 37.3 ms
[burst_clock: 16.4 s]
That settles it. After idling, the card starts at its idle clock and stays near it — tensor-core work there already draws close to the limit. After memory-bound work, it starts at the high clock that work left behind, delivers over 40 TFLOP/s, and the power controller pulls the clock down with a response of a few tens of milliseconds. Short multiplications that fit in that window are the 40 TFLOP/s bursts of Part 5.
Part 5 — the roofline
One kernel, one knob
intensity reads a float4, runs k FMAs on each of its four numbers, and writes it back: 8k FLOPs for 32 bytes, an arithmetic intensity of k/4 FLOP per byte. Nothing else changes. At small k the kernel waits on memory and the extra arithmetic is free; past some k the arithmetic becomes the bottleneck and every extra FMA costs time. Where the two regimes meet is the ridge point, and it should sit at peak FLOP/s ÷ peak bandwidth.
with experiment("roofline_knob"):
n4 = 2**24 # 256 MB in, 256 MB out
xi = cp.random.random(n4 * 4, dtype=cp.float32)
yi = cp.empty_like(xi)
rows = []
for k in [0, 1, 2, 4, 8, 16, 24, 32, 48, 64, 96, 128, 192, 256, 384, 512, 768, 1024]:
t = gpu_time(lambda: launch("intensity", FULL_BLOCKS * 4, 256, xi, yi, np.int64(n4), np.int32(k)),
iters=7)
flops, nbytes = 8 * k * n4, 32 * n4
rows.append(dict(k=k, ai=flops / nbytes, ms=t * 1e3, flops_per_s=flops / t, bytes_per_s=nbytes / t))
knob = pd.DataFrame(rows)
del xi, yi; free_gpu()
bw_copy = knob[knob.ai <= 1].bytes_per_s.max() # this kernel reads *and* writes
bw_read = RESULTS.get("latency_hiding", {}).get("bw_max", bw_copy) # best read-only stream
fp32_roof = RESULTS["compute_roof"]["fma_peak"]["flops_per_s"]
fp16_sustained = max(v["flops_per_s"] for k, v in RESULTS["compute_roof"]["gemm"].items() if "fp16" in k)
roof = {"bw": max(bw_read, bw_copy), "bw_copy": bw_copy, "bw_read": bw_read, "fp32": fp32_roof,
"fp16_tensor_sustained": fp16_sustained,
"ridge_fp32_knob": fp32_roof / bw_copy}
RESULTS["roofline"] = {"roofs": roof, "knob": knob.to_dict("records")}
print(f"memory roof: {bw_read / 1e9:.0f} GB/s read-only, {bw_copy / 1e9:.0f} GB/s read+write · "
f"fp32 roof {fp32_roof / 1e12:.2f} TFLOP/s · fp16 tensor roof (sustained) {fp16_sustained / 1e12:.1f} TFLOP/s")
print(f"ridge point of this kernel, predicted: {roof['ridge_fp32_knob']:.1f} FLOP/B (fp32 roof ÷ read+write bandwidth)")
print(knob.to_string(index=False, float_format=lambda v: f"{v:.4g}"))memory roof: 275 GB/s read-only, 229 GB/s read+write · fp32 roof 6.81 TFLOP/s · fp16 tensor roof (sustained) 20.8 TFLOP/s
ridge point of this kernel, predicted: 29.7 FLOP/B (fp32 roof ÷ read+write bandwidth)
k ai ms flops_per_s bytes_per_s
0 0 2.405 0 2.232e+11
1 0.25 2.345 5.723e+10 2.289e+11
2 0.5 2.349 1.143e+11 2.286e+11
4 1 2.341 2.293e+11 2.293e+11
8 2 2.343 4.583e+11 2.292e+11
16 4 2.334 9.202e+11 2.3e+11
24 6 2.324 1.386e+12 2.31e+11
32 8 2.307 1.862e+12 2.327e+11
48 12 2.312 2.787e+12 2.322e+11
64 16 2.282 3.765e+12 2.353e+11
96 24 3.596 3.583e+12 1.493e+11
128 32 4.483 3.832e+12 1.198e+11
192 48 5.684 4.534e+12 9.445e+10
256 64 6.998 4.91e+12 7.672e+10
384 96 9.844 5.236e+12 5.454e+10
512 128 12.55 5.474e+12 4.276e+10
768 192 18.29 5.636e+12 2.935e+10
1024 256 23.74 5.788e+12 2.261e+10
[roofline_knob: 1.2 s]
Real operations on the measured roofline
The same roofs, and on them the operations a model is actually made of. For each, the FLOPs and the compulsory bytes — each input read once, each output written once — are counted by hand, so the point’s x-coordinate is the best arithmetic intensity the operation could possibly have. (For softmax and layer norm the FLOP count is a convention — about 5 and 8 per element — since exponentials and square roots are not multiply-adds.)
with experiment("op_zoo"):
torch.backends.cudnn.benchmark = True
F = torch.nn.functional
f16, f32 = torch.float16, torch.float32
T = lambda *shape, dtype=f16: torch.randn(*shape, device=DEV, dtype=dtype)
zoo = []
def op(name, family, fn, flops, nbytes):
t = gpu_time(fn)
zoo.append(dict(name=name, family=family, seconds=t, flops=flops, bytes=nbytes,
ai=flops / nbytes, flops_per_s=flops / t, bytes_per_s=nbytes / t))
N = 2**26
a32, b32 = T(N, dtype=f32), T(N, dtype=f32)
op("x + y fp32", "element-wise", lambda: a32 + b32, N, 12 * N)
op("sum(x) fp32", "reduction", lambda: a32.sum(), N, 4 * N)
del a32, b32
a16, b16, c16 = T(N), T(N), T(N)
op("x + y fp16", "element-wise", lambda: a16 + b16, N, 6 * N)
op("x·y + z fp16", "element-wise", lambda: torch.addcmul(c16, a16, b16), 2 * N, 8 * N)
op("relu(x) fp16", "element-wise", lambda: torch.relu(a16), N, 4 * N)
del a16, b16, c16
R, C = 8192, 4096
xs = T(R, C)
op("softmax fp16", "normalization", lambda: torch.softmax(xs, -1), 5 * R * C, 4 * R * C)
op("layer norm fp16", "normalization", lambda: F.layer_norm(xs, (C,)), 8 * R * C, 4 * R * C)
del xs
for n in [256, 512, 1024, 2048, 4096, 8192]:
A, B = T(n, n), T(n, n)
op(f"matmul {n}³ fp16", "matmul", lambda: A @ B, 2 * n**3, 3 * 2 * n * n)
del A, B
for n in [1024, 4096]:
A, B = T(n, n, dtype=f32), T(n, n, dtype=f32)
op(f"matmul {n}³ fp32", "matmul fp32", lambda: A @ B, 2 * n**3, 3 * 4 * n * n)
del A, B
d = 4096
W = T(d, d)
for b in [1, 4, 16, 64, 256, 1024]:
X = T(b, d)
op(f"linear 4096→4096, batch {b} fp16", "linear, batch sweep", lambda: X @ W,
2 * b * d * d, 2 * (d * d + 2 * b * d))
del W, X
xc = T(32, 64, 56, 56).to(memory_format=torch.channels_last)
wc = T(64, 64, 3, 3).to(memory_format=torch.channels_last)
op("conv 3×3, 64→64, 56², batch 32 fp16", "convolution", lambda: F.conv2d(xc, wc, padding=1),
2 * 32 * 64 * 56 * 56 * 64 * 9, 2 * (2 * xc.numel() + wc.numel()))
del xc, wc
Bq, H, L, D = 8, 16, 1024, 64
q, k_, v = T(Bq, H, L, D), T(Bq, H, L, D), T(Bq, H, L, D)
op("attention, L=1024, d=64 fp16", "attention", lambda: F.scaled_dot_product_attention(q, k_, v),
4 * Bq * H * L * L * D, 2 * 4 * Bq * H * L * D)
del q, k_, v
free_gpu()
zoo = pd.DataFrame(zoo)
r = RESULTS["roofline"]["roofs"]
# Short fp16 GEMMs finish before the power limit bites, so they show a second, higher roof:
# the best tensor-core throughput any operation reached in these short bursts.
r["fp16_tensor_burst"] = float(zoo[~zoo.name.str.contains("fp32")].flops_per_s.max())
r["ridge_fp16_burst"] = r["fp16_tensor_burst"] / r["bw"]
r["ridge_fp16_sustained"] = r["fp16_tensor_sustained"] / r["bw"]
compute_roof = [r["fp32"] if "fp32" in nm else r["fp16_tensor_burst"] for nm in zoo.name]
zoo["roof_flops_per_s"] = np.minimum(compute_roof, r["bw"] * zoo.ai)
zoo["pct_of_roof"] = 100 * zoo.flops_per_s / zoo.roof_flops_per_s
zoo["bound"] = np.where(r["bw"] * zoo.ai < np.array(compute_roof), "memory", "compute")
RESULTS["op_zoo"] = zoo.to_dict("records")
print(f"fp16 tensor roof: {r['fp16_tensor_burst'] / 1e12:.1f} TFLOP/s in short bursts, "
f"{r['fp16_tensor_sustained'] / 1e12:.1f} sustained · memory roof {r['bw'] / 1e9:.0f} GB/s")
print(f"ridge points (fp16): {r['ridge_fp16_burst']:.0f} FLOP/B burst, {r['ridge_fp16_sustained']:.0f} sustained\n")
print(zoo[["name", "ai", "seconds", "flops_per_s", "bytes_per_s", "pct_of_roof", "bound"]]
.to_string(index=False, float_format=lambda v: f"{v:.3g}"))fp16 tensor roof: 40.3 TFLOP/s in short bursts, 20.8 sustained · memory roof 275 GB/s
ridge points (fp16): 146 FLOP/B burst, 76 sustained
name ai seconds flops_per_s bytes_per_s pct_of_roof bound
x + y fp32 0.0833 0.00337 1.99e+10 2.39e+11 86.8 memory
sum(x) fp32 0.25 0.000992 6.76e+10 2.71e+11 98.3 memory
x + y fp16 0.167 0.00166 4.04e+10 2.42e+11 88 memory
x·y + z fp16 0.25 0.00215 6.25e+10 2.5e+11 90.8 memory
relu(x) fp16 0.25 0.00116 5.81e+10 2.32e+11 84.4 memory
softmax fp16 1.25 0.00075 2.24e+11 1.79e+11 65 memory
layer norm fp16 2 0.000778 3.45e+11 1.72e+11 62.7 memory
matmul 256³ fp16 85.3 3.39e-05 9.89e+11 1.16e+10 4.21 memory
matmul 512³ fp16 171 3.72e-05 7.21e+12 4.23e+10 17.9 compute
matmul 1024³ fp16 341 7.58e-05 2.83e+13 8.3e+10 70.3 compute
matmul 2048³ fp16 683 0.000426 4.03e+13 5.9e+10 100 compute
matmul 4096³ fp16 1.37e+03 0.00813 1.69e+13 1.24e+10 41.9 compute
matmul 8192³ fp16 2.73e+03 0.0551 2e+13 7.31e+09 49.6 compute
matmul 1024³ fp32 171 0.000922 2.33e+12 1.36e+10 34.2 compute
matmul 4096³ fp32 683 0.0418 3.29e+12 4.82e+09 48.3 compute
linear 4096→4096, batch 1 fp16 1 0.000188 1.78e+11 1.78e+11 64.7 memory
linear 4096→4096, batch 4 fp16 3.99 0.000207 6.49e+11 1.63e+11 59.1 memory
linear 4096→4096, batch 16 fp16 15.9 0.000229 2.34e+12 1.47e+11 53.6 memory
linear 4096→4096, batch 64 fp16 62.1 0.000215 1e+13 1.61e+11 58.6 memory
linear 4096→4096, batch 256 fp16 228 0.000478 1.8e+13 7.9e+10 44.6 compute
linear 4096→4096, batch 1024 fp16 683 0.00164 2.1e+13 3.07e+10 52 compute
conv 3×3, 64→64, 56², batch 32 fp16 287 0.000307 2.41e+13 8.39e+10 59.8 compute
attention, L=1024, d=64 fp16 512 0.00214 1.61e+13 3.14e+10 39.9 compute
[op_zoo: 3.2 s]
with experiment("roofline_plot"):
r = RESULTS["roofline"]["roofs"]
knob = pd.DataFrame(RESULTS["roofline"]["knob"])
zoo = pd.DataFrame(RESULTS["op_zoo"])
ai = np.logspace(-2, 4, 300)
fig, ax = plt.subplots(figsize=(7.5, 4.4))
ax.plot(ai, np.minimum(r["fp16_tensor_burst"], r["bw"] * ai) / 1e12, color=INK, lw=1.2,
label="fp16 tensor-core roof, short bursts")
ax.plot(ai, np.minimum(r["fp16_tensor_sustained"], r["bw"] * ai) / 1e12, color=INK, lw=1.0, ls=":",
label="fp16 tensor-core roof, sustained (power-capped)")
ax.plot(ai, np.minimum(r["fp32"], r["bw"] * ai) / 1e12, color=GRAY, lw=1.2, ls="--", label="fp32 CUDA-core roof")
k = knob[knob.k > 0]
ax.plot(k.ai, k.flops_per_s / 1e12, "-o", color=FOREST, ms=3, label="one-knob kernel (fp32)")
fam_colors = {"element-wise": EMBER, "reduction": EMBER, "normalization": PLUM, "matmul": BLUE,
"matmul fp32": GRAY, "linear, batch sweep": BLUE, "convolution": FOREST, "attention": PLUM}
for fam, d in zoo.groupby("family"):
marker = "s" if "linear" in fam else "o"
ax.scatter(d.ai, d.flops_per_s / 1e12, s=18, color=fam_colors.get(fam, INK), marker=marker,
label=fam, zorder=3)
ax.set_xscale("log"); ax.set_yscale("log")
ax.set_xlabel("arithmetic intensity (FLOP / byte)"); ax.set_ylabel("TFLOP/s")
ax.legend(frameon=False, fontsize=7, loc="lower right", ncol=2)
plt.tight_layout(); plt.show()[roofline_plot: 0.7 s]
Why attention sits so far below its roof
scaled_dot_product_attention is not one kernel but a dispatcher over several implementations — FlashAttention, memory-efficient attention, cuDNN, and a plain “math” fallback that materializes the full L × L score matrix. Each is forced in turn here; the ones this GPU can’t run say why.
with experiment("attention_backends"):
import warnings
from torch.nn.attention import sdpa_kernel, SDPBackend
F = torch.nn.functional
Bq, H, L, D = 8, 16, 1024, 64
q, k_, v = (torch.randn(Bq, H, L, D, device=DEV, dtype=torch.float16) for _ in range(3))
flops = 4 * Bq * H * L * L * D
rows = [dict(backend="default (PyTorch's choice)",
seconds=gpu_time(lambda: F.scaled_dot_product_attention(q, k_, v)), why="")]
for be in [SDPBackend.FLASH_ATTENTION, SDPBackend.EFFICIENT_ATTENTION, SDPBackend.CUDNN_ATTENTION,
SDPBackend.MATH]:
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
try:
with sdpa_kernel(be):
t = gpu_time(lambda: F.scaled_dot_product_attention(q, k_, v))
rows.append(dict(backend=be.name, seconds=t, why=""))
except Exception as e:
first_line = lambda m: (str(m).splitlines() or [""])[0][:140]
reasons = [first_line(w.message) for w in caught]
rows.append(dict(backend=be.name, seconds=None,
why=" | ".join(reasons) or first_line(e)))
att = pd.DataFrame(rows)
att["tflops"] = flops / att.seconds / 1e12
RESULTS["attention_backends"] = att.to_dict("records")
del q, k_, v; free_gpu()
with pd.option_context("display.max_colwidth", 140):
print(att.to_string(index=False, float_format=lambda x: f"{x:.3g}")) backend seconds why tflops
default (PyTorch's choice) 0.00191 18
FLASH_ATTENTION NaN Memory efficient kernel not used because: (Triggered internally at /pytorch/aten/src/ATen/native/transformers/cuda/sdp_utils.cpp:960.) | Memory Efficient attention has been runtime disabled. (Triggered internally at /pytorch/aten/src/ATen/native/transformers/sdp_utils_cpp.h:55 | Flash attention kernel not used because: (Triggered internally at /pytorch/aten/src/ATen/native/transformers/cuda/sdp_utils.cpp:962.) | Flash attention only supports gpu architectures in the range [sm80, sm121]. Attempting to run on a sm 7.5 gpu. (Triggered internally at /pyt | cuDNN attention kernel not used because: (Triggered internally at /pytorch/aten/src/ATen/native/transformers/cuda/sdp_utils.cpp:964.) | cuDNN attention has been runtime disabled. (Triggered internally at /pytorch/aten/src/ATen/native/transformers/cuda/sdp_utils.cpp:680.) NaN
EFFICIENT_ATTENTION 0.00241 14.3
CUDNN_ATTENTION NaN Memory efficient kernel not used because: (Triggered internally at /pytorch/aten/src/ATen/native/transformers/cuda/sdp_utils.cpp:960.) | Memory Efficient attention has been runtime disabled. (Triggered internally at /pytorch/aten/src/ATen/native/transformers/sdp_utils_cpp.h:55 | Flash attention kernel not used because: (Triggered internally at /pytorch/aten/src/ATen/native/transformers/cuda/sdp_utils.cpp:962.) | Flash attention has been runtime disabled. (Triggered internally at /pytorch/aten/src/ATen/native/transformers/sdp_utils_cpp.h:540.) | cuDNN attention kernel not used because: (Triggered internally at /pytorch/aten/src/ATen/native/transformers/cuda/sdp_utils.cpp:964.) | cuDNN MHA only supports gpu architectures in the range [sm80, sm121]. Attempting to run on a sm 7.5 gpu. (Triggered internally at /pytorch/a NaN
MATH 0.0305 1.13
[attention_backends: 0.8 s]
FlashAttention and cuDNN’s attention both refuse: they need sm80 or newer, and the T4 is sm75. PyTorch’s default is the memory-efficient kernel — the two times match — and the math fallback, which writes the whole score matrix to memory, is an order of magnitude slower.
The cheapest byte is the one never moved
Three element-wise operations in a row — multiply, add, ReLU — are three kernels, and each reads its input from DRAM and writes its output back. Fused into one kernel, the intermediate values stay in registers: 4 bytes per element instead of 12 (FP16), for the same FLOPs. If the chain is memory-bound, the roofline predicts the fused version is three times faster. torch.compile does the fusing.
with experiment("fusion"):
x = torch.randn(2**26, device=DEV, dtype=torch.float16)
chain = lambda t: torch.relu(t * 1.5 + 0.5)
fused = torch.compile(chain)
fused(x); torch.cuda.synchronize() # compile outside the timing
t_eager, t_fused = gpu_time(lambda: chain(x)), gpu_time(lambda: fused(x))
RESULTS["fusion"] = {"eager_s": t_eager, "fused_s": t_fused, "speedup": t_eager / t_fused,
"bytes_ratio_predicted": 12 / 4, "elements": x.numel()}
print(f"eager (3 kernels) {t_eager * 1e3:.3f} ms · fused {t_fused * 1e3:.3f} ms · "
f"{t_eager / t_fused:.2f}× faster (bytes moved: 3× fewer)")
del x; free_gpu()eager (3 kernels) 3.322 ms · fused 1.184 ms · 2.81× faster (bytes moved: 3× fewer)
[fusion: 17.0 s]
Part 6 — the paradox, predicted
The fourth number, taken apart
Part 1’s slowest result was addition “including the copies”: the GPU lost by 10× at every size. Here the same naive line — torch.add(x.to("cuda"), y.to("cuda")).cpu() — is split into its stages and each is timed on its own: copying x in, copying y in, the addition, and copying the result back into a brand-new CPU tensor. Two references sit next to it. One is the cost of what .cpu() does besides copying: allocating a fresh host tensor and touching its memory for the first time. The other is the best the link allows: pinned buffers allocated once and reused, so nothing but the bytes themselves crosses PCIe.
with experiment("copy_path"):
rows = []
for N in [2**10, 2**16, 2**20, 2**24, 2**28]:
x_c, y_c, o_c = torch.randn(N), torch.randn(N), torch.empty(N)
cpu_s = wall_time(lambda: torch.add(x_c, y_c, out=o_c))
def naive_stages():
torch.cuda.synchronize()
t0 = time.perf_counter(); xg = x_c.to(DEV); torch.cuda.synchronize()
t1 = time.perf_counter(); yg = y_c.to(DEV); torch.cuda.synchronize()
t2 = time.perf_counter(); zg = xg + yg; torch.cuda.synchronize()
t3 = time.perf_counter(); zc = zg.cpu()
t4 = time.perf_counter()
return np.array([t1 - t0, t2 - t1, t3 - t2, t4 - t3])
naive_stages()
stages = np.median([naive_stages() for _ in range(3 if N >= 2**24 else 15)], axis=0)
alloc_touch_s = wall_time(lambda: torch.empty(N).zero_())
xp, yp, zp = x_c.pin_memory(), y_c.pin_memory(), torch.empty(N).pin_memory()
xg, yg, zg = (torch.empty(N, device=DEV) for _ in range(3))
def best():
xg.copy_(xp, non_blocking=True); yg.copy_(yp, non_blocking=True)
torch.add(xg, yg, out=zg); zp.copy_(zg, non_blocking=True)
best_s = wall_time(best, sync=True)
rows.append(dict(elements=N, cpu_s=cpu_s, h2d_x_s=stages[0], h2d_y_s=stages[1], add_s=stages[2],
d2h_s=stages[3], naive_s=float(stages.sum()), alloc_touch_s=alloc_touch_s,
best_s=best_s))
del x_c, y_c, o_c, xp, yp, zp, xg, yg, zg
free_gpu()
cpath = pd.DataFrame(rows)
cpath["h2d_gb_s"] = 4 * cpath.elements / cpath.h2d_x_s / 1e9
cpath["d2h_gb_s"] = 4 * cpath.elements / cpath.d2h_s / 1e9
cpath["naive_vs_cpu"] = cpath.cpu_s / cpath.naive_s
cpath["best_vs_cpu"] = cpath.cpu_s / cpath.best_s
RESULTS["copy_path"] = cpath.to_dict("records")
print(cpath.to_string(index=False, float_format=lambda v: f"{v:.3g}")) elements cpu_s h2d_x_s h2d_y_s add_s d2h_s naive_s alloc_touch_s best_s h2d_gb_s d2h_gb_s naive_vs_cpu best_vs_cpu
1024 2.66e-06 3.65e-05 3.29e-05 3.19e-05 2.48e-05 0.000126 3.42e-06 4.87e-05 0.112 0.165 0.0211 0.0546
65536 1.09e-05 0.000117 0.000111 3.92e-05 9.92e-05 0.000366 9.53e-06 9.92e-05 2.24 2.64 0.0298 0.11
1048576 0.000291 0.0011 0.0011 9.86e-05 0.00424 0.00654 0.000111 0.00114 3.8 0.989 0.0445 0.255
16777216 0.00995 0.0164 0.0154 0.000907 0.0682 0.101 0.0284 0.0169 4.08 0.984 0.0986 0.588
268435456 0.143 0.234 0.237 0.0131 0.963 1.45 0.452 0.27 4.59 1.12 0.0987 0.529
[copy_path: 20.8 s]
For the largest size, the naive line spends almost all its time on the road: the addition is a few milliseconds, the inputs take about half a second to go in through pageable memory, and the result takes about a second to come back, half of which is the cost of allocating and first touching a fresh host tensor. With pinned buffers allocated once, the round trip shrinks to what the link allows — 12 bytes per element at ~12 GB/s — and even then it is about twice as slow as the CPU adding the vectors in its own memory.
The model
Each machine is now reduced to three measured numbers per operation: a compute roof P, a memory roof B, and a floor t0 — the fixed cost of getting the operation started. The model is t = t0 + max (FLOPs/P, bytes/B). The GPU’s roofs come from Parts 4 and 5 (the FP32 FMA roof, and the read+write bandwidth of the one-knob kernel, since addition writes as much as it reads); the CPU’s from the largest sizes of Part 1. The floor is the one number taken from the Part 1 curves themselves: each operation’s time at its smallest size. Nothing else is fitted. The question is how much of the paradox this explains.
with experiment("paradox_model"):
d = pd.DataFrame(RESULTS["paradox"])
mm, ad = d[d.op == "matmul"], d[d.op == "add"]
roofs = RESULTS["roofline"]["roofs"]
cpu = {"P": float((mm.flops / mm.cpu_s).max()), "B": float((ad["bytes"] / ad.cpu_s).iloc[-1])}
gpu = {"P": roofs["fp32"], "B": roofs["bw_copy"]}
floor = {(op, dev): float(d[d.op == op].sort_values("size")[col].iloc[0])
for op in ("matmul", "add") for dev, col in (("cpu", "cpu_s"), ("gpu", "gpu_s"))}
model = lambda m, t0, F_, B_: t0 + max(F_ / m["P"], B_ / m["B"])
d["cpu_model_s"] = [model(cpu, floor[(o, "cpu")], f, b) for o, f, b in zip(d.op, d.flops, d["bytes"])]
d["gpu_model_s"] = [model(gpu, floor[(o, "gpu")], f, b) for o, f, b in zip(d.op, d.flops, d["bytes"])]
d["speedup_model"] = d.cpu_model_s / d.gpu_model_s
RESULTS["paradox_model"] = {"cpu": cpu, "gpu": gpu, "floors": {f"{o}/{m}": v for (o, m), v in floor.items()},
"rows": d[["op", "size", "speedup", "speedup_model"]].to_dict("records")}
print(f"CPU: P = {cpu['P'] / 1e9:.0f} GFLOP/s, B = {cpu['B'] / 1e9:.1f} GB/s")
print(f"GPU: P = {gpu['P'] / 1e12:.2f} TFLOP/s, B = {gpu['B'] / 1e9:.0f} GB/s")
print("floors: " + ", ".join(f"{o} on {m} {v * 1e6:.1f} µs" for (o, m), v in floor.items()))
print(f"=> asymptotic speed-up: compute-bound {gpu['P'] / cpu['P']:.0f}×, memory-bound {gpu['B'] / cpu['B']:.0f}×")
print(d[["op", "size", "speedup", "speedup_model"]].to_string(index=False, float_format=lambda v: f"{v:.3g}"))
fig, axes = plt.subplots(1, 2, figsize=(9, 3.4), sharey=True)
for ax, op in zip(axes, ["matmul", "add"]):
s = d[d.op == op]
ax.plot(s["size"], s.speedup, "o", color=BLUE, ms=4, label="measured")
ax.plot(s["size"], s.speedup_model, "-", color=INK, lw=1, label="three-number model")
ax.axhline(1, color=GRAY, lw=0.8, ls="--")
ax.set_xscale("log", base=2); ax.set_yscale("log"); ax.set_title(op)
axes[0].set_ylabel("GPU speed-up over the CPU (×)"); axes[0].legend(frameon=False, fontsize=8)
plt.tight_layout(); plt.show()CPU: P = 215 GFLOP/s, B = 22.5 GB/s
GPU: P = 6.81 TFLOP/s, B = 229 GB/s
floors: matmul on cpu 6.2 µs, matmul on gpu 34.8 µs, add on cpu 2.7 µs, add on gpu 20.5 µs
=> asymptotic speed-up: compute-bound 32×, memory-bound 10×
op size speedup speedup_model
matmul 16 0.179 0.183
matmul 24 0.185 0.188
matmul 32 0.189 0.195
matmul 48 0.228 0.214
matmul 64 0.405 0.248
matmul 96 0.531 0.41
matmul 128 0.866 0.721
matmul 192 1.85 1.95
matmul 256 2.54 4.08
matmul 384 6.98 10.3
matmul 512 8.6 16.9
matmul 768 11.9 25.1
matmul 1024 12.4 28.5
matmul 1536 12.8 30.6
matmul 2048 13 31.2
matmul 3072 18.4 31.5
matmul 4096 18.9 31.6
add 1024 0.132 0.158
add 2048 0.132 0.184
add 4096 0.147 0.236
add 8192 0.173 0.338
add 16384 0.231 0.536
add 32768 0.375 0.909
add 65536 0.494 1.57
add 131072 0.847 2.66
add 262144 3.3 4.17
add 524288 5.84 5.89
add 1048576 4.6 7.46
add 2097152 5.55 8.61
add 4194304 8.98 9.33
add 8388608 9.9 9.74
add 16777216 10.6 9.96
add 33554432 10.9 10.1
add 67108864 10.9 10.1
add 134217728 10.9 10.2
add 268435456 11 10.2
[paradox_model: 0.7 s]
The machine in one table
with experiment("summary"):
lat = pd.DataFrame(RESULTS["latency"]); hbw = pd.DataFrame(RESULTS["hierarchy_bandwidth"])
pc = pd.DataFrame(RESULTS["pcie"])
L2 = spec["l2_bytes"]
pick = lambda df, lo, hi, col: float(df[(df["bytes"] >= lo) & (df["bytes"] <= hi)][col].median())
# capacities as the pointer chase sees them: the largest chain that still runs at a level's speed
l2_ns, dram_ns = pick(lat, L2 / 8, L2 / 2, "ns"), pick(lat, 4 * L2, 16 * L2, "ns")
l1_cap = int(lat[lat.ns <= 1.3 * lat.ns.min()]["bytes"].max())
l2_cap = int(lat[lat.ns <= (l2_ns + dram_ns) / 2]["bytes"].max())
caps_kb = RESULTS.get("l1_capacity", {}).get("capacity_kb", {})
RESULTS["capacities"] = {"l1_bytes": l1_cap, "l2_bytes": l2_cap, "l1_by_carveout_kb": caps_kb}
l1_size = (" · ".join(f"{v} KB {k}" for k, v in caps_kb.items()) if caps_kb
else f"{l1_cap // 1024} KB / SM measured")
table = [
("registers", f"{spec['regs_per_sm'] * 4 // 1024} KB / SM", "≈ 1 cycle", "—"),
("L1 (data cache)", l1_size,
f"{pick(lat, 2**12, l1_cap, 'ns'):.0f} ns", f"{pick(hbw, 2**12, l1_cap // 2, 'gb_s'):.0f} GB/s"),
("L2", f"{L2 / 2**20:.0f} MB spec, {l2_cap / 2**20:.0f} MB measured", f"{l2_ns:.0f} ns",
f"{pick(hbw, L2 / 8, L2 / 2, 'gb_s'):.0f} GB/s"),
("DRAM", f"{spec['dram_bytes'] / 2**30:.0f} GB", f"{pick(lat, 4 * L2, 16 * L2, 'ns'):.0f} ns",
f"{pick(hbw, 64 * L2, 2**30, 'gb_s'):.0f} GB/s"),
("host RAM over PCIe", "—", f"{pc.h2d_pinned_s.iloc[0] * 1e6:.0f} µs (4-byte copy)",
f"{pc.h2d_pinned_gb_s.max():.1f} GB/s pinned"),
]
RESULTS["summary"] = table
print(pd.DataFrame(table, columns=["level", "size", "latency", "bandwidth"]).to_string(index=False))
print("\nerrors:", list(RESULTS["errors"]) or "none") level size latency bandwidth
registers 256 KB / SM ≈ 1 cycle —
L1 (data cache) 56 KB default · 56 KB max L1 (carveout 0) · 24 KB max shared (carveout 100) 27 ns 3870 GB/s
L2 4 MB spec, 4 MB measured 150 ns 1107 GB/s
DRAM 15 GB 314 ns 268 GB/s
host RAM over PCIe — 21 µs (4-byte copy) 12.3 GB/s pinned
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, "gpu_results.json"), "w") as f:
json.dump(RESULTS, f, default=jsonable, indent=1)
print("saved", os.path.join(out_dir, "gpu_results.json"))saved /kaggle/working/gpu_results.json