all_lessons/ai_compilers / lessons/07 · layoutlesson 8 / 20

Part III — Middle end

Layout & data formats

Lesson 06 decided where every buffer lives and when — it collapsed peak memory by reusing storage across non-overlapping lifetimes. What it never touched is the shape of the bytes inside each buffer: given a 4-D activation, which index runs fastest in memory? That choice is invisible to the math and decisive for the hardware. The wrong physical layout makes every downstream kernel read HBM in a strided, uncoalesced pattern, locks you out of the tensor cores, and — worst of all — forces the compiler to splice in transpose kernels between ops that disagree, each one a full extra HBM round trip. This lesson assigns a physical format to every tensor and then propagates those choices across the graph so adjacent transposes cancel.

The previous step left this broken
Memory planning treats a tensor as an opaque blob of N bytes with a lifetime — it answers "does buffer A's slot overlap buffer B's?" and packs them into one arena. But a tensor of shape (N, C, H, W) is not a blob; it is a multi-dimensional array that must be flattened into linear addresses, and there are many ways to do it. Store it NCHW (channel-major) and a convolution reads one channel-plane at a time; store it NHWC (channel-last) and the same conv reads all channels of one pixel contiguously. The math is identical — same numbers, same result — but the address pattern a kernel issues, whether a warp's 32 loads coalesce into one transaction, and whether the data even lands in a tensor-core fragment, all hinge on which axis is innermost. The planner left every buffer's internal stride order unspecified, so each kernel is free to want a different one, and the compiler must reconcile them.
Linear position
Forced by: a buffer's physical layout — which axis is contiguous — decides coalescing, tensor-core eligibility, and how many transpose kernels get inserted between ops; planning never set it.
New idea: layout assignment — pick each tensor's physical format (NCHW vs NHWC, row- vs column-major, tiled/blocked layouts that match tensor-core fragments, packed/padded for alignment) to maximize coalesced access and tensor-core use, and then propagate layouts through the graph so adjacent transposes cancel. It is a global assignment problem: the locally best layout for one op can force an expensive reformat for the next.
Forces next: the graph is now optimized, fused, planned, and laid out — but it is still abstract. It says what to compute on which bytes, not the loop nest — tile sizes, loop order, what stages to SRAM — that actually runs it on the chip. That is scheduling (lesson 08).
The plan
Five moves. (1) Why layout decides coalescing and tensor-core eligibility — the hardware facts, cross-linked to the kernel track. (2) NCHW vs NHWC for convolution, and why the disagreement between frameworks and hardware breeds transposes. (3) Tiled/blocked and packed layouts, plus padding for alignment and what it costs. (4) Layout propagation as a global min-cost assignment — why locally good ≠ globally good, and why it is NP-ish. (5) How XLA, TVM, and cuDNN actually pick layouts, and the transpose-elimination pass. Then drive the widget until a locally-greedy choice litters the chain with transposes and propagation wipes them out.

1 · Why layout decides speed: coalescing and tensor-core fragments

A layout is the rule that maps a tensor's logical indices to linear memory addresses — concretely, the strides: for a 2-D tensor, row-major means address = i·stride_row + j with stride_row = ncols (the j axis is contiguous); column-major makes i contiguous instead. The numbers stored are identical; only the order on the wire changes. Two hardware facts make that order decisive.

Coalescing. HBM is read in 128-byte cache lines, and a warp's 32 threads issue 32 loads at once. If those addresses fall in one line, the controller serves them in one transaction at peak bandwidth; if they are scattered across 32 lines, you pay up to 32× the transactions for the same data (gpu_kernels · 04 coalesced access, gpu_kernels · 02 memory hierarchy). Whether a kernel's threads stride contiguously is a direct consequence of which axis the layout put innermost. A reduction over channels wants channels contiguous; if the layout buried channels behind H·W, every load jumps a full plane and bandwidth craters.

Tensor-core fragments. An mma.sync instruction multiplies a small fixed-shape fragment — for bf16 the canonical tile is 16×8×16, distributed as 8 elements per thread across the 32-lane warp in a specific register layout the hardware expects (gpu_kernels · 09 tensor cores). The fast path that loads those fragments, ldmatrix, assumes the operand tile sits contiguously in shared memory in a known order. If your global layout doesn't let the kernel stage a contiguous, aligned tile into SRAM, it either falls back to the ~15× slower CUDA-core path or inserts a reformat first. Tensor-core eligibility is a layout property, not just a dtype property.

