Second Deep Dive post. The WGMMA post covered how a warpgroup's output tile lands in registers — split across 128 threads in a structured, Layout-describable pattern. What it didn't cover: what happens when that output needs to become the input to another WGMMA, which is exactly what fused kernels like FlashAttention do, and exactly where the FP8 path in FlashAttention-3 gets more expensive than it looks.
The fused-GEMM setup
FlashAttention's inner loop is two matrix multiplies back to back:
S = Q·K^T, then (after softmax) O = P·V. The output of the first
WGMMA — the accumulator holding P, in registers — needs to become
operand A of the second WGMMA. Not copied out to shared memory and
reloaded — used directly, register to register, which is the whole
point of fusing the two GEMMs in the first place.
Why FP16 gets this for free
For FP16, this just works: the layout the FP32 accumulator naturally sits in after the first WGMMA is already the layout WGMMA expects for an input operand. No data movement, no extra instructions — the compiler can hand the same registers straight to the second WGMMA call.
Why FP8 doesn't
FP8 WGMMA expects its operand data arranged differently: interleaved
across pairs of threads, rather than each thread simply owning its own
contiguous chunk. Colfax Research's writeup on adding FP8 to
FlashAttention-2 gives the concrete pattern — after the reshuffle,
thread 0 needs to hold, in order: T0d0, T0d1, T1d0, T1d1, T0d2, T0d3, T1d2, T1d3. Two of those eight values (T1d0, T1d1) originally
belonged to thread 1, not thread 0.
That's the actual problem: it's not that FP8 needs more data movement in some vague sense, it's that the accumulator's layout and the next operand's required layout are two different, specific arrangements — and turning one into the other requires values to physically cross between threads, which registers can't do on their own. A thread can't reach into another thread's registers directly; the only way to move a value from thread 1's registers into thread 0's is through an explicit instruction.
The fix: two kinds of movement, because there are two kinds of mismatch
Some of the reshuffling is within a single thread's own registers —
reordering bytes it already owns. That doesn't need cross-thread
communication at all, just __byte_perm:
auto upper0 = __byte_perm(upper, lower, 0x7654);
auto lower0 = __byte_perm(upper, lower, 0x3210);The rest genuinely needs to move between threads, which is what
__shfl_sync is for — each thread pulls a value from a specific other
thread's registers, using a fixed permutation map within groups of 4
threads:
int upper_map[4] = {0, 3, 1, 2};
int lower_map[4] = {1, 2, 0, 3};
upper0 = __shfl_sync(uint32_t(-1), upper0, upper_map[threadIdx.x % 4], 4);
lower0 = __shfl_sync(uint32_t(-1), lower0, lower_map[threadIdx.x % 4], 4);Both are warp-level intrinsics — no shared memory round-trip, no synchronization barrier across the whole block, just direct register-to-register exchange within a small group of threads. That's about as cheap as fixing a layout mismatch can be, but it's still real instructions on the critical path that FP16 simply never has to issue.
Why this is worth knowing
It's easy to read "FP8 is faster" as a flat statement — lower precision, higher Tensor Core throughput, done. The actual picture, once you're fusing GEMMs instead of running them in isolation, is that FP8 trades some of that throughput gain against a layout-conformance cost FP16 doesn't have. FlashAttention-3 still comes out ahead with FP8 (the numbers in the previous post show that clearly) — but "ahead" is a net result that already has this shuffle cost baked into it, not a number you get by assuming FP8 is a drop-in replacement for FP16 inside a fused kernel.
References
- Delivering 1 PFLOP/s of Performance with FP8 FlashAttention-2 — Colfax Research
- CUTLASS Tutorial: Fast Matrix-Multiplication with WGMMA on NVIDIA Hopper GPUs — Colfax Research
Layout comparison diagram above is original artwork made for this post.