reasoning
Let me analyze the problem. We need to implement a custom CUDA kernel for MaxPool3d that is faster than the PyTorch reference.
The parameters are:
- batch_size = 16
- channels = 32
- dim1 = dim2 = dim3 = 128
- kernel_size = 3
- stride = 2
- padding = 1
- dilation = 3
Let me compute the output dimensions:
With ceil_mode = False (default):
out_dim = floor((in_dim + 2*padding - dilation*(kernel_size-1) - 1) / stride + 1)
= floor((128 + 2*1 - 3*(3-1) - 1) / 2 + 1)
= floor((128 + 2 - 6 - 1) / 2 + 1)
= floor(123 / 2 + 1)
= floor(61.5 + 1)
= 62
So the output shape would be (16, 32, 62, 62, 62).
For maxpool3d with these parameters, each output element requires computing the max over a 3x3x3 region in the input (with dilation 3, so the actual span is larger).
Let me think about the memory access pattern. With dilation=3, the kernel samples elements at positions:
- in position (out_d, out_h, out_w), we look at input positions:
- d: out_d * stride - padding + k * dilation for k in [0, kernel_size)
- h: out_h * stride - padding + k * dilation
- w: out_w * stride - padding + k * dilation
With the given parameters:
- d: out_d * 2 - 1 + k * 3, so for k=0,1,2: 2*out_d - 1, 2*out_d + 2, 2*out_d + 5
- This ranges from -1 to 128+5 = 133, but with padding=1, valid input range is [-1, 129], and actual tensor dims are 0..127, so some elements will be "padded" (treated as -inf for maxpool)
The key considerations for an optimized kernel:
1. Memory coalescing - adjacent threads should access adjacent memory
2. Shared memory tiling - load input tiles into shared memory to reuse data
3. Register optimization
For 3D pooling on large inputs, a good strategy is to have each thread compute one output element. But with 32 channels, 16 batches, and 62^3 = 238328 spatial locations, that's about 122M output elements. With 3x3x3=27 comparisons per output element, that's about 3.3B max operations.
Actually, let me recalculate: 16 * 32 * 62 * 62 * 62 = 16 * 32 * 238328 = 122,023,936 output elements. Each needs 27 comparisons, so around 3.3 billion ops.
On H100 with 67 TFLOPS FP32, this is only 0.05 TF = 50 GF, which should be fast if memory bound. But maxpool isn't very compute intensive, it's more about memory bandwidth.
For memory bandwidth: input size = 16 * 32 * 128^3 * 4 bytes = 16 * 32 * 2,097,152 * 4 = 4,294,967,296 bytes ≈ 4.3 GB.
Output size = 16 * 32 * 62^3 * 4 ≈ 0.49 GB.
Total memory read (if each input element is read once per output that needs it) would be much higher since each input element is used in multiple pooling windows. But with PyTorch's optimized implementation, it should already be caching well.
The simplest approach that could win is often:
1. A straightforward element-per-thread CUDA kernel that carefully handles coalescing and loop ordering.
2. Or tiling with shared memory.
Actually, for maxpool, PyTorch has a very optimized implementation. But sometimes custom kernels can win by:
- Fusing operations
- Better occupancy
- Using vectorized loads
Wait, for maxpool on 3D with these relatively small channel dimensions (32) and large spatial dims, I think a tiled approach could work well. Let me think about shared memory tiling.
Each output block of (BD, BH, BW) requires an input region of size roughly (BD*stride + (kernel_size-1)*dilation, ...). With the given params:
- BD = 8: input d span = 8*2 + 2*3 = 22
- BH = 8: input h span = 22
- BW = 8: input w span = 22
A 22x22x22 tile is 10648 elements. With channels=32, and we load a batch at a time or channel at a time...
Actually, let me think about this differently. Since channels=32 and batch=16, we have 512 "feature maps". For each spatial location, we have 512 contiguous elements in memory (in NCDHW format, which PyTorch uses by default).
Wait - PyTorch default format for 3D conv/pool is NCDHW or NDHWC? Let me check. In PyTorch, nn.MaxPool3d expects input in (N, C, D, H, W) format (NCDHW).
So elements are contiguous along C, then W, then H, then D, then N.
For a fixed (n, c, d, h), w varies contiguously.
For tiling, it's often better to have threads in a warp access contiguous elements. So if a warp of 32 threads accesses 32 different w positions but same (n, c, d, h), they're NOT contiguous. But if they access same (n, d, h, w) but different c, they ARE contiguous.
Wait, each thread computing one output (n, c, d_out, h_out, w_out) - if we arrange so that w_out varies across the warp (or thread block x-dimension), then the accesses within the inner loop over kernel positions will be strided.
Actually, let me think about the simplest winning approach. Since PyTorch's maxpool3d implementation is already quite optimized (it likely uses CuDNN for large sizes), a custom kernel might not easily beat it. But for specific shapes, there could be wins.
One approach is to use a "naive" kernel but with good occupancy and no overheads. Another is to use tiled shared memory to reduce global memory traffic.
Wait, actually, PyTorch's native maxpool3d may not always use CuDNN. Let me think about what PyTorch does - for 3D pooling with certain parameters, it might fall back to its own CUDA kernels.
Let me try a vectorized approach. Since channels=32, and the data is contiguous in C, we could potentially process multiple channels at once with float4 (4 channels at a time). But maxpool operates independently per-channel, and output is per-channel.
Actually, for 3D pooling, the innermost loop is typically over the kernel window. We could do:
For each output position (n, c, d, h, w):
max_val = -inf
for kd in 0..2:
for kh in 0..2:
for kw in 0..2:
id = d*stride - padding + kd*dilation
ih = h*stride - padding + kh*dilation
iw = w*stride - padding + kw*dilation
if in bounds:
max_val = max(max_val, input[n, c, id, ih, iw])
The problem is that adjacent threads with different w output positions will access input at iw that differ by stride=2, so they won't fully coalesce when loading from input.
But multiple threads with the same w but different c will be perfectly coalesced!
So a better strategy is to have a thread block process a batch of channels together for the same spatial output region.
For example:
- Block dimensions: (BW, BH, BD) = (8, 4, 4) for spatial
- Each block handles one (n) and a range of c values, say 8 channels at a time
- So we'd have 512 / 8 = 64 such channel groups
Actually, let me try a different approach. I'll use shared memory tiling where a block handles a tile of output.
For a block of (TX, TY, TZ) handling output region:
- x: w_out dimension, TX threads
- y: h_out dimension, TY threads
- z: d_out dimension, TZ threads
And each thread handles one channel? No, each thread handles one output element for ONE channel. Then we need to loop over channels in batches or have the channel as a separate dimension.
Better: Let the block have shape (WARP_SIZE=32, 1, 1) for channels, and then process spatial tiles. But then spatial needs to be mapped to blocks.
Actually, the simplest approach that might work well:
- Grid: (ceil_div(w_out, block_x), ceil_div(h_out, block_y), ceil_div(d_out, block_z) * ceil_div(channels, block_c) * batch)
- Block: (block_x, block_y, block_z) where block_x * block_y * block_z <= 256 or 512 or 1024
- Each thread computes one output element
But then each thread accesses strided memory.
An alternative:
- Have each thread handle one output element but vectorize in C dimension.
- Block shape: (32, 8, 1) - 32 threads for w dimension (since memory is contiguous in C then W), but wait...
Actually in NCDHW:
- n is outermost
- c is next
- d is next
- h is next
- w is innermost
So for fixed (n, c, d, h), w varies contiguously in memory. This is ideal! If threads in a block vary in w, they access contiguous memory when reading the same (kd, kh, kw).
But different output w positions correspond to different input w positions (spaced by stride=2). However, for a given kernel window position kw, the input w index is w*stride - padding + kw*dilation, which means adjacent threads with w differing by 1 will access input w differing by stride=2. They're still somewhat adjacent (stride 2 = 8 bytes apart for float), so memory accesses are coalesced but with some gaps.
Actually, coalescing on modern GPUs only requires that threads in a warp access addresses within a 128-byte cache line. With stride=2 and float32, adjacent threads access 8 bytes apart, so 32 threads cover 256 bytes = 2 cache lines. This is reasonably coalesced.
But here's an even better idea: use a shared memory tile where we load a chunk of input into shared memory, then all threads in the block can access it without going to global memory.
For a block handling (BW_out, BH_out, BD_out) output elements:
- Each output element at (w, h, d) requires input in range [w*stride - padding, w*stride - padding + (kernel-1)*dilation]
- The union of all needed input for the block is larger
With stride=2, kernel=3, dilation=3:
- Output w: 0, 1, 2, ..., 7 (for BW_out=8)
- Input w needed for kw=0: -1, 1, 3, 5, 7, 9, 11, 13
- Input w needed for kw=1: 2, 4, 6, 8, 10, 12, 14, 16
- Input w needed for kw=2: 5, 7, 9, 11, 13, 15, 17, 19
So the total input w range needed is from -1 to 19, which is 21 elements. But with dilation, these aren't all consecutive! The positions needed are: -1, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 19.
Actually wait, let me recalculate. For a given output w and kw:
iw = w * 2 - 1 + kw * 3
For w in [0, BW-1] and kw in [0, 2]:
iw = -1, 2, 5 for w=0
iw = 1, 4, 7 for w=1
iw = 3, 6, 9 for w=2
...
So the union of all iw values is from -1 to 2*(BW-1) - 1 + 2*3 = 2*BW - 2 + 5 = 2*BW + 3.
For BW=8: from -1 to 19. The unique values are fairly sparse but cover most integers in that range. Specifically, all odd numbers from -1 to 15 (from kw=0), all numbers ≡ 1 mod 2 from kw=1... wait, let me think again.
For kw=0: iw = 2w - 1 (odd numbers from -1 to 13)
For kw=1: iw = 2w + 2 (even numbers from 2 to 16)
For kw=2: iw = 2w + 5 (odd numbers from 5 to 19)
Union: -1, and then 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 19.
Missing in [0, 19]: 0, 18. So most values are used.
For a simple kernel, maybe the best approach is just a straightforward global-memory kernel but with the thread indexing optimized for coalescing.
Here's my plan:
- Use a block size of (32, 8, 1) = 256 threads
- Grid: (ceil_div(w_out, 32), ceil_div(h_out, 8), d_out * channels * batch / some_factor)
- Actually, let me map: threadIdx.x handles w dimension (32 threads = warp size, good for coalescing), threadIdx.y handles h dimension (8 threads), and we loop over d (depth) in outer dimensions or have blocks handle d and batch*channels.
- Wait, 32 * 8 = 256 threads per block.
Actually, since for fixed (n, c, d) and varying (h, w), memory accesses are contiguous in w but strided by H*W for different h... wait no.
In NCDHW:
offset = ((n * C + c) * D + d) * H + h) * W + w
So for fixed n, c, d, and varying h, w: offset changes by W when w changes, and by H*W when h changes.
If we have threads with threadIdx.x handling w (32 values) and threadIdx.y handling h (8 values), then for a fixed w reading position, threads with different h are W elements apart, which for W=128 means 128*4 = 512 bytes. That's still within a few cache lines for adjacent h's, and memory accesses will be coalesced by the L1 cache.
Actually, let me just write a direct kernel. Given that PyTorch's implementation has some overhead and may not perfectly optimize for this specific shape, a clean custom kernel could win.
Let me also consider: can we use implicit GEMM/shared memory for this? For maxpool, shared memory tiling can be beneficial because each input element is reused by multiple outputs.
For an output region of BW x BH x BD, each input element in the corresponding input tile is reused by up to kernel_size^3 = 27 outputs. So shared memory tiling with 27x reuse should be great!
Let me design a shared memory kernel.
Input tile for output block (BW_out, BH_out, BD_out):
- Input w range: [w_start*stride - padding, w_start*stride - padding + (BW_out-1)*stride + (kernel_size-1)*dilation]
- Width: (BW_out-1)*stride + (kernel_size-1)*dilation + 1 = (BW_out-1)*2 + 6 + 1 = 2*BW_out + 5
For BW_out=8: input width = 21
For BH_out=4: input height = 13
For BD_out=4: input depth = 13
Total shared memory per channel: 21 * 13 * 13 = 3549 floats = 14.2 KB.
For 32 channels processed together: 454 KB, which is too much.
We can process 2 channels together: ~28 KB. Or 4 channels: ~57 KB. Still fits in 228 KB.
Wait, but with 256 threads, if we load 21*13*13*4 = 3549*4 = 14,196 floats, that's about 55 loads per thread. That might be a lot.
Alternative: process one channel per block, with block size 256 and shared memory ~14 KB per channel. Then for 512 feature maps (16*32), we'd have 512 blocks. With 132 SMs on H100, that's about 4 blocks per SM, which is decent.
Or process 2 channels per block: shared memory ~28 KB, 256 blocks total, ~2 per SM.
Actually, let me think more carefully. The H100 has 132 SMs (I think). Each block of 256 threads uses one block slot. With max 32 blocks per SM, occupancy isn't limited by block count.
Let me go with a simpler approach first and see if we can compile it:
1. Write a direct global-memory kernel
2. Test if it's correct and fast
3. If not fast enough, try shared memory tiling
Actually, let me reconsider. For PyTorch's maxpool3d on large tensors, it's very likely CuDNN handles this and is already quite well optimized. But CuDNN has some launch overhead, and our kernel might be able to do better with specific shape knowledge.
One trick is to use implicit unrolling. With kernel_size=3, we know it's exactly 3x3x3=27 comparisons at compile time, so we can fully unroll the loops.
Another trick: fuse the padding check into a single conditional or pre-load mask.
Actually, I just realized: batch=16, channels=32, D=H=W=128. The total output is 122M elements. At ~3.3B operations, if we can sustain say 1TB/s memory bandwidth and each output requires reading 27 floats = 108 bytes on average (without shared memory reuse), that's 13 GB of memory reads + 0.5 GB writes. At 1TB/s, memory time would be 13.5 ms. But with cache, the actual reads are less. PyTorch's implementation likely achieves close to this.
Actually, let me compute more carefully. Each output reads a 3x3x3 neighborhood. With stride=2 and dilation=3, the neighborhoods for adjacent outputs overlap somewhat but not completely.
For w dimension: output w=0 reads at iw=-1,2,5; output w=1 reads at iw=1,4,7. Only some overlap from the dilation pattern.
Actually with stride=2 and dilation=3:
w=0: {-1, 2, 5}
w=1: {1, 4, 7}
w=2: {3, 6, 9}
w=3: {5, 8, 11}
...
So w=0 and w=3 share 5. The overlap is sparse.
Let me just write the kernel and test. I'll start with a fairly optimized direct kernel.
```python
import torch
from torch.utils.cpp_extension import load_inline
cuda_source = '''
#include <torch/extension.h>
#include <cuda_runtime.h>
#include <float.h>
template <typename scalar_t>
__global__ void maxpool3d_kernel(
const scalar_t* __restrict__ input,
scalar_t* __restrict__ output,
int batch, int channels, int depth, int height, int width,
int out_d, int out_h, int out_w,
int kernel_size, int stride, int padding, int dilation) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
int total_outputs = batch * channels * out_d * out_h * out_w;
if (idx >= total_outputs) return;
// Decode index
int w = idx % out_w;
idx /= out_w;
int h = idx % out_h;
idx /= out_h;
int d = idx % out_d;
idx /= out_d;
int c = idx % channels;
int n = idx / channels;
scalar_t max_val = -FLT_MAX;
int id_base = d * stride - padding;
int ih_base = h * stride - padding;
int iw_base = w * stride - padding;
for (int kd = 0; kd < kernel_size; ++kd) {
int id = id_base + kd * dilation;
if (id < 0 || id >= depth) continue;
for (int kh = 0; kh < kernel_size; ++kh) {
int ih = ih_base + kh * dilation;
if (ih < 0 || ih >= height) continue;
for (int kw = 0; kw < kernel_size; ++kw) {
int iw = iw_base + kw * dilation;
if (iw < 0 || iw >= width) continue;
int in_idx = ((n * channels + c) * depth + id) * height + ih;
in_idx = in_idx * width + iw;
scalar_t val = input[in_idx];
if (val > max_val) max_val = val;
}
}
}
int out_idx = ((n * channels + c) * out_d + d) * out_h + h;
out_idx = out_idx * out_w + w;
output[out_idx] = max_val;
}
torch::Tensor maxpool3d_cuda(torch::Tensor input, int kernel_size, int stride, int padding, int dilation) {
int batch = input.size(0);
int channels = input.size(1);
int depth = input.size(2);
int height = input.size(3);
int width = input.size(4);
int out_d = (depth + 2 * padding - dilation * (kernel_size - 1) - 1) / stride + 1;
int out_h = (height + 2 * padding - dilation * (kernel_size - 1) - 1) / stride + 1;
int out_w = (width + 2 * padding - dilation * (kernel_size - 1) - 1) / stride + 1;
auto output = torch::empty({batch, channels, out_d, out_h, out_w}, input.options());
int total_outputs = batch * channels * out_d * out_h * out_w;
const int threads = 256;
const int blocks = (total_outputs + threads - 1) / threads;
AT_DISPATCH_FLOATING_TYPES(input.scalar_type(), "maxpool3d_cuda", ([&] {
maxpool3d_kernel<scalar_t><<<blocks, threads>>>(
input.data_ptr<scalar_t>(), output.data_ptr<scalar_t>(),
batch, channels, depth, height, width,
out_d, out_h, out_w,
kernel_size, stride, padding, dilation);
}));
return output;
}
'''
functions = ["maxpool3d_cuda"]
```
Wait, but I need to be careful. The output size formula for `ceil_mode=False` is `floor((L + 2*padding - dilation*(kernel_size-1) - 1) / stride + 1)`. My formula is correct.
Let me also make sure I handle `ceil_mode` since it might be passed as True. Looking at the constructor, `ceil_mode` defaults to False. The user might pass it as True.
Also, `stride` can be None, in which case it defaults to `kernel_size`.
Let me add support for ceil_mode and None stride.
Actually, according to PyTorch docs, when stride is None, it equals kernel_size. And the output size with ceil_mode is:
out_size = ceil((L + 2*padding - dilation*(kernel_size-1) - 1) / stride + 1)
In the problem, `stride = 2` is passed, and `ceil_mode = False` by default. But the ModelNew constructor must accept the same arguments. Let me handle everything properly.
Let me refine the kernel:
```python
cuda_source = '''
#include <torch/extension.h>
#include <cuda_runtime.h>
#include <float.h>
template <typename scalar_t>
__global__ void maxpool3d_kernel(
const scalar_t* __restrict__ input,
scalar_t* __restrict__ output,
int batch, int channels, int depth, int height, int width,
int out_d, int out_h, int out_w,
int kernel_size, int stride, int padding, int dilation) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
int total_outputs = batch * channels * out_d * out_h * out_w;
if (idx >= total_outputs) return;
int w = idx % out_w;
int tmp = idx / out_w;
int h = tmp % out_h;
tmp /= out_h;
int d = tmp % out_d;
tmp /= out_d;
int c = tmp % channels;
int n = tmp / channels;
scalar_t max_val = -FLT_MAX;
#pragma unroll
for (int kd = 0; kd < 3; ++kd) {
int id = d * stride - padding + kd * dilation;
if (id < 0 || id >= depth) continue;
#pragma unroll
for (int kh = 0; kh < 3; ++kh) {
int ih = h * stride - padding + kh * dilation;
if (ih < 0 || ih >= height) continue;
#pragma unroll
for (int kw = 0; kw < 3; ++kw) {
int iw = w * stride - padding + kw * dilation;
if (iw < 0 || iw >= width) continue;
int in_idx = (((n * channels + c) * depth + id) * height + ih) * width + iw;
scalar_t val = input[in_idx];
max_val = (val > max_val) ? val : max_val;
}
}
}
int out_idx = (((n * channels + c) * out_d + d) * out_h + h) * out_w + w;
output[out_idx] = max_val;
}
torch::Tensor maxpool3d_cuda(torch::Tensor input, int kernel_size, int stride, int padding, int dilation, bool ceil_mode) {
int batch = input.size(0);
int channels = input.size(1);
int depth = input.size(2);
int height = input.size(3);
int width = input.size(4);
if (stride == -1) stride = kernel_size;
auto compute_out = [&](int in_size) {
if (ceil_mode) {
return (in_size + 2 * padding - dilation * (kernel_size - 1) - 1 + stride - 1) / stride + 1;
} else {
return (in_size + 2 * padding - dilation * (kernel_size - 1) - 1) / stride + 1;
}
};
int out_d = compute_out(depth);
int out_h = compute_out(height);
int out_w = compute_out(width);
auto output = torch::empty({batch, channels, out_d, out_h, out_w}, input.options());
int total_outputs = batch * channels * out_d * out_h * out_w;
const int threads = 256;
const int blocks = (total_outputs + threads - 1) / threads;
AT_DISPATCH_FLOATING_TYPES(input.scalar_type(), "maxpool3d_cuda", ([&] {
maxpool3d_kernel<scalar_t><<<blocks, threads>>>(
input.data_ptr<scalar_t>(), output.data_ptr<scalar_t>(),
batch, channels, depth, height, width,
out_d, out_h, out_w,
kernel_size, stride, padding, dilation);
}));
return output;
}
'''
```
Wait, I hardcoded `#pragma unroll` with 3 but the kernel_size might not always be 3. However, for this specific task, it will be. But I should make it generic for correctness. Actually, `#pragma unroll` without a number lets the compiler decide, or `#pragma unroll 3` requires exactly 3 iterations which fails if kernel_size is different.
Better approach: use `#pragma unroll` without value but with compile-time known bounds, or just use a generic loop.
Actually, I think the fastest kernel for this specific case would probably use `#pragma unroll` with small fixed values. Since this is a benchmark for specific parameters, I could write a specialized kernel. But for correctness against all possible inputs, I need generic loops.
Actually wait - I should check what the get_init_inputs returns. It returns [kernel_size, stride, padding, dilation]. So ceil_mode and return_indices are passed as defaults. The actual benchmark uses these exact values. But for general correctness, the kernel should handle any valid values.
Actually, I realize I can make it generic and still hope it's fast. Let me try a direct kernel first and see if it's competitive.
But actually, there might be a much better approach. What if I restructure the memory access to be more coalesced?
In my current design, each thread handles one (n, c, d, h, w) output. When threads in a warp have the same n, c, d, h but different w, they read input at positions that are spaced by `stride` in the w dimension. For stride=2, this is 8 bytes apart, which is still quite coalesced.
However, the better coalescing would be to have threads in a warp vary in c (channel) while keeping w, h, d fixed. Because then for a given iw, all threads read contiguous memory!
So if I structure the block as:
- threadIdx.x = c % 32 (or something)
- And have the block loop over channel groups
But then each thread is computing a different output in the c dimension. That's actually perfect for coalescing!
Let me redesign:
- Block size: (32, 8, 1) where threadIdx.x handles channel increments, threadIdx.y handles w (or h or d)
- Actually, let's say each warp handles 32 channels for the SAME spatial location. Then all loads are perfectly coalesced.
- But 32 channels might not divide evenly into the total channels.
Actually, channels=32 exactly, which is perfect for a warp of 32 threads!
So: if a block handles one spatial output position (d, h, w) across all 32 channels, that's only 32 threads. We can handle multiple such spatial positions per block.
Better yet:
- blockDim = (32, 8, 1) = 256 threads
- threadIdx.x = channel (0..31) within the warp
- threadIdx.y = w position (0..7) within the block
- The block also has some x-dimension for spatial positions
Wait, but then threads with different w within the same warp would have divergent memory accesses for the same channel, and coalescing would be poor.
Let me think about this more carefully. What if:
- Each thread handles one (n, c, d, h, w)
- We use 3D grid:
- gridDim.x = ceil(out_w / blockDim.x)
- gridDim.y = ceil(out_h / blockDim.y)
- gridDim.z = out_d * ceil(channels / blockDim.z) * batch
No, that's complicated.
Actually, the simplest good mapping might be:
- 1D grid/1D blocks
- Each thread handles one output element
- blockDim.x = 256 or 512 or 1024
With this, threads 0..255 in a block will have consecutive output indices, meaning they traverse w, then h, then d, then c, then n.
For the innermost dimension w: consecutive threads have consecutive w. For a given (n, c, d, h), threads read input at w positions that are consecutive in OUTPUT space, but spaced by stride in INPUT space.
Wait, let me think about the memory access pattern for the inner loop.
For two consecutive threads with w0 and w1 = w0 + 1:
At kw=0: iw0 = w0*stride - padding, iw1 = w1*stride - padding = iw0 + stride
So they read input elements that are `stride` floats apart. For stride=2, that's 8 bytes.
For a warp of 32 threads, they read 32 elements spaced by 8 bytes = 256 bytes total. This spans exactly 2 cache lines (128 bytes each). The memory controller can handle this with 2 transactions, which is good coalescing.
For the loops over kd and kh, all threads in the warp read the same relative offsets, maintaining the same spacing.
So I think the 1D mapping is actually quite good for coalescing!
Let me just go with it and test.
Actually, one more thing: PyTorch's ATen max_pool3d kernel is probably quite optimized too. It might even be implemented with shared memory tiling. So to beat it, I might need something clever.
One idea: for this specific case with kernel_size=3, stride=2, padding=1, dilation=3, what if we precompute which input indices are valid and loop only over valid ones? The padding means some indices are out of bounds, especially for outputs near the edges.
Actually, with padding=1 and in_size=128, out_size=62:
For d=0: id values are -1, 2, 5. Only id=2,5 are valid.
For d=1: id values are 1, 4, 7. All valid.
...
For d=61: id values are 121, 124, 127. All valid.
So for d=0, we skip 1/3 of the kd loop. Similar for h and w. The total fraction of skipped inner iterations is small for the interior but higher at boundaries.
To handle this, we can split the kernel into two parts: a main loop for interior positions where all 27 elements are valid, and edge handling where we need bounds checks.
But interior positions are those where:
d*stride - padding >= 0 and d*stride - padding + (kernel_size-1)*dilation < depth
=> d >= ceil(padding/stride) = ceil(1/2) = 1
=> d < (depth + padding - (kernel_size-1)*dilation + 1) / stride = (128 + 1 - 6 + 1) / 2 = 124 / 2 = 62
Since out_d = 62, d ranges from 0 to 61. So interior d is [1, 61], which is most of them.
For h and w similarly.
Actually, a two-kernel approach (one for interior without bounds checks, one for edges with bounds checks) could help. But it adds launch overhead.
Alternatively, we can check bounds once at the beginning and use different code paths.
Let me think about another optimization: for the specific case where kernel_size=3, we know the dilation pattern creates a specific access pattern. Could we handle each "dilation group" separately?
Actually, I think the two-kernel approach is promising. Let me try it:
1. An "interior" kernel that assumes all 27 input elements are in bounds, no conditionals inside the hot loop.
2. An "edge" kernel that handles positions where some input elements are out of bounds.
Interior positions: those where for all kd in [0, 2], id = d*stride - padding + kd*dilation is in [0, depth-1].
This requires d*stride - padding >= 0 and d*stride - padding + 2*dilation < depth.
d >= ceil(padding/stride) = 1
d < (depth - 2*dilation + padding + 1) / stride = (128 - 6 + 1 + 1) / 2 = 124 / 2 = 62
Wait, out_d = 62, so d ranges 0..61. Interior d is [1, 61], which is all but d=0.
Similarly for h and w. So the edge is only one plane thick in each dimension.
The total interior fraction is (61/62)^3 ≈ 0.985, so 98.5% of outputs can use the fast path!
For the fast path, we can completely eliminate the if statements inside the loop. That's a significant optimization.
Let me write the kernel with two paths. I'll have a single kernel that checks at the start whether it's an interior position, and uses different loops.
Actually, even better: launch two separate kernels! This avoids the branch entirely.
But the launch overhead of a second tiny kernel might not be worth it. Let me just use a single kernel with a branch.
Actually, even simpler: in the single kernel, I can check once at the start if the position is interior, and if so, use an unrolled loop without bounds checks.
```cpp
bool interior = (d >= 1 && d < out_d - 0 && h >= 1 && h < out_h - 0 && w >= 1 && w < out_w - 0);
```
Wait, with out_d = 62, the condition d < out_d - 0 means d < 62, i.e., d <= 61. Since d ranges 0..61, this is always true for d. So interior just needs d >= 1, h >= 1, w >= 1.
Let me verify:
For d = 61: id_base = 61*2 - 1 = 121. With kd=2: id = 121 + 6 = 127 < 128. Good.
For d = 0: id_base = -1. With kd=0: id = -1 < 0. Bad.
So interior: d >= 1, h >= 1, w >= 1.
For the interior path, I can use `#pragma unroll` on all three loops and no bounds checks inside.
Let me also think about register pressure. With kernel_size=3 and all loops unrolled, the compiler will unroll 27 iterations, each reading memory. This should be fine.
Another optimization: precompute the base offset for (n, c) and use it for all loads.
```cpp
int nc_offset = (n * channels + c) * depth * height * width;
int d_offset_base = d * stride - padding;
```
Then inside the loop: `input[nc_offset + (d_offset_base + kd * dilation) * height * width + ...]`
Actually, let me keep it simple but clean.
I'll also check if using `__ldg` (load through read-only cache) helps. For input tensors that are read-only, `__ldg` can improve caching behavior.
OK let me write the kernel code:
```cpp
template <typename scalar_t>
__global__ void maxpool3d_kernel(
const scalar_t* __restrict__ input,
scalar_t* __restrict__ output,
int batch, int channels, int depth, int height, int width,
int out_d, int out_h, int out_w,
int kernel_size, int stride, int padding, int dilation) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
int total_outputs = batch * channels * out_d * out_h * out_w;
if (idx >= total_outputs) return;
int w = idx % out_w;
int tmp = idx / out_w;
int h = tmp % out_h;
tmp /= out_h;
int d = tmp % out_d;
tmp /= out_d;
int c = tmp % channels;
int n = tmp / channels;
int nc_offset = (n * channels + c) * depth * height * width;
int d_base = d * stride - padding;
int h_base = h * stride - padding;
int w_base = w * stride - padding;
scalar_t max_val = -FLT_MAX;
if (d >= 1 && h >= 1 && w >= 1) {
// Fast path: all indices in bounds
#pragma unroll
for (int kd = 0; kd < 3; ++kd) {
int id = d_base + kd * dilation;
#pragma unroll
for (int kh = 0; kh < 3; ++kh) {
int ih = h_base + kh * dilation;
#pragma unroll
for (int kw = 0; kw < 3; ++kw) {
int iw = w_base + kw * dilation;
scalar_t val = input[nc_offset + ((id * height) + ih) * width + iw];
max_val = (val > max_val) ? val : max_val;
}
}
}
} else {
#pragma unroll
for (int kd = 0; kd < 3; ++kd) {
int id = d_base + kd * dilation;
if (id < 0 || id >= depth) continue;
#pragma unroll
for (int kh = 0; kh < 3; ++kh) {
int ih = h_base + kh * dilation;
if (ih < 0 || ih >= height) continue;
#pragma unroll
for (int kw = 0; kw < 3; ++kw) {
int iw = w_base + kw * dilation;
if (iw < 0 || iw >= width) continue;
scalar_t val = input[nc_offset + ((id * height) + ih) * width + iw];
max_val = (val > max_val) ? val : max_val;
}
}
}
}
int out_idx = ((n * channels + c) * out_d + d) * out_h + h;
out_idx = out_idx * out_w + w;
output[out_idx] = max_val;
}
```
Wait, `kernel_size` is a runtime parameter, so `#pragma unroll` with a fixed iteration count won't work unless we use template specialization. Actually, `#pragma unroll` without a count might still unroll small constant loops if the compiler can see the value. But here kernel_size is passed as a function argument, so the compiler probably can't determine it's 3.
To force unrolling, I could use a switch statement based on kernel_size at runtime, but that's ugly. Alternatively, since this is a performance-critical benchmark for a specific size, I can hardcode kernel_size=3 or use C++ template specialization.
Actually, let me try a macro-based approach or constexpr. But kernel_size is a function argument...
A better approach is to pass it as a template parameter:
```cpp
template <typename scalar_t, int KS>
__global__ void maxpool3d_kernel(...) {
#pragma unroll
for (int kd = 0; kd < KS; ++kd) { ... }
}
// In launcher:
switch (kernel_size) {
case 1: maxpool3d_kernel<scalar_t, 1><<<...>>>(); break;
case 2: maxpool3d_kernel<scalar_t, 2><<<...>>>(); break;
case 3: maxpool3d_kernel<scalar_t, 3><<<...>>>(); break;
// ...
}
```
This is a common optimization technique. Let me do this.
For even better performance, I can also template on stride, padding, dilation - but that gets messy. I'll template on kernel_size since it directly controls loop unrolling.
Actually, I can template on kernel_size and also on an "is_interior" flag or similar. But let's keep it manageable.
Let me now think about shared memory. Is the direct approach likely to beat PyTorch? PyTorch's maxpool3d might use a shared memory tile approach. Let me check if shared memory can help here.
Actually, with dilation=3 and stride=2, the amount of shared memory needed per output tile is relatively large compared to the output tile size. Let me compute:
If a block processes a tile of (TILE_D, TILE_H, TILE_W) outputs:
- Input depth needed: (TILE_D - 1) * stride + (kernel_size - 1) * dilation + 1
= (TILE_D - 1) * 2 + 6 + 1 = 2*TILE_D + 5
- Input height: 2*TILE_H + 5
- Input width: 2*TILE_W + 5
For TILE_D=TILE_H=TILE_W=4: input size = 13*13*13 = 2197 elements.
For a block of 256 threads: each thread loads ~8.5 elements. Then each thread computes 64 outputs (since 256 threads / 4 outputs per thread wait no).
Actually, if 256 threads compute 4*4*4 = 64 outputs, that's 4 threads per output element. Or we could have each thread compute one output, and only need 64 threads.
Let me think about another tiling strategy: have each block process one "channel slice" across a spatial tile.
For example:
- Block size: (32, 8, 1)
- Each warp (32 threads) processes 32 output positions in the W dimension for one (d, h).
- There are 8 warps in the block (via threadIdx.y), each handling a different h.
- The block loops over d.
- For each (d, h), all 32 threads load the necessary input line into shared memory.
Actually, let me reconsider. A simpler tiling that might work well:
Have a block of 256 threads process a 4x4x4 output tile for ONE channel. That's 64 outputs, so each output is computed by 4 threads? No, each thread should compute one output ideally.
Wait, 256 threads and 64 outputs means 4 threads per output? That doesn't make sense for maxpool. Each output requires its own max computation.
Actually, I could have the block process a larger output tile. For example:
- 8x8x4 = 256 outputs, one per thread. Perfect!
- Input tile: 21 x 21 x 13 = 5733 elements.
- Shared memory: 5733 * 4 bytes = 22.9 KB. Fits easily!
Oh wait, I computed earlier:
For TILE_W=8: input width = 2*8 + 5 = 21
For TILE_H=8: input height = 21
For TILE_D=4: input depth = 2*4 + 5 = 13
Total: 21 * 21 * 13 = 5733 floats = 22.9 KB.
That's well within the 227 KB limit per block! In fact, I could go even larger:
- TILE_W=8, TILE_H=8, TILE_D=8: output = 512, need 512 threads.
Input: 21 x 21 x 21 = 9261 floats = 37 KB.
Or:
- TILE_W=16, TILE_H=8, TILE_D=4: output = 512, threads = 512.
Input: 37 x 21 x 13 = 10101 floats = 40.4 KB.
Or to use 1024 threads:
- TILE_W=8, TILE_H=8, TILE_D=16: output = 1024.
Input: 21 x 21 x 37 = 16317 floats = 65.3 KB.
All well within limits.
With shared memory tiling, each input element is loaded once and reused by all outputs in the tile that need it. For a 8x8x8 output tile, each interior input element is reused by approximately 27 outputs (the kernel size). This significantly reduces global memory reads.
But there's a catch: with dilation=3 and stride=2, the input elements needed are sparse. Many input elements in the bounding box aren't actually used. But we still load the entire bounding box into shared memory.
For TILE_W=8, input width=21. But the actual unique input w positions needed are about 19 distinct values (as calculated earlier). So we load 21 elements but only use ~19. That's a small waste.
Actually, with the dilation pattern, consecutive output w positions need quite spread-out input w positions. Let me reconsider whether shared memory tiling is actually beneficial here.
For output w=0: needs {-1, 2, 5}
For output w=1: needs {1, 4, 7}
...
For output w=7: needs {13, 16, 19}
The union is 19 distinct w positions. So loading a contiguous tile of 21 w positions into shared memory means we load some positions that aren't needed (0, 18 for this case? Let me check). Actually earlier I found -1 and the range [1, 19] covers mostly everything except some holes. Let me recheck:
For w in [0, 7]:
- kw=0: { -1, 1, 3, 5, 7, 9, 11, 13 } (odd values from -1 to 13)
- kw=1: { 2, 4, 6, 8, 10, 12, 14, 16 } (even values from 2 to 16)
- kw=2: { 5, 7, 9, 11, 13, 15, 17, 19 } (odd values from 5 to 19)
Union:
Even: {2, 4, 6, 8, 10, 12, 14, 16} = 8 values
Odd: {-1, 1, 3, 5, 7, 9, 11, 13, 15, 17, 19} = 11 values
Total: 19 distinct values.
A contiguous range from -1 to 19 has 21 values. So we load 21 but only use 19. That's a 9.5% waste. Not bad.
Similarly for d and h.
So shared memory tiling still seems beneficial. Let me design a proper shared memory kernel.
But wait - shared memory tiling requires careful synchronization and indexing. For a first attempt, let me just try the direct kernel and see how close it is to PyTorch.
Actually, you know what, let me try both approaches. But first, the direct kernel is easier to write and less bug-prone. Let me write a fairly optimized direct kernel:
1. Template on kernel_size for loop unrolling
2. Fast path for interior positions (no bounds checks)
3. Good indexing to minimize arithmetic
Then compile and test correctness, then benchmark.
Here's my plan for the code:
```python
import torch
from torch.utils.cpp_extension import load_inline
cpp_source = '''
#include <torch/extension.h>
#include <cuda_runtime.h>
#include <float.h>
template <typename scalar_t, int KS>
__global__ void maxpool3d_kernel(
const scalar_t* __restrict__ input,
scalar_t* __restrict__ output,
int batch, int channels, int depth, int height, int width,
int out_d, int out_h, int out_w,
int stride, int padding, int dilation) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
int total_outputs = batch * channels * out_d * out_h * out_w;
if (idx >= total_outputs) return;
int w = idx % out_w;
int tmp = idx / out_w;
int h = tmp % out_h;
tmp /= out_h;
int d = tmp % out_d;
tmp /= out_d;
int c = tmp % channels;
int n = tmp / channels;
int nc_offset = (n * channels + c) * depth * height * width;
int d_base = d * stride - padding;
int h_base = h * stride - padding;
int w_base = w * stride - padding;
bool is_edge = (d_base < 0 || h_base < 0 || w_base < 0 ||
d_base + (KS - 1) * dilation >= depth ||
h_base + (KS - 1) * dilation >= height ||
w_base + (KS - 1) * dilation >= width);
scalar_t max_val = -FLT_MAX;
if (!is_edge) {
#pragma unroll
for (int kd = 0; kd < KS; ++kd) {
int id = d_base + kd * dilation;
#pragma unroll
for (int kh = 0; kh < KS; ++kh) {
int ih = h_base + kh * dilation;
#pragma unroll
for (int kw = 0; kw < KS; ++kw) {
int iw = w_base + kw * dilation;
scalar_t val = input[nc_offset + ((id * height) + ih) * width + iw];
max_val = (val > max_val) ? val : max_val;
}
}
}
} else {
#pragma unroll
for (int kd = 0; kd < KS; ++kd) {
int id = d_base + kd * dilation;
if (id < 0 || id >= depth) continue;
#pragma unroll
for (int kh = 0; kh < KS; ++kh) {
int ih = h_base + kh * dilation;
if (ih < 0 || ih >= height) continue;
#pragma unroll
for (int kw = 0; kw < KS; ++kw) {
int iw = w_base + kw * dilation;
if (iw < 0 || iw >= width) continue;
scalar_t val = input[nc_offset + ((id * height) + ih) * width + iw];
max_val = (val > max_val) ? val : max_val;
}
}
}
}
int out_idx = ((n * channels + c) * out_d + d) * out_h + h;
out_idx = out_idx * out_w + w;
output[out_idx] = max_val;
}
#define DISPATCH_KS(KS, ...) \\
switch (KS) { \\
case 1: __VA_ARGS__(1); break; \\
case 2: __VA_ARGS__(2); break; \\
case 3: __VA_ARGS__(3); break; \\
case 4: __VA_ARGS__(4); break; \\
case 5: __VA_ARGS__(5); break; \\
case 6: __VA_ARGS__(6); break; \\
case 7: __VA_ARGS__(7); break; \\
default: TORCH_CHECK(false, "Unsupported kernel size"); \\
}
torch::Tensor maxpool3d_cuda(torch::Tensor input, int kernel_size, int stride, int padding, int dilation, bool ceil_mode) {
int batch = input.size(0);
int channels = input.size(1);
int depth = input.size(2);
int height = input.size(3);
int width = input.size(4);
if (stride == -1) stride = kernel_size;
auto compute_out = [&](int in_size) {
if (ceil_mode) {
return (in_size + 2 * padding - dilation * (kernel_size - 1) - 1 + stride - 1) / stride + 1;
} else {
return (in_size + 2 * padding - dilation * (kernel_size - 1) - 1) / stride + 1;
}
};
int out_d = compute_out(depth);
int out_h = compute_out(height);
int out_w = compute_out(width);
auto output = torch::empty({batch, channels, out_d, out_h, out_w}, input.options());
int total_outputs = batch * channels * out_d * out_h * out_w;
const int threads = 512;
const int blocks = (total_outputs + threads - 1) / threads;
#define LAUNCH(KS) \\
maxpool3d_kernel<scalar_t, KS><<<blocks, threads>>>( \\
input.data_ptr<scalar_t>(), output.data_ptr<scalar_t>(), \\
batch, channels, depth, height, width, \\
out_d, out_h, out_w, \\
stride, padding, dilation);
AT_DISPATCH_FLOATING_TYPES(input.scalar_type(), "maxpool3d_cuda", ([&] {
DISPATCH_KS(kernel_size, LAUNCH);
}));
return output;
}
'''
```
Wait, the macro `DISPATCH_KS` with `__VA_ARGS__` is tricky. Let me make it cleaner.
Actually, a simpler dispatch:
```cpp
#define DISPATCH_KERNEL(KS, ...) \
switch (KS) { \
case 1: __VA_ARGS__(scalar_t, 1); break; \
case 2: __VA_ARGS__(scalar_t, 2); break; \
case 3: __VA_ARGS__(scalar_t, 3); break; \
case 4: __VA_ARGS__(scalar_t, 4); break; \
case 5: __VA_ARGS__(scalar_t, 5); break; \
case 6: __VA_ARGS__(scalar_t, 6); break; \
case 7: __VA_ARGS__(scalar_t, 7); break; \
default: TORCH_CHECK(false, "Unsupported kernel size: ", KS); \
}
```
And then inside:
```cpp
AT_DISPATCH_FLOATING_TYPES(input.scalar_type(), "maxpool3d_cuda", ([&] {
DISPATCH_KERNEL(kernel_size, DISPATCH_FUNC);
}));
```
Where `DISPATCH_FUNC` is another macro... this gets messy. Let me write it more explicitly.
Actually, I can avoid the macro dispatch and just use a plain switch. The code will be more verbose but cleaner:
```cpp
AT_DISPATCH_FLOATING_TYPES(input.scalar_type(), "maxpool3d_cuda", ([&] {
switch (kernel_size) {
case 1:
maxpool3d_kernel<scalar_t, 1><<<blocks, threads>>>(...);
break;
case 2:
maxpool3d_kernel<scalar_t, 2><<<blocks, threads>>>(...);
break;
case 3:
maxpool3d_kernel<scalar_t, 3><<<blocks, threads>>>(...);
break;
// etc
default:
TORCH_CHECK(false, "Unsupported kernel size");
}
}));
```
This is more straightforward.
Now, about `stride == -1` to detect None: in Python, when we pass `None` to C++, it won't be -1. I need to handle None in Python:
```python
stride = self.stride if self.stride is not None else self.kernel_size
```
Wait, in the constructor, `stride` is stored. If it's None, then in the Python forward method I should compute the effective stride. Or I could just pass it as -1 from Python.
Actually, let me look at what happens. The constructor receives `stride` as an argument. If it's None, I should convert it before calling the CUDA kernel.
In the `ModelNew.forward`:
```python
effective_stride = self.stride if self.stride is not None else self.kernel_size
return maxpool3d_cuda(x, self.kernel_size, effective_stride, self.padding, self.dilation, self.ceil_mode)
```
Now, what about `return_indices`? The reference returns just the tensor (since return_indices=False by default). If return_indices=True, we need to return indices too. But looking at get_init_inputs, it doesn't return return_indices or ceil_mode, so they use the defaults (False, False). I should support them for completeness though.
Actually, looking at the reference more carefully:
- `get_init_inputs()` returns `[kernel_size, stride, padding, dilation]`
- So `ceil_mode` defaults to False and `return_indices` defaults to False.
For simplicity, I can just support the common case but I should probably support return_indices too for correctness if it's passed.
If return_indices=True, the output is `(values, indices)`. Implementing indices in CUDA is more work but doable. Let me add basic support for it.
Actually, for the CUDA kernel, computing indices means tracking the argmax:
```cpp
scalar_t max_val = -FLT_MAX;
int max_idx = -1;
// ...
if (val > max_val) {
max_val = val;
max_idx = ...; // compute flat index
}
```
Then if return_indices is true, we output both. But PyTorch's MaxPool3d with return_indices returns indices in the flattened input format for the kernel window, not the global flattened index. Actually, looking at PyTorch docs: the returned indices are in the shape of the output and contain the indices of the max elements in the input tensor.
Wait, the indices are flattened with respect to the kernel window, I think? Let me check...
Actually, for `nn.MaxPool3d(return_indices=True)`, the returned indices have the same shape as the output, and the values are the indices in the flattened input tensor (i.e., the index in the original NCDHW tensor). Let me verify this by checking the PyTorch documentation or source.
From PyTorch docs: "If return_indices is True, this will have the indices of the returned values." And the indices correspond to the flattened input tensor.
So I need to compute `max_idx = ((n * channels + c) * depth + id) * height + ih) * width + iw` and store it.
But actually, for return_indices=True, the benchmark might test with get_init_inputs that doesn't specify return_indices. Since the default is False, I might be safe just supporting False. But to be correct for all constructor arguments, I should support it.
However, for performance optimization, I'll primarily optimize the return_indices=False path since that's what the benchmark uses.
Let me now check: looking at the provided code, `get_init_inputs()` only returns [kernel_size, stride, padding, dilation]. So the benchmark passes:
- kernel_size=3
- stride=2
- padding=1
- dilation=3
And the other args use defaults: return_indices=False, ceil_mode=False.
So I can focus on optimizing this specific case. But I should still support the general API.
Let me write the complete code. I'll support return_indices in the kernel but only implement the fast path without it for simplicity, and add indices support if needed.
Actually wait, one more thought. Could I use `at::cuda::` APIs or `cub::` for the max-reduction? Probably not easily for this irregular access pattern.
Let me write the complete code now.
For the interior edge check, let me be more precise:
`is_edge = (d_base < 0) || (d_base + (KS-1) * dilation >= depth) || ...`
With the benchmark parameters:
- d_base = d*2 - 1
- edge if d*2 - 1 < 0 (i.e., d < 1) or d*2 - 1 + 6 >= 128 (i.e., d*2 >= 123, d >= 61.5, so d >= 62)
- Since d ranges 0..61, edge if d == 0.
Similarly for h and w. So position is interior if d >= 1 && h >= 1 && w >= 1.
Let me now write the full Python module.
```python
import torch
import torch.nn as nn
from torch.utils.cpp_extension import load_inline
cuda_source = """
#include <torch/extension.h>
#include <cuda_runtime.h>
#include <float.h>
template <typename scalar_t, int KS>
__global__ void maxpool3d_kernel(
const scalar_t* __restrict__ input,
scalar_t* __restrict__ output,
int64_t batch, int64_t channels, int64_t depth, int64_t height, int64_t width,
int64_t out_d, int64_t out_h, int64_t out_w,
int64_t stride, int64_t padding, int64_t dilation) {
int64_t idx = blockIdx.x * blockDim.x + threadIdx.x;
int64_t total_outputs = batch * channels * out_d * out_h * out_w;
if (idx >= total_outputs) return;
int64_t w = idx % out_w;
int64_t tmp = idx / out_w;
int64_t h = tmp % out_h;
tmp /= out_h;
int64_t d = tmp % out_d;
tmp /= out_d;
int64_t c = tmp % channels;
int64_t n = tmp / channels;
int64_t nc_offset = (n * channels + c) * depth * height * width;
int64_t d_base = d * stride - padding;
int64_t h_base = h * stride - padding;
int64_t w_base = w * stride - padding;
bool is_edge = (d_base < 0) || (d_base + (KS - 1) * dilation >= depth) ||
(h_base < 0) || (h_base + (KS - 1) * dilation >= height) ||
(w_base < 0) || (w_base + (KS - 1) * dilation >= width);
scalar_t max_val = -FLT_MAX;
if (!is_edge) {
#pragma unroll
for (int kd = 0; kd < KS; ++kd) {
int64_t id = d_base + kd * dilation;
int64_t id_offset = nc_offset + id * height * width;
#pragma unroll
for (int kh = 0; kh < KS; ++kh) {
int64_t ih = h_base + kh * dilation;
int64_t ih_offset = id_offset + ih * width;
#pragma unroll
for (int kw = 0; kw < KS; ++kw) {
int64_t iw = w_base + kw * dilation;
scalar_t val = input[ih_offset + iw];
max_val = (val > max_val) ? val : max_val;
}
}
}
} else {
#pragma unroll
for (int kd = 0; kd < KS; ++kd) {
int64_t id = d_base + kd * dilation;
if (id < 0 || id >= depth) continue;
int64_t id_offset = nc_offset + id * height * width;
#pragma unroll
for (int kh = 0; kh < KS; ++kh) {
int64_t ih = h_base + kh * dilation;
if (ih < 0 || ih >= height) continue;
int64_t ih_offset = id_offset + ih * width;
#pragma unroll
for (int kw = 0; kw < KS; ++kw) {
int64_t iw = w_base + kw * dilation;
if (iw < 0 || iw >= width) continue;
scalar_t val = input[ih_offset + iw];
max_val = (val > max_val) ? val : max_val;
}
}
}
}
int64_t out_idx = ((n * channels + c) * out_d + d) * out_h + h;
out_idx = out_idx * out_w + w;
output[out_idx] = max_val;
}
torch::Tensor maxpool3d_forward_cuda(
torch::Tensor input,
int64_t kernel_size,
int64_t stride,
int64_t padding,
int64_t dilation,
bool ceil_mode) {
int64_t batch = input.size(0);
int64_t channels = input.size(1);
int64_t depth = input.size(2);
int64_t height = input.size(3);
int64_t width = input.size(4);
if (stride == -1) stride = kernel_size;
auto compute_out = [&](int64_t in_size) {
if (ceil_mode) {
return (in_size + 2 * padding - dilation * (kernel_size - 1) - 1 + stride - 1) / stride + 1;
} else {
return (in_size + 2 * padding - dilation * (kernel_size - 1) - 1) / stride + 1;
}
};
int64_t out_d = compute_out(depth);
int64_t out_h = compute_out(height);
int64_t out_w = compute_out(width);
auto output = torch::empty({batch, channels, out_d, out_h, out_w}, input.options());
int64_t total_outputs = batch * channels * out_d * out_h * out_w;
const int threads = 512;
const int blocks = (total_outputs + threads - 1) / threads;
AT_DISPATCH_FLOATING_TYPES(input.scalar_type(), "maxpool3d_forward_cuda", ([&] {
switch (kernel_size) {
case 1:
maxpool3d_kernel<scalar_t, 1><<<blocks, threads>>>(
input.data_ptr<scalar_t>(), output.data_ptr<scalar_t>(),
batch, channels, depth, height, width,
out_d, out_h, out_w,
stride, padding, dilation);
break;
case 2:
maxpool3d_kernel<scalar_t, 2><<<blocks, threads>>>(
input.data_ptr<scalar_t>(), output.data_ptr<scalar_t>(),
batch, channels, depth, height, width,
out_d, out_h, out_w,
stride, padding, dilation);
break;
case 3:
maxpool3d_kernel<scalar_t, 3><<<blocks, threads>>>(
input.data_ptr<scalar_t>(), output.data_ptr<scalar_t>(),
batch, channels, depth, height, width,
out_d, out_h, out_w,
stride, padding, dilation);
break;
case 4:
maxpool3d_kernel<scalar_t, 4><<<blocks, threads>>>(
input.data_ptr<scalar_t>(), output.data_ptr<scalar_t>(),
batch, channels, depth, height, width,
out_d, out_h, out_w,
stride, padding, dilation);
break;
case 5:
maxpool3d_kernel<scalar_t, 5><<<blocks, threads>>>(
input.data_ptr<scalar_t>(), output.data_ptr<scalar_t>(),
batch, channels, depth, height, width,
out_d, out_h, out_w,
stride, padding, dilation);
break;
case 6:
maxpool3d_kernel<scalar_t, 6><<<blocks, threads>>>(
input.data_ptr<scalar_t>(), output.data_ptr<scalar_t>(),
batch, channels, depth, height, width,
out_d, out_h, out_w,
stride, padding, dilation);
break;
case 7:
maxpool3d_kernel<scalar_t, 7><<<blocks, threads>>>(
input.data_ptr<scalar_t>(), output.data_ptr<scalar_t>(),
batch, channels, depth, height, width,
out_d, out_h, out_w,
stride, padding, dilation);
break;
default:
TORCH_CHECK(false, "Unsupported kernel size: ", kernel_size);
}
}));
return output;
}
"""
# Compile
maxpool3d_module = load_inline(
name="maxpool3d_cuda",
cpp_sources="",
cuda_sources=cuda_source,
functions=["maxpool3d_forward_cuda"],
extra_cuda_cflags=["-O3", "--use_fast_math"],
verbose=False,
)
class ModelNew(nn.Module):
def __init__(self, kernel_size: int, stride: int = None, padding: int = 0, dilation: int = 1, return_indices: bool = False, ceil_mode: bool = False):
super().__init__()
self.kernel_size = kernel_size
self.stride = stride
self.padding = padding
self.dilation = dilation
self.return_indices = return_indices
self.ceil_mode = ceil_mode
def forward(self, x: torch.Tensor) -> torch.Tensor:
if self.return_indices:
raise NotImplementedError("return_indices=True is not supported in the optimized kernel")
effective_stride = self.stride if self.stride is not None else self.kernel_size
return maxpool3d_module.maxpool3d_forward_cuda(
x, self.kernel_size, effective_stride, self.padding, self.dilation, self.ceil_mode
)
```
Hmm, a few issues:
1. `cpp_sources` needs to be provided properly, or I can use `cpp_sources=""` and `cuda_sources=cuda_source`. Actually, looking at the PyTorch docs for `load_inline`, there's `cpp_sources` and `cuda_sources`. If I only have CUDA code, I can put it in `cuda_sources` and use `cpp_sources=""` or omit it.
2. Wait, does `load_inline` with both work? Let me check...
Actually, in the problem description, it says "Do not write your own `PYBIND11_MODULE(...)` block in `cpp_sources`. `load_inline` auto-generates one from the `functions=[...]` argument". This implies we should put our wrapper functions in `cpp_sources` and the device kernels in `cuda_sources`. Or we can put everything in `cuda_sources` since the wrapper function is also CUDA-compatible (it's just a host function that launches kernels).
Wait, `torch::Tensor maxpool3d_forward_cuda(...)` is a host function callable from Python. If I put it in `cuda_sources`, will it be compiled as host code? In `.cu` files, functions without `__device__` or `__global__` are compiled as host code. So yes, it should work.
But some versions of `load_inline` might require `cpp_sources` for host functions and `cuda_sources` for device code. Let me check the common pattern...
Looking at typical PyTorch examples:
```python
load_inline(
name="my_ext",
cpp_sources="torch::Tensor my_func(torch::Tensor x);",
cuda_sources="__global__ void kernel(...) { ... }",
functions=["my_func"],
)
```
So usually, the host wrappers are in `cpp_sources` and the CUDA kernels are in `cuda_sources`. But we can also just put the host wrapper in `cpp_sources` and reference the device kernels that are declared in `cuda_sources`.
Actually, to be safe, I'll put the host wrapper in `cpp_sources` and the kernel in `cuda_sources`, with a forward declaration.
Wait, but if they're compiled as separate translation units, we need proper linkage. In NVCC, both are compiled together since `.cu` files embed the host code.
Actually, I think the simplest approach is to put everything in `cpp_sources` and rely on NVCC compiling it. No wait - `cpp_sources` is compiled with C++ compiler, not NVCC. So `__global__` won't work there.
Let me verify by looking up `torch.utils.cpp_extension.load_inline` more carefully.
The typical pattern is:
- `cuda_sources`: code compiled by nvcc, can contain `__device__` and `__global__` functions
- `cpp_sources`: code compiled by C++ compiler, contains pybind11 bindings and host wrapper functions
- But host wrapper functions in `cpp_sources` can't directly call `__global__` functions unless they're declared.
So I need:
1. In `cuda_sources`: define the `__global__` kernel and maybe also the host wrapper
2. Or in `cpp_sources`: declare the kernel as `void kernel(...)` (without `__global__`) and call it, while defining it in `cuda_sources`
Wait, you can't call `__global__` functions from regular C++ code. The `<<<>>>` syntax is only in CUDA.
So the host wrapper that launches the kernel MUST be in a `.cu` file (i.e., `cuda_sources`).
But `load_inline` says:
> `cuda_sources` (string or list of str) – CUDA source code, or list of source files
And typically, people put the whole thing in `cuda_sources` if it includes both host and device code.
Let me look at a concrete example from PyTorch docs:
```python
import torch
from torch.utils.cpp_extension import load_inline
cuda_source = '''
__global__ void custom_kernel(float* data, int size) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
if (idx < size) {
data[idx] = data[idx] * 2.0f;
}
}
void launch_custom_kernel(torch::Tensor data) {
int size = data.numel();
custom_kernel<<<(size + 255) / 256, 256>>>(data.data_ptr<float>(), size);
}
'''
module = load_inline(name='custom_kernel', cuda_sources=cuda_source, functions=['launch_custom_kernel'])
data = torch.randn(10, device='cuda')
module.launch_custom_kernel(data)
```
So yes, the host launcher function goes in `cuda_sources`! And `functions=['launch_custom_kernel']` refers to the host function.
OK so I should put everything in `cuda_sources`.
Let me write the complete code now.
One more optimization idea: instead of using 1D indexing with division/modulo, I could use 3D grids. But 1D with linear thread index is often fine because the division by constants can be optimized by the compiler.
Actually, with `out_w`, `out_h`, `out_d` being runtime variables, the compiler might not optimize the divisions. But on modern GPUs, integer division is relatively fast, and we only do it once per thread. For 122M outputs with 512 threads per block = 239K blocks, each thread does a few integer divisions. That's negligible compared to 27 memory loads and comparisons.
Wait, but threads per block: I used 512. Could I use 1024 for better occupancy? The kernel is fairly lightweight, so 512 or 1024 should both be fine. Let me start with 512.
Actually, an important consideration: register usage. If the kernel uses too many registers, occupancy drops. With KS=3 and full unrolling, the compiler will need to keep track of 27 loads. But it can assign registers efficiently. Let me estimate: maybe 24-32 registers per thread. With 512 threads per block, that's 12K-16K registers. An SM has 64K registers, so we can run at most 4 blocks per SM (4 * 16K = 64K). But the limit of blocks per SM is 32, so we're limited by register count to 4 blocks per SM.
With 132 SMs and 4 blocks each = 528 blocks concurrently. We need 239K blocks, so that's fine.
If I use 256 threads per block, register usage stays the same per thread, but we have more blocks. Let me think about which is better.
Actually, using 1024 threads per block would mean fewer blocks (122K blocks), and if each block uses ~32 registers, 1024 * 32 = 32K registers per block, so 2 blocks per SM = 264 blocks concurrently. That might be enough.
I'll go with 512 threads per block as a reasonable middle ground.
One more thing: should I use `int` instead of `int64_t` for indices? The maximum index for this problem is:
batch * channels * depth * height * width = 16 * 32 * 128^3 = 67,108,864, which fits in a 32-bit signed int (max ~2.1B).
So I can safely use `int` for all indices, which is faster on GPU than `int64_t`. Let me change to `int`.
Actually wait, the output index product is 16 * 32 * 62^3 = 122M, which also fits in int.
Let me rewrite with `int` for better performance.
```cpp
template <typename scalar_t, int KS>
__global__ void maxpool3d_kernel(
const scalar_t* __restrict__ input,
scalar_t* __restrict__ output,
int batch, int channels, int depth, int height, int width,
int out_d, int out_h, int out_w,
int stride, int padding, int dilation) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
int total_outputs = batch * channels * out_d * out_h * out_w;
if (idx >= total_outputs) return;
int w = idx % out_w;
int tmp = idx / out_w;
int h = tmp % out_h;
tmp /= out_h;
int d = tmp % out_d;
tmp /= out_d;
int c = tmp % channels;
int n = tmp / channels;
int nc_offset = (n * channels + c) * depth * height * width;
int d_base = d * stride - padding;
int h_base = h * stride - padding;
int w_base = w * stride - padding;
bool is_edge = (d_base < 0) || (d_base + (KS - 1) * dilation >= depth) ||
(h_base < 0) || (h_base + (KS - 1) * dilation >= height) ||
(w_base < 0) || (w_base + (KS - 1) * dilation >= width);
scalar_t max_val = -FLT_MAX;
if (!is_edge) {
#pragma unroll
for (int kd = 0; kd < KS; ++kd) {
int id = d_base + kd * dilation;
int id_offset = nc_offset + id * height * width;
#pragma unroll
for (int kh = 0; kh < KS; ++kh) {
int ih = h_base + kh * dilation;
int ih_offset = id_offset + ih * width;
#pragma unroll
for (int kw = 0; kw < KS; ++kw) {
int iw = w_base + kw * dilation;
scalar_t val = input[ih_offset + iw];
max_val = (val > max_val) ? val : max_val;
}
}
}
} else {
#pragma unroll
for (int kd = 0; kd < KS; ++kd) {
int id = d_base + kd * dilation;
if (id < 0 || id >= depth) continue;
int id_offset = nc_offset + id * height * width;
#pragma unroll
for (int kh = 0; kh < KS; ++kh) {
int ih = h_base + kh * dilation;
if (ih < 0 || ih >= height) continue;
int ih_offset = id_offset + ih * width;
#pragma unroll
for (int kw = 0; kw < KS; ++kw) {
int iw = w_base + kw * dilation;
if (iw < 0 || iw >= width) continue;
scalar_t val = input[ih_offset + iw];
max_val = (val > max_val) ? val : max_val;
}
}
}
}
int out_idx = ((n * channels + c) * out_d + d) * out_h + h;
out_idx = out_idx * out_w + w;
output[out_idx] = max_val;
}
```
This looks quite clean. Let me put it together.
Actually, I want to double check the edge detection. For the benchmark:
- d_base = d * 2 - 1
- depth = 128
- KS = 3, dilation = 3
- d_base < 0 means d < 1, so d == 0.
- d_base + 2*3 >= 128 means d*2 - 1 + 6 >= 128 means d*2 >= 123 means d >= 61.5, so d >= 62. But d only goes up to 61. So this check is never true.
So `is_edge` is true iff d==0 or h==0 or w==0.
For the interior path, we skip the if checks inside the loop. For edges, we check each index.
One more optimization: what if `padding == 0`? Then d_base = d*2, and the check d_base < 0 is always false. But let's keep it generic.
Wait, I just realized: when padding=1 and d=0, d_base=-1. For kw=1: id = -1 + 3 = 2 (valid). For kw=2: id = -1 + 6 = 5 (valid). So even for edge positions, some elements are valid. My edge handler correctly skips kw=0 (id=-1) and processes kw=1,2.
However, in the edge path, with `#pragma unroll`, the compiler will unroll all 27 iterations but each has a conditional. That's fine.
Actually, I wonder if I can further optimize the edge case. Since only the `d=0, h=0, w=0` edges exist for this benchmark, and `is_edge` is only true for a small fraction of outputs, the edge path is rarely taken. So optimizing it isn't critical.
One more thing: I should check if `ceil_mode` is correctly handled. With ceil_mode=False, the formula I use is:
`out_size = (in_size + 2*padding - dilation*(kernel_size-1) - 1) / stride + 1`
With in_size=128, padding=1, dilation=3, kernel_size=3, stride=2:
= (128 + 2 - 6 - 1) / 2 + 1
= 123 / 2 + 1
= 61 + 1 = 62
Correct.
For ceil_mode=True:
`out_size = (in_size + 2*padding - dilation*(kernel_size-1) - 1 + stride - 1) / stride + 1`
= (128 + 2 - 6 - 1 + 1) / 2 + 1
= 124 / 2 + 1
= 62 + 1 = 63
Correct.
OK, let me assemble the full code and test it.
Let me also think about whether I should pass `extra_cuda_cflags` to optimize compilation. For H100, I might want to add architecture flags, but `load_inline` usually handles this automatically.
I'll compile now and test correctness. If the direct kernel isn't fast enough, I'll try the shared memory approach next.
One more idea before testing: could I vectorize the loads? For example, if I process 4 channels at once using float4, the memory bandwidth utilization would improve. But then I'd need to either:
1. Have 4 threads cooperate to compute 4 output channels for the same spatial position, or
2. Have one thread compute 4 channels serially.
Option 1: A warp of 32 threads could compute 8 spatial positions x 4 channels = 32 outputs. Each load would be a float4 (128-bit) transaction, which is very efficient.
But this requires reorganizing the thread mapping. Let me see: for a fixed (n, d, h, w), channels vary contiguously in memory. So adjacent channels are adjacent in memory. A float4 load gets 4 consecutive channels.
If I map threadIdx.x to channel_groups (4 channels each) and process one spatial position per block... that might not provide enough parallelism.
Let me think of a hybrid approach:
- Block size: 256 threads = 64 groups of 4 threads each
- Each group of 4 threads processes the same spatial position but 4 different channels? No, that wouldn't vectorize.
Actually, for vectorization with float4, a single thread should load 4 floats at once. Then the thread computes the max for 4 different output channels at the same spatial position. This means one thread handles 4 outputs instead of 1.
So: 256 threads handle 256 * 4 = 1024 outputs. These could be 32 spatial positions x 32 channels.
But wait, for different channels, the memory access is perfectly contiguous. So thread 0 loads channels 0,1,2,3 at spatial position (n, d, h, w, iw). Thread 1 loads channels 4,5,6,7 at the same spatial position. But we can't easily use float4 for this because thread 1's data isn't contiguous with thread 0's data in the same float4.
Actually, we could reorder: have thread 0 handle spatial position 0 for channels 0-3, thread 1 handle spatial position 0 for channels 4-7, etc. But that means all threads in a warp are accessing the same spatial position, just different channel groups. This is actually perfectly coalesced! Each thread does 4 scalar loads (or one float4 load) at offsets 0, 4, 8, 12 floats from the base address.
Wait, if thread 0 loads channels 0,1,2,3 and thread 1 loads channels 4,5,6,7, all at the same (n,d,h,w) offset, then the addresses are:
Thread 0: base + 0, base + 1, base + 2, base + 3
Thread 1: base + 4, base + 5, base + 6, base + 7
...
Thread 31: base + 124, base + 125, base + 126, base + 127
If each thread does scalar loads, the warp accesses 128 contiguous floats = 512 bytes. This is 4 cache lines and is perfectly coalesced.
If each thread does a float4 load, each warp issues 32 float4 loads, covering the same 512 bytes. The hardware coalesces these into 4 128-byte transactions. Either way, the bandwidth is well utilized.
The benefit of float4 would be fewer instructions (32 float4 loads vs 128 scalar loads). This might help with instruction throughput.
But the inner loop over 27 kernel positions would need to do this for each position. So 27 float4 loads per thread instead of 27 scalar loads. For 1024 outputs (256 threads * 4 channels), that's 27*256 = 6912 float4 loads total = 27,648 scalar-equivalent loads. Total memory read = 1024 * 27 * 4 bytes = 110.6 KB per block.
Actually, this is getting complex and might not be necessary. Let me try the simple scalar kernel first.
Wait, I realize I haven't fully considered the reference implementation. PyTorch's `nn.MaxPool3d` when called directly may dispatch to ATen native kernels or CuDNN. For 3D pooling on large tensors, CuDNN is usually quite fast. But CuDNN also has some overhead and may not be perfectly optimized for all parameter combinations.
One key advantage of a custom kernel is that we avoid the Python/C++ dispatch overhead and the CuDNN heuristics lookup. The kernel itself might not be much faster, but the total latency could be lower.
Actually, for a large operation like this (4.3 GB input), the computation time should dominate over dispatch overhead.
Let me just compile and test.
One more thing: `load_inline` might cache the compiled extension. To ensure it recompiles when I change the code, I might want to change the name each time, or add an `extra_cuda_cflags` that changes. But during benchmarking, the code will be evaluated once, so caching isn't an issue.
Actually wait — I should make sure `cpp_sources` and `cuda_sources` are properly handled in `load_inline`. In older PyTorch versions, these parameters might have different behavior. Let me just use `cuda_sources` since my code contains both device and host code, as shown in the PyTorch example.
Here is my final code:
Wait, I just thought of another optimization. For the interior case, since we know all accesses are in bounds, we can use `__ldg` (load global through read-only data cache) which can improve performance by using the texture/constant cache pipeline.
```cpp
scalar_t val = __ldg(input + ih_offset + iw);
```
This is a hint that the memory is read-only and not aliased. In modern GPUs (Pascal+), `__ldg` uses the read-only cache which can reduce L1 cache pressure.
Let me add `__ldg` for the interior path. For the edge path, `__ldg` is fine too since it's still read-only.
Actually, `__ldg` requires the pointer to be `const` and we should ensure proper alignment. For `float*`, aligned to 4 bytes, `__ldg` works. For generic scalar_t pointers, I'm not 100% sure it works with template types without proper casts. Let me just use it directly:
```cpp
scalar_t val = __ldg(&input[ih_offset + iw]);
```
This should work in modern CUDA.
Let me add it to the fast path.
Actually, I'll try without it first, and if needed, add it.
OK, let me write the complete code and compile.