gpt-5.5-priority · original · l123_single_gpt
correct 27_RegNet.py
model
gpt-5.5-priority
turns / max
1 / 1
tool calls
1
tokens (total)
0
wall clock
—
truncated
no
backend
cuda fp32
variant
original
runtime
2.32 μs
ref runtime
2.43 μs
speedup
1.05x
started
2026-05-05T00:04:17Z
timingkernelbench.timing
kernel runtime
2.32 μs
ref runtime
2.43 μs
speedup
1.05x
kernel mean / median / std
2.32 / — / 0.00 μs
ref mean / median / std
2.43 / — / 0.31 μs
numerical precisioneval_kernel_against_ref
max abs error
3.58e-07
mean abs error
5.98e-08
max rel error
2.62e-04
mean rel error
1.45e-06
gpu memoryextended_metrics.measure_memory
kernel peak
438.53 MB
ref peak
438.53 MB
kernel / ref
1.00x
kernel launches / fusiontorch.profiler
kernel launches
112
ref launches
133
fusion ratio (ref / kernel)
1.19x
total cuda time
9452 μs
energyNVML (pynvml)
kernel mJ / run
1299.76
ref mJ / run
555.06
ratio (ref / kernel)
0.43x
avg power
557.18 W
ref avg power
207.99 W
SOL (speed-of-light)heuristic_from_speedup
SOL score (0–1)
0.105
dram util %
—
compute util %
—
bottleneck
unknown
dominant pipe
—
arithmetic intensity
—
ridge point
—
roofline / occupancyheuristic
dram bandwidth
—
dram util %
—
fp32 tflops
—
fp32 util %
—
fp16 tflops
—
occupancy %
—
bottleneck
—
dominant pipe %
—
arithmetic intensity
—
ridge point
—
L1 hit %
—
L2 hit %
—
ld sectors / req
—
st sectors / req
—
regs / thread
—
smem / block
—
block size
—
peak bw
—
peak fp32 tflops
—
peak fp16 tflops
—
reference PyTorch source (the prompt the agent sees)
import torch
import torch.nn as nn
import torch.nn.functional as F

class Model(nn.Module):
    def __init__(self, input_channels, stages, block_widths, output_classes):
        """
        :param input_channels: int, Number of input channels for the first layer
        :param stages: int, Number of stages in the RegNet architecture
        :param block_widths: List[int], Width (number of channels) for each block in the stages
        :param output_classes: int, Number of output classes for classification
        """
        super(Model, self).__init__()

        self.stages = stages
        self.block_widths = block_widths
        
        layers = []
        current_channels = input_channels
        
        # Construct the stages with their respective blocks
        for i in range(stages):
            layers.append(self._make_stage(current_channels, block_widths[i]))
            current_channels = block_widths[i]
        
        self.feature_extractor = nn.Sequential(*layers)
        
        # Final fully connected layer for classification
        self.fc = nn.Linear(block_widths[-1], output_classes)
    
    def _make_stage(self, in_channels, out_channels):
        """
        Creates a simple block for each stage.
        :param in_channels: int, number of input channels
        :param out_channels: int, number of output channels
        :return: nn.Sequential block with convolutional layers
        """
        return nn.Sequential(
            nn.Conv2d(in_channels, out_channels, kernel_size=3, padding=1),
            nn.BatchNorm2d(out_channels),
            nn.ReLU(),
            nn.Conv2d(out_channels, out_channels, kernel_size=3, padding=1),
            nn.BatchNorm2d(out_channels),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size=2, stride=2)
        )

    def forward(self, x):
        """
        Forward pass through the RegNet model.
        :param x: torch.Tensor of shape (batch_size, input_channels, height, width)
        :return: torch.Tensor of shape (batch_size, output_classes)
        """
        x = self.feature_extractor(x)
        x = torch.mean(x, dim=[2, 3])  # Global Average Pooling
        x = self.fc(x)
        return x

# Test code for the RegNet model
batch_size = 8
input_channels = 3
image_height, image_width = 224, 224
stages = 3
block_widths = [64, 128, 256]
output_classes = 10

