Skip to content

Custom Operators: Extending PyTorch with CUDA Kernels

Overview

Custom operators enable: - Specialized CUDA kernels for optimized computation - Custom forward/backward passes for novel operations - C++ extensions for performance-critical code - Memory efficiency through fused operations

PyTorch provides multiple levels of customization for different use cases.


Pure Python Custom Functions

Basic Custom Operation

import torch
import torch.nn.functional as F

class ReLU(torch.autograd.Function):
    """Custom ReLU operation."""

    @staticmethod
    def forward(ctx, input):
        ctx.save_for_backward(input)
        return torch.clamp(input, min=0)

    @staticmethod
    def backward(ctx, grad_output):
        input, = ctx.saved_tensors
        grad_input = grad_output.clone()
        grad_input[input < 0] = 0
        return grad_input

def relu(x):
    return ReLU.apply(x)

# Test
x = torch.tensor([1.0, -2.0, 3.0], requires_grad=True)
y = relu(x)
loss = y.sum()
loss.backward()

print(x.grad)  # [1., 0., 1.]

GELU Activation (Example)

class GELU(torch.autograd.Function):
    """Gaussian Error Linear Unit (GELU) activation."""

    @staticmethod
    def forward(ctx, input):
        # GELU: x * Phi(x) where Phi is Gaussian CDF
        # Approximation: x * sigmoid(1.702 * x)
        ctx.save_for_backward(input)
        return 0.5 * input * (1 + torch.erf(input / math.sqrt(2)))

    @staticmethod
    def backward(ctx, grad_output):
        input, = ctx.saved_tensors

        # Derivative: Phi(x) + x * phi(x)
        # phi is Gaussian PDF: exp(-x^2/2) / sqrt(2*pi)
        pdf = torch.exp(-input ** 2 / 2) / math.sqrt(2 * math.pi)
        cdf = 0.5 * (1 + torch.erf(input / math.sqrt(2)))

        grad_input = grad_output * (cdf + input * pdf)
        return grad_input

CUDA Kernels

When to Use CUDA Kernels

# Use Python/PyTorch ops when:
# - Computation is simple
# - PyTorch ops are fast enough
# - Optimization not critical

# Use CUDA kernels when:
# - Need custom memory layout
# - Multiple operations can be fused
# - Significant performance bottleneck
# - Algorithm not in standard ops

# Example: Flash Attention fuses:
# 1. Q*K^T computation
# 2. Softmax
# 3. Dropout
# 4. Output computation
# → Single kernel (6x faster than separate ops)

CUDA Kernel Structure

// cuda_kernel.cu

#include <torch/extension.h>
#include <cuda.h>
#include <cuda_runtime.h>

// CUDA kernel
__global__ void custom_kernel(float* input, float* output, int size) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx < size) {
        output[idx] = input[idx] * 2.0f;  // Simple: multiply by 2
    }
}

// Wrapper for PyTorch
torch::Tensor custom_forward(torch::Tensor input) {
    auto output = torch::empty_like(input);
    int threads = 256;
    int blocks = (input.numel() + threads - 1) / threads;

    custom_kernel<<<blocks, threads>>>(
        input.data_ptr<float>(),
        output.data_ptr<float>(),
        input.numel()
    );

    return output;
}

// Register with PyTorch
PYBIND11_MODULE(TORCH_EXTENSION_NAME, m) {
    m.def("custom_forward", &custom_forward, "Custom CUDA kernel");
}

Building CUDA Extensions

# setup.py
from setuptools import setup
from torch.utils.cpp_extension import CUDAExtension, BuildExtension

setup(
    name='custom_ops',
    ext_modules=[
        CUDAExtension(
            'custom_ops',
            ['cuda_kernel.cu'],
            extra_compile_args={'cxx': ['-g'], 'nvcc': ['-O2']}
        ),
    ],
    cmdclass={'build_ext': BuildExtension}
)

# Build
# python setup.py build_ext --inplace

Using CUDA Extension

# Python code
import custom_ops

x = torch.randn(1000, 1000, device='cuda')
y = custom_ops.custom_forward(x)

PyBind11 for C++ Extensions

Custom Operation with Autograd

// custom_ops.cpp

