SIMT: The Execution Model

You have seen the machine: streaming multiprocessors, warps, a scheduler that hides latency by switching threads. This lesson looks at the same territory from the opposite side — as a contract. Before we can design GPU hardware in the coming modules, we need to state precisely what the software is promised: what a kernel launch means, which guarantees the silicon must honour, and — just as important — which guarantees are deliberately withheld. The withheld ones are where all the hardware freedom lives.

The contract is called SIMTSingle Instruction, Multiple Threads — and its genius is a sleight of hand: the programmer writes one ordinary scalar program, and the silicon runs vector operations. Understanding exactly where the illusion sits is the whole lesson.

The kernel: one program, a grid of threads

The unit of GPU work is a kernel: a scalar function describing what one thread does. You launch it over a grid of thread blocks, each block holding up to ~1024 threads. Every thread runs the same code and discovers who it is from its coordinates — here in CUDA-style pseudocode:

// one scalar program, launched across millions of threads __global__ void vecAdd(const float* a, const float* b, float* c, int n) { int i = blockIdx.x * blockDim.x + threadIdx.x; // my unique global index if (i < n) // guard the ragged edge c[i] = a[i] + b[i]; } // host side: launch enough blocks to cover all n elements int threadsPerBlock = 256; int blocks = (n + threadsPerBlock - 1) / threadsPerBlock; // ceil(n / 256) vecAdd<<<blocks, threadsPerBlock>>>(a, b, c, n);

The index arithmetic is the idiom you will type ten thousand times: i = \text{blockIdx} \times \text{blockDim} + \text{threadIdx}. Thread 3 of block 5, with 256 threads per block, owns element 5 \times 256 + 3 = 1283. And note the guard: the grid is built from whole blocks, so launching \lceil n / 256 \rceil blocks over-provisions threads at the ragged edge — the if (i < n) keeps the spares from scribbling out of bounds.

The hardware's secret: warps

Now the sleight of hand. The hardware does not run those threads independently. It bundles them, 32 at a time, into warps that execute in lock-step: one instruction issues, and all 32 lanes perform it on their own registers. Each thread keeps the illusion of a private program counter and private registers — but per warp there is really one instruction stream driving a 32-wide vector unit.

Compare the two honest alternatives you already know. With explicit SIMD intrinsics, you manage the vector width: your source code says "add these 8 floats to those 8 floats", and widening the machine means rewriting the code. SIMT hides the width entirely: you write scalar code per thread, and the compiler and hardware pick how to pack threads into lanes. That is why the same kernel source has survived warp schedulers, dual-issue SMs and four hardware generations unchanged — the width was never in the program. SIMT is SIMD with a programmer-friendly face: vector efficiency, scalar ergonomics.

The four levels, drawn

Step through the decomposition. Two of these levels (grid, block) belong to the programming model; the warp level belongs to the microarchitecture — keep that boundary in sight, because the "Watch out!" below turns on it:

Be the machine: a SIMT executor in TypeScript

The best way to internalise the model is to build it. Here is a toy SIMT machine: it takes a scalar kernel and runs it over a grid warp by warp, exactly as an SM's scheduler would (warp width 4 instead of 32, so the printout fits). Watch the index arithmetic produce each lane's global i, and watch the final ragged warp run with its out-of-range lanes masked:

const WARP = 4; // real GPUs: 32 — 4 keeps the printout readable const blockDim = 8; // threads per block const gridDim = 2; // blocks in the grid const n = 13; // vector length — deliberately NOT a multiple const a = Array.from({ length: n }, (_, k) => k); const b = Array.from({ length: n }, (_, k) => 10 * k); const c: number[] = new Array(n).fill(0); // The kernel: what ONE thread does. Scalar code, no widths anywhere. function kernel(blockIdx: number, threadIdx: number): string { const i = blockIdx * blockDim + threadIdx; if (i < n) { c[i] = a[i] + b[i]; return `i=${i}`; } return "masked"; // the ragged-edge guard, seen from the hardware side } // The machine: issue the kernel warp-by-warp, all lanes in lock-step. for (let blockIdx = 0; blockIdx < gridDim; blockIdx++) { for (let w = 0; w < blockDim / WARP; w++) { const lanes: string[] = []; for (let lane = 0; lane < WARP; lane++) { lanes.push(kernel(blockIdx, w * WARP + lane)); } console.log(`block ${blockIdx}, warp ${w}: ` + lanes.join(" ")); } } console.log("c = [" + c.join(", ") + "]");

The inner lane loop is the part that is hardware on a real GPU — all four (or 32) calls happen in the same cycle, driven by one instruction. Only the outer loops are genuinely sequential decisions of the scheduler, and even those interleave freely across SMs.

The block: the unit of togetherness — and the scalability contract

Why does the model bother with blocks at all, rather than one flat sea of threads? Because the contract needs a boundary between "threads that may cooperate" and "threads that may not":

That second, deliberately empty promise is the scalability contract, and it is why the GPU ecosystem works at all: a kernel that launches 10,000 independent blocks runs correctly on a laptop GPU with 8 SMs (blocks queue up, ~1250 each) and on a datacentre GPU with 140 SMs (blocks spread wide) — the same binary, no recompilation, near-linear scaling. The hardware designer gets to choose the SM count per market segment precisely because the software was never allowed to care.

The name comes from weaving: on a loom, the warp is the set of parallel threads held under tension while the shuttle passes through — dozens of threads, advancing together, one row at a time. For a machine that advances 32 "threads" together one instruction at a time, some early NVIDIA architect couldn't resist, and the textile pun stuck (Star Trek had nothing to do with it). As for 32: it is a compromise, not a law of nature. Wider warps amortise the instruction-issue machinery over more lanes (cheaper control per FLOP) but waste more work when threads diverge or the data runs ragged; narrower warps waste control. NVIDIA landed on 32 in 2006 and has never moved; AMD ran 64-wide "wavefronts" for a decade, then added a 32-wide mode in RDNA (2019) — two vendors' independent gradient descent converging on the same neighbourhood.

Nothing in the kernel language mentions warps — and that is a specification, not an oversight. The classic trap: threads in a warp run in lock-step today, so old code often skipped block barriers for threads it knew shared a warp ("implicit warp synchronisation") — and it worked, for years. Then Volta (2017) gave threads independent program counters so diverged threads could interleave, and that entire genre of code silently broke. The rule that survives hardware generations: correctness may rely only on the model (block barriers, explicit warp-sync primitives); warp awareness is for performance (coalescing, divergence — you will meet both in the next module). Code that is only correct because of a microarchitectural habit is a time bomb with a driver-update fuse.