def get_inputs():
    """ Generates random input tensor of shape (batch_size, input_channels, height, width) """
    return [torch.rand(batch_size, input_channels, image_height, image_width)]

def get_init_inputs():
    """ Initializes model parameters """
    return [input_channels, stages, block_widths, output_classes]
turn 0 · FINAL · 1 tool call(s) · 199.4s turn · 199.4s LLM · 14,361 chars (click to expand)
turn 0199.4s turn · 199.4s LLM · 0.0s tools · FINAL
reasoning
**Evaluating model architecture** I’m considering whether the convolution biases are truly random. Actually, the Conv2d bias is set to True, and if we're not in evaluation mode, the batch normalization will normalize the convolution output instead of keeping it as identity. I think I could create a new model that inherits nn.Module while using the same architecture. If it's in training mode, I wonder what happens with fallbacks and correctness during evaluation. It's time to think more about conditional training approaches. **Considering ReLU efficiency** I’m thinking about using the ReLU function separately, maybe applying it directly on the convolutional output with `torch.relu(conv)`. If I use `torch.relu_` in place, it could reduce memory allocation, but I noticed PyTorch's `nn.ReLU` isn't in-place. So, I might as well use an in-place version after the convolution for better speed. Ultimately, if I need something more customized, I could explore creating a custom `relu_maxpool`. **Considering training implementation** I'm thinking about how to implement training using F.conv2d, F.batch_norm, relu, and max pooling. Perhaps I should also think about benchmarking, maybe evaluating performance? If they instantiate without evaluating, our training path might not be custom and could end up being less effective. I wonder if I could implement training with in-place relu and custom relu_maxpool instead? Also need to consider the outputs during batch norm training. **Evaluating BatchNorm with ReLU** I need to compute the mean and variance after applying convolution for each batch normalization layer. Even though PyTorch’s BatchNorm2d is optimized, I’m considering implementing a fused version with ReLU and MaxPool. I also have to manage reductions per channel while specifying shapes for each layer. Fusing ReLU could provide performance benefits, but I'm contemplating how to implement it efficiently, especially during training when BatchNorm updates are needed. It's tricky! **Clarifying BatchNorm Updates** I need to ensure that when using BatchNorm, the running stats are updated correctly during training with `training=True`. It seems important to match the updates precisely because BatchNorm updates its running stats and uses batch stats for the output. I'm considering whether running stats should be compared after each call to check correctness. If the states change, should they align? I'm thinking that having consistent updates is essential for accurate performance—this feels like a crucial detail! **Evaluating tolerance in computations** I'm thinking about tolerance after stages of outputs. It seems the averages could differ slightly based on the order of operations, maybe by 1e-4, which poses a risk. I wonder if implementing a custom mean and using F.linear could help. The reference to torch.mean gives me mixed feelings on efficiency. Overall, for nearly 200k operations, the potential for error accumulation is a concern. I’ll need to balance speed with accuracy while considering using double for precision at the cost of speed. **Considering mean reduction in PyTorch** I'm looking into the unspecified order of mean reduction in PyTorch. The per-block strided 256 reduction might behave similarly, which could lead to a tolerance of 1e-4 being acceptable. However, I’m worried about the possibility of random linear cancellation affecting the absolute values, which might be small. I think using float could be beneficial here for precision while still keeping an eye on performance. It’s all about finding the right balance!
call → submit_kernel
callsubmit_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\n\n_cpp_src = r'''\n#include <torch/extension.h>\n#include <ATen/cuda/CUDAContext.h>\n\n#define CHECK_CUDA(x) TORCH_CHECK(x.is_cuda(), #x \" must be a CUDA tensor\")\n#define CHECK_CONTIGUOUS(x) TORCH_CHECK(x.is_contiguous(), #x \" must be contiguous\")\n#define CHECK_FLOAT(x) TORCH_CHECK(x.scalar_type() == at::kFloat, #x \" must be float32\")\n\nvoid relu_maxpool_launcher(const at::Tensor& input, at::Tensor& output);\nvoid avg_fc_launcher(const at::Tensor& input, const at::Tensor& weight, const at::Tensor& bias, at::Tensor& output);\n\ntorch::Tensor relu_maxpool_forward(torch::Tensor input) {\n CHECK_CUDA(input); CHECK_CONTIGUOUS(input); CHECK_FLOAT(input);\n TORCH_CHECK(input.dim() == 4, \"input must be NCHW\");\n const auto N = input.size(0);\n const auto C = input.size(1);\n const auto H = input.size(2);\n const auto W = input.size(3);\n TORCH_CHECK((H >= 2) && (W >= 2), \"spatial dims too small\");\n auto output = torch::empty({N, C, H / 2, W / 2}, input.options());\n relu_maxpool_launcher(input, output);\n return output;\n}\n\ntorch::Tensor avg_fc_forward(torch::Tensor input, torch::Tensor weight, torch::Tensor bias) {\n CHECK_CUDA(input); CHECK_CONTIGUOUS(input); CHECK_FLOAT(input);\n CHECK_CUDA(weight); CHECK_CONTIGUOUS(weight); CHECK_FLOAT(weight);\n CHECK_CUDA(bias); CHECK_CONTIGUOUS(bias); CHECK_FLOAT(bias);\n TORCH_CHECK(input.dim() == 4, \"input must be NCHW\");\n TORCH_CHECK(weight.dim() == 2, \"weight must be 2D\");\n TORCH_CHECK(bias.dim() == 1, \"bias must be 1D\");\n TORCH_CHECK(input.size(1) == weight.size(1), \"channel mismatch\");\n TORCH_CHECK(weight.size(0) == bias.size(0), \"class mismatch\");\n auto output = torch::empty({input.size(0), weight.size(0)}, input.options());\n avg_fc_launcher(input, weight, bias, output);\n return output;\n}\n'''\n\n_cuda_src = r'''\n#include <torch/extension.h>\n#include <ATen/cuda/CUDAContext.h>\n#include <cuda.h>\n#include <cuda_runtime.h>\n#include <cfloat>\n\n__global__ void relu_maxpool_kernel(const float* __restrict__ x,\n float* __restrict__ y,\n long total, int C, int H, int W, int Ho, int Wo) {\n long idx = (long)blockIdx.x * blockDim.x + threadIdx.x;\n if (idx >= total) return;\n int ow = idx % Wo;\n long t = idx / Wo;\n int oh = t % Ho;\n t /= Ho;\n int c = t % C;\n int n = t / C;\n\n long base = ((long)n * C + c) * (long)H * W + (long)(oh * 2) * W + (ow * 2);\n float v0 = x[base];\n float v1 = x[base + 1];\n float v2 = x[base + W];\n float v3 = x[base + W + 1];\n float m = fmaxf(fmaxf(v0, v1), fmaxf(v2, v3));\n y[idx] = fmaxf(m, 0.0f);\n}\n\nvoid relu_maxpool_launcher(const at::Tensor& input, at::Tensor& output) {\n const int C = (int)input.size(1);\n const int H = (int)input.size(2);\n const int W = (int)input.size(3);\n const int Ho = (int)output.size(2);\n const int Wo = (int)output.size(3);\n const long total = output.numel();\n const int threads = 256;\n const int blocks = (int)((total + threads - 1) / threads);\n relu_maxpool_kernel<<<blocks, threads, 0, at::cuda::getCurrentCUDAStream()>>>(\n input.data_ptr<float>(), output.data_ptr<float>(), total, C, H, W, Ho, Wo);\n}\n\n__global__ void avg_fc_kernel(const float* __restrict__ x,\n const float* __restrict__ w,\n const float* __restrict__ b,\n float* __restrict__ y,\n int N, int C, int H, int W, int K) {\n int nk = blockIdx.x;\n int k = nk % K;\n int n = nk / K;\n int tid = threadIdx.x;\n int HW = H * W;\n int CHW = C * HW;\n float sum = 0.0f;\n const float* xp = x + (long)n * CHW;\n const float* wp = w + (long)k * C;\n for (int idx = tid; idx < CHW; idx += blockDim.x) {\n int c = idx / HW;\n sum += xp[idx] * wp[c];\n }\n __shared__ float smem[256];\n smem[tid] = sum;\n __syncthreads();\n for (int s = blockDim.x >> 1; s > 0; s >>= 1) {\n if (tid < s) smem[tid] += smem[tid + s];\n __syncthreads();\n }\n if (tid == 0) {\n y[(long)n * K + k] = b[k] + smem[0] / (float)HW;\n }\n}\n\nvoid avg_fc_launcher(const at::Tensor& input, const at::Tensor& weight, const at::Tensor& bias, at::Tensor& output) {\n const int N = (int)input.size(0);\n const int C = (int)input.size(1);\n const int H = (int)input.size(2);\n const int W = (int)input.size(3);\n const int K = (int)weight.size(0);\n const int threads = 256;\n const int blocks = N * K;\n avg_fc_kernel<<<blocks, threads, 0, at::cuda::getCurrentCUDAStream()>>>(\n input.data_ptr<float>(), weight.data_ptr<float>(), bias.data_ptr<float>(),\n output.data_ptr<float>(), N, C, H, W, K);\n}\n'''\n\n_ext = load_inline(\n name=\"regnet_fused_ext_v1\",\n cpp_sources=_cpp_src,\n cuda_sources=_cuda_src,\n functions=[\"relu_maxpool_forward\", \"avg_fc_forward\"],\n extra_cflags=[\"-O3\"],\n extra_cuda_cflags=[\"-O3\"],\n verbose=False,\n)\n\n\nclass ModelNew(nn.Module):\n def __init__(self, input_channels, stages, block_widths, output_classes):\n super(ModelNew, self).__init__()\n self.stages = stages\n self.block_widths = block_widths\n layers = []\n current_channels = input_channels\n for i in range(stages):\n layers.append(self._make_stage(current_channels, block_widths[i]))\n current_channels = block_widths[i]\n self.feature_extractor = nn.Sequential(*layers)\n self.fc = nn.Linear(block_widths[-1], output_classes)\n self._fold_cache_key = None\n self._fold_cache = None\n\n def _make_stage(self, in_channels, out_channels):\n return nn.Sequential(\n nn.Conv2d(in_channels, out_channels, kernel_size=3, padding=1),\n nn.BatchNorm2d(out_channels),\n nn.ReLU(),\n nn.Conv2d(out_channels, out_channels, kernel_size=3, padding=1),\n nn.BatchNorm2d(out_channels),\n nn.ReLU(),\n nn.MaxPool2d(kernel_size=2, stride=2)\n )\n\n def _cache_key(self):\n key = []\n for i in range(self.stages):\n st = self.feature_extractor[i]\n for conv, bn in ((st[0], st[1]), (st[3], st[4])):\n ts = (conv.weight, conv.bias, bn.weight, bn.bias, bn.running_mean, bn.running_var)\n for t in ts:\n key.append((t.data_ptr(), t._version, tuple(t.shape), str(t.device)))\n return tuple(key)\n\n def _get_folded(self):\n key = self._cache_key()\n if self._fold_cache_key == key and self._fold_cache is not None:\n return self._fold_cache\n folded = []\n with torch.no_grad():\n for i in range(self.stages):\n st = self.feature_extractor[i]\n for conv, bn in ((st[0], st[1]), (st[3], st[4])):\n scale = bn.weight * torch.rsqrt(bn.running_var + bn.eps)\n w = (conv.weight * scale.reshape(-1, 1, 1, 1)).contiguous()\n if conv.bias is None:\n cb = torch.zeros_like(bn.running_mean)\n else:\n cb = conv.bias\n b = ((cb - bn.running_mean) * scale + bn.bias).contiguous()\n folded.append((w, b))\n self._fold_cache_key = key\n self._fold_cache = folded\n return folded\n\n def forward(self, x):\n # Fast inference path: fold BatchNorm into convolutions and fuse ReLU+2x2 maxpool.\n if not self.training:\n folded = self._get_folded()\n p = 0\n for i in range(self.stages):\n w1, b1 = folded[p]; w2, b2 = folded[p + 1]; p += 2\n x = F.conv2d(x, w1, b1, padding=1)\n x = torch.relu_(x)\n x = F.conv2d(x, w2, b2, padding=1)\n x = _ext.relu_maxpool_forward(x.contiguous())\n return _ext.avg_fc_forward(x.contiguous(), self.fc.weight.contiguous(), self.fc.bias.contiguous())\n\n # Training-mode-correct path: use PyTorch conv/BN for exact batch statistics, but\n # still avoid extra ReLU allocation and fuse ReLU with maxpool and final GAP+FC.\n for i in range(self.stages):\n st = self.feature_extractor[i]\n conv1, bn1, conv2, bn2 = st[0], st[1], st[3], st[4]\n x = F.conv2d(x, conv1.weight, conv1.bias, padding=1)\n x = F.batch_norm(x, bn1.running_mean, bn1.running_var, bn1.weight, bn1.bias,\n True, bn1.momentum, bn1.eps)\n x = torch.relu_(x)\n x = F.conv2d(x, conv2.weight, conv2.bias, padding=1)\n x = F.batch_norm(x, bn2.running_mean, bn2.running_var, bn2.weight, bn2.bias,\n True, bn2.momentum, bn2.eps)\n x = _ext.relu_maxpool_forward(x.contiguous())\n return _ext.avg_fc_forward(x.contiguous(), self.fc.weight.contiguous(), self.fc.bias.contiguous())\n" }
result ← submit_kernel (ok)
oksubmit_kernel submit_kernel PASSED: (5 / 5) correctness trials all passed. Kernel runtime: 2.32 μs Runtime stats: mean=2.32μs median=?μs std=0.00μs Numerical precision: max_abs_err=3.58e-07 mean_abs_err=5.98e-08 max_rel_err=2.62e-04 Memory: 438.53 MB peak (ref: 438.53 MB, ratio: 1.0x) Kernel launches: 112 (ref: 133, fusion ratio: 1.1875) Energy: 1299.76 mJ/run (ref: 555.06 mJ, ratio: 0.427x) SOL score: 0.1047
final submitted kernel (226 lines, 9,065 chars) — full source, untruncated
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.cpp_extension import load_inline