contiguous axis
The innermost (stride-1) axis. A warp reading along it coalesces into one transaction; reading across it pays up to 32× the transactions.
fragment-friendly
A layout that lets a kernel stage a 16×8×16-shaped, 128-bit-aligned tile into SRAM for ldmatrixmma. Otherwise: no tensor cores.
the cost unit
A wrong layout is paid in bytes moved — strided reads multiply transactions, and a fix-up transpose is a full extra HBM read + write of the whole tensor.

2 · NCHW vs NHWC: where transposes come from

A 4-D convolution activation has axes N (batch), C (channels), H, W (spatial). Two layouts dominate, named by their axis order from outermost to innermost:

Here is the trap. Suppose your graph is conv → batchnorm → conv. BatchNorm is a per-channel scale-and-shift; it is happiest with channels contiguous (NHWC) so each channel's mean/var streams in coalesced. The convs, on tensor cores, also want NHWC. So far so good — one global NHWC and everyone is happy. But now imagine a stray op in the chain that the compiler only has an NCHW kernel for (an older custom op, a pooling variant, a framework default). The compiler cannot run an NCHW kernel on NHWC bytes, so it splices in a transpose: a full kernel that reads the whole tensor in one order and writes it back in another. A transpose of a 256 MB activation is a 256 MB read plus a 256 MB write — ≈160 µs on an H100 at ≈3.3 TB/s — to move zero useful FLOPs. Do this between every pair of disagreeing ops and the transposes can cost more than the convs.

eager / naive layout — each op gets its locally preferred format:

  conv(NHWC) --[T]--> bn(NCHW) --[T]--> conv(NHWC)
              ^^^                ^^^
        transpose 256MB     transpose 256MB     ← 2 extra HBM round trips,
        (read+write)        (read+write)          zero useful FLOPs

propagated layout — pick ONE format the whole region tolerates:

  conv(NHWC) -------> bn(NHWC) -------> conv(NHWC)
        no transposes; convs hit tensor cores; bn coalesces on C

The two transposes are not in the math — the model never asked for them. They are an artifact of letting each op choose its layout in isolation. That is the disagreement layout assignment exists to resolve.

3 · Tiled, blocked, and packed layouts; padding for alignment

NCHW/NHWC are only the row/column-major story lifted to 4-D. Two further families matter for the metal.

