FP16 GEMM Optimization on AMD RDNA™ 3 and RDNA™ 4 Architectures AMD published a step-by-step guide to writing a fast FP16 GEMM kernel for RDNA 3, RDNA 3.5, and RDNA 4 GPUs using Wave Matrix Multiply-Accumulate (WMMA) instructions, reporting that the final kernel lands within a few percent faster or slower than ROCm's tuned hipBLASLt library depending on architecture and GEMM shape. The eight-step walkthrough covers LDS bank-conflict avoidance, buffer resource descriptors for out-of-bounds-safe global loads, deeper K slices, block reordering for cache reuse, interleaved prefetches, and a revisit of double buffering, with each change measured in time, registers, and Local Data Share (LDS) on an AMD Radeon PRO W7800 (gfx1100), a Radeon RX 9070 XT (gfx1201), and a Radeon 8060S (gfx1151). Part 2 will port the final kernel to FP8 on RDNA 4. FP16 GEMM Optimization on AMD RDNA™ 3 and RDNA™ 4 Architectures fp16-gemm-optimization-on-amd-rdna-3-and-rdna-4-architectures In this blog you will learn how to write a fast FP16 GEMM kernel for AMD RDNA™ 3 and RDNA™ 4 GPUs, one optimization at a time. You will see which techniques pay off on each architecture, why some help one and hurt the other, and how to measure the effect of every change in time, registers, and LDS, so you can apply the same approach to your own kernels. This is Part 1 of a two-part series. Part 2 will port the final kernel from this post to FP8 on RDNA 4. Computing C = A × B is one of the most heavily used kernels in AI and HPC. On AMD Radeon™ GPUs built on the RDNA architecture, the hardware exposes Wave Matrix Multiply-Accumulate WMMA instructions for it: one cooperative instruction in which all 32 threads in a wave together multiply 16×16×16 matrix tiles and add the result to an accumulator. This post builds an FP16 GEMM kernel from WMMA one step at a time. We start with a basic GEMM kernel and end a few percent faster or slower than hipBLASLt https://rocm.docs.amd.com/projects/hipBLASLt/en/latest/ , ROCm’s tuned GEMM library, depending on the architecture and GEMM shape. The goal is not a production library, but a guided walkthrough: each step names the problem it fixes, shows the code change, and reports what that change cost or saved in time, registers, and Local Data Share LDS , fast on-chip memory shared by threads in a workgroup. The kernel grows through eight steps, numbered the same way as the timing tables later in the post. Step 1 is a minimal starter kernel, one wave on a small tile, so you can see how far a naive WMMA loop is from the target. Step 2 applies four standard GEMM techniques at once: a larger tile, wide 128-bit memory copies, double buffering, and a staged commit. That is the only step that changes more than one thing at a time; those four changes together reach a conventional tiled kernel that every later step builds on. Steps 3 through 8 each change one thing: - Avoid LDS bank conflicts: consecutive rows of a tile can map to the same LDS bank and serialize; padding or permuting the layout spreads accesses apart. - Use buffer resource descriptors for global loads: out-of-bounds addresses return zero, so tile sizes need not divide the matrix dimensions evenly. - Deepen the K slice: hold several WMMA steps of K in LDS at once, doing more arithmetic between global-memory copies and barriers. - Reorder blocks for cache reuse: launch output tiles in an order that keeps concurrent blocks reading overlapping regions of A and B. - Interleave prefetches with WMMA: issue global loads between matrix instructions so memory latency hides behind arithmetic the wave already has to run. - Revisit double buffering: with interleaving in place, test whether two LDS buffers are still worth their occupancy cost, or whether one buffer and more resident workgroups is faster. Note: FP8 GEMM Optimization on AMD CDNA™ 4 Architecture https://rocm.blogs.amd.com/software-tools-optimization/cdna4-gemm-kernels/README.html covers the same optimization progression on CDNA hardware, in FP8. Each step is measured on three GPUs: an AMD Radeon™ PRO W7800 gfx1100, RDNA 3 , an AMD Radeon™ RX 9070 XT gfx1201, RDNA 4 , and an AMD Radeon™ 8060S gfx1151, RDNA 3.5 . The latter uses the same WMMA instructions as RDNA 3, so one kernel binary serves both, but it reaches memory through system RAM rather than dedicated GDDR, resulting in lower absolute throughput on gfx1151 even with optimization. Several steps help one architecture and hurt another; those cases are called out as they appear. To work through the examples you need a ROCm installation with the hipBLASLt development package, an RDNA 3, RDNA 3.5, or RDNA 4 GPU, and working familiarity with HIP and the basics of GEMM tiling how a large matrix multiply is split into blocks, waves, and K slices . All timings in this post come from ROCm 10. Most code snippets below are pseudo-code that shows structure and control flow. Full HIP appears where the details matter: WMMA built-ins, the buffer resource descriptor, and the address arithmetic behind LDS permutation. A complete implementation of every step, with the benchmark driver, is in the accompanying repository ./src . Part 2 of this series will apply the same progression to FP8 on RDNA 4. What We Are Computing what-we-are-computing Every kernel in this post computes C = A × B in FP16. Matrix A is stored row-major and matrix B is stored column-major. That pairing is deliberate: K is the dimension the multiply sums over, and with these layouts both operands are contiguous along K. When a workgroup copies a tile of A or B into LDS, each lane can walk K with consecutive addresses and no transpose on the way in. Every LDS tile in this post puts K last in the indexing, and the copy loops for A and B are mirror images of each other. The benchmark fills A and B directly in these layouts; real data may need to be transposed first to match what the kernel expects. The kernel writes C in the same column-major layout hipBLASLt uses, with leading dimension M, so the two outputs line up element for element. hipBLASLt labels this pairing TN, a name that recurs throughout the rest of this post. Alongside timing it, the benchmark checks each stage against hipBLASLt. It draws each element of A and B as -1 or +1 and compares the two exactly, with no tolerance: every product is then ±1 and every partial sum an integer. With random inputs those sums stay near ±√K, well inside the range FP16 represents exactly, so no rounding happens whatever order each side reduces in, and a correct kernel must match hipBLASLt bit for bit. This runs at both benchmark shapes, so the non-divisible tiling of the wide case is covered too. Wave Matrix Multiply Accumulate on RDNA wave-matrix-multiply-accumulate-on-rdna Section 1 introduces WMMA as the building block for this kernel. Before the optimization steps begin, this section covers what the instruction expects in registers and how that differs between RDNA 3 and RDNA 4. Those differences show up later in register pressure, LDS layout choices, and which architecture gains from a given step. WMMA is a wave-wide instruction: all 32 lanes in a wave cooperate on one 16×16×16 matrix multiply-accumulate. One instruction multiplies a 16×16 tile of A by a 16×16 tile of B and adds the result into a 16×16 tile of C. The two architectures spread each operand across the wave’s registers differently. On RDNA 3, WMMA treats the 32-lane wave as two cooperating 16-lane halves that share operand data but write different portions of the 16×16 output tile. The operand fragments are therefore replicated: lanes 0–15 and lanes 16–31 hold the same data, and every lane carries all 16 K values for its row. The kernel must load those full K rows into every lane even though only half the accumulator elements are used. On RDNA 4 the two halves of the wave hold different data: lanes 0–15 carry K values 0–7 and lanes 16–31 carry 8–15. As Figure 1 shows, every lane on RDNA 3 holds the full K range, while on RDNA 4 each half of the wave holds half of it. Figure 1: The operand layout the instruction expects. A and B are laid out the same way. Each lane must hold the correct elements before the instruction issues. The A and B tiles in LDS are K-contiguous, so each lane can copy its fragment with a single address calculation: // Both operands are read the same way; only the tile pointer differs. // row is the tile row this fragment starts at, k the K position within the tile. const int offset = row + lane % 16 stride + k + k offset lane ; builtin memcpy &fragment, &tile offset , sizeof fragment ; lane % 16 selects the row within the 16-wide tile. k offset is zero on RDNA 3 and 8 lane / 16 on RDNA 4, which splits the two halves of the wave on RDNA 4. The fragment type sets the copy width, so the same memcpy moves 32 bytes on RDNA 3 and 16 on RDNA 4. The accumulator is split across lanes the same way. On RDNA 3, with OPSEL = false , lane L holds column L mod 16 and accumulator element 2g holds row 2g + L/16 . On RDNA 4 the results are packed: lane L holds column L mod 16 and accumulator element g holds row 8 L/16 + g . Two consequences matter for the rest of the post: - An RDNA 3 fragment is twice as wide as an RDNA 4 fragment. Reading it from LDS takes two 128-bit loads instead of one, and any LDS address permutation has half as many places to move data to. - An RDNA 3 accumulator uses twice the registers for the same output tile. Register pressure therefore limits tile shape differently on the two architectures. Three constants appear throughout the code: /// Size of the WMMA instruction's output tile, and of its K step. constexpr int wmma tile = 16; /// FP16 values moved by one 128-bit access: 128 bits / 16 bits per value. constexpr int elems per b128 = 8; /// Lanes in a wave32 wavefront. constexpr int wave size = 32; elems per b128 is the widest single load a lane can issue and sets the granularity of every copy: an RDNA 4 fragment is one such access, an RDNA 3 fragment two. Rather than maintain two separate kernels, the code hides the architecture differences behind a policy: a struct of types and static functions, one per architecture, that the kernel is templated on. The choice resolves at compile time. The two policies disagree in four places: | | RDNA 3 gfx11 | RDNA 4 gfx12 | |---|---|---| | Fragment and accumulator width | 16 FP16 | 8 FP16 | | K values a lane reads | all 16, replicated | 0-7 or 8-15, by lane half | | Accumulator elements used | every second one | all of them | | Matrix instruction | ... w32 a, b, acc, false | ... w32 gfx12 a, b, acc | Table 1. What the two policies disagree about. Everything else in the kernel is shared. The builtin names differ, and the RDNA 3 call carries an extra argument: // RDNA 3. The trailing false is OPSEL, which selects whether results land in the // low or high half of each accumulator register. The builtin has no default for it: // the argument is required, and passing false is what puts the results in the even // elements described above. acc = builtin amdgcn wmma f16 16x16x16 f16 w32 a, b, acc, / OPSEL= /false ; // RDNA 4. Same logical operation, different builtin. acc = builtin amdgcn wmma f16 16x16x16 f16 w32 gfx12 a, b, acc ; Each policy also supplies the fragment read and the accumulator write-out, since both depend on the lane mapping above. The rest of the kernel does not see the difference. The AMD ROCm Optimization Guide documents the fragment and accumulator layouts of each architecture, with the matching builtins: RDNA3 dense WMMA builtins https://rocm-handbook.amd.com/projects/amd-rocm-optimization-guide/en/latest/compiler-builtins/rdna/rdna3-dense-wmma-builtins.html fragment-layouts and RDNA4 dense WMMA builtins https://rocm-handbook.amd.com/projects/amd-rocm-optimization-guide/en/latest/compiler-builtins/rdna/rdna4-dense-wmma-builtins.html fragment-layouts . For an introduction to these instructions, see How to accelerate AI applications on RDNA 3 using WMMA https://gpuopen.com/learn/wmma on rdna3/ and Using the Matrix Cores of AMD RDNA 4 architecture GPUs https://gpuopen.com/learn/using matrix core amd rdna4/ on GPUOpen. The encodings are in the “RDNA 3” https://www.amd.com/content/dam/amd/en/documents/radeon-tech-docs/instruction-set-architectures/rdna3-instruction-set-architecture.pdf and “RDNA 4” https://www.amd.com/content/dam/amd/en/documents/radeon-tech-docs/instruction-set-architectures/rdna4-instruction-set-architecture.pdf ISA guides, and AMD matrix cores https://rocm.blogs.amd.com/software-tools-optimization/matrix-cores/README.html covers the MFMA instructions that play the same role on CDNA. The Target the-target Every step in the progression is timed against hipBLASLt on the same problem: FP16 GEMM with A row-major and B column-major TN , at M = N = K = 8192 unless noted otherwise. Throughput is reported as 2MNK / t TFLOPS/s, where t is the average kernel time in seconds. The benchmark calls hipBLASLt directly rather than through rocBLAS. The heuristic either returns a solution or the run stops. Both our kernels and the baseline accumulate in FP16 HIPBLAS COMPUTE 16F , matching the WMMA instructions in this post. A request for HIPBLAS COMPUTE 32F would accumulate in FP32 instead. That is more accurate over a long K, but not what is measured here. hipBLASLt at M = N = K = 8192 \ 1\ id12 : | GPU | Avg ms | Avg TFLOPS/s | |---|---|---| | Radeon PRO W7800 gfx1100 | 19.34 | 56.86 | | Radeon 8060S gfx1151 | 28.14 | 39.07 | | Radeon RX 9070 XT gfx1201 | 8.05 | 136.58 | Table 2. hipBLASLt throughput at M=N=K=8192 , the baseline every step is measured against. These figures are the target range. Each optimization step below reports its timing against them. hipBLASLt is tuned per shape and architecture; our kernel uses one configuration across the progression unless a step says otherwise. The peak FP16 throughput is 90.4 TFLOPS/s on the W7800, 59.4 on the 8060S, and 195 on the 9070 XT. hipBLASLt reaches 63% to 70% of that. CU Mode and WGP Mode cu-mode-and-wgp-mode Every kernel in this post runs in CU mode which is enabled at compile time by passing -mcumode as a compiler option. Without it the compiler defaults to WGP mode. With HIP runtime compilation, include -mcumode in the options array passed to hiprtcCompileProgram . The benchmark in the accompanying repository passes the same flag to amdclang++ at build time. We did not benchmark WGP mode, so CU mode is a fixed part of the setup rather than a tuned parameter. On RDNA, two compute units CUs form a work-group processor WGP , each CU holding two SIMD32 units. The mode decides how many of a block’s waves execute together and how much LDS they get: - WGP mode: four waves of a block execute across all four SIMD32s of the WGP, over one contiguous LDS. - CU mode: two waves of a block execute on the two SIMD32s of one CU, over half the WGP LDS. WGP mode can raise occupancy, and can cost performance for kernels relying on atomics or heavy inter-wave communication. CU mode keeps the two CUs independent, which suits workloads that avoid heavy inter-wave communication. Either way a workgroup may share up to 64 KB of LDS, per the “RDNA 3” ISA guide https://www.amd.com/content/dam/amd/en/documents/radeon-tech-docs/instruction-set-architectures/rdna3-instruction-set-architecture.pdf , and that is the budget Table 9 works from. For more detail see CU mode vs WGP mode https://rocm.docs.amd.com/projects/HIP/en/latest/how-to/hip rtc.html cu-mode-vs-wgp-mode in the HIP documentation, and the LDS bank conflict analysis https://rocm-handbook.amd.com/projects/amd-rocm-optimization-guide/en/latest/patterns/examples/matrix-multiply-optimization.html lds-bank-conflict-analysis in the AMD ROCm Optimization Guide, which explains how each mode maps a workgroup onto the LDS banks. Step 1: Starting Simple, One Wave and One Tile step-1-starting-simple-one-wave-and-one-tile Step 1 is the baseline everything else is measured against. One wave owns a 32×32 output tile, a 2×2 grid of 16×16 WMMA tiles held in four accumulators. Each K iteration copies a slice of A and B into LDS, multiplies out of LDS, and repeats. Three bottlenecks dominate the timing: only one wave runs per block, each lane copies one element at a time, and the loop pays two barriers for every 16 values of K. for int k tile = 0; k tile < num k tiles; ++k tile { // One element per lane per step. for int i = lane; i < block m block k; i += wave size { tile a i / block k i % block k = A block row + i / block k K + k tile block k + i % block k ; } // ... the same for B ... syncthreads ; typename Policy::FragmentA a tiles m ; typename Policy::FragmentB b tiles n ; // load a/load b are a memcpy from LDS into a vector register: a fixed address calculation // per architecture see the WMMA section above . for int m = 0; m < tiles m; ++m { Policy::load a a m , &tile a 0 0 , m wmma tile, 0, lane, block k ; } for int n = 0; n < tiles n; ++n { Policy::load b b n , &tile b 0 0 , n wmma tile, 0, lane, block k ; } for int m = 0; m < tiles m; ++m { for int n = 0; n < tiles n; ++n { // Policy::mma calls the architecture's WMMA built-in Table 1 directly, with no // inline assembly. Policy::mma acc m n , a m , b n ; } } // The next iteration overwrites the tiles, so every lane must be done reading. syncthreads ; } tile a m k and tile b n k are both indexed with K last, matching the row-major A and column-major B layouts from earlier. The two copy loops are mirror images of each other. The performance numbers are as follows \ 1\ id12 : | GPU | Avg ms | hipBLASLt ms | |---|---|---| | gfx1100 | 148.26 | 19.34 | | gfx1151 | 334.95 | 28.14 | | gfx1201 | 150.12 | 8.05 | Table 3. Step 1: one wave per block, a 32×32 output tile. That is 150 ms versus the 8 to 28 ms hipBLASLt target. The steps that follow remove the costs above one at a time. Measuring Resources measuring-resources Alongside timings, each step reports the LDS a block needs and the occupancy that follows from it. Occupancy is how many waves a SIMD can keep resident, which the compiler derives from register and LDS demand: amdclang++ -O3 -std=c++17 --offload-arch=gfx1100 -mcumode \ -Rpass-analysis=kernel-resource-usage -c your kernel.hip -o /dev/null The HIP performance guidelines https://rocm.docs.amd.com/projects/HIP/en/latest/how-to/performance guidelines.html cover what occupancy does and does not buy you. The AMD ROCm Optimization Guide goes into improving occupancy https://rocm-handbook.amd.com/projects/amd-rocm-optimization-guide/en/latest/conceptual/introduction/guidelines.html improving-occupancy and works through register pressure and occupancy https://rocm-handbook.amd.com/projects/amd-rocm-optimization-guide/en/latest/patterns/examples/matrix-multiply-optimization.html step-6-register-pressure-and-occupancy in a GEMM kernel. To confirm those figures against what the hardware actually launched, take a rocprofv3 https://rocm.docs.amd.com/projects/rocprofiler-sdk/en/latest/how-to/using-rocprofv3.html kernel trace: rocprofv3 --kernel-trace --output-format csv -d resources -o trace -- ./your benchmark The resulting kernel trace.csv holds one row per kernel dispatch. The LDS Block Size column gives the LDS allocated per block in bytes — divide by 1024 to get kilobytes. It agrees exactly with the compiler’s figure, confirming the occupancy calculation. Step 2: Tiling for Reuse and a Software Pipeline step-2-tiling-for-reuse-and-a-software-pipeline Step 1 reads 16 values of A and B from global memory for every 32 outputs. Step 2 applies four standard GEMM techniques at once to reach a conventional tiled kernel. This is the only step that changes more than one thing at a time. 1. A bigger tile. Each block now computes a 256×256 patch of C through a 4×4 grid of waves. Each wave owns a 4×4 grid of WMMA tiles, so 16 accumulators. The block holds a 256×16 slice of A and a 16×256 slice of B in LDS and reads each element once for 256 outputs instead of once for 32. 2. Wider copies. Each lane moves eight FP16 values per access instead of one, using the full 128-bit load path. 3. Double buffering. Two LDS tiles per operand let the waves multiply out of one buffer while the next K slice copies into the other. 4. A staged commit. The incoming tile waits in registers and reaches LDS only after the current one has been read, which removes the second barrier per K step. The first change sets up the tile hierarchy the rest of the post uses. Figure 2 shows how the block tile divides into wave tiles and WMMA tiles, and which operand slices it reads: Figure 2: What one workgroup owns. The block tile is the patch of C it computes; the operand slices are what it holds in LDS. The four changes define the loop every later step keeps: prefetch, multiply, commit, one barrier. // Two LDS buffers per operand: one is read while the other is filled. LdsTile tile 2 ; int current = 0, next = 1; load tile to lds tile current , / k tile= /0 ; block sync ; for int t = 0; t < num k tiles - 1; ++t { // Issue the next tile's global reads into registers. They have the whole // multiply below to complete in. staged = load tile to registers / k tile= /t + 1 ; multiply from lds tile current , acc ; // Only here do those reads have to have landed. store registers to lds tile next , staged ; block sync ; swap current, next ; } // The loop stops one tile short. The last tile is already in LDS and has // nothing left to overlap with, so it is multiplied on its own. multiply from lds tile current , acc ; Double buffering alone does not fix everything. Without the staged commit, the loop writes the incoming tile to LDS as soon as it lands, which forces the wave to wait on that global read immediately: global load next wait for global load stall: nothing else was issued to overlap it ds write next LDS buffer ds read current LDS buffer wait for ds read WMMA Staging the read in registers until after the current tile has been multiplied moves that wait behind real work instead of in front of it: global load next ds read current LDS buffer wait for ds read WMMA the global load's latency overlaps this wait for global load much of it is already hidden ds write next LDS buffer wait for ds write Timings for the four changes together, against Step 1 \ 1\ id12 : | GPU | Step 1 ms | Avg ms | vs step 1 | hipBLASLt ms | |---|---|---|---|---| | gfx1100 | 148.26 | 36.89 | 4.0x | 19.34 | | gfx1151 | 334.95 | 88.24 | 3.8x | 28.14 | | gfx1201 | 150.12 | 30.25 | 5.0x | 8.05 | Table 4. Step 2: 256×256 output tile, wide copies, double buffering, and a staged commit. The resource report shows what the bigger tile costs. Each wave now accumulates 16 WMMA tiles instead of four, and the two LDS buffers take 32 KB: | | LDS | Waves/SIMD | |---|---|---| | RDNA 3 | 32 KB | 8 | | RDNA 4 | 32 KB | 12 | Table 5. Step 2 resource usage. RDNA 3 keeps fewer waves resident for the same tile because its accumulators are twice as wide. The same kernel and the same tile, but RDNA 3’s wider accumulators raise its register pressure. At 32 KB the LDS budget would allow more waves, so registers bind here and cost RDNA 3 four resident waves. Step 3: Avoiding LDS Bank Conflicts step-3-avoiding-lds-bank-conflicts When every lane reads its WMMA fragment from a different row of an LDS tile, the row stride decides which LDS bank each access hits. LDS has 32 banks of four bytes. If the stride aligns rows to the same banks, those accesses serialize. For the mechanism and both fixes below in detail, see Avoiding LDS Bank Conflicts on AMD GPUs with CK-Tile https://rocm.blogs.amd.com/software-tools-optimization/lds-bank-conflict/README.html and the LDS bank conflict analysis https://rocm-handbook.amd.com/projects/amd-rocm-optimization-guide/en/latest/patterns/examples/matrix-multiply-optimization.html lds-bank-conflict-analysis in the AMD ROCm Optimization Guide. Padding adds unused elements to each row so consecutive rows start in different banks: /// Padded row stride: the fix in this step. constexpr int padded stride = block k + lds pad; // 16 + 8 Permutation keeps the tile the same size and scrambles where each block of elements lands within a row. Two quantities set it up. unit is the run of elements that must stay together, which is Policy::fragment elems : 16 on RDNA 3, 8 on RDNA 4. Anything smaller would split a fragment across two places. blocks is how many such runs fit in a row, block k / unit . Element row, k then moves to block k / unit XOR row % blocks : /// Offset of element row, k once the row's blocks have been permuted. auto swizzle = int row, int k { return row block k + k / unit ^ row % blocks unit + k % unit ; }; At block k = 16 an RDNA 3 fragment is 16 elements, exactly one row, so blocks = 1 and the permutation does nothing on gfx1100. That is why the two timing columns agree there in Table 7. | block k | RDNA 3 blocks | RDNA 4 blocks | |---|---|---| | 16 | 1 | 2 | | 32 | 2 | 4 | | 64 | 4 | 8 | Table 6. Blocks available to permute between, per LDS row. One block means the permutation is the identity. Timings for both layouts \ 1\ id12 : | GPU | Padded ms | Permuted ms | hipBLASLt ms | |---|---|---|---| | gfx1100 | 37.02 | 36.71 | 19.34 | | gfx1151 | 88.90 | 88.08 | 28.14 | | gfx1201 | 30.25 | 30.37 | 8.05 | Table 7. Step 3: padding compared against XOR permutation, at a 256×256 output tile. The resource report shows what padding costs: | | LDS | Waves/SIMD | |---|---|---| | RDNA 3, padded | 48 KB | 8 | | RDNA 3, permuted | 32 KB | 8 | | RDNA 4, padded | 48 KB | 8 | | RDNA 4, permuted | 32 KB | 12 | Table 8. Step 3 resource usage. Padding grows the tile by half, which costs RDNA 4 four resident waves. Padding adds 16 KB of LDS. At 48 KB only one block fits in the 64 KB budget, which drops RDNA 4 from 12 resident waves to 8. Permutation keeps the tile at 32 KB and keeps that occupancy, but does nothing on RDNA 3 at this tile size. Neither fix changes the timing at this tile shape: Table 7’s padded and permuted numbers match Table 4’s 36.89 ms/88.24 ms/30.25 ms to within measurement noise. This is likely because block k is still 16 here, so only one K slice sits in LDS per barrier and there is little concurrent LDS traffic for bank conflicts to affect. We did not confirm that at a deeper block k ; either fix may matter more once Step 5 deepens K. The rest of the post uses padding. Permutation is fragment-size-specific: a different WMMA tile width would need a different permutation pattern, while padding just adds unused columns and works at any width that is a multiple of the fragment size. More importantly, occupancy is not the only lever on performance. Step 5 deliberately trades the 16 KB that padding adds back for a deeper K slice — more arithmetic between global copies — rather than spending it on more resident blocks. Higher occupancy hides latency; deeper K reduces how much latency there is to hide. The numbers show that the K-depth trade is the better one here. Step 4: Buffer Resource Descriptors for Global Loads step-4-buffer-resource-descriptors-for-global-loads Steps 1 through 3 assume every global address a block reads is in bounds. That forces tile dimensions to divide the matrix evenly: a 96-wide strip of A against M = 8192 would read past the end of the allocation in the trailing block. Step 4 switches global loads to buffer resource descriptors. The hardware knows the size of the allocation and returns zero for out-of-bounds addresses instead of faulting: /// A plain untyped buffer whose reads are bounds-checked against the size it is given. /// The field layout is in the ISA guides, under the buffer resource descriptor. constexpr unsigned buffer config word = 0x31004000; /// Loads issued against this descriptor return zero for out-of-bounds addresses instead /// of faulting, which is what allows block tiles that do not divide the matrix evenly. device forceinline amdgpu buffer rsrc t make buffer const void ptr, unsigned bytes { return builtin amdgcn make buffer rsrc const cast