A shader reads like a small program that runs once per pixel. The GPU does not run it that way. It runs 32 pixels at a time, with one instruction for all 32, and it keeps dozens of such groups in flight so that it never waits for memory. Two facts follow, and they explain most of what makes a shader fast or slow: an if costs both of its paths when neighbouring pixels disagree, and a texture read is cheap only while other pixels have work to do.
By the end you can
- Say what a warp is, and why 32 pixels share one instruction.
- Predict when an if costs both of its paths and when it costs one.
- Explain why a loop costs as much as its slowest pixel.
- Explain how registers decide how well texture reads are hidden.
- Tell cheap operations from expensive ones, and measure instead of guessing.
Graphics cards are sold by their number of cores: an NVIDIA RTX 4060 has 3,072. They are not 3,072 independent processors. The chip has 24 streaming multiprocessors (SMs), each SM has 4 schedulers, and each scheduler drives 32 arithmetic lanes. Every cycle, a scheduler picks one instruction and all 32 lanes execute it, each on its own data. 24 × 4 × 32 = 3,072.
The 32 invocations that share an instruction stream are a warp. Each lane is one invocation of your shader: one vertex in a vertex shader, one pixel in a fragment shader. A fragment warp is filled with whole 2×2 quads, 8 of them, because the derivatives need all four pixels of a quad (see Derivatives & Anti-aliasing). Every vendor builds GPUs this way; only the names and widths change:
| GPU | Name of the group | Lanes | Runs on |
|---|---|---|---|
| NVIDIA | warp | 32 | streaming multiprocessor (SM) |
| AMD RDNA | wave (wavefront) | 32 or 64, chosen by the driver | compute unit (CU) |
| AMD GCN | wavefront | 64 | compute unit (CU) |
| Intel | SIMD width | 8, 16 or 32, chosen by the compiler | execution unit (EU) / Xe core |
| Apple | SIMD-group | 32 | GPU core |
| Arm Mali (Valhall) | warp | 16 | shader core |
| Vulkan / GLSL | subgroup | gl_SubgroupSize | the portable name for all of the above |
Take if (c) { A } else { B }. When every lane of a warp has the same c, the warp jumps over the path nobody needs and runs only A or only B, as a CPU would. When the lanes disagree, there is still one instruction stream. The warp runs A with the B lanes switched off, then B with the A lanes switched off. Every pixel gets the right result, but the warp spends the time of both paths. This is called divergence.
■ wants A · ■ wants B · red frame = runs both
Amber pixels want path A, blue ones path B; the number in each warp is what it pays. With a uniform every warp agrees. With the circle only the warps on its edge pay for both paths. Narrow stripes and noise make almost every warp pay A + B, even though no pixel needs more than one. Try stripes 4 px wide with the 4 × 8 warp, then 8 × 8.
Three kinds of condition
| Condition | Example | What it costs |
|---|---|---|
| uniform: the same for every invocation of the draw | if (uQuality > 1) | only the path taken, in every warp |
| coherent: changes across the screen, but neighbours agree | if (d < 0.0) // a large shape | one path in most warps; both only in the warps along the border |
| incoherent: changes from pixel to pixel | if (hash(p) > 0.5) | both paths in nearly every warp |
This is not only theory. The next figure times a real pass on your GPU: 1024 × 1024 pixels, each running an if / else whose two paths cost exactly the same. On the RTX 4060 the pass takes 0.246 ms when the condition is uniform and 0.479 ms when it alternates every pixel: twice as long, although no pixel ran more than one path. Stripes make the size of a warp visible. Vertical stripes 4 pixels wide cost nothing extra, horizontal ones need to be 8 pixels tall: a warp there covers a block 4 pixels wide and 8 tall.
● checkerboard · ● vertical stripes · ● horizontal stripes
Both paths do the same 200 sines, so a pixel never costs more than one path. The dashed lines are one path (green) and both paths (red). Small cells cost double: every warp holds both colours. The size at which each line falls says how wide and how tall a warp is: on the RTX 4060 vertical stripes stop costing double at 4 pixels and horizontal ones at 8, so a warp there is a block 4 pixels wide and 8 tall, 32 lanes. Other GPUs give other shapes; press the button to see yours.
Loops wait for the slowest lane
A loop whose number of iterations differs between pixels diverges the same way. Lanes that leave the loop early are switched off and wait, and the warp keeps looping until its last lane is done. A warp therefore costs as much as its slowest pixel. Raymarching is the classic case: a ray that grazes a silhouette or the floor near the horizon takes many small steps, and every pixel in its warp pays for them. Switch the figure above to "loop" to see it, and use the heat map in the Raymarching chapter on a real scene.
A common piece of advice is to avoid if in shaders and write step and mix instead. It is half true. For a short if, the compiler already does it: it computes both sides and picks the result with a select instruction, with no jump at all. And mix always computes both sides, so it can never be cheaper than a branch that skips one. What matters is how expensive the paths are and how coherent the condition is:
1// 1. Both sides cheap: write what reads best. The compiler emits the same select either way.
2float a = x > 0.5 ? 1.0 : 0.2;
3float b = mix(0.2, 1.0, step(0.5, x));
4
5// 2. One side expensive, condition coherent: a real branch skips the work
6// in every warp that lies wholly outside the shape.
7if (d < 0.0) {
8 col = expensiveInterior(p); // 200 instructions, only inside the shape
9}
10
11// 3. The same work written with mix runs the expensive side everywhere.
12col = mix(col, expensiveInterior(p), step(d, 0.0));A texture read takes a few dozen cycles when the texels are in the cache and several hundred when they come from video memory. The warp that asked cannot continue, since its next instruction needs the value. So the scheduler switches to another warp that is ready, and switching costs nothing: every warp on the SM keeps its registers in the SM's register file all the time. With enough warps in flight, there is always one ready, and the waiting is hidden.
How many warps fit is decided by registers. An RTX 40 SM has 65,536 registers of 32 bits, 16,384 per scheduler, and takes at most 48 warps, 12 per scheduler. Every lane of every warp needs its own copy of every value the shader keeps alive at the same time, so a shader that needs many registers leaves room for fewer warps. The share of the maximum that fits is called occupancy.
Each row is a warp: bright = issuing an instruction, grey = waiting for its texture read. The bottom row is the arithmetic unit; every gap in it is time thrown away. Start at 64 registers: 8 warps, and the unit is mostly idle while they all wait. Lower the latency, or put more maths between reads, and the gaps close. Then raise the registers to 128: only 4 warps fit, and no amount of maths per read keeps the unit busy at a 600-cycle latency.
What drives registers up: many values alive at once, large local arrays, long unrolled loops, and many texture reads issued before their results are used (which is good for latency, so it is a trade-off). The compiler chooses the count; you cannot set it in GLSL. Profilers such as Nsight Graphics or the Radeon GPU Analyzer show it per shader. It is worth looking only when a shader is the bottleneck and heavy.
Exact numbers change with every generation, but the ranking has held for years. Counting in multiplies:
| Operation | Cost | Why |
|---|---|---|
| + − * , fma, min, max, clamp, abs, mix, select | 1 | full-rate lanes; mix(a, b, t) = a + t · (b − a) is a subtraction and one fused multiply-add |
| dot(vec3), length², cross | 2 to 6 | a short chain of multiply-adds |
| sqrt, inversesqrt, exp2, log2, sin, cos, 1/x | 4 to 8 | run on special function units: 16 per SM on the RTX 40 series, against 128 multiply lanes |
| a / b, normalize, pow(x, y) | 5 to 10 | built from the above: a · (1/b); v · inversesqrt(dot(v, v)); exp2(y · log2(x)), which is why pow of a negative x is undefined |
| int / and % | 20 or more | no hardware divider: emulated with many instructions; by a constant power of two it becomes a shift |
| texture() | a few, plus latency | the filtering is done by texture units; the cost is the wait, hidden by other warps |
| a texture read whose uv comes from another read | latencies add up | the second read cannot start until the first returns |
| double | 64 on GeForce | consumer cards run 64-bit floats at 1/64 rate; WebGL has no double at all |
Compilers reorder, merge and remove code, so the only reliable answer is a measurement. In OpenGL, time a pass with a timer query (see Measuring Performance in the OpenGL track); in WebGL the same queries come from EXT_disjoint_timer_query_webgl2, which the timing figure above used. Five rules make the numbers trustworthy:
| Rule | Why |
|---|---|
| Time in milliseconds, on the GPU | FPS hides small changes and includes the CPU; a CPU clock only measures how long the commands took to send |
| Warm up first | an idle GPU runs at a low clock for the first fraction of a second |
| Repeat, and keep the fastest run | interruptions and clock changes only ever add time |
| Make the shader the bottleneck | full screen, high resolution, nothing else drawn; otherwise you are timing something else |
| Change one thing at a time | two changes can cancel each other out |
To see why a shader is slow, not only how slow, use the vendor's profiler: Nsight Graphics (NVIDIA), Radeon GPU Profiler and Radeon GPU Analyzer (AMD), Intel GPA, or the Metal debugger in Xcode. They show registers, occupancy, how often warps diverge, and what each warp was waiting for.
| Symptom | Cause | Fix |
|---|---|---|
| an "early out" if makes nothing faster | the condition is incoherent, so every warp still runs the expensive path | make the test coarser (per tile or per object), or accept the cost |
| a raymarcher is slow only where objects meet the sky | rays grazing silhouettes take many steps, and their whole warp waits | cap the steps, grow the hit threshold with distance, bound the scene with a cheap shape |
| rewriting every if as mix made the shader slower | mix computes both sides, including the expensive one | keep real branches around expensive, coherent work |
| adding one texture read halved the speed | the shader already had low occupancy, so nothing hides the new latency | fewer live values, smaller arrays; read earlier and use later |
| a % or / by a variable shows up in the profile | integer division is emulated | use floats, or & (n − 1) and shifts for powers of two |
| an optimisation helps on one GPU and hurts on another | warp width, caches and compilers differ | measure on the hardware you ship to |
Key ideas
- A warp of 32 lanes (64 on some AMD GPUs, 16 on Mali) runs one instruction at a time; each lane is one pixel or vertex.
- An if costs every path that at least one lane of the warp takes; a loop costs the slowest lane's iterations.
- Uniform and coherent conditions are cheap; conditions that change from pixel to pixel run both paths almost everywhere.
- Short ifs become selects anyway; mix and step never skip work, so keep real branches around expensive, coherent code.
- Memory latency is hidden by switching between resident warps, and registers per lane decide how many are resident.
- Measure in milliseconds with timer queries, warmed up, fastest of many runs, one change at a time.