Tiled / blocked layouts. Instead of a flat row-major sheet, store the tensor as a grid of small contiguous tiles — e.g. break the channel axis into blocks of 8 and store NCHWc (oneDNN's notation: an outer channel-block axis, spatial, then an inner block of 8 channels contiguous). Each tile then matches the shape a kernel stages into SRAM or feeds to an mma, so the innermost block is exactly fragment-sized and 128-bit-aligned. Tiling the storage means the kernel's natural working set is already contiguous — no gather to assemble a fragment. cuDNN's NC/32HW32 (int8) and the blocked formats in oneDNN/XLA are exactly this: the physical layout is pre-shaped to the consuming instruction.

Packing. Packing reorders/copies a tensor into the exact byte arrangement a kernel's inner loop wants ahead of time — the classic example is GEMM, which packs panels of A and B into contiguous, aligned strips so the microkernel streams them with no stride arithmetic. The pack is a one-time copy whose cost amortizes over the matmul's O(M·N·K) FLOPs; for a weight it can be done once at compile time and reused every inference.

Padding for alignment. Tensor cores and vector loads want dimensions that are multiples of the fragment/transaction size — e.g. an inner dim that is a multiple of 8 (bf16) or 16, and base addresses on 128-byte boundaries. A channel count of 130 is awful: the last fragment is ragged, the kernel either masks (slow) or falls off the tensor-core path. Padding rounds 130 up to 136 (multiple of 8) or 256 with zeros so every tile is full and aligned. The cost is real and must be priced: padding 130→256 channels nearly doubles the bytes moved and the FLOPs computed on garbage zeros. The compiler pads only when the alignment win beats the wasted-traffic cost — small over-pad to hit a fragment boundary, yes; doubling the tensor, usually no.

layout familywhat it optimizesthe cost it pays
row/col-major (NCHW/NHWC)pick the contiguous axis to match the consumer's reduction / fragmenta transpose when consumers disagree
tiled / blocked (NCHWc, NC/32HW32)storage pre-shaped to the SRAM tile / mma fragmentreformat cost at layout boundaries; more bookkeeping
packed (GEMM panels)stride-free streaming in the microkernela one-time copy (amortized over the matmul)
paddedfull, aligned fragments → tensor-core eligibilityextra bytes + FLOPs on padding; OOM if over-padded

4 · Layout propagation: a global, not local, assignment

The naive algorithm is greedy: ask each op for its single favorite layout and grant it. That is locally optimal and globally terrible, because every boundary where two neighbors' favorites disagree becomes a transpose. The real objective is to assign a layout to every tensor (every graph edge) so that the total cost — kernel slowdown from non-ideal layouts plus the transposes inserted at every mismatch — is minimized over the whole graph.

Model the graph as nodes (ops) and edges (tensors). Each op has a cost function over the layout of each of its operands and result: a conv is cheap in NHWC, costly in NCHW; a transpose between adjacent edges costs the bytes of the tensor. Choosing a layout for one edge constrains its neighbors, so you cannot decide edges independently — this is a combinatorial assignment over a graph, equivalent in spirit to a min-cost labeling / graph-coloring problem, and it is NP-hard in general. Compilers do not solve it exactly; they use heuristics:

anchorFix the layout of the ops with the strongest preference and largest cost — the convs and GEMMs that must hit tensor cores. They are the immovable constraints.
propagateFlow each anchor's layout outward along edges to its neighbors: a layout-agnostic elementwise op (relu, add) inherits its producer's layout for free, extending a region of one format.
resolve conflictsWhere two propagated layouts meet at an op that disagrees, decide: convert one side (insert a transpose) or pick the cheaper compromise. Greedily cut at the lowest-cost edge.
cancel adjacenciesRun transpose elimination: two transposes back to back collapse to one (or to none if they invert); a transpose feeding a layout-agnostic op gets pushed through and absorbed into a neighbor.

Transpose elimination is the payoff pass. Algebraically, transpose(transpose(x)) with inverse permutations is the identity and is deleted outright; a transpose that meets a commuting elementwise op can be hoisted past it so it merges with the next transpose, and the pair then cancels. After propagation has chosen consistent layouts for long stretches of the graph, elimination sweeps up the few remaining transposes the conflict-resolution step left behind. The conv→bn→conv chain that had two transposes under greedy assignment ends with zero. This is the same correctness invariant from lessons 03–04: a layout change plus its inverse is a provable no-op on the math, which is exactly why the compiler is allowed to delete it.

5 · How XLA, TVM, and cuDNN actually pick layouts

Every production stack runs some version of this assignment-then-eliminate pipeline:

The verdict the field has converged on: pick layouts globally around the tensor-core-bound anchors, make everything else conform, and treat a surviving transpose as a bug to be eliminated, not a cost to be tolerated.

6 · Drive it: the layout chooser

The widget below is the conv → bn → conv chain. Each op has a locally preferred layout (the convs want NHWC for tensor cores; toggle BatchNorm to prefer the other format to manufacture a conflict). Choose a single global layout, or flip on propagate to cancel transposes to let the assignment pass conform the agnostic ops and run transpose elimination. Watch the KPIs: with greedy local choices the chain sprouts transposes (red arrows), bandwidth balloons with the extra round trips, and a wrong global format drops the convs off the tensor-core path. Propagation collapses the transposes to zero and the convs light up green.

Layout chooser — propagate to cancel transposes
A locally-good layout for one op forces a transpose at the next disagreement, and each transpose is a full extra HBM read+write. The knob that proves it: pick a global layout that disagrees with the tensor-core ops, or turn on propagate and watch the inserted transposes drop to zero while the convs become tensor-core eligible.
transposes inserted
est. HBM traffic
convs tensor-core eligible?
overhead vs no-transpose

The shape of the result is the whole lesson: a layout chosen op-by-op looks fine locally and is riddled with hidden transposes globally; one consistent layout, propagated around the tensor-core anchors, pays the data movement exactly once. Notice the conflict toggle — when BatchNorm wants the opposite format, a pure global pick still costs some transpose, but propagation places it where one conversion serves the whole region instead of one per boundary.

Failure modes & checklist

Failure modes

  • Greedy per-op layout. Granting each op its local favorite. Signal: the profiler is full of permute/transpose/copy kernels between your real ops, each moving a full tensor for zero FLOPs.
  • Right dtype, wrong layout, no tensor cores. Running bf16 convs in NCHW and wondering why MFU is low. Signal: the conv shows on the CUDA-core path or with a reformat prologue; switching to channels_last jumps it onto mma.
  • Strided reduction. A per-channel op (norm, bias) on a layout where channels are the outer axis. Signal: the kernel is bandwidth-bound at a fraction of peak — its loads are scattered, not coalesced.
  • Over-padding. Padding 130→256 channels to "be aligned." Signal: memory and FLOPs nearly double and peak goes up — the pad cost dwarfed the alignment win.
  • Treating a transpose as free. Assuming the layout change is bookkeeping. Signal: wall-time doesn't match FLOP estimates because invisible read+write transposes are eating bandwidth.

Checklist

  • Anchor on the heavy ops. Fix conv/GEMM layouts to the tensor-core-preferred format first; conform everything else.
  • Propagate, don't pick per-op. Let layout-agnostic ops inherit their producer's format to grow single-format regions.
  • Count surviving transposes. Each one is a full HBM round trip; a clean graph after elimination has near zero.
  • Pad to the fragment, not beyond. Round inner dims up to the mma/transaction multiple; reject pads that bloat the tensor.
  • Verify coalescing on reductions. Per-channel ops want channels contiguous; check the stride before blaming the kernel.
  • Use the framework's hint. torch channels_last, XLA layout assignment, TVM ConvertLayout — let the stack carry the format end-to-end.

Checkpoint

Try it
Open the widget with global layout NHWC and the conflict toggle off: zero transposes, both convs tensor-core eligible — the ideal. Now switch global layout to NCHW: the convs drop off the tensor-core path (channels are no longer the fast fragment axis) and you may see a reformat appear. Set it back to NHWC and turn on the BatchNorm prefers NCHW conflict: under greedy the chain shows two transposes (one each side of BN); now flip on propagate to cancel transposes and watch the count fall to one or zero as the pass conforms the region and eliminates back-to-back transposes. At tensor size 256 MB, compute by hand what two transposes cost on a 3.3 TB/s GPU (each is read+write ≈ 2 × 256 MB / 3.3 TB/s ≈ 155 µs, so ≈310 µs of pure data movement) — then confirm propagation buys that time back.

Where this points next

You now have a graph that is canonicalized, fused, memory-planned, and laid out: every tensor has a physical format chosen to coalesce, feed the tensor cores, and avoid transposes, with the few unavoidable reformats placed where one conversion serves a whole region. But everything so far is still declarative — it specifies what to compute on which bytes, not how the loops run. A single laid-out matmul still has astronomically many loop nests that compute it: which axis is the outer loop, how big are the tiles, what gets staged into SRAM, where do you vectorize and unroll. Those choices vary performance by 10–100× for identical FLOPs and an identical layout. Picking them is scheduling — the algorithm/schedule split, tiling for the memory hierarchy, the loop-nest knobs. That is lesson 08, Scheduling — the loop nest.

Takeaway
A layout is the index-to-address map — which axis is contiguous — and although it is a no-op on the math it decides everything the hardware cares about: whether a warp's loads coalesce into one 128-byte transaction or scatter into 32, and whether a tile can be staged as a tensor-core fragment or falls to the ~15× slower path. For convolution this is the NCHW vs NHWC choice — tensor cores want channels-last — and wherever two neighboring ops disagree the compiler must splice in a transpose, a full HBM read+write (≈160 µs for a 256 MB tensor) that moves zero FLOPs. Beyond row/col-major there are tiled/blocked layouts pre-shaped to the SRAM tile or mma fragment, packed GEMM panels for stride-free streaming, and padding to hit alignment (priced against the wasted bytes). Picking layouts op-by-op is locally fine and globally disastrous because every disagreement is a transpose, so it is a global min-cost assignment — NP-hard, solved heuristically: anchor on the tensor-core ops, propagate their layouts to agnostic neighbors, resolve conflicts at the cheapest edge, then run transpose elimination to cancel adjacencies — exactly what XLA layout-assignment, TVM ConvertLayout, and PyTorch channels_last do. The graph is now optimized and laid out but still abstract: it says what to compute, not the loop nest — that forces scheduling.

Interview prompts