reasoning
The task is to write a custom CUDA kernel that replaces a PyTorch forward computation of GroupNorm + SiLU with a fused kernel. The reference model normalizes channels with nn.GroupNorm and then applies F.silu, i.e., x * sigmoid(x).
We need to write a ModelNew class that loads a CUDA kernel using torch.utils.cpp_extension.load_inline. The kernel should be efficient on NVIDIA H100, which has high memory bandwidth and compute.
Given GroupNorm: For each group of channels, we normalize across (channels_in_group, H, W). For each group g within a batch sample n:
Compute mean = 1 / (C/g * H * W) * sum_{c in group} sum_{h,w} x[n, c, h, w]
Compute var = 1 / (C/g * H * W) * sum (x - mean)^2
Then normalize to y = (x - mean) * rsqrt(var + eps)
If affine, scale and shift per channel: y = y * gamma[c] + beta[c]
Then apply SiLU: z = y * sigmoid(y)
So the fused kernel does all in one read of x, gamma, beta, and writes out z.
For performance, we need to maximize memory bandwidth and minimize loads.
Given H100 with 3.35 TB/s bandwidth and huge compute, a simple fused kernel should beat PyTorch's two separate ops easily.
We need to write the kernel careful for fp32 inputs and outputs, with atol/rtol=1e-4 for correctness.
PyTorch's GroupNorm may operate in a different order (e.g., uses Welford's algorithm for numerical stability). To match exactly within tolerance, we should implement mean and variance in a numerically stable way, but given fp32 and relatively small spatial dimensions 24x24=576, simple sum and sum of squares should be accurate enough. However, to avoid catastrophic cancellation, we can compute mean first, then variance via sum of (x - mean)^2. But that requires two passes over the data for each group. Since the data is small (C=256, groups=32 => 8 channels per group, H=W=24 => 8*576=4608 elements per group), we could do a single pass using Welford, or do two passes using shared memory / registers.
For a thread block, we can assign threads per element. Let's think about a simple approach:
- Launch one block per (N, group). So total blocks = N * num_groups = 8 * 32 = 256 blocks.
- Each block processes C_per_group * H * W elements, where C_per_group = num_channels / num_groups = 8.
- Total elements per block = 8 * 24 * 24 = 4608.
- We can use a 1D or 2D thread block. Since H100 has 1024 threads per block max, a block size of 1024 threads would cover 4608 elements with ~4.5 iterations per thread. That's fine.
Alternatively, we can use a block size of 512 or 256. Let's aim for 1024 threads to maximize occupancy per SM. For 256 blocks and block size 1024, total threads = 262144. H100 has 132 SMs (I think H100 SXM has 132 SMs). But occupancy depends on registers and shared memory. 256 blocks might not fully saturate, but each block is lightweight. However, we can also process multiple groups per block, but 256 blocks is enough to keep GPU busy.
We need shared memory to reduce mean and variance across the block.
Plan:
- Each thread loads several elements (strided by blockDim.x).
- For each element, accumulate sum and sum of squares (or use two-step approach).
But for numerical stability matching PyTorch, we should be careful. Let's see: PyTorch's native GroupNorm implementation for CUDA likely computes mean and inv_std in a single reduction per group, then does the normalization and activation in a second kernel, or maybe they have a fused kernel already? Actually, PyTorch's GroupNorm isn't fused with SiLU. It does GroupNorm (which itself may have a forward kernel that normalizes) and then SiLU separately. Our fused kernel will read x once and write z once, saving memory traffic.
To match PyTorch exactly, especially for fp32, we need to use the same algorithm for mean/var. PyTorch uses Welford? Let's think.
In PyTorch's implementation (aten/src/ATen/native/cuda/group_norm_kernel.cu), they compute mean and rstd using a reduction over channels*H*W per group. For CUDA, they might launch a grid where each thread block handles one (N, G) and uses shared memory. They probably compute mean = sum / M, and var = (sum_sq - mean*mean * M) / M, or they might do a two-pass approach. Actually, looking at typical PyTorch code: They compute mean as the average. They compute var as the average of (x - mean)^2 using a second pass or they compute using sum of squares. I think they compute sum and sum of squares in one pass, then compute var = max(0, sum_sq/count - mean^2). Or they might use Welford. Let's assume they compute mean and then do a second pass for variance. We should replicate exactly to get matching results within 1e-4.
Let's inspect typical PyTorch group norm kernel. In PyTorch's native CUDA kernel for group norm (https://github.com/pytorch/pytorch/blob/main/aten/src/ATen/native/cuda/group_norm_kernel.cu), there is a `GroupNormKernel` that computes mean and invstd. For the default implementation (not the optimized one for specific sizes), they might use `cuda_utils::BlockReduce` for sum and sum of squares. Actually, I recall that for GroupNorm, PyTorch uses a custom kernel that launches with block size 256, each thread block handles one (N, G). They compute the sum of x and sum of x^2 in a single loop, then compute mean, variance = max(0, sum_sq / M - mean^2). Actually, looking at the code:
```
template <typename T>
__global__ void ComputeInternalGradientsCUDAKernel(...) { ... }
template <typename T>
__global__ void GroupNormForwardCUDAKernel(...) {
// computes y = (x - mean) * rstd * weight + bias
}
```
Wait, I'm not entirely sure.
But given tolerance is 1e-4 for fp32, any reasonable method should pass.
Let's implement a robust fused kernel:
- Block size: 256 or 512 or 1024. Let's choose 512.
- Grid: (num_groups, batch_size)
- Each block computes mean and inv_std for one (N, group), then normalizes and applies affine and SiLU.
- Use shared memory for reduction.
Algorithm for each block:
1. Load all elements for (N, group) into registers? No, store in shared memory? That would require 4608 floats = 18 KB per block. With 512 threads, we could load 9 elements per thread. But we don't need to store all elements; we can do reductions first, then a second pass to write output.
- Pass 1: Compute mean.
- Each thread iterates over elements with stride blockDim.x.
- Compute local_sum.
- Use block reduction (e.g., shared memory reduction tree) to get total_sum.
- Compute mean = total_sum / M.
- Pass 2: Compute variance.
- Broadcast mean to all threads.
- Each thread again iterates over elements, compute local_sum_sq = sum (x - mean)^2.
- Reduce to get total_sum_sq.
- var = total_sum_sq / M.
- inv_std = rsqrt(var + eps).
- Pass 3: Write output.
- Each thread iterates again, reads x, computes y = (x - mean) * inv_std.
- If affine: y = y * gamma[c] + beta[c].
- z = y * sigmoid(y).
- Write to output.
This does 3 passes over x. Since x lives in global memory, each pass reads from global memory. That's 3 reads. But we can combine pass 2 and pass 3? Actually, after computing mean and inv_std, we need to read x again for normalization. So total reads = 2x (first for mean, second for var+write? No, var requires another read). That's 3 reads of x. We can reduce to 2 reads by doing: Pass 1 compute sum and sum_sq simultaneously. But to match PyTorch's accuracy, maybe we can do sum and sum_sq in one pass, then compute var = max(0, sum_sq/M - mean^2). This is one pass for stats, second pass for write. That reduces reads to 2.
Will the accuracy be sufficient? For fp32, it should be fine. We should guard against negative variance due to rounding: var = max(0.0f, sum_sq / M - mean * mean).
Let's do that. It's standard.
So:
- Pass 1: each thread loads elements, accumulates sum and sum_sq.
- Reduce sum and sum_sq across block.
- Compute mean = sum / M.
- Compute var = max(0, sum_sq / M - mean * mean).
- Compute inv_std = rsqrt(var + eps).
- Pass 2: each thread loads elements again, computes normalized, affine, silu, writes.
Total global reads: 2 * M floats per (N, group). For M=4608, that's 9216 floats = 36.8 KB reads. For batch*groups=256, total reads ~9.2 MB. Writes: 4.6 MB. Total memory traffic ~13.8 MB. At 3.35 TB/s, theoretical time ~4.1 microseconds. Overheads and kernel launch will dominate. But PyTorch's separate ops likely read x twice and write intermediate once and final once: read x (for groupnorm), write groupnorm out (4.6 MB), read groupnorm out (for silu), write silu out (4.6 MB). Plus reads for gamma/beta. That's more traffic. So our fused kernel should be faster.
Wait, PyTorch's GroupNorm might also read x once and write output once, but then SiLU reads output and writes again. So total reads: x (1), temp (1), writes: temp (1), out (1). That's 4 passes over the data volume. Our fused kernel does 2 reads + 1 write = 3 passes. So 25% reduction. Plus avoids launching two kernels.
But we can do even better: if we cache x in shared memory during the first pass, we can avoid the second global read. However, M=4608 floats = 18.4 KB. With block size 512, shared memory per block 18.4 KB is fine. H100 has 227 KB shared memory per block, and 132 SMs * 18 KB = ~2.4 MB total, but occupancy might be limited if we use too much. Actually, shared memory per SM is 227 KB, so with 18 KB per block, we can have up to 12 blocks per SM concurrently. With 512 threads/block, that would exceed thread capacity? Let's check: H100 max threads per SM is 2048. 512 threads * 4 blocks = 2048. Shared memory: 4 blocks * 18 KB = 72 KB, well under 227 KB. So using 18 KB shared memory is fine and won't limit occupancy.
If we store x in shared memory during the first pass, we can do:
- Pass 1: load x into shared memory, and compute sum and sum_sq using the loaded values.
- Reduce sum and sum_sq.
- Compute mean, inv_std.
- Pass 2: read x from shared memory (much faster), compute output, write to global memory.
Total global reads: 1 pass of x + 1 pass of gamma/beta per channel. Writes: 1 pass. That's excellent.
However, loading all elements into shared memory requires all threads to loop over elements. We can have each thread load its portion and store to shared memory, but we need to ensure there are enough slots. With 512 threads and 4608 elements, each thread handles 9 elements. We can allocate shared memory of size ceil(M / blockDim.x) * blockDim.x or exactly M if M <= blockDim.x. Actually M=4608 > 512. We can allocate a fixed shared array, but dynamically sized is easier.
Using dynamic shared memory: `extern __shared__ float sdata[];` where size is `blockDim.x * sizeof(float)` for sum reduction, plus maybe `blockDim.x * sizeof(float)` for sum_sq. For caching x, we'd need M floats. But M varies with C_per_group * H * W.
Actually, we don't need to store all x in shared memory. Each thread can just re-read from global memory in the second pass. The second pass read is coalesced. Memory bandwidth is huge, and the main bottleneck is kernel launch. So 2 reads is likely fine. But if we can do 1 read with shared memory, why not? Let's see if it's worth the complexity. Given M is small (4608 floats), and we have many blocks (256), the time is memory bound but likely very small. Let's aim for a simple kernel with two passes and shared memory reduction. It's cleaner.
Wait, we can also compute sum and sum_sq using warp shuffle reductions, reducing shared memory usage. But the simplest is block-level shared memory reduction.
Let's design the kernel:
- Input x: (N, C, H, W) in NCHW format (PyTorch default).
- Gamma and beta: (C,) if affine, else None.
- Output y: (N, C, H, W).
- For each block, we handle one group. The grid is (num_groups, N). So blockIdx.x = group, blockIdx.y = batch.
- Block size: 512 threads.
- Inside block:
- Compute `n = blockIdx.y`, `g = blockIdx.x`.
- `C_per_group = num_channels / num_groups`.
- `M = C_per_group * H * W`.
- `offset_base = ((n * num_groups + g) * C_per_group) * H * W`.
- Threads iterate with stride `blockDim.x`.
```
float local_sum = 0.0f;
float local_sum_sq = 0.0f;
for (int i = threadIdx.x; i < M; i += blockDim.x) {
int c_in_group = i / (H * W);
int hw = i % (H * W);
int c = g * C_per_group + c_in_group;
int idx = ((n * num_channels + c) * H + hw / W) * W + hw % W;
float val = x[idx];
local_sum += val;
local_sum_sq += val * val;
}
```
Wait, computing index every time might be expensive. Since C_per_group is small, we can flatten.
Better: loop over channels in group, then over H*W.
```
for (int c_offset = 0; c_offset < C_per_group; ++c_offset) {
int c = g * C_per_group + c_offset;
int base = ((n * num_channels + c) * H) * W;
for (int hw = threadIdx.x; hw < H*W; hw += blockDim.x) {
float val = x[base + hw];
local_sum += val;
local_sum_sq += val * val;
}
}
```
This is better for coalescing because `base + hw` is contiguous for threads.
However, the inner loop runs 576 elements. With 512 threads, the first iteration covers 512 elements, second covers 64. Fine.
Reduction:
We can use shared memory of size `2 * blockDim.x` floats (for sum and sum_sq).
Or use warp shuffle for faster reduction.
Let's do warp shuffle for sum and sum_sq.
```
__inline__ __device__ float warpReduceSum(float val) {
for (int offset = 16; offset > 0; offset /= 2)
val += __shfl_down_sync(0xFFFFFFFF, val, offset);
return val;
}
```
Then block reduce:
```
__shared__ float shared_sum[32]; // max warps per block
__shared__ float shared_sq[32];
int lane = threadIdx.x % 32;
int wid = threadIdx.x / 32;
local_sum = warpReduceSum(local_sum);
local_sum_sq = warpReduceSum(local_sum_sq);
if (lane == 0) {
shared_sum[wid] = local_sum;
shared_sq[wid] = local_sum_sq;
}
__syncthreads();
if (threadIdx.x < 32) {
float val_sum = (threadIdx.x < blockDim.x / 32) ? shared_sum[threadIdx.x] : 0.0f;
float val_sq = (threadIdx.x < blockDim.x / 32) ? shared_sq[threadIdx.x] : 0.0f;
val_sum = warpReduceSum(val_sum);
val_sq = warpReduceSum(val_sq);
if (threadIdx.x == 0) {
shared_sum[0] = val_sum;
shared_sq[0] = val_sq;
}
}
__syncthreads();
float mean = shared_sum[0] / M;
float mean_sq = mean * mean;
float var = shared_sq[0] / M - mean_sq;
if (var < 0.0f) var = 0.0f;
float inv_std = rsqrtf(var + eps);
```
Then second pass:
```
for (int c_offset = 0; c_offset < C_per_group; ++c_offset) {
int c = g * C_per_group + c_offset;
int base = ((n * num_channels + c) * H) * W;
float gamma = affine ? weight[c] : 1.0f;
float beta = affine ? bias[c] : 0.0f;
for (int hw = threadIdx.x; hw < H*W; hw += blockDim.x) {
float val = x[base + hw];
float norm = (val - mean) * inv_std;
if (affine) {
norm = norm * gamma + beta;
}
float sigmoid = 1.0f / (1.0f + expf(-norm));
float out_val = norm * sigmoid;
y[base + hw] = out_val;
}
}
```
This looks good.
We need to pass `weight` and `bias` pointers (or nullptr if not affine).
Compile and test.
Let's think about numerical accuracy:
- `expf(-norm)` for large positive norm might underflow? `expf(-norm)` for norm = 88 will underflow to 0, but sigmoid -> 1. For norm = -88, `expf(88)` overflows to inf, but `1/(1+inf)` -> 0. However, for very negative norm, `-norm` is large positive, `expf(-norm) = expf(large)` overflows. To avoid that, we can use a stable sigmoid: `norm > 0 ? 1 / (1 + expf(-norm)) : expf(norm) / (1 + expf(norm))`. But PyTorch's SiLU implementation likely uses `torch.nn.functional.silu` which calls a kernel that computes `x / (1 + exp(-x))` maybe with stable logic. Fp32 tolerance is 1e-4, but overflow/underflow might produce large errors for extreme inputs. In the inputs, values are from standard normal distribution. After GroupNorm, values are centered around 0 with variance ~1. After affine scaling, gamma and beta are initialized to 1 and 0 by default. So values are not extreme. We can use the naive formula safely. But we should match PyTorch's SiLU behavior exactly for all possible values in the test. PyTorch's SiLU uses a custom kernel that handles stability:
In PyTorch's `_silu` CUDA kernel, they compute:
```
scalar_t z = std::exp(-x);
return x / (1 + z);
```
Wait, for x large positive, exp(-x) underflows to 0, result x. For x large negative, exp(-x) overflows, result x / inf -> 0 (or nan if exp returns inf? x/(1+inf) = 0). For x = -1000, exp(1000) = inf, x/(1+inf) = -0? In ieee, inf is finite? Actually, expf(1000) overflows to inf, then x / (1 + inf) = -1000 / inf = -0.0f? Actually, finite / inf = 0, with sign. So no nan. But what about x = 1000? exp(-1000) underflows to 0, 1000/(1+0) = 1000. So it's fine.
Alternatively, they might compute:
```
return x * sigmoid(x)
```
where sigmoid uses a stable formula.
In PyTorch's source, `torch.sigmoid` maps to `1 / (1 + exp(-x))`. For very negative x, exp(-x) overflows to inf, giving 0. For very positive x, exp(-x) underflows to 0, giving 1. For x=0, 0.5. This is stable enough. Then SiLU = x * sigmoid(x). For x = -1000, sigmoid(-1000) = 0, result -0? Actually, -1000 * 0 = -0? That's fine. For x = 1000, sigmoid(1000)=1, result 1000.
So using `norm * (1.0f / (1.0f + expf(-norm)))` is fine and matches PyTorch standard implementation.
However, to be slightly more robust, we can use:
```
float sig;
if (norm >= 0) {
sig = 1.0f / (1.0f + expf(-norm));
} else {
float exp_val = expf(norm);
sig = exp_val / (1.0f + exp_val);
}
```
This avoids overflow/underflow for extreme values. It's cheap. Let's use it for safety.
Now, what about the numerical stability of GroupNorm? Let's check PyTorch's exact formula.
PyTorch's GroupNorm docs:
`y = (x - E[x]) / sqrt(Var[x] + eps) * gamma + beta`
where mean and variance are computed per group across channels and spatial dims.
The variance is the unbiased or biased? GroupNorm uses biased variance (population variance, divide by M). Let's verify: In PyTorch's implementation, they compute variance as the average of squared deviations, i.e., divide by M. Yes, `Var[x] = \frac{1}{M} \sum (x - mean)^2`. So our formula `var = sum_sq / M - mean^2` is correct.
They also might use a two-pass algorithm to reduce error. But using sum and sum_sq in one pass might have slight cancellation for large means. However, the input values are small (standard normal times some conv weights). But this is generic. Since tolerance is 1e-4, one-pass should be fine.
Wait, there is a subtlety: PyTorch's GroupNorm on CUDA for training might use a different algorithm than inference, but here we are doing forward without tracking mean/var for backprop. So it should be fine.
Let's think about the case where `affine=False`. We need to handle that.
Also, the kernel must handle arbitrary batch size, num_channels, num_groups, H, W.
Let's templatize or just use float and handle dimensions generically. For flexibility, we can write a kernel that takes all dimensions as parameters.
We need to write the host wrapper function.
The wrapper will be a C++ function that takes `torch::Tensor x`, optional `torch::Tensor weight`, optional `torch::Tensor bias`, `int num_groups`, `float eps`, and returns `torch::Tensor y`.
We need to ensure the tensor is contiguous and on CUDA. The test likely provides contiguous NCHW tensors.
Let's structure the code:
CUDA source string with:
- The `warpReduceSum` device function.
- The `group_norm_silu_kernel` global function.
- A host wrapper `group_norm_silu_forward`.
We'll use `load_inline` with `cuda_sources` containing the CUDA code and `cpp_sources` containing the pybind wrapper (declared as `extern`). Actually, `load_inline` expects host functions to be declared in `cpp_sources` or `cuda_sources`? Usually, we put the CUDA kernel in `cuda_sources` and the C++ wrapper in `cpp_sources`. But since it's a single file, we can put everything in `cuda_sources` and provide `functions=['group_norm_silu_forward']`. Or we can put the wrapper in `cpp_sources` as well. The wrapper needs to call the kernel, so it must have access to the kernel declaration. If we put the kernel declaration in `cuda_sources` and the wrapper in `cpp_sources`, it might work if nvcc compiles them together. Actually, `load_inline` compiles `cpp_sources` with the C++ compiler and `cuda_sources` with nvcc, linking them together. So the kernel must be declared in a header accessible to both, or we can put the wrapper in `cuda_sources` too. Wait, `cuda_sources` is compiled as `.cu`, so it can contain both `__global__` and host C++ code. `cpp_sources` is compiled as `.cpp`. If we put the wrapper in `cpp_sources`, we need a forward declaration of the kernel. We can declare the kernel in `cpp_sources` as `void group_norm_silu_kernel(...);` and define it in `cuda_sources`. Or we can just put everything in `cuda_sources`. But `load_inline` allows `cuda_sources` to contain host code as well. However, `cuda_sources` is compiled with nvcc. The `functions` list tells pybind11 to bind whatever functions are declared in `cpp_sources`? Actually, the pybind11 module is auto-generated from functions declared in `cpp_sources` (or maybe from both?). According to the instructions: "Do not write your own `PYBIND11_MODULE(...)` block in `cpp_sources`. `load_inline` auto-generates one from the `functions=[...]` argument; including your own causes a duplicate-symbol redefinition error at compile time. List the host-side wrapper function names in `functions=[...]` instead."
The wrapper function must be defined in `cpp_sources` or `cuda_sources`? Usually it's in `cpp_sources`. But pybind can bind functions from `cuda_sources` as well if they are compiled with nvcc and have C++ linkage. Wait, nvcc compiles `.cu` files and generates host symbols as well. If `cuda_sources` contains a host function `group_norm_silu_forward(torch::Tensor x, ...)`, it will be compiled by nvcc and exported. Can pybind11 bind it? Yes, pybind11 just needs the symbol. But `load_inline` might only scan `cpp_sources` for generating bindings? Actually, the docs say: "functions – a list of function names to export to Python." It doesn't say they must be in `cpp_sources`. But to be safe, we can define the wrapper in `cpp_sources` and declare the kernel in `cpp_sources` too, with `extern "C"` or just `void kernel(...);`. Let's do that.
Actually, looking at examples, it's common to define the kernel in `cuda_sources` and the wrapper in `cpp_sources`, with a forward declaration. But we can also define the wrapper in `cuda_sources` and list it in `functions`. Let's check typical usage in KernelBench. Usually they put everything in `cuda_sources` and have `cpp_sources=""`. The `functions` list then picks functions from the compiled object files, which include both cpp and cu. I'll put everything in `cuda_sources` to keep it simple and avoid linkage issues. The host function will be compiled by nvcc as C++.
Let's write the code.
Important: We need to handle the case where `weight` and `bias` are undefined (affine=False). In PyTorch, `nn.GroupNorm` stores weight and bias only if affine=True. In our module, we will store them if needed. In the host wrapper, we will pass `nullptr` for weight and bias if not affine.
Wait, the wrapper should probably be called from Python, so it takes torch::Tensor arguments. In the host wrapper, we can accept `c10::optional<at::Tensor> weight_opt` etc. Or we can accept pointers and handle null.
In pybind11, overloaded functions can take `py::none()`. But `torch::Tensor` can be `undefined` if passed from Python as `None`? Actually, if we declare argument as `torch::Tensor`, pybind11 will try to convert `None` to a tensor and fail. We can use `c10::optional<torch::Tensor>`. With pybind11 automatic bindings, optional should work.
Let's define the wrapper as:
```
torch::Tensor group_norm_silu_forward(
torch::Tensor x,
int num_groups,
float eps,
c10::optional<torch::Tensor> weight_opt,
c10::optional<torch::Tensor> bias_opt) {
...
}
```
Check if x is contiguous and CUDA float.
Compute output tensor.
Launch kernel.
Return output.
Let's write this out carefully.
Let me draft the kernel code.
```cpp
#include <torch/extension.h>
#include <cuda_runtime.h>
#include <cmath>
__inline__ __device__ float warpReduceSum(float val) {
for (int offset = 16; offset > 0; offset /= 2)
val += __shfl_down_sync(0xFFFFFFFF, val, offset);
return val;
}
__inline__ __device__ float blockReduceSum(float val) {
static __shared__ float shared[32];
int lane = threadIdx.x % 32;
int wid = threadIdx.x / 32;
val = warpReduceSum(val);
if (lane == 0) shared[wid] = val;
__syncthreads();
val = (threadIdx.x < blockDim.x / 32) ? shared[lane] : 0.0f;
if (wid == 0) val = warpReduceSum(val);
return val;
}
__global__ void group_norm_silu_kernel(
const float* __restrict__ x,
float* __restrict__ y,
const float* __restrict__ weight,
const float* __restrict__ bias,
int N,
int C,
int H,
int W,
int num_groups,
float eps,
int affine) {
int n = blockIdx.y;
int g = blockIdx.x;
if (n >= N || g >= num_groups) return;
int C_per_group = C / num_groups;
int HW = H * W;
int M = C_per_group * HW;
float local_sum = 0.0f;
float local_sum_sq = 0.0f;
// First pass: compute sum and sum_sq
for (int c_offset = 0; c_offset < C_per_group; ++c_offset) {
int c = g * C_per_group + c_offset;
int base = ((n * C + c) * H) * W;
for (int hw = threadIdx.x; hw < HW; hw += blockDim.x) {
float val = x[base + hw];
local_sum += val;
local_sum_sq += val * val;
}
}
float total_sum = blockReduceSum(local_sum);
float total_sum_sq = blockReduceSum(local_sum_sq);
__shared__ float mean;
__shared__ float inv_std;
if (threadIdx.x == 0) {
mean = total_sum / static_cast<float>(M);
float var = total_sum_sq / static_cast<float>(M) - mean * mean;
if (var < 0.0f) var = 0.0f;
inv_std = rsqrtf(var + eps);
}
__syncthreads();
// Second pass: normalize, affine, silu
for (int c_offset = 0; c_offset < C_per_group; ++c_offset) {
int c = g * C_per_group + c_offset;
int base = ((n * C + c) * H) * W;
float gamma = 1.0f;
float beta = 0.0f;
if (affine) {
gamma = weight[c];
beta = bias[c];
}
for (int hw = threadIdx.x; hw < HW; hw += blockDim.x) {
float val = x[base + hw];
float norm = (val - mean) * inv_std;
if (affine) {
norm = norm * gamma + beta;
}
float sig;
if (norm >= 0.0f) {
sig = 1.0f / (1.0f + expf(-norm));
} else {
float exp_val = expf(norm);
sig = exp_val / (1.0f + exp_val);
}
y[base + hw] = norm * sig;
}
}
}
torch::Tensor group_norm_silu_forward(
torch::Tensor x,
int num_groups,
float eps,
c10::optional<torch::Tensor> weight_opt,
c10::optional<torch::Tensor> bias_opt) {
TORCH_CHECK(x.device().is_cuda(), "x must be a CUDA tensor");
TORCH_CHECK(x.is_contiguous(), "x must be contiguous");
TORCH_CHECK(x.dtype() == torch::kFloat32, "x must be float32");
int N = x.size(0);
int C = x.size(1);
int H = x.size(2);
int W = x.size(3);
TORCH_CHECK(C % num_groups == 0, "num_channels must be divisible by num_groups");
auto y = torch::empty_like(x);
int affine = 0;
const float* weight_ptr = nullptr;
const float* bias_ptr = nullptr;
if (weight_opt.has_value() && bias_opt.has_value()) {
auto weight = weight_opt.value();
auto bias = bias_opt.value();
TORCH_CHECK(weight.dtype() == torch::kFloat32 && bias.dtype() == torch::kFloat32,
"weight and bias must be float32");
TORCH_CHECK(weight.device().is_cuda() && bias.device().is_cuda(),
"weight and bias must be CUDA tensors");
TORCH_CHECK(weight.dim() == 1 && weight.size(0) == C, "weight shape mismatch");
TORCH_CHECK(bias.dim() == 1 && bias.size(0) == C, "bias shape mismatch");
weight_ptr = weight.data_ptr<float>();
bias_ptr = bias.data_ptr<float>();
affine = 1;
}
dim3 blocks(num_groups, N);
dim3 threads(512);
group_norm_silu_kernel<<<blocks, threads>>>(
x.data_ptr<float>(),
y.data_ptr<float>(),
weight_ptr,
bias_ptr,
N,
C,
H,
W,
num_groups,
eps,
affine);
C10_CUDA_KERNEL_LAUNCH_CHECK();
return y;
}
```
Wait, the `blockReduceSum` uses shared memory. The function `blockReduceSum` has `static __shared__ float shared[32];`. But if we call it twice in the same kernel, the static shared memory is the same array. That's fine because the first call synchronizes before returning, and the second call reuses it.
However, we call `blockReduceSum(local_sum)` and then `blockReduceSum(local_sum_sq)`. The first call writes to `shared` and reduces. The second call also uses the same `shared`. Since there is a `__syncthreads()` inside `blockReduceSum`, it should be safe. But after the first call returns, we are not guaranteed that all threads have finished the first call before the second call starts? Actually, `__syncthreads()` inside `blockReduceSum` ensures synchronization among threads in the block before returning. Wait, the second call starts after the first returns. But `__syncthreads()` is inside the first call. For all threads to return from the first call, they must pass the `__syncthreads()`. So after the first call returns, all threads have synchronized. Then they proceed to the second call. That's fine.
But there's a subtle bug in `blockReduceSum`:
```
val = (threadIdx.x < blockDim.x / 32) ? shared[lane] : 0.0f;
if (wid == 0) val = warpReduceSum(val);
return val;
```
This returns the reduced sum only in thread 0? Actually, the function returns `val` for all threads. The final `warpReduceSum` broadcasts the result within warp 0, but other warps do not get the result. In our code, after calling `blockReduceSum`, we only use the result in `threadIdx.x == 0` block, reading into shared `mean` and `inv_std`. Wait, but `blockReduceSum` returns the result only to threads in warp 0. For other warps, `val` is whatever they had? Let's trace:
- After first `__syncthreads()`, `shared[wid] = warp_sum` for each warp.
- Then `val = (threadIdx.x < blockDim.x / 32) ? shared[lane] : 0.0f;`
For threads in warp 0 (wid==0), lane is threadIdx.x. So thread 0 reads shared[0], thread 1 reads shared[1], etc.
For threads in warp 1 (wid==1), threadIdx.x = 32 + lane. `threadIdx.x < blockDim.x / 32`? If blockDim.x = 512, blockDim.x/32 = 16. So threadIdx.x=32 is not < 16. So val = 0.
Then `if (wid == 0) val = warpReduceSum(val);` This only executes for warp 0. For other warps, val remains 0.
- Return val. So only warp 0 gets the final sum. The other threads get 0.
But in our kernel, we do:
```
float total_sum = blockReduceSum(local_sum);
float total_sum_sq = blockReduceSum(local_sum_sq);
__shared__ float mean;
__shared__ float inv_std;
if (threadIdx.x == 0) {
mean = total_sum / float(M);
...
}
```
`total_sum` is only correct for threadIdx.x == 0 (which is in warp 0). For other threads, it's 0, but we don't use it. So this is fine.
However, what if blockDim.x is not a multiple of 32? For 512 it is.
Also, `blockReduceSum` uses `static __shared__ float shared[32];`. For block size 512, there are 16 warps, so 32 is enough. For block size 1024, 32 warps, but the array size is 32, but we only need `blockDim.x / 32` entries. For 1024, that's 32 entries. So 32 is enough for up to 1024 threads. Good.
But the code `val = (threadIdx.x < blockDim.x / 32) ? shared[lane] : 0.0f;` only works if `blockDim.x / 32 <= 32`, i.e., blockDim.x <= 1024. CUDA max block size is 1024, so it's fine.
Let's double-check: `lane = threadIdx.x % 32`, `wid = threadIdx.x / 32`.
In the first step, `shared[wid] = val` for lane==0. So shared[0..num_warps-1] are set.
Then `val = (threadIdx.x < blockDim.x / 32) ? shared[lane] : 0.0f;`
This reads `shared[lane]` for threads 0 to num_warps-1, and 0 otherwise.
For warp 0, lane=threadIdx.x. So thread 0 reads shared[0], thread 1 reads shared[1], ..., thread 15 reads shared[15]. All valid.
Then `warpReduceSum` accumulates them across warp 0. Thread 0 ends up with total. Thread 0 returns total. Others in warp 0 return partial or total? `warpReduceSum` returns the total to all threads in the warp because `__shfl_down_sync` broadcasts? Actually, `__shfl_down_sync` does not broadcast to all lanes; the result is only in the lower lanes? Let's check:
```
for (int offset = 16; offset > 0; offset /= 2)
val += __shfl_down_sync(0xFFFFFFFF, val, offset);
return val;
```
After the loop, lane 0 has sum of all lanes. Lane 1 has sum of lanes 1..31? Actually, `__shfl_down_sync(mask, var, delta)` returns the value from lane `lane_id + delta`. For delta=16, lane 0 gets lane 16, lane 1 gets lane 17, etc. Then val += that. Next delta=8: lane 0 gets lane 8 (which already includes lanes 8..15), lane 1 gets lane 9, etc. After all iterations, lane 0 has sum of lanes 0..31. Lane 1 has sum of lanes 1..31. So only lane 0 has the total sum. All other lanes have partial sums.
In `blockReduceSum`, after the second warp reduce, `lane 0` has the total. Then `if (wid == 0) val = warpReduceSum(val);` means only warp 0 threads update val. Thread 0 in warp 0 gets total. Thread 1 in warp 0 gets sum of shared[1..15], which is not total. But we only use the return value of `blockReduceSum` in thread 0 later. Actually, we call it for all threads, but only thread 0 uses `total_sum`. So it's okay that lane 1 returns a different value, as long as we only read from thread 0.
But wait, inside `blockReduceSum`, after the first `__syncthreads()`, we have `val = (threadIdx.x < blockDim.x / 32) ? shared[lane] : 0.0f;`. For thread 0 (wid=0, lane=0), val = shared[0] = warp0_sum. For thread 32 (wid=1, lane=0), `threadIdx.x=32`, `32 < 16` is false, so val=0. Then `if (wid==0)` is false, so val remains 0. It returns 0. That's fine.
So the reduction works for our usage pattern (only thread 0 needs the total).
Now, about performance: The block size of 512 means we have 16 warps. The first pass loops over C_per_group=8 channels and HW=576. Total elements 4608. Each thread handles ~9 elements (since 4608/512 = 9). The inner loop does `hw += blockDim.x`, for 512 threads, hw takes values 0..511, then 512..575 (only 64 threads active). Some divergence but minor.
The second pass is similar.
This kernel is memory bandwidth bound and should be fast.
Could we increase block size to 1024? Then each thread handles ~4.5 elements. The inner loop would have hw=0..511 and 512..575, with all 1024 threads, but only 576 elements, so 448 threads would be idle in the first iteration? Wait, `hw < HW`, so for blockDim.x=1024, threadIdx.x from 0 to 575 will process one element, 576..1023 will skip. That's a lot of idle threads. For block size 512, 512 threads process first 512 elements, 64 threads process next 64. So 512 is better. What about block size 256? Then each thread handles 2 or 3 elements, more active threads. 256 threads all active for first iteration (0..255), second iteration (256..511), third iteration (512..575 with 64 active). That's also fine. Occupancy with 256 threads might be better (more blocks per SM). But the total number of blocks is 256, which is 2 per SM roughly. With 256 threads, that's 2048 threads per SM (8 blocks), which saturates. With 512 threads, that's 4 blocks per SM = 2048 threads. So both saturate. Let's stick with 512. Or we could choose 256 for lower register usage and better latency hiding. But the kernel has low register pressure anyway.
Let's compile and test.
I need to write the complete Python file with `ModelNew`.
The Python file should import torch, torch.nn, etc., and define ModelNew.
At module load time, we compile the extension.
Important: The module-level code should not be too slow. `load_inline` might take some time but it's okay.
Let's also consider that `load_inline` may cache the compiled binary. That's fine.
Let's write the code.
call → compile_kernel
callcompile_kernel
{
"kernel_code": "import torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nfrom torch.utils.cpp_extension import load_inline\n\ncuda_source = \"\"\"\n#include <torch/extension.h>\n#include <cuda_runtime.h>\n#include <cmath>\n\n__inline__ __device__ float warpReduceSum(float val) {\n for (int offset = 16; offset > 0; offset /= 2)\n val += __shfl_down_sync(0xFFFFFFFF, val, offset);\n return val;\n}\n\n__inline__ __device__ float blockReduceSum(float val) {\n static __shared__ float shared[32];\n int lane = threadIdx.x % 32;\n int wid = threadIdx.x / 32;\n val = warpReduceSum(val);\n if (lane == 0) shared[wid] = val;\n __syncthreads();\n val = (threadIdx.x < blockDim.x / 32) ? shared[lane] : 0.0f;\n if (wid == 0) val = warpReduceSum(val);\n return val;\n}\n\n__global__ void group_norm_silu_kernel(\n const float* __restrict__ x,\n float* __restrict__ y,\n const float* __restrict__ weight,\n const float* __restrict__ bias,\n int N,\n int C,\n int H,\n int W,\n int num_groups,\n float eps,\n int affine) {\n int n = blockIdx.y;\n int g = blockIdx.x;\n if (n >= N || g >= num_groups) return;\n int C_per_group = C / num_groups;\n int HW = H * W;\n int M = C_per_group * HW;\n float local_sum = 0.0f;\n float local_sum_sq = 0.0f;\n\n // First pass: compute sum and sum of squares\n for (int c_offset = 0; c_offset < C_per_group; ++c_offset) {\n int c = g * C_per_group + c_offset;\n int base = ((n * C + c) * H) * W;\n for (int hw = threadIdx.x; hw < HW; hw += blockDim.x) {\n float val = x[base + hw];\n local_sum += val;\n local_sum_sq += val * val;\n }\n }\n\n float total_sum = blockReduceSum(local_sum);\n float total_sum_sq = blockReduceSum(local_sum_sq);\n\n __shared__ float mean;\n __shared__ float inv_std;\n if (threadIdx.x == 0) {\n mean = total_sum / static_cast<float>(M);\n float var = total_sum_sq / static_cast<float>(M) - mean * mean;\n if (var < 0.0f) var = 0.0f;\n inv_std = rsqrtf(var + eps);\n }\n __syncthreads();\n\n // Second pass: normalize, affine, SiLU\n for (int c_offset = 0; c_offset < C_per_group; ++c_offset) {\n int c = g * C_per_group + c_offset;\n int base = ((n * C + c) * H) * W;\n float gamma = 1.0f;\n float beta = 0.0f;\n if (affine) {\n gamma = weight[c];\n beta = bias[c];\n }\n for (int hw = threadIdx.x; hw < HW; hw += blockDim.x) {\n float val = x[base + hw];\n float norm = (val - mean) * inv_std;\n if (affine) {\n norm = norm * gamma + beta;\n }\n float sig;\n if (norm >= 0.0f) {\n sig = 1.0f / (1.0f + expf(-norm));\n } else {\n float exp_val = expf(norm);\n sig = exp_val / (1.0f + exp_val);\n }\n y[base + hw] = norm * sig;\n }\n }\n}\n\ntorch::Tensor group_norm_silu_forward(\n torch::Tensor x,\n int num_groups,\n float eps,\n c10::optional<torch::Tensor> weight_opt,\n c10::optional<torch::Tensor> bias_opt) {\n TORCH_CHECK(x.device().is_cuda(), \"x must be a CUDA tensor\");\n TORCH_CHECK(x.is_contiguous(), \"x must be contiguous\");\n TORCH_CHECK(x.dtype() == torch::kFloat32, \"x must be float32\");\n int N = x.size(0);\n int C = x.size(1);\n int H = x.size(2);\n int W = x.size(3);\n TORCH_CHECK(C % num_groups == 0, \"num_channels must be divisible by num_groups\");\n auto y = torch::empty_like(x);\n int affine = 0;\n const float* weight_ptr = nullptr;\n const float* bias_ptr = nullptr;\n if (weight_opt.has_value() && bias_opt.has_value()) {\n auto weight = weight_opt.value();\n auto bias = bias_opt.value();\n TORCH_CHECK(weight.dtype() == torch::kFloat32, \"weight must be float32\");\n TORCH_CHECK(bias.dtype() == torch::kFloat32, \"bias must be float32\");\n TORCH_CHECK(weight.device().is_cuda(), \"weight must be a CUDA tensor\");\n TORCH_CHECK(bias.device().is_cuda(), \"bias must be a CUDA tensor\");\n TORCH_CHECK(weight.dim() == 1 && weight.size(0) == C, \"weight shape mismatch\");\n TORCH_CHECK(bias.dim() == 1 && bias.size(0) == C, \"bias shape mismatch\");\n weight_ptr = weight.data_ptr<float>();\n bias_ptr = bias.data_ptr<float>();\n affine = 1;\n }\n dim3 blocks(num_groups, N);\n dim3 threads(512);\n group_norm_silu_kernel<<<blocks, threads>>>(\n x.data_ptr<float>(),\n y.data_ptr<float>(),\n weight_ptr,\n bias_ptr,\n N, C, H, W,\n num_groups,\n eps,\n affine);\n C10_CUDA_KERNEL_LAUNCH_CHECK();\n return y;\n}\n\"\"\"\n\ncpp_source = \"\"\"\ntorch::Tensor group_norm_silu_forward(\n torch::Tensor x,\n int num_groups,\n float eps,\n c10::optional<torch::Tensor> weight_opt,\n c10::optional<torch::Tensor> bias_opt);\n\"\"\"\n\n# Compile the inline CUDA extension\ngroup_norm_silu = load_inline(\n name=\"group_norm_silu\",\n cuda_sources=cuda_source,\n cpp_sources=cpp_source,\n functions=[\"group_norm_silu_forward\"],\n extra_cuda_cflags=[\"-O3\", \"--use_fast_math\"],\n)\n\n\nclass Model(nn.Module):\n \"\"\"\n Fused GroupNorm + SiLU (Swish), a common post-conv / residual-branch pattern in\n ResNet-family backbones (SiLU instead of ReLU) and in spatial blocks of diffusion\n transformers (DiT) where features are (N, C, H, W).\n\n Reference pipeline for a single fused kernel: normalize channels with\n `GroupNorm`, then apply `SiLU` elementwise (`x * sigmoid(x)`).\n \"\"\"\n\n def __init__(\n self,\n num_channels: int,\n num_groups: int,\n eps: float = 1e-5,\n affine: bool = True,\n ):\n super().__init__()\n assert num_channels % num_groups == 0\n self.norm = nn.GroupNorm(\n num_groups=num_groups,\n num_channels=num_channels,\n eps=eps,\n affine=affine,\n )\n\n def forward(self, x: torch.Tensor) -> torch.Tensor:\n \"\"\"\n Args:\n x: (N, C, H, W) feature map\n Returns:\n Same shape as x\n \"\"\"\n return F.silu(self.norm(x))\n\n\nclass ModelNew(nn.Module):\n \"\"\"\n KernelBench candidate entry point. Replace with a fused CUDA/Triton kernel;\n default delegates to `Model` for harness smoke tests (same file as ref + kernel).\n \"\"\"\n\n def __init__(\n self,\n num_channels: int,\n num_groups: int,\n eps: float = 1e-5,\n affine: bool = True,\n ):\n super().__init__()\n assert num_channels % num_groups == 0\n self.num_channels = num_channels\n self.num_groups = num_groups\n self.eps = eps\n self.affine = affine\n if affine:\n self.weight = nn.Parameter(torch.ones(num_channels))\n self.bias = nn.Parameter(torch.zeros(num_channels))\n else:\n self.register_parameter(\"weight\", None)\n self.register_parameter(\"bias\", None)\n\n def forward(self, x: torch.Tensor) -> torch.Tensor:\n return group_norm_silu.group_norm_silu_forward(\n x,\n self.num_groups,\n self.eps,\n self.weight if self.affine else None,\n self.bias if self.affine else None,\n )\n\n\nbatch_size = 8\nnum_channels = 256\nnum_groups = 32\nheight = 24\nwidth = 24\n\n\ndef get_inputs():\n x = torch.randn(batch_size, num_channels, height, width)\n return [x]\n\n\ndef get_init_inputs():\n return [num_channels, num_groups]\n"
}