reasoning
Let me analyze the problem:
1. The reference model does:
- LayerNorm on (B, N, N, pair_dim) tensor
- Symmetrize: `(x + x.permute(0, 2, 1, 3)) / 2.0`
- MLP with 3 linear layers and ReLUs
- Sigmoid
2. Input dimensions: (4, 64, 64, 64)
- batch=4, N=64, pair_dim=64
- hidden_dim=32
3. The MLP is applied independently to each pair (i,j).
Let me think about a fused CUDA kernel approach. Since B=4, N=64, the total number of pair elements is 4 * 64 * 64 = 16384. The inner dimension is 64 -> 32 -> 32 -> 1.
Since this is relatively small, doing a single fused kernel would be most efficient. Each thread/block can handle one or more (batch, i, j) pairs, computing the entire MLP for each pair.
Key considerations:
- Symmetrize: we need to read from both (b, i, j) and (b, j, i), average them, then compute
- LayerNorm: mean/var over pair_dim (64 elements), then normalize with learned gamma/beta
- MLP: 3 linear layers with ReLU
For LayerNorm parameters: in the reference, `nn.LayerNorm(pair_dim)` has weight and bias of shape (pair_dim,).
For the MLP:
- Linear 1: weight (hidden_dim, pair_dim), bias (hidden_dim,) -> but wait, PyTorch linear is (out_features, in_features)
- ReLU
- Linear 2: weight (hidden_dim, hidden_dim), bias (hidden_dim,)
- ReLU
- Linear 3: weight (1, hidden_dim), bias (1,)
Actually, let me think about memory access patterns. The symmetrization requires reading both (b, i, j, :) and (b, j, i, :). If we compute the MLP separately for each pair, we'd need:
1. Read pair_repr[b, i, j, :] for LayerNorm
2. Read pair_repr[b, j, i, :] for symmetrize
So each pair element is read twice (once as (i,j), once as (j,i)).
A fused kernel is definitely the way to go. Each thread or block computes the entire pipeline for one (b, i, j) position.
Given B*N*N = 16384 total outputs, and each output requires significant compute (LayerNorm + 3 linear layers + ReLUs), we should launch enough threads.
Option A: Each thread computes one (b, i, j)
Option B: Each block computes a tile of (b, i, j) to better utilize shared memory
For option A, each thread needs ~64+32+32+1 = 129 floats of temporaries. That's a lot of registers per thread, so we'd need fewer threads per block.
For option B with shared memory, we could tile the computation or just use shared memory for the weights.
Let me think about what's most efficient:
- B*N*N = 16384 outputs
- The compute is small (matrix-vector multiplications of size up to 64x32)
- Memory bandwidth might be the bottleneck
Since pair_repr is (16384, 64) = ~1M floats = 4MB, and weights are tiny, this is certainly compute-bound rather than memory-bound (the compute per byte is high).
For each output element (one (b,i,j)):
- LayerNorm: ~pair_dim * 5 ops + reads 64 floats
- Symmetrize: reads 128 floats total (but 64 unique per position, shared with transpose)
- Linear1: pair_dim*hidden_dim = 2048 mul-adds, reads 2048 weights + 32 bias
- ReLU1: 32 ops
- Linear2: hidden_dim*hidden_dim = 1024 mul-adds
- ReLU2: 32 ops
- Linear3: hidden_dim*1 = 32 mul-adds
- Sigmoid: ~10 ops
Total per output: ~3200 FMAs + some overhead
Total across all outputs: 16384 * 3200 = ~52 MFLOPs
At 67 TFLOPS, this would take ~0.8 microseconds if perfectly compute bound. But memory bandwidth for reading the pair_repr and all weights once might be the actual constraint.
Let me calculate memory:
- pair_repr: 4 * 64 * 64 * 64 * 4 bytes = 4 MB
- LayerNorm params: negligible
- MLP weights: ~4K + 1K + 32 = ~5K floats
- Each matrix is tiny
Actually wait, in the reference, symmetrization means each pair gets averaged with its transpose. So each (b,i,j) needs both (b,i,j,:) and (b,j,i,:). For the diagonal i==j, it's the same.
A single fused kernel: each (b,i,j) reads both positions, computes everything. Total reads of pair_repr = 2 * 4 MB = 8 MB. At 3.35 TB/s, that's ~2.4 microseconds. So memory bandwidth is a significant factor.
To optimize:
- We can store weights in shared memory or registers to avoid repeated reloads
- Use vectorized loads where possible
- Given the small size, we might even be able to read both (i,j) and (j,i) positions in a structured way
Since the tensor is (B, N, N, C) = (4, 64, 64, 64), and N=64 is small, we could have each block process a tile of positions, or each thread one position.
Let me think about a specific kernel design:
Design 1: Each thread handles one (b, i, j)
- 16384 threads total
- Each thread reads pair_dim=64 floats (or reads both (i,j) and (j,i), 128 floats)
- Does layer norm computation
- Does 3 sequential matvec operations
- Final sigmoid
For the LayerNorm: thread reads 64 floats, computes mean and variance, normalizes.
For symmetrize: the thread already has (i,j) data, but also needs (j,i) data. It can read both, average.
For matrix multiplications with such small matrices, it's actually more efficient to do the matvec in registers rather than shared memory.
The thread would need temporary storage:
- 64 floats for the input
- 32 floats for hidden1
- 32 floats for hidden2
- 1 float for output
That's about 129 floats = 516 bytes per thread. With 255 registers max and about 64K registers per SM on H100...
255 registers * 4 bytes = 1020 bytes per thread max. 516 bytes is within limits. But warp size is 32, so 32 * 516 = ~16.5KB per warp. At 2048 threads per SM max, that's 1MB - too much. But we won't run 2048 threads if each uses that many registers.
Actually, the compiler is smart enough to reuse registers. Let me think differently.
For a thread computing one output position:
1. Load C=64 floats into registers, compute mean and var for LayerNorm
2. Normalize in place
3. Load the transpose position C=64 floats, compute its mean and var, normalize it
4. Average the two
5. Matvec with W1 (64x32): compute 32 accumulators
6. Add bias1, ReLU
7. Matvec with W2 (32x32): compute 32 accumulators
8. Add bias2, ReLU
9. Matvec with W3 (32x1): compute 1 accumulator
10. Add bias3, sigmoid
The compiler might keep 64 + 32 + 1 floats alive at peak. 97 floats * 4 bytes = 388 bytes per thread.
With 32 threads per warp, that's 12.4KB per warp.
With 64 warps per SM max (2048 threads), that's 794KB, which exceeds register file (64K * 4 bytes = 256KB per SM, so 512KB for FP16, wait).
Actually, 64K 32-bit registers per SM = 256KB of register file. At 388 bytes per thread, max threads per SM would be 256KB / 388B ≈ 676 threads. That's about 21 warps, which is definitely achievable for occupancy.
But with fewer threads per SM, occupancy drops. However, this is a compute-light kernel so occupancy might not be the main concern.
Alternative: each thread handles multiple (b, i, j) positions to amortize register pressure per element. But that increases latency.
Another alternative: process in a more block-oriented fashion.
For a tile approach:
- Each block processes e.g., 8x8 positions in the (i, j) grid for one batch
- Shared memory for weights to avoid repeated loads
- Threads cooperatively compute
But given B=4, N=64, total outputs = 16384. If we use block size 256, that's 64 blocks. That's not enough to saturate the GPU. Better to use smaller blocks, e.g., 128 or 64, giving 128 or 256 blocks.
Or: each block handles one (b, i, j), with 32 threads collaborating. No, that would have too few work per thread.
Best approach might be:
- Block size: 128 or 256 threads
- Each thread handles one (b, i, j)
- Total threads: 16384
- Use grid-stride loop if needed
16384 positions / 256 threads per block = 64 blocks. That's probably not enough to saturate an H100 with 132-144 SMs.
Better approach to get more blocks:
- Each thread handles one (b, i, j), thread block size = 128
- 16384 / 128 = 128 blocks. This is still a bit low.
Actually, 128 blocks is fine. H100 has many SMs, but 128 blocks is enough to keep them busy if each block does enough work.
Wait, let me reconsider. If each block has 128 threads and each thread does significant work, 128 blocks running on ~132 SMs means each SM gets about 1 block, which is fine. The GPU will execute blocks as SMs become free.
But the real issue is: can we get better performance by having each thread compute multiple positions? That would increase arithmetic intensity per thread and reduce block count, potentially causing underutilization.
Actually, let's think about a different strategy. Since N=64 and B=4, total is 16384. What if we use 256 threads per block and each block handles 256 positions? That's 64 blocks. This is actually OK for an H100.
Or we could have each thread handle TWO positions and launch 8192 threads. Or use warp-level decomposition.
Hmm, let me think about this more carefully.
For 16384 outputs, with each output requiring ~3K FMAs:
- Total FMAs = ~49M
- Total memory reads of pair_repr = 2 * 4MB = 8MB (due to symmetrize)
- Memory bandwidth bound if: compute time > memory time
- Compute at 67 TFLOPS = 49M / 67T = 0.73 microseconds
- Memory at 3.35 TB/s = 8MB / 3.35T = 2.4 microseconds
So memory bound! We need to optimize memory access.
To reduce memory bandwidth:
- Cache weights in shared memory (or L1/constant memory)
- Vectorize loads of pair_repr
- But the main memory traffic is pair_repr itself
Actually, is there a way to reduce pair_repr reads? Each position is needed for (i,j) and (j,i). So each element is read exactly twice. We could tile: have a block read a tile of pair_repr into shared memory, then compute multiple outputs from it.
For a tile approach:
- A block handles a tile of say 8x8 (i,j) positions
- First, load the 8x8 tile's pair data (64 positions * 64 floats = 4096 floats) and the transpose tile into shared memory
- Then threads process their assigned positions
- But since we process the full (B, N, N) grid, and each output needs data from arbitrary transposed positions...
The transpose of a tile (ti..ti+7, tj..tj+7) is (tj..tj+7, ti..ti+7), which might be in a completely different block. To handle this efficiently, we need more global coordination.
Actually, for the whole B*N*N space, the transpose relationship pairs (i,j) with (j,i). For a given block processing some set of (i,j), it needs data at (j,i) which might not be in its work set.
A simpler approach: just read pair_repr twice. Since the memory bandwidth is ~3TB/s and we read 8MB, memory time is ~2.4us. If we can also do ~50M FMAs in that time, maybe we can hide latency with compute.
For a more efficient approach: tile the computation. A block processes a batch element and a sub-square of the N×N matrix. E.g., block processes (b, ti..ti+T-1, tj..tj+T-1). Then it loads:
- Tile A: (b, ti..ti+T-1, tj..tj+T-1, :) into shared memory
- Tile B: (b, tj..tj+T-1, ti..ti+T-1, :) into shared memory
But Tile B is exactly the transpose of a tile at a different position. Unless Ti = Tj, these are different memory regions.
Actually, if we restrict to T×T tiles where we process the whole (N/T)^2 tile positions, then for each tile we'd need its own data and its transpose data.
Let me try a different angle. Since N=64, we could use a block that processes all of one batch's diagonal. Or we could have each block handle one batch's whole matrix.
A single batch element is a 64×64×64 tensor = 262K floats = 1MB.
- If a block handles one batch element, it needs to read 1MB to process the 4096 positions (each position needs itself and its transpose, but on the whole matrix level, that's 2MB of reads for 4096 outputs).
Actually for one batch element with 4096 positions:
- Read the full (64, 64, 64) tensor: 262K floats * 2 positions each = 524K unique reads if cached well, or 2 * 262K if not.
Wait no, each position is read exactly twice across the whole matrix (once for (i,j), once for (j,i)). For positions on the diagonal, it's read once but used once (since i=j, the transpose is itself). So total reads = 2 * (N^2 - N) + N = 2N^2 - N. For N=64, that's 8128 positions, each with 64 floats = 520K floats per batch. At 4 bytes/float, that's ~2MB per batch.
A block that handles all 4096 positions of one batch element would make 2MB reads. With 4 batch elements, 8MB total.
For compute: 4096 positions * 3200 FMAs = 13M FMAs per batch.
At 67 TFLOPS, 13M FMAs = 0.2us. At 3.35 TB/s, 2MB reads = 0.6us. So memory bound.
To improve, we could have multiple batch elements per block, or use multiple blocks per batch.
With 256 threads per block and each thread handling 16 positions (4096/256 = 16), a block could handle one batch element. 4 blocks total. That's terrible utilization.
With 128 threads per block and each thread handling 1 position: 32 blocks (4096/128 = 32, times 4 batches = 128 blocks). That's much better! Actually wait: for 4 batch elements, if each block handles one batch element with 128 threads and each thread handles 32 positions, that's 32 blocks. For 4 batches: 128 blocks total. Hmm that's actually 128 blocks, not too bad.
Each thread handling 32 positions: that means processing 32 different (i,j) pairs sequentially. This increases the compute per thread and can help hide memory latency via instruction-level parallelism.
But for 128 threads per block * 32 positions = 4096 positions per block = one batch element. 4 blocks = 4 batch elements.
4 blocks is way too few. Let me reconsider.
Each thread handling 1 position: 4096 threads per batch. With 256 threads/block, that's 16 blocks per batch, 64 blocks total. That's much better but still might be a bit low.
Actually 64 blocks running on ~132 SMs might be OK if each block takes long enough. The issue is whether the GPU can find enough parallelism.
Alternatively: 128 threads/block, each handling 1 position: 32 blocks per batch, 128 blocks total. This is quite reasonable.
Or: 256 threads/block, 64 blocks total, each thread computes 1 position. This might be fine too.
Actually, on Hopper H100, the scheduler can handle many blocks, but occupancy per SM depends on resources. With 64 blocks and 132 SMs, at most 64 SMs would be active with 1 block each. But if a block uses few resources, multiple blocks can run on one SM. With 256 threads and maybe 64 registers each, 2-3 blocks could fit per SM.
Let's aim for more blocks. With thread block size 128 and each thread computing 1 position, we get 128 blocks. This seems like a sweet spot.
Or even smaller blocks: 64 threads each computing 1 position gives 256 blocks. This would ensure good SM utilization.
I think 256 blocks is quite good. Let me think about the kernel structure.
Kernel design:
- Grid: (256 blocks)
- Block: 64 threads
- Each thread computes multiple positions via grid-stride loop? Or each block gets a tile.
Actually, let's map it directly:
- Total positions = 16384
- Threads per block = 128
- Number of blocks = 128
- Total threads = 16384
- Each thread handles exactly 1 position
This gives 128 blocks. On H100 with 132 SMs, this means almost 1 block per SM initially, but as blocks finish, others can be scheduled.
To get 256 blocks: use 64 threads per block.
Actually, for small kernels, fewer threads per block can work well. But 64 threads per block might limit the number of warps (2 warps) and thus instruction throughput.
Let me think about another approach: cooperative loading.
A block of 256 threads can cooperatively load weights into shared memory. The weights are:
- LayerNorm: 2 * 64 = 128 floats
- MLP1: 64 * 32 + 32 = 2080 floats
- MLP2: 32 * 32 + 32 = 1056 floats
- MLP3: 32 * 1 + 1 = 33 floats
Total weights: ~3300 floats = ~13KB. This easily fits in shared memory!
So strategy: use shared memory to store ALL weights. Each block loads the weights once, then all threads use them. This avoids repeated global memory reads of weights.
For the input tensor, each thread still reads from global memory.
Actually, since weights are the same for all positions, the L1 cache should handle them well. But explicit shared memory loading guarantees it.
For 256 threads per block:
- 16384 positions / 256 = 64 blocks
- 64 blocks is too few for 132 SMs
For 128 threads per block:
- 16384 / 128 = 128 blocks
- Better
For 64 threads per block:
- 16384 / 64 = 256 blocks
- Great for SM utilization
But 64 threads = 2 warps. That might limit memory bandwidth utilization. H100 needs ~32 warps per SM for full memory throughput.
128 threads = 4 warps, which is better. With 128 blocks, if they distribute evenly, some SMs get 1, some get 0. But blocks execute quickly, so the scheduler can keep up.
Actually, I think a better approach is to NOT fix the block size to exactly 1 position per thread. Instead, use a grid-stride loop where each thread computes multiple positions. This way we can:
- Use larger blocks (e.g., 256 threads) for better memory coalescing
- Have each thread loop over multiple positions with stride = gridDim.x * blockDim.x
But that doesn't increase the number of blocks, it just changes how work is distributed.
Wait, we want more blocks, not fewer. With 256-thread blocks, we'd have only 64 blocks.
Hmm, but we can also use a 2D grid! For example:
- Block size: 256 threads
- Grid: (B=4, tile_i=8, tile_j=8) = 256 blocks!
- Each block handles some positions within its tile
Actually, that's a great idea! We can map the 2D nature of the problem to the grid:
- Grid in x: something related to B or i
- Grid in y: something related to j or tiles
- Block size: 256 threads
For example:
- gridDim.x = 64 (batch * N_i tiles or similar)
- gridDim.y = 4 (N_j tiles or similar)
- Total blocks = 256
Actually let me think differently. Since B=4 and N=64:
- We can use a 3D grid or combine dimensions.
- e.g., blockIdx.z for batch (0..3), blockIdx.y for i (0..63), blockIdx.x for j (0..63), but CUDA limits total blocks, and this would be 4*64*64 = 16384 blocks, which might actually be fine!
CUDA can handle millions of blocks. 16384 blocks of say 1 thread each is possible. But more efficiently:
- Use a 1D grid with 16384 blocks, each having 1 thread? No, that has terrible efficiency.
- Use a 2D grid: x for j, y for batch*i. E.g., blockIdx.y = b*64 + i (0..255), blockIdx.x = j (0..63). Total = 256*64 = 16384 blocks. Still too many if we want multiple threads per block.
Wait, 16384 blocks is fine! Each block can have 1 warp = 32 threads. Total threads = 16384 * 32 = 524288 threads. On H100 with 132 SMs, if each SM can run 2048 threads, total concurrent threads = ~270K. So we'd need 2 waves of blocks.
Actually, 16384 blocks * 32 threads/block = 524K threads. If each SM runs 2048 threads, total capacity = 132 * 2048 = 270K threads. So 2 waves. That's fine.
But each block doing very little work might have high launch overhead.
Better balance: 128 threads/block, each thread doing 1 position.
- Total blocks = 16384 / 128 = 128 blocks.
- Total threads = 16384.
- One wave can handle 270K threads, so this all fits in one wave.
- But 128 blocks for 132 SMs means some SMs are idle initially.
Actually, that's not quite right. 128 blocks for 132 SMs. Almost all SMs get one block. Given the latency of this kernel, they might all be active at once... wait, a block on an SM can have up to 32 warps. If a block has 4 warps (128 threads), an SM can run up to 16 such blocks concurrently (64 warps / 4 warps = 16 blocks). But only if register/shared memory permits.
So one SM could run multiple blocks from our 128-block grid. If each block uses few resources, multiple blocks can reside on one SM, and the scheduler switches between them.
128 blocks total / 132 SMs ≈ 1 block per SM. This is actually marginal.
Let's increase to 256 blocks. Use 64 threads/block.
- 16384 / 64 = 256 blocks.
- 256 / 132 ≈ 2 blocks per SM. Better!
Or use thread count 32: 512 blocks.
Or use 2D grid: e.g., 64 threads/block, with 4 blocks in x and 64 in y, total 256 blocks. Each block handles a subset of positions.
Actually, the simplest approach that gets enough blocks: use 1D grid with each thread handling multiple positions via a grid-stride loop.
For example:
- blockDim.x = 256 threads
- gridDim.x = 256 blocks
- Total virtual threads = 65536
- Each virtual thread handles ceil(16384/65536) = 1 position (actually some handle 0? No, 65536 > 16384, so many threads have no work if we just use blockIdx.x * blockDim.x + threadIdx.x).
Wait, with grid-stride loop:
```cuda
int tid = blockIdx.x * blockDim.x + threadIdx.x;
int total = B * N * N;
for (int idx = tid; idx < total; idx += gridDim.x * blockDim.x) {
// work
}
```
With 256 blocks * 256 threads = 65536 virtual threads. 65536 > 16384, so each virtual thread that has idx < 16384 does 1 position, others do 0. But with 65536 virtual threads and only 16384 positions, only 1/4 of threads do work.
Better: each thread does multiple positions.
- gridDim.x = 128, blockDim.x = 128 -> 16384 virtual threads. Each does exactly 1 position.
But that's only 128 blocks.
What about 2D grid?
- blockDim.x = 128
- gridDim.x = 32, gridDim.y = 8 -> 256 blocks total, 32768 virtual threads. Each active thread does 1 position, but only 16384 threads are needed. So half the threads do work.
Hmm. We want each thread to do contiguous work for good memory access.
Let me try: each thread handles a contiguous chunk.
- gridDim.x = 256 blocks
- blockDim.x = 128 threads
- Total = 32768 virtual threads
- 16384 / 32768 = 0.5 positions per thread on average. Not good.
What about: gridDim = 64 blocks, blockDim = 256 threads = 16384 threads total. Each thread does exactly 1 position. 64 blocks is too few.
gridDim = 256 blocks, blockDim = 64 threads = 16384 threads total. Each thread does exactly 1 position. 256 blocks! This is perfect.
So: 256 blocks, 64 threads each = 16384 threads total = 16384 positions. 1-to-1 mapping.
But 64 threads per block = 2 warps. Is that sufficient to saturate memory bandwidth? For this small kernel, it might be OK if there are 256 blocks, as each SM gets about 2 blocks and can interleave warps from different blocks.
Actually, 64 threads per block might be on the low side. Let me increase:
- 512 blocks * 32 threads = 16384 threads. 512 blocks / 132 SMs ≈ 4 blocks per SM. But 32 threads per block = 1 warp. This can work, but warp-level divergence might be an issue if not careful.
Actually, I just realized: if we do grid-stride with larger chunks per thread, we can use fewer total threads. For example:
- 256 blocks, 256 threads each = 65536 virtual threads
- Each active thread handles ceil(16384 / 65536) = 1 position
- Not helpful.
How about:
- 128 blocks, 128 threads each = 16384 threads
- Each thread does 1 position
- 128 blocks
Or what if we make the computation for each thread do more work? Like handle multiple positions in sequence to amortize overhead.
For example:
- 256 blocks, 128 threads each = 32768 virtual threads
- Each thread handles 1 position, half are idle. Not good.
Wait, I think 128 blocks is actually fine. Let me not overthink it. On H100, 128 blocks should execute well. The kernel is a mix of compute and memory, and 128 blocks should keep the GPU busy enough.
Alternatively, I could use a 2D block configuration where I naturally have more blocks:
- Process the matrix in tiles, e.g., each block handles a 8x8 tile of positions
- BlockDim = 64 threads (one per position in an 8x8 tile)
- Grid: B * (N/8) * (N/8) = 4 * 8 * 8 = 256 blocks!
This is elegant! 256 blocks of 64 threads each.
- Each block handles 64 positions.
- 256 blocks * 64 = 16384 positions total.
- Each block is responsible for one batch and an 8x8 tile of (i,j) pairs.
For tile (b, ti, tj) where ti, tj are multiples of 8:
- Handle positions (b, ti..ti+7, tj..tj+7)
- Inside the block, 64 threads, thread k handles position (ti + k/8, tj + k%8)
Memory access pattern within a block:
- Thread 0 reads (b, ti, tj, :)
- Thread 1 reads (b, ti, tj+1, :)
- ...
- Thread 7 reads (b, ti, tj+7, :)
- Thread 8 reads (b, ti+1, tj, :)
- etc.
This makes coalesced memory access across the thread block if we access pair_repr with the last dimension (pair_dim) in a strided way. Actually, since pair_dim = 64 and we're accessing 64 floats per thread, each thread reads a contiguous chunk. Different threads access adjacent (i,j) positions but different first two dimensions. The memory layout is (B, N, N, C), so adjacent threads reading (b, i, j+k, c) means they access memory that is separated by C floats. For good coalescing, we'd want threads in a warp to access adjacent memory addresses.
In a warp of 32 threads handling (b, ti..ti+3, tj..tj+7):
- Thread 0: (b, ti, tj, c=0..63)
- Thread 1: (b, ti, tj+1, c=0..63)
- ...
- Thread 7: (b, ti, tj+7, c=0..63)
- Thread 8: (b, ti+1, tj, c=0..63)
- ...
For a given c, threads 0..7 access addresses separated by C floats (64 * 4 = 256 bytes). That's a strided access with stride 256 bytes. For 32 threads, if c varies, we get better coalescing.
Actually, since each thread processes all 64 values of c sequentially, within-warp coalescing doesn't really apply here per c-element. Each thread reads 64 contiguous floats = 256 bytes.
For the transpose: each thread also needs (b, j, i, :). If thread 0 is at (ti, tj), its transpose is at (tj, ti). Thread 8 is at (ti+1, tj), transpose at (tj, ti+1). The transposes of positions in one tile (ti..ti+7, tj..tj+7) are in tile (tj..tj+7, ti..ti+7).
If ti ≠ tj, the transpose tile is different from the current tile. So the block needs to read data from two tiles: the current tile and its transpose tile.
We could load both tiles into shared memory! Each tile is 8 * 8 * 64 floats = 4096 floats = 16KB. Two tiles = 32KB. This fits in shared memory (227KB per block max, but typically limited to 128KB or so on H100).
But wait, if ti = tj (diagonal tile), the transpose is the same tile. If ti ≠ tj, the transpose tile might be handled by a different block.
Actually, to avoid double work, we could have the block handle both the tile and its transpose, but since tiles on one side of the diagonal correspond to tiles on the other side, each physical tile (excluding diagonal) would be processed by two blocks. That's double work! Not good.
Alternative: process all tiles where i ≤ j (upper triangle plus diagonal), and symmetrize the result. But the reference does symmetrize the inputs before the MLP, not the outputs. And then the MLP produces the logits. Since the MLP is nonlinear (ReLU), we can't just symmetrize the output.
So we must compute for all (i,j) positions. Each block handles one tile. For off-diagonal tiles, each block reads from its tile and the transpose tile. The transpose tile is also being read by another block (the one processing the transpose tile positions). So each element of pair_repr is read twice (once by the block processing (ti,tj), once by the block processing (tj,ti)).
This is exactly what we expected. The tiling doesn't reduce reads, just organizes them.
Shared memory approach:
- For a block at (b, ti, tj):
- Load the 8x8 tile (b, ti..ti+7, tj..tj+7, :) into shared memory: 16KB
- If ti ≠ tj, load the 8x8 transpose tile (b, tj..tj+7, ti..ti+7, :) into shared memory: 16KB
- Total shared memory: 32KB
- Then each thread processes its assigned positions using shared memory data
- Average the two inputs, apply LayerNorm + MLP
Wait, but LayerNorm parameters are per-channel. For a given position (i,j), we need to compute mean and std over the 64 channels. If we process one position per thread, the thread loads 64 floats (from shared memory), computes LayerNorm, then MLP.
But if we load the two tiles into shared memory, we also need to do LayerNorm on them. LayerNorm is trivial per thread.
But there's a more efficient approach: since all tiles for the same batch share the same LayerNorm parameters (gamma and beta), and we store the MLP weights in shared memory too.
Actually, shared memory for the weights is also nice:
- LayerNorm: 128 floats
- MLP weights: ~3300 floats
- Total weights: ~13KB
- Plus 2 tiles of 16KB each = 32KB
- Total: ~45KB
This is well within shared memory limits.
But actually, do we need shared memory for the tiles? Each thread reads its own data. The tile is 8x8 positions with 64 channels. A warp processes 32 positions. For coalescing, having 32 threads read adjacent (i,j) positions with the same channel... well, since each thread reads the full channel vector sequentially, coalescing is per-thread. Adjacent threads don't help each other with memory coalescing in the inner channel loop.
Wait, they do! If threads 0 and 1 both read channel 0 of their respective positions, and the positions are (ti, tj) and (ti, tj+1), the memory addresses are separated by 64 floats (256 bytes). This is a strided access. If multiple threads in a warp access the same c simultaneously with stride 256 bytes, memory bandwidth utilization drops.
To get coalesced access, we'd want threads in a warp to access contiguous channels of the same position, or contiguous memory.
Actually, for a thread reading 64 floats sequentially, its own reads are coalesced into a few cache lines (each 128 bytes = 32 floats). So each thread's 64 reads = 2 cache lines. That's efficient for that thread. The fact that different threads access with a 256-byte stride just means different cache lines. The memory controller can handle multiple cache line requests per cycle.
For this small tensor, L1/L2 cache should capture most of the locality.
I think the simplest approach that is likely to be fast enough:
**Approach: One thread per position, 1D grid, weights in shared memory**
```cuda
__global__ void contact_map_kernel(
const float* __restrict__ pair_repr,
const float* __restrict__ ln_weight,
const float* __restrict__ ln_bias,
const float* __restrict__ mlp_w1,
const float* __restrict__ mlp_b1,
const float* __restrict__ mlp_w2,
const float* __restrict__ mlp_b2,
const float* __restrict__ mlp_w3,
const float* __restrict__ mlp_b3,
float* __restrict__ output,
int B, int N, int C, int H)
{
int idx = blockIdx.x * blockDim.x + threadIdx.x;
int total = B * N * N;
if (idx >= total) return;
int b = idx / (N * N);
int rem = idx % (N * N);
int i = rem / N;
int j = rem % N;
// Load and process position (b, i, j)
// Load and process position (b, j, i)
// Average
// LayerNorm
// MLP
// Sigmoid
// Store
}
```
This is clean. Number of blocks depends on blockDim.x.
With blockDim.x = 128: 128 blocks total. Each thread does 1 position.
With blockDim.x = 64: 256 blocks total. Each thread does 1 position.
I think blockDim.x = 128 is a good middle ground. Let me check if 128 blocks is enough.
Actually, what's the occupancy?
- Each thread needs ~100 floats of live registers at peak = 400 bytes? Hard to know exactly.
- Let's assume ~64-80 registers per thread.
- 128 threads = 4 warps. At 255 registers max per thread, if we use 80, that's fine.
- Registers per block = 128 * 80 = 10240.
- Warps per block = 4.
- On H100, max threads per SM = 2048, max warps = 64, max blocks per SM = 32.
- With 4 warps, we can fit up to 16 blocks per SM (64/4 = 16).
- Total blocks = 128. If we can fit 2-4 blocks per SM, we run 32-64 SMs at once, which is fine.
Actually, if register usage is low, we could fit many blocks per SM. The GPU has enough work.
But I worry about warp-level efficiency. With 4 warps per block and 128 blocks, total warps = 512. H100 has 132 SMs. 512/132 ≈ 4 warps per SM on average. That's very low! We'd need much higher occupancy.
So we need MORE total warps, not less. To get more warps, we either need:
1. More threads per block
2. More blocks
Option 1: 256 threads per block.
- 16384 / 256 = 64 blocks.
- 64 blocks * 8 warps = 512 warps. Same total warps!
Option 2: Each thread handles multiple positions.
- e.g., 256 blocks, 256 threads = 65536 threads, each handles ~0.25 positions. Not useful.
- e.g., 256 blocks, 64 threads each = 16384 threads. But each thread handles 1 position. Total warps = 256 * 2 = 512. Same!
Hmm, the total amount of work is fixed at 16384 positions. If each position needs its own thread, we have exactly 16384 threads. At 32 threads per warp, that's 512 warps total. This is a hard limit.
But wait, we can have each thread handle MORE than one position, but in PARALLEL? No, each thread executes sequentially. The total parallelism is bounded by positions.
512 warps / 132 SMs ≈ 4 warps per SM simultaneously. That's very low occupancy.
However, each warp has significant work (thousands of instructions), so instruction-level parallelism within the warp helps. And the GPU can context switch warps quickly.
To increase occupancy, we could process multiple batch elements with the same thread... but that doesn't help.
Actually, the only way to increase warp count is to have threads that do less work each and loop. No wait, that decreases parallelism per thread but doesn't change total warp count.
Hmm, the total work items (16384) is small. So occupancy will inherently be limited. But the arithmetic per work item is high, so this should be a high-latency kernel per warp.
For this kind of kernel, the key optimization is:
1. Maximize instruction throughput per warp (avoid branches, use FMAs)
2. Reduce memory latency with L1 caching and coalescing
3. Fuse everything to avoid intermediate memory round trips
Occupancy doesn't matter as much when each warp has high latency and the GPU can switch between them. With 512 warps total, even if only 4 per SM, the scheduler can fill the pipeline.
So I think 64 blocks of 256 threads each is fine. Or 128 blocks of 128 threads. Both give the same total warps (512).
Actually, wait. The earlier tile-based approach:
- 256 blocks of 64 threads each = 256 * 2 = 512 warps. Same total warps.
- BUT, this uses 256 blocks vs 64 blocks. With 256 blocks, they distribute across 132 SMs, and since each SM can run multiple blocks, the scheduler has more options.
Wait, with 256 blocks and 132 SMs, initially 132 blocks start, then as blocks finish, remaining blocks are scheduled. If the kernel is long-running (which it should be with all the math), there's good overlap.
Actually, there's a subtle point: if each block finishes very quickly, launch overhead matters. But with ~3K FMAs per thread, each block takes microseconds at least.
I think 256 blocks of 64 threads is actually a good configuration for this kernel. It maximizes block count while keeping enough threads per block for cooperative operations.
Let me formalize the kernel with the tile approach:
Grid: (B, N/8, N/8) in some mapping = (4, 8, 8) = 256 blocks
Block: 64 threads (8x8 positions per tile)
Each thread handles one position in the tile:
- threadIdx.x = k
- local_i = k / 8
- local_j = k % 8
- global_i = tile_i * 8 + local_i
- global_j = tile_j * 8 + local_j
- idx = b * N * N + global_i * N + global_j
For symmetry: the thread needs data at (global_i, global_j) and (global_j, global_i).
For diagonal tiles (tile_i == tile_j), both are in the same tile.
For off-diagonal tiles, the transpose position is in a different tile (tile_j, tile_i).
So the block should load:
1. Current tile (b, tile_i*8 .. tile_i*8+7, tile_j*8 .. tile_j*8+7, :) into shared memory
2. Transpose tile (b, tile_j*8 .. tile_j*8+7, tile_i*8 .. tile_i*8+7, :) into shared memory
Wait, we need to be careful. For a block at (b, ti, tj), it processes all (i, j) in tile. For each position, it needs (b, j, i), which is in the transpose tile (tj, ti).
If we load both tiles into shared memory, each block can compute all its positions without additional global memory reads!
Current tile: 8 * 8 * 64 floats = 4096 floats = 16KB
Transpose tile: also 16KB
Total: 32KB per block
Additionally, loading weights into shared memory:
- LayerNorm weight+bias: 128 floats
- W1: 64*32 + 32 = 2080 floats
- W2: 32*32 + 32 = 1056 floats
- W3: 32 + 1 = 33 floats
Total weights: ~3300 floats = 13KB
Grand total: 32KB + 13KB = 45KB shared memory.
H100 has up to 227KB shared memory per block. But max blocks per SM depends on shared memory usage. With 45KB, max blocks per SM = 227KB / 45KB ≈ 5 blocks. That's good enough.
But do we really need to load the input tiles into shared memory? The alternative is each thread reads its two positions directly from global memory. Since global memory is cached via L1/L2, and the transpose reads might hit cache if another block loaded the same tile... but blocks run concurrently and cache eviction is tough to predict.
Actually, for this small tensor (4MB total), the whole thing should fit in L2 cache (50MB on H100 maybe). So cache hits should be decent. Loading into shared memory is an optimization but adds complexity.
However, with shared memory, we also need synchronization (__syncthreads()), and the loading of two tiles requires cooperation.
For off-diagonal tiles, each pair of transpose tiles is processed by two blocks. E.g., block A at (ti, tj) and block B at (tj, ti). Both load the same two physical tiles. This means each tile is loaded into shared memory twice (once by each block). That's a bit redundant but not avoidable unless we coordinate between blocks.
Since the tensor is small and likely L2 cached, maybe local reads are fine.
Actually, let me reconsider the simplest approach: each thread reads directly from global memory.
```cuda
__global__ void contact_map_kernel(...) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
int total = B * N * N;
if (idx >= total) return;
int b = idx / (N * N);
int rem = idx % (N * N);
int i = rem / N;
int j = rem % N;
// Read (b, i, j, :)
const float* ptr_ij = pair_repr + ((b * N + i) * N + j) * C;
float vals[64]; // register array
float mean = 0.0f, var = 0.0f;
#pragma unroll
for (int c = 0; c < C; c++) {
vals[c] = ptr_ij[c];
mean += vals[c];
}
mean /= C;
#pragma unroll
for (int c = 0; c < C; c++) {
float d = vals[c] - mean;
var += d * d;
}
var = rsqrtf(var / C + 1e-5f); // or whatever eps LayerNorm uses
// Apply LayerNorm gamma/beta
#pragma unroll
for (int c = 0; c < C; c++) {
vals[c] = (vals[c] - mean) * var * ln_weight[c] + ln_bias[c];
}
// Read transpose (b, j, i, :) and average
const float* ptr_ji = pair_repr + ((b * N + j) * N + i) * C;
float vals_ji[64];
#pragma unroll
for (int c = 0; c < C; c++) {
vals_ji[c] = ptr_ji[c];
}
// Actually we need to apply LN to vals_ji too, then average
// Better: compute LN on vals_ji separately
}
```
Wait, this is getting register-heavy with two arrays of 64 floats = 128 floats just for inputs. Plus temporaries for MLP. The compiler might spill to local memory.
An alternative: compute LayerNorm on (i,j) data, store normalized in vals. Then read (j,i), compute its LayerNorm on the fly, and average with vals in place.
```cuda
float vals[64];
// compute LN for (i,j)
...
// Read (j,i), compute its mean/var/LN, average with vals
float mean2 = 0;
#pragma unroll
for (int c = 0; c < C; c++) {
float x = ptr_ji[c];
mean2 += x;
}
mean2 /= C;
float var2 = 0;
#pragma unroll
for (int c = 0; c < C; c++) {
float d = ptr_ji[c] - mean2;
var2 += d * d;
}
var2 = rsqrtf(var2 / C + 1e-5f);
#pragma unroll
for (int c = 0; c < C; c++) {
float norm2 = (ptr_ji[c] - mean2) * var2 * ln_weight[c] + ln_bias[c];
vals[c] = (vals[c] + norm2) * 0.5f;
}
```
This way we only need one 64-float register array.
Then for the MLP:
```cuda
float hidden1[32];
// Linear 1
for (int h = 0; h < H; h++) {
hidden1[h] = mlp_b1[h];
for (int c = 0; c < C; c++) {
hidden1[h] += vals[c] * mlp_w1[h * C + c];
}
hidden1[h] = fmaxf(hidden1[h], 0.0f); // ReLU
}
...
```
This requires hidden1[32] + hidden2[32] + vals[64] = 128 floats. That's quite a lot but manageable.
We can reduce registers by not keeping hidden2 as an array. Compute linear 2, apply ReLU, then immediately use in linear 3.
```cuda
float hidden[32];
// Linear 1 + ReLU
for (int h = 0; h < H; h++) {
hidden[h] = mlp_b1[h];
#pragma unroll
for (int c = 0; c < C; c++) {
hidden[h] += vals[c] * mlp_w1[h * C + c];
}
if (hidden[h] < 0) hidden[h] = 0;
}
// Linear 2 + ReLU, store back in hidden (different loop)
// Actually: compute new hidden2, overwriting hidden
for (int h = 0; h < H; h++) {
float acc = mlp_b2[h];
for (int c = 0; c < H; c++) {
acc += hidden[c] * mlp_w2[h * H + c];
}
hidden[h] = fmaxf(acc, 0.0f);
}
// Linear 3
float logit = mlp_b3[0];
for (int c = 0; c < H; c++) {
logit += hidden[c] * mlp_w3[c];
}
float prob = 1.0f / (1.0f + expf(-logit));
```
Now register usage is: vals[64] + hidden[32] + accumulators. ~96 floats + a few temporaries. ~400 bytes per thread. At 128 threads/block, that's a lot. But again, the compiler reuses registers aggressively.
Actually, for the matrix multiplications, we can process them more efficiently. For Linear1: output is 32 elements. Instead of computing each output element in an inner loop over 64, we can do it as above. But there might be a better register blocking.
What if we keep the 32 hidden values in registers but loop carefully?
- #pragma unroll on the inner loop is key for performance.
For C=64 and H=32, the unrolled inner loops should generate efficient code.
Now, about block and grid size. I think a simple 1D configuration is best:
```cuda
dim3 block(128);
dim3 grid((B * N * N + 127) / 128);
```
This gives ceil(16384/128) = 128 blocks.
Wait, 128 blocks of 128 threads each = 16384 threads. Each thread does one position. 128 blocks is... maybe fine.
But I really want more blocks. How about each thread handling 1/2 positions? That makes no sense.
Or we could use a 2D grid naturally and not worry about exact thread count:
- x-dimension handles j (0..63)
- y-dimension handles the flattened b*i (0..255)
```cuda
dim3 block(64, 1, 1); // 64 threads per block
dim3 grid(64, 256, 1); // 64 * 256 = 16384 blocks? No, that's too many.
```
Wait, CUDA grid dimensions support large numbers. But 16384 blocks each with 64 threads = 1M threads. That's way more than needed.
How about:
- block(8, 8, 1) = 64 threads
- grid(8, 64, 4) = 2048 blocks
This maps: block x -> 8 j-tiles, block y -> 64 i positions (or bi combinations), block z -> batch.
Total blocks = 2048, each with 64 threads = 131072 threads. Way too many.
We want exactly 16384 threads, or a small multiple. How about:
- block(64, 1, 1)
- grid(256, 1, 1)
256 blocks of 64 threads = 16384 threads. Each thread gets one position. 256 blocks!
This is the sweet spot. 256 blocks gives good distribution across SMs.
Mapping:
```cuda
int idx = blockIdx.x * blockDim.x + threadIdx.x;
int b = idx / (N * N);
int rem = idx % (N * N);
int i = rem / N;
int j = rem % N;
```
With 256 blocks and 64 threads per block, and each thread doing significant compute, this should keep the GPU busy.
Now, weights. Should we use shared memory for weights?
If we do:
```cuda
__shared__ float s_ln_w[64];
__shared__ float s_ln_b[64];
__shared__ float s_w1[64*32];
...
```
But loading weights in each block from global memory: 3300 floats * 4 bytes = 13KB. With 256 blocks, total weight reads = 3.3MB. This is small compared to the input reads (8MB). But still, reading weights from global memory in each block adds latency.
Alternatively, the CUDA constant memory or L1 cache should cache the weights very effectively after the first few blocks read them. L2 cache is certainly large enough to hold all weights. So explicit shared memory loading might not help much.
However, explicit loading via shared memory ensures the compiler can generate efficient code with lower latency access. For a small kernel like this, it might help.
But it also uses precious shared memory and adds syncthreads() overhead. Let me skip it for now and just read weights directly from global memory with `__restrict__` and `const` qualifiers.
Another optimization: use `float4` or vectorized loads for reading the 64 channels. Since the channels are contiguous in memory, reading 4 floats at a time reduces instruction count.
```cuda
const float4* ptr4 = (const float4*)ptr;
for (int c = 0; c < C/4; c++) {
float4 v = ptr4[c];
vals[c*4] = v.x;
vals[c*4+1] = v.y;
vals[c*4+2] = v.z;
vals[c*4+3] = v.w;
}
```
But this might not actually help if the compiler already vectorizes. And it complicates the code.
I'll stick to simple float loads for now, with `#pragma unroll`.
Let me also think about the LayerNorm epsilon. PyTorch default is 1e-5.
Now, for the actual implementation. We need to extract weights from the PyTorch model and pass them to CUDA.
Wait, the ModelNew should replace the forward computation. The constructor should accept (pair_dim, hidden_dim), and we need to store the weights. But in the reference, weights are created randomly.
For ModelNew, we need to create the same structure: LayerNorm parameters and MLP parameters. We can use PyTorch nn.Module to hold the parameters and compute them, then pass them to the CUDA kernel.
Actually, looking at the reference, the weights are randomly initialized. For ModelNew, we should probably create the same parameter structure. But... do we need to? The evaluation creates Model, then ModelNew with same init args, and presumably copies parameters if needed. Let me re-read.
Actually, the evaluation code likely does something like:
1. Create Model(pair_dim, hidden_dim)
2. Create ModelNew(pair_dim, hidden_dim)
3. Copy weights from Model to ModelNew if the names match
4. Run forward on same input
So ModelNew should use `nn.Parameter` or `nn.Linear`/`nn.LayerNorm` with matching parameter names.
Looking at reference:
- `self.layer_norm = nn.LayerNorm(pair_dim)` -> parameters `layer_norm.weight` and `layer_norm.bias`
- `self.mlp` is Sequential of 3 Linears -> `mlp.0.weight`, `mlp.0.bias`, `mlp.1` is ReLU (no params), `mlp.2.weight`, `mlp.2.bias`, `mlp.3` is ReLU, `mlp.4.weight`, `mlp.4.bias`
For ModelNew, to make parameter copying work automatically, we should instantiate the same modules and extract their parameters for the CUDA kernel.
```python
class ModelNew(nn.Module):
def __init__(self, pair_dim, hidden_dim):
super().__init__()
self.pair_dim = pair_dim
self.hidden_dim = hidden_dim
# Keep same modules so state_dict loading works
self.layer_norm = nn.LayerNorm(pair_dim)
self.mlp = nn.Sequential(
nn.Linear(pair_dim, hidden_dim),
nn.ReLU(),
nn.Linear(hidden_dim, hidden_dim),
nn.ReLU(),
nn.Linear(hidden_dim, 1),
)
# Pre-extract weights for CUDA
```
But we don't want to use PyTorch's forward, we want CUDA. So in `forward()`:
1. Extract weight pointers
2. Call CUDA kernel
However, `torch.utils.cpp_extension.load_inline` is used at module load time. We need to compile the CUDA extension.
One issue: the weights might be on CPU or GPU. We should ensure they're on the same device as input. In `forward()`, we can `.contiguous()` the weights and pass them.
Wait, if the model is created and weights are loaded, they should be on the right device.
Actually, the structure is:
```python
import torch
import torch.nn as nn
from torch.utils.cpp_extension import load_inline
# Compile the extension at module load
cpp_source = """
... CUDA code ...
"""
contact_map_ext = load_inline(
name="contact_map_cuda",
cpp_sources=cpp_source,
functions=["contact_map_forward"],
extra_cuda_cflags=["-O3", "--use_fast_math"],
)
```
Wait, no `cpp_sources` should be a list? Let me check `load_inline` signature.
`torch.utils.cpp_extension.load_inline(name, cpp_sources, cuda_sources=None, functions=None, ...)`
Wait, looking at PyTorch docs: `load_inline` takes `cpp_sources` as a string or list, and optionally `cuda_sources`. For CUDA, we can put everything in `cuda_sources`.
Actually, let me be careful. In newer PyTorch:
```python
load_inline(
name='ext',
cpp_sources=cpp_source,
cuda_sources=cuda_source,
functions=['forward_fn'],
extra_cuda_cflags=['-O3'],
)
```
Wait, for functions we need to declare them in C++ and implement them in CUDA. Or we can put everything in cpp_sources with `extern "C"`? Actually, the `functions` argument tells load_inline which functions to expose via pybind11.
Let me look up the exact pattern. The prompt says:
> Do not write your own `PYBIND11_MODULE(...)` block in `cpp_sources`. `load_inline` auto-generates one from the `functions=[...]` argument; including your own causes a duplicate-symbol redefinition error at compile time. List the host-side wrapper function names in `functions=[...]` instead.
So I need a C++ wrapper function that calls my CUDA kernel. The wrapper is what gets exposed to Python.
```cpp
// In cpp_sources
torch::Tensor contact_map_forward(
torch::Tensor pair_repr,
torch::Tensor ln_w,
torch::Tensor ln_b,
torch::Tensor w1,
torch::Tensor b1,
torch::Tensor w2,
torch::Tensor b2,
torch::Tensor w3,
torch::Tensor b3
) {
// check inputs, allocate output
// launch kernel
return output;
}
```
And in cuda_sources, the actual CUDA kernel code:
```cuda
__global__ void contact_map_kernel(...) { ... }
```
Or I can put everything in `cpp_sources` since it's a `.cu` file under the hood. But `load_inline` with `cuda_sources` handles `.cu` compilation.
Actually, looking at the PyTorch docs more carefully, `load_inline` takes:
- `name`
- `cpp_sources`: C++ source code
- `cuda_sources`: CUDA source code (optional)
- `functions`: list of function names to expose
When `cuda_sources` is provided, it puts the C++ and CUDA together. The wrapper should be in `cpp_sources` (or the combined source) and the kernel in `cuda_sources` (or also combined).
Let me check if I can just pass one big string to `cpp_sources` containing both C++ and CUDA (with `__global__`). In many examples, this works because `load_inline` knows to compile as CUDA if `with_cuda=True` or if `cuda_sources` is provided.
To be safe, I can put the wrapper in `cpp_sources` and the kernel in `cuda_sources`. Actually, the `functions` list needs to find the C++ wrapper function, so it should be in `cpp_sources`.
Wait, actually I think everything can go in `cpp_sources` when using `load_inline`. Let me check with a search...
No search available. Based on common patterns:
```python
ext = load_inline(
name='contact_map',
cpp_sources='''
#include <torch/extension.h>
#include <cuda_runtime.h>
__global__ void kernel(...) { .... }
torch::Tensor forward(...) {
...
kernel<<<grid, block>>>(...);
return out;
}
''',
functions=['forward'],
)
```
This might work if PyTorch detects CUDA code. Or I might need:
```python
ext = load_inline(
name='contact_map',
cpp_sources='''
#include <torch/extension.h>
torch::Tensor forward(...);
''',
cuda_sources='''
#include <torch/extension.h>
#include <cuda_runtime.h>
__global__ void kernel(...) { ... }
torch::Tensor forward(...) {
...
kernel<<<grid, block>>>(...);
return out;
}
''',
functions=['forward'],
)
```
Hmm, actually based on my knowledge, `load_inline` compiles `cpp_sources` as C++ and `cuda_sources` as CUDA (.cu). The wrapper calling the kernel needs to be compiled as CUDA or the compiler won't understand `<<< >>>`. So the wrapper must be in `cuda_sources`.
Actually, PyTorch's `load_inline` often just puts everything together into one compilation unit. Let me think...
The safest approach is to put everything in one `cuda_sources` block and leave `cpp_sources` empty or minimal. But `functions` points to functions that must be in the compiled output.
Actually, from my understanding, the most reliable pattern is:
```python
cuda_source = """
#include <torch/extension.h>
#include <cuda_runtime.h>
__global__ void my_kernel(...) {
...
}
torch::Tensor my_forward(torch::Tensor x) {
...
my_kernel<<<grid, block>>>(...);
return y;
}
"""
ext = load_inline(
name="my_ext",
cpp_sources="", # or minimal
cuda_sources=cuda_source,
functions=["my_forward"],
extra_cuda_cflags=["-O3"],
)
```
Let me try this pattern. If it compiles, great.
For the kernel itself, let me write it more carefully.
The key concern is making sure the math is numerically equivalent enough.
PyTorch LayerNorm:
```python
mean = x.mean(-1, keepdim=True)
var = x.var(-1, unbiased=False, keepdim=True) # biased variance
out = (x - mean) / sqrt(var + eps) * weight + bias
```
Using unbiased=False means `var = sum((x-mean)^2) / C`.
The MLP:
```python
h1 = x @ W1.T + b1
h1 = relu(h1)
h2 = h1 @ W2.T + b2
h2 = relu(h2)
logits = h2 @ W3.T + b3
```
Where W is of shape (out_features, in_features), so W1 is (32, 64).
Matrix multiply in PyTorch: `output = input @ weight.t()`
In row vector form: `out_j = sum_i input_i * weight[j, i] + bias[j]`
For our kernel, if we process one position with input vector `x[0..C-1]`:
```cpp
// Linear 1
for (int j = 0; j < H; j++) {
float acc = b1[j];
for (int i = 0; i < C; i++) {
acc += x[i] * W1[j * C + i];
}
h1[j] = acc > 0 ? acc : 0;
}
```
Note the index: `W1[j * C + i]`. We need to make sure W1 is stored in a way that matches this. PyTorch tensors are row-major. `nn.Linear.weight` has shape (out_features, in_features), and is stored in row-major. So `weight[j, i]` is at `weight_ptr[j * in_features + i]`.
For W2: shape (32, 32). `W2[j * H + i]`.
For W3: shape (1, 32). `W3[i]` for the single output.
Now, let's think about numerical precision. We compute in float32 (the input is float32). We need to match within 1e-4.
Potential issues:
1. LayerNorm variance computation order
2. Division vs multiplication by reciprocal
3. Sigmoid implementation
We'll try to match PyTorch's numerics as closely as possible.
PyTorch uses `rsqrt` for the division in LayerNorm. We'll do the same.
For sigmoid: PyTorch uses `1 / (1 + exp(-x))`. We'll use the same. But CUDA's `expf` and division might differ slightly. `__expf` is faster but less accurate. We should use `expf` for correctness.
Actually, `--use_fast_math` enables approximate math. But PyTorch might use fast math too in some contexts. To be safe, I'll use `expf` without fast math, or with fast math if the tolerance is 1e-4.
Let's use `-O3` and `--use_fast_math` for speed. Tolerance is 1e-4, so fast math should be fine.
Wait, for the CUDA kernel launch configuration. Should we do dynamic block size based on input?
For simplicity, let's fix:
- blockDim.x = 128
- gridDim.x = (B*N*N + 127) / 128
But this is 128 blocks. Let me see if we can do 256 blocks by using a smaller block size.
Actually, on H100, 128 blocks of 128 threads = 16384 threads, exactly matching positions. But to increase block count, we can use 256 blocks of 64 threads.
I think I'll go with:
```cpp
const int threads = 128;
const int blocks = (B * N * N + threads - 1) / threads;
```
128 blocks is probably fine. But to be safe about occupancy, let me write the kernel with `blockDim.x` and `gridDim.x` and see what compile/run reports.
Actually, I just realized: maybe the simplest approach is EACH WARP handles one position? No, that's underutilization.
What if we vectorize the computation so that a warp collaboratively computes one position? For example, 32 threads each handle 2 channels of the 64-channel LayerNorm, then 32 threads each compute 1 output of the 32-wide hidden layer, etc.
This is much more complex but might be more efficient because:
- LayerNorm: 32 threads cooperate to compute mean/var of 64 elements. Use warp shuffle for reduction.
- Linear 1: each of 32 threads computes 1 output element, but with all threads in the warp reading the input vector. Actually, each thread could compute 1 output using its own registers for the accumulator, while all threads load input collaboratively.
- Linear 2 and 3 similarly.
This warp-cooperative approach could be very efficient because:
- It better utilizes the warp for the small matrix operations
- More warps total: 16384 positions * (128 threads/position if we do block-cooperative) -> wait, if one warp handles one position, total warps = 16384 * 1 = 16384 warps. But we'd need 16384 blocks of 1 warp each, which is possible!
Actually, CUDA can launch blocks with 32 threads. 16384 blocks of 32 threads. But that might have high launch overhead.
Or: blocks of 64 threads = 2 warps, each handling 2 positions. Total blocks = 8192.
Hmm, with 1 warp per position and proper warp-level primitives for reductions:
- LayerNorm mean reduction across warp: `__shfl_down_sync` over 32 threads for 64 elements -> each thread handles 2 elements.
- The computation is vectorized and efficient.
This approach is common for small MLPs and could indeed be faster. But it is quite complex to implement.
Given the time constraints and complexity, let me try the simple "one thread per position" approach first and see how it performs. If it's not fast enough, I can optimize further.
Actually, let me write a somewhat optimized version:
1. Use `__restrict__` for all pointers
2. Unroll all loops with `#pragma unroll`
3. Use `float` throughout (no double)
4. Use `rsqrtf` for LayerNorm
5. Use `fmaxf` for ReLU
6. Use `1.0f / (1.0f + expf(-logit))` for sigmoid
7. Use `--use_fast_math`
8. Use shared memory for ALL weights to ensure L1 doesn't thrash
Wait, for shared memory loading of weights, each block loads all weights. This is cleancanonical.
Let me also think about whether to use `constexpr` for the dimensions. Since they vary per model but are fixed at compile time if we pass them as template parameters... but `load_inline` doesn't support separate instantiations. So constants passed as function arguments.
Wait, we know pair_dim = 64 and hidden_dim = 32 for the specific test. But the model should work for any pair_dim and hidden_dim. We should write a general kernel.
However, we can micro-optimize for the test case while keeping it general. Or we can just use #pragma unroll and let the compiler handle it.
For the inner loops over C (64) and H (32), `#pragma unroll` on fixed small loops helps. But since C and H are runtime variables, `pragma unroll` might not fully unroll unless C and H are compile-time constants.
To force unrolling for the expected sizes while keeping generality, we can use `if constexpr` in C++17, or we can template the kernel. But templating with runtime arguments is tricky.
One approach: we can make the kernel take C and H as template parameters, and the wrapper function checks the sizes and dispatches to the right instantiation. But we'd need to instantiate all possibilities.
For the test: C=64, H=32. We can instantiate just these values. But to be general, let's just have the non-template version with pragma unroll loops.
Actually, I can write the loops with manual unrolling for C=64 and H=32 by treating them as compile time where possible. But since we want general code, let's use loops with `#pragma unroll 4` to partially unroll.
Actually, for C=64 and H=32, the compiler with `-O3` might unroll inner loops automatically if it can see the bounds as constants. But in the kernel, bounds come from function parameters, which the compiler might not know at compile time unless we pass them as template arguments.
Hmm, but device functions DO get the parameters as constants since the kernel is compiled and then launched. However, the compiler might not know the ranges.
Wait, the kernel parameters (C, H) are runtime values. The SASS compiler (part of nvcc) might not fully unroll loops with runtime bounds unless we use `#pragma unroll` with a literal number.
For partially unrolling:
```cpp
#pragma unroll 8
for (int c = 0; c < C; c++) { ... }
```
This unrolls the loop 8 times, keeping the loop count as C/8. This should improve instruction-level parallelism.
For the linear layers:
```cpp
#pragma unroll 8
for (int c = 0; c < C; c++) {
float x = vals[c];
#pragma unroll
for (int h = 0; h < H; h++) {
acc[h] += x * w1[h * C + c];
}
}
```
But wait, if H is unknown, the inner loop can't be unrolled either.
We can partially unroll the inner loop too:
```cpp
#pragma unroll 8
for (int c = 0; c < C; c++) {
float x = vals[c];
#pragma unroll 4
for (int h = 0; h < H; h++) {
acc[h] += x * w1[h * C + c];
}
}
```
This should give good ILP.
However, there's a tradeoff with register usage. Unrolling more uses more registers, potentially causing spills.
Let me think about this more carefully. For H=32, `acc[32]` is 32 floats. That's fine. The compiler can keep them in registers.
Actually, for the sequential dot products, we don't need to keep all H accumulators simultaneously if we process output-by-output:
```cpp
for (int h = 0; h < H; h++) { // can be unrolled by pragma
float acc = b1[h];
#pragma unroll 16
for (int c = 0; c < C; c++) {
acc += vals[c] * w1[h * C + c];
}
hidden[h] = acc > 0 ? acc : 0;
}
```
This keeps only 1 accumulator in registers at a time, plus `vals[64]`. This uses fewer registers (64 + 1 + loop vars ≈ 70 floats).
Then for layer 2:
```cpp
for (int h = 0; h < H; h++) {
float acc = b2[h];
#pragma unroll 16
for (int c = 0; c < H; c++) {
acc += hidden[c] * w2[h * H + c];
}
hidden[h] = acc > 0 ? acc : 0; // overwrite in place
}
```
And layer 3:
```cpp
float acc = b3[0];
#pragma unroll 16
for (int c = 0; c < H; c++) {
acc += hidden[c] * w3[c];
}
float prob = 1.0f / (1.0f + expf(-acc));
```
Now register usage is: `vals[64]` during all layers + `hidden[32]` during layers 2 and 3.
At peak (layer 2), we have `vals[64] + hidden[32] = 96` floats. Plus some temporaries. Maybe ~100 floats = 400 bytes.
For 64 threads per block, 100 registers each * 64 = 6400 registers. Well within 64K limit per block (actually per SM). For 256 blocks total, if each uses ~100 registers and 64 threads, multiple blocks can fit per SM.
Wait, no. 64 threads per block. With 100 registers per thread, that's 6400 registers per block. An SM has 64K registers = 65,536 registers. So about 10 blocks can fit per SM simultaneously. 256 blocks / 132 SMs ≈ 2 blocks per SM. That's great for occupancy!
So the configuration 256 blocks, 64 threads each, with ~100 registers per thread gives:
- 2 blocks per SM initially
- Up to 10 blocks could fit per SM
- This means good occupancy and latency hiding
I think this is a solid design.
Wait, another consideration: if we use shared memory for weights, each block needs shared memory. Let's say 45KB. With 64K shared memory per SM (wait, H100 has 228KB per SM but typically limited to 128KB or 164KB for certain configs). If each block needs 45KB, max blocks per SM = 228KB / 45KB ≈ 5. Still good.
But do we need shared memory for weights? I think not reading from shared memory saves the `__syncthreads()` overhead and simplifies code. Let me try without shared memory first.
The weights are small (total ~13KB) and should stay in L2 cache. In fact, since all 256 blocks read the same weights, L2 should have them cached after the first few blocks.
So I'll skip shared memory for weights in the first version.
Let me write the kernel:
```cuda
#include <torch/extension.h>
#include <cuda_runtime.h>
__global__ void contact_map_kernel(
const float* __restrict__ pair_repr,
const float* __restrict__ ln_weight,
const float* __restrict__ ln_bias,
const float* __restrict__ mlp_w1, // (H, C)
const float* __restrict__ mlp_b1, // (H,)
const float* __restrict__ mlp_w2, // (H, H)
const float* __restrict__ mlp_b2, // (H,)
const float* __restrict__ mlp_w3, // (1, H)
const float* __restrict__ mlp_b3, // (1,)
float* __restrict__ output,
int B, int N, int C, int H)
{
int idx = blockIdx.x * blockDim.x + threadIdx.x;
int total = B * N * N;
if (idx >= total) return;
int b = idx / (N * N);
int rem = idx % (N * N);
int i = rem / N;
int j = rem % N;
// Pointers to the two positions
const float* ptr_ij = pair_repr + ((b * N + i) * N + j) * C;
const float* ptr_ji = pair_repr + ((b * N + j) * N + i) * C;
float vals[64]; // max pair_dim
float mean = 0.0f;
float var = 0.0f;
// LayerNorm for (i,j)
#pragma unroll 8
for (int c = 0; c < C; c++) {
vals[c] = ptr_ij[c];
mean += vals[c];
}
mean /= (float)C;
#pragma unroll 8
for (int c = 0; c < C; c++) {
float d = vals[c] - mean;
var += d * d;
}
var = rsqrtf(var / (float)C + 1e-5f);
#pragma unroll 8
for (int c = 0; c < C; c++) {
vals[c] = (vals[c] - mean) * var * ln_weight[c] + ln_bias[c];
}
// LayerNorm for (j,i)
float vals_ji[64];
float mean2 = 0.0f;
float var2 = 0.0f;
#pragma unroll 8
for (int c = 0; c < C; c++) {
vals_ji[c] = ptr_ji[c];
mean2 += vals_ji[c];
}
mean2 /= (float)C;
#pragma unroll 8
for (int c = 0; c < C; c++) {
float d = vals_ji[c] - mean2;
var2 += d * d;
}
var2 = rsqrtf(var2 / (float)C + 1e-5f);
#pragma unroll 8
for (int c = 0; c < C; c++) {
float norm_ji = (vals_ji[c] - mean2) * var2 * ln_weight[c] + ln_bias[c];
vals[c] = (vals[c] + norm_ji) * 0.5f;
}
// MLP layer 1
float hidden[32];
#pragma unroll 4
for (int h = 0; h < H; h++) {
float acc = mlp_b1[h];
#pragma unroll 16
for (int c = 0; c < C; c++) {
acc += vals[c] * mlp_w1[h * C + c];
}
hidden[h] = fmaxf(acc, 0.0f);
}
// MLP layer 2
#pragma unroll 4
for (int h = 0; h < H; h++) {
float acc = mlp_b2[h];
#pragma unroll 16
for (int c = 0; c < H; c++) {
acc += hidden[c] * mlp_w2[h * H + c];
}
hidden[h] = fmaxf(acc, 0.0f);
}
// MLP layer 3
float logit = mlp_b3[0];
#pragma unroll 16
for (int c = 0; c < H; c++) {
logit += hidden[c] * mlp_w3[c];
}
float prob = 1.0f / (1.0f + expf(-logit));
output[idx] = prob;
}
```
Wait, I used `vals_ji[64]`. That's an additional 64 floats. Combined with `vals[64]` and `hidden[32]`, that's 160 floats = 640 bytes per thread. For 64 threads, that's 40KB. This might cause spills or reduce occupancy.
Can we avoid `vals_ji`? We can compute the LN of (j,i) and average in one pass without storing:
```cuda
// Read (j,i), compute mean2
float mean2 = 0.0f;
#pragma unroll 8
for (int c = 0; c < C; c++) {
mean2 += ptr_ji[c];
}
mean2 /= C;
float var2 = 0.0f;
#pragma unroll 8
for (int c = 0; c < C; c++) {
float d = ptr_ji[c] - mean2;
var2 += d * d;
}
var2 = rsqrtf(var2 / C + 1e-5f);
// Now compute LN and average in one pass
#pragma unroll 8
for (int c = 0; c < C; c++) {
float norm_ji = (ptr_ji[c] - mean2) * var2 * ln_weight[c] + ln_bias[c];
vals[c] = (vals[c] + norm_ji) * 0.5f;
}
```
This eliminates the `vals_ji` array! Now we have `vals[64]` + `hidden[32]` = 96 floats. That's much better.
Also note: for the diagonal (i == j), we compute the LN twice and average. That's correct since `(x + x) / 2 = x`. But it's inefficient. We can add a check: if (i == j), skip the second LN and just keep vals. But this adds a branch. Since the diagonal is only 64 positions out of 4096 per batch (1.5%), the branch is almost always not taken. The cost of checking is minimal.
Actually, with 64 threads per block and i/j computed from blockIdx/threadIdx, we can add the check without significant divergence.
```cuda
if (i == j) {
// Already computed vals for (i,j), no need to average
// Actually, we already did the first LN. For i==j, we don't need to do anything extra.
// But currently my code does the first LN, then enters the second block unconditionally.
}
```
Wait, for the current design, I need to restructure slightly:
1. Compute LN for (i,j) into vals
2. If i != j: compute LN for (j,i) on the fly and average into vals
3. If i == j: vals is already correct
Let me restructure:
```cuda
// Compute LN for (i,j)
// ... stores in vals ...
if (i != j) {
// Compute LN for (j,i)
float mean2 = 0.0f;
#pragma unroll 8
for (int c = 0; c < C; c++) {
mean2 += ptr_ji[c];
}
mean2 /= (float)C;
float var2 = 0.0f;
#pragma unroll 8
for (int c = 0; c < C; c++) {
float d = ptr_ji[c] - mean2;
var2 += d * d;
}
var2 = rsqrtf(var2 / (float)C + 1e-5f);
#pragma unroll 8
for (int c = 0; c < C; c++) {
float norm_ji = (ptr_ji[c] - mean2) * var2 * ln_weight[c] + ln_bias[c];
vals[c] = (vals[c] + norm_ji) * 0.5f;
}
}
```
This is cleaner and saves registers. The branch is divergent only for diagonal threads, which are few.
But in a warp, if some threads are on the diagonal and others aren't, we get divergence. Since threads in the same warp might have different (i,j), a warp might have 1 diagonal thread and 31 off-diagonal threads. The diagonal thread will skip the computation while the others execute it, causing serialization. However, since only 1.5% of threads are diagonal, most warps have 0 diagonal threads. Occasionally a warp has 1, causing minor divergence. This is acceptable.
Actually, we can also just compute the second LN unconditionally for simplicity. Since i==j, we'd read ptr_ji which is the same as ptr_ij, but we'd compute LN again and average. This is redundant but avoids branches.
Wait, computing LN twice for diagonals means reading the same memory again. That's extra memory bandwidth for those positions. But diagonals are few, so it's fine.
For simplicity and to avoid branch divergence, let me just compute the second LN unconditionally!
```cuda
// After computing vals for (i,j):
// Compute LN for (j,i) unconditionally
float mean2 = 0.0f;
#pragma unroll 8
for (int c = 0; c < C; c++) {
mean2 += ptr_ji[c];
}
mean2 /= (float)C;
float var2 = 0.0f;
#pragma unroll 8
for (int c = 0; c < C; c++) {
float d = ptr_ji[c] - mean2;
var2 += d * d;
}
var2 = rsqrtf(var2 / (float)C + 1e-5f);
#pragma unroll 8
for (int c = 0; c < C; c++) {
float norm_ji = (ptr_ji[c] - mean2) * var2 * ln_weight[c] + ln_bias[c];
vals[c] = (vals[c] + norm_ji) * 0.5f;
}
```
For diagonal positions, we're doing double the compute. But it's only 64 positions out of 16384. Negligible.
Now, about shared memory for weights. I said I'd skip it, but I wonder if loading weights to shared memory helps with occupancy or performance.
Actually, the main concern is register spills. If the compiler spills `vals[64]` or `hidden[32]` to local memory, performance drops significantly. Local memory is in global memory and has high latency.
To avoid spills, we can use shared memory for the input vectors.
Shared memory approach:
- Load the 64 input elements into shared memory instead of registers.
- Then compute LayerNorm using shared memory.
- Keep hidden layer in shared memory too.
- Then do the MLP.
But shared memory access is slower than registers. And we'd still need registers for the temporaries.
Actually, for such a small number of positions per thread, register arrays are almost always kept in registers by the compiler. Let me make sure by using `float vals[64]` with `#pragma unroll` loops.
Nvcc is usually quite good at keeping arrays in registers if the access patterns are simple and all indices are compile-time known or loop variables with unrolling.
One issue: `vals[c]` where `c` is a loop variable. If the loop is not fully unrolled, `c` is dynamic and the compiler might put the array in local memory.
For an array of size 64 accessed with dynamic index inside a non-unrolled loop, nvcc WILL put it in local memory. This is a critical point!
To keep the array in registers, the compiler needs to know all accesses statically. This means either:
1. Fully unroll all loops that access the array
2. Use arrays of size <= 32 or so (though even then dynamic indexing can cause local memory)
3. Manually unroll the code
For pair_dim = 64, if we write:
```cpp
float vals[64];
for (int c = 0; c < C; c++) { ... }
```
And C is a runtime variable, the compiler might not unroll the loop fully. Without full unrolling, `vals[c]` with dynamic `c` becomes a local memory access.
This is a CRITICAL issue. Let me think about how to handle it.
Options:
1. Make C a template parameter so the loops are over compile-time constants.
2. Manually unroll the 64 iterations using a macro or template.
3. Use `#pragma unroll` with the exact count (but C is runtime).
4. Hope the compiler is smart enough... (it usually isn't for arrays this large)
Actually, in CUDA, the compiler CAN put small arrays in registers even with dynamic indexing if the array is within a single basic block and the compiler can track all definitions and uses. But for 64 elements accessed in loops, it almost certainly spills to local memory.
Let me verify this by thinking about it: registers don't support dynamic indexing in PTX/SASS (though newer architectures have some support). To access `vals[c]` where c is a register value, the compiler must either:
- Unroll the loop and map each `vals[i]` to a separate register
- Or put vals in local memory
Since C=64 is a runtime parameter, unless the compiler can propagate constants through the kernel call, it won't unroll.
BUT WAIT: if we use `constexpr int C = 64;` in the kernel, the loops DO have compile-time bounds!
Actually, in the kernel, `C` is passed as a function parameter `int C`. The compiler sees this as a runtime variable. But if we use template parameters:
```cpp
template<int C, int H>
__global__ void contact_map_kernel(...) { ... }
```
Then in the wrapper:
```cpp
if (C == 64 && H == 32) {
contact_map_kernel<64, 32><<<grid, block>>>(...);
}
```
This makes C and H compile-time constants, enabling full loop unrolling!
But for generality, we can have the dispatcher check common sizes and fall back to a non-template version.
However, `load_inline` compiles the code at module load time. The dispatcher with template instantiations needs to be compiled. We can instantiate for common sizes and use a switch statement.
For the test case: C=64, H=32. We can do:
```cpp
template<int C, int H>
__global__ void contact_map_kernel_t(...) {
// fully unrolled loops
}
__global__ void contact_map_kernel(...) {
// generic version with runtime C, H
}
```
And in the wrapper:
```cpp
if (C == 64 && H == 32) {
contact_map_kernel_t<64, 32><<<grid, block>>>(...);
} else {
contact_map_kernel<<<grid, block>>>(...);
}
```
This is a good approach. The template version will generate fully unrolled code with all arrays in registers.
But template code with `#pragma unroll` on a `for (int c = 0; c < C; c++)` where C is a template parameter will indeed be fully unrolled by the compiler. This means 64 copies of the loop body. The resulting code will be much larger but use registers instead of local memory.
Given that we only need to support a few sizes (or just 64 and 32), this is worth doing.
However, wait. If the loop is fully unrolled to 64 iterations, the instruction cache pressure increases. But 64 iterations is manageable. The actual equivalent instruction count is maybe a few hundred instructions, which is fine.
For the MLP layers, the inner loops over C=64 or H=32 will also be unrolled. This generates a lot of instructions. Let me count:
- Linear 1: 32 iterations of an inner loop over 64 = 2048 multiply-add instructions when unrolled.
- Linear 2: 32 * 32 = 1024 instructions.
- Linear 3: 1 * 32 = 32 instructions.
- LayerNorm: ~3 * 64 = ~192 instructions.
- Total: ~3300 instructions per kernel.
That is a LOT of instructions. But the GPU can execute independent instructions in parallel. For float FMAs, the throughput is high (2 per clock per SM maybe). With 3300 instructions and 2-way issue, that's ~1650 cycles per warp.
At 2GHz, that's 0.8 microseconds. This is actually quite a bit of work per thread.
But instruction cache might be an issue with fully unrolled loops. 3300 instructions might exceed the instruction cache per warp. For small loops, it might be fine. For large unrolled loops, icache misses could occur.
Actually, 3300 instructions is well within the icache of modern GPUs (which is several KB, holding maybe 1000-4000 instructions). But with 256 blocks, do they all share the same icache? No, each SM has its own icache.
I think 3300 instructions is pushing it but might be OK. To be safe, we can partially unroll instead of fully unrolling.
Alternative approach: process the MLP in chunks without fully unrolling, but keep the arrays in registers by manual register assignment. This is complex.
Actually, there's a trick: if we write the code with fixed-size arrays and `#pragma unroll`, the compiler MIGHT keep them in registers even without full unrolling if it can analyze the loop bounds as constants through constant propagation.
Wait, nvcc is quite good at constant propagation for template parameters. But does it keep arrays in registers with dynamic indexing from unrolled loops?
Let me think. If we write:
```cpp
template<int C>
__global__ void kernel(..., int C_param) {
// But if C is template parameter and C_param == C
}
```
If we fully unroll:
```cpp
#pragma unroll
for (int c = 0; c < C; c++) { vals[c] = ...; }
```
Since C is a compile-time constant, the loop is fully unrolled. Each `vals[c]` becomes `vals_0`, `vals_1`, etc. in the unrolled code, and the compiler maps them to registers.
Yes, this works.
For the MLP, full unrolling generates a lot of instructions. But what if we DON'T fully unroll the MLP, and instead partially unroll with `#pragma unroll 4`? The compiler keeps the array in local memory if C is large and the loop isn't fully unrolled.
Hmm. Actually, since the array size is a compile-time constant C=64, the compiler might recognize that the array fits in registers if we access it with constant indices. In a partially unrolled loop with a known trip count, the indices might still be tracked.
Wait, in a loop `for (int c = 0; c < C; c += 4)` that's unrolled by 4, the body accesses `vals[c]`, `vals[c+1]`, `vals[c+2]`, `vals[c+3]`. After unrolling the outer loop 16 times, the compiler sees accesses to `vals[0..63]`. Since the unrolled loop fully covers the array with compile-time known indices, the compiler CAN map this to registers!
The key is whether the compiler can see that the loop fully covers the range [0, C). With `#pragma unroll` and a compile-time constant C, it should be able to.
Actually, `#pragma unroll` forces full unrolling. So `for (int c = 0; c < C; c++)` with `#pragma unroll` and constexpr C becomes fully unrolled. This means all indices are compile-time constants, and arrays go to registers.
But the problem is code bloat. For 64 iterations, it's fine. For 32 iterations, it's fine.
For the inner dot product:
```cpp
#pragma unroll
for (int h = 0; h < H; h++) {
float acc = b1[h];
#pragma unroll
for (int c = 0; c < C; c++) {
acc += vals[c] * w1[h * C + c];
}
hidden[h] = fmaxf(acc, 0.0f);
}
```
Here, the inner loop over c is 64 iterations. Unrolled, that's 64 FMAs + 64 loads. The outer loop over h is 32 iterations. Total: 32 * (1 + 64 + 1) = 2112 instructions for just this section. That's significant.
But the GPU has high FMA throughput, so this should be fine.
Actually, wait. The memory access `w1[h * C + c]` in the unrolled loop: since h and c are compile-time constants in the unrolled code, this becomes `w1[0]`, `w1[1]`, ..., `w1[2047]`. The compiler can load these weights into registers or keep them as constant memory accesses.
If the weights are in global memory, each access is a load. With 2048 loads per position, and many positions in flight, this is memory-bound for weights unless cached.
But remember, weights are shared across all threads. If we use 256 blocks, each thread block loads the weights from global memory. With L2 caching, after the first few blocks, weights should be cache-hot. For a small model like this, the L2 should hold all weights easily.
Still, 2048 loads per thread is a lot. With 256 blocks, total weight loads = 256 * 2048 * some warp count. Each block has 64 threads, but warps load in parallel.
Hmm, but the compiler should vectorize the loads or at least cache them well.
Actually, since each thread loads ALL weights for the MLP, and threads in the same block load the same weights simultaneously, the L1 cache should help. If 32 threads in a warp all request `w1[0]`, they're reading the same address. The GPU broadcasts this single load to all threads (constant memory optimization). Similarly for `w1[1]`, etc.
Wait, does the GPU do this? For loads with the same address across all threads in a warp, the memory system handles a single load and broadcasts. This is very efficient!
Yes! This is called a "uniform load" or "broadcast load". If all threads in a warp access the same address at the same time, it's as fast as one load.
In our unrolled code:
```cpp
#pragma unroll
for (int h = 0; h < H; h++) {
for (int c = 0; c < C; c++) {
// all threads load w1[h*C + c]
acc += vals[c] * w1[h * C + c];
}
}
```
All threads in the warp load the SAME weight address `w1[h*C + c]` simultaneously. This is a broadcast load! Very efficient.
Then `vals[c]` is different for each thread. That's a per-thread register read.
This is actually an excellent pattern for our kernel. With broadcast loads for weights, the memory bandwidth for weights is tiny. And with vals in registers, the compute is just register FMAs.
So the fully unrolled template approach is very promising.
Let me write the kernel with templates for C and H:
```cuda
template<int C, int H>
__global__ void contact_map_kernel_t(
const float* __restrict__ pair_repr,
const float* __restrict__ ln_weight,
const float* __restrict__ ln_bias,
const float* __restrict__ mlp_w1,
const float* __restrict__ mlp_b1,
const float* __restrict__ mlp_w2,
const float* __restrict__ mlp_b2,
const float* __restrict__ mlp_w3,
const float* __restrict__ mlp_b3,
float* __restrict__ output,
int B, int N)
{
int idx = blockIdx.x * blockDim.x + threadIdx.x;
int total = B * N * N;
if (idx >= total) return;
int b = idx / (N * N);
int rem = idx % (N * N);
int i = rem / N;
int j = rem % N;
const float* ptr_ij = pair_repr + ((b * N + i) * N + j) * C;
const float* ptr_ji = pair_repr + ((b * N + j) * N + i) * C;
float vals[C];
float mean = 0.0f;
#pragma unroll
for (int c = 0; c < C; c++) {
vals[c] = ptr_ij[c];
mean += vals[c];
}
mean /= (float)C;
float var = 0.0f;
#pragma unroll
for (int c = 0; c < C; c++) {
float d = vals[c] - mean;
var += d * d;
}
var = rsqrtf(var / (float)C + 1e-5f);
#pragma unroll
for (int c = 0; c < C; c++) {
vals[c] = (vals[c] - mean) * var * ln_weight[c] + ln_bias[c];
}
// Second LN and symmetrize
float mean2 = 0.0f;
#pragma unroll
for (int c = 0; c < C; c++) {
mean2 += ptr_ji[c];
}
mean2 /= (float)C;
float var2 = 0.0f;
#pragma unroll
for (int c = 0; c < C; c++) {
float d = ptr_ji[c] - mean2;
var2 += d * d;
}
var2 = rsqrtf(var2 / (float)C + 1e-5f);
#pragma unroll
for (int c = 0; c < C; c++) {
float norm_ji = (ptr_ji[c] - mean2) * var2 * ln_weight[c] + ln_bias[c];
vals[c] = (vals[c] + norm_ji) * 0.5f;
}
// MLP
float hidden[H];
#pragma unroll
for (int h = 0; h < H; h++) {
float acc = mlp_b1[h];
#pragma unroll
for (int c = 0; c < C; c++) {
acc += vals[c] * mlp_w1[h * C + c];
}
hidden[h] = fmaxf(acc, 0.0f);
}
#pragma unroll
for (int h = 0; h < H; h++) {
float acc = mlp_b2[h];
#pragma unroll
for (int c = 0; c < H; c++) {
acc += hidden[c] * mlp_w2[h * H + c];
}
hidden[h] = fmaxf(acc, 0.0f);
}
float logit = mlp_b3[0];
#pragma unroll
for (int c = 0; c < H; c++) {
logit += hidden[c] * mlp_w3[c];
}
output[idx] = 1.0f / (1.0f + expf(-logit));
}
```
This looks correct. But one issue: the VLA `float vals[C]` where C is a template parameter. In C++, this is technically a Variable Length Array extension (not standard C++). But in CUDA, `float vals[C]` with a template parameter is fine because C is a compile-time constant.
Actually, standard C++ requires array sizes to be compile-time constants, which template parameters are. So `float vals[64]` is fine.
Now for the generic fallback kernel. I'll write a simpler version without templates, which might use local memory. But for the test, the template version will be used.
Wait, but what if the evaluation uses different sizes? The problem says `pair_dim = 64` and `hidden_dim = 32`, but the model should accept arbitrary sizes. I'll provide a dispatcher.
Now, in the C++ wrapper:
```cpp
torch::Tensor contact_map_forward(
torch::Tensor pair_repr,
torch::Tensor ln_weight,
torch::Tensor ln_bias,
torch::Tensor w1,
torch::Tensor b1,
torch::Tensor w2,
torch::Tensor b2,
torch::Tensor w3,
torch::Tensor b3)
{
int B = pair_repr.size(0);
int N = pair_repr.size(1);
int C = pair_repr.size(3);
int H = w1.size(0); // out_features of first linear
auto output = torch::empty({B, N, N}, pair_repr.options());
int total = B * N * N;
int threads = 64;
int blocks = (total + threads - 1) / threads;
auto stream = at::cuda::getCurrentCUDAStream();
if (C == 64 && H == 32) {
contact_map_kernel_t<64, 32><<<blocks, threads, 0, stream>>>(
pair_repr.data_ptr<float>(),
ln_weight.data_ptr<float>(),
ln_bias.data_ptr<float>(),
w1.data_ptr<float>(),
b1.data_ptr<float>(),
w2.data_ptr<float>(),
b2.data_ptr<float>(),
w3.data_ptr<float>(),
b3.data_ptr<float>(),
output.data_ptr<float>(),
B, N
);
} else {
contact_map_kernel<<<blocks, threads, 0, stream>>>(
pair_repr.data_ptr<float>(),
ln_weight.data_ptr<float>(),
ln_bias.data_ptr<float>(),
w1.data_ptr<float>(),
b1.data_ptr<float>(),
w2.data_ptr<float>(),
b2.data_ptr<float>(),
w3.data_ptr<float>(),
b3.data_ptr<float>(),
output.data_ptr<float>(),
B, N, C, H
);
}
return output;
}
```
Wait, `w1` has shape (H, C) in PyTorch, so `w1.size(0)` = H. Yes.
But I need to create the generic fallback kernel too.
For the generic kernel, to avoid local memory issues, I can process the MLP in a different way. Or I can just use the same code without templates, accepting potential local memory usage for non-standard sizes. For the test, the template version handles it.
Actually, looking at nvcc behavior, even without templates, if we write `#pragma unroll` and the loops bounds are runtime but known to be small, the compiler might still generate decent code. But local memory for the arrays is likely.
Alternative for generic kernel: use shared memory for the intermediate arrays.
```cpp
__global__ void contact_map_kernel(...) {
extern __shared__ float shared[]; // or fixed size
// each thread uses shared memory for its vals and hidden
}
```
But managing shared memory per thread is complex and has other issues.
For now, I'll write a generic kernel that mirrors the template one. It will be slower for non-standard sizes but work.
Actually, wait. If I write `float vals[64]` in a kernel where C is runtime=64, nvcc might STILL keep it in local memory because the compiler doesn't know the upper bound is 64 at compile time.
Actually wait, the array size IS compile-time constant: `float vals[64]`. But the loop `for (int c = 0; c < C; c++)` where C is runtime doesn't cover the entire array. The compiler isn't sure if all elements are accessed.
To help the compiler, I could write `#pragma unroll` regardless. But if C is runtime, the compiler can't fully unroll.
Actually, let me check: in nvcc, `#pragma unroll` on a loop with runtime bound causes the compiler to... not unroll? Or it uses a heuristic? I think `#pragma unroll` without a number asks the compiler to fully unroll, which it won't do for runtime bounds.
With `#pragma unroll 8`, it unrolls 8 times. The indices are still dynamic.
I think for the generic kernel, I'll just use simple code and rely on the fact that the test case uses the template version.
Now, for the Python side:
```python
import torch
import torch.nn as nn
from torch.utils.cpp_extension import load_inline
cuda_source = """
... all the CUDA/C++ code ...
"""
contact_map_ext = load_inline(
name="contact_map_cuda",
cpp_sources="",
cuda_sources=cuda_source,
functions=["contact_map_forward"],
extra_cuda_cflags=["-O3", "--use_fast_math"],
verbose=True,
)
class ModelNew(nn.Module):
def __init__(self, pair_dim, hidden_dim):
super().__init__()
self.pair_dim = pair_dim
self.hidden_dim = hidden_dim
# Use same modules so state dict loading works
self.layer_norm = nn.LayerNorm(pair_dim)
self.mlp = nn.Sequential(
nn.Linear(pair_dim, hidden_dim),
nn.ReLU(),
nn.Linear(hidden_dim, hidden_dim),
nn.ReLU(),
nn.Linear(hidden_dim, 1),
)
def forward(self, pair_repr):
# Extract contiguous float tensors
ln_w = self.layer_norm.weight.contiguous()
ln_b = self.layer_norm.bias.contiguous()
w1 = self.mlp[0].weight.contiguous()
b1 = self.mlp[0].bias.contiguous()
w2 = self.mlp[2].weight.contiguous()
b2 = self.mlp[2].bias.contiguous()
w3 = self.mlp[4].weight.contiguous()
b3 = self.mlp[4].bias.contiguous()
return contact_map_ext.contact_map_forward(
pair_repr, ln_w, ln_b, w1, b1, w2, b2, w3, b3
)
```
One important detail: `nn.Linear.weight` in PyTorch is of shape (out_features, in_features). For `mlp[0]`, weight shape is (hidden_dim, pair_dim). When we pass it to the kernel, the 1D pointer accesses `w1[h * C + c]`, which matches the row-major storage.
Another detail: we need to make sure the tensors are contiguous and on the same device. `contiguous()` handles this, but we should also ensure dtype is float32 (which it should be by default).
Also, the output is (B, N, N). The kernel writes to a flat array of size B*N*N. The output tensor is created with shape {B, N, N}, and data_ptr points to contiguous memory.
Let me refine the kernel for better numerical stability in LayerNorm. PyTorch uses `rsqrt(var + eps)` where var is computed as `sum(d*d) / C`. So:
```cpp
float inv_std = rsqrtf(var / (float)C + 1e-5f);
```
This matches PyTorch.
Also, `fmaxf(acc, 0.0f)` for ReLU. PyTorch's ReLU: `max(0, x)`.
For sigmoid, I'll use `1.0f / (1.0f + expf(-logit))`.
One more optimization: instead of reading `ptr_ji` unconditionally, for i==j it's the same as `ptr_ij`. The compiler might not optimize this, but memory is cached. I could add a conditional to avoid the extra read when i==j:
```cpp
const float* ptr_ji = (i == j) ? ptr_ij : (pair_repr + ((b * N + j) * N + i) * C);
```
This avoids fetching separate cache lines for diagonal elements. But for off-diagonal, it's the same. This is a small optimization.
Actually, I'll keep it simple for correctness.
Now let me think about whether 64 threads per block and 256 blocks is better than 128 threads per block and 128 blocks.
With the template kernel generating ~3000 instructions per thread, each block of 64 threads has ~192K instructions. At 2GHz and 2 FMAs per cycle, this might take a while. Having 256 blocks vs 128 blocks doubles the block count, which helps occupancy.
But 64 threads per block = 2 warps. The SM warp scheduler on H100 can issue instructions from multiple warps. With only 2 warps, there's less opportunity to hide latency. For example, if both warps are waiting on memory, the SM stalls.
With 128 threads per block = 4 warps, there's more opportunity for inter-warp latency hiding per block. But the block count drops to 128.
So there's a tradeoff: 128 blocks * 4 warps = 512 total warps, vs 256 blocks * 2 warps = 512 total warps. Same total warps!
The difference is in how warps are grouped into blocks. With 4 warps per block, the block takes longer to finish (since it has twice the threads). But each block is assigned to one SM. An SM running a 4-warp block can switch between those 4 warps. An SM running a 2-warp block can only switch between 2 warps.
However, with 256 blocks, each SM gets about 2 blocks on average. If an SM can run 2 blocks concurrently, it has 2 * 2 = 4 warps to switch between. This is the same as 1 block with 4 warps.
But can an SM run 2 blocks concurrently given register and shared memory constraints? With 100 registers per thread and 64 threads, that's 6400 registers per block. An SM has 64K registers, so it can run up to 10 such blocks. So yes, an SM can easily run 2 blocks.
But whether 2 blocks are actually assigned to the same SM depends on the block scheduler. Usually, the scheduler tries to distribute blocks evenly. However, blocks are dispatched to SMs as they become free. If a block takes some time, other SMs might pick up the next blocks.
In practice, both configurations will likely perform similarly. I'll go with 128 threads per block and 128 blocks, because it's a more standard block size and gives more warps per block for latency hiding.
Wait, actually, 128 threads per block gives 4 warps. H100 can schedule up to 64 warps per SM. With 128 blocks total, and some SMs running multiple blocks, the average concurrent warps per SM is 512 / 132 ≈ 3.9. That's very low for an SM that can hold 64 warps.
But each of those warps has high latency. The key metric is whether the warp scheduler can always find an executable warp. If a warp is stalled on memory or executing FMAs, another warp needs to be ready.
For our kernel, warps are mostly doing arithmetic with some memory loads. With ~50% arithmetic and ~50% memory, a warp might stall on memory sometimes. Having 4 warps per block means if one is stalled, there are 3 others. But the scheduler issues per-warp independently.
I think 128 threads per block is fine. But let me reconsider 256 blocks of 64 threads. With 256 blocks, if the initial wave fills all 132 SMs with 1 block each (64 threads), and SMs can take 2 blocks, we have 264 blocks in the first wave. With 256 total blocks, almost all fit in one wave! The remaining 132 - (256 % 132) = wait, 132 * 1 = 132, 132 * 2 = 264. So all 256 blocks could fit on 132 SMs if each SM takes 2 blocks (264 capacity > 256).
This means ALL blocks could execute concurrently in a single wave! That's actually really good.
With 128 blocks of 128 threads, capacity is 132 blocks in one wave (if each SM takes 1 block of 128 threads = 4 warps, max 64 warps/SM = 16 blocks/SM). Actually, with 128 threads and 6400 registers, an SM can hold ~10 blocks. So 128 blocks would easily fit in one wave too.
Both fit in one wave. The difference is: with 256 blocks of 64 threads, each block has fewer warps but more blocks. With 128 blocks of 128 threads, fewer blocks but more warps.
With the same total warps (512) and same work per warp, both should finish similarly. I'll go with 128 threads per block since it's a more typical block size and might have slightly better memory coalescing within warps.
Actually, there's another effect: kernel launch overhead. Launching 256 blocks might have slightly more overhead than 128 blocks, but this is negligible for this kernel size.
Let me stick with 128 threads per block and 128 blocks. It's simpler.
Now, one more thing: static array sizes in CUDA. If I declare `float vals[64]` in the kernel, and C is a template parameter 64, the compiler is happy. But some versions of nvcc warn about or fail at runtime if register arrays are too large. With 64 floats = 256 bytes, this should be fine.
For the generic fallback, I could use `float vals[64]` with a note that `C` shouldn't exceed 64 for the non-template path. Actually, the problem says pair_dim = 64 for the test. But to be robust, I should dynamically allocate or use a larger bound.
For the generic kernel, let me just use `#define MAX_DIM 64` or something. But actually, to be fully general, I should support any C and H. But for this task, templates for (64, 32) are sufficient.
Let me write the full kernel code now.
Actually, one important thing: `torch::empty({B, N, N}, pair_repr.options())` creates the output tensor. We need to make sure this is contiguous. `torch::empty` is contiguous by default.
For the kernel launch, we should use `at::cuda::getCurrentCUDAStream()` to make it async.
Wait, I need to `include <ATen/cuda/CUDAContext.h>` for `at::cuda::getCurrentCUDAStream`. In `load_inline`, I think `torch/extension.h` might include this. Or we can use the default stream with `0`.
Actually, `torch/extension.h` usually includes everything needed. But to be safe, I can use the default stream or include the right header.
With `load_inline`, the CUDA source is compiled with nvcc. The standard approach is:
```cpp
#include <torch/extension.h>
#include <cuda_runtime.h>
```
And use `at::cuda::getCurrentCUDAStream(pair_repr.device().index())` perhaps. But `torch/extension.h` includes aten headers.
Let me check: `torch::empty` is in `ATen/ATen.h` which is included by `torch/extension.h`.
For the stream, I can just pass `at::cuda::getCurrentCUDAStream()` if available. Or I can pass `0` for the default stream. For correctness, passing `0` is fine, though less efficient for pipelining.
Actually, for performance we should use the current stream:
```cpp
cudaStream_t stream = at::cuda::getCurrentCUDAStream().stream();
```
Wait, `at::cuda::getCurrentCUDAStream()` returns a `c10::cuda::CUDAStream`, and we use `.stream()` to get the `cudaStream_t`.
```cpp
auto stream = at::cuda::getCurrentCUDAStream().stream();
kernel<<<blocks, threads, 0, stream>>>(...);
```
Now, I need to write the Python code that wraps all this.
One thing I want to double-check: does `load_inline` with `cuda_sources` work? Let me try to write it.
```python
cuda_source = r'''
#include <torch/extension.h>
#include <cuda_runtime.h>
template<int C, int H>
__global__ void contact_map_kernel_t(...) { ... }
__global__ void contact_map_kernel(...) { ... }
torch::Tensor contact_map_forward(...) {
...
}
'''
ext = torch.utils.cpp_extension.load_inline(
name="contact_map_cuda",
cpp_sources="", # empty
cuda_sources=cuda_source,
functions=["contact_map_forward"],
extra_cuda_cflags=["-O3", "--use_fast_math"],
)
```
Wait, actually I realized something. `load_inline` signature is:
```python
load_inline(name, cpp_sources, cuda_sources=None, functions=None, extra_cuda_cflags=None, verbose=False)
```
When `cuda_sources` is provided, it creates a `.cu` file with the CUDA source, and also may need C++ wrappers. If we put the wrapper function (which uses `<<< >>>`) in `cuda_sources`, nvcc should compile it correctly. The `cpp_sources` can be empty or contain pure C++ declarations.
Actually, I think the common pattern works fine with everything in `cuda_sources`. But to be safe, I can put declarations in `cpp_sources`:
```python
cpp_source = """
torch::Tensor contact_map_forward(
torch::Tensor pair_repr,
torch::Tensor ln_weight,
torch::Tensor ln_bias,
torch::Tensor w1,
torch::Tensor b1,
torch::Tensor w2,
torch::Tensor b2,
torch::Tensor w3,
torch::Tensor b3
);
"""
cuda_source = """
#include <torch/extension.h>
#include <cuda_runtime.h>
... kernels and wrapper ...
"""
```
And then:
```python
ext = load_inline(
name="contact_map_cuda",
cpp_sources=cpp_source,
cuda_sources=cuda_source,
functions=["contact_map_forward"],
extra_cuda_cflags=["-O3", "--use_fast_math"],
)
```
This should work.
Actually, looking at the PyTorch source, `load_inline` generates a pybind11 wrapper that calls the functions listed. The functions must be defined somewhere in the compiled code. Since our wrapper is in `cuda_sources`, it will be compiled by nvcc and available.
Let me verify by trying to compile it.
Actually, before writing the final code, I should test compilation.
Wait, one more thought: should we consider using shared memory for the weights after all? If all threads in a block load weights cooperatively into shared memory, and then access them, it might be faster than L2 cache reads.
Given 128 threads per block, and each thread needing to read the same weights, doing explicit shared memory load:
```cpp
__shared__ float s_w1[64*32];
// Thread 0 loads all weights? Or cooperative load:
for (int i = threadIdx.x; i < 64*32; i += blockDim.x) {
s_w1[i] = mlp_w1[i];
}
__syncthreads();
```
For 2048 weights + 32 bias + 1024 weights + 32 bias + 32 weights + 1 bias + 64 + 64 = ~3300 floats. At 128 threads, we'd need ceil(3300/128) = 26 loads per thread. That's trivial.
Then all threads access `s_w1[h*C + c]` instead of `mlp_w1[h*C + c]`. Shared memory latency is much lower than global memory, and there's no bank conflict since all threads read the same address (broadcast).
This could provide a significant speedup for the weight-heavy part of the kernel.
But we also need to consider that the MLP weights are read after the pair_repr data. The kernel is memory-bound on pair_repr reads. The compute part might not be the bottleneck. But let's add shared memory for weights anyway; it's a relatively cheap optimization.
Shared memory usage:
- s_ln_w[64]
- s_ln_b[64]
- s_w1[2048]
- s_b1[32]
- s_w2[1024]
- s_b2[32]
- s_w3[32]
- s_b3[1]
Total: 3297 floats = ~13KB.
Wait, with block size 128, shared memory of 13KB is very manageable. The max shared memory per block is ~227KB, but typically the kernel uses much less.
Let's add shared memory for weights!
But wait, the template kernel generates a LOT of instructions if fully unrolled. Adding `__syncthreads()` and shared memory loads adds a bit more code but also reduces global memory traffic.
For the generic kernel, shared memory for weights is even more important because it might otherwise do repeated global loads.
Let me write the kernel with shared memory for weights:
```cuda
template<int C, int H>
__global__ void contact_map_kernel_t(
const float* __restrict__ pair_repr,
const float* __restrict__ mlp_w1, // (H, C)
const float* __restrict__ mlp_b1, // (H,)
const float* __restrict__ mlp_w2, // (H, H)
const float* __restrict__ mlp_b2, // (H,)
const float* __restrict__ mlp_w3, // (1, H)
const float* __restrict__ mlp_b3, // (1,)
const float* __restrict__ ln_weight,
const float* __restrict__ ln_bias,
float* __restrict__ output,
int B, int N)
{
// Shared memory for weights
__shared__ float s_ln_w[64];
__shared__ float s_ln_b[64];
__shared__ float s_w1[64*32]; // max
__shared__ float s_b1[32];
__shared__ float s_w2[32*32]; // max
__shared__ float s_b2[32];
__shared__ float s_w3[32]; // max
__shared__ float s_b3[1];
int tid = threadIdx.x;
// Cooperative load of weights
// Total weights: 2*C + H*C + H + H*H + H + H + 1
int total_weights = 2*C + H*C + H + H*H + H + H + 1;
// Map all weights into a contiguous conceptual array
// Actually, load each array separately
if (tid < C) {
s_ln_w[tid] = ln_weight[tid];
s_ln_b[tid] = ln_bias[tid];
}
// Load w1 and b1
// w1 has H*C elements
for (int i = tid; i < H*C; i += blockDim.x) {
s_w1[i] = mlp_w1[i];
}
if (tid < H) {
s_b1[tid] = mlp_b1[tid];
}
// Load w2 and b2
for (int i = tid; i < H*H; i += blockDim.x) {
s_w2[i] = mlp_w2[i];
}
if (tid < H) {
s_b2[tid] = mlp_b2[tid];
}
// Load w3 and b3
for (int i = tid; i < H; i += blockDim.x) {
s_w3[i] = mlp_w3[i];
}
if (tid == 0) {
s_b3[0] = mlp_b3[0];
}
__syncthreads();
// Now compute
int idx = blockIdx.x * blockDim.x + threadIdx.x;
int total = B * N * N;
if (idx >= total) return;
int b = idx / (N * N);
int rem = idx % (N * N);
int i = rem / N;
int j = rem % N;
const float* ptr_ij = pair_repr + ((b * N + i) * N + j) * C;
const float* ptr_ji = pair_repr + ((b * N + j) * N + i) * C;
// LayerNorm + symmetrize + MLP
float vals[64]; // assuming max C=64, but for template C is fixed
// ... same as before but reading from s_* instead of global ...
}
```
Wait, but `float vals[64]` when the template C might be smaller than 64. That's fine, we just use the first C elements.
However, for the template version, I used `float vals[C]`. To make the shared memory sizes work with templates, we'd need `__shared__ float s_w1[64*32];` vs template-sized arrays. We can just use the max sizes.
Using fixed shared memory sizes is simpler and avoids template-dependent shared memory sizing.
But one concern: `__shared__ float s_w1[64*32] = 2048` floats. On H100, shared memory is 48KB per block by default, extendable to 164KB or 227KB. 2048 floats = 8KB. Total with other arrays: ~8 + 4 + 0.128 + 0.128 + ... let's calculate:
- s_ln_w[64] = 256 bytes
- s_ln_b[64] = 256 bytes
- s_w1[2048] = 8192 bytes
- s_b1[32] = 128 bytes
- s_w2[1024] = 4096 bytes
- s_b2[32] = 128 bytes
- s_w3[32] = 128 bytes
- s_b3[1] = 4 bytes
Total: 256*2 + 8192 + 128 + 4096 + 128 + 128 + 4 = 256 + 8192 + 128 + 4096 + 128 + 128 + 4 = 12,932 bytes ≈ 13KB.
This is well under 48KB. So no problem with default shared memory limits.
However, there's a subtlety: if we use fixed arrays like `s_w1[2048]`, the compiler allocates that space regardless of actual H and C. That's fine if max H*C <= 2048, which it is for H=32, C=64.
But the generic kernel should support arbitrary sizes. So for the generic kernel, either we dynamically determine shared memory size, or we just don't use shared memory.
For now, let me make the template kernel with fixed-size shared memory for the known max dims, and the generic kernel without shared memory.
Actually, let me just skip shared memory for now and focus on getting a working, reasonably fast kernel. The weights are tiny and will be L2 cached. The main memory traffic is pair_repr, which we can't reduce.
Wait, but I just thought of something. If we do shared memory for weights, we need to load them at the start of each block. With 128 blocks, that's 128 * 13KB = ~1.6MB of weight reads from global memory. Without shared memory, each thread reads weights directly: 16384 threads * ~3.3KB weights... no, the threads read weights that are cached. The actual global memory reads are cached, so the total memory traffic is much less than 16384 * 3.3KB. In fact, with L2 cache, all weights might be read from L2 after the first block or two.
The critical path is pair_repr: 8MB of reads. At 3.35 TB/s, that's 2.4 microseconds. The compute is ~50M FMAs, which at 67 TFLOPS is ~0.75 microseconds. So the kernel is memory-bound.
Therefore, optimizing weight memory access won't help much if pair_repr is the bottleneck. The kernel will be dominated by reading pair_repr.
So let me skip shared memory for weights to keep the code simpler.
Wait, there's another way to look at this. The theoretical memory time is ~2.4us for 8MB. But we also need to WRITE the output: 4 * 64 * 64 * 4 bytes = 64KB. That's negligible.
If we could somehow compute the symmetrization more efficiently by having each thread handle both (i,j) and (j,i), we could... no, we're already computing both. The issue is we read both positions.
Actually, what if we process only the upper triangle (including diagonal), and then mirror the output? But the MLP is nonlinear, so we can't mirror the output. We'd have to compute for all (i,j).
Wait, actually, the reference DOES symmetrize the INPUT, then apply the MLP. Since the MLP is on the symmetrized input, and the MLP is nonlinear, the output is NOT symmetric in general. So we MUST compute for all (i,j).
One optimization: since each pair (i,j) and (j,i) read the same two memory locations, but process them independently, maybe we can have a block handle both (i,j) and (j,i) and reuse data.
For example, a thread computes (i,j), and another thread in the same block computes (j,i). They both need ptr_ij and ptr_ji. If these threads are in the same block and same warp, their memory accesses can be cached.
But this only helps L1 cache hits for blocks that happen to have both (i,j) and (j,i). In a random thread assignment, this is unlikely.
In our block assignment (sequential indices), thread idx computes (i,j) and thread idx±1 computes (i,j±1). The transpose (j,i) is at index b*N*N + j*N + i, which might be far away.
The tile approach (8x8 tiles) is better for this! In an 8x8 tile, threads compute (ti+di, tj+dj). The transposes are in the transpose tile (tj+dj, ti+di). These transposes are NOT in the current tile unless it's diagonal.
However, if we use a tile-based assignment where each block handles a symmetric pair of tiles... wait, we could have blocks only process tiles where ti <= tj, and within the block, threads handle positions in the upper triangle. But then for the lower triangle positions, they need to be computed too. There are (N*N + N)/2 positions in the upper triangle, and by symmetry... no wait, we can't exploit symmetry in the output.
Actually, let me reconsider: what if a single block handles an entire batch's matrix? 4096 positions, each computed by one thread. 4096 threads per block is too many.
What if a block handles a row of the matrix? 64 positions, 64 threads. Then transposes of positions in this row are in the corresponding columns, spread across other rows. Not much locality.
For N=64, the whole matrix is small: 4096 positions * 64 channels = 262K floats. That's 1MB per batch. If a block loads the entire batch's matrix into shared memory... 1MB is too much for shared memory.
But 1MB fits in L2 cache easily. And the whole tensor for B=4 is 4MB, which also fits in L2 cache. So repeated reads of the same elements from different threads will hit L2.
With L2 cache hits, the effective bandwidth is much higher than DRAM bandwidth. L2 bandwidth on H100 is several TB/s. So the actual memory bandwidth might not be the bottleneck.
In that case, the kernel might be compute-bound after all! With ~50M FMAs and 67 TFLOPS, that's ~0.75us. Can we achieve that?
To achieve 0.75us, we'd need the kernel to execute all 512 warps with very high throughput and no stalls.
Given the instruction count per warp (~3004 fully unrolled instructions), at 2GHz and 4 warp schedulers per SM:
Actually, let's compute more carefully.
Each warp executes maybe 3000-5000 instructions. At 2 issue per clock per scheduler, and 4 schedulers per SM... but not all instructions are FMAs.
For our kernel, the main instructions are:
- FMAs: ~2048 (linear1) + ~1024 (linear2) + ~32 (linear3) = ~3104
- Loads: ~64*2 (pair_repr) + ~64*4 (weights via broadcast) = ~384
- Other ALU: ~200-300
Total: ~4000 instructions per warp.
Issue rate: H100 can issue 4 warps per clock (1 per scheduler). But each scheduler can issue to multiple execution units.
For FP32 FMAs: throughput is 1 per clock per CUDA core. With 128 FP32 cores per SM (maybe more on H100... actually H100 has 128 FP32 cores per SM). Wait, H100 SM has 128 FP32 CUDA cores, and can execute 128 FMAs per clock. With 4 warps executing, each warp gets 32 FMAs per clock (since threads in a warp are SIMT, they execute the same instruction on 32 data elements simultaneously... wait, not quite. A warp executes one instruction for all 32 threads. So 32 FMAs per clock per warp per SM).
With 128 FP32 cores, 4 warps can execute FMAs simultaneously (32 threads * 4 = 128). So FMA throughput is 128 per clock per SM.
With 3104 FMAs per warp, and 32 warps per SM (let's say), the FMA time is: 3104 / (128 * (32/4)?) No, let me think again.
Each warp has 3104 FMA instructions. Each instruction operates on 32 threads (32 FMAs in hardware). Executing 4 warps takes 4x the time of 1 warp if they run sequentially. But with 128 cores, if 4 warps all want to issue FMAs, they can do so simultaneously? No, each warp issues 1 instruction, and 32 threads execute it. 128 cores can handle 4 warps' worth of FMAs per clock: 4 warps * 32 threads = 128 threads.
Actually, for a single instruction in a warp, all 32 threads execute simultaneously on 32 cores. The remaining 96 cores can execute instructions from 3 other warps. So 4 warps can issue FMAs simultaneously.
Time per SM to execute 3104 FMA instructions for 4 warps: 3104 clocks (since 4 warps * 1 FMA instruction per clock each = 4 per clock, matching 128 cores).
Actually, each warp takes 3104 clocks to execute its FMAs, assuming all 4 warps are executing FMAs every clock. But they also execute loads and other ops.
If a warp executes 3104 FMAs and ~500 other instructions, it takes ~3600 clocks. At 2GHz, that's ~1.8 microseconds per warp.
But this is if the warp is the ONLY one on the SM. With 64 warps per SM, the SM can execute 64 warps concurrently. The scheduler issues instructions from ready warps.
I think the arithmetic per position is high enough that this kernel won't be trivial.
OK, I think my design is solid. Let me write the actual code and test it.
One more thing: the generic kernel. I'll write it alongside the template kernel. For the generic kernel, to avoid excessive local memory usage, I'll process the operations in a way that doesn't require large arrays with dynamic indexing.
For the generic kernel:
1. For the first LayerNorm, I can read pair_repr into registers using a fixed-size array but only using C elements. The array is `float vals[64]`, and I loop up to C with pragma unroll 8.
Actually, even without templates, if C=64 at runtime and I have `float vals[64]`, nvcc might put it in local memory because it's accessed via loop index. But since the array size is a compile-time constant and the loop might be partially unrolled... it depends.
To guarantee register usage, we'd need dynamic indexing to be avoided. But for simplicity, I'll just write the generic version and hope it's not too bad. For the test case, we use the template.
Actually, I just realized: for the generic case, I could store the 64-element vector in registers by processing it in chunks. But that's complex.
OK, let me write the full code now:
Wait, I want to double check: `load_inline` with everything in one source. Let me use this pattern:
```python
cuda_source = r"""
#include <torch/extension.h>
#include <cuda_runtime.h>
template<int C, int H>
__global__ void contact_map_kernel_t(...) { ... }
torch::Tensor contact_map_forward(...) { ... }
"""
ext = torch.utils.cpp_extension.load_inline(
name="contact_map_cuda",
cuda_sources=cuda_source,
functions=["contact_map_forward"],
extra_cuda_cflags=["-O3", "--use_fast_math"],
verbose=False,
)
```
I need to check if `load_inline` accepts the `cpp_sources` and `cuda_sources` together. Looking at the signature, it does. Let me just pass `cuda_sources` and leave `cpp_sources` as empty or None.
Actually, for `torch.utils.cpp_extension.load_inline`, if `cuda_sources` is provided and `cpp_sources` is empty, the generated pybind11 module needs to find the function declaration. Wait, `load_inline` generates Python bindings for the functions listed. The function definitions can be in `cuda_sources`.
Let me try this pattern. If it doesn't compile, I'll adjust.
Also, `extra_cuda_cflags=["-O3", "--use_fast_math"]` might need to be a list.
Let me draft the code: