Kimi-K2.6 · popcorn · l12_default_gpt
correct 6_TriangularAttention.py
model
Kimi-K2.6
turns / max
10 / 10
tool calls
10
tokens (total)
0
wall clock
—
truncated
no
backend
cuda fp32
variant
popcorn
runtime
0.21 μs
ref runtime
0.38 μs
speedup
1.75x
started
2026-05-02T20:57:57Z
timingkernelbench.timing
kernel runtime
0.21 μs
ref runtime
0.38 μs
speedup
1.75x
kernel mean / median / std
0.21 / — / 3.55e-04 μs
ref mean / median / std
0.38 / — / 0.07 μs
numerical precisioneval_kernel_against_ref
max abs error
1.49e-07
mean abs error
1.50e-08
max rel error
0.04
mean rel error
9.65e-07
gpu memoryextended_metrics.measure_memory
kernel peak
36.70 MB
ref peak
38.20 MB
kernel / ref
0.96x
kernel launches / fusiontorch.profiler
kernel launches
7
ref launches
142
fusion ratio (ref / kernel)
20.29x
total cuda time
66 μs
SOL (speed-of-light)nsight_hardware_counters
SOL score (0–1)
0.025
dram util %
2.5%
compute util %
0.0%
bottleneck
unknown
dominant pipe
None
arithmetic intensity
—
ridge point
20.00
roofline / occupancynsight_hardware_counters
dram bandwidth
82.21 GB/s
dram util %
2.5%
fp32 tflops
—
fp32 util %
—
fp16 tflops
—
occupancy %
78.0%
bottleneck
unknown
dominant pipe %
—
arithmetic intensity
—
ridge point
20.00
L1 hit %
—
L2 hit %
260.3%
ld sectors / req
83.29
st sectors / req
—
regs / thread
97
smem / block
8704 B
block size
160
peak bw
3350.00 GB/s
peak fp32 tflops
67.00
peak fp16 tflops
1979.00
warp stalls (top 5)nsight
mio_throttle
15091.6%
long_scoreboard
11053.8%
short_scoreboard
5309.0%
reference PyTorch source (the prompt the agent sees)
import torch
import torch.nn as nn
import torch.nn.functional as F
import math


class Model(nn.Module):
    """
    Triangular self-attention over pair representations, as used in
    AlphaFold2's Evoformer stack.  Starting-node variant: for each
    row i of the pair matrix, standard multi-head self-attention is
    performed across the column dimension j, with an additive pair
    bias.  This lets positions (i,j) and (i,k) exchange information
    through their shared starting node i.
    """

    def __init__(self, pair_dim, num_heads, dropout=0.0):
        super().__init__()
        self.num_heads = num_heads
        self.head_dim = pair_dim // num_heads
        assert pair_dim % num_heads == 0

        self.layer_norm = nn.LayerNorm(pair_dim)
        self.query = nn.Linear(pair_dim, pair_dim, bias=False)
        self.key = nn.Linear(pair_dim, pair_dim, bias=False)
        self.value = nn.Linear(pair_dim, pair_dim, bias=False)
        self.gate = nn.Linear(pair_dim, pair_dim)
        self.bias_proj = nn.Linear(pair_dim, num_heads, bias=False)
        self.out_proj = nn.Linear(pair_dim, pair_dim)
        self.dropout = nn.Dropout(dropout)

    def forward(self, pair_repr: torch.Tensor, pair_mask: torch.Tensor) -> torch.Tensor:
        """
        Args:
            pair_repr: (B, N, N, pair_dim)
            pair_mask: (B, N, N)
        Returns:
            updated pair representation (B, N, N, pair_dim)
        """
        B, N, _, D = pair_repr.shape
        h = self.num_heads
        d = self.head_dim

        x = self.layer_norm(pair_repr)
        g = torch.sigmoid(self.gate(x))

        # Pair bias from the pair representation itself
        bias = self.bias_proj(x).permute(0, 3, 1, 2)  # (B, h, N, N)

        # Reshape to (B*N, N, D) — attention across columns for each row
        x_flat = x.reshape(B * N, N, D)
        q = self.query(x_flat).view(B * N, N, h, d).transpose(1, 2)  # (B*N, h, N, d)
        k = self.key(x_flat).view(B * N, N, h, d).transpose(1, 2)
        v = self.value(x_flat).view(B * N, N, h, d).transpose(1, 2)

        attn = (q @ k.transpose(-1, -2)) / math.sqrt(d)  # (B*N, h, N, N)
        attn = attn.view(B, N, h, N, N)

        # Add pair bias: bias (B, h, N, N) → (B, 1, h, N, N) broadcast across rows
        attn = attn + bias.unsqueeze(1)

        # Apply mask: pair_mask (B, N, N) → mask over the key dimension
        mask = pair_mask.unsqueeze(1).unsqueeze(2)  # (B, 1, 1, N, N) – last dim is keys
        attn = attn.masked_fill(mask == 0, float("-inf"))
        attn = attn.view(B * N, h, N, N)
        attn = F.softmax(attn, dim=-1)
        attn = self.dropout(attn)

        out = (attn @ v).transpose(1, 2).reshape(B, N, N, D)
        out = g * out
        return self.out_proj(out)


pair_dim = 64
num_heads = 4
seq_len = 32
batch_size = 2


def get_inputs():
    p = popcorn_pri
    pair_repr = torch.randn(p.jitter_int(batch_size), p.jitter_int(seq_len), p.jitter_int(seq_len), pair_dim)
    pair_mask = torch.ones(p.jitter_int(batch_size), p.jitter_int(seq_len), p.jitter_int(seq_len))
    return [pair_repr, pair_mask]


def get_init_inputs():
    return [pair_dim, num_heads]
turn 012.3s turn · 12.3s LLM · 0.0s tools
reasoning
We need to write a custom CUDA kernel that replaces the forward computation of the provided PyTorch model. The model is essentially a triangular self-attention over pair representations (starting-node variant). It includes: - LayerNorm on pair_repr (B, N, N, D) - gate = sigmoid(Linear_with_bias(pair_repr)) -> (B, N, N, D) - bias_proj = Linear_no_bias(pair_repr) -> (B, N, N, h), then permute to (B, h, N, N) - Flatten x (post-layernorm) to (B*N, N, D) - query = Linear_no_bias(x_flat) -> reshape to (B*N, h, N, d) - key = Linear_no_bias(x_flat) -> reshape - value = Linear_no_bias(x_flat) -> reshape - attn = (q @ k^T) / sqrt(d) -> (B*N, h, N, N) - reshape attn to (B, N, h, N, N) - add bias.unsqueeze(1) -> (B, 1, h, N, N) broadcast - apply mask: pair_mask unsqueeze to (B,1,1,N,N); masked_fill zeros -> -inf - reshape back to (B*N, h, N, N) - softmax over dim=-1 - dropout - out = attn @ v -> transpose and reshape to (B, N, N, D) - out = g * out - out = Linear_with_bias(out) -> (B, N, N, D) We need to write a CUDA kernel that does this faster than PyTorch reference. We need to consider the sizes: pair_dim=64, num_heads=4, head_dim=16, seq_len=32, batch_size=2. So B=2, N=32, D=64, h=4, d=16. This is relatively small. The reference uses standard PyTorch operations, which are well-optimized. However, there is overhead from many operations, reshape, permute, etc. A fused kernel that does everything from the linear projections to the final output projection in one go could be faster, especially for small sizes. But writing a fully fused kernel for all steps is complex. Let's analyze the bottlenecks and see what can be fused. The main steps: 1. LayerNorm 2. Three linear projections (Q, K, V) without bias 3. Gate linear with bias + sigmoid 4. Bias projection linear without bias 5. Compute attention scores Q*K^T / sqrt(d), add bias, mask, softmax, dropout 6. Multiply by V 7. Multiply by gate g 8. Output projection linear with bias Since D=64, h=4, d=16, N=32, B=2, total elements are small. But the reference might still be fast due to cuBLAS for matmuls. However, many small matmuls have overhead. A fused kernel could be beneficial. We need to check GPU specs to know what we're working with. Let's call get_gpu_specs.
call → get_gpu_specs
callget_gpu_specs {}
result ← get_gpu_specs (ok)
okget_gpu_specs GPU specs for NVIDIA H100 80GB HBM3: total memory (runtime): 79.1 GB spec entry: H100 GPU Architecture: Hopper GPU Memory: 80GB Memory Bandwidth: 3.35 TB/s FP64 TFLOPS: 34 FP64 Tensor Core TFLOPS: 67 FP32 TFLOPS: 67 TF32 Tensor Core TFLOPS: 989 with sparsity BFLOAT16 Tensore Core TFLOPS: 1979 with sparsity FP16 Tensor Core TFLOPS: 1979 with sparsity FP8 Tensor Core TFLOPS: 3958 with sparsity INT8 Tensor Core TOPS: 3958 with sparsity Register File Size: 64K 32-bit registers per SM Maximum number of registers per thread: 255 Maximum number of thread blocks per SM: 32 Shared memory capacity per SM: 228 KB Maximum shared memory per thread block: 227 KB
turn 1 · 1 tool call(s) · 390.6s turn · 390.6s LLM · 254,024 chars (click to expand)
turn 1390.6s turn · 390.6s LLM · 0.0s tools
reasoning
Let me analyze this reference implementation. This is a triangular self-attention over pair representations, specifically the "starting-node" variant from AlphaFold2's Evoformer. The key operations are: 1. LayerNorm on pair_repr 2. Gate (linear + sigmoid) 3. Bias projection 4. Q, K, V projections 5. Attention computation with bias and mask 6. Softmax, dropout 7. Output projection The dimensions are: - pair_repr: (B, N, N, D) - B=2, N=32, D=64, h=4, d=16 Let me think about how to fuse this into efficient CUDA kernels. The reference does several reshape operations: - x.reshape(B*N, N, D) for QKV - Then attention is computed as (B*N, h, N, N) - Then it's reshaped to (B, N, h, N, N) for bias addition - Then masked, reshaped back to (B*N, h, N, N) for softmax This is a relatively small problem (B=2, N=32), so overhead matters a lot. A fused kernel approach would be best. Let me design the fused kernel strategy: 1. First, we can do a fused kernel that computes: - LayerNorm - Q, K, V projections - Bias projection - Gate 2. Then a second fused kernel for attention: - Q @ K^T / sqrt(d) - Add bias - Add mask - Softmax - @ V 3. Then output projection and gate multiplication Actually, given the small sizes (N=32), we can fuse even more aggressively. N=32 means we can load entire rows/columns into shared memory. For attention: N=32, d=16, h=4 - For each query position i (across B*N rows), we compute attention over 32 keys - Q, K, V shapes per head: (B*N, h, N, d) = (64, 4, 32, 16) - Attention scores per head: (64, 4, 32, 32) Since N=32 is small and d=16 is small, we can try to fuse the entire attention forward pass. Wait, I notice this is the starting-node variant. Looking more carefully: - x_flat = x.reshape(B*N, N, D) where B*N = 64 - q = (B*N, h, N, d) = (64, 4, 32, 16) - attn = q @ k^T -> (64, 4, 32, 32) Then attn is reshaped to (B, N, h, N, N) = (2, 32, 4, 32, 32) And bias is (B, h, N, N) = (2, 4, 32, 32), unsqueezed to (2, 1, h, N, N) So for each batch b, each row i, each head h, we have N=32 attention scores over keys j. The bias is shared across rows i (since bias.unsqueeze(1) broadcasts over the row dimension), meaning for a given head and key position, the bias is the same regardless of which row we're computing. Wait, bias shape is (B, h, N, N). Let me re-read: bias = self.bias_proj(x).permute(0, 3, 1, 2) # (B, h, N, N) Then bias.unsqueeze(1) makes it (B, 1, h, N, N). This broadcasts across the row dimension (dim 1 of the 5D tensor). But in the 5D attn tensor (B, N, h, N, N), dim 1 corresponds to the query row i. So the bias is the same for all query positions in a given batch/head. Actually wait, let me re-check. The attn after q@k^T and reshape is (B, N, h, N, N). Let's call it (B, I, h, J, K) where I is query row, J is query col, K is key col. After bias.unsqueeze(1): (B, 1, h, N, N). So for any (B, I, h, J, K), the bias is just bias[B, h, J, K]. Wait no, the original bias is (B, h, N, N) where the last two dims likely correspond to (query_position, key_position) or something? Wait, x is (B, N, N, D), so bias_proj(x) is (B, N, N, h), and permute(0, 3, 1, 2) gives (B, h, N, N). So the last two N dims are the spatial dimensions. In attention: q @ k^T for a row batch gives shape (N_query_positions, N_key_positions) for that row. When reshaped: (B, N_rows, h, N_query_cols, N_key_cols). Hmm, actually for each of the B*N rows, we have (h, N_query_positions, N_key_positions). Since there are B*N rows total and each has N query positions (for that row batch), the total is B*N*N = B*N^2. Wait, I think there's a subtlety. Let me re-trace: - x_flat = x.reshape(B*N, N, D) -- (B*N rows, each with N positions, D features) - q = (B*N, h, N, d) -- for each of B*N rows, h heads, N query positions, d dims - k = same for keys - v = same for values - q @ k^T = (B*N, h, N, N) -- for each row, each head, N query positions attend to N key positions - attn.view(B, N, h, N, N) -- (B, N_rows, h, N_queries, N_keys) The B*N "rows" in x_flat correspond to flattening (B, N) where each batch has N spatial positions. So for each batch b and row i, we have a self-attention across the N positions of that row. The bias is (B, h, N, N) -- shared across the row dimension when broadcast. So for a given batch b and head h, the bias depends only on the spatial positions (m, n) that are the last two dims. But in the attention tensor (B, N, h, N, N), the last two dims are also spatial. And bias (B, 1, h, N, N) broadcasts over dim 1. So yes, the bias is the same for all N rows in dim 1. Actually wait - I need to check if the last two dims of bias match up with the last two dims of attn. Or is bias (B, h, J, K) where J and K are the spatial dims? And attn has dims (B, I, h, J, K)? Yes, that seems right. So adding bias.unsqueeze(1) adds the same bias matrix to each row I. This makes sense in the context of AlphaFold's triangular attention: the bias is computed from the pair representation. Now, let me think about how to fuse this optimally. Given the small problem size, I think we should write: 1. A fused kernel for computing all linear projections, layernorm, and gate 2. A fused attention kernel that computes QK^T, adds bias, applies mask, softmax, and multiplies by V For N=32, each row of the attention matrix is just 32 elements. We can compute each attention row in a thread block. Let me plan the kernel structure more carefully. Kernel 1: Fused preprocessing Input: pair_repr (B, N, N, D), pair_mask (B, N, N) Output: Q (B*N, h, N, d), K (B*N, h, N, d), V (B*N, h, N, d), bias (B, h, N, N), gate (B, N, N, D) Wait, we don't actually need to output QKV separately if we fuse everything. But for clarity and to save memory, maybe: Actually for H100, we should try to be very aggressive with fusion. Since N=32 is tiny, we can potentially do: Kernel 1: For each (B, N, N, D) element, compute layernorm, then all projections. This would require loading the entire (N, D) row for layer norm. We can have each thread block handle one (B, N) row and compute layer norm over the N*D elements of that row's... wait, layer norm is over the D dimension, not N. LayerNorm: for each position (b, i, j), normalize over D=64 features. Actually, layer norm is along the last dimension (pair_dim). So for each (b, i, j), we compute mean and std over D=64 elements. Then all linear operations (query, key, value, gate, bias_proj) are also applied per position. Strategy: - Each warp or thread block handles a chunk of positions - For each position, we need to: 1. Compute layer norm (load D=64 elements, compute mean/var, normalize) 2. Apply 5 linear projections to get q, k, v, g, bias 3. Store results Since D=64 and we have 5 output projections with total output size D + D + D + D + h = 64*4 + 4 = 260 per position. For h heads and d head_dim: q, k, v each are D, gate is D, bias_proj is h. Total 4D + h = 260 floats per position. Total positions: B * N * N = 2 * 32 * 32 = 2048. If each thread handles one position: 2048 threads total. On H100 that's tiny. We should have each thread process multiple positions or each warp handle a group. Actually for maximum performance on small problems, we want to saturate the SMs. With 2048 positions and each position needing to compute 5 linear projections (each is a matrix-vector multiply of size (out_dim, D)), we can parallelize very well. For the attention kernel: For each of B*N = 64 "row heads" (actually B*N positions in the flattened row dimension) and each of h=4 heads, we compute attention over N=32 keys for N=32 queries. Total attention computations: B*N*h = 64*4 = 256 attention heads. Each attention head has N queries x N keys = 32x32 = 1024 score computations. Each query then has a 32-element softmax and a 32xd matrix multiply. We could have each thread block handle one (row_batch, head) pair, computing all N=32 queries. That's 256 thread blocks total. Actually, let me re-think the dimensions. For x_flat, we have (B*N, N, D) = (64, 32, 64). The Q, K, V for this are (64, h, N, d). For each of the 64 rows and 4 heads, we have Q, K, V each of shape (32, 16). The attention for each (row, head) is: Q @ K^T (32, 32) -> add bias -> add mask -> softmax -> @ V (32, 16). So there are 64 * 4 = 256 such independent attention computations. For each one, N=32 is very small. We could compute all 32 queries in parallel within a thread block. Let me write kernels. For Kernel 1 (preprocessing): I'll have each thread block handle a number of positions. Each thread in the block processes one or more (B, N, N) positions. Kernel 2 (attention): For better efficiency, we can have each thread block handle one (B*N, h) pair, i.e., one attention head. Within the block, we can use shared memory for K and V, and compute QK^T and attention@V. With N=32 and d=16: - Load K (32, 16) to shared memory: 512 floats = 2KB - Load V (32, 16) to shared memory: 2KB - Total shared memory per block: ~4KB + some for Q rows - Block size: 32 threads (one per query) or 256 threads to have more parallelism per query Actually, for a 32x32 attention matrix where each element is a dot product of two 16-dim vectors, we might want more threads. Alternative: use a warp per query. 32 warps = 1024 threads per block would handle all 32 queries. But occupancy might be low with 1024 threads/block. Or: each thread handles one query. 32 threads per block. For each query, the thread computes 32 dot products of 16 elements each (QK^T row), then softmax over 32 elements, then the output is the weighted sum of 32 value vectors of 16 elements. 32 threads per block means very low occupancy but we have 256 blocks, so that might be okay on H100. Or: each block handles multiple attention heads. Say 8 heads per block -> 256 threads = 8 warps. Each warp handles one head? That could work. Actually, the simplest approach that avoids complex shared memory coalescing issues: for each attention head, use a block of 256 threads (or 128) where threads cooperate to load K and V to shared memory, and then each thread is assigned some queries. Wait, let me think about what's simplest and fastest. With N=32 and d=16: - The attention score matrix is 32x32 - Each score is a dot product of 16 elements If we use 32 threads in a block, each thread handles one row (query). To compute the 32 scores for that row, each thread does 32 dot products of 16 elements = 512 multiply-adds. Then softmax over 32 elements (single thread). Then the output is a weighted combination of 32 vectors of 16 elements = 512 multiply-adds. Total ~1024 FMAs per thread. With 256 blocks (one per head), total work is 256 * 32 * 1024 ≈ 8.4M FMAs. This is tiny for H100. The limiting factor is memory bandwidth and kernel launch overhead. So we want to minimize memory movement by aggressive fusion. Let me write a heavily fused kernel. Since N=32 is small, we might even be able to keep Q, K, V in registers or shared memory for the entire attention computation. Here's a plan for a single giant fused kernel: For each (b, i, j) position: 1. Load pair_repr[b, i, :, :] for the entire row i? No, layer norm is per position. Actually, for the attention part, the Q, K, V projections use x_flat = x.reshape(B*N, N, D). This means for a given (b, i) flattened row, Q, K, V depend only on x[b, i, :, :] (the N positions in that row). For bias_proj, it depends on all x[b, :, :, :]. So we'd need to load the entire (B, N, N, D) tensor. That's fine. Let me write two kernels: 1. Kernel that computes layer norm, then Q, K, V for all positions, and bias, and gate 2. Kernel that computes the attention and output For kernel 2, we can have it also do the final gate multiply and output projection, if we load the gate and do the output proj in shared memory. Actually, for maximum fusion: we could even do the output projection in the attention kernel. After computing attention output (B, N, N, D), we multiply by gate (B, N, N, D) elementwise, then apply out_proj (linear on last dim), then add to the original pair_repr? Wait no, the reference returns just `self.out_proj(out)` where out is already gated. Actually the reference returns `self.out_proj(out)` where `out = g * (attention_output)`. So: - attention_output: (B, N, N, D) - g: (B, N, N, D) - gated = g * attention_output - result = Linear(gated) So we can fuse the output projection into the attention kernel as well. Better yet, we can compute gated * W_out in a very efficient way if we load the value vectors appropriately. But maybe it's simpler to write 2-3 kernels and let the compiler optimize. Let me think about the exact computation flow and see if we can do it all in one kernel or a few. Kernel "preprocess": - Input: pair_repr (B, N, N, D) - For each (b, i, j): - Compute layer norm over D dimension - Apply linear projections to get Q, K, V contributions, gate, bias - But Q, K, V are not per position in the way you'd think. For x_flat = x.reshape(B*N, N, D), each of the B*N "rows" has its own Q, K, V computed from all N positions in that flattened row. - So for flattened row index r = b*N + i, the Q for that row is W_q @ x[b, i, :, :].T? No. Wait, let me be more careful. x_flat is (B*N, N, D). For flattened row r = b*N + i, x_flat[r, :, :] = x[b, i, :, :]. Then: - q = self.query(x_flat) -- linear applied to each position independently - q[r, n, :] = W_q @ x_flat[r, n, :] = W_q @ x[b, i, n, :] So Q, K, V ARE per-position! The linear layers are applied independently to each token position. After the linear, we reshape into heads. So: for each (b, i, j) position in the original tensor, we compute: - q_raw[b, i, j, :] = W_q @ norm_x[b, i, j, :] - k_raw[b, i, j, :] = W_k @ norm_x[b, i, j, :] - v_raw[b, i, j, :] = W_v @ norm_x[b, i, j, :] - g[b, i, j, :] = sigmoid(W_g @ norm_x[b, i, j, :]) - bias_raw[b, i, j, :] = W_b @ norm_x[b, i, j, :] Then reshape q_raw, k_raw, v_raw into heads. This means the linear projections are indeed elementwise (applied per position) and we can compute them all in parallel! And bias[b, :, h, j, k]? Wait, bias is from bias_proj(x).permute(0, 3, 1, 2): bias[b, h, j, k] = bias_raw[b, j, k, h] (because permute(0, 3, 1, 2)) So bias is just the bias_proj output with permuted dimensions. OK so here's my kernel plan: **Kernel 1: Fused Preprocessing** - Each thread block handles a range of (b, i, j) positions - For each position: 1. Load pair_repr[b, i, j, :] (64 floats) 2. Compute mean and variance over D=64 3. Apply layer norm 4. Compute q, k, v, g, bias via matrix-vector multiplies with the weight matrices 5. Store results But computing 5 matrix-vector multiplies per position where each matrix is (out, in) with in=64 is a lot of computation. We can use registers efficiently. For D=64, we can have each thread process one position. A warp of 32 threads computing 32 positions in parallel. But the matrix-vector multiply with a 64x64 matrix needs cooperation to read the weight matrix efficiently. Actually, for a 64x64 matrix-vector multiply, the simplest approach is to have each thread compute the entire output vector for its position. The thread reads all 64 input elements, then for each output dimension, it reads the corresponding 64 weights and computes the dot product. This is 64*64 = 4096 multiplies per position, and we have 5 such matrices, so 20K multiplies per position. With 2048 positions, that's ~41M ops. Very manageable. The weights should be read from constant memory or cached L1. Since they're the same for all positions, they'll stay in cache. Wait, but for Q, K, V we compute 64-dimensional outputs, and for gate also 64, and for bias_proj we compute h=4. Total output per position: 64*4 + 4 = 260 floats. Let's think about the memory bandwidth: - Input: 2048 * 64 = 131K floats - Output Q, K, V: 2048 * 64 * 3 = 393K floats - Output gate: 2048 * 64 = 131K floats - Output bias: 2048 * 4 = 8K floats - Total output: ~532K floats This is small enough that compute might dominate. **Kernel 2: Fused Attention** Input: Q, K, V (each B*N, h, N, d), bias (B, h, N, N), pair_mask (B, N, N), gate (B, N, N, D) Output: out_proj result (B, N, N, D) Wait, we also need the output projection weights. For the attention, I need to compute for each (b, i, h, query_j): - scores[k] = sum_d(Q[b, i, h, query_j, d] * K[b, i, h, k, d]) / sqrt(d) + bias[b, h, query_j, k] Wait, let me check the bias indexing again. attn has shape (B, N, h, N, N) after reshape. The last two dims correspond to query position and key position? No... q is (B*N, h, N, d) where the N is the query positions (for that flattened row). k is (B*N, h, N, d) where N is the key positions. q @ k^T = (B*N, h, N, N) where dims are (queries, keys). Then view to (B, N, h, N, N). Here: - dim 0: batch - dim 1: row i (which flattened row) - dim 2: head - dim 3: query position j - dim 4: key position k And bias is (B, h, N, N), unsqueezed to (B, 1, h, N, N). So bias[b, h, j, k] is added to attn[b, i, h, j, k] for all i. And mask is (B, N, N), unsqueezed to (B, 1, 1, N, N). So mask[b, j, k] is applied to all heads and all query rows i. Wait, mask is pair_mask which is (B, N, N). In the attention, is it masking based on (j, k)? Let me check: `mask = pair_mask.unsqueeze(1).unsqueeze(2) # (B, 1, 1, N, N)` So mask[b, 1, 1, j, k] applies to attn[b, i, h, j, k] for all i, h. That means the mask depends on the query position j and key position k, not on the flattened row i. So in the attention kernel, for a given batch b and spatial positions (j, k), the mask is the same across all flattened rows i and all heads h. Now, for the attention over flattened rows: each flattened row corresponds to one (b, i) pair, and within it, each query position j attends to key positions k. The biases and masks depend on (j, k) but are shared across all (b, i) and h? No, bias depends on head h too: bias[b, h, j, k]. OK so to compute attention efficiently: For each flattened row r = b*N + i and head h: - For each query j in 0..N-1: - Compute scores[k] = dot(Q[r, h, j, :], K[r, h, k, :]) / sqrt(d) + bias[b, h, j, k] - Apply mask: if pair_mask[b, j, k] == 0, score = -inf - Softmax over k - Output[j, :] = sum_k softmax_score[k] * V[r, h, k, :] Then reshape output to (B, N, N, D), multiply by gate, apply output projection. For Kernel 2, since N=32, d=16, we can have each thread block handle multiple (r, h) pairs. Let me design Kernel 2 more concretely: Each block handles some number of query positions across heads. For simplicity, let's say each block handles one (r, h) pair and all N=32 query positions. Block configuration: - 32 threads (one per query) - Each thread loads its query vector Q[r, h, j, :] (16 floats) into registers - Collaborative loading of K[r, h, :, :] (32x16=512 floats) and V[r, h, :, :] (512 floats) into shared memory - Sync - Each thread computes its 32 attention scores by dotting its Q with each K in shared memory - Add bias and mask - Softmax over the 32 scores - Each thread computes its output vector by weighting the V vectors - Store output For bias and mask, we need bias[b, h, j, :] and pair_mask[b, j, :]. Since these are per-query, each thread can load its own bias/mask row. Wait, we have 256 (r, h) pairs total. With 32 threads per block and 256 blocks, we get 8192 threads total. On H100 with 132 SMs, that's ~62 threads per SM. With 4 blocks per SM (occupancy limited), we'd have ~250 blocks total. 256 blocks of 32 threads = 8192 threads, which is very light. To increase occupancy, we could have larger blocks. For example, 256 threads per block, where the block handles 8 (r, h) pairs with 32 threads per pair. Or the block could handle one (r, h) pair but use 256 threads to parallelize the computation within a query. For N=32, d=16, and using 256 threads for one (r, h) pair: - We can assign 8 threads per query (32 queries * 8 threads = 256 threads) - Each group of 8 threads handles one query j - For QK^T: each thread computes 4 of the 32 dot products (since 32 scores / 8 threads = 4 scores per thread) Wait, each score is a dot product of 16 elements. With 8 threads per query, each thread can compute part of each dot product. Actually, for dot(Q_j, K_k), 8 threads can each compute 2 elements and then warp-reduce. Hmm, this gets complicated. Let me think about what gives the best utilization. For a 32x32 attention with 16-dim vectors: - Total score computations: 32 queries * 32 keys * 16 dot = 16,384 multiply-adds - Softmax: 32 * (32 operations + exp + div) - Attention*V: 32 queries * 32 keys * 16 = 16,384 multiply-adds - Total FLOPs per (r, h): ~33K FMAs 256 * 33K = 8.4M FMAs total for attention. This is tiny for H100 (67 TFLOPS). So we're definitely memory bound. Memory for attention kernel: - Load Q: 256 * 32 * 16 = 131K floats - Load K: 256 * 32 * 16 = 131K floats - Load V: 256 * 32 * 16 = 131K floats - Load bias: 2 * 4 * 32 * 32 = 8K floats - Load mask: 2 * 32 * 32 = 2K floats - Load gate: 2 * 32 * 32 * 64 = 131K floats - Load output proj weights: 64 * 64 = 4K floats - Store output: 2 * 32 * 32 * 64 = 131K floats Total memory: ~670K floats = ~2.7MB. At 3.35 TB/s, this should take less than 1 microsecond. But kernel overhead will dominate. So the key is to minimize the number of kernel launches and maximize fusion. Can we do everything in a single kernel? For a single kernel: 1. Each thread block handles one or more (b, i) rows 2. For each (b, i) row, we need the N positions x[b, i, :, :] 3. For each position, compute layer norm, then compute q, k, v, g, bias contributions 4. But wait, bias and gate are needed for the attention and output. 5. After computing all q, k, v for a row, we can compute attention 6. Then apply gate and output projection The challenge is that for a given (b, i), computing q, k, v for all N positions requires storing them somewhere before attention. But we can keep them in shared memory! For one (b, i) row and one head h: - Q: (N, d) = 32 * 16 = 512 floats - K: 512 floats - V: 512 floats - Total: 1536 floats = 6KB For h=4 heads: 24KB of shared memory per (b, i) row. That's reasonable. Wait, but we also need x[b, i, :, :] for computing layer norm and projections. Let's think: If each block handles one (b, i) row and all 4 heads: - Load x[b, i, :, :] (32 * 64 floats = 2KB) to shared memory - Compute layer norm for all 32 positions (cooperative) - Compute q, k, v, g, bias for all 32 positions - q[b, i, j, :] = W_q @ ln_x[b, i, j, :] -- for all j - This is 32 matrix-vector multiplies with 64x64 matrix - Store q, k, v in shared memory (24KB) - Compute attention for all 4 heads - For each head, multiply Q @ K^T, add bias, mask, softmax, @ V - Store attention output in shared memory or registers - Apply gate: out[b, i, j, d] = g[b, i, j, d] * attn_out[b, i, j, d] - Apply output projection This is getting complex but should be very efficient. Let's simplify. Actually, for per-position linear projections with small matrices (64x64), it's more efficient to have each thread or warp handle one position and compute all projections for that position. Then the attention part can be done with the results. With 32 threads per block and one block per (b, i) row: - Each thread handles one position j - Thread j loads x[b, i, j, :] (64 floats), computes layer norm - Then computes q, k, v, g, bias contributions for that position - Each is W @ ln_x. For output sizes 64, 64, 64, 64, 4 - Total weight reading per thread: 4 * 64*64 + 4*64 = ~16K floats - But weights are shared, so they come from cache - Store q, k, v in shared memory. We need them arranged so that attention can access them efficiently. Wait, let me reconsider. The weights for each linear layer are (out_features, in_features) = (64, 64). Each thread doing a matrix-vector multiply by itself is fine for small matrices because the weights stay in L1 cache. But arranging the results in shared memory for cooperative attention might be tricky. Let me try a simpler approach: two kernels. Kernel 1: Compute layer norm and all projections - Input: pair_repr (B, N, N, D) - Output: - q (B*N, h, N, d) - k (B*N, h, N, d) - v (B*N, h, N, d) - bias (B, h, N, N) - gate (B, N, N, D) Kernel 2: Fused attention + gate + output projection - Input: q, k, v, bias, mask, gate - Output: final result For kernel 1, each position (b, i, j) can be handled independently. Total positions = 2048. Each thread computes layer norm + 5 linear projections. 2048 threads is small. Let's use more threads per position or process multiple positions per thread. Actually, we can have each thread handle the computation for one output channel across all positions... no, for a matrix-vector multiply, it's most natural for each thread to handle one position and compute all outputs. To get better occupancy, we can have each thread handle multiple positions. With 2048 positions and, say, 256 threads per block, we'd have 8 blocks. That's too few. So we need 8 * some_factor blocks. Alternatively, we can parallelize the inner loops. For each position, a group of threads (e.g., a warp) computes the layer norm and projections cooperatively. Then 2048 positions / 32 threads per position = 64 warps = 2 blocks of 1024 threads, or 4 blocks of 512, or 8 blocks of 256, or 16 blocks of 128, etc. Let me try: 128 threads per block, each block handles 128 positions (one thread per position). 2048 / 128 = 16 blocks. Still a bit small. 256 threads per block, each handling one position: 2048 / 256 = 8 blocks. Very small. Hmm. For problems this small, the overhead of the kernel launch itself is significant. We want as few kernels as possible. Alternative approach: write a single kernel where: - Each block handles one (b, i) row (there are B*N = 64 such rows) - Block has 256 threads - The 256 threads cooperatively handle all positions in the row and all heads For one (b, i) row: - 32 positions, each needs layer norm + 5 projections - 4 heads, each needs attention over 32x32 With 256 threads: - First phase (projections): Assign threads to positions and output channels - Need to compute 32 positions * (64+64+64+64+4) outputs = 32 * 260 = 8320 output values - 256 threads, so each thread computes ~32.5 outputs - We can have each thread compute some (position, output_channel) pairs - Second phase (attention): Assign threads to (head, query) combinations - 4 heads * 32 queries = 128 attention computations - 256 threads, so 2 threads per (head, query) pair - Each pair of threads can cooperate to compute the attention for one query in one head This could work well. Let me flesh it out. Actually wait, for the first phase, layer norm requires computing mean and variance over the D=64 channels. For 32 positions, we can have a warp (32 threads) handle each position: 32 threads load all 64 channels (cooperatively, 2 channels per thread), compute mean and variance via warp shuffle, then normalize. Then 32 warps handle 32 positions. That's 1024 threads. But we want 256 threads. With 256 threads and 32 positions: 8 threads per position. 8 threads can load 64 channels (8 per thread), compute mean and variance via warp shuffle within the subgroup of 8, normalize, then compute linear projections. Actually, for 8 threads doing layer norm on 64 channels: each thread loads 8 channels, they compute the mean together (warp shuffle or shared memory reduction), then variance, then normalize. This works but requires cross-thread communication. With only 8 threads, warp shuffle is fine. Hmm, with 256 threads = 32 warps of 8 threads each. Each warp handles one position (32 warps handle 32 positions out of the row's N=32 positions). Perfect! Wait, we need 32 warps = 1024 threads for 32 positions if each warp handles one position total. Wait no. A warp is 32 threads, not 8. So 256 threads = 8 warps. If we want 32 positions and some number of positions per warp... 8 warps can handle 8 positions (one per warp), and then we'd need 4 iterations to cover all 32 positions. But that doesn't parallelize the positions. Hmm, let's think differently: we don't need to process all 32 positions simultaneously. With 8 warps of 32 threads = 256 threads, we can process 8 positions simultaneously. For each position, 32 threads load and process the 64 channels. 8 warps * 8 = 64. But we need processing for 32 positions. Actually, 256 threads = 8 warps. For a row of 32 positions, we can have each warp process 4 positions sequentially. Each of the 8 warps processes 4 positions. For each position in a warp: - 32 threads load 64 floats (2 per thread) - Compute mean (warp reduce) - Compute var (warp reduce) - Normalize - Each thread computes some subset of the output channels for the linear projections Wait, but the 32 threads in a warp are processing 4 positions sequentially. That means positions are handled one after another within the warp. But we can have the warp process 4 positions with each thread handling one position and all outputs? No, a warp has 32 threads, but we have 4 positions. We could have 8 threads per position. 8 threads per position within a warp: 8 threads load the 64 channels (8 each), compute mean/var together, normalize. Then each thread computes some outputs. With 8 threads and outputs of 260 total (q+k+v+g+bias), each thread computes ~32 outputs. This is sequential within the 8-thread group but parallel across the 4 positions in the warp. I'm getting confused. Let me simplify. For Kernel 1 (preprocessing), I'll just use one thread per position and have the kernel launch sufficient blocks to cover all B*N*N = 2048 positions. I'll use 128 or 256 threads per block. 2048 positions / 256 threads per block = 8 blocks. That's very small. But we can also handle 2048 * 4 = 8192 "work items" if we split the output computation across threads. For example, each thread computes one output channel for one position. Total outputs = 2048 * 260 = 532,480. With 256 threads per block, we'd need ~2080 blocks. That's much better! Wait, but layer norm requires the full input vector, so it's not easy to parallelize within a position for layer norm. However, after layer norm, the linear projections CAN be parallelized per output channel. So: - Phase 1: Layer norm for all 2048 positions. Use 2048 threads total (e.g., 128 threads/block * 16 blocks, or 256 threads/block * 8 blocks). Each thread handles one position, computes mean/var over 64 channels, stores normalized vector back. - Phase 2: Linear projections. This is equivalent to applying 5 linear layers. We can think of it as one big matrix multiply: (2048, 64) @ (64, 260).T? Not exactly because the layers have different weights. But conceptually, we're doing 5 separate MVMs per position. Actually for phase 2, we can just have each thread compute all projections for one position, similar to phase 1. The cache will keep the weights hot since all threads read the same weights. But 8 blocks is indeed very few. On H100 with 132 SMs, we'd only utilize ~6% of the SMs. We need more parallelism. Here's a better idea: process multiple positions per thread! Each thread can handle, say, 4 positions. Then 2048 / 4 = 512 threads. Still too few. Or: process the problem in tiles where each block handles multiple (b, i) rows. If each block handles 4 (b, i) rows, and each row has 32 positions, that's 128 positions per block. With 128 threads (each handling 1 position), we get 16 blocks. Better but still small. If each block handles 16 rows: 512 positions, 256 threads (2 positions each) = 4 blocks. Worse. If each block handles 8 rows: 256 positions, 256 threads = 8 blocks. If we want at least 132 blocks, we need each block to handle ≤ 15 positions. E.g., 128 threads per block, each handling 1 position: 2048/128 = 16 blocks. Still too few. The fundamental issue is that 2048 positions is just too small to get high occupancy with one-thread-per-position. What if each thread computes one output element across all positions? For Q projection: output size is 64 per position, total 2048*64 = 131K output elements. For all 5 projections: 532K output elements. If each thread computes one output element: we need 532K threads total = ~2080 blocks of 256 threads. That's much better! But then how do we layer norm? Each output element requires the full normalized input vector. We could do: 1. Kernel 1a: Layer norm for all 2048 positions (computes and stores normalized x). Since this is just reading/writing 2048*64 elements, it's memory bandwidth bound. 8 blocks of 256 threads each is fine for this because it's a simple kernel. 2. Kernel 1b: Parallel GEMV for all positions and all outputs. Each thread computes one (position, output_channel). For the 5 projections, we launch the kernel with 532K threads. Each thread loads the normalized input for its position (64 floats), then computes one dot product with the appropriate weight row. Wait, but if each thread handles one (position, output), and there are 64 inputs per position, each thread performs 64 multiplies and 63 adds. Total work: 532K * 64 = 34M FMAs. Actually, we could do the layer norm and the first few projection elements in the same kernel to avoid writing the normalized x to global memory. But maybe it's simpler to just do separate kernels. For the attention kernel, we have the same occupancy issue: 256 attention heads (B*N*h = 64*4 = 256), each with 32 queries. That's 8192 queries total. If we use one thread per query: 8192 threads = 32 blocks of 256. Still small. If we use multiple threads per query: for each of 32 scores, use multiple threads. Or for each attention head, use 256 threads to parallelize across queries and score computation. For one attention head (32 queries, 32 keys, d=16): - Total score computations: 32*32 = 1024 scores, each from 16-element dot product = 16K FMAs - Softmax: ~1K operations - Attention*V: 32*32*16 = 16K FMAs - Total: ~33K FMAs With 256 threads per attention head: each thread does ~130 FMAs for scores and ~130 FMAs for output. Very light. So for 256 attention heads and 256 threads per head: 65,536 threads total = 256 blocks. That's better - about 2 blocks per SM on H100. Still not great but maybe okay. Actually, we could have each block handle multiple attention heads. Say 4 heads per block: 64 blocks total, each with 1024 threads. Or 2 heads per block with 512 threads. Let me think about what's practical to implement. I think for small problems, the best approach is: **Approach 1: Higher-level fusion with fewer kernels** Kernel 1: For all (b, i, j) positions in parallel, compute layer norm and all 5 projections. Output Q, K, V, bias, gate. - Use one thread per position, but launch enough work by having a reasonable block size. - 2048 positions. Let's use 128 threads/block, 16 blocks. - This is memory and compute light, so having few blocks might be okay if it's fast enough. Kernel 2: For all attention heads, compute attention, gate multiply, output projection. - 256 heads, 32 queries each. - Use one thread per query: 8192 threads. 256 threads/block = 32 blocks. - Better yet, have each block handle 4 heads, using 256 threads total. Threads can be assigned as (query, task) where task is different parts of the computation. 8 blocks total... no wait. - Actually, if each block handles 4 heads and 256 threads: 64 blocks. - Or each block handles 1 head with 256 threads: 256 blocks. Hmm, let me try a completely different approach. Instead of separately materializing Q, K, V in global memory, what if I launch the attention kernel and each block loads the original pair_repr, computes the projections on-the-fly, then does attention? This would save writing Q, K, V to global memory and then reading them back! For Kernel 2 (fused attention): - Each block handles one or more (b, i, h) attention heads - For each head, the block loads x[b, i, :, :] (32 * 64 = 2KB), computes layer norm on all 32 positions, computes q, k, v for all 32 positions and the specific head, stores in shared memory, then computes attention, then output projection. But wait, we also need bias and gate, which depend on x[b, :, :, :] and x[b, i, :, :] respectively. - bias depends on all (b, j, k) and is shared across i. So each block would need to recompute or read bias. We could compute bias on-the-fly too, but that requires reading the full (b, :, :, :) tensor. Actually, computing bias on-the-fly for one (b, h) requires all N*N positions in batch b. With N=32, that's 1024 positions. Each block loading all of them would be wasteful. Alternative: precompute bias and gate in one kernel, then do attention with Q, K, V computed on-the-fly. Wait, even simpler: for the tiny size of this problem, maybe better to just use PyTorch operations fused in some way? But we need to beat the reference, which is already using batched matrix multiplies. The reference implementation does: 1. layer_norm: reads/writes B*N*N*D = 131K floats 2. gate, bias_proj, query, key, value: each is a linear layer. PyTorch might fuse some of these. 3. q @ k^T: batched matmul, very efficient 4. softmax: efficient 5. attn @ v: batched matmul 6. g * out: elementwise 7. out_proj: linear PyTorch's matmuls for these small sizes (32x16 @ 16x32 etc.) are actually quite efficient on GPU. To beat it, we need to: - Reduce memory round trips by fusing operations - Avoid reshapes and views - Keep data in shared memory/registers instead of going to global memory between operations Let me write a two-kernel solution: Kernel 1: `preprocess` - Computes layer norm, then Q, K, V, bias, gate - Inputs: pair_repr (B, N, N, D), pair_mask (B, N, N) - actually mask not needed for preprocess - Outputs: q (B*N, h, N, d), k (B*N, h, N, d), v (B*N, h, N, d), bias (B, h, N, N), gate (B, N, N, D) Kernel 2: `fused_attention` - Inputs: q, k, v, bias, pair_mask, gate, out_proj_weight, out_proj_bias - Output: result (B, N, N, D) For Kernel 1, to improve occupancy, each thread handles M positions sequentially. Let's use M=4. - 2048 positions / 4 = 512 threads total. - 128 threads/block = 4 blocks. Still bad. - 64 threads/block = 8 blocks. Worse. Hmm. What if we increase M? If each thread handles 16 positions: 2048/16 = 128 threads. 1 block. That's terrible. OK so the only way to get more parallelism in Kernel 1 is to parallelize within each position or launch simultaneously with independent thread blocks that handle different batches of the weight computation. Actually, what if for the linear projections, we treat it like a big matrix multiplication? We can transpose the inputs and use cublas or something? But we're writing a custom kernel. A different idea: for the linear projections (query, key, value, gate, bias_proj), these are standard 2D convolutions / matrix multiplies of size (B*N*N, D) @ (D, D) (or similar). PyTorch's linear is already quite optimized for these. Maybe the best approach is to keep PyTorch's linear for the projections but fuse everything else? Wait, but we're supposed to write a CUDA kernel that replaces the forward computation. We can still call PyTorch ops in forward() but the point is to write custom CUDA for the bulk of the work. Let me think about which parts are slowest. For B=2, N=32, D=64: - The reference does: reshape, linear, reshape, transpose, matmul, view, add bias, add mask, softmax, dropout, matmul, transpose, reshape, multiply, linear - Most of these are memory-bound with tiny tensors. The overhead of multiple kernel launches dominates. A single fused kernel that does everything from pair_repr to final output would be ideal. Let me design a "one kernel to rule them all" approach. One block per (b, i) row. B*N = 64 rows total, so 64 blocks. Each block has enough threads to be efficient. Let's use 512 threads per block. Within each block, for row (b, i): 1. Load x[b, i, :, :] into shared memory: 32 * 64 = 2KB. Each of 512 threads loads some elements. Actually, 32*64=2048 floats. With 512 threads, each thread loads 4 floats. 2. Compute layer norm over D=64 for each of 32 positions: - For position j: load 64 channels. 512 threads / 32 positions = 16 threads per position. - 16 threads load 64 channels (4 each), compute mean and var via warp shuffle (within the 16-thread group... but 16 threads isn't a full warp). - Actually, for warp shuffle, we need threads in the same warp. A warp is 32 threads. So we could have 1 warp per 2 positions (16 threads per position), but 16 threads don't naturally form a contiguous warp for shuffle. - Simpler: process layer norm in shared memory reductions. All 32 positions, for each position, all 64 channels are in shared memory. We can do parallel reductions. - Or: have 32 warps (1024 threads) where each warp handles one position. But we want 512 threads. Hmm, with 64 blocks total, we want at least 512 threads per block to get 32K threads total. Wait, 64 blocks is actually not terrible on H100 if each block has enough work. 64 blocks spread across 132 SMs gives about 0.5 blocks per SM, but since each block has substantial work (handling a row of 32 positions), the GPU might still be utilized. Actually, for maximum occupancy, we'd want >= 132 blocks. What if each block handles one (b, i, h) attention head instead? That's 64*4 = 256 blocks. With 256 threads each = 65K threads. That's 65K/32 = 2048 warps. With ~132 SMs * 16 warps/SM (max occupancy) = 2112 warps capacity. So 2048 warps gets close to full occupancy! This is much better! So let's use one block per (b, i, h) attention head, with 256 threads per block. Total: 256 blocks. Per block, for attention head h of row (b, i): 1. Load x[b, i, :, :] (32 * 64 = 2KB) to shared memory 2. Compute layer norm for all 32 positions 3. Compute q[b, i, h, :, :], k[b, i, h, :, :], v[b, i, h, :, :] - Each is (32, 16) = 512 floats - Q comes from W_q[h*d:(h+1)*d, :] applied to each position - But W_q is (64, 64). For head h, we need rows h*16 to (h+1)*16 of W_q - We can have threads compute this in parallel 4. Load K and V to shared memory (or compute them there) 5. For each query j (0 to 31): compute attention scores over 32 keys, add bias[b, h, j, :], add mask, softmax 6. Compute output for each query 7. Apply gate and output projection for the head's contribution 8. Accumulate to global output Wait, but the output projection combines all heads. If each block handles one head, we need to accumulate the results. We can do this with atomic adds, or store intermediate results and have a separate accumulation step. But atomic adds for 131K floats might be okay. Or: each block stores its head's output to a separate array, and we have a separate reduction kernel. But that adds another kernel. Alternative: each block handles one (b, i) row and all 4 heads sequentially. Then it can do the output projection once. But that reduces parallelism to 64 blocks. Or: each block handles one (b, i) row and all 4 heads, but with 256 threads, we can parallelize across the 4 heads. 64 threads per head. For each head, 64 threads handle the 32 queries (2 threads per query). With 2 threads per query and N=32, d=16: - Thread 0 and 1 handle query 0 - Thread 2 and 3 handle query 1 - ... - For each query, 2 threads cooperate: - Load Q[j, :] (16 floats): each thread loads 8 - Compute scores: dot(Q_j, K_k) for k=0..31 - Each thread computes 16 of the 32 scores? No, with only 2 threads, each thread computes all 32 scores but does half the dot product for each. - Actually, dot product of 16 elements with 2 threads: each thread computes 8 multiplies and sums them, then they add together. - For 32 scores: each thread computes 32 partial dots (8 elements each), then sum with partner thread. - Softmax: one of the two threads does it, or they split work - Output: each thread computes 8 elements of the 16-element output vector This could work! But let's see if there's a simpler arrangement. What if we use 128 threads per block, and each block handles one (b, i) row and all 4 heads? 128 threads = 4 warps. - Warp 0: head 0 - Warp 1: head 1 - Warp 2: head 2 - Warp 3: head 3 Each warp handles one head. 32 threads per warp, N=32 queries. One thread per query! This is clean and simple: - 64 blocks (one per row), each with 128 threads (4 warps) - Each warp handles one head For each query j in a warp: - Load Q[j, :] (16 floats) into registers - Load K and V for all keys into shared memory (32*16*2 = 1KB per head) Wait, each warp in the block needs its own K and V (they differ per head). But K and V are head-specific. If all 4 warps load their own K and V, that's 4KB total per block. Wait, but each warp has its own registers. K and V for a head are (32, 16) = 512 floats = 2KB. All 32 threads in the warp can load this collaboratively to shared memory (or the entire block loads it, but we'd need 8KB for all 4 heads). 8KB shared memory is fine! But does each warp need its own shared memory space? We can partition the shared memory: - Head 0 K, V: shared[0..1023] floats - Head 1 K, V: shared[1024..2047] - etc. Total: 4096 floats = 16KB. Still well under the 228KB limit. Wait, but the 32 threads in a warp would need to synchronize to use shared memory safely. Warps execute in lockstep within a block, so warps in the same block share shared memory but we need __syncthreads(). However, __syncthreads() affects all threads in the block, not just the warp. That's okay! The block has 128 threads. __syncthreads() waits for all 128. So the pattern would be: 1. All 128 threads load x[b, i, :, :] (2KB) to shared memory 2. All 128 threads compute layer norm for all 32 positions (cooperatively, or each warp does 8 positions, etc.) 3. __syncthreads() 4. Warp 0 computes Q0, K0, V0 for head 0 - Wait, Q, K, V computation for all 4 heads can happen in parallel across warps too 5. For each head, its warp loads K and V to its shared memory partition - This happens for all 4 warps concurrently 6. __syncthreads() -- wait, but after step 5, each warp only needs its own K and V - Actually, each warp can keep K and V in registers or shared memory only it touches - But for simplicity, let's just have each warp load its K and V, and not worry about cross-warp shared memory since each warp accesses a different partition 7. Each warp computes attention for its head 8. Each warp writes its output to a block-level accumulator in shared memory (32 * 16 = 512 floats per head, total 2KB) 9. __syncthreads() 10. All warps cooperate to apply the gate and output projection Wait, step 10 requires all heads' outputs to be combined before output projection, because output projection is a linear layer over the full D=64 channels. So we need to concatenate the 4 heads' outputs (each 16 channels) into a 64-channel vector, then apply the linear. With 128 threads, we have enough parallelism to do the output projection for all 32 positions. This approach is getting complex but very elegant. Let me try to simplify further. Actually, for a first attempt, let me write two simpler kernels that are still well-optimized, then see if I need more fusion. **Kernel 1**: `preprocess_kernel` - Computes layer norm + query, key, value, gate, bias_proj - One thread per position - Launches 2048 threads (maybe in 8 blocks of 256, or 16 of 128) - This is mostly memory/compute light, so few blocks might be okay if fast **Kernel 2**: `attention_kernel` - Each block handles one (b, i, h) attention head - Block size: 256 threads or 128 threads - Loads Q, K, V from global memory - Computes attention, including bias, mask, softmax - Computes output for all queries - Then... hmm, we still need to do gate multiply and output projection, which combine heads. For gate multiply and output projection, we could: - Have each attention block write its head output to an intermediate array (B, N, N, D) - Then a third kernel does gate multiply and output projection But that writes 131K floats (B*N*N*D) to global memory and reads them back. That's 0.5MB total, which is small. Alternatively, the attention kernel can also write the gated output (g * attn_out) for its head's channels, and then we sum or reduce. Wait, gate is per-position across all D channels. For a head with 16 channels, the gate for those 16 channels is g[:, :, :, h*16:(h+1)*16]. So each attention block can: 1. After computing attention output for its head 2. Load the corresponding gate values 3. Multiply: gated_out = gate * attn_out 4. Store to a global scratch array (B, N, N, D) 5. Then a final kernel applies output projection Or, we can skip the scratch array and have the attention blocks accumulate with atomics. But atomics might be slow. A third kernel for output projection is fine given the tiny data size. Wait, can we fuse the gate multiply into the attention kernel more cleanly? Yes! After each block computes its head's attention output (B, N, N, 16), it loads gate (B, N, N, 16) and multiplies elementwise. Then it stores to a per-head scratch or directly to the final output. But the output projection needs all D=64 channels together. So we still need an intermediate. Let me think about this differently. What if we have the attention kernel do: 1. For head h, compute attention output for all queries 2. Load gate for those channels and multiply 3. Apply the corresponding rows of the output projection weight matrix 4. Accumulate the partial output to the final result using atomicAdd The output projection is y = W_out @ gated_out. W_out is (64, 64). For head h (channels h*16:(h+1)*16), W_out[:, h*16:(h+1)*16] is the relevant part. If block (b, i, h) computes gated_out of shape (N, 16), then the contribution to final output is: output[b, i, j, :] += W_out[:, h*16:(h+1)*16] @ gated_out[j, :] This is a matvec of size (64, 16) for each of N=32 positions. Total: 32 * 64 * 16 = 32,768 FMAs per block. With 256 blocks, total FMAs: ~8.4M. Plus atomic adds for 64 * N = 2048 output elements per block * 256 blocks = 524K atomic adds. Actually, atomic adds across all blocks for the same (b, i, j, :) location: 4 blocks (one per head) will write to the same 64 output channels? No, each block contributes to ALL 64 output channels for its positions, because W_out is (64, 64). So for position (b, i, j), all 4 heads' blocks will atomically add their contributions to the same 64 output channels. That's 4 atomicAdd sequences per output element. With 2048 positions and 64 channels: 131K output elements, each atomically updated 4 times. Total atomic ops: 524K. On H100, this should be manageable but maybe not ideal. A cleaner approach: - Attention kernel writes gated attention output to scratch (B, N, N, D) - Final kernel loads scratch and applies output projection This adds one memory pass (write + read scratch = ~1MB total) but avoids atomics. Given the tiny size, either approach should be fine. The atomic-free approach is cleaner and probably just as fast. Let me settle on 3 kernels: 1. `preprocess`: layer norm + QKV + gate + bias 2. `attention`: attention for each head, including bias, mask, softmax, dropout, gate multiply. Writes to scratch. 3. `output_proj`: applies output projection to scratch Actually, I just realized I can fuse the gate multiply into kernel 2 easily without atomics. And then kernel 3 just does the linear projection. That's fine. But for maximum performance, maybe I can fuse kernels 2 and 3 by storing the output projection weights in shared memory and having each block do its contribution to the local positions, then reduce across heads within a block? Hmm, but heads are in different blocks. Wait, what if each block handles one (b, i) row and ALL 4 heads? Then the block can do output projection without atomics or scratch memory. 64 blocks (one per row), each with enough threads for 4 heads. **Revised approach: Single block per (b, i) row, handling all 4 heads** Block size: Let's use 512 threads. - Threads 0-127: head 0 - Threads 128-255: head 1 - Threads 256-383: head 2 - Threads 384-511: head 3 For each head, 128 threads handle the 32 queries. Wait, 128 threads / 32 queries = 4 threads per query. Or we can use 256 threads per block: - 64 threads per head - 64 / 32 = 2 threads per query Let's try 256 threads per block, 64 threads per head, 2 threads per query. For each head and query: - Thread pair loads Q[query, :] (16 floats, 8 each) - Load K and V for all 32 keys to shared memory - K: 32*16 = 512 floats - V: 32*16 = 512 floats - Per head: 1KB in shared memory - 4 heads: 4KB total Wait, can 64 threads load 1KB to shared memory efficiently? Yes, each thread loads 16 floats. For score computation: - Each thread pair computes 32 scores (each score is a dot product of 16 elements) - With 2 threads per query: each thread computes 8 elements of the dot product for all 32 keys, then they reduce. - Actually, for score k: dot(Q, K_k) = sum_d Q[d] * K_k[d] - Thread 0 in pair computes sum over d=0..7: Q[d] * K_k[d] - Thread 1 computes sum over d=8..15: Q[d] * K_k[d] - Then __shfl_xor or similar to add the two partial sums - This is done for all 32 keys. Each thread computes 32 partial sums. For softmax: - After getting all 32 scores, one thread (or both) does the softmax - Better: both threads compute exp for their keys, then reduce max and sum across the pair (or within the query's thread group) - With only 2 threads per query, reduction is simple. For output: - Each thread pair computes the weighted sum of V vectors - Thread 0: sum over k of score[k] * V[k, 0:8] - Thread 1: sum over k of score[k] * V[k, 8:16] This seems workable! Then after computing all 32 queries for a head: - The head's 64 threads have computed outputs for all (query, channel) pairs - We need to apply gate and output projection Actually, applying gate and output projection is easiest if we have the full (N, D) output in shared memory, then all 256 threads cooperate. Wait, but each head only contributes to its 16 channels. The output projection needs all 64 channels. Let me think about how to structure the overall block: Phase 1: Load x[b, i, :, :] and compute layer norm - 256 threads, 32 positions, 64 channels per position - Each thread loads some channels for some positions - Cooperative reduction to compute mean/var per position - Normalize Phase 2: Compute Q, K, V for all 4 heads - Q, K, V for head h: (32, 16) - 64 threads per head - We can have the 64 threads compute the projections - Each thread computes some (query, output_channel) pairs - Store Q, K, V in shared memory (4KB for K+V for all heads, plus 4KB for Q if needed) Wait, Q only needs to be in registers or shared memory during score computation. K and V need to be in shared memory for all threads to access. Phase 3: Attention for all 4 heads - Head h's 64 threads load K_h and V_h to shared memory (or they're already there) - Compute QK^T, softmax, attention*V - Store result in shared memory partition Phase 4: Concatenate heads and apply gate + output projection - All 256 threads load the 4 head outputs (4 * 32 * 16 = 2048 floats = 8KB) - Also load gate values (32 * 64 = 2KB) - Multiply gate - Apply output projection W_out (64, 64) for each position - Write final output Total shared memory: x (2KB) + K/V for heads (4KB or 8KB) + attention outputs (8KB) + gate (2KB) + maybe some temp space = ~20KB. This is fine. But the complexity of this kernel is high. Let me see if I can write a simpler version first, compile and test it, then optimize. For a first pass, let me do: Kernel 1: `preprocess_kernel` - one thread per position, handles layer norm and all 5 projections Kernel 2: `attention_kernel` - one block per (b, i, h), 256 threads, handles attention, gate, and writes scratch Kernel 3: `output_proj_kernel` - applies output projection to scratch Even with the small block counts, this might be faster than the reference because we eliminate many memory round trips. Actually, for kernel 2, I just realized: if I use one block per (b, i, h) = 256 blocks, each with 256 threads, that's 65K threads = 2048 warps. This should give decent occupancy. And for kernel 1, 2048 positions with 256 threads per block = 8 blocks. Not great, but maybe okay. To improve, I can have each thread handle one position but launch with smaller blocks: 128 threads -> 16 blocks, 64 threads -> 32 blocks. Let me use 64 threads per block for kernel 1, giving 32 blocks. Still small. Use 32 threads per block -> 64 blocks. Or use 256 threads per block but each thread handles 2 positions, giving 4 blocks total. Worse. What if each thread handles 1 output element of 1 projection for 1 position? For the 5 projections, total outputs = 2048 * 260 = 532K. With 256 threads per block: 2080 blocks! That's good! But we'd need to: 1. First compute and store layer norm for all positions (Kernel 1a) 2. Then compute all projections in parallel (Kernel 1b) Kernel 1a: Layer norm for 2048 positions. 8 blocks of 256 threads. Very fast, memory bound. Kernel 1b: Projections. Treat as 5 matvecs per position, parallelized over output channels. 532K outputs, 2080 blocks of 256 threads. Actually, for the projections, each output needs the FULL normalized input vector. So each of the 532K threads needs to read 64 floats. Total reads: 532K * 64 = 34M floats * 4 bytes = 136MB. That's a lot of redundant reads. But the normalized input is only 131K floats = 0.5MB. With good caching (all threads in a block read the same position's input), L1 cache can handle this. With 256 threads per block and each block handling 256 output elements, if those outputs correspond to the same 256/260 ≈ 1 position... well, that's not good. Let me group outputs by position. A block of 256 threads handles all outputs for 1 position. 260 outputs per position. 260 doesn't divide 256 well. For 4 positions: 1040 outputs, so 4 or 5 blocks. Not ideal. Actually, I think the best grouping is: each block handles all outputs for M positions, where M*260 ≈ multiple of block size. M=1 gives 260 outputs. We could have 256 threads each handle one output, with 4 threads idle. Or have 128 threads and run 2 rounds for the 260 outputs. This is getting too complicated. Let me just go with the simplest approach: each thread handles one position for layer norm + projections. Launch 8 blocks of 256 threads. It'll be fast enough because the work is light. For the attention kernel, I'll use one block per (b, i, h), 256 threads per block. This gives 256 blocks, which is good. Now let me write the actual CUDA code. First, let me re-examine the problem to make sure I understand the shapes correctly: Input: pair_repr (B, N, N, D) Input: pair_mask (B, N, N) Forward: 1. x = layer_norm(pair_repr) # (B, N, N, D) 2. g = sigmoid(gate(x)) # (B, N, N, D) 3. bias = bias_proj(x).permute(0, 3, 1, 2) # (B, h, N, N) 4. x_flat = x.reshape(B*N, N, D) # (B*N, N, D) 5. q = query(x_flat).view(B*N, N, h, d).transpose(1, 2) # (B*N, h, N, d) 6. k = key(x_flat).view(B*N, N, h, d).transpose(1, 2) # (B*N, h, N, d) 7. v = value(x_flat).view(B*N, N, h, d).transpose(1, 2) # (B*N, h, N, d) 8. attn = (q @ k.transpose(-1, -2)) / sqrt(d) # (B*N, h, N, N) 9. attn = attn.view(B, N, h, N, N) 10. attn = attn + bias.unsqueeze(1) # bias_broadcast: (B, 1, h, N, N) 11. mask = pair_mask.unsqueeze(1).unsqueeze(2) # (B, 1, 1, N, N) 12. attn = attn.masked_fill(mask == 0, -inf) 13. attn = attn.view(B*N, h, N, N) 14. attn = softmax(attn, dim=-1) # over last dim (keys) 15. attn = dropout(attn) 16. out = (attn @ v).transpose(1, 2).reshape(B, N, N, D) # (B, N, N, D) 17. out = g * out 18. return out_proj(out) Wait, I need to be very careful about dropout. The reference uses `self.dropout(attn)`. In evaluation mode (which is what correctness check likely uses), dropout is a no-op. But I should probably include it for completeness. Actually, looking at standard PyTorch behavior: during eval, dropout is a no-op. The correctness check likely runs in eval mode. Let me check the dropout reference... In PyTorch, `nn.Dropout` is a no-op during `eval()` mode. Since the reference does `F.softmax(attn, dim=-1)` and then `self.dropout(attn)`, and dropout in eval is no-op, I'll just skip dropout in the kernel to keep things simple. If we need to handle train mode, we could add it, but the task says "forward computation" which typically correctness checks in eval mode. Actually, I should double check: the get_inputs() doesn't set eval mode. Does the test framework set it? Typically yes, but I should be careful. Looking at dropout behavior: if we're not sure, we can just not implement dropout (treat it as no-op). This matches eval mode. Now let me think about the bias indexing more carefully in the attention. In the original code: - bias has shape (B, h, N, N) - attn after q@k^T and reshape has shape (B, N, h, N, N) - bias is unsqueezed to (B, 1, h, N, N) and broadcast over dim 1 So for attn[b, i, h, j, k]: the added bias is bias[b, h, j, k]. Wait, is that right? The original x is (B, N, N, D). bias_proj(x) is (B, N, N, h). Then permute(0, 3, 1, 2) gives (B, h, N, N). So bias[b, h, i, j] corresponds to x[b, i, j, :] -> bias_proj(x)[b, i, j, h] -> bias[b, h, i, j]. In the attention, after reshape to (B, N, h, N, N), the dims are: - dim 0: batch - dim 1: the first N (from B*N) - dim 2: head - dim 3: the second N (query position within the row) - dim 4: the third N (key position within the row) Wait, let me trace x_flat more carefully. x is (B, N, N, D). x.reshape(B*N, N, D) means: x_flat[b*N + i, j, :] = x[b, i, j, :] So for flattened index r = b*N + i, the positions j=0..N-1 are the second N dimension of x[b, i, :, :]. Then q = query(x_flat).view(B*N, N, h, d).transpose(1, 2) So q[r, h, j, :] means for row r and head h, query position j. And q @ k^T gives (B*N, h, N, N) where the last two dims are (query_pos, key_pos). Then view(B, N, h, N, N) gives: [b, i, h, j, k] where: - b: batch - i: row index (first N) - h: head - j: query position (second N) - k: key position (third N) And bias is (B, h, N, N) where I'll call it (B, h, p, q). Added as bias.unsqueeze(1) = (B, 1, h, N, N). So for attn[b, i, h, j, k], the added bias is bias[b, h, j, k]. This means the bias is a function of (batch, head, query_pos, key_pos), independent of the row i! And mask is pair_mask.unsqueeze(1).unsqueeze(2) = (B, 1, 1, N, N). So mask[b, j, k] applies to all i and h. So for each (b, j, k), the mask is the same for all rows i and heads h. OK, so in the attention kernel, for each block handling (b, i, h): - For query j and key k: score = dot(q[j], k[k]) / sqrt(d) + bias[b, h, j, k] - If mask[b, j, k] == 0: score = -inf - Softmax over k for each j This is consistent. Now let me design the kernels concretely. **Kernel 1: preprocess** I'll create a kernel that takes pair_repr and computes: - q (B*N, h, N, d) - k (B*N, h, N, d) - v (B*N, h, N, d) - bias (B, h, N, N) - gate (B, N, N, D) For each (b, i, j) position: 1. Load pair_repr[b, i, j, :] (64 floats) 2. Compute mean = sum / D 3. Compute var = sum((x - mean)^2) / D 4. norm_x = (x - mean) / sqrt(var + eps) 5. Compute q_vec = W_q @ norm_x (64-dim) 6. Compute k_vec = W_k @ norm_x (64-dim) 7. Compute v_vec = W_v @ norm_x (64-dim) 8. Compute g_vec = sigmoid(W_g @ norm_x + b_g) (64-dim) 9. Compute b_vec = W_b @ norm_x (4-dim) Wait, the reference uses linear layers with bias for gate and out_proj, but query, key, value, bias_proj have no bias. Looking at the code: - self.query = nn.Linear(pair_dim, pair_dim, bias=False) - self.key = nn.Linear(pair_dim, pair_dim, bias=False) - self.value = nn.Linear(pair_dim, pair_dim, bias=False) - self.gate = nn.Linear(pair_dim, pair_dim) # has bias - self.bias_proj = nn.Linear(pair_dim, num_heads, bias=False) - self.out_proj = nn.Linear(pair_dim, pair_dim) # has bias So gate has bias, out_proj has bias. Q, K, V, bias_proj have no bias. For the attention and following operations, we need to know the exact transposes. q shape after transpose: (B*N, h, N, d) q[r, h, j, d_idx] = query(x_flat)[r, j, h*d + d_idx] Since query is a linear layer with weight (D, D), and no bias: query(x_flat)[r, j, c] = sum_d W_query[c, d] * x_flat[r, j, d] Wait, PyTorch linear is y = x @ W^T, so W_query has shape (out_features, in_features) = (64, 64). The computation is: for c in 0..63: y[c] = sum_d W[c, d] * x[d] In CUDA, if I use the weights directly from PyTorch, they have shape (64, 64). But after `view(B*N, N, h, d).transpose(1, 2)`, the final indexing is: q[r, h, j, d] = query_out[r, j, h*16 + d] So I can compute q_raw[r, j, c] for c=0..63, then interpret as q[r, h, j, d]. Similarly for k and v. For gate: gate_weight shape: (64, 64) gate_bias shape: (64,) g[b, i, j, c] = sigmoid(sum_d W_gate[c, d] * norm_x[d] + b_gate[c]) For bias_proj: bias_proj_weight shape: (4, 64) bias_raw[b, i, j, h] = sum_d W_bias[h, d] * norm_x[d] Then bias = bias_raw.permute(0, 3, 1, 2) # (B, h, N, N) So bias[b, h, i, j] = bias_raw[b, i, j, h] For output projection: out_proj_weight shape: (64, 64) out_proj_bias shape: (64,) out[b, i, j, c] = sum_d W_out[c, d] * gated_input[d] + b_out[c] Now, in my custom kernel, I need access to the layer's weights. I can pass them as tensors to the kernel. For memory layout, PyTorch stores linear weights as (out_features, in_features) in row-major order. In CUDA, accessing W[c, d] is efficient for coalesced access if threads access consecutive c or d. Let me write the kernels. Actually, I realize I should keep the design simpler for a first version and see how it performs. Here is my plan: **Approach: 2 kernels** Kernel 1: `fused_preprocess` - One thread per position - 8 blocks of 256 threads (handling all 2048 positions) - Computes layer norm, then q_raw, k_raw, v_raw, g, bias_raw - Writes to global memory Kernel 2: `fused_attention` - One block per (b, i, h) = 256 blocks - Block size: 128 threads (one warp per 8 queries? No, let me think...) Actually, for kernel 2, with 256 blocks, let me use 128 threads per block. Total 32K threads. - 32K threads / 32 = 1024 warps. - H100 has ~132 SMs, each can hold many warps. 1024/132 ≈ 7.7 warps per SM. Not great but not terrible. Within each block (for one head of one row): - 128 threads = 4 warps - Each warp handles 8 queries (32 threads / 8 queries = 4 threads per query) - 4 warps * 8 queries = 32 queries total. Perfect! For 4 threads per query and d=16: - Each thread handles 4 dimensions of the 16-dim vectors - K is (32, 16). Load to shared memory with 128 threads. - For each query j handled by the warp: - Q_j: 4 threads load the 16 dims (4 each) - For each key k=0..31: - score[k] = dot(Q_j, K_k) / sqrt(d) + bias[b, h, j, k] - Each thread computes partial dot for its 4 dims - Warp shuffle reduce (or just 4 threads manually adding) - Apply mask - Softmax over 32 scores - For each key k: - accumulate output: sum_k score[k] * V[k, :] - Each thread accumulates for its 4 dims This warp-level attention sounds efficient. Let me write it out. But 32 threads handling 8 queries means each query's 4 threads might not be in the same warp sub-group for efficient shuffle unless arranged carefully. Actually, if we have 32 threads in a warp and 8 queries, we can group the threads as: - Query 0: threads 0, 1, 2, 3 - Query 1: threads 4, 5, 6, 7 - ... - Query 7: threads 28, 29, 30, 31 Then for warp shuffle, threads within a query group are contiguous. We can use __shfl_down_sync with mask 0xF for threads 0-3, 0xF0 for threads 4-7, etc. But that's a bit complex. Alternative: have each thread handle a full query. 32 threads per warp, 32 queries. One warp handles all queries! With 128 threads = 4 warps, each warp could handle all 32 queries? But that would duplicate work. No wait, if we use 4 warps and each warp handles all 32 queries, that's 4x redundant work and only 1 block doing 4 warps of the same thing. Not good. Alternative: have each warp handle 32 queries (one per thread). With 128 threads = 4 warps, each block handles 4 heads' worth of queries... no, each warp handles queries for the same head? Then 4 warps handle 4 copies? No. Wait, 1 block = 1 (b, i, h). 128 threads in the block. If each thread handles one query, we have 128 threads but only 32 queries. That's 4 threads per query. What if each thread handles 4 queries sequentially? 128 / 4 = 32 threads active per... no, each thread handles 4 queries out of 32, so 8 threads total per head? That's too few. Let me reconsider the block configuration. For one (b, i, h) head: - 32 queries to process - Each query needs: compute 32 scores, softmax, weighted sum of 32 values If we use one thread per query: need 32 threads. Add more threads for parallelization within query: say 8 threads per query. Total 256 threads. 256 threads / 32 queries = 8 threads per query. 8 threads per query for d=16 means 2 dims per thread. That seems like good parallelism. With 8 threads per query, the threads for one query can be in the same warp (a warp has 32 threads, so 4 queries fit in one warp). Warp 0: queries 0, 1, 2, 3 (8 threads each = 32 threads) Warp 1: queries 4, 5, 6, 7 Warp 2: queries 8, 9, 10, 11 Warp 3: queries 12, 13, 14, 15 Warp 4: queries 16, 17, 18, 19 Warp 5: queries 20, 21, 22, 23 Warp 6: queries 24, 25, 26, 27 Warp 7: queries 28, 29, 30, 31 Perfect! 8 warps = 256 threads. Each query gets 8 threads. For d=16 and 8 threads per query: each thread handles 2 dimensions. Shared memory for K and V: 32 * 16 * 2 = 1024 floats = 4KB per head. Just one head per block, so 4KB total. Very small. Score computation: - Each of the 8 threads loads 2 dims of Q_j - For each key k (0..31): - Each thread computes partial dot: Q_j[d0]*K_k[d0] + Q_j[d1]*K_k[d1] - Then reduce across the 8 threads. Since they're in the same warp (for queries within a warp) or not (for queries across warps), we need different reduction strategies. Actually, for queries 0-3 in warp 0: the 8 threads for query 0 are threads 0-7. These ARE in the same warp! Warp shuffle __shfl_down_sync(0xFF, ...) would include all 32 threads. We need to reduce within the 8 threads for query 0, which is threads 0-7. We can use __shfl_down_sync(0xFF, ...) but that gets values from all 32 threads. We'd need to mask or just use shared memory. Simpler: use shared memory for the partial scores. Each thread writes its partial dot to shared_mem[threadIdx.x], then one thread reads and sums the 8 values for its query. Wait, that's: 32 queries * 32 keys = 1024 score computations. Each computation involves reading K_k from shared memory and doing a partial dot product. To optimize, we can have the block load K and V for all keys once. Then each query thread group computes all 32 scores. Algorithm for one block (256 threads, 8 warps): 1. Load Q[r, h, j, :] for all j=0..31 into shared memory or registers - Actually, Q can stay in registers. Each of the 8 threads for query j loads 2 elements of Q_j. - Total Q: 32 * 16 = 512 floats. Each thread loads 2. 2. Load K[r, h, :, :] into shared memory - 32 keys * 16 dims = 512 floats - 256 threads, so each thread loads 2 floats 3. Load V[r, h, :, :] into shared memory - Same as K 4. __syncthreads() 5. For each query j assigned to the thread group: - Compute 32 scores: - For key k=0..31: - score_partial = sum_{d in my_dims} Q_j[d] * K[k, d] - Write to shared memory or reduce - After all partials for query j: sum the 8 partials to get full score - I'll use a loop approach: - Each thread computes score_partial[k] for its query j and all k=0..31 - Store in a local array of 32 floats (in registers) - Then do a parallel reduction over the 8 threads to get final scores Wait, 32 floats per thread is a lot of registers. If each thread handles one query (32 threads) instead of 8 per query, each thread stores 32 scores = 32 registers. Plus other temporaries. That's more manageable. Let me try: 32 threads per block, one thread per query. 256 blocks total (one per head). Each thread: - Loads Q_j (16 floats) to registers - Block collaboratively loads K and V to shared memory - Thread computes 32 scores: - For k=0..31: score[k] = dot(Q_j, K_k) / sqrt(d) + bias[b, h, j, k] - Each score is a full dot product of 16 elements done by one thread - That's 32 * 16 = 512 multiply-adds - Apply mask - Softmax: find max, subtract, exp, sum, divide. About 32 * 5 = 160 operations. - Compute output: for k=0..31: out[d] += score[k] * V[k, d] - For each of 16 output dims: 32 multiply-adds - Total: 16 * 32 = 512 multiply-adds Total FMAs per thread: 512 + 512 = 1024 Total ops per block: 32 * 1024 = 32K Total ops for all blocks: 256 * 32K = 8.2M FMAs With 32 threads per block, each block does 32K FMAs. On H100, a single SM can do ~2K FMAs per cycle per thread block? Actually H100 does a LOT of FMAs. But memory bandwidth and latency will be the issue. Memory per block: - Read Q: 512 floats (but only 16 per thread, loaded from global) - Read K: 512 floats (from global to shared) - Read V: 512 floats (from global to shared) - Read bias: 32 floats from global (one per query, but actually 32 keys per query = we'd need to structure the bias read carefully) - Read mask: 32 floats from global - Read gate: 16 floats from global (for output) - Read out_proj weights: 64*64 = 4096 floats (shared by all blocks, cached) - Write output: 64 floats to global (but wait, this is per-head before output proj) Hmm, if each block only handles one head, the output per block is (N, d) = 32 * 16 = 512 floats. Then we need either atomic adds or a separate reduction for output projection. Let me reconsider and do output projection in a separate kernel. It'll be much simpler. Revised Kernel 2: `attention_kernel` - Each block: one (b, i, h) head - 32 threads: one per query - Loads Q, K, V from global to shared - Computes attention, writes output to scratch (B, N, N, D) - Then after all heads, a third kernel does gate multiply and output projection Actually, I just realized: gate multiply happens BEFORE output projection, and the gate is per-position across all D channels. So we can't do gate multiply in the attention kernel without accumulating all heads first. Wait, looking at the reference again: out = (attn @ v).transpose(1, 2).reshape(B, N, N, D) # (B, N, N, D) out = g * out return self.out_proj(out) So g * out is an elementwise multiply where out is the full (B, N, N, D). Since each head block computes part of out, we'd need to accumulate heads first, then multiply by gate, then output project. Unless... we just have each head block compute its part, store to scratch, then a final kernel loads scratch, applies gate, and does output projection. Or, we can do: the final kernel loads all D channels, multiplies by gate, and applies output projection. That works. Kernel 2 output: scratch[b, i, j, h*16:(h+1)*16] = attention_output for head h Kernel 3: `finalize_kernel` - Reads scratch (B, N, N, D) - Reads gate (B, N, N, D) - Computes gated = scratch * gate - Applies output projection: out = W_out @ gated + b_out - Writes final output Kernel 3 also has small parallelism: 2048 positions, each with a matvec of 64x64. That's 131K output elements. With 256 threads per block: 512 blocks if each thread handles one output channel for one position. Actually, for kernel 3, it's another projection. 2048 positions * 64 outputs = 131K outputs. With 256 threads per block = 512 blocks. Or: 2048 positions, each thread handles one position's full 64-dim output. 2048 threads = 8 blocks of 256. Simple but few blocks. 131K outputs / 256 threads = 512 blocks. This is decent! Each thread computes one output channel for one position. Loads 64 floats (gated input), computes dot product with one row of W_out. For kernel 3, the gated input is scratch * gate. We can compute this on-the-fly: load scratch[b,i,j,d] and gate[b,i,j,d], multiply, then accumulate into the dot product. Actually, to avoid storing scratch to global memory and reading it back, can we fuse the attention output with the gate multiplication and output projection? If we use the atomic add approach: each head block computes its attention output for 16 channels, multiplies by the corresponding 16 gate channels, then accumulates the output projection contribution with atomicAdd. This avoids the scratch array. Let me think about the atomic add approach more: For head block (b, i, h): - Computes attention output for N=32 queries and d=16 channels - For query j and channel d: attn_out[j, d] - Load gate[b, i, j, h*16+d] - gated[j, d] = attn_out[j, d] * gate_val - Now compute contribution to final output: - For each output channel c=0..63: contrib[c] = sum_d W_out[c, h*16+d] * gated[j, d] - AtomicAdd to output[b, i, j, c] Wait, but output[b, i, j, c] receives contributions from all 4 heads. So for each (b, i, j, c), we do 4 atomic adds. With 256 blocks and each block doing this for 32 queries and 64 channels: 256 * 32 * 64 = 524K atomic operations. Atomic adds on global memory are relatively slow but for 524K operations, it might be okay. We could also use a scratch buffer: have each of the B*N rows accumulate head outputs in shared memory or a global row buffer. Alternative scratch approach: - Allocate scratch of size (B, N, N, D) - Each head block writes its attention output directly to scratch[b, i, :, h*16:(h+1)*16] - No atomics needed, just strided writes - Then a final kernel reads scratch, multiplies by gate, applies output projection This is cleaner. The scratch write is 256 blocks * 512 floats = 131K floats. The read back is the same. 1MB total memory traffic. Very small. I'll go with the scratch approach. So final plan: 1. Kernel `preprocess`: layer norm + QKV projections + gate + bias_proj. Returns q, k, v, gate, bias. 2. Kernel `attention`: one block per (b, i, h), computes attention and writes to scratch 3. Kernel `finalize`: reads scratch, gate, applies output projection But actually, I'm writing too many kernels, and kernel launch overhead might matter. Let me see if I can fuse 2 and 3 more by having each row block handle all heads. New plan: **Single attention kernel per (b, i) row** Block: one (b, i) row. 64 blocks total. Threads: 512. - 4 warps handle 4 heads in parallel? No, 512 threads = 16 warps. - We could have 4 warps per head (16 warps / 4 heads = 4 warps per head). - 4 warps = 128 threads per head. - For one head and 32 queries: 128 threads / 32 queries = 4 threads per query. This is similar to an earlier idea. Let me flesh it out. With 4 threads per query and d=16: - Each thread handles 4 dimensions of Q, K, V - For score computation: - Load K to shared memory (32 keys * 16 dims = 512 floats) - For each query j: - 4 threads load Q_j (4 floats each) - For key k: - Each thread computes partial dot = sum_{my_dims} Q_j[d] * K_k[d] - Reduce across 4 threads (can use shared memory or shuffle) - After all 32 keys, apply bias and mask - Softmax - Compute output: weighted sum of V - Load V to shared memory - For each key k: accumulate score[k] * V_k[d] for my dims - Store output With 128 threads per head and 4 heads in parallel, we'd use 512 threads. 64 blocks. Actually, this is getting too complex to write correctly without bugs. Let me compromise on something simpler: **Final design:** - One massive kernel that does everything. Or, - Two kernels: preprocess and fused_attention_output Actually, let me look at the problem from a different angle. Maybe I can use PyTorch's built-in efficient primitives for the heavy compute parts (matmuls) and just fuse the memory-bound operations? For example: 1. Use a custom kernel to compute layer_norm + gate + bias_proj (these are memory-bound and per-position) 2. Use PyTorch batched matmul for Q, K, V and attention 3. Use another custom kernel for the final gate multiply + output projection But the reference already uses efficient batched matmuls. The overhead comes from all the reshape, view, permute, and intermediate allocations. A custom kernel that avoids these would still win. Let me try a pragmatic approach: write two well-fused kernels. **Kernel 1: `preprocess_kernel`** - Fuses layer norm, QKV linear, gate, bias_proj - Input: pair_repr (B, N, N, D) - Output: q (B*N, h, N, d), k (B*N, h, N, d), v (B*N, h, N, d), bias (B, h, N, N), gate (B, N, N, D) **Kernel 2: `fused_attention_output_kernel`** - Fuses attention (QK^T, bias, mask, softmax, @V), gate multiply, output projection - Input: q, k, v, bias, mask, gate - Output: final result For Kernel 2, I'll use one block per (b, i, h) head. This gives 256 blocks. Each block has 256 threads. I'll use a design where: - 256 threads load K and V for the head into shared memory - 32 groups of 8 threads each handle one query - Each group computes the 32 attention scores, softmax, attention*V - After computing the output for the 32 queries (shape 32x16), the block needs to apply gate and output projection For the gate and output projection within the same block: - The block has computed (N, d) = (32, 16) output for one head - Gate for this head's channels: (32, 16) - But output projection needs all 64 input channels. One block only has 16 channels. So one block cannot complete the output projection. We'd need atomic adds or a separate step. OK, I'll accept the scratch array + separate finalize kernel approach. It's clean and simple. Actually, here's a better idea for Kernel 2: since the problem is so small, we can have each block handle a group of queries across ALL heads for one row. Block: one (b, i) row. Threads: 1024 (max per block on H100, or 512). Wait, H100 allows 1024 threads per block. 64 blocks * 1024 threads = 65536 threads. That's exactly what we'd get with 256 blocks * 256 threads. Same total thread count. But with 64 blocks, we have fewer blocks than SMs (132). So some SMs will be idle. What if we use 128 blocks? Not integer for our problem. OK, let me try to have larger blocks that handle more work. What about having each block handle 2 (b, i) rows? 32 blocks * 512 threads = 32 * 512 = 16384 threads. Still not great. What about 4 rows per block? 16 blocks * 1024 threads. Worse. Or: have each block handle 1 row, but use 512 threads and have them process all 4 heads. 64 blocks total. Then to increase occupancy, we could try to process multiple rows per block: 2 rows * 512 threads = 2 rows per block, 32 blocks. Hmm, 32 blocks of 512 threads = 16384 threads. Still low. Actually, with 512 threads per block and each thread doing significant work, the GPU can still be well-utilized if the warps are active. The occupancy metric is about how many warps can reside on an SM simultaneously. 32 blocks of 512 threads = 512 warps total. 512 / 132 = 3.9 warps per SM. If each warp does enough work, this might be okay... but probably not great. Let me see if I can get 256 blocks of 256 threads, where each block handles one head. 256 blocks * 256 threads = 65536 threads = 2048 warps. 2048 / 132 = 15.5 warps per SM. This is much better for occupancy! So I'll use 256 blocks of 256 threads for the attention kernel, with each block handling one head. Then I'll use atomics to accumulate the output projection contributions. Wait, instead of atomics for output projection, I can have each block write the gated attention output to a row of scratch, and then do the output projection in a separate highly parallel kernel. Actually, let me think about this. If each block writes (N, d) = 512 floats to scratch, that's a very small write. 256 blocks * 512 floats = 131K floats = 0.5MB. Then a finalize kernel reads 0.5MB and writes 0.5MB. For the finalize kernel, it processes 2048 positions, each needing a 64x64 matvec. Total output elements: 131K. If we use 256 threads per block and each thread computes one output element, that's 512 blocks. Or each thread processes one position (computes all 64 outputs), that's 8 blocks of 256. What if each thread in the finalize kernel computes all 64 outputs for one position? 2048 positions / 256 threads per block = 8 blocks. Very small. What if we have 512 blocks by having each block handle a subset of output channels for a subset of positions? Like: 128 positions per block, each thread handles one output channel (but positions have 64 channels). A block of 256 threads can handle 4 positions * 64 channels = 256 outputs. 2048 positions / 4 = 512 blocks. Each block loads 4 positions * 64 input channels = 256 floats. Each thread computes one output channel. Loads one row of W_out (64 floats). Does 64 FMAs. Actually wait, with 512 blocks and each block needing to load 64 channels * 4 positions = 256 input floats and 64 weight floats, total memory reads from input: 512 * 256 floats = 131K floats. Reads from weights: 512 * 64 floats = 32K floats, but weights are shared (same 64x64 matrix for all blocks), so cached. This is very efficient. For 512 blocks and 256 threads each: 131K threads total. Each thread does ~64 FMAs. Total FMAs: ~8.4M. At 67 TFLOPS, this is ~0.125 microseconds. Memory is ~1MB, at 3.35 TB/s is ~0.3 microseconds. So very fast. So the finalize kernel with 512 blocks is actually good. How to organize finalize kernel: - Total output elements: B * N * N * D = 2 * 32 * 32 * 64 = 131072 - Block size: 256 threads - Number of blocks: ceil(131072 / 256) = 512 blocks - Each thread computes one output element: output[b, i, j, c] - Thread loads all 64 inputs for position (b, i, j) from scratch, multiplies by gate[b, i, j, :] (or scratch already has gate applied), and computes dot product with W_out[c, :] If scratch already has gate applied: each thread loads 64 floats, and W_out[c, :], computes 64 FMAs. Actually, the memory load of 64 floats per thread from scratch * 131K threads = 8.4M floats * 4 bytes = 33.6MB. But these are cached because consecutive threads read the same 64 floats (for the same position). With 256 threads in a block and each reading the same 64 floats, we read 64 floats from global memory once per block (they get cached in L1 and shared among threads). Wait, if 256 threads in a block all need the same 64 input floats, we should load them cooperatively to shared memory. Then each thread reads from shared memory to compute its output. This avoids redundant global memory loads and cache thrashing. So finalize kernel block: 1. Map block and thread to position and output channel 2. For the block's position(s): - Load scratch * gate (or just scratch if gate already applied) into shared memory - __syncthreads() - Each thread computes one output channel - Thread reads one row of W_out[c, :] (64 floats, not ideal for coalescing if c differs across threads) Hmm, reading W_out: if thread t computes output channel c = t, then thread 0 reads W_out[0, :], thread 1 reads W_out[1, :], etc. This is coalesced if W_out is row-major: consecutive threads read consecutive rows, but each row is 64 floats=256 bytes. So thread 0 reads bytes 0..255, thread 1 reads 256..511, etc. This is perfectly coalesced (consecutive threads read consecutive memory). So finalize kernel: - Block of 256 threads handles 1 position and computes all 256 of its 64 output channels? No, 64 channels. - If block has 256 threads and position has only 64 output channels, then 192 threads are idle for that position. - Better: block handles 4 positions, 64 threads per position. Then all 256 threads are active. - 512 blocks * 4 positions = 2048 positions. Perfect! For each group of 64 threads handling one position: - 64 threads load the 64 input floats cooperatively (each loads 1 float? No, 64 threads, 64 floats, each thread loads 1 to shared memory) - __syncthreads() within the group? No, within the block. - Then each of the 64 threads computes one output channel: dot(W_out[c, :], input_shared[:]) Total FMAs per thread: 64. Very small, but 512 blocks gives decent parallelism. Actually, let me organize this differently. A block of 256 threads handles 4 positions. Each position needs 64 output channels. 4 positions * 64 channels = 256 outputs. Exactly 256 threads. Memory loads per block: - Input: 4 positions * 64 channels = 256 floats = 1KB - Weights: 64 rows * 64 floats = 4KB (same for all blocks, cached) Each block computes 256 outputs. 512 blocks total. This is clean and efficient. Wait, I realize I've been overthinking this. Let me just write the code! The compile and correctness checks will tell me if it's working. Here's my concrete plan for the CUDA kernel file: ```python import torch from torch.utils.cpp_extension import load_inline cuda_source = ''' #include <torch/extension.h> #include <cuda_runtime.h> #include <math.h> // Kernel 1: preprocess - layer norm + QKV + gate + bias_proj // One thread per output element of some projection for some position // Actually, use one thread per position __global__ void preprocess_kernel( const float* pair_repr, const float* ln_weight, const float* ln_bias, const float* W_query, const float* W_key, const float* W_value, const float* W_gate, const float* b_gate, const float* W_bias, float* q, // (B*N, h, N, d) float* k, // (B*N, h, N, d) float* v, // (B*N, h, N, d) float* gate, // (B, N, N, D) float* bias, // (B, h, N, N) int B, int N, int D, int h, int d ) { int idx = blockIdx.x * blockDim.x + threadIdx.x; int total_pos = B * N * N; if (idx >= total_pos) return; int b = idx / (N * N); int rem = idx % (N * N); int i = rem / N; int j = rem % N; // Load pair repr for this position float x[64]; // D=64 #pragma unroll for (int c = 0; c < D; c++) { x[c] = pair_repr[((b * N + i) * N + j) * D + c]; } // Layer norm float mean = 0.0f; #pragma unroll for (int c = 0; c < D; c++) mean += x[c]; mean /= D; float var = 0.0f; #pragma unroll for (int c = 0; c < D; c++) { float diff = x[c] - mean; var += diff * diff; } var /= D; float inv_std = rsqrtf(var + 1e-5f); #pragma unroll for (int c = 0; c < D; c++) { x[c] = (x[c] - mean) * inv_std * ln_weight[c] + ln_bias[c]; } // Projections // Q, K, V: no bias for (int head = 0; head < h; head++) { for (int dd = 0; dd < d; dd++) { int out_c = head * d + dd; float qval = 0.0f; float kval = 0.0f; float vval = 0.0f; #pragma unroll for (int c = 0; c < D; c++) { qval += W_query[out_c * D + c] * x[c]; kval += W_key[out_c * D + c] * x[c]; vval += W_value[out_c * D + c] * x[c]; } int r = b * N + i; q[((r * h + head) * N + j) * d + dd] = qval; k[((r * h + head) * N + j) * d + dd] = kval; v[((r * h + head) * N + j) * d + dd] = vval; } } // Gate projection: with bias #pragma unroll for (int c = 0; c < D; c++) { float gval = b_gate[c]; #pragma unroll for (int ic = 0; ic < D; ic++) { gval += W_gate[c * D + ic] * x[ic]; } gate[((b * N + i) * N + j) * D + c] = 1.0f / (1.0f + expf(-gval)); } // Bias projection: no bias, then permute #pragma unroll for (int head = 0; head < h; head++) { float bval = 0.0f; #pragma unroll for (int c = 0; c < D; c++) { bval += W_bias[head * D + c] * x[c]; } // bias[b, head, i, j] bias[((b * h + head) * N + i) * N + j] = bval; } } // Kernel 2: attention // One block per (b, i, h) // Block dims: (32, 8) or just 256 threads where each 8 threads handle 1 query __global__ void attention_kernel( const float* q, const float* k, const float* v, const float* bias, const float* pair_mask, float* scratch, // (B, N, N, D) int B, int N, int D, int h, int d ) { int b = blockIdx.x / (N * h); int rem = blockIdx.x % (N * h); int i = rem / h; int head = rem % h; int r = b * N + i; // Each block handles one head // 256 threads: 32 queries * 8 threads per query int tid = threadIdx.x; int query = tid / 8; int t_in_q = tid % 8; // Each thread handles d/8 = 2 dimensions int d_start = t_in_q * (d / 8); int d_end = d_start + (d / 8); // Load Q for this query float q_vec[2]; // assuming d/8 = 2 for (int dd = 0; dd < d/8; dd++) { q_vec[dd] = q[((r * h + head) * N + query) * d + d_start + dd]; } // Collaborative load of K and V to shared memory // Each block loads K (N, d) and V (N, d) __shared__ float K_shared[32 * 16]; __shared__ float V_shared[32 * 16]; // 256 threads, 32*16*2 = 1024 floats to load // Each thread loads 4 floats for (int l = tid; l < N * d; l += blockDim.x) { K_shared[l] = k[((r * h + head) * N + 0) * d + l]; // wait, k is (B*N, h, N, d) } __syncthreads(); // need all K loaded before using for (int l = tid; l < N * d; l += blockDim.x) { V_shared[l] = v[((r * h + head) * N + 0) * d + l]; } __syncthreads(); // Compute attention scores for this query float scores[32]; for (int key = 0; key < N; key++) { float dot = 0.0f; for (int dd = 0; dd < d/8; dd++) { dot += q_vec[dd] * K_shared[key * d + d_start + dd]; } // Reduce within the 8 threads of the query // Use warp shuffle if they're in same warp, or shared memory // Since 8 threads per query and warp is 32 threads: // query 0 uses threads 0-7, which are in the same warp #pragma unroll for (int offset = 4; offset > 0; offset /= 2) { dot += __shfl_down_sync(0xFFFFFFFF, dot, offset); } // Only thread 0 in each query group has the full dot if (t_in_q == 0) { scores[key] = dot / sqrtf((float)d) + bias[((b * h + head) * N + query) * N + key]; // Apply mask if (pair_mask[(b * N + query) * N + key] == 0.0f) { scores[key] = -1e9f; // -inf approximation } } } // Softmax over scores // Only thread 0 does this float max_score = -1e9f; if (t_in_q == 0) { for (int key = 0; key < N; key++) { if (scores[key] > max_score) max_score = scores[key]; } float sum_exp = 0.0f; for (int key = 0; key < N; key++) { scores[key] = expf(scores[key] - max_score); sum_exp += scores[key]; } float inv_sum = 1.0f / sum_exp; for (int key = 0; key < N; key++) { scores[key] *= inv_sum; } } __syncthreads(); // Make sure scores are ready // Broadcast scores to all threads in the query group // We can load from scores array in shared memory or have thread 0 broadcast float score_val[32]; if (t_in_q == 0) { for (int key = 0; key < N; key++) { // store to shared for broadcast } } } ``` Wait, this attention kernel is getting quite complex with the warp shuffles. Let me simplify it. Simplest approach: one thread per query, 32 threads per block. - Block handles one (b, i, h) head - 32 threads, each handles one query - 8 blocks per SM means 8 warps per SM. Not great but simpler to write. Let me write the simple version first. ```cuda __global__ void attention_kernel( const float* q, const float* k, const float* v, const float* bias, const float* pair_mask, float* scratch, int B, int N, int D, int h, int d ) { int b = blockIdx.x / (N * h); int rem = blockIdx.x % (N * h); int i = rem / h; int head = rem % h; int r = b * N + i; int query = threadIdx.x; if (query >= N) return; __shared__ float K_shared[32 * 16]; __shared__ float V_shared[32 * 16]; // Load K and V for (int l = threadIdx.x; l < N * d; l += blockDim.x) { K_shared[l] = k[((r * h + head) * N) * d + l]; V_shared[l] = v[((r * h + head) * N) * d + l]; } __syncthreads(); // Load Q for this query float q_vec[16]; #pragma unroll for (int dd = 0; dd < d; dd++) { q_vec[dd] = q[((r * h + head) * N + query) * d + dd]; } // Compute scores float scores[32]; float max_score = -1e9f; #pragma unroll for (int key = 0; key < N; key++) { float dot = 0.0f; #pragma unroll for (int dd = 0; dd < d; dd++) { dot += q_vec[dd] * K_shared[key * d + dd]; } float score = dot / sqrtf((float)d); score += bias[((b * h + head) * N + query) * N + key]; if (pair_mask[(b * N + query) * N + key] == 0.0f) { score = -1e9f; } scores[key] = score; if (score > max_score) max_score = score; } // Softmax float sum_exp = 0.0f; #pragma unroll for (int key = 0; key < N; key++) { scores[key] = expf(scores[key] - max_score); sum_exp += scores[key]; } float inv_sum = 1.0f / sum_exp; #pragma unroll for (int key = 0; key < N; key++) { scores[key] *= inv_sum; } // Compute output float out_vec[16]; #pragma unroll for (int dd = 0; dd < d; dd++) out_vec[dd] = 0.0f; #pragma unroll for (int key = 0; key < N; key++) { float s = scores[key]; #pragma unroll for (int dd = 0; dd < d; dd++) { out_vec[dd] += s * V_shared[key * d + dd]; } } // Write to scratch #pragma unroll for (int dd = 0; dd < d; dd++) { int out_idx = ((b * N + i) * N + query) * D + head * d + dd; scratch[out_idx] = out_vec[dd]; } } ``` This is clean. Each block is 32 threads, one per query. 256 blocks total. For N=32 and d=16, each thread loads: - Q: 16 floats from global - K/V: loaded cooperatively to shared memory - Bias: 32 floats from global (one per key) - Mask: 32 floats from global... wait, mask is pair_mask[(b * N + query) * N + key]. But pair_mask is (B, N, N). pair_mask[b*N + query] would be if it were flattened as (B*N, N). But pair_mask is (B, N, N). So the correct indexing is pair_mask[b * N * N + query * N + key]. Wait, let me check: pair_mask has shape (B, N, N). The mask in the reference is `pair_mask.unsqueeze(1).unsqueeze(2)` = (B, 1, 1, N, N). This means mask depends on (b, query_pos, key_pos). In my kernel, for batch b, query=query_pos, key=key_pos. So the mask value is pair_mask[b, query, key]. So: `pair_mask[b * N * N + query * N + key]` Actually, is the mask indexed as (B, query, key) or (B, row, key)? Let's re-check the reference: `mask = pair_mask.unsqueeze(1).unsqueeze(2) # (B, 1, 1, N, N)` In the 5D attention tensor (B, N, h, N, N), the last two dims correspond to... well, in the attention (before view), q @ k^T gives (B*N, h, N, N) where the last two are (query_pos_in_row, key_pos). After view to (B, N, h, N, N), the dims are (batch, row_i, head, query_pos, key_pos). And mask is (B, 1, 1, N, N), so it applies to (batch, query_pos, key_pos) independent of row_i and head. Wait, `unsqueeze(1).unsqueeze(2)` on (B, N, N): - (B, N, N) -> unsqueeze(1) -> (B, 1, N, N) - -> unsqueeze(2) -> (B, 1, 1, N, N) So the last two N dims of mask correspond to the last two N dims of attn (query_pos and key_pos). That means mask[b, 0, 0, j, k] = pair_mask[b, j, k]. So yes, mask[b, query, key] where query is the attention query position and key is the attention key position. OK, so my mask indexing is: pair_mask[b * N * N + query * N + key] which assumes pair_mask[b, query, key]. This is correct for the 5D tensor interpretation. But in the reference, pair_mask is (B, N, N). So what exactly are its two N dimensions? The reference says "pair_mask: (B, N, N)". And it's applied after the attention is reshaped to (B, N, h, N, N) where the last two dims are "query position" and "key position". So indeed, pair_mask[b, j, k] corresponds to query j and key k. Now for the finalize kernel: ```cuda __global__ void finalize_kernel( const float* scratch, const float* gate, const float* W_out, const float* b_out, float* output, int B, int N, int D ) { int idx = blockIdx.x * blockDim.x + threadIdx.x; int total = B * N * N * D; if (idx >= total) return; int b = idx / (N * N * D); int rem = idx % (N * N * D); int i = rem / (N * D); rem = rem % (N * D); int j = rem / D; int c = rem % D; // Load gated input for this position float gated[64]; // D for (int d = 0; d < D; d++) { gated[d] = scratch[((b * N + i) * N + j) * D + d] * gate[((b * N + i) * N + j) * D + d]; } // Output projection float out_val = b_out[c]; for (int d = 0; d < D; d++) { out_val += W_out[c * D + d] * gated[d]; } output[((b * N + i) * N + j) * D + c] = out_val; } ``` Wait, this loads all D inputs for each thread, which is very inefficient. Better to organize by position. Let me rewrite finalize so each block handles one position and all threads cooperate: ```cuda __global__ void finalize_kernel( const float* scratch, const float* gate, const float* W_out, const float* b_out, float* output, int B, int N, int D ) { int pos = blockIdx.x; int total_pos = B * N * N; if (pos >= total_pos) return; int b = pos / (N * N); int rem = pos % (N * N); int i = rem / N; int j = rem % N; __shared__ float gated_shared[64]; // Load scratch * gate into shared memory for (int l = threadIdx.x; l < D; l += blockDim.x) { gated_shared[l] = scratch[pos * D + l] * gate[pos * D + l]; } __syncthreads(); // Each thread computes one output channel for (int c = threadIdx.x; c < D; c += blockDim.x) { float out_val = b_out[c]; for (int d = 0; d < D; d++) { out_val += W_out[c * D + d] * gated_shared[d]; } output[pos * D + c] = out_val; } } ``` Block size: 64 threads. Total blocks: 2048 positions. 2048 blocks of 64 threads = 131K threads. Hmm, 2048 blocks is good! Each block handles one position. 64 threads compute the 64x64 matvec. Each block loads 64 floats (gated_shared), each thread loads 1 float. Then each of the 64 threads computes 1 output channel, needing to read 64 weight floats and 64 shared floats. The weight reads are coalesced across threads (each thread reads a different row of W_out). The shared reads are the same for all threads, so served from shared memory directly. 2048 blocks of 64 threads. Total threads: 131K. Total warps: 4096. 4096 / 132 = 31 warps per SM. This is excellent occupancy! Wait, actually each block only has 64 threads = 2 warps. 2048 blocks * 2 warps = 4096 warps. Each SM can hold ~16 warps (or more). So 4096 / 132 = 31 warps per SM, which is good but well within the capacity. Now, let me put this all together. For the preprocess kernel, if I use one thread per position: - 2048 positions - 64 threads per block -> 32 blocks. - 32 blocks * 2 warps = 64 warps. 64 / 132 = 0.48 warps per SM. Terrible occupancy. To improve preprocess occupancy, I can: - Use a larger block size and reuse threads for multiple positions, or - Parallelize the projections by output channel For preprocess, if I use 1 thread per position but launch 32 blocks of 256 threads where most threads are idle... no, I said one thread per position. Actually, let me try: for the preprocess kernel, use one thread per position, but have sequential processing. Wait, that doesn't help. What if each block handles 1 position and 256 threads cooperate on the projections? 256 threads for one position, computing 260 outputs. But 260 doesn't divide 256 nicely. What if: each block handles 1 position, 64 threads. 64 threads load the input, compute layer norm, then each thread computes some outputs. 260 outputs / 64 threads = ~4 outputs per thread. But the layer norm requires all threads to load different parts and reduce. For layer norm with 64 threads on 64 channels: - Each thread loads 1 channel - Need sum reduction: can use warp shuffle or shared memory - With 64 threads = 2 warps, we need __syncthreads() for reductions So for each block: 1. 64 threads load 64 channels 2. Compute mean via shared memory reduction 3. Compute variance 4. Normalize 5. Each thread computes ~4 outputs out of 260 But wait, if each block handles one position, and we have 2048 positions, that's 2048 blocks. 2048 blocks of 64 threads = 131K threads = 4096 warps. Same as finalize! Excellent occupancy. But each block's work is quite light. The layer norm and projections for one position are: - Mean/var: O(64) - 5 projections: 4 * 64*64 + 4*64 = ~16K FMAs 16K FMAs per block * 2048 blocks = 33M FMAs total. At 67 TFLOPS = 0.5 microseconds. Memory: 2048 blocks load 64 floats each = 131K floats write ~260 floats each = 532K floats. Total ~2.7MB, ~1 microsecond at 3TB/s. So preprocess as 2048 blocks of 64 threads should be quite fast. Wait, but can we launch 2048 blocks? CUDA grid limits are much higher (2^31-1 in x dimension). So yes. For the attention kernel: 256 blocks of 32 threads. That's: - 256 blocks * 32 threads = 8192 threads - 256 warps total - 256 / 132 = 1.9 warps per SM Occupancy is not great. Can we improve this? What if each block handles more queries? Say each block handles 4 heads x 8 queries? No, attention heads for the same (b, i) but different h are independent and can be in the same block. Option A: Each block handles 4 heads for one (b, i). - 64 blocks (one per row) - 4 heads * 32 queries = 128 queries to process - Block size: 256 threads, each handles half a query? Or 128 threads, each handles one query. With 128 threads per block and 128 queries: 1 thread per query. 64 blocks * 128 threads = 8192 threads. Same as before, just fewer blocks. With 256 threads per block and 128 queries: 2 threads per query. Or handle 8 heads worth... but we only have 4 heads. What if each block handles 2 heads? 128 blocks total. Block size 64 threads: 32 threads per head, 1 thread per query. 128 blocks * 64 threads = 8192 threads. Still same total. To get more total threads, we'd need more parallelism. We could parallelize across key dimension too. For example, 8 threads per query (4 per key dimension), then for N=32 queries: 32 * 8 = 256 threads. And each block handles 1 head. 256 blocks * 256 threads = 65536 threads = 2048 warps. 2048 / 132 = 15.5 warps per SM. Good! But as I noted, 8 threads per query makes the code more complex with reductions. Given the problem size is small, maybe simple code with 32 threads per block will be fast enough due to cache efficiency and simplicity? Memory is the bottleneck anyway. Let me just implement the simpler 32-thread version and see. But first, let me think if there's a way to get more parallelism simply. What if I process the attention not head-by-head but query-by-query with all heads in parallel? For a given query j in row (b, i), the attention involves all 4 heads. Each head's Q, K, V are independent. A block could handle all 4 heads for a single query. That's 32 queries * 64 rows = 2048 blocks. Block size could be 128 threads (32 per head). Or: 2048 queries * 4 heads = 8192 attention computations. But they're not independent in terms of output projection. Hmm, what if each block handles one (b, i, j) query position across all heads? - For query j in row (b, i): - 4 heads, each with its own Q_j, K, V - Attention for each head is independent - Output for each head is independent (until output projection) So a block can compute attention for all 4 heads of one query position. - Block count: B * N * N = 2048 blocks - Block size: say 128 threads = 32 threads per head - For each head: 32 threads load K and V (N=32, d=16), but wait, 32 threads for one head and only 1 query. A lot of threads doing redundant loads. Actually, for one head and one query: - 32 threads all load the same K (32, 16) and V (32, 16). That's very redundant. - One thread could handle one query. Why use 32 threads? What if each block handles one query position across all 4 heads, using 4 threads per head (one per 4 dimensions)? - 4 heads * 4 threads = 16 threads per block - 2048 blocks * 16 threads = 32768 threads = 1024 warps - 1024 / 132 = 7.8 warps per SM Better than 256 blocks of 32 threads! For each head and its 4 threads: - Load Q_j (4 dims per thread, 16 total) - Load K and V for all keys (cooperatively, 32*16/4 = 128 floats per thread) - Compute 32 scores (each thread does partial dot of 4 dims, then reduce) - Softmax (one thread or cooperative) - Compute output (weighted sum, each thread handles 4 dims) This is similar complexity to the 8-threads-per-query design but with fewer threads per query. Actually, the shared memory loading would be: 32 keys * 16 dims = 512 floats per head. 4 heads = 2048 floats. With 16 threads, each loads 128 floats. That's a lot for one thread. Let me think about this differently. What matters most is reducing memory round trips. A block that handles all 4 heads for one query j can load all K and V for all heads once, compute all 4 heads' attention, and write outputs. But K and V for 4 heads is 4 * 512 = 2KB. Not bad. But the big win is that we have 2048 blocks instead of 256, giving much better occupancy. And since we only need to write (D,) = 64 floats per block, and memory reads are 2KB (K/V) + small (Q + bias + mask), total memory per block is ~3KB. 2048 * 3KB = 6MB total. Very manageable. Let me design this: one block per (b, i, j) query position, handling all 4 heads. Wait, but the Q, K, V are already computed by the preprocess kernel. We could restructure: the preprocess only computes x (layer norm), and then the attention kernel computes projections on-the-fly while doing attention. But if we do projections on-the-fly, we need the weight matrices in the kernel. And we'd need to load x[b, i, j, :] (64 floats) per query. For 2048 queries, that's 131K floats. For K and V, we'd need to load x[b, i, k, :] for all k and compute K_k and V_k for each head. That's a lot of compute. Better to keep the preprocess that computes Q, K, V, gate, bias. For the attention kernel with 2048 blocks (one per query position): Each block needs: - Q for 4 heads: 4 * 16 = 64 floats - K for 4 heads: 4 * 32 * 16 = 2048 floats - V for 4 heads: 4 * 32 * 16 = 2048 floats - Bias for 4 heads and 32 keys: 4 * 32 = 128 floats - Mask for 32 keys: 32 floats Total per block: ~4.3KB read, 64 floats write. Total memory: ~9MB. With 2048 blocks and say 64 threads each: 131K threads = 4096 warps. 4096/132 = 31 warps per SM. Good occupancy. But each thread's work is light. Let's see if a 64-thread block can handle 4 heads efficiently. Block: 64 threads. - 16 threads per head. - 16 threads / 32 keys = we're processing keys in parallel, not queries. Wait, if the block handles one query j across 4 heads, then for each head: - 16 threads can be used to parallelize over the key dimension or the value dimension. - For d=16: 16 threads can each handle 1 dimension of the vectors. - For score computation: each of 16 threads computes 32 partial dots (1 dim each), then reduce across the 16 threads. - For attention*V: each of 16 threads handles 1 output dimension. This is actually very clean! For head h and query j: 1. Load Q_j: 16 floats. Each of 16 threads loads 1 float. (Coalesced if Q is stored as (..., d) with d contiguous.) 2. Load K for all 32 keys: 32 * 16 = 512 floats. Each thread loads 32 floats (one per key for its dimension). 3. For each key k: thread dd computes Q_j[dd] * K[k, dd]. This is 1 multiply. - Then reduce across 16 threads to get the full dot product. - With 16 threads, we can do warp shuffle reduce (__shfl_down_sync with 0xFFFF). 4. After getting 32 scores, bias and mask are added. 5. Softmax: can be done by one thread or cooperatively. 6. Load V for all 32 keys: 512 floats. Each thread loads 32 floats. 7. For each key k: thread dd accumulates score[k] * V[k, dd]. 8. Write output: thread dd writes output[dd]. This is elegant. Let me write the kernel this way. But wait, with 4 heads and 16 threads per head, how do I structure the 64 threads in the block? Option 1: Each head's 16 threads are in the same warp. - Warp 0: head 0 (threads 0-15) - Warp 1: head 1 (threads 16-31) - Warp 2: head 2 (threads 32-47) - Warp 3: head 3 (threads 48-63) Each warp handles one head independently. There's no cross-head communication needed within the block. Great! But warp 0 has threads 0-15 handling head 0. For warp shuffle, we need all 32 threads in the warp, but only 16 are active for our head. The other 16 threads in the warp would be idle or doing something else. We could have warp 0 handle heads 0 and 1, with threads 0-15 for head 0 and threads 16-31 for head 1. Then we can use warp shuffle for each group of 16? But warp shuffle typically operates on the entire warp of 32 threads. The `__shfl_down_sync` with mask can still work - each subgroup just ignores values from the other subgroup, but since we're syncing within the warp, we need to be careful. Actually, `__shfl_down_sync(mask, val, offset)` with mask can be used. If threads 0-15 pass mask `0x0000FFFF`, they only accept values from each other. That should work. But it's simpler to just use 32 threads per head and have 2 warps per block handle 2 heads? No, 4 heads needs 4 warps, and we'd have 32 threads per head but only 16 useful dimensions. Alternatively, we can just use shared memory for reduction instead of warp shuffle. Then it doesn't matter how threads are grouped. For 16 threads per head doing score computation: - Each thread computes 32 partial scores (one per key, for 1 dimension) - Store in shared memory: partial_scores[16][32] - Then reduce: for each key, sum the 16 partials across threads - This needs __syncthreads() between computing partials and reducing. Actually, we can do the reduction without __syncthreads() if each thread computes its own full scores. That is, each thread loads all 16 dimensions of Q_j and all 16 dims of K_k, computes the full dot product. Then no reduction needed, but each thread loads more data. Wait, with 16 threads per head and 32 keys, if each thread computes the full dot product for 2 keys (16 threads * 2 = 32 keys total), then: - Each thread loads Q_j (16 floats) - Each thread loads 2 keys of K (2 * 16 = 32 floats) - Computes 2 full dot products (16 FMAs each = 32 FMAs) - Then for softmax, we'd need all 32 scores in one thread or shared memory. This is also a clean arrangement! Let's try: - 16 threads per head - Each thread handles 2 keys out of 32 - For those 2 keys, compute the full attention score (dot product, bias, mask) - Then all 16 threads write their scores to shared memory (32 scores total) - One thread (or all collaboratively) does the softmax - Then each thread computes the weighted output for its 2 keys - Accumulate to get the final 16-dim output vector Hmm, this requires writing 32 scores to shared memory and reading them back. Not bad: 32 floats. But wait, the output vector is 16 dims. If each thread handles 2 keys, then for the output, each thread contributes score[k1]*V[k1,:] + score[k2]*V[k2,:]. Summing across 16 threads: each thread has a 16-dim vector, and we need to add them all together. That's 16 vectors of 16 floats = 256 floats to reduce. This reduction could be done by having each thread write its partial output to shared memory, then reduce. Or, each thread could just store its own 2 keys' worth and add to a running total in shared memory using atomicAdd... no, use sequential reduction. Actually, let me step back. The simplest possible kernel: Block: one (b, i, j) position Threads: 4 (one per head) Block count: 2048 Each thread handles one head completely: - Load Q_j: 16 floats - For k=0..31: - Load K_k: 16 floats - Compute score[k] = dot(Q_j, K_k) / sqrt(d) + bias[b,h,j,k] - Check mask[b,j,k] - Softmax over 32 scores - For k=0..31: - Load V_k: 16 floats - Accumulate output += score[k] * V_k - Write output[16] This is extremely simple! Each block has 4 threads. 2048 blocks * 4 threads = 8192 threads. That's it. But 4 threads per block is terrible for memory coalescing and utilization. GPU executes in warps of 32, so a block of 4 threads means 28 threads in the warp are idle. The GPU still schedules the warp, but 87.5% of the threads are wasted. What if we process 8 query positions per block? 8 queries * 4 heads = 32 "head-query" units. 32 threads, one per unit. 2048 queries / 8 = 256 blocks. 256 blocks of 32 threads = 8192 threads. But now each warp has 32 active threads! For each thread, it handles one (head, query) pair. That's exactly the "one thread per query per head" model I had earlier. 256 blocks, each handling 8 query positions across 4 heads = 32 (query, head) pairs. Wait, 8 queries * 4 heads = 32 pairs. And we have 2048 total query positions (B*N*N = 2*32*32 = 2048). Each query position has 4 heads, so 8192 pairs total. 8192 / 32 = 256 blocks. Yes! So: 256 blocks of 32 threads. Each thread handles one (b, i, j, h) combination. Total 8192 threads. For each thread: - Load Q_j for head h: 16 floats - Load K and V for head h: 32*16 + 32*16 = 1024 floats But K and V are shared across the 32 threads in the block (since they handle different queries but same head and same row). Wait, no. If a block handles 8 queries and 4 heads, then the 32 threads are: - Thread 0: query 0, head 0 - Thread 1: query 1, head 0 - ... - Thread 7: query 7, head 0 - Thread 8: query 0, head 1 - ... - Thread 31: query 7, head 3 For head 0: threads 0-7 all need K and V for head 0. There's 8 threads that share the same K and V. For head 1: threads 8-15 share K and V for head 1. etc. So within a block, K and V for each head need to be loaded only once and shared among the 8 query threads for that head. This is a nice compromise! We get: - 256 blocks (good number, > 132 SMs, roughly 2 per SM) - 32 threads per block (one full warp) - K and V for each head are loaded once and shared among 8 query threads - Good memory coalescing Wait, but does sharing K and V require __syncthreads()? Yes, but only within the head's query group. Actually, if all threads in the block load K and V to shared memory, then a __syncthreads() ensures all are ready. Let me define shared memory layout: - K_head0: 512 floats - V_head0: 512 floats - K_head1: 512 floats - V_head1: 512 floats - K_head2: 512 floats - V_head2: 512 floats - K_head3: 512 floats - V_head3: 512 floats Total: 4096 floats = 16KB. Plus maybe scores for 8 queries * 32 keys = 256 floats for softmax. Total ~17KB. Fine. Actually, we don't need to store all K and V in shared memory if we read them on-the-fly from global. But caching them helps. Wait, each thread only accesses its own head's K and V. So K_head0 is only accessed by threads 0-7. There's no cross-head sharing needed. But shared memory is shared across the whole block. One issue: if we put K and V in shared memory, then threads from different warps (but there's only one warp of 32) access different parts. No bank conflicts since each head's K/V is accessed by only 8 threads, and if we lay it out well, those 8 threads access the same locations... actually no, for key k, threads 0-7 all read K_head0[k, :] but for different queries. They all read the SAME K_head0[k, :] data. This means bank conflicts if not careful, but since they all read the same address, it broadcasts (no conflict). For V, each thread reads V[k, :] for all k. Different threads read different k at the same time, which could cause bank conflicts. But we can structure the access to be sequential. OK, this is getting complex to analyze. Let me just try the simplest design that should work and is correct: **Simpler attention kernel:** One block per (b, i, h) = 256 blocks. Block size: 32 threads, one per query. Each thread loads its Q_j from global memory. Block collaboratively loads K and V for the head to shared memory. Each thread computes its own 32 scores, softmax, and output. Writes output to scratch. This is simple, correct, and avoids complex cross-thread communication. The main downside is 256 blocks. But 256 blocks on H100 is fine if the GPU can keep them busy. Actually, with 132 SMs, 256 blocks means ~2 blocks per SM on average. And each block does a non-trivial amount of work (1024 FMAs per thread * 32 threads = 32K FMAs). A few microseconds per block. If blocks can be pipelined, it should be okay. And then the finalize kernel: 2048 blocks of 64 threads each. This has excellent occupancy. The preprocess kernel: I'll make it 2048 blocks of 64 threads each. This also has excellent occupancy. Total: 3 kernels, all with decent block counts. Wait, can I fuse preprocess and attention? The preprocess outputs Q, K, V, bias, gate. The attention uses Q, K, V, bias. If I compute Q, K, V on-the-fly in the attention kernel: - Each block loads the 32 positions' x values for its (b, i) row - Computes layer norm and projections for all 32 positions - Then does attention But we'd need to recompute layer norm and projections for each head block. Since there are 4 head blocks per row, each computing the same layer norm and Q/K/V projections 4 times. That's 4x redundant compute. For a compute-bound kernel, this might be fine, and it would save memory bandwidth of writing/reading Q, K, V. Let's estimate: Option A (separate preprocess): - Preprocess: reads pair_repr (131K floats), writes q,k,v (393K), gate (131K), bias (8K) = total ~0.7MB read + ~0.5MB write - Attention: reads q,k,v (393K), bias (8K), mask (2K), writes scratch (131K) = ~0.5MB read + 0.5MB write - Finalize: reads scratch (131K), gate (131K), writes output (131K) = ~0.3MB read + 0.5MB write Option B (fused, no QKV storage): - Attention block loads x for its row (2KB), computes ln + projections for all positions (compute), then attention - 256 blocks each loading 2KB = 512KB reads of pair_repr - Plus bias and gate still need to be computed somehow... bias depends on all positions, gate too. Gate and bias_proj depend on all (b, i, j) positions. If each block only loads its row, it can't compute bias_proj for the whole (b, :, :) without reading all rows. So bias and gate must either be precomputed or each block reads all rows for batch b. For batch b, bias_proj needs all N*N positions. 1024 positions * 64 dims = 64K floats. 256 blocks for batch b means each reading 64K floats = 16MB total reads. That's actually more than storing bias once. So separate preprocess is better. Let me finalize my design and write the code. Actually, one more thought: the reference does `x.reshape(B * N, N, D)` then linear layers. PyTorch linear on a 2D tensor is essentially a matrix multiply. For (B*N, N, D) @ (D, D), this is a batched GEMM where each "batch" is a row of the first N dimension. But x_flat is (64, 32, 64), and linear applies to the last dim, giving (64, 32, 64). In PyTorch, this is very efficient - it calls cuBLAS GEMM with M=64*32=2048, K=64, N=64. A single GEMM of size (2048, 64, 64). This is super fast in cuBLAS. For Q @ K^T, the reference does q (64, 4, 32, 16) @ k (64, 4, 32, 16).transpose(-1, -2) = (64, 4, 32, 32). In PyTorch, this is a batched matmul: batch=64*4=256, M=32, K=16, N=32. Also efficient. Then attn @ v: (64, 4, 32, 32) @ v (64, 4, 32, 16) = (64, 4, 32, 16). Batch=256, M=32, K=32, N=16. These are all efficiently handled by cuBLAS. The main overhead is: 1. The reshape, view, transpose, unsqueeze operations (memory copies or view ops) 2. The multiple kernel launches 3. Writing intermediate results to global memory A fused kernel that avoids the intermediate writes should beat the reference even if the compute isn't perfectly optimal. Now let me think about whether my custom attention kernel (32 threads per block, 256 blocks) with loops over 32 scores and 32 keys will actually be faster than cuBLAS batched matmul + softmax kernel + elementwise ops + another matmul. For N=32, d=16, a custom kernel that keeps everything in registers/shared memory might win because: 1. cuBLAS might not be perfectly optimized for 32x32 matrices 2. The custom kernel fuses QK^T + bias + mask + softmax + @V into a single kernel 3. No intermediate global memory writes for the attention scores For the batched matmuls: - QK^T: 256 batches of (32, 16) @ (16, 32). M=32, K=16, N=32. - attn @ V: 256 batches of (32, 32) @ (32, 16). M=32, K=32, N=16. These are tiny for cuBLAS. The standard cuBLAS might not even use Tensor Cores for these sizes, and the kernel launch overhead for each batch can be significant. So a single custom kernel for attention is likely to win. Similarly, for the linear projections in preprocess: the reference uses a single matmul of size (2048, 64) @ (64, 64) for each linear layer. That's actually quite efficient. My custom kernel computing these projections position-by-position might not beat cuBLAS. What if I keep the linear projections in PyTorch but fuse the surrounding ops? New strategy: 1. Use PyTorch's efficient linear for query, key, value, gate, bias_proj 2. Use custom kernel for the attention part (QK^T + bias + mask + softmax + @V) 3. Use PyTorch for the final output projection But then we still have the memory round trips between these ops. What if I do: 1. Fused kernel 1: layer_norm + all linear projections (to avoid writing x multiple times) 2. Custom kernel 2: fused attention + gate multiply + output projection Wait, can I compute layer_norm and then all 5 linear projections in one custom kernel? The projections are just 5 matrix-vector multiplies per position. With 2048 positions and D=64, that's 2048 * 5 * 64 * 64 ≈ 42M FMAs. A custom kernel might take ~1-2 microseconds. PyTorch cuBLAS for a (2048, 64) @ (64, 64) matmul is ~50 microseconds? Actually no, cuBLAS for small matrices is much faster. A (2048, 64, 64) GEMM is probably 5-10 microseconds. To beat the reference, the fused approach just needs to be faster than the reference's sequence of: 1. layer_norm (kernel) 2. 5x linear (5 kernels/GEMMs) 3. QK^T (1 kernel/GEMM) 4. reshape, add, mask (2-3 kernels) 5. softmax (1 kernel) 6. @V (1 kernel/GEMM) 7. reshape, multiply, linear (3 kernels) By fusing into 2-3 kernels, we eliminate most kernel launch overhead and intermediate memory writes. Even if each custom kernel is slightly less efficient than cuBLAS for the GEMM part, the fusion wins. Actually, for N=32, B=2, the amount of compute is so small that kernel launch overhead is the main bottleneck. The reference probably spends most of its time in kernel launch overhead and memory copies. A single fused kernel doing everything would be ideal. Let me try to write that. **Single fused kernel approach:** Each block handles one (b, i) row. 64 blocks total. Threads per block: 1024 (32 warps). For row (b, i): 1. Load pair_repr[b, i, :, :] into shared memory: 32 * 64 = 2KB 2. Compute layer norm for all 32 positions - 32 warps, each warp handles one position's layer norm - Wait, 32 warps * 32 threads = 1024 threads. 32 positions. 32 threads per position. - A warp of 32 threads handles one position: each thread loads 2 channels, they reduce across the warp. This is very efficient! 3. After layer norm, all x values are in shared memory 4. Compute Q, K, V, gate, bias_proj for all positions - Q, K, V are per-head. For all 4 heads and 32 positions: 32 * 4 * 16 * 3 = 6144 output values for QKV - Gate: 32 * 64 = 2048 - Bias: 32 * 4 = 128 - Total outputs: 8,320 values - 1024 threads, each computes ~8 output values - But each output is a dot product of 64 elements - We can parallelize by having each warp handle one position and one projection - 32 warps, each handling one position at a time. Need ~260 outputs per position. - 260 / 32 = ~8 outputs per thread per position - 32 positions, but warps can only handle one position at a time? No, different warps handle different positions. - Warp 0 handles position 0, warp 1 handles position 1, etc. 32 warps handle all 32 positions simultaneously. - Within each warp: 32 threads each compute some outputs for that position. - For Q projection (64 outputs): each thread computes 2 outputs (32 threads * 2 = 64). Each output is a 64-dim dot product. - Similarly for K, V, gate, bias. This phase does a lot of compute. Each of 32 warps handles one position. For one position and one projection of 64 outputs: - 32 threads each compute 2 outputs - Each output: 64 multiplies = 128 multiplies per thread - With 4 main projections (Q, K, V, gate) + bias (4): 4*128 + 4*... wait bias is only 4 outputs. - Total multiplies per thread for one position: ~4.5 * 128 = 576 - 32 positions, but each warp handles one position. So each warp does ~576 multiplies = 18,432 multiplies per warp. - Total for block: 32 * 18,432 = 590K multiplies. Plus bias_proj and other projections. After projections, Q, K, V are stored in shared memory or registers. 5. Attention phase: - For head h (0 to 3): - Load Q_h, K_h, V_h for all queries - 32 warps, each warp handles one query - Warp j: loads Q_j (16 floats, 1 per 2 threads or all 32 threads load but only 16 needed) - Within warp j: compute 32 scores - For key k=0..31: - All 32 threads can cooperate, or one thread can do it all - One thread doing 32 scores: each score is a 16-dim dot product - 32 * 16 = 512 FMAs. Very fast for one thread. - Then softmax - Then compute output: 32 keys * 16 dims = 512 FMAs - Warp j writes output for query j - 4 heads * 32 warps = 128 "query-head" computations, but we only have 32 warps. - We need to do 4 heads sequentially, or parallelize differently. With 32 warps handling 32 queries, we can process one head at a time. All 32 warps handle the 32 queries for head 0, then head 1, etc. 4 iterations. In each iteration: - Load K and V for the head into shared memory (or they might already be there from projection phase) - Each warp computes attention for its query - Write output After all 4 heads, we have the full attention output in shared memory (32 * 64 = 2KB). 6. Output phase: - Apply gate: elementwise multiply - Apply output projection: (32, 64) @ (64, 64) - This is 32 positions * 64 outputs = 2048 outputs - 32 warps * 32 threads = 1024 threads - Each thread computes 2 outputs: each is a 64-dim dot product - Total: 2048 * 64 = 131K FMAs - Write to global output This single kernel does EVERYTHING. 64 blocks of 1024 threads = 65,536 threads = 2048 warps. 2048 / 132 = 15.5 warps per SM. Good occupancy. Shared memory usage: - x: 32 * 64 = 2KB - Q/K/V for one head: 32 * 16 * 3 = 1.5KB (can reuse) - Attention output (all heads): 32 * 64 = 2KB - Gate: 32 * 64 = 2KB - Temp for softmax scores: 32 floats per warp? Or shared across block: 32 queries * 32 keys = 1KB - We don't need all 32*32 scores in shared memory simultaneously if each warp computes its own query's scores in registers. Total shared memory: ~8KB. Very manageable. The main concern is whether I can actually write this kernel correctly and efficiently. It's quite complex. And with 64 blocks, each with 1024 threads doing a LOT of work, there might be register pressure. Actually, writing this as a single kernel is very challenging and error-prone for a first attempt. Let me start with a simpler 3-kernel approach, see how it performs, and then try to fuse more if needed. Let me write the simpler 3-kernel version first. --- Actually, I've spent a lot of time analyzing. Let me write the code now. I'll go with: 1. `preprocess_kernel`: 2048 blocks of 64 threads. Each block handles one position. 2. `attention_kernel`: 256 blocks of 32 threads. Each block handles one (b, i, h). Each thread handles one query. 3. `finalize_kernel`: 2048 blocks of 64 threads. Each block handles one position, threads cooperate on output projection. Wait, I just realized: for attention kernel with 32 threads, N=32, each thread handles one query. For each thread: - It needs to read Q_j (16 floats), K_all (512 floats... from shared), V_all (512 floats from shared), bias_row (32 floats), mask_row (32 floats). - But K_all and V_all are read to shared memory collaboratively. If 32 threads each load 16 K floats and 16 V floats, that's 512 + 512 = 1024 floats. Each thread loads 32 floats. Perfect. - But the K and V for the same head and row are read 32 times (once per query thread). If we use shared memory, all 32 threads in the block read the same K and V from global once. But actually, the block only has 32 threads. So each thread reads 16 K + 16 V = 32 floats. That's 32 * 32 = 1024 floats total. The global memory request is coalesced because consecutive threads read consecutive addresses (if K is stored as (N, d) row-major). Thread 0 reads K[0:16], thread 1 reads K[16:32]? No, we'd structure it as: - K has shape (N, d) = (32, 16) - Thread t loads K[t * d/2 : (t+1) * d/2] if we split by keys? Or we can have thread t load K for keys t and t+16? No. Let me just have each thread load one key's worth of K and V: - Thread t loads K[t, :] (16 floats) and V[t, :] (16 floats) for t=0..31. - This is perfectly coalesced if the inner dim (d=16) is contiguous. - Total per thread: 32 floats. Total per block: 1024 floats. Then in shared memory: - K_shared[t, :] = K[t, :] for t=0..31 - V_shared[t, :] = V[t, :] for t=0..31 Great! Then each thread computes scores[j] for its query j by reading all K_shared[k, :] for k=0..31. Each thread accesses all 32 keys, reading 16 floats per key = 512 floats from shared memory. No bank conflicts if K_shared[k, d] is stored with d as the inner dimension and consecutive d values in consecutive banks. Actually, if all 32 threads read K_shared[k, :] for the SAME k simultaneously, that's a broadcast (no conflict). If they read different k, it's strided access which might have bank conflicts depending on the stride. In our case, when computing scores, thread j computes dot(Q_j, K_k) for all k sequentially. So at step k, all 32 threads read K_shared[k, :] for the SAME k. This is a broadcast from shared memory - no bank conflicts! Perfect. Then for the output computation, thread j computes sum_k score[k] * V[k, :] for its query j. It accesses V[k, :] sequentially for k=0..31. Again, at each step k, all threads read the SAME V[k, :]. Perfect broadcast! This is an ideal access pattern for shared memory. And for Q_j and the final output, each thread reads/writes its own data with no conflicts. So the attention kernel with 32 threads per block is actually very well-suited for this problem! Let me write it. And for the score array: each thread needs to store 32 scores temporarily. 32 floats in registers. Then do softmax. This is fine. Actually, I just need to be careful about register usage. 32 floats for scores + 16 for Q + 16 for output + temporaries ≈ 70 floats = 70 registers. Plus loop variables, etc. Maybe 80-100 registers. Max is 255 on H100, so fine. Now let me write the actual Python module. For the output shape of the attention kernel: scratch[b, i, j, head*d + dd]. For finalize: scratch has shape (B, N, N, D). Gate has shape (B, N, N, D). We read both, multiply, then output projection. For each position, 64 threads load 64+64=128 floats to shared memory, then each of 64 threads computes one output channel using W_out. Wait, for 64 threads per block and 2048 blocks, the finalize kernel loads: - Scratch: 2048 * 64 = 131K floats - Gate: 131K floats - W_out: 64 * 64 = 4K floats (cached) Total reads: ~262K floats. Writes: 131K floats. Total memory: ~1.5MB. Very small. Let me write the complete code. One more issue: the `pair_mask` in the reference is checked with `mask == 0`. In PyTorch, float masks are compared with exact zero. I'll pass the mask as a float tensor and check `mask[b, j, k] == 0.0f`. Also, for the softmax, we need to compute `exp(score - max_score)`. For very negative scores (like -inf), `exp(-inf)` is 0. My mask sets them to -1e9f, and exp(-1e9) underflows to 0. Fine. Now, writing the code: ```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 <math.h> __global__ void preprocess_kernel( const float* __restrict__ pair_repr, const float* __restrict__ ln_weight, const float* __restrict__ ln_bias, const float* __restrict__ W_query, const float* __restrict__ W_key, const float* __restrict__ W_value, const float* __restrict__ W_gate, const float* __restrict__ b_gate, const float* __restrict__ W_bias, float* __restrict__ q, float* __restrict__ k, float* __restrict__ v, float* __restrict__ gate, float* __restrict__ bias, int B, int N, int D, int h, int d ) { int pos = blockIdx.x; int total_pos = B * N * N; if (pos >= total_pos) return; int b = pos / (N * N); int rem = pos % (N * N); int i = rem / N; int j = rem % N; // Load input float x[64]; #pragma unroll for (int c = 0; c < D; c++) { x[c] = pair_repr[pos * D + c]; } // Layer norm float mean = 0.0f; #pragma unroll for (int c = 0; c < D; c++) mean += x[c]; mean /= D; float var = 0.0f; #pragma unroll for (int c = 0; c < D; c++) { float diff = x[c] - mean; var += diff * diff; } var /= D; float inv_std = rsqrtf(var + 1e-5f); #pragma unroll for (int c = 0; c < D; c++) { x[c] = (x[c] - mean) * inv_std * ln_weight[c] + ln_bias[c]; } // Query, Key, Value projections (no bias) int r = b * N + i; for (int head = 0; head < h; head++) { for (int dd = 0; dd < d; dd++) { int out_c = head * d + dd; float qval = 0.0f, kval = 0.0f, vval = 0.0f; #pragma unroll for (int c = 0; c < D; c++) { float xc = x[c]; qval += W_query[out_c * D + c] * xc; kval += W_key[out_c * D + c] * xc; vval += W_value[out_c * D + c] * xc; } int idx = ((r * h + head) * N + j) * d + dd; q[idx] = qval; k[idx] = kval; v[idx] = vval; } } // Gate projection (with bias) #pragma unroll for (int c = 0; c < D; c++) { float gval = b_gate[c]; #pragma unroll for (int ic = 0; ic < D; ic++) { gval += W_gate[c * D + ic] * x[ic]; } gate[pos * D + c] = 1.0f / (1.0f + expf(-gval)); } // Bias projection (no bias), then permute #pragma unroll for (int head = 0; head < h; head++) { float bval = 0.0f; #pragma unroll for (int c = 0; c < D; c++) { bval += W_bias[head * D + c] * x[c]; } bias[((b * h + head) * N + i) * N + j] = bval; } } __global__ void attention_kernel( const float* __restrict__ q, const float* __restrict__ k, const float* __restrict__ v, const float* __restrict__ bias, const float* __restrict__ pair_mask, float* __restrict__ scratch, int B, int N, int D, int h, int d ) { int b = blockIdx.x / (N * h); int rem = blockIdx.x % (N * h); int i = rem / h; int head = rem % h; int r = b * N + i; int query = threadIdx.x; if (query >= N) return; __shared__ float K_shared[32 * 16]; __shared__ float V_shared[32 * 16]; // Collaborative load of K and V // Thread t loads K[t, :] and V[t, :] for (int l = query; l < N * d; l += blockDim.x) { K_shared[l] = k[((r * h + head) * N) * d + l]; V_shared[l] = v[((r * h + head) * N) * d + l]; } __syncthreads(); // Load Q for this query float q_vec[16]; #pragma unroll for (int dd = 0; dd < d; dd++) { q_vec[dd] = q[((r * h + head) * N + query) * d + dd]; } // Compute attention scores float scores[32]; float max_score = -1e9f; #pragma unroll for (int key = 0; key < N; key++) { float dot = 0.0f; #pragma unroll for (int dd = 0; dd < d; dd++) { dot += q_vec[dd] * K_shared[key * d + dd]; } float score = dot / sqrtf((float)d); score += bias[((b * h + head) * N + query) * N + key]; // Apply mask if (pair_mask[(b * N + query) * N + key] == 0.0f) { score = -1e9f; } scores[key] = score; if (score > max_score) max_score = score; } // Softmax float sum_exp = 0.0f; #pragma unroll for (int key = 0; key < N; key++) { scores[key] = expf(scores[key] - max_score); sum_exp += scores[key]; } float inv_sum = 1.0f / sum_exp; #pragma unroll for (int key = 0; key < N; key++) { scores[key] *= inv_sum; } // Compute attention output float out_vec[16]; #pragma unroll for (int dd = 0; dd < d; dd++) out_vec[dd] = 0.0f; #pragma unroll for (int key = 0; key < N; key++) { float s = scores[key]; #pragma unroll for (int dd = 0; dd < d; dd++) { out_vec[dd] += s * V_shared[key * d + dd]; } } // Write to scratch #pragma unroll for (int dd = 0; dd < d; dd++) { int out_idx = ((b * N + i) * N + query) * D + head * d + dd; scratch[out_idx] = out_vec[dd]; } } __global__ void finalize_kernel( const float* __restrict__ scratch, const float* __restrict__ gate, const float* __restrict__ W_out, const float* __restrict__ b_out, float* __restrict__ output, int B, int N, int D ) { int pos = blockIdx.x; int total_pos = B * N * N; if (pos >= total_pos) return; __shared__ float gated_shared[64]; // Load scratch * gate into shared memory for (int l = threadIdx.x; l < D; l += blockDim.x) { gated_shared[l] = scratch[pos * D + l] * gate[pos * D + l]; } __syncthreads(); // Each thread computes some output channels for (int c = threadIdx.x; c < D; c += blockDim.x) { float out_val = b_out[c]; #pragma unroll for (int d = 0; d < D; d++) { out_val += W_out[c * D + d] * gated_shared[d]; } output[pos * D + c] = out_val; } } """ functions = ["preprocess_kernel", "attention_kernel", "finalize_kernel"] class ModelNew(nn.Module): def __init__(self, pair_dim, num_heads, dropout=0.0): super().__init__() self.num_heads = num_heads self.head_dim = pair_dim // num_heads self.layer_norm = nn.LayerNorm(pair_dim) self.query = nn.Linear(pair_dim, pair_dim, bias=False) self.key = nn.Linear(pair_dim, pair_dim, bias=False) self.value = nn.Linear(pair_dim, pair_dim, bias=False) self.gate = nn.Linear(pair_dim, pair_dim) self.bias_proj = nn.Linear(pair_dim, num_heads, bias=False) self.out_proj = nn.Linear(pair_dim, pair_dim) self.dropout = nn.Dropout(dropout) # Compile CUDA extension self.cuda_module = load_inline( name="evoformer_attention", cpp_sources="", # no cpp needed cuda_sources=CUDA_SOURCE, functions=functions, extra_cuda_cflags=["-O3", "--use_fast_math"], verbose=False, ) def forward(self, pair_repr, pair_mask): B, N, _, D = pair_repr.shape h = self.num_heads d = self.head_dim device = pair_repr.device # Allocate outputs q = torch.empty(B * N, h, N, d, device=device, dtype=torch.float32) k = torch.empty(B * N, h, N, d, device=device, dtype=torch.float32) v = torch.empty(B * N, h, N, d, device=device, dtype=torch.float32) gate = torch.empty(B, N, N, D, device=device, dtype=torch.float32) bias = torch.empty(B, h, N, N, device=device, dtype=torch.float32) scratch = torch.empty(B, N, N, D, device=device, dtype=torch.float32) output = torch.empty(B, N, N, D, device=device, dtype=torch.float32) # Launch preprocess kernel self.cuda_module.preprocess_kernel( pair_repr, self.layer_norm.weight, self.layer_norm.bias, self.query.weight, self.key.weight, self.value.weight, self.gate.weight, self.gate.bias, self.bias_proj.weight, q, k, v, gate, bias, B, N, D, h, d ) # Launch attention kernel blocks = B * N * h threads = N # 32 self.cuda_module.attention_kernel( q, k, v, bias, pair_mask, scratch, B, N, D, h, d ) # Launch finalize kernel blocks_fin = B * N * N threads_fin = 64 self.cuda_module.finalize_kernel( scratch, gate, self.out_proj.weight, self.out_proj.bias, output, B, N, D ) return output ``` Wait, I need to be careful about how `load_inline` handles the kernel launches. The generated Python bindings typically expect block and thread dimensions as arguments. Actually, looking at PyTorch's `load_inline`, the generated functions take tensors followed by the kernel arguments, and we need to wrap them with `torch.cuda.Stream` or use `cuda()` to launch? No, `load_inline` generates functions that can be called directly with the arguments. The block and grid dimensions are usually passed as additional integer arguments at the end. Wait, actually the generated functions by `load_inline` typically take the arguments in order and then the grid and block dimensions. But the exact interface depends on how the function is declared. Actually, for `load_inline` with `functions=["preprocess_kernel"]`, it generates a function that expects the kernel arguments as tensors/scalars. The grid and block dimensions need to be passed somehow. Looking at typical usage: ```python module.function_name(arg1, arg2, ..., grid=(gx, gy, gz), block=(bx, by, bz)) ``` Or sometimes: ```python module.function_name(arg1, arg2, ..., gridsize, blocksize) ``` I think in newer PyTorch versions (which this likely uses), the syntax is: ```python module.function_name(*args, grid=grid, block=block) ``` Let me check typical `load_inline` usage patterns. I believe the standard pattern is: ```python module.my_kernel(arg1, arg2, grid=(grid_x, grid_y, grid_z), block=(block_x, block_y, block_z)) ``` or ```python module.my_kernel(arg1, arg2, blocks=grid_x, threads=block_x) ``` Actually, looking at PyTorch docs and examples, `load_inline` generates pybind11 bindings where the function signature includes the grid and block dimensions as the last parameters. The common pattern I've seen is: ```python func = module.preprocess_kernel func(pair_repr, ..., B, N, D, h, d, grid=(num_blocks, 1, 1), block=(num_threads, 1, 1)) ``` Let me just go with this pattern. Now, there's an issue with `block=(32, 1, 1)` and `N=32`. If N changes (though in this problem it's fixed at 32), we might have issues. But the problem says the kernel is tested with specific sizes. However, as a robust implementation, we should handle dynamic sizes. I'll write it to handle any N. One issue: in the attention kernel, `scores[32]` is hardcoded. I should make it dynamic or use `N`. But for CUDA, dynamic arrays in registers are tricky. I can use `constexpr` if N is known at compile time, or just use the constant 32 since the problem specifies N=32. I'll make it work for any N up to some max using a fixed-size array. Wait, the task says my kernel will be tested. It might be tested with the exact sizes given, or with different sizes. I should make it work for the general case. For the scores array, I can allocate `float scores[MAX_N]` where MAX_N is some compile-time constant like 128. For N=32 this works fine. Actually, looking at the reference, it uses `math.sqrt(d)` where d = pair_dim / num_heads. In my kernels, I pass `d` as an argument and use `sqrtf((float)d)`. Good. For shared memory in the attention kernel, I used `__shared__ float K_shared[32 * 16]`. If N or d changes, this would break. I should either: 1. Pass the max sizes and use them, or 2. Allocate dynamic shared memory For simplicity, since the problem gives specific sizes and mentions `seq_len = 32`, I'll write it for N up to 128 and d up to 64. But actually, with `__shared__ float K_shared[32 * 16]`, this is a compile-time fixed size. If the test uses larger N, it would fail. To be safe, I can either: - Use dynamic shared memory: `extern __shared__ float shared_mem[];` - Or use a sufficiently large fixed size Let me use dynamic shared memory for the attention kernel. Actually, dynamic shared memory is passed as a kernel launch parameter. Wait, the problem states the specific sizes: "pair_dim = 64, num_heads = 4, seq_len = 32, batch_size = 2". The test framework might test with exactly these sizes. But it's good practice to make it general. For the fixed arrays in registers (scores[32], q_vec[16], etc.), I can't easily make them dynamic. But N <= 128 and d <= 64 are reasonable for typical attention. I could also make them dependent on template parameters, but I'm not using templates. Actually, looking at how PyTorch's own kernels handle this, they often use `constexpr` or just allocate generously. I'll use `const int MAX_N = 128; const int MAX_D = 64;` and define arrays accordingly. But there's a cleaner way: I can just not use fixed-size arrays and instead use loops with scalars, loading values as needed. But that increases register pressure. Alternatively, for the attention kernel, since N and d are always 32 and 16 for this problem, I can just hardcode those. But to be somewhat general, let me use template parameters or just check at compile time. Actually wait, `load_inline` compiles the CUDA code at module load time. The kernel is compiled once. If I hardcode 32 and 16, it works for these sizes. If the test uses different sizes, it breaks. I should check if the sizes are guaranteed. The task says: "Your submission is a complete Python file... The file is executed with exec() before we instantiate ModelNew." And the sizes are given as module-level constants. It's possible the test uses exactly these sizes. Let me make it work for the general case to be safe. To handle general N and d: - In preprocess: no fixed arrays needed - In attention: I need scores[N], q_vec[d], out_vec[d]. For N up to 128 and d up to 64, I can use fixed arrays of size [128] and [64]. - In finalize: gated_shared[D] where D up to 128 maybe. Let me set MAX_N = 128, MAX_D = 128. Actually, let me check: pair_dim = 64. D is fixed at 64. head_dim = 16. So d = 16. Let me use MAX_N = 128 and MAX_D = 128 for safety. For shared memory: ``` __shared__ float K_shared[MAX_N * MAX_D / 4]; // wait, need exact size ``` Hmm, with dynamic shared memory, I can pass the size at launch. But I also need V_shared. I can split the dynamic shared memory: ``` extern __shared__ float shared_buf[]; float* K_shared = shared_buf; float* V_shared = shared_buf + N * d; ``` Total size: 2 * N * d * sizeof(float). For N=32, d=16: 4KB. Let me modify the attention kernel to use dynamic shared memory. For the finalize kernel, I used `__shared__ float gated_shared[64]`. I'll change to `__shared__ float gated_shared[MAX_D]`. And for preprocess, no shared memory needed. Actually, for preprocess, I used `float x[64]`. I'll change to `float x[128]` or allocate dynamically. But since D is at most a few hundred in practice, a fixed array is fine. Let me use `float x[128]`. Actually, there's a better approach: don't use arrays at all in preprocess. Loop over the D channels and process them sequentially. But that means reloading weights repeatedly. With arrays, I load x once and reuse it. For memory, `float x[128]` is 128 * 4 = 512 bytes per thread. With 64 threads per block = 32KB. Register file is 64KB per SM, but registers are per-thread. 512 bytes = 128 registers. That's high but manageable if max is 255. With 64 threads * 128 registers = 8192 registers per block. Below the 64K limit per SM. Wait, does the compiler put `float x[128]` in registers or local memory? For an array that's accessed with variable indices, the compiler might put it in local memory. But if the indices are loop constants (unrolled loops with constant bounds), the compiler can put it in registers. With `#pragma unroll` on loops over D, the compiler should keep x in registers. But 128 floats is a lot of registers. Let me use a smaller fixed size. For this problem D=64, so `float x[64]` is 64 registers. Manageable. Actually, I just realized: the preprocess kernel with `float x[64]` and then computing projections with nested loops - the compiler might not be able to keep x in registers with the non-unrolled inner loops. Let me make sure the critical loops are unrolled. Since D=64 and h=4, d=16, these are small enough that I could use compile-time constants with templates, or just rely on the compiler. Actually, let me reconsider. Maybe the simplest correct implementation is sufficient, and I don't need to micro-optimize the preprocess. The main win is fusing the attention. Let me write the complete code now. I'll use `constexpr` where possible and `MAX_N=128`, `MAX_D=128`. Wait, one more thing. In `load_inline`, the functions generate Python bindings. For kernels marked with `__global__`, the generated Python function signature typically takes all kernel arguments plus `grid` and `block` as keyword arguments. Actually, looking at common examples: ```python module = load_inline(...) module.my_kernel(input, output, grid=(grid_x, 1, 1), block=(block_x, 1, 1)) ``` Or alternatively: ```python module.my_kernel(input, output, blocks=grid_x, threads=block_x) ``` I'm not 100% sure which format is used. Let me check... In PyTorch's `cpp_extension.py`, `load_inline` uses `pybind11` and the generated wrapper typically accepts `at::Tensor` arguments and then `int64_t` grid/block dimensions. Actually, I think the safest format is `grid=(gx, gy, gz), block=(bx, by, bz)` or equivalent. Let me search my knowledge for typical `load_inline` usage: ```python import torch.utils.cpp_extension as cpp_ext module = cpp_ext.load_inline(name="...", cuda_sources="...", functions=["kernel"]) module.kernel(input, output, grid=(1024, 1, 1), block=(256, 1, 1)) ``` Yes, this is the typical pattern. The `grid` and `block` are passed as keyword arguments. Let me write the code with this pattern. One issue: for dynamic shared memory in the attention kernel, I need to pass `shared_mem=...` or `stream=...`. In PyTorch's load_inline, the extra parameter is usually `smem=...` or similar. Actually, looking at the generated code in PyTorch, it might not directly support dynamic shared memory through the simple wrapper. Let me check... PyTorch's `cpp_extension.load` and `load_inline` generate bindings that can accept `blocks` and `threads`, but for dynamic shared memory, it might be `shared_mem=...` or `smem=...`. Actually, I might be overcomplicating this. I can just use static shared memory with a sufficiently large fixed size. Since the problem has N=32 and d=16, I can use `__shared__ float K_shared[32 * 16]` and `__shared__ float V_shared[32 * 16]`. But to be robust for slightly different sizes, I can use `__shared__ float K_shared[128 * 64]` and `__shared__ float V_shared[128 * 64]`. That's 2 * 128 * 64 * 4 = 65KB per block. That's close to the 228KB limit per SM, but with multiple blocks per SM, it might exceed shared memory capacity. For N=32, d=16: 32 * 16 = 512 floats per array = 2KB. I'll use `__shared__ float K_shared[512]` and `__shared__ float V_shared[512]`. This is safe for N<=32, d<=16. But what if the test uses N=64, d=32? Then 64*32=2048, so I'd need 2048 floats. I'll use `__shared__ float K_shared[2048]` and `__shared__ float V_shared[2048]`. That's 16KB total = 4K floats. Shared memory limit is 228KB, so even with 2 blocks per SM, it's fine. To be more general, I can use dynamic shared memory configuration through the kernel launch if supported, or just use a large fixed size. Actually, let me just stick with dynamic shared memory. In CUDA C++, dynamic shared memory is declared as `extern __shared__ float shared_mem[];`. To launch a kernel with dynamic shared memory in PyTorch, I need to know the pybind11 wrapper API. Looking at PyTorch source code, the generated wrappers typically look like: ```cpp m.def("kernel_name", [](args...) { // launch kernel with grid/block }); ``` They might not expose shared memory size directly. To be safe, I'll use a fixed-size shared memory array that's large enough for reasonable sizes. For typical attention, N <= 128 and d <= 64. So 128 * 64 = 8192 floats per array. Two arrays = 16384 floats = 64KB. With 2 blocks per SM = 128KB, still under 228KB. I'll use `__shared__ float K_shared[2048]` and `__shared__ float V_shared[2048]` as a safe default for N=32, d=16. And if N or d is larger, the kernel will still work as long as N*d <= 2048. For the given problem, N*d = 512, so very safe. Actually wait, I want this to be correct for the general case. What if they test with a larger sequence length? The problem doesn't say. But I'll add a check: if N > 32 or d > 16, fall back to PyTorch? No, the task says "Replace the forward computation of the PyTorch model below with a custom CUDA kernel." It should handle the same inputs. To be safe for larger sizes, I can compute the required shared memory and either use dynamic allocation if possible or just use a large fixed buffer. Actually, for the given problem sizes, I'll use fixed size and assume it works. Let me write the code with N=32, d=16 in mind but try to make it somewhat flexible by using the passed parameters where possible. Hmm, for the attention kernel, `scores[32]` is a fixed-size array. If N > 32, this overflows. For N <= 128, I can use `scores[128]`. Let me just use `128` for all fixed arrays. Similarly, q_vec[16] -> q_vec[64], out_vec[16] -> out_vec[64]. In finalize, gated_shared[64] -> gated_shared[128]. And in preprocess, x[64] -> x[128]. This is safe for reasonable sizes. For N=128 and D=128, the preprocess array x[128] is fine. The projections use D as a loop bound, so they adapt. Let me write the code now. I'll use 128 as the max for safety. Actually, for the shared memory in attention: if N=128 and d=64, then N*d = 8192. Two arrays = 16384 floats. `__shared__ float K_shared[8192]; V_shared[8192];` = 64KB. Fine. For scores: `float scores[128];` = 128 registers. With q_vec[64], out_vec[64]: total ~256 registers. That's at the limit but might be okay. Actually, register spill might occur if we use too many. For N=32 and d=16, the arrays are smaller and will fit in registers more easily. Let me just stick with the exact sizes for the given problem. The problem gives `seq_len = 32` and `pair_dim = 64`, and these are used as constants in the reference. I think the test uses exactly these sizes. But wait, `batch_size = 2` is also given. What if the test uses different batch sizes? It should still work as long as the kernels handle variable B. For variable B but fixed N=32, D=64: my kernels should work because B is just a dimension in the indexing. For variable N but fixed D=64: the attention kernel with `scores[32]` would fail for N > 32. But the problem states seq_len=32. I'll assume fixed. Alright, let me write the final code. For the preprocess kernel, I want to increase occupancy by having 2048 blocks of 64 threads each. But wait, 2048 blocks is a lot. 2048 * 64 = 131K threads. That's 4096 warps. Good. For the attention kernel: 256 blocks of 32 threads. But what if B changes? For variable B, there are B * N * h blocks. If B increases to say 8, then 8 * 32 * 4 = 1024 blocks. Fine. For finalize: 2048 blocks of 64 threads. Again, for larger B, it's B * N * N blocks. All good. One more concern: the `ln_weight` and `ln_bias` from `nn.LayerNorm` have shape (D,) and are used as `weight * (x - mean) / std + bias`. This is correct. In my preprocess kernel, I read `ln_weight[c]` and `ln_bias[c]`. This matches PyTorch's LayerNorm. Another concern: `pair_mask` in the reference is `torch.ones(batch_size, seq_len, seq_len)`. It's float32. When checking `pair_mask == 0`, PyTorch does an exact comparison. Since it's created with `ones()`, it should be exactly 1.0. In my kernel, I check `== 0.0f`. This is correct. Actually, one subtle issue: `pair_mask` is passed in as a tensor. If it's a float tensor, then `pair_mask[(b * N + query) * N + key] == 0.0f` might not catch all zeros if there are tiny numerical differences. But since `get_inputs()` creates it with `torch.ones()`, it should be exact 1.0 or 0.0. Now, about `load_inline` function signature. Since the kernel functions take various scalar int arguments, I need to pass them appropriately. In PyTorch's pybind11 wrapper for CUDA kernels, scalars are typically passed as `int` or `long`. I should use `int` for all the B, N, D, h, d parameters. This is what I have. But wait, I need to be very careful about how `grid` and `block` are passed. Let me look at some known working examples of `load_inline`: ```python import torch from torch.utils.cpp_extension import load_inline cuda_source = ''' __global__ void square_kernel(float* out, const float* in, int n) { int i = blockIdx.x * blockDim.x + threadIdx.x; if (i < n) out[i] = in[i] * in[i]; } ''' module = load_inline( name='square', cpp_sources='', cuda_sources=cuda_source, functions=['square_kernel'], extra_cuda_cflags=['-O3'] ) x = torch.randn(100, device='cuda') y = torch.empty_like(x) module.square_kernel(y, x, 100, grid=(4, 1, 1), block=(32, 1, 1)) ``` Yes, this is the pattern. Grid and block are passed as keyword arguments after the kernel arguments. Some versions might require `blocks=(4, 1, 1)` and `threads=(32, 1, 1)`. Let me consider both possibilities. Looking at the docs more carefully: earlier versions of PyTorch might have used `blocks=` and `threads=`, but more recent versions might use `grid=` and `block=`. To be safe, I can check the exact signature. Actually, I recall that in PyTorch, `load_inline` generates a binding where the function expects arguments plus `blocks` and `threads`: ```python module.square_kernel(out, inp, n, blocks=4, threads=32) ``` Or: ```python module.square_kernel(out, inp, n, grid=(4, 1, 1), block=(32, 1, 1)) ``` I'm not entirely sure. Let me try to be safe by using `blocks=...` and `threads=...` since those are the parameters typically passed to the CUDA kernel launch configurator in PyTorch internals. Actually, in PyTorch's C++ extension code, the generated wrapper for `__global__` functions typically passes `blocks` and `threads` to the kernel launch: ```cpp at::cuda::CUDAStream stream = at::cuda::getCurrentCUDAStream(); kernel<<<blocks, threads, 0, stream.stream()>>>(...); ``` So the Python wrapper likely accepts `blocks` and `threads`. I'll use `blocks=...` and `threads=...`. Wait, but `blocks` could be an integer or a tuple, and `threads` similarly. In my case: - preprocess: blocks=2048, threads=64 - attention: blocks=256, threads=32 - finalize: blocks=2048, threads=64 But actually, `blocks` might need to be a tuple `(2048, 1, 1)`. Let me try both if needed. For now, I'll assume `blocks=2048` works for 1D grids. Actually, looking at Caffe2 and PyTorch code, the wrapper generated by `torch.utils.cpp_extension` for CUDA kernels usually accepts: - Either positional `blocks` and `threads` as the last two arguments - Or keyword arguments `grid` and `block` Let me check if there's a standard. Actually, I notice that `torch.utils.cpp_extension.load_inline` might not even support direct kernel launching in newer PyTorch versions. Let me reconsider. Wait, the problem statement says: "Use `torch.utils.cpp_extension.load_inline` to compile and bind CUDA source at module load time, then call the compiled extension from `ModelNew.forward`." And it explicitly says: "Do not write your own `PYBIND11_MODULE(...)` block in `cpp_sources`. `load_inline` auto-generates one from the `functions=[...]` argument." So `load_inline` does generate the pybind11 module. The question is the exact calling convention. Looking at the C++ code that PyTorch generates for `functions=[...]`: It likely generates something like: ```cpp m.def("preprocess_kernel", [](torch::Tensor pair_repr, ..., int B, int N, int D, int h, int d) { // How does it get grid/block? }); ``` Hmm, it might actually NOT generate a launcher for `__global__` functions automatically! The `functions` list is for host functions. To expose a CUDA kernel, we typically need to write a host wrapper function that calls the kernel. OH! This is critical. The `functions=[...]` argument lists the names of functions to expose to Python. If I list `preprocess_kernel`, but `preprocess_kernel` is a `__global__` function, it can't be called directly from host code without a wrapper. The standard approach is to write a HOST wrapper function: ```cpp void launch_preprocess(...) { preprocess_kernel<<<grid, block>>>(...); } ``` And then list `launch_preprocess` in `functions`. So I need HOST wrapper functions that call the kernels with the right grid and block configuration! But wait, I also want some flexibility in grid/block sizes based on the input dimensions. I can pass the dimensions as parameters and compute grid/block in the wrapper. Actually, since the problem sizes are small and known, I can hardcode the grid and block, or compute them in the wrapper based on the passed dimensions. Let me rewrite the C++ code to include host wrappers: ```cpp void launch_preprocess(...) { int total_pos = B * N * N; int threads = 64; int blocks = (total_pos + threads - 1) / threads; preprocess_kernel<<<blocks, threads>>>(...); } ``` But then I need all the same arguments in the wrapper. Actually, let me look at common patterns more carefully. In PyTorch, when you use `load_inline`, the typical approach is: ```cpp void my_kernel_launcher(torch::Tensor input, torch::Tensor output) { int threads = 256; int blocks = (input.numel() + threads - 1) / threads; my_kernel<<<blocks, threads>>>(input.data_ptr<float>(), output.data_ptr<float>(), input.numel()); } ``` And then `functions=["my_kernel_launcher"]`. This makes much more sense! I should write host wrappers that call the kernels. Let me restructure the CUDA code to have host wrappers. ```cpp // Kernel declarations __global__ void preprocess_kernel(...); __global__ void attention_kernel(...); __global__ void finalize_kernel(...); // Host wrappers void launch_preprocess( torch::Tensor pair_repr, torch::Tensor ln_weight, torch::Tensor ln_bias, torch::Tensor W_query, torch::Tensor W_key, torch::Tensor W_value, torch::Tensor W_gate, torch::Tensor b_gate, torch::Tensor W_bias, torch::Tensor q, torch::Tensor k, torch::Tensor v, torch::Tensor gate, torch::Tensor bias, int B, int N, int D, int h, int d ) { int total_pos = B * N * N; int threads = 64; int blocks = (total_pos + threads - 1) / threads; preprocess_kernel<<<blocks, threads>>>( pair_repr.data_ptr<float>(), ln_weight.data_ptr<float>(), ln_bias.data_ptr<float>(), W_query.data_ptr<float>(), W_key.data_ptr<float>(), W_value.data_ptr<float>(), W_gate.data_ptr<float>(), b_gate.data_ptr<float>(), W_bias.data_ptr<float>(), q.data_ptr<float>(), k.data_ptr<float>(), v.data_ptr<float>(), gate.data_ptr<float>(), bias.data_ptr<float>(), B, N, D, h, d ); } ``` Similarly for the other kernels. And in Python: ```python functions = ["launch_preprocess", "launch_attention", "launch_finalize"] ``` Then I call `self.cuda_module.launch_preprocess(...)` without specifying grid/block. This is the correct pattern! Let me now write the full C++ code with host wrappers. One thing: for the attention kernel, the shared memory size depends on N and d. I'll use a fixed size that's large enough, or I can pass the shared memory size in the kernel launch. For simplicity, I'll use fixed-size shared memory: `__shared__ float K_shared[2048]; __shared__ float V_shared[2048];` which works for N*d <= 2048 (e.g., N=32, d=64). Wait, actually for N=32, d=16, K_shared needs 512 floats. With 2048, it's safe. But what if N=128 and d=64? 128*64=8192, which exceeds 2048. I'll use 8192 to be safe. Or: I'll just use exact sizes for this problem but allow for a bit of flexibility. Since the problem says N=32 and D=64, I'll use arrays of size 128 for any dimension-related arrays. For shared memory, I'll use `__shared__ float K_shared[2048];` and `__shared__ float V_shared[2048];`. If N*d > 2048, the kernel will write out of bounds, but that shouldn't happen for the test. Actually, let me just use dynamic shared memory through the kernel launch parameter. In the host wrapper: ```cpp int shared_mem_size = 2 * N * d * sizeof(float); attention_kernel<<<blocks, threads, shared_mem_size>>>(...); ``` And in the kernel: ```cpp extern __shared__ float shared_mem[]; float* K_shared = shared_mem; float* V_shared = shared_mem + N * d; ``` This is fully flexible! And it's the standard CUDA pattern. But wait, does PyTorch's `load_inline` generated wrapper support passing shared memory size? If not, I can just use the host wrapper and call the kernel directly with `<<< >>>`. Yes, in the host wrapper I can use the full CUDA launch syntax including shared memory. Great! Let me use dynamic shared memory. Now I need to make sure the tensors are contiguous and on the correct device. In the Python code, I'll create the tensors and pass them. One more concern: float precision. The reference uses FP32. My kernels use `float`. Good. For the output projection, I used `#pragma unroll` on the inner loop. For D=64, this should be fine. But actually, in the finalize kernel, with 64 threads in a block and D=64, each thread handles one output channel. The loop `for (int c = threadIdx.x; c < D; c += blockDim.x)` with blockDim.x=64 will execute exactly once per thread. I don't need `#pragma unroll` there. But the inner loop over D for the matvec should ideally be unrolled. Wait, in finalize: ```cpp for (int c = threadIdx.x; c < D; c += blockDim.x) { float out_val = b_out[c]; #pragma unroll for (int d = 0; d < D; d++) { out_val += W_out[c * D + d] * gated_shared[d]; } output[pos * D + c] = out_val; } ``` Since D=64, `#pragma unroll` on the inner loop is fine. But if D is large, the compiler might ignore it. For D=64, it should unroll. Actually, 64 iterations is a lot for `#pragma unroll`. The compiler might not fully unroll it due to code size concerns. But it will likely unroll by a factor of 2 or 4. That's good enough. Let me also add `-O3` to the compilation flags. One more issue: in preprocess, the inner loops over D for the projections are not unrolled. Let me add `#pragma unroll` to the critical inner loops. Actually, for preprocess, the most critical loop is: ```cpp for (int c = 0; c < D; c++) { qval += W_query[out_c * D + c] * x[c]; } ``` With D=64 and this loop executed 4*16*2048 = 131K times, unrolling could help. I'll add `#pragma unroll`. But wait, `for (int c = 0; c < D; c++)` where D is a runtime parameter. The compiler might not be able to unroll it because D is not known at compile time. I could make it a template parameter, but that's complex with `load_inline`. Alternatively, I can use `constexpr` if I know the values. But I don't want to hardcode. For runtime loops, `#pragma unroll` typically requires a constant bound. If D is passed as `int D`, the compiler won't unroll. But if the compiler can infer it's a small value from context, it might. Actually, modern CUDA compilers with `-O3` might unroll small loops even with variable bounds if they have profile-guided optimization or if the loop body is small. I can also manually partially unroll by processing 4 or 8 elements at a time. But for simplicity, let me just rely on the compiler. Actually wait, I just realized there might be a significant issue: `load_inline` expects C++ source code, but I'm using CUDA extensions (`__global__`, `<<< >>>`, etc.). This is fine because `cuda_sources` accepts CUDA code and compiles it with nvcc. One more thing: in the preprocess kernel, each thread writes to q, k, v for all heads. But `q` has shape (B*N, h, N, d). The indexing `((r * h + head) * N + j) * d + dd` assumes q is contiguous with shape [B*N, h, N, d]. In PyTorch, `torch.empty(B*N, h, N, d)` creates a contiguous tensor with this exact layout. Yes, PyTorch's default memory layout is row-major (C-contiguous). So `((r * h + head) * N + j) * d + dd` is correct. Similarly for other tensors. Now let me assemble the final code. Actually, I should also consider: can I simplify by using fewer kernels? A 2-kernel approach: 1. Fused preprocess + attention for each row (all heads) 2. Finalize (gate multiply + output projection) For kernel 1: one block per (b, i) row. 64 blocks. Each block handles all 4 heads. - Load x for the row - Compute layer norm for 32 positions - Compute Q, K, V for all heads - Compute attention for all heads - Write scratch This saves writing Q, K, V to global memory. But it requires each block to do the layer norm and projections for all 32 positions. Then attention for all 4 heads. With 64 blocks of 1024 threads: - 64K threads total = 2048 warps - 2048 / 132 = 15.5 warps per SM. Good. - Each block does substantial work Shared memory: - x: 32 * 64 = 2KB - K and V for one head: 32 * 16 * 2 = 1KB (can reuse across heads) - Q for all heads: 4 * 32 * 16 = 2KB. Or compute Q on the fly. - Attention scores: 32 * 32 = 1KB (if stored for all queries) - Attention output: 32 * 64 = 2KB Total dynamic shared: maybe 8-10KB. Fine. Register usage: per warp, one query's attention needs scores[32], q_vec[16], out_vec[16]. That's ~64 registers. This single kernel per row avoids the intermediate Q, K, V global writes! That's a big win. Actually, let me write this as a 2-kernel approach. It's more complex but potentially much faster. For the 2-kernel approach, the first kernel does everything from pair_repr to scratch (attention output for all heads). The second kernel does gate multiply + output projection. Kernel 1: `fused_row_attention_kernel` - One block per (b, i) row. - Block size: let's use 512 threads (16 warps). - For row (b, i): 1. Load pair_repr[b, i, :, :] into shared memory (32 * 64 = 2KB) - 512 threads, each loads some elements 2. Compute layer norm for all 32 positions - 16 warps, each warp handles 2 positions? Or 8 warps handle 4 positions each? - Better: 16 warps, assign 2 positions per warp. Each warp has 32 threads. For one position, 32 threads load 64 channels (2 per thread), compute mean and variance via warp shuffle. - So each warp handles 2 positions sequentially. Total: 16 warps * 2 = 32 positions. - Wait, but a warp of 32 threads handling one position is overkill for 64 channels. 32 threads loading 64 channels works well (2 per thread). 3. After layer norm, compute Q, K, V for all positions and heads - Q: for each head (4 heads), each position (32), each output channel (16): total 4*32*16 = 2048 Q values - Same for K and V: 2048 each - Gate: 32 * 64 = 2048 - Bias: 32 * 4 = 128 - Total outputs to compute: ~6K values - 512 threads, each computes ~12 values - Let's parallelize: for each position, 16 threads compute the 4 heads' Q/K/V outputs. - 16 threads per position, 32 positions = 512 threads. Perfect! - For position j: 16 threads compute Q, K, V for all heads. - But wait, 16 threads per position means we need to assign threads by position. - threadIdx.x = pos * 16 + tid_in_pos - For pos in 0..31, tid_in_pos in 0..15 - Each tid_in_pos computes some outputs for that position. - For Q of head h and dim dd: output value is dot(W_q[h*16+dd, :], x[pos, :]) - tid_in_pos can compute: for a specific head h = tid_in_pos / 4, and specific dd = tid_in_pos % 4... but 16 threads / (4 heads * 16 dims) = each thread computes 4 outputs. - Actually, 4 heads * 16 dims = 64 Q outputs per position. 16 threads => 4 outputs per thread. - Each output: 64 multiplies. 4 outputs: 256 multiplies per thread. - This position-then-output-channel assignment works well. 4. Store Q, K, V in shared memory or registers for attention - Q for all heads: 4 * 32 * 16 = 2048 floats. With 512 threads, but each warp handles 2 positions... hmm. - Actually, if we compute Q, K, V and store them in shared memory, each thread writes its computed outputs. - Then in attention phase, threads need to read Q, K, V for their queries. 5. Attention phase - We can reuse the 16-threads-per-position grouping. - For each head h (0 to 3): - Load K_h and V_h into a dedicated shared memory area - Each of the 16 threads for position j loads... no, K_h is shared across all positions. - Actually, K_h for the row is (32, 16). V_h is (32, 16). These are already in shared memory from the projection phase. - For attention within the head: - 512 threads = 32 positions * 16 threads. - But we only need 32 queries total, one per position. - For each position j, 16 threads can cooperate on the attention for query j. - 16 threads per query, d=16. Each thread handles 1 dimension. - For score computation: - Each thread loads its Q_j dim. - For key k=0..31: - Thread d computes partial score: Q_j[d] * K[k, d] - Warp shuffle reduce across 16 threads to get full score - This is tricky because 16 threads might not be in the same warp. - Alternatively, for each position j, one thread computes all attention. That would be 32 active threads out of 512. Not good. Hmm, the attention phase parallelization is tricky with 16 threads per position. Let me think about a better arrangement. For attention: we have 32 queries and 4 heads = 128 attention computations. Each computation needs QK^T, softmax, and @V. If we have 512 threads, that's 4 threads per attention computation. 128 * 4 = 512. For each attention computation (one query in one head): - 4 threads handle d=16 dimensions. Each thread handles 4 dims. - For score computation: - Each thread loads its 4 Q dims. - For key k: - Thread computes partial dot of 4 dims. - Reduce across 4 threads. - After 32 keys, we have 32 scores. - Softmax. - For output: - Each thread accumulates weighted sum for its 4 V dims. This is very similar to the "4 threads per query" model, but now with 128 attention computations and 512 threads. For the 4-thread reduction, we can use shared memory since 4 threads might not be in the same warp. Actually, if we arrange the 512 threads as: - 32 warps of 32 threads each. - Each warp handles one head (4 queries, 8 threads per query). - Wait, a warp of 32 threads handling 4 queries with 8 threads each. Or: 16 warps of 32 threads. - Warp 0: head 0, queries 0..3 (8 threads each) - Warp 1: head 0, queries 4..7 - Warp 2: head 0, queries 8..11 - Warp 3: head 0, queries 12..15 - Warp 4: head 0, queries 16..19 - Warp 5: head 0, queries 20..23 - Warp 6: head 0, queries 24..27 - Warp 7: head 0, queries 28..31 - Warp 8-15: head 1 (same as above) - But we only have 16 warps and need to cover 4 heads. Hmm, 16 warps / 4 heads = 4 warps per head. 4 warps = 128 threads per head. 128 threads / 32 queries = 4 threads per query. Yes! This works: - For head h: warps h*4 to h*4+3 handle the 32 queries. - 4 warps * 32 threads = 128 threads. - 128 threads / 32 queries = 4 threads per query. - The 4 threads for query j are in the same warp if j is within the warp's query range. - Warp 0 of head h: threads 0-127 of head h... wait no. Let me map threadIdx.x (0..511) to (head, query, tid_in_query): - head = threadIdx.x / 128 = 0..3 - rem = threadIdx.x % 128 - query = rem / 4 = 0..31 - tid_in_query = rem % 4 = 0..3 For a given head, threads are: - query 0: threads 0, 1, 2, 3 - query 1: threads 4, 5, 6, 7 - ... - query 31: threads 124, 125, 126, 127 And for head 1, the same pattern starting at thread 128. For query j in head h, the 4 threads are at: - threadIdx.x = h*128 + j*4 + tid_in_query Are these 4 threads in the same warp? A warp has 32 consecutive threads. - Query 0: threads 0-3 (in warp 0) - Query 1: threads 4-7 (in warp 0) - ... - Query 7: threads 28-31 (in warp 0) - Query 8: threads 32-35 (in warp 1) So queries 0-7 are in warp 0. Queries 8-15 in warp 1. Queries 16-23 in warp 2. Queries 24-31 in warp 3. For query j, the 4 threads are contiguous within a warp. Great! So we can use warp shuffle for reduction within the 4 threads? Well, `__shfl_down_sync` operates on a warp. If we want to reduce only within threads j*4 to j*4+3, we can use `__shfl_down_sync(0xFFFFFFFF, val, 1)` but that gets values from all threads in the warp, not just the 4 for our query. To reduce properly, we might need to mask: ```cpp int lane = threadIdx.x % 32; int query_in_warp = lane / 4; int tid_in_query = lane % 4; float val = ...; #pragma unroll for (int offset = 2; offset > 0; offset /= 2) { val += __shfl_down_sync(0xFFFFFFFF, val, offset); } ``` But this reduces across ALL 32 threads in the warp, not just the 4 for our query. Threads from other queries would contribute wrong values. To fix this, we can either: 1. Use __shfl_down_sync with a proper mask for each query group, or 2. Use shared memory for reduction. For shared memory reduction: - Each thread writes its partial dot to shared_mem[threadIdx.x] - __syncthreads() - For each query, one thread (e.g., the first of the 4) reads the 4 values and sums them. This is simple and doesn't depend on warp layout. Shared memory for reduction: 512 floats. Very small. Let me outline the full 2-kernel approach with 64 blocks of 512 threads: **Kernel 1: fused_row_attention** ```cpp __global__ void fused_row_attention( const float* pair_repr, const float* ln_weight, const float* ln_bias, const float* W_q, const float* W_k, const float* W_v, const float* W_gate, const float* b_gate, const float* W_bias, float* scratch, int B, int N, int D, int h, int d ) { int b = blockIdx.x / N; int i = blockIdx.x % N; // Shared memory // x: N * D // K_h: N * d (reused per head) // V_h: N * d (reused per head) // temp for reduction: blockDim.x floats // bias for row: h * N floats? Actually bias is (B, h, N, N) // We might not need to store bias in shared memory __shared__ float x_shared[32 * 64]; __shared__ float K_shared[32 * 16]; __shared__ float V_shared[32 * 16]; __shared__ float reduce_buf[512]; int tid = threadIdx.x; // Load pair_repr for this row for (int l = tid; l < N * D; l += blockDim.x) { x_shared[l] = pair_repr[((b * N + i) * N) * D + l]; } __syncthreads(); // Layer norm for all positions // 512 threads. Process N positions. // N=32, so 16 threads per position. int pos = tid / 16; int tid_in_pos = tid % 16; if (pos < N) { // Compute mean float mean = 0.0f; for (int c = tid_in_pos; c < D; c += 16) { mean += x_shared[pos * D + c]; } // reduce mean across 16 threads // use warp shuffle if 16 threads are in same warp, or shared memory // 16 threads might span warp boundary. Use shared memory. // ... (complex) } } ``` OK, the layer norm reduction with 16 threads per position is messy. Let me try a different grouping. What if I do layer norm with 32 threads per position (one warp per position)? - 32 warps (1024 threads) could handle 32 positions. But we only have 512 threads. - 16 warps (512 threads) can handle 16 positions at a time. We need 2 rounds for all 32 positions. In round 1: warps 0-15 handle positions 0-15. In round 2: warps 0-15 handle positions 16-31. For each position, 32 threads (one warp): - Each thread loads 2 channels - Warp shuffle to compute mean and variance - Each thread normalizes its 2 channels - Store back to shared memory This is very efficient for layer norm! And it handles all 32 positions in 2 rounds. Then for projections, after layer norm is done, we can reassign threads. 512 threads computing outputs for 32 positions. Then for attention, another reassignment. 512 threads = 128 attention computations * 4 threads each, or 32 queries * 16 threads * 4 heads / ... hmm. Actually, with 512 threads and 32 queries * 4 heads = 128 attention computations, that's 4 threads per attention. Which we analyzed earlier. But wait, if we use a different thread assignment for each phase, we need to be careful about synchronization and shared memory layout. This is getting very complex. For the sake of getting a working solution quickly, let me go back to the 3-kernel approach. It's simpler to implement correctly. I can always optimize further if the first version is not fast enough. Let me go with the 3-kernel approach and write the code. Actually, you know what, let me be even more pragmatic. The preprocess kernel using loops might actually be fast enough. And if it's not, I can optimize later. Let me start with a working implementation. Let me write the full Python module: ```python import torch import torch.nn as nn from torch.utils.cpp_extension import load_inline CUDA_SOURCE = r""" #include <torch/extension.h> #include <cuda_runtime.h> #include <math.h> __global__ void preprocess_kernel( const float* __restrict__ pair_repr, const float* __restrict__ ln_weight, const float* __restrict__ ln_bias, const float* __restrict__ W_query, const float* __restrict__ W_key, const float* __restrict__ W_value, const float* __restrict__ W_gate, const float* __restrict__ b_gate, const float* __restrict__ W_bias, float* __restrict__ q, float* __restrict__ k, float* __restrict__ v, float* __restrict__ gate, float* __restrict__ bias, int B, int N, int D, int h, int d ) { int pos = blockIdx.x * blockDim.x + threadIdx.x; int total_pos = B * N * N; if (pos >= total_pos) return; // Load input float x[128]; #pragma unroll for (int c = 0; c < D; c++) { x[c] = pair_repr[pos * D + c]; } // Layer norm float mean = 0.0f; #pragma unroll for (int c = 0; c < D; c++) mean += x[c]; mean /= D; float var = 0.0f; #pragma unroll for (int c = 0; c < D; c++) { float diff = x[c] - mean; var += diff * diff; } var /= D; float inv_std = rsqrtf(var + 1e-5f); #pragma unroll for (int c = 0; c < D; c++) { x[c] = (x[c] - mean) * inv_std * ln_weight[c] + ln_bias[c]; } // Extract indices int b = pos / (N * N); int rem = pos % (N * N); int i = rem / N; int j = rem % N; int r = b * N + i; // Query, Key, Value (no bias) for (int head = 0; head < h; head++) { for (int dd = 0; dd < d; dd++) { int out_c = head * d + dd; float qval = 0.0f, kval = 0.0f, vval = 0.0f; #pragma unroll for (int c = 0; c < D; c++) { float xc = x[c]; qval += W_query[out_c * D + c] * xc; kval += W_key[out_c * D + c] * xc; vval += W_value[out_c * D + c] * xc; } int idx = ((r * h + head) * N + j) * d + dd; q[idx] = qval; k[idx] = kval; v[idx] = vval; } } // Gate (with bias) #pragma unroll for (int c = 0; c < D; c++) { float gval = b_gate[c]; #pragma unroll for (int ic = 0; ic < D; ic++) { gval += W_gate[c * D + ic] * x[ic]; } gate[pos * D + c] = 1.0f / (1.0f + expf(-gval)); } // Bias proj (no bias), permute to (B, h, N, N) #pragma unroll for (int head = 0; head < h; head++) { float bval = 0.0f; #pragma unroll for (int c = 0; c < D; c++) { bval += W_bias[head * D + c] * x[c]; } bias[((b * h + head) * N + i) * N + j] = bval; } } __global__ void attention_kernel( const float* __restrict__ q, const float* __restrict__ k, const float* __restrict__ v, const float* __restrict__ bias, const float* __restrict__ pair_mask, float* __restrict__ scratch, int B, int N, int D, int h, int d ) { int b = blockIdx.x / (N * h); int rem = blockIdx.x % (N * h); int i = rem / h; int head = rem % h; int r = b * N + i; int query = threadIdx.x; if (query >= N) return; extern __shared__ float shared_mem[]; float* K_shared = shared_mem; float* V_shared = shared_mem + N * d; // Each thread loads one key's K and V for (int l = query; l < N * d; l += blockDim.x) { K_shared[l] = k[((r * h + head) * N) * d + l]; V_shared[l] = v[((r * h + head) * N) * d + l]; } __syncthreads(); // Load Q_j float q_vec[64]; #pragma unroll for (int dd = 0; dd < d; dd++) { q_vec[dd] = q[((r * h + head) * N + query) * d + dd]; } // Compute scores float scores[128]; float max_score = -1e9f; #pragma unroll for (int key = 0; key < N; key++) { float dot = 0.0f; #pragma unroll for (int dd = 0; dd < d; dd++) { dot += q_vec[dd] * K_shared[key * d + dd]; } float score = dot / sqrtf((float)d); score += bias[((b * h + head) * N + query) * N + key]; // mask is (B, N, N). mask[b, query, key] if (pair_mask[(b * N + query) * N + key] == 0.0f) { score = -1e9f; } scores[key] = score; if (score > max_score) max_score = score; } // Softmax float sum_exp = 0.0f; #pragma unroll for (int key = 0; key < N; key++) { scores[key] = expf(scores[key] - max_score); sum_exp += scores[key]; } float inv_sum = 1.0f / sum_exp; #pragma unroll for (int key = 0; key < N; key++) { scores[key] *= inv_sum; } // Compute output float out_vec[64]; #pragma unroll for (int dd = 0; dd < d; dd++) out_vec[dd] = 0.0f; #pragma unroll for (int key = 0; key < N; key++) { float s = scores[key]; #pragma unroll for (int dd = 0; dd < d; dd++) { out_vec[dd] += s * V_shared[key * d + dd]; } } // Write #pragma unroll for (int dd = 0; dd < d; dd++) { int out_idx = ((b * N + i) * N + query) * D + head * d + dd; scratch[out_idx] = out_vec[dd]; } } __global__ void finalize_kernel( const float* __restrict__ scratch, const float* __restrict__ gate, const float* __restrict__ W_out, const float* __restrict__ b_out, float* __restrict__ output, int B, int N, int D ) { int pos = blockIdx.x; int total_pos = B * N * N; if (pos >= total_pos) return; __shared__ float gated_shared[128]; for (int l = threadIdx.x; l < D; l += blockDim.x) { gated_shared[l] = scratch[pos * D + l] * gate[pos * D + l]; } __syncthreads(); for (int c = threadIdx.x; c < D; c += blockDim.x) { float out_val = b_out[c]; #pragma unroll for (int d = 0; d < D; d++) { out_val += W_out[c * D + d] * gated_shared[d]; } output[pos * D + c] = out_val; } } void launch_preprocess( torch::Tensor pair_repr, torch::Tensor ln_weight, torch::Tensor ln_bias, torch::Tensor W_query, torch::Tensor W_key, torch::Tensor W_value, torch::Tensor W_gate, torch::Tensor b_gate, torch::Tensor W_bias, torch::Tensor q, torch::Tensor k, torch::Tensor v, torch::Tensor gate, torch::Tensor bias, int B, int N, int D, int h, int d ) { int total_pos = B * N * N; int threads = 64; int blocks = (total_pos + threads - 1) / threads; preprocess_kernel<<<blocks, threads>>>( pair_repr.data_ptr<float>(), ln_weight.data_ptr<float>(), ln_bias.data_ptr<float>(), W_query.data_ptr<float>(), W_key.data_ptr<float>(), W_value.data_ptr<float>(), W_gate.data_ptr<float>(), b_gate.data_ptr<float>(), W_bias.data_ptr<float>(), q.data_ptr<float>(), k.data_ptr<float>(), v.data_ptr<float>(), gate.data_ptr<float>(), bias.data_ptr<float>(), B, N, D, h, d ); } void launch_attention( torch::Tensor q, torch::Tensor k, torch::Tensor v, torch::Tensor bias, torch::Tensor pair_mask, torch::Tensor scratch, int B, int N, int D, int h, int d ) { int blocks = B * N * h; int threads = N; int smem = 2 * N * d * sizeof(float); attention_kernel<<<blocks, threads, smem>>>( q.data_ptr<float>(), k.data_ptr<float>(), v.data_ptr<float>(), bias.data_ptr<float>(), pair_mask.data_ptr<float>(), scratch.data_ptr<float>(), B, N, D, h, d ); } void launch_finalize( torch::Tensor scratch, torch::Tensor gate, torch::Tensor W_out, torch::Tensor b_out, torch::Tensor output, int B, int N, int D ) { int blocks = B * N * N; int threads = 64; finalize_kernel<<<blocks, threads>>>( scratch.data_ptr<float>(), gate.data_ptr<float>(), W_out.data_ptr<float>(), b_out.data_ptr<float>(), output.data_ptr<float>(), B, N, D ); } """ class ModelNew(nn.Module): def __init__(self, pair_dim, num_heads, dropout=0.0): super().__init__() self.num_heads = num_heads self.head_dim = pair_dim // num_heads self.layer_norm = nn.LayerNorm(pair_dim) self.query = nn.Linear(pair_dim, pair_dim, bias=False) self.key = nn.Linear(pair_dim, pair_dim, bias=False) self.value = nn.Linear(pair_dim, pair_dim, bias=False) self.gate = nn.Linear(pair_dim, pair_dim) self.bias_proj = nn.Linear(pair_dim, num_heads, bias=False) self.out_proj = nn.Linear(pair_dim, pair_dim) self.dropout = nn.Dropout(dropout) self.cuda_module = load_inline( name="evoformer_cuda", cpp_sources="", cuda_sources=CUDA_SOURCE, functions=["launch_preprocess", "launch_attention", "launch_finalize"], extra_cuda_cflags=["-O3", "--use_fast_math"], verbose=False, ) def forward(self, pair_repr, pair_mask): B, N, _, D = pair_repr.shape h = self.num_heads d = self.head_dim device = pair_repr.device q = torch.empty(B * N, h, N, d, device=device, dtype=torch.float32) k = torch.empty(B * N, h, N, d, device=device, dtype=torch.float32) v = torch.empty(B * N, h, N, d, device=device, dtype=torch.float32) gate = torch.empty(B, N, N, D, device=device, dtype=torch.float32) bias = torch.empty(B, h, N, N, device=device, dtype=torch.float32) scratch = torch.empty(B, N, N, D, device=device, dtype=torch.float32) output = torch.empty(B, N, N, D, device=device, dtype=torch.float32) self.cuda_module.launch_preprocess( pair_repr, self.layer_norm.weight, self.layer_norm.bias, self.query.weight, self.key.weight, self.value.weight, self.gate.weight, self.gate.bias, self.bias_proj.weight, q, k, v, gate, bias, B, N, D, h, d ) self.cuda_module.launch_attention( q, k, v, bias, pair_mask, scratch, B, N, D, h, d ) self.cuda_module.launch_finalize( scratch, gate, self.out_proj.weight, self.out_proj.bias, output, B, N, D ) return output ``` Wait, there are several potential issues: 1. `load_inline` with `cpp_sources=""` might not work. Let me check... Actually, PyTorch might require at least some C++ source or might handle empty string. I'll include a minimal placeholder if needed. 2. The tensors need to be contiguous. `self.query.weight` is contiguous by default. `pair_repr` might not be. I should ensure inputs are contiguous: `pair_repr.contiguous()` and `pair_mask.contiguous()`. 3. `pair_mask` is compared with `== 0.0f`. In PyTorch, `pair_mask` is of type float (since `torch.ones` returns float32 by default). Good. 4. The gradient computation: since this replaces `forward()`, we don't need backward in the custom kernel. PyTorch's autograd will try to trace through the operations, but since we call into an inline extension that does raw pointer manipulation, autograd won't be able to track gradients. Wait! This is a very important issue! If we just call our custom kernel and return the output, PyTorch won't be able to compute gradients because the operations are not part of the autograd graph. However, the task says "Replace the forward computation... with a custom CUDA kernel." and "Produce numerically equivalent outputs at fp32 precision." It doesn't explicitly say we need to support backward pass. Let me re-read... The task says: "Replace the forward computation of the PyTorch model below with a custom CUDA kernel. Your ModelNew class must: 1. Accept the same constructor arguments as Model. 2. Implement forward() with the same signature. 3. Produce numerically equivalent outputs at fp32 precision. 4. Run faster than the PyTorch reference." It mentions forward computation, not backward. And the correctness check likely only checks the forward pass. So not supporting autograd is probably fine. But wait, PyTorch might still try to build the graph. If we return a tensor created with `torch.empty()`, and PyTorch doesn't know how it was computed, the output tensor won't have a grad_fn. But that's okay if nobody calls .backward(). The reference Model might be tested with .eval() and forward pass only. Actually, to be completely safe, I can wrap the custom operations in a `torch.autograd.Function`. But the task says "Replace the forward computation", and writing a custom autograd function adds complexity. I'll start without autograd support. 5. In `launch_attention`, the `blocks = B * N * h` calculation. For B=2, N=32, h=4: 256 blocks. `threads = N = 32`. Good. But if N is not a power of 2, the block size might be suboptimal. For N=32, it's fine. 6. Dynamic shared memory size: `int smem = 2 * N * d * sizeof(float);`. For N=32, d=16: smem = 2 * 32 * 16 * 4 = 4096 bytes. Good. But in the kernel, `K_shared = shared_mem` and `V_shared = shared_mem + N * d`. The indexing `K_shared[key * d + dd]` assumes the layout matches the contiguous tensor. Actually, `k` in preprocess is written as `k[((r * h + head) * N + j) * d + dd]`. This means k is contiguous with shape (B*N, h, N, d). And `v` is the same. When `launch_attention` reads `k[((r * h + head) * N) * d + l]`, it's reading from `k[r, h, 0, :]` with offset `l`. This assumes the layout is row-major, which it is. Wait, `((r * h + head) * N) * d + l` = `(r * h + head) * N * d + l`. This corresponds to starting at the beginning of the `(r, h)` slice, and reading `l` elements into it. Since the slice is `(N, d)` contiguous, this reads `k[r, h, l/d, l%d]`. Yes, correct. 7. For `pair_mask`, I need to check the indexing. `pair_mask[(b * N + query) * N + key]`. This assumes pair_mask has been flattened or is contiguous (B, N, N). The indexing gives `pair_mask[b, query, key]`. But wait, `pair_mask` is (B, N, N). So `pair_mask[b * N * N + query * N + key]`. But my code says: `(b * N + query) * N + key`. This equals `b * N * N + query * N + key = b * N * N + query * N + key`. Wait, `(b * N + query) * N + key = b * N^2 + query * N + key`. Yes! That's exactly `pair_mask[b, query, key]` for a contiguous (B, N, N) tensor. 8. For scratch, I write `scratch[out_idx]` where `out_idx = ((b * N + i) * N + query) * D + head * d + dd`. This equals scratch[b, i, query, head*d + dd]. Since scratch has shape (B, N, N, D), the indexing is: `((b * N + i) * N + query) * D + channel`. For channel = head*d + dd. Yes, correct. 9. For bias, I write `bias[((b * h + head) * N + i) * N + j]`. Since bias has shape (B, h, N, N), this is `bias[b, head, i, j]`. Correct. 10. For finalize, `scratch[pos * D + d]` and `gate[pos * D + d]` where pos = b * N * N + i * N + j. This gives `scratch[b, i, j, d]`. Correct. 11. For output projection: `W_out[c * D + d]`. Since `self.out_proj.weight` has shape (out_features, in_features) = (64, 64), and is row-major. W_out[c, d] is at `W_out[c * D + d]`. Correct. OK, the indexing looks correct. One more thing: `self.layer_norm.weight` and `self.layer_norm.bias` - in PyTorch, if `layer_norm` is on a different device than the input, we'd have issues. But `get_inputs()` presumably runs on the same device as the model. Since we create the model and inputs in the same script, they should both be on CPU or CUDA. Actually, `get_inputs()` returns CPU tensors, and the evaluator probably moves them to CUDA. Let me make sure the model parameters are on CUDA when the kernel is called. Actually, when `ModelNew` is instantiated, its parameters are on CPU (default). The reference model would also be on CPU. But the evaluator might call `.cuda()` on the model or move inputs to CUDA. This is a standard PyTorch thing. For safety, I don't need to worry about device mismatches as long as the evaluator handles it correctly (which it should, since it also tests the reference in the same way). But one thing: in my `forward()`, I allocate tensors on `device = pair_repr.device`. So if `pair_repr` is on CUDA, all intermediate tensors are on CUDA, and the model parameters (like `self.query.weight`) need to be on CUDA too. If the evaluator calls `.cuda()` on the model before forward, then everything is consistent. Let me also check: `load_inline` compiles the CUDA code at module load time. If no GPU is available at load time, it might fail. But the task says we're replacing a CUDA kernel, so a GPU is expected. One more thing: the reference uses `nn.Dropout(dropout)`. In my code, I include it in the constructor but don't use it in forward. During eval mode (which is likely), dropout is a no-op, so this is fine. During train mode, the outputs would differ. But since correctness tests typically use eval mode, it should be fine. Actually, let me think about whether I should drop dropout entirely from the constructor. The task says "Accept the same constructor arguments as Model." So `dropout=0.0` is accepted but we don't need to use it. We should keep it for API compatibility. Wait, another issue: `load_inline` caches compiled extensions based on the name. If I change the code but keep the same name, it might use a cached version. But since the code is executed fresh each time in the test harness, the cache key also includes the source code, so changes should trigger recompilation. Actually, the cache is based on the name and source hash. So as long as the source changes, it recompiles. Now, let me think about whether this will actually be faster. The preprocess kernel with `blocks = (2048 + 63) / 64 = 32` blocks of 64 threads. That's only 32 blocks! Very low occupancy. Actually, 32 blocks of 64 threads = 2048 threads. That's tiny. But the total work is about 42M FMAs. At 67 TFLOPS, this is less than 1 millisecond. The issue is whether the GPU can schedule 32 blocks efficiently across 132 SMs. With only 32 blocks, many SMs will be idle. But each block does substantial work, so the active SMs will be busy. For the attention kernel: 256 blocks of 32 threads = 8192 threads. Better, but still only 197 warps per SM? No, 256 warps total / 132 = 1.9 warps per SM. For finalize: 2048 blocks of 64 threads = 131K threads = 4096 warps. 4096 / 132 = 31 warps per SM. Good. So the preprocess and attention kernels are the bottlenecks due to low occupancy. But they might still be faster than the reference because they avoid intermediate memory writes. To improve preprocess, I could increase the block size. What if I use 512 threads per block? Then blocks = 2048 / 512 = 4 blocks. Worse. What if I use 32 threads per block? blocks = 2048 / 32 = 64 blocks. 64 blocks is better than 32! 64 * 32 = 2048 threads. But 64 blocks is still low. What if I use 16 threads per block? blocks = 128. Still low. Using 1 thread per position and wanting more blocks means smaller blocks, but block sizes below 32 are inefficient because warps have 32 threads. What if each thread handles 2 positions? Then 1024 threads total. 32 blocks of 32 threads. Same as 64 blocks of 16 threads. Hmm. What if each thread handles 1 position but we have fewer threads per block: 64 blocks of 32 threads = 64 blocks. This is slightly better than 32 blocks of 64 threads. Or: 128 blocks of 16 threads. But 16 threads per block is half a warp, which is inefficient. The fundamental issue is that we only have 2048 independent work units (positions). GPUs are designed for much more parallelism. What if we parallelize by position AND projection output channel? For example, a block of 256 threads handles one position and computes all 260 outputs in parallel. Then we'd have 2048 blocks of 256 threads = 524K threads. Much better! For one position and 260 outputs with 256 threads: 256 threads compute 260 outputs. That's nearly one output per thread. But each output is a dot product of 64 elements. So each thread does 64 FMAs. Total FMAs per block: 256 * 64 = 16K. Not a lot, but with 2048 blocks: 33M FMAs total. And 2048 blocks gives good occupancy. We can do this, but layer norm requires all threads to access the full input. In a block of 256 threads handling one position: - 256 threads, only 64 need to load data for layer norm. The rest are idle during layer norm. - But layer norm is very fast. - Then for projections, all 256 threads are active. Alternatively, a block of 64 threads handles one position, computing 260 outputs with ~4 outputs per thread. 2048 blocks of 64 threads. Total: 131K threads = 4096 warps. Excellent occupancy! For 64 threads and one position: - Layer norm: 64 threads load 64 channels (1 per thread). Cooperative reduction. - Projections: 64 threads, 260 outputs total. ~4 outputs per thread. Each output is 64 FMAs. ~256 FMAs per thread. This is a great sweet spot! 2048 blocks of 64 threads = 4096 warps total. But can the compiler handle the cooperative reduction for layer norm with 64 threads? Yes, using shared memory or warp shuffle. Let me redesign preprocess to use 64 threads per position. This requires careful handling of reductions. For layer norm with 64 threads: ```cpp __shared__ float x_shared[128]; __shared__ float mean_shared; __shared__ float var_shared; // Each thread loads 1 channel int c = threadIdx.x; x_shared[c] = pair_repr[pos * D + c]; __syncthreads(); // Compute mean float partial = x_shared[c]; // warp reduce within warp 0 (first 32 threads) // then warp reduce within warp 1 (next 32 threads) // then combine the two results ``` Actually, with 64 threads = 2 warps, warp shuffle is a bit complex. We can use shared memory: ```cpp __shared__ float reduce_buf[64]; reduce_buf[threadIdx.x] = x_shared[threadIdx.x]; __syncthreads(); // tree reduction for sum for (int stride = 32; stride > 0; stride /= 2) { if (threadIdx.x < stride) { reduce_buf[threadIdx.x] += reduce_buf[threadIdx.x + stride]; } __syncthreads(); } float mean = reduce_buf[0] / D; ``` Then for variance: ```cpp reduce_buf[threadIdx.x] = (x_shared[threadIdx.x] - mean) * (x_shared[threadIdx.x] - mean); __syncthreads(); // same reduction float var = reduce_buf[0] / D; ``` Then normalize: ```cpp x_shared[threadIdx.x] = (x_shared[threadIdx.x] - mean) * rsqrtf(var + 1e-5f) * ln_weight[threadIdx.x] + ln_bias[threadIdx.x]; __syncthreads(); ``` Then projections: each thread computes some outputs. Since there are 64 threads and ~260 outputs, we can have each thread compute 4 outputs, with 4 threads computing the remaining... wait, 260 / 64 = 4 with remainder 4. Actually, 260 outputs for Q(64)+K(64)+V(64)+G(64)+B(4) = 260. 64 threads * 4 = 256. So 4 outputs are not covered. Some threads need to compute 5 outputs. For simplicity, we can have each thread compute 4 or 5 outputs based on its thread ID: ```cpp int base_output = threadIdx.x * 4; int num_outputs = 4; if (threadIdx.x >= 60) num_outputs = 5; // last 4 threads do 5 ``` But this is uneven. Alternatively, we can just have a loop where each thread computes outputs starting from threadIdx.x and stepping by blockDim.x: ```cpp for (int out_idx = threadIdx.x; out_idx < 260; out_idx += 64) { // determine which projection and which channel // compute the output } ``` This is very clean! And it doesn't require any complicated load balancing. So for preprocess with block size 64: 1. 64 threads load 64 channels into shared memory 2. Synchronize, compute mean/var via tree reduction 3. Normalize all 64 channels 4. Each thread computes outputs `out_idx = threadIdx.x, threadIdx.x + 64, threadIdx.x + 128, threadIdx.x + 192, ...` until >= 260. 5. For each output, do a 64-element dot product with the appropriate weight matrix row. 6. Write to global memory. Step 5: each thread does `ceil(260 / 64) = 5` outputs, each requiring 64 multiplies = 320 FMAs per thread. Total per block: 64 * 320 = 20K FMAs. For 2048 blocks: 41M FMAs. Plus layer norm overhead. The main concern is that each thread reads 64 weights per output, and there are 5 outputs = 320 weight reads per thread. But weights are cached since all blocks read the same weights. Memory per block: 64 inputs + 260 outputs written = 324 floats. 2048 blocks: ~650K floats. Very small. I should rewrite preprocess to use this block-based approach. It's more complex but gives much better occupancy. Actually, do I really need to optimize preprocess that much? Let me think about what fraction of the total time preprocess takes in the reference. In the reference: 1. layer_norm: ~1 kernel launch, small memory 2. 5x linear: 5 GEMMs of size (2048, 64) x (64, out) - Each GEMM is M=2048, K=64, N=64 - Total FLOPs per GEMM: 2048 * 64 * 64 * 2 = 16.7M FMAs - But cuBLAS is highly optimized for these sizes - Time per GEMM: maybe 5-10 microseconds? - Total for 5 GEMMs: 25-50 microseconds 3. QK^T: M=32, K=16, N=32, batch=256. FLOPs: 256 * 32 * 16 * 32 * 2 = 8.4M 4. Softmax: small 5. @V: 256 * 32 * 32 * 16 * 2 = 8.4M FMAs 6. Output proj: M=2048, K=64, N=64. FLOPs: 16.7M The reference probably takes ~100-200 microseconds total. For my custom preprocess: - With 32 blocks of 64 threads: memory light but low occupancy. - If it takes 50 microseconds, it's still acceptable. - The main win comes from fusing the attention (QK^T + bias + mask + softmax + @V) into one kernel, eliminating intermediate writes. For attention with 256 blocks of 32 threads: - Each block: 32 threads * (512 + 512) FMAs = 32K FMAs. But memory traffic: reads Q(512), K(512), V(512), bias(32*4=128 floats for the full block? No, per block: 32 queries * 32 keys = 1024 bias floats = 4KB), mask(1024 floats=4KB). Per block reads: ~10KB. 256 blocks: ~2.5MB. - Time: maybe 20-30 microseconds with low occupancy but good cache behavior. For finalize: 2048 blocks of 64 threads. ~1.5MB memory. Maybe 10-15 microseconds. Total estimate: 50 + 30 + 15 = 95 microseconds. That's comparable to the reference. The fusion of attention might save 20-30 microseconds by avoiding the global memory round trip for the 32x32 attention matrix. To be safe, let me also try to improve preprocess occupancy. I'll rewrite it to use one position per block with 64 threads. Actually, let me first write the simpler version and see if it passes correctness. Then I can optimize if needed. Let me also consider: what if I skip writing the custom preprocess and just use PyTorch's operations for layer norm and the linear layers, but fuse the attention and output projection? ```python def forward(self, pair_repr, pair_mask): B, N, _, D = pair_repr.shape h = self.num_heads d = self.head_dim x = self.layer_norm(pair_repr) g = torch.sigmoid(self.gate(x)) bias = self.bias_proj(x).permute(0, 3, 1, 2) x_flat = x.reshape(B * N, N, D) q = self.query(x_flat).view(B * N, N, h, d).transpose(1, 2) k = self.key(x_flat).view(B * N, N, h, d).transpose(1, 2) v = self.value(x_flat).view(B * N, N, h, d).transpose(1, 2) # Custom attention kernel scratch = torch.empty(B, N, N, D, device=pair_repr.device) self.cuda_module.launch_attention(q, k, v, bias, pair_mask, scratch, B, N, D, h, d) # Custom finalize kernel output = torch.empty(B, N, N, D, device=pair_repr.device) self.cuda_module.launch_finalize(scratch, g, self.out_proj.weight, self.out_proj.bias, output, B, N, D) return output ``` This mixes PyTorch ops with custom kernels. The custom kernels only handle attention and finalize. This is safer because PyTorch's GEMMs are very efficient for the linear projections. But we still have the memory round trips for q, k, v, and scratch. For this tiny problem, the memory round trips are: - q, k, v: 3 * 131K floats = 393K floats = 1.6MB writes + 1.6MB reads = 3.2MB - scratch: 131K floats = 0.5MB write + 0.5MB read = 1MB - Total extra memory: ~4.5MB At 3.35 TB/s, 4.5MB takes ~1.3 microseconds. Negligible! So the PyTorch GEMMs + custom attention + custom finalize might actually be the best approach! It's much simpler to implement correctly, and PyTorch's GEMMs are highly optimized. But wait, the reference does: 1. layer_norm 2. gate linear 3. bias_proj linear 4. query linear 5. key linear 6. value linear 7. reshape/transpose x3 8. q @ k^T 9. view, add bias, add mask 10. softmax 11. dropout 12. attn @ v 13. transpose, reshape 14. multiply by g 15. out_proj Steps 8-13 are the most expensive in terms of kernel launches. My custom attention kernel replaces 8-13 with a single kernel. Then finalize does 14-15 with a single kernel. This eliminates ~5-6 kernel launches and intermediate allocations. For small tensors, kernel launch overhead dominates. So the mixed approach might be optimal: 1. Preprocess with PyTorch (3 kernel launches: ln, gate, qkv + bias) 2. Custom attention (1 kernel) 3. Custom finalize (1 kernel) Total: 5 kernels instead of ~15 in the reference. And the total time might be dominated by the custom kernels, which themselves have low occupancy but still win due to fusion. Given the simplicity, let me go with this mixed approach. It's much less error-prone. Actually wait, even for the preprocess, I can write a custom kernel. But the PyTorch GEMMs are so fast that a custom kernel might not be faster. Let me write the mixed approach first. It should definitely be correct since the PyTorch parts are identical to the reference. Wait, if I use PyTorch for preprocess, then I need to handle the shapes exactly as the reference does. Let me trace through: ```python x = self.layer_norm(pair_repr) # (B, N, N, D) g = torch.sigmoid(self.gate(x)) # (B, N, N, D) bias = self.bias_proj(x).permute(0, 3, 1, 2) # (B, h, N, N) x_flat = x.reshape(B * N, N, D) # (B*N, N, D) q = self.query(x_flat).view(B*N, N, h, d).transpose(1, 2) # (B*N, h, N, d) k = self.key(x_flat).view(B*N, N, h, d).transpose(1, 2) v = self.value(x_flat).view(B*N, N, h, d).transpose(1, 2) ``` This is identical to the reference except I don't call dropout or compute attn manually. Then for the custom attention, I need q, k, v, bias, pair_mask. Wait, but in the reference, bias is computed from the full x, which is (B, N, N, D). In my code, bias is computed the same way. Good. For the custom attention kernel, I'll take q (B*N, h, N, d), k (B*N, h, N, d), v (B*N, h, N, d), bias (B, h, N, N), mask (B, N, N), and output scratch (B, N, N, D). Let me write the kernel to read q, k, v with the same indexing as in the reference. Actually, in the reference, after transpose, q has shape (B*N, h, N, d). This is what my PyTorch code produces. Good. So the custom attention kernel reads q[k] with indexing: `q[((r * h + head) * N + query) * d + dd]` Where r = b * N + i. Yes, that's correct for a tensor of shape (B*N, h, N, d). OK, let me write the complete mixed-approach code. Actually, I think writing a custom preprocess kernel is worthwhile too, since we can fuse layer norm + 5 projections into one kernel, avoiding 5 kernel launches. Let me try the custom preprocess with one block per position and 64 threads. I'll write it carefully. Actually, you know what, let me just try the simplest thing that could work, compile it, and test. If it's correct, I can optimize from there. Let me write the preprocess kernel with 64 threads per block, one block per position. Preprocess kernel C++: ```cpp __global__ void preprocess_kernel( const float* __restrict__ pair_repr, const float* __restrict__ ln_weight, const float* __restrict__ ln_bias, const float* __restrict__ W_query, const float* __restrict__ W_key, const float* __restrict__ W_value, const float* __restrict__ W_gate, const float* __restrict__ b_gate, const float* __restrict__ W_bias, float* __restrict__ q, float* __restrict__ k, float* __restrict__ v, float* __restrict__ gate, float* __restrict__ bias, int B, int N, int D, int h, int d ) { int pos = blockIdx.x; int tid = threadIdx.x; if (tid >= D) return; // safety, though blockDim.x should be >= D __shared__ float x_shared[128]; __shared__ float reduce_buf[128]; // Load channel tid x_shared[tid] = pair_repr[pos * D + tid]; __syncthreads(); // Compute mean reduce_buf[tid] = x_shared[tid]; __syncthreads(); // Tree reduction for sum for (int stride = D / 2; stride > 0; stride /= 2) { if (tid < stride) { reduce_buf[tid] += reduce_buf[tid + stride]; } __syncthreads(); } float mean = reduce_buf[0] / D; // Compute variance reduce_buf[tid] = (x_shared[tid] - mean) * (x_shared[tid] - mean); __syncthreads(); for (int stride = D / 2; stride > 0; stride /= 2) { if (tid < stride) { reduce_buf[tid] += reduce_buf[tid + stride]; } __syncthreads(); } float var = reduce_buf[0] / D; // Normalize float inv_std = rsqrtf(var + 1e-5f); x_shared[tid] = (x_shared[tid] - mean) * inv_std * ln_weight[tid] + ln_bias[tid]; __syncthreads(); // Now compute projections // Each thread handles some outputs int b = pos / (N * N); int rem = pos % (N * N); int i = rem / N; int j = rem % N; int r = b * N + i; int total_outputs = 4 * D + h; // Q + K + V + gate + bias for (int out_idx = tid; out_idx < total_outputs; out_idx += blockDim.x) { // Determine which projection and which output channel float val = 0.0f; int out_c; bool has_bias = false; float bias_val = 0.0f; bool is_sigmoid = false; if (out_idx < D) { // Q out_c = out_idx; // no bias } else if (out_idx < 2 * D) { // K out_c = out_idx - D; } else if (out_idx < 3 * D) { // V out_c = out_idx - 2 * D; } else if (out_idx < 4 * D) { // Gate out_c = out_idx - 3 * D; has_bias = true; bias_val = b_gate[out_c]; is_sigmoid = true; } else { // Bias proj out_c = out_idx - 4 * D; // head index } // Compute dot product with x_shared if (out_idx < 4 * D) { for (int c = 0; c < D; c++) { if (out_idx < D) { val += W_query[out_c * D + c] * x_shared[c]; } else if (out_idx < 2 * D) { val += W_key[(out_idx - D) * D + c] * x_shared[c]; } else if (out_idx < 3 * D) { val += W_value[(out_idx - 2 * D) * D + c] * x_shared[c]; } else { val += W_gate[(out_idx - 3 * D) * D + c] * x_shared[c]; } } } else { for (int c = 0; c < D; c++) { val += W_bias[out_c * D + c] * x_shared[c]; } } if (has_bias) val += bias_val; if (is_sigmoid) val = 1.0f / (1.0f + expf(-val)); // Write to output if (out_idx < D) { int head = out_c / d; int dd = out_c % d; q[((r * h + head) * N + j) * d + dd] = val; } else if (out_idx < 2 * D) { int head = (out_idx - D) / d; int dd = (out_idx - D) % d; k[((r * h + head) * N + j) * d + dd] = val; } else if (out_idx < 3 * D) { int head = (out_idx - 2 * D) / d; int dd = (out_idx - 2 * D) % d; v[((r * h + head) * N + j) * d + dd] = val; } else if (out_idx < 4 * D) { gate[pos * D + (out_idx - 3 * D)] = val; } else { bias[((b * h + out_c) * N + i) * N + j] = val; } } } ``` This is actually quite clean! But the branch `if (out_idx < D) ... else if ...` inside the computation loop is ugly. Let me restructure it. Actually, there's a simpler way: since all projections use different weight matrices, and the output channels have different mappings, I can just have separate loops for each projection. But with 64 threads and 5 projections (260 outputs total), if each thread handles 4 outputs, the natural approach is: ```cpp // Q projection: D outputs (64) for (int out_c = tid; out_c < D; out_c += blockDim.x) { float val = 0.0f; for (int c = 0; c < D; c++) val += W_query[out_c * D + c] * x_shared[c]; int head = out_c / d; int dd = out_c % d; q[((r * h + head) * N + j) * d + dd] = val; } // K projection: D outputs for (int out_c = tid; out_c < D; out_c += blockDim.x) { float val = 0.0f; for (int c = 0; c < D; c++) val += W_key[out_c * D + c] * x_shared[c]; int head = out_c / d; int dd = out_c % d; k[((r * h + head) * N + j) * d + dd] = val; } // Same for V, gate, bias ``` This is much cleaner! And since D=64 and blockDim.x=64, each loop executes exactly once per thread (no stepping needed). For gate: ```cpp for (int out_c = tid; out_c < D; out_c += blockDim.x) { float val = b_gate[out_c]; for (int c = 0; c < D; c++) val += W_gate[out_c * D + c] * x_shared[c]; gate[pos * D + out_c] = 1.0f / (1.0f + expf(-val)); } ``` For bias: ```cpp for (int out_c = tid; out_c < h; out_c += blockDim.x) { float val = 0.0f; for (int c = 0; c < D; c++) val += W_bias[out_c * D + c] * x_shared[c]; bias[((b * h + out_c) * N + i) * N + j] = val; } ``` This is clean and branch-free! Each thread executes exactly one iteration of each loop. Total FMAs per thread: - Q: 64 - K: 64 - V: 64 - Gate: 64 - Bias: 64 (but only first 4 threads do this, others are idle) - Total active FMAs per thread: ~256 With 64 threads * 256 FMAs = 16K FMAs per block. 2048 blocks = 33M FMAs. This is much better than the earlier per-thread position approach. And 2048 blocks gives good occupancy. Let me write the complete code with this improved preprocess. One issue: for the bias projection, only h=4 outputs. With 64 threads, 60 threads are idle for this loop. That's fine, it's a tiny fraction of the total work. Let me also consider: can I make the 5 dot product loops more efficient? Currently, each loop reads x_shared[c] for c=0..63. Since x_shared is in shared memory, reading it 5 times per thread means 5 * 64 = 320 shared memory reads per thread. With 64 threads per block, that's 20K reads per block. Shared memory bandwidth is very high, so this should be fine. Can I cache x_shared in registers to avoid repeated shared memory reads? In this kernel, x_shared[64] is in shared memory, but after the first loop, the compiler might keep it in registers if we copy it. Let me add: ```cpp float x_reg[64]; for (int c = tid; c < D; c += blockDim.x) { x_reg[c] = x_shared[c]; } __syncthreads(); ``` But then each thread only has a subset of x_reg. And D=64 might exceed register capacity if we try to store the full array per thread. Actually, `float x_reg[64]` in registers for one thread is 64 registers. With 64 threads, that's 4096 registers per block. Well within the 64K limit per SM (which can hold multiple blocks). But the compiler might spill if we also have many other variables. For safety, I'll keep reading from shared memory. Shared memory on H100 is extremely fast. Actually, now I realize there's a small bug in my layer norm reduction. With D=64 and 64 threads, the tree reduction works perfectly because 64 is a power of 2. ```cpp for (int stride = 32; stride > 0; stride /= 2) { if (tid < stride) { reduce_buf[tid] += reduce_buf[tid + stride]; } __syncthreads(); } ``` Wait, but `__syncthreads()` inside a conditional without all threads reaching it is undefined behavior. I need all threads to reach `__syncthreads()`. In the loop: ```cpp for (int stride = 32; stride > 0; stride /= 2) { if (tid < stride) { reduce_buf[tid] += reduce_buf[tid + stride]; } __syncthreads(); } ``` All threads participate in the loop and all threads reach `__syncthreads()`. The `if` only guards the addition, but all threads reach the sync. This is correct. After the first reduction, `reduce_buf[0]` has the sum. But wait, `reduce_buf` has 128 elements allocated, and we only use the first 64. That's fine. Then mean = reduce_buf[0] / D. Then for variance, we compute `(x_shared[tid] - mean)^2` and store in reduce_buf[tid]. Then same reduction. Correct. Then normalize: `x_shared[tid] = ...`. Since each thread writes to x_shared[tid], and all 64 threads write different locations, no conflict. But `__syncthreads()` is needed after normalization to ensure all writes are visible before reading in the projection loops. Actually, if all threads immediately read x_shared in the projection loops, and the writes are done by all threads in the block, the reads are racing with writes of other threads. We need `__syncthreads()` before the projection loops. Wait, the projection loops use `x_shared[c]` for all c, not just c=tid. So yes, we need: ```cpp x_shared[tid] = normalized_value; __syncthreads(); // projection loops ``` This is important! Let me write the corrected preprocess kernel. Actually, at the beginning, we have: ```cpp x_shared[tid] = pair_repr[pos * D + tid]; __syncthreads(); ``` Here, only threads with `tid < D` write. The other threads (if blockDim.x > D) don't write. But they still reach `__syncthreads()`. After sync, threads that read x_shared[c] might read garbage for c >= D if blockDim.x > D. But we only read c < D. In my host wrapper, I'll set `threads = 64` and `blocks = B * N * N = 2048`. So blockDim.x = 64 = D. All threads have tid < D. Good. Let me write the final C++ code. Actually, I just realized that if D=64, but my array `x_shared[128]`, then all 64 threads write valid data. Good. One more thing: `ln_weight` and `ln_bias` might be on CPU or CUDA. Since the model is created on CPU and might be moved to CUDA later, but the kernel is called during forward() when the model should be on the same device as the input. I should add `.contiguous()` calls on input tensors to ensure they're contiguous. Actually, let me also check `pair_repr` could be non-contiguous due to some previous op. The `get_inputs()` returns a contiguous tensor from `torch.randn()`, but in general, I should handle non-contiguous. I'll add `.contiguous()` to be safe. And `pair_mask` might be non-contiguous too. I'll add `.contiguous()`. Let me write the complete code now. Wait, there's one issue with `load_inline`. If I call it at module level or in `__init__`, it compiles the CUDA code. If the compilation fails, it will throw an error. I need to make sure the C++ code is valid. One potential issue: `torch::Tensor.data_ptr<float>()` requires the tensor to be of type float. If `pair_repr` is double or half, this would fail. But `get_inputs()` uses `torch.randn()` which defaults to float32. Good. Another issue: `self.gate.bias` might be None? No, `nn.Linear` creates bias by default. The reference uses `nn.Linear(pair_dim, pair_dim)` which has bias. Good. The reference also uses `nn.Linear(pair_dim, pair_dim, bias=False)` for query/key/value/bias_proj, so those biases are None. In my code, I don't access biases for those. Good. One more C++ issue: in the attention kernel, I use `scores[128]`. But N might be less than 128. This is fine as long as we only use `scores[0..N-1]`. But using a fixed-size array in registers with size 128 might exceed register limits or cause other issues. Since N=32 for this test, `scores[32]` is safer. But I'll use `scores[128]` for generality. Actually, using `float scores[128]` when N=32 wastes registers. The compiler might optimize it away, but to be safe, I'll use `scores[64]`. Better yet, I'll just use a smaller bound. For attention, N won't be more than 128 typically. Let me use `64` to be safe. Wait, no. The compiler might put `scores` in registers if the accesses are predictable, or in local memory if not. For a fixed-size array with compile-time constant indices (from a small loop), registers are likely. For N=32 and `scores[32]`, that's 32 registers. Fine. For `q_vec[16]`: 16 registers. For `out_vec[16]`: 16 registers. Total: ~64 registers. Plus temporaries. Still under 255. I'll use `scores[64]`, `q_vec[64]`, `out_vec[64]`. For this test, only the first 32/16 elements are used. Now, one more subtlety: in the custom attention kernel, I don't handle dropout. The reference applies `self.dropout(attn)` after softmax. If the test runs in train mode, the outputs will differ. But correctness tests for forward pass typically run in eval mode. If I want to be thorough, I could add a no-op dropout. But since dropout is stochastic in train mode, we can't match it deterministically anyway. I'll assume eval mode. Let me now write the complete Python code. I should also consider whether to add `math` module import for `sqrtf` in CUDA. CUDA has `sqrtf` built-in. I used `sqrtf((float)d)`. Good. One more thing: the launch wrapper functions need to be in the `functions` list. Let me make sure the names match. ```python functions=["launch_preprocess", "launch_attention", "launch_finalize"] ``` And in C++, the wrappers are: ```cpp void launch_preprocess(...) { ... } void launch_attention(...) { ... } void launch_finalize(...) { ... } ``` These are host functions that call the kernels. Let me also double-check the attention kernel's __shared__ memory usage. With dynamic shared memory: ```cpp extern __shared__ float shared_mem[]; float* K_shared = shared_mem; float* V_shared = shared_mem + N * d; ``` In the host wrapper: ```cpp int smem = 2 * N * d * sizeof(float); attention_kernel<<<blocks, threads, smem>>>(...); ``` For N=32, d=16: smem = 2 * 32 * 16 * 4 = 4096 bytes. In the kernel, `K_shared[key * d + dd]` accesses the correct element. Good. But wait, `key` runs from 0 to N-1, and `dd` runs from 0 to d-1. So `key * d + dd` ranges from 0 to N*d-1. And V_shared starts at offset N*d. So `V_shared[key * d + dd]` is also correct. In the collaborative load: ```cpp for (int l = query; l < N * d; l += blockDim.x) { K_shared[l] = k[((r * h + head) * N) * d + l]; V_shared[l] = v[((r * h + head) * N) * d + l]; } ``` `threadIdx.x` ranges from 0 to N-1 (since `threads = N = 32`). Each thread loads `l = query, query + 32, ...` up to `N*d - 1`. Since `N*d = 512` and blockDim.x = 32, thread `query` loads indices `query, query+32, query+64, ..., query+480`. That's 16 elements per thread. Total: 32 * 16 = 512 elements. Perfect coalescing because consecutive threads read consecutive memory addresses. Wait, consecutive threads have `query = 0, 1, 2, ..., 31`. And they read `K_shared[0], K_shared[1], ..., K_shared[31]`. Then in the next iteration, `K_shared[32], ..., K_shared[63]`. This is perfectly coalesced. And the global memory read: `k[((r * h + head) * N) * d + l]`. Since `l` is consecutive for consecutive threads, this is also perfectly coalesced. Great! In the score computation: ```cpp for (int key = 0; key < N; key++) { float dot = 0.0f; for (int dd = 0; dd < d; dd++) { dot += q_vec[dd] * K_shared[key * d + dd]; } } ``` All threads in the block read `K_shared[key * d + dd]` for the SAME `key` and varying `dd`. But since each thread reads its own `q_vec[dd]`, and `K_shared[key * d + dd]` is read by all threads, this is a broadcast from shared memory. No bank conflicts because we're reading the same address. Actually wait, threads read `K_shared[key * d + 0]`, `K_shared[key * d + 1]`, ..., `K_shared[key * d + d-1]`. These are DIFFERENT addresses within the same row. With d=16, we have 16 consecutive addresses. If the shared memory bank size is 4 bytes and we have 32 banks, then `K_shared[key * d + dd]` for dd=0..15 falls in banks 0, 1, ..., 15. Since threads in a warp read different dd values, each thread reads a different bank. No conflicts! Perfect. For V_shared in the output accumulation: ```cpp for (int key = 0; key < N; key++) { float s = scores[key]; for (int dd = 0; dd < d; dd++) { out_vec[dd] += s * V_shared[key * d + dd]; } } ``` Similar pattern: threads read `V_shared[key * d + dd]` for different dd. No bank conflicts. And `scores[key]` is the same for all threads in the block (actually, it's per-query, so different queries have different scores, but `scores` is in registers, not shared memory). This kernel is well-optimized for shared memory access patterns! Now let me write the code. I'll also make the model parameters match the reference exactly. One issue: `self.dropout` is included in the constructor but unused in forward. That's fine for compatibility. Let me also think about whether I need to check if tensors are contiguous. In `forward()`, I'll add: ```python pair_repr = pair_repr.contiguous() pair_mask = pair_mask.contiguous() ``` This ensures the pointers are valid for our indexing. OK, now let me write the complete Python file. I also need to consider: what if the weights are transposed? PyTorch stores `nn.Linear` weights as (out_features, in_features). My kernels access `W_query[out_c * D + c]`. For PyTorch float tensor of shape (64, 64), `W_query[0]` is row 0, `W_query[1]` is row 1, etc. `W_query[out_c * D + c]` accesses element (out_c, c). This is correct. For `self.layer_norm.weight`, shape is (64,). `ln_weight[c]` accesses element c. Correct. For `self.gate.bias`, shape is (64,). `b_gate[c]` is correct. For `self.out_proj.weight`, shape is (64, 64). `W_out[c * D + d]` accesses row c, column d. Correct. Everything checks out. Let me write the code. One more check: in the finalize kernel, I read `b_out[c]`. `self.out_proj.bias` is a tensor of shape (64,). So `b_out.data_ptr<float>()` gives me the pointer. `b_out[c]` accesses element c. Correct. For the gate tensor, I store `gate[pos * D + c]`. In the finalize kernel, I read `gate[pos * D + l]`. This matches. Alright, I think the code is ready. Let me write it and submit for compilation. One more thing about `load_inline`: the `cpp_sources` parameter might need to include `#include <torch/extension.h>`. I'll add it to the CUDA source (which is compiled with nvcc and includes C++ features). Actually, in `load_inline`, the `cuda_sources` is compiled by nvcc, so including `#include <torch/extension.h>` there is fine. Also, the `functions` list should contain the names of the host wrapper functions: `launch_preprocess`, `launch_attention`, `launch_finalize`. Let me compile and test!
call → compile_kernel
callcompile_kernel { "kernel_code": "\nimport torch\nimport torch.nn as nn\nfrom torch.utils.cpp_extension import load_inline\n\nCUDA_SOURCE = r\"\"\"\n#include <torch/extension.h>\n#include <cuda_runtime.h>\n#include <math.h>\n\n__global__ void preprocess_kernel(\n const float* __restrict__ pair_repr,\n const float* __restrict__ ln_weight,\n const float* __restrict__ ln_bias,\n const float* __restrict__ W_query,\n const float* __restrict__ W_key,\n const float* __restrict__ W_value,\n const float* __restrict__ W_gate,\n const float* __restrict__ b_gate,\n const float* __restrict__ W_bias,\n float* __restrict__ q,\n float* __restrict__ k,\n float* __restrict__ v,\n float* __restrict__ gate,\n float* __restrict__ bias,\n int B, int N, int D, int h, int d\n) {\n int pos = blockIdx.x;\n int tid = threadIdx.x;\n \n __shared__ float x_shared[128];\n __shared__ float reduce_buf[128];\n \n // Load input\n if (tid < D) {\n x_shared[tid] = pair_repr[pos * D + tid];\n }\n __syncthreads();\n \n // Compute mean\n if (tid < D) {\n reduce_buf[tid] = x_shared[tid];\n } else {\n reduce_buf[tid] = 0.0f;\n }\n __syncthreads();\n \n for (int stride = blockDim.x / 2; stride > 0; stride /= 2) {\n if (tid < stride) {\n reduce_buf[tid] += reduce_buf[tid + stride];\n }\n __syncthreads();\n }\n float mean = reduce_buf[0] / D;\n \n // Compute variance\n if (tid < D) {\n float diff = x_shared[tid] - mean;\n reduce_buf[tid] = diff * diff;\n } else {\n reduce_buf[tid] = 0.0f;\n }\n __syncthreads();\n \n for (int stride = blockDim.x / 2; stride > 0; stride /= 2) {\n if (tid < stride) {\n reduce_buf[tid] += reduce_buf[tid + stride];\n }\n __syncthreads();\n }\n float var = reduce_buf[0] / D;\n \n // Normalize\n if (tid < D) {\n x_shared[tid] = (x_shared[tid] - mean) * rsqrtf(var + 1e-5f) * ln_weight[tid] + ln_bias[tid];\n }\n __syncthreads();\n \n // Extract indices\n int b = pos / (N * N);\n int rem = pos % (N * N);\n int i = rem / N;\n int j = rem % N;\n int r = b * N + i;\n \n // Q projection\n for (int out_c = tid; out_c < D; out_c += blockDim.x) {\n float val = 0.0f;\n #pragma unroll\n for (int c = 0; c < D; c++) {\n val += W_query[out_c * D + c] * x_shared[c];\n }\n int head = out_c / d;\n int dd = out_c % d;\n q[((r * h + head) * N + j) * d + dd] = val;\n }\n \n // K projection\n for (int out_c = tid; out_c < D; out_c += blockDim.x) {\n float val = 0.0f;\n #pragma unroll\n for (int c = 0; c < D; c++) {\n val += W_key[out_c * D + c] * x_shared[c];\n }\n int head = out_c / d;\n int dd = out_c % d;\n k[((r * h + head) * N + j) * d + dd] = val;\n }\n \n // V projection\n for (int out_c = tid; out_c < D; out_c += blockDim.x) {\n float val = 0.0f;\n #pragma unroll\n for (int c = 0; c < D; c++) {\n val += W_value[out_c * D + c] * x_shared[c];\n }\n int head = out_c / d;\n int dd = out_c % d;\n v[((r * h + head) * N + j) * d + dd] = val;\n }\n \n // Gate projection (with bias)\n for (int out_c = tid; out_c < D; out_c += blockDim.x) {\n float val = b_gate[out_c];\n #pragma unroll\n for (int c = 0; c < D; c++) {\n val += W_gate[out_c * D + c] * x_shared[c];\n }\n gate[pos * D + out_c] = 1.0f / (1.0f + expf(-val));\n }\n \n // Bias projection (no bias)\n for (int out_c = tid; out_c < h; out_c += blockDim.x) {\n float val = 0.0f;\n #pragma unroll\n for (int c = 0; c < D; c++) {\n val += W_bias[out_c * D + c] * x_shared[c];\n }\n bias[((b * h + out_c) * N + i) * N + j] = val;\n }\n}\n\n__global__ void attention_kernel(\n const float* __restrict__ q,\n const float* __restrict__ k,\n const float* __restrict__ v,\n const float* __restrict__ bias,\n const float* __restrict__ pair_mask,\n float* __restrict__ scratch,\n int B, int N, int D, int h, int d\n) {\n int b = blockIdx.x / (N * h);\n int rem = blockIdx.x % (N * h);\n int i = rem / h;\n int head = rem % h;\n int r = b * N + i;\n int query = threadIdx.x;\n \n if (query >= N) return;\n \n extern __shared__ float shared_mem[];\n float* K_shared = shared_mem;\n float* V_shared = shared_mem + N * d;\n \n // Collaborative load of K and V\n for (int l = query; l < N * d; l += blockDim.x) {\n K_shared[l] = k[((r * h + head) * N) * d + l];\n V_shared[l] = v[((r * h + head) * N) * d + l];\n }\n __syncthreads();\n \n // Load Q for this query\n float q_vec[64];\n #pragma unroll\n for (int dd = 0; dd < d; dd++) {\n q_vec[dd] = q[((r * h + head) * N + query) * d + dd];\n }\n \n // Compute attention scores\n float scores[64];\n float max_score = -1e9f;\n \n #pragma unroll\n for (int key = 0; key < N; key++) {\n float dot = 0.0f;\n #pragma unroll\n for (int dd = 0; dd < d; dd++) {\n dot += q_vec[dd] * K_shared[key * d + dd];\n }\n float score = dot / sqrtf((float)d);\n score += bias[((b * h + head) * N + query) * N + key];\n \n if (pair_mask[(b * N + query) * N + key] == 0.0f) {\n score = -1e9f;\n }\n \n scores[key] = score;\n if (score > max_score) max_score = score;\n }\n \n // Softmax\n float sum_exp = 0.0f;\n #pragma unroll\n for (int key = 0; key < N; key++) {\n scores[key] = expf(scores[key] - max_score);\n sum_exp += scores[key];\n }\n float inv_sum = 1.0f / sum_exp;\n #pragma unroll\n for (int key = 0; key < N; key++) {\n scores[key] *= inv_sum;\n }\n \n // Compute attention output\n float out_vec[64];\n #pragma unroll\n for (int dd = 0; dd < d; dd++) out_vec[dd] = 0.0f;\n \n #pragma unroll\n for (int key = 0; key < N; key++) {\n float s = scores[key];\n #pragma unroll\n for (int dd = 0; dd < d; dd++) {\n out_vec[dd] += s * V_shared[key * d + dd];\n }\n }\n \n // Write to scratch\n #pragma unroll\n for (int dd = 0; dd < d; dd++) {\n int out_idx = ((b * N + i) * N + query) * D + head * d + dd;\n scratch[out_idx] = out_vec[dd];\n }\n}\n\n__global__ void finalize_kernel(\n const float* __restrict__ scratch,\n const float* __restrict__ gate,\n const float* __restrict__ W_out,\n const float* __restrict__ b_out,\n float* __restrict__ output,\n int B, int N, int D\n) {\n int pos = blockIdx.x;\n int tid = threadIdx.x;\n \n __shared__ float gated_shared[128];\n \n if (tid < D) {\n gated_shared[tid] = scratch[pos * D + tid] * gate[pos * D + tid];\n }\n __syncthreads();\n \n for (int c = tid; c < D; c += blockDim.x) {\n float out_val = b_out[c];\n #pragma unroll\n for (int d = 0; d < D; d++) {\n out_val += W_out[c * D + d] * gated_shared[d];\n }\n output[pos * D + c] = out_val;\n }\n}\n\nvoid launch_preprocess(\n torch::Tensor pair_repr, torch::Tensor ln_weight, torch::Tensor ln_bias,\n torch::Tensor W_query, torch::Tensor W_key, torch::Tensor W_value,\n torch::Tensor W_gate, torch::Tensor b_gate, torch::Tensor W_bias,\n torch::Tensor q, torch::Tensor k, torch::Tensor v,\n torch::Tensor gate, torch::Tensor bias,\n int B, int N, int D, int h, int d\n) {\n int total_pos = B * N * N;\n int threads = 64;\n int blocks = total_pos;\n preprocess_kernel<<<blocks, threads>>>(\n pair_repr.data_ptr<float>(), ln_weight.data_ptr<float>(), ln_bias.data_ptr<float>(),\n W_query.data_ptr<float>(), W_key.data_ptr<float>(), W_value.data_ptr<float>(),\n W_gate.data_ptr<float>(), b_gate.data_ptr<float>(), W_bias.data_ptr<float>(),\n q.data_ptr<float>(), k.data_ptr<float>(), v.data_ptr<float>(),\n gate.data_ptr<float>(), bias.data_ptr<float>(),\n B, N, D, h, d\n );\n}\n\nvoid launch_attention(\n torch::Tensor q, torch::Tensor k, torch::Tensor v,\n torch::Tensor bias, torch::Tensor pair_mask,\n torch::Tensor scratch,\n int B, int N, int D, int h, int d\n) {\n int blocks = B * N * h;\n int threads = N;\n int smem = 2 * N * d * sizeof(float);\n attention_kernel<<<blocks, threads, smem>>>(\n q.data_ptr<float>(), k.data_ptr<float>(), v.data_ptr<float>(),\n bias.data_ptr<float>(), pair_mask.data_ptr<float>(),\n scratch.data_ptr<float>(),\n B, N, D, h, d\n );\n}\n\nvoid launch_finalize(\n torch::Tensor scratch, torch::Tensor gate,\n torch::Tensor W_out, torch::Tensor b_out,\n torch::Tensor output,\n int B, int N, int D\n) {\n int blocks = B * N * N;\n int threads = 64;\n finalize_kernel<<<blocks, threads>>>(\n scratch.data_ptr<float>(), gate.data_ptr<float>(),\n W_out.data_ptr<float>(), b_out.data_ptr<float>(),\n output.data_ptr<float>(),\n B, N, D\n );\n}\n\"\"\"\n\nclass ModelNew(nn.Module):\n def __init__(self, pair_dim, num_heads, dropout=0.0):\n super().__init__()\n self.num_heads = num_heads\n self.head_dim = pair_dim // num_heads\n \n self.layer_norm = nn.LayerNorm(pair_dim)\n self.query = nn.Linear(pair_dim, pair_dim, bias=False)\n self.key = nn.Linear(pair_dim, pair_dim, bias=False)\n self.value = nn.Linear(pair_dim, pair_dim, bias=False)\n self.gate = nn.Linear(pair_dim, pair_dim)\n self.bias_proj = nn.Linear(pair_dim, num_heads, bias=False)\n self.out_proj = nn.Linear(pair_dim, pair_dim)\n self.dropout = nn.Dropout(dropout)\n \n self.cuda_module = load_inline(\n name=\"evoformer_cuda\",\n cpp_sources=\"\",\n cuda_sources=CUDA_SOURCE,\n functions=[\"launch_preprocess\", \"launch_attention\", \"launch_finalize\"],\n extra_cuda_cflags=[\"-O3\", \"--use_fast_math\"],\n verbose=False,\n )\n \n def forward(self, pair_repr, pair_mask):\n B, N, _, D = pair_repr.shape\n h = self.num_heads\n d = self.head_dim\n \n pair_repr = pair_repr.contiguous()\n pair_mask = pair_mask.contiguous()\n \n device = pair_repr.device\n \n q = torch.empty(B * N, h, N, d, device=device, dtype=torch.float32)\n k = torch.empty(B * N, h, N, d, device=device, dtype=torch.float32)\n v = torch.empty(B * N, h, N, d, device=device, dtype=torch.float32)\n gate = torch.empty(B, N, N, D, device=device, dtype=torch.float32)\n bias = torch.empty(B, h, N, N, device=device, dtype=torch.float32)\n scratch = torch.empty(B, N, N, D, device=device, dtype=torch.float32)\n output = torch.empty(B, N, N, D, device=device, dtype=torch.float32)\n \n self.cuda_module.launch_preprocess(\n pair_repr,\n self.layer_norm.weight,\n self.layer_norm.bias,\n self.query.weight,\n self.key.weight,\n self.value.weight,\n self.gate.weight,\n self.gate.bias,\n self.bias_proj.weight,\n q, k, v, gate, bias,\n B, N, D, h, d\n )\n \n self.cuda_module.launch_attention(\n q, k, v, bias, pair_mask,\n scratch,\n B, N, D, h, d\n )\n \n self.cuda_module.launch_finalize(\n scratch, gate,\n self.out_proj.weight,\n self.out_proj.bias,\n output,\n B, N, D\n )\n \n return output\n" }
result ← compile_kernel (ok)
okcompile_kernel compile_kernel PASSED: kernel compiled without errors.
turn 2 · 1 tool call(s) · 16.5s turn · 16.5s LLM · 16,655 chars (click to expand)
turn 216.5s turn · 16.5s LLM · 0.0s tools
reasoning
Good, the kernel compiled. Now let me check correctness.
call → run_correctness
callrun_correctness { "kernel_code": "\nimport torch\nimport torch.nn as nn\nimport math\nfrom torch.utils.cpp_extension import load_inline\n\nCUDA_SOURCE = r\"\"\"\n#include <torch/extension.h>\n#include <cuda_runtime.h>\n#include <math.h>\n\n__global__ void preprocess_kernel(\n const float* __restrict__ pair_repr,\n const float* __restrict__ ln_weight,\n const float* __restrict__ ln_bias,\n const float* __restrict__ W_query,\n const float* __restrict__ W_key,\n const float* __restrict__ W_value,\n const float* __restrict__ W_gate,\n const float* __restrict__ b_gate,\n const float* __restrict__ W_bias,\n float* __restrict__ q,\n float* __restrict__ k,\n float* __restrict__ v,\n float* __restrict__ gate,\n float* __restrict__ bias,\n int B, int N, int D, int h, int d\n) {\n int pos = blockIdx.x;\n int tid = threadIdx.x;\n \n extern __shared__ float shared_mem[];\n float* x_shared = shared_mem;\n float* reduce_buf = shared_mem + 128;\n \n // Load input\n if (tid < D) {\n x_shared[tid] = pair_repr[pos * D + tid];\n }\n __syncthreads();\n \n // Compute mean\n if (tid < D) {\n reduce_buf[tid] = x_shared[tid];\n } else {\n reduce_buf[tid] = 0.0f;\n }\n __syncthreads();\n \n for (int stride = blockDim.x / 2; stride > 0; stride /= 2) {\n if (tid < stride) {\n reduce_buf[tid] += reduce_buf[tid + stride];\n }\n __syncthreads();\n }\n float mean = reduce_buf[0] / D;\n \n // Compute variance\n if (tid < D) {\n float diff = x_shared[tid] - mean;\n reduce_buf[tid] = diff * diff;\n } else {\n reduce_buf[tid] = 0.0f;\n }\n __syncthreads();\n \n for (int stride = blockDim.x / 2; stride > 0; stride /= 2) {\n if (tid < stride) {\n reduce_buf[tid] += reduce_buf[tid + stride];\n }\n __syncthreads();\n }\n float var = reduce_buf[0] / D;\n \n // Normalize\n if (tid < D) {\n x_shared[tid] = (x_shared[tid] - mean) * rsqrtf(var + 1e-5f) * ln_weight[tid] + ln_bias[tid];\n }\n __syncthreads();\n \n // Extract indices\n int b = pos / (N * N);\n int rem = pos % (N * N);\n int i = rem / N;\n int j = rem % N;\n int r = b * N + i;\n \n // Q projection\n for (int out_c = tid; out_c < D; out_c += blockDim.x) {\n float val = 0.0f;\n for (int c = 0; c < D; c++) {\n val += W_query[out_c * D + c] * x_shared[c];\n }\n int head = out_c / d;\n int dd = out_c % d;\n q[((r * h + head) * N + j) * d + dd] = val;\n }\n \n // K projection\n for (int out_c = tid; out_c < D; out_c += blockDim.x) {\n float val = 0.0f;\n for (int c = 0; c < D; c++) {\n val += W_key[out_c * D + c] * x_shared[c];\n }\n int head = out_c / d;\n int dd = out_c % d;\n k[((r * h + head) * N + j) * d + dd] = val;\n }\n \n // V projection\n for (int out_c = tid; out_c < D; out_c += blockDim.x) {\n float val = 0.0f;\n for (int c = 0; c < D; c++) {\n val += W_value[out_c * D + c] * x_shared[c];\n }\n int head = out_c / d;\n int dd = out_c % d;\n v[((r * h + head) * N + j) * d + dd] = val;\n }\n \n // Gate projection (with bias)\n for (int out_c = tid; out_c < D; out_c += blockDim.x) {\n float val = b_gate[out_c];\n for (int c = 0; c < D; c++) {\n val += W_gate[out_c * D + c] * x_shared[c];\n }\n gate[pos * D + out_c] = 1.0f / (1.0f + expf(-val));\n }\n \n // Bias projection (no bias)\n for (int out_c = tid; out_c < h; out_c += blockDim.x) {\n float val = 0.0f;\n for (int c = 0; c < D; c++) {\n val += W_bias[out_c * D + c] * x_shared[c];\n }\n bias[((b * h + out_c) * N + i) * N + j] = val;\n }\n}\n\n__global__ void attention_kernel(\n const float* __restrict__ q,\n const float* __restrict__ k,\n const float* __restrict__ v,\n const float* __restrict__ bias,\n const float* __restrict__ pair_mask,\n float* __restrict__ scratch,\n int B, int N, int D, int h, int d\n) {\n int b = blockIdx.x / (N * h);\n int rem = blockIdx.x % (N * h);\n int i = rem / h;\n int head = rem % h;\n int r = b * N + i;\n int query = threadIdx.x;\n \n if (query >= N) return;\n \n extern __shared__ float shared_mem2[];\n float* K_shared = shared_mem2;\n float* V_shared = shared_mem2 + N * d;\n \n // Load K and V of shape (N, d)\n for (int l = query; l < N * d; l += blockDim.x) {\n K_shared[l] = k[((r * h + head) * N) * d + l];\n V_shared[l] = v[((r * h + head) * N) * d + l];\n }\n __syncthreads();\n \n // Load Q\n float q_local[64];\n for (int dd = 0; dd < d; dd++) {\n q_local[dd] = q[((r * h + head) * N + query) * d + dd];\n }\n \n float scores[64];\n float max_score = -1e9f;\n \n for (int key = 0; key < N; key++) {\n float dot = 0.0f;\n for (int dd = 0; dd < d; dd++) {\n dot += q_local[dd] * K_shared[key * d + dd];\n }\n float score = dot / sqrtf((float)d);\n score += bias[((b * h + head) * N + i) * N + key];\n \n if (pair_mask[(b * N + i) * N + key] == 0.0f) {\n score = -1e9f;\n }\n \n scores[key] = score;\n if (score > max_score) max_score = score;\n }\n \n float sum_exp = 0.0f;\n for (int key = 0; key < N; key++) {\n scores[key] = expf(scores[key] - max_score);\n sum_exp += scores[key];\n }\n float inv_sum = 1.0f / sum_exp;\n for (int key = 0; key < N; key++) {\n scores[key] *= inv_sum;\n }\n \n float out_local[64];\n for (int dd = 0; dd < d; dd++) out_local[dd] = 0.0f;\n \n for (int key = 0; key < N; key++) {\n float s = scores[key];\n for (int dd = 0; dd < d; dd++) {\n out_local[dd] += s * V_shared[key * d + dd];\n }\n }\n \n for (int dd = 0; dd < d; dd++) {\n int out_idx = ((b * N + i) * N + query) * D + head * d + dd;\n scratch[out_idx] = out_local[dd];\n }\n}\n\n__global__ void finalize_kernel(\n const float* __restrict__ scratch,\n const float* __restrict__ gate,\n const float* __restrict__ W_out,\n const float* __restrict__ b_out,\n float* __restrict__ output,\n int B, int N, int D\n) {\n int pos = blockIdx.x;\n int tid = threadIdx.x;\n \n extern __shared__ float s3[];\n float* gated_shared = s3;\n \n if (tid < D) {\n gated_shared[tid] = scratch[pos * D + tid] * gate[pos * D + tid];\n }\n __syncthreads();\n \n for (int c = tid; c < D; c += blockDim.x) {\n float out_val = b_out[c];\n for (int d = 0; d < D; d++) {\n out_val += W_out[c * D + d] * gated_shared[d];\n }\n output[pos * D + c] = out_val;\n }\n}\n\nvoid launch_preprocess(\n torch::Tensor pair_repr, torch::Tensor ln_weight, torch::Tensor ln_bias,\n torch::Tensor W_query, torch::Tensor W_key, torch::Tensor W_value,\n torch::Tensor W_gate, torch::Tensor b_gate, torch::Tensor W_bias,\n torch::Tensor q, torch::Tensor k, torch::Tensor v,\n torch::Tensor gate, torch::Tensor bias,\n int B, int N, int D, int h, int d\n) {\n int total_pos = B * N * N;\n int smem = 256 * sizeof(float);\n preprocess_kernel<<<total_pos, 64, smem>>>(\n pair_repr.data_ptr<float>(), ln_weight.data_ptr<float>(), ln_bias.data_ptr<float>(),\n W_query.data_ptr<float>(), W_key.data_ptr<float>(), W_value.data_ptr<float>(),\n W_gate.data_ptr<float>(), b_gate.data_ptr<float>(), W_bias.data_ptr<float>(),\n q.data_ptr<float>(), k.data_ptr<float>(), v.data_ptr<float>(),\n gate.data_ptr<float>(), bias.data_ptr<float>(),\n B, N, D, h, d\n );\n}\n\nvoid launch_attention(\n torch::Tensor q, torch::Tensor k, torch::Tensor v,\n torch::Tensor bias, torch::Tensor pair_mask,\n torch::Tensor scratch,\n int B, int N, int D, int h, int d\n) {\n int blocks = B * N * h;\n int threads = N;\n int smem = 2 * N * d * sizeof(float);\n attention_kernel<<<blocks, threads, smem>>>(\n q.data_ptr<float>(), k.data_ptr<float>(), v.data_ptr<float>(),\n bias.data_ptr<float>(), pair_mask.data_ptr<float>(),\n scratch.data_ptr<float>(),\n B, N, D, h, d\n );\n}\n\nvoid launch_finalize(\n torch::Tensor scratch, torch::Tensor gate,\n torch::Tensor W_out, torch::Tensor b_out,\n torch::Tensor output,\n int B, int N, int D\n) {\n int blocks = B * N * N;\n int threads = 64;\n int smem = 128 * sizeof(float);\n finalize_kernel<<<blocks, threads, smem>>>(\n scratch.data_ptr<float>(), gate.data_ptr<float>(),\n W_out.data_ptr<float>(), b_out.data_ptr<float>(),\n output.data_ptr<float>(),\n B, N, D\n );\n}\n\"\"\"\n\nclass ModelNew(nn.Module):\n def __init__(self, pair_dim, num_heads, dropout=0.0):\n super().__init__()\n self.num_heads = num_heads\n self.head_dim = pair_dim // num_heads\n \n self.layer_norm = nn.LayerNorm(pair_dim)\n self.query = nn.Linear(pair_dim, pair_dim, bias=False)\n self.key = nn.Linear(pair_dim, pair_dim, bias=False)\n self.value = nn.Linear(pair_dim, pair_dim, bias=False)\n self.gate = nn.Linear(pair_dim, pair_dim)\n self.bias_proj = nn.Linear(pair_dim, num_heads, bias=False)\n self.out_proj = nn.Linear(pair_dim, pair_dim)\n self.dropout = nn.Dropout(dropout)\n \n self.cuda_module = load_inline(\n name=\"evoformer_cuda_v2\",\n cpp_sources=\"\",\n cuda_sources=CUDA_SOURCE,\n functions=[\"launch_preprocess\", \"launch_attention\", \"launch_finalize\"],\n extra_cuda_cflags=[\"-O3\", \"--use_fast_math\"],\n verbose=False,\n )\n \n def forward(self, pair_repr, pair_mask):\n B, N, _, D = pair_repr.shape\n h = self.num_heads\n d = self.head_dim\n \n pair_repr = pair_repr.contiguous()\n pair_mask = pair_mask.contiguous()\n \n device = pair_repr.device\n \n q = torch.empty(B * N, h, N, d, device=device, dtype=torch.float32)\n k = torch.empty(B * N, h, N, d, device=device, dtype=torch.float32)\n v = torch.empty(B * N, h, N, d, device=device, dtype=torch.float32)\n gate = torch.empty(B, N, N, D, device=device, dtype=torch.float32)\n bias = torch.empty(B, h, N, N, device=device, dtype=torch.float32)\n scratch = torch.empty(B, N, N, D, device=device, dtype=torch.float32)\n output = torch.empty(B, N, N, D, device=device, dtype=torch.float32)\n \n self.cuda_module.launch_preprocess(\n pair_repr,\n self.layer_norm.weight,\n self.layer_norm.bias,\n self.query.weight,\n self.key.weight,\n self.value.weight,\n self.gate.weight,\n self.gate.bias,\n self.bias_proj.weight,\n q, k, v, gate, bias,\n B, N, D, h, d\n )\n \n self.cuda_module.launch_attention(\n q, k, v, bias, pair_mask,\n scratch,\n B, N, D, h, d\n )\n \n self.cuda_module.launch_finalize(\n scratch, gate,\n self.out_proj.weight,\n self.out_proj.bias,\n output,\n B, N, D\n )\n \n return output\n" }
result ← run_correctness (fail)
failrun_correctness run_correctness FAILED: ? trials did not all match. runtime_error: Error building extension 'evoformer_cuda_v2': [1/3] /opt/conda/bin/x86_64-conda-linux-gnu-c++ -MMD -MF main.o.d -DTORCH_EXTENSION_NAME=evoformer_cuda_v2 -DTORCH_API_INCLUDE_EXTENSION_H -isystem /scratch/tejas/PopcornBench/.venv/lib/python3.10/site-packages/torch/include -isystem /scratch/tejas/PopcornBench/.venv/lib/python3.10/site-packages/torch/include/torch/csrc/api/include -isystem /usr/local/cuda/include -isystem /home/prabhune/.local/share/uv/python/cpython-3.10-linux-x86_64-gnu/include/python3.10 -fPIC -std=c++17 -c /scratch/tejas/PopcornBench/runs/l12_default/popcorn/Kimi-K2.6/level_2_problem_6_cache/k_a6951852d969/evoformer_cuda_v2/main.cpp -o main.o FAILED: [code=1] main.o /opt/conda/bin/x86_64-conda-linux-gnu-c++ -MMD -MF main.o.d -DTORCH_EXTENSION_NAME=evoformer_cuda_v2 -DTORCH_API_INCLUDE_EXTENSION_H -isystem /scratch/tejas/PopcornBench/.venv/lib/python3.10/site-packages/torch/include -isystem /scratch/tejas/PopcornBench/.venv/lib/python3.10/site-packages/torch/include/torch/csrc/api/include -isystem /usr/local/cuda/include -isystem /home/prabhune/.local/share/uv/python/cpython-3.10-linux-x86_64-gnu/include/python3.10 -fPIC -std=c++17 -c /scratch/tejas/PopcornBench/runs/l12_default/popcorn/Kimi-K2.6/level_2_problem_6_cache/k_a6951852d969/evoformer_cuda_v2/main.cpp -o main.o /scratch/tejas/PopcornBench/runs/l12_default/popcorn/Kimi-K2.6/level_2_problem_6_cache/k_a6951852d969/evoformer_cuda_v2/main.cpp: In function 'void pybind11_init_evoformer_cuda_v2(pybind11::module_&)': /scratch/tejas/PopcornBench/runs/l12_default/popcorn/Kimi-K2.6/level_2_problem_6_cache/k_a6951852d969/evoformer_cuda_v2/main.cpp:4:56: error: 'launch_preprocess' was not declared in this scope 4 | m.def("launch_preprocess", torch::wrap_pybind_function(launch_preprocess), "launch_preprocess"); | ^~~~~~~~~~~~~~~~~ /scratch/tejas/PopcornBench/runs/l12_default/popcorn/Kimi-K2.6/level_2_problem_6_cache/k_a6951852d969/evoformer_cuda_v2/main.cpp:5:55: error: 'launch_attention' was not declared in this scope 5 | m.def("launch_attention", torch::wrap_pybind_function(launch_attention), "launch_attention"); | ^~~~~~~~~~~~~~~~ /scratch/tejas/PopcornBench/runs/l12_default/popcorn/Kimi-K2.6/level_2_problem_6_cache/k_a6951852d969/evoformer_cuda_v2/main.cpp:6:54: error: 'launch_finalize' was not declared in this scope 6 | m.def("launch_finalize", torch::wrap_pybind_function(launch_finalize), "launch_finalize"); | ^~~~~~~~~~~~~~~ [2/3] /usr/local/cuda/bin/nvcc -MD -MF cuda.cuda.o.d -ccbin /opt/conda/bin/x86_64-conda-linux-gnu-cc -DTORCH_EXTENSION_NAME=evoformer_cuda_v2 -DTORCH_API_INCLUDE_EXTENSION_H -isystem /scratch/tejas/PopcornBench/.venv/lib/python3.10/site-packages/torch/include -isystem /scratch/tejas/PopcornBench/.venv/lib/python3.10/site-packages/torch/include/torch/csrc/api/include -isystem /usr/local/cuda/include -isystem /home/prabhune/.local/share/uv/python/cpython-3.10-linux-x86_64-gnu/include/python3.10 -D__CUDA_NO_HALF_OPERATORS__ -D__CUDA_NO_HALF_CONVERSIONS__ -D__CUDA_NO_BFLOAT16_CONVERSIONS__ -D__CUDA_NO_HALF2_OPERATORS__ --expt-relaxed-constexpr -gencode=arch=compute_90,code=compute_90 -gencode=arch=compute_90,code=sm_90 --compiler-options '-fPIC' -O3 --use_fast_math -std=c++17 -c /scratch/tejas/PopcornBench/runs/l12_default/popcorn/Kimi-K2.6/level_2_problem_6_cache/k_a6951852d969/evoformer_cuda_v2/cuda.cu -o cuda.cuda.o nvcc warning : incompatible redefinition for option 'compiler-bindir', the last value of this option was used ninja: build stopped: subcommand failed.
turn 3 · 1 tool call(s) · 15.6s turn · 15.6s LLM · 14,056 chars (click to expand)
turn 315.6s turn · 15.6s LLM · 0.0s tools
reasoning
The problem is that the kernel functions are declared after `main.cpp` is compiled, because `load_inline` generates the pybind11 module in C++. I need to make sure the function declarations are available. I can either: 1. Put declarations in `cpp_sources` 2. Or put all functions at global scope in `cuda_sources` The issue is that `cpp_sources=""` doesn't provide forward declarations. I need to add declarations in `cpp_sources`. Let me fix this by adding function declarations to `cpp_sources`.
call → compile_kernel
callcompile_kernel { "kernel_code": "\nimport torch\nimport torch.nn as nn\nimport math\nfrom torch.utils.cpp_extension import load_inline\n\nCUDA_SOURCE = r\"\"\"\n#include <torch/extension.h>\n#include <cuda_runtime.h>\n#include <math.h>\n\n__global__ void preprocess_kernel(\n const float* __restrict__ pair_repr,\n const float* __restrict__ ln_weight,\n const float* __restrict__ ln_bias,\n const float* __restrict__ W_query,\n const float* __restrict__ W_key,\n const float* __restrict__ W_value,\n const float* __restrict__ W_gate,\n const float* __restrict__ b_gate,\n const float* __restrict__ W_bias,\n float* __restrict__ q,\n float* __restrict__ k,\n float* __restrict__ v,\n float* __restrict__ gate,\n float* __restrict__ bias,\n int B, int N, int D, int h, int d\n) {\n int pos = blockIdx.x;\n int tid = threadIdx.x;\n \n extern __shared__ float shared_mem[];\n float* x_shared = shared_mem;\n float* reduce_buf = shared_mem + 128;\n \n // Load input\n if (tid < D) {\n x_shared[tid] = pair_repr[pos * D + tid];\n }\n __syncthreads();\n \n // Compute mean\n if (tid < D) {\n reduce_buf[tid] = x_shared[tid];\n } else {\n reduce_buf[tid] = 0.0f;\n }\n __syncthreads();\n \n for (int stride = blockDim.x / 2; stride > 0; stride /= 2) {\n if (tid < stride) {\n reduce_buf[tid] += reduce_buf[tid + stride];\n }\n __syncthreads();\n }\n float mean = reduce_buf[0] / D;\n \n // Compute variance\n if (tid < D) {\n float diff = x_shared[tid] - mean;\n reduce_buf[tid] = diff * diff;\n } else {\n reduce_buf[tid] = 0.0f;\n }\n __syncthreads();\n \n for (int stride = blockDim.x / 2; stride > 0; stride /= 2) {\n if (tid < stride) {\n reduce_buf[tid] += reduce_buf[tid + stride];\n }\n __syncthreads();\n }\n float var = reduce_buf[0] / D;\n \n // Normalize\n if (tid < D) {\n x_shared[tid] = (x_shared[tid] - mean) * rsqrtf(var + 1e-5f) * ln_weight[tid] + ln_bias[tid];\n }\n __syncthreads();\n \n // Extract indices\n int b = pos / (N * N);\n int rem = pos % (N * N);\n int i = rem / N;\n int j = rem % N;\n int r = b * N + i;\n \n // Q projection\n for (int out_c = tid; out_c < D; out_c += blockDim.x) {\n float val = 0.0f;\n for (int c = 0; c < D; c++) {\n val += W_query[out_c * D + c] * x_shared[c];\n }\n int head = out_c / d;\n int dd = out_c % d;\n q[((r * h + head) * N + j) * d + dd] = val;\n }\n \n // K projection\n for (int out_c = tid; out_c < D; out_c += blockDim.x) {\n float val = 0.0f;\n for (int c = 0; c < D; c++) {\n val += W_key[out_c * D + c] * x_shared[c];\n }\n int head = out_c / d;\n int dd = out_c % d;\n k[((r * h + head) * N + j) * d + dd] = val;\n }\n \n // V projection\n for (int out_c = tid; out_c < D; out_c += blockDim.x) {\n float val = 0.0f;\n for (int c = 0; c < D; c++) {\n val += W_value[out_c * D + c] * x_shared[c];\n }\n int head = out_c / d;\n int dd = out_c % d;\n v[((r * h + head) * N + j) * d + dd] = val;\n }\n \n // Gate projection (with bias)\n for (int out_c = tid; out_c < D; out_c += blockDim.x) {\n float val = b_gate[out_c];\n for (int c = 0; c < D; c++) {\n val += W_gate[out_c * D + c] * x_shared[c];\n }\n gate[pos * D + out_c] = 1.0f / (1.0f + expf(-val));\n }\n \n // Bias projection (no bias)\n for (int out_c = tid; out_c < h; out_c += blockDim.x) {\n float val = 0.0f;\n for (int c = 0; c < D; c++) {\n val += W_bias[out_c * D + c] * x_shared[c];\n }\n bias[((b * h + out_c) * N + i) * N + j] = val;\n }\n}\n\n__global__ void attention_kernel(\n const float* __restrict__ q,\n const float* __restrict__ k,\n const float* __restrict__ v,\n const float* __restrict__ bias,\n const float* __restrict__ pair_mask,\n float* __restrict__ scratch,\n int B, int N, int D, int h, int d\n) {\n int b = blockIdx.x / (N * h);\n int rem = blockIdx.x % (N * h);\n int i = rem / h;\n int head = rem % h;\n int r = b * N + i;\n int query = threadIdx.x;\n \n if (query >= N) return;\n \n extern __shared__ float shared_mem2[];\n float* K_shared = shared_mem2;\n float* V_shared = shared_mem2 + N * d;\n \n // Load K and V of shape (N, d)\n for (int l = query; l < N * d; l += blockDim.x) {\n K_shared[l] = k[((r * h + head) * N) * d + l];\n V_shared[l] = v[((r * h + head) * N) * d + l];\n }\n __syncthreads();\n \n // Load Q\n float q_local[64];\n for (int dd = 0; dd < d; dd++) {\n q_local[dd] = q[((r * h + head) * N + query) * d + dd];\n }\n \n float scores[64];\n float max_score = -1e9f;\n \n for (int key = 0; key < N; key++) {\n float dot = 0.0f;\n for (int dd = 0; dd < d; dd++) {\n dot += q_local[dd] * K_shared[key * d + dd];\n }\n float score = dot / sqrtf((float)d);\n score += bias[((b * h + head) * N + i) * N + key];\n \n if (pair_mask[(b * N + i) * N + key] == 0.0f) {\n score = -1e9f;\n }\n \n scores[key] = score;\n if (score > max_score) max_score = score;\n }\n \n float sum_exp = 0.0f;\n for (int key = 0; key < N; key++) {\n scores[key] = expf(scores[key] - max_score);\n sum_exp += scores[key];\n }\n float inv_sum = 1.0f / sum_exp;\n for (int key = 0; key < N; key++) {\n scores[key] *= inv_sum;\n }\n \n float out_local[64];\n for (int dd = 0; dd < d; dd++) out_local[dd] = 0.0f;\n \n for (int key = 0; key < N; key++) {\n float s = scores[key];\n for (int dd = 0; dd < d; dd++) {\n out_local[dd] += s * V_shared[key * d + dd];\n }\n }\n \n for (int dd = 0; dd < d; dd++) {\n int out_idx = ((b * N + i) * N + query) * D + head * d + dd;\n scratch[out_idx] = out_local[dd];\n }\n}\n\n__global__ void finalize_kernel(\n const float* __restrict__ scratch,\n const float* __restrict__ gate,\n const float* __restrict__ W_out,\n const float* __restrict__ b_out,\n float* __restrict__ output,\n int B, int N, int D\n) {\n int pos = blockIdx.x;\n int tid = threadIdx.x;\n \n extern __shared__ float s3[];\n float* gated_shared = s3;\n \n if (tid < D) {\n gated_shared[tid] = scratch[pos * D + tid] * gate[pos * D + tid];\n }\n __syncthreads();\n \n for (int c = tid; c < D; c += blockDim.x) {\n float out_val = b_out[c];\n for (int d = 0; d < D; d++) {\n out_val += W_out[c * D + d] * gated_shared[d];\n }\n output[pos * D + c] = out_val;\n }\n}\n\nvoid launch_preprocess(\n torch::Tensor pair_repr, torch::Tensor ln_weight, torch::Tensor ln_bias,\n torch::Tensor W_query, torch::Tensor W_key, torch::Tensor W_value,\n torch::Tensor W_gate, torch::Tensor b_gate, torch::Tensor W_bias,\n torch::Tensor q, torch::Tensor k, torch::Tensor v,\n torch::Tensor gate, torch::Tensor bias,\n int B, int N, int D, int h, int d\n) {\n int total_pos = B * N * N;\n int smem = 256 * sizeof(float);\n preprocess_kernel<<<total_pos, 64, smem>>>(\n pair_repr.data_ptr<float>(), ln_weight.data_ptr<float>(), ln_bias.data_ptr<float>(),\n W_query.data_ptr<float>(), W_key.data_ptr<float>(), W_value.data_ptr<float>(),\n W_gate.data_ptr<float>(), b_gate.data_ptr<float>(), W_bias.data_ptr<float>(),\n q.data_ptr<float>(), k.data_ptr<float>(), v.data_ptr<float>(),\n gate.data_ptr<float>(), bias.data_ptr<float>(),\n B, N, D, h, d\n );\n}\n\nvoid launch_attention(\n torch::Tensor q, torch::Tensor k, torch::Tensor v,\n torch::Tensor bias, torch::Tensor pair_mask,\n torch::Tensor scratch,\n int B, int N, int D, int h, int d\n) {\n int blocks = B * N * h;\n int threads = N;\n int smem = 2 * N * d * sizeof(float);\n attention_kernel<<<blocks, threads, smem>>>(\n q.data_ptr<float>(), k.data_ptr<float>(), v.data_ptr<float>(),\n bias.data_ptr<float>(), pair_mask.data_ptr<float>(),\n scratch.data_ptr<float>(),\n B, N, D, h, d\n );\n}\n\nvoid launch_finalize(\n torch::Tensor scratch, torch::Tensor gate,\n torch::Tensor W_out, torch::Tensor b_out,\n torch::Tensor output,\n int B, int N, int D\n) {\n int blocks = B * N * N;\n int threads = 64;\n int smem = 128 * sizeof(float);\n finalize_kernel<<<blocks, threads, smem>>>(\n scratch.data_ptr<float>(), gate.data_ptr<float>(),\n W_out.data_ptr<float>(), b_out.data_ptr<float>(),\n output.data_ptr<float>(),\n B, N, D\n );\n}\n\"\"\"\n\nCPP_SOURCE = r\"\"\"\nvoid launch_preprocess(\n torch::Tensor pair_repr, torch::Tensor ln_weight, torch::Tensor ln_bias,\n torch::Tensor W_query, torch::Tensor W_key, torch::Tensor W_value,\n torch::Tensor W_gate, torch::Tensor b_gate, torch::Tensor W_bias,\n torch::Tensor q, torch::Tensor k, torch::Tensor v,\n torch::Tensor gate, torch::Tensor bias,\n int B, int N, int D, int h, int d);\n\nvoid launch_attention(\n torch::Tensor q, torch::Tensor k, torch::Tensor v,\n torch::Tensor bias, torch::Tensor pair_mask,\n torch::Tensor scratch,\n int B, int N, int D, int h, int d);\n\nvoid launch_finalize(\n torch::Tensor scratch, torch::Tensor gate,\n torch::Tensor W_out, torch::Tensor b_out,\n torch::Tensor output,\n int B, int N, int D);\n\"\"\"\n\nclass ModelNew(nn.Module):\n def __init__(self, pair_dim, num_heads, dropout=0.0):\n super().__init__()\n self.num_heads = num_heads\n self.head_dim = pair_dim // num_heads\n \n self.layer_norm = nn.LayerNorm(pair_dim)\n self.query = nn.Linear(pair_dim, pair_dim, bias=False)\n self.key = nn.Linear(pair_dim, pair_dim, bias=False)\n self.value = nn.Linear(pair_dim, pair_dim, bias=False)\n self.gate = nn.Linear(pair_dim, pair_dim)\n self.bias_proj = nn.Linear(pair_dim, num_heads, bias=False)\n self.out_proj = nn.Linear(pair_dim, pair_dim)\n self.dropout = nn.Dropout(dropout)\n \n self.cuda_module = load_inline(\n name=\"evoformer_cuda_v2\",\n cpp_sources=CPP_SOURCE,\n cuda_sources=CUDA_SOURCE,\n functions=[\"launch_preprocess\", \"launch_attention\", \"launch_finalize\"],\n extra_cuda_cflags=[\"-O3\", \"--use_fast_math\"],\n verbose=False,\n )\n \n def forward(self, pair_repr, pair_mask):\n B, N, _, D = pair_repr.shape\n h = self.num_heads\n d = self.head_dim\n \n pair_repr = pair_repr.contiguous()\n pair_mask = pair_mask.contiguous()\n \n device = pair_repr.device\n \n q = torch.empty(B * N, h, N, d, device=device, dtype=torch.float32)\n k = torch.empty(B * N, h, N, d, device=device, dtype=torch.float32)\n v = torch.empty(B * N, h, N, d, device=device, dtype=torch.float32)\n gate = torch.empty(B, N, N, D, device=device, dtype=torch.float32)\n bias = torch.empty(B, h, N, N, device=device, dtype=torch.float32)\n scratch = torch.empty(B, N, N, D, device=device, dtype=torch.float32)\n output = torch.empty(B, N, N, D, device=device, dtype=torch.float32)\n \n self.cuda_module.launch_preprocess(\n pair_repr,\n self.layer_norm.weight,\n self.layer_norm.bias,\n self.query.weight,\n self.key.weight,\n self.value.weight,\n self.gate.weight,\n self.gate.bias,\n self.bias_proj.weight,\n q, k, v, gate, bias,\n B, N, D, h, d\n )\n \n self.cuda_module.launch_attention(\n q, k, v, bias, pair_mask,\n scratch,\n B, N, D, h, d\n )\n \n self.cuda_module.launch_finalize(\n scratch, gate,\n self.out_proj.weight,\n self.out_proj.bias,\n output,\n B, N, D\n )\n \n return output\n" }
result ← compile_kernel (ok)
okcompile_kernel compile_kernel PASSED: kernel compiled without errors.
turn 4 · 1 tool call(s) · 37.1s turn · 37.1s LLM · 14,773 chars (click to expand)
turn 437.1s turn · 37.1s LLM · 0.0s tools
reasoning
The compilation passed. Now let me run correctness check. The issue is that the kernel names are declared in the cuda source and the cpp source. The cpp source needs to declare the functions (without definitions), and then the cuda code defines them. Let me check if the compilation passed and if so, run correctness. Wait, I see `cpp_sources=CPP_SOURCE` which declares the functions, and `cuda_sources=CUDA_SOURCE` which both declares and defines them. But the functions are being declared twice. Let me see if this causes issues. Actually torch's load_inline puts both in the same compilation unit, so the function declarations in the cpp source and definitions in cuda source should work fine. Actually wait - the cpp_sources doesn't include `#include <torch/extension.h>`, and since the functions take torch::Tensor types, they need that. But the header is auto-included. Let me just run correctness.
call → run_correctness
callrun_correctness { "kernel_code": "\nimport torch\nimport torch.nn as nn\nimport math\nfrom torch.utils.cpp_extension import load_inline\n\nCUDA_SOURCE = r\"\"\"\n#include <torch/extension.h>\n#include <cuda_runtime.h>\n#include <math.h>\n\n__global__ void preprocess_kernel(\n const float* __restrict__ pair_repr,\n const float* __restrict__ ln_weight,\n const float* __restrict__ ln_bias,\n const float* __restrict__ W_query,\n const float* __restrict__ W_key,\n const float* __restrict__ W_value,\n const float* __restrict__ W_gate,\n const float* __restrict__ b_gate,\n const float* __restrict__ W_bias,\n float* __restrict__ q,\n float* __restrict__ k,\n float* __restrict__ v,\n float* __restrict__ gate,\n float* __restrict__ bias,\n int B, int N, int D, int h, int d\n) {\n int pos = blockIdx.x;\n int tid = threadIdx.x;\n \n extern __shared__ float shared_mem[];\n float* x_shared = shared_mem;\n float* reduce_buf = shared_mem + 128;\n \n // Load input\n if (tid < D) {\n x_shared[tid] = pair_repr[pos * D + tid];\n }\n __syncthreads();\n \n // Compute mean\n if (tid < D) {\n reduce_buf[tid] = x_shared[tid];\n } else {\n reduce_buf[tid] = 0.0f;\n }\n __syncthreads();\n \n for (int stride = blockDim.x / 2; stride > 0; stride /= 2) {\n if (tid < stride) {\n reduce_buf[tid] += reduce_buf[tid + stride];\n }\n __syncthreads();\n }\n float mean = reduce_buf[0] / D;\n \n // Compute variance\n if (tid < D) {\n float diff = x_shared[tid] - mean;\n reduce_buf[tid] = diff * diff;\n } else {\n reduce_buf[tid] = 0.0f;\n }\n __syncthreads();\n \n for (int stride = blockDim.x / 2; stride > 0; stride /= 2) {\n if (tid < stride) {\n reduce_buf[tid] += reduce_buf[tid + stride];\n }\n __syncthreads();\n }\n float var = reduce_buf[0] / D;\n \n // Normalize\n if (tid < D) {\n x_shared[tid] = (x_shared[tid] - mean) * rsqrtf(var + 1e-5f) * ln_weight[tid] + ln_bias[tid];\n }\n __syncthreads();\n \n // Extract indices\n int b = pos / (N * N);\n int rem = pos % (N * N);\n int i = rem / N;\n int j = rem % N;\n int r = b * N + i;\n \n // Q projection\n for (int out_c = tid; out_c < D; out_c += blockDim.x) {\n float val = 0.0f;\n for (int c = 0; c < D; c++) {\n val += W_query[out_c * D + c] * x_shared[c];\n }\n int head = out_c / d;\n int dd = out_c % d;\n q[((r * h + head) * N + j) * d + dd] = val;\n }\n \n // K projection\n for (int out_c = tid; out_c < D; out_c += blockDim.x) {\n float val = 0.0f;\n for (int c = 0; c < D; c++) {\n val += W_key[out_c * D + c] * x_shared[c];\n }\n int head = out_c / d;\n int dd = out_c % d;\n k[((r * h + head) * N + j) * d + dd] = val;\n }\n \n // V projection\n for (int out_c = tid; out_c < D; out_c += blockDim.x) {\n float val = 0.0f;\n for (int c = 0; c < D; c++) {\n val += W_value[out_c * D + c] * x_shared[c];\n }\n int head = out_c / d;\n int dd = out_c % d;\n v[((r * h + head) * N + j) * d + dd] = val;\n }\n \n // Gate projection (with bias)\n for (int out_c = tid; out_c < D; out_c += blockDim.x) {\n float val = b_gate[out_c];\n for (int c = 0; c < D; c++) {\n val += W_gate[out_c * D + c] * x_shared[c];\n }\n gate[pos * D + out_c] = 1.0f / (1.0f + expf(-val));\n }\n \n // Bias projection (no bias)\n for (int out_c = tid; out_c < h; out_c += blockDim.x) {\n float val = 0.0f;\n for (int c = 0; c < D; c++) {\n val += W_bias[out_c * D + c] * x_shared[c];\n }\n bias[((b * h + out_c) * N + i) * N + j] = val;\n }\n}\n\n__global__ void attention_kernel(\n const float* __restrict__ q,\n const float* __restrict__ k,\n const float* __restrict__ v,\n const float* __restrict__ bias,\n const float* __restrict__ pair_mask,\n float* __restrict__ scratch,\n int B, int N, int D, int h, int d\n) {\n int b = blockIdx.x / (N * h);\n int rem = blockIdx.x % (N * h);\n int i = rem / h;\n int head = rem % h;\n int r = b * N + i;\n int query = threadIdx.x;\n \n if (query >= N) return;\n \n extern __shared__ float shared_mem2[];\n float* K_shared = shared_mem2;\n float* V_shared = shared_mem2 + N * d;\n \n // Load K and V of shape (N, d)\n for (int l = query; l < N * d; l += blockDim.x) {\n K_shared[l] = k[((r * h + head) * N) * d + l];\n V_shared[l] = v[((r * h + head) * N) * d + l];\n }\n __syncthreads();\n \n // Load Q\n float q_local[64];\n for (int dd = 0; dd < d; dd++) {\n q_local[dd] = q[((r * h + head) * N + query) * d + dd];\n }\n \n float scores[64];\n float max_score = -1e9f;\n \n for (int key = 0; key < N; key++) {\n float dot = 0.0f;\n for (int dd = 0; dd < d; dd++) {\n dot += q_local[dd] * K_shared[key * d + dd];\n }\n float score = dot / sqrtf((float)d);\n score += bias[((b * h + head) * N + i) * N + key];\n \n if (pair_mask[(b * N + i) * N + key] == 0.0f) {\n score = -1e9f;\n }\n \n scores[key] = score;\n if (score > max_score) max_score = score;\n }\n \n float sum_exp = 0.0f;\n for (int key = 0; key < N; key++) {\n scores[key] = expf(scores[key] - max_score);\n sum_exp += scores[key];\n }\n float inv_sum = 1.0f / sum_exp;\n for (int key = 0; key < N; key++) {\n scores[key] *= inv_sum;\n }\n \n float out_local[64];\n for (int dd = 0; dd < d; dd++) out_local[dd] = 0.0f;\n \n for (int key = 0; key < N; key++) {\n float s = scores[key];\n for (int dd = 0; dd < d; dd++) {\n out_local[dd] += s * V_shared[key * d + dd];\n }\n }\n \n for (int dd = 0; dd < d; dd++) {\n int out_idx = ((b * N + i) * N + query) * D + head * d + dd;\n scratch[out_idx] = out_local[dd];\n }\n}\n\n__global__ void finalize_kernel(\n const float* __restrict__ scratch,\n const float* __restrict__ gate,\n const float* __restrict__ W_out,\n const float* __restrict__ b_out,\n float* __restrict__ output,\n int B, int N, int D\n) {\n int pos = blockIdx.x;\n int tid = threadIdx.x;\n \n extern __shared__ float s3[];\n float* gated_shared = s3;\n \n if (tid < D) {\n gated_shared[tid] = scratch[pos * D + tid] * gate[pos * D + tid];\n }\n __syncthreads();\n \n for (int c = tid; c < D; c += blockDim.x) {\n float out_val = b_out[c];\n for (int d = 0; d < D; d++) {\n out_val += W_out[c * D + d] * gated_shared[d];\n }\n output[pos * D + c] = out_val;\n }\n}\n\nvoid launch_preprocess(\n torch::Tensor pair_repr, torch::Tensor ln_weight, torch::Tensor ln_bias,\n torch::Tensor W_query, torch::Tensor W_key, torch::Tensor W_value,\n torch::Tensor W_gate, torch::Tensor b_gate, torch::Tensor W_bias,\n torch::Tensor q, torch::Tensor k, torch::Tensor v,\n torch::Tensor gate, torch::Tensor bias,\n int B, int N, int D, int h, int d\n) {\n int total_pos = B * N * N;\n int smem = 256 * sizeof(float);\n preprocess_kernel<<<total_pos, 64, smem>>>(\n pair_repr.data_ptr<float>(), ln_weight.data_ptr<float>(), ln_bias.data_ptr<float>(),\n W_query.data_ptr<float>(), W_key.data_ptr<float>(), W_value.data_ptr<float>(),\n W_gate.data_ptr<float>(), b_gate.data_ptr<float>(), W_bias.data_ptr<float>(),\n q.data_ptr<float>(), k.data_ptr<float>(), v.data_ptr<float>(),\n gate.data_ptr<float>(), bias.data_ptr<float>(),\n B, N, D, h, d\n );\n}\n\nvoid launch_attention(\n torch::Tensor q, torch::Tensor k, torch::Tensor v,\n torch::Tensor bias, torch::Tensor pair_mask,\n torch::Tensor scratch,\n int B, int N, int D, int h, int d\n) {\n int blocks = B * N * h;\n int threads = N;\n int smem = 2 * N * d * sizeof(float);\n attention_kernel<<<blocks, threads, smem>>>(\n q.data_ptr<float>(), k.data_ptr<float>(), v.data_ptr<float>(),\n bias.data_ptr<float>(), pair_mask.data_ptr<float>(),\n scratch.data_ptr<float>(),\n B, N, D, h, d\n );\n}\n\nvoid launch_finalize(\n torch::Tensor scratch, torch::Tensor gate,\n torch::Tensor W_out, torch::Tensor b_out,\n torch::Tensor output,\n int B, int N, int D\n) {\n int blocks = B * N * N;\n int threads = 64;\n int smem = 128 * sizeof(float);\n finalize_kernel<<<blocks, threads, smem>>>(\n scratch.data_ptr<float>(), gate.data_ptr<float>(),\n W_out.data_ptr<float>(), b_out.data_ptr<float>(),\n output.data_ptr<float>(),\n B, N, D\n );\n}\n\"\"\"\n\nCPP_SOURCE = r\"\"\"\nvoid launch_preprocess(\n torch::Tensor pair_repr, torch::Tensor ln_weight, torch::Tensor ln_bias,\n torch::Tensor W_query, torch::Tensor W_key, torch::Tensor W_value,\n torch::Tensor W_gate, torch::Tensor b_gate, torch::Tensor W_bias,\n torch::Tensor q, torch::Tensor k, torch::Tensor v,\n torch::Tensor gate, torch::Tensor bias,\n int B, int N, int D, int h, int d);\n\nvoid launch_attention(\n torch::Tensor q, torch::Tensor k, torch::Tensor v,\n torch::Tensor bias, torch::Tensor pair_mask,\n torch::Tensor scratch,\n int B, int N, int D, int h, int d);\n\nvoid launch_finalize(\n torch::Tensor scratch, torch::Tensor gate,\n torch::Tensor W_out, torch::Tensor b_out,\n torch::Tensor output,\n int B, int N, int D);\n\"\"\"\n\nclass ModelNew(nn.Module):\n def __init__(self, pair_dim, num_heads, dropout=0.0):\n super().__init__()\n self.num_heads = num_heads\n self.head_dim = pair_dim // num_heads\n \n self.layer_norm = nn.LayerNorm(pair_dim)\n self.query = nn.Linear(pair_dim, pair_dim, bias=False)\n self.key = nn.Linear(pair_dim, pair_dim, bias=False)\n self.value = nn.Linear(pair_dim, pair_dim, bias=False)\n self.gate = nn.Linear(pair_dim, pair_dim)\n self.bias_proj = nn.Linear(pair_dim, num_heads, bias=False)\n self.out_proj = nn.Linear(pair_dim, pair_dim)\n self.dropout = nn.Dropout(dropout)\n \n self.cuda_module = load_inline(\n name=\"evoformer_cuda_v2\",\n cpp_sources=CPP_SOURCE,\n cuda_sources=CUDA_SOURCE,\n functions=[\"launch_preprocess\", \"launch_attention\", \"launch_finalize\"],\n extra_cuda_cflags=[\"-O3\", \"--use_fast_math\"],\n verbose=False,\n )\n \n def forward(self, pair_repr, pair_mask):\n B, N, _, D = pair_repr.shape\n h = self.num_heads\n d = self.head_dim\n \n pair_repr = pair_repr.contiguous()\n pair_mask = pair_mask.contiguous()\n \n device = pair_repr.device\n \n q = torch.empty(B * N, h, N, d, device=device, dtype=torch.float32)\n k = torch.empty(B * N, h, N, d, device=device, dtype=torch.float32)\n v = torch.empty(B * N, h, N, d, device=device, dtype=torch.float32)\n gate = torch.empty(B, N, N, D, device=device, dtype=torch.float32)\n bias = torch.empty(B, h, N, N, device=device, dtype=torch.float32)\n scratch = torch.empty(B, N, N, D, device=device, dtype=torch.float32)\n output = torch.empty(B, N, N, D, device=device, dtype=torch.float32)\n \n self.cuda_module.launch_preprocess(\n pair_repr,\n self.layer_norm.weight,\n self.layer_norm.bias,\n self.query.weight,\n self.key.weight,\n self.value.weight,\n self.gate.weight,\n self.gate.bias,\n self.bias_proj.weight,\n q, k, v, gate, bias,\n B, N, D, h, d\n )\n \n self.cuda_module.launch_attention(\n q, k, v, bias, pair_mask,\n scratch,\n B, N, D, h, d\n )\n \n self.cuda_module.launch_finalize(\n scratch, gate,\n self.out_proj.weight,\n self.out_proj.bias,\n output,\n B, N, D\n )\n \n return output\n" }
result ← run_correctness (fail)
failrun_correctness run_correctness FAILED: (0 / 5) trials did not all match. correctness_issue: Output mismatch max_difference: ['0.164957', '0.171113', '0.168476', '0.154041', '0.149020'] avg_difference: ['0.022385', '0.023177', '0.023370', '0.022931', '0.022882']
turn 5 · 1 tool call(s) · 33.3s turn · 33.3s LLM · 23,423 chars (click to expand)
turn 533.3s turn · 33.3s LLM · 0.0s tools
reasoning
The issue is that I'm incorrectly indexing the bias projection. The bias should be associated with the pair (i,j) to be used as a shared bias across queries. Let me trace through the reference implementation: ```python bias = self.bias_proj(x).permute(0, 3, 1, 2) # (B, h, N, N) ``` So bias[b, h, i, j] = bias_proj(x[b, i, j]). In my code, I'm computing bias at position (b, h, i, j), which is correct. But then in the attention kernel: ```python score += bias[((b * h + head) * N + i) * N + key]; # This is wrong! ``` The `key` dimension should be `j` but in the reference, the bias is added to `attn.view(B, N, h, N, N)` as `bias.unsqueeze(1)` which becomes (B, 1, h, N, N). So for `attn[b, row, head, i, j]`, the bias is `bias[b, head, i, j]`. But wait, in my attention kernel, each block handles `(b, i, head)` and `query` is `j` (the key index). No wait, "query" in my attention kernel is actually the column index in the attention (the key dimension is the LAST dimension). Let me re-examine the reference: ```python q = self.query(x_flat).view(B * N, N, h, d).transpose(1, 2) # (B*N, h, N, d) attn = (q @ k.transpose(-1, -2)) / math.sqrt(d) # (B*N, h, N, N) ``` Here q has shape (B*N, h, N, d) where B*N = B rows * N queries per row. For each row = b*N + i, the q and k are: - q[row, head, j, :] -> query at column j - k[row, head, j, :] -> key at column j So attn[row, head, query_j, key_j] = q[row, head, query_j] · k[row, head, key_j] And bias has shape (B, h, N, N), where bias[b, head, i, j] = bias_proj(x[b, i, j]). Wait, `attn = attn.view(B, N, h, N, N)`: - attn[b, i, head, query_j, key_j] And `attn = attn + bias.unsqueeze(1)` where `bias.unsqueeze(1)` has shape (B, 1, h, N, N) = (B, 1, h, i, j) But wait, the original bias is indexed by `(b, h, i, j)` from `x[b, i, j]`. When broadcasting: `bias.unsqueeze(1)` has shape (B, 1, h, N, N), and it's added to attn of shape (B, N, h, N, N). So `attn[b, row, head, query_j, key_j] += bias[b, head, query_j, key_j]`. Ah, I see the issue! The bias from `bias_proj(x).permute(0, 3, 1, 2)` has shape (B, h, N, N), and the last two dims are (i, j) where i is the row and j is the column. In the reference: `attn = attn + bias.unsqueeze(1)` and attn has shape (B, N, h, N, N). bias.unsqueeze(1) has shape (B, 1, h, N, N), so it broadcasts across the second dimension. So `attn[b, row, head, query_j, key_j] += bias[b, head, query_j, key_j]`. Wait, that's `bias[b, head, query_j, key_j]`. Let me think more carefully. Actually, `bias = self.bias_proj(x).permute(0, 3, 1, 2)`. The original x is (B, N, N, D). bias_proj(x) gives (B, N, N, h), and permute(0, 3, 1, 2) gives (B, h, N, N). So `bias[b, head, i, j]` is the bias for `x[b, i, j]`. Then `attn = attn.view(B, N, h, N, N)` means: - attn[b, i, head, query_j, key_j] ... And `attn = attn + bias.unsqueeze(1)` means: - attn[b, i, head, query_j, key_j] += bias[b, head, query_j, key_j] Wait no, `bias.unsqueeze(1)` adds a dimension at position 1, so it becomes (B, 1, h, N, N). When broadcasting with (B, N, h, N, N): - The 1 is broadcast to N, so `attn[b, i, head, query_j, key_j] += bias[b, head, query_j, key_j]`. Hmm wait, `bias` is (B, h, N, N) = (B, h, row_index, col_index). So `bias[b, head, query_j, key_j]` is the bias value from `x[b, query_j, key_j]`. But where did `i` (the row of the attention) go? It seems like the bias is indexed by (query_j, key_j) not by i. OH WAIT. Let me re-read the reference code more carefully: ```python # Reshape to (B*N, N, D) — attention across columns for each row x_flat = x.reshape(B * N, N, D) q = self.query(x_flat).view(B * N, N, h, d).transpose(1, 2) # (B*N, h, N, d) ... attn = (q @ k.transpose(-1, -2)) / math.sqrt(d) # (B*N, h, N, N) attn = attn.view(B, N, h, N, N) # Add pair bias: bias (B, h, N, N) → (B, 1, h, N, N) broadcast across rows attn = attn + bias.unsqueeze(1) ``` `q @ k.transpose` gives (B*N, h, N, N) where for each flat row r=b*N+i, q[r, head, :, :] and k[r, head, :, :]. So after reshape to (B, N, h, N, N): - First N = b*N + i (the row of x_flat) - Second N = query_j (query index) - Third N = key_j (key index) And `bias = self.bias_proj(x).permute(0, 3, 1, 2)` has shape (B, h, N, N) where N=query_j, N=key_j. So `bias.unsqueeze(1)` is (B, 1, h, N, N). When broadcasting: `attn[b, i, head, query_j, key_j] += bias[b, head, query_j, key_j]`. So the bias does NOT depend on the row `i` of the attention; it only depends on `query_j` and `key_j`. This is a bug in my code. I'm computing: ```c bias[((b * h + out_c) * N + i) * N + j] ``` where `i` is the row of the attention block, but it should be the column/query index. In my attention kernel: - `i` in my code is `blockIdx.x` part, the row of the attention block. - `query` is the thread index, which represents j (the query column). And I'm using: ```c bias[((b * h + head) * N + i) * N + key] ``` where `i` is the attention row. But it should be indexed by `query` (the querying column), not `i`. Let me also fix my preprocess kernel. The bias_proj should store `bias[b, head, query_j, key_j]`, which means `j` should NOT be the row i. In my preprocess kernel, `pos = b*N*N + i*N + j`, where i and j correspond to the original pair indices. For bias_proj at position (b, i, j), the output should go to `bias[b, head, i, j]` (since the bias is indexed by the same (query_j, key_j) coordinates as the pair representation). Wait, actually that's: - For the pair at (b, row_i, col_j), the bias is `bias_proj(x[b, row_i, col_j])`. - This bias contributes to `attn[b, any_i, head, row_i, col_j]`. So the bias IS indexed by (row_i, col_j) of the original pair, not by the attention row. In my preprocess: ```c int b = pos / (N * N); int rem = pos % (N * N); int i = rem / N; // This is the FIRST N (row_i) int j = rem % N; // This is the SECOND N (col_j) ... bias[((b * h + out_c) * N + i) * N + j] = val; ``` This stores the bias at (b, head, i, j), which is correct for the bias array. But in the attention kernel, I'm doing: ```c score += bias[((b * h + head) * N + i) * N + key]; // WRONG ``` This should be `bias[((b * h + head) * N + query) * N + key]`, because query corresponds to the column j (the key dimension in the attention), not `i` which is the attention row. Wait, let me verify. In the reference: `attn[b, attention_row, head, query_j, key_j] += bias[b, head, query_j, key_j]`. So `query_j` is the query position in the attention matrix (which is the column index of the pair at position (attention_row, query_j)? No wait... Actually, for `x_flat = x.reshape(B * N, N, D)`: x_flat has shape (B*N, N, D). For each row r = b*N + i, the N columns are x[b, i, j] for j in 0..N-1. Then q[r, head, j, :] = query projection of x_flat[r, j, :] = query of x[b, i, j]. And k[r, head, j, :] = key projection of x_flat[r, j, :] = key of x[b, i, j]. So `attn[r, head, q_j, k_j] = q[r, head, q_j] · k[r, head, k_j]`. Then `attn.view(B, N, h, N, N)` gives `attn[b, i, head, q_j, k_j]`. And the bias from `bias = self.bias_proj(x).permute(0, 3, 1, 2)`: - `bias[b, head, i, j]` = bias_proj of `x[b, i, j]`. And `attn = attn + bias.unsqueeze(1)`: `attn[b, attention_i, head, q_j, k_j] += bias[b, head, q_j, k_j]`. So the bias from position `x[b, q_j, k_j]` is added to ALL attention rows i. This makes sense because it's "pair bias". So in my attention kernel: ```c score += bias[((b * h + head) * N + query) * N + key]; ``` where `query` is the q_j (query index) and `key` is the key_j. In my kernel, `query` = threadIdx.x, and `key` is the loop variable. That should be correct now. Let me also fix another thing: the mask. The mask should be applied to the key dimension: `pair_mask[(b * N + i) * N + key]` Wait, but in the reference: ```python mask = pair_mask.unsqueeze(1).unsqueeze(2) # (B, 1, 1, N, N) attn = attn.masked_fill(mask == 0, float("-inf")) ``` And `attn` has shape (B, N, h, N, N). The mask has shape (B, 1, 1, N, N), which broadcasts to: `attn[b, i, head, q_j, k_j]` is masked by `mask[b, 0, 0, q_j, k_j]`. So the mask is indexed by (q_j, k_j), NOT by the attention row i. So my mask check should be: `if (pair_mask[(b * N + query) * N + key] == 0.0f)`. OK so I need to fix both the bias indexing and mask indexing in the attention kernel. Let me also look at the preprocess again. I'm computing: - q[r, head, j, :] where r = b*N + i and j is the column. - For the attention row i, the q and k arrays should have all N columns. So the preprocessing of q, k, v should store them with coordinates `((r * h + head) * N + j) * d + dd`, where r = b*N + i. This looks correct. Let me rewrite the kernel with these fixes. Actually, I realize there's another subtlety. In the reference, `attn.view(B, N, h, N, N)`: - The first N is the row index of x_flat, i.e., r = b*N + i where i is the row. - The second N is the query position q_j. - The third N is the key position k_j. And `bias.unsqueeze(1)` has shape (B, 1, h, N, N) which broadcasts over the row dimension. So the bias is indexed by (q_j, k_j), which matches what I found. Also, the mask: `pair_mask.unsqueeze(1).unsqueeze(2)` has shape (B, 1, 1, N, N), also indexed by (q_j, k_j). So in my attention kernel, where each block handles (b, i, head) and each thread handles a query index: - The query index threadIdx.x is q_j. - key is k_j. So: - bias should be `bias[((b * h + head) * N + query) * N + key]` - mask should be `pair_mask[(b * N + query) * N + key]` Wait, but then what about the attention row `i`? It shouldn't appear in bias or mask! Right, so it should be: ```c score += bias[((b * h + head) * N + query) * N + key]; // query, not i ... if (pair_mask[(b * N + query) * N + key] == 0.0f) { ... } // query, not i ``` OK let me make this correction and run correctness again.
call → compile_kernel
callcompile_kernel { "kernel_code": "\nimport torch\nimport torch.nn as nn\nimport math\nfrom torch.utils.cpp_extension import load_inline\n\nCUDA_SOURCE = r\"\"\"\n#include <torch/extension.h>\n#include <cuda_runtime.h>\n#include <math.h>\n\n__global__ void preprocess_kernel(\n const float* __restrict__ pair_repr,\n const float* __restrict__ ln_weight,\n const float* __restrict__ ln_bias,\n const float* __restrict__ W_query,\n const float* __restrict__ W_key,\n const float* __restrict__ W_value,\n const float* __restrict__ W_gate,\n const float* __restrict__ b_gate,\n const float* __restrict__ W_bias,\n float* __restrict__ q,\n float* __restrict__ k,\n float* __restrict__ v,\n float* __restrict__ gate,\n float* __restrict__ bias,\n int B, int N, int D, int h, int d\n) {\n int pos = blockIdx.x;\n int tid = threadIdx.x;\n \n extern __shared__ float shared_mem[];\n float* x_shared = shared_mem;\n float* reduce_buf = shared_mem + 128;\n \n // Load input\n if (tid < D) {\n x_shared[tid] = pair_repr[pos * D + tid];\n }\n __syncthreads();\n \n // Compute mean\n if (tid < D) {\n reduce_buf[tid] = x_shared[tid];\n } else {\n reduce_buf[tid] = 0.0f;\n }\n __syncthreads();\n \n for (int stride = blockDim.x / 2; stride > 0; stride /= 2) {\n if (tid < stride) {\n reduce_buf[tid] += reduce_buf[tid + stride];\n }\n __syncthreads();\n }\n float mean = reduce_buf[0] / D;\n \n // Compute variance\n if (tid < D) {\n float diff = x_shared[tid] - mean;\n reduce_buf[tid] = diff * diff;\n } else {\n reduce_buf[tid] = 0.0f;\n }\n __syncthreads();\n \n for (int stride = blockDim.x / 2; stride > 0; stride /= 2) {\n if (tid < stride) {\n reduce_buf[tid] += reduce_buf[tid + stride];\n }\n __syncthreads();\n }\n float var = reduce_buf[0] / D;\n \n // Normalize\n if (tid < D) {\n x_shared[tid] = (x_shared[tid] - mean) * rsqrtf(var + 1e-5f) * ln_weight[tid] + ln_bias[tid];\n }\n __syncthreads();\n \n // Extract indices\n int b = pos / (N * N);\n int rem = pos % (N * N);\n int i = rem / N;\n int j = rem % N;\n int r = b * N + i;\n \n // Q projection\n for (int out_c = tid; out_c < D; out_c += blockDim.x) {\n float val = 0.0f;\n for (int c = 0; c < D; c++) {\n val += W_query[out_c * D + c] * x_shared[c];\n }\n int head = out_c / d;\n int dd = out_c % d;\n q[((r * h + head) * N + j) * d + dd] = val;\n }\n \n // K projection\n for (int out_c = tid; out_c < D; out_c += blockDim.x) {\n float val = 0.0f;\n for (int c = 0; c < D; c++) {\n val += W_key[out_c * D + c] * x_shared[c];\n }\n int head = out_c / d;\n int dd = out_c % d;\n k[((r * h + head) * N + j) * d + dd] = val;\n }\n \n // V projection\n for (int out_c = tid; out_c < D; out_c += blockDim.x) {\n float val = 0.0f;\n for (int c = 0; c < D; c++) {\n val += W_value[out_c * D + c] * x_shared[c];\n }\n int head = out_c / d;\n int dd = out_c % d;\n v[((r * h + head) * N + j) * d + dd] = val;\n }\n \n // Gate projection (with bias)\n for (int out_c = tid; out_c < D; out_c += blockDim.x) {\n float val = b_gate[out_c];\n for (int c = 0; c < D; c++) {\n val += W_gate[out_c * D + c] * x_shared[c];\n }\n gate[pos * D + out_c] = 1.0f / (1.0f + expf(-val));\n }\n \n // Bias projection (no bias)\n for (int out_c = tid; out_c < h; out_c += blockDim.x) {\n float val = 0.0f;\n for (int c = 0; c < D; c++) {\n val += W_bias[out_c * D + c] * x_shared[c];\n }\n bias[((b * h + out_c) * N + i) * N + j] = val;\n }\n}\n\n__global__ void attention_kernel(\n const float* __restrict__ q,\n const float* __restrict__ k,\n const float* __restrict__ v,\n const float* __restrict__ bias,\n const float* __restrict__ pair_mask,\n float* __restrict__ scratch,\n int B, int N, int D, int h, int d\n) {\n int b = blockIdx.x / (N * h);\n int rem = blockIdx.x % (N * h);\n int i = rem / h;\n int head = rem % h;\n int r = b * N + i;\n int query = threadIdx.x;\n \n if (query >= N) return;\n \n extern __shared__ float shared_mem2[];\n float* K_shared = shared_mem2;\n float* V_shared = shared_mem2 + N * d;\n \n // Load K and V of shape (N, d)\n for (int l = query; l < N * d; l += blockDim.x) {\n K_shared[l] = k[((r * h + head) * N) * d + l];\n V_shared[l] = v[((r * h + head) * N) * d + l];\n }\n __syncthreads();\n \n // Load Q\n float q_local[64];\n for (int dd = 0; dd < d; dd++) {\n q_local[dd] = q[((r * h + head) * N + query) * d + dd];\n }\n \n float scores[64];\n float max_score = -1e9f;\n \n for (int key = 0; key < N; key++) {\n float dot = 0.0f;\n for (int dd = 0; dd < d; dd++) {\n dot += q_local[dd] * K_shared[key * d + dd];\n }\n float score = dot / sqrtf((float)d);\n score += bias[((b * h + head) * N + query) * N + key];\n \n if (pair_mask[(b * N + query) * N + key] == 0.0f) {\n score = -1e9f;\n }\n \n scores[key] = score;\n if (score > max_score) max_score = score;\n }\n \n float sum_exp = 0.0f;\n for (int key = 0; key < N; key++) {\n scores[key] = expf(scores[key] - max_score);\n sum_exp += scores[key];\n }\n float inv_sum = 1.0f / sum_exp;\n for (int key = 0; key < N; key++) {\n scores[key] *= inv_sum;\n }\n \n float out_local[64];\n for (int dd = 0; dd < d; dd++) out_local[dd] = 0.0f;\n \n for (int key = 0; key < N; key++) {\n float s = scores[key];\n for (int dd = 0; dd < d; dd++) {\n out_local[dd] += s * V_shared[key * d + dd];\n }\n }\n \n for (int dd = 0; dd < d; dd++) {\n int out_idx = ((b * N + i) * N + query) * D + head * d + dd;\n scratch[out_idx] = out_local[dd];\n }\n}\n\n__global__ void finalize_kernel(\n const float* __restrict__ scratch,\n const float* __restrict__ gate,\n const float* __restrict__ W_out,\n const float* __restrict__ b_out,\n float* __restrict__ output,\n int B, int N, int D\n) {\n int pos = blockIdx.x;\n int tid = threadIdx.x;\n \n extern __shared__ float s3[];\n float* gated_shared = s3;\n \n if (tid < D) {\n gated_shared[tid] = scratch[pos * D + tid] * gate[pos * D + tid];\n }\n __syncthreads();\n \n for (int c = tid; c < D; c += blockDim.x) {\n float out_val = b_out[c];\n for (int d = 0; d < D; d++) {\n out_val += W_out[c * D + d] * gated_shared[d];\n }\n output[pos * D + c] = out_val;\n }\n}\n\nvoid launch_preprocess(\n torch::Tensor pair_repr, torch::Tensor ln_weight, torch::Tensor ln_bias,\n torch::Tensor W_query, torch::Tensor W_key, torch::Tensor W_value,\n torch::Tensor W_gate, torch::Tensor b_gate, torch::Tensor W_bias,\n torch::Tensor q, torch::Tensor k, torch::Tensor v,\n torch::Tensor gate, torch::Tensor bias,\n int B, int N, int D, int h, int d\n) {\n int total_pos = B * N * N;\n int smem = 256 * sizeof(float);\n preprocess_kernel<<<total_pos, 64, smem>>>(\n pair_repr.data_ptr<float>(), ln_weight.data_ptr<float>(), ln_bias.data_ptr<float>(),\n W_query.data_ptr<float>(), W_key.data_ptr<float>(), W_value.data_ptr<float>(),\n W_gate.data_ptr<float>(), b_gate.data_ptr<float>(), W_bias.data_ptr<float>(),\n q.data_ptr<float>(), k.data_ptr<float>(), v.data_ptr<float>(),\n gate.data_ptr<float>(), bias.data_ptr<float>(),\n B, N, D, h, d\n );\n}\n\nvoid launch_attention(\n torch::Tensor q, torch::Tensor k, torch::Tensor v,\n torch::Tensor bias, torch::Tensor pair_mask,\n torch::Tensor scratch,\n int B, int N, int D, int h, int d\n) {\n int blocks = B * N * h;\n int threads = N;\n int smem = 2 * N * d * sizeof(float);\n attention_kernel<<<blocks, threads, smem>>>(\n q.data_ptr<float>(), k.data_ptr<float>(), v.data_ptr<float>(),\n bias.data_ptr<float>(), pair_mask.data_ptr<float>(),\n scratch.data_ptr<float>(),\n B, N, D, h, d\n );\n}\n\nvoid launch_finalize(\n torch::Tensor scratch, torch::Tensor gate,\n torch::Tensor W_out, torch::Tensor b_out,\n torch::Tensor output,\n int B, int N, int D\n) {\n int blocks = B * N * N;\n int threads = 64;\n int smem = 128 * sizeof(float);\n finalize_kernel<<<blocks, threads, smem>>>(\n scratch.data_ptr<float>(), gate.data_ptr<float>(),\n W_out.data_ptr<float>(), b_out.data_ptr<float>(),\n output.data_ptr<float>(),\n B, N, D\n );\n}\n\"\"\"\n\nCPP_SOURCE = r\"\"\"\nvoid launch_preprocess(\n torch::Tensor pair_repr, torch::Tensor ln_weight, torch::Tensor ln_bias,\n torch::Tensor W_query, torch::Tensor W_key, torch::Tensor W_value,\n torch::Tensor W_gate, torch::Tensor b_gate, torch::Tensor W_bias,\n torch::Tensor q, torch::Tensor k, torch::Tensor v,\n torch::Tensor gate, torch::Tensor bias,\n int B, int N, int D, int h, int d);\n\nvoid launch_attention(\n torch::Tensor q, torch::Tensor k, torch::Tensor v,\n torch::Tensor bias, torch::Tensor pair_mask,\n torch::Tensor scratch,\n int B, int N, int D, int h, int d);\n\nvoid launch_finalize(\n torch::Tensor scratch, torch::Tensor gate,\n torch::Tensor W_out, torch::Tensor b_out,\n torch::Tensor output,\n int B, int N, int D);\n\"\"\"\n\nclass ModelNew(nn.Module):\n def __init__(self, pair_dim, num_heads, dropout=0.0):\n super().__init__()\n self.num_heads = num_heads\n self.head_dim = pair_dim // num_heads\n \n self.layer_norm = nn.LayerNorm(pair_dim)\n self.query = nn.Linear(pair_dim, pair_dim, bias=False)\n self.key = nn.Linear(pair_dim, pair_dim, bias=False)\n self.value = nn.Linear(pair_dim, pair_dim, bias=False)\n self.gate = nn.Linear(pair_dim, pair_dim)\n self.bias_proj = nn.Linear(pair_dim, num_heads, bias=False)\n self.out_proj = nn.Linear(pair_dim, pair_dim)\n self.dropout = nn.Dropout(dropout)\n \n self.cuda_module = load_inline(\n name=\"evoformer_cuda_v2\",\n cpp_sources=CPP_SOURCE,\n cuda_sources=CUDA_SOURCE,\n functions=[\"launch_preprocess\", \"launch_attention\", \"launch_finalize\"],\n extra_cuda_cflags=[\"-O3\", \"--use_fast_math\"],\n verbose=False,\n )\n \n def forward(self, pair_repr, pair_mask):\n B, N, _, D = pair_repr.shape\n h = self.num_heads\n d = self.head_dim\n \n pair_repr = pair_repr.contiguous()\n pair_mask = pair_mask.contiguous()\n \n device = pair_repr.device\n \n q = torch.empty(B * N, h, N, d, device=device, dtype=torch.float32)\n k = torch.empty(B * N, h, N, d, device=device, dtype=torch.float32)\n v = torch.empty(B * N, h, N, d, device=device, dtype=torch.float32)\n gate = torch.empty(B, N, N, D, device=device, dtype=torch.float32)\n bias = torch.empty(B, h, N, N, device=device, dtype=torch.float32)\n scratch = torch.empty(B, N, N, D, device=device, dtype=torch.float32)\n output = torch.empty(B, N, N, D, device=device, dtype=torch.float32)\n \n self.cuda_module.launch_preprocess(\n pair_repr,\n self.layer_norm.weight,\n self.layer_norm.bias,\n self.query.weight,\n self.key.weight,\n self.value.weight,\n self.gate.weight,\n self.gate.bias,\n self.bias_proj.weight,\n q, k, v, gate, bias,\n B, N, D, h, d\n )\n \n self.cuda_module.launch_attention(\n q, k, v, bias, pair_mask,\n scratch,\n B, N, D, h, d\n )\n \n self.cuda_module.launch_finalize(\n scratch, gate,\n self.out_proj.weight,\n self.out_proj.bias,\n output,\n B, N, D\n )\n \n return output\n" }
result ← compile_kernel (ok)
okcompile_kernel compile_kernel PASSED: kernel compiled without errors.
turn 6 · 1 tool call(s) · 63.2s turn · 63.2s LLM · 42,036 chars (click to expand)
turn 663.2s turn · 63.2s LLM · 0.0s tools
reasoning
The correctness issue is probably in the attention kernel. Let me carefully trace through the reference code: 1. In the reference, `pair_mask (B, N, N)` masks over the "key" dimension 2. `mask = pair_mask.unsqueeze(1).unsqueeze(2)` → `(B, 1, 1, N, N)` 3. For attn with shape `(B*N, h, N, N)`, mask is broadcast to `(B, 1, 1, N, N)` across rows... but wait: - `attn = attn.view(B, N, h, N, N)` → `(B, N_queries, h, N_keys, N_keys?)` Wait, let me re-read the reference. The original reference does: - `attn.view(B, N, h, N, N)` — this is shape (B, N, h, N_keys?, N_keys?) Hmm no: - q shape: `(B*N, h, N, d)` — here N is the query length and also key length - `attn = (q @ k.transpose(-1, -2)) / sqrt(d)` → shape `(B*N, h, N, N)` - `attn.view(B, N, h, N, N)` → shape (B, N_queries_in_B, h, N_keys, ?) Wait, I need to understand more carefully: - `x_flat = x.reshape(B * N, N, D)` — each row i, we have a sequence of N items (columns j) - `q = self.query(x_flat).view(B * N, N, h, d).transpose(1, 2)` → shape `(B*N, h, N, d)` - This means for each row i in the N×N matrix, we have queries over the N columns - `(q @ k.transpose(-1, -2))` → q: `(B*N, h, N, d)`, k: `(B*N, h, N, d)` → result `(B*N, h, N, N)` - This is attention across columns for each row of the pair matrix - So for row r=(b,i), query position j is attending to key positions k across columns - `attn.view(B, N, h, N, N)` → shape (B, N, h, N, N) - Here it seems the 2nd dimension is N (row index), 4th is N (query index), 5th is N (key index) - `bias.unsqueeze(1)` → `(B, 1, h, N, N)` — here bias is (B, h, N, N) - This means bias has shape (B, h, N_rows, N_cols) - `mask = pair_mask.unsqueeze(1).unsqueeze(2)` → `(B, 1, 1, N, N)` - pair_mask is (B, N, N), so the mask is over (row_i, col_j)? In my kernel, I masked as `pair_mask[(b * N + i) * N + key]`, where i is the row index, and key is the column position. But looking at the reference more carefully: `mask = pair_mask.unsqueeze(1).unsqueeze(2) # (B, 1, 1, N, N)` `attn = attn + bias.unsqueeze(1)` — bias is (B, h, N, N), unsqueezed gives (B, 1, h, N, N) After view: attn is (B, N, h, N, N) The mask is applied as: `attn = attn.masked_fill(mask == 0, float("-inf"))` mask is (B, 1, 1, N, N), broadcast across (B, N, h, N, N). So the mask dimensions correspond to: - dim 0: B - dim 1: 1 → broadcast to N (query positions?) - dim 2: 1 → broadcast to h - dim 3: N (key positions?) - dim 4: N (??) Wait, in `view(B, N, h, N, N)`, the view means: - First N: from B*N → so this is the row index within each batch - h: number of heads - Second N: query positions (N_q) - Third N: key positions (N_k) Actually no. Let me trace: - `attn` before view has shape `(B*N, h, N, N)` - `attn.view(B, N, h, N, N)` — the B*N becomes B and N, so: - dim 0: batch B - dim 1: row index in N×N matrix (i) - dim 2: head h - dim 3: query position j (column index in row i) - dim 4: key position k (column index being attended to) `bias = self.bias_proj(x).permute(0, 3, 1, 2)` → shape (B, h, N, N) - bias[b, h, i, j] is the pair bias for position (i,j) under head h `bias.unsqueeze(1)` → shape (B, 1, h, N, N) `attn + bias.unsqueeze(1)` → broadcast bias across dim 1 (query positions j) So for each query position j in row i, the SAME bias[b, h, i, :] is added. Wait, that doesn't make sense. Actually, `bias` is (B, h, N, N), and we're adding it to attn of shape (B, N, h, N, N). The shapes align as: - B with B - 1 with N (first dimension after B, which is the row index i) - h with h - N with N (query j) - N with N (key k) So `bias[b, h, i, k]` is added to `attn[b, i, h, j, k]` for all j. This means the bias only depends on (i, k), not j. This is the "pair bias" for the ending node k, shared across all query positions j in row i. Wait, but then `mask = pair_mask.unsqueeze(1).unsqueeze(2)` → (B, 1, 1, N, N) - Broadcast over row index i and head h - Only depends on (j, k) where j is query and k is key But `pair_mask` is (B, N, N), which would correspond to mask[j, k]. Actually, wait. I think I mixed up the indices. Let me re-read the reference comment: "Apply mask: pair_mask (B, N, N) → mask over the key dimension" But the mask is applied as `mask = pair_mask.unsqueeze(1).unsqueeze(2)` which gives (B, 1, 1, N, N). This is broadcast over row and head. Hmm, but this means the mask depends on BOTH the query and key dimensions (since pair_mask is N×N), but it's being broadcast across all rows. So for any pair (j, k), if pair_mask[b, j, k] = 0, then attention at query position j attending to key position k is masked out, regardless of row i. In my kernel, I'm using `pair_mask[(b * N + i) * N + key]` but I should be using `pair_mask[(b * N + i) * N + ???]`. Actually wait, the mask in the reference is `pair_mask.unsqueeze(1).unsqueeze(2)` which broadcast to shape (B, N, h, N, N). In `view(B, N, h, N, N)`, the dimensions are (B, row_i, head, query_j, key_k). The mask (B, 1, 1, N, N) means for each batch b, mask[b, 1, 1, j, k]. So my kernel should use `pair_mask[(b * N + query) * N + key]` where query is the query position and key is the key position. Let me re-examine my kernel: In my attention kernel, `query = threadIdx.x` which is j (the query position), and `key` iterates from 0 to N-1 (the key position). But I'm using `pair_mask[(b * N + i) * N + key]` which uses `i` (the row index) instead of `query` (the query position). Let me fix this to use `pair_mask[(b * N + query) * N + key]`. Also looking at my bias indexing: `bias[((b * h + head) * N + i) * N + key]` — this is correct since bias is (B, h, i, k) where i is the row index. Actually wait, but in the original code, attn has shape (B*N, h, N, N) before view, where N is both query and key. After view(B, N, h, N, N), the mask is applied with shape (B, 1, 1, N, N). The query dimension of attn is the 4th dim and key is the 5th dim, while the mask's 4th and 5th dims are also N and N. So the mask applies to each (query, key) pair. But in the attention, for each row r=(b, i), the query position is j and key position is k. The bias is `[b, h, i, k]`. So in my kernel, I should have: ``` score += bias[((b * h + head) * N + i) * N + key]; if (pair_mask[(b * N + query) * N + key] == 0) score = -inf; ``` Wait, looking at `pair_mask` as used in reference: `mask = pair_mask.unsqueeze(1).unsqueeze(2) # (B, 1, 1, N, N)`. In attn.view(B, N, h, N, N), the last two Ns correspond to query and key positions respectively. So pair_mask[b, query, key] = pair_mask[b, j, k]. So yes, I should use `pair_mask[(b * N + query) * N + key]`, NOT `pair_mask[(b * N + i) * N + key]`. Also, I need to check my bias indexing. In the reference: `bias = self.bias_proj(x).permute(0, 3, 1, 2) # (B, h, N, N)` In `preprocess_kernel`, I'm setting `bias[((b * h + out_c) * N + i) * N + j]`. So bias is indexed as (b, h, i, j). In my attention kernel, I'm using `bias[((b * h + head) * N + i) * N + key]` which corresponds to (b, h, i, key). That should be correct. But wait, in the reference, when adding bias to attn: `attn = attn + bias.unsqueeze(1)` where attn is (B, N, h, N, N) and bias.unsqueeze(1) is (B, 1, h, N, N). The broadcast means bias[b, h, i, k] is added to attn[b, row_i, h, query_j, key_k] for all query_j. So yes, bias depends on row i and key k, not on query j. OK so my bias indexing looks correct, but let me double check: - In preprocess: `bias[((b * h + out_c) * N + i) * N + j]` where `out_c` is the head, `i` is the row index, and `j` is the column index. So bias is (b, h, i, j) which is (B, h, N_row, N_col). - In attention: `bias[((b * h + head) * N + i) * N + key]` — head is h, i is row, key is column. This matches bias[b, h, i, key]. Let me now look at how I handle the mask. I should use `query` instead of `i`: `if (pair_mask[(b * N + query) * N + key] == 0.0f)` instead of `if (pair_mask[(b * N + i) * N + key] == 0.0f)`. Actually, wait. In my attention kernel,`i` is the row index (from blockIdx), and `query = threadIdx.x` is the query position j. The mask should be `pair_mask[(b * N + query) * N + key]` according to the reference. Let me also carefully re-examine the reference's `out` computation and how it relates to my kernel's scratch. In the reference: ```python out = (attn @ v).transpose(1, 2).reshape(B, N, N, D) ``` attn shape: (B*N, h, N, N), v shape: (B*N, h, N, d) `attn @ v` → (B*N, h, N, d) `.transpose(1, 2)` → (B*N, N, h, d) `.reshape(B, N, N, D)` → (B, N, N, D) In my kernel: - scratch output is indexed as `((b * N + i) * N + query) * D + head * d + dd` - Which is (batch, row i, query position, head, d) - This gives shape (B, N, N, D), which matches reshape(B, N, N, D) So the indexing in my kernel should be correct for the output shape. Let me also check for other issues. The Q, K, V shapes in my preprocess kernel: `q[((r * h + head) * N + j) * d + dd]` where r = b * N + i - This gives shape (B*N, h, N, d), which matches. In attention kernel, I load k as `k[((r * h + head) * N) * d + l]` where r = b * N + i - This accesses all K for a given (batch, row, head), i.e., shape (N, d) And q: `q[((r * h + head) * N + query) * d + dd]` - This accesses Q for (batch, row, head, query), shape (d,) So the attention computation should be correct. Let me also verify the `i` variable. In the attention kernel: ```cpp int b = blockIdx.x / (N * h); int rem = blockIdx.x % (N * h); int i = rem / h; int head = rem % h; int r = b * N + i; ``` This means each block handles one (b, i, head) combination. The thread handles one query position j. OK so the mask bug is definitely `pair_mask[(b * N + i) * N + key]` vs `pair_mask[(b * N + query) * N + key]`. Let me fix that. Actually wait, looking at it more carefully... In the reference, the mask is `pair_mask` of shape (B, N, N). After `unsqueeze(1).unsqueeze(2)` it becomes (B, 1, 1, N, N). In the context of `attn.view(B, N, h, N, N)`: - dim 0: B - dim 1: row i (N) - dim 2: head h - dim 3: query position (N) - dim 4: key position (N) The mask (B, 1, 1, N, N) broadcasts to (B, N, h, N, N), where the last two dimensions match dim 3 and dim 4. So `pair_mask[b, query_j, key_k]` masks `attn[b, any_i, any_h, query_j, key_k]`. So yes, mask should be over `(query, key)`, not `(row, key)`. But wait, could it also be `(row, key)`? Let me look at the dimensions again... Actually, `unsqueeze(1).unsqueeze(2)` on a tensor of shape (B, N, N): - After unsqueeze(1): (B, 1, N, N) - After unsqueeze(2): (B, 1, 1, N, N) When broadcasting to (B, N, h, N, N) (assuming N matches N and the last N matches N): - The mask aligns as: (B, 1, 1, N, N) with (B, N, h, N, N) - The two Ns in mask correspond to dimensions 3 and 4 of attn - Dimension 3 is query position, dimension 4 is key position So pair_mask[b, j, k] where j is the 2nd N and k is the 3rd N. In the original pair_mask of shape (B, N, N), this would be pair_mask[b, query, key]. So my fix should be `pair_mask[(b * N + query) * N + key]`. Now let me also think about numerical precision. The differences were around 0.15 max, which is significant but not huge. This might be due to: 1. The bug I identified 2. Or floating point accumulation order differences Actually, there might be another issue. In the reference, the bias is added to attn BEFORE masking. In my kernel, I compute score, add bias, then apply mask. That should be equivalent. But wait, in the reference, the nn.Linear layers are applied to the LAYER-NORMALIZED input, and the gate is also applied to the LAYER-NORMALIZED input. In my preprocess kernel, I'm normalizing, then computing q, k, v, gate, bias all from the normalized x_shared. That's correct. One more thing: the reference uses `F.softmax(attn, dim=-1)` where attn is (B*N, h, N, N). In my kernel, I compute softmax within each thread. Since each thread handles one query position and all key positions, the softmax is over the key dimension, which is correct. But wait, I need to double-check: `float max_score = -1e9f;` and if all keys are masked, max_score might still be -1e9f, leading to exp(-1e9f - (-1e9f)) = exp(0) = 1.0 for all masked positions. But this would only happen if the entire row is masked, and in that case the softmax would be 1/N for all positions (since they're all set to -inf and max is also -inf). Actually, since all masked positions are set to -1e9f, max_score will be at least -1e9f. Then exp(-1e9f - (-1e9f)) = exp(0) = 1.0, so each masked position contributes 1.0 to the sum, and the softmax would be uniform 1/N for all masked positions. This is different from the reference where "-inf" would lead to NaN if all positions are masked (since exp(-inf) = 0, sum = 0, and 0/0 = NaN). But the reference also uses `float("-inf")`, which would give the same behavior if all positions are masked. However, the test cases probably don't have all-masked rows. Actually wait, the reference might produce 0 when all positions are masked because softmax handling of -inf... Let me check. In PyTorch, F.softmax with all -inf values on a dimension would produce NaN. But the test cases probably have at least one valid element per query. OK, I think the main issue is the mask indexing. Let me fix that and re-run correctness. Actually, I want to also think about whether there are other issues. The bias_proj in preprocess: in the reference, `bias = self.bias_proj(x)` where x has shape (B, N, N, D), and `.permute(0, 3, 1, 2)` gives (B, h, N, N). Wait, `self.bias_proj` is a linear layer that maps from pair_dim to num_heads. It's applied to each position independently. So for each (b, i, j), `bias_proj(x[b, i, j])` gives a vector of length h. Then `.permute(0, 3, 1, 2)` rearranges (B, N, N, h) → (B, h, N, N). In my preprocess kernel, for position (b, i, j), and for each head `out_c`: ``` val = 0 for c in 0..D-1: val += W_bias[out_c, c] * x_shared[c] bias[b, out_c, i, j] = val ``` Then in attention, I use `bias[b, head, i, key]`. This should match the reference where bias is (B, h, i, k) — but wait, in the reference the permute gives: - permute(0, 3, 1, 2) on (B, N, N, h) → (B, h, N, N) - dimension 2 (size N) comes from original dim 1 (which was the first N, i.e., row index) - dimension 3 (size N) comes from original dim 2 (which was the second N, i.e., column index) So bias[b, h, i, j] in the reference corresponds to position (b, i, j) in the pair matrix. In the attention, this bias is added to the query at row i, for any query position j, attending to key position k... WAIT. I need to re-examine. In the reference: `bias = self.bias_proj(x).permute(0, 3, 1, 2) # (B, h, N, N)` Here x is (B, N, N, D) where the two Ns are (row, col). So `bias_proj(x)` gives (B, N, N, h). After permute: (B, h, N, N) where: - dim 0: B - dim 1: h (head) - dim 2: N (row index i) - dim 3: N (column index j) But in the attention, `attn = attn + bias.unsqueeze(1)` where attn is (B, N, h, N, N) after view. Wait, let me re-read the view: ```python attn = (q @ k.transpose(-1, -2)) / math.sqrt(d) # (B*N, h, N, N) attn = attn.view(B, N, h, N, N) ``` So attn[b, i, h, query, key] where: - b: batch - i: row index in pair matrix - h: head - query: query position j (column in row i) - key: key position k (column being attended to) `bias.unsqueeze(1)` → (B, 1, h, N, N) Adding to attn gives broadcast over dim 1 (row index). So bias[b, h, i, j] is added to attn[b, i, h, query, key] for all query. But wait, if bias is (B, h, N, N), the dimensions align as: - B with B - 1 with i (broadcast) - h with h - N with query (???) - N with key (???) The N in bias corresponds to... what? In `bias_proj(x).permute(0, 3, 1, 2)` on (B, N, N, h): - Original dims: (B, row, col, h) - Permute(0, 3, 1, 2): (B, h, row, col) So bias[b, h, row, col] where row=i, col=j. But in the attention, the last two Ns of attn after view are (query, key). So bias[b, h, i, j] would be added to attn[b, i, h, query=j, key=j]? No wait, let me use proper terms: In the broadcast: - bias shape: (B, 1, h, N, N) - attn shape: (B, N, h, N, N) For broadcasting, the dimensions are aligned from the right: - bias dim 4 (N) with attn dim 4 (N) → key dimension - bias dim 3 (N) with attn dim 3 (N) → query dimension - bias dim 2 (h) with attn dim 2 (h) → head dimension - bias dim 1 (1) with attn dim 1 (N) → row dimension (broadcast) - bias dim 0 (B) with attn dim 0 (B) → batch So bias[b, h, query, key] is added to attn[b, row, h, query, key] for all rows. But this means the bias value at [query, key] is the pair bias for position (query, key) in the pair matrix, and it's the SAME for all rows i. This makes sense from the structure: the bias depends on the query position and key position, not on the row. But wait, I computed bias from x[b, i, j] which varies with row i. So how can the bias be independent of row i? Unless... the bias doesn't actually depend on row i. Let me reconsider. In `preprocess_kernel`, I compute bias from x_shared, which is the input at position (b, i, j). But in the reference, `bias = self.bias_proj(x)` where x is the FULL (B, N, N, D) tensor. So bias_proj is applied independently at each (b, i, j) position, giving (B, N, N, h). This naturally depends on all positions. BUT in the attention, when adding bias.unsqueeze(1) to attn.view(B, N, h, N, N), the broadcast makes the bias's N dimensions correspond to query and key dimensions, not to any other interpretation. Let me think about this differently. If bias has shape (B, h, N, N), and we're adding it to (B, N, h, N, N) with unsqueeze at dim 1: - bias[b, h, j, k] (after unsqueeze gives [b, 1, h, j, k]) - This gets broadcast to all rows i - So attn[b, i, h, j, k] += bias[b, h, j, k] This means the bias added to query position j attending to key position k is `bias[b, h, j, k]`, which is the bias_proj output at position (b, j, k) — independent of row i! But in my kernel, I'm using `bias[((b * h + head) * N + i) * N + key]`, which is bias at position (b, head, row i, key). This is WRONG! The bias should be indexed as `bias[((b * h + head) * N + query) * N + key]`, NOT `... + i) * N + key`! Wait, that changes things significantly. Let me re-verify with the permute: - x before permute: (B, N, N, h) where Ns are (row, col) - After permute(0, 3, 1, 2): (B, h, N, N) where Ns are (row, col) from the original So `bias[b, h, row, col]` in the permuted tensor. Now in the attention: `attn.view(B, N, h, N, N)` where the view is on (B*N, h, N, N) - Original (B*N, h, N, N) came from q @ k where q, k are (B*N, h, N, d) - The B*N = B * N (rows), and the second N is query positions Actually, let me be even more careful. In the reference, `x_flat = x.reshape(B * N, N, D)` reshapes (B, N, N, D) to (B*N, N, D). The first N in B*N corresponds to the first N (row dimension) of the original tensor, and the second N (size N in the last dim of x_flat) corresponds to the second N (column dimension). Then q, k, v have shape (B*N, h, N, d) where: - B*N = B * N (rows) - h = heads - N = column positions (queries/keys) Then `attn = q @ k.T` has shape (B*N, h, N, N) where: - The first N is query column position - The second N is key column position Then `attn.view(B, N, h, N, N)` gives: - dim 0: B - dim 1: row index - dim 2: head - dim 3: query column - dim 4: key column Now bias after permute is (B, h, N, N) where the two Ns are (row, col) from the original x. Then `bias.unsqueeze(1)` is (B, 1, h, N, N). When broadcasting to (B, N, h, N, N): - dim 0: B matches - dim 1: 1 broadcasts to N (row dimension) - dim 2: h matches - dim 3: N matches N (query column) - dim 4: N matches N (key column) But wait, the bias's N dimensions are (row, col) from the original x. So in `bias[b, h, row=i, col=j]`, the `col=j` is the column of the pair matrix. When adding to attn[b, row=i, head, query=j, key=k], the bias[b, h, i, j] is added... but does j correspond to query or key? Actually, I think the issue is that bias's two N dimensions are (row, col) from original, but in the broadcast they align with the LAST two dimensions of attn.view(B, N, h, N, N), which are (query, key). So: - bias dim 3 (row from original) aligns with attn dim 3 (query) - bias dim 4 (col from original) aligns with attn dim 4 (key) Wait no. When broadcasting, dimensions are matched from the RIGHT. So: - bias shape after unsqueeze: (B, 1, h, N, N) - attn shape: (B, N, h, N, N) Matched from right: - Last N of bias (N) → last N of attn (N, key dim) - Second-last N of bias (N) → second-last N of attn (N, query dim) - h → h - 1 → N (broadcast over row) - B → B So bias[b, h, query, key] is added to attn[b, row, h, query, key]. But bias[b, h, query, key] originally comes from bias[b, query, key, h] before permute, which is the bias_proj output at position (query, key) — independent of the row! This interpretation says that the bias added to query position j attending to key position k is the pair bias at position (j, k) in the pair matrix. But that seems strange for "starting node" attention where row i is fixed. Let me reconsider. Maybe I'm misunderstanding the permute. Let me look at it again: ```python bias = self.bias_proj(x).permute(0, 3, 1, 2) # (B, h, N, N) ``` `self.bias_proj(x)` operates on the last dimension of x. Since x is (B, N, N, D), `bias_proj` gives (B, N, N, h) where each element is the bias for one head at one spatial position. The spatial dimensions are (row, col). After `permute(0, 3, 1, 2)`: - dim 0: B - dim 1: h (from original dim 3) - dim 2: N (from original dim 1, which was row) - dim 3: N (from original dim 2, which was col) So `bias[b, h, row, col]` where row and col are positions in the pair matrix. Now in `attn.view(B, N, h, N, N)`: - dim 0: B - dim 1: N (this came from B*N split, so it's the row index) - dim 2: h - dim 3: N (this is the query position within the row) - dim 4: N (this is the key position) `bias.unsqueeze(1)` gives (B, 1, h, N, N). When broadcast to (B, N, h, N, N): - bias[b, 1, h, row_bias, col_bias] aligns with attn[b, row_attn, h, query, key] Since broadcasting matches from the right: - bias's col_bias (dim 4) aligns with attn's key (dim 4) - bias's row_bias (dim 3) aligns with attn's query (dim 3) - This seems most natural So bias[b, h, query, key] where query and key are positions in the pair matrix. This means the bias added to query position j attending to key position k comes from the pair bias at position (j, k) — which is INDEPENDENT of the row i being processed! But wait, in the "starting node" attention, for each row i, query j attends to key k. The pair bias at (i, j) or (i, k) would make sense, but a pair bias at (j, k) that's the same for all rows seems unusual. Let me look at the name: "starting-node variant". The comment says: "for each row i of the pair matrix, standard multi-head self-attention is performed across the column dimension j, with an additive pair bias. This lets positions (i,j) and (i,k) exchange information through their shared starting node i." If the bias comes from pair_proj(x) where x is the input, and in the attention it's added as bias.unsqueeze(1), maybe the bias at position (j, k) represents the "pair bias" between positions j and k, which is relevant for the attention mechanism regardless of which row i we're in. In other words, when computing attention from position j to position k, the pair bias between j and k helps guide the attention — independent of row i. This interpretation actually makes sense: the pair bias encodes relationships between positions j and k, and this bias is used in all rows' attention computations. So in my kernel, the bias should be indexed by (query, key) not by (row, key)! Wait, but I had it as `bias[((b * h + head) * N + i) * N + key]`. This uses row `i` and key `k`. It should be `bias[((b * h + head) * N + query) * N + key]`! OK so this is another bug. Let me fix both: 1. In preprocess, bias should be stored as `bias[b, head, j, k]` where j is the column (not i as first N). - Actually wait, in the reference, bias_proj(x) permute gives (B, h, N, N) where the Ns are (row, col) from x. - When adding to attention, the first N of bias aligns with query and the second N aligns with key. - So bias[b, h, row, col] = bias at spatial position (row, col). - In attention, this becomes bias[b, h, query, key]. 2. In attention kernel, the bias should be accessed as `bias[((b * h + head) * N + query) * N + key]`. Actually wait, let me reconsider the preprocess. In preprocess, for position (b, i, j) in the pair matrix, x_shared holds the input at that position. The bias_proj output for head h at this position goes to some location in bias. The reference computes `bias_proj(x)` for all positions, giving (B, N, N, h), then permutes to (B, h, N, N). So: - bias[b, h, i, j] = bias_proj(x[b, i, j])[h] This stores the bias for spatial position (i, j). In the attention (where we attend from query to key), the bias used is `bias[b, h, query, key]` — using query and key as spatial coordinates. So in my preprocess kernel, I'm already correctly storing `bias[((b * h + head) * N + i) * N + j] = value` where (i, j) is the spatial position. This is correct. In my attention kernel, I should access `bias[((b * h + head) * N + query) * N + key]` instead of `bias[((b * h + head) * N + i) * N + key]`. This is the fix needed. And for the mask: `pair_mask[(b * N + query) * N + key]` — the mask is pair_mask[b, query, key] according to the reference. Wait, is the mask also independent of row? Looking at the reference: ```python mask = pair_mask.unsqueeze(1).unsqueeze(2) # (B, 1, 1, N, N) attn = attn.masked_fill(mask == 0, float("-inf")) ``` mask is (B, 1, 1, N, N), broadcast to match attn (B, N, h, N, N). The last two Ns align with query and key: - mask[b, 1, 1, query, key] → applied to attn[b, row, head, query, key] So yes, pair_mask[b, query, key] is applied independently of row. My fix to use `pair_mask[(b * N + query) * N + key]` is correct. OK, let me also double check if there are any other issues. One thing I notice is that in the reference, the dropout is applied: `attn = self.dropout(attn)`. In my kernel, I don't apply dropout. The problem statement says to produce numerically equivalent output. Since the reference includes dropout, but the correctness tests likely use dropout=0.0 (as in get_init_inputs), this shouldn't affect correctness. Let me check: yes, `get_init_inputs()` returns `[pair_dim, num_heads]` without dropout, so dropout defaults to 0.0. Another thing: in the reference, `attn.view(B, N, h, N, N)` then `attn + bias.unsqueeze(1)`, then `attn = attn.masked_fill(...)`, then `attn.view(B * N, h, N, N)`, then `F.softmax(attn, dim=-1)`. In my kernel, I'm computing attention directly without going through the intermediate views. But the mathematical result should be the same if I use the correct indexing. Let me fix the bias and mask indexing and re-run. I'll also carefully trace through a small example to make sure. Wait, actually let me re-read my attention kernel more carefully: ```cpp int b = blockIdx.x / (N * h); int rem = blockIdx.x % (N * h); int i = rem / h; int head = rem % h; int r = b * N + i; int query = threadIdx.x; ``` So each CUDA block handles (b, i, head). Each thread handles one query position. The keys are looped over. ```cpp score += bias[((b * h + head) * N + i) * N + key]; ``` This should be `bias[((b * h + head) * N + query) * N + key]`. ```cpp if (pair_mask[(b * N + i) * N + key] == 0.0f) ``` This should be `pair_mask[(b * N + query) * N + key]`. OK, let me make these fixes and re-run correctness. I also want to check if there are any other subtle differences. One thing I should double-check: the QK dot product scaling. In the reference: `attn = (q @ k.transpose(-1, -2)) / math.sqrt(d)`. In CUDA: `score = dot / sqrtf((float)d)`. These should match. Also, the softmax computation in my kernel might differ slightly from PyTorch due to numerical precision in the reduction. But since the differences are around 0.15, it's likely due to a logic bug rather than numerical precision. Let me fix the bugs and rerun.
call → run_correctness
callrun_correctness { "kernel_code": "\nimport torch\nimport torch.nn as nn\nimport math\nfrom torch.utils.cpp_extension import load_inline\n\nCUDA_SOURCE = r\"\"\"\n#include <torch/extension.h>\n#include <cuda_runtime.h>\n#include <math.h>\n\n__global__ void preprocess_kernel(\n const float* __restrict__ pair_repr,\n const float* __restrict__ ln_weight,\n const float* __restrict__ ln_bias,\n const float* __restrict__ W_query,\n const float* __restrict__ W_key,\n const float* __restrict__ W_value,\n const float* __restrict__ W_gate,\n const float* __restrict__ b_gate,\n const float* __restrict__ W_bias,\n float* __restrict__ q,\n float* __restrict__ k,\n float* __restrict__ v,\n float* __restrict__ gate,\n float* __restrict__ bias,\n int B, int N, int D, int h, int d\n) {\n int pos = blockIdx.x;\n int tid = threadIdx.x;\n \n extern __shared__ float shared_mem[];\n float* x_shared = shared_mem;\n float* reduce_buf = shared_mem + 128;\n \n // Load input\n if (tid < D) {\n x_shared[tid] = pair_repr[pos * D + tid];\n }\n __syncthreads();\n \n // Compute mean\n if (tid < D) {\n reduce_buf[tid] = x_shared[tid];\n } else {\n reduce_buf[tid] = 0.0f;\n }\n __syncthreads();\n \n for (int stride = blockDim.x / 2; stride > 0; stride /= 2) {\n if (tid < stride) {\n reduce_buf[tid] += reduce_buf[tid + stride];\n }\n __syncthreads();\n }\n float mean = reduce_buf[0] / D;\n \n // Compute variance\n if (tid < D) {\n float diff = x_shared[tid] - mean;\n reduce_buf[tid] = diff * diff;\n } else {\n reduce_buf[tid] = 0.0f;\n }\n __syncthreads();\n \n for (int stride = blockDim.x / 2; stride > 0; stride /= 2) {\n if (tid < stride) {\n reduce_buf[tid] += reduce_buf[tid + stride];\n }\n __syncthreads();\n }\n float var = reduce_buf[0] / D;\n \n // Normalize\n if (tid < D) {\n x_shared[tid] = (x_shared[tid] - mean) * rsqrtf(var + 1e-5f) * ln_weight[tid] + ln_bias[tid];\n }\n __syncthreads();\n \n // Extract indices\n int b = pos / (N * N);\n int rem = pos % (N * N);\n int i = rem / N;\n int j = rem % N;\n int r = b * N + i;\n \n // Q projection\n for (int out_c = tid; out_c < D; out_c += blockDim.x) {\n float val = 0.0f;\n for (int c = 0; c < D; c++) {\n val += W_query[out_c * D + c] * x_shared[c];\n }\n int head = out_c / d;\n int dd = out_c % d;\n q[((r * h + head) * N + j) * d + dd] = val;\n }\n \n // K projection\n for (int out_c = tid; out_c < D; out_c += blockDim.x) {\n float val = 0.0f;\n for (int c = 0; c < D; c++) {\n val += W_key[out_c * D + c] * x_shared[c];\n }\n int head = out_c / d;\n int dd = out_c % d;\n k[((r * h + head) * N + j) * d + dd] = val;\n }\n \n // V projection\n for (int out_c = tid; out_c < D; out_c += blockDim.x) {\n float val = 0.0f;\n for (int c = 0; c < D; c++) {\n val += W_value[out_c * D + c] * x_shared[c];\n }\n int head = out_c / d;\n int dd = out_c % d;\n v[((r * h + head) * N + j) * d + dd] = val;\n }\n \n // Gate projection (with bias)\n for (int out_c = tid; out_c < D; out_c += blockDim.x) {\n float val = b_gate[out_c];\n for (int c = 0; c < D; c++) {\n val += W_gate[out_c * D + c] * x_shared[c];\n }\n gate[pos * D + out_c] = 1.0f / (1.0f + expf(-val));\n }\n \n // Bias projection (no bias)\n for (int out_c = tid; out_c < h; out_c += blockDim.x) {\n float val = 0.0f;\n for (int c = 0; c < D; c++) {\n val += W_bias[out_c * D + c] * x_shared[c];\n }\n bias[((b * h + out_c) * N + i) * N + j] = val;\n }\n}\n\n__global__ void attention_kernel(\n const float* __restrict__ q,\n const float* __restrict__ k,\n const float* __restrict__ v,\n const float* __restrict__ bias,\n const float* __restrict__ pair_mask,\n float* __restrict__ scratch,\n int B, int N, int D, int h, int d\n) {\n int b = blockIdx.x / (N * h);\n int rem = blockIdx.x % (N * h);\n int i = rem / h;\n int head = rem % h;\n int r = b * N + i;\n int query = threadIdx.x;\n \n if (query >= N) return;\n \n extern __shared__ float shared_mem2[];\n float* K_shared = shared_mem2;\n float* V_shared = shared_mem2 + N * d;\n \n // Load K and V of shape (N, d)\n for (int l = query; l < N * d; l += blockDim.x) {\n K_shared[l] = k[((r * h + head) * N) * d + l];\n V_shared[l] = v[((r * h + head) * N) * d + l];\n }\n __syncthreads();\n \n // Load Q\n float q_local[64];\n for (int dd = 0; dd < d; dd++) {\n q_local[dd] = q[((r * h + head) * N + query) * d + dd];\n }\n \n float scores[64];\n float max_score = -1e9f;\n \n for (int key = 0; key < N; key++) {\n float dot = 0.0f;\n for (int dd = 0; dd < d; dd++) {\n dot += q_local[dd] * K_shared[key * d + dd];\n }\n float score = dot / sqrtf((float)d);\n score += bias[((b * h + head) * N + query) * N + key];\n \n if (pair_mask[(b * N + query) * N + key] == 0.0f) {\n score = -1e9f;\n }\n \n scores[key] = score;\n if (score > max_score) max_score = score;\n }\n \n float sum_exp = 0.0f;\n for (int key = 0; key < N; key++) {\n scores[key] = expf(scores[key] - max_score);\n sum_exp += scores[key];\n }\n float inv_sum = 1.0f / sum_exp;\n for (int key = 0; key < N; key++) {\n scores[key] *= inv_sum;\n }\n \n float out_local[64];\n for (int dd = 0; dd < d; dd++) out_local[dd] = 0.0f;\n \n for (int key = 0; key < N; key++) {\n float s = scores[key];\n for (int dd = 0; dd < d; dd++) {\n out_local[dd] += s * V_shared[key * d + dd];\n }\n }\n \n for (int dd = 0; dd < d; dd++) {\n int out_idx = ((b * N + i) * N + query) * D + head * d + dd;\n scratch[out_idx] = out_local[dd];\n }\n}\n\n__global__ void finalize_kernel(\n const float* __restrict__ scratch,\n const float* __restrict__ gate,\n const float* __restrict__ W_out,\n const float* __restrict__ b_out,\n float* __restrict__ output,\n int B, int N, int D\n) {\n int pos = blockIdx.x;\n int tid = threadIdx.x;\n \n extern __shared__ float s3[];\n float* gated_shared = s3;\n \n if (tid < D) {\n gated_shared[tid] = scratch[pos * D + tid] * gate[pos * D + tid];\n }\n __syncthreads();\n \n for (int c = tid; c < D; c += blockDim.x) {\n float out_val = b_out[c];\n for (int d = 0; d < D; d++) {\n out_val += W_out[c * D + d] * gated_shared[d];\n }\n output[pos * D + c] = out_val;\n }\n}\n\nvoid launch_preprocess(\n torch::Tensor pair_repr, torch::Tensor ln_weight, torch::Tensor ln_bias,\n torch::Tensor W_query, torch::Tensor W_key, torch::Tensor W_value,\n torch::Tensor W_gate, torch::Tensor b_gate, torch::Tensor W_bias,\n torch::Tensor q, torch::Tensor k, torch::Tensor v,\n torch::Tensor gate, torch::Tensor bias,\n int B, int N, int D, int h, int d\n) {\n int total_pos = B * N * N;\n int smem = 256 * sizeof(float);\n preprocess_kernel<<<total_pos, 64, smem>>>(\n pair_repr.data_ptr<float>(), ln_weight.data_ptr<float>(), ln_bias.data_ptr<float>(),\n W_query.data_ptr<float>(), W_key.data_ptr<float>(), W_value.data_ptr<float>(),\n W_gate.data_ptr<float>(), b_gate.data_ptr<float>(), W_bias.data_ptr<float>(),\n q.data_ptr<float>(), k.data_ptr<float>(), v.data_ptr<float>(),\n gate.data_ptr<float>(), bias.data_ptr<float>(),\n B, N, D, h, d\n );\n}\n\nvoid launch_attention(\n torch::Tensor q, torch::Tensor k, torch::Tensor v,\n torch::Tensor bias, torch::Tensor pair_mask,\n torch::Tensor scratch,\n int B, int N, int D, int h, int d\n) {\n int blocks = B * N * h;\n int threads = N;\n int smem = 2 * N * d * sizeof(float);\n attention_kernel<<<blocks, threads, smem>>>(\n q.data_ptr<float>(), k.data_ptr<float>(), v.data_ptr<float>(),\n bias.data_ptr<float>(), pair_mask.data_ptr<float>(),\n scratch.data_ptr<float>(),\n B, N, D, h, d\n );\n}\n\nvoid launch_finalize(\n torch::Tensor scratch, torch::Tensor gate,\n torch::Tensor W_out, torch::Tensor b_out,\n torch::Tensor output,\n int B, int N, int D\n) {\n int blocks = B * N * N;\n int threads = 64;\n int smem = 128 * sizeof(float);\n finalize_kernel<<<blocks, threads, smem>>>(\n scratch.data_ptr<float>(), gate.data_ptr<float>(),\n W_out.data_ptr<float>(), b_out.data_ptr<float>(),\n output.data_ptr<float>(),\n B, N, D\n );\n}\n\"\"\"\n\nCPP_SOURCE = r\"\"\"\nvoid launch_preprocess(\n torch::Tensor pair_repr, torch::Tensor ln_weight, torch::Tensor ln_bias,\n torch::Tensor W_query, torch::Tensor W_key, torch::Tensor W_value,\n torch::Tensor W_gate, torch::Tensor b_gate, torch::Tensor W_bias,\n torch::Tensor q, torch::Tensor k, torch::Tensor v,\n torch::Tensor gate, torch::Tensor bias,\n int B, int N, int D, int h, int d);\n\nvoid launch_attention(\n torch::Tensor q, torch::Tensor k, torch::Tensor v,\n torch::Tensor bias, torch::Tensor pair_mask,\n torch::Tensor scratch,\n int B, int N, int D, int h, int d);\n\nvoid launch_finalize(\n torch::Tensor scratch, torch::Tensor gate,\n torch::Tensor W_out, torch::Tensor b_out,\n torch::Tensor output,\n int B, int N, int D);\n\"\"\"\n\nclass ModelNew(nn.Module):\n def __init__(self, pair_dim, num_heads, dropout=0.0):\n super().__init__()\n self.num_heads = num_heads\n self.head_dim = pair_dim // num_heads\n \n self.layer_norm = nn.LayerNorm(pair_dim)\n self.query = nn.Linear(pair_dim, pair_dim, bias=False)\n self.key = nn.Linear(pair_dim, pair_dim, bias=False)\n self.value = nn.Linear(pair_dim, pair_dim, bias=False)\n self.gate = nn.Linear(pair_dim, pair_dim)\n self.bias_proj = nn.Linear(pair_dim, num_heads, bias=False)\n self.out_proj = nn.Linear(pair_dim, pair_dim)\n self.dropout = nn.Dropout(dropout)\n \n self.cuda_module = load_inline(\n name=\"evoformer_cuda_v3\",\n cpp_sources=CPP_SOURCE,\n cuda_sources=CUDA_SOURCE,\n functions=[\"launch_preprocess\", \"launch_attention\", \"launch_finalize\"],\n extra_cuda_cflags=[\"-O3\", \"--use_fast_math\"],\n verbose=False,\n )\n \n def forward(self, pair_repr, pair_mask):\n B, N, _, D = pair_repr.shape\n h = self.num_heads\n d = self.head_dim\n \n pair_repr = pair_repr.contiguous()\n pair_mask = pair_mask.contiguous()\n \n device = pair_repr.device\n \n q = torch.empty(B * N, h, N, d, device=device, dtype=torch.float32)\n k = torch.empty(B * N, h, N, d, device=device, dtype=torch.float32)\n v = torch.empty(B * N, h, N, d, device=device, dtype=torch.float32)\n gate = torch.empty(B, N, N, D, device=device, dtype=torch.float32)\n bias = torch.empty(B, h, N, N, device=device, dtype=torch.float32)\n scratch = torch.empty(B, N, N, D, device=device, dtype=torch.float32)\n output = torch.empty(B, N, N, D, device=device, dtype=torch.float32)\n \n self.cuda_module.launch_preprocess(\n pair_repr,\n self.layer_norm.weight,\n self.layer_norm.bias,\n self.query.weight,\n self.key.weight,\n self.value.weight,\n self.gate.weight,\n self.gate.bias,\n self.bias_proj.weight,\n q, k, v, gate, bias,\n B, N, D, h, d\n )\n \n self.cuda_module.launch_attention(\n q, k, v, bias, pair_mask,\n scratch,\n B, N, D, h, d\n )\n \n self.cuda_module.launch_finalize(\n scratch, gate,\n self.out_proj.weight,\n self.out_proj.bias,\n output,\n B, N, D\n )\n \n return output\n" }
result ← run_correctness (ok)
okrun_correctness run_correctness PASSED: (5 / 5) trials all matched the reference. Numerical precision: max_abs_err=1.49e-07 mean_abs_err=1.50e-08 max_rel_err=3.67e-02
turn 7 · 1 tool call(s) · 261.3s turn · 261.3s LLM · 135,726 chars (click to expand)
turn 7261.3s turn · 261.3s LLM · 0.0s tools
reasoning
The correctness passed. Now I need to check if there's a performance speedup. Let me look at the existing kernel more carefully and think about optimization opportunities. The current kernel does: 1. Preprocess: one block per (batch, row, col) element → very high parallelism but each block processes just 1 vector 2. Attention: one block per (batch, row, head) with N threads per block → each thread handles one query 3. Finalize: one block per (batch, row, col) element For small seq_len=32, N=32 is small so attention blocks are only 32 threads each. This might leave many SMs underutilized. Optimization ideas: 1. In preprocess: Each block processes 1 element → B*N*N=2048 blocks for B=2,N=32. That's decent but not great. 2. In attention: Use 128 threads per block, each handling multiple queries → better occupancy. 3. For small N=32 and h=4, B*N*h=256 blocks. With 132 SMs on H100, each SM gets ~2 blocks which may be OK but with only 32 threads, occupancy is low. 4. The memory layout for q, k, v uses (r, h, N, d) - we might want to re-check this. Actually, let me think about what optimizations would help: - Use shared memory more effectively for matrix multiplies - Use warp-level primitives for reduction - In attention, coalesce memory accesses better - Consider fusing more operations Let me think about the most impactful change: - In preprocess kernel, each block does 5 linear projections from a shared buffer. This is compute-bound. - In attention, the shared memory loads for K/V are good but each thread does only 32 dot products of dim 16. That's quite memory-bound. - The key bottleneck for attention with N=32 is likely register pressure and the fact that we only have 32 warps active per SM (one block per SM with 32 threads = 1 warp). To improve occupancy, I should use more threads per block in attention - e.g., 64 or 128 threads. But N=32, so if I use 128 threads, each thread would process N/4 queries? No, since there are only N=32 queries, I'd need each thread to handle queries in a strided fashion. Actually, for better performance I could restructure the attention kernel to process multiple queries per block, but with more threads helping each query. For example, process 8 queries per block with 32 threads per query = 256 threads. But N=32, so that's 4 blocks per head per row. This might help with occupancy. Alternatively, I could keep the structure similar but use 32 threads per query with 1 query per block (as it is), but increase the number of active warps by using smaller shared memory per block. Wait, actually there's another optimization: In the preprocess kernel, the Q, K, V are stored as (B*N, h, N, d) but PyTorch's linear uses default layout which is (D, D) for weight. The weight access is coalesced (each thread reads contiguous elements from different output channels). Actually, looking at the bias computation: `bias[((b * h + out_c) * N + i) * N + j] = val;` - the output should be `(B, h, N, N)` matching the reference's `permute(0, 3, 1, 2)`. Currently the indexing for bias is: - In preprocess: writes `bias[((b * h + out_c) * N + i) * N + j]` - In attention: reads `bias[((b * h + head) * N + i) * N + key]` Wait, this uses `i` from the blockIdx but in `attention_kernel`, `i` is extracted from `blockIdx.x` and query is `threadIdx.x`. So for each query, the bias read is at position `(b, head, query, key)` - but the reference uses `bias.unsqueeze(1)` so the bias depends on the row `i` and the query/key positions, NOT on the query position of the thread. Let me re-check the reference: ```python # bias shape: (B, h, N, N) # attn shape: (B, N, h, N, N) attn = attn + bias.unsqueeze(1) # (B, 1, h, N, N) → broadcast to (B, N, h, N, N) ``` So bias[b, h, i, key] is added to attn[b, query, h, i, key] for all query. That means bias depends on the row `i` and key, but NOT on query. Wait now let me look at my code: ```python # preprocess: bias[((b * h + out_c) * N + i) * N + j] = val # attention: score += bias[((b * h + head) * N + i) * N + key]; ``` This is `bias[b, head, i, key]` which matches the reference. Good. Now, for `pair_mask`, the reference does: ```python mask = pair_mask.unsqueeze(1).unsqueeze(2) # (B, 1, 1, N, N) attn = attn.masked_fill(mask == 0, float("-inf")) ``` So mask depends on both the row `i` (query dim in the pair matrix) and key. But wait, in the reference, the mask is `(B, 1, 1, N, N)` and the attention is over columns for each row. The pair_mask shape is `(B, N, N)`. Let me re-read the reference carefully. In the reference, `x_flat = x.reshape(B * N, N, D)` and then attention is across the column dimension (the `N` in `x_flat`). So q, k, v are `(B*N, h, N, d)`. The attention is `q @ k.T` for each of the `B*N` rows. Then `attn = attn.view(B, N, h, N, N)` — the first N after B is the row dimension `i`, and the last N is the key dimension. For `bias.unsqueeze(1)`, bias is `(B, h, N, N)` and broadcasting adds it across the row dimension. So bias[b, h, i, key] is added. For the mask, `pair_mask.unsqueeze(1).unsqueeze(2)` gives `(B, 1, 1, N, N)`. This masks based on `(B, i, key)` where `i` is the row and key is the column of the pair matrix. So mask[b, i, key] is used. In my attention kernel, I use `pair_mask[(b * N + i) * N + key]` which is `pair_mask[b, i, key]`. But the reference masks using `pair_mask[b, query, key]`? No wait, let me check... Actually the attention in my kernel uses `i` as the row from blockIdx. But what about `query`? In the reference, the attention has shape `(B, N, h, N, N)` where the dimensions are (batch, row, head, query, key). Wait, but q and k are computed from the same flattened tensor `x_flat` of shape `(B*N, N, D)`. For each flattened index `r = b*N + i`, the query dimension in q is the column index j. So when computing attention for row `i`, query is the column index `j` and key is also a column index `k`. Wait, let me re-read the reference: ```python x_flat = x.reshape(B * N, N, D) q = self.query(x_flat).view(B * N, N, h, d).transpose(1, 2) # (B*N, h, N, d) k = self.key(x_flat).view(B * N, N, h, d).transpose(1, 2) v = self.value(x_flat).view(B * N, N, h, d).transpose(1, 2) attn = (q @ k.transpose(-1, -2)) / math.sqrt(d) # (B*N, h, N, N) attn = attn.view(B, N, h, N, N) ``` So for each `r = b*N + i` (i.e., each row of the pair matrix), q, k, v have shape `(h, N, d)` where N is the column dimension. The attention is `(B*N, h, N, N)` where the last two N's are query and key positions along the column dimension. When reshaped to `(B, N, h, N, N)`, the dimensions are: batch, row_i, head, query_j, key_k. The mask: `pair_mask.unsqueeze(1).unsqueeze(2)` gives `(B, 1, 1, N, N)` which is `pair_mask[b, query_j, key_k]`. So the mask depends on `query_j` and `key_k`, not on `row_i`. OH WAIT! In my attention kernel I used: ```c if (pair_mask[(b * N + i) * N + key] == 0.0f) ``` But this should be: ```c if (pair_mask[(b * N + query) * N + key] == 0.0f) ``` Because the mask is over the query-key pair in the column dimension, not over the row dimension. But correctness passed, so... let me re-check. The test inputs use `pair_mask = torch.ones(batch_size, seq_len, seq_len)`. So the mask is all ones! That means the mask doesn't affect the output at all in the tests. The correctness test passed purely because the mask is always 1. But for correctness with arbitrary masks, I need to fix this. Actually, let me look at the bias too. In `preprocess_kernel`, I wrote: ```c bias[((b * h + out_c) * N + i) * N + j] = val; ``` And in `attention_kernel`: ```c score += bias[((b * h + head) * N + i) * N + key]; ``` This is correct: `bias[b, head, i, key]` where `i` is the row and `key` is the column. The reference does `bias.unsqueeze(1)` which broadcasts across the query dimension (the column index of q), so bias is independent of the query position and depends only on the row and key position. So the mask should depend on `(query, key)` not `(i, key)`. Since tests always pass with mask=1, the correctness was fine. But I need to fix it for general correctness. Let me fix the mask indexing and also think about performance improvements. Wait, let me re-read the reference one more time to be absolutely sure: ```python attn = attn.view(B, N, h, N, N) # Add pair bias: bias (B, h, N, N) → (B, 1, h, N, N) broadcast across rows attn = attn + bias.unsqueeze(1) # Apply mask: pair_mask (B, N, N) → mask over the key dimension mask = pair_mask.unsqueeze(1).unsqueeze(2) # (B, 1, 1, N, N) attn = attn.masked_fill(mask == 0, float("-inf")) ``` The mask is `(B, 1, 1, N, N)` which broadcasts across both `head` and the query dimension. So it's `mask[b, query, key]`. So yes, I should use `(b * N + query) * N + key` not `(b * N + i) * N + key`. Given that tests use all-ones mask, I should fix it for correctness but it might not affect speed. Now for performance: the key bottleneck is probably: 1. Low occupancy in attention kernel (32 threads per block) 2. Memory-bound linear projections in preprocess For the H100, I want to maximize parallelism. Let me think about restructuring: **Preprocess optimization:** - Current: 1 block per position (B*N*N=2048 blocks), 64 threads each → decent parallelism - Could fuse the LayerNorm + linear projections more efficiently - Could use shared memory for weights (tile the matrix multiply) Given D=64, each projection is 64x64. With shared memory, I can load a tile of weights and dot product. Actually, for D=64, the computation is very small. Each position does 5 projections of 64x64, which is 5*64*64=20480 FLOPs. With 2048 positions, total FLOPs = ~42M. At 67 TFLOPS, this is 0.6 microseconds compute-bound, but in practice it's memory-bound since weights are read from global memory. **Attention optimization:** - Current: B*N*h = 2*32*4 = 256 blocks, each with 32 threads = 1 warp - Only 256 warps total, on 132 SMs → ~2 warps per SM - Each warp loads K/V into shared memory (2048 bytes for N=32, d=16) - This is extremely memory-starved To improve attention: - Use more threads per block: e.g., 128 or 256 threads - Each thread processes multiple queries? No, there are only 32 queries. - Instead, process multiple heads or multiple rows per block - Or use 32 threads for 32 queries but pack more blocks onto each SM by reducing register pressure Actually, with N=32 and d=16: - K/V shared memory = 2 * 32 * 16 * 4 = 4096 bytes - Q per thread = 16 floats = 64 bytes - scores = 32 floats = 128 bytes - out = 16 floats = 64 bytes - Total registers per thread ≈ 16+32+16+temp = ~80 floats = 320 bytes - Register usage: 320 * 32 = 10 KB per block - One SM can hold ~64K/10K = ~6 blocks, but we're limited by the number of thread blocks (32 threads = 1 warp, max 64K registers / (320*32) blocks... actually let me compute correctly). At 255 max registers per thread: 80 registers per thread → 255 limit means plenty room. Each block has 32 threads = 1 warp. An SM can hold up to 32 blocks. So register pressure isn't the issue. The issue is simply that we launch only 256 blocks with 1 warp each. On 132 SMs, that's ~2 concurrent warps per SM. Each warp takes many cycles due to memory latency. We need more warps per SM to hide latency. Options: 1. Process multiple queries per warp by having multiple warps per block 2. Have each block process all 32 queries across multiple warps (e.g., 4 warps = 128 threads) 3. Each thread processes 1 query, there are 32 queries, so 32 threads in a block handle 32 queries. If we use 128 threads per block, we'd need 128/32 = 4 blocks per head-row, processing the same head-row but this wastes compute. Actually, a better approach: process multiple heads per block, or multiple rows per block. But h=4 is small. Alternative: tile the K and V in shared memory and have each warp process a subset of queries. For N=32, a block of 128 threads can process 32 queries (each query handled by 4 threads). Wait, we need each query to compute dot products. If 4 threads work on the same query, they can split the d dimension: - Each thread computes dot(q, k) for a subset of d - Then do warp reduction to get the full score - For N=32 keys, each of 32 queries needs 32 scores Actually, with 128 threads and 32 queries, we can assign 4 threads per query. Each thread computes 4 elements of the dot product, then warp shfl to sum. Then all 4 threads have the score. Only 1 thread actually needs it for the score array, but all 4 could compute... Hmm, let me think simpler: use cooperative groups. But maybe the simplest is to increase threads to 128 per block and process 1 query per 4 threads. Wait, with 4 threads per query and 32 queries, that's 128 threads. Each thread computes a partial dot product over d/4 = 4 elements. Each query warp handles 8 queries (32 threads / 4 threads per query = 8 queries per warp). With 4 warps, we handle 32 queries. This increases block occupancy: 4 warps per block instead of 1. Launching same 256 blocks gives 1024 total warps. 1024/132 ≈ 8 warps per SM, which is much better. But actually, N=32, d=16. Compute per query: 32 keys * 16 dot products = 512 FMAs for attention scores, plus 32 softmax, plus 32*16=512 FMAs for output. Total ~1024 FMAs per query. With 4 threads per query doing 4 elements each: - partial dot product: 4 FMAs per key, then reduce via warp shuffle This seems like it could help. But implementing this in CUDA is more complex. Actually, let me think about what the real bottleneck is. For this small problem: - B=2, N=32, D=64, h=4, d=16 - Preprocess: reads pair_repr (2*32*32*64 = 131K floats) + weights (5*64*64 = 20K) + writes Q,K,V,gate,bias - Attention: reads Q,K,V and writes scratch - Finalize: reads scratch, gate + weights + writes output All kernels are memory-bound with relatively low arithmetic intensity. The key to speedup is reducing memory traffic and improving parallelism. For preprocess, the current approach is already fused (one kernel does LN + QKV + gate + bias). This is good. For attention, the current approach loads K and V into shared memory once per block. This is good. But 32 threads per block is very few. For finalize, same issue as preprocess but it's just one linear layer. Let me try a simpler optimization: increase block size in attention to 128 threads, but each thread still handles 1 query. Wait, there are only 32 queries... So if block size=128, I can process 4 heads per block? Or 4 rows per block? Actually, I can restructure: one block per (batch, head), and within the block process all rows and all queries. So block processes B*N*h / (B*h) = N rows. With N=32 queries per row and N rows, that's 32*32 = 1024 "query positions". With 128 threads, each thread handles 8 positions (1 row, 8 queries). But consecutive threads could handle consecutive queries in the same row, which gives good shared memory reuse for K/V. Hmm, but K and V are different for each row. So within a block, if threads work on different rows, they need to load different K/V. Better: group by rows. 128 threads could handle 4 rows (32 threads per row), and each row has 32 queries. If each thread in a warp handles one query per row, that's not right either. Let me think of it differently. For small N=32, just make each block handle 1 row × 1 head, with 128 threads. 32 threads handle 32 queries, and the other 96 threads are idle? No, that's wasteful. Actually, I can have each block handle 4 heads for the same row. 128 threads / 4 = 32 threads per head. Then we launch B*N blocks instead of B*N*h blocks. Each block loads K/V for 4 heads (one row), processes 32 queries per head. This reduces the number of blocks from 256 to 64, but each block has 4 warps instead of 1. Total warps = 64*4 = 256 still. But K/V for the 4 heads can be loaded into shared memory. Wait, the shared memory size for 4 heads: 4 * 2 * 32 * 16 * 4 = 16KB. That's fine for shared memory. And each block processes 4 heads × 32 queries = 128 query positions. With 128 threads, each thread handles 1 query position across 1 head. Actually let me structure it: - Block: (batch b, row i), threads: 128 - Thread tid: head = tid / 32, query = tid % 32 - Each block processes h=4 heads for 1 row - Threads in the same head share K/V for that head K/V shared mem: h * 2 * N * d * sizeof(float) = 4 * 2 * 32 * 16 * 4 = 16384 bytes. That's fine. Wait, but h=4, each head has N=32 queries. So 32 threads per head handle 32 queries. With 4 heads, total 128 threads. Each thread: 1. Loads K and V for its head into shared memory (cooperatively with other threads in same head, or all threads load all K/V) 2. Computes attention for its query This gives 4 warps per block. Blocks = B*N = 64. Total warps = 64*4 = 256. Same total warps, but better memory coalescing? Actually the main advantage of this approach is that each block has 4 warps, so an SM can run fewer blocks but each block has more warps. With max 32 blocks per SM and 4 warps each, that's up to 32*4 = 128 warps per SM (but limited by other resources). On H100, each SM can hold up to 64 warps (2048 threads / 32 = 64). With 4 warps per block, each SM can hold up to 16 blocks = 64 warps. Total blocks 64, SMs 132. So each SM gets less than 1 block, but each block has 4 warps = full utilization of 4 out of 64 warp slots. This is still not great. Hmm, 64 blocks for 132 SMs means roughly half the SMs get 1 block and the other half get 0. Each active SM has 4 warps out of 64. That's only 6.25% occupancy. This is the fundamental issue: there just isn't enough work to keep the GPU busy with N=32. Let me think about processing multiple rows per block. Say 8 rows per block. Then blocks = B*N/8 = 8. Each block still has 128 threads, so each row gets 16 threads. With h=4 heads, 16/4 = 4 threads per head. With N=32 queries, 4 threads per head can't handle 32 queries very well. If we do 2 rows per block: 128 threads handle 2 rows × 4 heads × 32 queries. Each thread handles 2 queries. Blocks = B*N/2 = 32. Each block has 4 warps. Total warps = 128. On 132 SMs, about 1 warp per SM. Still bad. Actually, maybe the right approach is much simpler: since N=32 is so small, we should fuse the attention operation into fewer, larger blocks. Another idea: fuse preprocess + attention + finalize into fewer kernels to reduce memory round trips. Or, we could do a "mega kernel" where each block processes multiple (b, i) pairs through the entire pipeline. Let me try a different approach for attention: rather than loading K/V into shared memory per block, just use tensor cores or do the computation directly with global memory. For N=32 and d=16, the memory footprint is tiny and the compute is tiny. The overhead of kernel launches and shared memory setup might exceed the compute time. Maybe a simpler approach for attention: - Use 1 warp per (b, i, head), but have each thread handle multiple keys - Actually, 32 threads handling 32 queries, 1 thread per query is already quite efficient for small N. Let me think about what else might be slow. The preprocess kernel launches B*N*N = 2048 blocks. Each block processes 1 position. With 64 threads per block, total threads = 131K. H100 has 132 SMs, so each SM gets ~1000 threads / 2048 = ~1000 threads. At 2048 max threads per SM, this is ~50% occupancy. Not bad. Actually, for preprocess, I think the kernel is fine. The issue might be that each block does very little work but has to load all weights. Let me count FLOPs for preprocess per position: - LN: 2*D + D = 3*64 = 192 FLOPs - 5 projections: 5 * (2*D*D) = 5 * 8192 = 40960 FLOPs - Total per position: ~41K FLOPs - Total: 2048 * 41K = ~84M FLOPs - At 67 TFLOPS: ~1.3 microseconds (if compute bound) - But memory reads: weights ~5*64*64*4=80KB, input ~131K*4=524KB, output ~131K*5*4=2.6MB. Total memory ~3.2MB. At 3.35 TB/s: ~0.95 microseconds. So preprocess is memory bandwidth bound. For attention: - Per head per row: q@k.T is 32 * 32 * 16 * 2 = 32K FLOPs - Per head per row: attn@v is 32 * 32 * 16 * 2 = 32K FLOPs - Total: B * N * h * 64K = 2 * 32 * 4 * 64K = ~16.4M FLOPs - Memory reads: Q,K,V each 2*32*4*32*16*4 = 524KB, output 524KB. Total ~1.6MB. At 3.35 TB/s: ~0.48 microseconds. - Plus bias read: 2*4*32*32*4 = 32KB - Total memory: ~1.6MB. Time: ~0.5 microseconds. For finalize: - FLOPs: 2 * B * N * N * D * D = 2 * 2048 * 64 * 64 = ~16.8M FLOPs - Memory: input 2.6MB, weights 16KB, output 524KB. Total ~3.1MB. Time: ~0.9 microseconds. So the total reference time should be dominated by memory bandwidth at about 2-3 microseconds total. But PyTorch probably takes much more due to: 1. Multiple kernel launches 2. Unfused operations 3. Intermediate memory allocations and reads/writes PyTorch reference breakdown: 1. layer_norm: fused kernel (good) 2. gate = sigmoid(linear(x)): 2 kernels (linear + sigmoid), writes intermediate 3. bias_proj: 1 kernel 4. query/key/value: 3 separate linear layers 5. reshape/transpose of q, k, v: just view operations in PyTorch 6. q @ k.T: bmm kernel 7. reshape attn + add bias: 1 kernel 8. mask add: 1 kernel 9. softmax: 1 kernel 10. dropout: 1 kernel 11. attn @ v: bmm kernel 12. transpose + reshape: view 13. gate * out: elementwise 14. out_proj: linear So there are maybe 10+ kernel launches, each reading/writing memory. The custom fused kernel avoids ALL intermediate memory traffic. My current kernel is already much better, but can I make it faster? Let me think about optimizing the attention kernel. For N=32, each block only has 32 threads. On H100, the minimum practical block size for good occupancy is at least 128 threads (4 warps). But we only have 32 queries... What if I process multiple rows per block in attention? Say 4 rows per block. Then B*N/4 = 16 blocks. With 4 heads per row, each block processes 4*4=16 heads. If I use 128 threads per block, each thread handles 16*32/128 = 4 queries. Wait, that's 16 heads × 32 queries = 512 query positions, divided by 128 threads = 4 queries per thread. So each thread handles 4 queries for a specific head and row. The K/V is shared among all threads handling the same head and row. With 4 rows × 4 heads = 16 head-rows per block, and each head-row needs 2*N*d = 1024 bytes of shared memory, total shared memory = 16 * 1024 = 16KB. That's fine. Let me think about execution: - Block: 128 threads - 4 rows × 4 heads = 16 head-rows - Each head-row has 32 queries - Divide 128 threads among 16 head-rows: 8 threads per head-row - 8 threads handle 32 queries: each thread handles 4 queries - For K/V load: 8 threads cooperatively load K/V for their head-row (N*d=512 numbers, 8 threads → each loads 64) This seems somewhat complex but workable. However, with only 16 blocks total (for B*N/4=16 when B=2,N=32), we'd have 16 blocks for 132 SMs. That's terrible occupancy. OK so processing multiple rows per block actually makes it worse because it reduces block count. What if I process 1 row per block but ALL heads, and use 128 threads? Each thread handles 32*4/128 = 1 query-head position. So each thread handles exactly 1 query for 1 head. That's the same as before but with all 4 heads in 1 block instead of 4 blocks. Blocks = B*N = 64. Each block has 128 threads = 4 warps. Total warps = 256. On 132 SMs: ~2 warps per SM. Occupancy = 2/64 = 3%. Still terrible. Fundamentally, the problem is just too small for the H100. With N=32, there isn't enough parallelism. Let me think about whether I can fuse the entire operation into a single kernel. If I do one block per (batch, row, column) like preprocess, but have it compute the FULL attention output for that column position across all heads? Wait, in attention, each column position j in row i needs to: 1. Be the query for attention in its row 2. Wait, the output for position (i, j) is computed from attention over all keys for row i, then multiplied by V values. In the reference, for each row i: - q[i, :] has shape (N, h, d) — Q for each column position - k[i, :] has shape (N, h, d) - v[i, :] has shape (N, h, d) - attn[i] = softmax(q[i] @ k[i].T / sqrt(d) + bias[i]) - out[i, j] = sum over keys k: attn[i, j, k] * v[i, k] So for each output position (i, j), the value depends on all keys k in row i. This means I can't compute the full output for a single position without reading all K and V for that row. But in a single kernel with one thread per (i, j), each thread would need to: 1. Read its x[i, j] 2. Compute LN, Q, gate 3. Wait, Q is per-column, but K and V are also per-column... each thread needs K and V for all columns in row i. If I launch one block per row (B*N blocks), and each block has N*D threads = 2048 threads... that's way too many. Actually, one block per row with 32*4=128 threads (one per query-head). Block loads all x[i, :] for the row, computes Q, K, V for all positions, stores in shared memory, then computes attention. This is plausible: Shared memory per block: - x[row]: N * D = 2048 floats = 8KB - q, k, v: 3 * N * h * d = 3 * 2048 floats = 24KB - bias: N * h * N = 4096 floats = 16KB - gate: N * D = 8KB - attn/scratch: N * h * N = 16KB Total: ~72KB. That's too much for standard shared memory (max 48KB for full occupancy, but H100 supports up to 227KB per block, though using more reduces occupancy). But I don't need to keep everything in shared mem at once. I could: 1. Load x, compute gate and bias, write to global memory 2. Compute Q, K, V in registers, write to shared memory 3. Use q, k, v in shared memory for attention 4. Compute output, write to shared memory 5. Load output and gate, compute final projection Hmm, this is getting complex. Let me think about what actually limits performance. Given the extreme smallness of the problem (N=32), the overhead of kernel launches probably matters. Let me count the kernel launches in my fused version: 1. preprocess: 1 launch 2. attention: 1 launch 3. finalize: 1 launch That's already just 3 launches. The PyTorch reference has maybe 10+. So kernel launch overhead is minimized. For the attention kernel with 256 blocks × 32 threads = 8192 threads total. H100 has 132 SMs × 2048 threads/SM = 270K threads capacity. We're using 3% of thread capacity. Given how small the problem is, maybe the key is to make each kernel as efficient as possible and rely on fusing to win vs. PyTorch. Actually, I should look more carefully at what might be numerically slightly off. The correctness test passed but there might be edge cases (like the mask I mentioned). Let me also think about performance tweaks: 1. In preprocess, I currently use 64 threads per block. Let me try 128 threads for better memory coalescing and occupancy. 2. In finalize, same thing. 3. In attention, maybe changing from query-oriented to key-oriented could help with shared memory reuse. Wait, in attention, each query needs to dot with ALL keys. With 32 threads (1 per query), the shared K is read by all 32 threads simultaneously. Since K is in shared memory, bank conflicts are minimal. But the issue is that there are only 32 threads per block. What if I use 64 threads per block and have 2 blocks per head-row (each handling 16 queries)? Then: - Blocks = B * N * h * 2 = 512 - Each block = 64 threads = 2 warps - Total warps = 1024 - Warps per SM = 1024/132 ≈ 8 - Occupancy = 8/64 = 12.5% Better but still low. What about 4 blocks per head-row, each handling 8 queries with 32 threads? Then 128 threads per block, but each handling 8 queries? Wait, 32 threads × 8 queries / 32 threads = 8 queries per block... no that doesn't work well. Actually, just keep 32 threads per query-block, but rearrange so a block handles 4 queries across 4 different head-rows? No, K/V is specific to head-rows. Let me try another approach: in the attention kernel, each thread handles a slice of keys for ALL queries. So instead of each thread computing 1 query's scores over 32 keys, each thread computes ALL 32 queries' scores over a subset of keys. With 32 threads and 32 keys, each thread handles 1 key. For that key, the thread computes the dot product with all 32 queries: - Load K for its key (d=16 floats) - For each query q=0..31: dot(q[q], K[key]) - Each thread handles 1 key, computes 32 dot products - After computing all 32 scores, do a warp-level reduction for max and sum Wait, this is transposed! Let me think: - Thread 0 handles key 0: computes scores[q][0] for all q - Thread 1 handles key 1: computes scores[q][1] for all q - ... - Thread 31 handles key 31: computes scores[q][31] for all q Then for softmax, each thread has one column of the attention matrix. To compute softmax over keys for each query, we need to reduce across the warp (32 threads). We can use warp shuffles for this. Then for the output: each thread loads V for its key, and accumulates into output vectors for each query. Thread key multiplies attn[q][key] * V[key] and adds to out[q]. This "key-oriented" approach has some advantages: 1. K and V for a key are loaded once and used for all queries — great register reuse 2. The scores for each query are distributed across threads, so softmax requires warp reductions 3. Output accumulation also requires combining contributions from all keys But the main issue is that the output for each query is scattered across all threads. Each thread computes a partial output vector for each query (just from 1 key). Then we need to reduce across the warp to get the final output. Actually, for partial output reduction, we can't easily reduce vectors of size 16 across 32 threads using warp shuffles efficiently (it takes 16 shuffle operations per query). Hmm, let me think about using shared memory for the output. Each thread writes its partial output to shared memory, then we reduce. But that's more complex. Actually, with the query-oriented approach (1 thread per query), each thread: 1. Loads Q for its query (16 floats) 2. All threads cooperatively load K and V into shared memory 3. Each thread computes 32 scores (32 dot products), does softmax 4. Each thread computes output vector (16 elements) 5. Each thread writes output This is very straightforward and the shared memory loads are coalesced. The issue is only occupancy. For small N=32 on H100, the main win is likely from fusing operations rather than extreme occupancy. My kernel already does significant fusion. Let me see if I can squeeze more performance with some tweaks. One important optimization: in preprocess, use 128 threads per block instead of 64. This doubles the thread count per block. Each thread handles fewer output channels in the linear projections, but more threads help with occupancy. Actually, 2048 blocks with 64 threads = 131K threads. With 128 threads, that's 262K threads. The H100 has 270K threads capacity. So 128 threads gives nearly 100% thread utilization. Wait, but each thread in preprocess does less work. And we might run into shared memory limits. Let me check: - preprocess uses 256 floats = 1KB shared memory per block - 2048 blocks, each using 1KB = total 2MB of shared memory dynamically allocated - H100 has 227KB per SM, so each SM can hold multiple blocks OK, let me try 128 threads for preprocess and finalize. For attention, let me try another approach: process 1 row per block, all heads, with more threads. Since 1 row has h=4 heads × N=32 queries = 128 query positions. Use 128 threads, each handling exactly 1 query position. Shared memory: each head needs K and V. We can load all 4 heads' K/V into shared memory: - K: h * N * d = 4 * 32 * 16 = 2048 floats = 8KB - V: same, 8KB - Total: 16KB Each thread: - Computes its query's attention scores - Does softmax - Computes output Blocks: B * N = 2 * 32 = 64 blocks Threads per block: 128 = 4 warps Total warps: 256 With 64 blocks for 132 SMs, roughly half the SMs are idle or have 1 block. Each active SM runs 4 warps out of 64 = 6.25% occupancy. But what if I use fewer SMs by using cooperative groups? Or what if I launch with different grid dimensions to distribute better? Actually, I realize that 64 blocks can't fill 132 SMs well. But PyTorch probably launches much more blocks. For example, the bmm in PyTorch might use tiles that create many blocks. Hmm, maybe the solution is to NOT try to fill all SMs for this tiny problem, but instead minimize the critical path and memory round trips. Let me think about another major optimization: fusing preprocess + attention + finalize into a SINGLE kernel. One block per (batch, row), threads = 128 (one per head×query). Each block: 1. Load all x[row, :] → shared memory (N×D) 2. Compute layer norm 3. Compute Q, K, V, gate, bias — all stored in registers or shared memory 4. Compute attention (using Q, K, V in shared memory) 5. Compute gated output 6. Compute out_proj 7. Write output But Q, K, V take up a lot of space: - Q: N×h×d = 2048 floats - K: same - V: same - Total: 6144 floats = 24KB Plus x: 2KB, bias: 4KB, scratch: 8KB, gate: 2KB. Total > 40KB. Still within 48KB for reasonable occupancy. Actually for a single kernel, I don't need separate scratch and x. Let me compute memory more carefully: - x_in: N * D = 2048 floats (can be recycled after LN+projections) - q, k, v: 3 * N * h * d = 6144 floats - bias: h * N * N = 4096 floats - gate: N * D = 2048 floats - output: N * D = 2048 floats Total: 16384 floats = 64KB. That's too much for 48KB shared memory at full occupancy. But H100 supports 227KB shared memory per block! Using 64KB is totally fine. The only issue is that when shared memory > 48KB, the SM can hold fewer thread blocks concurrently. But with only 64 blocks total, that's not an issue. So a single fused kernel per row is possible! Each block handles all N queries for all h heads in one row. Let me design this: - Block size: 128 threads (4 warps) - Grid: B * N blocks - Shared memory: ~64KB Execution within a block: 1. Threads 0..127 load x[row, :] from global memory into shared memory (coalesced) 2. Compute layer norm (reduction, shared memory) 3. Each thread computes 1 linear projection output: - Thread tid handles output channel `tid` for Q, K, V, gate, bias_proj - Wait, Q has h*d=64 outputs per position. But there are N=32 positions and 128 threads. - Each position needs 64 Q values, 64 K values, 64 V values, 64 gate values, 4 bias values. - Total outputs per row: 32*(64*3+64+4) = 32*260 = 8320 outputs. - With 128 threads, each thread computes 65 outputs. - This is messy because Q/K/V/gate are for different positions. Actually, let me be more structured. With 1 block per row, the block processes all N column positions. LN: all threads read different parts of x, compute mean and var. Then each thread normalizes its own channels. Projections: There are N=32 positions, each needing Q (D=64), K (64), V (64), gate (64), bias (h=4). Total work: 32 * (64+64+64+64+4) = 32 * 260 = 8320 scalar outputs. With 128 threads, each thread computes 65 outputs. Thread tid handles: - Position p = tid / 4 (since 260 values per position, 128 threads ≈ 2.3 positions per thread... actually 8320/128=65) This makes the thread assignment complex. I could simplify: - Process all projections for position j by threads [j*4, j*4+3] (4 threads per position) - Since there are 32 positions, 128 threads handles 32 positions × 4 threads each - Wait, 32 positions × 4 threads = 128 threads. Exactly! - But each position needs 260 scalar outputs. With 4 threads, each computes 65 values. - This is uneven and complex to load balance. Alternative: have each thread compute one full output channel for all positions in the row. - Q weight is (D, D). Each output channel is a dot product with input. - Thread tid computes output channel `tid` for ALL positions. - Q, K, V, gate each have D=64 channels. That's 4*64=256 channels total. - Bias has h=4 channels. - Total: 260 channels. With 128 threads, each thread computes 2 channels for all 32 positions, plus some threads do bias. Thread tid: - For position j=0..31: - Read input x[j] from shared memory - Compute Q[j][tid] = dot(W_q[tid], x[j]) - Compute Q[j][tid+128] = dot(W_q[tid+128], x[j]) - Compute K[j][tid], K[j][tid+128] - Compute V[j][tid], V[j][tid+128] - Compute gate[j][tid], gate[j][tid+128] - If tid < 4: bias[j][tid] = dot(W_bias[tid], x[j]) Wait, but Q is (h, N, d) where h=4, d=16, so Q is stored as (h, 32, 16). If thread tid computes 2 channels, which are 2 scalars across all positions. Actually with 128 threads, I could assign: - Threads 0..63: each computes 1 Q channel for all 32 positions (but Q has 64 channels, and there are 64 threads → each thread computes 1 channel for all positions). But 64 threads × 1 channel = 64 channels for Q. Need 64 channels. Perfect! - Same for K: threads 64..127 compute K channels. - But that only handles Q and K. Need V, gate, bias too. Hmm, let me think differently. We need 5 projection matrices applied to each of 32 inputs. Total scalar outputs: 32 * (64+64+64+64+4) = 8320. With 128 threads: 8320 / 128 = 65 outputs per thread. 65 is odd. Some threads do 65, others do 65. Actually not bad. Each thread computes 65 dot products of length D=64. That's 65*64=4160 FMAs per thread. At 128 threads, total FLOPs = 532K per block. For 64 blocks: ~34M FLOPs. Reasonable. Each thread loads the same 64 weights and reads shared memory input 32 times. This is a lot of register pressure. Actually, a simpler approach: tile the matrix multiply. Load a tile of weights into shared memory, compute partial products for all positions. Given the complexity of writing a fully fused single kernel correctly, let me first try simpler optimizations on the existing 3-kernel approach. Simple optimizations: 1. In preprocess: change from 64 threads to 128 threads 2. In preprocess: use __ldg for read-only weights 3. In attention: increase shared memory by coalescing better 4. In finalize: change from 64 to 128 threads But honestly, the most impactful might be to just restructure attention to have more threads. Let me try: in attention, process 4 head-rows per block (same batch b, same row i, all 4 heads). With 128 threads, each thread handles 1 query for 1 head (32 queries × 4 heads = 128). Block = (b, i), threads = 128 Each thread: head = tid / 32, query = tid % 32 Shared memory: K and V for all 4 heads. Size = 4 * 2 * 32 * 16 * 4 = 16KB. Grid: B * N = 64 blocks Total threads: 64 * 128 = 8192 (same as before, but packed into fewer blocks) Actually, this is exactly what I described earlier. Let me implement this. Wait, there's another advantage: in preprocess, Q, K, V are currently stored as `(B*N, h, N, d)`. When loading K and V for attention at `(r, h, :, :)`, the memory is contiguous for the last two dimensions. That's good. But if I process all 4 heads in one block, I can load all K/V more efficiently. Wait actually, for h=4, the current layout is: - q[((r * h + head) * N + query) * d + dd] - r = b*N + i So for a fixed r and head, K is at offset `(r*h+head)*N*d` and is contiguous for `N*d` elements. For the next head, it's at `(r*h+head+1)*N*d`. In a block processing all heads, I can load K/V for head 0, then head 1, etc. Actually, let me also reconsider: in the current attention kernel, each block processes (b, i, head) with 32 threads. The K/V for that head is loaded by all 32 threads. The loads are coalesced since consecutive threads read consecutive addresses. Output is written to `scratch[((b * N + i) * N + query) * D + head * d + dd]`. This is also somewhat coalesced: threads with the same query but different dd write to different addresses. But within a warp, all 32 threads have different query values (0..31) and same dd=0, then dd=1, etc. So when writing dd=0, threads write to addresses separated by D=64. That's a stride of 64 floats = 256 bytes. With 32 threads, this is 32*256 = 8KB stride, which is not great for memory coalescing. Actually, for L2 cache, this might be OK, but it's not ideal L1 coalescing. For better coalescing, I should have threads write consecutive memory. In the reference, the output from `attn @ v` is transposed and reshaped. The sequence is: - attn @ v: shape (B*N, h, N, d) - transpose: (B*N, N, h, d) - reshape: (B, N, N, D) So the output order should be `(B, N, N, D)` where the last D is contiguous. In my scratch, I store as: `scratch[((b * N + i) * N + query) * D + head * d + dd]` This corresponds to `(B, N, N, D)` with `head*d + dd` being the channel. But within a warp, thread tid has `query = tid`, so addresses are `base + tid*D + head*d + dd`. Consecutive threads differ by D = 64 floats = 256 bytes. This is NOT coalesced. For coalesced writes, I want thread tid to write to address `base + tid`, i.e., consecutive channels. But then each thread handles consecutive channels for the same query, not different queries. Actually, for coalescing, within a warp of 32 threads, I want them to access a contiguous 128-byte segment. So I can have: - Threads 0..15 handle query 0, channels 0..15 - Threads 16..31 handle query 0, channels 16..31 - ... no, that's too many threads per query. Or: - Each warp handles 1 head - Thread tid within warp: channel = tid, query = tid / D? But D=64 > 32. Hmm, with h=4, d=16, D=64. A warp has 32 threads. - Threads could handle (head, dd) across N queries. Actually, let me think about the output differently. Each position in scratch needs D=64 floats written. A warp could handle all 64 channels for 1 query by using 2 warps (64 threads). Then for 32 queries, we'd need 32 * 64 / 32 = 64 warps per head-row. That's too many. Alternatively, 1 warp handles 2 queries × 32 channels each? No, each query has 16 channels per head. Wait, d=16. Each query-head output has 16 floats. A warp (32 threads) can handle 2 query-head outputs (32 threads / 16 channels = 2 queries per warp). For N=32 queries per head, that's 16 warps per head. For h=4 heads, that's 64 warps per head-row. With 64 threads per warp, that's 4096 threads. Way too many. But block size doesn't need to match work size. We can loop. Let me reconsider: for small N=32, the most practical approach is: - Each thread handles 1 query-head - For coalescing, reorder the output layout Actually, the write pattern: `scratch[((b * N + i) * N + query) * D + head * d + dd]` With threads 0..31, each handling query=0..31, head=0, dd=0..15 sequentially. For dd=0, thread 0 writes to offset A+0*D+0, thread 1 writes to A+1*D+0, etc. These are strided by D=64 floats. For dd=1, same strided pattern. This strided write has poor L1 coalescing but might hit L2 cache OK if D*N fits in cache. D*N = 64*32 = 2048 floats = 8KB per head-row. With h=4, 32KB per (b, i). For 64 blocks, that's 2MB total. This fits in L2. The reads from K_shared and V_shared are excellent since they're fully in shared memory. For better global memory writes in attention, I could reorder the output to have channels interleaved differently, but then finalize expects `(B, N, N, D)`. Actually, in finalize_kernel, each block reads `scratch[pos * D + tid]` which IS coalesced for 32 threads since consecutive threads read consecutive channels. So the scratch layout is good for reading in finalize. The write in attention is strided, but for small sizes this might be OK. OK let me not overthink this. The current kernel is already quite good for correctness. Let me think about what performance optimizations are both practical and impactful. Let me try a different block size for preprocess. Currently 64 threads → 2048 blocks. If I use 128 threads, each thread does half the work but there are twice as many threads per block. Total threads = 262K. This should give better SM utilization. But actually, in preprocess, each block does: - Load 64 floats (1 position) - LN (reductions) - 5 projections totaling 260 output scalars - With 64 threads, each thread handles ~4-5 outputs - With 128 threads, each thread handles ~2 outputs The reduction for LN becomes less efficient with 128 threads (more threads than data). With D=64, using 128 threads means half the threads are idle during reductions. Hmm, for D=64, a warp size of 32 is good for reductions. Maybe 64 threads is actually optimal. Let me also check the attention kernel more carefully. Currently: ```c for (int key = 0; key < N; key++) { float dot = 0.0f; for (int dd = 0; dd < d; dd++) { dot += q_local[dd] * K_shared[key * d + dd]; } ... } ``` With d=16, the inner loop is 16 iterations. This could be unrolled by the compiler if it can determine d at compile time, but d is a runtime parameter. If I use template or constexpr for d=16, it will unroll. Actually wait, d is `self.head_dim = pair_dim // num_heads = 64 // 4 = 16` in the constructor. But in the CUDA code, d is passed as a parameter. The compiler might still unroll the loop if it can be proven constant, but it can't. I can create a switch statement with template parameters for common values. Or I can just pass d and trust the compiler's partial unrolling. Given that d=16, the overhead of the loop is minimal. Actually, let me try using template specialization for d=16, N=32, D=64, h=4 since these are fixed by the test. No wait, the problem says the kernel should work for the given parameters. But maybe I can optimize for these specific sizes since the benchmark will use them. No, actually the parameters might vary per run? The prompt mentions `pair_dim = 64, num_heads = 4, seq_len = 32, batch_size = 2` as specific values, but the constructor takes `pair_dim, num_heads`. If the hidden test uses different sizes, I need to be general. Actually, the reference says these values are for testing, and the input generation uses them. But the class constructor might receive different values in hidden tests. So I should keep it general. Let me think about using smaller intermediate buffers or fusing operations. Wait, I just realized one more optimization. In preprocess_kernel, I compute query, key, value, gate, and bias for all positions. For query, key, value, I write them to global memory (q, k, v). The attention kernel then reads them from global memory. This round-trip through global memory is wasteful. For a single row i, the preprocess computes q[r, :, :, :], k[r, :, :, :], v[r, :, :, :] where r = b*N+i. The attention uses q, k, v for the same r. If I fuse preprocess and attention for each position... but wait, preprocess operates on individual positions, while attention operates on entire rows. The trick is: preprocess for all positions in row i can be done, then immediately use q, k, v. But in CUDA, blocks are independent. I'd need one block to process all positions in a row (for preprocess aspects) and then do attention. Actually, this is exactly the "one block per row" approach I was considering earlier. And it's quite feasible! One block per (b, i): - Threads: 128 (h heads × N queries) - Shared memory: - x: N × D = 2048 floats - For Q, K, V, gate, bias computation: each thread handles one position's linear projections? Let me think about this differently. I'll use a persistent kernel design. For position (b, i, j) where j is the column: - Each thread can handle one column position j - Thread j (out of N=32 threads) loads x[i, j], computes LN - Then computes its own Q[j], K[j], V[j], gate[j], bias[j] - Store these in shared memory So far, this is similar to preprocess but with 32 threads processing an entire row simultaneously. After all positions' K and V are in shared memory, each thread j can use Q[j] to compute attention: - Compute dot products with all K[k] for k=0..31 (from shared memory) - Softmax across keys - Compute output[j] = sum_k attn[j][k] * V[k] - Apply gate[j] * output[j] - Compute out_proj But out_proj is a linear layer: output[c] = sum_d W[c][d] * (gate[j][d] * attn_output[j][d]). This is a matrix multiply for each position. So one thread handles one position j all the way through. 32 threads handle one row's 32 positions. But we also need to handle h=4 heads. With 32 threads, we can loop over heads, but then K and V need to be reloaded. Wait, q, k, v have shape (h, N, d). For each head, we do attention separately. With 32 threads, each thread handles all queries for one head at a time. But there are N=32 queries and 32 threads, so each thread handles 1 query per head. We do 4 iterations for 4 heads. But the shared memory for K, V needs h * N * d = 2048 floats. For all 4 heads simultaneously, that's 8192 floats. Still manageable. If I process all 4 heads simultaneously with 128 threads (32 queries × 4 heads), each thread handles 1 query for 1 head. So the block design: - 128 threads - Shared memory: - x: N * D = 2K floats (input) - q, k, v NOT in shared memory (computed on-the-fly or stored in global) Actually, to minimize shared memory, each thread can compute Q, K, V for its position j in registers, then write K and V to shared memory for others to use. But then Q stays in registers. Wait, each thread handles just 1 position j. It computes K[j] and V[j] (16 floats each for its head), writes them to shared memory. Then after __syncthreads(), all K and V are in shared memory. Then each thread computes its attention using Q in registers and K/V in shared memory. For h=4 heads, each head has its own K/V shared memory region. K/V shared memory layout: for head h, key k, channel d: - K[h][k][d] at offset h*N*d + k*d + d Size: 4 * 32 * 16 = 2048 floats = 8KB. Same for V. Total 16KB. Each thread computes K and V for its position. Position j is determined by thread ID. With 128 threads: - tid 0..31: head 0, query 0..31 - tid 32..63: head 1, query 0..31 - tid 64..95: head 2, query 0..31 - tid 96..127: head 3, query 0..31 Thread tid: - head = tid / 32 - query = tid % 32 - Read x[query] from shared memory - Compute Q[head][query], K[head][query], V[head][query], gate[query], bias[query][head] - Q, K, V are d=16 floats each - gate is D=64 floats - bias is 1 float - Write K[head][query] and V[head][query] to shared memory - __syncthreads() - Now compute attention: - Load Q from register (already there) - For each key k: - Load K[head][k] from shared memory - Compute score - Softmax - For each key k: - Load V[head][k] from shared memory - Accumulate output - Multiply by gate - Compute out_proj - Write output This is a lot of compute per thread. Each thread does: - Linear projections: 5 projections × 64 input features = 320 FMAs for bias-less, plus gate has bias so 321 FMAs - Wait, Q is 16 outputs, K is 16, V is 16, gate is 64, bias is 1. Total outputs = 113. - Each output is dot product of length 64: 113 * 64 * 2 = 14.5K FMAs? No, wait, each output is a dot product of 64 inputs: 113 * 64 = 7232 multiply-adds. But in practice, 113*64 = 7232 FMAs (each is one mul + one add, but on GPU it's fused). Actually, a linear layer with input D=64 and output C is C*D FMAs. So: - Q: 16 * 64 = 1024 FMAs - K: 1024 FMAs - V: 1024 FMAs - gate: 64 * 64 = 4096 FMAs - bias: 1 * 64 = 64 FMAs Total: 7232 FMAs Attention: - Q@K.T for 1 query over 32 keys: 32 * 16 = 512 FMAs - Attn @ V: 32 * 16 = 512 FMAs Total: 1024 FMAs Out proj: 64 * 64 = 4096 FMAs Total per thread: 7232 + 1024 + 4096 = 12352 FMAs. At ~2 cycles per FMA on H100 (simple estimate), that's ~25K cycles. At 2GHz, that's ~12.5 microseconds. Hmm, that's actually quite a lot per thread. But wait, this is per position (query). Each thread handles 1 query for 1 head. There are 32 queries. For just 1 head, all 32 threads do ~13.5K FMAs (7232/4 + 1024 + 4096/32 = ... actually gate and out_proj depend on all channels, not just one head). Hmm, the projections for gate and out_proj are full D=64 channels, not per-head. So the thread that handles head=0 also needs to compute all 64 gate values and then use them. Actually, if thread tid computes gate for its position, then all threads in the block compute the same gate values? No, each thread handles one position (query), and gate is per-position. So thread tid (query=j) computes gate[j] which is 64 values. But other threads also need gate for their own queries. For out_proj, each thread needs to apply the linear layer to its own output vector. That's fine, each thread does its own 64×64 matvec. So each thread's workload is: - LN: read x, compute mean/var, normalize: ~200 ops - Projections for Q (16), K (16), V (16), gate (64), bias (1): ~7232 FMAs - Wait, thread tid needs to compute Q, K, V for its specific head AND gate for all channels. So 16+16+16+64+1 = 113 outputs. - Attention: 1024 FMAs - out_proj: 4096 FMAs Total: ~12352 FMAs. With 100 TFLOPS per SM (rough estimate), 1 SM can do this in ~0.12 microseconds for 1 thread. But we have 128 threads per block, so 1 block = 128 * 12352 = 1.58M FMAs. At 100 TFLOPS: ~15.8 microseconds for 1 block. But there are 64 blocks, so total work = 64 * 1.58M = 101M FMAs. At full 67 TFLOPS GPU: ~1.5 microseconds. At more realistic 10 TFLOPS for this code: ~10 microseconds. Hmm, but memory won't be the bottleneck since most data is in shared memory or registers. Actually, a major concern: each thread computing 113 linear outputs with 64 inputs means each thread needs to read all 64 weights for each output. If weights are in global memory, each thread reads 113 * 64 = 7232 floats from global memory = 29KB. For 128 threads, that's 3.6MB per block. For 64 blocks, total weight reads = 230MB? That's way too much. Wait, weights are shared across all positions in a row. Q weights are the same for all queries. If all 128 threads read the same weights, there's massive redundant traffic. In preprocess with 1 block per position, each block reads the weights independently for its position. Total weight reads = 2048 * 113 * 64 = 14.8M floats = 59MB. At 3.35 TB/s, that's 17 microseconds. In the row-fused approach, weight reads are still the same: each thread reads weights for its outputs, but they're the same weights. There's no caching between threads unless the weights fit in L1. 59MB is way too big for L1. So the memory traffic is similar. But in the row-fused approach, weights for Q, K, V, gate, out_proj are all loaded from global memory. The compiler might keep some in registers or the L1 cache might help. However, with 128 threads each reading 7232 floats for projections (Q,K,V,gate), plus 4096 for out_proj, per thread memory reads = ~22K floats = 88KB. For 128 threads = 11MB. But many of these reads are the same weights. Actually, with L1 cache, the first thread might load a weight, and subsequent threads hit cache. But cache lines are 128 bytes = 32 floats. If threads read the same weights but at slightly different times, some cache hits might occur. This seems complicated. Let me stick with the 3-kernel approach because it's already correct and the separation allows simpler scheduling. Let me think about what's wrong with the current attention kernel. For B=2, N=32, h=4: - 256 blocks - 32 threads each - 8192 threads total PyTorch probably uses `torch.bmm` for q @ k.T, which for `2*32 x h x N x d` = `64 x 4 x 32 x 16` might use highly optimized cuBLAS kernels. cuBLAS could be very fast even for small batches because it uses tensor cores or special small-batch paths. If cuBLAS is efficient for the small matmuls, then my custom attention kernel might not be faster than PyTorch's bmm + softmax combination. In fact, for small matrices, PyTorch's bmm might use specialized kernels that are quite efficient. The main win for my kernel is fusing everything to reduce memory round trips. Let me check where the time is spent. In the reference: 1. x = layer_norm(pair_repr) - fused, fast 2. g = sigmoid(linear(x)) - 1-2 kernel launches 3. bias = linear(x).permute - 1 kernel + transpose (free) 4. x_flat = x.reshape(B*N, N, D) - free 5. q, k, v = 3 linear layers - 3 kernels 6. attn = bmm(q, k.T) / sqrt(d) - cuBLAS kernel 7. attn = attn.view + bias - 1 kernel 8. mask - 1 kernel 9. softmax - 1 kernel 10. dropout - 1 kernel 11. out = bmm(attn, v) - cuBLAS kernel 12. out = g * out - 1 kernel 13. out = linear(out) - 1 kernel So there are ~10 kernel launches plus 2 cuBLAS calls. Each cuBLAS call for 64 matrices of size 32x16 @ 16x32 is quite fast. My approach: 3 kernels with no intermediate global memory (except q, k, v, gate, bias, scratch which I allocate). One optimization: use `torch.empty` for temporaries? Actually I already do. Wait, let me think about the scratch buffer. In my kernel, scratch is in global memory. The attention writes to scratch, and finalize reads from scratch. This is a round-trip through global memory of size B*N*N*D = 2*32*32*64 = 131K floats = 524KB. At 3.35 TB/s, that's ~0.15 microseconds. Negligible. Similarly, q, k, v: B*N*h*N*d = 64 * 4 * 32 * 16 = 131K floats each. Total 393KB for all three. Plus gate and bias. So my kernel reads/writes about 1-2MB of intermediates. At 3.35 TB/s, that's < 1 microsecond. The total runtime is probably dominated by compute. For the small problem, my kernel might not beat PyTorch by much because PyTorch's cuBLAS is very fast for the matmuls, and my custom matmul-like code (projections and attention) is not as optimized. But I might still win from fusion, reducing the number of memory round trips. One possible performance issue in my attention kernel: I'm not using vectorized loads. For K and V, I load `N*d` floats one by one from global memory. With N=32, d=16, that's 512 floats. Using float4 would reduce the number of load instructions. Similarly for Q, K, V in shared memory. Let me try vectorizing the loads in attention. For K and V shared memory loading: ```c for (int l = query; l < N * d; l += blockDim.x) { K_shared[l] = k[((r * h + head) * N) * d + l]; } ``` With blockDim.x = 32. Each thread loads 16 values (512/32=16). The loads from global memory are coalesced (consecutive threads read consecutive elements). But each load is 4 bytes. If I use float4, each thread loads 4 floats at once, reducing instruction count. Actually, for K: `((r * h + head) * N) * d + l` should be `((r * h + head) * N + l / d) * d + l % d`... wait, let me fix the indexing. Actually in my code: ```c K_shared[l] = k[((r * h + head) * N) * d + l]; ``` I think there's a bug! The K tensor has shape (B*N, h, N, d) but I created it with `torch.empty(B * N, h, N, d)`. In PyTorch, the stride for dimension -1 is 1, for dimension -2 is d, for dimension -3 is N*d, for dimension -4 is h*N*d. So `k[((r * h + head) * N + j) * d + dd]` should be the correct indexing. But in my attention code: `k[((r * h + head) * N) * d + l]` This is wrong! It should be: `k[((r * h + head) * N + l / d) * d + l % d]` Wait no. Since the tensor is (B*N, h, N, d), the elements are stored as: - batch index: r * h * N * d + head * N * d + j * d + dd So `k[((r * h + head) * N + j) * d + dd]` is correct. And if we iterate l from 0 to N*d-1, it's: `k[((r * h + head) * N) * d + l]` where l = j*d + dd. Yes! `((r * h + head) * N) * d + l` = `((r * h + head) * N) * d + j * d + dd` = `((r * h + head) * N + j) * d + dd`. This is correct! Good, the indexing is fine. But for the float4 optimization: `l` goes from 0 to 511. If I use float4, I'd load 128 float4s (512/4 = 128). With 32 threads, each thread loads 4 float4s. The addresses are `base + l` where l is 0,1,2,... for threads 0,1,2,... Actually thread `query` starts at `base + query` and increments by 32. So thread 0 loads at 0, 32, 64, 96. Thread 1 at 1, 33, 65, 97. These are not aligned for float4! For float4 loads, I'd need each thread to load at multiples of 4. So the starting offset should be `4 * threadIdx.x` rather than `threadIdx.x`. Let me change to: ```c for (int l = query * 4; l < N * d; l += blockDim.x * 4) { ((float4*)K_shared)[l / 4] = ((float4*)k)[(((r * h + head) * N) * d + l) / 4]; } ``` With 32 threads, thread 0 loads at 0, 128, 256, 384. Thread 1 at 4, 132, 260, 388, etc. All aligned. This is a nice optimization. Let me implement it. Similarly for the write to scratch: ```c for (int dd = 0; dd < d; dd++) { int out_idx = ((b * N + i) * N + query) * D + head * d + dd; scratch[out_idx] = out_local[dd]; } ``` Consecutive threads write at `out_idx` with stride D=64. This is strided by 64 floats = 256 bytes. For float4, I'd need each thread to write multiple values, but the stride isn't alignment-friendly. Actually, if I reorder the output so that for a fixed (b, i, head), query and dd are contiguous, then consecutive threads write contiguous addresses. Let me think... If scratch is stored as (B, N, h, N, d), then: `scratch[((b * N + i) * h + head) * N * d + query * d + dd]` Then thread `query` with `dd=0` writes to offset `base + query * d`. With d=16 and consecutive queries, addresses are base, base+16, base+32, ... offset by 16 floats = 64 bytes. Still not float4 aligned for consecutive threads. If scratch is (B, N, N, h, d): `scratch[(((b * N + i) * N + query) * h + head) * d + dd]` Consecutive threads (same head, consecutive query) write at base + query*h*d + head*d + dd = base + query*64 + ... Stride is 64 floats = 256 bytes. Multiple of float4. Actually, let me just focus on the float4 loads for K/V which are more impactful. Another important optimization: I should try to process more queries per thread in attention to amortize the shared memory loading cost, but with N=32 this is already minimal. Actually, let me reconsider the overall approach. Maybe instead of separate q, k, v buffers, I can compute them and immediately use them. But the one-block-per-row fused approach seems too complex. Let me try another tactic: in the attention kernel, use thread coarsening. Each thread handles 2 consecutive queries. Then block size can be 16 threads (1 warp), and we launch 512 blocks (2x as many blocks as before, but each has 1 warp). Wait, with 16 threads handling 32 queries (2 per thread), blocks = B*N*h*2 = 512. Each block = 16 threads = 0.5 warp. That seems worse. Or 64 threads handling 32 queries (some threads idle). Blocks = 256. Each block = 64 threads = 2 warps. Total warps = 512. Warps per SM = 512/132 ≈ 4. Still not great. What about 128 threads, each handling 1 query, processing 2 head-rows per block? Then blocks = B*N*h / 2 = 128. Each block = 128 threads = 4 warps. Total warps = 512. Or 256 threads per block, processing 4 head-rows. Blocks = 64. Each block = 8 warps. Total warps = 512. The total warps is always 256 * queries_per_block. With 32 queries and h=4, total query-head positions = 4096. To maximize total warps while keeping block size reasonable, I can process all query-head positions in one block... but that's 4096 threads which exceeds 1024. Max 1024 threads per block. 4096 / 1024 = 4 blocks per head-row? No. 4096 query-head positions across all head-rows. With 1024-thread blocks, that's 4 blocks. Each block handles 1024 query-head positions = 8 head-rows. With 64 total head-rows (B*N=64), that's 8 blocks total. Wait, that's too few blocks (8) for 132 SMs. Terrible. Let me think about this differently. I want many blocks and many warps per block. The constraints are: - Each block handles some subset of (b, i, head, query) positions - Total positions = 2 * 32 * 4 * 32 = 8192 - Max threads per block = 1024 If each thread handles 1 position, total threads needed = 8192. With 1024 threads per block: 8 blocks. Too few. With 256 threads per block: 32 blocks. Still too few. With 128 threads per block: 64 blocks. With 64 threads per block: 128 blocks. With 32 threads per block: 256 blocks. For 64 blocks with 128 threads (4 warps): 256 total warps. ~2 warps per SM. For 128 blocks with 64 threads (2 warps): 256 total warps. Same. For 256 blocks with 32 threads (1 warp): 256 total warps. Same. The total number of warps is constant if each thread handles 1 position. To get more warps, I need each thread to handle less than 1 position? That's impossible. Or I need to duplicate work, which is wasteful. The maximum total warps for this problem is 8192 / 32 = 256 warps. From thread perspective, that's fixed. To get more warps, I'd need to... there's no way. The problem is just too small. Oh wait! I made an error. Total thread work is 4096 query-head positions. Since each position requires ~1000 FMAs, I can't magically create more work. But the warps don't need to just handle one query-head position. They can also help with the same position in different ways. For example, in out_proj, I could have multiple threads work on different output channels for the same position, creating more parallel work. In my current finalize kernel, I process 2048 positions with 64 threads each. That's 131K threads. The attention kernel only has 8192 threads. If I could somehow make the attention work more parallel... Actually, there's no need. The total work determines the runtime. With enough blocks to keep all SMs busy (we have 64-256 blocks which should be plenty for occupancy of the preprocess kernel), the runtime is determined by arithmetic throughput and memory bandwidth. For preprocess: 2048 blocks is plenty for parallelism. For attention: 256 blocks is decent. For finalize: 2048 blocks is plenty. Actually, let me reconsider. 256 blocks means 256 SMs can process them simultaneously, and H100 has 132 SMs. So some SMs get 2 blocks. Each block has 32 threads = 1 warp. An SM can hold 64 warps, so with 2 blocks it's 2 warps out of 64 = 3% occupancy. But that's the maximum for this tiny kernel. The question is: does low occupancy hurt performance? For memory-bound kernels, yes, because you need lots of warps to hide latency. With only 2 warps per SM, when one warp stalls on a memory load, there might not be another ready warp to switch to. However, for the attention kernel, most loads are from shared memory (K_shared, V_shared). Shared memory has very low latency (~20 cycles). So the kernel might not stall much. Global memory loads for Q are coalesced and quick. The main limitation might be instruction throughput. Each thread does ~1000 FMAs and some exp/sqrt. With only 1 warp per SM, the SM is barely utilized. To improve this, we need to somehow get more warps per SM. But we can't create more work for attention without duplicating effort. One trick: use persistent kernels or cooperative groups. But CUDA persistent kernels are complex. Actually, there's another idea: use fewer blocks with more work per block, but keep the warps busy. For example, in attention, instead of 1 block per (b, i, head), use 1 warp per (b, i, head) but pack 4 warps into 1 block that does nothing extra. That is, 1 block = 4 warps = 128 threads, but the extra 96 threads just return immediately. This gives 256 blocks (same as before) but each has 4 warps. Total warps = 1024. Warps per SM = 1024/132 ≈ 8. Occupancy = 8/64 = 12.5%. Better! But the extra 96 threads per block just do `if (tid >= 32) return;`. There's some overhead for launching these threads, but it's minimal. And now each SM gets ~8 warps instead of ~2. Actually, I can do better. What if I pack ALL query-head positions for a row into one block? B*N = 64 blocks, each with 128 threads (all 4 heads × 32 queries). Total warps = 64 * 4 = 256. Same total warps as before! I keep making the same mistake. Total warps = total_threads / 32. Total threads = number of positions processed. If each position requires 1 thread, total threads = 4096. Total warps = 128. Wait, 4096 / 32 = 128 warps? No: 4096 threads / 32 = 128 warps. Yes. Earlier I said 256 blocks × 32 threads = 8192 threads. But there are only 4096 positions (b, i, head, query). Let me recompute: - B = 2, N = 32 (rows), h = 4, queries per row-head = 32 - Total positions = 2 * 32 * 4 * 32 = 8192 Oh, 8192 threads! 8192 / 32 = 256 warps. OK so I had it right. 256 warps total for 132 SMs = ~2 warps per SM. To get more warps, I need more threads. But each thread handles 1 position, and there are only 8192 positions. I can't get more than 256 warps without processing positions redundantly. BUT: in the attention operation, each query position requires reading all 32 keys. What if I use multiple threads to compute the same query's dot products? For example, 2 threads per query, each computing half the dot product. Then I'd have 16384 threads = 512 warps. Warps per SM = 512/132 ≈ 4. Still low but better. Or 16 threads per query: 32 queries × 16 threads = 512 threads per head-row. But per head-row there are 32 queries. With 16 threads per query, each thread computes 1 element of the dot product? No, d=16, so 16 threads would each compute 1 element, then do warp reduction. For a head-row (b, i, head), there are 32 queries. If I use 32 threads per query (1 warp), that's 32 warps per head-row. With 64 head-rows (B*N*h = 256), that's 256 * 32 = 8192 warps! Now warps per SM = 8192/132 ≈ 62. That's nearly full occupancy! But can we use 32 threads per query? Each thread computes d/32 of the dot product. But d=16, so 16 threads are enough. With 16 threads per query, 32 queries need 512 threads = 16 warps per head-row. For 256 head-rows: 256 * 16 = 4096 warps. Warps per SM = 4096/132 ≈ 31. That's 50% occupancy. Good! But 512 threads per block exceeds 1024? No, 512 < 1024. Each block processes one head-row. Blocks = 256. Each block = 512 threads = 16 warps. Total warps = 4096. This is a much better design! Let me work it out: Block = (b, i, head), threads = 512 But wait, there are only 32 queries. With 512 threads, each query gets 16 threads. The 16 threads cooperatively: 1. Load K and V into shared memory (512 threads load 32*16*2 = 1024 floats, each loads 2 floats) 2. Each query-group of 16 threads loads Q for their query into registers (16 threads load 16 floats total, each loads 1 float... or all 16 load all 16, redundant) 3. Each of the 16 threads computes partial dot products for all 32 keys: - Thread t computes dot over channels t and t+16? No, d=16, 16 threads, so thread t computes channel t only. - For each key k: score_partial[k] = q[t] * K[k][t] 4. Warp shuffle sum to get full score for each key 5. Each thread computes all 32 softmax scores? No, after shuffle, all 16 threads in the warp have the same 32 scores. 6. Then for output: each thread computes partial accumulation over its channel: out_partial[k] = score[k] * V[k][t] 7. Wait, the output is a vector of size d=16. All 16 threads need to accumulate into the same output vector. But each thread only handles channel t. So no reduction needed for output! Thread t just accumulates channel t. Actually this is very clean: - Query q = 16 floats. Thread t handles channel t. - For key k: score = sum_{t=0}^{15} q[t] * K[k][t]. All 16 threads compute partial and reduce via warp shuffle. - After reduction, all threads have score[k]. - Each thread computes its output channel: out[t] += score[k] * V[k][t]. Complexity: - 32 iterations (keys) - Per iteration: 16 loads from shared memory (K[k][t] for thread t), 1 multiply, warp shuffle (broadcast the sum to all threads), 1 load (V[k][t]), 1 multiply-add. - Total: 32 * (16 loads + compute + shuffle + 16 loads + compute) = 1024 loads + 32 shuffles + compute. But with only 16 active threads per warp (out of 32), the warp executes with some lanes idle. Actually, for 32 queries, I need 32 warps (one per query), each with 16 active threads. Wait no, let me restructure. Alternative: use 1 block per head-row, with 32 warps. Each warp handles 1 query. Within the warp, 16 threads are active (lanes 0..15), handling d=16 channels. But CUDA warps always have 32 threads. If only 16 are doing useful work, the other 16 could handle another query? No, because then we'd need 64 active threads, which means 2 warps handling 2 queries. Wait, with d=16, if I use 32 threads per query, each thread handles 0.5 channels? That doesn't work cleanly. The cleanest is: 16 threads per query, with each thread handling 1 channel. For 32 queries, I need 32 groups of 16 threads = 512 threads = 16 warps. Block handles 1 head-row with 512 threads. But in a warp of 32 threads, I can put 2 queries: threads 0..15 for query 0, threads 16..31 for query 1. Then I have 16 warps, each handling 2 queries. Yes! This is perfect: - Block: 16 warps = 512 threads - Warp w: handles queries 2w and 2w+1 - Within warp, threads 0..15 handle query 2w, threads 16..31 handle query 2w+1 - Each thread handles 1 channel of its query For K/V loading: all 512 threads load K and V for the head-row into shared memory. Since K/V size is 32*16 = 512 floats, each thread loads 1 float! Perfect distribution. Wait, K has 32*16 = 512 floats. With 512 threads, each loads 1 float. But the block only needs K and V for one head. One head-row has N=32 positions, each with d=16 channels. K = 32*16 = 512 floats. V = 512 floats. Load K: 512 threads, each loads 1 float. `K_shared[threadIdx.x] = K[threadIdx.x]`. Done in 1 instruction (coalesced). Load V: same. Then for dot product: - Thread t handles channel t % 16 for query q = 2*warp_id + t/16 - Wait, this depends on how I assign threads. Actually, let me define: - warp_id = threadIdx.x / 32 (0..15) - lane_id = threadIdx.x % 32 (0..31) - query = 2 * warp_id + (lane_id / 16) → queries 0..31 - channel = lane_id % 16 → channels 0..15 So: - Warp 0: query 0 (lanes 0..15), query 1 (lanes 16..31) - Warp 1: query 2 (lanes 0..15), query 3 (lanes 16..31) - ... - Warp 15: query 30, query 31 Each warp handles 2 queries. Within a warp, threads for the same query need to compute the dot product and reduce. For query q, the active threads are every 16 threads (e.g., lanes 0..15 for q=0). The dot product partials are in these 16 lanes. I need to reduce across these 16 lanes. I can use warp shuffle with a mask. For lane 0..15, I can do a butterfly reduction: ```c for (int offset = 8; offset > 0; offset /= 2) { val += __shfl_down_sync(0x0000FFFF, val, offset); } ``` Wait, `__shfl_down_sync` with mask 0x0000FFFF operates on lanes 0..15. After this, lane 0 has the sum of lanes 0..15. Then I need to broadcast to all 16 lanes in that query group: ```c float score = __shfl_sync(0x0000FFFF, val, 0); ``` Actually, I think I can use a simpler pattern with `__shfl_xor_sync`: ```c for (int offset = 8; offset > 0; offset /= 2) { val += __shfl_xor_sync(0x0000FFFF, val, offset); } ``` After this, all lanes 0..15 have the sum. Then each lane computes its output channel. All lanes for the same query compute different channels, so no reduction needed for output. Finally, each thread writes its output channel. But writing to global memory is strided. To improve coalescing, I can reorder the output layout. Actually, for this approach, the write pattern would be: - Warp 0: threads 0..15 write query 0 channels 0..15 - Warp 0: threads 16..31 write query 1 channels 0..15 - etc. If scratch is stored as (B, N, N, D), then thread for query q and channel c writes to: `scratch[((b * N + i) * N + q) * D + head * d + c]` For a warp, threads write: - q=0, c=0..15 (addresses: base + 0*D + head*d + 0, base + 0*D + head*d + 1, ..., base + 0*D + head*d + 15) - q=1, c=0..15 (addresses: base + 1*D + head*d + 0, ...) For the first 16 threads, addresses are consecutive (stride 1 float). This is perfectly coalesced! For threads 16..31, addresses are also consecutive. So within a warp, I have two coalesced segments: one for q=0, c=0..15 and one for q=1, c=0..15. These are separated by D-16 floats. If I write each query's channels together, I get two coalesced writes of 16 floats each. Not perfect but much better than before. Actually, I can use float4 writes. Each query writes 16 floats = 4 float4s. Two queries = 8 float4s per warp. That's 8 store instructions per warp. This is getting complex but could be quite fast. However, let me step back. The real question is whether all this warp-level trickery will be faster than the simpler approach. Given the tiny problem size, my current kernel might already be winning significantly from fusion alone. Let me also consider another optimization: rather than restructuring attention, what if I just add more padding threads (e.g., 128 threads per block but only 32 do work)? This increases occupancy at the cost of some wasted threads. Actually, the simplest improvement is probably this: - Change attention block size from N=32 to 128 threads - Use 4 blocks per head-row (each handling 8 queries with 32 threads) - No, this doesn't work well. OR: - Change attention to 32 threads per block, but launch using CUDA stream priorities? Not relevant. OK, let me just try one specific optimization: increase the block size of preprocess and finalize from 64 to 128 threads. This doubles the threads per block and might improve occupancy. Also, let me try using torch.compile (Triton) instead of custom CUDA... no, the task requires custom CUDA. Let me think about one more thing: my q, k, v buffers. In preprocess, I write to q[r, h, j, dd]. In attention, I read the same. The layout is (B*N, h, N, d). This is good for attention because K for a head is contiguous. But wait, I create q, k, v with `torch.empty(B * N, h, N, d)`. In CUDA, I access with `q[((r * h + head) * N + j) * d + dd]`. PyTorch default layout is row-major. So for shape (B*N, h, N, d), strides are (h*N*d, N*d, d, 1). My indexing is correct. One more thing: in attention, when loading K and V: ```c for (int l = query; l < N * d; l += blockDim.x) { K_shared[l] = k[((r * h + head) * N) * d + l]; } ``` With blockDim.x = 32 and query = threadIdx.x. Thread 0 loads at offset 0, thread 1 at 1, etc. The global memory access is perfectly coalesced. Good. For Q loading in registers: ```c for (int dd = 0; dd < d; dd++) { q_local[dd] = q[((r * h + head) * N + query) * d + dd]; } ``` Thread 0 loads Q at offset (r*h+head)*N*d + 0*d + 0, thread 1 at +1*d + 0. These are strided by d=16 floats = 64 bytes. Consecutive threads access addresses 64 bytes apart. This is NOT coalesced for L1 (which prefers 128-byte contiguous accesses), but it IS coalesced for L2 in terms of cache line utilization. Each 128-byte cache line contains 2 queries' Q vectors. So thread 0 loads bytes 0..63, thread 1 loads bytes 64..127. These are in the same cache line. So the warp's 32 loads hit 16 cache lines. This is OK. Actually, this is still coalesced in the broader sense. Each thread loads 16 floats (64 bytes), and consecutive threads load consecutive 64-byte chunks. The warp loads 32 * 64 = 2048 bytes = 16 cache lines. The hardware can handle this in a few transactions. For the scratch write: ```c scratch[((b * N + i) * N + query) * D + head * d + dd] ``` Thread 0: base + 0*D + head*d + dd Thread 1: base + 1*D + head*d + dd ... Thread 31: base + 31*D + head*d + dd With D=64, consecutive threads differ by 64 floats = 256 bytes. The warp writes 32 values, each 256 bytes apart. Total span = 31*256 = 7936 bytes. Each cache line is 128 bytes, so 62 cache lines. Each thread writes 4 bytes (for dd=0), 1 cache line per 32 threads? No, each 256-byte stride means each thread's write is in a different cache line. 32 threads write to 32 different cache lines. This is terrible for coalescing. Yes! The scratch write is the biggest memory inefficiency in attention. With 32 threads writing strided by 256 bytes, the memory subsystem has to handle 32 separate cache lines for just 32 floats = 128 bytes of data. That's a 32x blowup. To fix this, I need to change the write pattern. Options: 1. Change scratch layout to make query dimension inner for each head 2. Use shared memory buffer for output and then do a transpose write Option 1: Store scratch as (B, N, h, N, d) — wait that's too many dimensions. Or reorder so within a block, threads write consecutively. If I change scratch to have layout where for each (b, i, head), the data is stored as (N, d) consecutively, then: `scratch[((b * N * h + i * h + head) * N + query) * d + dd]` Wait, but then finalize needs to read in a different order. Let me restructure. Actually, let me just change the indexing in finalize to match. The current finalize reads: `scratch[pos * D + tid]` where pos = blockIdx.x = b*N*N + i*N + j (the position index), and tid < D. This assumes scratch is stored as (B, N, N, D) with D contiguous. So `scratch[pos * D + c]` is position (b, i, j) channel c. In attention, I write `scratch[((b * N + i) * N + query) * D + head * d + dd]`. This writes to position (b, i, query), channels [head*d + dd]. For coalesced writes, I want consecutive threads to write consecutive channels for the SAME position. But threads handle different queries (0..31). So to write consecutively, I'd need the query dimension to be innermost. Like: `scratch[((b * N * N + i * N) * h + head) * N * d + query * d + dd]` With this layout: - Thread 0 writes to offset base + head*N*d + 0*d + dd - Thread 1 writes to offset base + head*N*d + 1*d + dd - Consecutive threads differ by d=16 floats = 64 bytes. Still strided by 64 bytes = half a cache line. Better than 256 bytes, but still not fully coalesced. If I make each thread write all d=16 channels for 1 query, then: ```c int base = ((b * N * N + i * N) * h + head) * N * d; for (int dd = 0; dd < d; dd++) { scratch[base + query * d + dd] = out_local[dd]; } ``` Consecutive threads (query=0,1,2,...) write to addresses base+0, base+1, base+2,... for dd=0. Wait no, they write to base+query*d+dd. With d=16, thread 0 writes to base+0..15, thread 1 to base+16..31. Each thread writes 16 consecutive floats, and consecutive threads write consecutive 16-float chunks. This is perfectly coalesced if each thread issues 4 float4 stores! With float4 stores, thread 0 writes to base, base+4, base+8, base+12. Thread 1 writes to base+16, base+20, base+24, base+28. These are all in the same cache line (128 bytes = 32 floats). Two threads' writes fit in one cache line. Actually, 4 threads' writes fit in 2 cache lines. Very efficient! Let me verify: - 16 floats = 64 bytes per query - Thread 0: 64 bytes at base+0 - Thread 1: 64 bytes at base+64 - Thread 2: base+128 - Thread 3: base+192 - 4 threads span 256 bytes = 2 cache lines. Actually, float4 writes: 4 floats = 16 bytes per store. A warp of 32 threads doing 4 float4 stores each = 128 store instructions (actually 4 per thread). The addresses for the first float4: base+0, base+64, base+128, ..., base+31*64. That's stride 64 bytes. For a warp, the first store accesses 32 addresses spaced 64 bytes apart. Total span = 32*64 = 2048 bytes = 16 cache lines. The hardware will issue 16 separate 128-byte transactions. Each transaction fetches 128 bytes, but only 16 bytes are used. 50% utilization. Not terrible for L2, but not great. Actually, consecutive threads accessing addresses 64 bytes apart is better than 256 bytes apart. And with 4 float4 writes per thread, the pattern is regular. If I could interleave channels across queries... for example, store as (query, channel) → (channel, query)? No, that would hurt the read pattern in finalize. Alternatively, in finalize, I read `scratch[pos * D + tid]` with tid = 0..D-1. This is coalesced for D=64 and 64 threads: thread 0 reads 0, thread 1 reads 1, etc. If scratch is stored as (B, N, N, h, d), then for thread tid in finalize reading channel tid: `scratch[pos * D + tid]` is natural since D = h*d. For attention, writing with query-oriented strided by d: ```c scratch[((b * N * N + i * N + query) * h + head) * d + dd] ``` This is the natural (B, N, N, h, d) layout. Let me check: - Thread 0 (query=0): writes at base + (0*h+head)*d + dd - Thread 1 (query=1): writes at base + (1*h+head)*d + dd - Thread 2: base + (2*h+head)*d + dd - Consecutive threads differ by h*d = 64 floats = 256 bytes. This is the same strided-by-256-bytes pattern. The only way to make writes coalesced is to have consecutive threads write to consecutive memory locations. In finalize, consecutive threads read consecutive channels for the same position. This IS coalesced. The issue is that attention and finalize need opposite layouts. One solution: use a transpose in shared memory. In attention, write to a shared memory buffer in (query, dd) layout. Then do a transpose to (dd, query) or just write in a way that consecutive threads write consecutively. Actually, in the scratch buffer, what matters is that finalize reads efficiently. Attention writes less data than finalize reads (attention writes 131K floats, finalize reads 131K floats and writes 131K floats, plus computes a matmul). So maybe the write coalescing in attention isn't the bottleneck. Let me think about this more carefully. For attention: - Each block writes N * d = 32 * 16 = 512 floats = 2KB - 256 blocks write 512KB total - Each write is strided by 256 bytes - Time for these writes: 512KB at 3.35 TB/s = 0.15 microseconds With poor coalescing (say 4x worse), it's 0.6 microseconds. Still very small. For the matmul operations: - Q@K.T per query-head: 512 FMAs - Attn @ V per query-head: 512 FMAs - Total: 1024 FMAs per query-head - 8192 query-heads: 8.4M FMAs - At 67 TFLOPS: 0.125 microseconds (pure compute) - At 10 TFLOPS (realistic for custom code): 0.84 microseconds So the attention kernel runtime is probably around 1-2 microseconds even with poor coalescing. Is this faster than PyTorch's bmm? PyTorch uses cuBLAS which might achieve 20-30 TFLOPS for these small matmuls. 8.4M FMAs at 20 TFLOPS = 0.42 microseconds. Plus softmax and masking. Total maybe 1-2 microseconds. So the fused kernel might be similar or slightly faster. The main win for my kernel is likely: 1. No separate memory allocation for q, k, v, attn, etc. — saves time 2. Fused operations reduce memory bandwidth 3. Fewer kernel launches Given the tiny runtime, kernel launch overhead (typically 5-10 microseconds per launch) might dominate! PyTorch launches ~12 kernels, while I launch only 3. That alone could save 50-100 microseconds. Actually, kernel launch overhead on H100 is around 2-5 microseconds. If PyTorch launches 10 kernels, that's 20-50 microseconds of overhead. My 3 kernels have 6-15 microseconds overhead. Plus my kernels actually do the computation in less time because of fusion. Wait, but PyTorch's cuBLAS kernels can overlap memory operations and might use persistent threads that reduce launch overhead. And PyTorch might cache compiled kernels. Still, for a 1-2 microsecond computation, 2-5 microseconds of kernel launch overhead is significant. My fused approach should win. OK so I'm fairly confident the current approach is good. Let me now make some specific optimizations that don't change the overall structure: 1. Use float4 for K/V loads in attention 2. Consider restructuring attention to have more threads per block for better occupancy 3. Use shared memory more effectively 4. Make sure mask indexing is correct Actually, for correctness, the mask indexing: `pair_mask[(b * N + i) * N + key]` should be `pair_mask[(b * N + query) * N + key]` IF the mask is on the query-key pair. Let me re-verify: - pair_mask shape in test: (B, N, N) - In reference: `mask = pair_mask.unsqueeze(1).unsqueeze(2)` → (B, 1, 1, N, N) - attn shape: (B, N, h, N, N) - mask is applied to attn with broadcasting - So mask[b, query, key] = pair_mask[b, query, key] So in attention kernel, query corresponds to the column index j. The reference does `attn.view(B, N, h, N, N)` where the first N after B is the row index (from r = b*N+i), and the last two Ns are query and key. So mask should be: `pair_mask[b, query, key]` which is `pair_mask[(b * N + query) * N + key]`. But wait, in my attention kernel, `i` is the row index from blockIdx.x, and `query` is `threadIdx.x`. The query in attention is the column position j. So `query = threadIdx.x`. For mask, I should use `query` not `i`. Let me fix this. Actually, in the preprocess kernel, the positions are (b, i, j). The block index `pos` maps to (b, i, j). In attention, `i` is the row and `query` is the column. In the reference: ```python x_flat = x.reshape(B * N, N, D) q = self.query(x_flat).view(B * N, N, h, d).transpose(1, 2) ``` For x_flat at index `r = b*N + i`, the N dimension is column j. So `q[r, :, j, :]` is the query for position j in row i. When computing attention `q @ k.T`, the output dimension is (N, N) where the first N is query j and the second N is key k. Then `attn.view(B, N, h, N, N)` gives dimensions: batch, row_i, head, query_j, key_k. The mask: `pair_mask.unsqueeze(1).unsqueeze(2)` → (B, 1, 1, N, N). Applied as `mask[b, 0, 0, query_j, key_k] = pair_mask[b, query_j, key_k]`. So yes, mask uses `query_j`, not `row_i`. My current code uses `pair_mask[(b * N + i) * N + key]`. This should be `pair_mask[(b * N + query) * N + key]`. But since the test uses all-ones mask, it doesn't matter. Let me fix it anyway. Actually, in the attention kernel, query = threadIdx.x. So for a block at row i, the queries are 0..31, and the mask should be based on the query position, not the row position. Wait, but `query` in my kernel is the thread's query index (the column position). The reference mask is based on pair_mask[b, :, :] for each row. But broadcasted across rows, it uses pair_mask[b, query_j, key_k] for ALL rows i. So the mask is the same for every row i, but depends on query_j and key_k. So yes, I should use `query` for mask. Let me fix. Now, another potential correctness issue: in preprocess, I compute bias as `bias[((b * h + out_c) * N + i) * N + j]`. In the reference, `bias = self.bias_proj(x).permute(0, 3, 1, 2)` which gives shape (B, h, N, N). The output of bias_proj for position (b, i, j) is a vector of h values. After permute(0, 3, 1, 2), the layout becomes: - Original: (B, N, N, h) — position (b, i, j, head) - Permuted: (B, h, N, N) — position (b, head, i, j) So bias[b, head, i, j] comes from bias_proj output at (b, i, j, head). My indexing: `bias[((b * h + out_c) * N + i) * N + j]` where out_c is head. This is `(b, head, i, j)` in row-major. Correct! In attention: `bias[((b * h + head) * N + i) * N + key]` This is `bias[b, head, i, key]`. The reference uses `bias.unsqueeze(1)` which gives shape (B, 1, h, N, N). When added to attn of shape (B, N, h, N, N), broadcasting gives `attn[b, i, head, query, key] += bias[b, 0, head, i, key]`. Wait, that's `bias[b, head, i, key]`. Yes, correct! So bias indexing is correct. Mask indexing is wrong (but tests pass due to all-ones). Let me fix the mask and also add some performance optimizations. For attention kernel, I'll try increasing the block size to 64 or 128 threads by having some threads process multiple heads or just padding. Actually, let me try a simpler approach: keep the query=threadIdx.x structure but use blockDim.x = 64 (or 128) and have each block process 2 queries per thread (or something similar). Hmm, 64 threads per block, N=32 queries. Each thread could handle 1 query for 2 different head-rows? But that would need K/V from 2 different head-rows. Not straightforward. What about 1 block per (b, i), 32 threads per block, each thread handles all h=4 heads sequentially? Grid: B*N = 64 blocks Threads: 32 Each thread: for head=0..3: - Load K, V for this head - Compute Q (16 floats) - Compute attention - Write output Total warps: 64 * 1 = 64. Even worse! No, let me not decrease parallelism. Let me try increasing it by using 128 threads per block and having each thread process 1 head for 1 query, but process multiple heads per row. Wait, h=4, N=32. Total query-head positions = 128 per row. With 128 threads, each handles exactly 1 position. Blocks = B*N = 64. Each block handles 1 row × all heads × all queries. Total warps = 64 * 4 = 256. Same total warps, but each block has 4 warps. Let me implement this restructuring. It might be cleaner and have better occupancy characteristics due to fewer blocks with more warps (even though total warps is the same, the warp scheduler works better with more warps per block). Actually, there's another consideration: shared memory loading. With 1 block per head-row, K/V loads use 32 threads. With 1 block per row (all 4 heads), K/V loads use 128 threads for 4 heads × 512 floats = 2048 floats. Each thread loads 16 floats. This is very coalesced and efficient. And with 4 warps per block, when one warp is computing, another might be loading or vice versa. The warp scheduler can hide latency between warps. But for L1 cache and shared memory, 4 warps per block sharing the same K/V is good. The shared memory isn't a bottleneck. Let me implement this: 1 block per (b, i), 128 threads = 4 warps. Each thread handles 1 head × 1 query. Shared memory: K and V for all 4 heads. 4 * 2 * 32 * 16 * 4 = 16KB. For Q: keep in registers (16 floats per thread). For bias: 4 * 32 * 32 floats = 4096 floats = 16KB. Hmm, that's getting large. Total shared memory = 16KB for K/V + maybe 16KB for bias = 32KB. Still within 48KB. Actually, bias can stay in global memory and be read directly. Each thread reads 32 floats (one per key). Or I can have threads load bias into shared memory at the start. Let me skip bias in shared memory for now; read it directly from global memory. Wait, but if I process all heads in one block, the Q, K, V for each head are loaded separately. Let me think about the data flow: 1. All 128 threads load x for the row into shared memory. x is 32 * 64 = 2048 floats = 8KB. Threads can load 16 floats each. 2. Compute layer norm for x. 3. Each thread computes its Q, K, V, gate, bias projections. For each position j, there are 4 heads × 32 queries = 128 thread-positions. Each thread computes 1 query for 1 head. But wait, the projections (Q, K, V, gate, bias) operate on the input x at position j. So thread tid: - j = (tid % 32) — wait, not necessarily. Actually, if thread 0 handles query=0, head=0, then it needs x[0] for projections (since Q,K,V at position j depend on x[j]). Thread 1 handles query=1, head=0, needs x[1]. And so on. So threads 0..31 all need x[0..31] respectively. To compute Q[head=0, query=0:31], each thread needs x[query] and W_q[head=0, channel:]. Hmm, this means all 32 threads for head=0 need to read all their respective x[query]s and compute Q, K, V. But the weights W_q are the same for all queries. So there's redundancy. In the preprocess kernel, each block handles ONE position and ALL projections. In the row-fused approach, each block handles ALL positions and projections. The total work is the same, just distributed differently. For the row-fused block: - Threads 0..31 (head=0): compute Q, K, V for queries 0..31 - Threads 32..63 (head=1): compute Q, K, V for queries 0..31 - etc. Each group of 32 threads loads x[0..31] (each thread loads its own x[j]). But they all need the same weights W_q for their respective heads. If W_q is in global memory, there'll be cache hits. Actually, for W_q, W_k, W_v, each head has its own slice of the weight matrix. The weight shape is (D, D) = (64, 64). For Q of head h, the weight slice is W_q[h*d : (h+1)*d, :]. That's d×D = 16×64 floats = 1KB per head per weight matrix. With 4 heads and 3 weight matrices (Q, K, V), total weight memory = 4 * 3 * 1KB = 12KB. Plus gate weights = 16KB, bias weights = 1KB. Total ~30KB. If I load all these weights into shared memory, I could avoid repeated global memory reads. But 30KB of weights is a lot. Actually, with 128 threads all computing simultaneously, each reads its own subset of weights. The L1 cache might capture some reuse, but not much. This is getting very complex. Let me take a step back and make targeted improvements to the existing 3-kernel approach. Key changes to make: 1. Fix mask indexing for correctness 2. Increase shared memory usage in preprocess to cache weights 3. Try different block sizes Actually, for this specific small size, let me check if PyTorch's reference is slower than my kernel. If my kernel is already winning from fusion, I might not need extreme optimization. Let me think about another approach: use CUTLASS or write a simpler kernel using torch's built-in fused operations where possible. Wait, one simple optimization: replace my custom preprocess/attention/finalize with a single kernel that does everything per row. But I'm not sure I can write that quickly. Another idea: in preprocess, I currently write q, k, v to global memory. Then attention reads them. But I can avoid some global memory by computing q, k, v in one kernel and immediately using them. But attention needs q, k, v for the entire row to compute the attention. Preprocess computes q, k, v per position. So the dependency is position → row. All positions in a row must be processed before attention can start. Unless I fuse preprocess and attention at the row level. Which is what I was considering. OK, let me try a hybrid approach: fuse preprocess and attention into one kernel per row. Each block handles one row. This eliminates the q, k, v global memory round trip. Block size: I'll use 128 threads (4 warps), but there are 32 positions per row. Each position needs its own projections and attention. Actually, for each row, I need to: 1. Compute x (LN) for all 32 positions 2. Compute Q, K, V for all positions and all heads 3. Compute attention for all queries and all heads If I use 128 threads: - Warp 0 (32 threads): process queries 0..7 for heads 0..? No, that doesn't fit. Let me try 2 blocks per row: - Block 0: queries 0..15 for all heads - Block 1: queries 16..31 for all heads With 64 threads per block: - Block 0: 64 threads for 16 queries × 4 heads = 64 positions. Each thread handles 1 position. - Block 1: same for queries 16..31. Total blocks = B * N * 2 = 128. Each block = 64 threads = 2 warps. Total warps = 256. Wait, this is the same total warps. I'm going in circles. Let me try yet another angle. What if I use many more threads than positions to load and compute? For example, 256 threads per block, where each position's projections are computed by multiple threads. This creates more parallel work. Block: 256 threads for 1 row. 32 positions × 4 heads = 128 query-head positions. With 256 threads, each query-head position gets 2 threads. For projections: 2 threads cooperate on one linear output. Thread 0 computes channels 0..7, thread 1 computes channels 8..15. Then they write to shared memory. This speeds up the projection. For attention: each query gets 2 threads. Thread 0 handles keys 0..15, thread 1 handles keys 16..31. But attention needs all keys for softmax. After partial computation, they'd need to combine. Actually, for attention, let's say 2 threads per query. Thread 0 computes scores for keys 0..15, thread 1 for keys 16..31. Then they need to: 1. Find global max across all 32 keys 2. Compute global sum of exp This requires inter-thread communication, which is complex. OK, the cleanest optimization I've identified is simply to restructure attention to process 4 heads per block (1 row, all heads, 128 threads). This doesn't create more warps, but it might improve the execution profile slightly. But the total warps is still 256. Hmm. Wait, I just realized: in preprocess and finalize, I have 2048 blocks with 64 threads each = 131K threads. That's 4096 warps. These kernels can fully utilize the GPU. The bottleneck is attention with only 256 warps. One solution: overlap attention with something else? No, it's a dependency chain. Could I move some of the attention work into preprocess or finalize? Actually, what if I compute q, k, v and immediately store them, but then have the attention kernel do MORE work — like process multiple rows? If each attention block processes 4 rows, blocks = B*N/4 = 16. But each block needs K/V for 4 rows, and each row has 4 heads. Shared memory = 4 * 4 * 2 * 32 * 16 * 4 = 65KB. Too much. How about 2 rows? Shared memory = 2 * 4 * 2 * 32 * 16 * 4 = 32KB. Manageable. Blocks = B*N/2 = 32. Each block = 128 threads = 4 warps. Total warps = 128. Worse! Or each block processes 2 rows with 256 threads. Total warps = 256. Same. I'm stuck at 256 total warps for attention. That's the fundamental limit of the problem. Unless... I process multiple query positions redundantly. No, that just wastes compute. Or: I use a different algorithm for attention that has more parallelism. For example, instead of each query computing its own attention, I tile the matrix multiply using many threads per tile. In standard matmul tiling, a block of threads (e.g., 16×16) computes a tile of the output matrix. For Q@K.T, the output is (N, N) = (32, 32). With 16×16 thread blocks, I'd need 4 blocks per head. Each block has 256 threads. Blocks per head-row = 4. Total blocks = 256 * 4 = 1024. Total threads = 1024 * 256 = 262K! Total warps = 8192! Wait, this is different. In this approach, I use many threads to compute partial dot products and reduce. The total thread count is much higher than the number of output elements. Let me think about this. For a tile of size 16×16 in the Q@K.T output: - C[i][j] = sum_{d=0}^{15} A[i][d] * B[j][d] - A block of 256 threads computes 16×16 output elements. For N=32, the output is 32×32, so I need 4 such blocks. For h=4 heads: 4 * 4 = 16 blocks per row. For B=2, N=32 rows: 2 * 32 * 16 = 1024 blocks. Each block = 256 threads = 8 warps. Total warps = 1024 * 8 = 8192! Warps per SM = 8192 / 132 ≈ 62. That's 97% occupancy! This is a huge win! Let me design this matmul-style attention kernel. Design: - Tile size: 16×16 for the (query, key) output matrix - Threads per block: 256 (16×16 threads, each computes 1 output element) - Each thread computes C[query][key] = sum_{dd} Q[query][dd] * K[key][dd] But wait, attention also needs: 1. Bias addition: bias[b, head, i, key] added to ALL queries for the same key 2. Mask: pair_mask[b, query, key] 3. Softmax over keys for each query 4. Output: out[query][dd] = sum_{key} attn[query][key] * V[key][dd] For softmax, each query needs to normalize across all 32 keys. After computing the raw scores, I'd need to: 1. For each query, find max score across keys 2. For each query, compute exp(score - max) and sum 3. For each query, divide by sum This requires reductions along the key dimension. In the tile-based approach: - Step 1: Compute all scores Q@K.T + bias. Each block handles a 16×16 tile. - After all blocks finish, I need global synchronization to do softmax. But CUDA blocks don't synchronize globally within a kernel. - I'd need a second kernel for softmax, or use atomics / global memory. Alternatively, I can compute the entire (query, key) = (32, 32) matrix within one block. With 256 threads, each handling 4 output elements (since 32×32/256 = 4). But then I need to store the full 32×32 matrix in shared memory for softmax. A 32×32 float matrix = 1024 floats = 4KB. For 4 heads per block? That'd be 16KB. Still manageable. But each block handling one head-row (b, i, head) with 256 threads: - 256 threads compute 32×32 = 1024 scores. Each thread computes 4 scores. - After all scores computed in shared memory, do softmax per query. - Then compute output: 256 threads compute 32×16 = 512 output elements. Each thread computes 2 elements. Total blocks = B*N*h = 256. Each block = 256 threads = 8 warps. Total warps = 2048! Warps per SM = 2048/132 ≈ 15.5. That's 24% occupancy. Better than 3%! And I don't need a second kernel for softmax. Let me design this kernel: Block: one (b, i, head), threads: 256 Shared memory: - scores: 32 * 32 floats = 4KB (for raw scores) - Maybe K: 32 * 16 = 512 floats (but can load directly from global) - Actually, I'll load K and V into shared memory as before, but the thread organization is different. Wait, with 256 threads and 32 queries, I have 8 threads per query. If I assign threads to output elements of the matmul, each thread handles 1 or more (query, key) pairs. For a 32×32 output with 256 threads: 256/1024 = 0.25 outputs per thread? No, 1024 outputs for 256 threads = 4 outputs per thread. Each thread computes 4 score elements. For example: ```c int q0 = threadIdx.x % 32; // But 256 threads can't all fit in 32 queries... ``` Actually, 256 threads, 32 queries. I can have 8 threads per query. Each thread handles 4 keys. Thread assignment: ```c int query = threadIdx.x / 8; // 0..31 int key_group = threadIdx.x % 8; // 0..7 ``` Thread handles 4 keys: key = key_group * 4 + k_offset where k_offset = 0..3. For each key, thread computes score[query][key] = dot(Q[query], K[key]) + bias[head][i][key]. Q is loaded into registers for this thread (16 floats). K is loaded from shared memory (which all threads loaded cooperatively). After computing 4 scores, all threads in the block (256 threads) have computed all 1024 scores. But they're scattered across threads. I need to put them into shared memory for softmax. Each thread writes its 4 scores to shared memory: ```c scores[query * 32 + key] = score; ``` After __syncthreads(), all scores are in shared memory. Then for softmax, each query needs its 32 scores. I can assign 32 threads (one per query) to do the softmax: ```c int softmax_query = threadIdx.x; // 0..255, but I'll use first 32 threads ``` Actually, all 256 threads can participate. 32 threads handle 1 query each (using threads query*8, but only 1 thread per query does softmax). Or I can use more efficient parallel reduction. With 32 queries and 256 threads, I can have 8 threads per query do the softmax reduction. But softmax for one query is: 1. Find max of 32 values 2. Compute exp(value - max) for each 3. Sum the exps 4. Divide each by sum With 8 threads per query: - Threads 0..7 handle query 0, reading scores[0][key0..key3] (4 keys each) - Find local max, then reduce across 8 threads - Compute local exp sum, reduce - Then all 8 threads normalize their 4 values This is very efficient! After softmax, scores are normalized in shared memory. Then for output computation: out[query][dd] = sum_{key} scores[query][key] * V[key][dd]. Again, 256 threads. 32 queries, 16 channels. Total outputs = 32 * 16 = 512. Each thread computes 2 outputs. Thread assignment: ```c int out_query = threadIdx.x / 16; // 0..15, but we have 32 queries... ``` Wait, 256 threads for 512 outputs = 2 outputs per thread. Each output is 1 channel for 1 query. So each thread handles (query0, ch0) and (query1, ch1). Actually, it's cleaner to have: ```c int out_idx = threadIdx.x; // 0..255 int query0 = out_idx / 16; // 0..15 int ch0 = out_idx % 16; int query1 = query0 + 16; // 16..31 int ch1 = ch0; ``` Each thread computes 2 output channels (one for query 0..15, one for query 16..31, same channel). For query q and channel c: ```c float val = 0; for (int key = 0; key < 32; key++) { val += scores[q * 32 + key] * V[key * 16 + c]; } output[q * 16 + c] = val; ``` V is in shared memory (32×16 = 512 floats). All threads access it. Then, after output is computed, apply gate and out_proj. Gate and out_proj are per-position linear layers. Each thread can handle its own outputs. Hmm, but output is just the attention result (size h*d = D = 64 per position). So after computing the 16 attention channels for head `head`, I need to store them, then move to other heads. Wait, in this design, each block handles only ONE head! For h=4 heads per row, I'd need 4 blocks per row. Blocks = B * N * h = 256. Each block = 256 threads = 8 warps. Total warps = 2048. This is good! Let me refine this kernel. Kernel: one block per (b, i, head), threads = 256 Shared memory: - K_shared: N * d = 512 floats = 2KB - V_shared: 2KB - scores: N * N = 1024 floats = 4KB - maybe temp for softmax: 1KB Total: ~10KB Execution: 1. Compute r = b*N + i 2. Load x[r, j] for all j into... actually, I need x to compute Q, K, V. But in this design, I assume Q, K, V are already computed? Wait, I still need to compute Q, K, V from x. I was thinking of keeping preprocess separate. So the flow would be: 1. Preprocess kernel: computes Q, K, V, gate, bias (unchanged, writes to global memory) 2. Attention kernel (new design): reads Q, K, V, bias, mask, writes scratch 3. Finalize kernel: reads scratch, gate, writes output For the new attention kernel: ```c __global__ void attention_kernel_v2( const float* q, const float* k, const float* v, const float* bias, const float* pair_mask, float* scratch, int B, int N, int D, int h, int d ) { int b = blockIdx.x / (N * h); int rem = blockIdx.x % (N * h); int i = rem / h; int head = rem % h; int r = b * N + i; int tid = threadIdx.x; extern __shared__ float smem[]; float* K_s = smem; float* V_s = smem + N * d; float* scores = smem + 2 * N * d; // Load K and V for this head-row for (int l = tid; l < N * d; l += blockDim.x) { K_s[l] = k[((r * h + head) * N) * d + l]; V_s[l] = v[((r * h + head) * N) * d + l]; } __syncthreads(); // Compute scores: Q @ K.T + bias // Each thread handles 4 (query, key) pairs int num_score_pairs = N * N; // 1024 int pairs_per_thread = (num_score_pairs + blockDim.x - 1) / blockDim.x; // 4 float q_reg[16]; int query_for_q = tid / 32; // We'll load Q for specific queries // Actually, let's assign queries differently } ``` This is getting very complex to implement correctly. The thread assignment needs to be very careful. Let me think of a simpler tile design: - 32×32 output matrix. Divide into 8 tiles of size 16×8 (32 elements each). - 256 threads / 32 = 8 tiles. Each thread handles one tile? No, 256 threads for 1024 elements. - Divide into tiles of 16×16 = 256 elements. Need 4 tiles. 256 threads / 4 tiles = 64 threads per tile. - Each thread in a tile handles 256/64 = 4 elements. For a 16×16 tile, 64 threads: - Row r = threadIdx.x / 16 (0..3 within tile) - Col c = threadIdx.x % 16 - Actually, 64 threads for 16×16 = 256 elements. Each thread handles 4 elements. - Element assignment: (r0, c0), (r0+4, c0), (r0+8, c0), (r0+12, c0) where r0 = (tid % 64) / 16, c0 = tid % 16... Hmm, this gets complicated. I think the cleanest is: - 256 threads, each handles (query, key) pairs spaced by 256. - Thread tid handles pairs: tid, tid+256, tid+512, tid+768. - For each pair (q, k): score = dot(Q[q], K[k]) + bias[b, head, i, k] - Q[q] loaded into registers (temporarily, per pair). To load Q efficiently: each of the 32 queries' Q vectors is needed by 32 threads (one per key). If I load Q into shared memory, all 32 threads can read it. Q shared memory: 32 * 16 = 512 floats = 2KB. Total shared memory: K(2KB) + V(2KB) + Q(2KB) + scores(4KB) = 10KB. Kernel steps: 1. Load K, V into shared memory 2. Load Q into shared memory (all 32 queries × 16 channels) 3. All threads compute their assigned scores: - For pair (q, k): read Q[q] and K[k] from shared memory, compute dot product - Each thread computes 4 scores 4. Write scores to global/shared memory 5. Do softmax (complex with this thread layout) 6. Compute output Actually, step 5 (softmax) is hard if scores are distributed. If I put scores in shared memory, I can do softmax there. But with 4KB for scores, I need all 1024 scores in shared memory. After computing scores, each thread writes to `scores[q * N + k]`. Then __syncthreads(). Now all scores are in shared memory. Then for softmax, threads 0..31 (one per query) can do the max/sum normalizations. Each thread loops over 32 keys. Then for output, threads compute out[q][c] using scores and V. Thread assignment for output: 256 threads, 512 outputs (32 queries × 16 channels). Each thread computes 2 outputs. ```c int out_base = tid * 2; int q0 = out_base / 16; int c0 = out_base % 16; int q1 = q0 + 1; // or handle differently ``` Actually, 256 threads for 512 outputs = 2 each. Each thread computes: - output[q0][c0] and output[q1][c1] Where q0 = (tid % 128) / 16 = 0..7, c0 = tid % 16 = 0..15 Then q1 = q0 + 8, c1 = c0. So thread tid handles (query, channel) pairs: - (q0, c0) and (q0+8, c0) where q0 = (tid % 128) / 16, c0 = tid % 16. Each thread computes 2 outputs: ```c float val0 = 0, val1 = 0; for (int key = 0; key < N; key++) { float s0 = scores[q0 * N + key]; float s1 = scores[(q0 + 8) * N + key]; float v_k = V_s[key * 16 + c0]; val0 += s0 * v_k; val1 += s1 * v_k; } ``` Then each thread writes 2 values to scratch. This tile-based attention with 256 threads per block seems very promising! Let me write it out carefully. But the complexity is high and there might be bugs. Let me try something simpler first. Simpler approach: in the current attention kernel, just change from 32 threads to 64 threads per block, with each block still handling 1 head-row but using multiple threads per query. With 64 threads and 32 queries, each query gets 2 threads. The 2 threads split the d=16 dot product: - Thread 0: channels 0..7 - Thread 1: channels 8..15 After computing partials, reduce via warp shuffle or shared memory. For 64 threads: - 2 threads per query - 32 queries per head-row - 64 threads per block - Blocks = B*N*h = 256 - Total warps = 256 * 2 = 512 - Warps per SM = 512/132 ≈ 3.9 Better! And not too complex. Let me implement this: ```c __global__ void attention_kernel_64( const float* q, const float* k, const float* v, const float* bias, const float* pair_mask, float* scratch, int B, int N, int D, int h, int d ) { int b = blockIdx.x / (N * h); int rem = blockIdx.x % (N * h); int i = rem / h; int head = rem % h; int r = b * N + i; int tid = threadIdx.x; int query = tid / 2; // 0..31 int t = tid % 2; // 0..1 (which half of channels) int d_half = d / 2; // 8 extern __shared__ float shared[]; float* K_s = shared; float* V_s = shared + N * d; // Load K and V for (int l = tid; l < N * d; l += blockDim.x) { K_s[l] = k[((r * h + head) * N) * d + l]; V_s[l] = v[((r * h + head) * N) * d + l]; } __syncthreads(); // Load Q for this query (all 16 channels) float q_reg[16]; for (int dd = 0; dd < d; dd++) { q_reg[dd] = q[((r * h + head) * N + query) * d + dd]; } // Compute scores for this query float scores[32]; float max_score = -1e9f; for (int key = 0; key < N; key++) { float dot = 0.0f; for (int dd = t * d_half; dd < (t + 1) * d_half; dd++) { dot += q_reg[dd] * K_s[key * d + dd]; } // Reduce with the other thread for same query dot += __shfl_xor_sync(0xFFFFFFFF, dot, 1); float score = dot / sqrtf((float)d); score += bias[((b * h + head) * N + i) * N + key]; // Wait, should be query for mask but i for bias? if (pair_mask[(b * N + query) * N + key] == 0.0f) { score = -1e9f; } scores[key] = score; if (score > max_score) max_score = score; } // Softmax float sum_exp = 0.0f; for (int key = 0; key < N; key++) { scores[key] = expf(scores[key] - max_score); sum_exp += scores[key]; } float inv_sum = 1.0f / sum_exp; for (int key = 0; key < N; key++) { scores[key] *= inv_sum; } // Compute output float out_reg[16]; for (int dd = 0; dd < d; dd++) out_reg[dd] = 0.0f; for (int key = 0; key < N; key++) { float s = scores[key]; for (int dd = t * d_half; dd < (t + 1) * d_half; dd++) { out_reg[dd] += s * V_s[key * d + dd]; } } // Write output (both threads write all channels) for (int dd = 0; dd < d; dd++) { int out_idx = ((b * N + i) * N + query) * D + head * d + dd; scratch[out_idx] = out_reg[dd]; } } ``` Wait, there's an issue with the shuffle. `__shfl_xor_sync(0xFFFFFFFF, dot, 1)` exchanges between adjacent threads. Thread 0 and thread 1 are for the same query. Thread 2 and thread 3 are for query 1, etc. XOR with 1 swaps between thread pairs: - Thread 0 (binary 000000) XOR 1 = thread 1 (000001) ✓ - Thread 2 (000010) XOR 1 = thread 3 (000011) ✓ - Thread 4 (000100) XOR 1 = thread 5 (000101) ✓ So `__shfl_xor_sync(mask, dot, 1)` correctly pairs threads for the same query! After the shuffle, both threads in the pair have the full dot product. Then both compute the same score, same softmax, same output. There's duplication but no correctness issue. For the write, both threads write the same output. This is a race condition! Both threads are writing to the same memory location. If they write the same value, it's technically fine for common GPU memory models (write same value is safe), but it's wasteful. To avoid the race, I can have only 1 thread per query write. For example, thread with t=0 writes, t=1 skips. Alternatively, each thread can write only its half of the channels. But then the output in scratch needs to be assembled. Hmm. Actually, it's simpler to just let both threads write the full output. In practice, since they write the same value, there's no correctness issue (though it wastes bandwidth). Wait, scratch is in global memory. Having 64 threads but only 32 queries means 2 threads per query. Both threads writing the same address is definitely a waste. Let me fix it. I can use a shared memory buffer for the output. Each thread writes its computed channels to a shared memory buffer, then one thread writes to global memory. But that's more complex. Alternatively, just have 1 thread per query do everything, and the extra thread helps only with dot product reduction. After that, t=1 thread can return early. ```c if (t == 1) return; // after computing and reducing dot product // But then t=1 thread doesn't participate in loading K/V or in __syncthreads() after K/V load. // So I need to make sure t=1 returns AFTER all __syncthreads() calls. ``` Wait, K/V loading has `__syncthreads()`. If t=1 returns before the second `__syncthreads()`, it'll deadlock. CUDA has `__syncthreads_and` but not conditional sync. Actually, `__syncthreads()` requires ALL threads to reach it. So t=1 can't return early. New approach: don't have t=1 thread skip. Instead, have it compute everything but not write to global memory. Or, have only t=0 threads write. ```c if (t == 0) { for (int dd = 0; dd < d; dd++) { int out_idx = ...; scratch[out_idx] = out_reg[dd]; } } ``` But t=1 still needs the `out_reg` values. Actually, out_reg is the same for both threads if they compute everything. So t=0 writes, t=1 skips. For the bias in attention: I used `bias[((b * h + head) * N + i) * N + key]`. Let me check: bias shape should be (B, h, N, N). So bias[b, head, i, key]. My indexing gives base = b*h*N*N + head*N*N + i*N + key. But the preprocess wrote: `bias[((b * h + out_c) * N + i) * N + j]` where out_c is head, i is row, j is column. So bias for row i and key k is: `bias[b, head, i, k]`. Yes, this matches. In attention, I'm reading `bias[b, head, i, key]` where key is the column dimension. Correct. For the mask: it should be `pair_mask[(b * N + query) * N + key]`. Let me fix this. OK, I think the 64-thread approach is worth trying. It doubles the warps per block for attention. Let me implement it. Actually, let me reconsider whether I should change the structure at all. The correctness already passes. Maybe the simplest optimization is just to fix the mask and increase the block size to 64 (or even 128 with 4 threads per query). With 4 threads per query: - d=16 channels, 4 threads each handle 4 channels - `__shfl_xor_sync` with XOR 1, then XOR 2 to reduce across 4 threads - 4 threads per query × 32 queries = 128 threads per block - Blocks = 256 - Total warps = 256 * 4 = 1024 - Warps per SM = 1024/132 ≈ 7.8 That's better! And writing: only thread 0 writes the full output. Let me implement this 128-thread version. It might be more impactful. For the shared memory K/V load, with 128 threads and N*d=512 floats, each thread loads 4 floats. Perfect. Actually, I realize I should just implement a straightforward fused kernel that doesn't overcomplicate things. The current 3-kernel version passes correctness. Let me optimize the attention kernel with 2 threads per query (64 threads) and see if it helps. Wait, let me check if there are other issues first. The max difference in correctness is very small (1.49e-07), which is great. The reference uses dropout, but dropout is random and disabled during evaluation. Wait, dropout... in the reference `self.dropout(attn)` applies dropout. In my kernel, I don't apply dropout. Is dropout applied during evaluation? Yes, `nn.Dropout` only applies during training (`self.training = True`). During eval, it does nothing. In the correctness test, the model is set to eval mode, so dropout is a no-op. So it's fine that I skip dropout. OK, let me try to run a quick benchmark to see if the current kernel is already faster. But the tools don't give me timing for correctness runs. I need to submit to see timing. Let me fix the mask and try the 64-thread optimization, then run correctness and submit. Actually, I'm overcomplicating this. Let me just make these specific targeted changes: 1. Fix mask indexing 2. Change attention block size to 64 threads (2 per query) using shuffle reduction 3. Keep preprocess and finalize mostly the same Let me start coding this.
call → compile_kernel
callcompile_kernel { "kernel_code": "\nimport torch\nimport torch.nn as nn\nimport math\nfrom torch.utils.cpp_extension import load_inline\n\nCUDA_SOURCE = r\"\"\"\n#include <torch/extension.h>\n#include <cuda_runtime.h>\n#include <math.h>\n\n__global__ void preprocess_kernel(\n const float* __restrict__ pair_repr,\n const float* __restrict__ ln_weight,\n const float* __restrict__ ln_bias,\n const float* __restrict__ W_query,\n const float* __restrict__ W_key,\n const float* __restrict__ W_value,\n const float* __restrict__ W_gate,\n const float* __restrict__ b_gate,\n const float* __restrict__ W_bias,\n float* __restrict__ q,\n float* __restrict__ k,\n float* __restrict__ v,\n float* __restrict__ gate,\n float* __restrict__ bias,\n int B, int N, int D, int h, int d\n) {\n int pos = blockIdx.x;\n int tid = threadIdx.x;\n \n extern __shared__ float shared_mem[];\n float* x_shared = shared_mem;\n float* reduce_buf = shared_mem + 128;\n \n if (tid < D) {\n x_shared[tid] = pair_repr[pos * D + tid];\n }\n __syncthreads();\n \n if (tid < D) {\n reduce_buf[tid] = x_shared[tid];\n } else {\n reduce_buf[tid] = 0.0f;\n }\n __syncthreads();\n \n for (int stride = blockDim.x / 2; stride > 0; stride /= 2) {\n if (tid < stride) {\n reduce_buf[tid] += reduce_buf[tid + stride];\n }\n __syncthreads();\n }\n float mean = reduce_buf[0] / D;\n \n if (tid < D) {\n float diff = x_shared[tid] - mean;\n reduce_buf[tid] = diff * diff;\n } else {\n reduce_buf[tid] = 0.0f;\n }\n __syncthreads();\n \n for (int stride = blockDim.x / 2; stride > 0; stride /= 2) {\n if (tid < stride) {\n reduce_buf[tid] += reduce_buf[tid + stride];\n }\n __syncthreads();\n }\n float var = reduce_buf[0] / D;\n \n if (tid < D) {\n x_shared[tid] = (x_shared[tid] - mean) * rsqrtf(var + 1e-5f) * ln_weight[tid] + ln_bias[tid];\n }\n __syncthreads();\n \n int b = pos / (N * N);\n int rem = pos % (N * N);\n int i = rem / N;\n int j = rem % N;\n int r = b * N + i;\n \n for (int out_c = tid; out_c < D; out_c += blockDim.x) {\n float val = 0.0f;\n for (int c = 0; c < D; c++) {\n val += W_query[out_c * D + c] * x_shared[c];\n }\n int head = out_c / d;\n int dd = out_c % d;\n q[((r * h + head) * N + j) * d + dd] = val;\n }\n \n for (int out_c = tid; out_c < D; out_c += blockDim.x) {\n float val = 0.0f;\n for (int c = 0; c < D; c++) {\n val += W_key[out_c * D + c] * x_shared[c];\n }\n int head = out_c / d;\n int dd = out_c % d;\n k[((r * h + head) * N + j) * d + dd] = val;\n }\n \n for (int out_c = tid; out_c < D; out_c += blockDim.x) {\n float val = 0.0f;\n for (int c = 0; c < D; c++) {\n val += W_value[out_c * D + c] * x_shared[c];\n }\n int head = out_c / d;\n int dd = out_c % d;\n v[((r * h + head) * N + j) * d + dd] = val;\n }\n \n for (int out_c = tid; out_c < D; out_c += blockDim.x) {\n float val = b_gate[out_c];\n for (int c = 0; c < D; c++) {\n val += W_gate[out_c * D + c] * x_shared[c];\n }\n gate[pos * D + out_c] = 1.0f / (1.0f + expf(-val));\n }\n \n for (int out_c = tid; out_c < h; out_c += blockDim.x) {\n float val = 0.0f;\n for (int c = 0; c < D; c++) {\n val += W_bias[out_c * D + c] * x_shared[c];\n }\n bias[((b * h + out_c) * N + i) * N + j] = val;\n }\n}\n\n__global__ void attention_kernel(\n const float* __restrict__ q,\n const float* __restrict__ k,\n const float* __restrict__ v,\n const float* __restrict__ bias,\n const float* __restrict__ pair_mask,\n float* __restrict__ scratch,\n int B, int N, int D, int h, int d\n) {\n int b = blockIdx.x / (N * h);\n int rem = blockIdx.x % (N * h);\n int i = rem / h;\n int head = rem % h;\n int r = b * N + i;\n int tid = threadIdx.x;\n \n int query = tid / 2;\n int t = tid % 2;\n int d_half = d / 2;\n \n if (query >= N) return;\n \n extern __shared__ float shared_mem2[];\n float* K_shared = shared_mem2;\n float* V_shared = shared_mem2 + N * d;\n \n for (int l = tid; l < N * d; l += blockDim.x) {\n K_shared[l] = k[((r * h + head) * N) * d + l];\n V_shared[l] = v[((r * h + head) * N) * d + l];\n }\n __syncthreads();\n \n float q_local[32];\n for (int dd = 0; dd < d; dd++) {\n q_local[dd] = q[((r * h + head) * N + query) * d + dd];\n }\n \n float scores[64];\n float max_score = -1e9f;\n \n for (int key = 0; key < N; key++) {\n float dot = 0.0f;\n for (int dd = t * d_half; dd < (t + 1) * d_half; dd++) {\n dot += q_local[dd] * K_shared[key * d + dd];\n }\n dot += __shfl_xor_sync(0xFFFFFFFF, dot, 1);\n float score = dot / sqrtf((float)d);\n score += bias[((b * h + head) * N + i) * N + key];\n if (pair_mask[(b * N + query) * N + key] == 0.0f) {\n score = -1e9f;\n }\n scores[key] = score;\n if (score > max_score) max_score = score;\n }\n \n float sum_exp = 0.0f;\n for (int key = 0; key < N; key++) {\n scores[key] = expf(scores[key] - max_score);\n sum_exp += scores[key];\n }\n float inv_sum = 1.0f / sum_exp;\n for (int key = 0; key < N; key++) {\n scores[key] *= inv_sum;\n }\n \n float out_local[32];\n for (int dd = 0; dd < d; dd++) out_local[dd] = 0.0f;\n \n for (int key = 0; key < N; key++) {\n float s = scores[key];\n for (int dd = t * d_half; dd < (t + 1) * d_half; dd++) {\n out_local[dd] += s * V_shared[key * d + dd];\n }\n }\n \n if (t == 0) {\n for (int dd = 0; dd < d; dd++) {\n int out_idx = ((b * N + i) * N + query) * D + head * d + dd;\n scratch[out_idx] = out_local[dd];\n }\n }\n}\n\n__global__ void finalize_kernel(\n const float* __restrict__ scratch,\n const float* __restrict__ gate,\n const float* __restrict__ W_out,\n const float* __restrict__ b_out,\n float* __restrict__ output,\n int B, int N, int D\n) {\n int pos = blockIdx.x;\n int tid = threadIdx.x;\n \n extern __shared__ float s3[];\n float* gated_shared = s3;\n \n if (tid < D) {\n gated_shared[tid] = scratch[pos * D + tid] * gate[pos * D + tid];\n }\n __syncthreads();\n \n for (int c = tid; c < D; c += blockDim.x) {\n float out_val = b_out[c];\n for (int d = 0; d < D; d++) {\n out_val += W_out[c * D + d] * gated_shared[d];\n }\n output[pos * D + c] = out_val;\n }\n}\n\nvoid launch_preprocess(\n torch::Tensor pair_repr, torch::Tensor ln_weight, torch::Tensor ln_bias,\n torch::Tensor W_query, torch::Tensor W_key, torch::Tensor W_value,\n torch::Tensor W_gate, torch::Tensor b_gate, torch::Tensor W_bias,\n torch::Tensor q, torch::Tensor k, torch::Tensor v,\n torch::Tensor gate, torch::Tensor bias,\n int B, int N, int D, int h, int d\n) {\n int total_pos = B * N * N;\n int smem = 256 * sizeof(float);\n preprocess_kernel<<<total_pos, 64, smem>>>(\n pair_repr.data_ptr<float>(), ln_weight.data_ptr<float>(), ln_bias.data_ptr<float>(),\n W_query.data_ptr<float>(), W_key.data_ptr<float>(), W_value.data_ptr<float>(),\n W_gate.data_ptr<float>(), b_gate.data_ptr<float>(), W_bias.data_ptr<float>(),\n q.data_ptr<float>(), k.data_ptr<float>(), v.data_ptr<float>(),\n gate.data_ptr<float>(), bias.data_ptr<float>(),\n B, N, D, h, d\n );\n}\n\nvoid launch_attention(\n torch::Tensor q, torch::Tensor k, torch::Tensor v,\n torch::Tensor bias, torch::Tensor pair_mask,\n torch::Tensor scratch,\n int B, int N, int D, int h, int d\n) {\n int blocks = B * N * h;\n int threads = 64;\n int smem = 2 * N * d * sizeof(float);\n attention_kernel<<<blocks, threads, smem>>>(\n q.data_ptr<float>(), k.data_ptr<float>(), v.data_ptr<float>(),\n bias.data_ptr<float>(), pair_mask.data_ptr<float>(),\n scratch.data_ptr<float>(),\n B, N, D, h, d\n );\n}\n\nvoid launch_finalize(\n torch::Tensor scratch, torch::Tensor gate,\n torch::Tensor W_out, torch::Tensor b_out,\n torch::Tensor output,\n int B, int N, int D\n) {\n int blocks = B * N * N;\n int threads = 64;\n int smem = 128 * sizeof(float);\n finalize_kernel<<<blocks, threads, smem>>>(\n scratch.data_ptr<float>(), gate.data_ptr<float>(),\n W_out.data_ptr<float>(), b_out.data_ptr<float>(),\n output.data_ptr<float>(),\n B, N, D\n );\n}\n\"\"\"\n\nCPP_SOURCE = r\"\"\"\nvoid launch_preprocess(\n torch::Tensor pair_repr, torch::Tensor ln_weight, torch::Tensor ln_bias,\n torch::Tensor W_query, torch::Tensor W_key, torch::Tensor W_value,\n torch::Tensor W_gate, torch::Tensor b_gate, torch::Tensor W_bias,\n torch::Tensor q, torch::Tensor k, torch::Tensor v,\n torch::Tensor gate, torch::Tensor bias,\n int B, int N, int D, int h, int d);\n\nvoid launch_attention(\n torch::Tensor q, torch::Tensor k, torch::Tensor v,\n torch::Tensor bias, torch::Tensor pair_mask,\n torch::Tensor scratch,\n int B, int N, int D, int h, int d);\n\nvoid launch_finalize(\n torch::Tensor scratch, torch::Tensor gate,\n torch::Tensor W_out, torch::Tensor b_out,\n torch::Tensor output,\n int B, int N, int D);\n\"\"\"\n\nclass ModelNew(nn.Module):\n def __init__(self, pair_dim, num_heads, dropout=0.0):\n super().__init__()\n self.num_heads = num_heads\n self.head_dim = pair_dim // num_heads\n \n self.layer_norm = nn.LayerNorm(pair_dim)\n self.query = nn.Linear(pair_dim, pair_dim, bias=False)\n self.key = nn.Linear(pair_dim, pair_dim, bias=False)\n self.value = nn.Linear(pair_dim, pair_dim, bias=False)\n self.gate = nn.Linear(pair_dim, pair_dim)\n self.bias_proj = nn.Linear(pair_dim, num_heads, bias=False)\n self.out_proj = nn.Linear(pair_dim, pair_dim)\n self.dropout = nn.Dropout(dropout)\n \n self.cuda_module = load_inline(\n name=\"evoformer_cuda_v3\",\n cpp_sources=CPP_SOURCE,\n cuda_sources=CUDA_SOURCE,\n functions=[\"launch_preprocess\", \"launch_attention\", \"launch_finalize\"],\n extra_cuda_cflags=[\"-O3\", \"--use_fast_math\"],\n verbose=False,\n )\n \n def forward(self, pair_repr, pair_mask):\n B, N, _, D = pair_repr.shape\n h = self.num_heads\n d = self.head_dim\n \n pair_repr = pair_repr.contiguous()\n pair_mask = pair_mask.contiguous()\n \n device = pair_repr.device\n \n q = torch.empty(B * N, h, N, d, device=device, dtype=torch.float32)\n k = torch.empty(B * N, h, N, d, device=device, dtype=torch.float32)\n v = torch.empty(B * N, h, N, d, device=device, dtype=torch.float32)\n gate = torch.empty(B, N, N, D, device=device, dtype=torch.float32)\n bias = torch.empty(B, h, N, N, device=device, dtype=torch.float32)\n scratch = torch.empty(B, N, N, D, device=device, dtype=torch.float32)\n output = torch.empty(B, N, N, D, device=device, dtype=torch.float32)\n \n self.cuda_module.launch_preprocess(\n pair_repr,\n self.layer_norm.weight,\n self.layer_norm.bias,\n self.query.weight,\n self.key.weight,\n self.value.weight,\n self.gate.weight,\n self.gate.bias,\n self.bias_proj.weight,\n q, k, v, gate, bias,\n B, N, D, h, d\n )\n \n self.cuda_module.launch_attention(\n q, k, v, bias, pair_mask,\n scratch,\n B, N, D, h, d\n )\n \n self.cuda_module.launch_finalize(\n scratch, gate,\n self.out_proj.weight,\n self.out_proj.bias,\n output,\n B, N, D\n )\n \n return output\n" }
result ← compile_kernel (ok)
okcompile_kernel compile_kernel PASSED: kernel compiled without errors.
turn 8 · 1 tool call(s) · 21.2s turn · 21.2s LLM · 15,018 chars (click to expand)
turn 821.2s turn · 21.2s LLM · 0.0s tools
reasoning
I'm being asked to either make a concrete next change or submit now. I had a correctness-passed kernel earlier, then I tried an optimization with warp shuffles that compiled but I didn't run correctness on it. Wait, I compiled the shuffle version but didn't call run_correctness on it. Actually, looking back, my previous run_correctness PASSED (5/5 trials). That was with the version using N threads per block in the attention kernel. Then I tried a version with 64 threads and shuffle XOR, but I only compiled it - never checked correctness. I should either run correctness on that new version or just submit the version that passed. Let me be safe: I'll submit the known-good version (v2) that passed correctness. But wait, I compiled v3 under the same name as v2 (evoformer_cuda_v3) but with different CUDA code. The compile succeeded but I should check correctness. Actually, the last thing I said before the user's message was that I compiled v3. Let me run correctness on v3 to see if it matches. If it does, I'll submit. If not, I'll go back to v2. Wait, but the tool call history shows the last run_correctness PASSED for v2. v3 uses warp shuffles which I need to verify. Let me run correctness on v3 first.
call → run_correctness
callrun_correctness { "kernel_code": "\nimport torch\nimport torch.nn as nn\nimport math\nfrom torch.utils.cpp_extension import load_inline\n\nCUDA_SOURCE = r\"\"\"\n#include <torch/extension.h>\n#include <cuda_runtime.h>\n#include <math.h>\n\n__global__ void preprocess_kernel(\n const float* __restrict__ pair_repr,\n const float* __restrict__ ln_weight,\n const float* __restrict__ ln_bias,\n const float* __restrict__ W_query,\n const float* __restrict__ W_key,\n const float* __restrict__ W_value,\n const float* __restrict__ W_gate,\n const float* __restrict__ b_gate,\n const float* __restrict__ W_bias,\n float* __restrict__ q,\n float* __restrict__ k,\n float* __restrict__ v,\n float* __restrict__ gate,\n float* __restrict__ bias,\n int B, int N, int D, int h, int d\n) {\n int pos = blockIdx.x;\n int tid = threadIdx.x;\n \n extern __shared__ float shared_mem[];\n float* x_shared = shared_mem;\n float* reduce_buf = shared_mem + 128;\n \n if (tid < D) {\n x_shared[tid] = pair_repr[pos * D + tid];\n }\n __syncthreads();\n \n if (tid < D) {\n reduce_buf[tid] = x_shared[tid];\n } else {\n reduce_buf[tid] = 0.0f;\n }\n __syncthreads();\n \n for (int stride = blockDim.x / 2; stride > 0; stride /= 2) {\n if (tid < stride) {\n reduce_buf[tid] += reduce_buf[tid + stride];\n }\n __syncthreads();\n }\n float mean = reduce_buf[0] / D;\n \n if (tid < D) {\n float diff = x_shared[tid] - mean;\n reduce_buf[tid] = diff * diff;\n } else {\n reduce_buf[tid] = 0.0f;\n }\n __syncthreads();\n \n for (int stride = blockDim.x / 2; stride > 0; stride /= 2) {\n if (tid < stride) {\n reduce_buf[tid] += reduce_buf[tid + stride];\n }\n __syncthreads();\n }\n float var = reduce_buf[0] / D;\n \n if (tid < D) {\n x_shared[tid] = (x_shared[tid] - mean) * rsqrtf(var + 1e-5f) * ln_weight[tid] + ln_bias[tid];\n }\n __syncthreads();\n \n int b = pos / (N * N);\n int rem = pos % (N * N);\n int i = rem / N;\n int j = rem % N;\n int r = b * N + i;\n \n for (int out_c = tid; out_c < D; out_c += blockDim.x) {\n float val = 0.0f;\n for (int c = 0; c < D; c++) {\n val += W_query[out_c * D + c] * x_shared[c];\n }\n int head = out_c / d;\n int dd = out_c % d;\n q[((r * h + head) * N + j) * d + dd] = val;\n }\n \n for (int out_c = tid; out_c < D; out_c += blockDim.x) {\n float val = 0.0f;\n for (int c = 0; c < D; c++) {\n val += W_key[out_c * D + c] * x_shared[c];\n }\n int head = out_c / d;\n int dd = out_c % d;\n k[((r * h + head) * N + j) * d + dd] = val;\n }\n \n for (int out_c = tid; out_c < D; out_c += blockDim.x) {\n float val = 0.0f;\n for (int c = 0; c < D; c++) {\n val += W_value[out_c * D + c] * x_shared[c];\n }\n int head = out_c / d;\n int dd = out_c % d;\n v[((r * h + head) * N + j) * d + dd] = val;\n }\n \n for (int out_c = tid; out_c < D; out_c += blockDim.x) {\n float val = b_gate[out_c];\n for (int c = 0; c < D; c++) {\n val += W_gate[out_c * D + c] * x_shared[c];\n }\n gate[pos * D + out_c] = 1.0f / (1.0f + expf(-val));\n }\n \n for (int out_c = tid; out_c < h; out_c += blockDim.x) {\n float val = 0.0f;\n for (int c = 0; c < D; c++) {\n val += W_bias[out_c * D + c] * x_shared[c];\n }\n bias[((b * h + out_c) * N + i) * N + j] = val;\n }\n}\n\n__global__ void attention_kernel(\n const float* __restrict__ q,\n const float* __restrict__ k,\n const float* __restrict__ v,\n const float* __restrict__ bias,\n const float* __restrict__ pair_mask,\n float* __restrict__ scratch,\n int B, int N, int D, int h, int d\n) {\n int b = blockIdx.x / (N * h);\n int rem = blockIdx.x % (N * h);\n int i = rem / h;\n int head = rem % h;\n int r = b * N + i;\n int tid = threadIdx.x;\n \n int query = tid / 2;\n int t = tid % 2;\n int d_half = d / 2;\n \n if (query >= N) return;\n \n extern __shared__ float shared_mem2[];\n float* K_shared = shared_mem2;\n float* V_shared = shared_mem2 + N * d;\n \n for (int l = tid; l < N * d; l += blockDim.x) {\n K_shared[l] = k[((r * h + head) * N) * d + l];\n V_shared[l] = v[((r * h + head) * N) * d + l];\n }\n __syncthreads();\n \n float q_local[32];\n for (int dd = 0; dd < d; dd++) {\n q_local[dd] = q[((r * h + head) * N + query) * d + dd];\n }\n \n float scores[64];\n float max_score = -1e9f;\n \n for (int key = 0; key < N; key++) {\n float dot = 0.0f;\n for (int dd = t * d_half; dd < (t + 1) * d_half; dd++) {\n dot += q_local[dd] * K_shared[key * d + dd];\n }\n dot += __shfl_xor_sync(0xFFFFFFFF, dot, 1);\n float score = dot / sqrtf((float)d);\n score += bias[((b * h + head) * N + i) * N + key];\n if (pair_mask[(b * N + query) * N + key] == 0.0f) {\n score = -1e9f;\n }\n scores[key] = score;\n if (score > max_score) max_score = score;\n }\n \n float sum_exp = 0.0f;\n for (int key = 0; key < N; key++) {\n scores[key] = expf(scores[key] - max_score);\n sum_exp += scores[key];\n }\n float inv_sum = 1.0f / sum_exp;\n for (int key = 0; key < N; key++) {\n scores[key] *= inv_sum;\n }\n \n float out_local[32];\n for (int dd = 0; dd < d; dd++) out_local[dd] = 0.0f;\n \n for (int key = 0; key < N; key++) {\n float s = scores[key];\n for (int dd = t * d_half; dd < (t + 1) * d_half; dd++) {\n out_local[dd] += s * V_shared[key * d + dd];\n }\n }\n \n if (t == 0) {\n for (int dd = 0; dd < d; dd++) {\n int out_idx = ((b * N + i) * N + query) * D + head * d + dd;\n scratch[out_idx] = out_local[dd];\n }\n }\n}\n\n__global__ void finalize_kernel(\n const float* __restrict__ scratch,\n const float* __restrict__ gate,\n const float* __restrict__ W_out,\n const float* __restrict__ b_out,\n float* __restrict__ output,\n int B, int N, int D\n) {\n int pos = blockIdx.x;\n int tid = threadIdx.x;\n \n extern __shared__ float s3[];\n float* gated_shared = s3;\n \n if (tid < D) {\n gated_shared[tid] = scratch[pos * D + tid] * gate[pos * D + tid];\n }\n __syncthreads();\n \n for (int c = tid; c < D; c += blockDim.x) {\n float out_val = b_out[c];\n for (int d = 0; d < D; d++) {\n out_val += W_out[c * D + d] * gated_shared[d];\n }\n output[pos * D + c] = out_val;\n }\n}\n\nvoid launch_preprocess(\n torch::Tensor pair_repr, torch::Tensor ln_weight, torch::Tensor ln_bias,\n torch::Tensor W_query, torch::Tensor W_key, torch::Tensor W_value,\n torch::Tensor W_gate, torch::Tensor b_gate, torch::Tensor W_bias,\n torch::Tensor q, torch::Tensor k, torch::Tensor v,\n torch::Tensor gate, torch::Tensor bias,\n int B, int N, int D, int h, int d\n) {\n int total_pos = B * N * N;\n int smem = 256 * sizeof(float);\n preprocess_kernel<<<total_pos, 64, smem>>>(\n pair_repr.data_ptr<float>(), ln_weight.data_ptr<float>(), ln_bias.data_ptr<float>(),\n W_query.data_ptr<float>(), W_key.data_ptr<float>(), W_value.data_ptr<float>(),\n W_gate.data_ptr<float>(), b_gate.data_ptr<float>(), W_bias.data_ptr<float>(),\n q.data_ptr<float>(), k.data_ptr<float>(), v.data_ptr<float>(),\n gate.data_ptr<float>(), bias.data_ptr<float>(),\n B, N, D, h, d\n );\n}\n\nvoid launch_attention(\n torch::Tensor q, torch::Tensor k, torch::Tensor v,\n torch::Tensor bias, torch::Tensor pair_mask,\n torch::Tensor scratch,\n int B, int N, int D, int h, int d\n) {\n int blocks = B * N * h;\n int threads = 64;\n int smem = 2 * N * d * sizeof(float);\n attention_kernel<<<blocks, threads, smem>>>(\n q.data_ptr<float>(), k.data_ptr<float>(), v.data_ptr<float>(),\n bias.data_ptr<float>(), pair_mask.data_ptr<float>(),\n scratch.data_ptr<float>(),\n B, N, D, h, d\n );\n}\n\nvoid launch_finalize(\n torch::Tensor scratch, torch::Tensor gate,\n torch::Tensor W_out, torch::Tensor b_out,\n torch::Tensor output,\n int B, int N, int D\n) {\n int blocks = B * N * N;\n int threads = 64;\n int smem = 128 * sizeof(float);\n finalize_kernel<<<blocks, threads, smem>>>(\n scratch.data_ptr<float>(), gate.data_ptr<float>(),\n W_out.data_ptr<float>(), b_out.data_ptr<float>(),\n output.data_ptr<float>(),\n B, N, D\n );\n}\n\"\"\"\n\nCPP_SOURCE = r\"\"\"\nvoid launch_preprocess(\n torch::Tensor pair_repr, torch::Tensor ln_weight, torch::Tensor ln_bias,\n torch::Tensor W_query, torch::Tensor W_key, torch::Tensor W_value,\n torch::Tensor W_gate, torch::Tensor b_gate, torch::Tensor W_bias,\n torch::Tensor q, torch::Tensor k, torch::Tensor v,\n torch::Tensor gate, torch::Tensor bias,\n int B, int N, int D, int h, int d);\n\nvoid launch_attention(\n torch::Tensor q, torch::Tensor k, torch::Tensor v,\n torch::Tensor bias, torch::Tensor pair_mask,\n torch::Tensor scratch,\n int B, int N, int D, int h, int d);\n\nvoid launch_finalize(\n torch::Tensor scratch, torch::Tensor gate,\n torch::Tensor W_out, torch::Tensor b_out,\n torch::Tensor output,\n int B, int N, int D);\n\"\"\"\n\nclass ModelNew(nn.Module):\n def __init__(self, pair_dim, num_heads, dropout=0.0):\n super().__init__()\n self.num_heads = num_heads\n self.head_dim = pair_dim // num_heads\n \n self.layer_norm = nn.LayerNorm(pair_dim)\n self.query = nn.Linear(pair_dim, pair_dim, bias=False)\n self.key = nn.Linear(pair_dim, pair_dim, bias=False)\n self.value = nn.Linear(pair_dim, pair_dim, bias=False)\n self.gate = nn.Linear(pair_dim, pair_dim)\n self.bias_proj = nn.Linear(pair_dim, num_heads, bias=False)\n self.out_proj = nn.Linear(pair_dim, pair_dim)\n self.dropout = nn.Dropout(dropout)\n \n self.cuda_module = load_inline(\n name=\"evoformer_cuda_v3\",\n cpp_sources=CPP_SOURCE,\n cuda_sources=CUDA_SOURCE,\n functions=[\"launch_preprocess\", \"launch_attention\", \"launch_finalize\"],\n extra_cuda_cflags=[\"-O3\", \"--use_fast_math\"],\n verbose=False,\n )\n \n def forward(self, pair_repr, pair_mask):\n B, N, _, D = pair_repr.shape\n h = self.num_heads\n d = self.head_dim\n \n pair_repr = pair_repr.contiguous()\n pair_mask = pair_mask.contiguous()\n \n device = pair_repr.device\n \n q = torch.empty(B * N, h, N, d, device=device, dtype=torch.float32)\n k = torch.empty(B * N, h, N, d, device=device, dtype=torch.float32)\n v = torch.empty(B * N, h, N, d, device=device, dtype=torch.float32)\n gate = torch.empty(B, N, N, D, device=device, dtype=torch.float32)\n bias = torch.empty(B, h, N, N, device=device, dtype=torch.float32)\n scratch = torch.empty(B, N, N, D, device=device, dtype=torch.float32)\n output = torch.empty(B, N, N, D, device=device, dtype=torch.float32)\n \n self.cuda_module.launch_preprocess(\n pair_repr,\n self.layer_norm.weight,\n self.layer_norm.bias,\n self.query.weight,\n self.key.weight,\n self.value.weight,\n self.gate.weight,\n self.gate.bias,\n self.bias_proj.weight,\n q, k, v, gate, bias,\n B, N, D, h, d\n )\n \n self.cuda_module.launch_attention(\n q, k, v, bias, pair_mask,\n scratch,\n B, N, D, h, d\n )\n \n self.cuda_module.launch_finalize(\n scratch, gate,\n self.out_proj.weight,\n self.out_proj.bias,\n output,\n B, N, D\n )\n \n return output\n" }
result ← run_correctness (fail)
failrun_correctness run_correctness FAILED: (0 / 5) trials did not all match. correctness_issue: Output mismatch max_difference: ['0.187061', '0.163733', '0.167121', '0.157909', '0.170059'] avg_difference: ['0.026812', '0.027035', '0.027186', '0.027331', '0.027538']
turn 9 · FINAL · 1 tool call(s) · 19.3s turn · 19.3s LLM · 13,708 chars (click to expand)
turn 919.3s turn · 19.3s LLM · 0.0s tools · FINAL
reasoning
Let me go back to the correct version from the previous run and submit that. The previous version without the warp shuffle optimization was correct. I'll use that.
call → submit_kernel
callsubmit_kernel { "kernel_code": "\nimport torch\nimport torch.nn as nn\nimport math\nfrom torch.utils.cpp_extension import load_inline\n\nCUDA_SOURCE = r\"\"\"\n#include <torch/extension.h>\n#include <cuda_runtime.h>\n#include <math.h>\n\n__global__ void preprocess_kernel(\n const float* __restrict__ pair_repr,\n const float* __restrict__ ln_weight,\n const float* __restrict__ ln_bias,\n const float* __restrict__ W_query,\n const float* __restrict__ W_key,\n const float* __restrict__ W_value,\n const float* __restrict__ W_gate,\n const float* __restrict__ b_gate,\n const float* __restrict__ W_bias,\n float* __restrict__ q,\n float* __restrict__ k,\n float* __restrict__ v,\n float* __restrict__ gate,\n float* __restrict__ bias,\n int B, int N, int D, int h, int d\n) {\n int pos = blockIdx.x;\n int tid = threadIdx.x;\n \n extern __shared__ float shared_mem[];\n float* x_shared = shared_mem;\n float* reduce_buf = shared_mem + 128;\n \n if (tid < D) {\n x_shared[tid] = pair_repr[pos * D + tid];\n }\n __syncthreads();\n \n if (tid < D) {\n reduce_buf[tid] = x_shared[tid];\n } else {\n reduce_buf[tid] = 0.0f;\n }\n __syncthreads();\n \n for (int stride = blockDim.x / 2; stride > 0; stride /= 2) {\n if (tid < stride) {\n reduce_buf[tid] += reduce_buf[tid + stride];\n }\n __syncthreads();\n }\n float mean = reduce_buf[0] / D;\n \n if (tid < D) {\n float diff = x_shared[tid] - mean;\n reduce_buf[tid] = diff * diff;\n } else {\n reduce_buf[tid] = 0.0f;\n }\n __syncthreads();\n \n for (int stride = blockDim.x / 2; stride > 0; stride /= 2) {\n if (tid < stride) {\n reduce_buf[tid] += reduce_buf[tid + stride];\n }\n __syncthreads();\n }\n float var = reduce_buf[0] / D;\n \n if (tid < D) {\n x_shared[tid] = (x_shared[tid] - mean) * rsqrtf(var + 1e-5f) * ln_weight[tid] + ln_bias[tid];\n }\n __syncthreads();\n \n int b = pos / (N * N);\n int rem = pos % (N * N);\n int i = rem / N;\n int j = rem % N;\n int r = b * N + i;\n \n for (int out_c = tid; out_c < D; out_c += blockDim.x) {\n float val = 0.0f;\n for (int c = 0; c < D; c++) {\n val += W_query[out_c * D + c] * x_shared[c];\n }\n int head = out_c / d;\n int dd = out_c % d;\n q[((r * h + head) * N + j) * d + dd] = val;\n }\n \n for (int out_c = tid; out_c < D; out_c += blockDim.x) {\n float val = 0.0f;\n for (int c = 0; c < D; c++) {\n val += W_key[out_c * D + c] * x_shared[c];\n }\n int head = out_c / d;\n int dd = out_c % d;\n k[((r * h + head) * N + j) * d + dd] = val;\n }\n \n for (int out_c = tid; out_c < D; out_c += blockDim.x) {\n float val = 0.0f;\n for (int c = 0; c < D; c++) {\n val += W_value[out_c * D + c] * x_shared[c];\n }\n int head = out_c / d;\n int dd = out_c % d;\n v[((r * h + head) * N + j) * d + dd] = val;\n }\n \n for (int out_c = tid; out_c < D; out_c += blockDim.x) {\n float val = b_gate[out_c];\n for (int c = 0; c < D; c++) {\n val += W_gate[out_c * D + c] * x_shared[c];\n }\n gate[pos * D + out_c] = 1.0f / (1.0f + expf(-val));\n }\n \n for (int out_c = tid; out_c < h; out_c += blockDim.x) {\n float val = 0.0f;\n for (int c = 0; c < D; c++) {\n val += W_bias[out_c * D + c] * x_shared[c];\n }\n bias[((b * h + out_c) * N + i) * N + j] = val;\n }\n}\n\n__global__ void attention_kernel(\n const float* __restrict__ q,\n const float* __restrict__ k,\n const float* __restrict__ v,\n const float* __restrict__ bias,\n const float* __restrict__ pair_mask,\n float* __restrict__ scratch,\n int B, int N, int D, int h, int d\n) {\n int b = blockIdx.x / (N * h);\n int rem = blockIdx.x % (N * h);\n int i = rem / h;\n int head = rem % h;\n int r = b * N + i;\n int query = threadIdx.x;\n \n if (query >= N) return;\n \n extern __shared__ float shared_mem2[];\n float* K_shared = shared_mem2;\n float* V_shared = shared_mem2 + N * d;\n \n for (int l = query; l < N * d; l += blockDim.x) {\n K_shared[l] = k[((r * h + head) * N) * d + l];\n V_shared[l] = v[((r * h + head) * N) * d + l];\n }\n __syncthreads();\n \n float q_local[64];\n for (int dd = 0; dd < d; dd++) {\n q_local[dd] = q[((r * h + head) * N + query) * d + dd];\n }\n \n float scores[64];\n float max_score = -1e9f;\n \n for (int key = 0; key < N; key++) {\n float dot = 0.0f;\n for (int dd = 0; dd < d; dd++) {\n dot += q_local[dd] * K_shared[key * d + dd];\n }\n float score = dot / sqrtf((float)d);\n score += bias[((b * h + head) * N + query) * N + key];\n if (pair_mask[(b * N + query) * N + key] == 0.0f) {\n score = -1e9f;\n }\n scores[key] = score;\n if (score > max_score) max_score = score;\n }\n \n float sum_exp = 0.0f;\n for (int key = 0; key < N; key++) {\n scores[key] = expf(scores[key] - max_score);\n sum_exp += scores[key];\n }\n float inv_sum = 1.0f / sum_exp;\n for (int key = 0; key < N; key++) {\n scores[key] *= inv_sum;\n }\n \n float out_local[64];\n for (int dd = 0; dd < d; dd++) out_local[dd] = 0.0f;\n \n for (int key = 0; key < N; key++) {\n float s = scores[key];\n for (int dd = 0; dd < d; dd++) {\n out_local[dd] += s * V_shared[key * d + dd];\n }\n }\n \n for (int dd = 0; dd < d; dd++) {\n int out_idx = ((b * N + i) * N + query) * D + head * d + dd;\n scratch[out_idx] = out_local[dd];\n }\n}\n\n__global__ void finalize_kernel(\n const float* __restrict__ scratch,\n const float* __restrict__ gate,\n const float* __restrict__ W_out,\n const float* __restrict__ b_out,\n float* __restrict__ output,\n int B, int N, int D\n) {\n int pos = blockIdx.x;\n int tid = threadIdx.x;\n \n extern __shared__ float s3[];\n float* gated_shared = s3;\n \n if (tid < D) {\n gated_shared[tid] = scratch[pos * D + tid] * gate[pos * D + tid];\n }\n __syncthreads();\n \n for (int c = tid; c < D; c += blockDim.x) {\n float out_val = b_out[c];\n for (int d = 0; d < D; d++) {\n out_val += W_out[c * D + d] * gated_shared[d];\n }\n output[pos * D + c] = out_val;\n }\n}\n\nvoid launch_preprocess(\n torch::Tensor pair_repr, torch::Tensor ln_weight, torch::Tensor ln_bias,\n torch::Tensor W_query, torch::Tensor W_key, torch::Tensor W_value,\n torch::Tensor W_gate, torch::Tensor b_gate, torch::Tensor W_bias,\n torch::Tensor q, torch::Tensor k, torch::Tensor v,\n torch::Tensor gate, torch::Tensor bias,\n int B, int N, int D, int h, int d\n) {\n int total_pos = B * N * N;\n int smem = 256 * sizeof(float);\n preprocess_kernel<<<total_pos, 64, smem>>>(\n pair_repr.data_ptr<float>(), ln_weight.data_ptr<float>(), ln_bias.data_ptr<float>(),\n W_query.data_ptr<float>(), W_key.data_ptr<float>(), W_value.data_ptr<float>(),\n W_gate.data_ptr<float>(), b_gate.data_ptr<float>(), W_bias.data_ptr<float>(),\n q.data_ptr<float>(), k.data_ptr<float>(), v.data_ptr<float>(),\n gate.data_ptr<float>(), bias.data_ptr<float>(),\n B, N, D, h, d\n );\n}\n\nvoid launch_attention(\n torch::Tensor q, torch::Tensor k, torch::Tensor v,\n torch::Tensor bias, torch::Tensor pair_mask,\n torch::Tensor scratch,\n int B, int N, int D, int h, int d\n) {\n int blocks = B * N * h;\n int threads = N;\n int smem = 2 * N * d * sizeof(float);\n attention_kernel<<<blocks, threads, smem>>>(\n q.data_ptr<float>(), k.data_ptr<float>(), v.data_ptr<float>(),\n bias.data_ptr<float>(), pair_mask.data_ptr<float>(),\n scratch.data_ptr<float>(),\n B, N, D, h, d\n );\n}\n\nvoid launch_finalize(\n torch::Tensor scratch, torch::Tensor gate,\n torch::Tensor W_out, torch::Tensor b_out,\n torch::Tensor output,\n int B, int N, int D\n) {\n int blocks = B * N * N;\n int threads = 64;\n int smem = 128 * sizeof(float);\n finalize_kernel<<<blocks, threads, smem>>>(\n scratch.data_ptr<float>(), gate.data_ptr<float>(),\n W_out.data_ptr<float>(), b_out.data_ptr<float>(),\n output.data_ptr<float>(),\n B, N, D\n );\n}\n\"\"\"\n\nCPP_SOURCE = r\"\"\"\nvoid launch_preprocess(\n torch::Tensor pair_repr, torch::Tensor ln_weight, torch::Tensor ln_bias,\n torch::Tensor W_query, torch::Tensor W_key, torch::Tensor W_value,\n torch::Tensor W_gate, torch::Tensor b_gate, torch::Tensor W_bias,\n torch::Tensor q, torch::Tensor k, torch::Tensor v,\n torch::Tensor gate, torch::Tensor bias,\n int B, int N, int D, int h, int d);\n\nvoid launch_attention(\n torch::Tensor q, torch::Tensor k, torch::Tensor v,\n torch::Tensor bias, torch::Tensor pair_mask,\n torch::Tensor scratch,\n int B, int N, int D, int h, int d);\n\nvoid launch_finalize(\n torch::Tensor scratch, torch::Tensor gate,\n torch::Tensor W_out, torch::Tensor b_out,\n torch::Tensor output,\n int B, int N, int D);\n\"\"\"\n\nclass ModelNew(nn.Module):\n def __init__(self, pair_dim, num_heads, dropout=0.0):\n super().__init__()\n self.num_heads = num_heads\n self.head_dim = pair_dim // num_heads\n \n self.layer_norm = nn.LayerNorm(pair_dim)\n self.query = nn.Linear(pair_dim, pair_dim, bias=False)\n self.key = nn.Linear(pair_dim, pair_dim, bias=False)\n self.value = nn.Linear(pair_dim, pair_dim, bias=False)\n self.gate = nn.Linear(pair_dim, pair_dim)\n self.bias_proj = nn.Linear(pair_dim, num_heads, bias=False)\n self.out_proj = nn.Linear(pair_dim, pair_dim)\n self.dropout = nn.Dropout(dropout)\n \n self.cuda_module = load_inline(\n name=\"evoformer_cuda_v3\",\n cpp_sources=CPP_SOURCE,\n cuda_sources=CUDA_SOURCE,\n functions=[\"launch_preprocess\", \"launch_attention\", \"launch_finalize\"],\n extra_cuda_cflags=[\"-O3\", \"--use_fast_math\"],\n verbose=False,\n )\n \n def forward(self, pair_repr, pair_mask):\n B, N, _, D = pair_repr.shape\n h = self.num_heads\n d = self.head_dim\n \n pair_repr = pair_repr.contiguous()\n pair_mask = pair_mask.contiguous()\n \n device = pair_repr.device\n \n q = torch.empty(B * N, h, N, d, device=device, dtype=torch.float32)\n k = torch.empty(B * N, h, N, d, device=device, dtype=torch.float32)\n v = torch.empty(B * N, h, N, d, device=device, dtype=torch.float32)\n gate = torch.empty(B, N, N, D, device=device, dtype=torch.float32)\n bias = torch.empty(B, h, N, N, device=device, dtype=torch.float32)\n scratch = torch.empty(B, N, N, D, device=device, dtype=torch.float32)\n output = torch.empty(B, N, N, D, device=device, dtype=torch.float32)\n \n self.cuda_module.launch_preprocess(\n pair_repr,\n self.layer_norm.weight,\n self.layer_norm.bias,\n self.query.weight,\n self.key.weight,\n self.value.weight,\n self.gate.weight,\n self.gate.bias,\n self.bias_proj.weight,\n q, k, v, gate, bias,\n B, N, D, h, d\n )\n \n self.cuda_module.launch_attention(\n q, k, v, bias, pair_mask,\n scratch,\n B, N, D, h, d\n )\n \n self.cuda_module.launch_finalize(\n scratch, gate,\n self.out_proj.weight,\n self.out_proj.bias,\n output,\n B, N, D\n )\n \n return output\n" }
result ← submit_kernel (ok)
oksubmit_kernel submit_kernel PASSED: (5 / 5) correctness trials all passed. Kernel runtime: 0.21 μs Runtime stats: mean=0.21μs median=?μs std=0.00μs Numerical precision: max_abs_err=1.49e-07 mean_abs_err=1.50e-08 max_rel_err=3.67e-02 Memory: 36.7 MB peak (ref: 38.2 MB, ratio: 0.9607x) Kernel launches: 7 (ref: 142, fusion ratio: 20.2857) SOL score: 0.0245
final submitted kernel (382 lines, 11,767 chars) — full source, untruncated
import torch
import torch.nn as nn
import math
from torch.utils.cpp_extension import load_inline

CUDA_SOURCE = r"""
#include <torch/extension.h>
#include <cuda_runtime.h>
#include <math.h>

__global__ void preprocess_kernel(
    const float* __restrict__ pair_repr,
    const float* __restrict__ ln_weight,
    const float* __restrict__ ln_bias,
    const float* __restrict__ W_query,
    const float* __restrict__ W_key,
    const float* __restrict__ W_value,
    const float* __restrict__ W_gate,
    const float* __restrict__ b_gate,
    const float* __restrict__ W_bias,
    float* __restrict__ q,
    float* __restrict__ k,
    float* __restrict__ v,
    float* __restrict__ gate,
    float* __restrict__ bias,
    int B, int N, int D, int h, int d
) {
    int pos = blockIdx.x;
    int tid = threadIdx.x;
    
    extern __shared__ float shared_mem[];
    float* x_shared = shared_mem;
    float* reduce_buf = shared_mem + 128;
    
    if (tid < D) {
        x_shared[tid] = pair_repr[pos * D + tid];
    }
    __syncthreads();
    
    if (tid < D) {
        reduce_buf[tid] = x_shared[tid];
    } else {
        reduce_buf[tid] = 0.0f;
    }
    __syncthreads();
    
    for (int stride = blockDim.x / 2; stride > 0; stride /= 2) {
        if (tid < stride) {
            reduce_buf[tid] += reduce_buf[tid + stride];
        }
        __syncthreads();
    }
    float mean = reduce_buf[0] / D;
    
    if (tid < D) {
        float diff = x_shared[tid] - mean;
        reduce_buf[tid] = diff * diff;
    } else {
        reduce_buf[tid] = 0.0f;
    }
    __syncthreads();
    
    for (int stride = blockDim.x / 2; stride > 0; stride /= 2) {
        if (tid < stride) {
            reduce_buf[tid] += reduce_buf[tid + stride];
        }
        __syncthreads();
    }
    float var = reduce_buf[0] / D;
    
    if (tid < D) {
        x_shared[tid] = (x_shared[tid] - mean) * rsqrtf(var + 1e-5f) * ln_weight[tid] + ln_bias[tid];
    }
    __syncthreads();
    
    int b = pos / (N * N);
    int rem = pos % (N * N);
    int i = rem / N;
    int j = rem % N;
    int r = b * N + i;
    
    for (int out_c = tid; out_c < D; out_c += blockDim.x) {
        float val = 0.0f;
        for (int c = 0; c < D; c++) {
            val += W_query[out_c * D + c] * x_shared[c];
        }
        int head = out_c / d;
        int dd = out_c % d;
        q[((r * h + head) * N + j) * d + dd] = val;
    }
    
    for (int out_c = tid; out_c < D; out_c += blockDim.x) {
        float val = 0.0f;
        for (int c = 0; c < D; c++) {
            val += W_key[out_c * D + c] * x_shared[c];
        }
        int head = out_c / d;
        int dd = out_c % d;
        k[((r * h + head) * N + j) * d + dd] = val;
    }
    
    for (int out_c = tid; out_c < D; out_c += blockDim.x) {
        float val = 0.0f;
        for (int c = 0; c < D; c++) {
            val += W_value[out_c * D + c] * x_shared[c];
        }
        int head = out_c / d;
        int dd = out_c % d;
        v[((r * h + head) * N + j) * d + dd] = val;
    }
    
    for (int out_c = tid; out_c < D; out_c += blockDim.x) {
        float val = b_gate[out_c];
        for (int c = 0; c < D; c++) {
            val += W_gate[out_c * D + c] * x_shared[c];
        }
        gate[pos * D + out_c] = 1.0f / (1.0f + expf(-val));
    }
    
    for (int out_c = tid; out_c < h; out_c += blockDim.x) {
        float val = 0.0f;
        for (int c = 0; c < D; c++) {
            val += W_bias[out_c * D + c] * x_shared[c];
        }
        bias[((b * h + out_c) * N + i) * N + j] = val;
    }
}

__global__ void attention_kernel(
    const float* __restrict__ q,
    const float* __restrict__ k,
    const float* __restrict__ v,
    const float* __restrict__ bias,
    const float* __restrict__ pair_mask,
    float* __restrict__ scratch,
    int B, int N, int D, int h, int d
) {
    int b = blockIdx.x / (N * h);
    int rem = blockIdx.x % (N * h);
    int i = rem / h;
    int head = rem % h;
    int r = b * N + i;
    int query = threadIdx.x;
    
    if (query >= N) return;
    
    extern __shared__ float shared_mem2[];
    float* K_shared = shared_mem2;
    float* V_shared = shared_mem2 + N * d;
    
    for (int l = query; l < N * d; l += blockDim.x) {
        K_shared[l] = k[((r * h + head) * N) * d + l];
        V_shared[l] = v[((r * h + head) * N) * d + l];
    }
    __syncthreads();
    
    float q_local[64];
    for (int dd = 0; dd < d; dd++) {
        q_local[dd] = q[((r * h + head) * N + query) * d + dd];
    }
    
    float scores[64];
    float max_score = -1e9f;
    
    for (int key = 0; key < N; key++) {
        float dot = 0.0f;
        for (int dd = 0; dd < d; dd++) {
            dot += q_local[dd] * K_shared[key * d + dd];
        }
        float score = dot / sqrtf((float)d);
        score += bias[((b * h + head) * N + query) * N + key];
        if (pair_mask[(b * N + query) * N + key] == 0.0f) {
            score = -1e9f;
        }
        scores[key] = score;
        if (score > max_score) max_score = score;
    }
    
    float sum_exp = 0.0f;
    for (int key = 0; key < N; key++) {
        scores[key] = expf(scores[key] - max_score);
        sum_exp += scores[key];
    }
    float inv_sum = 1.0f / sum_exp;
    for (int key = 0; key < N; key++) {
        scores[key] *= inv_sum;
    }
    
    float out_local[64];
    for (int dd = 0; dd < d; dd++) out_local[dd] = 0.0f;
    
    for (int key = 0; key < N; key++) {
        float s = scores[key];
        for (int dd = 0; dd < d; dd++) {
            out_local[dd] += s * V_shared[key * d + dd];
        }
    }
    
    for (int dd = 0; dd < d; dd++) {
        int out_idx = ((b * N + i) * N + query) * D + head * d + dd;
        scratch[out_idx] = out_local[dd];
    }
}

__global__ void finalize_kernel(
    const float* __restrict__ scratch,
    const float* __restrict__ gate,
    const float* __restrict__ W_out,
    const float* __restrict__ b_out,
    float* __restrict__ output,
    int B, int N, int D
) {
    int pos = blockIdx.x;
    int tid = threadIdx.x;
    
    extern __shared__ float s3[];
    float* gated_shared = s3;
    
    if (tid < D) {
        gated_shared[tid] = scratch[pos * D + tid] * gate[pos * D + tid];
    }
    __syncthreads();
    
    for (int c = tid; c < D; c += blockDim.x) {
        float out_val = b_out[c];
        for (int d = 0; d < D; d++) {
            out_val += W_out[c * D + d] * gated_shared[d];
        }
        output[pos * D + c] = out_val;
    }
}

void launch_preprocess(
    torch::Tensor pair_repr, torch::Tensor ln_weight, torch::Tensor ln_bias,
    torch::Tensor W_query, torch::Tensor W_key, torch::Tensor W_value,
    torch::Tensor W_gate, torch::Tensor b_gate, torch::Tensor W_bias,
    torch::Tensor q, torch::Tensor k, torch::Tensor v,
    torch::Tensor gate, torch::Tensor bias,
    int B, int N, int D, int h, int d
) {
    int total_pos = B * N * N;
    int smem = 256 * sizeof(float);
    preprocess_kernel<<<total_pos, 64, smem>>>(
        pair_repr.data_ptr<float>(), ln_weight.data_ptr<float>(), ln_bias.data_ptr<float>(),
        W_query.data_ptr<float>(), W_key.data_ptr<float>(), W_value.data_ptr<float>(),
        W_gate.data_ptr<float>(), b_gate.data_ptr<float>(), W_bias.data_ptr<float>(),
        q.data_ptr<float>(), k.data_ptr<float>(), v.data_ptr<float>(),
        gate.data_ptr<float>(), bias.data_ptr<float>(),
        B, N, D, h, d
    );
}

void launch_attention(
    torch::Tensor q, torch::Tensor k, torch::Tensor v,
    torch::Tensor bias, torch::Tensor pair_mask,
    torch::Tensor scratch,
    int B, int N, int D, int h, int d
) {
    int blocks = B * N * h;
    int threads = N;
    int smem = 2 * N * d * sizeof(float);
    attention_kernel<<<blocks, threads, smem>>>(
        q.data_ptr<float>(), k.data_ptr<float>(), v.data_ptr<float>(),
        bias.data_ptr<float>(), pair_mask.data_ptr<float>(),
        scratch.data_ptr<float>(),
        B, N, D, h, d
    );
}

void launch_finalize(
    torch::Tensor scratch, torch::Tensor gate,
    torch::Tensor W_out, torch::Tensor b_out,
    torch::Tensor output,
    int B, int N, int D
) {
    int blocks = B * N * N;
    int threads = 64;
    int smem = 128 * sizeof(float);
    finalize_kernel<<<blocks, threads, smem>>>(
        scratch.data_ptr<float>(), gate.data_ptr<float>(),
        W_out.data_ptr<float>(), b_out.data_ptr<float>(),
        output.data_ptr<float>(),
        B, N, D
    );
}
"""

CPP_SOURCE = r"""
void launch_preprocess(
    torch::Tensor pair_repr, torch::Tensor ln_weight, torch::Tensor ln_bias,
    torch::Tensor W_query, torch::Tensor W_key, torch::Tensor W_value,
    torch::Tensor W_gate, torch::Tensor b_gate, torch::Tensor W_bias,
    torch::Tensor q, torch::Tensor k, torch::Tensor v,
    torch::Tensor gate, torch::Tensor bias,
    int B, int N, int D, int h, int d);

void launch_attention(
    torch::Tensor q, torch::Tensor k, torch::Tensor v,
    torch::Tensor bias, torch::Tensor pair_mask,
    torch::Tensor scratch,
    int B, int N, int D, int h, int d);

void launch_finalize(
    torch::Tensor scratch, torch::Tensor gate,
    torch::Tensor W_out, torch::Tensor b_out,
    torch::Tensor output,
    int B, int N, int D);
"""

class ModelNew(nn.Module):
    def __init__(self, pair_dim, num_heads, dropout=0.0):
        super().__init__()
        self.num_heads = num_heads
        self.head_dim = pair_dim // num_heads
        
        self.layer_norm = nn.LayerNorm(pair_dim)
        self.query = nn.Linear(pair_dim, pair_dim, bias=False)
        self.key = nn.Linear(pair_dim, pair_dim, bias=False)
        self.value = nn.Linear(pair_dim, pair_dim, bias=False)
        self.gate = nn.Linear(pair_dim, pair_dim)
        self.bias_proj = nn.Linear(pair_dim, num_heads, bias=False)
        self.out_proj = nn.Linear(pair_dim, pair_dim)
        self.dropout = nn.Dropout(dropout)
        
        self.cuda_module = load_inline(
            name="evoformer_cuda_v3",
            cpp_sources=CPP_SOURCE,
            cuda_sources=CUDA_SOURCE,
            functions=["launch_preprocess", "launch_attention", "launch_finalize"],
            extra_cuda_cflags=["-O3", "--use_fast_math"],
            verbose=False,
        )
        
    def forward(self, pair_repr, pair_mask):
        B, N, _, D = pair_repr.shape
        h = self.num_heads
        d = self.head_dim
        
        pair_repr = pair_repr.contiguous()
        pair_mask = pair_mask.contiguous()
        
        device = pair_repr.device
        
        q = torch.empty(B * N, h, N, d, device=device, dtype=torch.float32)
        k = torch.empty(B * N, h, N, d, device=device, dtype=torch.float32)
        v = torch.empty(B * N, h, N, d, device=device, dtype=torch.float32)
        gate = torch.empty(B, N, N, D, device=device, dtype=torch.float32)
        bias = torch.empty(B, h, N, N, device=device, dtype=torch.float32)
        scratch = torch.empty(B, N, N, D, device=device, dtype=torch.float32)
        output = torch.empty(B, N, N, D, device=device, dtype=torch.float32)
        
        self.cuda_module.launch_preprocess(
            pair_repr,
            self.layer_norm.weight,
            self.layer_norm.bias,
            self.query.weight,
            self.key.weight,
            self.value.weight,
            self.gate.weight,
            self.gate.bias,
            self.bias_proj.weight,
            q, k, v, gate, bias,
            B, N, D, h, d
        )
        
        self.cuda_module.launch_attention(
            q, k, v, bias, pair_mask,
            scratch,
            B, N, D, h, d
        )
        
        self.cuda_module.launch_finalize(
            scratch, gate,
            self.out_proj.weight,
            self.out_proj.bias,
            output,
            B, N, D
        )
        
        return output