reasoning
The kernel passes correctness. Let me analyze if I can optimize it further:
1. Each kernel block handles one (batch, h_out, w_out) location
2. We load input tile: 3*24*3*3 = 648 floats
3. We load weights: 24*3*3*3*3 = 1944 floats
4. We compute 24 output channels, each with 22 values of depth: 24*22 = 528 conv operations
5. Then softther thread over 24 channels
6. Then writ output
The problem with the current kernel is that blockDim.x = 32, but C_out = 24, so only 24 threads are busy during some phases.
Actually, I should maybe approach this differently. Instead of doing 1 block per (b, h, w) with threads cooperating on channels, I could do better:
- Map threads to multiple output locations to increase parallelism
- Or use fewer threads (24) per block but launch more blocks
Actually the grid has 128*30*30 = 115,200 blocks which is plenty. With only 32 threads per block, each block processes 1 pixel from 1 image - there's enough parallelism across blocks.
Let me think about occupancy. 32 threads per block: 32 warps per SM max, so 32 blocks per SM max. Each block uses:
- 648 + 1944 + 100 ≈ 2700 floats = 10.8 KB shared memory (< 227 KB)
- Very few registers (small loops)
So occupancy should be high.
But let me think about how to make the computation more efficient. The key optimization would be to reorder the computation to maximize memory coalescing and reduce redundant work.
Wait, I think there are two primary issues:
1. We load 648 + 1944 = 2592 shared memory floats per block. That's quite a lot of work for 32 threads.
2. The computation is: 24 channels * 22 depth * 3 cin * 3 kd * 3 kh * 3 kw = 85536 multiply-add operations. With 32 threads, that's about 2672 ops per thread - decent amount of compute.
Actually, the issue might be that loading weights and input sequentially with 32 threads is fine, but perhaps the memory throughput for inputs/weights is the bottleneck since we read the same weights over and over (for each (b, h, w) pixel).
Hmm, with 115,200 blocks, each reading weights one time: 115200 * 1944 floats * 4 bytes = 0.84 GB. Input data: 128 * 3 * 24 * 32 * 32 = 37,748,736 floats ≈ 151 MB. Output: 128 * 24 * 30 * 30 = 2,764,800 floats ≈ 11 MB. Total memory traffic ≈ 1 GB. At 3.35 TB/s, this should be very fast (< 1 ms). But the PyTorch reference takes 2.97 ms and our kernel takes 4.48 ms.
Why is our kernel slower? Let me think...
The reference uses cudnn for conv, which is highly optimized (probably winograd or implicit GEMM). The min and softmax operations are simple elementwise reductions. So the reference is:
- Conv: cudnn optimized (say 2.5 ms)
- Min: simple reduction (0.1 ms)
- Softmax: simple scan/reduction (0.1 ms)
Total: ~2.7 ms
Our kernel:
- Loads input + weights from global mem (takes some time)
- Computes all ops in registers/shared mem
- Writes output
The perf difference might be because we're not utilizing the tensor cores or optimized GEMM pathways that cudnn uses.
Actually, I realize what the issue might be. Our kernel fuses everything, but the convolution is inefficient because:
1. We're doing a direct convolution (nested loops)
2. We're not using any tensor cores or optimized GEMM
For a small 3x3x3 convolution, direct convolution can be reasonable, but it's still slower than cudnn's optimized path.
What if I keep the conv in cudnn but fuse min+softmax?
But wait, the reference had to do separate kernel launches for conv, min, softmax, causing kernel launch overhead and intermediate memory writes. If I use cudnn for conv but fuse min+softmax, I'd avoid one write to global memory. Let me try:
Approach 4: cudnn conv -> custom fused_min_softmax kernel
This would:
1. Launch cudnn conv (outputs [B, 24, 22, 30, 30])
2. Launch our custom kernel that reads [B, 24, 22, 30, 30], computes min over dim=2, then softmax
This avoids writing the conv output then reading it back. Actually, cudnn already writes it directly. Our fused kernel would then read from this global mem.
But actually we can potentially use cudnn with the output already allocated, then launch our kernel.
Let me think more carefully. In the reference:
1. conv: write to x1 (size: B * C_out * D_out * H_out * W_out)
2. min: read x1, write x2 (B * C_out * H_out * W_out)
3. softmax: read x2, write x3 (B * C_out * H_out * W_out)
Our fused kernel:
- Reads input + weights, writes output directly
But our conv compute is manual and not tensor-core accelerated.
What if we do:
1. Use cudnn.conv3d to compute x = conv(x)
2. Then fuse min+softmax in one kernel
This has:
- conv writes to intermediate (same as reference for step 1)
- fused kernel reads intermediate, writes output
So we save one memory write/read roundtrip compared to reference:
- Reference: write conv_out (big), read conv_out, write min_out (small), read min_out, write softmax_out (small)
- This approach: write conv_out (big), read conv_out, write softmax_out (small)
We save writing and reading min_out, which is B * C_out * H_out * W_out = 128 * 24 * 30 * 30 = 2,764,800 floats ≈ 11 MB.
For 2.97 ms at 3.35 TB/s, removing 22 MB should save ~6.6 µs. That's not significant.
Hmm, let me reconsider. The reference is:
```
x = self.conv(x) # write conv_out
x = torch.min(x, dim=self.dim)[0] # read conv_out, write min_out
x = torch.softmax(x, dim=1) # read min_out, write softmax_out
```
With the fused kernel, the bottleneck is our manual conv. With cudnn, the conv is much faster but we do more memory traffic.
Actually, looking at cudnn performance for this size: conv3d with 128, 3->24 channels, 24x32x32->22x30x30, 3x3x3 kernel. This is a relatively small conv. cudnn might be fast but not extremely fast.
Let me try a different strategy: Split the work differently in our kernel. Instead of each block doing 1 (b, h, w), maybe each block handles 2+ (b, h, w) locations or does channel-parallel better.
Actually, wait. I should think about this more carefully. The kernel I wrote has each (b, h, w) compute over all 24 channels and all 22 depths. Perhaps I can use warp-level primitives for better performance.
But actually, let me think if I should just use the reference approach with fused min+softmax:
- Use self.conv(x) to get conv output
- Then fuse min+softmax into one kernel
This way we get cudnn's optimized conv, and avoid the intermediate write for min.
Let me try this approach!
Actually wait, if I think about it - the reference took 2.97 ms. If conv takes say 2.5 ms and the other ops take 0.47 ms, then fusing min+softmax saves maybe 0.3 ms, bringing it to 2.67 ms. But our kernel is currently 4.48 ms.
Hmm, so using cudnn conv + fused min+softmax would get us ~2.7 ms, which should beat the reference. But can we do even better?
Actually, the evaluation compares to reference execution time of 2.97 ms. If we get ~2.7 ms, that's only 10% speedup. We need to do better.
Wait, I want to understand why the first kernel (which reads weights from global mem but is otherwise similar) took 4.47 ms, and the second (with weights in shared mem) was whaqt 4.48. That's nearly identical. So weights caching doesn't help much here, meaning the bottleneck is compute, not memory.
Our conv compute is: for each output pixel, for each of 24 output channels, for each of 22 depths, we do 81 multiply-adds, for a total of 24 * 22 * 81 = 42,768 FMAs per output location. With 4.48 ms for all 115,200 output locations, that's:
115200 locations * 42768 FMAs = 4.93e9 FMAs in 4.48 ms = 1.1 TFLOPS
The H100 can do 67 TFLOPS fp32, so we're only at 1.6% utilization. That's because we're doing direct convolution without any vectorization or optimization.
The issue is that our nested loops are very inefficient compared to cudnn's GEMM-based approach. For a 3D conv, cudnn will transform the problem into a GEMM and use tensor cores or at least highly optimized sgemm kernels.
So the right approach is probably:
1. Keep cudnn for the conv
2. Fuse min+softmax to save the intermediate kernel launch and memory round-trip
But that only gives ~10% speedup.
Is there a way to fuse conv + min + softmax while still using cudnn-like efficiency?
One approach: Implicit GEMM with fusion. We could write the convolution as a GEMM ourselves, using shared memory tiling, and then fuse the min+softmax at the end. But writing an efficient GEMM-based conv3d is quite complex.
Another approach: Use torch.nn.functional.conv3d which calls cudnn, then fuse min+softmax. Let me check if there's an even better approach.
Actually, I just realized something. The reference model stores conv as `self.conv` - which means during evaluation, we can't change the model architecture, but we CAN use the same conv layer and add a fused post-processing kernel.
Let me check if we can potentially reuse the conv output in a more cache-friendly way...
Actually, let me think about another approach. What if I unfold the problem differently? Since kernel_size=3 and we're computing over depth, maybe there's structure I can exploit.
The conv output for each (b, c_out, h, w) is computed over all depths d=0..21. Within each depth, the conv uses 3 input depths (due to kernel size 3). So there's overlap between consecutive output depths. But in our problem, we take the MIN over depth, which breaks most correlations.
Wait, let me reconsider. The min is over dim=2, which in the conv output is the depth dimension. In our kernel, for each (b, c_out, h_out, w_out), we compute the conv at all 22 depths, then take the min. This is correct but perhaps we can structure it better.
Hmm, actually I just had another idea. What if I try to use a more optimized convolution? The basic pattern for fast conv is to use im2col to convert to GEMM, but that requires extra memory. However, we can do implicit GEMM directly in shared memory.
For each output block, we can:
1. Load a C_in x kD x kH x kW input patch into shared memory
2. Load weight into shared memory
3. Compute output tile using GEMM-like operations
But this is quite complex. Let me think if there's a simpler win.
Actually, let me just try the hybrid approach first: cudnn conv + fused min-softmax kernel, and see if that's enough.
Actually, wait. I should check if maybe I can call cudnn from within my CUDA kernel? Probably not easily.
Or maybe I can just call self.conv() and then fuse the rest. Let me try this.
Let me write a fused min+softmax kernel.
For each output pixel (b, h, w):
- We need to read 24 * 22 values from conv output
- Find min over dim=2 (depth) for each channel
- Then do softmax over channels
The conv output layout is [B, C_out, D_out, H_out, W_out]. The min over dim=2 means for each (b, c, h, w), we read all D_out values and take the min.
If I process one (b, h, w) per block with 32 threads:
- Each thread reads ceil(24*22/32) = 17 values from global mem
- Then do warp-level min reduction
- Then softmax
Actually with 32 threads, each thread can handle one channel and do its own min over 22 values. Then we need cooperative softmax.
Let me write this approach.
Actually, maybe better: process multiple (b, h, w) per block to amortize the softmax shared memory sync overhead.
Or use a single thread per (b, h, w), handling all channels serially:
- Read 24 * 22 values sequentially
- Compute 24 min values
- Compute softmax
This avoids across-thread synchronization but might underutilize the GPU if not enough blocks. We have 128 * 30 * 30 = 115,200 blocks though, which is plenty.
Actually 115,200 blocks at 128 threads per SM on H100, with 132 SMs, gives 16,896 concurrent blocks potentially but limited by register/shared memory. With tiny per-block resource usage, we could run maybe 32 blocks per SM = 4224 blocks concurrently. So we need ~27 waves of blocks. That's fine.
Let me try the hybrid approach. But first, I want to see if maybe using a simple self.conv + fused kernel is enough to beat 2.97 ms.
Actually, I'm curious: what if I use self.conv for the conv, and then write a very optimized fused min+softmax kernel? The time for conv from PyTorch might dominate, but maybe I can save on the min+softmax part.
Wait, let me think again. If conv takes 2.5 ms and combined min+softmax takes 0.47 ms, even if I completely eliminate the min+softmax time (which I can't), I'd save at most 0.47 ms, bringing me to 2.5 ms. That's only a 1.19x speedup.
For a meaningful speedup, I need to optimize the conv itself.
Let me think about how to write a faster direct conv kernel. The key insight is that our current kernel does 42,768 FMAs per output pixel but only achieves ~1 TFLOPS. Why is it so slow?
1. Our conv is not vectorized - each thread does scalar operations
2. We're limited by instruction throughput
3. The nested loops create many dependencies
If I process 4 channels per thread (with 6 threads handling 24 channels), each thread does 4*22*81 = 7128 FMAs. With better instruction interleaving, maybe I can achieve higher throughput. But still, direct scalar conv is hard to optimize.
What if I use half-precision? The weights and activations could be kept in fp32 but the compute done in... no, we need fp32 output.
Let me try another approach: use more threads per block and have each thread handle fewer operations but with better parallelism.
Actually, I think the core issue is that cudnn uses GEMM-based conv which gets much better FLOP utilization. For the given problem sizes, im2col would create a GEMM of:
M = 24 * 30 * 30 = 21,600 (spatial*output_channels... wait no)
For conv3d, im2col creates:
- Filter matrix: C_out × (C_in * kD * kH * kW) = 24 × 81
- Input matrix: (C_in * kD * kH * kW) × (D_out * H_out * W_out * B) = 81 × (22 * 30 * 30 * 128) = 81 × 2,534,400
So the GEMM is approximately 24 × 81 × 2,534,400. This is a "tall and skinny" GEMM.
cudnn would handle this efficiently using various algorithms. For such a small M dimension (24), it might use a direct algorithm or implicit GEMM.
Actually, let me try to optimize our kernel using a GEMM-like approach in shared memory. We can tile the computation:
- Each block computes some output channels and some (b, h, w) locations
- Load a tile of input activations into shared memory
- Load a tile of weights into shared memory
- Compute via GEMM
For output: 24 channels × 128*30*30 = 24 × 115,200 locations
For weights: 24 channels × 81 = 1944
For activations: 81 × (per location)
Hmm, maybe I can do it differently. Since each output location is independent, I should focus on making each location's computation faster.
For each (b, h, w), I need to compute 24 channels × 22 depths = 528 conv results. Each conv is 81 FMAs.
528 × 81 = 42,768 FMAs per location.
42,768 FMAs in 4.48 ms for 115,200 locations = 1.1 TFLOPS as I calculated before.
The H100 has 132 SMs, and at 67 TFLOPS fp32, that's ~0.5 TFLOPS per SM. Each SM can do 128 FP32 FMAs per clock at around 2 GHz = 256 GFLOPS per SM. So total ~33.8 TFLOPS from ALUs... wait, the spec says 67 TFLOPS total.
If each SM does 67/132 = 0.508 TFLOPS, then for 115,200 blocks spread over 132 SMs in waves, each block needs 42,768 FMAs. At 0.508 TFLOPS, this should take 42,768 / (0.508e12) = 84 ns per block if fully utilizing the SM. But we have ~27 waves, so total time ~2.3 µs... that's clearly not right.
Wait, I'm confusing myself. The total FMAs is 115,200 * 42,768 = 4.93e9 FMAs. At 67 TFLOPS, this is 73.6 µs. But we're taking 4.48 ms. So utilization is 73.6µs / 4.48ms = 1.6%.
If I could get just 10% utilization (which is still terrible), I'd get 448 µs. That's 10x faster!
The question is: why is utilization so low?
I think it's because:
1. Memory latency dominates - we read from shared memory with complex indexing patterns
2. Thread divergence - the for loop over channels has varying amounts of work per thread
3. Loop overhead - the nested loops generate many instructions
4. Not enough instruction-level parallelism per thread
Let me try to address these by:
1. Using more threads per block for channel parallelism
2. Better memory access patterns
3. Possibly processing multiple output pixels per block
Let me try with 128 threads per block, where each block handles multiple (b, h, w) locations or uses thread cooperation for the convolution.
Actually, the most important optimization might be to use more threads. With 128 threads, channel loop iterations are 24/128 = 1 (but some threads idle for channels). Hmm, that's wasteful.
What if we handle 2 output channels per thread? Then 12 threads do all channels. Still not great.
Let me try a COMPLETELY different organization:
- One warp (32 threads) handles one channel for one (b, h, w)
- Each thread in warp handles ceil(22/32) = 1 depth computation, with some threads handling 2
- After conv, do warp-level min reduction
- Then do softmax across warps
Wait, 32 threads for 22 depths means not all threads have work. That's inefficient.
How about: one thread handles one (b, c, h, w) output completely?
- Each thread computes 22 depths × 81 FMAs = 1782 FMAs per thread
- A block of 128 threads handles 128 different (channel, pixel) combinations
- For 24 channels and 115,200 pixels, we need 24 * 115,200 / 128 = 21,600 blocks
Wait no, each (b, h, w) location has 24 channels, and we need to process all 24 for softmax.
Alternative: one block handles one (b, h, w) with 128 threads. 24 warps (768 threads) is too many. With 128 threads:
- 24 threads handle 1 channel each (compute 22 depths)
- 4 threads idle or help somehow
- After min, do softmax with all 128 threads
Actually, 128 threads for 24 channels where each channel needs 22 computes of 81 FMAs:
- We could assign 5-6 threads per channel
- Those threads split the 22 depths among themselves
- Then warp-level reduce for min
That's getting complicated but might work better.
Actually, let me simplify. What if we use 24 threads per block (one per channel), and each thread computes ALL depths for its channel? Then:
- 24 threads, each does 22 * 81 = 1782 FMAs
- Shared memory for the 24 min values + softmax
- Block count = 115,200
This gives us 115,200 * 24 = 2,764,800 threads total. With H100 having max ~20,000 concurrent threads (132 SMs * 1536 threads/SM = 202,752), we'd have ~14 waves. Each block has 24 threads.
But the benefit is each thread has more contiguous work and less synchronization overhead. Actually, warps need 32 threads for peak efficiency, so 24 threads per block wastes 25%.
What if we pack 2 (b, h, w) locations into one block with 48 threads? Or better, have each warp (32 threads) handle multiple channels from different pixels?
Actually, let me try a radical simplification: use self.conv() and fuse only min+softmax. Even if it's only 10% faster, it's still a win. Then maybe I can combine this with a trick.
But wait, I want significant speedup. Let me think if I can use cudnn for conv and then do a very efficient min+softmax.
Actually, here's another idea: what if I precompute the conv differently? Since kernel_size=3 and stride=1, and we're minimizing over the depth dimension, perhaps I can restructure the computation.
For the depth dimension, the conv at depth d_out uses input depths from d_out to d_out+2. The min over all d_out takes the minimum of these conv results. Is there any algebraic simplification? Probably not, since conv is linear but min is non-linear.
Let me try the 24-threads-per-block approach and see if it helps.
Actually, I want to try using more threads per block with each thread computing multiple things. Let me try one block = one (b, h, w) with 96 threads:
- 24 groups of 4 threads
- Each group does 1 channel, splitting 22 depths among 4 threads (5-6 depths each)
- Warp-level reduce for min
- Then softmax
Actually wait, with 96 threads we'd have 3 warps of 32 threads. Grouping by channel makes the channel groups cross warp boundaries if not careful.
Let me try: each warp (32 threads) handles multiple output channels completely.
- 1 warp = 32 threads
- Each thread handles 1 channel (out of 24... no, 32 > 24)
- Wait, 24 channels, 32 threads: we could have each thread handle ceil(24/32) = 1 channel, with 8 threads idle
- But 8 idle threads out of 32 = 25% waste
Maybe better: 1 warp handles 32 (b, h, w) locations, each computing all 24 channels. No, then softmax can't work easily...
OK, let me try yet another approach: use a smaller number of threads but have each thread do more independent work.
What if each thread computes a complete (b, c, h, w) result including all depths, min, and contribution to softmax?
- One thread = one (b, h, w) location with all 24 channels
- Each thread serially computes 24 min values
- Then computes softmax locally
- Writes output
Block size = let's say 8 or 16 threads, each handling one (b, h, w).
Block count = 115,200 / 8 = 14,400 or 115,200 / 16 = 7,200.
The key benefit: NO shared memory for intermediate results (except maybe caching input/weights), NO thread synchronization within the block.
Let me try两个 variants:
1. Load weights in shared mem (good reuse across threads in block)
2. Keep weights in constant memory or L1 cache
Actually, for variant 1 with 8 threads per block and shared weights: each block loads 1944 weights once, shared by 8 threads. 8 threads × 42,768 FMAs = 342,144 FMAs per block. At reasonable utilization, this could be faster.
But honestly, I'm worried about occupancy with small block sizes. Let me use 128 threads per block, where each block handles 4 (b, h, w) locations with 32 threads each.
Block = 128 threads:
- Group into 4 subgroups of 32 threads
- Each subgroup handles 1 (b, h, w)
- Within subgroup, 24 threads compute 1 channel each, 8 threads idle
-_subgroup loads weights and input for its (b, h, w) into shared mem
- (But we only have 1 shared mem per block... hmm)
This gets complicated. Let me try the simpler approach with 1 thread = 1 (b, h, w), using some shared mem for weights.
Wait, I think I should just try the 128-thread-gemm-like approach. Let me write a kernel where:
- Block = 128 threads
- Each block handles multiple channels for one (b, h, w)
- Block loads input patch and weights into shared mem
- Threads cooperate to compute conv results for all depths and channels
- Then threads do min and softmax
Actually, another idea: for a given (b, h, w), the convolution across different output channels ONLY differs in weights, not in input activations. So:
1. Load input activations into shared memory once (for all channels)
2. Each thread takes some channels and iterates over depths, using the shared input
3. This is what I already do, but maybe I need more threads for channel parallelism
With 256 threads and 24 channels, each thread handles ~0.09 channels - doesn't work. With 48 threads handling 24 channels: 2 threads per channel. Each thread computes 11 depths. Then we need to combine the 2 partial min results.
Actually, let me try 48 threads per block:
- Threads 0-1 handle channel 0 (depths 0-10 and 11-21)
- Threads 2-3 handle channel 1
- ...
- Thread 46-47 handle channel 23
- After computing, each pair does min reduction
- Then all 48 threads participate in softmax
48 threads = 1.5 warps. Non-power-of-2 warps are inefficient. NVIDIA GPUs execute in warps of 32, so we'd have 2 warps with some inactive threads.
Let me try 64 threads:
- 24 channels, 64 threads
- We could have 2 rounds: first 24 threads handle 1 channel each, next 24 threads also handle 1 channel each, but compute the same thing... that's wasteful.
Or: 64 threads / 24 channels ≈ 2.67 threads per channel. Doesn't divide nicely.
Let me try 96 threads (3 warps):
- 24 channels / 96 = 0.25 channels per thread... doesn't work.
Actually, what if I process 2 (b, h, w) per block? 48 locations (2 × 24 channels)? No no.
OK, how about this: 64 threads handle ONE (b, h, w).
- Divide 22 depths among 64 threads: each thread handles ceil(22/64) = 1 depth. Only 22 threads have work. 42 threads idle. That's terrible.
What if 64 threads handle 4 (b, h, w) locations? Each location gets 16 threads.
- 16 threads for 24 channels: doesn't divide.
- Or each location gets 16 threads that handle all channels serially.
Actually, if each of 16 threads handles all 24 channels for 1 location:
- 16 threads × 24 channels × 22 depths × 81 FMAs = ... wait, no. Each thread does all channels for its location.
- So 16 threads, each does 42,768 FMAs
- Total per block = 16 × 42,768 = 684,288 FMAs
- Block count = 115,200 / 16 = 7,200
This amortizes the shared memory load of weights (1944 floats) across 16 threads, and the input load (648 floats per location = 10,368 floats total) across 16 threads.
Each block loads 1944 + 10,368 = 12,312 floats of shared memory.
With 64 threads per block and 7,200 blocks, total threads = 460,800. This is decent.
But wait, each thread doing 42,768 FMAs serially might still be slow due to instruction latency. On H100, each FMA has 4-cycle latency, so we need 4 independent instructions in flight to hide latency. Since our FMAs are chained (conv_val += ...), there's a data dependency that prevents parallel execution.
To hide latency, we need to interleave independent computations. One way is to compute multiple channels or multiple depths interleaved.
Hmm, let me try to write a kernel where each thread computes 2 or 4 channels interleaved:
In the loop over d_out:
conv_val0, conv_val1, conv_val2, conv_val3 = 0
for c_in, kd, kh, kw:
load input
conv_val0 += input * weight[c0]
conv_val1 += input * weight[c1]
conv_val2 += input * weight[c2]
conv_val3 += input * weight[c3]
min0 = fmin(min0, conv_val0)
...
This processes 4 channels per thread, each using the same input activation but different weights. Since the input is reused, this has better cache behavior. And we have 4 independent FMA chains.
If we have 24/4 = 6 threads per block handling all channels, that's very few. But each thread has 4 independent accumulators.
Actually, I should look at the tensor memory architecture. H100 has L1 cache and shared memory. With our current kernel, we explicitly use shared memory. Maybe letting L1 cache handle it would be better? Or maybe shared memory IS faster since it's explicitly managed.
OK, I think the fundamental issue is that 3D direct convolution with tiny output dimensions is just not well-suited to simple CUDA kernels. cudnn has years of optimization for exactly these cases.
So let me try the hybrid approach: use cudnn for conv, fuse min+softmax. Even if it's only 10-15% faster, it's still valid.
Wait, actually I just realized: can I use Tensor Cores or some optimized path? Let me see what happens if I use torch's conv3d and then write a very efficient min+softmax fused kernel that uses vectorized loads.
Let me write a fused_min_softmax kernel:
- Input: [B, C, D, H, W] with C=24, D=22
- Output: [B, C, H, W]
- For each (b, h, w), read all C*D values, find min over D for each C, then softmax over C
The input is in NCDHW format. For a fixed (b, h, w), the values for different (c, d) are at offset: ((b * C + c) * D + d) * H * W + h * W + w.
This means consecutive d values are consecutive in memory (since H*W is the stride for d). And consecutive c values are separated by D*H*W.
For vectorized loads, I can load 4 floats at a time for the d dimension. Since D=22, I can load 5 vec4s (20 values) + 2 individual.
Let me write this kernel.
Or, maybe even simpler: just use a simple kernel with good memory coalescing.
Actually, I realize I should also try something else: what if we use self.conv(x) but with channels_last_3d memory format? That might help the softmax after.
OK let me just write a simple but efficient fused min+softmax kernel and combine with cudnn conv.
For the fused kernel, I'll have each block handle one (b, h, w):
- 32 threads, each reads some (c, d) pairs
- After loading/computing min over d, do softmax
- Write output
Actually, since D=22 and C=24, for each (b, h, w) we read 528 floats. At 4 bytes each, that's 2.1 KB. This easily fits in shared memory.
Kernel approach:
- Block size = 128 threads
- Each block handles 4 (b, h, w) locations (since 128/32 = 4 warps, and 32 threads can cooperate on one location)
- Actually, with 128 threads handling 4 locations, each warp handles 1 location.
Wait, but we need cross-channel cooperation for softmax. Let me think...
For each location (b, h, w):
- 24 channels, each with 22 depths
- We need min over depth: vector of 24 values
- Then softmax over these 24 values
If 1 warp (32 threads) handles 1 location:
- Threads 0-23 each compute min for 1 channel (reading 22 depth values each)
- Threads 24-31 idle or help
- Then do warp-level operations for softmax max, exp, sum
Since there are only 24 channels, 8 threads are idle during most of the computation. That's 25% waste.
But the grid would be 115,200 blocks (each with 1 warp), and each warp handles 1 location. 115,200 blocks for 132 SMs = 873 blocks per SM. With max 32 blocks per SM, we'd have 27 waves.
Actually, I could pack 2 locations into 1 warp:
- 16 threads handle location 0 (16 channels each, but 24 channels... not clean)
- Doesn't work well.
Or: 1 warp handles 2 locations, with 12 threads per location. 12 threads for 22 depths: each thread computes ~2 depths, then warp-level reduce for min. For 24 channels, we'd need 24 threads per location. With 2 locations, 48 threads. 1 warp only has 32. Doesn't work.
So 1 warp per location seems reasonable, even with 25% idle threads.
Total threads = 115,200 warps * 32 = 3,686,400 thread-instances. That's plenty.
For the min computation, each of threads 0-23 reads 22 floats and computes min. Each thread does 22 memory reads + 21 comparisons. The memory reads are coalesced for d but not for c (since different c are at different offsets).
Actually, for consecutive c, the offset difference is D*H*W = 22*30*30 = 19,800 floats = 79,200 bytes. That's a large stride. So threads in a warp reading different c's do NOT coalesce well.
Wait, this is a problem! In the conv output [B, C, D, H, W], for fixed (b, h, w), the values at channel c and depth d are at:
offset = ((b * C + c) * D + d) * H * W + h * W + w
For thread c reading d=0, offset = ((b*C + c)*D) * H*W + h*W + w
For thread c+1 reading d=0, offset = ((b*C + c+1)*D) * H*W + h*W + w = previous + D*H*W
Since D*H*W = 19,800 floats, threads in a warp are accessing memory 19,800*4 = 79,200 bytes apart. This is NOT coalesced at all! Each warp access causes 24 separate cache lines.
To coalesce, we'd want threads to read consecutive memory. So we should organize the reads differently:
- Thread 0 reads all (c=0, d=0..21), then (c=1, d=0..21), etc.
- But then min computation becomes harder.
Actually, in our case, since each thread needs all 22 depths for one channel, and the depths are consecutive in memory, we COULD have better access by having each thread read its channel's data sequentially. The non-coalescing is only across threads, not within a thread's access pattern. A single thread reading 22 consecutive floats gets good cache behavior ( prefetching).
But the warp-level non-coalescing means the L1 cache might not be fully utilized. However, with 22 floats * 24 channels = 528 floats = 2.1 KB per block, and many blocks running concurrently, the cache might still work OK.
Let me think about how to improve memory coalescing. If I transpose the conv output to have channels as the last dimension... but cudnn already wrote it in NCDHW.
Alternatively, we could use shared memory to stage the data. One thread reads all channels sequentially, storing to shared memory, then threads read their channels from shared memory.
But that serializes the reads.
Actually, for the min+softmax fused kernel, let me try organizing it as:
- 1 block = 1 (b, h, w)
- 24 threads, each reading 22 depths for 1 channel
- The reads are sequential within each thread, not coalesced across threads but that's OK for 22 floats
The issue is that for the softmax, we need all 24 threads to cooperate.
But honestly, the min+softmax is a tiny fraction of the total work. The conv dominates. Let me focus on optimizing the conv.
Hmm, what if I use a completely different strategy for the conv?
Since the kernel is 3×3×3 and the input is small, maybe I can:
1. Precompute ALL possible conv output values by restructuring the computation
2. Or use the fact that we're taking min over depth to avoid computing all conv results fully
Actually, the min over depth breaks linearity, so no algebraic shortcut.
Let me think about the reference's approach again. It uses:
1. cudnn conv3d
2. torch.min
3. torch.softmax
The reason cudnn is so much faster is that it transforms the small 3D conv into an optimized GEMM or uses specialized algorithms. We simply can't beat that with a naive CUDA kernel for just the conv part.
So the best strategy is to use cudnn for conv and then optimize the rest.
But wait, what if we use torch's conv3d inside our forward() without writing our own conv kernel? That should give us cudnn-speed conv. Then we write a custom kernel for min+softmax.
Let me try this approach and see how much faster it is than the reference.
Actually, actually, wait. I just realized something important. The reference does:
1. conv3d: [128, 3, 24, 32, 32] -> [128, 24, 22, 30, 30]
2. min: [128, 24, 22, 30, 30] -> [128, 24, 30, 30]
3. softmax: [128, 24, 30, 30] -> [128, 24, 30, 30]
The conv output is 128 * 24 * 22 * 30 * 30 = 76,032,000 floats ≈ 304 MB.
The min output is 128 * 24 * 30 * 30 = 11 MB.
In the reference, these are three separate kernel launches with three separate memory allocations and writes. The memory bottleneck is:
1. Write conv out: 304 MB
2. Read conv out + write min out: 304 + 11 = 315 MB
3. Read min out + write softmax out: 11 + 11 = 22 MB
Total memory traffic: ~641 MB
If we use cudnn conv + fused min+softmax:
1. Write conv out: 304 MB
2. Read conv out + write softmax out: 304 + 11 = 315 MB
Total: ~619 MB
That's only a 3.4% reduction in memory traffic. Not significant.
But the reference time of 2.97 ms means the bandwidth is 641 MB / 2.97 ms = 216 GB/s. That's nowhere near the 3.35 TB/s theoretical. This means the operations are compute-bound or latency-bound.
The conv is compute-bound. cudnn achieves much higher compute utilization than our manual kernel. So we need cudnn for conv.
Therefore, the best approach IS:
1. Use self.conv() for the conv
2. Write a fused min+softmax kernel
This will give us whatever cudnn's conv speed is, plus we save the separate min and softmax kernel launch overhead.
Wait, but even with that, we only save the kernel launch overhead + the intermediate min buffer allocation. The kernel launch overhead is maybe 5-10 µs, and allocation could be significant.
Let me think about whether PyTorch's eager mode allocator cache helps. In the reference, the intermediate tensor is probably allocated from the caching allocator. So allocation is fast but still has some overhead.
With our fused approach, we'd allocate the conv output (same as reference), then read it and compute in our custom kernel.
Actually, what if we can avoid allocating the conv output and do everything in-place? We can't, because we need to read the conv output.
Hmm, I wonder if we can do the min+softmax right on the conv output tensor, writing to a new output tensor. That avoids one allocation vs reference but reference's min probably reuses or overwrites memory.
OK, let me just try the hybrid approach and see what happens. But actually, can I use the exact same self.conv and add a fused kernel after?
Wait, I'll use `self.conv` to get the conv result, then launch a custom kernel that reads the conv result and directly writes the final output.
Let me write this kernel. I'll also try to optimize it well:
- Process 1 (b, h, w) per block with 32 threads
- 24 threads compute min for 1 channel each
- 8 threads help or are idle
- Use warp-level primitives for softmax max and sum
Actually, for softmax, I need:
1. Find max over 24 channels
2. Compute exp(x - max) for each channel
3. Sum the exps
4. Divide each by sum
For warp-level max reduction over 24 values, I can use `__shfl_sync` or `__reduce_max_sync` (if available on H100).
On H100, we have `__reduce_max_sync` and `__reduce_add_sync` in some CUDA versions, but for broader compatibility let's use the more portable shuffle approach.
Let me write a warp-level max:
```cuda
float max_val = -1e30f;
for (int c = lane_id; c < 24; c += 32) {
max_val = fmaxf(max_val, s_mins[c]);
}
// Then warp reduce
for (int offset = 16; offset > 0; offset /= 2) {
max_val = fmaxf(max_val, __shfl_down_sync(0xFFFFFFFF, max_val, offset));
}
```
Then broadcast:
```cuda
max_val = __shfl_sync(0xFFFFFFFF, max_val, 0);
```
Similarly for sum.
This way we don't need shared memory for the max/sum.
But for the min values, we need to store them in shared memory or registers. Each thread could store its channel's min in a register. Then for the max reduction, we need to get all threads' values.
Actually, with 32 threads where only 24 have data:
- Thread 0 has min for channel 0
- Thread 1 has min for channel 1
- ...
- Thread 23 has min for channel 23
- Threads 24-31 have no data
For warp-level reduce_max:
```cuda
float my_val = (lane_id < 24) ? my_min : -1e30f;
float max_val = my_val;
max_val = fmaxf(max_val, __shfl_down_sync(0xFFFFFFFF, max_val, 16));
max_val = fmaxf(max_val, __shfl_down_sync(0xFFFFFFFF, max_val, 8));
max_val = fmaxf(max_val, __shfl_down_sync(0xFFFFFFFF, max_val, 4));
max_val = fmaxf(max_val, __shfl_down_sync(0xFFFFFFFF, max_val, 2));
max_val = fmaxf(max_val, __shfl_down_sync(0xFFFFFFFF, max_val, 1));
max_val = __shfl_sync(0xFFFFFFFF, max_val, 0);
```
Then each thread computes exp(my_min - max_val) or 0 if lane_id >= 24. Then another warp reduce for sum. Then each thread divides.
This is clean and efficient.
For reading the data, each thread handles 1 channel and reads 22 depth values. The depth values are consecutive in memory for each channel.
Memory access pattern for thread c:
- offset start: ((b * 24 + c) * 22 + 0) * 30 * 30 + h * 30 + w
- Then reads 22 floats sequentially
Each thread reads 22 floats = 88 bytes. With 24 active threads, total 2112 bytes per warp. The L1 cache line is 128 bytes, so each thread touches maybe 1-2 cache lines.
With 132 SMs and many warps in flight, the L1 cache might be under pressure. Let me load via shared memory explicitly.
Actually, 2.1 KB per block × 32 blocks per SM = 67 KB, well under L1 capacity. So L1 cache should work fine.
But wait, there's a cache associativity issue. Each thread reads from a different offset spaced by 22*30*30 = 19,800 floats. These map to different cache sets, so no conflict.
OK I think this warp-based approach can work well for the min+softmax part.
Now for the complete solution:
1. `self.conv(x)` gives conv_out
2. Launch our fused kernel
But I also want to avoid the large conv_out intermediate write if possible. Can I somehow fuse conv into the same kernel?
Actually, what if I launch the conv via self.conv, and then immediately call my kernel on its output?
The question is: does this give enough speedup? Compared to the reference:
- Reference: conv + min + softmax (2.97 ms)
- Our approach: conv + fused_min_softmax (~same conv time + slightly faster rest)
If conv takes 2.5 ms and min+softmax takes 0.47 ms, our approach might take 2.5 + 0.3 = 2.8 ms. That's only 6% faster.
Hmm. Is there any way to improve the conv?
What if I try to use a custom kernel that's structured more like an implicit GEMM? For example, I can:
1. Load 3×3×3 input patch into shared memory
2. Load weights for a few output channels
3. Compute the conv via vectorized dot product
Actually, let me think about this more carefully. The conv is essentially:
For each output location (b, d, h, w):
For each output channel c:
result = bias[c]
For each input channel ci, kd, kh, kw:
result += input[b, ci, d+kd, h+kh, w+kw] * weight[c, ci, kd, kh, kw]
The input and weight are both 3D tensors. The computation is 24×22×3×3×3×3 = 85536 multiply-adds per output location (wait, that's per (b, h, w) location, summing over all channels and depths).
The key issue is that for different (b, h, w), the input patches don't overlap at all in the h,w dimensions (since we use kernel=3, adjacent (h, w) locations share some input data).
Adjacent h_out locations share kH rows of input:
- h_out and h_out+1 share kH-1 = 2 rows
- Similarly for w
So there's potential overlap we could exploit! A block that computes multiple adjacent h_out, w_out locations could load a larger input tile and reuse it.
For example, a block computing 2×2 output locations (in h, w) would need a 5×5 input region (since 2 output + 2 overlap = 5). With depth 24 and channels 3, that's 3×24×5×5 = 1800 floats.
And each location has 4 (2×2) output pixels, each needing 24×22 conv results. Total conv computations = 4 * 24 * 22 * 81 = 170,496 FMAs.
With say 256 threads, each does 666 FMAs. At 4-cycle latency, we need ~170 independent chains. 666 is enough for some pipelining but probably not enough to fully hide latency.
Actually, let me think differently. What if a block computes a tile of output channels and a tile of spatial locations? Like:
- Output: 8 channels × 4 h × 4 w = 128 values
- But we also need 22 depths per value... so this is 128 × 22 convs = 2,816 conv results.
With 256 threads, each thread handles 11 conv results. Each conv is 81 FMAs, so 891 FMAs per thread. Better but still complex.
I think the overlap idea is valid but the implementation gets complicated. And for a 3D conv with 22 depths, the depth dimension already dominates the computation.
Let me try a different approach. What if I keep the cudnn conv, but use a custom min+softmax that operates directly on the conv output without creating a separate intermediate tensor for min?
In PyTorch, `torch.min(x, dim=2)[0]` returns a new tensor. If I write a kernel that reads the 5D conv tensor and writes the 4D softmax output directly, I avoid creating the min tensor and launching the separate softmax kernel.
Let me implement this and test.
Actually, wait, there's another idea! What if I process the conv in a way that generates min'd values directly, without writing the full 5D tensor?
For each (b, c, h, w), I need to convolve over the full depth and take the min. If I iterate over depth and compute one conv result at a time, tracking the running minimum, I never need to store the intermediate 5D conv output. I just store the final 4D output.
But this is exactly what my original fused kernel did! The issue was that my conv compute was slow.
Hmm, but maybe I can speed up the conv compute by using a better algorithm. Let me think about vectorization.
For each (b, c, h, w), I need to compute:
sum over ci, kd, kh, kw of input[...] * weight[...]
The weight for each output channel is a 3×3×3×3 = 81-element filter. The input for each location is also 81 elements (3 channels × 3×3×3 kernel).
This is essentially a dot product between two 81-element vectors!
For output channels 0..23, we're computing 24 dot products with the SAME input vector but different weight vectors.
This is a matrix-vector multiplication:
- Input vector: 81 elements (shared across all output channels)
- Weight matrix: 24 × 81
- Output: 24-element vector
Then we take min over depth: we need to do 22 of these dot products and find the minimum for each of the 24 output channels.
So for one (b, h, w) location:
For d = 0..21:
input_patch = extract 81-element patch at depth d
output_patch[d] = weight_matrix @ input_patch (24-element vector)
Then final_output[c] = min over d of output_patch[d][c]
The big insight: the 24 × 81 weight matrix is the same for all depths!
So we can load the 24 × 81 = 1944 weights into shared memory once. Then for each depth, we extract a different 81-element input patch and compute a GEMV.
A GEMV with matrix 24×81 can be optimized. With 24 output channels and 81 inputs, we can use various strategies.
Strategy: Each thread computes 1 output channel and does a dot product of 81 elements.
- 24 threads
- Each thread reads 81 input elements and 81 weights, doing 81 FMAs
- Then updates its running min
With 81 FMAs per thread, at 4-cycle latency, we need ~20 warps in flight to saturate the SM. With block count 115,200 and only 24 threads per block, we have 2,764,800 total threads. At 132 SMs, that's 20,945 threads per SM, which is more than enough (max is ~2048 per SM). So we should have good occupancy.
But wait, 24 threads per block = 0.75 warps. The GPU can't execute 0.75 warps efficiently; it still schedules a full warp but with 8 threads disabled. So we're at 75% thread utilization. And more importantly, warp-level primitives don't work as cleanly.
What if we use 32 threads but only 24 of them compute?
- 24 threads do the GEMV
- 8 threads are idle
- After GEMV, we need to find min across depths. Each thread already tracks its own running min.
- Then softmax requires all 24 channels. We can use the warp-level reduce.
Actually, this is what I had before. And the utilization is 75% which is not terrible.
Why was the original kernel so slow? Let me re-examine. In the original kernel, each thread DID compute one channel and track its own min. But there was a big difference: the input wasn't treated as a simple 81-element vector per depth. Instead, it was loaded as a 3×24×3×3 tile (all depths at once), and each thread iterated through the depths.
Wait, in my original kernel:
1. Load ALL input data for all depths into shared memory: 3×24×3×3 = 648 floats
2. Each thread handles 1 channel and iterates through 22 depths
3. For each depth, the thread does nested loops over ci, kd, kh, kw
The problem is the nested loop structure. Each iteration reads from shared memory with computed indices, and the inner loop has data dependencies (accumulating conv_val).
If instead I restructure it as a clean 81-element dot product, the compiler might vectorize better or at least generate more efficient code.
Let me rewrite the kernel with this GEMV perspective:
For each depth d_out:
// Build input vector of 81 elements
// Compute dot product with weights for channel c
conv_val = bias[c];
for (int i = 0; i < 81; i++) {
conv_val += input_vec[d_out][i] * weight[c][i];
}
min_val = fminf(min_val, conv_val);
But this requires either:
- Accessing input_vec[d_out][i] from shared memory with computed index
- Or precomputing the input vector layout
The key is to make the inner loop as simple as possible, reading sequentially from shared memory.
Actually, what if I store the input patch in a flattened format? For each depth offset, the 81 elements are stored consecutively. Then the kernel iterates:
for (int d = 0; d < 22; d++) {
float* inp = s_input + d * 81;
float conv_val = bias[c];
for (int i = 0; i < 81; i++) {
conv_val += inp[i] * s_weights[c * 81 + i];
}
min_val = fminf(min_val, conv_val);
}
This is much cleaner! The inner loop is a simple sequential dot product, which the compiler can optimize well.
And the input layout in shared memory would be:
- depth 0: 81 elements (flattened ci, kd, kh, kw)
- depth 1: 81 elements
- ...
- depth 21: 81 elements
Total: 22 × 81 = 1782 floats.
Wait, but in my original kernel I loaded 648 floats = all depths' data in one shot. Actually 648 = 3×24×3×3 which is 3 channels × 24 depths × 3×3 spatial. But for conv3d, the input at each output depth uses 3 input depths. So for 22 output depths, we need 24 input depths. That's why 3×24×3×3 = 648.
If we store as 22 groups of 81 elements each, where group d contains the 81 input values for computing output depth d, then:
- group 0: input depths 0,1,2 for the spatial patch
- group 1: input depths 1,2,3
- ...
- group 21: input depths 21,22,23
Total: 22 × 81 = 1782 floats. That's more than 648! Because the original 648 exploited overlap between adjacent depths. Groups 0 and 1 share input depths 1,2. So storing separately means duplication.
With 1782 floats ≈ 7.1 KB per block, and 32 blocks per SM = 228 KB. That's close to the 227 KB limit! Actually, 7.1 KB is fine since 32 * 7.1 = 227 KB exactly at the limit. But we also need weight storage: 24 * 81 = 1944 floats = 7.8 KB. Total 14.9 KB. 32 * 14.9 = 476 KB > 227 KB. So we can't have 32 blocks per SM.
Max blocks = 227 / 14.9 = 15.2, so about 15 blocks per SM. That's still decent.
But wait, we don't need to store the input redundantly. We can store the overlapped input (648 floats) and have each thread/computation access the right offsets.
Actually, with 648 floats input + 1944 weights = 2592 floats = 10.4 KB. 32 blocks * 10.4 KB = 332 KB > 227 KB. So max blocks = 227 / 10.4 = 21.8, so about 21 blocks per SM. That's still good.
The original kernel used 648 + 1944 + 100 ≈ 2700 floats = 10.8 KB. Same ballpark.
So the memory footprint isn't the issue. The issue is the conv compute loop structure.
Let me restructure the inner loop to be a clean dot product over 81 elements, where the 81 elements are indexed from the shared memory.
For a given output depth d_out and output channel c:
conv_val = bias[c]
for (int i = 0; i < 81; i++) {
int s_input_idx = d_out * 81 + i; // This is WRONG because input is stored as 648 floats without redundancy
}
Wait, the original kernel stored input as [C_in][D_in][kH][kW] = [3][24][3][3]. For output depth d_out and filter depth kd (0..2), the input depth is d_out + kd.
If I flatten the filter dimensions as [kd][kh][kw][ci] or [ci][kd][kh][kw], the indexing becomes:
conv_val = bias[c]
int w_base = c * 81;
for (int ci = 0; ci < 3; ci++) {
for (int kd = 0; kd < 3; kd++) {
int d_in = d_out + kd;
for (int kh = 0; kh < 3; kh++) {
for (int kw = 0; kw < 3; kw++) {
int s_idx = ((ci * 24 + d_in) * 3 + kh) * 3 + kw;
conv_val += s_input[s_idx] * s_weights[w_base + ((ci * 3 + kd) * 3 + kh) * 3 + kw];
}
}
}
}
This is basically what the original kernel did (but with different weight layout). The problem is the 4-level nested loop with computed indices.
Let me unroll it manually to help the compiler. Since 3×3×3×3 = 81 is a compile-time constant, I can write it as a single loop over 81 elements:
```cuda
for (int i = 0; i < 81; i++) {
int ci = i / 27;
int t = i % 27;
int kd = t / 9;
int t2 = t % 9;
int kh = t2 / 3;
int kw = t2 % 3;
int d_in = d_out + kd;
int s_idx = ((ci * 24 + d_in) * 3 + kh) * 3 + kw;
conv_val += s_input[s_idx] * s_weights[w_base + i];
}
```
This is still 81 iterations with some integer math. But integer math is cheap on modern GPUs (1 cycle, can dual-issue with FMA).
The real issue might be memory access latency from shared memory. Each iteration reads s_input[s_idx] and s_weights[w_base + i] from shared memory. Shared memory latency is ~20-30 cycles. FMA throughput is 1 per cycle per SM (or more). So to hide 30-cycle latency, we need ~30 independent FMA chains in flight per SM. But each thread only has 1 chain of 81 FMAs! So we'd need 30 threads per SM working on independent chains.
With 24 threads per block and 21 blocks per SM = 504 threads per SM, we should have plenty. But the warp scheduler issues instructions from a warp. Within a warp, all threads execute the same instruction. If all threads in a warp are doing dependent FMAs, the warp stalls.
Actually, within a warp, threads CAN'T have independent FMA chains if they all execute the same instruction at the same time. The warp executes:
cycle 0: thread 0: FMA using reg0, thread 1: FMA using reg1, ..., thread 31: FMA using reg31
cycle 1: thread 0: FMA using result from cycle 0, thread 1: FMA using result from cycle 1, ...
Since all threads in the warp have the same dependency chain length, the warp can't issue the next FMA until the previous one completes. So the entire warp stalls for ~4 cycles between FMAs.
The GPU can switch to other warps during this stall. With 21 blocks * (1 warp per block if 24-32 threads) = 21 warps per SM. Wait, 21 blocks per SM with 32 threads = 21 warps per SM. At 4-cycle FMA latency, we'd need ~8 warps to fully hide latency. But with only 21 warps and many other instructions (loads, etc.), maybe it's not enough.
Actually, the scheduling works differently. A warp can issue an FMA every cycle if the previous FMA completed. But FMA takes 4 cycles. So a warp issues FMA at cycle 0, 4, 8, 12, etc. If no other warps are ready, the execution unit sits idle 75% of the time.
With 21 warps per SM, and each warp issuing an FMA every 4 cycles, the total FMA issue rate is 21 warps * (1 FMA / 4 cycles) = 5.25 FMAs per cycle. If the SM can execute 128 FMAs per cycle, utilization is 5.25 / 128 ≈ 4%. That's terrible!
To saturate the SM's FMA units, we need enough warps to issue 128 FMAs per cycle. With each warp issuing 1 FMA every 4 cycles, we need 128 * 4 = 512 warps. But each SM can only hold ~64 warps at most! (H100 has 2048 threads / SM = 64 warps).
So it's IMPOSSIBLE to saturate FMA units with this kind of dependent FMA chain, UNLESS we have instruction-level parallelism within each thread.
Instruction-level parallelism means each thread has multiple independent FMA operations that can be issued back-to-back. For example:
```cuda
float conv_val0 = 0, conv_val1 = 0, conv_val2 = 0, conv_val3 = 0;
for (int i = 0; i < 81; i += 4) {
conv_val0 += a[i+0] * b[i+0];
conv_val1 += a[i+1] * b[i+1];
conv_val2 += a[i+2] * b[i+2];
conv_val3 += a[i+3] * b[i+3];
}
```
Now the thread has 4 independent FMA chains. During the 4-cycle latency of conv_val0's FMA, it can issue conv_val1, conv_val2, conv_val3. Then by the time it comes back to conv_val0, the result is ready.
With 4 independent chains, a warp can issue 4 FMAs in 4 consecutive cycles, then needs to wait for latency before issuing cycle 5. Actually, it can issue:
cycle 0: FMAs for conv_val0
cycle 1: FMAs for conv_val1
cycle 2: FMAs for conv_val2
cycle 3: FMAs for conv_val3
cycle 4: FMAs for conv_val0 (result from cycle 0 is ready)
So a warp now issues 4 FMAs every 4 cycles = 1 FMA per cycle. This still means each warp is at 25% of peak IF the SM can issue 1 FMA per thread per cycle.
Actually, let me check H100's instruction issue rate. H100 can issue 1 FMA instruction per SM per cycle (per thread), but for all threads in a warp simultaneously. So a warp of 32 threads can issue 32 FMAs per cycle.
With 4 independent chains, a warp can issue 32 FMAs per cycle continuously.
With 21 warps per SM, that's 21 * 32 = 672 FMAs every 4 cycles = 168 FMAs per cycle. The SM can execute 128 FMAs per cycle for fp32. So we'd be at 168/128 ≈ 131% utilization if all warps were doing this continuously. In practice, there are other instructions, but this is much better!
So the KEY optimization is to have each thread compute multiple independent output channels (or depths) simultaneously, enabling instruction-level parallelism.
Let me rewrite the kernel so that each thread computes 4 output channels at once:
For a given (b, h, w) and depth d:
conv_val0 = bias[c+0];
conv_val1 = bias[c+1];
conv_val2 = bias[c+2];
conv_val3 = bias[c+3];
for (int i = 0; i < 81; i++) {
float inp = input_patch[i];
conv_val0 += inp * weights[c+0][i];
conv_val1 += inp * weights[c+1][i];
conv_val2 += inp * weights[c+2][i];
conv_val3 += inp * weights[c+3][i];
}
This creates 4 independent FMA chains. And the same input is reused 4 times, improving cache efficiency.
With 24 channels and 4 channels per thread, we need 24/4 = 6 threads per block to handle all channels. But then we need 6 threads per block × many blocks.
Actually, we can have each block use 32 threads:
- Threads 0-5 handle channels in groups of 4
- Thread 0: channels {0, 1, 2, 3}
- Thread 1: channels {4, 5, 6, 7}
- ...
- Thread 5: channels {20, 21, 22, 23}
- Threads 6-31: idle or handle extra work
Wait, 24 channels / 4 = 6 groups. We have 32 threads, so 6 threads active and 26 idle. That's even worse utilization!
What if each thread handles 1 channel but computes 4 depths at once?
For depths d, d+1, d+2, d+3:
Each depth uses a different input patch and produces a different conv result
We track 4 running min values (one per depth)
But each of the 4 depths uses a different 81-element input vector. These might not all be in cache simultaneously.
Hmm, but for a given channel, all depths use the SAME weights. So we can load 1 weight and reuse it 4 times.
conv_val0 = bias[c]; // for depth d0
conv_val1 = bias[c]; // for depth d1
conv_val2 = bias[c]; // for depth d2
conv_val3 = bias[c]; // for depth d3
for (int i = 0; i < 81; i++) {
float w = weights[c][i];
conv_val0 += input_d0[i] * w;
conv_val1 += input_d1[i] * w;
conv_val2 += input_d2[i] * w;
conv_val3 += input_d3[i] * w;
}
This has 4 independent FMA chains! Each thread computes 4 depths for 1 channel. Each thread still handles 1 channel total, but does 4 depths in parallel.
Then after the loop, we take the min of the 4 conv_vals and compare with other depth groups.
For D_out = 22, each thread would process 22/4 = 5.5 groups. So 6 groups total.
With 24 channels and 24 threads (each handling 1 channel):
- 24 threads active
- Each thread computes 6 groups of 4 depths = 24 depths
- But we only have 22 depths, so last group has only 2 valid depths
- Total blocks = 115,200 (one per (b, h, w))
With 24 threads per block = 0.75 warps. Again, 25% thread waste. But within active threads, we have 4 independent FMA chains!
Utilization estimate:
- 24 threads per block = 1 warp (with 8 threads masked)
- Each warp can issue 4 FMAs per 4 cycles = 1 FMA per cycle per active thread? No wait.
Each thread has 4 independent FMAs. The warp scheduler can issue one independent FMA per thread per cycle. So in cycle 0, all 24 active threads issue FMA for conv_val0. In cycle 1, all 24 issue FMA for conv_val1. In cycle 2, conv_val2. In cycle 3, conv_val3. In cycle 4, conv_val0 again (now dependent result is ready? No, result from cycle 0 is ready at cycle 4). Yes! So the warp can issue FMAs continuously at 24 FMAs per cycle.
But the SM can execute 128 FMAs per cycle (32 threads * 4 FMAs per thread? No, H100 can execute 128 fp32 ops per cycle per SM, which is 64 FMAs per cycle per SM).
Wait, let me check. "67 TFLOPS fp32" at ~2 GHz clock means ~33.5 FLOPS per cycle per SM for fp32. But H100 has 128 FP32 cores per SM. At 2 GHz, that's 256 FP32 ops per cycle, or 128 FMAs per cycle. Hmm, but 128 * 132 * 2e9 = 33.8 TFLOPS... that doesn't match 67.
Actually, Ampere/H100 have dual-issue capability or special Tensor Cores. The 67 TFLOPS might be from using both FP32 units simultaneously, or from Tensor Cores doing matrix operations.
Regardless, the cores can execute substantial FMA throughput. If a warp issues 24 FMAs per cycle continuously, and we have 21 warps per SM, that's 504 FMAs per cycle, which should saturate or exceed the FMA throughput.
Actually wait, even with 24 threads per block, the warp is considered to have 32 threads by the hardware. The 8 inactive threads still consume some resources but don't execute. So the effective FMA rate per warp is 24 FMAs per cycle? Or is it 32, with 8 doing dummy work?
In CUDA, threads that are masked out (beyond the block size or due to conditionals) don't execute the instruction at all. The warp still issues the instruction for all 32 threads, but the 8 threads' results are discarded. This means the FMA units for those 8 threads are wasted. So 24 active threads out of 32 = 75% FMA unit utilization.
Per warp: 32 FMAs issued per cycle, but 8 are useless. So effective: 24 FMAs per cycle.
With 21 warps: 21 * 24 = 504 effective FMAs per cycle. This should definitely saturate the SM.
Great, so the 4-depth-parallel approach should give much better performance!
But wait, there's a complication: input memory. When computing 4 depths at once, we need 4 different input patches. Each input patch is 81 floats. If we store all input data in shared memory (648 floats for all 24 depths), the 4 patches for depths d, d+1, d+2, d+3 overlap significantly.
Actually, for depths d and d+1:
- Depth d uses input depths d, d+1, d+2
- Depth d+1 uses input depths d+1, d+2, d+3
- So they share input depths d+1, d+2
If we have each thread read 4 input patches from shared memory, the accesses are interleaved in depth.
With groups of 4 depths: {0,1,2,3}, {4,5,6,7}, {8,9,10,11}, {12,13,14,15}, {16,17,18,19}, {20,21} (last group has only 2).
For group {0,1,2,3}:
- Depth 0 input patch = input depths 0,1,2
- Depth 1 input patch = input depths 1,2,3
- Depth 2 input patch = input depths 2,3,4
- Depth 3 input patch = input depths 3,4,5
These patches are mostly overlapping! If we read them element by element from shared memory, we get good cache behavior.
But reading 4 elements per iteration from what is essentially the same shared memory array:
```cuda
for (int i = 0; i < 81; i++) {
float inp0 = s_input[d0_base + i];
float inp1 = s_input[d1_base + i];
float inp2 = s_input[d2_base + i];
float inp3 = s_input[d3_base + i];
float w = s_weights[w_base + i];
conv_val0 += inp0 * w;
conv_val1 += inp1 * w;
conv_val2 += inp2 * w;
conv_val3 += inp3 * w;
}
```
The 4 input loads and 1 weight load are from shared memory. Shared memory can service multiple accesses per cycle (up to 32 independent banks). So these 5 loads should be well within shared memory bandwidth.
And we have 4 independent FMAs. This looks good!
Let me also consider: can we fit more parallel work? What if each thread handles 2 channels × 2 depths = 4 independent accumulators total, in a different arrangement?
Actually, let me try another layout: each block handles multiple (b, h, w) locations and threads work on them. That way we get both data reuse and instruction parallelism.
For example, 1 block = 8 (b, h, w) locations × 4 channels each = 32 threads total.
Wait, that doesn't help because all threads would be active. Let me be more precise.
Each (b, h, w) has 24 channels and 22 depths = 24 * 22 = 528 values to compute. We want to assign these to threads for maximum parallelism.
If we process 2 (b, h, w) locations per block:
- Location 0: 24 channels × 22 depths
- Location 1: 24 channels × 22 depths
Total: 1056 values
With 32 threads, each thread handles 1056 / 32 = 33 values. A "value" here means one (channel, depth) combination. Each value requires an 81-element dot product.
So each thread does 33 * 81 = 2673 FMAs. That's a lot! But the problem is these 33 dot products for different (channel, depth) pairs are independent, so they can be interleaved.
Actually, if a thread handles 1 channel for location 0 and 1 channel for location 1, that's 2 channels × 22 depths each = 44 dot products. That's more than 33.
What if:
- Thread 0 handles channel 0 of loc0 and channel 0 of loc1
- Thread 1 handles channel 1 of loc0 and channel 1 of loc1
- ...
- Thread 23 handles channel 23 of loc0 and channel 23 of loc1
- Threads 24-31 idle
For each location-channel, the thread computes 22 depths. With 4 depths in parallel (as before), each location-channel needs 22/4 = 6 groups.
So each thread computes: 2 locations × 6 groups × 4 depths = 48 conv results, but organized as parallel chains of 4. In terms of independent FMAs: 4 chains at a time, 6 groups + some partial group.
The input loads for 2 locations need 2 × 648 = 1296 floats. Weight loads are shared (1944 floats). Shared memory total = 3240 floats = 13 KB.
With 13 KB per block, max blocks per SM = 227 / 13 = 17.4. So ~17 blocks per SM.
Threads per block = 32 (1 warp). But only 24 are active. So active warps per SM = 17 * 0.75 = 12.75 warps.
With 4 independent FMAs per thread, throughput per warp = 24 effective FMAs per cycle. Per SM = 12.75 * 24 = 306 FMAs per cycle. This is probably close to saturating the SM.
Actually wait, let me recalculate. If we compute 2 locations per block, each block has 32 threads (1 warp). 17 blocks per SM = 17 warps. Each warp has 24 active threads, each doing 4 independent FMAs per cycle (interleaved across iterations). Effective FMAs per cycle per SM = 17 * 24 = 408. This should be good.
But we're reading 2 locations' inputs. Is there enough shared memory bandwidth? Each iteration reads 4 inputs + 1 weight = 5 values, all from shared memory. At 32-bit per value, and 32 threads doing this, that's 160 values per cycle = 640 bytes per cycle. Shared memory bandwidth per SM is ~bytes per cycle... actually shared memory bandwidth is quite high. Should be fine.
OK, let me try implementing this: 2 locations per block, 32 threads, each thread handles 2 channels (1 per location), 4 depths in parallel.
Hmm actually, this is getting complex. Let me go step by step.
What if I try something simpler first: the 4-depths parallel approach with 1 location per block but 32 threads.
24 threads active, each handling 1 channel with 4 depths parallel. 8 threads idle. Blocks = 115,200.
Let me write this kernel and test.
Actually, I realize there's a much simpler thing to try first. Let me check what happens if I just use the original kernel but with different block sizes and without the shared memory weight copy (keep weights in global memory and let L1 cache handle it).
Actually, let me try yet another angle. What if I use the Tensor Cores? For a 24×81 GEMV, I can treat it as a 1×24 × 24×81 = 1×81? No, GEMV is matrix-vector.
Actually, 24 output channels and 81 inputs. If I batch multiple depth computations together, I can do a matrix-matrix multiply:
- Matrix A: 22 depths × 81 inputs (after extracting patches)
- Matrix B: 24 channels × 81 inputs (weights)
- Result: 22 depths × 24 channels
This is a batched GEMM-like operation! For each (b, h, w), I need to compute a 22×24 matrix where each row is a GEMV.
With 22 = batch_size, 24 = M, 81 = K. This is a small batched GEMM that could potentially use Tensor Cores if sized appropriately.
For Tensor Cores on H100, the minimum size is typically 8×8×4 or similar for some modes. 22×24×81 doesn't align well with Tensor Core requirements.
But we can use WMMA or similar APIs. This might be overkill and hard to get right.
OK, let me try a more straightforward optimization: use 4 independent accumulators per thread by computing 4 channels simultaneously, but for only 1 depth at a time, then iterate over depths and update mins.
Wait, I just realized that if I compute 4 channels for 1 depth simultaneously, the channels all use the SAME input patch but different weights. So:
For depth d:
float inp[81];
// load input patch for depth d (uses input depths d, d+1, d+2)
for c in {0, 4, 8, 12, 16, 20}:
conv_val0 = bias[c+0];
conv_val1 = bias[c+1];
conv_val2 = bias[c+2];
conv_val3 = bias[c+3];
for i in 0..80:
float in = inp[i];
conv_val0 += in * weights[c+0][i];
conv_val1 += in * weights[c+1][i];
conv_val2 += in * weights[c+2][i];
conv_val3 += in * weights[c+3][i];
min_val[c+0] = fminf(min_val[c+0], conv_val0);
// etc
Here, each thread handles 4 channels (out of 24), and each block has 6 threads. This is terrible block utilization.
What if each thread handles 1 channel but computes 2 channels' worth of work by processing 4 output channels' weights but for only half the depths? No, that doesn't help with memory reuse.
Actually, maybe I should flip it: process multiple (b, h, w) per block to fill the block with more threads.
For example, a block of 128 threads handles 16 (b, h, w) locations. Each location gets 8 threads. 8 threads for 24 channels: each thread handles 3 channels. Then within each thread, compute 3 channels for its location, with 22 depths in some order.
For 16 locations × 24 channels = 384 channel-locations to process. 128 threads handle 3 each = 384. Perfect!
Each thread:
- Has 3 channels, 22 depths each = 66 conv computations
- 66 × 81 = 5346 FMAs per thread
If each thread computes one depth at a time for all 3 channels:
```cuda
for (int d = 0; d < 22; d++) {
float c0 = bias[ch0];
float c1 = bias[ch1];
float c2 = bias[ch2];
for (int i = 0; i < 81; i++) {
float in = input_patch[d][i];
c0 += in * weights[ch0][i];
c1 += in * weights[ch1][i];
c2 += in * weights[ch2][i];
}
min0 = fminf(min0, c0);
min1 = fminf(min1, c1);
min2 = fminf(min2, c2);
}
```
Here we have 3 independent FMA chains. And 128 threads per block means 4 warps.
The input load: for 16 locations, each needs 648 floats of input. Total = 10,368 floats = 41.5 KB. Plus weights = 7.8 KB. Total shared = 49.3 KB. Max blocks per SM = 4. 4 blocks × 128 threads = 512 threads per SM. Good occupancy.
This seems promising! 3 independent FMA chains per thread, and 128 threads per block.
But I need to think about whether 3 independent chains is enough to hide latency. Each iteration of the inner loop has:
- 1 shared memory load for input
- 3 shared memory loads for weights
- 3 FMAs
That's 7 instructions. With dual-issue, maybe 4 cycles. FMA takes 4 cycles. So the next FMA for c0 might stall for 1 cycle.
If we have more chains (e.g., c0, c1, c2, c3 = 4 chains), we can hide latency better:
- c0 FMA: cycle 0
- c1 FMA: cycle 1
- c2 FMA: cycle 2
- c3 FMA: cycle 3
- c0 FMA: cycle 4 (result ready)
So 4 chains gives continuous FMA issue. With 3 chains, there's a 1-cycle gap every 4 cycles. 75% utilization of FMA issue rate still within the thread, which is decent.
Actually, let me see if I can get 4 channels per thread. With 128 threads and 16 locations × 24 channels = 384 channel-locations: 384 / 128 = 3 channels per thread. Not 4.
To get 4 channels per thread: need 384 / 4 = 96 threads. But 96 threads = 3 warps. That's fine, but 96 isn't a power of 2 and might have some inefficiency. Actually, any multiple of 32 works, but the last warp would have only 32 active... wait, 96 = 3 warps with all 96 threads active. That's fine!
Actually, 96 threads per block. Blocks per SM = 227 KB / 49.3 KB = 4.6, so 4 blocks. 4 × 96 = 384 threads per SM. Still decent.
Alternatively, reduce locations per block to 12: 12 × 24 = 288 channel-locations. 288 / 4 = 72 threads. 72 = 2.25 warps - not ideal.
Or 8 locations: 192 channel-locations. 192 / 4 = 48 threads = 1.5 warps - not ideal.
Or 4 locations: 96 channel-locations. 96 / 4 = 24 threads = 0.75 warps - not ideal.
Or 32 locations: 768 channel-locations. 768 / 4 = 192 threads = 6 warps. Shared memory = 32 * 648 + 1944 = 22,680 floats = 90.7 KB. Blocks per SM = 2. 2 * 192 = 384 threads per SM.
Hmm, actually for 32 locations per block with 192 threads, I'd need to load 32 different input patches. That's a lot of shared memory and might cause bank conflicts during load.
Let me stick with 16 locations and either 128 threads (3 channels each) or figure out a different distribution.
With 128 threads and 3 channels each, I have 3 independent accumulators. Let me see if I can structure the code to squeeze out a 4th independent accumulator by processing 2 depths in parallel!
Wait, I have an idea! Each thread handles 3 channels × 1 depth at a time. But what if I process 2 depths at a time? That's 3 channels × 2 depths = 6 independent accumulators.
For each pair of depths (d0, d1):
// d0 uses input depths d0, d0+1, d0+2
// d1 uses input depths d1, d1+1, d1+2
// For 3 channels c0, c1, c2:
float val00 = bias[c0], val01 = bias[c0]; // c0 for d0 and d1
float val10 = bias[c1], val11 = bias[c1];
float val20 = bias[c2], val21 = bias[c2];
for i in 0..80:
// Problem: d0 and d1 use different input patches!
// input for d0 at index i is at some shared mem location
// input for d1 at index i is at a different location
float in0 = s_input_d0[i];
float in1 = s_input_d1[i];
float w0 = weights[c0][i];
float w1 = weights[c1][i];
float w2 = weights[c2][i];
val00 += in0 * w0; val01 += in1 * w0;
val10 += in0 * w1; val11 += in1 * w1;
val20 += in0 * w2; val21 += in1 * w2;
min0 = fminf(min0, fminf(val00, val01));
min1 = fminf(min1, fminf(val10, val11));
min2 = fminf(min2, fminf(val20, val21));
Now I have 6 independent accumulators! This should definitely hide FMA latency.
For D_out = 22, we have 11 pairs of depths. Processing 11 pairs × 6 accumulators = 66 conv computations. Each is 81 FMAs = 5346 FMAs per thread.
The shared memory usage: we need to store input patches for both depths in each pair. But we only need 2 depths' worth of patches at a time. Each patch is derived from 3 input depths.
Actually, wait. If I'm processing depths (0, 1), I need input patches for depth 0 and depth 1. These overlap. Storing both explicitly would be wasteful. But if the input is already in shared memory as a [3][24][3][3] array, I can compute the offsets on the fly.
For a given output depth d and input channel ci and filter index (kd, kh, kw):
input_idx = ((ci * D_in + (d + kd)) * kH + kh) * kW + kw
So in the inner loop, for each of the 81 filter positions, we compute the input index for d0 and d1.
Actually, the inner loop structure is:
```cuda
for (int i = 0; i < 81; i++) {
int ci = i / 27;
int kd = (i % 27) / 9;
int kh = (i % 9) / 3;
int kw = i % 3;
int idx_d0 = ((ci * 24 + (d0 + kd)) * 3 + kh) * 3 + kw;
int idx_d1 = ((ci * 24 + (d1 + kd)) * 3 + kh) * 3 + kw;
float in0 = s_input[idx_d0];
float in1 = s_input[idx_d1];
float w = s_weights[...];
...
}
```
The index computation adds some overhead, but the 6 independent FMAs should more than make up for it.
Hmm, but I need to be careful about shared memory bank conflicts. With 128 threads in a block, all computing the same d0 and d1 for different channels, the input accesses might conflict.
For thread t handling channels (c0, c1, c2):
- Thread t loads weights for c0, c1, c2
- Thread t loads input at idx_d0 which depends only on ci, kd, kh, kw (same for ALL threads!)
- All threads load the SAME input elements simultaneously
This means all threads read the same shared memory address. In CUDA, when all threads in a warp read the same shared memory address, it's a broadcast - no bank conflict! Only 1 cycle needed. This is actually OPTIMAL for shared memory!
And the weight loads: each thread reads different weights (for its own channels). These are consecutive in memory (since weights are [channel][i]). If threads are reading weights at offsets c0*81+i, c1*81+i, c2*81+i, and these are interleaved based on thread ID...
Wait, if thread 0 reads weights at offsets 0, 81, 162 (for channels 0, 1, 2), and thread 1 reads at 243, 324, 405 (for channels 3, 4, 5), etc., then at the same instruction, threads in a warp are reading different offsets.
If the weights are stored as [channel][i] with 81 floats per channel, then channel c starts at c*81. Thread t (handling channels c0, c1, c2) reads from c0*81+i, c1*81+i, c2*81+i. These are separated by 81 elements = 324 bytes. With 32 banks of 4 bytes each, 324 % 128 = 68. So consecutive threads hit different banks - no conflicts!
Actually, let me double check. Bank = (address_in_bytes / 4) % 32.
Thread 0: address = c0*81*4 + i*4 → bank = (c0*81 + i) % 32
Thread 1: address = c1*81*4 + i*4 → bank = (c1*81 + i) % 32
If c1 = c0 + 3, then bank difference = 3*81 = 243 → 243 % 32 = 19. So thread 1 hits bank (thread0_bank + 19) % 32.
For 32 threads, if the channel assignments don't repeat banks, there's no conflict. With each thread handling 3 channels spaced by 3, and 32 threads handling channels 0..95 (but we only have 24 channels!), there would be wrapping.
Actually, with 24 channels total and 128 threads handling 3 channels each, we're assigning each channel to 128/8 = 16 threads? No, that can't be right.
16 locations × 24 channels = 384 channel instances. 128 threads × 3 channels = 384. So each channel is handled by 16 threads (one for each location).
Within a warp of 32 threads, how many distinct channels are accessed? If the assignment is interleaved, warp 0 might handle locations 0, 1, 2, 3 and channels 0..23 distributed among threads.
Actually, I should think about this more carefully. Let me define the assignment:
Block handles locations: loc0, loc1, ..., loc15.
Total channel-locations: 16 × 24 = 384.
Threads: 128, each handles 3 channels.
Thread t handles channels: (t * 3) % 24, (t * 3 + 1) % 24, (t * 3 + 2) % 24... but also for a specific location.
If thread t's channels cycle every 8 threads (since 8*3 = 24), then threads 0-7 handle channels 0-23 for some location. Threads 8-15 handle the same channels for another location, etc.
With 128 threads, we have 16 groups of 8 threads. Each group handles all 24 channels for one location. 16 groups = 16 locations. Perfect!
So warp 0 (threads 0-31): handles 4 groups = 4 locations. Each group of 8 threads handles all 24 channels for 1 location.
- Group 0 (threads 0-7): location 0, channels (0,1,2), (3,4,5), (6,7,8), (9,10,11), (12,13,14), (15,16,17), (18,19,20), (21,22,23)
- Group 1 (threads 8-15): location 1, same channel distribution
- Group 2 (threads 16-23): location 2
- Group 3 (threads 24-31): location 3
In warp 0, for a given weight index i:
- Thread 0 reads weight[0][i]
- Thread 1 reads weight[3][i]
- ...
- Thread 7 reads weight[21][i]
- Thread 8 reads weight[0][i] (for location 1)
- ...
- Thread 31 reads weight[21][i] (for location 3)
So each weight is read by 4 threads simultaneously (for 4 different locations). This is a broadcast pattern! No bank conflict - perfect!
And for input:
- All threads in groups that process the same location read the same input[i]. But groups 0, 1, 2, 3 process DIFFERENT locations, so they read different inputs.
Wait, groups within a warp process different locations, so they read from different parts of shared memory. But the input for each location is stored starting at different offsets.
If we store input for location l at offset l * 648, then:
- Warp 0 accesses input at offsets 0, 648, 1296, 1944 for the 4 groups
- Offset 648 = 162 32-bit words → 162 % 32 = 2
- So the bank for input at location l is (base + l*648/4) % 32
For l=0: bank = base
For l=1: bank = (base + 162) % 32 = (base + 2) % 32
For l=2: bank = (base + 4) % 32
For l=3: bank = (base + 6) % 32
These are all different banks! No conflict.
For l=4: bank = (base + 8) % 32... but warp 1 handles locations 4-7. So within a warp, 4 locations hit 4 different banks.
What about different threads in the same group (same location) accessing different weights? As I analyzed before, weight addresses are separated by 81 elements = 324 bytes = 81 32-bit words. 81 % 32 = 17. So consecutive threads in a group hit different banks. Across 8 threads, they hit banks base, base+17, base+2, base+19, base+4, base+21, base+6, base+23 (all mod 32). All different! No conflict.
This is AMAZING. The memory access pattern is nearly zero-conflict for shared memory!
And with 6 independent FMAs per thread (3 channels × 2 depths), we should have great instruction-level parallelism.
Let me try to write this kernel!
Block configuration:
- grid: (B, ceil(H_out / 4), ceil(W_out / 4))? No, let's keep it simple.
- Each block handles 16 (b, h, w) locations. But b, h, w have to be organized.
Actually, to keep thread organization simple, let me have each block handle 1 batch and a tile of h, w. For example, a block handles b and 16 consecutive (h, w) pairs.
With H_out=30 and W_out=30, total locations per batch = 900. Blocks per batch = 900/16 = 56.25, so 57 blocks. Total blocks = 128 * 57 = 7296. That's fewer than 115,200 but still plenty.
Actually, maybe I should handle more locations per block to reduce block count. For 128 threads and 16 locations, that's 8 threads per location as I planned. But maybe I can do 32 locations with 192 threads (6 warps)?
32 locations, 192 threads: 192/32 = 6 warps. Each location gets 192/32 = 6 threads. 6 threads for 24 channels = 4 channels per thread. That's 4 independent accumulators per depth, plus if I do 2 depths, that's 8 total. Even better!
But shared memory for 32 locations: 32 * 648 + 1944 = 22,680 floats = 90.7 KB. Blocks per SM = 227/90.7 = 2.5, so 2 blocks. 2 * 192 = 384 threads per SM. Still OK.
Alternatively, 8 locations with 96 threads (3 warps). 96/8 = 12 threads per location. 12 threads for 24 channels = 2 channels per thread. With 2 depths parallel: 4 independent accumulators. Shared memory = 8*648 + 1944 = 7128 floats = 28.5 KB. Blocks per SM = 7.9 → 7 blocks. 7 * 96 = 672 threads per SM. Better occupancy but fewer independent accumulators.
Let me pick 16 locations, 128 threads, 3 channels per thread, 2 depths parallel = 6 independent accumulators. Shared = 16*648 + 1944 = 12,312 floats = 49.2 KB. Blocks per SM = 4.6 → 4. 4*128 = 512 threads per SM. Good balance.
Wait, I need to double check: 16 locations × 648 floats = 10,368. Plus weights 1944. Total 12,312 floats. At 4 bytes each = 49,248 bytes ≈ 49.2 KB. Yes.
With 228 KB shared memory per SM (H100), max blocks = floor(228/49.2) = 4. 4 blocks × 128 threads = 512 threads. At max 2048 threads per SM, we're at 25% occupancy. But with 6 independent accumulators per thread, the high ILP might compensate.
Actually, 25% thread occupancy with high ILP can still perform well. Let me try it.
But wait, there's another constraint: each block has 128 threads, and we can have up to 32 blocks per SM regardless of shared memory. With 4 blocks, we're well under that limit.
Now, how do I assign 16 (h, w) locations to 128 threads?
I'll use a 4×4 tile of h, w per block. Block indices:
- blockIdx.x = batch index b
- blockIdx.y = tile index in h (0 to ceil(30/4)-1 = 7)
- blockIdx.z = tile index in w (0 to ceil(30/4)-1 = 7)
Within a block:
- int local_id = threadIdx.x; // 0 to 127
- int group = local_id / 8; // 0 to 15, which location in the tile
- int channel_group = local_id % 8; // 0 to 7, which channels this thread handles
Channels per thread: channel_group starts at channel_group * 3. So:
- group 0: channels 0, 1, 2
- group 1: channels 3, 4, 5
- ...
- group 7: channels 21, 22, 23
Location in tile:
- loc_h = group / 4
- loc_w = group % 4
- h_out = blockIdx.y * 4 + loc_h
- w_out = blockIdx.z * 4 + loc_w
Wait, with 16 groups of 8 threads, and group = local_id / 8:
- group 0: threads 0-7 → location (0,0) in tile
- group 1: threads 8-15 → location (0,1) in tile
- But we said threads 0-7 handle channels 0-23 for location 0.
Actually, I think it's cleaner to interleave differently:
- local_id 0-7: location 0, channels distributed
- local_id 8-15: location 1, same channels
- etc.
group = threadIdx.x % 16; // which of the 16 locations (0-15)
sub_thread = threadIdx.x / 16; // which of 8 threads per location (0-7)
Wait, there are 128 threads. 128 / 16 = 8 threads per location. Each location needs all 24 channels handled. 8 threads × 3 channels each = 24 channels. Yes!
So:
- int loc_idx = threadIdx.x % 16;
- int sub_t = threadIdx.x / 16;
- int c0 = sub_t * 3;
- int c1 = c0 + 1;
- int c2 = c0 + 2;
- int loc_h = loc_idx / 4;
- int loc_w = loc_idx % 4;
- int h0 = blockIdx.y * 4 + loc_h;
- int w0 = blockIdx.z * 4 + loc_w;
If h0 or w0 exceeds H_out or W_out, the thread should return early.
Each thread computes 3 channels for 1 location. All 16 locations are different.
Wait, but then within a warp (32 threads), we'd have:
- Threads 0, 16: loc_idx=0 and 16%16=0, sub_t=0 and 1. Both handle location 0!
- Actually: thread 0: loc_idx=0, sub_t=0
- Thread 1: loc_idx=1, sub_t=0
- ...
- Thread 15: loc_idx=15, sub_t=0
- Thread 16: loc_idx=0, sub_t=1
- Thread 17: loc_idx=1, sub_t=1
- ...
So in warp 0 (threads 0-31):
- Threads handle loc_idx = 0,1,2,...,15,0,1,2,...,15 (two copies of each location)
For a given location (say loc_idx=0), it's handled by threads 0 and 16 (different sub_t, so different channels). Threads 0 handles channels 0,1,2. Thread 16 handles channels 3,4,5.
During the inner loop, all threads read the same input for their location. But threads 0 and 16 are reading the same location's input - that's a broadcast, no conflict.
Threads 0 and 16 read different weights though. Thread 0: weights 0,1,2. Thread 16: weights 3,4,5. These might conflict.
Weight bank analysis: weight for channel c is at c*81 + offset. Thread 0 c=0: bank = offset % 32. Thread 16 c=3: bank = (3*81 + offset) % 32 = (243 + offset) % 32 = (19 + offset) % 32. Different banks, no conflict!
Threads within the same sub_t but different loc_idx (e.g., thread 0 and thread 1): thread 0 reads input at loc0's offset, thread 1 reads input at loc1's offset. If loc0 and loc1 are consecutive, their input offsets differ. For loc0 = (0,0), loc1 = (0,1) in tile, the actual h,w differ by 1. The input in shared memory is stored per location. So loc0 input starts at 0, loc1 starts at 648. Bank = (648/4 + offset) % 32 = (162 + offset) % 32 = (2 + offset) % 32. Different from thread 0's bank. No conflict!
This seems really good.
But wait, I also need to think about loading data into shared memory. We have 16 locations, each needing 648 input floats. That's 10,368 floats. We need to load them using 128 threads.
10,368 / 128 = 81 floats per thread. Perfect - each thread loads exactly 81 floats!
And the global memory access pattern: each thread reads 81 consecutive floats from global memory? Well, the input for 1 location is [3][24][3][3]. In the original tensor layout [B][C][D][H][W], for a given location (b, h, w), the input patch covers [C][D][H_patch][W_patch].
Actually, when we load input for 16 different locations into shared memory, each location's input is at a different place in global memory. But within each location's input, we want to load it contiguously.
For location (h, w), the input patch is for all c, all d, h..h+2, w..w+2. In the tensor [B, C, D, H, W], this is:
- For fixed b, c, d, h+kH, w+kW: the values are contiguous for kW=0,1,2.
- For kH, there are W elements between consecutive rows.
- For d, there are H*W elements.
- For c, there are D*H*W elements.
If we want to load all c, d, kH, kW for one location, the most efficient way is to load the entire patch. For each c and d, the 3×3 spatial patch has elements at offsets that are NOT fully contiguous (since W might be 32, and consecutive rows are offset by W, not 3).
For the reference parameters: H=32, W=32.
For a given c, d, the 3×3 patch at (h, w):
- Element (0,0): offset = ((b*C + c)*D + d)*H*W + h*W + w
- Element (0,1): offset + 1
- Element (0,2): offset + 2
- Element (1,0): offset + W
- Element (1,1): offset + W + 1
- etc.
The 9 spatial elements are NOT contiguous! They're spread across 3 rows, with gaps of W-3 = 29 elements between rows.
So we can't load them in one big memcpy. We'd need strided loads.
For loading into shared memory, each thread could load some of these scattered elements. With 128 threads and 16 locations × 648 floats = 10,368 floats, each thread loads 81 floats. But these 81 floats are scattered in global memory.
Alternatively, we could have each warp load one location's patch. A warp of 32 threads loads 648 floats. Each thread loads 648/32 = 20.25 floats. We could have each thread load a portion.
For location l with base offset base_l:
Thread t in the warp loads elements t, t+32, t+64, ... from the flattened [C][D][kH][kW] patch.
The flattened index maps to:
ci = idx / (24*3*3)
t2 = idx % (24*3*3)
d = t2 / (3*3)
kh = (t2 % 9) / 3
kw = t2 % 3
Global offset = ((b*C + ci)*D + d)*H*W + (h+kh)*W + (w+kw)
These global addresses are far apart for consecutive idx values (since they span different c and d). So loading them might not be coalesced.
Actually, consecutive idx values correspond to:
- idx 0: (c=0, d=0, kh=0, kw=0)
- idx 1: (c=0, d=0, kh=0, kw=1): offset differs by 1 (contiguous!)
- idx 2: (c=0, d=0, kh=0, kw=2): offset differs by 1
- idx 3: (c=0, d=0, kh=1, kw=0): offset differs by W-2 = 30
So consecutive idx values are somewhat contiguous for kw, but kh jumps by W. This is NOT great for coalesced loads.
For better coalescing, we could have each thread load a 3×3 spatial patch for one (c, d). A 3×3 patch has elements that are somewhat close (spans 3 rows, but within 2 columns of each other).
Actually, for a given (c, d), the 9 elements are at:
base, base+1, base+2, base+W, base+W+1, base+W+2, base+2W, base+2W+1, base+2W+2
Consecutive threads loading these would have strided access, not coalesced. But if a single thread loads all 9 elements, they might be fetched in a few cache lines.
Hmm, the input loading might be a bottleneck. But the total input data is only 151 MB for all blocks. And we're reading it once per block. With 7296 blocks per batch (128 batches? Actually B=128, and each block handles 1 b, so total blocks = 128 * 57 = 7296).
7296 blocks, each loading 16 * 648 = 10,368 input floats = 41.5 KB. Total input traffic = 7296 * 41.5 KB = 302.8 MB. Plus weights: 7296 * 7.8 KB = 56.9 MB. Total from global mem = 360 MB.
At 3.35 TB/s, reading 360 MB takes ~107 µs. So global memory loads are NOT the bottleneck. The bottleneck is the FMA computation and how well we can utilize the SMs.
OK let me just write the kernel. I'll structure it as:
1. Block handles 1 batch b, and a 4×4 tile of (h, w)
2. 128 threads
3. Each thread handles 3 channels for 1 location
4. Process 2 depths at a time (3 channels × 2 depths = 6 independent FMAs)
5. After all depths, do min for each channel
6. Then warp-level softmax
位置映射:
- blockIdx.x = b
- blockIdx.y = tile_h (0 to 7 for H_out=30, tile_size=4)
- blockIdx.z = tile_w (0 to 7)
Within block:
- threadIdx.x = 0..127
- loc_idx = threadIdx.x % 16 (0..15, which of the 16 positions in tile)
- sub_t = threadIdx.x / 16 (0..7, which channel group)
- loc_h = loc_idx / 4, loc_w = loc_idx % 4
- h_out = blockIdx.y * 4 + loc_h
- w_out = blockIdx.z * 4 + loc_w
If h_out >= 30 or w_out >= 30, early return.
c0 = sub_t * 3, c1 = c0 + 1, c2 = c0 + 2
输入加载:
Each thread loads 81 floats (648 / 8 = 81). Wait, there are 16 locations and 128 threads. Each location needs 648 floats. 128 threads loading 16 * 648 = 10,368 floats: 10,368 / 128 = 81 floats per thread.
For thread t:
- loc = t % 16 (which location to load for)
- offset_in_loc = t / 16 * 81 (which 81-element chunk within the location)
Actually, t ranges from 0 to 127. For loading, we need to map t to a (location, offset) pair.
Each location's 648 floats are laid out as [C_in][D_in][kH][kW] = [3][24][3][3].
For simplicity, I'll have each thread load 81 consecutive floats from the flattened layout of one location.
For thread t:
- loc = t % 16
- base_idx = (t / 16) * 81
- For i = 0 to 80:
- global_flat_idx = loc * 648 + base_idx + i
- Convert global_flat_idx to original tensor indices
- Load from global memory
Hmm, converting flat_idx back to tensor indices involves integer division/modulo, which is expensive.
Alternative: each warp (32 threads) loads one location. Warp w (0, 1, 2, 3) handles locations that need loading. Wait, there are 16 locations and 4 warps. That's 4 locations per warp.
Within a warp, loading 4 * 648 = 2592 floats. 2592 / 32 = 81 floats per thread. Each thread loads 81 floats scattered in global memory.
This is similar to before. But since 81 * 4 bytes = 324 bytes per thread, and cache line is 128 bytes, each thread touches about 3 cache lines. With 32 threads in a warp, that's 96 cache lines. L1 cache might not be able to service all of these in parallel. But since we're not loading from the same cache line, at least there's no conflict.
I'm overthinking the loading. Let me just write a simple loading loop and see if the kernel works. The loading is a small fraction of total time.
Wait, actually, I realized I can simplify things. Let me NOT pre-load all input into shared memory. Instead, I'll compute everything directly from global memory using tiling over (c, d, kh, kw) but with vectorized loads and instruction-level parallelism.
For each batch-location, I need to compute 24 channels, each with 22 depths, each depth using a 3×3×3 input patch.
What if I process the computation in tiles over the spatial dimensions, keeping weights in shared memory but reading input directly from global memory?
Actually, H100 has large L1 cache. If I process all channels and depths for one location without writing intermediate results, the input for that location might stay in L1 cache.
Let me try a much simpler kernel: each block = 32 threads, 1 warp. Each warp handles 1 location. Within warp, 24 threads handle 1 channel each (computing all 22 depths), 8 threads idle.
But now, instead of loading all input into shared memory, each thread loads input on demand from global memory.
For thread c handling channel c:
```cuda
float min_val = 1e30f;
for (int d = 0; d < 22; d++) {
float conv_val = bias[c];
for (int ci = 0; ci < 3; ci++) {
for (int kd = 0; kd < 3; kd++) {
int d_in = d + kd;
for (int kh = 0; kh < 3; kh++) {
int h_in = h + kh;
for (int kw = 0; kw < 3; kw++) {
int w_in = w + kw;
float in = input[((b*3 + ci)*24 + d_in)*32*32 + h_in*32 + w_in];
float w = weight[((c*3 + ci)*3 + kd)*3*3 + kh*3 + kw];
conv_val += in * w;
}
}
}
}
min_val = fminf(min_val, conv_val);
}
```
Wait, with all 24 threads executing this and reading their own channel's weights, plus the same input elements for a given (ci, kd, kh, kw), the global memory access pattern for input is:
- All 24 threads read the SAME input element simultaneously (for a given ci, kd, kh, kw, d).
- This is a broadcast! L1 cache handles this beautifully.
- For weights, each thread reads a different weight. The weights are at consecutive offsets for consecutive channels. 24 threads reading 24 consecutive weights = 96 bytes = one cache line? Almost. 24 floats = 96 bytes. Two cache lines of 128 bytes each? Actually 24*4=96 bytes, which fits in 1 cache line (128 bytes). So ONE cache line fetches all weights for one (ci, kd, kh, kw) iteration!
This is BRILLIANT if we read directly from global memory for weights! The weights are perfectly coalesced.
And because all threads read the same input element, the L1 cache can broadcast it.
The catch is: we need to iterate over 22 depths × 3 ci × 3 kd × 3 kh × 3 kw = 1782 iterations. Each iteration does:
1. Read 1 input element (broadcast, cached)
2. Read 24 weight elements (coalesced, consecutive)
3. 24 FMAs
4. 1 comparison per thread for min (every 81 iterations)
Wait, the innermost loop is over kw, and the min is only at the end of each d iteration. So 81 FMAs per depth before a min.
Instruction flow per d:
- 81 iterations of 24 FMAs + some loads
- 1 fminf
Within each iteration:
- Load input (broadcast from L1)
- Load weights (coalesced)
- 24 FMAs (1 per thread)
The FMAs are still dependent within each thread's accumulator. But now the iteration loop itself provides some instruction-level parallelism because:
- The load for iteration i+1 can start while iteration i's FMA is in flight? No, because the FMA result is needed for the next FMA.
Wait, unless the compiler unrolls the loop. With 81 iterations, the compiler might unroll 2-4 iterations, creating 2-4 independent FMA chains.
Actually, in the inner loop, the only dependency is `conv_val += ...`. The compiler can unroll and interleave:
```cuda
// Unrolled by 2
float conv_val_a = 0, conv_val_b = 0;
for (int i = 0; i < 80; i += 2) {
conv_val_a += input[i] * weights[i];
conv_val_b += input[i+1] * weights[i+1];
}
conv_val = conv_val_a + conv_val_b;
```
This creates 2 independent chains. With more unrolling, more parallelism.
But the compiler's unrolling decisions depend on optimization flags. With -O3 and __restrict__, it should unroll small loops.
Actually, since ci, kd, kh, kw are all compile-time constants (3 each), the compiler CAN fully unroll the 81-iteration loop. If fully unrolled, there are 81 FMAs in a row. The compiler can schedule them to maximize ILP, but with only 1 accumulator, there's inherent dependency.
However, for consecutive channels c and c+1, their conv computations are completely independent. If warp 0 handles channels 0-23, all threads in the warp are doing independent computations. The warp scheduler can only issue 1 instruction per cycle, but it issues to all 32 threads simultaneously.
The key question: during the cycles when the FMA from cycle 0 is in the pipeline, can the warp issue other instructions?
Yes! The warp can issue loads and other independent instructions. But since all threads in the warp are executing the same FMA instruction, they ALL wait for their respective FMA results. The warp can't issue the NEXT FMA for any thread until the previous one completes.
But, can the warp context-switch to another warp? Yes! The GPU can schedule another warp to execute on the same SM. With many warps per SM, this can hide latency.
With 115,200 blocks, each with 1 warp: 115,200 warps total. H100 has 132 SMs. 115,200 / 132 ≈ 873 warps per SM. But each SM can only hold ~64 warps at a time. So we have 873 / 64 = 13.6 waves of warps per SM.
During wave 1, 64 warps are executing. Each warp issues an FMA, then stalls for 4 cycles. The scheduler switches to other warps. With 64 warps and 4-cycle latency, we have 64 / 4 = 16 warps on average that can issue in a given cycle. Each warp issues 24 FMAs (since 8 threads are idle). So 16 * 24 = 384 FMAs per cycle.
H100 can execute 128 FMAs per cycle per SM (assuming 1 FMA per thread per cycle, and 128 FP32 units). Actually, let me check this again.
If H100 can execute 128 FP32 ops per cycle per SM, and each FMA is 2 ops (multiply + add), then 64 FMAs per cycle per SM.
With 384 FMAs "requested" per cycle from 16 warps, but the SM can only execute 64 FMAs per cycle, we'd need 384/64 = 6 SMs... but we're analyzing 1 SM.
Wait, I'm confusing myself. Let me be more precise.
Each SM has a certain number of execution units. For H100, the SM can execute:
- Up to 128 FP32 FMAs per cycle (source: H100 whitepaper says 128 FP32/INT32 ops per clock per SM)
So per cycle, the SM can do 128 FMAs.
Each warp has 24 active threads doing 1 FMA = 24 FMAs per instruction. If the warp issues 1 instruction per 4 cycles (due to FMA latency), then the warp's throughput is 24/4 = 6 FMAs per cycle.
To saturate 128 FMAs per cycle, we need 128/6 ≈ 21.3 warps. With 64 warps on the SM, we're well above this. So FMA throughput should be saturated!
Why did the original kernel only achieve 1.1 TFLOPS then?
Let me reconsider. In the original kernel, each block had 32 threads and did much more work with shared memory loads. The occupancy might have been the bottleneck.
With 115,200 blocks of 32 threads = 3,686,400 threads total. H100 has 132 SMs with 2048 threads each = 270,336 threads max concurrent. That's 13.6 waves. Wave 1 runs 270,336 threads. After 4 cycles, the first warps are ready for their next FMA. The scheduler might pick them up or continue with other warps. With 64 warps per SM, that's 64 * 32 = 2048 threads.
But in my original kernel, there were memory loads in the inner loop from shared memory. Shared memory has ~20 cycle latency. If a warp does:
1. Load from shared mem (request issued, warp stalls for ~20 cycles)
2. FMA (depends on load result, additional 4 cycles)
The total stall per iteration might be 20+ cycles. With 64 warps on the SM, and each warp stalling for 24 cycles, the scheduler can keep other warps busy. 64 warps / 24 ≈ 2.7 warps ready per cycle. 2.7 * 32 = 86 FMAs per cycle. That's less than 128, so we're not saturating FMA units. But 86 FMAs per cycle at 2 GHz = 172 GFLOPS per SM. 132 SMs = 22.7 TFLOPS. Still way more than 1.1 TFLOPS.
So why was the original kernel so slow?
OH WAIT. I think I found the issue. In my original kernel, the shared memory loads might have bank conflicts. Let me re-analyze.
Original kernel inner loop:
```cuda
#pragma unroll
for (int c_in = 0; c_in < C_in; c_in++) {
#pragma unroll
for (int kd = 0; kd < 3; kd++) {
#pragma unroll
for (int kh = 0; kh < 3; kh++) {
#pragma unroll
for (int kw = 0; kw < 3; kw++) {
int d_in = d_out + kd;
int s_idx = ((c_in * 24 + d_in) * 3 + kh) * 3 + kw;
int w_idx = (((c * C_in + c_in) * 3 + kd) * 3 + kh) * 3 + kw;
conv_val += s_input[s_idx] * weight[w_idx]; // weight from global mem!
}
}
}
}
```
Wait a second! In the original kernel, I read `weight[w_idx]` from GLOBAL memory, not shared memory! That means every iteration of the inner loop was doing a global memory load for the weight!
With 81 iterations per depth, 22 depths, and 24 channels, each thread does 81 * 22 = 1782 global weight loads. And all threads in a warp load DIFFERENT weights for DIFFERENT channels. The weights CAN be coalesced for some patterns, but let me check.
Thread c (for channel c) at iteration i loads weight[w_idx] where w_idx depends on c. For consecutive c values:
- w_idx(c=0) = some base for this (c_in, kd, kh, kw)
- w_idx(c=1) = w_idx(c=0) + 3*3*3 = w_idx(c=0) + 27
So consecutive threads load weights at offsets differing by 27 floats = 108 bytes. With 128-byte cache lines, threads 0 and 1 might be in different cache lines. And threads 0-3 would span 81 floats = 324 bytes = 3 cache lines.
With 32 threads, the total span is 31 * 108 = 3348 bytes = 27 cache lines. That's a LOT of cache lines to fetch for one warp instruction. The L1 cache can service multiple cache lines per cycle, but probably not 27.
No wait, the weight layout is [C_out][C_in][kD][kH][kW]. For a fixed (c_in, kd, kh, kw), weights for different c are consecutive because C_out is the outer dimension.
Actually, let me verify. The weight tensor shape is [24, 3, 3, 3, 3]. The stride for dimension 0 (C_out) is 3*3*3*3 = 81. So weight[c][c_in][kd][kh][kw] is at offset c*81 + c_in*27 + kd*9 + kh*3 + kw.
For a fixed (c_in, kd, kh, kw), all c values have offsets that differ by multiples of 81. So thread c loads at offset base + c*81. These are NOT consecutive in memory! They're 81 floats apart.
For thread 0: offset = base
For thread 1: offset = base + 81
For thread 2: offset = base + 162
81 floats = 324 bytes. With 128-byte cache lines, each thread's weight is in a different cache line (324 / 128 = 2.5 cache lines apart). So 32 threads load from 32 different cache lines!
This is the WORST possible coalescing pattern. The L1 cache can service maybe 4-8 cache lines per cycle. So it takes ~4-8 cycles just to fetch all the weights. Then the FMA can execute.
This explains why the original kernel was SO SLOW! It was completely bottlenecked by global memory weight loads.
In the second kernel, I added shared memory for weights:
```cuda
// Load weights
for (int i = tid; i < wSize; i += blockDim.x) {
s_weights[i] = weight[i];
}
```
Then in the inner loop, I read from `s_weights`. But I still had the same indexing pattern. The weights in shared memory are at offsets differing by 81 floats = 324 bytes. With 32 banks of 4 bytes, 324 / 4 = 81. Bank 81 % 32 = 17. So consecutive threads hit banks 17 apart. For 32 threads, we'd hit 32 different banks (since gcd(81, 32) = 1). No bank conflicts!
So the second kernel should have been MUCH faster... but it was about the same speed.
Wait, maybe because I used 32 threads per block and 115,200 blocks, the occupancy was low, or the shared memory loads dominated.
Actually, with shared memory, the load latency is ~20-30 cycles. Then FMA is 4 cycles. So each iteration takes ~30 cycles. With 1782 iterations and 2.4 GHz clock, that's 1782 * 30 / 2.4e9 = 22.3 µs per thread. With 115,200 blocks and 32 threads each, there are 3,686,400 thread instances. At 132 SMs and completion rate of 1 thread per 22.3 µs (with perfect scheduling)... hmm.
Actually, each SM can execute many threads concurrently. With 64 warps per SM and 32 threads per warp, 2048 threads per SM. If each thread takes 22.3 µs, and 2048 threads run in parallel, the SM processes 2048 threads in ~22.3 µs. Total threads 3,686,400. Waves = 3,686,400 / (132 * 2048) = 13.6 waves. Total time = 13.6 * 22.3 µs = 303 µs = 0.303 ms.
But the kernel actually took 4.48 ms. So something else is wrong.
Oh! I bet the issue is that the grid dimensions are (B=128, H_out=30, W_out=30), which is 115,200 blocks. But H100 has 132 SMs. If each block takes 22.3 µs, and each SM can run at most 32 blocks at a time (due to warp scheduling limits), then max concurrent blocks = 32 * 132 = 4224. Blocks take 22.3 µs each, so block throughput is 4224 / 22.3µs ≈ 189 million blocks/second. For 115,200 blocks, time = 115,200 / 189e6 = 609 µs = 0.61 ms. Still less than 4.48 ms.
Wait, the 22.3 µs per thread assumed each iteration takes 30 cycles. But how many cycles per iteration actually?
Let me look at the inner loop again with shared memory:
```cuda
for (int c_in = 0; c_in < 3; c_in++) {
for (int kd = 0; kd < 3; kd++) {
int d_in = d_out + kd;
for (int kh = 0; kh < 3; kh++) {
for (int kw = 0; kw < 3; kw++) {
int s_idx = ((c_in * 24 + d_in) * 3 + kh) * 3 + kw;
conv_val += s_input[s_idx] * s_weights[w_idx++];
}
}
}
}
```
Wait, I'm computing `s_idx` with multiple arithmetic operations per iteration. This adds overhead.
And I count 3 iterations of c_in, 3 of kd, 3 of kh, 3 of kw = 81 iterations. In each: 1 shared load for input, 1 shared load for weight, 1 FMA, plus index arithmetic.
So per iteration:
- 2 shared loads
- 1 FMA
- Several integer ops (index computation, loop counter)
If the processor can dual-issue, maybe the integer ops can be scheduled alongside the FMA. But the 2 shared loads and 1 FMA still take multiple cycles.
Shared memory load: typically takes 20-30 cycles for latency, but the next instruction can issue after 1 cycle if there's no dependency. The FMA depends on the load result, so it must wait ~20-30 cycles. Then the next iteration's load also depends on the FMA? No, the next iteration's load is independent of the FMA result (it depends on loop counter).
Wait, actually:
1. Load s_input[s_idx] (cycle 0, result available cycle ~24)
2. Load s_weights[w_idx] (cycle 1, result available cycle ~25)
3. FMA (cycle 25, depends on both loads)
4. Next load s_input (cycle 26 - but this can start earlier since it doesn't depend on FMA!)
Actually, because s_idx and w_idx for the NEXT iteration are computed from loop counters (known early), the next loads can start as soon as address generation is done. Load address generation takes ~4 cycles. So:
Cycle 0: compute s_idx0
Cycle 1: issue load s_input0
Cycle 4: compute w_idx0
Cycle 5: issue load s_weights0
Cycle 24: load s_input0 returns
Cycle 25: load s_weights0 returns
Cycle 26: issue FMA0
Cycle 30: FMA0 completes
Meanwhile:
Cycle 4: compute s_idx1
Cycle 5: issue load s_input1
Cycle 8: compute w_idx1
Cycle 9: issue load s_weights1
Cycle 28: load s_input1 returns
Cycle 29: load s_weights1 returns
Cycle 30: FMA1 can issue... but FMA unit is busy with FMA0? No, FMA0 completed at cycle 30. So FMA1 issues at cycle 31.
Hmm, if the loads are pipelined, a new FMA can issue roughly every 5 cycles (since FMA takes 4 cycles and loads take ~24 cycles but overlap). So throughput might be 1 FMA per 5 cycles.
81 FMAs * 5 cycles = 405 cycles per depth. 22 depths = 8910 cycles. At 2 GHz, that's 4.45 µs per thread. Per block (32 threads), still ~4.45 µs. 115,200 blocks at 4224 concurrent = 27.3 waves. Total = 27.3 * 4.45 µs = 121 µs.
This is still 0.12 ms, way less than 4.48 ms. So something else is fundamentally wrong.
Oh! I think I know what might be wrong. The CUDA compiler might not be generating the instructions I expect. Let me look at the PTX or SASS. Since I can't, let me try to write cleaner code.
Or maybe the problem is that with 115,200 blocks, the kernel launch overhead itself is significant? No, kernel launch overhead is only a few microseconds.
Maybe my mental model is wrong because the blocks don't actually execute in perfectly overlapping waves. Some SMs might be slower due to occupancy limits.
Wait, actually, how many warps can an H100 SM hold? It's 64 warps per SM = 2048 threads. With 32 threads per block, that's 64 blocks per SM. But shared memory limits us.
In the second kernel: shared memory per block = weights 1944 + input 648 + mins 100 = 2692 floats = 10,768 bytes ≈ 10.6 KB. Max blocks per SM = 228 KB / 10.6 KB = 21.5, so 21 blocks. 21 blocks * 32 threads = 672 threads per SM. That's not terrible.
At 672 threads per SM and 132 SMs = 88,704 threads concurrent. Total thread instances = 115,200 * 32 = 3,686,400. Waves = 3,686,400 / 88,704 ≈ 41.6 waves.
If each block takes 4.45 µs (from my calculation above), but actually, each thread takes 4.45 µs and threads in a block run together. So each wave of 88,704 threads takes ~4.45 µs. Total time = 41.6 * 4.45 µs = 185 µs = 0.185 ms.
Still nowhere near 4.48 ms.
I'm clearly missing something fundamental about GPU execution. Let me reconsider.
Ah! I think the issue might be that my estimate of cycles per iteration is way too low. In reality:
- The kernel has many nested loops with pragma unroll, but the compiler might not unroll everything.
- With --use_fast_math, some operations are faster, but the structure might still be inefficient.
- The shared memory layout might have bank conflicts I didn't detect.
- Registers might be spilling.
Actually, let me look at the kernel more carefully. I had:
```cuda
for (int c = tid; c < C_out; c += blockDim.x) {
float min_val = 1e30f;
for (int d_out = 0; d_out < D_out; d_out++) {
float conv_val = bias[c];
int w_idx = c * C_in * kDHW;
for (int c_in = 0; c_in < C_in; c_in++) {
for (int kd = 0; kd < 3; kd++) {
int d_in = d_out + kd;
for (int kh = 0; kh < 3; kh++) {
for (int kw = 0; kw < 3; kw++) {
int s_idx = ((c_in * D_in + d_in) * 3 + kh) * 3 + kw;
// w_idx is auto-incremented
conv_val += s_input[s_idx] * s_weights[w_idx++];
}
}
}
}
min_val = fminf(min_val, conv_val);
}
s_mins[c] = min_val;
}
```
Wait, `w_idx++` in the innermost loop! This means each thread is incrementing a register for every iteration. That's fine, it's just an integer add. But the `s_idx` computation involves multiplication as well.
The loop is over c_in (3), kd (3), kh (3), kw (3). If unrolled by the compiler, the `s_idx` computation for each unrolled body might be constant-folded. But `d_in = d_out + kd` depends on `d_out`, which is a loop variable. So for each `d_out`, the 27 inner iterations have 9 different `s_idx` values (since d_in changes with kd). These still need to be computed.
So per depth, 81 iterations with:
- 2 shared memory loads
- 1 FMA
- Address arithmetic
With a 3-level manual unroll, maybe the compiler can do better.
Actually, I think my analysis might be confused because I'm not accounting for how warp execution actually works in the presence of memory divergence, register pressure, etc. Let me just try a completely different kernel approach.
How about: use Tensor Cores or at least a block-level GEMM!
For each (b, h, w), I need to compute:
- Weight matrix: C_out × (C_in * kD * kH * kW) = 24 × 81
- For 22 depths, I need 22 input vectors of size 81
- Result: 22 vectors of size 24
This is equivalent to: for each depth d, compute weight_matrix @ input_vector_d, giving output_vector_d of size 24.
I can use a warp-level GEMM approach. Each warp computes 1 depth for 1 location. But we have 22 depths, so 22 warps per location? That's a lot.
Alternatively, use CUTLASS-style tiling for a small GEMM.
Actually, for 24×81, a simple approach is:
- Each warp computes 8 output elements (channels)
- The 81 input elements are loaded by the warp and kept in registers
- Each thread accumulates partial sums
For 32 threads in a warp:
- To compute 24 channels, 32 threads is more than enough.
- Each thread could compute 1 channel (with some threads idle).
Or, for better FMA utilization:
- Warp computes all 24 channels for 1 depth
- 24 threads each do dot product of 81 elements
- 8 threads assist with loading or are idle
- Use warp shuffle for reduction if the dot product is split among threads
Wait, the dot product is 81 elements per channel. 24 threads each doing 81 FMAs for one channel. This is the same as before. But now, instead of each thread loading its own elements, the warp cooperates.
For a given depth d:
- The input vector has 81 elements
- Thread t loads element t, t+24, t+48, etc. from the input vector
- Does partial dot product with corresponding weight elements
- Warp shuffle reduce to get final dot product
Wait, for 24 threads and 81 elements, each thread computes partial sum of ~3-4 elements (81/24 ≈ 3.4). Then warp shuffle to sum across threads.
This is a very small dot product. The warp shuffle overhead might not be worth it.
Actually, I think the fundamental problem is that the conv3d is just too small to extract efficient performance from a manual CUDA kernel. The cudnn library has specialized algorithms for exactly these sizes.
Let me revisit the hybrid approach with fresh eyes.
Hybrid approach: use cudnn for conv, then custom fused min+softmax.
- Conv: self.conv(x) - this calls cudnn which might use winograd or im2col+gemm
- Fused kernel: reads conv output, computes min over depth, then softmax
The question is: how fast is the reference conv, and how fast can we make the min+softmax?
If the reference conv is 2.5 ms, and our fused min+softmax is 0.1 ms, total is 2.6 ms. That's 14% faster than 2.97 ms.
But is the reference conv actually 2.5 ms? Let me think. The reference includes all three kernel launches. If conv is ~2.0 ms, min is 0.3 ms, softmax is 0.67 ms, then fusing min+softmax saves ~1.0 ms of overhead, bringing total to ~2.0 ms. That'd be 1.5x speedup!
Why would min+softmax take 0.97 ms in the reference but only 0.1 ms in our kernel? Because the reference uses two separate PyTorch operations, each with their own kernel launch overhead, memory allocation, and suboptimal kernels. Whereas our fused kernel does both in one pass.
Actually, for small tensors, kernel launch overhead can be significant. Min over dim=2 on [128, 24, 22, 30, 30] requires:
1. Launch reduction kernel for min
2. Tensor allocation for output [128, 24, 30, 30]
Softmax on [128, 24, 30, 30] requires:
1. Allocate max/sum temporaries
2. Launch multiple kernels for max, exp, sum, divide
So there's definitely overhead from multiple kernel launches and allocations.
If we fuse them, we eliminate multiple launches and get better memory locality. The conv output is read once (from L1 cache if we're lucky) and the final result is written once.
Let me try this approach.
For the fused kernel, I'll process one (b, h, w) per warp:
- 32 threads
- Threads 0-23 each compute min for 1 channel
- Threads 24-31 participate in softmax or are idle
Wait, can I process multiple (b, h, w) per warp to reduce idle threads?
For 32 threads:
- 2 locations × 12 channels (out of 24). 12 threads per location. Doesn't work because 24 < 32.
- 4 locations × 8 channels = 32 threads. But we need all 24 channels for softmax. Each location's softmax requires all 24 channels in one warp.
- 1 location × 24 channels + 8 idle threads = 32 threads. Softmax is within one warp. This is the cleanest.
With 1 location per warp, the grid is 115,200 warps. Total threads = 115,200 * 32 = 3,686,400. At 88,704 concurrent threads (from earlier), that's 41.5 waves. If each wave takes, say, 2 µs (very rough estimate for reading 528 floats and computing min+softmax), total time = 83 µs. That's 0.083 ms. Very fast!
So the min+softmax is negligible, and the total time is just the conv time plus ~0.1 ms.
If the reference conv is 2.5 ms and min+softmax is 0.47 ms, but our approach has conv 2.5 ms + min+softmax 0.1 ms = 2.6 ms, we save ~0.37 ms or ~12%.
Hmm. Is conv() really 2.5 ms? In the reference, the total is 2.97 ms. If PyTorch cudnn is well optimized, the conv IS the dominant part, and min+softmax are relatively quick. So the speedup might be small.
But wait, let me think about whether there are cases where cudnn conv is not as optimized for these sizes. 3D conv with tiny spatial dimensions and channels might not be cudnn's sweet spot.
Actually, let me take a different approach entirely. Instead of trying to write a faster conv kernel, let me use PyTorch's conv but transform the problem to use more efficient primitives.
For example:
- Use `torch.nn.functional.conv3d` with `groups` parameter? No.
- Transpose memory to channels_last format? This can help with some cuDNN algorithms.
Or, use a clever math trick. Since we're doing min over depth, can we exploit separability?
A 3D conv with kernel 3×3×3 is NOT separable in general, but if the weights have some structure (e.g., product of 1D filters), it could be. But the weights are learned and arbitrary.
What if I use the fact that depth dimension (D=24) is small, and I can unroll it explicitly in a custom kernel as I tried, but with much better vectorization?
Another idea: use warp matrix multiply (WMMA) for the dot products!
For each output depth and location, the conv is a GEMV: weight_matrix [24 × 81] × input_vector [81] → output_vector [24].
WMMA on H100 requires specific tile sizes. For fp32, WMMA might not be available or might have limitations. H100 supports wmma::mma_sync for various configurations, but fp32 accumulation with fp16 inputs is common. Since our data is fp32, we'd need fp32 wmma which might not exist or be efficient.
Actually, let me look at this from the cuBLAS perspective. The operation for each (b, h, w) is:
- For each depth d: output_d = W @ input_d
Where W is 24×81 and input_d is a 81-element vector.
This is a batched GEMV: 128*30*30*22 = 2,534,400 GEMVs of size (24, 81).
cuBLAS batched GEMV might be efficient for this. But calling cuBLAS from a custom kernel is complex. And the min over depth complicates things.
Wait, I can compute ALL depths' GEMVs and store them, then do min and softmax as separate operations. But that's what the reference does (roughly). The difference is that cudnn does the conv as one operation.
Hmm. Let me think about the reference time again. 2.97 ms for 128 batch, 24 out channels, 22×30×30 spatial, 3×3×3 kernel.
If I can get 2.0 ms, that's 1.5x speedup. What if I use PyTorch's conv3d but with explicit channel tiling?
Actually, I just realized: what if I use a grouped convolution or some other PyTorch primitive that's faster for this size? Or what if I change the memory format?
Let me check: if I use `channels_last_3d`, does it help?
In PyTorch, for 3D conv, `channels_last_3d` means NCDHW becomes NDHWC? Actually, PyTorch 3D channels_last format is not as mature as 2D.
Hmm, what if I convert the 3D conv into a sequence of 2D convs + pointwise operations? A 3D conv can be seen as:
1. Convolve along depth (1×1×3 kernel) to get intermediate
2. Convolve along height×width (1×3×3 kernel) on the result
But this is only correct if the 3D kernel is separable. For arbitrary 3×3×3 kernels, it's NOT the same.
Wait, a 3D conv with kernel [c_out, c_in, 3, 3, 3] can be written as:
For each (kd, kh, kw):
weighted_input[c_in, d+kd, h+kh, w+kw] * weight[c_out, c_in, kd, kh, kw]
This is a sum over 3×3×3 windows. It's NOT separable.
But I can rewrite it as: for each c_out, it's a 3D cross-correlation of the input with a 3D filter. No simplification.
Let me try one more approach: a well-tiled manual kernel with the optimizations I discussed, but WITHOUT the complex block layout. Just use 1 thread = 1 channel-location, and try to maximize occupancy.
Kernel design:
- Each block = 96 threads (3 warps)
- Each block handles 4 (b, h, w) locations
- Each location gets 24 threads = 1 per channel
- 96 threads / 4 locations = 24 threads per location
Within each location's 24 threads:
- Each thread computes 1 channel, all 22 depths
- 6 independent FMA streams by grouping 2 depths... no, 24 threads is not easily divisible.
Wait, how about:
- Each block = 64 threads (2 warps)
- Handles 1 (b, h, w) location
- 24 threads handle 1 channel each
- 8 threads help with loading or are idle for min+softmax
No, 64 threads for 1 location with only 24 active threads is a lot of waste.
What if:
- Each block = 64 threads
- Handles 2 (b, h, w) locations
- Each location gets 32 threads
- Of the 32 threads, 24 compute channels and 8 are idle
Still 25% waste.
What if:
- Each block = 64 threads
- Handles 8 (b, h, w) locations
- Each location gets 8 threads
- 8 threads compute 3 channels each (24/8 = 3)
8 threads = 0.25 warp. A warp of 32 threads handles 4 locations (8 threads each). Within the warp, 4 locations are being processed. But after computing mins, each location needs its own softmax. A single warp can't easily do 4 independent softmaxs simultaneously because the warp-level reduce works on all 32 threads.
Actually, a warp can do 4 independent softmaxs if they're interleaved correctly:
- Thread 0: location 0, channel 0
- Thread 1: location 0, channel 3
- Thread 2: location 0, channel 6
- ...
- Thread 7: location 0, channel 21
- Thread 8: location 1, channel 0
- ...
- Thread 31: location 3, channel 21
For warp-level max reduction, all 32 threads participate. But threads from different locations would be mixed together, so the reduce would give garbage.
We'd need to use masks for independent reductions. `__shfl_down_sync` with masks can do this, but it's tricky. We'd need 4 separate reductions, one for each location.
Using masks:
- Mask for location 0: threads 0-7, 8 active? No, 8 threads for 24 channels means the 8 threads are not contiguous.
Wait, if 8 threads handle 3 channels each, then:
- Thread 0: channels 0, 1, 2
- Thread 1: channels 3, 4, 5
- Thread 7: channels 21, 22, 23
For warp-level reduce, each thread only has 1 min value (the min across its 3 channels? No, each channel has its own min).
Actually, if thread 0 computes 3 channels, it has 3 min values in registers. If thread 1 also has 3 min values, etc. The 24 min values for location 0 are spread across threads 0-7 (3 values each).
Then for softmax, we need to do max over 24 values that are spread across threads 0-7. With only 8 threads, warp-level reduce over 32 threads would include threads from other locations. We'd need to either:
1. Use warp shuffles with mask = 0xFF (threads 0-7) - but shuffle needs all threads in the warp to participate for some operations.
2. Or use shared memory.
If we use shared memory, each thread writes its 3 min values. Then we synchronize and do the softmax in shared memory.
OK, let me take a step back and write the simplest possible fused_all kernel (conv+min+softmax) with a cleaner loop structure and see if it helps.
Actually, you know what, let me try the most brute-force optimization: completely unroll the 3×3×3 loop and let the compiler optimize it.
Since C_in=3, kD=3, kH=3, kW=3, the total is 81 iterations. With `#pragma unroll`, the compiler should unroll this. Then the body of the unrolled loop is just:
conv_val += s_input[idx_i] * s_weights[w_idx_i];
Where idx_i and w_idx_i are compile-time constants (for a fixed d_out, since d_in = d_out + kd).
Actually, the indices depend on d_out and the loop variables. But for a fixed d_out, the inner 81 iterations have fixed indices if the compiler unrolls everything. Since kd, kh, kw, and c_in are all loop constants of size 3, the compiler CAN fully unroll.
But wait, this might explode the instruction cache. 81 unrolled iterations each with 1-2 loads and 1 FMA. The generated code might be large.
Alternatively, the compiler might keep a loop but with fewer levels.
Let me just try writing the kernel with explicit flattening:
```cuda
for (int d_out = 0; d_out < 22; d_out++) {
float conv_val = bias[c];
for (int i = 0; i < 81; i++) {
int ci = i / 27;
int kd = (i / 9) % 3;
int kh = (i / 3) % 3;
int kw = i % 3;
int d_in = d_out + kd;
int s_idx = ((ci * 24 + d_in) * 3 + kh) * 3 + kw;
conv_val += s_input[s_idx] * s_weights[c * 81 + i];
}
min_val = fminf(min_val, conv_val);
}
```
With `#pragma unroll` on the 81-iteration loop and -O3, hopefully the compiler optimizes the indexing and instruction scheduling.
Actually, let me think about whether I can compute multiple channels in one thread to increase ILP, using vectorized operations.
What if I use float4 loads for weights? Weights for 4 consecutive channels (at the same filter position) are at offsets c*81+i, (c+1)*81+i, (c+2)*81+i, (c+3)*81+i. These are not consecutive in memory! So float4 won't work directly.
Unless I transpose the weight tensor so that for each filter position, weights for all channels are consecutive. Like [C_in][kD][kH][kW][C_out].
If weights are stored as [3][3][3][3][24], then for a given (ci, kd, kh, kw), the 24 channel weights are consecutive. This is a much better layout!
I can transpose the weights in the constructor and store them in this format. Then in the kernel, for a given input element and filter position, I load 24 weights as 6 float4s or similar.
And for the convolution, each thread can process 1 channel, loading weights at stride 1? No, each channel's weight is at a different offset.
Wait, if weights are [ci][kd][kh][kw][c_out], then for a given (ci, kd, kh, kw):
- weight[ci][kd][kh][kw] is a 24-element array
- Thread c loads weight[ci][kd][kh][kw][c]
The 24 elements are consecutive. But thread c loads at offset c from the base. This is NOT coalesced for float4 because thread 0 would load [0,1,2,3], thread 4 would load [4,5,6,7], etc. Actually, for float4 loads:
- Thread 0 loads float4 at offset 0: elements 0,1,2,3
- Thread 1 can't also load float4 without overlap.
For standard loads: thread c loads element c. Consecutive threads load consecutive elements. This IS coalesced! A single 128-byte transaction can serve threads 0-31 loading 32 consecutive floats.
So if I transpose weights to [ci][kd][kh][kw][c_out], thread c loading weight[c] for a fixed (ci, kd, kh, kw) is perfectly coalesced!
And for the input: all threads load the SAME input element (for fixed ci, kd, kh, kw, d_out). This is a broadcast, also efficient.
This is a huge improvement over the original weight layout where threads loaded weights 81 floats apart (terrible coalescing).
So if I:
1. Transpose weights in Python to [C_in][kD][kH][kW][C_out] format
2. In the kernel, load input (broadcast) and weights (coalesced) directly from global memory
3. Each thread accumulates conv_val for 1 channel
4. Process 22 depths, keeping running min
Then for each depth and each of the 81 filter positions:
- 1 broadcast input load
- 1 coalesced weight load (32 consecutive floats for 32 threads)
- 32 FMAs
Now, 32 FMAs per warp per iteration, with the memory pattern being optimal!
But still, the FMA dependency chain means only 1 FMA per thread per 4 cycles. With 32 threads in a warp, that's 32 FMAs per 4 cycles = 8 FMAs per cycle.
If the SM can do 128 FMAs per cycle, we'd need 128/8 = 16 warps to saturate. With 64 warps per SM, we have plenty.
But what if I process multiple depths or multiple (h, w) locations per thread to increase ILP?
Actually, with this new weight layout, I could try having each block = 1 warp = 32 threads, each warp handles 1 (b, h, w), and within the warp, 24 threads handle 1 channel each. The remaining 8 threads could handle additional work.
What if I have 24 threads handle channels 0-23, and the 8 extra threads help by processing some depths in parallel or handling additional locations? It's tricky to coordinate.
Let me just try the transposed weight approach with direct global memory loads (no shared memory for weights) and see if it's much faster. For this, I need to store input in shared memory still? Actually, for a given (b, h, w), the 81-element input patches for different depths overlap. If I process each depth independently and load input on demand from global memory:
For depth d_out and filter position (ci, kd, kh, kw):
- All threads in warp load input[b, ci, d_out+kd, h+kh, w+kw]
- This is a broadcast load
The total input reads across all 22 depths and 81 positions = 1782 elements = 7.1 KB per block. At 3.35 TB/s, this is negligible.
So I can load input directly from global memory too! No shared memory needed at all!
This simplifies the kernel massively:
- Block = 1 warp = 32 threads
- Grid = 115,200 blocks (1 per (b, h, w))
- 24 active threads per block
- No shared memory needed (except for softmax)
- Direct global memory loads with perfect coalescing for weights and broadcast for input
Let me write this kernel!
Wait, actually, loading weights from global memory 1782 times per block (for 24 channels... no, the weights are loaded once and reused across depths?).
Hmm, for a given channel c, the weights for all 81 positions need to be loaded for each depth. But weights are in global memory. Loading them 22 times per block (once per depth) means 22 * 81 weight loads = 1782 loads per thread. Wait no, each thread only loads weights for its own channel.
For thread c handling channel c:
- For each depth d=0..21:
- For each filter position i=0..80:
- Load weight[c][i]
Wait, if weights are [ci][kd][kh][kw][c], then weight for channel c at position i is at some offset. Thread c loads it.
But the weight value weight[c] is the SAME for all depths! Since the weights don't depend on depth. So I'm loading the same weight 22 times!
This is terrible. I should definitely cache weights in shared memory or registers.
So let me load all weights for the block into shared memory once:
- Block = 32 threads
- Load 24 * 81 = 1944 weights into shared memory
- Each thread loads ceil(1944/32) = 61 weights
Then each thread reads weights from shared memory for its channel during the 22 depths × 81 iterations.
For shared memory, weights are at offsets that differ by 1 for consecutive threads (since threads 0-23 read weights for channels 0-23 at the same filter position). Bank = offset % 32. For filter position i, thread c reads at bank (i*24 + c) % 32.
If i changes, the bank changes. For a given i, consecutive threads hit consecutive banks. No bank conflict!
And for the NEXT filter position i+1:
- Thread c reads at bank ((i+1)*24 + c) % 32 = (i*24 + c + 24) % 32 = (previous_bank + 24) % 32.
Since 24 and 32 share a factor of 8, consecutive filter positions hit banks offset by 24 % 32 = 24 (mod 32), but actually the pattern is more complex. Let's check for thread 0:
- i=0: bank 0
- i=1: bank 24
- i=2: bank (48%32)=16
- i=3: bank (72%32)=8
- i=4: bank (96%32)=0
Oh! Banks repeat every 4 filter positions for thread 0: 0, 24, 16, 8, 0, ...
And for thread 1: 1, 25, 17, 9, 1, ...
Threads 0-7 and threads 8-15 and threads 16-23 might have conflicts when banks repeat.
At i=0: threads 0-23 hit banks 0-23. No conflict.
At i=1: threads 0-23 hit banks 24-15 (wrapping). Specifically: thread 0 hits 24, thread 1 hits 25, ..., thread 7 hits 31, thread 8 hits 0, ..., thread 23 hits 15.
At i=1, threads 8-23 hit banks 0-15, while at i=0, threads 0-15 hit banks 0-15. But these are at different TIMES (different iterations), so no conflict.
Within a single iteration i, all 24 threads hit different banks (since 24 consecutive values in 32 banks). No conflict.
But wait, consecutive threads in a warp execute simultaneously. And the shared memory bank size is 4 bytes per bank. So 24 threads hitting 24 different banks: each bank handles 1 request. With 32 banks, 24 can be serviced in 1 cycle. No conflict!
This is great!
But what about when the SAME bank is accessed by different threads at different iterations? That's fine.
So the shared memory weight layout with [filter_position][channel] (i.e., 81×24) with 24 consecutive channels per filter position has NO bank conflicts!
For thread c reading weight[i][c]: offset = i*24 + c. Bank = (i*24 + c) % 32.
Yes, within an iteration, all threads hit different banks. Perfect!
For the input, stored in shared memory or loaded from global:
Option A: Load input into shared memory as [C_in][D_in][kH][kW] = [3][24][3][3] = 648 floats.
- Thread t loads ceil(648/32) = 21 elements.
- Stored at offsets that correspond to [ci][d][kh][kw] = ((ci*24 + d)*3 + kh)*3 + kw.
- Bank = ((ci*24 + d)*9 + kh*3 + kw) % 32.
For thread c (0-23) reading input for filter position i=ci*27 + kd*9 + kh*3 + kw at depth d:
offset = ((ci*24 + (d+kd))*3 + kh)*3 + kw
Bank depends on (d+kd), which varies with d and kd.
For a fixed iteration over i and warp processing depth d together... wait, different threads in the warp are processing different channels c, but all use the SAME depth d and SAME filter position components (ci, kd, kh, kw determined by i).
For a given i and d:
- ci, kd, kh, kw are fixed (same for all threads)
- d_in = d + kd is fixed
- Thread c reads input at offset ((ci*24 + d_in)*3 + kh)*3 + kw
- This offset is the SAME for all threads c!
All threads read the SAME shared memory address! This is a broadcast. No bank conflict. Perfect!
So if I store input in shared memory and weights in shared memory with the transposed layout, I get:
- Broadcast for input (1 cycle)
- Consecutive banks for weights (1 cycle for 24 threads)
- FMA (4 cycles, but next loads can start before it completes)
This is a huge improvement over the original kernel which had terrible global memory coalescing for weights.
Now let me also load bias into shared memory or registers.
OK, I think I should write this kernel with:
1. Transposed weights in constructor
2. Shared memory for input (648 floats) and weights (1944 floats)
3. 32 threads per block, 24 active
4. Clean loop structure
Let me see if this beats the original.
Actually, I also realize: for better instruction-level parallelism, I could have each thread compute 2 channels. But that doesn't help with memory (each thread still loads 1 weight per iteration, but for 2 channels it's 2 weights, which would require 2 loads per iteration instead of 1, possibly increasing latency).
Wait, I can load 2 weights consecutively from shared memory in the same instruction if they're in the same bank... but actually, a thread can only issue 1 load per cycle. And consecutive loads from the same thread to different addresses are independent.
If thread c handles channels c and c+12 (for example):
```cuda
for (int i = 0; i < 81; i++) {
float in = s_input[...];
float w0 = s_weights[i*24 + 0];
float w1 = s_weights[i*24 + 12];
conv0 += in * w0;
conv1 += in * w1;
}
```
Now there are 2 independent FMA chains. This helps hide latency. But I only have 24 channels. With 12 threads each doing 2 channels: 12 threads active, 20 idle. Worse thread utilization.
With 24 threads each doing 1 channel, I have 24 FMAs per warp per iteration. The loads are pipelined, and the next iteration can start loading while the current FMA is in the pipeline.
Actually, the compiler should be able to pipeline this automatically:
```cuda
for (int i = 0; i < 81; i++) {
float in = s_input[input_idx(i)];
float w = s_weights[weight_idx(c, i)];
conv_val += in * w;
}
```
If the compiler unrolls this loop by 4:
```cuda
for (int i = 0; i < 81; i += 4) {
float in0 = s_input[input_idx(i+0)];
float in1 = s_input[input_idx(i+1)];
float in2 = s_input[input_idx(i+2)];
float in3 = s_input[input_idx(i+3)];
float w0 = s_weights[weight_idx(c, i+0)];
float w1 = s_weights[weight_idx(c, i+1)];
float w2 = s_weights[weight_idx(c, i+2)];
float w3 = s_weights[weight_idx(c, i+3)];
conv_val += in0 * w0; // chain 0
conv_val += in1 * w1; // chain 1? No same accumulator!
}
```
Wait, all 4 FMAs use the SAME accumulator `conv_val`. So they're NOT independent. The compiler can't automatically create independent chains unless I use separate accumulators.
With 81 iterations and `#pragma unroll 4`, the compiler would probably sequence:
load in0, load w0 → fma(conv_val, in0, w0) → load in1, load w1 → fma(conv_val, in1, w1) → ...
Since each FMA depends on conv_val, they MUST be sequential. The only parallelism comes from the loads overlapping with previous FMAs.
With 2 accumulators, I could write:
```cuda
float conv_a = 0, conv_b = 0;
for (int i = 0; i < 80; i += 2) {
conv_a += in[i] * w[i];
conv_b += in[i+1] * w[i+1];
}
float conv_val = conv_a + conv_b;
```
This creates 2 independent chains. With `#pragma unroll 4`, 4 independent chains.
The key is to manually create multiple accumulators. Let me do that.
For 32 threads and 24 channels, if each thread handles 1 channel, I can't easily create multiple independent accumulators for the SAME channel without splitting the work. But I can have each thread process 2 filter positions at a time:
```cuda
float acc0 = 0, acc1 = 0;
for (int i = 0; i < 80; i += 2) {
float in0 = s_input[idx(i)];
float in1 = s_input[idx(i+1)];
float w0 = s_weights[w_idx(c, i)];
float w1 = s_weights[w_idx(c, i+1)];
acc0 += in0 * w0;
acc1 += in1 * w1;
}
float conv_val = acc0 + acc1;
```
This splits the 81 FMAs into 2 chains of ~40 FMAs. With 2 independent chains, a thread can issue one FMA every 2 cycles instead of every 4, doubling effective throughput.
With 4 accumulators, issuing 1 FMA per cycle.
I should also load input and weights with sufficient offset so loads don't stall.
Given the complexity, let me try this approach:
- 4 accumulators per thread
- 32 threads per block (24 active)
- Shared memory for input and transposed weights
- Global memory loads are coalesced during init
For the weight transpose, I need to do it in the constructor in Python. The original weights are [C_out, C_in, kD, kH, kW]. I'll transpose to [C_in, kD, kH, kW, C_out] for the kernel.
Let me write this kernel.
Also for input shared memory loading: I'll keep the input in [C_in, D_in, kH, kW] layout as before.
Actually, I'll completely redesign the kernel based on what I've learned. I'll call it `optimized_fused_kernel`.
One more thing: I'll check `static_check` before submitting, and I need to make sure I don't use try/except fallbacks.
Let me also think about whether to keep using self.conv from PyTorch or do the full fusion. Given that our improved kernel should have much better memory coalescing and some ILP, it might be competitive with or even faster than cudnn for this small size.
Let's say our kernel achieves 10% of peak FLOPS (still terrible but 6x better than before): 6.7 TFLOPS. Total FMAs = 4.93e9. Time = 4.93e9 / 6.7e12 * 2 = 1.47 ms (multiply by 2 because FMA = 2 FLOPs). That's about half the reference time!
If we achieve 15% of peak: 10 TFLOPS. Time = 0.98 ms. That's 3x faster!
So a well-optimized manual kernel could significantly beat the reference.
Let me write this improved fused kernel.
I'll prepare a clean version with all optimizations:
1. Transpose weights in Python to [C_in][kD][kH][kW][C_out]
2. Kernel per block:
- 1 warp (32 threads)
- 24 active threads
- Process 1 (b, h, w) location
3. Load input into shared memory (648 floats)
4. Load weights into shared memory (1944 floats, transposed layout)
5. Each thread computes 1 channel with 4 accumulators (unroll by 4)
6. Process all 22 depths, tracking min per channel
7. Warp-level softmax using shuffle
Let me compute the shared memory layout:
- s_input: 648 floats
- s_weights: 1944 floats
Total: 2592 floats = 10,368 bytes = 10.1 KB
Max blocks per SM = 228 KB / 10.1 KB = 22.6 → 22 blocks
22 blocks × 32 threads = 704 threads per SM. Good.
For ILP with 4 accumulators:
```cuda
float acc0 = bias[c], acc1 = bias[c], acc2 = bias[c], acc3 = bias[c];
for (int i = 0; i < 80; i += 4) {
float in0 = s_input[idx(i+0)];
float in1 = s_input[idx(i+1)];
float in2 = s_input[idx(i+2)];
float in3 = s_input[idx(i+3)];
float w0 = s_weights[(i+0)*24 + c];
float w1 = s_weights[(i+1)*24 + c];
float w2 = s_weights[(i+2)*24 + c];
float w3 = s_weights[(i+3)*24 + c];
acc0 += in0 * w0;
acc1 += in1 * w1;
acc2 += in2 * w2;
acc3 += in3 * w3;
}
// handle last element if 81 not divisible by 4
float conv_val = acc0 + acc1 + acc2 + acc3 + last_term;
```
Wait, bias[c] should be added once, not 4 times. So:
```cuda
float acc0 = 0, acc1 = 0, acc2 = 0, acc3 = 0;
for (int i = 0; i < 80; i += 4) {
...
acc0 += in0 * w0;
...
}
float conv_val = bias[c] + acc0 + acc1 + acc2 + acc3 + (last if needed);
```
But the compiler might not like 4 separate accumulators with shared memory loads. Let me think about the instruction schedule.
Loop body:
Cycle 0: compute idx0, w_addr0
Cycle 1: issue load input0
Cycle 2: issue load weight0
Cycle 3: compute idx1, w_addr1
Cycle 4: issue load input1
Cycle 5: issue load weight1
Cycle 6: compute idx2, w_addr2
Cycle 7: issue load input2
Cycle 8: issue load weight2
Cycle 9: compute idx3, w_addr3
Cycle 10: issue load input3
Cycle 11: issue load weight3
Cycle 12: input0 returns, weight0 returns → issue FMA0
Cycle 16: input1 returns, weight1 returns → issue FMA1
Cycle 20: input2 returns, weight2 returns → issue FMA2
Cycle 24: input3 returns, weight3 returns → issue FMA3
Cycle 28: FMA0 completes (needed for next iteration's acc0)
Cycle 32: FMA1 completes
...
So a new loop iteration can start every ~28 cycles? That's not great.
Wait, the FMAs for acc0, acc1, acc2, acc3 are INDEPENDENT. So FMA1 doesn't depend on FMA0. The loads for the NEXT iteration's input1 and weight1 depend on nothing (just loop counter). So they can start immediately after the previous loads, not waiting for FMAs.
Let me re-think:
Cycle 0: issue load input[i+0]
Cycle 1: issue load weight[i+0]
Cycle 2: issue load input[i+1]
Cycle 3: issue load weight[i+1]
Cycle 4: issue load input[i+2]
Cycle 5: issue load weight[i+2]
Cycle 6: issue load input[i+3]
Cycle 7: issue load weight[i+3]
Cycle 20: load input[i+0] returns, load weight[i+0] returns → issue FMA0
Cycle 21: load input[i+1] returns, load weight[i+1] returns → issue FMA1
Cycle 22: load input[i+2] returns, load weight[i+2] returns → issue FMA2
Cycle 23: load input[i+3] returns, load weight[i+3] returns → issue FMA3
Cycle 24: don't need FMA0 result yet for loads! Continue to next iteration:
Cycle 24: issue load input[i+4]
...
So the loop body takes ~24 cycles (dominated by shared memory latency), and we issue 4 FMAs in cycles 20-23. That's 4 FMAs per 24 cycles = 1 FMA per 6 cycles per thread.
For 32 threads, that's 32 FMAs per 6 cycles per warp. Or about 5.3 FMAs per cycle per warp.
With 22 blocks per SM (704 threads = 22 warps), that's 22 * 5.3 = 117 FMAs per cycle per SM. Close to saturating 128 FMAs per cycle!
This is a huge improvement. Time per block = 81/4 * 24 cycles ≈ 486 cycles = 0.2 µs at 2.4 GHz.
For 22 depths: 22 * 0.2 µs = 4.4 µs.
Total time = 115,200 blocks / (132 * 22 blocks per SM) * 4.4 µs = 115,200 / 2904 * 4.4 µs = 39.7 * 4.4 µs = 174.7 µs = 0.175 ms.
Now this is in the realm of 0.2 ms, which would be 15x faster than the reference 2.97 ms! That seems too optimistic, but even if it's 1.0 ms, that's 3x speedup.
Wait, I think my calculation is too optimistic because it ignores loop overhead, address computation, branching, and the warp scheduler limitations. But even 2x would be good.
OK, let me write this kernel. But I also need to think carefully about whether the loop unrolling will actually happen this way. Let me use explicit accumulators and see.
One more thing: the indexing for input and weights with the flattened loop.
If I store weights as [81][24] (81 filter positions, 24 channels), then for filter position i and channel c, weight is at s_weights[i * 24 + c].
And input for a given depth d_out and filter position i:
- i maps to (ci, kd, kh, kw) where:
- ci = i / 27
- kd = (i % 27) / 9
- kh = (i % 9) / 3
- kw = i % 3
- d_in = d_out + kd
- input_idx = ((ci * 24 + d_in) * 3 + kh) * 3 + kw
If I precompute a lookup table in shared memory or constant memory for input_idx given (d_out, i), that might help. But it's only 22 * 81 = 1782 entries, which is small but still shared memory.
Actually, let me precompute the full index table:
```cuda
__shared__ int s_input_idx[22][81]; // too big: 1782 ints = 7 KB
```
That's a lot of shared memory. Better to compute on the fly with integer arithmetic.
Actually, integer arithmetic is relatively cheap on modern GPUs. The division by constant 27, 9, 3 can be done with magic multipliers.
Or I can restructure the loop to avoid the division:
```cuda
for (int ci = 0; ci < 3; ci++) {
for (int kd = 0; kd < 3; kd++) {
int d_in = d_out + kd;
for (int kh = 0; kh < 3; kh++) {
for (int kw = 0; kw < 3; kw++) {
int i = ((ci * 3 + kd) * 3 + kh) * 3 + kw;
int s_idx = ((ci * 24 + d_in) * 3 + kh) * 3 + kw;
...
}
}
}
}
```
Then I can manually partially unroll:
```cuda
#pragma unroll
for (int ci = 0; ci < 3; ci++) {
#pragma unroll
for (int kd = 0; kd < 3; kd++) {
int d_in = d_out + kd;
int base_ci = ci * 24 + d_in;
#pragma unroll
for (int kh = 0; kh < 3; kh++) {
int base_kh = (base_ci * 3 + kh) * 3;
#pragma unroll
for (int kw = 0; kw < 3; kw++) {
// unrolled 9 iterations
}
}
}
}
```
With 4 levels of unroll (3×3×3×3 = 81), the compiler should produce 81 separate code blocks or at least a tight loop. But 81 unrolled bodies might be large.
Alternatively, let me keep a single loop over i=0..80 and compute s_idx using macros or lookups.
Actually, let me use a hybrid. Since the original kernel compiled but was slow, I'll change just the weight access pattern and add accumulator-level parallelism.
Wait, there's an easier way to get ILP: process multiple depths in the same thread using multiple accumulators.
For example, with 2 depths per pass:
```cuda
for (int d = 0; d < 22; d += 2) {
float acc0 = bias[c], acc1 = bias[c];
for (int i = 0; i < 81; i++) {
float in0 = s_input[idx(d, i)];
float in1 = s_input[idx(d+1, i)];
float w = s_weights[i * 24 + c];
acc0 += in0 * w;
acc1 += in1 * w;
}
min_val = fminf(min_val, fminf(acc0, acc1));
}
```
Now each iteration processes 2 depths. There are 2 independent accumulators. The shared memory loads for in0 and in1 might overlap in the cache since the input patches for d and d+1 share 2 out of 3 depth planes.
But the weight load is the SAME for both accumulators! So:
- Load in0 (cycle 0)
- Load in1 (cycle 1)
- Load weight (cycle 2)
- FMA0 (cycle 3, when in0 and weight are ready)
- FMA1 (cycle 4, when in1 and weight are ready)
Wait, in0 and in1 might return at different times, but weight is shared. FMA0 and FMA1 are independent!
So per iteration (2 depths): 2 loads for input, 1 load for weight (same weight used twice), 2 FMAs. And the 2 FMAs are independent.
With this, a warp processes 2 depths at a time using 2 independent FMAs per thread. For D_out = 22, we have 11 iterations of the outer loop (with the last one only having 1 depth, since 22 is not divisible by 2).
Hmm, 22/2 = 11 exactly! Great.
Time per outer iteration: 81 inner iterations. Each inner iteration:
- Issue load in0, load in1, load weight
- Wait ~24 cycles
- Issue FMA0, FMA1
- FMA latency: 4 cycles, but FMA0 and FMA1 can be back-to-back
So ~24 cycles per inner iteration, but we get 2 FMAs. That's 2 FMAs per 24 cycles = 1 FMA per 12 cycles per thread. Wait, that's worse than before.
No, at the instruction level:
Cycle 0-2: issue 3 loads
Cycle 3-23: other instructions from other iterations/warps
Cycle 24: FMA0
Cycle 25: FMA1
Cycle 26: next iteration's loads can start
Wait, but the next iteration's loads don't depend on FMA0/1. So:
Cycle 0: load in0[i]
Cycle 1: load in1[i]
Cycle 2: load w[i]
Cycle 3: load in0[i+1]
Cycle 4: load in1[i+1]
Cycle 5: load w[i+1]
...
Cycle 24: FMA0[i] (in0[i] and w[i] ready)
Cycle 25: FMA1[i]
Cycle 26: FMA0[i+1]
Cycle 27: FMA1[i+1]
...
So every 3 cycles starting from cycle 24, we issue 2 FMAs (one per cycle). Actually, FMA0[i] at cycle 24, FMA1[i] at cycle 25, FMA0[i+1] at cycle 26, FMA1[i+1] at cycle 27, etc.
That's 2 FMAs per 2 cycles = 1 FMA per cycle per thread! But this is only possible if loads are pipelined far enough ahead.
For iteration i+8 at cycle 24: the loads for i+8 were issued at cycles 24, 25, 26. FMA0[i+8] would be at cycle 48. So the pipeline depth is 24 cycles = 8 iterations. This means we need 8 independent FMA pairs in flight.
In our code, each pair corresponds to 2 depths. So 8 pairs = 16 depths. But we only have 11 pairs (22 depths). Hmm, the pairs are sequential in the outer loop, not concurrent.
Wait, I think I've been confusing myself. Let's look at the actual loop structure again.
For a SINGLE thread processing 2 depths:
```cuda
for (int d = 0; d < 22; d += 2) {
float acc0 = bias[c], acc1 = bias[c];
for (int i = 0; i < 81; i++) {
float in0 = s_input[idx(d, i)];
float in1 = s_input[idx(d+1, i)];
float w = s_weights[i * 24 + c];
acc0 += in0 * w;
acc1 += in1 * w;
}
min_val = fminf(min_val, fminf(acc0, acc1));
}
```
The inner loop over i has 81 iterations. Each iteration is:
1. load in0
2. load in1
3. load w
4. FMA0
5. FMA1
Instructions 1-3 are independent (except they all come from the same thread, so they execute sequentially in this thread). Actually, instructions from the SAME thread must execute sequentially unless there's dual-issue.
In a single thread:
Cycle 0: load in0
Cycle 1: load in1
Cycle 2: load w
Cycle 3: FMA0
Cycle 4: FMA1
Cycle 5: next iteration's load in0
But load in0 for the next iteration can start at cycle 3 (or earlier if dual-issue with FMA0), because it doesn't depend on FMA0/1.
Actually, with dual-issue capability:
Cycle 0: load in0 | (other op)
Cycle 1: load in1 | (other op)
Cycle 2: load w | (other op)
Cycle 3: FMA0 | load in0(next) -- dual issue!
Cycle 4: FMA1 | load in1(next)
Cycle 5: (FMA0 next iteration?) not yet, FMA depends on loads.
Wait, FMA0(next) needs in0(next) and w(next). in0(next) was loaded at cycle 3, returning ~cycle 27. So FMA0(next) at cycle 27.
But at cycle 5, we can do other work like load w(next).
Let me trace more carefully:
Cycle 0: LD in0[i]
Cycle 1: LD in1[i]
Cycle 2: LD w[i]
Cycle 3: LD in0[i+1]
Cycle 4: LD in1[i+1]
Cycle 5: LD w[i+1]
Cycle 6: LD in0[i+2]
...
Cycle 24: in0[i] ready, w[i] ready → FMA0[i]
Cycle 25: FMA1[i] (in1[i] ready at cycle 25? Actually loaded at cycle 1, ready ~cycle 25)
Cycle 26: in0[i+1] ready, w[i+1] ready (loaded at cycles 3 and 5, ready ~cycles 27 and 29)
Wait, w[i+1] was loaded at cycle 5, ready ~cycle 29. in0[i+1] at cycle 3, ready ~cycle 27. FMA0[i+1] can issue at cycle 29 (when both are ready).
So:
Cycle 24: FMA0[i]
Cycle 25: FMA1[i]
Cycle 29: FMA0[i+1]
Cycle 30: FMA1[i+1]
...
There's a gap of 4 cycles between FMA1[i] and FMA0[i+1]. This is because the loads for i+1 take time and finish at different times.
With more lookahead (loading more iterations ahead):
Cycle 0: LD in0[i]
Cycle 1: LD in1[i]
Cycle 2: LD w[i]
Cycle 3: LD in0[i+1]
Cycle 4: LD in1[i+1]
Cycle 5: LD w[i+1]
Cycle 6: LD in0[i+2]
Cycle 7: LD in1[i+2]
Cycle 8: LD w[i+2]
...
Now by cycle 24, we have loads for i through i+8 all in flight. FMA0[i] issues at cycle 24, FMA1[i] at 25. FMA0[i+1] at 29... hmm, still gaps.
Actually, the bottleneck is that w[i+1] returns at cycle 29, and FMA0 can't issue until both operands are ready. Even with deep pipelining, the shared memory latency (~24 cycles) means we can only start a new FMA every ~5 cycles (24/5 ≈ 4.8).
Wait, 24-cycle latency with 5 independent chains means we can issue every 24/5 = 4.8 cycles. But we only have 2 chains (acc0, acc1). So we can issue every 12 cycles. 2 FMAs every 12 cycles = 1 FMA per 6 cycles.
With 32 threads: 32 FMAs per 6 cycles = 5.3 FMAs per cycle per warp.
With 22 warps per SM: 117 FMAs per cycle. This is close to the 128 limit.
So the 2-depth parallel approach might be sufficient to achieve near-saturation.
But what if we do 4 depths parallel with 4 accumulators? Then we can issue 4 FMAs every ~24 cycles = 1 FMA per 6 cycles... wait, no.
With 4 accumulators and 24-cycle load latency:
- Loads for i, i+1, i+2, i+3 are issued in cycles 0-11
- FMA0[i] at cycle 24
- FMA1[i] at cycle 25
- FMA2[i] at cycle 26
- FMA3[i] at cycle 27
- FMA0[i+1] at cycle 29 (w[i+1] ready at ~29)
- ...
4 FMAs every 5 cycles = 0.8 FMAs per cycle per thread.
32 threads: 25.6 FMAs per cycle per warp.
22 warps: 563 FMAs per cycle. EXCEEDS the 128 SM limit. But that's OK - means we're compute-bound, not latency-bound.
Now with 4 depths, the total FMAs per block are still the same (24 channels × 22 depths × 81 = 42,768 FMAs). But with higher utilization, the compute time decreases.
With 4 accumulators, issue rate of 1 FMA per 1.25 cycles per thread (4 FMAs per 5 cycles).
81 FMAs / 1.25 = 64.8 cycles per depth group. Wait no.
Actually, with 4 depths processed together, there are 81 iterations. Each iteration does 4 FMAs (one per depth). So total FMA issue cycles = 81 * 5/4 ≈ 101 cycles. Plus load lead-in of ~24 cycles. Total ≈ 125 cycles per 4-depth group.
For 22 depths: 5.5 groups (22/4). 6 groups * 125 cycles = 750 cycles = 0.31 µs at 2.4 GHz.
This would be incredibly fast. Total time = 115,200 blocks / 2904 concurrent * 0.31 µs = 12.3 µs. That's absurdly fast and probably not realistic due to other overheads.
A more realistic estimate might be 2-3x the reference. Let me just code this up and test.
OK, I'll write a kernel with 4-depth parallel processing, transposed weights, and shared memory. I'll also fully unroll the 81-iteration loop manually using the 4-level nested loops, and use 4 accumulators.
Wait, the 81 iterations are over (ci, kd, kh, kw). If I process 4 depths in parallel, for each combination of (ci, kd, kh, kw), I need 4 different input values (for d, d+1, d+2, d+3) but the SAME weight (since weight doesn't depend on depth).
For depth d_base and offset kd (0..2):
- Depth d_base uses input depth d_base + kd
- Depth d_base+1 uses input depth d_base+1 + kd
- etc.
So for a given (ci, kd, kh, kw) and 4 depths starting at d_base:
- in0 = s_input[ci][d_base + kd][kh][kw]
- in1 = s_input[ci][d_base+1 + kd][kh][kw]
- in2 = s_input[ci][d_base+2 + kd][kh][kw]
- in3 = s_input[ci][d_base+3 + kd][kh][kw]
- w = s_weights[ci][kd][kh][kw][c]
Then:
acc0 += in0 * w;
acc1 += in1 * w;
acc2 += in2 * w;
acc3 += in3 * w;
All 4 FMAs are independent! And they use the same weight (loaded once).
For shared memory:
- We load 1 weight per iteration (broadcast to all threads in the warp, sort of - actually each thread loads its own channel's weight, but all channels are loaded in parallel across the warp)
- We load 4 input elements per iteration
But the 4 input elements for different depths might conflict in shared memory banks if they're at offsets differing by multiples of 32.
Input offset: ((ci * 24 + d_in) * 3 + kh) * 3 + kw
Bank = offset % 32.
For in0 (d_in = d_base + kd): bank0 = (((ci * 24 + d_base + kd) * 3 + kh) * 3 + kw) % 32
For in1 (d_in = d_base+1 + kd): bank1 = (bank0_base + 9) % 32 (since d_in increased by 1, offset increases by 9)
9 % 32 = 9. So consecutive depths hit banks differing by 9.
in0: bank b
in1: bank (b+9) % 32
in2: bank (b+18) % 32
in3: bank (b+27) % 32
Are these all different? b, b+9, b+18, b+27 mod 32.
Differences: +9, +18, +27. None are 0 mod 32. So yes, 4 different banks. No conflict!
For a warp of 32 threads, if each thread loads in0, in1, in2, in3 for its own computation... wait, all threads in the warp load the SAME 4 input elements (since input doesn't depend on channel c). So:
- in0 load by all 32 threads: broadcast! 1 cycle.
- in1 load: broadcast! 1 cycle.
- in2 load: broadcast! 1 cycle.
- in3 load: broadcast! 1 cycle.
- Weight load by all threads: coalesced across 24 active threads, hitting 24 different banks. 1 cycle.
So 5 shared memory loads in 5 cycles. Then 4 FMAs in 4 cycles. Loads for next iteration can start in parallel.
This is really good!
For 22 depths and 81 filter positions:
- We process depth groups: {0,1,2,3}, {4,5,6,7}, {8,9,10,11}, {12,13,14,15}, {16,17,18,19}, {20,21} (last group has only 2)
For groups 0-4: 81 iterations, each with 5 loads + 4 FMAs.
For group 5: 81 iterations with 3 loads + 2 FMAs.
Total FMAs = 5 * 81 * 4 + 1 * 81 * 2 = 1620 + 162 = 1782. Correct (24 channels * 22 depths * 81... no, per thread: 22 depths * 81 = 1782 FMAs. Yes!)
Cycles per group of 4 depths:
- 81 iterations * (5 load cycles + 4 FMA cycles) = 729 cycles. But loads and FMAs overlap across iterations due to pipelining.
- More realistically, with pipelining: each iteration's loads take 5 cycles and FMAs take 4 cycles, but next iteration's loads can start while previous FMAs are in flight.
So total per group: 81 * 5 + 4 = 409 cycles? No, because the FMAs of iteration 0 start after its loads. But iteration 1's loads can start right after iteration 0's loads.
Let me think of it as a pipeline:
Phase 1: Issue loads for iterations 0, 1, 2, ... (5 loads per iteration, 1 cycle per load)
Phase 2: FMAs become ready after ~24 cycles
The first FMA can issue at cycle ~24. FMAs continue at 4 per 4 cycles = 1 per cycle. Total FMA issue time: 1782 FMAs / 32 threads / 1 FMA per cycle = 55.7 cycles per warp. Wait, that's only if we can sustain 1 FMA per cycle across all threads.
This is getting too theoretical. Let me just write the kernel and see.
Oh, I realize there's actually a much simpler way to think about this. Let me write a kernel where the inner loop is fully unrolled and each thread computes 1 channel for 4 depths at a time. I'll keep the code clean and rely on the compiler.
Actually, I'll take a pragmatic approach: write the kernel as I described, compile it, and see the timing.
One more consideration: I need to verify correctness, so I'll use `run_correctness` first.
Let me write the kernel code now.
After writing the kernel, if the compile passes, I'll test correctness. Then if it's still not fast enough, I'll try other optimizations.
Some additional optimizations I can add:
- Use `__ldg()` for read-only global memory (weights and input) to use read-only cache
- Use `float4` or vector loads where possible
- Process multiple (h, w) pixels per block
Let me start with a solid kernel.
For the kernel, I'll use:
- Grid: (B, H_out, W_out)
- Block: 32 threads (1 warp)
- 24 threads active (tid < 24)
- tid maps to channel c = tid
Shared memory:
- s_input: 648 floats (loaded by all threads cooperatively)
- s_weights: 1944 floats (loaded cooperatively, layout transposed to [81][24])
- s_mins: 24 floats (for storing min before softmax)
Weight transpose in Python:
```python
# Original weight: [C_out, C_in, kD, kH, kW]
# New weight: [C_in, kD, kH, kW, C_out]
W = self.conv.weight.data
self.fused_weight = W.permute(1, 2, 3, 4, 0).contiguous()
```
Wait, I need to be careful. The module needs to have `self.conv` for compatibility. But I can store the transposed weights as an extra parameter or buffer.
Actually, I'll just add:
```python
def __init__(...):
super().__init__()
self.conv = nn.Conv3d(...)
# Store transposed weights for kernel
with torch.no_grad():
w = self.conv.weight.permute(1, 2, 3, 4, 0).contiguous()
self.register_buffer('fused_weight', w)
```
But I need to make sure `fused_weight` stays in sync if weights are updated (but the evaluation probably doesn't update weights after init).
The kernel:
```cuda
__global__ void fused_conv3d_kernel(
const float* __restrict__ input,
const float* __restrict__ weight,
const float* __restrict__ bias,
float* __restrict__ output,
int B, int C_in, int D_in, int H_in, int W_in,
int C_out, int D_out, int H_out, int W_out
) {
int b = blockIdx.x;
int h = blockIdx.y;
int w = blockIdx.z;
int tid = threadIdx.x;
if (b >= B || h >= H_out || w >= W_out || tid >= 24) return;
__shared__ float s_input[648];
__shared__ float s_weight[1944];
__shared__ float s_mins[24];
// Load input
for (int i = tid; i < 648; i += 32) {
int kw = i % 3;
int t = i / 3;
int kh = t % 3;
t = t / 3;
int d = t % 24;
int ci = t / 24;
int h_in = h + kh;
int w_in = w + kw;
int idx = ((b * C_in + ci) * D_in + d) * H_in * W_in + h_in * W_in + w_in;
s_input[i] = input[idx];
}
// Load weights (1944 = 81 * 24)
for (int i = tid; i < 1944; i += 32) {
s_weight[i] = weight[i];
}
__syncthreads();
int c = tid;
float min_val = 1e30f;
float bias_c = bias[c];
// Process 4 depths at a time for ILP
#pragma unroll 1
for (int d_base = 0; d_base < 20; d_base += 4) {
float acc0 = bias_c;
float acc1 = bias_c;
float acc2 = bias_c;
float acc3 = bias_c;
#pragma unroll
for (int ci = 0; ci < 3; ci++) {
#pragma unroll
for (int kd = 0; kd < 3; kd++) {
#pragma unroll
for (int kh = 0; kh < 3; kh++) {
#pragma unroll
for (int kw = 0; kw < 3; kw++) {
int d_in0 = d_base + kd;
int d_in1 = d_base + 1 + kd;
int d_in2 = d_base + 2 + kd;
int d_in3 = d_base + 3 + kd;
int s_i0 = ((ci * 24 + d_in0) * 3 + kh) * 3 + kw;
int s_i1 = ((ci * 24 + d_in1) * 3 + kh) * 3 + kw;
int s_i2 = ((ci * 24 + d_in2) * 3 + kh) * 3 + kw;
int s_i3 = ((ci * 24 + d_in3) * 3 + kh) * 3 + kw;
int fi = ((ci * 3 + kd) * 3 + kh) * 3 + kw;
float w = s_weight[fi * 24 + c];
acc0 += s_input[s_i0] * w;
acc1 += s_input[s_i1] * w;
acc2 += s_input[s_i2] * w;
acc3 += s_input[s_i3] * w;
}
}
}
}
min_val = fminf(min_val, acc0);
min_val = fminf(min_val, acc1);
min_val = fminf(min_val, acc2);
min_val = fminf(min_val, acc3);
}
// Handle remaining 2 depths (20, 21)
{
float acc0 = bias_c;
float acc1 = bias_c;
#pragma unroll
for (int ci = 0; ci < 3; ci++) {
#pragma unroll
for (int kd = 0; kd < 3; kd++) {
#pragma unroll
for (int kh = 0; kh < 3; kh++) {
#pragma unroll
for (int kw = 0; kw < 3; kw++) {
int d_in0 = 20 + kd;
int d_in1 = 21 + kd;
int s_i0 = ((ci * 24 + d_in0) * 3 + kh) * 3 + kw;
int s_i1 = ((ci * 24 + d_in1) * 3 + kh) * 3 + kw;
int fi = ((ci * 3 + kd) * 3 + kh) * 3 + kw;
float w = s_weight[fi * 24 + c];
acc0 += s_input[s_i0] * w;
acc1 += s_input[s_i1] * w;
}
}
}
}
min_val = fminf(min_val, acc0);
min_val = fminf(min_val, acc1);
}
s_mins[c] = min_val;
__syncthreads();
// Warp-level softmax (all 32 threads participate, 24 have data)
float my_min = (tid < 24) ? s_mins[tid] : -1e30f;
// Reduce max
float max_val = my_min;
max_val = fmaxf(max_val, __shfl_down_sync(0xFFFFFFFF, max_val, 16));
max_val = fmaxf(max_val, __shfl_down_sync(0xFFFFFFFF, max_val, 8));
max_val = fmaxf(max_val, __shfl_down_sync(0xFFFFFFFF, max_val, 4));
max_val = fmaxf(max_val, __shfl_down_sync(0xFFFFFFFF, max_val, 2));
max_val = fmaxf(max_val, __shfl_down_sync(0xFFFFFFFF, max_val, 1));
max_val = __shfl_sync(0xFFFFFFFF, max_val, 0);
// Compute exp
float exp_val = (tid < 24) ? expf(my_min - max_val) : 0.0f;
// Reduce sum
float sum_val = exp_val;
sum_val += __shfl_down_sync(0xFFFFFFFF, sum_val, 16);
sum_val += __shfl_down_sync(0xFFFFFFFF, sum_val, 8);
sum_val += __shfl_down_sync(0xFFFFFFFF, sum_val, 4);
sum_val += __shfl_down_sync(0xFFFFFFFF, sum_val, 2);
sum_val += __shfl_down_sync(0xFFFFFFFF, sum_val, 1);
sum_val = __shfl_sync(0xFFFFFFFF, sum_val, 0);
// Write output
if (tid < 24) {
int out_idx = ((b * C_out + c) * H_out + h) * W_out + w;
output[out_idx] = exp_val / sum_val;
}
}
```
Let me compile this and see.
Wait, I have d_in1 = 21 + kd. When kd=2, d_in1 = 23. That's within the 24 depth range. Good.
And d_base starts at 0, so d_in0 = 0+0 = 0 up to d_in3 = 0+3+2 = 5. Then next group d_base=4: d_in0=4+0=4 up to d_in3=4+3+2=9. And so on.
Wait, there's overlap between groups! Group 0 uses input depths {0,1,2}, {1,2,3}, {2,3,4}, {3,4,5}. Group 1 uses {4,5,6}, {5,6,7}, {6,7,8}, {7,8,9}.
Actually, for output depth d, the input depths are d, d+1, d+2.
- d=0: inputs 0,1,2
- d=1: inputs 1,2,3
- d=2: inputs 2,3,4
- d=3: inputs 3,4,5
- d=4: inputs 4,5,6
So groups 0 and 1 do overlap in inputs. But since we're computing each output depth independently, this is fine. Each accumulator uses the correct input depth. The loads are from shared memory, so it's fast.
One concern: the input shared memory has 24 depth values. For d_in3 = d_base + 3 + kd in the last group (d_base=16): d_in3 = 16+3+2 = 21. OK. For the remaining depths (d=20, d=21): d_in for kd=2 is 22 and 23 respectively. Both valid.
Now, about the weight index `fi * 24 + c`. The weights are in layout [C_in][kD][kH][kW][C_out]. `fi = ((ci * 3 + kd) * 3 + kh) * 3 + kw` is the flat index over the first 4 dimensions. Then `fi * 24 + c` is the offset for channel c.
This means I need to transpose the weights in Python:
```python
# Original: [C_out, C_in, kD, kH, kW]
# Transposed: [C_in, kD, kH, kW, C_out]
transposed = self.conv.weight.permute(1, 2, 3, 4, 0).contiguous()
```
Let me check: `permute(1, 2, 3, 4, 0)` moves dimension 0 (C_out) to the end. So new shape is [C_in, kD, kH, kW, C_out]. Yes.
Also, CUDA kernel loads weights via `weight[i]` where `i = tid` in the loop. If `weight` is a contiguous tensor of shape [1944], this should work fine.
Wait, when I create `transposed = weight.permute(1, 2, 3, 4, 0).contiguous()`, it has shape [3, 3, 3, 3, 24]. I need to pass it as a 1D array. When I call `weight.data_ptr<float>()` on the transposed tensor, I get a pointer to the contiguous data. In the kernel, `weight[i]` accesses element i directly. Since it's contiguous, this is correct.
Good.
Now let me also think about the bias: `bias[c]` is just the original bias. No transpose needed.
And output: I write directly to the output tensor.
Let me compile this.
Actually, I also want to add `__launch_bounds__(32)` or similar. With 32 threads per block, there's no issue. But I can add `__launch_bounds__(32, 64)` to hint occupancy.
Let me try a cleaner version. I'll also check if I should use `constexpr` for the sizes since they're fixed for this problem.
Actually, the kernel needs to work for the exact problem parameters but be generic enough if sizes change slightly. Let me keep it general.
One issue: in my weight load loop, `weight[i]` assumes weight is a 1D tensor. If the transposed weight is [3,3,3,3,24], it IS contiguous and flat length 1944. But when I pass it from Python via `.contiguous()`, and call `.data_ptr<float>()`, the pointer is to the first element. So `weight[i]` in CUDA accesses the i-th element. That's correct.
Let me write the Python ModelNew class.
For the constructor, I need to make sure `fused_weight` is created and updated properly. Since the reference doesn't modify weights after init, I can just create it in `__init__`.
Actually, I should use `register_buffer` so it moves with `.cuda()` calls. But `load_inline` might have issues with buffer registration? No, the Python module is just normal PyTorch code.
```python
class ModelNew(nn.Module):
def __init__(self, in_channels, out_channels, kernel_size, dim):
super(ModelNew, self).__init__()
self.conv = nn.Conv3d(in_channels, out_channels, kernel_size)
self.dim = dim
# Transpose weight for kernel-friendly layout: [C_in, kD, kH, kW, C_out]
with torch.no_grad():
w = self.conv.weight.permute(1, 2, 3, 4, 0).contiguous()
self.register_buffer('fused_weight', w)
```
In forward:
```python
def forward(self, x):
weight = self.fused_weight
bias = self.conv.bias
...
fused_op.fused_conv3d_min_softmax(
x.contiguous(), weight, bias.contiguous(), output,
...
)
return output
```
Wait, if I use `self.conv` and then also `self.fused_weight`, the backward pass (if any) might not update `fused_weight`. But again, evaluation doesn't do backward, so this is fine.
I'm going to write this kernel now and see if it compiles and passes correctness. I'll also try to measure if it's faster.
Actually, one more thing: should I load bias into shared memory too? Bias has 24 elements. It's tiny. Let me keep it in registers (loaded on demand from global memory). Each thread loads 1 bias element: `bias_c = bias[c]`. This is coalesced and cached.
OK, I'll generate the kernel code now.
Oh, wait. I should think about whether to use `expf` from fast math or `__expf` from intrinsics. The fast math flag should convert `expf` to `__expf` automatically. But for numerical correctness, I need to be careful. The reference PyTorch might use a high-precision exp. But with fp32 and tolerance 1e-4, fast math should be fine.
Actually, in the previous kernel, I got max_abs_err=4.47e-08 which is very tight. Using `--use_fast_math` is fine.
Let me write the complete kernel file.
For the softmax, I need to be careful about numerical stability. I compute max first, then exp(x - max), then sum, then divide. That's standard and stable.
One potential issue: when all values are very negative, exp(x - max) might underflow to 0 for some values. But min values can be large negative, and max is the largest among them, so exp(x - max) is in [0, 1]. If max is very negative, exp(0) = 1 and others are smaller. This is fine.
Actually, MIN values... if min over depth gives very negative values, but the max of these min values might still be negative. exp of a negative number is well-behaved (no overflow). If all min values are -1000, max = -1000, exp(0) = 1, others = exp(-big) ≈ 0. Fine.
OK let me generate the code.