_cpp_src = r'''
#include <torch/extension.h>
#include <ATen/cuda/CUDAContext.h>

#define CHECK_CUDA(x) TORCH_CHECK(x.is_cuda(), #x " must be a CUDA tensor")
#define CHECK_CONTIGUOUS(x) TORCH_CHECK(x.is_contiguous(), #x " must be contiguous")
#define CHECK_FLOAT(x) TORCH_CHECK(x.scalar_type() == at::kFloat, #x " must be float32")

void relu_maxpool_launcher(const at::Tensor& input, at::Tensor& output);
void avg_fc_launcher(const at::Tensor& input, const at::Tensor& weight, const at::Tensor& bias, at::Tensor& output);

torch::Tensor relu_maxpool_forward(torch::Tensor input) {
    CHECK_CUDA(input); CHECK_CONTIGUOUS(input); CHECK_FLOAT(input);
    TORCH_CHECK(input.dim() == 4, "input must be NCHW");
    const auto N = input.size(0);
    const auto C = input.size(1);
    const auto H = input.size(2);
    const auto W = input.size(3);
    TORCH_CHECK((H >= 2) && (W >= 2), "spatial dims too small");
    auto output = torch::empty({N, C, H / 2, W / 2}, input.options());
    relu_maxpool_launcher(input, output);
    return output;
}

torch::Tensor avg_fc_forward(torch::Tensor input, torch::Tensor weight, torch::Tensor bias) {
    CHECK_CUDA(input); CHECK_CONTIGUOUS(input); CHECK_FLOAT(input);
    CHECK_CUDA(weight); CHECK_CONTIGUOUS(weight); CHECK_FLOAT(weight);
    CHECK_CUDA(bias); CHECK_CONTIGUOUS(bias); CHECK_FLOAT(bias);
    TORCH_CHECK(input.dim() == 4, "input must be NCHW");
    TORCH_CHECK(weight.dim() == 2, "weight must be 2D");
    TORCH_CHECK(bias.dim() == 1, "bias must be 1D");
    TORCH_CHECK(input.size(1) == weight.size(1), "channel mismatch");
    TORCH_CHECK(weight.size(0) == bias.size(0), "class mismatch");
    auto output = torch::empty({input.size(0), weight.size(0)}, input.options());
    avg_fc_launcher(input, weight, bias, output);
    return output;
}
'''

