XORs, swizzling, and reductions

One way to think about XOR is as a bit flip: XORing a bit with 00 is a no-op, while XORing it with 11 flips it. The same idea extends to bitwise XOR: to compute x⊕yx \oplus y, flip each bit of xx wherever the corresponding bit of yy is 11.

The rule is straightforward, but I never had a good intuition for what bitwise XOR does to a number. That changed when I visualized what XOR does to a sequence.

XORs and sequences

Consider the sequence from 00 to 1515. Apply each mask independently to the original sequence, using masks with exactly one set bit:

The sequence zero through fifteen, followed by the permutations produced by XOR masks one, two, four, and eight
Masks with one set bit swap adjacent blocks of one, two, four, or eight elements.

XOR with 121_2 flips the lowest bit, so each even number trades places with the odd number next to it. XOR with 10210_2 flips the next bit, swapping adjacent blocks of two. The masks 1002100_2 and 100021000_2 swap blocks of four and eight. Applying the same mask twice restores the original order: (x⊕m)⊕m=x(x \oplus m) \oplus m = x.

What happens when a mask has more than one set bit? The result is the same as applying the corresponding single-bit masks in succession. The order does not matter: XOR is associative and commutative.

For the sequence 0,1,…,2n−10,1,\ldots,2^n-1, each nn-bit mask produces a distinct permutation, built from swaps of adjacent blocks whose sizes are powers of two. There are 2n2^n possible masks, giving 2n2^n distinct XOR permutations.

A mask with all nn bits set reverses the sequence by successively swapping adjacent blocks of increasing size. We can reach the same conclusion algebraically: the original number and its bitwise complement add up to 2n−12^n-1, so x⊕(2n−1)=2n−1−xx \oplus (2^n-1) = 2^n-1-x.

We can apply these permutations to indices, too. XOR can then select which worker to exchange values with, or where to store an element in memory.

Reductions

Suppose four workers, numbered 00 through 33, hold values a,b,c,da,b,c,d. We want to compute their sum and leave the result in every worker. This is an all-reduce.

At each stage, worker ii combines its current value with the previous-stage value from worker i⊕mi\oplus m, where mm is the XOR mask. The illustration shows two stages, using masks 121_2 and 10210_2. Multiple letters inside a tile represent the sum of those values:

Four workers start with a, b, c, d; XOR-selected exchanges and additions give every worker the sum a+b+c+d

The first stage pairs workers 00 and 11, and workers 22 and 33. The first pair now holds a+ba+b; the second holds c+dc+d. The second stage pairs workers 00 and 22, and workers 11 and 33, leaving all four with a+b+c+da+b+c+d.

At each stage, the XOR mask pairs workers whose partial sums contain disjoint sets of inputs. Combining those sums doubles the number of original values included in each worker’s sum without counting anything twice. With this butterfly pattern, a power-of-two group of NN workers needs log⁡2N\log_2 N stages, using masks 1,2,4,…,N/21,2,4,\ldots,N/2.

Swizzling

Swizzling changes the mapping from logical array indices to physical memory locations. An XOR swizzle can avoid bank conflicts in GPU shared memory. We’ll use NVIDIA’s CUDA programming model as a concrete example.

For the scalar 32-bit accesses considered here, shared memory has 32 banks, each serving 4-byte words. A word at index qq maps to bank q mod 32q \bmod 32. Threads in a warp can access different banks in parallel. When multiple threads access different words in the same bank, however, those accesses must be serialized, reducing throughput. This is called a bank conflict.

Suppose we have a 32×3232\times32 matrix of 32-bit floating-point values. The matrix occupies 4 KiB of shared memory. We want the 32 threads in a warp to read either a whole row or a whole column, with each thread reading one element.

Assume we store the matrix in row-major order, using zero-based indices. The element at row rr, column cc has word offset 32r+c32r+c. Taking this offset modulo 3232 keeps only its lowest five bits, which come entirely from cc.

When reading a row, rr is fixed and cc runs from 00 to 3131, so every thread accesses a different bank. When reading a column, cc is fixed and rr varies, but (32r+c) mod 32=c(32r+c)\bmod 32=c. Every thread therefore accesses a different word in the same bank.

XOR lets us mix the row index into those bits: store each element in column c⊕rc\oplus r instead. For a fixed logical column cc, the bank indices c⊕rc\oplus r cover all 32 banks exactly once as rr runs from 00 to 3131. Row accesses remain conflict-free because XOR with a fixed row index simply permutes the columns.

The following 4×44\times4 example illustrates the idea. Each entry labels an element by its original column index. The bank-conflict argument above applies to the 32×3232\times32 layout:

A four-by-four matrix with each row permuted by XORing the column index with the row index

The swizzled word offset for logical element (r,c)(r,c) is qswizzled(r,c)=32r+(c⊕r)q_{\mathrm{swizzled}}(r,c)=32r+(c\oplus r). Both reads and writes use this mapping; the stored values themselves are unchanged. Practical GPU layouts choose which address bits to XOR according to the element size and access pattern.