reasoning
Let me analyze the reference model:
1. `nn.Conv3d(in_channels=32, out_channels=64, kernel_size=3, stride=1, padding=1)` - 3D convolution
2. `nn.MaxPool3d(kernel_size=2, stride=2)` - 3D max pooling
3. `torch.logsumexp(x, dim=1, keepdim=True)` - log-sum-exp along channel dim
4. `torch.relu(x)` - ReLU activation
Input shape: (batch_size=4, in_channels=32, depth=32, height=128, width=128)
After conv3d with padding=1, stride=1, kernel_size=3: output is (4, 64, 32, 128, 128)
After max_pool3d with kernel=2, stride=2: output is (4, 64, 16, 64, 64)
After logsumexp(dim=1, keepdim=True): output is (4, 1, 16, 64, 64)
After relu: output is (4, 1, 16, 64, 64)
We need to fuse these operations into a single CUDA kernel for speed.
The challenge is that conv3d is compute-intensive and the rest are element-wise or reduction operations. Fusing everything into a single kernel would be ideal but conv3d needs careful implementation.
Actually, for performance, we should probably:
1. Use cublas or some optimized conv3d implementation? But we need custom CUDA...
2. Implement a fused kernel that does conv3d + maxpool + reduction
Given the input sizes, implementing a full 3D convolution in CUDA is doable but complex. Let me think about whether we can use a simpler approach.
Wait - the reference implementation uses PyTorch's optimized Conv3d + MaxPool3d + logsumexp + relu. For speedup, we need to:
1. Either fuse the operations after conv3d
2. Or implement a more efficient conv3d
For 3D conv on 128x128x32 with 32->64 channels, this is quite compute heavy. PyTorch likely calls cuDNN which is highly optimized. It's unlikely we can beat cuDNN's conv3d with a handwritten kernel unless we can fuse downstream operations to save memory bandwidth.
The best strategy is probably:
1. Keep conv3d (it's hard to beat cuDNN)
2. But fuse maxpool3d + logsumexp + relu into a single kernel
Actually, can we fuse all of them? We could write a kernel that:
1. Computes conv3d output tile by tile
2. Immediately applies maxpool3d, logsumexp, and relu
But writing a full conv3d kernel that rivals cuDNN is extremely difficult.
Alternative: use PyTorch's conv3d but write a custom kernel for maxpool + logsumexp + relu. This saves two memory passes.
Wait, but can we do better? Let me reconsider. If we call conv3d first, we write (4*64*32*128*128) = ~134MB to memory. Then read it back for maxpool, then write, then read for logsumexp, etc. That's a lot of bandwidth.
But cuDNN conv3d is very fast. The bandwidth savings from fusing might not overcome the compute cost if our conv3d is slow.
Actually, for this problem, I think the best approach is:
1. Call conv3d using PyTorch (cuDNN)
2. Write a custom fused kernel for MaxPool3d + logsumexp + ReLU
Let me think about fused maxpool + logsumexp:
- After conv: (B, C, D, H, W) = (4, 64, 32, 128, 128)
- After maxpool (2x2x2 with stride 2): (4, 64, 16, 64, 64)
- Then logsumexp over C=64 channels into 1 channel, then ReLU
The fused kernel would process the output spatially. For each output position (b, d', h', w'), we need to:
1. Compute max over the 2x2x2 pool window across all C channels
2. Then compute logsumexp over the C channels of the pooled result
Wait, that's not right. The operations are sequential:
1. MaxPool3d: for each (b, c, d', h', w'), max over 2x2x2 window in (d, h, w)
2. logsumexp along c: for each (b, d', h', w'), compute log(sum(exp(x[:, c, d', h', w']))) over c
3. relu: max(0, x)
So the fused kernel would be:
For each (b, d', h', w'):
For each channel c:
max_val = -inf
For dd in 0..1, dh in 0..1, dw in 0..1:
max_val = max(max_val, input[b, c, 2*d'+dd, 2*h'+dh, 2*w'+dw])
store max_pooled[c] = max_val
Then compute logsumexp over c on max_pooled[0..C-1]
Then relu
This reads directly from conv output and writes final output. It saves reading/writing the large intermediate tensor from maxpool.
But is this enough to beat the reference? The reference also computes conv3d.
Actually, I realize we might be able to do even better by fusing conv3d too if the convolution is small enough. But kernel_size=3, which means it's a standard 3x3x3 conv. PyTorch/cuDNN handles this very well.
Let me calculate: the conv3d has:
- Input: 4 * 32 * 32 * 128 * 128 = 67,108,864 elements
- Output: 4 * 64 * 32 * 128 * 128 = 134,217,728 elements
- Weights: 64 * 32 * 3 * 3 * 3 = 55,296
- FLOPs: 4 * 64 * 32 * 128 * 128 * 32 * 3 * 3 * 3 ≈ 115 GFLOPs
At 67 TFLOPS FP32, that's ~1.7ms compute bound. At 3.35 TB/s bandwidth, reading input+weights+writing output = ~0.8ms memory bound. So conv3d is likely compute bound.
For our fused maxpool+logsumexp+relu:
- Input: 134M elements * 4 bytes = 536 MB
- Output: 4 * 1 * 16 * 64 * 64 = 262,144 elements * 4 bytes = 1 MB
- The fused kernel reads once and writes once: bandwidth = ~537 MB
- At 3.35 TB/s = 0.16ms
Reference: conv3d (~1.7ms) + maxpool (~0.16ms read+write + compute) + logsumexp (~0.08ms read+write) + relu (~0.04ms)
If maxpool+write intermediate is ~0.3ms, logsumexp+write is ~0.2ms, relu is ~0.1ms, total reference ~2.3ms.
Our approach: conv3d (~1.7ms) + fused kernel (~0.2ms) = ~1.9ms. That's maybe 20% speedup.
Can we fuse conv3d too? Let's think...
Actually, for 3D conv with small C_in=32, C_out=64, and spatial 32x128x128, we might be able to write a reasonable conv3d+maxpool+logsumexp+relu kernel using shared memory.
The conv3d part: For each output position after pooling (d', h', w'), the conv3d output at (c, 2d', 2h', 2w') depends on input at positions (2d'+dd-1, 2h'+dh-1, 2w'+dw-1).
If we tile the computation by output blocks after pooling, say 4x8x8 spatial blocks (corresponding to 5x9x9 input spatial region due to padding and kernel overlap), we can load input into shared memory and compute conv3d + maxpool + logsumexp all together.
For a tile of (T_d, T_h, T_w) output pool positions:
- Conv output positions: (T_d*2, T_h*2, T_w*2)
- But due to maxpool, we only need to consider groups of 2x2x2
- Actually, the conv output positions that map to a pool output tile of size (T_d, T_h, T_w) are (2*T_d, 2*T_h, 2*T_w), but because of the kernel overlap, the input region is (2*T_d+2, 2*T_h+2, 2*T_w+2)
For T_d=4, T_h=8, T_w=8:
- Input region: 10x18x18 = 3240 positions
- Per position: 32 channels
- In shared memory: 3240 * 32 * 4 bytes = 414,720 bytes ≈ 405 KB
That's close to the 227 KB shared memory limit per block. We need to be more careful.
Actually, we can process channel groups. Or use smaller tiles. Or load input on-the-fly.
Let me think about an alternative: instead of loading the full input tile, we can compute the conv3d directly from global memory. But for 32 input channels, each conv output element requires 32*27 = 864 multiplies, which is quite a lot of register pressure if we do multiple outputs per thread.
For a thread processing one pool output (which involves 8 conv outputs, one per 2x2x2 window):
- For each channel c_out in 0..63:
- For each of 8 positions in pool window:
- Compute conv3d at that position (864 FMAs)
- Take max of 8 values
- Then do logsumexp over 64 channels
That's 64 * 8 * 864 = 442,368 FMAs per thread per output position! That's way too much.
We need to parallelize over channels. Each thread block could process a spatial tile and all threads collaborate on channels.
Actually, a better approach:
- Process conv3d for one c_out at a time across all spatial positions in the tile
- Take maxpool values
- Accumulate for logsumexp
For example, a thread block of 256 threads processes a spatial region of 16x16x4 = 1024 conv outputs = 128 pool outputs (if arranged as 8x8x2 pool outputs).
Wait, 16x16x4 conv outputs = 8x8x2 pool outputs.
For each c_out:
- Each thread computes conv for some conv outputs in the tile
- Store the conv results in shared memory or registers
- Then do maxpool to get max over 2x2x2
- Add to running logsumexp accumulation in shared memory
But logsumexp needs a running accumulation. Let me use the stable formulation:
lse(x1, x2, ..., xn) = max(xi) + log(sum(exp(xi - max(xi))))
So we need to track running max and running sum. For each new c_out value v:
new_max = max(old_max, v)
new_sum = old_sum * exp(old_max - new_max) + exp(v - new_max)
Actually, that's a sequential reduction over c_out. If we process c_out one at a time across the tile, each thread can maintain its own running max and sum in registers for its assigned pool outputs.
So the algorithm per thread:
For each pool output position p assigned to this thread:
max_val = -inf
sum_val = 0
For c_out = 0 to 63:
For each pool output position p:
// Compute maxpool by computing conv3d at 8 positions and taking max
pool_max = -inf
for each of 8 conv positions in pool window:
conv_val = conv3d(input, weight[c_out], at conv_position)
pool_max = max(pool_max, conv_val)
// Update logsumexp
old_max = max_val
max_val = max(old_max, pool_max)
sum_val = sum_val * exp(old_max - max_val) + exp(pool_max - max_val)
For each pool output position p:
result = max_val + log(sum_val)
result = max(0, result) // relu
This is still a lot of compute per thread. For each c_out and each pool position, we do 8 * 864 = 6912 FMAs just for conv. With 64 channels, that's ~442k FMAs per pool output.
If a thread handles, say, 8 pool outputs, that's ~3.5M FMAs. At 67 TFLOPS, with good utilization, a block of 256 threads on one SM could do this in maybe ~0.2ms. But with memory access patterns, it might be slower.
Wait, I think I'm overthinking this. The PyTorch reference with cuDNN + elementwise ops is probably very fast. To beat it significantly, we'd need to either:
1. Write a really good fused kernel
2. Or realize that the reference might not be that optimized for this exact sequence
Actually, PyTorch's logsumexp is not super optimized for this case since it operates along dim=1 on a tensor of size (4, 64, 16, 64, 64). It probably launches several kernels. And maxpool3d is another kernel. So the reference has:
1. conv3d (cuDNN)
2. maxpool3d (custom kernel)
3. logsumexp (multiple kernels for stable computation)
4. relu (elementwise kernel)
That's at least 4 kernel launches. Fusing steps 2-4 saves significant overhead.
And if we can also integrate step 1, we save the memory write of conv output and read back.
Let me try to write a fused kernel that includes conv3d + maxpool + logsumexp + relu. I'll need to be careful about shared memory and tiling.
Actually, I realize there might be an even simpler approach: use implicit GEMM or im2col for conv3d, then do the rest. But implicit GEMM for 3D conv is complex.
Let me reconsider whether I should just do conv3d in PyTorch and fuse the rest.
The reference is:
```python
x = self.conv(x) # -> cuDNN
x = self.max_pool(x) # -> PyTorch kernel
x = torch.logsumexp(x, dim=1, keepdim=True) # -> PyTorch composite (probably ~3 kernels)
x = torch.relu(x) # -> PyTorch elementwise kernel
```
4-6 kernel launches. If we reduce to conv3d + 1 custom kernel, that's 2 launches. The speedup might come from:
- Fewer kernel launches
- No intermediate memory traffic between maxpool/logsumexp/relu
For a compute-heavy conv3d, the speedup from fusing the rest might only be 10-20%. But maybe that's enough.
Wait, let me reconsider the problem. The evaluation might have a specific test setup where our custom kernel is compared against the PyTorch reference. Even a 10% speedup might be enough if the reference is slow for some reason.
Let me try a different strategy: implement the conv3d using CUTLASS or something? No, we're limited to writing raw CUDA.
Actually, let me check if there's a simpler way. What if we decompose the 3D conv into three 1D convs? No, that's not equivalent.
What about using Fast Fourier Transform? For 3x3x3 kernel, direct conv is faster.
Let me try just fusing the post-conv operations first, and see what speedup we get. If it's not enough, I'll try fusing everything.
I'll write a kernel that:
1. Takes conv output (B, C_out, D, H, W)
2. Computes maxpool3d across 2x2x2 windows
3. Computes logsumexp along C_out
4. Applies ReLU
The kernel will read directly from conv output and write the final result.
Wait, I should consider whether we can even beat the reference by just fusing. Let me also check if there's implicit synchronization in PyTorch's logsumexp that we can avoid.
PyTorch's logsumexp implementation typically does:
1. Compute max along dim
2. Subtract max, exp, sum along dim
3. log + add back max
That's 3-4 kernel launches if not fused. Our single kernel would be a win.
Let me implement this fused kernel and also try a fully fused approach. I'll start with the fused post-conv kernel since it's simpler and less error-prone.
For the fully fused approach, I need to think about thread block organization.
Let me think about the fully fused kernel more carefully:
Input: (B=4, C_in=32, D=32, H=128, W=128)
Weights: (C_out=64, C_in=32, K=3, K=3, K=3)
Output after pooling: (B=4, 1, D'=16, H'=64, W'=64)
For each output element (b, d', h', w'):
max_lse = 0 // running logsumexp state
max_val = -inf
sum_val = 0
for c_out = 0 to 63:
// Compute 8 conv values for this c_out at the 2x2x2 pool positions
pool_max = -inf
for kd in 0..1, kh in 0..1, kw in 0..1:
conv_pos_d = 2*d' + kd
conv_pos_h = 2*h' + kh
conv_pos_w = 2*w' + kw
conv_val = bias[c_out] // if there's bias
for c_in = 0 to 31:
for kdd in 0..2, khh in 0..2, kww in 0..2:
in_d = conv_pos_d + kdd - 1
in_h = conv_pos_h + khh - 1
in_w = conv_pos_w + kww - 1
if in bounds:
conv_val += weight[c_out, c_in, kdd, khh, kww] * input[b, c_in, in_d, in_h, in_w]
pool_max = max(pool_max, conv_val)
// Update logsumexp
if pool_max > max_val:
sum_val = sum_val * exp(max_val - pool_max) + 1
max_val = pool_max
else:
sum_val += exp(pool_max - max_val)
result = max_val + log(sum_val)
if result < 0: result = 0
This is about 64 * (8 * 32 * 27 + 8) ≈ 441k FMAs + 512 comparisons + 64 exponential evaluations per output element.
There are 4 * 16 * 64 * 64 = 262,144 output elements.
Total FLOPs: 262,144 * 441k ≈ 115 GFLOPs for conv + some for exps.
At 67 TFLOPS, that's ~1.7ms just for the FMAs. Plus memory access to read input (67M elements * 4 bytes = 268 MB) and weights (55k * 4 bytes = 0.2 MB). At 3.35 TB/s, memory is ~0.08ms if perfectly cached.
So this is compute-bound. The question is whether we can achieve good utilization.
The main issue is register usage and memory access patterns. Each thread computing one output needs ~441k FMAs, which is a huge loop body. To keep the GPU busy, we'd want each thread to handle multiple outputs, but that increases register pressure.
Alternatively, a thread block of 256 threads could each handle one output. 262,144 / 256 = 1024 thread blocks. An H100 has 132 SMs, so that's ~8 blocks per SM. If each thread does 441k FMAs, and assuming 1 FMA per clock at FP32, at ~2GHz that's ~0.2ms per thread. But with 64-wide warps, the DP throughput is what matters.
An H100 SM has 128 FP32 FMA units per SM (actually it's more due to tensor cores, but for pure FP32 it's less). At 67 TFLOPS with ~132 SMs, that's ~500 GFLOPS per SM. At ~2GHz, that's 250 FMAs per clock per SM, or ~2 FMAs per clock per thread if fully occupied with 128 threads per SM.
Actually H100 has 128 FP32 CUDA cores per SM. At ~2GHz base clock, peak is ~256 GFLOPS per SM for FMA, or 512 GFLOPS for ADD+MUL. With 132 SMs, that's ~67 TFLOPS. So each SM can do 128 FMAs per clock. With 2048 threads per SM (the max occupancy for H100 is 2048 threads/SM for some configs), that's 1 FMA per 16 threads per clock, or very low throughput per thread if all threads are active.
The issue is that to saturate the SM, we need enough warps to hide latency. With 64 warps per SM (2048 threads / 32), and each warp doing independent work, we can hide the ~20 cycle latency of FMAs.
But each thread here would be doing 441k FMAs sequentially. With 256 threads per block and 8 blocks per SM, that's 2048 threads per SM, perfect. But will the memory access patterns allow good performance?
The weights for 64 * 32 * 27 are small enough to be in L2 cache. The input for one output position is a 3x3x3 neighborhood. Adjacent output positions read overlapping inputs. With adjacent threads in a warp accessing adjacent spatial positions, we should get good cache behavior.
Wait, adjacent threads processing adjacent (d', h', w') positions would read adjacent conv inputs, which map to overlapping but slightly shifted input regions. Since each position reads a 3x3x3 neighborhood, adjacent positions in the same warp share many input values.
For example, threads in a warp processing (d', h', w') = (0, 0, 0..31) would read input regions that overlap significantly in the W dimension. Specifically, thread w' reads input w positions from 2*w'-1 to 2*w'+1. So adjacent threads w' and w'+1 read w positions that overlap by 1. This is great for caching.
For the H dimension, if threads in a block are arranged as a 2D tile in H and W, they read overlapping regions in both dimensions.
So the memory access pattern can be quite good. Let me design the thread block as a 3D tile in (d', h', w').
I could use block sizes like (4, 4, 16) = 256 threads, processing a 4x4x16 tile of pool outputs. This means the conv outputs are 8x8x32 = 2048 positions, and the input region is 10x10x34 = 3400 positions.
For each c_out, each thread computes 8 conv values (for the 2x2x2 pool window), each requiring 32*27 = 864 FMAs.
Wait, each thread handles one pool output? No, with a block of 256 threads and a tile of, say, 4x4x16 = 256 pool outputs, each thread handles exactly one pool output. Each pool output requires 8 conv values, so each thread does 8 * 864 = 6912 FMAs per c_out. For 64 c_outs, that's 442,368 FMAs per thread. At 500 GFLOPS per SM with 8 blocks, total FLOPS is 8 * 256 * 442k = 905M FMAs per SM... wait, that doesn't make sense.
Total FLOPs in the whole kernel: 4 * 16 * 64 * 64 * 64 * 8 * 32 * 27 = 4 * 16 * 64 * 64 * 64 * 8 * 864 = hmm, that can't be right. Let me recalculate.
For each of the 4 * 1 * 16 * 64 * 64 = 262,144 output elements:
- We compute logsumexp over 64 channels
- For each channel, we compute max over 8 conv positions
- For each conv position, we do 32 * 27 = 864 FMAs
Total FMAs = 262,144 * 64 * 8 * 864 = 115.7 billion FMAs.
Wait, that's 115 GFLOPs, not GFMAs. Actually 1 FMA = 2 FLOPs. So 115B FMAs = 231 BFLOPs = 231 GFLOPs? No:
262,144 * 64 * 8 * 864 = 115,964,469,248 ≈ 116 billion multiplications and 116 billion adds = 232 GFLOPs.
At 67 TFLOPS, that's 232 / 67,000 = 0.0035 seconds? No wait, TFLOPS is 10^12 FLOPS, GFLOPS is 10^9. 232 GFLOPs / 67,000 GFLOPS = 0.00346 seconds = 3.46ms.
Hmm wait, 67 TFLOPS = 67,000 GFLOPS. 232 GFLOPs / 67,000 = 0.00346s = 3.46ms. But earlier I calculated the conv3d alone should be around 1.7ms. Let me check my math.
Actually in PyTorch's conv3d, it does 32 * 27 FMAs per output channel per output position. The conv output has 4 * 64 * 32 * 128 * 128 = 134,217,728 positions. Each requires 32 * 27 = 864 FMAs. Total = 134M * 864 = 115.9B FMAs. At 67 TFLOPS FP32 = 33.5B FMA/s (since 1 FMA = 2 FLOPs). Wait, actually TFLOPS counts FLOPs, not FMAs.
67 TFLOPS means 67 trillion floating point operations per second. Each FMA is 2 floating point operations (1 mul + 1 add). So 67 TFLOPS = 33.5 trillion FMAs per second.
Total FMAs for conv = 115.9B. Time = 115.9B / 33.5T = 0.00346s = 3.46ms.
So the conv alone would take ~3.5ms at peak FP32 throughput. Plus maxpool, logsumexp, relu.
For the fully fused kernel, we're doing the same number of conv FMAs PLUS additional work for maxpool, logsumexp, and relu. So it can't be faster unless we find a way to reduce the conv FMAs, which isn't possible.
Wait, but PyTorch uses cuDNN which can use tensor cores (TF32 = 989 TFLOPS). At 989 TFLOPS, the conv takes 0.23ms! That's a huge difference.
So a custom FP32 conv3d kernel has NO CHANCE of beating cuDNN because cuDNN uses tensor cores for matmul-based conv (im2col + GEMM).
Therefore, the CORRECT strategy is:
1. Use PyTorch's conv3d (cuDNN with tensor cores)
2. FUSE maxpool + logsumexp + relu into a single kernel
This saves the memory bandwidth and kernel launch overhead of the post-conv operations, which is our only path to a speedup.
Let me design the fused post-conv kernel.
Input to kernel: conv3d output of shape (B, C, D, H, W) = (4, 64, 32, 128, 128)
Output: (B, 1, 16, 64, 64)
Each thread handles one or more output positions (b, 1, d', h', w').
For a thread processing one output position:
max_val = -inf
sum_val = 0.0
// For each channel c, compute maxpool value
for c in 0..63:
pool_max = -inf
// Read 8 values and take max
for dd in 0..1, dh in 0..1, dw in 0..1:
idx = b * C * D * H * W + c * D * H * W + (2*d'+dd) * H * W + (2*h'+dh) * W + (2*w'+dw)
val = input[idx]
pool_max = max(pool_max, val)
// Update logsumexp
if pool_max > max_val:
sum_val = sum_val * exp(max_val - pool_max) + 1.0
max_val = pool_max
else:
sum_val += exp(pool_max - max_val)
result = max_val + log(sum_val)
if result < 0: result = 0
output[b * D' * H' * W' + d' * H' * W' + h' * W' + w'] = result
This is straightforward. The main optimization opportunities:
1. Vectorized loads (float4) if possible
2. Shared memory to cache input reads if threads in a block share data
3. Process multiple channels per iteration or use cooperative loading
The kernel is memory bandwidth bound since it reads 64*8=512 float values per output element. But only writes 1 float. The arithmetic intensity is very low: ~512 reads + some exp/log per 1 write.
Wait, is it bandwidth bound? Let me check:
- Reads: 262,144 outputs * 512 floats = 134,217,728 floats = 536.9 MB
- Exps and logs: 262,144 * 64 = 16.8M exp, 262,144 log = heavy transcendental operations
Hmm, exp and log are expensive. On H100, throughput of special functions is much lower than FMA. H100 has special function units that can do ~1 special op per clock per thread block or something like that. Actually, on Ampere/Hopper, special function throughput is 1 per SM per 4 clocks or something? Let me recall.
Actually, special function throughput varies. On H100, I believe it's 8 special function units per SM? Or maybe 16? Let's assume it's still relatively low. 16.8M exp operations plus 262k log operations. At, say, 16 special ops per clock per SM at 2GHz = 32G special ops/s per SM. With 132 SMs, that's ~4.2T special ops/s. 16.8M / 4.2T = 0.004ms. So special functions are not the bottleneck.
Actually wait, that assumes full utilization. With 256 threads per block and one special op at a time per thread, the throughput per thread is low, but we have many threads. If special function throughput is 16 per SM per clock, that's 16 ops per 2048 threads per clock = 1 op per 128 threads per clock. For 16.8M total ops across all threads, time = 16.8M / (132 SMs * 16 ops/clock * 2GHz) = 16.8M / 4.2T = 0.004ms. That's negligible.
So the kernel is bandwidth-bound at 537 MB read + 1 MB write = 538 MB. At 3.35 TB/s = 0.16ms.
The reference for maxpool + logsumexp + relu separately:
- maxpool: reads 536 MB, writes 67 MB (for 4*64*16*64*64) = ~603 MB traffic
- logsumexp: reads 67 MB, writes maybe via temp arrays, let's say ~200 MB
- relu: reads 1 MB, writes 1 MB
Total: ~800+ MB + multiple kernel launches.
So we save maybe 0.1-0.2ms, which is small compared to the conv time of ~0.23ms (if cuDNN uses TF32 tensor cores). That's only a few percent speedup.
Are there other optimizations? What if PyTorch's conv3d is not using TF32 by default? Let me think...
Actually, for the evaluation, PyTorch defaults might not use tensor cores for this conv. cuDNN heuristics choose algorithms. For 3D conv, the algorithm choice depends on sizes. It might pick FFT, Winograd, or implicit GEMM.
But even at 67 TFLOPS FP32, conv takes 3.46ms. Our post-conv fusion saves maybe 0.2ms, which is 6% speedup. Is that enough?
Hmm, let me reconsider whether I can do better. What if I:
1. Use PyTorch conv3d
2. Then do maxpool + logsumexp in a kernel that also uses some smarts?
Actually, what about using warp-level primitives to parallelize over channels? A warp of 32 threads could each handle 2 channels, doing the maxpool in parallel, then use warp shuffle for the logsumexp reduction.
Wait, I should use grid-stride loop with vectorized loads and process multiple spatial positions per thread to amortize overhead.
Actually, let me think about memory access. With float4 loads, each thread can load 4 contiguous floats at once. For the W dimension, if W=128 and we're processing w' from 0 to 63, the conv positions are at 2*w' and 2*w'+1, which are not contiguous in groups of 4. So float4 loading is tricky.
For a thread processing position (b, c, d, h, w) where w is even or odd:
- w=2*w': we could load [w, w+1, w+2, w+3] as float4
- But for maxpool, the 2x2x2 window at (d', h', w') needs positions (2d', 2h', 2w'), (2d', 2h', 2w'+1), (2d', 2h'+1, 2w'), etc.
If we have threads in a warp processing consecutive w' values, the reads for the "even" w positions of different w' might not align well. Let me think...
w' = 0: needs W=0,1
w' = 1: needs W=2,3
w' = 2: needs W=4,5
...
For the same (b, c, d, h), the W positions needed by consecutive w' are consecutive! So a warp processing w'=0,1,2,3,... would read W=0,1,2,3,4,5,... which is perfectly contiguous. Great!
Similarly in H: h'=0 needs H=0,1, h'=1 needs H=2,3, etc.
So if threads are arranged in a 1D/2D/3D grid where the fastest dimension is w', then within a warp (32 threads), we process w'=0 to 31 (or some range). The reads for the "first half" of each 2x2x2 window will be contiguous.
But we still have to read 8 positions per channel per output. We could process channels in a way that threads in a warp split the channel dimension.
Actually, let me try a different approach. What if I do the maxpool first and store it in shared memory, then do logsumexp?
A block of 256 threads processes a tile of, say, 8x8x4 = 256 pool outputs (which correspond to 16x16x8 conv outputs). For each channel:
1. All threads compute their pool outputs and store in shared memory
2. Then do a parallel logsumexp over channels
But logsumexp over 64 channels with 256 threads... we could have 4 threads per spatial position, each handling 16 channels. But logsumexp needs sequential reduction over channels to be stable, or we can use a parallel tree reduction with the stable update formula.
Actually, we can do:
1. Each thread computes maxpool for its positions across all 64 channels, storing results in a local array? No, 64 channels * 1 float = 256 bytes per thread. For 256 threads, that's 64KB, which fits in shared memory if stored per position.
Better: for a tile of 256 positions, we process all 64 channels. For each channel:
- Load conv values, compute maxpool for each position in the tile
- Store the 256 maxpool values in shared memory
- Then update the running logsumexp for each position
Running logsumexp state: 2 floats per position (max and sum), so 512 floats = 2KB. Very small.
So the shared memory usage is ~1KB for running state + 1KB for current channel's maxpool values = 2KB. Very manageable.
Each iteration reads 256 * 8 = 2048 floats from conv output.
If we arrange the 256 threads as a 4x4x16 tile in (d', h', w'), then for channel c and positions in the tile, the reads are mostly contiguous in W and somewhat scattered in H and D.
Actually, let me keep it simple at first. I'll write a straightforward kernel where each thread processes one output position and loops over channels. Then if needed, I can optimize.
Actually, I just realized something important. Can I use Tensor Core-accelerated matrix multiplication for the conv3d via PyTorch, and then just do a really efficient fused kernel for the rest? Yes.
But I wonder if there's a way to use CUTLASS or something for the conv and then fuse. Actually, PyTorch's conv3d already uses the best available cuDNN algorithm.
Let me also consider: what if PyTorch is doing the conv3d in channels-last format for better performance? We don't need to worry about that since we're just calling it.
One more thought: can we precompute the maxpool and reuse data via shared memory? For our fused kernel reading from conv output, if we process a spatial tile, we can load data cooperatively into shared memory to reduce global memory bandwidth.
For a 4x4x4 tile of pool outputs (64 pool outputs, 512 conv outputs), the conv output region is 8x8x8. For each channel, that's 512 floats = 2KB. With 64 channels, that's 128KB total if we loaded everything, but we process one channel at a time.
Alternatively, we could compute the maxpool on-the-fly without storing to shared memory, just updating the logsumexp state directly. This means each thread reads 8 floats per channel, updates its state, and moves to the next channel. No shared memory needed for the data itself.
With a block size of 256 threads, each thread does its own reads. For good memory coalescing, adjacent threads should read adjacent memory. If thread i processes position (b, d', h', w'+i), then for channel c, they all read positions (b, c, 2d', 2h', 2(w'+i)) which are contiguous in memory (since W is the fastest dimension).
So if a warp reads the "even" W positions, it's perfectly coalesced. Then they read the "odd" W positions, also coalesced. Same for H and D dimensions.
This is great! So a simple kernel where:
- Thread block = (tx, ty, tz) processing a tile
- Each thread loops over c=0..63
- For each c, reads 8 values and takes max
- Updates logsumexp state
- At end, writes result
Would have excellent memory coalescing for the first 4 reads (the ones in W-direction). The H and D direction reads are strided but still regular.
Actually, the 8 reads per channel are at offsets:
(0,0,0), (0,0,1), (0,1,0), (0,1,1), (1,0,0), (1,0,1), (1,1,0), (1,1,1)
In terms of memory layout (row-major):
offset = ((2d'+dd)*H + (2h'+dh))*W + (2w'+dw)
For fixed dd, dh, and varying dw=0,1:
offset = base + 2w' + dw
So for a warp of threads with consecutive w', the reads for dw=0 are at base+2*w', base+2*w'+2, base+2*w'+4, ... which are strided by 2. This is not perfectly coalesced!
Wait, consecutive threads reading with stride-2 in W means half of the cache line is wasted per warp. That's bad for bandwidth.
Actually, for a warp reading 32 floats with stride 2, it touches 64 consecutive floats, which is 256 bytes = 4 cache lines (64B each). So it's not terrible, but not perfect.
If we could process w' in pairs... like have each thread process 2 consecutive w' positions. Then for dw=0:
Thread 0 reads w=0, thread 1 reads w=2, thread 2 reads w=4... still stride 2.
What if we have each thread process one w' position but we use a thread block where threads in a warp process positions with the same w' but different h'? Then the reads for dw=0 are at base + (2h'+dh)*W + 2w' + dw.
For fixed w' and varying h', this is strided by 2W = 256 floats = 1024 bytes. That's 16 cache lines for 32 threads! Terrible.
So the best coalescing is when threads vary in w'. Stride 2 is not ideal but acceptable.
Can we restructure to get stride-1 reads? For the "even" part of maxpool, we need to read W=0,2,4,6,... for the "base" of each 2x2x2 window. These are exactly the values we need for a stride-2 maxpool.
Actually, what if we transpose or reorder our computation? If we process two channels at once and use float2 or something?
Or, what if we just accept the stride-2 reads and rely on L2 cache? Since the conv output is 134M floats = 536 MB, which fits in L2 cache? H100 has 50MB L2 cache, so no, it doesn't fit. Only a fraction fits.
Hmm, actually 536 MB doesn't fit in L2. But each channel slice is 8.4 MB (32*128*128). There are 64 channels. If we process all spatial positions for one channel before moving to the next, we read each channel entirely once. For one channel at a time, the working set is 8.4 MB, which is bigger than L2 but might partially stay cached.
If we process channel 0 for all positions, then channel 1, etc., each channel read is ~8.4 MB. For 64 channels, total read is 537 MB. The reuse within a channel is high if we process spatially nearby positions together.
Actually, this is the same as before. Let me just accept the memory access pattern and write the kernel. The key is to use enough threads to hide latency.
One more idea: use vectorized loads where possible. For the two W positions, a float2 load at (2w', 2w'+1) would load two adjacent floats. This is perfectly coalesced!
For each of the 8 positions in the pool window, the W positions are (2w', 2w'+1). These are adjacent in memory. So we can load them as float2!
For each thread processing position w':
- float2 w_vals = reinterpret_cast<float2*>(&input[...base + 2*w'])[0];
This loads both w positions in one instruction.
The other dimensions (dd and dh) are strided by H*W. So for the 4 combinations of (dd, dh), we have 4 float2 loads per channel per thread. That's 4 loads instead of 8. And each float2 load is coalesced.
Wait, are they coalesced? For a warp of threads with consecutive w', the float2 loads at base+2*w' are reading positions base+2*w', base+2*w'+1 for thread w'. Thread w' and w'+1 read base+2*(w'+1), base+2*(w'+1)+1 = base+2*w'+2, base+2*w'+3. So a warp reads 64 consecutive floats = 256 bytes. Perfect coalescing!
This is great. So we can use float2 loads for the W dimension. But we still need to handle dd and dh, which give us 4 separate base addresses per thread.
Each thread per channel does 4 float2 loads. Per channel, 4 * 2 = 8 floats read. Perfect.
So the kernel structure:
```cuda
__global__ void fused_kernel(const float* __restrict__ conv_out,
float* __restrict__ output,
int B, int C, int D, int H, int W,
int D_out, int H_out, int W_out) {
int b = blockIdx.x;
int d = blockIdx.y;
int h = blockIdx.z;
int w = threadIdx.x + blockIdx.w * blockDim.x; // Need 4D grid or tile mapping
// Actually CUDA only supports up to 3D grid. Need to flatten spatial dimensions.
}
```
I need to flatten the output spatial dimensions. Let me map threads to output positions.
Total outputs: B * D_out * H_out * W_out = 4 * 16 * 64 * 64 = 262,144
I'll use a 1D grid of thread blocks. Each block has 256 threads and processes 256 consecutive output positions (or some mapping).
To get good locality, I should map consecutive threads to consecutive positions in the W dimension, then H, then D, then B.
For position idx in 0..261143:
b = idx / (D_out * H_out * W_out)
rem = idx % (D_out * H_out * W_out)
d = rem / (H_out * W_out)
rem = rem % (H_out * W_out)
h = rem / W_out
w = rem % W_out
Then thread idx processes output position (b, d, h, w).
But consecutive threads have consecutive w, which gives good coalescing for float2 loads. However, within a warp, we'd have threads with different (b,d,h) if W_out=64 < 32. Actually 64 >= 32, so a warp of 32 threads spans at most 32 consecutive w values, which is within one (b,d,h) plane since W_out=64. Two warps fit in one (b,d,h) plane. So threads in a warp always share the same b, d, h. Perfect!
Wait, W_out=64. A warp of 32 threads has w from n*32 to n*32+31. That's all within the same (b,d,h). So yes, perfect.
For the float2 loads:
Base address for channel c and spatial (d_pos, h_pos, w_pos):
base = ((b * C + c) * D + d_pos) * H * W + h_pos * W + w_pos
For a thread with output (b, d, h, w):
The conv positions needed are at d_pos=2d+dd, h_pos=2h+dh, w_pos=2w+dw.
With float2, we read (2w, 2w+1) together.
For each (dd, dh) in {(0,0), (0,1), (1,0), (1,1)}:
d_pos = 2d + dd
h_pos = 2h + dh
base_addr = ((b * C + c) * D + d_pos) * H * W + h_pos * W + 2w
float2 vals = load(base_addr)
max_val = max(max_val, vals.x)
max_val = max(max_val, vals.y)
That's 4 float2 loads per channel per thread.
Wait, D_out=16, and 2d+dd can be 32 or 33 for d=16? No, d ranges from 0 to 15, so 2d ranges from 0 to 30, and 2d+1 ranges from 1 to 31. D=32, so all positions are valid.
Similarly H=128, H_out=64, 2h+dh ranges from 0 to 127. Valid.
W=128, W_out=64, 2w+dw ranges from 0 to 127. Valid. (w ranges from 0 to 63)
Great, no boundary checks needed!
Now let me think about register usage. Each thread needs:
- running max: 1 float
- running sum: 1 float
- loop variables
- load values
That's very few registers. So we can have high occupancy.
What about occupancy? With 256 threads per block and 32 registers per thread, that's 8K registers, well within the 64K per SM limit, so 2 blocks per SM minimum, likely more. Actually with 255 register limit, we could have 256 threads * 64 registers = 16K, still 4 blocks per SM. High occupancy is easy.
Now, let's consider grid size. Total outputs = 262,144. With 256 threads per block:
- If each thread handles 1 output: 1024 blocks. With 132 SMs, that's ~8 blocks per SM. Good.
- If each thread handles multiple outputs via grid-stride loop: fewer blocks but more work per thread. Might be worse for cache locality.
At 1024 blocks of 256 threads, with 4 blocks per SM (assuming 64K registers / 16K per block = 4 blocks), we have perfect occupancy. Each SM has 1024 threads, and with 2048 max threads per SM, that's 50% occupancy. With 32 registers per thread, we get 8 blocks per SM = 2048 threads = 100% occupancy.
The throughput would be limited by memory bandwidth. At 100% occupancy and good coalescing, we should get near peak bandwidth.
Now, 537 MB read at 3.35 TB/s = 0.16ms. Plus some compute. The reference has conv3d at, say, 3.46ms (FP32) or 0.23ms (TF32), plus post-processing.
Wait, does PyTorch default to TF32 for conv3d? Yes, since PyTorch 1.7, TF32 is enabled by default on Ampere/Hopper. So the conv3d likely uses TF32 tensor cores and takes ~0.23ms.
Then maxpool + logsumexp + relu in PyTorch probably takes:
- maxpool: 134M reads (conv output) / 3.35TB/s ≈ 0.16ms for bandwidth. Plus writing 67MB = another 0.02ms. But kernels have overhead. Realistically 0.2-0.3ms.
- logsumexp: reads 67MB, does some compute, writes 1MB. But internally it needs temp storage for max, then exp, sum, log. Probably 3-4 kernels totaling 0.3-0.5ms.
- relu: trivial, 0.05ms.
Total reference: ~0.23ms + 0.3ms + 0.4ms + 0.05ms = ~1ms? That seems high for logsumexp. Actually logsumexp on (4, 64, 16, 64, 64) = 16.7M elements might be optimized to 2-3 kernels. The reduction isn't huge since dim=1 has only 64 elements. So it's more element-wise than reduction-heavy.
Actually for logsumexp(dim=1, keepdim=True):
1. max over dim 1: (4, 64, 16, 64, 64) -> (4, 1, 16, 64, 64). This is a reduction of 64 -> 1 per output. PyTorch might use a block-wise reduction.
2. subtract max, exp, sum over dim 1: similar reduction.
3. log + add max back: elementwise.
Each of these involves reading the full tensor and writing partial results or the output. So total memory traffic for logsumexp alone might be 3 * 67MB ≈ 200MB read + write.
My fused kernel reads 537MB (only once) and writes 1MB. The reference would read conv output for maxpool (134MB), write maxpool output (67MB), read maxpool output 3 times for logsumexp (~200MB), write logsumexp output (1MB), read/write for relu (2MB). Total: ~134+67+200+1+2 = 404MB. Plus my kernel reads 537MB... wait, that's MORE than the reference!
Oh! I made an error. The reference maxpool reads 134MB (conv output) and writes 67MB. Then logsumexp reads that 67MB, not the full conv output. My fused kernel reads the full 537MB because it reads 8 float values per channel per output (64 channels * 8 = 512 floats per output position).
But wait, the reference also reads 8 floats per channel per output... it's just split into two operations. Maxpool reads 8 floats per channel and writes 1 float per channel. Then logsumexp reads 1 float per channel.
So reference total reads:
- Maxpool: 134M reads of conv output = 537MB (wait, 134M elements * 4 bytes = 536MB, yes)
- Logsumexp: 16.7M elements * 4 bytes = 67MB read of maxpool output
- ReLU: 262k elements * 4 bytes = 1MB read
Total reads: ~604MB
My fused kernel reads: 537MB
So my fused kernel actually reads LESS because it doesn't need to write and re-read the maxpool output! The reference maxpool writes 67MB and logsumexp reads 67MB, which is 134MB of traffic that my kernel avoids.
Additionally, my kernel avoids kernel launch overhead for multiple kernels.
So my fused kernel: 537MB read + 1MB write
Reference: 536MB read (maxpool) + 67MB write (maxpool) + 67MB read (logsumexp) + 1MB write (logsumexp) + maybe temp arrays + 1MB read (relu) + 1MB write (relu)
Reference traffic: ~536 + 67 + 67 + 1 + 1 + 1 = 673MB minimum, plus temp arrays which probably add another 100MB or so. So my kernel saves ~200MB+ of memory traffic.
At 3.35 TB/s, 200MB = 0.06ms. Plus kernel launch overhead (~0.01ms per launch * 3 extra launches = 0.03ms). So total savings: ~0.1ms.
If the reference takes ~0.5ms for post-conv (in addition to 0.23ms conv), then our fused kernel taking ~0.2ms gives a total of 0.43ms vs 0.73ms, which is 1.7x speedup! That would be great.
Actually, I need to be more careful about PyTorch's logsumexp implementation. Let me think about what it does internally...
PyTorch's logsumexp is implemented in ATen as:
1. Compute max values along the dimension
2. Broadcast max and subtract
3. Compute exp
4. Sum along dimension
5. Compute log
6. Add back max
For `torch.logsumexp(x, dim=1, keepdim=True)` where x shape is (4, 64, 16, 64, 64):
1. Max: launches a reduction kernel. Reads x (67MB), writes max (1MB) + maybe some workspace.
2. Subtract + exp: elementwise kernel. Reads x (67MB) and max (1MB), writes exp(x-max) (67MB).
3. Sum: reduction kernel. Reads 67MB, writes sum (1MB) + workspace.
4. Log + add max: elementwise. Reads sum (1MB) and max (1MB), writes output (1MB).
Total memory traffic for logsumexp alone: ~204MB read + ~70MB write = 274MB.
Plus maxpool: 536MB read + 67MB write = 603MB.
Plus relu: 1MB read + 1MB write = 2MB.
Total post-conv: ~879MB.
My fused kernel: 536MB read + 1MB write = 537MB.
Savings: ~342MB of memory traffic, plus 3 fewer kernel launches.
At 3.35TB/s, 342MB = 0.1ms. Plus kernel launch overhead. This is definitely worthwhile.
I should also consider whether PyTorch might fuse some of these operations automatically. With the default eager mode, no automatic fusion happens unless using torch.jit.trace or torch.compile. The reference is eager mode, so each operation is a separate kernel.
OK, so the plan is solid. Let me write the kernel.
I'll also add a small optimization: since logsumexp and relu are applied together, and relu(x) = max(0, x), I can check if we can skip some computation. But no, we need the exact logsumexp value first.
Let me write the code.
```python
import torch
import torch.nn as nn
from torch.utils.cpp_extension import load_inline
cpp_source = """
#include <torch/extension.h>
#include <cuda.h>
#include <cuda_runtime.h>
#include <cmath>
__global__ void fused_maxpool_logsumexp_relu_kernel(
const float* __restrict__ input,
float* __restrict__ output,
int B, int C, int D, int H, int W,
int D_out, int H_out, int W_out) {
int total_outputs = B * D_out * H_out * W_out;
int idx = blockIdx.x * blockDim.x + threadIdx.x;
for (int i = idx; i < total_outputs; i += gridDim.x * blockDim.x) {
// Decode output position
int b = i / (D_out * H_out * W_out);
int rem = i % (D_out * H_out * W_out);
int d = rem / (H_out * W_out);
rem = rem % (H_out * W_out);
int h = rem / W_out;
int w = rem % W_out;
float max_val = -INFINITY;
float sum_val = 0.0f;
for (int c = 0; c < C; ++c) {
float pool_max = -INFINITY;
int base_d = 2 * d;
int base_h = 2 * h;
int base_w = 2 * w;
// Read all 8 positions in the 2x2x2 pool window
// d dimension: base_d, base_d+1
// h dimension: base_h, base_h+1
// w dimension: base_w, base_w+1
int c_offset = ((b * C + c) * D + base_d) * H * W + base_h * W + base_w;
// We can load w positions as float2 for coalesced access
// But let's start with simple loads
float v0 = input[c_offset];
float v1 = input[c_offset + 1];
float v2 = input[c_offset + W];
float v3 = input[c_offset + W + 1];
c_offset += D * H * W; // Move to base_d + 1
float v4 = input[c_offset];
float v5 = input[c_offset + 1];
float v6 = input[c_offset + W];
float v7 = input[c_offset + W + 1];
#pragma unroll
pool_max = fmaxf(pool_max, v0);
pool_max = fmaxf(pool_max, v1);
pool_max = fmaxf(pool_max, v2);
pool_max = fmaxf(pool_max, v3);
pool_max = fmaxf(pool_max, v4);
pool_max = fmaxf(pool_max, v5);
pool_max = fmaxf(pool_max, v6);
pool_max = fmaxf(pool_max, v7);
// Stable logsumexp update
if (pool_max > max_val) {
sum_val = sum_val * expf(max_val - pool_max) + 1.0f;
max_val = pool_max;
} else {
sum_val += expf(pool_max - max_val);
}
}
float result = max_val + logf(sum_val);
if (result < 0.0f) result = 0.0f;
output[i] = result;
}
}
torch::Tensor fused_maxpool_logsumexp_relu(torch::Tensor input, int D_out, int H_out, int W_out) {
int B = input.size(0);
int C = input.size(1);
int D = input.size(2);
int H = input.size(3);
int W = input.size(4);
auto output = torch::empty({B, 1, D_out, H_out, W_out}, input.options());
int total_outputs = B * D_out * H_out * W_out;
int threads = 256;
int blocks = (total_outputs + threads - 1) / threads;
// Cap blocks to reasonable number
blocks = min(blocks, 65535 * 256); // Actually we can use many blocks
fused_maxpool_logsumexp_relu_kernel<<<blocks, threads>>>(
input.data_ptr<float>(),
output.data_ptr<float>(),
B, C, D, H, W,
D_out, H_out, W_out
);
return output;
}
"""
# Then in ModelNew:
class ModelNew(nn.Module):
def __init__(self, in_channels, out_channels, kernel_size, stride, padding):
super().__init__()
self.conv = nn.Conv3d(in_channels, out_channels, kernel_size, stride=stride, padding=padding)
# Store output sizes
self.D_out = 32 // 2 # Since D=32, pool=2, stride=2 -> 16
self.H_out = 128 // 2 # 64
self.W_out = 128 // 2 # 64
def forward(self, x):
x = self.conv(x)
# Call custom kernel
x = fused_maxpool_logsumexp_relu(x, self.D_out, self.H_out, self.W_out)
return x
```
Wait, I hardcoded 32 and 128. I should calculate properly or just pass the tensor and have the kernel figure it out. Actually, the kernel does figure it out from the tensor sizes. D_out, H_out, W_out could theoretically be computed, but they're fixed by the architecture.
Actually, I can just compute them in forward or eliminate them as parameters. But for generality, I'll compute from input dims.
Actually, I realize I should be careful. The model parameters (kernel_size, stride, padding) are fixed in __init__, but the input could theoretically vary. However, the get_inputs() always produces the same size. I'll compute D_out, H_out, W_out dynamically in forward to be safe.
For a 3D conv with padding=1, stride=1, kernel_size=3, the output spatial size is the same as input:
D_out_conv = D_in + 2*padding - kernel_size + 1 = 32 + 2 - 3 + 1 = 32 (wait, formula is floor((D + 2*padding - kernel_size)/stride) + 1)
= floor((32 + 2*1 - 3)/1) + 1 = floor(31) + 1 = 32. Yes.
Then maxpool3d with kernel=2, stride=2:
D_out = floor((32 - 2)/2) + 1 = 16
H_out = floor((128 - 2)/2) + 1 = 64
W_out = floor((128 - 2)/2) + 1 = 64
I should compute these in __init__ based on the parameters, or just derive from the conv output shape.
For simplicity, I'll just let the kernel use the actual conv output shape. I only need D_out etc. for the output tensor size, which I can compute.
Actually, looking at my CUDA function signature, it takes D_out, H_out, W_out. But I can compute those from the conv output and the model parameters. Let me compute them in __init__.
```python
def __init__(self, in_channels, out_channels, kernel_size, stride, padding):
super().__init__()
self.conv = nn.Conv3d(in_channels, out_channels, kernel_size, stride=stride, padding=padding)
# Compute expected pooled dimensions for the default input size
# We'll also handle dynamic sizes in forward
self.pool_kernel = 2
self.pool_stride = 2
```
In forward:
```python
def forward(self, x):
x = self.conv(x)
B, C, D, H, W = x.shape
D_out = (D - self.pool_kernel) // self.pool_stride + 1
H_out = (H - self.pool_kernel) // self.pool_stride + 1
W_out = (W - self.pool_kernel) // self.pool_stride + 1
return fused_maxpool_logsumexp_relu(x, D_out, H_out, W_out)
```
But actually, since D, H, W are known at compile time in practice, I could just pass x and compute inside the kernel.
Let me also think about improving the kernel with:
1. `__restrict__` and `__builtin_assume` or alignment hints if possible
2. Using `float2` for the W dimension loads
3. Loop unrolling
4. Warp-level optimizations for logsumexp
Actually, can I use warp primitives for logsumexp? The logsumexp is over channels (C=64), and each thread processes one spatial position. So threads in a warp process DIFFERENT spatial positions, not different channels. Warp reductions won't help directly.
However, I could restructure so that a warp of 32 threads processes ONE spatial position and 32 channels at a time. Then they'd do a warp-level logsumexp. But 32 threads per position means 256 threads process only 8 positions per block, which is terrible for occupancy and parallelism. With only 8 positions per block and 1024 total positions in D_out=16*H_out=64*... wait there are 262,144 total positions. With 8 per block, that's 32,768 blocks. Still fine.
But then each thread in the warp handles 2 channels (64/32 = 2), does maxpool for those 2 channels, and then we do warp-level logsumexp. Wait, logsumexp with warp shuffle is possible but tricky because of the stable formulation.
Actually, for 64 channels, if a warp handles one spatial position:
- Thread 0: channels 0, 1 -> pool_max0, pool_max1 -> lse of these two
- Thread 1: channels 2, 3 -> pool_max2, pool_max3 -> lse of these two
- ...
- Thread 31: channels 62, 63 -> pool_max62, pool_max63 -> lse of these two
- Then warp-level reduce of 32 partial lse values
This requires a warp-level logsumexp reduction. We can do this with shuffle:
```
float warp_max = __reduce_max_sync(0xFFFFFFFF, local_max);
float warp_sum = __shfl_sync(0xFFFFFFFF, local_sum, lane) * exp(local_max - warp_max);
// wait, this is complicated.
```
Actually, a simpler warp-level logsumexp:
1. First find max across all threads: use warp reduce max
2. Then compute sum of exp(local_vals - global_max) using warp reduce sum
This requires two passes, and each thread would need to expose its individual channel values, not just an aggregated pair.
If each thread handles one channel, it computes pool_max for that channel. Then:
- warp_max = warp_reduce_max(pool_max)
- each thread computes exp(pool_max - warp_max)
- warp_sum = warp_reduce_sum(exp(pool_max - warp_max))
- result = warp_max + log(warp_sum)
But this is for ONE spatial position handled by 32 threads (one warp). For 64 channels, we'd need 2 warps per spatial position.
If we have 256 threads = 8 warps. Each warp handles one spatial position and 32 channels at a time. We process 8 spatial positions per block. So 256 threads per block.
Total spatial positions = 262,144. Blocks needed = 32,768. At 132 SMs, that's 248 blocks per SM. With 1 block per SM at a time (limited by shared memory or registers), total waves = 248. Launching 32,768 blocks might have some overhead.
Alternatively, each warp could handle one spatial position and ALL 64 channels (2 iterations of 32 channels). Then 8 warps handle 8 positions per block. Same as above.
Wait, but I could have each warp handle one spatial position, and have 8 warps handle 8 positions. With 256 threads per block, that's fine. The issue is whether 32,768 blocks is a lot. Actually, 32k blocks is totally fine for modern GPUs. Kernel launch overhead is minimal.
But with 32,768 blocks and only 132 SMs, we have 248 blocks per SM. At 100% occupancy with 8 blocks per SM (2048 threads), we complete 1056 blocks at a time, so it takes ~31 waves. That's fine.
Actually wait, 32,768 blocks / 132 SMs = 248.2 blocks per SM. If each SM can run 8 blocks concurrently, that's 31 waves. Each wave processes 1056 blocks. Each block has 8 warps, so each wave has 8448 warps. Total warps = 262,144 (same as total positions, since one warp per position). At ~32 threads active per warp (since each warp handles 32 channels for one position), warp utilization is 100%.
Hmm, but each warp only has 32 threads doing useful work? No, all 32 threads are active. Each thread handles one channel. A warp handles 32 channels for one spatial position.
The advantage of this approach is:
1. No loop over channels (fully unrolled via warp parallelism)
2. Warp shuffle for logsumexp is very fast
3. Memory coalescing: 32 threads in a warp reading the same spatial position but different channels... wait, that's strided by D*H*W in memory. That's TERRIBLE for coalescing!
Oh no! If thread 0 reads channel 0, thread 1 reads channel 1, etc., adjacent threads read memory separated by D*H*W*4 bytes = 32*128*128*4 = 2MB. That's the worst possible coalescing.
So the warp-parallel-over-channels approach gives terrible memory bandwidth.
We MUST have adjacent threads reading adjacent memory, which means adjacent threads should process adjacent spatial positions with the same channel. That's the channel-loop approach I had before.
So let's stick with the original approach: each thread processes one spatial position and loops over channels. Adjacent threads read adjacent W positions for the same channel, giving good coalescing.
To improve further, I can use `float2` for the W loads. Since adjacent threads read (2w, 2w+1) and (2(w+1), 2(w+1)+1), these are contiguous. So float2 loads are perfectly coalesced.
Let me also try to prefetch or use more ILP by processing multiple outputs per thread. If each thread handles 2 spatial positions (e.g., 2 consecutive w' values), it does twice the work and we need half the blocks. This might help if the kernel is latency-bound rather than throughput-bound.
Actually with 1024 blocks and 132 SMs, at 4 blocks per SM that's 528 active blocks, which is more than enough to hide latency. So I don't need ILP via multiple positions per thread.
But let me add `float2` loads for the W dimension pairs. I'll also add `#pragma unroll` where appropriate.
One concern: the `#pragma unroll` on the channel loop might hurt if the loop body is too large. But 64 iterations is fine.
Actually, I realize I can also structure the inner maxpool as sequential loads of float2. For the two W positions:
For a fixed (dd, dh), the addresses of (w, w+0.5) wait no, W is integer. The two W positions are base_w and base_w+1. These are adjacent, so float2 works.
```cuda
for (int c = 0; c < C; ++c) {
float pool_max = -INFINITY;
int c_offset = ((b * C + c) * D + 2*d) * H * W + (2*h) * W + 2*w;
float2 v01 = *reinterpret_cast<const float2*>(input + c_offset);
float2 v23 = *reinterpret_cast<const float2*>(input + c_offset + W);
c_offset += D * H * W;
float2 v45 = *reinterpret_cast<const float2*>(input + c_offset);
float2 v67 = *reinterpret_cast<const float2*>(input + c_offset + W);
pool_max = fmaxf(pool_max, v01.x);
pool_max = fmaxf(pool_max, v01.y);
pool_max = fmaxf(pool_max, v23.x);
// ... etc
}
```
This should give good memory throughput. However, alignment is a concern. Are the addresses aligned to 8 bytes for float2?
`c_offset = ((b * C + c) * D + base_d) * H * W + base_h * W + base_w`
base_w = 2*w, so for w=0, base_w=0 which is aligned. For w=1, base_w=2 which is 8-byte aligned. For any integer w, 2*w is 8-byte aligned since 2*w*4 = 8*w. So yes, perfectly aligned!
Great.
Another optimization: use `__ldg` for read-only loads from global memory. With `const float* __restrict__ input`, the compiler might already use LDG, but explicit `__ldg` can help.
```cuda
float2 v01 = __ldg(reinterpret_cast<const float2*>(input + c_offset));
```
Actually, `__ldg` on float2 might be supported. Let me check... On modern GPUs, `__ldg` works with vector types. But I'll let the compiler handle it via the `const __restrict__` qualifier, which should generate LDG instructions.
Another idea: can I reduce the number of exp() calls? 16.8M exp() calls. On H100, expf() throughput is... let me think. Special function units on H100: I believe there are 32 special function units per SM? Or maybe 16? At 2GHz, 16 SFUs per SM gives 32G special ops/s per SM. 132 SMs gives 4.2T special ops/s. 16.8M exps = 0.004ms. So exp is NOT a bottleneck. The branch in the logsumexp update might be more problematic.
Can I make the logsumexp update branchless? It's tricky because of the conditional. But modern GPUs handle branches well when threads in a warp tend to take the same path. Here, the branch depends on pool_max vs max_val, which varies across threads. Not great.
Alternative logsumexp formulation:
```
float max_val = local_max_of_all_channels;
float sum_val = sum(exp(local_vals - max_val));
```
But that requires two passes over channels. One pass to find max, another to compute sum. That might actually be FASTER despite reading twice, because:
1. It avoids the branch and the extra exp in the update formula
2. It might be more vectorizable
For the first pass, we just find the maximum pool_max over 64 channels.
For the second pass, we compute sum of exp(pool_max - global_max).
Two passes = read each conv value twice. But since we read in registers/cache, the second read might be cheap.
Actually, we can't "read twice" because we're not storing the intermediate values. We'd have to recompute the maxpool, which means re-reading from memory. That doubles memory bandwidth.
However, we could store the 64 pool_max values in a local array. 64 floats = 256 bytes. That's 64 registers. A bit heavy but manageable.
Wait, 64 registers per thread... with 256 threads per block, that's 16K registers per block. With 64K registers per SM, that's 4 blocks per SM. Still fine. But 64 registers for the array, plus other registers... still under 128.
Actually, we don't need to store all 64. We can do:
Pass 1: compute max over 64 channels, storing each pool_max in registers.
Pass 2: compute sum of exp(pool_max - max_val).
With 64 floats in registers, that's 64 registers. Plus index calculations, loads, etc. Maybe 80-100 registers. Still under 128.
But wait, can the compiler actually fit 64 float variables in registers? Yes, if we declare `float pool_vals[64];` but loops might cause issues. Better to process in chunks and store.
Actually, let me count: 64 floats is a lot for the register allocator. It might spill to local memory. Each thread gets its own local memory for spills, which is in global memory and VERY slow.
Alternative: process in chunks of, say, 8 channels. For each chunk, find the local max, then accumulate properly.
Or just compute max and running LSE in one pass with the branch. The branch is probably fine. Let me profile mentally: the branch has ~50% prediction accuracy maybe, causing some warp divergence. But with 64 iterations, the divergence is short (just the update code, which is a few instructions).
Actually, there's a better stable update that can be somewhat branchless:
```
float old_max = max_val;
max_val = fmaxf(old_max, pool_max);
sum_val = sum_val * expf(old_max - max_val) + expf(pool_max - max_val);
```
This is always the same computation, no branch. The expressions `old_max - max_val` and `pool_max - max_val` are always <= 0, and one of them is 0.
So exp(0) = 1, and exp(negative) = some small number.
This is BRANCHLESS! And it's mathematically equivalent. Let me verify:
- If pool_max > old_max:
max_val = pool_max
sum_val = sum_val * exp(old_max - pool_max) + exp(pool_max - pool_max)
= sum_val * exp(old_max - pool_max) + 1
Correct.
- If old_max >= pool_max:
max_val = old_max
sum_val = sum_val * exp(old_max - old_max) + exp(pool_max - old_max)
= sum_val * 1 + exp(pool_max - old_max)
Correct.
So the branchless version is:
```cuda
float old_max = max_val;
max_val = fmaxf(old_max, pool_max);
sum_val = sum_val * expf(old_max - max_val) + expf(pool_max - max_val);
```
This is excellent! No branch, no warp divergence. Each thread does the same sequence of instructions.
Now there's a potential numerical issue: if old_max and pool_max are both large and close, the exp of a small negative number might underflow if their difference is < -100 or so. But for typical neural network activations, the values are usually in a reasonable range. And this is the standard stable logsumexp formulation.
Actually, there is a subtle issue when both old_max and pool_max are -inf at the start. Then old_max - max_val = -inf - (-inf) = NaN, and exp(NaN) = NaN. We need to handle the first iteration specially, or initialize differently.
We can initialize:
```
max_val = -1e20f; // or -FLT_MAX, but not -inf
sum_val = 0.0f;
```
For the first channel, say pool_max = 1.5:
old_max = -1e20
max_val = max(-1e20, 1.5) = 1.5
sum_val = 0 * exp(-1e20 - 1.5) + exp(1.5 - 1.5) = 0 + 1 = 1
Result = 1.5 + log(1) = 1.5. Perfect.
For the second channel, say pool_max = 2.0:
old_max = 1.5
max_val = 2.0
sum_val = 1 * exp(1.5 - 2.0) + exp(2.0 - 2.0) = exp(-0.5) + 1 ≈ 0.6065 + 1 = 1.6065
Result = 2.0 + log(1.6065) ≈ 2.0 + 0.474 = 2.474
Actual logsumexp of [1.5, 2.0] = 2.0 + log(exp(-0.5) + 1) ≈ 2.474. Correct.
So initializing with -1e20f (or -FLT_MAX) and sum_val = 0 works perfectly.
Wait, what if all values are very negative, like -1000? Then initializing with -1e20 still works:
First value = -1000:
max_val = -1000, sum_val = 1. Result = -1000 + log(1) = -1000. Correct.
As long as no true value is less than -1e20, we're fine.
OK so I'll use the branchless formulation.
One more optimization: use `expf` instead of `exp`. And we can compute the two exps with `expf` which might use the faster approximate intrinsic? On H100, `expf` is likely the fast version already.
Can I use `__expf` for even faster (but less accurate) computation? The tolerance for fp32 is atol=rtol=1e-4, which is tight. `__expf` might not be accurate enough. Let me stick with `expf`.
Actually, is `expf` vs `__expf` accuracy relevant here? We're doing log(sum(exp(x))). The error can accumulate. Let me be safe and use `expf`.
Now let me think about the grid configuration.
Total outputs: 262,144
Threads per block: 256
Grid: (total_outputs + 255) / 256 = 1024 blocks.
I'll just use a 1D grid. This is simple and effective.
But actually, `blockIdx.x` max is 2^31-1, so 1024 is fine.
Wait, I want to make sure we're launching enough blocks to saturate the GPU. 1024 blocks of 256 threads = 262,144 threads total. The GPU has 132 SMs, each can hold up to 2048 threads. Total capacity = 132 * 2048 = 270,336 threads. So we ALMOST exactly fill the GPU once. That's theoretically perfect.
In practice, occupancy is limited by registers. If we use ~64 registers per thread, a block of 256 uses 16K registers. An SM has 64K, so 4 blocks per SM. 4 * 256 = 1024 threads per SM. Total active threads = 132 * 1024 = 135,168, which is 50% occupancy. To get 100%, we need either more threads or fewer registers.
50% occupancy might be fine. But I can reduce register usage. The main register users are:
- Loop counters: a few
- max_val, sum_val: 2
- The 8 loaded values: 8 (or reuse 2 for float2)
- c_offset and similar: a few
Total should be under 32 registers. So 256 threads * 32 = 8K registers per block. 8 blocks per SM = 2048 threads. 100% occupancy!
With 100% occupancy and 1024 blocks, 1024 / (132 * 8) = 1024 / 1056 < 1 wave. Almost exactly 1 wave. PERFECT.
So this kernel should run very efficiently.
One issue: `float2` loads. In CUDA, `reinterpret_cast<const float2*>(ptr)` requires the pointer to be aligned to `alignof(float2)` = 8 bytes. I've verified that `c_offset` is always a multiple of 2 (since base_w = 2*w), so 2*w*4 = 8*w bytes, which is 8-byte aligned. Good.
However, I should also make sure the tensor itself is aligned to at least 8 bytes. PyTorch tensors are typically 256-byte aligned, so that's fine.
Let me also think about whether I should transpose the tensor or do anything else. No, the default NCDHW layout is channel-first, which is what we're assuming and what PyTorch uses.
Wait, I should double check: PyTorch 3D conv uses layout (N, C, D, H, W). The maxpool operates on this layout. Yes, this is correct.
Let me finalize the code.
One more thing: I'll add `__launch_bounds__(256)` to the kernel to hint to the compiler that we want at most 256 threads per block, which can help with register allocation.
Also, I should use `at::cuda::CUDAStreamGuard` or just let CUDA use the default stream. PyTorch operations use the current stream, so the kernel should too.
In the C++ extension, I can write:
```cpp
#include <c10/cuda/CUDAStream.h>
// In the function:
auto stream = at::cuda::getCurrentCUDAStream();
fused_maxpool_logsumexp_relu_kernel<<<blocks, threads, 0, stream>>>(...);
```
This ensures the kernel runs on the correct stream.
Let me now write the complete code.
One more optimization: instead of computing `c_offset` from scratch each channel iteration, I can increment it by `D * H * W` each time. This is what I already do for the dd step. Let me make sure the inner loop is efficient.
```cuda
int spatial_stride = D * H * W;
int ch_offset = ((b * C) * D + 2*d) * H * W + (2*h) * W + 2*w;
for (int c = 0; c < C; ++c) {
float pool_max = -INFINITY;
float2 v01 = *reinterpret_cast<const float2*>(input + ch_offset);
float2 v23 = *reinterpret_cast<const float2*>(input + ch_offset + W);
int ch_offset2 = ch_offset + spatial_stride;
float2 v45 = *reinterpret_cast<const float2*>(input + ch_offset2);
float2 v67 = *reinterpret_cast<const float2*>(input + ch_offset2 + W);
pool_max = fmaxf(pool_max, v01.x);
// ...
float old_max = max_val;
max_val = fmaxf(old_max, pool_max);
sum_val = sum_val * expf(old_max - max_val) + expf(pool_max - max_val);
ch_offset += C * spatial_stride; // Wait, this is wrong.
}
```
Wait, `ch_offset` should advance by `spatial_stride` each channel, not `C * spatial_stride`. Because `((b * C + c) * D + 2*d) * H * W = b*C*D*H*W + c*D*H*W + 2*d*H*W`.
So the offset for channel c is `base + c * spatial_stride`.
In my loop, I should do:
```cuda
int base_offset = ((b * C) * D + 2*d) * H * W + (2*h) * W + 2*w;
int ch_offset = base_offset + c * spatial_stride;
```
Or increment:
```cuda
int ch_offset = ((b * C) * D + 2*d) * H * W + (2*h) * W + 2*w;
for (int c = 0; c < C; ++c) {
// ...
ch_offset += spatial_stride;
}
```
That's correct and simpler.
Let me write the full Python file now.
Actually, I need to make sure about the maxpool3d with kernel_size=2 and stride=2. PyTorch's MaxPool3d with these parameters on a tensor of shape (B, C, D, H, W) produces output of shape (B, C, D//2, H//2, W//2) when D, H, W are even.
For D=32: (32 - 2) // 2 + 1 = 16. Yes.
For H=128: (128 - 2) // 2 + 1 = 64. Yes.
For W=128: 64. Yes.
And the window is at positions (0,0,0) to (1,1,1) relative to each output position. So for output at (d', h', w'), the inputs are (2d'+dd, 2h'+dh, 2w'+dw) for dd,dh,dw in {0,1}.
This matches what I have.
One more thing: does PyTorch's maxpool3d use `ceil` mode or `floor` mode? By default, `ceil_mode=False`, so it uses floor. The output size formula is floor((input_size + 2*padding - dilation*(kernel_size-1) - 1) / stride) + 1. With padding=0, dilation=1, kernel=2, stride=2: floor((32 - 2 - 1)/2) + 1 = floor(29/2) + 1 = 14 + 1 = 15... wait, that gives 15, not 16!
Wait, the formula is: output_size = floor((L + 2*padding - dilation*(kernel_size - 1) - 1) / stride) + 1
Actually, let me check PyTorch's exact formula. For MaxPool3d with kernel_size=2, stride=2, padding=0, dilation=1:
out_size = floor((in_size + 2*padding - dilation*(kernel_size - 1) - 1) / stride) + 1
= floor((32 + 0 - 1*(2-1) - 1) / 2) + 1
= floor((32 - 1 - 1) / 2) + 1
= floor(30/2) + 1
= 15 + 1 = 16. Yes.
For H=128: floor((128 - 2)/2) + 1 = 63 + 1 = 64.
OK so the formula is indeed what I thought: floor((L - 1) / 2) + 1 for kernel=2, stride=2... wait that's floor((L - 2 + 1 - 1)/2) + 1 with dilation=1.
Actually for stride=2, kernel=2, no padding: out = floor((L - 2) / 2) + 1 = floor(L/2) when L is even. 32/2=16, 128/2=64. Correct.
So my output sizes are correct.
Now, let me also consider: can I avoid the `C * D * H * W` multiplication by precomputing some strides? Yes, I can compute `b * C * D * H * W` as a single offset, then add `c * D * H * W` for each channel.
```cuda
int b_offset = b * C * D * H * W;
int spatial_stride = D * H * W;
int spatial_offset = (2*d) * H * W + (2*h) * W + (2*w);
int ch_offset = b_offset + spatial_offset;
#pragma unroll 4
for (int c = 0; c < C; ++c) {
float2 v01 = *reinterpret_cast<const float2*>(input + ch_offset);
float2 v23 = *reinterpret_cast<const float2*>(input + ch_offset + W);
float2 v45 = *reinterpret_cast<const float2*>(input + ch_offset + spatial_stride);
float2 v67 = *reinterpret_cast<const float2*>(input + ch_offset + spatial_stride + W);
float pool_max = fmaxf(fmaxf(fmaxf(v01.x, v01.y), fmaxf(v23.x, v23.y)),
fmaxf(fmaxf(v45.x, v45.y), fmaxf(v67.x, v67.y)));
float old_max = max_val;
max_val = fmaxf(old_max, pool_max);
sum_val = sum_val * expf(old_max - max_val) + expf(pool_max - max_val);
ch_offset += spatial_stride;
}
```
This is clean. I use nested fmaxf for the max reduction of 8 values. The compiler can schedule this well.
Actually, `fmaxf` might generate a sequence of compare-and-select instructions. For 8 values, we need 7 compares. That's fine.
I can also use `fmaxf` with nested calls or a tree-like structure:
```cuda
float m01 = fmaxf(v01.x, v01.y);
float m23 = fmaxf(v23.x, v23.y);
float m45 = fmaxf(v45.x, v45.y);
float m67 = fmaxf(v67.x, v67.y);
float m0123 = fmaxf(m01, m23);
float m4567 = fmaxf(m45, m67);
float pool_max = fmaxf(m0123, m4567);
```
This is a tree of 7 fmaxfs. It might have slightly better instruction-level parallelism since m01 and m23 can be computed in parallel, etc.
Let me write it this way.
Also, one subtlety: the model has `nn.Conv3d` which includes bias by default (bias=True). So the conv3d output includes bias. My kernel just reads from conv3d output, so it naturally includes the bias. I don't need to worry about it.
Wait, actually I should double check: `nn.Conv3d(...)` defaults to bias=True. So yes, there's a bias term. Since I use PyTorch's conv3d, the bias is applied. My kernel reads the conv output and doesn't need to know about the bias.
One potential issue: does the conv3d in PyTorch operate in deterministic mode? For this problem, we only need numerical equivalence within tolerance, not bit-exactness.
Let me now write the code and compile it.
I realize I should also verify the exact numerical behavior of logsumexp. PyTorch's logsumexp uses float32 precision with the stable algorithm. My kernel should produce very close results.
Let me be careful about one edge case: if the input to logsumexp is very large, expf might overflow. But since we're using the stable formulation where we subtract the max before exp, the largest exp argument is 0, so exp(0)=1. No overflow. Underflow can happen if values are much smaller than the max, but that's expected and harmless.
What about when all pool_max values are -inf? Then the result should be -inf, but the reference might return -inf or some other value. In practice, this shouldn't happen for normal inputs.
What if the result of logsumexp is slightly negative due to numerical error (like when there's only one value and it's negative, but exp/log introduces small error)? PyTorch's logsumexp of a single value should return that value. With my formulation:
- First channel: pool_max = v, max_val = v, sum_val = 1, result = v + log(1) = v. Correct.
- If there's only one channel total: result = v.
If v = -0.001, result = -0.001. ReLU gives 0. Correct.
If there are multiple channels with max = 0 and others negative: result = 0 + log(1 + small). This is slightly positive. ReLU keeps it. If all values are negative and very close, the result might be slightly different from PyTorch due to different ordering of operations. Since channels are processed in order, the reduction is deterministic.
Let me write the complete code.
Wait, I should also check if there are any issues with using float2 load when the memory doesn't exist. But we've verified all outputs are in bounds, and base_w+1 is always valid when W is even and W_out = W/2.
For W=128, w ranges from 0 to 63, base_w = 2*w ranges from 0 to 126, and base_w+1 ranges from 1 to 127. All valid.
For H=128, base_h = 2*h ranges from 0 to 126, and we access base_h and base_h+1 by using offsets of 0 and W in the same "slice" of D, and offsets of spatial_stride and spatial_stride + W for the next D slice. Since H=128 and W=128, base_h*W = 2*h*128 ranges from 0 to 16128, and (base_h+1)*W from 128 to 16256. All valid as long as base_h+1 < H. Since h ranges from 0 to 63, base_h ranges from 0 to 126, so base_h+1 = 127 < 128. Valid.
For D=32, base_d = 2*d ranges from 0 to 30. We access base_d and base_d+1 by using offsets 0 and spatial_stride. base_d+1 ranges from 1 to 31 < 32. Valid.
All bounds checks are satisfied. No need for conditionals in the inner loop.
Let me construct the final code.
Actually, I'll use `at::Tensor` from `torch/extension.h` which is already included.
One more thing: `torch.utils.cpp_extension.load_inline` needs a specific format. The CUDA source goes in `cuda_sources` and C++ wrapper in `cpp_sources`. Actually, I can put everything in `cuda_sources` since it can contain host code too. Or I can use `cpp_sources` for the C++ wrapper and `cuda_sources` for the kernel.
Let me check: `load_inline` takes `cpp_sources` and `cuda_sources`. Usually, `cpp_sources` contains the C++ wrapper functions that call CUDA kernels, and `cuda_sources` contains the actual `.cu` code with `__global__` kernels. But both are compiled together.
Actually, for simplicity, I can put the CUDA kernel and the C++ wrapper in `cuda_sources`, and leave `cpp_sources` empty. Or put the wrapper in `cpp_sources`. Either works.
I'll put the kernel in `cuda_sources` and the host wrapper in `cpp_sources`.
Also, the `functions=[...]` argument lists the names of the host wrapper functions to expose to Python.
```python
cuda_source = '''
#include <cuda.h>
#include <cuda_runtime.h>
#include <cmath>
__global__ void fused_maxpool_logsumexp_relu_kernel(...) {
// ...
}
'''
cpp_source = '''
#include <torch/extension.h>
torch::Tensor fused_maxpool_logsumexp_relu(torch::Tensor input, int D_out, int H_out, int W_out) {
// ...
}
'''
```
Wait, I need to make sure the kernel is visible to the host code. Since they're in separate strings, they might be compiled separately. Actually, `load_inline` compiles them together, so the kernel declared in `cuda_sources` is visible to the host code in `cpp_sources`.
Let me verify this is the standard pattern. Yes, typically `cuda_sources` contains kernels and `cpp_sources` contains pybind11-wrapped C++ functions that call the kernels.
Actually, looking at examples, many people put host wrapper and kernels together in `cuda_sources`. Since `cuda_sources` is compiled with nvcc, it can have both `__global__` functions and regular C++ functions.
I'll put everything in `cuda_sources` to avoid any visibility issues.
Final code structure:
```python
cuda_source = """
#include <torch/extension.h>
#include <cuda.h>
#include <cuda_runtime.h>
#include <cmath>
#include <c10/cuda/CUDAStream.h>
__global__ void fused_maxpool_logsumexp_relu_kernel(...) { ... }
torch::Tensor fused_maxpool_logsumexp_relu(torch::Tensor input, int D_out, int H_out, int W_out) {
// validation
auto output = torch::empty(...);
int total = B * D_out * H_out * W_out;
int threads = 256;
int blocks = (total + threads - 1) / threads;
auto stream = at::cuda::getCurrentCUDAStream();
fused_maxpool_logsumexp_relu_kernel<<<blocks, threads, 0, stream>>>(...);
return output;
}
"""
```
I'll use the `load_inline` function with appropriate arguments.
Wait, I should check if `c10/cuda/CUDAStream.h` is available or if I need `ATen/cuda/CUDAContext.h`. In PyTorch, `at::cuda::getCurrentCUDAStream()` is declared in `c10/cuda/CUDAStream.h` or `ATen/cuda/CUDAContext.h`. To be safe, I'll include both or just use `c10::cuda::getCurrentCUDAStream()`.
Actually, looking at PyTorch source, `at::cuda::getCurrentCUDAStream()` is available via `ATen/cuda/CUDAContext.h` or `c10/cuda/CUDAStream.h`. I'll include `ATen/cuda/CUDAContext.h` or just skip the explicit stream handling if I'm not sure. PyTorch's CUDA extension examples often just use the default stream by not specifying a stream. However, it's better practice to pass the current stream.
Let me just use `at::cuda::getCurrentCUDAStream()` and include the right header. Or I can use `c10::cuda::getCurrentCUDAStream()`.
```cpp
#include <c10/cuda/CUDAStream.h>
auto stream = c10::cuda::getCurrentCUDAStream();
```
Or even simpler, I can use `at::cuda::getCurrentCUDAStream()`. Let me check if `torch/extension.h` already includes this... `torch/extension.h` includes `ATen/ATen.h` which might include CUDA context headers when building for CUDA. To be safe, I'll explicitly include `ATen/cuda/CUDAContext.h`.
But actually, for minimal compilation issues, I'll just skip explicit stream handling and let it use the default stream. In the context of this problem, PyTorch calls will be on the default stream, so it's fine. But to be correct, I should pass the current stream.
```cpp
#include <ATen/cuda/CUDAContext.h>
// ...
auto stream = at::cuda::getCurrentCUDAStream();
fused_maxpool_logsumexp_relu_kernel<<<blocks, threads, 0, stream>>>(...);
```
Actually, looking at common patterns, `torch/extension.h` should be sufficient to access stream utilities when compiling CUDA extensions. Let me just use it.
Wait, I can also do:
```cpp
cudaStream_t stream = at::cuda::getCurrentCUDAStream().stream();
```
OK, let me just write the code and see if it compiles. I'll iterate if there are issues.
Let me also think about one more thing: should I precompute the conv3d and then call the fused kernel, or can I somehow improve the conv3d itself?
Given that cuDNN is highly optimized and uses tensor cores, I'll stick with PyTorch's conv3d. The speedup comes from fusing the remaining operations.
One more thought: could I use `torch.compile` or other PyTorch optimizations? No, we're replacing the model with a custom kernel. The reference is the eager-mode PyTorch model.
Actually, let me think: is there a way to do the maxpool during the conv3d computation using a custom conv kernel? For example, using im2col + GEMM where the columns are already maxpooled? No, maxpool is after conv, not before.
What about using depthwise separable conv or some decomposition? No, the model uses a standard conv3d.
Could I use FFT-based conv3d? For 3x3x3 kernel and 128x128x32 spatial, FFT would be slower than direct/GEMM-based conv.
OK, I'll go with the conv3d + fused kernel approach.
Let me write the code and compile.
Wait, one more optimization idea: can I launch the kernel asynchronously and overlap with something? Not really, since the conv3d must finish before the fused kernel can start.
Could I use channels-last format for the conv3d to get better performance? PyTorch's conv3d in channels-last (NDHWC) might be faster on some GPUs. But then I'd need to write the fused kernel for NDHWC layout, which changes the memory access patterns.
For NDHWC, adjacent W positions for the same channel are separated by C floats. The float2 loads would load adjacent W positions which span 2*C floats = 512 bytes. That's still coalesced if C=64 (256 bytes). Actually, in NDHWC, position (b, d, h, w, c) is at offset ((b*D + d)*H + h)*W*C + w*C + c. Adjacent threads with different w are separated by C floats. A warp of 32 threads reading w=0,1,2,... would be separated by 64 floats = 256 bytes. Still coalesced into 256*32 = 8KB, which would use multiple cache lines. It's not as good as NCDHW where they're contiguous.
Also, PyTorch conv3d in NDHWC is less optimized than NCDHW for 3D conv on many GPUs. Let me stick with NCDHW.
Alright, let me write and compile the code.