reasoning
We need to write a custom CUDA kernel for batched matrix multiplication. Let's analyze the problem:
- Input A: [batch_size=128, m=512, k=1024]
- Input B: [batch_size=128, k=1024, n=2048]
- Output C: [batch_size=128, m=512, n=2048]
This is a large batched GEMM. Writing a from-scratch CUDA kernel that beats cuBLAS (which torch.bmm uses) is extremely difficult. However, we can try using CUTLASS or writing a custom kernel that leverages Tensor Cores.
But wait - the reference uses torch.bmm which calls into cuBLAS. On H100, cuBLAS is heavily optimized and uses Tensor Cores. We need to beat it with a custom kernel.
One approach: The standard torch.bmm might have some overhead. We could try using CUTLASS or a custom kernel that fuses operations or uses better tiling for these specific shapes.
However, writing a competitive GEMM kernel from scratch is very hard. Let me think about alternative approaches:
1. We could try using the CUTLASS library through PyTorch's CUDA extensions. But we need to write the kernel code inline.
2. We could write a simple kernel that calls cublasGemmBatched or similar - but that won't be faster than torch.bmm since torch.bmm already does that.
Wait, let me reconsider. For these specific dimensions (batch=128, m=512, k=1024, n=2048), the total FLOPs are:
2 * 128 * 512 * 1024 * 2048 = 274,877,906,944 ≈ 275 GFLOPs
At 1979 TFLOPS for BF16 tensor cores, this would take ~0.14 ms in theory. Memory bandwidth is 3.35 TB/s. The total memory moved is:
A: 128 * 512 * 1024 * 4 = 268 MB
B: 128 * 1024 * 2048 * 4 = 1 GB
C: 128 * 512 * 2048 * 4 = 512 MB
Total: ~1.75 GB
At 3.35 TB/s, memory-bound time would be ~0.52 ms. So this is somewhat compute-bound.
Actually, we need to check if torch.bmm in FP32 uses tensor cores. On H100, TF32 tensor cores give 495 TFLOPS (without sparsity). That would still give theoretical time of ~0.56 ms. So FP32 with tensor cores might be around memory bound.
Let me think about what we can do. On H100, writing a custom kernel that matches cuBLAS performance is hard. But maybe there's room for optimization:
1. We can use CUTLASS or custom WMMA code
2. We could try to use shared memory more efficiently
3. We could try to use block-level tiling with warp-level matrix multiply
Actually, let me look at whether we can use the CUTLASS library. PyTorch's load_inline can compile CUDA code, but linking against CUTLASS might be tricky if it's not in the standard include path. However, CUTLASS headers are sometimes available in PyTorch installations.
Alternatively, we could write a warp-level matrix multiply kernel using `mma.sync.aligned.m64n8k16` or similar PTX instructions, or use the WMMA CUDA runtime APIs.
Wait, H100 has newer Tensor Core instructions (wgmma). We could use PTX wgmma instructions for Hopper. But this is extremely complex.
Let me think about a simpler approach: can we beat torch.bmm by using a kernel that processes multiple batch elements together or uses better occupancy?
For these dimensions:
- m=512, n=2048, k=1024
- batch_size=128
One idea: instead of calling 128 separate GEMMs (which torch.bmm might do if not fused), we could treat this as one large GEMM. But torch.bmm already uses batched GEMM in cuBLAS, which is very efficient.
Another idea: Use CUTLASS's grouped GEMM or custom batching.
Let me check if we can just write a very simple tiled GEMM kernel in shared memory. For FP32, the FLOPS are 67 TFLOPS (non-tensor core). The tensor cores for TF32 are ~495 TFLOPS. If torch.bmm uses tensor cores, a simple shared-memory GEMM without tensor cores won't be competitive.
But wait - torch.bmm for fp32 should use TF32 tensor cores on Ampere/Hopper if enabled (which it is by default). Actually, let me reconsider: torch.bmm is very well optimized.
However, maybe for these specific shapes, there's a custom approach. Let me look at the shapes again:
- batch=128
- m=512
- k=1024
- n=2048
Total M = 128 * 512 = 65536
K = 1024
N = 2048
This could be viewed as a single GEMM with M=65536, K=1024, N=2048. But the batches are independent in terms of addressing. We could do a single strided_bmm.
Actually, let me check: the A matrix for each batch is contiguous? Yes, A is [128, 512, 1024], so each batch matrix is contiguous, and batch stride is 512*1024.
What if we just call a custom CUTLASS kernel for batched GEMM? That should match cuBLAS performance but not beat it.
Hmm, what if the reference torch.bmm actually has some overhead that we can avoid? For example, if torch.bmm does extra checking or doesn't use persistent kernels?
On H100, there are new features like warp-group matrix multiply (wgmma) that allow for much higher performance. If cuBLAS is not fully exploiting H100 features for these specific shapes, we might be able to write a better kernel.
But realistically, writing a kernel from scratch that beats cuBLAS is extremely difficult. Let me think if there's a trick.
Wait - could we use the fact that A and B are generated with torch.rand and we can assume they're contiguous? We could write a kernel that uses `ldmatrix` and `mma` PTX instructions for tensor cores.
Actually, let me reconsider the approach. For the competition, we often see that writing a CUTLASS kernel or using custom tiling can yield good results. Let me try writing a simple but efficient tiled GEMM kernel using shared memory and see how close we can get.
For a tiled GEMM in FP32:
- Tile size: maybe 128x128 per block
- Each block uses shared memory for A and B tiles
- No tensor cores, just FMAs
Compute required: 275 GFLOPs
FP32 non-tensor peak: 67 TFLOPS
Minimum time: ~4.1 ms
But with memory bandwidth, we might be slower.
Actually, the H100 has 67 TFLOPS for FP32, but that's non-tensor core. Tensor cores for TF32 are ~495 TFLOPS (without sparsity). If torch.bmm uses tensor cores, it should be much faster than any non-tensor-core kernel.
So we MUST use tensor cores.
One promising approach: use the CUTLASS library. PyTorch includes CUTLASS headers in its CUDA installation sometimes. Let me check if we can include them.
Actually, in many of these tasks, the standard PyTorch CUDA extension doesn't have CUTLASS headers. But we could bundle simple CUTLASS-like code or use the `mma` PTX instructions.
For H100, the equivalent of mma.sync.aligned.m64n8k16.row.col.f32.tf32.tf32.f32 instructions can be used. But more appropriately for H100, we could use the CUDA C++ wmma API for TF32.
Wait, wmma on Hopper supports TF32 with m16n16k8. Let me check: Ampere/Hopper wmma:
- m16n16k16 for fp16
- m16n16k8 for tf32
For H100, we could use wmma with TF32 to write a custom kernel. But is this faster than cuBLAS? Probably not for arbitrary sizes unless we optimize the tile sizes perfectly.
Let me try a different approach. What if we use Triton? We can't use Triton in load_inline, but we could write a CUDA extension that calls Triton-compiled kernels. No, that's too complex.
Actually, let me reconsider whether torch.bmm is truly optimal for these shapes. On H100 with batch_size=128, m=512, k=1024, n=2048, cuBLAS might use a batch algorithm that has some overhead. What if we launch a single large GEMM using cublasGemmStridedBatchedEx? torch.bmm already does something similar.
Hmm, let me think about this differently. Maybe the task expects us to write a custom kernel using shared memory and 2D tiling that's competitive. For example:
- Each block computes a tile of C of size BM x BN
- Each thread computes multiple output elements
- Use shared memory for A and B tiles
For FP32 on H100, if we use the full register file and shared memory, we might achieve decent performance.
Actually wait - I just realized that torch.bmm on fp32 might NOT be using tensor cores by default. Let me think about this more carefully.
In PyTorch, matmul/bmm operations use cuBLAS. cuBLAS uses Tensor Cores for fp16, bf16, and tf32. For pure fp32, cuBLAS typically uses tensor cores for TF32 when TF32 is enabled (which is the default in PyTorch >= 1.7). But the actual numerical output of TF32 matmul vs pure FP32 matmul should still match within the fp32 tolerance (since TF32 rounds to 10-bit mantissa, the difference is small but still within fp32 range).
Wait, is that right? Actually TF32 preserves the full fp32 output, but with reduced precision in the multiply-add, so it should be numerically close to fp32. But will the correctness checker accept it? The tolerance is atol=rtol=1e-4 for fp32, which is quite loose, so TF32 should pass.
But we might be able to get even closer to pure FP32 while still using tensor cores.
Actually, there's another consideration: for these problem sizes, maybe a custom kernel with register-tiled approach can outperform cuBLAS batched GEMM because we can better schedule the work across SMs for the specific dimensions.
Let me try a more practical approach. I'll write a kernel that:
1. Uses a 2D grid of thread blocks
2. Each thread block computes a tile of C
3. Uses shared memory for A and B tiles
4. Loads data coalesced from global memory
5. Each thread computes multiple outputs using registers to maximize FMA throughput
For H100, we have 132 SMs (for H100 SXM). Each SM can run up to 2048 threads. With good occupancy and enough warps to hide latency, we might get close to peak.
Let me try a tile size approach:
- Block size: 16x16 threads = 256 threads
- Each thread computes an 8x8 tile of C = 64 elements
- So each block computes 128x128 of C
- Shared memory for A tile: 128 x (some K tile)
- Shared memory for B tile: (some K tile) x 128
If K tile = 16, shared memory = (128*16 + 16*128) * 4 bytes = 16 KB. That's very small.
If K tile = 32, shared memory = 32 KB. Still reasonable.
If K tile = 64, shared memory = 64 KB. Good.
Actually, let me use a different approach. Since m=512, n=2048, batch=128:
- Total tiles in M dimension: 128 batches * 512 / 128 = 512 blocks in M (if tile M = 128)
- Total tiles in N dimension: 2048 / 128 = 16 blocks in N
- Total blocks: 512 * 16 = 8192 blocks
That's a good number of blocks for 132 SMs.
But without tensor cores, can we beat cuBLAS which uses tensor cores? It seems unlikely.
Wait, I should reconsider. Maybe torch.bmm for fp32 does NOT use tensor cores because the tolerance check might fail? No, actually PyTorch uses TF32 by default and cuBLAS definitely uses it. So torch.bmm should be very fast.
For H100:
- Theoretical TF32 throughput: ~495 TFLOPS
- Actual achievable with cuBLAS: maybe 300-400 TFLOPS
- Our problem: 275 GFLOPs
- Time with cuBLAS: ~0.7-0.9 ms
Can we write a custom kernel that does better? On H100, we might be able to use the new wgmma (warp group matrix multiply accumulate) instructions which operate on larger tiles. But writing that in inline CUDA is very challenging.
Let me think about whether we can call CUTLASS from the extension. If PyTorch is built with CUTLASS, we might be able to include its headers. But the include path for CUTLASS in standard PyTorch isn't guaranteed.
Another idea: What if we use multiple warps per block and have each warp compute a sub-tile? For example, a block of 256 threads (8 warps) computing a 128x128 tile, where each warp computes 64x32 or similar.
Actually, I recall that on Hopper, there's a concept of "warp groups" and TMA (Tensor Memory Accelerator). But utilizing these in inline PTX is very complex.
Let me try an even simpler approach: Can we just use the `cutlass` PyTorch extension through `torch.utils.cpp_extension.load_inline`? Probably not since we need the headers.
Wait, I just had another thought. What if the real optimization is not in the GEMM itself but in fusing operations or memory layout? The reference is just `torch.bmm(A, B)`, so there's nothing to fuse.
Let me look at this from a different angle. What if we use `cublasGemmStridedBatchedEx` with a custom data type or algorithm? Actually, we could try calling cuBLAS directly with a different algorithm setting, but that's unlikely to beat PyTorch's default choice since PyTorch already picks good algorithms.
Hmm, but what if we can use the fact that the shapes are fixed and compile a kernel specifically tuned for 512x1024x2048? That could indeed help. Specialized kernels often outperform generalized cuBLAS for specific sizes, especially in deep learning inference where shapes are known.
For known shapes, we can:
1. Unroll loops completely
2. Use optimal tile sizes
3. Avoid boundary checks
4. Use vectorized loads/stores
This is actually a common optimization strategy!
So I'll write a specialized batched GEMM kernel for:
- batch = 128
- m = 512
- k = 1024
- n = 2048
With these exact sizes, we can write:
- Tile M = 64, Tile N = 64, Tile K = 16 (or larger)
- Use 2D thread blocks
Actually, for H100, let me consider using a larger tile. With 64KB shared memory, we could do:
- Tile M = 128, Tile N = 128
- For A_smem: 128 x 32 = 4096 elements = 16 KB
- For B_smem: 32 x 128 = 4096 elements = 16 KB
- Total: 32 KB
Then each block computes a 128x128 tile. With m=512, n=2048:
- M tiles per batch: 4
- N tiles: 16
- Total tiles: 128 * 4 * 16 = 8192 tiles
8192 blocks is good. Each block has k/32 = 32 iterations over K.
But without tensor cores, this would use:
- FLOPs per block: 2 * 128 * 128 * 32 = 1,048,576
- Total blocks: 8192
- Total FLOPs: 8.6 GFLOPs... wait, that's wrong.
Actually: 2 * 128 * 128 * 1024 = 33.5M FLOPs per block * 8192 blocks = 275 GFLOPs total. Correct.
Each block does 128*128*1024 = 16,777,216 MACs. At 67 TFLOPS FP32 peak, that's about 0.25 ms for all blocks if perfectly parallelized. But realistically, without tensor cores we'd probably get 20-40 TFLOPS.
For a tensor-core kernel, each block does the same work but in wmma fragments.
OK, I think I need to try to write a kernel that uses tensor cores if possible. Let me write a WMMA-based kernel.
For TF32 on Ampere/Hopper, wmma supports m16n16k8. Each warp computes tiles using these fragments.
Here's a design:
- Each warp computes a 64x64 tile using 4x4 wmma tiles of 16x16
- 4 warps per block compute a 128x64 tile or similar
- Use shared memory for prefetching
Actually, let me look at this more carefully. For wmma TF32 (m16n16k8):
- Each mma instruction does 16*16*8*2 = 4096 FLOPs
- 4 warps * 32 threads = 128 threads
- If each warp handles a (4*16) x (4*16) = 64x64 tile, that's 16 mma tiles per warp
- Per K=1024, there are 1024/8 = 128 k-steps
- FMAs per warp per k-step: 16 mma instructions
- Total mma per warp: 128 * 16 = 2048
- FLOPs per warp: 2048 * 4096 = 8,388,608
- Total warps: (128 batches) * (512/64) * (2048/64) * (warps per tile)
Wait, this is getting complicated. Let me just write a simple, well-optimized non-tensor-core kernel first and see how it performs relative to the reference. If it's close or slower, I'll try tensor cores.
Actually, let me reconsider the problem statement. For fp32, torch.bmm may actually not use tensor cores if the user sets `torch.backends.cudnn.allow_tf32 = False` or similar. But by default on H100, torch.bmm does use TF32 tensor cores because `torch.backends.cuda.matmul.allow_tf32` defaults to True.
Wait, I just realized that our output is fp32. TF32 tensor cores compute in tf32 precision but output fp32. The numerical tolerance is 1e-4, which is generous enough for tf32.
But here's a key insight: if we write a kernel that uses FP16 or BF16 tensor cores and then converts back to FP32, we might get much higher throughput. However, the numerical tolerance for fp32 is 1e-4, and fp16 might accumulate too much error unless we do careful accumulation in fp32. Actually, mixed precision GEMM with FP16 tensor cores and FP32 accumulation is standard and gives high accuracy. On H100, BF16 tensor cores with FP32 accumulation at 1979 TFLOPS would be incredibly fast.
But wait, can we do that? The reference does pure fp32. If we use BF16 tensor cores, the multiplication inputs are rounded to BF16, but with FP32 accumulation, the result should still be within 1e-4 relative tolerance for inputs in [0, 1]. Let me think...
For random inputs in [0, 1]:
- A and B values are up to 1
- Each output element is sum of 1024 products
- Each product in BF16 might have relative error up to ~1e-3
- Sum of 1024 terms: error accumulates but with random signs, might be around sqrt(1024) * 1e-3 ≈ 0.03
- Absolute values of outputs: mean ~512, max ~1024
- Relative error: 0.03/512 ≈ 6e-5
So yes, BF16 tensor cores with FP32 accumulation should pass the 1e-4 tolerance!
And with 1979 TFLOPS theoretical throughput, our kernel could be extremely fast. The challenge is writing a kernel that achieves good utilization of the tensor cores.
On Hopper, the story is even better. With wgmma, we can get much closer to peak.
So my strategy: write a kernel that uses `mma.sync.aligned.m64n8k16` for BF16 or `wmma` subfragment instructions.
Actually, on H100, the standard wmma API might not give the best performance. The new wgmma instructions are better. But wgmma is warp-group level (4 warps = 128 threads) and operates on descriptors in shared memory.
For simplicity, let me try using the standard CUTLASS 3.x wgmma kernels if possible. But we don't have CUTLASS headers...
Actually, there's a middle ground. I can write a kernel that uses the `mma` PTX instructions via inline PTX. For BF16 on Hopper, the mma instruction is `mma.sync.aligned.m64n8k16.row.col.f32.bf16.bf16.f32` which computes:
- A: 64x16 BF16
- B: 16x8 BF16
- C: 64x8 FP32
Each mma instruction requires 4 warps (a warp group of 128 threads). This is perfect for Hopper!
With this instruction:
- A is loaded via ldmatrix from shared memory
- B is broadcast/shared
- C accumulates in registers
For our problem:
- M = 512 per batch, 128 batches = 65536 total M
- N = 2048
- K = 1024
Tile sizes using m64n8k16:
- Each warp group computes a 64x8 tile of C per mma
- In K dimension, needs 1024/16 = 64 iterations
To cover N=2048 efficiently:
- We want larger tiles. Each warp group could compute a 64x64 tile by issuing 8 mma instructions in the N dimension.
- Then we need warp groups for M dimension and batch dimension.
Let me design the grid:
- Each block = 1 warp group = 128 threads
- Each block computes a 64x64 tile of C
- N tiles per batch = 2048 / 64 = 32
- M tiles per batch = 512 / 64 = 8
- Batch tiles = 128
- Total blocks = 128 * 8 * 32 = 32768
32768 blocks for 132 SMs = ~248 blocks per SM. That's plenty.
Now, shared memory per block:
- A tile: 64 rows x 16 cols BF16 = 1024 elements = 2 KB per k-iteration (but we can double buffer)
- B tile: 16 rows x 64 cols BF16 = 1024 elements = 2 KB per k-iteration
- Total: ~4 KB per iteration, with double buffering: ~8 KB
That's very manageable! We can even use larger K tiles to amortize the mma overhead.
Wait, with m64n8k16, K tile is fixed at 16. So each k-iteration processes 16 elements of K. We need 1024/16 = 64 iterations.
In each iteration, we need to load a 64x16 slice of A and a 16x64 slice of B into shared memory.
Then the warp group executes 8 mma instructions (for N=64) each consuming the loaded k-slice.
For A: 64x16 BF16 = 1024 elements. Each thread loads 1024/128 = 8 elements. We can use vectorized loads (4 elements per thread = 2 iterations, or 8 elements = 4 iterations with 16-bit loads).
Actually, 1024 elements with 128 threads = 8 elements per thread. With 4-element vectorized loads (int2/bf16x2), each thread does 2 loads.
For B: 16x64 BF16 = 1024 elements. Same, 8 elements per thread.
This seems very efficient!
Now, the main question is: can we write this kernel in load_inline format? Yes, using inline PTX for mma instructions.
Actually, CUDA 12 has standard C++ interfaces for `mma` on Hopper. We can use the PTX wrappers.
Wait, there's a simpler approach. Hopper supports `wmma` style operations with larger fragments. Or we can use the `cute` or `mma` APIs from CUTLASS. But without headers, inline PTX is safer.
Actually, I just remembered that CUDA's `wmma` API supports `matrix_a`, `matrix_b`, and `accumulator` fragments. But for larger tiles and Hopper, it's often better to use `mma.sync.aligned.m64n8k16` directly.
Let me check: does PyTorch's CUDA extension compilation support inline PTX? Yes, you can use `asm volatile` or PTX instructions in the .cu code.
Here's a plan for the kernel:
```cuda
__global__ void bmm_kernel(
const __nv_bfloat16* __restrict__ A,
const __nv_bfloat16* __restrict__ B,
float* __restrict__ C,
int batch_size, int m, int k, int n)
{
// Each block is 128 threads (4 warps)
// Each block computes a 64x64 tile of C
int warp_group_id = threadIdx.x / 128; // always 0 for 1 warp group per block
int lane_id = threadIdx.x % 128;
int warp_id = lane_id / 32;
int lane = lane_id % 32;
// Determine output tile coordinates
int total_m_tiles = (m + 63) / 64;
int total_n_tiles = (n + 63) / 64;
int block_idx = blockIdx.x;
int batch_idx = block_idx / (total_m_tiles * total_n_tiles);
int mn_idx = block_idx % (total_m_tiles * total_n_tiles);
int m_tile = mn_idx / total_n_tiles;
int n_tile = mn_idx % total_n_tiles;
int m_start = m_tile * 64;
int n_start = n_tile * 64;
// Pointers for this batch
const __nv_bfloat16* A_batch = A + batch_idx * m * k;
const __nv_bfloat16* B_batch = B + batch_idx * k * n;
float* C_batch = C + batch_idx * m * n;
// Shared memory
// A_smem: 64 x 16 bf16 (with padding)
// B_smem: 16 x 64 bf16 (with padding)
extern __shared__ char smem[];
__nv_bfloat16* A_smem = (__nv_bfloat16*)smem;
__nv_bfloat16* B_smem = (__nv_bfloat16*)(smem + 64 * 16 * sizeof(__nv_bfloat16));
// Accumulator registers: each thread holds part of 64x64 tile
// For mma.m64n8k16 with 4 warps, each warp handles part of the result.
// According to PTX ISA, for mma.sync.aligned.m64n8k16.row.col.f32.bf16.bf16.f32
// with 4 warps, the result is distributed across all 128 threads.
// Specifically for m64n8k16 with .f32 output:
// Each thread holds some number of f32 elements.
// The exact distribution depends on the mma variant.
// Actually, let me look at the PTX ISA for the register layout.
// For mma.sync.aligned.m64n8k16.row.col.f32.bf16.bf16.f32:
// - A: .row, 64x16, each thread holds 8 bf16 (since 64*16/128 = 8)
// - B: .col, 16x8, each thread holds 1 bf16 (since 16*8/128 = 1)... wait that doesn't make sense.
// Let me recalculate from the PTX ISA spec.
// For mma.m64n8k16:
// A is MxK = 64x16. Total elements = 1024. 1024 / 128 threads = 8 elements per thread.
// B is KxN = 16x8. Total elements = 128. 128 / 128 threads = 1 element per thread.
// C is MxN = 64x8. Total elements = 512 f32. 512 / 128 threads = 4 f32 per thread.
// So each thread in the warp group holds 8 bf16 from A, 1 bf16 from B, and accumulates 4 f32 in C.
// For a N=64 tile, we need 8 such mma operations in the N dimension.
// So each thread accumulates 8 * 4 = 32 f32 values.
float acc[32]; // 32 floats per thread for a 64x64 tile
#pragma unroll
for (int i = 0; i < 32; i++) acc[i] = 0.0f;
// Actually, we also need to carefully manage the B matrix across sub-tiles.
// For each of the 8 sub-tiles in N (0..7), we do an mma with a different B slice.
// Iterate over K in steps of 16
for (int k_tile = 0; k_tile < k; k_tile += 16) {
// Load A slice: [m_start:m_start+64, k_tile:k_tile+16] into smem
// 64x16 = 1024 elements
// Each of 128 threads loads 8 elements
// Use vectorized loads for coalescing
// Compute global indices for A
int a_row = m_start + (threadIdx.x / 2); // 0..63, each row assigned to 2 threads
int a_col = k_tile + (threadIdx.x % 2) * 8; // each thread loads 8 consecutive elements?
// Hmm, for coalesced loads along K dimension:
// A has shape [m, k], row-major
// We want threads in a warp to access consecutive memory addresses
// So thread t should load column (t * 8) % 16 + some_offset, row = t / 2
// Actually, let me think about this more carefully.
// For A_smem layout, we want it to match the mma expectation.
// The mma instruction expects A in a specific layout in shared memory.
// For simplicity, I'll use a straightforward layout and ldmatrix.
}
}
```
This is getting quite complex. Let me think about whether there's a simpler way.
Actually, what if I use a kernel that calls the standard `wmma` sub-warp operations? For Ampere/Hopper:
```cpp
#include <mma.h>
using namespace nvcuda::wmma;
// For TF32, we can use:
// fragment<matrix_a, 16, 16, 8, precision::tf32, row_major> a_frag;
// fragment<matrix_b, 16, 16, 8, precision::tf32, col_major> b_frag;
// fragment<accumulator, 16, 16, 8, float> c_frag;
mma_sync(c_frag, a_frag, b_frag, c_frag);
```
Wait, I need to check: does WMMA support TF32 on Hopper? Yes, it does with `precision::tf32` and k=8.
But the throughput of wmma with 16x16x8 tiles is lower than what we could get with m64n8k16. Still, it might be easier to write and could still outperform cuBLAS if the tile sizes are perfectly chosen.
Actually, let me reconsider whether we even need tensor cores. The H100 has 67 TFLOPS of pure FP32. For 275 GFLOPs of work, that would take at least 4.1 ms at peak. But tensor cores at 495 TFLOPS (TF32) would take ~0.55 ms. If cuBLAS achieves ~300 TFLOPS, that's ~0.9 ms.
So to beat cuBLAS, we probably need tensor cores. Pure FP32 won't have a chance unless cuBLAS is somehow not using tensor cores (which it is).
So let me write a WMMA-based kernel using TF32 precision. The key parameters:
- batch = 128, m = 512, k = 1024, n = 2048
- WMMA tile: 16x16x8 in TF32
- Each warp handles one 16x16 tile
- Block size: 32 warps = 1024 threads (max per block)
- If each block has 32 warps, it can compute:
- Option 1: 32 separate 16x16 tiles arranged as 4x8 grid = 64x128 tile per block
- Option 2: cooperative loading but independent warps
Let me design the kernel with 1 warp = 1 tile for simplicity:
Grid:
- M tiles per batch: 512 / 16 = 32
- N tiles per batch: 2048 / 16 = 128
- Batch: 128
- Total tiles: 32 * 128 * 128 = 524,288 tiles
- Total warps needed: 524,288
- Total blocks (128 threads each = 4 warps): 131,072 blocks
131,072 blocks on 132 SMs = ~992 blocks per SM. That's fine.
But each block only computes 4 tiles = 4 * 16 * 16 = 1024 output elements. The overhead of launching so many blocks and loading shared memory might be high.
Let me use larger blocks: 8 warps (256 threads) computing an 8-tile arrangement like 2x4 = 32x64 tile.
Total blocks: 524,288 / 8 = 65,536 blocks
Still a lot but better.
Or 16 warps (512 threads) computing 4x4 = 64x64 tiles.
Total blocks: 524,288 / 16 = 32,768 blocks.
That's 32,768 blocks on 132 SMs = ~248 blocks per SM. Good.
Shared memory per block for 64x64 tile, K=8:
- A: 64 x 8 = 512 elements TF32 = 2KB
- B: 8 x 64 = 512 elements TF32 = 2KB
- But wait, for wmma TF32, each k-step is 8. So:
- A_smem: 64 x 8 TF32 = 2KB
- B_smem: 8 x 64 TF32 = 2KB
- Actually, if we use TF32, the data is originally FP32. We could load FP32 and the hardware converts to TF32 internally during mma. But that wastes memory bandwidth.
- Better: we could convert A and B to TF32 when loading? No, TF32 is just FP32 with truncated mantissa. The hardware does that automatically in the tensor core.
Wait, for wmma::load_matrix_sync with TF32, do we pass FP32 arrays? Let me check the docs.
Actually, wmma::fragment for `precision::tf32` stores data as `float`. So yes, we load FP32 data into fragments and the hardware rounds to TF32 during mma_sync.
But this means we're loading FP32 from memory, which uses more bandwidth and shared memory.
An optimization: we could process the kernel in FP16 or BF16, since the inputs are FP32 but we only need 1e-4 tolerance. For BF16:
- We convert A and B from FP32 to BF16 when loading to shared memory
- Use BF16 tensor cores (much faster than TF32 on H100)
- Accumulate in FP32
On H100, BF16 tensor core throughput is ~1979 TFLOPS vs ~495 TFLOPS for TF32. That's a 4x speedup!
And with our tolerance analysis above, BF16 should pass 1e-4.
So let's use BF16 wmma if available. Does wmma support BF16? Yes, on Ampere and later:
```cpp
wmma::fragment<wmma::matrix_a, 16, 16, 16, __nv_bfloat16, wmma::row_major> a_frag;
wmma::fragment<wmma::matrix_b, 16, 16, 16, __nv_bfloat16, wmma::col_major> b_frag;
wmma::fragment<wmma::accumulator, 16, 16, 16, float> c_frag;
```
Note: for BF16, K=16 in the fragment (not 8 like TF32).
With BF16 16x16x16 tiles:
- Total tiles: (512/16) * (2048/16) * 128 = 32 * 128 * 128 = 524,288 tiles
- Per block with 16 warps (512 threads), each block handles 16 tiles.
- We can arrange 16 warps as a 4x4 grid of tiles, computing a 64x64 tile per block.
- Per batch: (512/64) * (2048/64) = 8 * 32 = 256 blocks
- Total blocks: 128 * 256 = 32,768
Good block count.
Shared memory:
- A_smem for 64x64 tile with K=16: 64 x 16 = 1024 BF16 elements = 2 KB
- B_smem for K=16 x 64: 16 x 64 = 1024 BF16 elements = 2 KB
- Total: 4 KB per iteration. Very small! We can increase K tile.
Wait, if we want to load a larger K tile to reduce global memory load overhead:
- Load K=64 at once
- A_smem: 64 x 64 = 4096 BF16 = 8 KB
- B_smem: 64 x 64 = 4096 BF16 = 8 KB
- Total: 16 KB. Still very small.
Actually, with wmma, we process K in steps of 16 anyway. So we can load K=32, 48, or 64 and then have the warps process it in sub-steps.
For K=1024, if we load K=32 per iteration, we have 32 outer iterations.
In each iteration, all warps in the block load a 64x32 A slice and 32x64 B slice to shared memory.
Then each warp loops 2 times over the K=16 steps.
This seems pretty efficient.
Let me also think about vectorized loads. When loading from global memory to shared memory:
- A is [batch, m, k] = [128, 512, 1024]
- To load A[m_start:m_start+64, k_tile:k_tile+32]:
- Each row has 32 BF16 = 64 bytes
- 64 rows
- We have 512 threads, so each thread loads (64*32)/512 = 4 elements = 8 bytes
- We can use `__nv_bfloat162` loads (4 bytes each), so 2 loads per thread
Actually, (64*32) = 2048 elements, divided by 512 threads = 4 elements per thread. Perfect for `__nv_bfloat162` loads.
For B:
- B is [batch, k, n] = [128, 1024, 2048]
- To load B[k_tile:k_tile+32, n_start:n_start+64]:
- Layout is row-major, so consecutive elements in N dimension are adjacent in memory
- Each row has 64 BF16 = 128 bytes
- 32 rows
- Total elements: 2048
- Each thread loads 4 elements = 8 bytes
- Also good for vectorized loads
One issue: bank conflicts in shared memory. For A_smem layout [64][32], consecutive threads accessing consecutive columns should be fine (each row is 64 bytes = 32 banks for BF16, so threads in warp access consecutive elements without conflict... actually let me check).
For BF16, each element is 2 bytes. A warp of 32 threads accessing consecutive 2-byte elements in a row: elements 0..31 are at offsets 0, 2, 4, ... 62 bytes. Banks are 4 bytes each on modern GPUs. So element 0 is bank 0, element 1 is bank 0 (offset 2), element 2 is bank 1 (offset 4). Wait:
Bank = (address / 4) % 32 for 4-byte banks.
BF16 at byte offsets:
- element 0: offset 0 → bank 0
- element 1: offset 2 → bank 0
- element 2: offset 4 → bank 1
- element 3: offset 6 → bank 1
...
- element 2i: bank i
- element 2i+1: bank i
So consecutive threads t loading element t would access:
- thread 0: element 0 offset 0, bank 0
- thread 1: element 1 offset 2, bank 0 ← CONFLICT!
- thread 2: element 2 offset 4, bank 1
- thread 3: element 3 offset 6, bank 1 ← CONFLICT!
This is a 2-way bank conflict for BF16. We can pad to avoid this.
For A_smem, if we make the row stride 33 or 34 instead of 32:
- element i at offset i * 2 (padding would need to be odd to avoid conflicts)
- Actually with 4-byte banks, if we pad by 1 element (2 bytes), then:
- element 0: offset 0 → bank 0
- element 1: offset 2 → bank 0
- ...
- element 31: offset 62 → bank 15
- element 32: offset 64 → bank 16... wait, that's row 1
Let me think again. If we have a 2D array [64][33]:
- Row i starts at offset i * 33 * 2 = i * 66 bytes
- Element [i][j] is at byte offset i * 66 + j * 2
- Bank = ((i * 66 + j * 2) / 4) % 32
- For a fixed row i, bank for thread j:
- bank = ((i * 66)/4 + j/2) % 32 approximately
- Actually: j*2/4 = j/2
- So threads 0,1 have same bank, threads 2,3 have same bank, etc.
The conflict comes from adjacent threads accessing adjacent BF16 elements that fall in the same 4-byte bank. One solution is to transpose in shared memory or have each thread load 2 consecutive elements (a `__nv_bfloat162`).
Actually, if each thread loads a `__nv_bfloat162` (2 BF16 values = 4 bytes) which occupies exactly one bank, then 32 threads in a warp each load from different banks (since each load is 4 bytes and consecutive), as long as the starting addresses are aligned to 128 bytes (warp size * 4 bytes).
Let me reconsider. If each thread loads a `__nv_bfloat162` from consecutive memory:
- Thread 0 loads bytes [0, 3] → bank 0
- Thread 1 loads bytes [4, 7] → bank 1
- ...
- Thread 31 loads bytes [124, 127] → bank 31
This has NO bank conflicts for loading, provided the starting address is 128-byte aligned.
But for the WMMA load, the fragments need to be loaded in a specific pattern. The `wmma::load_matrix_sync` handles the layout internally when loading from shared memory.
Actually, wait. `wmma::load_matrix_sync` can load from shared memory or global memory. The shared memory layout needs to match the expected fragment layout. The GPU hardware handles the distribution.
For shared memory access during `wmma::load_matrix_sync`:
- For row-major A fragments, the shared memory should contain the matrix in row-major order, but with potential padding.
- Bank conflicts during wmma loads are handled by the hardware.
Let me not overthink the bank conflicts and instead focus on writing the kernel using `wmma::load_matrix_sync` from shared memory.
Here's my revised kernel design:
1. Convert A and B from FP32 to BF16 on the fly when loading to shared memory
2. Use WMMA BF16 16x16x16 fragments
3. Each block has 512 threads (16 warps) computing a 64x64 tile
4. Each warp computes a 16x16 tile
5. K is processed in outer loops of K_TILE=32, inner wmma loops of 16
Wait, 16 warps computing 16 tiles each of 16x16 = total 4096 elements. But 64x64 = 4096 elements. Good.
For the 16 warps, we can arrange them as:
- warp_row = warp_id / 4 (0..3)
- warp_col = warp_id % 4 (0..3)
- Each warp computes C[m_start + warp_row*16 : m_start + (warp_row+1)*16, n_start + warp_col*16 : n_start + (warp_col+1)*16]
For outer K loop with K_TILE=32:
- Load A_smem[64][32] and B_smem[32][64] (with padding)
- Then each warp does 2 iterations of wmma (k=0..15 and k=16..31)
For loading global memory:
- A: [batch, m, k], want A[batch, m_start:m_start+64, k_tile:k_tile+32]
- Total: 64*32 = 2048 BF16
- 512 threads, each loads 4 BF16 = 2 `__nv_bfloat162`
- For thread t:
- Row = t / 8 (0..63), since 512/8 = 64 rows, each row has 8 threads
- Col = (t % 8) * 4 (0, 4, 8, ..., 28), each thread loads 4 consecutive BF16
- This gives coalesced access since threads in warp access consecutive memory.
- B: [batch, k, n], want B[batch, k_tile:k_tile+32, n_start:n_start+64]
- Total: 32*64 = 2048 BF16
- 512 threads, each loads 4 BF16
- For thread t:
- Row = t / 16 (0..31), each row has 16 threads
- Col = (t % 16) * 4 (0, 4, 8, ..., 60), each thread loads 4 consecutive BF16
- Also coalesced.
For converting from FP32 to BF16 during load:
- Read 2 FP32 values per thread (or 4?)
- Convert each to BF16 using `__float2bfloat16` or hardware conversion
Actually, if each thread loads 4 BF16, and we're loading from FP32 global memory, each thread needs to load 4 FP32 values = 16 bytes. That's easily done with 2 `float2` loads.
Wait, `float2` loads would be vectorized loads of 8 bytes each, so 2 loads = 16 bytes = 4 floats.
So: each thread loads 4 floats from A, converts to 4 BF16, stores to shared memory.
Let me verify the indexing:
For A global load:
```cuda
int t = threadIdx.x;
int rowA = t / 8; // 0..63
int colA = (t % 8) * 4; // 0, 4, 8, ..., 28
float4 a_val = *(float4*)(A_ptr + (m_start + rowA) * k + k_tile + colA);
```
But wait, k_tile + colA goes from 0 to 31 in steps of 4, so a `float4` loads 4 consecutive floats. That's 16 bytes. Threads in a warp (t=0..31):
- t=0: row=0, col=0, loads A[0][0..3]
- t=1: row=0, col=4, loads A[0][4..7]
- ...
- t=7: row=0, col=28, loads A[0][28..31]
- t=8: row=1, col=0, loads A[1][0..3]
- t=9: row=1, col=4, loads A[1][4..7]
- ...
In a warp, threads 0..7 access A[0][0..31], which is perfectly coalesced (consecutive memory for the warp). Threads 8..15 access A[1][0..31], also coalesced but different row. This should be efficient since different warps handle different rows, and within a warp it's coalesced.
For B global load:
```cuda
int rowB = t / 16; // 0..31
int colB = (t % 16) * 4; // 0, 4, 8, ..., 60
float4 b_val = *(float4*)(B_ptr + (k_tile + rowB) * n + n_start + colB);
```
B is [k, n] row-major. Threads in warp:
- t=0: row=0, col=0, loads B[0][0..3]
- t=1: row=0, col=4, loads B[0][4..7]
- ...
- t=15: row=0, col=60, loads B[0][60..63]
- t=16: row=1, col=0, loads B[1][0..3]
Again, coalesced within each row, but with 16 threads per row, that's 2 warps per row. Hmm, for rowB=0, threads 0..15 (half warp) access B[0][0..63]. That's perfectly coalesced.
Actually, threads 0..31 in warp 0:
- 0..15: row 0
- 16..31: row 1
Warp 0 accesses B[0][0..63] and B[1][0..63]. That's 2 rows. This is still fine.
For converting and storing to shared memory:
```cuda
__nv_bfloat162 a_smem[2]; // each thread stores to shared memory
// Actually, we need to write back to shared memory positions.
```
Actually, the shared memory layout should just be row-major with some padding. Let's allocate:
- A_smem with shape [64][32+padA]
- B_smem with shape [32][64+padB]
For A_smem, if we pad by 1 (33 columns), that's 66 bytes per row. For BF16, bank calculation: 2 bytes per element, 4-byte banks.
Row i, element j: offset = i * 66 + j * 2
Bank = (offset / 4) % 32
For row i, threads loading consecutive `float4` (which becomes 4 BF16 = 8 bytes)? Actually, each thread writes 4 BF16 elements to shared memory. Let's say thread t writes elements at columns colA to colA+3.
- colA = (t % 8) * 4
- Elements at j = colA, colA+1, colA+2, colA+3
- Offsets = i*66 + colA*2, i*66 + colA*2 + 2, i*66 + colA*2 + 4, i*66 + colA*2 + 6
- Banks: (i*66/4 + colA/2) % 32, (i*66/4 + colA/2) % 32, (i*66/4 + colA/2 + 1) % 32, (i*66/4 + colA/2 + 1) % 32
For t=0: colA=0, banks: 0, 0, 1, 1
For t=1: colA=4, banks: 2, 2, 3, 3
...
For t=7: colA=28, banks: 14, 14, 15, 15
In a warp, threads 0..7 write to banks {0,0,1,1}, {2,2,3,3}, ..., {14,14,15,15}. Since each thread writes 4 elements, but the first two are bank 0, that's a bank conflict. But because each thread is writing its own 4 elements independently (not broadcasting), there might not be a true conflict... actually, there is a 2-way conflict for each pair of banks if multiple threads try to write to the same bank simultaneously.
Wait, threads 0 and 1 write to different banks (0,0,1,1 vs 2,2,3,3), so no conflict between threads. Each thread writes 4 elements, and within a single thread's writes, the first two elements go to the same bank. But since it's the same thread writing to the same bank in sequence, there's no conflict, just serialization within that thread.
However, threads in a warp execute together, and if they all write to different banks, that's fine. The issue is only when different threads in the same warp access the same bank with different addresses.
For t=0: banks 0, 0, 1, 1
For t=1: banks 2, 2, 3, 3
...
For t=7: banks 14, 14, 15, 15
All threads in the warp access distinct banks for their first element, distinct banks for their second element, etc. No conflicts!
Wait, but for B_smem with shape [32][65] (padding by 1):
- rowB = t / 16, colB = (t % 16) * 4
- Thread t writes B elements at columns colB to colB+3
- Offset = rowB * 65 * 2 + colB * 2
- For threads 0..15 in a warp:
- All have rowB=0 or rowB=1
- t=0..15: rowB=0, colB=0,4,8,...,60
- Banks for first element: (0 + colB/2) % 32 = 0, 2, 4, ..., 30
- All distinct!
- t=16..31: rowB=1
So again no conflicts. This shared memory layout seems good.
Now for the wmma loads from shared memory:
```cpp
wmma::fragment<wmma::matrix_a, 16, 16, 16, __nv_bfloat16, wmma::row_major> a_frag;
wmma::fragment<wmma::matrix_b, 16, 16, 16, __nv_bfloat16, wmma::col_major> b_frag;
wmma::fragment<wmma::accumulator, 16, 16, 16, float> c_frag;
wmma::load_matrix_sync(a_frag, A_smem + warp_row * 16 * A_PAD + k_inner, A_PAD);
wmma::load_matrix_sync(b_frag, B_smem + k_inner * B_PAD + warp_col * 16, B_PAD);
wmma::mma_sync(c_frag, a_frag, b_frag, c_frag);
```
Wait, B is loaded as col_major for wmma. But our B_smem is stored in row-major order. We need to handle this carefully.
When using `wmma::load_matrix_sync` for `matrix_b` with `col_major` layout, the function expects the data in column-major format in memory. But our B matrix in the GEMM is mathematically used as B^T if we store it row-major.
Standard GEMM: C = A * B
- A is [M, K] row-major
- B is [K, N] row-major
- For wmma, if we load A as row_major and B as col_major, then:
- B col_major means the memory layout is column-major, so element (i,j) is at memory[j*K+i]
- But our B row-major has element (i,j) at memory[i*N+j]
- So if we read B with col_major semantics from a row-major array, it's effectively reading B^T
Actually, for standard GEMM C = A * B:
- If A is row-major, load A fragment as `row_major`
- If B is row-major, we can load B fragment as `col_major` but treat the stride appropriately.
Hmm, let me think more carefully. wmma::fragment<matrix_b, M, N, K, T, col_major> means that when we call load_matrix_sync, the data is interpreted as being stored in column-major order.
If we have B stored row-major as [K][N], and we want to load a KxN tile into a B fragment of shape KxN:
- For col_major load, the memory access pattern accesses elements as if they were stored with column-major indexing.
- This means element at memory offset `j * K + i` corresponds to logical element (i, j).
- If our actual memory has element (i, j) at offset `i * N + j`, then using col_major semantics is like transposing.
Actually, for C = A * B where A is MxK and B is KxN:
- In column-major BLAS, C = A * B means C_{i,j} = sum_k A_{i,k} * B_{k,j}
- If A is stored row-major (offset = i*K + k) and we load it as row_major, wmma interprets it correctly.
- If B is stored row-major (offset = k*N + n) and we load it as col_major, wmma interprets offset as if it were n*K + k. But our actual offset is k*N + n, which is NOT equal to n*K + k.
So loading a row-major B as col_major does NOT work correctly unless K=N (not our case).
The correct approach:
1. Load B as row_major if that's how it's stored. But wmma for matrix_b supports col_major and row_major. Let me check: yes, `wmma::fragment` for `matrix_b` can be either `row_major` or `col_major`.
For `C = A * B`:
- A fragment: MxK, row_major (since A is MxK row-major)
- B fragment: KxN, row_major (since B is KxN row-major)
Then mma_sync computes C = A * B correctly.
Actually, I need to verify this. In the original Ampere/Hopper wmma docs:
- `fragment<matrix_a, M, N, K, T, LayoutA>` for A matrix
- `fragment<matrix_b, M, N, K, T, LayoutB>` for B matrix
- `fragment<accumulator, M, N, K, T>` for C matrix
`mma_sync` computes D = A * B + C where:
- A is MxK
- B is KxN
- C and D are MxN
So for our case with M=16, N=16, K=16:
- A_frag represents 16x16 of A (sub-tile of the MxK matrix)
- B_frag represents 16x16 of B (sub-tile of the KxN matrix)
For row-major A (which is [M][K]), a 16x16 tile starting at (m0, k0) is loaded with `row_major`.
For row-major B (which is [K][N]), a 16x16 tile starting at (k0, n0) is loaded with `row_major`.
Then `mma_sync(c_frag, a_frag, b_frag, c_frag)` computes:
- c_frag[m,n] += sum_k a_frag[m,k] * b_frag[k,n]
But wait, B_frag loaded as row_major from B[k0..k0+15][n0..n0+15]:
- b_frag[k,n] = B[k0+k][n0+n]
And A_frag loaded as row_major from A[m0..m0+15][k0..k0+15]:
- a_frag[m,k] = A[m0+m][k0+k]
Then the product is:
sum_k A[m0+m][k0+k] * B[k0+k][n0+n]
But in standard matrix multiplication C[m,n] = sum_k A[m,k] * B[k,n], the indices should be:
sum_k A[m0+m][k0+k] * B[k0+k][n0+n]
Yes, that's correct!
So we can use `row_major` for both A and B fragments.
I previously thought matrix_b needed col_major, but that's only if we want to transpose B. For standard GEMM with both matrices in row-major, we use `row_major` for A and `row_major` for B.
Wait, let me double-check with the WMMA documentation...
The WMMA PTX instruction `mma.sync.aligned.m16n16k16.row.row` exists and does exactly what we need: A is row-major, B is row-major.
So yes, we use row_major for both.
Actually, there's a subtlety. The wmma API in CUDA C++ uses `col_major` for `matrix_b` in many examples because those examples are doing C = A * B^T or the matrices are in column-major format (Fortran/BLAS style).
For our case with row-major tensors from PyTorch:
- A: row_major
- B: row_major
So we use `wmma::row_major` for both `matrix_a` and `matrix_b`.
Great! So the kernel becomes:
```cpp
wmma::fragment<wmma::matrix_a, 16, 16, 16, __nv_bfloat16, wmma::row_major> a_frag;
wmma::fragment<wmma::matrix_b, 16, 16, 16, __nv_bfloat16, wmma::row_major> b_frag;
wmma::fragment<wmma::accumulator, 16, 16, 16, float> acc_frag;
wmma::fill_fragment(acc_frag, 0.0f);
for (int k_outer = 0; k_outer < K; k_outer += K_TILE) {
// Load A and B to shared memory
__syncthreads();
// Then for k_inner in 0, 16 within K_TILE
for (int k_inner = 0; k_inner < K_TILE; k_inner += 16) {
wmma::load_matrix_sync(a_frag, A_smem + (warp_row * 16) * A_PAD + k_inner, A_PAD);
wmma::load_matrix_sync(b_frag, B_smem + (k_inner) * B_PAD + (warp_col * 16), B_PAD);
wmma::mma_sync(acc_frag, a_frag, b_frag, acc_frag);
}
}
// Store result
wmma::store_matrix_sync(C_ptr + warp_row * 16 * n + warp_col * 16, acc_frag, n, wmma::mem_row_major);
```
Wait, wmma::store_matrix_sync for accumulator needs the memory layout parameter too.
Actually, for `wmma::store_matrix_sync`, the layout parameter doesn't exist in the same way. The accumulator is always MxN. We store it to row-major memory with:
```cpp
wmma::store_matrix_sync(C_smem_or_global + offset, acc_frag, leading_dimension, wmma::mem_row_major);
```
Now, there's another issue. The wmma::load_matrix_sync expects the shared memory pointer to point to the start of the tile. For A fragment of shape 16x16 from a [64][K_TILE] shared memory:
- Warp at row `warp_row` of the 4x4 grid loads A tile at rows [warp_row*16, (warp_row+1)*16) and some k columns.
- For each k_inner step: columns [k_inner, k_inner+16)
- The shared memory pointer should be `&A_smem[warp_row * 16][k_inner]`
- The leading dimension is `A_PAD`
For B fragment of shape 16x16 from a [K_TILE][64] shared memory:
- Warp at col `warp_col` loads B tile at rows [k_inner, k_inner+16) and cols [warp_col*16, (warp_col+1)*16)
- The shared memory pointer should be `&B_smem[k_inner][warp_col * 16]`
- The leading dimension is `B_PAD`
This looks correct!
Let me also check: we need to handle the batch dimension. Each block handles one 64x64 tile for one batch element. With 128 batches, 8 m-tiles, 32 n-tiles = 32768 blocks total.
For blockIdx.x to (batch, m_tile, n_tile):
```cpp
int n_tiles = n / 64; // 32
int m_tiles = m / 64; // 8
int total_tiles_per_batch = m_tiles * n_tiles; // 256
int batch_idx = blockIdx.x / total_tiles_per_batch;
int tile_idx = blockIdx.x % total_tiles_per_batch;
int m_tile = tile_idx / n_tiles;
int n_tile = tile_idx % n_tiles;
int m_start = m_tile * 64;
int n_start = n_tile * 64;
```
Now for K_TILE. We want K_TILE to be large enough to amortize the __syncthreads() cost but small enough for shared memory. For K=1024, we could do K_TILE=32, 64, or even 128.
With K_TILE=64:
- A_smem: 64 x 64 BF16 = 4096 * 2 = 8 KB
- B_smem: 64 x 64 BF16 = 8 KB
- Total: 16 KB. Great.
But then we have 1024/64 = 16 outer iterations, each with 64/16 = 4 inner wmma iterations. Total 16*4 = 64 wmma calls per warp.
With K_TILE=128:
- A_smem: 64 x 128 = 16 KB
- B_smem: 128 x 64 = 16 KB
- Total: 32 KB. Still within limits.
But 32 KB might limit occupancy. Let me check H100 shared memory:
- Per SM: 228 KB
- Max per block: 227 KB
- But to achieve good occupancy, we want shared memory per block to allow multiple blocks per SM.
With 512 threads per block and shared memory config:
- If each block uses 32 KB shared memory, then 228/32 = ~7 blocks per SM maximum.
- With 512 threads, 2048 threads per SM max, so 4 blocks per SM maximum due to thread count.
- So occupancy is thread-limited, not shared-memory-limited.
Let's use K_TILE=64 for a balance.
But wait, we also need to think about double buffering to hide latency. With K_TILE=64, we load 64 K values at once, then do 4 wmma iterations. The loads are amortized over 4 wmma calls.
Alternatively, K_TILE=32 with 2 inner iterations might also work well.
Let me try K_TILE=64 first.
Now, let me also think about whether we need to convert the inputs from FP32 to BF16. For correctness, we can convert on the fly using:
```cpp
__nv_bfloat16 __float2bfloat16(float a);
```
Or in CUDA 12, we can use:
```cpp
__nv_bfloat16 h = __float2bfloat16_rn(f);
```
Actually, the standard function is `__float2bfloat16` which might round to nearest even.
Now, the numerical question: will converting from FP32 to BF16 and computing the GEMM in BF16 with FP32 accumulation give results within 1e-4 of torch.bmm in FP32?
torch.bmm in FP32 by default uses TF32 on H100 (since allow_tf32 is True). TF32 has 10 bits of mantissa compared to BF16's 7 bits and FP32's 23 bits. So TF32 is more accurate than BF16.
But our tolerance is 1e-4 = 0.01%. For outputs that can be as large as K (since A, B ~ [0,1]), max output is about K = 1024. Absolute tolerance is 1e-4. Relative tolerance is 1e-4.
For an output of ~512 (mean case), absolute error tolerance is 512 * 1e-4 = 0.0512. So we need absolute error less than about 0.05 for typical values.
With BF16 accumulation in the tensor cores but FP32 output accumulator:
- Each multiply is BF16 x BF16 → the error per product is about 1-2 ULP of BF16.
- BF16 ULP at value ~1 is 2^-7 ≈ 0.0078.
- 1024 products summed: worst-case error could be 1024 * 0.0078 ≈ 8, but that's worst-case.
- With random inputs, errors average out. The standard deviation of rounding errors is much smaller.
- In practice, mixed-precision GEMM with BF16 multiply and FP32 accumulate typically achieves <0.01 relative error on random data in [0,1].
So we should be fine.
Actually wait, I just realized: torch.bmm in FP32 with TF32 enabled will also have reduced precision (10-bit mantissa vs 7-bit for BF16). TF32 has精度 somewhere between BF16 and FP16. The difference between TF32 and BF16 might be noticeable but should still be within tolerance.
Let me be more precise. TF32 mantissa is 10 bits (same as FP16's range, but with FP32 exponent). BF16 has 7 bits of mantissa.
For values ~1:
- TF32 ULP: 2^-10 ≈ 0.001
- BF16 ULP: 2^-7 ≈ 0.0078
The per-product error for TF32 is smaller than BF16 by a factor of ~8.
But both should still pass the 1e-4 relative tolerance, especially since the tolerance is pretty loose (1e-3 for BF16 tasks, but here it's FP32 output with 1e-4 tolerance).
For random [0,1] inputs:
- Mean output = K/4 = 256
- Std of output = sqrt(K * var(product)) ≈ sqrt(1024 * 1/9) ≈ 10.7 (using uniform distribution variance ~1/12)
- So most outputs are in [200, 300] range.
- Relative tolerance of 1e-4 means absolute tolerance of ~0.03.
- BF16 GEMM with FP32 accumulation typically has errors around 0.01-0.1 for matrices of this size.
Hmm, on the edge. Let me think about whether to use TF32 or BF16.
TF32 tensor cores: ~495 TFLOPS. Still much faster than pure FP32.
BF16 tensor cores: ~1979 TFLOPS. Even faster.
Maybe I should try BP16 first and see if it passes correctness.
Actually, the tolerance is `atol=rtol=1e-4` for fp32. If outputs are around 256, rtol=1e-4 means atol requirement is 0.0256. And atol itself is 1e-4.
Let me do a quick calculation for BF16 error. Each product A[i][k] * B[k][j] has error up to ~0.01 (since BF16 ULP at value ~1 is ~0.008). With 1024 terms, by the central limit theorem, the error distribution has std dev ~0.01 * sqrt(1024) / sqrt(3) ≈ 0.01 * 32 / 1.7 ≈ 0.19.
Wait, that's too large. Let me recalculate.
The rounding of each product to BF16: the exact product p is rounded to BF16. Error e = round(p) - p. |e| <= 0.5 ULP(BF16) ≈ 0.004. e is approximately uniform in [-0.004, 0.004].
Sum of 1024 independent uniform errors:
- Mean = 0
- Variance = 1024 * (0.004)^2 / 3 ≈ 1024 * 5.3e-6 ≈ 0.0055
- Std dev ≈ 0.074
So errors should mostly be within ±0.2 or so. The tolerance is max(1e-4, 1e-4 * |expected|). For expected=256, tolerance is 0.0256. But error std is 0.074, so we'd have many elements outside tolerance!
Hmm, that's concerning. But actually:
1. The inputs A and B are also in BF16 in our kernel, whereas in the reference they're FP32 (or TF32). So A and B themselves have rounding errors compared to the reference.
2. torch.bmm with TF32 also rounds A and B to TF32, so it has similar but smaller rounding errors.
Wait, torch.bmm with TF32 doesn't convert A and B to TF32 explicitly; the tensor cores do it internally during each mma. But since A and B are stored in FP32, each load gets the full FP32 value, and the mma rounds it to TF32 on-the-fly.
In our kernel, we convert A and B to BF16 in shared memory. This means:
- A_smem[i] = round_to_bf16(A_fp32[i])
- B_smem[i] = round_to_bf16(B_fp32[i])
Then the products use these rounded values. So the total error is:
1. Error from rounding A to BF16
2. Error from rounding B to BF16
3. Error from rounding products to BF16
This might be too much for the 1e-4 tolerance.
But wait, I need to think about what torch.bmm actually computes with TF32. If A and B are FP32, then torch.bmm with TF32 internally rounds each operand to TF32 in the tensor cores. The error per operand is at most 0.5 ULP(TF32) ≈ 0.0005.
In our case with BF16 shared memory, the error per operand is at most 0.5 ULP(BF16) ≈ 0.004.
And product error:
- For TF32: product of two FP32 values rounded to TF32. The product p is exact in some higher precision, then rounded to TF32. Error ≈ 0.0005.
- For BF16: product rounded to BF16. Error ≈ 0.004.
So BF16 is about 8x less accurate per operation. Across 1024 operations, the accumulated error might be too large.
Let me try to be more precise about the tolerance. For A, B in [0,1]:
- Exact output C_exact = sum A_k * B_k
- Reference output C_ref = torch.bmm(A, B) which uses TF32. C_ref ≈ C_exact + error_tf32
- Our output C_ours ≈ C_exact + error_bf16
- We need |C_ours - C_ref| < 1e-4 * |C_ref| + 1e-4
- |C_ours - C_ref| ≈ |error_bf16 - error_tf32| ≈ |error_bf16| (since error_tf32 is smaller)
For sum of 1024 products with BF16 operands:
- Each product: A_bf16 * B_bf16
- A_bf16 = A_fp32 + e_A, |e_A| < 0.004
- B_bf16 = B_fp32 + e_B, |e_B| < 0.004
- Product = (A + e_A)(B + e_B) = AB + A*e_B + B*e_A + e_A*e_B
- Error ≈ A*e_B + B*e_A (first order), bounded by ~0.008 in magnitude
- Then the product is rounded to BF16: additional error ~0.004
- Total product error bound: ~0.012
- But the errors are random and mostly independent
- Standard deviation of sum: sqrt(1024) * 0.012 / sqrt(3) * factor... this is roughly 0.2 as estimated.
For normal distribution with std=0.2, probability of |error| > 0.025 is about 88%. That's way too much.
So BF16 might not pass the 1e-4 tolerance for fp32!
I should use TF32 instead, which should have error ~8x smaller and likely pass.
For TF32 with FP32 accumulation:
- Per operand rounding: 0.0005
- Per product rounding: 0.0005
- Total per-product error std: ~0.0005 / sqrt(3) * sqrt(2) ≈ 0.0004 (very rough)
- Sum of 1024: std ≈ 0.0004 * sqrt(1024) ≈ 0.013
For expected outputs of ~256, atol tolerance is 0.0256. With error std of 0.013, most elements should pass! The 99.7% range is ±3*std = ±0.039, which is slightly above the tolerance for large outputs. But since the reference ALSO uses TF32 (error std ~0.004), the difference should be smaller.
Actually wait - if both our kernel and the reference use TF32, then the outputs should match very closely! Because we're doing the same computation (just our custom kernel vs cuBLAS).
Perfect! So I should use TF32 tensor cores, same as what cuBLAS uses, but with a custom-tiled kernel.
Using TF32 wmma:
```cpp
wmma::fragment<wmma::matrix_a, 16, 16, 8, wmma::precision::tf32, wmma::row_major> a_frag;
wmma::fragment<wmma::matrix_b, 16, 16, 8, wmma::precision::tf32, wmma::row_major> b_frag;
wmma::fragment<wmma::accumulator, 16, 16, 8, float> acc_frag;
```
Note: for TF32, K=8 in the fragment dimensions. The `precision::tf32` data type stores as `float` in the fragment.
With TF32 16x16x8 fragments, and our desired block tiling:
- 16 warps per block = 512 threads
- 4x4 warp grid = 64x64 tile
- Each warp computes one 16x16 tile
- K_TILE=64: outer loop has 1024/64 = 16 iterations
- Inner loop: 64/8 = 8 wmma iterations
Each outer iteration:
1. Load A[64][64] and B[64][64] to shared memory from FP32 global memory
- No conversion needed! Just load as FP32 into shared memory.
- Shared memory size: 64*64*4*2 = 32 KB for FP32
- Or we could use smaller K_TILE=32: 64*32*4*2 = 16 KB
With K_TILE=32 (16 KB shared memory):
- Outer loop: 1024/32 = 32 iterations
- Inner loop: 32/8 = 4 wmma iterations
32 outer iterations might have significant overhead from __syncthreads. Let me try K_TILE=64 (32 KB shared memory):
- Outer loop: 16 iterations
- Inner loop: 8 wmma iterations
16 is a reasonable number. And since each warp does 8 wmma_sync calls per outer iteration, the mma work dominates.
Actually, 32 KB shared memory is fine for H100. Let me verify occupancy:
- Threads per block: 512
- Max threads per SM: 2048
- Max blocks per SM due to threads: 2048/512 = 4
- Shared memory per SM: 228 KB
- Max blocks per SM due to SMEM: 228/32 = 7
- So threads limit: 4 blocks per SM.
Occupancy = 4/8 (for H100, 8 blocks per SM is sometimes the theoretical max, but with 512 threads, max is 4) = 50%. That's not bad.
Could we use 256 threads (8 warps) with a 64x32 tile?
- Threads per block: 256
- Blocks per SM: 8 (thread-limited)
- SMEM per block: let's say 16 KB for K_TILE=32
- Blocks per SM due to SMEM: 228/16 = 14
- Thread-limited: 8 blocks per SM.
Occupancy: 8/8 = 100%! That might be better.
But the total work per block is halved, so there might be more overhead per FLOP. However, with 2x the blocks per SM, we might hide latency better.
Let's analyze throughput:
- With 512-thread blocks: 32768 blocks total. 32768 / 132 SMs = ~248 blocks per SM. At 4 concurrent blocks per SM, that's 62 waves of blocks. Good parallelization.
- With 256-thread blocks and 64x32 tiles: m_tiles = 512/64 = 8, n_tiles = 2048/32 = 64. Total tiles per batch = 512. Total blocks = 128 * 512 = 65536. 65536 / 132 = 496 blocks per SM. At 8 concurrent blocks per SM, that's 62 waves. Same number of waves, but higher occupancy.
Actually, with 256 threads and K_TILE=32:
- Each block computes 64x32 = 2048 output elements
- FLOPs per block: 2 * 64 * 32 * 1024 = 4,194,304 = 4.2 MFLOPs
- Total blocks: 65536
- Total FLOPs: 275 GFLOPs
Shared memory:
- A_smem: 64 x 32 float = 8 KB
- B_smem: 32 x 32 float = 4 KB (wait, N tile is 32, so B_sm is 32x32)
- Total: 12 KB
Actually, B_smem for K_TILE=32 and N=32: 32 x 32 = 1024 floats = 4 KB.
Total shared: 12 KB.
Or K_TILE=64 with N=32:
- A_smem: 64 x 64 = 16 KB
- B_smem: 64 x 32 = 8 KB
- Total: 24 KB
With 256 threads per block and 24 KB shared:
- Blocks per SM: min(2048/256, 228/24) = min(8, 9.5) = 8
- Great occupancy!
Let me use:
- BlockDim = 256 threads (8 warps)
- Tile M = 64, Tile N = 32
- K_TILE = 64
- M warps: 64/16 = 4 warps in M
- N warps: 32/16 = 2 warps in N
- Total: 4 x 2 = 8 warps. Perfect.
Grid:
- batch: 128
- m_tiles: 512/64 = 8
- n_tiles: 2048/32 = 64
- Total blocks: 128 * 8 * 64 = 65536
Each block:
- Loads A[64][64] and B[64][32] into shared memory
- 8 warps, each handles a 16x16 tile
- Warp grid: 4x2
- M iterations: 4 (warp rows)
- N iterations: 2 (warp cols)
- K iterations: 64 (outer), 8 (inner, per wmma)
Let me verify total FLOPs per block: 2 * 64 * 32 * 1024 = 4,194,304 FLOPs
At 495 TFLOPS TF32, each block should take ~8.5 ns in theory, but that's unrealistic. In practice, with memory overhead, maybe a few microseconds.
Hmm, 65536 blocks at say 10 µs each on 132 SMs = 65536 * 10 / 132 / 1000 ms = ~5 ms. That seems slow.
Wait, if each block takes 10 µs and we have 8 blocks per SM concurrently, the actual time is:
65536 blocks / (132 SMs * 8 blocks/SM) * 10 µs = 62 waves * 10 µs = 620 µs = 0.62 ms.
For 275 GFLOPs in 0.62 ms, that's 275 / 0.62 = 444 TFLOPS. That's very close to theoretical peak if achievable!
But can our custom kernel actually achieve that? The issue is memory bandwidth for loading A and B tiles.
Memory loads per block:
- A tile: 64 * 64 = 4096 floats = 16 KB
- B tile: 64 * 32 = 2048 floats = 8 KB
- Total per outer K iteration: 24 KB
- For 16 outer iterations: 384 KB per block
- Total memory traffic for all blocks: 65536 * 384 KB = 24,576 MB = 24.6 GB
At 3.35 TB/s memory bandwidth, memory-bound time would be 24.6 / 3350 = 0.0073 s = 7.3 ms.
Wait, that can't be right. Let me recalculate:
- 65536 blocks
- Each block loads 16 KB + 8 KB = 24 KB
- Total reads: 65536 * 24 KB = 1,572,864 KB = 1.57 GB
Hmm, that's much less. I was multiplying by 16 outer iterations but that's wrong because each block processes the full K dimension. So each block indeed loads 24 KB total (not per iteration; the tiles are loaded once per K_TILE, and K_TILE=64 means 16 iterations, each loading 24 KB).
Wait, no. With K_TILE=64, each outer iteration loads A[64][64] and B[64][32]. With 16 outer iterations, each block loads:
- A: 16 * 64 * 64 = 65,536 floats = 256 KB
- B: 16 * 64 * 32 = 32,768 floats = 128 KB
- Total: 384 KB per block
Total for all blocks: 65536 * 384 KB = 25,165,824 KB ≈ 24 GB.
But wait - in a batched GEMM, each A and B element is only read once by some block. Total data in A = 128 * 512 * 1024 * 4 = 268 MB. Total data in B = 128 * 1024 * 2048 * 4 = 1,073 MB. Total = 1,341 MB ≈ 1.34 GB.
Why does my per-block calculation give 24 GB? Because I forgot that the tiles overlap in the K dimension for different MN tiles, but not in the M and N dimensions.
Actually, each A element at position [batch][m][k] is read by exactly one block: the block that computes the MN tile containing m. Since K is iterated over, A[batch][m][k] for all k values are read by the same block. So total A reads = total A size = 268 MB.
Similarly, each B element is read by exactly one block. Total B reads = 1,073 MB.
Total reads = 1,341 MB. Total writes = C size = 128 * 512 * 2048 * 4 = 536 MB.
Total memory traffic = ~1.88 GB.
At 3.35 TB/s, this is 0.56 ms. So the kernel is compute-bound if we can achieve >200 TFLOPS.
Good, so our calculation of memory traffic being a bottleneck was wrong - the 24 GB apparent total was counting each element multiple times across different blocks for different K_TILEs, but actually each element is only needed by specific blocks.
OK great, so with 1.88 GB of memory traffic and 275 GFLOPs of compute, the arithmetic intensity is 275/1.88 = 146 FLOPs/byte. For TF32 at 495 TFLOPS and 3.35 TB/s bandwidth, the break-even point is 495/3.35 = 148 FLOPs/byte. So we're right at the break-even! This means both memory and compute matter.
If we can achieve 80% of TF32 peak (396 TFLOPS), we'd be compute-bound and take 0.69 ms. If we only achieve 50% (247 TFLOPS), we'd be somewhat memory-bound at ~1.1 ms.
cuBLAS torch.bmm likely achieves good utilization. We need to match or beat it.
One advantage of our custom kernel: we can process the specific shape more efficiently than cuBLAS's general batched kernel, potentially achieving better register and shared memory utilization.
Now, there's another optimization: instead of using shared memory for both A and B with K_TILE=64, we could use the new TMA (Tensor Memory Accelerator) on Hopper to load directly to shared memory. But that's quite complex.
Alternatively, we can increase the tile sizes to increase arithmetic intensity:
- Tile M = 128, Tile N = 64
- Threads per block: 512 (32 warps in 8x4 grid)
- K_TILE = 64
- A_smem: 128 x 64 = 32 KB
- B_smem: 64 x 64 = 16 KB
- Total: 48 KB
- Blocks per SM: min(2048/512, 228/48) = min(4, 4.75) = 4
- Total blocks: 128 * 4 * 32 = 16384
- Waves: 16384 / (132 * 4) = 31
Each block does 2 * 128 * 64 * 1024 = 16.7 MFLOPs.
Memory per block: 128*64*4 + 64*64*4 = 32KB + 16KB = 48KB for K_TILE=64, over 16 iterations.
Total memory per block: 16 * 48KB = 768KB.
Total memory traffic: 16384 * 768KB = 12.6 GB... wait, again this counts each element multiple times.
Actually, the correct total memory traffic is still the same: 1.88 GB. The per-block analysis is misleading because different blocks read disjoint MN tiles.
With larger tiles:
- Fewer blocks (16384)
- More work per block
- Better amortization of overhead
- Same memory traffic
- But fewer blocks might mean worse load balancing if some SMs finish early
With smaller tiles:
- More blocks (65536)
- Less work per block
- More overhead
- But more parallelism and better latency hiding
I think 64x32 or 64x64 are good sweet spots.
Let me go with 64x64 tiles and 256 threads (8 warps):
Wait, 64x64 requires 16 warps (one per 16x16 tile). So that's 512 threads.
OK, 512 threads with 64x64 tile, K_TILE=64:
- Blocks: 128 * 8 * 32 = 32768
- Blocks per SM: 4 (thread-limited)
- Total waves: 32768 / (132*4) = 62
- Each block FLOPs: 2 * 64 * 64 * 1024 = 8.4M
- Shared memory per K_TILE: 64*64*4 + 64*64*4 = 32KB
- But with 16 K-iterations, total SMEM used concurrently is just 32KB.
Wait, A_smem for K_TILE=64 is 64x64 floats = 16KB. B_smem is also 64x64 = 16KB. Total = 32KB.
With 512 threads and 32KB SMEM:
- Blocks per SM = min(2048/512, 228/32) = min(4, 7.1) = 4
- 4 blocks per SM is fine.
Actually, let me double check whether `wmma::fragment` with `precision::tf32` is available on Hopper. TF32 tensor cores were introduced with Ampere (SM80) and are supported on Hopper (SM90). So yes.
However, I should also consider using a simpler approach: write a kernel that uses the CUTLASS library. PyTorch sometimes includes CUTLASS headers, but it's not guaranteed.
Actually, let me check if we can use `cublasGemmStridedBatchedEx` directly. Since `torch.bmm` already uses cuBLAS with BF16 or TF32, calling it directly won't give us any advantage unless torch.bmm has overhead.
But wait - torch.bmm might be doing extra work like checking dimensions, preparing descriptors, etc. A direct cuBLAS call could avoid some of that overhead.
However, for a batch size of 128 with large matrices, the overhead of torch.bmm is negligible compared to the compute time.
Hmm, another idea: what if we use `cutlass` through `torch`'s built-in support? Some PyTorch builds include CUTLASS. But this is risky.
OK, let me commit to writing a custom WMMA kernel with TF32. I'll implement it step by step.
Actually, I just realized there might be an even simpler and faster approach. On H100, PyTorch might be using cuBLAS's default algorithms which aren't always optimal for batched GEMM. But cuBLAS does have batched GEMM APIs that are very efficient.
Wait, let me try another approach entirely: TMA-based kernel with wgmma. But writing that from scratch without CUTLASS is extremely hard.
Let me write a simpler kernel using the WMMA API and see how it performs. We can iterate and optimize.
Here's my proposed kernel:
```cuda
#include <cuda.h>
#include <cuda_runtime.h>
#include <mma.h>
using namespace nvcuda;
__global__ void bmm_tf32_kernel(
const float* __restrict__ A,
const float* __restrict__ B,
float* __restrict__ C,
int batch_size, int m, int n, int k)
{
// Block dimensions: 512 threads = 16 warps
// Each block computes a 64x64 tile of C
// Warp layout: 4x4 grid (4 in M, 4 in N)
const int WARP_M = 4;
const int WARP_N = 4;
const int BM = 64; // Block tile M
const int BN = 64; // Block tile N
const int BK = 64; // Block tile K
const int WM = 16; // Warp tile M
const int WN = 16; // Warp tile N
const int WK = 8; // Warp tile K (TF32)
int warp_id = threadIdx.x / 32;
int lane_id = threadIdx.x % 32;
int warp_m = warp_id / WARP_N;
int warp_n = warp_id % WARP_N;
int n_tiles = n / BN;
int m_tiles = m / BM;
int tiles_per_batch = m_tiles * n_tiles;
int batch_idx = blockIdx.x / tiles_per_batch;
int tile_idx = blockIdx.x % tiles_per_batch;
int m_tile = tile_idx / n_tiles;
int n_tile = tile_idx % n_tiles;
int m_start = m_tile * BM;
int n_start = n_tile * BN;
const float* A_ptr = A + batch_idx * m * k;
const float* B_ptr = B + batch_idx * k * n;
float* C_ptr = C + batch_idx * m * n;
// Shared memory layout
// A_smem: [BM][BK] floats with padding for alignment
// B_smem: [BK][BN] floats with padding
__shared__ float A_smem[BM][BK + 4]; // pad to avoid bank conflicts
__shared__ float B_smem[BK][BN + 4];
wmma::fragment<wmma::matrix_a, WM, WN, WK, wmma::precision::tf32, wmma::row_major> a_frag;
wmma::fragment<wmma::matrix_b, WM, WN, WK, wmma::precision::tf32, wmma::row_major> b_frag;
wmma::fragment<wmma::accumulator, WM, WN, WK, float> acc_frag;
wmma::fill_fragment(acc_frag, 0.0f);
for (int k_outer = 0; k_outer < k; k_outer += BK) {
// Load A to shared memory
// A[batch][m_start + row_a][k_outer + col_a] for row_a in [0, BM), col_a in [0, BK)
// Total: BM * BK = 4096 floats
// 512 threads, each loads 8 floats
int t = threadIdx.x;
int a_row = t / (BK / 4); // 512 / 16 = 32... wait
// Better: each thread loads 8 floats via float4
// 4096 floats / 512 threads = 8 floats per thread = 2 float4
int load_count = (BM * BK) / blockDim.x; // 8 floats per thread
float4* A_smem_vec = (float4*)&A_smem[0][0];
const float4* A_ptr_vec = (const float4*)(A_ptr + m_start * k + k_outer);
// Let me use a simpler approach: each thread loads 2 float4, covering 8 floats
#pragma unroll
for (int i = 0; i < 2; i++) {
int idx = t + i * blockDim.x; // 0..1023
int row = idx / (BK / 4); // each row has BK/4 = 16 float4s
int col4 = idx % (BK / 4); // which float4 in the row
// But BM = 64, so row goes 0..63
// idx goes 0..1023, row = idx / 16 = 0..63
// col4 = idx % 16 = 0..15
if (row < BM) {
float4 val = A_ptr_vec[row * (k / 4) + col4];
A_smem_vec[row * ((BK + 4) / 4) + col4] = val;
}
}
// Load B to shared memory
// B[batch][k_outer + row_b][n_start + col_b] for row_b in [0, BK), col_b in [0, BN)
// Total: BK * BN = 4096 floats
const float4* B_ptr_vec = (const float4*)(B_ptr + k_outer * n + n_start);
float4* B_smem_vec = (float4*)&B_smem[0][0];
#pragma unroll
for (int i = 0; i < 2; i++) {
int idx = t + i * blockDim.x;
int row = idx / (BN / 4);
int col4 = idx % (BN / 4);
if (row < BK) {
float4 val = B_ptr_vec[row * (n / 4) + col4];
B_smem_vec[row * ((BN + 4) / 4) + col4] = val;
}
}
__syncthreads();
// Compute using wmma
#pragma unroll
for (int k_inner = 0; k_inner < BK; k_inner += WK) {
int a_row_offset = warp_m * WM;
int b_col_offset = warp_n * WN;
wmma::load_matrix_sync(a_frag, &A_smem[a_row_offset][k_inner], BK + 4);
wmma::load_matrix_sync(b_frag, &B_smem[k_inner][b_col_offset], BN + 4);
wmma::mma_sync(acc_frag, a_frag, b_frag, acc_frag);
}
__syncthreads();
}
// Store results
int c_row_offset = m_start + warp_m * WM;
int c_col_offset = n_start + warp_n * WN;
wmma::store_matrix_sync(C_ptr + c_row_offset * n + c_col_offset, acc_frag, n, wmma::mem_row_major);
}
```
Wait, I need to check if `wmma::precision::tf32` is the right type for TF32 fragments. Let me verify in the CUDA docs.
In CUDA 11+, for TF32 WMMA:
```cpp
wmma::fragment<wmma::matrix_a, 16, 16, 8, wmma::precision::tf32, wmma::row_major>
```
Yes, `precision::tf32` is correct. And the K dimension is 8 for TF32 (vs 16 for FP16/BF16).
Also, for `wmma::load_matrix_sync` with TF32, the pointer should be `float*` and the stride is in elements.
One issue: TF32 fragments only support K=8, whereas my shared memory uses BK=64. The inner loop will iterate 64/8 = 8 times, which is fine.
Another issue: when loading from shared memory with `wmma::load_matrix_sync`, the shared memory must not have bank conflicts for the access pattern used by the warp. The padding in my shared memory declarations `(BK + 4)` should help avoid conflicts.
Actually, for A_smem[BM][BK+4], with BK=64, the row stride is 68 floats = 272 bytes. For a warp accessing 16 columns of floats (64 bytes), with 4-byte banks:
- Bank for column j: (row_offset + j*4) / 4 % 32 = (row_offset/4 + j) % 32
- For j=0..15, consecutive columns hit consecutive banks. No conflicts!
For B_smem[BK][BN+4], with BN=64, row stride is 68 floats = 272 bytes.
- Bank for column j: same logic, consecutive banks for consecutive j.
- But wmma for matrix_b accesses elements differently depending on layout.
Actually, I realize `wmma::load_matrix_sync` might have special memory access patterns optimized by hardware, so mild bank conflicts are often not a problem.
Let me think about whether there are issues with my vectorized loads to shared memory.
For A global → shared:
```cpp
float4* A_smem_vec = (float4*)&A_smem[0][0];
A_smem_vec[row * ((BK + 4) / 4) + col4] = val;
```
This writes directly to shared memory as float4. The row stride in float4 is (68/4) = 17 float4s. For a row of 64 floats = 16 float4s, we have padding of 1 float4 per row. This should make consecutive rows start at different bank alignments, helping avoid conflicts.
But wait, the indexing:
- A_smem_vec is a float4 pointer to the start of A_smem.
- Element [row][col4] in float4 terms: row * 17 + col4.
- For row=0, col4=0: offset 0
- For row=1, col4=0: offset 17 * 16 bytes = 272 bytes
- Bank = 272/4 = 68 % 32 = 4
- So consecutive rows start at banks 0, 4, 8, 12, 16, 20, 24, 28, 0, 4, ...
This seems fine.
Actually, I want to use a simpler approach for the kernel to reduce bugs. Let me use `__nv_bfloat16` with K=16 and see if it passes the tolerance. If not, I'll switch to TF32.
But I've already convinced myself that BF16 might fail the tolerance. Let me think once more...
For random inputs A, B in [0, 1]:
- torch.bmm(A, B) computes with TF32 precision
- Our kernel with BF16 computes with 7-bit mantissa for operands
- The expected values range from ~0 to ~1024
- Tolerance: max(1e-4, 1e-4 * expected) ≈ max(1e-4, 0.1) = 0.1
Wait, for expected=1024, 1e-4 * 1024 = 0.1024. So the tolerance is 0.1 for large values!
And for expected=256, tolerance is 0.0256.
For expected=512, tolerance is 0.0512.
For expected=1024, tolerance is 0.1024.
So the tolerance grows with the magnitude of the output!
For random inputs, the maximum output is about 1024 (if all A[i]=1 and B[i]=1, but for uniform [0,1], it's about sum of 1024 products of U[0,1] values, which averages 256 and has a max around 600-700 at 3 sigma, but could theoretically reach 1024).
Standard deviation of the sum is about sqrt(1024 * 1/36) = sqrt(28.4) ≈ 5.3 for products of independent U[0,1]. Wait, E[XY] = 1/4, Var(XY) = E[X^2 Y^2] - E[XY]^2 = (1/3)(1/3) - (1/4)^2 = 1/9 - 1/16 = 7/144 ≈ 0.0486.
Std = sqrt(1024 * 0.0486) = sqrt(49.8) ≈ 7.1.
Wait, I calculated this before. So most values are within 256 ± 21 (3 sigma). Error from BF16 should be well within tolerance.
Actually, the key question is: what fraction of elements have error > 0.1?
With std dev of output ~7.1, values above 300 are very rare (more than 6 sigma). Tolerance at 300 is 0.03.
The BF16 error per element was estimated to have std dev ~0.07-0.2. So some elements might exceed 0.03.
But for elements near 256 (the mean), tolerance is 0.0256. If error std is 0.07, then about 35% of elements would have |error| > 0.0256.
Hmm, that's a lot. But let me recalculate the error more carefully.
Actually, there's a crucial detail I missed: torch.bmm with TF32 also introduces errors! So we're not comparing to exact arithmetic, but to TF32 arithmetic. Both our kernel (if we use BF16) and the reference use lower precision than exact FP32.
Wait no - the reference is `torch.bmm(A, B)` which might be using TF32. The correctness checker compares our output to the reference output. So if both introduce errors, but different ones, the difference could be large.
For reference (TF32):
- Each operand rounded to TF32: error ~ 2^-10 * value ≈ 0.001
- Product rounded to TF32: error ~ 0.001
- Accumulated in FP32 exactly
For our kernel (BF16):
- Each operand rounded to BF16: error ~ 2^-7 * value ≈ 0.008
- Product rounded to BF16: error ~ 0.008
- Accumulated in FP32 exactly
The difference between BF16 and TF32 per product: ~0.007 in the operand, leading to product error difference of ~0.014.
Sum of 1024 terms with error difference ~0.014 per term:
- If signs are random, std of difference ~ 0.014 * sqrt(1024) / sqrt(3) ≈ 0.26
This could easily exceed the tolerance of 0.03 for most elements!
So BF16 is likely to FAIL. We need TF32.
Actually, wait. I just thought of something. What if torch.bmm does NOT use TF32? What if it's disabled?
In PyTorch, `torch.backends.cuda.matmul.allow_tf32` defaults to True on Ampere/Hopper for matmul. But batched matmul might have a different code path.
Actually, `torch.bmm` calls into cuBLAS where the TF32 policy in PyTorch is set globally. For FP32 input on H100, it should use TF32.
To be safe, let me use `wmma::precision::tf32` which should match cuBLAS's precision.
Alternatively, we could write a pure FP32 kernel using shared memory tiles without tensor cores. For these problem sizes, the theoretical time at 67 TFLOPS peak would be ~4.1 ms. But的实际 achievable might be 20-30 TFLOPS, giving ~9-14 ms. cuBLAS with TF32 should be around 1-2 ms. So pure FP32 won't be competitive.
So TF32 it is.
Let me refine my TF32 kernel. I'll use a more careful implementation.
Actually, I realize there's another approach. Instead of wmma, I can write a kernel that calls the CUTLASS library functions. Let me check if CUTLASS headers are available.
In PyTorch, CUTLASS headers are sometimes available. But in the `load_inline` context, I don't know if they'll be accessible. The CUDA include path for `load_inline` typically includes `/usr/local/cuda/include` and PyTorch's own include directories. CUTLASS might not be there.
Another approach: use `cublasGemmStridedBatchedEx` with CUBLAS_COMPUTE_32F_FAST_TF32. This is exactly what torch.bmm does. But we could potentially call it with a better algorithm selection.
Actually, let me check: in PyTorch, the TF32 heuristic is set globally. If we create our own cuBLAS handle, we could set the math mode to TF32 and potentially choose a better algorithm. But torch.bmm already uses the default cuBLAS heuristic which is very good.
Wait, what if PyTorch doesn't use `cublasGemmStridedBatchedEx` but instead calls `cublasSgemmBatched` or loops over individual `cublasSgemm` calls? For some older versions or specific shapes, PyTorch might not use the most optimal path.
But on H100 with recent PyTorch, it should definitely use the best available path.
Let me try a completely different strategy. What if we write a kernel using a register-tiled approach for FP32 without tensor cores, but with very large tiles and aggressive unrolling? On H100, pure FP32 might actually be faster than expected if we can use the full SM. Let me calculate:
H100 has 132 SMs, 67 TFLOPS FP32 = ~0.5 TFLOPS per SM.
For 275 GFLOPs total, we need 275 / 67 = ~4.1 ms at peak.
But peak is rarely achieved. 50% peak would be 8.2 ms.
For cuBLAS TF32 at 300 TFLOPS effective: 0.9 ms.
So pure FP32 is likely 5-10x slower.
Not competitive.
Let me commit to the TF32 WMMA kernel.
Actually, I should also consider using multiple CTAs per SM via cooperative groups or just relying on high occupancy.
One more thing: in Hopper, `wmma` might not be the fastest path. The `mma.sync.aligned.m64n8k16` PTX instruction is specifically for Hopper and is much more powerful. But `wmma` should compile down to the right instructions.
Let me check if `wmma::precision::tf32` actually exists and is supported.
From the CUDA C++ Programming Guide:
- `precision::tf32` is supported on compute capability >= 80 (Ampere, Hopper).
- Fragment dimensions: 16x16x8.
Yes, it exists.
Now, one practical concern: compiling with CUDA 12 on H100. The extension compilation should work.
Let me also think about whether I should try a mixed approach. For example, what if I first convert A and B to TF32 in shared memory? Actually, they're already FP32 in global memory, and wmma loads them directly to the fragment where the hardware rounds to TF32. So no conversion needed.
Wait, for `wmma::load_matrix_sync` with `precision::tf32`, do we pass float arrays or do we need to convert first?
From the docs: For `precision::tf32`, the fragment stores data as `float`. The `load_matrix_sync` takes a pointer to the appropriate type (`float*` for TF32) and loads from memory. The hardware automatically interprets the float data as TF32 during the mma operation.
So we can just load from float arrays directly.
OK, let me write the kernel. But I realize my vectorized load approach using `float4` has an issue: the pitch of A in global memory is `k * sizeof(float) = 1024 * 4 = 4096 bytes`, which is divisible by 16. So `float4` loads are aligned. Similarly for B: pitch `n * sizeof(float) = 2048 * 4 = 8192 bytes`, divisible by 16. Good.
For A_smem shared memory, with padding `BK + 4 = 68`, pitch is `68 * 4 = 272 bytes`, divisible by 16. So `float4` stores to shared memory are also aligned.
For B_smem, same: `BN + 4 = 68`, pitch is 272 bytes, divisible by 16.
Now for the `wmma::load_matrix_sync` calls:
- For A: `&A_smem[warp_m * WM][k_inner]`, leading dimension `BK + 4`
- For B: `&B_smem[k_inner][warp_n * WN]`, leading dimension `BN + 4`
These should work since the pointer is aligned (k_inner=0,8,16,... and the row start is aligned to at least 16 bytes because `BK+4` is not a multiple of 4 in the dimension that matters... wait.
The pointer `&A_smem[warp_m * WM][k_inner]` points to byte offset:
`(warp_m * WM) * (BK + 4) * sizeof(float) + k_inner * sizeof(float)`
For warp_m = 0..3, WM = 16:
- warp_m * WM = 0, 16, 32, 48
- Offset = row * 272 + k_inner * 4
For row=0, k_inner=0: offset 0 (aligned)
For row=16, k_inner=0: offset 16 * 272 = 4352 = 272 * 16 = 16 * 272. Is this divisible by 16? Yes, 272 = 16 * 17, so 4352 = 16 * 16 * 17. Aligned.
For `&B_smem[k_inner][warp_n * WN]`:
- Offset = k_inner * (BN + 4) * sizeof(float) + warp_n * WN * sizeof(float)
- = k_inner * 272 + warp_n * 64
For k_inner=0, warp_n=0: 0 (aligned)
For k_inner=8, warp_n=0: 8 * 272 = 2176 = 16 * 136. Aligned.
For k_inner=0, warp_n=1: 64 = 16 * 4. Aligned.
Great, alignment is fine.
Now, there might be an issue with `wmma::store_matrix_sync`. The output pointer `C_ptr + c_row_offset * n + c_col_offset` has leading dimension `n`. For C, each row is `n * sizeof(float) = 2048 * 4 = 8192 bytes`, divisible by 16. And `c_col_offset = warp_n * 16`, also divisible by 16. So alignment is good.
One more concern: the total number of blocks with 64x64 tiles is 32768. On H100 with 132 SMs, that's ~248 blocks per SM. At 4 concurrent blocks per SM, that's ~62 waves. This is plenty to keep all SMs busy.
But the launch configuration might be tricky. I should use `dim3 grid(32768)` and `dim3 block(512)`.
Actually, let me use a smaller tile to increase parallelism. What about 32x32 tiles with 128 threads (4 warps)?
- Blocks: 128 * 16 * 64 = 131072
- Threads: 128
- Blocks per SM: min(2048/128, ...) = 16
- Waves: 131072 / (132 * 16) = 62
- Each block computes 32x32 = 1024 elements
- But the overhead of so many blocks might be significant.
I'll stick with 64x64 and 512 threads.
Actually, let me try with 64x32 and 256 threads first. This gives:
- Blocks: 128 * 8 * 64 = 65536
- Threads: 256
- Blocks per SM: 8
- Waves: 65536 / (132 * 8) = 62
- Each block: 64x32 = 2048 elements
- A_smem: 64x64 = 16KB
- B_smem: 64x32 = 8KB
- Total: 24KB
With 256 threads and 24KB shared, blocks per SM = min(2048/256, 228/24) = min(8, 9.5) = 8. 100% occupancy!
And each warp handles 16x16: 2 warps in M, 4 warps in N? Wait:
- Block tile M=64, N=32
- Warp tile M=16, N=16
- Warps in M: 64/16 = 4
- Warps in N: 32/16 = 2
- Total warps: 8 = 256 threads
This works! And higher occupancy might help.
Let me go with:
- BM=64, BN=32, BK=64
- 8 warps = 256 threads
Kernel code:
```cpp
__global__ void bmm_tf32_kernel(
const float* __restrict__ A,
const float* __restrict__ B,
float* __restrict__ C,
int batch_size, int m, int n, int k)
{
const int BM = 64;
const int BN = 32;
const int BK = 64;
const int WM = 16;
const int WN = 16;
const int WK = 8;
int warp_id = threadIdx.x / 32;
int lane_id = threadIdx.x % 32;
int warp_m = warp_id / 2; // 4 warps in M
int warp_n = warp_id % 2; // 2 warps in N
int n_tiles = n / BN; // 64
int m_tiles = m / BM; // 8
int tiles_per_batch = m_tiles * n_tiles; // 512
int batch_idx = blockIdx.x / tiles_per_batch;
int tile_idx = blockIdx.x % tiles_per_batch;
int m_tile = tile_idx / n_tiles;
int n_tile = tile_idx % n_tiles;
int m_start = m_tile * BM;
int n_start = n_tile * BN;
const float* A_ptr = A + (long long)batch_idx * m * k;
const float* B_ptr = B + (long long)batch_idx * k * n;
float* C_ptr = C + (long long)batch_idx * m * n;
__shared__ float A_smem[BM][BK + 4];
__shared__ float B_smem[BK][BN + 4];
wmma::fragment<wmma::matrix_a, WM, WN, WK, wmma::precision::tf32, wmma::row_major> a_frag;
wmma::fragment<wmma::matrix_b, WM, WN, WK, wmma::precision::tf32, wmma::row_major> b_frag;
wmma::fragment<wmma::accumulator, WM, WN, WK, float> acc_frag;
wmma::fill_fragment(acc_frag, 0.0f);
for (int k_outer = 0; k_outer < k; k_outer += BK) {
// Load A to shared memory: A[m_start..m_start+BM, k_outer..k_outer+BK]
// 64x64 = 4096 floats. 256 threads, each loads 16 floats = 4 float4
#pragma unroll
for (int i = 0; i < 4; i++) {
int t = threadIdx.x + i * blockDim.x; // 0..1023
int row = t / (BK / 4); // 0..63 (since 1024/16 = 64)
int col4 = t % (BK / 4); // 0..15
if (row < BM) {
const float4* A_global = (const float4*)(A_ptr + (m_start + row) * k + k_outer + col4 * 4);
float4* A_shared = (float4*)(&A_smem[row][col4 * 4]);
*A_shared = *A_global;
}
}
// Load B to shared memory: B[k_outer..k_outer+BK, n_start..n_start+BN]
// 64x32 = 2048 floats. 256 threads, each loads 8 floats = 2 float4
#pragma unroll
for (int i = 0; i < 2; i++) {
int t = threadIdx.x + i * blockDim.x; // 0..511
int row = t / (BN / 4); // 0..63 (512/8 = 64)
int col4 = t % (BN / 4); // 0..7
if (row < BK) {
const float4* B_global = (const float4*)(B_ptr + (k_outer + row) * n + n_start + col4 * 4);
float4* B_shared = (float4*)(&B_smem[row][col4 * 4]);
*B_shared = *B_global;
}
}
__syncthreads();
#pragma unroll
for (int k_inner = 0; k_inner < BK; k_inner += WK) {
wmma::load_matrix_sync(a_frag, &A_smem[warp_m * WM][k_inner], BK + 4);
wmma::load_matrix_sync(b_frag, &B_smem[k_inner][warp_n * WN], BN + 4);
wmma::mma_sync(acc_frag, a_frag, b_frag, acc_frag);
}
__syncthreads();
}
// Store C
int c_row = m_start + warp_m * WM;
int c_col = n_start + warp_n * WN;
wmma::store_matrix_sync(C_ptr + c_row * n + c_col, acc_frag, n, wmma::mem_row_major);
}
```
Wait, I need to double check the A load indexing. Let me trace through:
- t ranges from 0 to 1023 in steps of 256 (4 iterations total, covering all 1024 values)
- Actually, threadIdx.x ranges 0..255
- i=0: t = 0..255
- i=1: t = 256..511
- i=2: t = 512..767
- i=3: t = 768..1023
For each t:
- row = t / 16 (since BK/4 = 64/4 = 16)
- col4 = t % 16
For t=0: row=0, col4=0
For t=1: row=0, col4=1
...
For t=15: row=0, col4=15
For t=16: row=1, col4=0
Each thread (with a fixed t) loads 1 float4 = 4 floats from A_global and stores to A_shared.
Total floats loaded: 1024 threads * 4 floats = 4096 floats. Coverage: 64 rows * 64 cols = 4096. Good.
For thread 0 in warp 0:
- t=0, row=0, col4=0
- A_global address: A_ptr + (m_start + 0) * k + k_outer + 0*4 = A_ptr + m_start * k + k_outer
- Loads A[m_start][k_outer..k_outer+3]
Thread 1:
- t=1, row=0, col4=1
- A_global: A_ptr + m_start * k + k_outer + 4
- Loads A[m_start][k_outer+4..k_outer+7]
Threads 0..15 in the first warp all load from row m_start, consecutive columns. Perfect coalescing!
Similarly for B:
- t ranges 0..511 (2 iterations of 256)
- row = t / 8 (BN/4 = 32/4 = 8)
- col4 = t % 8
For t=0: row=0, col4=0
For t=7: row=0, col4=7
For t=8: row=1, col4=0
Total floats: 512 * 4 = 2048. Coverage: 64 rows * 32 cols = 2048.
Thread 0 loads B[k_outer][n_start..n_start+3]
Thread 1 loads B[k_outer][n_start+4..n_start+7]
...
Thread 7 loads B[k_outer][n_start+28..n_start+31]
Thread 8 loads B[k_outer+1][n_start..n_start+3]
Again, threads 0..7 in a warp load from row k_outer, consecutive columns. Coalesced!
This looks good.
Now let me think about whether there are any bank conflicts when writing to shared memory.
For A_smem writes:
- A_smem[row][col4*4] to A_smem[row][col4*4+3] are 4 consecutive floats = 16 bytes.
- Threads in a warp: each writes a `float4` = 16 bytes.
- Thread 0 writes to address 0 (bank 0)
- Thread 1 writes to address 16 (bank 4)
- Thread 2 writes to address 32 (bank 8)
- ...
- Thread 7 writes to address 112 (bank 28)
- Thread 8 writes to address 128 (bank 0) since 128/4 = 32 % 32 = 0
Wait, thread 8 writes to row=0, col4=8, which is address 8*4*4 = 128 bytes = bank 0.
Thread 0 also writes to bank 0 (address 0). But thread 0 and thread 8 are in the same warp! They both try to write to bank 0 but different addresses (0 vs 128).
On modern GPUs, there are no bank conflicts for `float4` (128-bit) accesses as long as the accesses are aligned and contiguous? Actually, in shared memory, a 128-bit access from each thread is split into 4 x 32-bit accesses, each going to a different bank. Or is it treated as one transaction?
Actually, for `float4` stores to shared memory, each thread issues 4 x 32-bit stores. The first store from thread 0 goes to bank 0, thread 1 to bank 4, etc. The second store from thread 0 goes to bank 1, thread 1 to bank 5, etc. With 32 threads, some might conflict.
But actually, shared memory bank conflicts happen at the 32-bit level for compute capability >= 70. Each 32-bit word maps to a bank. For `float4`, thread t accesses banks (t*4 + 0, t*4 + 1, t*4 + 2, t*4 + 3) mod 32.
For threads 0..31:
- Thread 0: banks 0,1,2,3
- Thread 1: banks 4,5,6,7
- ...
- Thread 7: banks 28,29,30,31
- Thread 8: banks 0,1,2,3 → CONFLICT with thread 0!
Yes, there would be bank conflicts for the shared memory writes. But this is only if threads 0 and 8 execute their stores in the same cycle. Actually, with 128-bit accesses, I believe the hardware can handle them without bank conflicts if they're properly aligned, because the access is atomic at the 128-bit level.
Wait, no. Shared memory bank conflicts are at the 32-bit level. For a `float4` store, there are 4 separate 32-bit stores, and each can have conflicts.
However, since threads 0-7 write to banks 0-31 (each thread writes 4 consecutive banks), and threads 8-15 also write to banks 0-31, there is a 2-way conflict for each bank (threads t and t+8 both write to banks 4t through 4t+3, but shifted).
Actually, the exact mapping depends on the starting address. Let me be more careful.
A_smem[row][col] where col = col4 * 4.
For a fixed row, the address of A_smem[row][col] in bytes:
`row * (BK + 4) * 4 + col * 4 = row * 272 + col * 4`
Bank = (address / 4) % 32 = (row * 68 + col) % 32
For row=0, col = col4 * 4:
Bank = (0 + col4 * 4) % 32 = col4 * 4 % 32
For thread t=0..15 (first 16 threads in warp):
- col4 = t % 16 = 0..15
- Bank = (t % 16) * 4 % 32
t=0: col4=0, bank=0
t=1: col4=1, bank=4
t=2: col4=2, bank=8
...
t=7: col4=7, bank=28
t=8: col4=8, bank=0 (32 % 32 = 0) ← CONFLICT with t=0
t=9: col4=9, bank=4 (36 % 32 = 4) ← CONFLICT with t=1
...
t=15: col4=15, bank=60%32=28 ← CONFLICT with t=7
So threads 0-7 and 8-15 have bank conflicts when writing float4! This is a 2-way conflict.
To fix this, we need padding that ensures different col4 values don't map to the same bank. We want:
`(row * 68 + col4 * 4) % 32` to be unique for col4=0..15, or at least not have duplicates in the same 16-thread group.
Currently: (col4 * 4) % 32 for col4=0..15 gives: 0,4,8,12,16,20,24,28,0,4,8,12,16,20,24,28.
Duplicates at col4=0,8; 1,9; etc.
If we change the row pad such that the effective columns per row is not 68 but something else. For per-row offset 272 bytes = 68 words. The issue is that 4 * 16 = 64, and 64 % 32 = 0 (since 64 is a multiple of 32). If instead the row stride in words is coprime with 32, then consecutive col4 values would hit different banks.
But col4 * 4 means we step by 4 in word index. We need (pad + 16*4) % 32 != 0, i.e., (pad + 64) % 32 != 0, which means pad % 32 != 0.
Our current pad is 4 words, so total words per row is 68. 68 % 32 = 4. Let's check:
Bank = (row * 68 + col4 * 4) % 32 = (row * 4 + col4 * 4) % 32 = 4 * (row + col4) % 32
For row=0: bank = 4 * col4 % 32
This still gives: 0,4,8,12,16,20,24,28,0,4,8,12,16,20,24,28. Same duplicates!
The issue is that 4 * col4 mod 32 has period 8, so col4 and col4+8 give the same bank.
To avoid this, we need the constant multiplier to be odd (coprime with 32). If the word step was 1, then col4=0..15 gives 0..15, no duplicates. But our columns are in groups of 4.
Actually, if each thread writes a `float4`, the 4 elements map to banks base, base+1, base+2, base+3. For thread t in a warp, the base bank is:
base(t) = (row * stride + t * 4) % 32
We want base(t) and base(t+8) to differ.
base(t+8) = (row * stride + (t+8) * 4) % 32 = base(t) + 32 % 32 = base(t).
So with 32 threads in a warp and float4 accesses, threads t and t+8 will ALWAYS have the same base bank, causing conflicts if they write to the same row at the same time.
Wait, but threads t=0..15 might not all be in the same warp processing the same row. In our load code, threads 0..15 all have row=0 (since t/16 = 0 for t=0..15). So warp 0 has threads 0..31, and threads 0..15 all write to row=0, while threads 16..31 write to row=1. So threads 0..15 in warp 0 write to row=0, and within these 16 threads, t and t+8 write to the same base bank.
Actually, the issue is:
- t=0: row=0, col4=0, banks 0,1,2,3
- t=8: row=0, col4=8, banks 32%32=0,1,2,3
So both threads 0 and 8 write to banks 0,1,2,3. This is a 2-way bank conflict.
Similarly:
- t=1: row=0, col4=1, banks 4,5,6,7
- t=9: row=0, col4=9, banks 4,5,6,7
This is indeed a 2-way conflict for each group of 4 banks.
To fix this, we need either:
1. Use a different access pattern so that no two threads in a warp access the same bank for any element.
2. Accept the 2-way conflict (it doubles the shared memory store time, but stores might not be the bottleneck).
Is a 2-way conflict terrible? It means each warp takes 2x as long for the shared memory store. But our kernel is compute-bound, so a 2x slowdown in shared memory stores might be acceptable.
Alternatively, we can change how we distribute the work. If instead of having 16 threads per row, we have fewer, or if we transpose the shared memory layout.
Let me try a different approach: have each thread load 1 float (not float4), so that consecutive threads access consecutive floats. Then:
- Thread t loads A_smem[row][col] where col = t % 64 (for some row distribution)
- Bank = (row * 68 + col) % 32 = (row * 4 + col) % 32
- For 32 threads in a warp with consecutive cols: banks are (base, base+1, ..., base+31) % 32 = all 32 distinct banks. No conflict!
But loading 1 float per thread means 4x more instructions, potentially hurting performance. However, global memory loads would still be coalesced (each thread loads 4 bytes from consecutive addresses).
Let me try this: each thread loads a single float. Then in the store to shared memory, there are no bank conflicts.
With BM=64, BK=64, total elements = 4096. 256 threads, each loads 4096/256 = 16 floats. That's 16 loads per thread. With float loads instead of float4 loads, we have more instructions but no bank conflicts.
Alternatively, we could use `__syncthreads()` and let the hardware handle it; a 2-way conflict might not be too bad.
Actually, for A_smem, I realize there's another issue: threads 16..31 have row=1. So warp 0 has:
- threads 0..15: row=0
- threads 16..31: row=1
For row=1, banks = (1 * 68 + col4 * 4) % 32 = (4 + col4 * 4) % 32.
Thread 16 (col4=0): banks 4,5,6,7
Thread 24 (col4=8): banks 36%32=4,5,6,7
So conflicts also happen within threads 16..31 (row=1).
But threads 0..15 and 16..31 don't conflict with each other because they write to different rows and thus different banks (except at boundaries). Thread 0 writes to banks 0,1,2,3; thread 16 writes to banks 4,5,6,7. No conflict between these groups.
So the conflict is only 2-way within each group of 16 threads.
I think this is acceptable. Let me keep the float4 loads for efficiency and accept the mild bank conflicts.
For B_smem:
BK=64, BN=32. Total elements = 2048. 256 threads * 2 loads * 4 floats = 2048.
t = 0..511
row = t / 8 (BN/4 = 8)
col4 = t % 8
warp 0 threads 0..31:
- t=0..7: row=0
- t=8..15: row=1
- t=16..23: row=2
- t=24..31: row=3
For row=0, col4=0..7:
Bank = (0 * 68 + col4 * 4) % 32 = col4 * 4 % 32 = 0,4,8,12,16,20,24,28. All distinct!
For row=1, col4=0..7:
Bank = (1 * 68 + col4 * 4) % 32 = (4 + col4*4) % 32 = 4,8,12,16,20,24,28,0. All distinct!
For row=2: 8,12,16,20,24,28,0,4. All distinct!
For row=3: 12,16,20,24,28,0,4,8. All distinct!
Wait, BN + 4 = 36 words per row. 36 * 4 = 144 bytes per row.
Bank = (row * 36 + col4 * 4) % 32 = (4*row + 4*col4) % 32 = 4*(row + col4) % 32.
For row=0, col4=0..7: 0,4,8,12,16,20,24,28. Distinct!
For row=1, col4=0..7: 4,8,12,16,20,24,28,0. Distinct!
For row=2, col4=0..7: 8,12,16,20,24,28,0,4. Distinct!
For row=3, col4=0..7: 12,16,20,24,28,0,4,8. Distinct!
And threads in a warp:
- t=0: row=0, col4=0, banks 0,1,2,3
- t=1: row=0, col4=1, banks 4,5,6,7
- ...
- t=7: row=0, col4=7, banks 28,29,30,31
- t=8: row=1, col4=0, banks 4,5,6,7
Wait, t=1 writes to banks 4,5,6,7 and t=8 writes to banks 4,5,6,7! CONFLICT between threads 1 and 8!
Oh no! So for B_smem, there are conflicts too.
Bank for B_smem[row][col] = (row * 36 + col) % 32.
Thread t=1: row=0, col=4..7 → banks 4,5,6,7.
Thread t=8: row=1, col=0..3 → banks 36%32=4, 37%32=5, 38%32=6, 39%32=7.
Yes, threads 1 and 8 conflict on all 4 banks!
Hmm, this is worse than A_smem. We have a 2-way conflict for B_smem as well.
Let me think about how to fix B_smem. We need the starting bank for each thread's float4 to be distinct within the warp.
With row_stride = 36 words = 144 bytes:
base_bank(t) = (row(t) * 36 + col4(t) * 4) % 32 = 4 * (row(t) * 9 + col4(t)) % 32
Wait, 36 % 32 = 4. So row(t) * 36 ≡ row(t) * 4 (mod 32).
base_bank = (4 * row + 4 * col4) % 32 = 4 * (row + col4) % 32.
row = t / 8, col4 = t % 8.
base_bank = 4 * (t/8 + t%8) % 32 = 4 * f(t) % 32.
For t=0..31:
t=0: 4*(0+0)=0
t=1: 4*(0+1)=4
t=2: 4*(0+2)=8
t=3: 4*(0+3)=12
t=4: 4*(0+4)=16
t=5: 4*(0+5)=20
t=6: 4*(0+6)=24
t=7: 4*(0+7)=28
t=8: 4*(1+0)=4 ← duplicate with t=1
t=9: 4*(1+1)=8 ← duplicate with t=2
...
Yes, duplicates at t and t+7 (when row increases by 1 and col4 wraps).
Wait, t+7: row increases by ~1, col4 decreases by ~1. So row+col4 might stay similar.
To avoid this, we need the row stride in words to be odd (or at least such that 4*(row*stride_w + col4) gives unique values mod 32 for all t in 0..31).
Actually, since we're multiplying by 4, the base bank is always a multiple of 4. There are only 8 distinct base banks: 0,4,8,12,16,20,24,28. With 32 threads, some will definitely share base banks.
But the question is whether threads with the same base bank execute their stores in the same cycle. Since each thread writes a float4 (4 consecutive banks), if two threads have the same base bank, they write to exactly the same 4 banks. This is a true bank conflict.
Can we fix this by having each thread write only 1 float instead of 4? Then:
- Thread t writes to bank = (row * 36 + col) % 32
- col = (t % 8) * 4 + offset, where offset = 0 for the first of 4 stores.
For t=0: row=0, col=0, bank=0
For t=1: row=0, col=1, bank=1
...
For t=31: row=3, col=7, bank=(108+7)%32=115%32=19
All banks 0..31 might be covered without conflict if we write 1 float at a time. But we need 4 writes per thread.
Alternatively, what if we change the B_smem layout to be column-major or transposed?
If B_smem is stored as [BN][BK] (transposed), then each thread loading from B_global writes to a different pattern in shared memory. But wmma::load_matrix_sync for matrix_b expects row-major or col-major based on the fragment type. Since we use row_major for B, B_smem should match the logical KxN layout of B.
Wait, another idea: what if we just accept the bank conflicts? Modern GPUs have mechanisms to handle some conflicts efficiently, and our kernel is compute-bound anyway. A 2-way conflict might add only a few cycles to the shared memory operations, which are hidden by the compute.
I think it's fine to proceed with the float4 loads and accept the mild bank conflicts. The critical path is the wmma operations, not the shared memory stores.
Actually, one more optimization: we can pipeline by using double-buffered shared memory. While the current K_TILE is being computed, we load the next K_TILE. This hides the global memory latency behind the computation.
For double buffering with K_TILE=64:
- A_smem: 2 * 64 * 64 * 4 = 32 KB
- B_smem: 2 * 64 * 32 * 4 = 16 KB
- Total: 48 KB
- Blocks per SM: min(2048/256, 228/48) = min(8, 4.75) = 4
Occupancy drops to 50%. With single-buffer:
- Total: 24 KB
- Blocks per SM: min(8, 9.5) = 8
- Occupancy: 100%
Since our working set for each block is small and we have many blocks, single-buffer with high occupancy is probably better. The memory latency can be hidden by having other warps/blocks execute while one waits.
So I'll keep single-buffer.
Now let me think about whether there are other optimization opportunities.
One thing: using `__launch_bounds__(256)` to help the compiler optimize register usage.
Another: using `#pragma unroll` on the k_inner loop.
Also, I should make sure to use the correct absolute indexing with long long for the pointers, since the batch matrices might be large. In our case, batch_idx * m * k can be up to 128 * 512 * 1024 = 67M, which fits in int. But better to use long long just in case.
Wait, one more thing: I should check if 32768 blocks or 65536 blocks is enough for the H100. 65536 is the max gridDim.x on some GPUs, but modern GPUs support much larger, up to 2^31-1. The H100 definitely supports large grids.
32768 blocks is fine.
OK, I think the kernel is ready. Let me also add a host wrapper function that launches the kernel and handles the grid/block dimensions.
The Python file will look like:
```python
import torch
from torch.utils.cpp_extension import load_inline
cuda_source = """
#include <cuda.h>
#include <cuda_runtime.h>
#include <mma.h>
using namespace nvcuda;
__global__ void ... // kernel
torch::Tensor bmm_forward(torch::Tensor A, torch::Tensor B) {
int batch_size = A.size(0);
int m = A.size(1);
int k = A.size(2);
int n = B.size(2);
auto C = torch::empty({batch_size, m, n}, A.options());
int n_tiles = n / 32;
int m_tiles = m / 64;
int tiles_per_batch = m_tiles * n_tiles;
int total_blocks = batch_size * tiles_per_batch;
dim3 grid(total_blocks);
dim3 block(256);
bmm_tf32_kernel<<<grid, block, 0, at::cuda::getCurrentCUDAStream()>>>(
A.data_ptr<float>(), B.data_ptr<float>(), C.data_ptr<float>(),
batch_size, m, n, k);
return C;
}
"""
cpp_sources = "..." # Usually empty or just includes for pybind
functions = ["bmm_forward"]
module = load_inline(
name="bmm_cuda",
cpp_sources=cpp_sources,
cuda_sources=cuda_source,
functions=functions,
extra_cuda_cflags=["-O3", "--use_fast_math", "-arch=sm_90"],
)
```
Wait, I need to be careful about the `cpp_sources` and `cuda_sources` parameters. Since we need to include `<mma.h>` which is a CUDA header, the kernel code should be in `cuda_sources`. Any host-side Torch binding code that calls the kernel can also be in `cuda_sources` (as .cu files can contain host code). Or we can put the kernel in `cuda_sources` and the wrapper in `cpp_sources`.
Actually, for `load_inline`, `cpp_sources` and `cuda_sources` are separate. The kernel should go in `cuda_sources`, and the host wrapper should probably also go in `cuda_sources` since it needs to call the CUDA kernel.
PyTorch headers can be included in `cuda_sources` too. Let me structure it properly.
One more thing: I should make sure we're compiling for sm_90 (Hopper). The extra CUDA flags should include `-gencode=arch=compute_90,code=sm_90`.
Actually, if the CUDA compiler defaults to a lower arch, wmma might still work but not use Hopper-specific optimizations. We definitely want sm_90.
Let me also add `"-maxrregcount=128"` or similar to control register usage, but the compiler should manage this.
Actually, I want to check if `wmma::precision::tf32` is supported with the default nvcc that PyTorch uses. If PyTorch is using CUDA 11.8 or later, it should support Hopper and TF32.
For H100, we need CUDA 11.8+ or CUDA 12.x. The kernel should compile fine.
Let me also think about edge cases and correctness:
- The inputs A and B must be contiguous in the relevant dimensions.
- The shapes must be divisible by our tile sizes: batch=128 (irrelevant), m=512 (divisible by 64), k=1024 (divisible by 64), n=2048 (divisible by 32).
- Our kernel assumes these divisibility conditions. Since the problem gives fixed shapes, this is fine.
Wait, I need to handle the case where the inputs might not be exactly these shapes. The problem says the shapes are fixed, but it's good practice to either assert or handle generically. For simplicity, I'll assert or just assume the shapes.
Actually, the ModelNew forward should work on any inputs of the right shape, but for performance and simplicity, I'll write a kernel that works for the specific sizes.
One more optimization: we can precompute some indices and use them. But the compiler should optimize this.
Let me also consider whether to use an even larger tile size. With BM=128 and BN=64:
- A_smem: 128x64 = 32KB
- B_smem: 64x64 = 16KB
- Total: 48KB
- Threads: 512 (32 warps: 8x4 grid for 128x64 tile with 16x16 warps)
- Blocks: 128 * 4 * 32 = 16384
- Blocks per SM: min(2048/512, 228/48) = min(4, 4.75) = 4
Occupancy is lower (50% vs 100%), but each block does more work. Fewer blocks might mean less overhead. Let me try both.
Actually, let me keep 64x32 for now and see the performance.
There's one more consideration. TF32 wmma fragments use the type `precision::tf32`, but I need to make sure the `wmma::fill_fragment` and `wmma::store_matrix_sync` work correctly with float accumulators.
Yes:
```cpp
wmma::fragment<wmma::accumulator, 16, 16, 8, float> acc_frag;
wmma::fill_fragment(acc_frag, 0.0f);
wmma::store_matrix_sync(C_ptr + offset, acc_frag, n, wmma::mem_row_major);
```
This should be correct.
One issue: when we store via `wmma::store_matrix_sync` to global memory, the hardware handles the store. But the output C array is in FP32, which is what we want.
Now let me think about whether we need any additional synchronization between blocks. No, each block computes a disjoint tile of C.
Another subtlety: if A or B are not contiguous in memory, our pointer arithmetic might be wrong. The problem says they're PyTorch tensors, which are typically contiguous when created with `torch.rand`. And `torch.bmm` requires specific strides anyway.
Actually, `torch.bmm` works with non-contiguous inputs too. For correctness, we should probably check if A and B are contiguous, and if not, make copies. But since `get_inputs()` returns contiguous tensors, and the task says produce numerically equivalent outputs, we should handle it.
I'll add a check in the PyTorch wrapper:
```python
if not A.is_contiguous():
A = A.contiguous()
if not B.is_contiguous():
B = B = B.contiguous()
```
OK, I think the kernel is ready to be written. Let me draft it and compile.
Actually, one more thing: I realize `wmma::precision::tf32` might trigger some compiler warnings or might need a specific CUDA version. As a backup, if TF32 doesn't compile or work, I could fall back to pure FP32 tiles.
But let me try TF32 first.
Let me also check if there are any issues with the `float4` loads. In CUDA, `float4` requires 16-byte alignment. For A:
- A_ptr + (m_start + row) * k + k_outer + col4 * 4
- (m_start + row) * k is divisible by 4 (k=1024)
- k_outer is divisible by 4 (multiples of 64)
- col4 * 4 is divisible by 4
- So each address is divisible by 16 for `float4` alignment?
- Yes: all terms are multiples of 4, and the starting offset is already aligned to 16 bytes because A is allocated with 256-byte alignment by PyTorch.
For B:
- B_ptr + (k_outer + row) * n + n_start + col4 * 4
- (k_outer + row) * n: n=2048, divisible by 4
- n_start: multiples of 32, divisible by 4
- col4 * 4: divisible by 4
- Total: divisible by 16.
Alignment is good.
One more subtlety: the shared memory arrays `A_smem` and `B_smem` are declared as 2D arrays with padding. For `float4` accesses to shared memory, the starting address must also be aligned to 16 bytes.
For A_smem:
- A_smem[row][col4*4]: the address is `&A_smem[0][0] + row * (BK+4) + col4*4`
- Row 0, col4=0: base address. Aligned because the array starts at shared memory base which is aligned.
- Row 1, col4=0: offset = 1 * 68 * sizeof(float) = 272 bytes = 16 * 17. Divisible by 16. ✓
- Row 2: 544 = 16 * 34. Divisible by 16. ✓
- In general: row * 68 * 4 = row * 272. 272 = 16 * 17. So every row is aligned to 16 bytes. ✓
For B_smem:
- B_smem[row][col4*4]: offset = row * (BN+4) * sizeof(float) + col4*4
- BN+4 = 36. Row offset = row * 36 * 4 = row * 144.
- 144 = 16 * 9. So every row is aligned to 16 bytes. ✓
Great, shared memory alignment is also good.
Now, please let me think about potential numerical issues one more time. Both our kernel and torch.bmm use TF32 precision (assuming PyTorch default). The reference implementation computes `torch.bmm(A, B)` which on H100 with default settings uses TF32. Our kernel also uses TF32 via WMMA. So the numerical results should be very close, likely well within 1e-4 tolerance.
However, there's a subtlety: torch.bmm through cuBLAS might use a different algorithm or ordering of operations than our WMMA kernel. The associativity of floating-point addition means different accumulation orders can produce different results. TF32 has even more pronounced differences.
For example, cuBLAS might use a divide-and-conquer approach or split K, while our kernel accumulates sequentially in K. This could cause differences beyond 1e-4.
One way to reduce this: ensure we're doing the exact same sequence of operations, or use a kernel that matches cuBLAS's approach as closely as possible.
Actually, for a simple blocked GEMM, both cuBLAS and our kernel should accumulate in similar order: each output element accumulates K values in sequence. The difference might come from:
- How the K dimension is tiled (e.g., cuBLAS might use larger K tiles with different accumulation order)
- Whether cuBLAS uses split-K (parallelizing the K reduction)
For our specific problem sizes (K=1024), split-K might or might not be used by cuBLAS. It's more common for very large K.
I think the sequential accumulation within each thread/warp should match closely enough. But it's something to be aware of.
Let me also consider using `float` accumulator initialized to 0, and the wmma operations. The WMMA accumulator is a set of 16x16 floats, and multiple mma_sync calls accumulate into it in order.
Actually, one cool feature: we can use block-level synchronization inside the kernel to enable split-K, where multiple blocks compute partial sums for the same output tile and then reduce. But this adds complexity. For K=1024, split-K is probably unnecessary and could hurt more than help due to atomic operations or extra synchronization.
OK, I'm going to write the kernel and test it.
Wait, I just realized I need to think about one more thing. The `wmma::load_matrix_sync` for row-major matrix_a expects the shared memory to contain the elements in row-major order, and the leading dimension parameter tells it the pitch between rows. But WMMA fragments use a specific internal layout that maps threads to elements in a complex way.
The key question is: when I call `wmma::load_matrix_sync(a_frag, ptr, ld)`, does it correctly load a 16x8 tile (for TF32) from the shared memory starting at `ptr` with row stride `ld`?
Yes, according to the CUDA docs, `load_matrix_sync` handles the remapping from the dense row-major layout in memory to the fragment's internal distributed layout across the warp.
For matrix_a with row_major layout:
- The memory pointed to by `ptr` should contain a MxK matrix in row-major order.
- `ld` is the leading dimension (number of columns, i.e., the row stride).
- For our case: ptr = `&A_smem[warp_m * 16][k_inner]`, ld = `BK + 4 = 68`.
Wait, is `ld` the number of elements or bytes? The docs say "leading dimension", and for row-major it's the number of columns (in elements). Yes, it's in elements, not bytes.
For A_smem, each row has BK+4 = 68 elements. So ld = 68.
For B_smem, each row has BN+4 = 36 elements. So ld = 36.
Wait, B_smem is [BK][BN+4], and we're loading a KxN tile from it (or actually, for matrix_b with row_major, it's K rows by N columns). So ld should be BN+4 = 36.
Actually, for the B fragment in matrix_b, the logical dimensions are KxN, and it's stored in row-major format. So the leading dimension is the number of columns = N = BN (for our tile), but since we have padding, it's BN+4.
So:
```cpp
wmma::load_matrix_sync(a_frag, &A_smem[warp_m * WM][k_inner], BK + 4);
wmma::load_matrix_sync(b_frag, &B_smem[k_inner][warp_n * WN], BN + 4);
```
Hmm wait, `&B_smem[k_inner][warp_n * WN]` - is this valid C++ syntax for a 2D array? Yes.
Actually, in CUDA kernel code, `B_smem` is declared as `__shared__ float B_smem[BK][BN + 4];`. So `&B_smem[k_inner][warp_n * WN]` gives the address of element [k_inner][warp_n * WN]. And the leading dimension is BN+4.
This is correct.
Similarly for A: `&A_smem[warp_m * WM][k_inner]` with leading dimension BK+4.
Actually wait, I want to double check the fragment dimensions. For TF32:
- matrix_a: 16 x 8 (M x K)
- matrix_b: 8 x 16 (K x N) ? Or 16 x 16?
Let me verify in the CUDA docs.
For `mma.sync.aligned.m16n16k8`:
- A is MxK = 16x8
- B is KxN = 8x16
- C is MxN = 16x16
So:
- `wmma::fragment<matrix_a, 16, 16, 8, ...>` represents A as 16x8
- `wmma::fragment<matrix_b, 16, 16, 8, ...>` represents B as 8x16
For the B fragment, the layout applies to the 8x16 matrix. If we use row_major, the memory should be an 8x16 matrix in row-major order.
In our shared memory B_smem[k_inner][warp_n * WN], we're accessing starting at row k_inner, column warp_n * WN. The memory region from there is a BK x BN submatrix (64x32), but we're only loading an 8x16 tile from it.
With row_major layout and ld = BN+4 = 36:
- wmma reads B[k_inner + i][warp_n * WN + j] for i in [0,8), j in [0,16).
- Address = &B_smem[k_inner][warp_n * WN] + i * 36 + j.
- This is correct row-major access.
For A fragment, row_major, ld = BK+4 = 68:
- wmma reads A[warp_m * WM + i][k_inner + j] for i in [0,16), j in [0,8).
- Address = &A_smem[warp_m * WM][k_inner] + i * 68 + j.
- This is correct.
Great.
Now for the store:
```cpp
wmma::store_matrix_sync(C_ptr + c_row * n + c_col, acc_frag, n, wmma::mem_row_major);
```
This stores a 16x16 tile starting at C_ptr + c_row * n + c_col, with leading dimension n. And we use `wmma::mem_row_major` because C is row-major.
This seems correct.
One thing: C_ptr points to the start of the batch's C matrix. c_row = m_start + warp_m * WM, c_col = n_start + warp_n * WN. So the absolute address is correct.
Now I should also handle the case where c_row * n + c_col might overflow int. In our case:
- c_row <= 512, n = 2048, c_col <= 2048
- c_row * n <= 512 * 2048 = 1,048,576
- Plus c_col: max ~1,050,624
- Batch offset: max ~128 * 512 * 2048 = 134,217,728
This fits in a 32-bit signed int (max 2,147,483,647). But I'll use long long for safety.
Actually, for the kernel launch, the total number of blocks is batch_size * m_tiles * n_tiles = 128 * 8 * 64 = 65536. This fits in a 32-bit int.
Alright, let me write the complete code and compile it.
Wait, one more optimization thought. Can I increase block tile size to BM=128, BN=128, BK=16 with a different warp arrangement?
For BM=128, BN=128:
- Warps: 8x8 = 64 warps = 2048 threads. That's the max per block.
- A_smem for BK=16: 128x16 = 2KB
- B_smem for BK=16: 16x128 = 2KB
- Total: 4KB. Very small!
- Inner loop: 1 iteration (since BK=16 = WK*2 for TF32). Wait, K=1024, so outer loop has 64 iterations.
- Each outer iteration: load A[128][16] and B[16][128], then 2 wmma iterations per warp.
With 2048 threads per block:
- A load: 128*16 = 2048 floats. Each thread loads 1 float. That's slow.
- Or each thread loads 1 float2 = 2 floats, so 1024 active threads. But we have 2048 threads...
Actually, with 2048 threads, each could load 1 float, covering 2048 elements. A[128][16] has exactly 2048 elements! Perfect coalescing if arranged properly.
But having 2048 threads per block means only 1 block per SM (thread-limited). Occupancy is very low. We'd need many blocks (128 * 4 * 16 = 8192 blocks), giving 8192/132 = 62 blocks per SM, but only 1 concurrent. So 62 waves. The kernel might still be fast due to high utilization per block, but low occupancy means less latency hiding.
With 512 threads per block and BM=128, BN=64:
- Warps: 8x4 = 32 warps = 1024 threads. Hmm, that's 1024 threads.
- Or arrange as 8x2 = 16 warps = 512 threads, computing 128x32.
- Then blocks = 128 * 4 * 64 = 32768.
With 512 threads per block (16 warps), blocks per SM = 4 (thread limited).
Total waves = 32768 / (132 * 4) = 62.
A_smem: 128x32 = 16KB, B_smem: 32x32 = 4KB, total = 20KB per K_TILE. With K_TILE=32, shared = 20KB. Blocks per SM = min(4, 228/20=11) = 4.
This seems reasonable.
But honestly, it's getting complicated with all these trade-offs. Let me just write one kernel with 64x32 tiles and 256 threads, compile it, and see if it passes correctness. If it does but is slow, I'll tune.
Actually, wait. I want to try a slightly different approach: use a smaller BK to reduce shared memory and allow more computation reuse. With BK=16, the shared memory is tiny, and we can load everything very quickly. But then we have many more outer iterations.
With BK=16 and K=1024, outer loop = 64 iterations. Each iteration loads A[64][16] and B[16][32]. The loads are small but frequent.
I think BK=32 or 64 is better.
OK, I'll go with the 64x32 tile, 256 threads, BK=64 kernel.
Let me write the code now.
One more thought: I should verify that `wmma::precision::tf32` is available in the nvcc version used by PyTorch. If the PyTorch installation is compiled with CUDA 11.0-11.7 for some reason, TF32 wmma might not be available. But H100 definitely needs CUDA 11.8+, so it should be fine.
Another thought: instead of using `load_inline`, I could put the host wrapper in `cpp_sources` and the device code in `cuda_sources`. But `load_inline` actually treats them differently - `cuda_sources` are compiled with nvcc as `.cu` files, and `cpp_sources` are compiled as `.cpp` files. The wrapper function that creates the torch::Tensor and calls the kernel needs to be in a `.cu` file since it calls a `__global__` function. So I'll put everything in `cuda_sources`.
Let me draft the kernel: