reasoning
Looking at the reference implementation:
- It's a 3D convolution with kernel size `(kernel_size, kernel_size, 1)`
- This is essentially a 2D convolution applied independently for each frame in depth
- It's a grouped convolution potentially
Since the kernel depth is 1, this is much simpler than a full 3D convolution. Each output element only depends on a single depth element.
I should write an optimized CUDA kernel. Since the depth dimension doesn't have any actual convolution (kernel size is 1 in depth), I can think of this as:
- For each depth position, apply a 2D conv to the HxW plane
- With groups support
Let me think about how to optimize this. The kernel size in depth is 1, so:
- out[b, c_out, h, w, d] = sum over c_in, kh, kw of weight[c_out, c_in, kh, kw, 0] * input[b, c_in, h*stride+kh*dilation-padding, w*stride+kw*dilation-padding, d] + bias[c_out]
This is essentially a 2D convolution repeated for each depth slice.
An efficient approach on H100:
- Use tensor cores via WMMA/MA instructions, but for custom kernels without CUTLASS that's hard
- Use shared memory tiling for 2D conv
- Since depth is small (10 in test code), maybe process multiple depth slices
Wait, let me re-read. The test input is:
- batch_size = 16
- in_channels = 3
- out_channels = 64
- kernel_size = 3
- width = 256, height = 256, depth = 10
With groups=1, this is 3 -> 64 channels, 3x3 spatial kernel, depth kernel=1.
Actually, a very efficient approach: since kernel depth is 1, I can reshape the input to merge batch and depth dimensions, and use a cuDNN/Conv2d. But the task says to write a custom CUDA kernel.
For a custom CUDA kernel, the most efficient approach is:
1. Tile in output space (batch, out_channels, height, width, depth)
2. Load input tiles into shared memory
3. Load weights into shared memory
4. Compute partial results with register tiling
Let me think about the tiling strategy. H100 has 228KB shared memory per SM.
For conv3d with kernel size (3,3,1), stride=1, padding=0:
- Output size: batch=16, out_c=64, Hout=254, Wout=254, Dout=10 (with padding=0)
- Total output elements: 16 * 64 * 254 * 254 * 10 ≈ 6.6 billion elements
Each output element requires: in_channels * 3 * 3 = 3 * 9 = 27 multiply-adds.
For fp32: 6.6B * 27 ≈ 178 GFLOPs. At 67 TFLOPS, compute is ~0.3% of peak, so this is memory bandwidth bound if naive.
But wait - with the standard PyTorch Conv3d, cuDNN is pretty optimized. We need to beat it.
Actually, let me think about this more carefully. The kernel size is (kernel_size, kernel_size, 1) where the last dimension is depth. So for each output position, we only read 1 depth element per weight application. But the weights still have shape [out_channels, in_channels/groups, kernel_size, kernel_size, 1].
One key optimization: since the depth dimension isn't convolved, we can treat the problem as doing a 2D conv on each depth slice, and potentially process multiple depth slices per thread block.
Let me think of tiling:
- Block dimensions: maybe (Bx=16, By=16) for spatial, with each block handling some output channels and some batch/depth elements
- Thread-level parallelization over output channels, spatial, batch*depth
For memory bandwidth:
- Input: 16 * 3 * 256 * 256 * 10 * 4 bytes ≈ 125 MB
- Weights: 64 * 3 * 3 * 3 * 1 * 4 bytes ≈ 7 KB
- Output: 16 * 64 * 254 * 254 * 10 * 4 bytes ≈ 2.6 GB
Total memory if fully read once input and weights and written output once: ~2.7 GB. At 3.35 TB/s, that's about 0.8 ms theoretical minimum.
PyTorch Conv3d would need to read input, weights, and write output. It might have some overhead due to algorithm selection. A well-written custom kernel should be competitive.
Let me write an efficient kernel using shared memory for input halos and register tiling for weights.
Actually, since depth is small (10), maybe we can:
1. Have each block handle a tile in (H, W) space
2. Have threads within the block cooperate to load the corresponding input tile into shared memory
3. Each thread computes multiple output pixels for multiple output channels / batch elements / depth elements
For a 3x3 kernel with stride=1, padding=0:
- To compute an output tile of size THxTW, we need an input tile of size (TH+2)x(TW+2)
Let's say we use a block of 256 threads (8 warps).
- We could tile output spatially as 16x16 = 256 elements per block
- Then we need 18x18 input elements in shared memory
- Each block could handle, say, 4 output channels and 1 batch*depth element
- Or 1 output channel and 4 batch*depth elements
With in_channels=3, out_channels=64, this is a small-ish problem.
Actually, I think the best approach for a small kernel like 3x3 is implicit GEMM or direct convolution with shared memory.
Let me write a kernel that:
- Uses shared memory for input spatial tile
- Processes multiple output channels via register accumulation
- Each thread computes one or more output spatial positions
```cuda
template<int BLOCK_H, int BLOCK_W, int BLOCK_OC, int BLOCK_IC, int BLOCK_BATCH_DEPTH>
__global__ void conv3d_kernel(...) {
// Shared memory for input tile
// BLOCK_H + kernel_size - 1 in height, BLOCK_W + kernel_size - 1 in width
// Load input tile cooperatively
// Each thread accumulates BLOCK_OC output channels for its spatial position
// and for its assigned batch*depth elements
// Weights are loaded from global memory (or L1 cache) on demand
// With in_channels=3, out_channels=64, weights are small enough to cache
}
```
Wait, for implicit GEMM we'd reshape and use matrix multiplication. But implementing that from scratch is complex.
Let me try a simpler but still effective approach:
For each output position (b, d, h, w), compute all out_channels. This way we have good locality reading the same input pixel for all out_channels.
```cuda
__global__ void conv3d_kernel(
const float* __restrict__ input,
const float* __restrict__ weight,
const float* __restrict__ bias,
float* __restrict__ output,
...) {
int b = blockIdx.z / Dout;
int d = blockIdx.z % Dout;
int h = blockIdx.y * BLOCK_H + threadIdx.y;
int w = blockIdx.x * BLOCK_W + threadIdx.x;
if (b < batch && d < Dout && h < Hout && w < Wout) {
for (int oc = 0; oc < out_channels; oc += VECTOR_WIDTH) {
// Compute VECTOR_WIDTH output channels
float acc[VECTOR_WIDTH] = {0};
for (int ic = 0; ic < in_channels; ic++) {
for (int kh = 0; kh < KH; kh++) {
for (int kw = 0; kw < KW; kw++) {
float inp = input[...];
for (int v = 0; v < VECTOR_WIDTH; v++) {
acc[v] += inp * weight[...];
}
}
}
}
for (int v = 0; v < VECTOR_WIDTH; v++) {
output[...] = acc[v] + bias[...];
}
}
}
}
```
But this naive approach has poor memory access for weights (each thread reads different weights, no sharing).
Better approach with shared memory for weights:
- Have each block handle a subset of output channels (e.g., 32 or 64)
- Load those weights into shared memory
- All threads in the block use the same weights to compute their spatial positions
- This allows weight sharing
For spatial tiling:
- Let's say block = (16, 16, 1) threads = 256 threads
- Each block computes a 16x16 output tile
- Block handles some number of output channels
Wait, if we process 64 output channels in shared memory, that's 64 * 3 * 3 * 3 * 1 * 4 = ~7KB which fits easily.
So: For each block, load all weights for a subset of output channels into shared memory, then compute output for 16x16 spatial tile for those output channels.
But we also need to handle batch and depth. If we run blocks over (batch*depth, Hout/16, Wout/16), that's 16*10 * 16 * 16 = 25,600 blocks. Each block with 256 threads = ~6.5M threads total, which is fine.
Actually, better: each thread computes 1 output pixel but for all output channels? No, that would be too many registers. Each thread computes 1 pixel for some output channels.
Let's say each block computes (16, 16) spatial output for 64 output channels. That's 16*16*64 = 16,384 output values. With 256 threads, each thread computes 64 output values (1 pixel, 64 channels).
Each thread needs 64 registers for accumulation. That's a lot but manageable (64 floats = 256 bytes).
For weights: 64 * 3 * 3 * 3 = 1,728 floats = ~7KB in shared memory.
For input: to compute 16x16 output with 3x3 kernel, need 18x18 spatial input, for 1 batch*depth element, for 3 input channels. That's 18*18*3 = 972 floats = ~4KB in shared memory.
Total shared memory: ~11KB, very reasonable.
Each thread:
- Reads one spatial position from shared memory input (or multiple if we need more depth/batch)
- For oc = 0 to 63: acc[oc] += input * weight[oc][ic][kh][kw]
- This is 64 * 27 = 1,728 FMAs per thread
But wait, threads would need to read weights for 64 channels. If weights are in shared memory, all threads read the same weight values. But each thread computes different spatial positions.
Actually, let me think about this differently. We have 256 threads, each computing 64 output channels for 1 pixel. Total work per block = 256 pixels * 64 channels = 16,384 outputs.
The number of FMAs per thread is 64 * 3 * 3 * 3 = 1,728. At H100 clock ~2 GHz with 2 FMAs/SM/cycle, each SM can do ~4 TFLOPS fp32. 1,728 FMAs = 3,456 FLOPs. At 4 TFLOPS per SM, that's 0.86 microseconds per block on 1 SM. With ~100+ SMs, total time could be ~10-20 microseconds if perfectly parallel.
But memory bandwidth will matter. Input: each block reads 18*18*3 floats = ~4KB. Weights: ~7KB. Output: 256*64 floats = ~64KB. Total: ~75KB per block. At 3.35 TB/s, that's 0.075/3350 = 22.4 ns. But that's impossible to achieve due to latency and other overheads.
For PyTorch, cuDNN is highly optimized. But maybe with small in_channels=3, our custom kernel has advantages because cuDNN might not have a highly optimized path for this case, or might use implicit GEMM which has overhead.
Let me also consider using Tensor Cores. With FP32 on H100, we don't have tensor cores for pure FP32. We have TF32 tensor cores. But using them from a custom CUDA kernel is complex.
Let me just write a good shared-memory direct convolution kernel and see how it performs.
Actually, let me think more carefully. The kernel is (kernel_size, kernel_size, 1) = (3, 3, 1). Groups=1.
Wait, I should also consider that for each batch/depth, the HxW planes can be convolved independently. So we could also treat this as:
- Flatten batch*depth as a single batch dimension for Conv2d
- The PyTorch reference already does this internally in cuDNN
But to beat cuDNN with a custom kernel, I need some advantage. For small in_channels (3), direct convolution can sometimes beat GEMM-based approaches because:
1. No need for im2col conversion
2. Weight + input fit in shared memory
3. No extra workspace needed
Let me write the kernel. I'll make it general to handle arbitrary parameters but optimized for the common case.
Wait, I need to handle groups too. With groups > 1, the computation changes. Let me include that.
For the kernel code:
```python
import torch
import torch.utils.cpp_extension
cuda_source = '''
#include <torch/extension.h>
#include <cuda_runtime.h>
template <typename scalar_t>
__global__ void conv3d_fast_kernel(
const scalar_t* __restrict__ input,
const scalar_t* __restrict__ weight,
const scalar_t* __restrict__ bias,
scalar_t* __restrict__ output,
int batch, int in_channels, int out_channels,
int height, int width, int depth,
int kernel_h, int kernel_w,
int stride_h, int stride_w,
int pad_h, int pad_w,
int dilation_h, int dilation_w,
int groups) {
// Block dimensions: spatial tile
// ...
}
'''
```
Let me think about the specific tiling strategy more carefully.
For the test case: batch=16, in_c=3, out_c=64, H=W=256, D=10, K=3, stride=1, pad=0
- Output: 16 x 64 x 254 x 254 x 10
Let me define:
- BLOCK_OC = 64 (all output channels per block)
- BLOCK_HW = 16 (tile size in H and W)
- blockDim = (BLOCK_HW, BLOCK_HW, 1) = (16, 16, 1) = 256 threads
But actually, we can be more flexible with BLOCK_OC. Since out_c=64, we could handle all 64 in one block to avoid reloading weights. But if out_c is larger (e.g., 256), we might want BLOCK_OC=32 or 64.
For generality, let me use template parameters for block sizes.
Actually, one very good optimization: loop unrolling for 3x3 kernel. Since kernel_h and kernel_w are known at runtime (passed as arguments), the compiler might not unroll. But we could create specialized paths for common kernel sizes.
Let me write a kernel with:
- Shared memory for input tile: (BLOCK_HW + kernel_h - 1) * (BLOCK_HW + kernel_w - 1) * in_channels_per_group
- Shared memory for weights: BLOCK_OC * in_channels_per_group * kernel_h * kernel_w
But there's a problem: kernel_h could be large, making shared memory huge. However, kernel_depth is 1 always (since the nn.Conv3d is hardcoded with kernel_size=(kernel_size, kernel_size, 1)).
The kernel size in depth is always 1. The kernel_h and kernel_w come from the constructor argument.
Let me write a more flexible kernel that uses template parameters for compile-time kernel sizes where helpful.
Actually, for maximum performance with a 3x3 kernel, I should write special code paths for small kernels.
Hmm, but the problem says the kernel_size can vary (it's a constructor argument). So I need a general kernel.
Let me try a two-level approach:
1. For small kernel sizes (<= 7), use shared memory for weights
2. For larger kernels, rely on L1 cache
Actually, let me start with a simpler approach and see how it performs.
One more thought: since this is H100 with 228KB shared memory, we can afford quite a lot.
Let's say:
- Each block handles TILE_H x TILE_W output spatial positions
- Each block handles some output channels (TILE_OC)
- Each block handles some batch_depth elements (TILE_BD)
For maximum occupancy, I want small register usage. Let me have each thread compute only a small number of output channels.
Option: blockDim.x = 16, blockDim.y = 16, blockDim.z = 4 = 1024 threads
- Each block handles 16x16 spatial, 4 output channels, for some batch_depth
- That's 16*16*4 = 1024 outputs, 1 per thread
Shared memory for weights: 4 * in_channels * 3 * 3 ≈ 108 floats for in_c=3
Shared memory for input: (16+2) * (16+2) * in_channels = 324 * 3 = 972 floats
Total: ~1,080 floats = ~4.3KB
This is very small shared memory usage. Each thread computes 1 output channel for 1 pixel.
Total FMAs per thread = 3 * 3 * 3 = 27. Very little compute per thread.
With so little compute, the kernel will be limited by launch overhead and memory latency.
Option 2: blockDim = (8, 8, 1) = 64 threads. Each thread computes 16 output channels.
- Block handles 8x8 spatial, for some batch_depth
- Each thread computes 16 output channels for 1 pixel
- 64 threads * 16 ch = 1024 outputs per block
Shared memory for weights: 16 * 3 * 3 * 3 = 432 floats (~1.7KB)
Shared memory for input: (8+2) * (8+2) * 3 = 300 floats (~1.2KB)
Compute per thread: 16 * 27 = 432 FMAs. Better.
With this, we can run many blocks in parallel. For the full output 16*64*254*254*10 = 6.6M outputs, we'd need 6.6M / 1024 ≈ 6,400 blocks. With 1024 threads/block, that's ~6.5M threads. On 132 SMs, that's ~50,000 blocks/SM, so good parallelism.
Actually, let me think about optimal thread block size again. The test case has H=W=256, kernel=3, stride=1, pad=0, so Hout=Wout=254.
If I use block=(8,8,1):
- Grid in H: ceil(254/8) = 32
- Grid in W: ceil(254/8) = 32
- Grid in batch*depth: 16*10 = 160
- Grid in OC: 64/16 = 4
- Total blocks: 32 * 32 * 160 * 4 = 655,360 blocks
655K blocks is a lot but not too much. With 132 SMs, that's ~5K blocks per SM, which provides good occupancy even if each block is small.
Wait, 655K blocks of 64 threads each is 42M threads. The GPU can handle millions of threads, but the kernel launch overhead might be significant.
If I use larger blocks, say (16, 16, 1) = 256 threads, with each thread handling 4 OC:
- Grid H: 16, Grid W: 16, BD: 160, OC: 64/4 = 16
- Total blocks: 16 * 16 * 160 * 16 = 655,360 blocks still
Wait, no: if each block handles 16x16 spatial, then:
- Grid H: ceil(254/16) = 16
- Grid W: ceil(254/16) = 16
- BD: 160
- OC: 64/4 = 16
- Total blocks: 16 * 16 * 160 * 16 = 655,360
Same number of blocks. But each block is bigger (256 threads) and computes more (256*4 = 1024 outputs).
Hmm, but if we don't have enough occupancy with 256 threads because of register pressure, we might do better with smaller blocks.
Actually, each thread for 4 output channels needs 4 registers for accumulation. Plus some index registers. That's very low. So occupancy would be high.
Let me try a different approach. What about having each block handle multiple batch*depth elements?
For example:
- block=(16, 16, 1) = 256 threads
- Block handles 16x16 spatial for 4 output channels and 1 batch*depth element
- Grid: H:16, W:16, BD_OC: (160 * 64/4) = 2560
Wait, 2560 is too big for a single grid dimension. We need to split.
Or:
- block=(16, 16, 1)
- Block handles 16x16 spatial for 4 OC and 4 BD elements
- Total outputs per block: 16*16*4*4 = 4096
- Each thread handles 4 OC for 1 pixel, and loop over 4 BD elements
- Grid: H:16, W:16, BD_OC: (160/4) * (64/4) = 40 * 16 = 640
That's 16*16*640 = 163,840 blocks of 256 threads = 42M threads total. Same total thread count but fewer blocks.
Actually, fewer blocks is usually better due to launch overhead, as long as there are enough to saturate the GPU.
Let me reconsider. 42M threads is actually a lot. H100 has 132 SMs * 2048 threads/SM = 270K concurrent threads. So 42M threads means each SM processes ~155 blocks sequentially. That's fine.
With the block handling 4 BD and 4 OC:
- Input shared memory: Need (16+2) * (16+2) input spatial, but for 4 BD elements. Wait, each BD has different spatial input.
- Actually, for the same H and W positions, different BD elements are at different locations in memory.
- We can't share input across different BD elements easily in shared memory because their spatial positions are the same relative to the tile, but at different addresses.
Actually, for different BD (batch, depth), the spatial coordinates are the same, just at different offsets in memory. So we could load 4 different input tiles. But that multiplies shared memory by 4.
Alternatively, each batch*depth element uses the same input shared memory tile (same spatial coordinates), loaded sequentially. We compute outputs for BD[0], then reload shared memory for BD[1], etc.
Or we can have threads just load from global memory for each BD, relying on L1 cache. Since the spatial tile is small (18x18), it might stay in L1.
Hmm, let me think about this differently.
For H100, global memory bandwidth is very high (3.35 TB/s). L2 and L1 caches are also good. For a small kernel, maybe direct loads from global memory are OK if coalesced.
The most critical thing might be weight access. If each thread reads weights from global memory, and 256 threads all need the same weights, we get 256x broadcast. On H100, broadcast from L1 is fast.
For input: each thread reads its own input pixels, which are coalesced if adjacent threads read adjacent pixels. This is efficient.
So maybe I don't even need shared memory! A carefully written kernel with good register usage, where weights stay in L1 cache due to being read by all threads, might be very competitive.
Let me try writing a kernel without shared memory for simplicity:
```cuda
__global__ void conv3d_kernel_v2(...) {
int b = blockIdx.z / num_bd_groups;
int bd = blockIdx.z % num_bd_groups;
int h = blockIdx.y * BLOCK_H + threadIdx.y;
int w = blockIdx.x * BLOCK_W + threadIdx.x;
// etc...
// Compute corresponding batch and depth from bd
// ...
// Load input at current position for all input channels, kernel positions
// Load weights
// Compute
}
```
Wait, I think the best approach is to use shared memory for input. This guarantees locality and avoids redundant loads for the overlapping regions. For a 3x3 kernel, each input pixel is read by up to 9 threads (for interior pixels). Shared memory reduces this to 1 load.
For weights, since all threads in a block use the same weights, they can be loaded once and stay in registers or shared memory.
Let me write a kernel with:
- Shared memory for input tile
- Either shared memory or direct broadcast for weights
Let me code this up. I'll use template parameters where it helps.
Actually, I realize I need to be more careful. Let me write a first version, compile, and see if it's correct. Then optimize.
```python
kernel_code = '''
import torch
import torch.utils.cpp_extension
cuda_source = """
#include <torch/extension.h>
#include <cuda_runtime.h>
template <typename scalar_t, int BLOCK_H, int BLOCK_W, int BLOCK_OC>
__global__ void conv3d_forward_kernel(
const scalar_t* __restrict__ input,
const scalar_t* __restrict__ weight,
const scalar_t* __restrict__ bias,
scalar_t* __restrict__ output,
int batch, int in_channels, int out_channels,
int height, int width, int depth,
int out_height, int out_width, int out_depth,
int kernel_h, int kernel_w,
int stride_h, int stride_w,
int pad_h, int pad_w,
int dilation_h, int dilation_w,
int groups) {
int in_channels_per_group = in_channels / groups;
int out_channels_per_group = out_channels / groups;
int oc_block = blockIdx.z * BLOCK_OC;
int h_out = blockIdx.y * BLOCK_H + threadIdx.y;
int w_out = blockIdx.x * BLOCK_W + threadIdx.x;
// For depth and batch, we use additional grid dimensions or loop
// Let's put batch*depth in blockIdx.w if we could, but CUDA only has 3D grid
// So we can loop over batch and depth, or put them in blockIdx.z mixed with oc
...
}
""";
conv3d_fast = torch.utils.cpp_extension.load_inline(
name="conv3d_fast",
cpp_sources="",
cuda_sources=cuda_source,
functions=["conv3d_forward"],
extra_cuda_cflags=["-O3", "--use_fast_math"],
verbose=True,
)
class ModelNew(torch.nn.Module):
...
'''
```
I need to think about grid mapping. We have 5 dimensions to parallelize over: batch, out_channels, out_height, out_width, out_depth. CUDA only gives us 3 grid dimensions.
Common approaches:
1. Map (h_out, w_out, some_combination) to (blockIdx.x, blockIdx.y, blockIdx.z)
2. Combine batch and depth into one dimension (bd = batch * depth)
3. Then we have (bd, oc_block, h_out, w_out) = 4 dims
4. We can map (h_out, w_out, bd) to grid and have each block handle a chunk of OC
5. Or map (h_out, w_out, oc_block) and loop over bd
For depth=10, batch=16, bd=160. If we map bd to blockIdx.z and loop over oc, or vice versa.
I think the best mapping is:
- blockIdx.x -> w_out tile
- blockIdx.y -> h_out tile
- blockIdx.z -> (bd, oc_block) combined
For example: bd = blockIdx.z / num_oc_blocks, oc_block = blockIdx.z % num_oc_blocks
This distributes work evenly.
Now for BLOCK sizes:
- BLOCK_W = 16
- BLOCK_H = 16
- BLOCK_OC: let's say 32 or 64
With BLOCK_OC=64, if out_channels=64, num_oc_blocks=1, so blockIdx.z = bd * 1 + 0 = bd. Total z blocks = 160.
Grid:
- x: ceil(254/16) = 16
- y: ceil(254/16) = 16
- z: 160
Total blocks = 16*16*160 = 40,960 blocks. Each block has 256 threads. Total threads = 10.5M.
Wait, that's only 10.5M threads, but we need to compute 6.6B output elements. So each thread computes 6.6B/10.5M = 629 outputs.
If each block computes 16*16*64 = 16,384 outputs, and there are 40,960 blocks, total = 671M outputs.
Wait, 16*16*64 = 16,384 outputs per block. 40,960 * 16,384 = 671M. But we need 16*64*254*254*10 = 6.6B? Wait...
Let me recalculate:
- batch = 16
- out_channels = 64
- Hout = 254
- Wout = 254
- Dout = depth = 10 (since kernel depth is 1, stride=1, pad=0, dilation=1)
Wait, does depth change? The kernel is (3, 3, 1). If stride=1, pad=0, then Dout = depth = 10.
Total outputs = 16 * 64 * 254 * 254 * 10
= 16 * 64 * 64,516 * 10
= 16 * 64 * 645,160
= 660,643,840
Yes, ~660M outputs. Not 6.6B.
Outputs per block (16x16 spatial, all 64 channels) = 256 * 64 = 16,384
Number of blocks = ceil(254/16) * ceil(254/16) * (16*10) = 16 * 16 * 160 = 40,960
Total capacity = 40,960 * 16,384 = 671,088,640
So each thread computes 64 outputs. That's 64 accumulators. Per thread that's 64 registers just for accumulators, plus some for indexing. Total ~70-80 registers per thread. That gives about 50% occupancy on H100 (max 255 registers, but optimal is often lower).
Actually, 64 registers for accumulators + ~20 for weights/indexing = ~84 registers. Occupancy would be around 25% (since 256 threads * 84 = 21,504 registers per block, and each SM has 64K registers, so ~3 blocks per SM concurrently).
Let me try with BLOCK_OC=32. Then each thread computes 32 outputs. That's 32 registers + ~20 = 52 registers. Occupancy improves.
Or, each thread can handle 2 or more spatial positions, reducing the number of blocks and increasing work per thread.
For example, using block=(8, 8, 1) = 64 threads:
- Each thread computes 4 spatial positions (e.g., 2x2 tile) and 16 channels
- Total outputs per thread = 4 * 16 = 64
- Accumulators: 16 registers
- Plus we loop over the 4 spatial positions
Block outputs = 8*8*4*16 = 4,096
But this is getting complicated.
Let me try a cleaner approach:
**Approach: block=(16, 16, 1), each thread handles 1 pixel and 4 channels**
- BLOCK_OC = 4 (each thread computes 4 output channels)
- To cover 64 channels, need 16 blocks in OC dimension
- blockIdx.z: combine bd and oc_block
- z = bd * 16 + oc_block
- bd: 0..159, oc_block: 0..15
- total z: 2560
Grid: x=16, y=16, z=2560
Total blocks: 655,360
Each block: 256 threads
Total threads: 167.7M
Wait, 655K blocks is a lot. But with 132 SMs, that's ~5K blocks per SM. Each SM can run maybe 16-32 blocks concurrently (depending on resources). So each SM processes ~5K/32 ≈ 160 rounds of blocks.
Actually, launch overhead for 655K blocks might be noticeable. But on H100, grid launch is fast.
Compute per thread: 4 channels * 3 in_channels * 3 * 3 kernel = 108 FMAs. Very little.
Maybe we can reduce the number of blocks by having each block handle multiple BD or multiple spatial.
Block=(16,16,1) with BLOCK_OC=64 gives 40,960 blocks, which is nice and compact. Register pressure of 64 might be OK.
Actually, for H100, 64 accumulators per thread (64 fp32 = 256 bytes) is well within register file (64KB per SM). But occupancy depends on how the compiler allocates. If each thread uses exactly 64 registers for accumulators + ~16 for other stuff = ~80 registers, then 256 threads * 80 = 20,480 registers per block. With 64K per SM, we can run 3 blocks concurrently per SM. With 132 SMs, that's 396 blocks concurrently. Total blocks 40,960, so ~103 rounds.
Each block does 16*16*64*27 = 442,368 FMAs. 442K FMAs at ~2GHz with 128 FMA units per SM... H100 has 128 FP32 CUDA cores per SM. At 2 GHz, 256 GFLOPS per SM. 442K FMAs = 884K FLOPs. At 256 GFLOPS, that's ~3.45 microseconds per block on one SM. With 3 blocks per SM, ~1.15 microseconds effective. 103 rounds * 1.15 us = ~118 microseconds total for compute.
Memory: each block reads ~4KB input + ~7KB weights + writes ~64KB output. But weights are shared across all blocks that handle the same OC set. If all blocks need all weights (they do if BLOCK_OC=64 and out_c=64), weights are ~7KB and could stay in L2 cache. Each block writes ~64KB output. 40,960 * 64KB = 2.6 GB output total. At 3.35 TB/s, just output is ~0.8 ms.
Hmm wait, 118 microseconds for compute, but 800 microseconds for output memory. So it's memory bandwidth bound.
To reduce memory bandwidth, we need to exploit data reuse. By processing all OC for 1 pixel, we reuse the input pixel but write many outputs. This is already optimal for output bandwidth (can't reduce it).
But wait - can we reduce input bandwidth? By having smaller blocks, we can't. But with larger spatial tiles in a block, we reduce redundant input loads (the halo is smaller fraction of the tile).
For 16x16 tile with 3x3 kernel: input tile is 18x18. Redundant area is 18*18 - 16*16 = 68 pixels, or 68/256 = 26.6% overhead.
For 32x32 tile: input tile is 34x34. Redundant area is 34*34 - 32*32 = 132 pixels, or 132/1024 = 12.9% overhead.
So bigger tiles reduce input bandwidth. But bigger tiles need more shared memory.
32x32 tile, 3 input channels:
- Input shared: 34*34*3 = 3,468 floats = ~13.9KB
- Weights: 64*3*3*3 = 1,728 floats = ~6.9KB
- Total: ~21KB
With block=(32, 32, 1) = 1024 threads:
- Each thread computes 64 output channels for 1 pixel
- 64 registers for accumulators + ~20 = ~84 registers
- 1024 threads * 84 = 86K registers > 64K per SM!
So we can't run a 1024-thread block with 84 registers. We'd need to reduce to ~62 registers per thread (max 64 if we want 2 blocks per SM, or we just get 1 block per SM).
If we use 1024 threads and 64 registers each, that's exactly 64K registers, which means only 1 block per SM. With 132 SMs, 132 blocks concurrently. Total blocks: ceil(254/32) * ceil(254/32) * 160 = 8 * 8 * 160 = 10,240 blocks. That's ~78 rounds. Compute per block: 32*32*64*27 = 1.77M FMAs. At 256 GFLOPS per SM, that's ~6.9 microseconds. 78 rounds * 6.9 us ≈ 540 microseconds.
But memory output: 32*32*64*4 = 262KB per block. 10,240 blocks * 262KB = 2.6 GB total. Writing 2.6 GB at 3.35 TB/s = 776 microseconds. So total would be around 800+ microseconds.
With 16x16 blocks (256 threads), we had 118 us compute + 800 us output (output bandwidth is same since total output is same) = ~900 us.
So 32x32 blocks are slightly better due to lower input bandwidth overhead. But the difference is small.
Actually, I'm overthinking this. The main question is: can we beat PyTorch's cuDNN? cuDNN is extremely optimized and might use Winograd, FFT, or highly tuned GEMM. For a 3x3 conv with in_c=3, out_c=64, cuDNN might use Winograd or direct algo.
For small in_channels, direct conv is often fastest. cuDNN should figure this out. So beating it will be tough.
However, one thing that might give us an edge: depth=10 means we're doing 10 independent 2D convs per batch element. Our kernel might interleave depth processing to get better cache behavior, or we might avoid some cuDNN overhead.
Wait, I just realized: the cuDNN Conv3d might not be as optimized for this specific case (kernel=(3,3,1)) as a custom 2D-like kernel. cuDNN 3D conv is generally less optimized than 2D conv. So there might be room to win.
Another approach: Don't write a custom kernel but instead decompose to a series of 2D convs. But the task requires a custom CUDA kernel.
Actually, let me look at this from another angle. The problem says depth=10. What if we have each block process the entire depth dimension for a spatial tile? That is, for a fixed batch and spatial position, compute all depth positions.
Since the kernel depth is 1, different depth positions share the same weight and the same spatial input (just different depth indices in memory). If we process all depth positions together, we can amortize weight loading and shared memory setup.
For example:
- block=(16, 16, 1) = 256 threads
- Each block handles 16x16 spatial for ALL 64 output channels and ALL 10 depth positions, for 1 batch element.
- Total outputs per block: 16*16*64*10 = 163,840
- Each thread handles 64 channels for 1 spatial position, and loops over 10 depth positions.
Wait, but then each thread needs 64 accumulators (one set per depth position? No, we can reset accumulators for each depth).
If we loop over depth:
```cuda
for d = 0 to depth-1:
for oc = 0 to 63:
acc[oc] = 0
load input for this depth
for ic, kh, kw:
for oc = 0 to 63:
acc[oc] += input * weight
for oc = 0 to 63:
output[d][oc] = acc[oc]
```
So we only need 64 accumulators at any time, but we loop over depth. For 10 depths, that's 10x more work per block.
Block count: batch * ceil(Hout/16) * ceil(Wout/16) = 16 * 16 * 16 = 4,096 blocks.
Each block: 256 threads.
Total threads: ~1M.
Each thread: 64 acc * 27 FMAs * 10 depth = 17,280 FMAs.
At 256 GFLOPS per SM, 17,280 / 256B = 67.5 nanoseconds per thread. But wait, a block has 256 threads, and they share compute resources. Total FMAs per block = 256 * 17,280 = 4.4M. At 256 GFLOPS, that's 17.2 microseconds per block.
4,096 blocks total. With 132 SMs and maybe 2 blocks per SM concurrently (since register usage for 64 acc = ~64 regs, 256 threads * 64 = 16K regs < 64K, could run 3-4 blocks per SM). Let's say 3 blocks per SM = 396 blocks concurrently. 4,096 / 396 ≈ 10.3 rounds. Total time ≈ 10.3 * 17.2 us = 177 microseconds.
Memory bandwidth:
- Input per block: 18*18*3*10 = 9,720 floats = ~39KB
- Weights: ~7KB
- Output: 16*16*64*10 = 163,840 floats = ~655KB
- Total per block: ~701KB
- 4,096 blocks * 701KB = 2.8 GB
- At 3.35 TB/s: ~840 microseconds
So memory still dominates at ~840 us. Compute is only ~177 us. Total ~1 ms.
Hmm, but with fewer blocks (4K vs 40K), kernel launch overhead is lower. And processing all depths together might have better cache behavior for weights (only load once per block).
Actually, I think processing multiple depths per block is a good idea because:
1. Weights are loaded once (or cached better)
2. Input for different depths might be close in memory, helping spatial locality
3. Fewer blocks means less launch overhead
Let me code this up.
Actually, I want to also think about unrolling. For kernel_h=3, kernel_w=3, in_channels=3, the inner loops are small. We can manually unroll if we know these at compile time. But since they're parameters, we can use `#pragma unroll` or write specialized versions.
For in_channels=3 and kernel=3x3, the inner loop is 27 iterations. That's small enough that the compiler might unroll it automatically with -O3.
Let me write the kernel with:
- Each block: 1 batch element, 1 spatial tile, ALL depth
- Threads: (BLOCK_H, BLOCK_W, 1)
- Each thread: 1 spatial position, ALL out_channels (up to some max), loop over depth
- Shared memory: input tile for current depth, weights for all oc
But if we loop over depth with shared memory, we need to reload shared memory for each depth. That's fine.
Actually, maybe even better: process all depth positions for all OC for 1 spatial pixel in a single thread. This avoids needing to reload shared memory.
Wait, no. If 256 threads each process 1 pixel, and all 256 pixels are in the same input tile, then a single loading of shared memory serves all 256 threads. Then we loop over depth, reusing the same shared memory spatial layout, just with different depth data.
For depth=10, we'd:
1. Load shared memory for depth 0
2. Compute outputs for all threads for depth 0
3. Load shared memory for depth 1
4. Compute outputs for all threads for depth 1
etc.
But actually, we don't need shared memory to be reloaded for different depths if the spatial coordinates are the same. Different depths just mean different memory offsets, but the spatial tile is the same. So threads can read directly from global memory, or we can load all depth slices at once if they fit.
For 18x18 spatial, 3 channels, 10 depths: 18*18*3*10 = 9,720 floats = 38.9KB. That fits easily in shared memory! We could load all depth slices into shared memory at once.
Then each thread reads input[spatial][ic][d] for its spatial position, all depths, all input channels.
This is great! Let's do it.
Shared memory layout:
- shmem_input[depth][ic][tile_h+KH-1][tile_w+KW-1]
For 3x3 kernel, 3 IC, 10 depth, 18x18 tile: 10 * 3 * 18 * 18 = 9,720 floats = 38.9KB
Weights: 64 * 3 * 3 * 3 = 1,728 floats = 6.9KB
Total shared memory per block: ~46KB. Well within limits.
With 46KB shared memory per block, and 228KB per SM, we can run at most 4 blocks per SM concurrently. With 256 threads per block, that's 1,024 threads per SM. H100 has 2,048 threads per SM max, so occupancy is at most 50% due to shared memory.
Is that OK? Probably. We only have 4,096 blocks, so 132 SMs * 4 blocks = 528 blocks concurrently. 4,096 / 528 ≈ 7.8 rounds. That's very good.
Each block:
- Loads 46KB shared memory
- Computes 16*16*64*10 = 163,840 outputs
- 163,840 * 27 FMAs = 4.4M FMAs per block
- At 256 GFLOPS per SM, 4.4M / 256B = 17.2 microseconds per block
- 7.8 rounds * 17.2 us = 134 microseconds for compute
Memory:
- Read input: 16 * 3 * 256 * 256 * 10 * 4 = 125.8 MB
- Read weights: 64 * 3 * 3 * 3 * 1 * 4 = 6.9 KB (negligible)
- Write output: 16 * 64 * 254 * 254 * 10 * 4 = 2.54 GB
- Total memory traffic: ~2.67 GB
- At 3.35 TB/s: ~797 microseconds
So memory bound at ~800 microseconds. Can we reduce memory traffic?
Actually, the input is read from global memory to shared memory. That's 125.8 MB read. Output is 2.54 GB written. The ratio is 1:20. Most memory is output.
One way to reduce output memory is to... well, we can't reduce it. We have to write all outputs.
But wait - can we compute in a lower precision? No, we need fp32 output.
Another idea: What if the problem is compute-bound for PyTorch because PyTorch might use a less efficient algorithm? Or what if our custom kernel has lower overhead?
Actually, for this specific case with depth=10, PyTorch's Conv3d might be doing a lot of unnecessary work or using an algorithm that's not optimal for kernel=(3,3,1). Our direct approach might indeed be faster.
I recall that cuDNN Conv3d doesn't always pick the best algorithm for 3D convs, especially with asymmetric kernels. It might use a general GEMM-based approach that converts the entire 3D problem to a matrix multiply, which involves workspace allocation and extra memory movement.
Our kernel avoids all that by directly computing the result.
Let me also think about vectorized writes. Writing 64 channels per pixel sequentially is bad for memory coalescing if adjacent threads write different channels. We want thread t to write channel c for pixel p, and thread t+1 to write channel c for pixel p+1.
Actually, for our block where thread (tx, ty) handles pixel (bx+tx, by+ty) and all channels:
- Thread 0 writes channels 0..63 for pixel (0, 0)
- Thread 1 writes channels 0..63 for pixel (1, 0)
- ...
If output is stored as [batch][oc][h][w][d], then:
- Thread 0 writes to offset: ((b * oc + c) * h + bh) * w + bw) * d + d0
- Thread 1 writes to: ((b * oc + c) * h + bh) * w + bw+1) * d + d0
These addresses are separated by `d` elements. For d=10, the stride between adjacent pixels is 10 floats = 40 bytes.
Adjacent threads in a warp access addresses with stride 40 bytes. That's not fully coalesced - a warp of 32 threads accesses 32*40 = 1280 bytes. On H100, a memory transaction is 128 bytes. So we'd need 1280/128 = 10 transactions for 32 threads. That's bad.
To fix this, we need threads to write in a coalesced pattern. Options:
1. Change output layout (not allowed)
2. Have threads write one channel at a time but in a coalesced manner
3. Use shared memory to transpose writes
If we use shared memory for output staging, we can do:
- Compute partial results in registers
- Write to shared memory in a transposed layout
- Then write from shared memory to global memory in a coalesced pattern
This is getting complex.
Actually, wait. The output layout is NCDHW = [batch][out_c][h][w][d] or is it [batch][out_c][d][h][w]?
PyTorch Conv3d output is (N, C_out, D_out, H_out, W_out). Let me check the reference:
The docstring says: `Input tensor of shape (batch_size, in_channels, height, width, depth)`
and output: `(batch_size, out_channels, height_out, width_out, depth_out)`
Wait, is that the standard NCDHW? Actually, PyTorch Conv3d uses (N, C, D, H, W) for 5D input by default. But the docstring says (batch_size, in_channels, height, width, depth). That's NCHWD? Or maybe they mean (N, C, H, W, D).
Let me look at the PyTorch Conv3d docs. Standard is (N, C, D, H, W). But this reference seems to use (N, C, H, W, D)?
Actually, in the reference code:
```python
self.conv3d = nn.Conv3d(in_channels, out_channels, (kernel_size, kernel_size, 1), ...)
```
And test:
```python
x = torch.rand(batch_size, in_channels, height, width, depth)
```
PyTorch nn.Conv3d expects input of shape (N, C, D, H, W) by default. So the test code creates (N, C, H, W, D) which would be wrong unless they're using a non-standard layout.
Wait, no. Let me check: `nn.Conv3d(kernel_size=(kernel_size, kernel_size, 1))`
If input is (batch, in_channels, height, width, depth), then the dimensions are NCHWD? That doesn't match PyTorch's standard.
Actually, I think the reference code might be wrong, or they're intentionally defining height first, then width, then depth. But PyTorch Conv3d will operate on the last 3 dimensions. So if input is (N, C, H, W, D), PyTorch treats the spatial dimensions as H, W, D and applies the kernel (K, K, 1) to them.
So the output shape would be computed by applying:
- Kernel size K to H dimension
- Kernel size K to W dimension
- Kernel size 1 to D dimension
With padding=0, stride=1:
- H_out = H - K + 1 = 254
- W_out = W - K + 1 = 254
- D_out = D - 1 + 1 = D = 10
Output shape: (N, C_out, H_out, W_out, D_out) = (16, 64, 254, 254, 10)
This means the output layout is also (N, C, H, W, D).
So in memory, for fixed N and C, the layout is [H][W][D]. So D is the fastest changing dimension.
Let's verify:
- Thread at pixel (h, w) and depth d, channel c:
- Address = ((n * C_out + c) * H_out + h) * W_out + w) * D_out + d
Wait, actually PyTorch is row-major, and for 5D tensor (N, C, H, W, D):
- offset = n * (C*H*W*D) + c * (H*W*D) + h * (W*D) + w * D + d
So D is the fastest changing dimension.
If thread 0 handles pixel (0, 0, d=0, c=0) and thread 1 handles pixel (0, 1, d=0, c=0):
- Thread 0: offset = n*... + c*... + 0 + 0 + 0 = base
- Thread 1: offset = base + D = base + 10
These are adjacent (separated by 10 floats = 40 bytes). Not perfectly coalesced but not terrible. A warp of 32 threads accesses offsets: base, base+10, base+20, ..., base+310. That's 310 floats = 1240 bytes. In 128-byte transactions, this would need 10 transactions (since each transaction is 128 bytes = 32 floats, and the accesses span 310 floats, with 32 threads requesting 1 float each at intervals of 10).
Actually, on modern GPUs, a warp of 32 threads where thread i requests address base + i*10 * 4 bytes:
- Addresses: base, base+40, base+80, ..., base+1240
- This spans 1240 + 4 = 1244 bytes
- In 128-byte aligned transactions, that's 10 or 11 transactions.
So we get ~3x effective bandwidth (32 requests / 11 transactions ≈ 3 floats per transaction, vs 32 for fully coalesced). That's bad.
To get good coalescing, we need adjacent threads to access adjacent memory locations. So we want:
- Thread 0: base + 0
- Thread 1: base + 1
- Thread 2: base + 2
- etc.
This means adjacent threads should work on the same (h, w, d) but adjacent channels, OR same (h, w, c) but adjacent d.
If adjacent threads in x handle adjacent w, and they all write the same c and d:
- Thread 0: (h, w, d=0, c=0): offset = ... + h*W*D + w*D + d
- Thread 1: (h, w+1, d=0, c=0): offset = ... + h*W*D + (w+1)*D + 0 = prev + D
Stride is D = 10 floats. Not fully coalesced.
If adjacent threads handle adjacent d for fixed h, w, c:
- Thread 0: (h, w, d=0, c=0): base
- Thread 1: (h, w, d=1, c=0): base + 1
This is fully coalesced!
But we only have D=10 depth positions. We can't make a warp of 32 threads all work on adjacent d because there are only 10 values.
Alternative: adjacent threads handle adjacent channels for fixed h, w, d:
- Thread 0: c=0: base
- Thread 1: c=1: base + H*W*D
Stride is H*W*D = 254*254*10 = 645,160 floats. That's terrible.
What if we have a block of (W_THREADS, D_THREADS, C_THREADS) where x handles w, y handles d, z handles c chunks? But we only have 3 block dimensions.
Actually, for this problem, a better approach is to have each warp process a contiguous chunk of channels for a small spatial region.
Or we can use shared memory for the output to do a transposed write:
1. Compute output in registers
2. Store to shared memory in layout [thread][channel]
3. Then read from shared memory and write to global in a pattern where threads cooperate to write all channels for one pixel at a time, or all pixels for one channel at a time.
Actually, the most coalesced access pattern for output [N][C][H][W][D] is to write all D values for a pixel via 10 threads, then move to the next pixel.
Specifically:
- A warp of 32 threads can handle 3 full pixels (3*10=30 threads) plus 2 extra threads.
- For pixel 0, threads 0-9 write d=0..9
- For pixel 1, threads 10-19 write d=0..9
- For pixel 2, threads 20-29 write d=0..9
This gives:
- Thread 0-9 write to addresses that are +0, +1, +2, ..., +9 (adjacent, fully coalesced if aligned)
- Thread 10-19 write to addresses +10, +11, ..., +19 (adjacent, fully coalesced)
Wait, for a fixed c and h, w, the addresses are:
- base + h*W*D + w*D + d
For w=0, d=0..9: addresses base+0, base+1, ..., base+9 (adjacent)
For w=1, d=0..9: addresses base+10, base+11, ..., base+19 (adjacent)
So a warp writing all depth values for 3 adjacent pixels is fully coalesced within each pixel!
To make it even better, we could have a warp write depth for pixel (h, w) across channels. But the addresses for channel c would be spaced by H*W*D.
For a thread block, I think the best approach is:
- Compute all channels for all pixels in the tile using registers/SMEM
- Then for each output pixel, have 10 threads (or however many channels per thread) write the output in depth-contiguous order
But this requires coordinating writes across the block.
Actually, let me reconsider the thread organization.
What if instead of having each thread handle all channels for one pixel, we flip it:
- Each thread handles one channel for multiple pixels, OR
- We use a block where threads collectively compute a tile, then write in a coalesced manner using shared memory staging.
I think the shared memory staging for output is the most robust approach, but adds complexity.
Let me think about a simpler approach that might still be fast enough:
- Use a block of (D, W, C_per_block) something like (10, 16, 1)
- Actually, 10*16*1 = 160 threads, not a multiple of 32. Not ideal.
- Use block of (D, W, H) where H is small
- For D=10, W=16, H=2: 320 threads
- But H=2 is tiny.
Actually, the standard approach in CUTLASS and other conv kernels is to tile the GEMM in a way that achieves coalescing. For implicit GEMM, the output matrix is M=H*W*D, N=C_out, K=C_in*K_h*K_w. Writing the output matrix in M-major order gives coalesced writes since the output tensor is essentially M-major with N being the channel dimension.
Wait, in our case the output is [N][C][H][W][D]. For fixed batch, this is like [C][H][W][D]. The M dimension would be H*W*D, and N would be C. If we use column-major or row-major for the output of the GEMM:
- Row-major M*N: each row is length N=C. But our output is C-major, so this would be column-major for the C dimension.
- So if we compute as a GEMM where the result is stored as [M][N] = [H*W*D][C], and we want the tensor to be [C][H*W*D], we'd need a transpose.
Actually, the standard im2col for conv does output as [C_out][M] where M = H*W*D. This is exactly our output layout if we view [C][H][W][D] as [C][H*W*D]. So a GEMM output of [N][M] with N=C_out and M=H*W*D would match!
In cuBLAS, sgemm C = A*B where A is [M][K] and B is [K][N]. For conv:
- A (activations) = [M][K] where M = H*W*D and K = C_in*K_h*K_w
- B (weights) = [K][N] where N = C_out
- C (output) = [M][N] = [H*W*D][C_out]
This is stored in row-major as [H*W*D][C_out]. Our tensor view [C_out][H*W*D] is column-major for this matrix.
So a GEMM producing [M][N] in row-major would NOT match our tensor layout unless transposed.
cuDNN handles all this internally. It might compute [N][M] or use specialized algos.
For our custom kernel, I think the pragmatic approach is:
1. Compute with each thread handling all output channels for one pixel
2. Write outputs directly
3. Accept that memory writes are strided by D=10, which is not perfect but not terrible
The write pattern is: each thread writes 64 floats, at addresses separated by D=10.
- 64 floats * 4 bytes = 256 bytes per thread
- The write pattern for a warp of 32 threads: thread i writes at address base_i + j*10*4 for j=0..63
- base_i = base + i*10*4
For j=0, the warp writes at base, base+40, base+80, ..., base+1240. Span = 1244 bytes. Number of 128-byte transactions ≈ 10.
Bytes requested = 32*4 = 128 bytes.
Effective bandwidth = 128/10/128 = 10% of peak. That's really bad!
Wait, this is worse than I thought. Each memory transaction can serve up to 128 bytes. But our accesses are scattered across 1244 bytes with only 128 bytes actually needed. So we're using about 10% of memory bandwidth for writes.
This is a big problem. We need to fix coalescing.
How can we fix this?
Option 1: Shared memory staging
- After computing all outputs, store them in shared memory in a layout that allows coalesced reads
- Then have threads read from SMEM and write to global memory in a coalesced pattern
SMEM layout: [H_tile][W_tile][D_tile][C_chunk]
- But with 16*16*10*64 = 163,840 floats = 655KB, that's too big for SMEM.
We need smaller tiles.
Let's say: BLOCK_HW = 8x8 = 64 pixels. BLOCK_OC = 8 channels. Process 1 depth at a time.
SMEM for outputs: 8*8*8 = 512 floats = 2KB. Very manageable.
But then each block computes 64*8 = 512 outputs. For the full output of 660M, we'd need 1.3M blocks. Way too many.
Option 2: Larger thread blocks with more collaborative output writing.
Block = (8, 4, 4) = 128 threads.
- x handles W (8 pixels)
- y handles D (4 depths)
- z handles H (4 pixels)
Wait, we only need to parallelize over W, H, D, C, B.
Actually, let me think about a warp-level cooperative approach.
If one warp = 32 threads:
- Option A: 32 threads each handle 1 pixel, all 64 channels. Writes are strided by D=10.
- Option B: 32 threads cooperate on 1 pixel, 32 channels at a time (need 2 iterations for 64). For 1 pixel with 32 channels, thread i writes channel i. Address stride is H*W*D = 645,160. Terrible.
- Option C: 10 threads handle 1 pixel, all 10 depths, for some channels. But warp size is 32, so 3 pixels * 10 depths = 30 threads, 2 threads idle.
For Option C in a warp:
- Pixels (w=0,1,2), each with d=0..9
- Thread 0: (w=0, d=0)
- Thread 1: (w=0, d=1)
- ...
- Thread 9: (w=0, d=9)
- Thread 10: (w=1, d=0)
- etc.
For a fixed h and c, the addresses are:
- base + h*W*D + w*D + d
- Thread 0: base + 0*D + 0 = base
- Thread 1: base + 0*D + 1 = base + 1
- ...
- Thread 9: base + 9
- Thread 10: base + 1*D + 0 = base + D
- Thread 11: base + D + 1
- ...
For D=10: base, base+1, ..., base+9, base+10, base+11, ..., base+19, base+20, ..., base+29
These are contiguous! base through base+29. Fully coalesced!
So if a warp handles 3 pixels (w=0,1,2) with all 10 depth values each (30 values), the writes are to contiguous memory.
To handle all W positions: we need multiple warps.
To handle all C channels: we need multiple iterations or warps.
So the strategy:
- A thread block consists of multiple warps
- Each warp handles a few adjacent W positions, all D values, for some H position and some C range
- But wait, if we want each thread to compute the output, and each thread handles 1 (w, d) combination, then we need to handle C channels somehow.
Within a warp of 32 threads handling 30 (w,d) combinations:
- How does each thread compute its output?
- Each thread needs all 64 channels eventually.
- But a warp only has 32 threads, and we have 30 active.
If each thread computes ALL channels for 1 (w,d), then for 64 channels each thread writes 64 values with stride H*W*D between channels. That's 64 separate writes per thread, and the writes within a warp for the same channel but different (w,d) are fully coalesced!
Let me verify:
- For channel c=0:
- Thread 0 writes (w=0, d=0): base + 0
- Thread 1 writes (w=0, d=1): base + 1
- ...
- Thread 9 writes (w=0, d=9): base + 9
- Thread 10 writes (w=1, d=0): base + 10
- These are contiguous: base to base+29. Fully coalesced!
- For channel c=1:
- Thread 0: base + H*W*D + 0
- Thread 1: base + H*W*D + 1
- Also contiguous with stride 1.
- But threads write channel 0 first, then channel 1, etc. So for each channel, 30 threads write 30 contiguous floats. For 64 channels, each thread writes 64 floats, and each channel write is fully coalesced.
This is perfect! We write 64 channels, each with a fully coalesced warp write of 30 floats (for 3 pixels * 10 depths).
But there's a subtlety: 30 floats = 120 bytes. Memory transactions are 128 bytes aligned. So base through base+29 spans 120 bytes. As long as base is aligned to 4 bytes (which it is for float), the hardware might need 1 or 2 transactions depending on alignment. Actually, if base is 128-byte aligned, then 120 bytes fits in one 128-byte transaction with 8 bytes wasted. If not aligned, it might span 2 transactions.
With D=10, and W varying, base = n*C*H*W*D + c*H*W*D + h*W*D + w_start*D + d_start
For w_start multiples of 3, and d_start=0: base = ... + h*W*D + w_start*D
For this to be a multiple of 32 (128 bytes / 4 bytes), we need w_start*D to be a multiple of 32. With D=10, w_start*10 needs to be multiple of 32, so w_start needs to be multiple of 3.2, which means w_start = 0, 3 doesn't work (30 not multiple of 32), w_start = 16 works (160 is multiple of 32).
Actually, the alignment only matters for the starting address of each warp. For most warp positions, the start address won't be 128-byte aligned. But the warp will still issue 1 or 2 transactions for 120 bytes.
Actually, newer GPUs have very good uncoalesced memory access handling. The key is that the addresses are close together (spanning only 120 bytes). This is much better than spanning 1244 bytes.
So this warp organization is good. Let me formalize it.
**Warp-level work distribution:**
- Warp handles a tile of W positions and all D positions
- Specifically, a warp handles W_TILE adjacent W values and all D values
- W_TILE = floor(32 / D) = 3 for D=10 (3*10=30 threads, 2 threads idle/wasted)
- Or we can handle 32 D values if we pad depth to 32, but depth is only 10.
For D=10 exactly:
- Each warp handles 3 W positions × 10 D positions = 30 values
- 2 threads are idle
- Total warps needed for W dimension: ceil(W_out/3) per H position
If W_out = 254, we need ceil(254/3) = 85 warps per H position.
For H_out = 254, total warps for spatial = 254 * 85 = 21,590 warps.
For 64 channels, if each warp handles all 64 channels (each thread computes 64 channels for 1 (w,d)), then each warp writes 30*64 floats.
For batch=16: total warps = 16 * 21,590 = 345,440 warps.
With 128 threads per block (4 warps), we'd have ~86K blocks. This is manageable.
But wait, each thread computing 64 channels for 1 (w,d) needs 64 accumulators. 64 registers. Plus other registers. For 128 threads, 128*64 = 8,192 registers for accumulators just for the live set. But the compiler might need more. Total per block maybe 16K-20K registers. Well within limits.
Actually, I realize I can have each warp handle just 1 channel group at a time to reduce register usage, but then we'd need to loop over channel groups and reload weights.
Hmm, let me think about the overall structure again.
I think a clean approach is:
- Block dimensions: (WARP_W * D, 1, 1) but with careful mapping
- Actually, let's use a block of 256 threads = 8 warps
- Each warp handles a tile of 3 W × 1 H × all D, for some channel range
Wait, let me step back and think about simplicity vs performance.
Actually, I want to try a different approach. Instead of trying to get perfect coalescing in every dimension, what if we use a layout where we process the tensor as if it's [N][H][W][C][D] or [N][H][W][D][C]?
We can't change the output layout, but maybe we can change how threads are assigned to minimize striding.
Actually, a key insight: The tensor layout is (N, C, H, W, D).
For a fixed n and c, the slice is [H][W][D].
For a fixed n, c, h, the slice is [W][D] with D contiguous.
For a fixed n, c, h, w, the element is [D] with D contiguous.
If we assign adjacent threads to the same (n, c, h) but adjacent (w, d):
- Fully coalesced within each (n, c, h)
- Strided by H*W*D between channels
If adjacent threads are same (n, h, w, d) but adjacent c:
- Strided by H*W*D, terrible
So for a warp, processing 1 channel but multiple (w, d) is good. Processing multiple channels but 1 (w, d) is bad.
But we have 64 channels. We need each pixel to have all 64 channels computed.
What if we launch blocks where:
- Each block handles a spatial region (say 8x8) for all channels
- Threads are organized as groups: each group of 32 threads handles 1 channel for all spatial positions in the block
- We need 64 groups = 64*32 = 2048 threads per block. That's a lot.
With 2048 threads per block, and each thread handles 1 channel for some spatial positions:
- 32 threads can cover 8x8=64 spatial positions as 2 positions each (since we want coalesced (w,d) writes)
- Or we assign 32 threads to 32 (w,d) combinations, but 8x8 has 64 positions with D=10 giving 640 values. That's 20 warps worth.
Actually, let me try:
- Block = (32, 8, 1) = 256 threads
- x dimension: 32 threads handle (w, d) combinations for 1 channel
- Specifically, 32 threads handle floor(32/10)=3 pixels with all 10 depth values (30 values), 2 threads idle
- y dimension: 8 threads handle 8 different channels
- So the block handles 8 channels for 3*8=24 pixels (but actually 30 pixels per warp, and 8 warps in y)
Wait, block=(32,8,1) means 32 threads in x, 8 in y.
- Each warp is 32 threads in x, so 8 warps along y? No, warps are formed from threadIdx in order of threadIdx.x then y then z.
- Threads (0..31,0) form warp 0. Threads (0..31,1) form warp 1, etc.
- So we have 8 warps, each warp handles 1 channel.
For 1 channel, a warp handles 30 (w,d) values (3 w × 10 d).
- For 8 channels, 8 warps handle 8*30 = 240 (w,d) values.
- But we want a block to handle a spatial tile, say 8x8=64 pixels × 10 d = 640 values.
- 240 < 640, so we can only cover part of it.
If we want to cover 640 values with 240 per block, we'd need 2.67 blocks per spatial tile. Not clean.
Alternatively, block = (32, 16, 1) = 512 threads = 16 warps.
Each warp handles 1 channel, 30 (w,d) values.
16 channels * 30 = 480 values. Still less than 640.
For a 16x16 spatial tile: 256 pixels * 10 = 2,560 (w,d) values.
Need 2560/30 ≈ 86 warps. Or we use smaller spatial tiles.
How about a 8x4 spatial tile = 32 pixels * 10 = 320 values.
With block=(32, 8, 1) = 8 warps: 8*30 = 240 values. Not enough.
With block=(32, 16, 1) = 16 warps: 16*30 = 480 values. Covers 320 with 160 wasted.
This is getting complicated with the constraint that D=10 doesn't divide evenly into warp size.
Let me try a different approach. What if each warp handles multiple channels but fewer spatial positions?
Warp = 32 threads.
- 4 threads handle 1 pixel (all 10 depths + we ignore 2 threads or do something else)
- Actually, 64 channels / 4 threads = 16 channels per thread
- But 10 depths / 4 threads = 2.5, not clean.
Actually, I think the fundamental issue is that D=10 doesn't divide 32 evenly. Unless we process multiple pixels together.
What if a warp computes ALL channels for a small number of pixels?
- 32 threads, 64 channels, 1 pixel: each thread computes 2 channels. Writing is strided by H*W*D, terrible.
- 32 threads, 8 channels, 4 pixels × 10 depths: each thread computes 1 channel for 40/32 = 1.25 positions... not clean.
What if we forget about warp-level coalescing and just use shared memory for output?
SMEM approach:
1. Compute outputs in registers (each thread handles some channel-pixel combinations)
2. Write to SMEM in a layout that allows coalesced global writes
3. Read from SMEM and write to global memory
For SMEM of output: we need enough space for the block's outputs.
If block handles 16x16 spatial, 64 channels, 1 depth: 16*16*64 = 16,384 floats = 64KB.
If we process 1 depth at a time, 64KB is fine for SMEM (228KB limit).
If we process all 10 depths, 640KB is too much.
So: Process 1 depth at a time. For each depth:
1. Load input tile for that depth into SMEM
2. Load weights into SMEM (or registers)
3. Compute outputs for all channels and spatial positions for that depth
4. Store outputs to SMEM in layout [H][W][C]
5. Then have threads write from SMEM to global in coalesced pattern
But wait, writing from SMEM to global in coalesced pattern for layout [C][H][W][D] (our tensor layout):
For fixed C and D, the global memory is [H][W] with stride D between W and next H.
Actually, for output at depth d: offset = ((n*C + c)*H + h)*W + w)*D + d
For a fixed d, varying h, w, c:
- For fixed c and h, varying w: addresses differ by D = 10 floats. Not contiguous.
- For fixed c, varying h and w: addresses are h*W*D + w*D + d
This is never going to give perfect coalescing unless D=1. With D=10, we're always going to have some striding.
However, we can do this:
- Have a group of 10 threads write all D values for 1 pixel, moving across W
- For pixel (h, w), thread d writes depth d
- The addresses are base + d for d=0..9
- This is contiguous! base, base+1, ..., base+9.
- 10 threads writing 10 contiguous floats.
A warp has 32 threads, so we can handle 3 pixels fully (30 threads) and 2 threads idle.
Or we can handle 32 pixels with 32 threads each writing 1 depth, but then we write 32 separate pixels at depth d, which gives addresses at base + w*D for w=0..31. Strided by 10. That's bad.
No wait, if we write with 10 threads for 1 pixel, we need 10 threads per pixel. 32 threads can handle 3 pixels.
But what if we have 32 pixels and 32 threads, and each thread writes all 10 depths for 1 pixel? Then each thread writes 10 values at addresses base + w*D + d for d=0..9. These are contiguous within the thread, but what about across threads?
- Thread 0: base+0, base+1, ..., base+9
- Thread 1: base+10, base+11, ..., base+19
If thread 0 writes all 10, then thread 1 writes all 10, etc., the warp writes:
base to base+9 (thread 0), then base+10 to base+19 (thread 1), etc.
These are all contiguous if written sequentially! But each thread issues 10 store instructions.
On H100, the memory subsystem can combine stores from different threads within a warp if they're to contiguous addresses. Thread 0 writing [base..base+9] and thread 1 writing [base+10..base+19] might be combined by the write coalescer.
Actually, if threads in a warp each execute `for d=0 to 9: store output[d]`, they diverge in execution but the memory requests might still be coalesced because thread 0 and thread 1 don't conflict. The hardware might not coalesce them perfectly, but the addresses are at least sequential across the warp.
Alternatively, if we structure it as:
- All 32 threads write depth 0 for pixels 0..31: addresses base, base+10, base+20, ... bad
- Then all write depth 1: base+1, base+11, ... still bad
But:
- Thread 0 writes depths 0..9 for pixel 0: base, base+1, ..., base+9
- Thread 1 writes depths 0..9 for pixel 1: base+10, base+11, ..., base+19
- ...
If each thread uses a vector store of 10 floats (or 2 stores of float4 + 1 store of float2), the addresses within each thread are contiguous, and across threads they're contiguous too. This is essentially a block of 320 contiguous floats being written by 32 threads.
With 320 floats = 1280 bytes. The warp is writing 1280 bytes starting at base. This will be split into 10 transactions of 128 bytes each (assuming base is aligned). Each thread writes 40 bytes (10 floats), but the total is well-coalesced across the warp.
Wait, but the hardware doesn't know the threads are cooperating. It looks at the addresses requested by each thread at each instruction. If thread 0 requests base..base+9 at instruction 0, and thread 1 requests base+10..base+19 at instruction 0, these are different instructions within each thread but at the same program counter.
Actually, each thread would issue 10 store instructions (one per depth). At store instruction d (where all threads store depth d):
- Thread 0: base + d
- Thread 1: base + 10 + d
- Thread 2: base + 20 + d
- ...
- Addresses: base+d, base+10+d, base+20+d, ..., base+310+d
These are strided by 10. The span is 310 floats = 1240 bytes. Number of 128-byte transactions ≈ 10. Bytes actually used = 32*4 = 128. So utilization is 128/1280 = 10%.
So having each thread write all depths for 1 pixel gives bad utilization if done one depth at a time across threads.
But if each thread uses vector stores? A single `st.v4.f32` writes 4 contiguous floats. If thread 0 writes [base, base+4), thread 1 writes [base+10, base+14), etc., the coalescing is still poor because the spacing is 10 floats.
Wait, but what if the threads write non-vectorized but each thread writes 10 contiguous values, and within a warp the program order is:
- Thread 0 executes 10 stores to [base, base+10)
- Thread 1 executes 10 stores to [base+10, base+20)
- etc.
In the SIMT model, all threads execute the same instruction together. So:
- Instruction 0: all threads store to their `base + 0` -> addresses base, base+10, base+20, ... (strided)
- Instruction 1: all threads store to `base + 1` -> addresses base+1, base+11, ... (strided)
- etc.
So it's still strided by 10 at each instruction.
The vector store approach: if we could have thread 0 write 10 floats with a single instruction, there would be no other threads executing at that exact moment in the warp-level view. Actually, all 32 threads would each issue a 10-float vector store. The addresses would be base, base+10, base+20, ... for the first float of each vector. The memory unit might see this as 32 separate 40-byte stores at different base addresses, and might or might not combine them.
I'm not sure if H100's memory subsystem can combine stores from different threads when they're interleaved like this. It probably can't combine into fewer transactions than 32, since each request is for a different region.
So the fundamental problem is that with D=10, having different threads write different W positions means strided access.
What if we instead have all threads in a warp write the same W position? That is, each warp handles only 1 W position (or a few if we use multiple warps).
Actually, I just realized: What if we just accept imperfect coalescing for writes and focus on maximizing compute? On H100 with 3.35 TB/s, even 10% effective bandwidth is 335 GB/s. Our output is 2.54 GB. So writing output takes ~7.6 ms at 10% efficiency. That's too slow.
So coalescing matters a lot. We need to fix it.
The best fix for D=10 is:
- Have threads write in groups of 10, where each group handles all D for 1 pixel.
- A warp of 32 threads can form 3 groups of 10 threads (3 pixels) with 2 threads idle.
- But what about the compute? How do the 10 threads in a group compute their values?
If 10 threads compute 1 pixel with all 64 channels:
- Each thread computes all 64 channels? Then 10 threads each compute the same 64 channels, which is redundant.
- Each thread computes some channels: 64/10 = 6.4, not clean.
What about having 32 threads where 10 threads each handle 1 depth for 3 pixels × all channels?
- 30 threads active, 2 idle.
- Each thread computes all 64 channels for 1 (w,d) combination.
- Then, when writing, threads 0-9 write depth 0-9 for w=0. But thread 0 computed all channels for (w=0,d=0), thread 1 for (w=0,d=1), etc.
- For channel 0: thread 0 writes base, thread 1 writes base+1, ..., thread 9 writes base+9. This is a contiguous 10-float write by 10 threads! Coalesced!
- But across the warp, threads 0-9 are writing base..base+9, threads 10-19 are writing base+10..base+19, threads 20-29 are writing base+20..base+29. Still contiguous overall.
Wait, if threads 0-9 write to base..base+9 (channel 0, w=0), that's fully coalesced. Then after that, they write channel 1 at base+H*W*D .. base+H*W*D+9, which is far away.
The key question: can 10 threads in a warp write 10 contiguous floats in a coalesced manner? Yes! A warp of 32 threads where only 10 are active writing to contiguous addresses will result in 1 or 2 memory transactions (depending on alignment).
So the pattern is:
- A warp handles 3 W positions × 10 D positions = 30 values, with 2 threads unused or used for something else.
- Each thread computes all 64 channels for 1 value.
- When writing channel c for the 3 pixels: we issue 3 coalesced writes of 10 floats each (one per pixel).
This means each warp issues 64 * 3 = 192 store instructions (64 channels × 3 pixels). Each store is a 10-float coalesced write by 10 threads.
Actually, we can do better. We can have each thread write its 10 floats but organized such that we get coalescing. Or we can use shared memory to reorder.
But wait - if we have the warp write channels one at a time, and within each channel, the warp writes 30 floats (3 pixels × 10 depths) in a coalesced manner:
- For channel c, pixel w=0: threads 0-9 write to offset(c, h, w=0, d=0..9). These are contiguous.
- For channel c, pixel w=1: threads 10-19 write to offset(c, h, w=1, d=0..9). Contiguous.
- For channel c, pixel w=2: threads 20-29 write to offset(c, h, w=2, d=0..9). Contiguous.
But these are 3 separate writes within the warp. The warp can't do all 3 simultaneously (unless using separate warps or something). In a single instruction, only threads at the same PC execute. So we do:
```cuda
for c = 0 to 63:
output[base + c*H*W*D + thread_group*W*D + d] = acc[c];
```
Where thread_group is 0, 1, or 2 based on which pixel the thread belongs to, and d is the depth within the pixel.
At instruction for c=0:
- Threads 0-9: store to base + 0 + 0 + d = base + d
- Threads 10-19: store to base + 0 + W*D + d = base + 10 + d
- Threads 20-29: store to base + 0 + 2*W*D + d = base + 20 + d
These are at base, base+10, base+20. Strided by 10! Not coalesced!
Ah, I misunderstood. If all threads in the warp execute a single store instruction, thread i stores to address_i. Even though within each group of 10 the addresses are contiguous, across the warp the addresses are strided. The memory coalescer looks at all addresses in the warp for that instruction.
For threads 0-9: base, base+1, ..., base+9
For threads 10-19: base+10, base+11, ..., base+19
For threads 20-29: base+20, base+21, ..., base+29
Wait a second! These ARE contiguous! base through base+29. 30 contiguous floats.
Let me recheck:
- Thread 0: base + 0
- Thread 1: base + 1
- ...
- Thread 9: base + 9
- Thread 10: base + 10
- Thread 11: base + 11
- ...
- Thread 29: base + 29
YES! These are base, base+1, base+2, ..., base+29. All contiguous!
So if we define:
```cuda
int tid = threadIdx.x; // 0..31
int pixel_idx = tid / D; // 0, 1, or 2
int d = tid % D; // 0..9
int w = w_start + pixel_idx;
// compute output for (h, w, d)
// store to: ((n*C_out + c)*H_out + h)*W_out + w)*D_out + d
```
For a fixed c and h, and fixed pixel start:
- Thread tid writes to base(c, h, w_start + tid/10, tid%10)
- = base(c, h, w_start, 0) + (tid/10)*W*D + (tid%10)
- = base0 + (tid/10)*10 + (tid%10)
- = base0 + (tid/10)*10 + tid%10
For tid=0: base0 + 0
tid=1: base0 + 1
...
tid=9: base0 + 9
tid=10: base0 + 10 + 0 = base0 + 10
tid=11: base0 + 10 + 1 = base0 + 11
...
tid=29: base0 + 20 + 9 = base0 + 29
These are base0, base0+1, ..., base0+29. Contiguous!
YES! This works! We get perfect coalescing for 30 threads out of 32.
For 31 and 32:
tid=30: base0 + 30 + 0 = base0 + 30 (but w_start+3 might be out of bounds)
tid=31: base0 + 30 + 1 = base0 + 31
If we only have 3 pixels (w_start, w_start+1, w_start+2), then threads 30 and 31 need to be masked out or handle w_start+3 if it exists.
So a warp can handle up to 3 adjacent W positions with all D values, for 1 H position and 1 channel at a time.
To handle all 64 channels, the warp loops over channels. For each channel, it computes and stores 30 values contiguously.
But wait, how does each thread compute its output?
- Thread tid computes the full convolution for its assigned (w, d) and one channel c.
- To compute for all 64 channels, it needs to loop c=0..63.
For each c:
acc = 0
for ic, kh, kw:
acc += input[...] * weight[c, ic, kh, kw, 0]
output[...] = acc + bias[c]
This means for each c, all threads load the SAME weight values (for that c and all ic, kh, kw). That's good - broadcast from L1 cache.
And each thread loads its own input values. With 30 active threads, that's 30 input reads, which are somewhat scattered (different w and d) but local.
The issue: each thread needs to loop over 64 channels individually. A warp executes 64 iterations together. Each iteration loads weights for 1 channel, all threads load the same weights. Then each thread computes its own dot product with the input.
Compute per warp per channel: 30 inputs * 27 FMAs = 810 FMAs (3 pixels * 10 d * 3 ic * 3 kh * 3 kw, wait no: 1 output = 3*3*3=27 FMAs. 30 outputs = 810 FMAs.)
For 64 channels: 64 * 810 = 51,840 FMAs per warp.
At H100 with 128 cores per SM at 2GHz: 256 GFLOPS.
51,840 / 256B = 0.2 microseconds per warp. Very fast.
Memory for weights: 64*3*3*3 = 1,728 floats. Read once per warp. ~7KB. Trivial.
Memory for input: 30 values per channel, but the same input values are used for all channels. So read 30 floats once, use 64 times. 120 bytes read once. Actually, we need to load input for each channel computation if we don't cache it.
Wait, if we loop over channels:
```
for c in 0..63:
for ic in 0..2:
for kh in 0..2:
for kw in 0..2:
w = weight[c, ic, kh, kw, 0]
for each thread:
inp = input[n, ic, h+kh, w+kw, d]
acc += inp * w
```
The input is loaded inside the channel loop. For 64 channels, the same input locations are loaded 64 times! That's terrible.
We should load input once and keep it in registers or shared memory.
With 30 input values * 3 input channels = 90 floats per thread. That's too many registers.
Better: load input for the current (ic, kh, kw) combination once, before the channel loop. Then reuse it for all channels.
```
for ic in 0..2:
for kh in 0..2:
for kw in 0..2:
// Load input for all threads for this (ic, kh, kw)
inp = input[n, ic, h+kh, w+kw, d]
// Now for all channels, multiply by respective weights
for c in 0..63:
w = weight[c, ic, kh, kw, 0]
acc[c] += inp * w
```
This means we load 30 input values (1 per active thread) for each (ic, kh, kw), and then loop over 64 channels. The input loads are reduced from 64*30 to 30.
Each thread needs 64 accumulators for 64 channels. 64 registers. Plus inp register. Total ~66 registers. That's a lot but manageable.
Total input loads per warp: 27 (ic*kh*kw) * 30 values = 810 float loads = 3.2KB.
Weight loads: 64*27 = 1,728 floats = 6.9KB. But same for all warps, cached in L1/L2.
Output stores: 64 channels * 30 values = 1,920 floats = 7.7KB.
This looks very reasonable.
Total memory per warp: ~3.2KB input + ~7.7KB output + weight (cached) = ~11KB.
At 3.35 TB/s, 11KB = ~3.3 nanoseconds. But there will be latency, so maybe ~100 ns.
Compute per warp: 64 * 30 * 27 = 51,840 FMAs = ~103K FLOPs.
At 256 GFLOPS per SM, ~0.4 microseconds.
So compute dominates. This is good! It means the kernel might be compute-bound or at least balanced.
Now, how to structure the full kernel:
**Block configuration:**
- Let's say each block has 8 warps = 256 threads.
- But each warp independently handles 3 W × all D for 1 H and loops over all C.
- So a block of 8 warps handles 8 different H positions or 8 different spatial regions.
Actually, we can have:
- blockDim.x = 32 (warp size)
- blockDim.y = 8 (8 warps)
- gridDim.x = ceil(W_out / 3) // each warp handles 3 W positions
- gridDim.y = ceil(H_out / 8) // each block handles 8 H positions
- gridDim.z = batch
Wait, each warp handles 3 W positions. With 8 warps per block, and blockDim.y = 8, gridDim.y would handle H positions.
Actually, let me map it:
- blockIdx.x: which W group (each group is 3 W positions)
- blockIdx.y: which H position (0..H_out-1, each block handles 1 H)
- blockIdx.z: batch element
- threadIdx.x: 0..31, determines which of 3 W × 10 D values
- threadIdx.y: 0..7, determines which H position in the block's tile?
Hmm, if blockIdx.y maps to H, then each block handles only 1 H position. But we have 8 warps in y. What do they do?
They could handle different channel ranges? But each warp loops over all 64 channels. So we don't need to split channels.
Or they could handle different H positions within a tile. If block handles 8 consecutive H positions, and each warp takes 1 H position:
- Warp 0: H = block_start + 0
- Warp 1: H = block_start + 1
- ...
- Warp 7: H = block_start + 7
Then:
- blockDim = (32, 8, 1)
- gridDim.x = ceil(W_out / 3)
- gridDim.y = ceil(H_out / 8)
- gridDim.z = batch * depth_factor?
Wait, we also need to handle depth. But our warp handles all D=10 depths (via threadIdx.x % 10). So depth is fully covered within each warp!
And blockIdx.z = batch, since all depths are handled.
But what about groups and out_channels > 64? Let's think generally.
For general parameters:
- out_channels = OC
- The warp loops over oc = 0..OC-1
- Each thread has OC accumulators
If OC=64, 64 accumulators per thread is OK. If OC=256, 256 accumulators is 1KB per thread in registers, which is too much (would be 256 registers just for accumulators, exceeding the 255 limit).
So for larger OC, we'd need each warp to handle a subset of channels.
For the test case (OC=64), handling all channels per warp is fine.
For generality, let me make the kernel handle OC in chunks.
**General kernel design:**
```cuda
template<int BLOCK_WARPS, int MAX_OC_PER_WARP>
__global__ void conv3d_kernel(...) {
int warp_id = threadIdx.y;
int lane_id = threadIdx.x;
int w_group = blockIdx.x;
int h_block = blockIdx.y * BLOCK_WARPS;
int n = blockIdx.z;
int h = h_block + warp_id;
int w_base = w_group * 3; // 3 W positions per warp
int pixel_idx = lane_id / D_out;
int d = lane_id % D_out;
int w = w_base + pixel_idx;
if (h >= H_out || w >= W_out || pixel_idx >= 3) return;
// Load input for this (n, h, w, d) for all kernel positions
// But do it efficiently: load once per (ic, kh, kw), reuse for all oc
// Each warp handles a range of oc
int oc_per_warp = min(MAX_OC_PER_WARP, out_channels);
int num_oc_warps = (out_channels + oc_per_warp - 1) / oc_per_warp;
for (int oc_warp = 0; oc_warp < num_oc_warps; oc_warp++) {
int oc_start = oc_warp * oc_per_warp;
int oc_end = min(oc_start + oc_per_warp, out_channels);
float acc[64]; // use template or dynamic based on oc_per_warp
for (int oc = oc_start; oc < oc_end; oc++) acc[oc - oc_start] = 0;
for (int ic = 0; ic < in_channels_per_group; ic++) {
for (int kh = 0; kh < kernel_h; kh++) {
for (int kw = 0; kw < kernel_w; kw++) {
int h_in = h * stride_h + kh * dilation_h - pad_h;
int w_in = w * stride_w + kw * dilation_w - pad_w;
float inp = 0;
if (h_in >= 0 && h_in < height && w_in >= 0 && w_in < width) {
inp = input[((n * in_channels + ic_g) * height + h_in) * width + w_in) * depth + d];
}
for (int oc = oc_start; oc < oc_end; oc++) {
int oc_g = oc; // with groups...
float w = weight[...];
acc[oc - oc_start] += inp * w;
}
}
}
}
for (int oc = oc_start; oc < oc_end; oc++) {
int out_idx = ...;
output[out_idx] = acc[oc - oc_start] + (bias ? bias[oc] : 0);
}
}
}
```
Wait, I need to handle groups properly:
- in_channels_per_group = in_channels / groups
- out_channels_per_group = out_channels / groups
- Each output channel only connects to its group's input channels
For the weights: weight[oc][ic_per_group][kh][kw][0]
For input: input[n][g * in_channels_per_group + ic_per_group][h][w][d]
Actually, PyTorch Conv3d with groups: For output channel oc, it belongs to group g = oc / out_channels_per_group. It only reads from input channels in group g.
So in the kernel, for each output channel oc:
- g = oc / out_channels_per_group
- For input channel ic (0 to in_channels_per_group - 1):
- actual_input_channel = g * in_channels_per_group + ic
For the test case with groups=1, this simplifies.
Now, about the input loading:
For each warp, threads are at different (h, w, d). For a given (kh, kw), the input coordinates are:
- h_in = h + kh * dilation_h - pad_h (same for all threads in warp since same h)
- w_in = w + kw * dilation_w - pad_w (different for each thread)
Wait! All threads in the warp have the SAME h but DIFFERENT w (3 different w values).
For a given warp (fixed h) and given (kh, kw):
- h_in is the same for all threads
- w_in is different for threads with different w
So threads with the same w but different d have the same (h_in, w_in), just different d.
And threads with different w have different (h_in, w_in).
For D=10, and 3 w values:
- w0: 10 threads with d=0..9, all at (h_in, w_in0, d)
- w1: 10 threads with d=0..9, all at (h_in, w_in1, d)
- w2: 10 threads with d=0..9, all at (h_in, w_in2, d)
For loading input at a specific (ic, kh, kw):
- Threads at w0 load from address offset0 + d. Contiguous for d=0..9.
- Threads at w1 load from address offset1 + d. Contiguous for d=0..9.
- Threads at w2 load from address offset2 + d. Contiguous for d=0..9.
But across the warp, the loads are at 3 different base addresses. The threads with d=0 load from offset0, offset1, offset2. Since there are only 3 distinct base addresses and 30 active threads, the load might be:
- 10 threads at offset0 + d (d=0..9), which is contiguous
- 10 threads at offset1 + d (d=0..9), contiguous
- 10 threads at offset2 + d (d=0..9), contiguous
For the hardware, a warp load instruction has threads requesting:
offset0+0, offset0+1, ..., offset0+9, offset1+0, ..., offset1+9, offset2+0, ..., offset2+9
These are 3 separate contiguous segments. The hardware will issue 3 memory transactions (or fewer if the segments are close).
If offset1 - offset0 = W*D and W=256, D=10, then offset1 = offset0 + 2560. The segments are far apart. So 3 separate transactions, each for 10 contiguous floats (40 bytes). Total: 3 transactions per input load, covering 30 floats = 120 bytes.
Not perfectly efficient (120 bytes requested, but 3 * 128 = 384 bytes transferred due to minimum transaction size). Utilization is ~31%. But this is still much better than random access.
Actually, the minimum transaction size for global loads might be 32 bytes, not 128. For L1 cache, it might fetch cache lines of 128 bytes. For loads that hit L1, it's fast anyway. For loads that miss, each 40-byte contiguous request by 10 threads might trigger a 128-byte cache line fetch. So 3 cache lines for 3 segments = 384 bytes fetched for 120 bytes requested. 31% utilization.
Since we do 27 such loads per warp (for 3*3*3 kernel with 3 input channels), total fetched = 27 * 384 = 10,368 bytes per warp. For 8 warps = 82,944 bytes per block. For 40,960 blocks = 3.4 GB of input memory traffic.
This is higher than the actual input size (125 MB) due to lack of coalescing. Not ideal.
However, with proper tiling in shared memory, we could load the input tile once, cooperatively, with good coalescing, and then all threads read from SMEM.
Let me add shared memory for input.
**With shared memory for input:**
For a block handling 8 H positions and some W range:
- We need input for H=block_h to block_h+7, plus kernel_h-1 padding.
- But each warp in the block handles a different H and different W.
Actually, since our warp design has threads at different W but same H within a warp, and different H across warps in y, it's hard to make a single shared memory tile serve the whole block.
For a block of 8 warps, each handling a different H:
- Warp 0: H = H0
- Warp 1: H = H0+1
- ...
- Warp 7: H = H0+7
For a 3x3 kernel, warp 0 needs input at H0-1, H0, H0+1.
Warp 1 needs H0, H0+1, H0+2.
There's overlap, but it's messy to coordinate shared memory across warps with different H.
Alternatively, we can redesign so all warps in a block handle the same H but different W ranges:
- blockDim.x = 32 (one warp in x)
- blockDim.y = 8 (8 warps in y, but all handle same H, different W groups)
Wait, if all warps handle the same H but different W groups:
- Warp 0: W = 0, 1, 2
- Warp 1: W = 3, 4, 5
- Warp 2: W = 6, 7, 8
- etc.
Then for a given H, all warps need the same rows of input (H-1, H, H+1), but different columns.
The input tile for H and all W in the block's range could be loaded into shared memory cooperatively.
For block handling 8 warps * 3 W = 24 W positions:
- Input needed: 3 H rows (for 3x3 kernel) × (24 + 2) W columns × D depths × in_channels
- For D=10, IC=3: 3 × 26 × 10 × 3 = 2,340 floats = ~9.4KB
All warps can load this cooperatively. Then each warp reads from SMEM.
But with 8 warps and blockDim=(32, 8, 1) = 256 threads:
- We can load 9.4KB using 256 threads: each thread loads ~4 floats. Good.
However, we still have the issue that our warps are at different W positions, and within a warp, threads are at different W and D. But now they read from SMEM, which is fast regardless of access pattern.
This seems like a great approach!
Let me formalize:
**Block structure:**
- blockDim = (32, 8, 1) = 256 threads
- gridDim.x = ceil(W_out / (8 * 3)) // 8 warps * 3 W each = 24 W
- gridDim.y = ceil(H_out / 1) = H_out // each block handles 1 H
- gridDim.z = batch
Wait, if each block handles 1 H position, and there are 8 warps, what do the 8 warps do? They all handle the same H but different W groups.
- Warp w in block handles W = [w*3, w*3+2] for the block's H position.
But 8 warps * 3 W = 24 W positions. Most blocks will have 24 W, but the last block in a row might have fewer.
Actually, W_out = 254. 254 / 24 = 10.58, so gridDim.x = 11.
11 blocks * 24 W = 264 capacity, but we only need 254. The last block has some warps that are partially out of bounds.
For H_out = 254, gridDim.y = 254.
For batch = 16, gridDim.z = 16.
Total blocks = 11 * 254 * 16 = 44,704 blocks.
Each block = 256 threads = ~11.4M threads.
Each block handles up to 24 W × 1 H × 10 D × 64 C = 15,360 outputs.
Total capacity = 44,704 * 15,360 = 686M > 660M needed. Good.
Wait, actually each warp handles up to 30 values (3 W × 10 D), but if W is out of bounds then fewer. Let me recalculate.
block covers W range [w_base, w_base+23] where w_base = blockIdx.x * 24.
For W_out=254, the last block has w_base = 10*24 = 240. So W=240..263, but only 240..253 are valid. That's 14 valid W positions.
14 / 3 = 4.67, so 5 warps handle valid W, and 3 warps are fully or partially out of bounds.
For H positions, each block handles exactly 1 H position.
Shared memory input tile:
- For H position h, with kernel_h=3, pad=0, stride=1:
- Input H range: [h, h+2] (since 3x3 kernel, no padding in this case if we ignore boundary)
- Actually with pad=0: h_in = h + kh for kh=0,1,2. So input rows h, h+1, h+2.
- For W range: w_base to w_base+23, with kernel_w=3:
- Input W range: w_base to w_base+23+2 = w_base to w_base+25.
- For depth: all 10 depths.
- For in_channels: all 3 channels.
SMEM size: 3 (H) × 26 (W) × 10 (D) × 3 (IC) = 2,340 floats = 9.36KB.
Each warp reads from SMEM for its specific (w, d) combinations.
Since warp 0 handles W = w_base, w_base+1, w_base+2.
Warp 1 handles W = w_base+3, w_base+4, w_base+5.
And within each warp, thread tid handles W = warp_w_start + tid/10, D = tid%10.
For input at a specific (ic, kh, kw), warp threads read from SMEM[h_in_block][w_in_block][d][ic].
The SMEM indexing is a bit complex because we have interpolation from global H/W to block-local H/W.
Local h index: 0, 1, or 2 (for the 3 input rows).
Local w index: w_global - w_base + kw (where w_global is the output W position, and kw is the kernel offset).
For warp handling W_start, the threads at W = W_start + p (p=0,1,2) need:
- For kw=0: local_w = p
- For kw=1: local_w = p + 1
- For kw=2: local_w = p + 2
So local_w ranges from 0 to 4 for p=0,1,2. Wait, p=2, kw=2: local_w = 4.
But our SMEM loaded W range is w_base to w_base+25, which is 26 columns. So local_w goes 0..25. The warp at W_start = w_base + p*3 has p=0,1,2 mapping to local_w in range [0..4], [3..7], [6..10], etc.
Actually, if W_start for warp q is w_base + q*3, then local_w = (w_base + q*3 + p) - w_base + kw = q*3 + p + kw, where p=0,1,2.
For q=0: p+kw = 0..4
For q=1: 3+p+kw = 3..7
For q=2: 6+p+kw = 6..11
...
For q=7: 21+p+kw = 21..25
These are all within 0..25. Good!
So the SMEM size of 26 W columns is sufficient.
Let me code this up.
Actually wait, I need to handle the case where pad > 0. With padding, h_in could be negative or >= height. The shared memory needs to account for padding by shifting the loaded region, or by checking bounds.
For general padding: the input tile for output h is [h*stride - pad_h, h*stride - pad_h + kernel_h - 1]. With dilation, it's [h*stride - pad_h, h*stride - pad_h + (kernel_h-1)*dilation_h].
For simplicity, I'll load the input tile from global memory, checking bounds, and threads that would read out-of-bounds get 0.
In the kernel, each thread loads some portion of the input tile. With 256 threads and 2,340 floats to load, each thread loads ~9 floats.
OK, let me write the kernel. I'll make it handle general parameters, with the specific optimization for the test case.
Wait, actually, let me reconsider the approach. Maybe there's a simpler way.
For a 3D conv with kernel (K, K, 1), this is essentially running a 2D conv on each D slice. In fact, we can implement it by:
1. Reshaping input from (N, C, H, W, D) to (N*D, C, H, W)
2. Running 2D conv with kernel (K, K) to get (N*D, C_out, H_out, W_out)
3. Reshaping back to (N, C_out, H_out, W_out, D)
But step 2 requires a custom CUDA kernel. Or we can use a highly optimized 2D conv kernel.
Actually, for a custom CUDA kernel, maybe doing an implicit GEMM with CUTLASS or similar would be fastest. But writing that from scratch is very complex.
Let me just write the direct conv kernel with shared memory. It might be competitive with cuDNN for 3D conv with depth=1.
I should also consider using `__half2` or other vectorized loads, but for fp32, `float4` is available.
Let me try to write the kernel in a clean way.
One more thing: I should specialize for common cases. Since kernel_w=kernel_h always (square kernel), and the depth kernel is always 1, I can simplify.
Also, for D=10, our warp design of dividing by D works.
What if D is not 10? The kernel needs to be general. Our approach of `pixel_idx = lane_id / D_out` and `d = lane_id % D_out` works for any D_out <= 32. If D_out > 32, then a warp can only handle 1 pixel (if D > 32) or floor(32/D) pixels (if D <= 32).
For D > 32, we'd need to handle multiple warps per pixel. But in the test case D=10, our design works.
Actually, let me write a more general warp design:
```cuda
int values_per_warp = min(32, D_out); // if we handle 1 pixel per warp
int pixels_per_warp = 32 / D_out; // number of pixels handled by a warp
```
Wait, for D=10: pixels_per_warp = 3 (since 32/10=3), values_per_warp = 30 (3*10).
For D=1: pixels_per_warp = 32, values_per_warp = 32.
For D=32: pixels_per_warp = 1, values_per_warp = 32.
For D=33: pixels_per_warp = 0, which means this simple design doesn't work.
For D > 32, we'd need each warp to handle 1 pixel but multiple depths via some other mechanism. I'll add a fallback for D > 32.
For the test case, D=10, so we're fine.
Let me write the actual code now.
```python
kernel_code = '''
import torch
import torch.utils.cpp_extension
cuda_source = """
#include <torch/extension.h>
#include <cuda_runtime.h>
template <typename scalar_t>
__global__ void conv3d_fast_kernel(
const scalar_t* __restrict__ input,
const scalar_t* __restrict__ weight,
const scalar_t* __restrict__ bias,
scalar_t* __restrict__ output,
int batch, int in_channels, int out_channels,
int height, int width, int depth,
int out_height, int out_width, int out_depth,
int kernel_h, int kernel_w,
int stride_h, int stride_w,
int pad_h, int pad_w,
int dilation_h, int dilation_w,
int groups) {
// Block: (32, 8, 1) threads
// Each warp (32 threads) handles up to floor(32 / D) W positions with all D depths
// 8 warps per block handle different W-groups for the same H position
const int lane_id = threadIdx.x;
const int warp_id = threadIdx.y;
const int WARPS_PER_BLOCK = blockDim.y;
// Each block handles 1 H output position
const int h_out = blockIdx.y;
const int n = blockIdx.z;
// Determine how many W positions and D values each warp handles
const int D = out_depth;
const int pixels_per_warp = (D <= 32) ? (32 / D) : 1;
const int active_threads = pixels_per_warp * D;
const int warp_w_base = (blockIdx.x * WARPS_PER_BLOCK + warp_id) * pixels_per_warp;
const int pixel_idx = lane_id / D;
const int d_out = lane_id % D;
const int w_out = warp_w_base + pixel_idx;
// Bounds check
if (h_out >= out_height || w_out >= out_width || lane_id >= active_threads) return;
const int in_channels_per_group = in_channels / groups;
const int out_channels_per_group = out_channels / groups;
// Input is (N, C, H, W, D)
// Weight is (C_out, C_in/groups, KH, KW, 1)
// Output is (N, C_out, H_out, W_out, D_out)
// Shared memory for input tile
// We load input for h_out*stride_h-pad_h to h_out*stride_h-pad_h+(kernel_h-1)*dilation_h
// and for the W range of this block plus kernel_w padding
// and for all depths and all input channels
// Determine the W range for this block
const int block_w_base = blockIdx.x * WARPS_PER_BLOCK * pixels_per_warp;
const int block_w_end = min(block_w_base + WARPS_PER_BLOCK * pixels_per_warp + (kernel_w - 1) * dilation_w, width);
// Actually for shared memory, we need to cover the input range for all warps in this block
// For output w in [block_w_base, block_w_base + WARPS_PER_BLOCK * pixels_per_warp - 1]
// Input w needed: w*stride_w - pad_w + kw*dilation_w for kw=0..kernel_w-1
// Min input w: block_w_base * stride_w - pad_w
// Max input w: (block_w_base + WARPS_PER_BLOCK * pixels_per_warp - 1) * stride_w - pad_w + (kernel_w-1)*dilation_w
const int in_w_start = block_w_base * stride_w - pad_w;
const int in_w_end = (block_w_base + WARPS_PER_BLOCK * pixels_per_warp - 1) * stride_w - pad_w + (kernel_w - 1) * dilation_w + 1;
const int in_w_size = in_w_end - in_w_start;
const int in_h_start = h_out * stride_h - pad_h;
const int in_h_end = in_h_start + (kernel_h - 1) * dilation_h + 1;
const int in_h_size = in_h_end - in_h_start;
// Shared memory size: in_h_size * in_w_size * depth * in_channels
// But in_h_size depends on the kernel and padding, and could be large
// For the test case: kernel_h=3, pad_h=0, so in_h_size=3
extern __shared__ char smem_raw[];
scalar_t* smem_input = reinterpret_cast<scalar_t*>(smem_raw);
// Cooperative loading of input tile into shared memory
const int smem_size = in_h_size * in_w_size * depth * in_channels;
for (int idx = threadIdx.x + threadIdx.y * 32; idx < smem_size; idx += 32 * WARPS_PER_BLOCK) {
// Decode idx to (ih, iw, d, ic)
int tmp = idx;
int ic = tmp % in_channels;
tmp /= in_channels;
int id = tmp % depth;
tmp /= depth;
int iw = tmp % in_w_size;
tmp /= in_w_size;
int ih = tmp;
int h_in = in_h_start + ih;
int w_in = in_w_start + iw;
scalar_t val = 0;
if (h_in >= 0 && h_in < height && w_in >= 0 && w_in < width) {
val = input[(((n * in_channels + ic) * height + h_in) * width + w_in) * depth + id];
}
smem_input[idx] = val;
}
__syncthreads();
// Now compute convolution
// Load weights from global memory (small, cached)
// For each output channel, accumulate
// Maximum output channels per warp to avoid too many registers
// For test case: 64 channels, 64 registers, manageable
// For general case, we might need to chunk
const int MAX_OC_PER_ITER = 64;
int num_oc_iters = (out_channels + MAX_OC_PER_ITER - 1) / MAX_OC_PER_ITER;
for (int oc_iter = 0; oc_iter < num_oc_iters; oc_iter++) {
int oc_start = oc_iter * MAX_OC_PER_ITER;
int oc_end = min(oc_start + MAX_OC_PER_ITER, out_channels);
int oc_count = oc_end - oc_start;
scalar_t acc[64]; // Will use only oc_count elements
for (int i = 0; i < oc_count; i++) acc[i] = 0;
for (int kh = 0; kh < kernel_h; kh++) {
int h_in = h_out * stride_h - pad_h + kh * dilation_h;
int ih_local = h_in - in_h_start;
if (ih_local < 0 || ih_local >= in_h_size) continue;
for (int kw = 0; kw < kernel_w; kw++) {
int w_in = w_out * stride_w - pad_w + kw * dilation_w;
int iw_local = w_in - in_w_start;
if (iw_local < 0 || iw_local >= in_w_size) continue;
for (int ic = 0; ic < in_channels_per_group; ic++) {
// For groups, actual input channel needs group offset
// But we need to know which group this output channel belongs to
// Since different OC can be in different groups, this complicates things
// For groups > 1, we process group by group
// But for simplicity, let's handle groups=1 first (most common)
// Actually, let's handle groups by computing per group
// Load input from SMEM
int smem_idx = ((ih_local * in_w_size + iw_local) * depth + d_out) * in_channels + ic;
scalar_t inp = smem_input[smem_idx];
for (int oc_local = 0; oc_local < oc_count; oc_local++) {
int oc = oc_start + oc_local;
int g = oc / out_channels_per_group;
int ic_actual = g * in_channels_per_group + ic;
// Weight index: (oc, ic_actual % in_channels_per_group, kh, kw, 0)
int w_idx = (((oc * in_channels_per_group + (ic_actual % in_channels_per_group)) * kernel_h + kh) * kernel_w + kw) * 1 + 0;
scalar_t w_val = weight[w_idx];
acc[oc_local] += inp * w_val;
}
}
}
}
// Write output
for (int oc_local = 0; oc_local < oc_count; oc_local++) {
int oc = oc_start + oc_local;
if (bias != nullptr) acc[oc_local] += bias[oc];
int out_idx = (((n * out_channels + oc) * out_height + h_out) * out_width + w_out) * depth + d_out;
output[out_idx] = acc[oc_local];
}
}
}
""";
// Host wrapper
torch::Tensor conv3d_forward(
torch::Tensor input,
torch::Tensor weight,
c10::optional<torch::Tensor> bias,
int stride_h, int stride_w,
int pad_h, int pad_w,
int dilation_h, int dilation_w,
int groups) {
// input: (N, C, H, W, D)
// weight: (C_out, C_in/groups, KH, KW, 1)
int batch = input.size(0);
int in_channels = input.size(1);
int height = input.size(2);
int width = input.size(3);
int depth = input.size(4);
int out_channels = weight.size(0);
int kernel_h = weight.size(2);
int kernel_w = weight.size(3);
int out_height = (height + 2 * pad_h - dilation_h * (kernel_h - 1) - 1) / stride_h + 1;
int out_width = (width + 2 * pad_w - dilation_w * (kernel_w - 1) - 1) / stride_w + 1;
int out_depth = depth; // kernel depth is always 1
auto output = torch::empty({batch, out_channels, out_height, out_width, out_depth}, input.options());
// Block and grid configuration
const int WARPS_PER_BLOCK = 8;
int pixels_per_warp = (out_depth <= 32) ? (32 / out_depth) : 1;
int warps_x = (out_width + pixels_per_warp - 1) / pixels_per_warp;
int blocks_x = (warps_x + WARPS_PER_BLOCK - 1) / WARPS_PER_BLOCK;
int blocks_y = out_height;
int blocks_z = batch;
// Shared memory size calculation (upper bound)
int in_h_size = (kernel_h - 1) * dilation_h + 1;
// For worst case in_w_size, assuming all warps are active
int max_warps_in_block = WARPS_PER_BLOCK;
int max_pixels = max_warps_in_block * pixels_per_warp;
int in_w_size = (max_pixels - 1) * stride_w + (kernel_w - 1) * dilation_w + 1;
if (pad_w > 0) {
// Actually we need to be more careful here
in_w_size = max_pixels + (kernel_w - 1) * dilation_w;
}
// Proper in_w_size calculation
int block_w_base_max = (blocks_x - 1) * max_pixels;
int in_w_start = block_w_base_max * stride_w - pad_w;
int in_w_end = (block_w_base_max + max_pixels - 1) * stride_w - pad_w + (kernel_w - 1) * dilation_w + 1;
in_w_size = in_w_end - in_w_start;
// Hmm this gives max over all blocks, which might be too conservative
// For a given block, we should compute exact size
// Simpler: compute exact size per block and pass it, or allocate max
int actual_in_w_size = max_pixels * stride_w + (kernel_w - 1) * dilation_w;
if (actual_in_w_size > width + 2 * pad_w) actual_in_w_size = width + 2 * pad_w;
int smem_size = in_h_size * actual_in_w_size * depth * in_channels * sizeof(float);
// But wait, not all blocks have the same W range size if out_width doesn't divide evenly
// For the last block, the W range is smaller, so in_w_size is smaller
// Using max is safe but might waste some SMEM
dim3 block(32, WARPS_PER_BLOCK, 1);
dim3 grid(blocks_x, blocks_y, blocks_z);
AT_DISPATCH_FLOATING_TYPES(input.scalar_type(), "conv3d_fast", [&] {
conv3d_fast_kernel<scalar_t><<<grid, block, smem_size>>>(
input.data_ptr<scalar_t>(),
weight.data_ptr<scalar_t>(),
bias.has_value() ? bias.value().data_ptr<scalar_t>() : nullptr,
output.data_ptr<scalar_t>(),
batch, in_channels, out_channels,
height, width, depth,
out_height, out_width, out_depth,
kernel_h, kernel_w,
stride_h, stride_w,
pad_h, pad_w,
dilation_h, dilation_w,
groups
);
});
return output;
}
""";
conv3d_fast = torch.utils.cpp_extension.load_inline(
name="conv3d_fast",
cpp_sources="""
torch::Tensor conv3d_forward(
torch::Tensor input,
torch::Tensor weight,
c10::optional<torch::Tensor> bias,
int stride_h, int stride_w,
int pad_h, int pad_w,
int dilation_h, int dilation_w,
int groups);
""",
cuda_sources=cuda_source,
functions=["conv3d_forward"],
extra_cuda_cflags=["-O3", "--use_fast_math"],
verbose=True,
);
class ModelNew(torch.nn.Module):
def __init__(self, in_channels, out_channels, kernel_size, stride=1, padding=0, dilation=1, groups=1, bias=False):
super().__init__()
self.in_channels = in_channels
self.out_channels = out_channels
self.kernel_size = kernel_size
self.stride = stride
self.padding = padding
self.dilation = dilation
self.groups = groups
self.weight = torch.nn.Parameter(torch.empty(out_channels, in_channels // groups, kernel_size, kernel_size, 1))
self.bias = torch.nn.Parameter(torch.empty(out_channels)) if bias else None
# Initialize
torch.nn.init.kaiming_uniform_(self.weight, a=5**0.5)
if self.bias is not None:
fan_in, _ = torch.nn.init._calculate_fan_in_and_fan_out(self.weight)
bound = 1 / fan_in**0.5
torch.nn.init.uniform_(self.bias, -bound, bound)
def forward(self, x):
# Call custom CUDA kernel
bias_tensor = self.bias if self.bias is not None else None
return conv3d_fast.conv3d_forward(
x, self.weight, bias_tensor,
self.stride, self.stride,
self.padding, self.padding,
self.dilation, self.dilation,
self.groups
)
'''
```
Wait, there are issues with this code:
1. The shared memory size calculation is wrong because in_w_size depends on the block. Different blocks have different `in_w_size`, but CUDA only allows one shared memory size per kernel launch. I need to compute the maximum shared memory needed across all blocks.
2. The indexing into SMEM uses `in_w_size`, but `in_w_size` can't be a variable unless I pass it to the kernel or compute it per block.
3. Groups handling is messy in the inner loop. I should precompute which group each output channel belongs to, or process channels group by group.
4. I used `64` as a fixed-size array for accumulators, but `oc_count` could be less than 64. CUDA allows variable-length arrays on the stack in device code, but `scalar_t acc[64]` with compile-time constant 64 is fine.
For problem 1: I can compute the maximum input tile size needed and use that for all blocks. The extra SMEM for smaller blocks won't hurt much.
Actually, for a block at position blockIdx.x:
- The W range is [blockIdx.x * WARPS_PER_BLOCK * pixels_per_warp, blockIdx.x * WARPS_PER_BLOCK * pixels_per_warp + max_pixels - 1]
- The input W range needed is [w_start * stride - pad, (w_end) * stride - pad + (kernel_w-1)*dilation]
- Maximum across all blocks: we'd need to consider all possible block positions.
For simplicity: since the block W positions are contiguous from 0 to out_width-1, the maximum input tile width occurs at the last block. But actually, it's roughly the same for all blocks: `max_pixels * stride + (kernel_w-1)*dilation_w` plus some padding adjustments.
For stride=1, kernel=3, dilation=1: input width needed = max_pixels + 2. With 8 warps * 3 pixels = 24, in_w_size = 26 for all blocks except possibly edge blocks. But we can allocate 26 for all blocks.
For the first block, if pad > 0, some input positions might be negative, but we still allocate space for them and fill with 0.
Let me revise: `in_w_size = max_pixels * stride_w + (kernel_w - 1) * dilation_w + 2 * pad_adjustment`. Actually, with padding, the output position w_out=0 corresponds to input w_in = -pad. So the input tile extends to negative coordinates, which we handle with bounds checks during loading.
The SMEM dimensions should cover:
- H: kernel_h (with stride=1, padding could add a bit, but for first/last h_out, the actual input range is smaller)
- W: max_pixels (per block) * stride + (kernel_w-1)*dilation, but adjusted by padding
Wait, I think I'm overcomplicating SMEM. Let me just load the needed global coordinates and compute the SMEM index as (ih_local * some_w_size + iw_local) * D * C + ic, where some_w_size is big enough.
For general kernel:
- The output W positions handled by a block are [w_start, w_end] where w_end = min(w_start + max_pixels - 1, out_width - 1).
- The input W positions needed for these output positions are: [w_start * stride - pad, w_end * stride - pad + (kernel_w-1)*dilation]
- The count is: w_end * stride - pad + (kernel_w-1)*dilation - (w_start * stride - pad) + 1 = (w_end - w_start) * stride + (kernel_w-1)*dilation + 1
- Maximum (w_end - w_start) = max_pixels - 1
- So max in_w_count = (max_pixels - 1) * stride + (kernel_w - 1) * dilation + 1
With max_pixels=24, stride=1, kernel=3, dilation=1: max in_w_count = 23 + 2 + 1 = 26.
For any block, the actual in_w_count might be smaller (if w_end < w_start + max_pixels - 1), but using 26 for all blocks is safe and wastes negligible SMEM.
Similarly for H: each block handles 1 H position, so in_h_count = (1-1)*stride + (kernel_h-1)*dilation + 1 = (kernel_h-1)*dilation + 1. For kernel=3, dilation=1: in_h_count = 3.
So SMEM size per block = 3 * 26 * D * in_channels.
For the test case: 3 * 26 * 10 * 3 = 2,340 floats = 9.36KB.
With 228KB SMEM per SM, we can run ~24 blocks per SM concurrently. 256 threads * 24 = 6,144 threads per SM, well over 2,048 limit. So the thread count limits us to 8 blocks per SM (2048/256 = 8). SMEM is not the bottleneck.
This is good! 8 blocks per SM * 132 SMs = 1,056 blocks concurrently.
Total blocks: ~44,704. 44,704 / 1,056 = 42.3 rounds.
Each block does: up to 24 W × 1 H × 10 D × 64 C × 27 FMAs = ~24.8M FMAs.
At 256 GFLOPS per SM: 24.8M / 256B = 96.9 microseconds per block.
42 rounds * 97 us = 4.1 ms per SM.
Wait, that can't be right. Let me recalculate.
24 W positions × 1 H × 10 D = 240 output values per warp.
8 warps = 1,920 output values per block.
64 channels each: 1,920 × 64 = 122,880 total outputs per block.
FMAs per output: 3 × 3 × 3 = 27.
Total FMAs per block: 122,880 × 27 = 3,317,760.
At 256 GFLOPS per SM: 3.3M / 256B = 13 microseconds per block.
42 rounds * 13 us = 546 microseconds. This is reasonable.
But we need to account for memory loads too. However, with SMEM, the input loads are efficient. The weight loads are cached.
OK, let me write the actual kernel more carefully.
Actually, I realize I made an error. With 8 warps per block, and each warp handling a different W group, the block handles WARPS_PER_BLOCK * pixels_per_warp = 8 * 3 = 24 W positions. That's correct.
For gridDim.x: we need enough blocks to cover all W positions.
warps_x = ceil(254 / 3) = 85
blocks_x = ceil(85 / 8) = 11
Total blocks = 11 * 254 * 16 = 44,704. Yes.
Let me write the corrected kernel.