← Chapter 15: Designing an LLM chip Chapter 17: A kernel is not a function →
date 01 / jul / 26
mtwtfss
THE TRANSLATION BARRIER — Chapter 16 index home

Why C++ won't auto-compile into GPU code

*the anatomy of a gpu thread*

If you write GPU code thinking a thread is a tiny worker the way it is on a CPU, you will be confused by everything the hardware does. Because on a GPU, a thread is not a worker at all. It's an illusion.

Understanding what a thread actually is at the silicon level, and why NVIDIA wraps them in blocks and warps, is the thing that separates people who write fast GPU code from people who write code that mysteriously crawls. Start with the hard truth: there is no physical thing called a thread inside a GPU.

The Illusion: What a Thread Actually Is

On a CPU, a thread is a heavy object. It has its own stack, its own registers, its own scheduling brain, and the operating system treats it as a real entity.

On a GPU, a thread is just two things. An index number, like a seat number, and a program counter pointing at which line of code is running. That's it.

When you launch a kernel with ten thousand threads, you are not creating ten thousand workers. You are issuing one instruction and telling the hardware to run it across ten thousand different pieces of data. The thread is a data lane, not a person.

CPU THREAD (Real Heavy Object) Stack Registers Program Counter Scheduling Logic OS Managed Independent. Runs its own separate path. GPU THREAD (Just an Illusion) Thread ID (Index / Seat Number) Program Counter (Which line of code) That's it. A DATA LANE, not a person. Launch 10,000 threads ≠ 10,000 workers. You issue ONE instruction and run it across 10,000 pieces of data.

The Structure: Index Math

So how does a thread find its own data, if it's just an index? With pure arithmetic, no lookup, no scheduling. Every thread runs the identical code, so each one computes its own address from its coordinates.

Its global position is its block number times the size of a block, plus its position inside that block. Block one, thread two, in blocks of four, lands at global position six, so it grabs the sixth item.

The silicon works this out in a single step. No operating system involved, just math.

Every thread computes its own address using pure arithmetic. GLOBAL INDEX FORMULA global_id = block_id × block_size + thread_id No lookup. No scheduling. No OS. Just math. EXAMPLE block size = 4 Block 0 Block 1 block_id thread_id 0 0 0 0 1 1 1 1 0 1 2 3 0 1 2 3 global_id 0 1 2 3 4 5 6 7 block 1, thread 2 → global_id = 6 ( 1 × 4 ) + 2 = 6 The silicon works this out in a single step. No OS, just math.

The Barrier: Why Blocks Exist

Now the question that actually matters. If the real work is done by math units inside the chip, why did NVIDIA bother inventing blocks? Why not just launch a flat pile of threads? The answer is that three hard silicon limits would break if they did:

  • First, the wiring problem. Imagine ten thousand workers in one hall who can all talk to each other. To wire every worker to every other worker, the number of connections explodes roughly with the square of the worker count. A million workers would need a trillion wires. The chip would be nothing but copper. So NVIDIA partitions the threads into small cabins, the blocks. Inside a block, the threads sit together and share a fast scratchpad of memory to talk cheaply. But a thread in one block cannot reach into another block's scratchpad. The wiring only has to be built locally.
  • Second, the resource problem. Each processing unit on the chip has a fixed pool of registers. A block acts like a booking ticket. The hardware looks at a block and knows exactly how many registers and how much scratchpad it needs, so it can ask: do I have room for this whole block right now? If yes, it admits it. If not, it waits in line.
  • Third, the scaling problem. You write your code once as a grid of many blocks. A small laptop GPU with two processing units runs those blocks a few at a time. A giant data-center GPU with a hundred units runs a hundred blocks at once. Same code, no changes. The block is the unit that lets one program automatically scale.
A flat pile of threads breaks 3 hard silicon limits. 1 WIRING PROBLEM All-to-all wiring explodes ~ N². A million workers would need a trillion wires. Fix: split threads into BLOCKS. Inside a block, threads share a fast scratchpad (shared memory). Across blocks, no direct sharing. BLOCK 0 shared mem (local) BLOCK 1 shared mem (local) 2 RESOURCE PROBLEM Each SM has a fixed pool of registers & shared memory. A block is like a booking ticket. Hardware asks: room for this whole block? yes -> admit it. no -> wait in line. QUEUE (waiting) B5 B4 B3 B2 B1 SM running B0 3 SCALING PROBLEM Write once as a grid of blocks. Hardware grabs as many as it has room for. Same code, laptop to supercomputer. LAPTOP (2 SM) SM0 SM1 DATA CENTRE (100 SM) SM0 ... SM99

The Warp: Lockstep and Branching

Now go one level deeper. The real unit the hardware runs is not the thread and not the block. It's the warp, a fixed group of 32 threads. When your block of 256 threads lands on a processing unit, the hardware silently chops it into 8 warps of 32.

And a warp moves in lockstep. The scheduler fetches one instruction and broadcasts it to all 32 lanes at once, each working its own data. The threads were never independent. They're bolted together in groups of 32.