_cuda_src = r'''
#include <torch/extension.h>
#include <ATen/cuda/CUDAContext.h>
#include <cuda.h>
#include <cuda_runtime.h>
#include <cfloat>

__global__ void relu_maxpool_kernel(const float* __restrict__ x,
                                    float* __restrict__ y,
                                    long total, int C, int H, int W, int Ho, int Wo) {
    long idx = (long)blockIdx.x * blockDim.x + threadIdx.x;
    if (idx >= total) return;
    int ow = idx % Wo;
    long t = idx / Wo;
    int oh = t % Ho;
    t /= Ho;
    int c = t % C;
    int n = t / C;

    long base = ((long)n * C + c) * (long)H * W + (long)(oh * 2) * W + (ow * 2);
    float v0 = x[base];
    float v1 = x[base + 1];
    float v2 = x[base + W];
    float v3 = x[base + W + 1];
    float m = fmaxf(fmaxf(v0, v1), fmaxf(v2, v3));
    y[idx] = fmaxf(m, 0.0f);
}

void relu_maxpool_launcher(const at::Tensor& input, at::Tensor& output) {
    const int C = (int)input.size(1);
    const int H = (int)input.size(2);
    const int W = (int)input.size(3);
    const int Ho = (int)output.size(2);
    const int Wo = (int)output.size(3);
    const long total = output.numel();
    const int threads = 256;
    const int blocks = (int)((total + threads - 1) / threads);
    relu_maxpool_kernel<<<blocks, threads, 0, at::cuda::getCurrentCUDAStream()>>>(
        input.data_ptr<float>(), output.data_ptr<float>(), total, C, H, W, Ho, Wo);
}

__global__ void avg_fc_kernel(const float* __restrict__ x,
                              const float* __restrict__ w,
                              const float* __restrict__ b,
                              float* __restrict__ y,
                              int N, int C, int H, int W, int K) {
    int nk = blockIdx.x;
    int k = nk % K;
    int n = nk / K;
    int tid = threadIdx.x;
    int HW = H * W;
    int CHW = C * HW;
    float sum = 0.0f;
    const float* xp = x + (long)n * CHW;
    const float* wp = w + (long)k * C;
    for (int idx = tid; idx < CHW; idx += blockDim.x) {
        int c = idx / HW;
        sum += xp[idx] * wp[c];
    }
    __shared__ float smem[256];
    smem[tid] = sum;
    __syncthreads();
    for (int s = blockDim.x >> 1; s > 0; s >>= 1) {
        if (tid < s) smem[tid] += smem[tid + s];
        __syncthreads();
    }
    if (tid == 0) {
        y[(long)n * K + k] = b[k] + smem[0] / (float)HW;
    }
}

void avg_fc_launcher(const at::Tensor& input, const at::Tensor& weight, const at::Tensor& bias, at::Tensor& output) {
    const int N = (int)input.size(0);
    const int C = (int)input.size(1);
    const int H = (int)input.size(2);
    const int W = (int)input.size(3);
    const int K = (int)weight.size(0);
    const int threads = 256;
    const int blocks = N * K;
    avg_fc_kernel<<<blocks, threads, 0, at::cuda::getCurrentCUDAStream()>>>(
        input.data_ptr<float>(), weight.data_ptr<float>(), bias.data_ptr<float>(),
        output.data_ptr<float>(), N, C, H, W, K);
}
'''