#include <torch/extension.h>

torch::Tensor custom_forward(torch::Tensor input, torch::Tensor weight) {
    return torch::matmul(input, weight);
}

torch::Tensor custom_backward_input(
    torch::Tensor grad_output, 
    torch::Tensor weight
) {
    return torch::matmul(grad_output, weight.t());
}

torch::Tensor custom_backward_weight(
    torch::Tensor grad_output, 
    torch::Tensor input
) {
    return torch::matmul(grad_output.t(), input);
}

PYBIND11_MODULE(TORCH_EXTENSION_NAME, m) {
    m.def("forward", &custom_forward, "Forward pass");
    m.def("backward_input", &custom_backward_input, "Backward w.r.t. input");
    m.def("backward_weight", &custom_backward_weight, "Backward w.r.t. weight");
}

Fused Operations

Fused LayerNorm + GELU

class FusedGELU(torch.autograd.Function):
    """Fused GELU activation (faster than separate operations)."""

    @staticmethod
    def forward(ctx, input):
        ctx.save_for_backward(input)
        # Fused computation in single pass
        return 0.5 * input * (1 + torch.erf(input / math.sqrt(2)))

    @staticmethod
    def backward(ctx, grad_output):
        input, = ctx.saved_tensors

        pdf = torch.exp(-input ** 2 / 2) / math.sqrt(2 * math.pi)
        cdf = 0.5 * (1 + torch.erf(input / math.sqrt(2)))

        return grad_output * (cdf + input * pdf)

# Practical: Fused LayerNorm + GELU
class FusedLayerNormGELU(torch.nn.Module):
    def __init__(self, hidden_size, eps=1e-12):
        super().__init__()
        self.weight = torch.nn.Parameter(torch.ones(hidden_size))
        self.bias = torch.nn.Parameter(torch.zeros(hidden_size))
        self.eps = eps

    def forward(self, input):
        # Fused: LayerNorm + GELU in one kernel (hypothetically)
        # Real implementation would use CUDA

        # 1. LayerNorm
        mean = input.mean(-1, keepdim=True)
        std = input.std(-1, keepdim=True)
        normalized = (input - mean) / (std + self.eps)
        normalized = normalized * self.weight + self.bias

        # 2. GELU
        output = FusedGELU.apply(normalized)

        return output

Fused Softmax + Dropout

class FusedSoftmaxDropout(torch.autograd.Function):
    """Fused softmax and dropout (reduces memory access)."""

    @staticmethod
    def forward(ctx, input, p=0.1):
        # Softmax
        input_exp = torch.exp(input - input.max(-1)[0].unsqueeze(-1))
        softmax = input_exp / input_exp.sum(-1, keepdim=True)

        # Dropout (training)
        if p > 0:
            mask = torch.bernoulli(torch.ones_like(softmax) * (1 - p))
            output = softmax * mask / (1 - p)
        else:
            output = softmax
            mask = None

        ctx.save_for_backward(input, softmax, mask)
        ctx.p = p

        return output

    @staticmethod
    def backward(ctx, grad_output):
        input, softmax, mask = ctx.saved_tensors
        p = ctx.p

        # Gradient through dropout
        if mask is not None:
            grad_output = grad_output * mask / (1 - p)

        # Gradient through softmax
        grad_input = softmax * (
            grad_output - (grad_output * softmax).sum(-1, keepdim=True)
        )

        return grad_input, None

Performance: Custom vs Built-in

Benchmark Custom Kernel

import torch
import time

def benchmark_relu():
    x = torch.randn(10000, 10000, device='cuda', requires_grad=True)

    # Built-in ReLU
    start = time.time()
    for _ in range(100):
        y = torch.relu(x)
        y.backward(torch.ones_like(y))
        x.grad.zero_()
    builtin_time = time.time() - start

    # Custom ReLU (Python)
    start = time.time()
    for _ in range(100):
        y = ReLU.apply(x)
        y.backward(torch.ones_like(y))
        x.grad.zero_()
    custom_time = time.time() - start

    print(f"Built-in: {builtin_time:.3f}s")
    print(f"Custom: {custom_time:.3f}s")
    print(f"Speedup: {custom_time / builtin_time:.2f}x")

