reasoning
Basic kernel compiled and passed correctness. But it's quite naive — one thread per node which will lead to poor occupancy and warps with very imbalanced workloads (since some rows have 16, some have 20 edges). We should design a more efficient kernel.
We have avg_degree around 18, num_heads = 8, num_nodes = 384.
The input is float32. Total edges ~ 18*384 = 6912. Each row is independent softmax per head, across edges. Since each row has ~18 edges and 8 heads, total elements per row: ~144. That's quite small. A warp per row could work, processing each head perhaps cooperatively.
Given that the degree is small (~16-20), a block per row is too much. A warp per row is good. 32 threads per row, but only 18 elements to read. We could assign 1 warp per row. For each head, use warp-level reduction to compute max and sum.
Alternatively, a block processes multiple rows e.g. 256 threads = 8 warps, each warp handles one row. That would process 8 rows per block. With 384 nodes, that's 48 blocks. On an H100, this is fine but not saturating the GPU. Still, speedup over CPU-like torch loop is expected.
Given the small problem size (384 nodes), the loop on CPU already is fast but Python overhead + kernel launch + torch.softmax for each row is large. Even the naive kernel should give significant speedup. But we can do better.
First, let me try the naive kernel with submit to see if speedup is enough. But maybe I should optimize first.
Let me try a warp-per-row kernel. Use 32 threads per warp. For each row, each warp:
- For each head h (0..7):
- Load edge_scores for edges in strided manner from start to end.
- Compute warp max.
- Compute warp sum of exp.
- Each thread writes its assigned edges.
Since num_heads=8 and degree~18, each warp does 8 iterations, each with ~18 loads and stores. Total loads per warp = 8*18=144. Each thread does ~144/32 = 4.5 loads. Very light. But better than 1 thread per row doing all sequential loads.
Also, shared memory may not be needed; we can keep values in registers while doing the load twice (once for max, once for sum). However, degree is small, so we could load into registers or shared memory. Since max degree is clamped from avg 18 plus/minus 2, max degree is 20. 20*8 = 160 floats = 640 bytes per row. Not much. But registers: each thread in a warp handling a row could store ~ceil(20/32)=1 value? Not enough. Actually if we want to reuse across max and sum passes, we need to either store in shared memory or reload. Reloading is fine since it's from L1 and only 18 elements * 8 heads = 144 floats = 576 bytes per row. That's tiny.
So the warp-per-row kernel reloading is fine. But to reduce the number of passes, we could load once into shared memory for the row, then do max and sum from shared memory.
Let's implement a block-per-multiple-rows kernel. Each block has WARPS_PER_BLOCK warps. Each warp handles one row. We can have e.g. 8 warps per block (256 threads). So each block handles 8 rows.
Shared memory: For each warp (row), we allocate MAX_DEGREE floats per head? Actually we can allocate per warp a small shared memory buffer. Since max degree is around 20, and we have 8 warps, we need 8 * 20 * 8 heads? If we store per edge per head, but we can process heads sequentially on the fly. Let's just use registers and reload. Simpler.
Implementation details:
- grid: (num_nodes + WARPS_PER_BLOCK - 1) / WARPS_PER_BLOCK blocks.
- block: WARPS_PER_BLOCK * 32 threads.
- warp_id = threadIdx.x / 32
- lane = threadIdx.x % 32
- row = blockIdx.x * WARPS_PER_BLOCK + warp_id
- if row >= num_nodes: return
- start = row_ptr[row]; end = row_ptr[row+1];
- for h in 0..num_heads-1:
- Compute max_val across edges [start, end) for this head.
- Use __shfl_sync for warp reduce, or use WarpReduce from cub. For simplicity, use __shfl_sync.
- Then compute sum of expf(val - max) across edges.
- Then write back expf(val - max) / sum for each edge.
Warp reduction for max and sum. We need each thread to iterate over edges in strided fashion by lane id, and then use warp-level reduction.
Pseudo:
float max_val = -1e30f;
for (int e = start + lane; e < end; e += 32) {
float v = edge_scores[e * num_heads + h];
max_val = fmaxf(max_val, v);
}
// warp reduce max
#pragma unroll
for (int offset = 16; offset > 0; offset /= 2)
max_val = fmaxf(max_val, __shfl_down_sync(0xFFFFFFFF, max_val, offset));
// broadcast max
max_val = __shfl_sync(0xFFFFFFFF, max_val, 0);
float sum = 0.0f;
for (int e = start + lane; e < end; e += 32) {
sum += expf(edge_scores[e * num_heads + h] - max_val);
}
// warp reduce sum
#pragma unroll
for (int offset = 16; offset > 0; offset /= 2)
sum += __shfl_down_sync(0xFFFFFFFF, sum, offset);
sum = __shfl_sync(0xFFFFFFFF, sum, 0);
float inv_sum = 1.0f / sum;
for (int e = start + lane; e < end; e += 32) {
out[e * num_heads + h] = expf(edge_scores[e * num_heads + h] - max_val) * inv_sum;
}
This is good. But we re-read edge_scores three times per head. 3 * 8 * ~18 = 432 loads per warp. Still very small. But we could reduce to 2 reads if we compute expf and store in a register array? Not feasible due to variable degree. But maybe use shared memory: load all edges for this head into a shared buffer, then do max reduction on that buffer, then sum, then write back. However, that requires synchronization within warp, but warps are SIMT so they execute together; we can use shared memory per warp.
Since degree is small, we could have each thread load one or zero edges into shared mem, then do reductions. But the strided loop with shfl is simpler and likely fast enough for this size.
But is this faster than the naive kernel? The naive kernel has one thread per node doing sequential access. With 384 threads total, many warps will be active but each warp has only 1 active thread (others idle). That's bad for occupancy but also memory access patterns are not coalesced because each thread reads contiguous floats, but within a warp only thread 0 reads, so memory is fetched in 32-byte lines but mostly wasted. With warp-per-row, all 32 lanes participate and reads are coalesced (consecutive threads read consecutive edges). This gives 32x better memory bandwidth utilization.
Also, warp-level reduction avoids shared memory and is fast.
Let's test this kernel.
One consideration: The row_ptr in the reference is created on CPU, so passed to forward may be CPU tensor. We move it to GPU inside forward. Need to ensure the kernel receives all tensors on GPU and contiguous.
Also, we might want to process multiple rows per warp? Since degree is ~18, one warp per row is slightly underutilized (only 18/32 lanes have work per iteration). But since there are 8 heads, total work per warp is ~144 elements. That's okay. Could process 2 rows per warp? Then each lane handles edges from two rows, which complicates indexing and reduction. Not worth it.
Alternatively, process 1 thread per head per row? Since num_heads=8, a warp of 32 threads could handle 4 rows simultaneously (8 threads per row). But reduction across 8 threads is a bit more complex but can be done with shfl. However, memory coalescing: if thread 0..7 handle row 0 edges 0.., they would access edge_scores[e*8 + h] where h is fixed per thread. So within a row, consecutive threads access consecutive head dimension. That is actually coalesced if we arrange properly: thread h accesses head h. But e varies across iterations. Not sure it helps much.
I think warp-per-row is the best simple optimization.
Also, for even more performance, we could use vectorized loads (float4) if degree >= 4. But degree is ~18, so float4 could help, but the indexing is [e * num_heads + h]. Since num_heads=8, consecutive e mean offset 8 floats. That is 32 bytes, which is one float4. So we can load float4 for 4 consecutive edges? Actually edge_scores[e*8 + h], if we fix h, consecutive e are offset by 8 floats = 32 bytes. So a float4 load would load 4 edges at once (e, e+1, e+2, e+3) for the same head. That is perfectly aligned if start is aligned and e increments by 4. Since the row lengths are arbitrary, alignment may not be guaranteed. But on GPU, misaligned float4 loads can still be okay but may split into multiple transactions. To keep it simple, I'll skip vectorization.
Another optimization: Process multiple heads in a single loop to reuse loaded values? Not possible since each head is independent.
Also, degree varies per row (16 to 20). The loop `for (int e = start + lane; e < end; e += 32)` will have at most 1 iteration because degree < 32. So each lane either loads 1 element or none. That's fine.
Let's write the kernel.
gridDim.x = (num_nodes + WARPS_PER_BLOCK - 1) / WARPS_PER_BLOCK
blockDim.x = WARPS_PER_BLOCK * 32
WARPS_PER_BLOCK can be 8 for 256 threads.
Let's compile and test.
call → compile_kernel
callcompile_kernel
{
"kernel_code": "import torch\nimport torch.nn as nn\nfrom torch.utils.cpp_extension import load_inline\n\ncpp_sources = \"\"\"\n#include <torch/extension.h>\nvoid csr_softmax(torch::Tensor row_ptr, torch::Tensor edge_scores, torch::Tensor out);\n\"\"\"\n\ncuda_source = \"\"\"\n#include <torch/extension.h>\n#include <cuda_runtime.h>\n\n__inline__ __device__ float warp_reduce_max(float val) {\n for (int offset = 16; offset > 0; offset /= 2)\n val = fmaxf(val, __shfl_down_sync(0xFFFFFFFF, val, offset));\n return val;\n}\n\n__inline__ __device__ float warp_reduce_sum(float val) {\n for (int offset = 16; offset > 0; offset /= 2)\n val += __shfl_down_sync(0xFFFFFFFF, val, offset);\n return val;\n}\n\n__global__ void csr_softmax_kernel(\n const int32_t* __restrict__ row_ptr,\n const float* __restrict__ edge_scores,\n float* __restrict__ out,\n int num_nodes,\n int num_heads)\n{\n const int WARP_SIZE = 32;\n int warp_id = threadIdx.x / WARP_SIZE;\n int lane = threadIdx.x % WARP_SIZE;\n int row = blockIdx.x * (blockDim.x / WARP_SIZE) + warp_id;\n if (row >= num_nodes) return;\n\n int start = row_ptr[row];\n int end = row_ptr[row + 1];\n\n for (int h = 0; h < num_heads; ++h) {\n float max_val = -1e30f;\n for (int e = start + lane; e < end; e += WARP_SIZE) {\n float v = edge_scores[e * num_heads + h];\n max_val = fmaxf(max_val, v);\n }\n max_val = warp_reduce_max(max_val);\n max_val = __shfl_sync(0xFFFFFFFF, max_val, 0);\n\n float sum = 0.0f;\n for (int e = start + lane; e < end; e += WARP_SIZE) {\n sum += expf(edge_scores[e * num_heads + h] - max_val);\n }\n sum = warp_reduce_sum(sum);\n sum = __shfl_sync(0xFFFFFFFF, sum, 0);\n\n float inv_sum = 1.0f / sum;\n for (int e = start + lane; e < end; e += WARP_SIZE) {\n out[e * num_heads + h] = expf(edge_scores[e * num_heads + h] - max_val) * inv_sum;\n }\n }\n}\n\nvoid csr_softmax(torch::Tensor row_ptr, torch::Tensor edge_scores, torch::Tensor out) {\n int num_nodes = row_ptr.numel() - 1;\n int num_heads = edge_scores.size(1);\n\n const int WARP_SIZE = 32;\n const int WARPS_PER_BLOCK = 8;\n int threads = WARPS_PER_BLOCK * WARP_SIZE;\n int blocks = (num_nodes + WARPS_PER_BLOCK - 1) / WARPS_PER_BLOCK;\n\n csr_softmax_kernel<<<blocks, threads>>>(\n row_ptr.data_ptr<int32_t>(),\n edge_scores.data_ptr<float>(),\n out.data_ptr<float>(),\n num_nodes,\n num_heads\n );\n}\n\"\"\"\n\ncsr_softmax = load_inline(\n name=\"csr_softmax_warp\",\n cpp_sources=cpp_sources,\n cuda_sources=cuda_source,\n functions=[\"csr_softmax\"],\n extra_cuda_cflags=[\"-O3\"],\n)\n\nclass ModelNew(nn.Module):\n def __init__(self):\n super().__init__()\n\n def forward(self, row_ptr: torch.Tensor, edge_scores: torch.Tensor) -> torch.Tensor:\n if not row_ptr.is_cuda:\n row_ptr = row_ptr.to(edge_scores.device, non_blocking=True)\n if row_ptr.dtype != torch.int32:\n row_ptr = row_ptr.to(torch.int32)\n row_ptr = row_ptr.contiguous()\n edge_scores = edge_scores.contiguous()\n\n out = torch.empty_like(edge_scores)\n csr_softmax.csr_softmax(row_ptr, edge_scores, out)\n return out\n"
}