Why 32 specifically? It's a balance. Make the group too small, and you need more schedulers eating silicon. Make it too big (say 64), and the bigger the group forced to march in step, the more likely some of them want to go different ways.

The real unit the hardware runs is the WARP — a fixed group of 32 threads. BLOCK OF 256 THREADS chopped into 8 warps of 32 WARP 0 WARP 1 ... WARP 7 Each warp = 32 threads, scheduled as one unit. ONE WARP = 32 LANES Lane 0 Lane 1 ... Lane 31 one instruction, broadcast to all data 0 data 1 ... data 31 all lanes move in LOCKSTEP same instruction, different data WHY 32? too small -> more schedulers eat silicon too big (64) -> more divergence, more idle 32 is the sweet spot

The Cost of Branching

When a warp hits an if/else where some threads go one way and some the other, the hardware cannot run both paths at once. It builds a 32-bit mask (an on/off switch). It runs the if-path with only those threads enabled while the rest sit idle. Then it flips the mask and runs the else-path with the other threads, while the first group sits idle.

The two paths run one after the other instead of together, so the warp does the work in double the time at half the efficiency. This is why branching in GPU code is so costly.

A warp hits an if/else. Some lanes want A, some want B. The hardware can't run both at once. ONE WARP — 32 lanes, mixed paths A B A B ... A B So it serializes — runs one path, then the other: CYCLE 1 — run A A idle A idle ... half the lanes work, half sit idle CYCLE 2 — run B (mask flipped) idle B idle B ... now the first group sits idle ACTIVE MASK (32-bit on/off) A: 1 0 1 0 B: 0 1 0 1 1 = active, 0 = idle Two paths run one after the other, not together. Double the time, half the efficiency.

The Silent Killers: Registers and Occupancy

Now the part that trips up almost everyone writing real kernels: registers. Each thread gets a slice of that fast register pool (1 cycle to access). But the pool is fixed.

If a thread needs more registers than the hardware can give it, the leftover variables get dumped out to a place misleadingly called local memory. Don't let the name fool you. Local memory physically lives out in the slow main memory. So a variable that took 1 cycle to reach now takes 200 or more. This is register spilling, turning your bullet train into a bicycle.

And registers feed directly into the deepest trap of all: occupancy. Occupancy is how full you keep the processing unit with active warps. Here's the math: say a unit can hold 65,536 registers and run at most 64 warps. If each thread uses 32 registers, all 64 warps fit. Full occupancy.

Now you add a few variables, and each thread needs 64 registers. Suddenly only half as many threads fit in the pool. Occupancy drops to 50%, and math units sit idle. You didn't change how many threads you launched; you just used more registers, and the hardware choked.

REGISTERS PER THREAD Each thread gets a slice of the fast register pool (1 cycle). FAST REGISTERS (on-chip) ~1 cycle Need more than the pool holds? SPILL to "local memory" (the name lies — it's out in DRAM) 200+ cycles (slow!) bullet train -> bicycle OCCUPANCY: HOW FULL IS THE SM? Example SM: 65,536 registers, max 64 warps. More registers per thread -> fewer warps fit. regs / thread warps that fit occupancy effect 32 64 100% full speed 64 32 50% half idle 96 21 ~33% mostly idle More registers per thread -> fewer warps -> lower occupancy -> lower performance.

The Answer: Why the Compiler Can't Save You

So let's go back to the title of this chapter: why can't you just take standard C++ code and let a clever compiler turn it into GPU instructions?

Because standard C++ was designed for the "Heavy Engine." The language assumes threads are independent, memory is flat, and branching is practically free. A compiler translating a standard for loop has no way to know how to chop your data into a grid, how to size the blocks to avoid killing register occupancy, or which variables need to sit in the shared scratchpad to avoid melting the memory bus.

If a compiler simply unrolled a C++ loop and dumped it onto the GPU's "Conveyor Belt", it would shatter the lockstep execution, spill registers to slow memory, and stall out. The compiler lacks the vocabulary to map standard code onto this radically different hardware.

To write fast GPU code, you need a way to explicitly tell the hardware about blocks, grids, and shared memory. You need a new vocabulary. That vocabulary is CUDA.

↳ The General Takeaway

So the whole picture comes together. A GPU thread is not a worker, it's an index and a program counter riding one lane of a 32-wide warp.

Blocks exist so the wiring stays sane, the resources stay accountable, and the same code scales from a laptop to a supercomputer. Warps run in lockstep, so branching splits them and slows everything down. And registers quietly govern how much of the chip you actually get to use, where one careless variable can halve your performance.

Understand what the thread actually is,
and you stop fighting the hardware and start working with it.

vs CPU THREAD "the heavy engine" massive logic engine heavy private state (stack, registers, PC, branch predictor) independent — runs its own path GPU THREAD "the conveyor belt" #0 #1 #2 ... #31 one instruction (warp broadcast) just an index + a data slot locked in lockstep Understand what the thread is, and you stop fighting the hardware.