# Output (typical):
# Built-in: 0.234s
# Custom: 0.456s
# Speedup: 1.95x (slower!)
# → Custom Python autograd slower than optimized built-in

When Custom Wins

# Example: Flash Attention
# Forward: 2.8x faster (fused kernel)
# Backward: 2x faster (fused backward)
# Memory: 50% reduction (no intermediate attention matrix)

# Conditions for speedup:
# 1. Multiple operations fused
# 2. Significant memory savings
# 3. GPU compute-bound (not memory-bound)
# 4. Complex control flow saved

Practical: Fused Linear + GELU

import torch
import torch.nn as nn

class FusedLinearGELU(nn.Module):
    """Fused Linear + GELU layer."""

    def __init__(self, in_features, out_features):
        super().__init__()
        self.linear = nn.Linear(in_features, out_features)

    def forward(self, x):
        # Without fusion (3 operations):
        # 1. Matrix multiply
        # 2. Add bias
        # 3. GELU
        # → 3 kernel launches, 3x memory bandwidth

        # With fusion (1 operation):
        # All in single CUDA kernel

        # Python version (simulates):
        x = self.linear(x)
        x = 0.5 * x * (1 + torch.erf(x / math.sqrt(2)))

        return x

# Real implementation would use:
# 1. PyBind11 wrapper
# 2. CUDA kernel for fusion
# 3. Custom autograd function

Gradient Checking Custom Op

import torch

def gradient_check(func, input, eps=1e-5, atol=1e-4):
    """Verify custom operation gradients."""
    input.requires_grad = True

    # Analytical gradient
    output = func(input)
    output.backward(torch.ones_like(output))
    analytical_grad = input.grad.clone()

    # Numerical gradient
    input.grad.zero_()
    numerical_grad = torch.zeros_like(input)

    for i in range(input.numel()):
        # f(x + eps)
        input_plus = input.clone()
        input_plus.view(-1)[i] += eps
        output_plus = func(input_plus).sum()

        # f(x - eps)
        input_minus = input.clone()
        input_minus.view(-1)[i] -= eps
        output_minus = func(input_minus).sum()

        # (f+ - f-) / 2eps
        numerical_grad.view(-1)[i] = (
            (output_plus - output_minus) / (2 * eps)
        )

    # Compare
    if torch.allclose(analytical_grad, numerical_grad, atol=atol):
        print("✓ Gradient check passed!")
        return True
    else:
        print("✗ Gradient check failed!")
        print(f"Max diff: {(analytical_grad - numerical_grad).abs().max()}")
        return False

# Test custom ReLU
gradient_check(relu, torch.randn(10, requires_grad=True))

Common Pitfalls

Pitfall 1: Memory Layout

# Tensors can be C-contiguous or not
x = torch.randn(3, 4, 5)
y = x.transpose(0, 2)  # Not contiguous!

# CUDA kernel expects contiguous memory
# Must call .contiguous() first
y = y.contiguous()

Pitfall 2: Device Mismatch

# Tensor on GPU, kernel expects CPU
x = torch.randn(10, device='cuda')
y = cpu_kernel(x)  # Error!

# Must handle device placement
device = x.device
x = x.to('cpu')
y = cpu_kernel(x)
y = y.to(device)

Pitfall 3: Gradient Leaks

class BadOp(torch.autograd.Function):
    @staticmethod
    def forward(ctx, x):
        # Forgot to save for backward!
        return x ** 2

    @staticmethod
    def backward(ctx, grad_output):
        # Need saved x to compute gradient
        # This will fail!
        return None  # No gradient

class GoodOp(torch.autograd.Function):
    @staticmethod
    def forward(ctx, x):
        ctx.save_for_backward(x)  # Save for backward
        return x ** 2

    @staticmethod
    def backward(ctx, grad_output):
        x, = ctx.saved_tensors  # Retrieve
        return 2 * x * grad_output

Summary

Method Speed Memory Complexity Use Case
Python autograd Slow High Low Prototyping
PyTorch ops Medium Medium Low Most cases
Fused ops Fast Low High Bottlenecks
CUDA kernels Very fast Low Very high Production
TorchScript Medium Medium Medium Deployment