_ext = load_inline(
    name="regnet_fused_ext_v1",
    cpp_sources=_cpp_src,
    cuda_sources=_cuda_src,
    functions=["relu_maxpool_forward", "avg_fc_forward"],
    extra_cflags=["-O3"],
    extra_cuda_cflags=["-O3"],
    verbose=False,
)


class ModelNew(nn.Module):
    def __init__(self, input_channels, stages, block_widths, output_classes):
        super(ModelNew, self).__init__()
        self.stages = stages
        self.block_widths = block_widths
        layers = []
        current_channels = input_channels
        for i in range(stages):
            layers.append(self._make_stage(current_channels, block_widths[i]))
            current_channels = block_widths[i]
        self.feature_extractor = nn.Sequential(*layers)
        self.fc = nn.Linear(block_widths[-1], output_classes)
        self._fold_cache_key = None
        self._fold_cache = None

    def _make_stage(self, in_channels, out_channels):
        return nn.Sequential(
            nn.Conv2d(in_channels, out_channels, kernel_size=3, padding=1),
            nn.BatchNorm2d(out_channels),
            nn.ReLU(),
            nn.Conv2d(out_channels, out_channels, kernel_size=3, padding=1),
            nn.BatchNorm2d(out_channels),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size=2, stride=2)
        )

    def _cache_key(self):
        key = []
        for i in range(self.stages):
            st = self.feature_extractor[i]
            for conv, bn in ((st[0], st[1]), (st[3], st[4])):
                ts = (conv.weight, conv.bias, bn.weight, bn.bias, bn.running_mean, bn.running_var)
                for t in ts:
                    key.append((t.data_ptr(), t._version, tuple(t.shape), str(t.device)))
        return tuple(key)

    def _get_folded(self):
        key = self._cache_key()
        if self._fold_cache_key == key and self._fold_cache is not None:
            return self._fold_cache
        folded = []
        with torch.no_grad():
            for i in range(self.stages):
                st = self.feature_extractor[i]
                for conv, bn in ((st[0], st[1]), (st[3], st[4])):
                    scale = bn.weight * torch.rsqrt(bn.running_var + bn.eps)
                    w = (conv.weight * scale.reshape(-1, 1, 1, 1)).contiguous()
                    if conv.bias is None:
                        cb = torch.zeros_like(bn.running_mean)
                    else:
                        cb = conv.bias
                    b = ((cb - bn.running_mean) * scale + bn.bias).contiguous()
                    folded.append((w, b))
        self._fold_cache_key = key
        self._fold_cache = folded
        return folded

    def forward(self, x):
        # Fast inference path: fold BatchNorm into convolutions and fuse ReLU+2x2 maxpool.
        if not self.training:
            folded = self._get_folded()
            p = 0
            for i in range(self.stages):
                w1, b1 = folded[p]; w2, b2 = folded[p + 1]; p += 2
                x = F.conv2d(x, w1, b1, padding=1)
                x = torch.relu_(x)
                x = F.conv2d(x, w2, b2, padding=1)
                x = _ext.relu_maxpool_forward(x.contiguous())
            return _ext.avg_fc_forward(x.contiguous(), self.fc.weight.contiguous(), self.fc.bias.contiguous())

        # Training-mode-correct path: use PyTorch conv/BN for exact batch statistics, but
        # still avoid extra ReLU allocation and fuse ReLU with maxpool and final GAP+FC.
        for i in range(self.stages):
            st = self.feature_extractor[i]
            conv1, bn1, conv2, bn2 = st[0], st[1], st[3], st[4]
            x = F.conv2d(x, conv1.weight, conv1.bias, padding=1)
            x = F.batch_norm(x, bn1.running_mean, bn1.running_var, bn1.weight, bn1.bias,
                             True, bn1.momentum, bn1.eps)
            x = torch.relu_(x)
            x = F.conv2d(x, conv2.weight, conv2.bias, padding=1)
            x = F.batch_norm(x, bn2.running_mean, bn2.running_var, bn2.weight, bn2.bias,
                             True, bn2.momentum, bn2.eps)
            x = _ext.relu_maxpool_forward(x.contiguous())
        return _ext.avg_fc_forward(x.contiguous(), self.fc.weight.contiguous(), self.fc.bias.contiguous())