Week 8 · Lesson 2

Global and local loops

In .uir a kernel is still just loops. Two new axes say which loop is the grid of blocks and which is the threads of a block. The result never changes, so run works as before. What's new is a stronger rule for proving that no two threads, and no two blocks, write the same element.

Two more axes

%b = range 3 axis=global      # one iteration per block
%t = range 32 axis=local      # one iteration per thread of the block
%m = mul %b 32
%i = add %m %t                # element 32b + t
...
%et = end %s %t
%eb = end %et %b

A global loop is the grid: each iteration is a block, and blocks may run in any order, at the same time. A local loop is a block's threads, so its bound is the block size: a literal from 1 to 1024. Like week 7's axes, neither changes a single bit. Run as plain loops, the program means what it always did.

A few rules keep the program shaped like a kernel:

Loops into a kernel

This program computes 3x + 1 with two plain loops. Make them a grid of 4 blocks of 16 threads. Only attributes change.

Why week 7's slice rule isn't enough

Week 7's rule gave each thread-loop iteration a slice r·S to r·S + S - 1, with one parallel index. Now there are two at once, the block and the thread, and real kernels mix them up. The 512×512 GEMM in the speed test writes element

(16·by + ty)·512 + 16·bx + tx        by = g div 32, bx = g mod 32,  ty = t div 16, tx = t mod 16

Thread t of block g isn't writing one contiguous slice: block g owns a 16×16 square, spread across 16 rows. We need a rule that sees that.

The mixed-radix rule

Write the offset as a sum of terms. A term is a digit when it's a loop index, or that index's div or mod by a literal, times a positive literal coefficient. Every digit has a largest value: n - 1 for a loop of n, (n - 1) div k for a div, and k - 1 for a mod (or n - 1, if that's smaller). The GEMM's offset expands to four digits:

digitcoefficientlargest valuethe most it adds
by = g div 32819231253952
ty = t div 16512157680
bx = g mod 321631496
tx = t mod 1611515

Now sort by coefficient, and check that each one is greater than the most all the smaller digits can add up to: 8192 is more than 7680 + 496 + 15 = 8191, 512 is more than 511, 16 is more than 15. Then the offset is a mixed-radix number, like a time written as hours, minutes and seconds. Two different combinations of digits always give different offsets, for the same reason 2:59 and 3:00 are different times. So no two threads, of the same block or of different blocks, ever write the same element.

The rule is checked twice, on two pieces of code. Once per local loop, with t as the parallel index and the block fixed: do the block's threads collide? And once over the whole global loop body, with g as the parallel index: do the blocks collide? Loops inside the piece being checked count as digits too, since one thread runs all their iterations.

Four more parts complete the rule:

It applies to every store, and every load of memory the same code also stores into. Memory that's only read can be read anywhere: the GEMM's A and B are read by many blocks at once, which is fine.

Can two threads collide?

Write unique digits: true when the digits, sorted by coefficient, form a mixed-radix number.

The same expression, every time

One more rule: within the code being checked, every access to one buffer must use the same offset expression, the very same nodes, not just the same digits. sh[t] and sh[t + 1] have the same digits, but if thread t stores to sh[t] and loads sh[t + 1], it reads its neighbor's slot while the neighbor may be writing it. Requiring the identical expression rules that out without any arithmetic. Your hash-consing from week 2 makes the check a pointer comparison.

The next lesson explains why reading a neighbor's slot is fine one phase later.

Check yourself

Blocks b in 0..7, threads t in 0..15. Which store offset is valid?

The largest t is 15. What must the coefficient of b beat?

The coefficient of b must be greater than 1·15. With 8, block 0 thread 8 and block 1 thread 0 both write element 8.

Is b + 4t valid, with b in 0..3 and t in 0..7?

The rule sorts the digits. It doesn't care which one is the block.

The threads of each block are spread out with stride 4 and the blocks fill the gaps between them. That's still a unique number. On a real GPU this layout is often slower, since neighboring threads write elements 4 apart (16 bytes for f32).

A local loop has 64 threads, and the offset uses t div 4. What is that digit's largest value?

The largest t is 63.

63 div 4 = 15. And t mod 4 has largest value 3.

In one local loop, thread t stores sh[t] and loads sh[t + 1]. Why is it rejected, when both offsets have the digit t?

Who writes sh[t + 1]?

Thread t + 1 stores into sh[t + 1] in the same phase, at the same time. The load would see the old or the new value depending on timing.