reasoning
Let me analyze the task:
1. We need to implement a row-wise softmax over edge scores stored in CSR format
2. Each node has a contiguous edge segment in `edge_scores[row_ptr[i]:row_ptr[i + 1]]`
3. The kernel normalizes scores independently within each segment
The reference implementation does a for loop over nodes and applies softmax to each segment. This is inefficient because:
1. It's a Python loop
2. Each softmax call involves separate kernel launches
For a custom CUDA kernel, I need to:
1. Launch one kernel per node (or batch multiple nodes)
2. Compute softmax for each segment efficiently
The softmax computation requires:
1. Finding the max in each segment
2. Subtracting the max
3. Exp all values
4. Sum them
5. Divide each by the sum
For efficiency, I can:
1. Have each block handle one or more nodes
2. Use shared memory for reduction operations within a block
3. Handle varying segment sizes
Let me think about the approach:
Option 1: One thread per node
- Each thread handles its segment
- Works well for small segments
- For large segments, need multiple passes
Option 2: One warp per node
- 32 threads cooperate on each segment
- Better for larger segments
- Warp reduction for max and sum
Option 3: One block per node
- Best for very large segments
- Overkill for small segments
Given the typical problem size (512 nodes, ~24 average degree), segments are small (around 21-27 elements). So:
- Option 1 (one thread per node) would be efficient
- Option 2 (one warp per node) could also work well
Let me go with a hybrid approach:
- Use one warp per node for the typical case
- For very small segments (< 32), some threads are idle but that's okay
- For very large segments, we'd need special handling
- I'll start with a warp-per-node implementation using warp shuffle for reductions
I need to be careful about edge cases: empty segments, single-element segments, and segments larger than 32 elements that require multiple iterations. The core algorithm involves each warp processing one node by computing the max value, subtracting it, exponentiating, summing the results, then normalizing. A simpler approach might use one thread per node instead, which could work better for small segments where the 24 elements per segment fit within a single thread's sequential processing.
For each thread, I'd iterate through its segment to find the max, then do a second pass to compute exp(x - max) and accumulate the sum, followed by a third pass dividing by that sum. Since this is compute-bound with many small, independent operations, the single-thread approach avoids synchronization overhead and works well for typical segment sizes around 24 elements.
However, with 512 threads across 2 blocks and only ~12288 total edges, each thread handles roughly 24 operations sequentially. While this minimizes synchronization, it may not fully utilize the GPU's parallel capacity. An alternative would assign one warp per node instead, letting threads within each warp process different edges in parallel, then reduce across the warp for the final result.
I'll start with a straightforward one-thread-per-node implementation since that's easier to get working correctly. Each thread loads its assigned edges, computes local reductions, then normalizes. For correctness I need to ensure edge cases work: segments with zero or one element, and segments exceeding 32 edges which would require the warp to iterate through multiple chunks.
For float precision, the max and sum reductions use warp shuffle operations. The kernel handles each node by first computing the segment bounds from the row pointer array, then processing all edges in that segment through repeated iterations. Since the implementation should match the PyTorch reference, it should produce equivalent results rather than handling numerical edge cases differently. I need to complete the partial thought about the thread-per-node approach and then summarize it.
The simpler alternative is assigning one thread per node, where each thread handles a segment independently. However, this limits parallelism to 512 threads total, which is quite low for GPU utilization. A better approach is one warp per node, where 32 threads collaborate on each segment. This works well for segments around 24 edges in size—each thread processes roughly one edge, then the warp cooperatively computes the max and sum via reduction. This leverages parallelism within each segment rather than across segments, which should provide better GPU utilization given the problem dimensions.
However, segments can vary in size, so I need to handle cases where segments exceed 32 edges. The strategy is to have threads iterate through the segment in strides of 32, accumulating max and sum values as they go. The algorithm becomes: threads compute local max/exp/sum values across multiple iterations, then combine these partial results through warp-level reductions. This maintains efficiency even for larger segments while still working well for the average case.
```python
#include <cuda_fp16.h>
__device__ float warp_reduce_max(float val) {
for (int offset = 16; offset > 0; offset /= 2) {
val = max(val, __shfl_down_sync(0xffffffff, val, offset));
}
return val;
}
__device__ float warp_reduce_sum(float val) {
for (int offset = 16; offset > 0; offset /= 2) {
val += __shfl_down_sync(0xffffffff, val, offset);
}
return val;
}
__global__ void segment_softmax_kernel(
const int* __restrict__ row_ptr,
const float* __restrict__ edge_scores,
float* __restrict__ out,
int num_nodes
) {
int node = blockIdx.x * blockDim.y + threadIdx.y; // One warp per node (warp = row of block)
if (node >= num_nodes) return;
int start = row_ptr[node];
int end = row_ptr[node + 1];
int length = end - start;
if (length == 0) return;
// Phase 1: Find max
float local_max = -INFINITY;
for (int i = threadIdx.x; i < length; i += 32) {
float val = edge_scores[start + i];
local_max = max(local_max, val);
}
float max_val = warp_reduce_max(local_max);
// Phase 2: Compute exp and sum
float local_sum = 0.0f;
for (int i = threadIdx.x; i < length; i += 32) {
float val = expf(edge_scores[start + i] - max_val);
out[start + i] = val;
local_sum += val;
}
float sum_val = warp_reduce_sum(local_sum);
// Phase 3: Normalize
for (int i = threadIdx.x; i < length; i += 32) {
out[start + i] /= sum_val;
}
}
```
The kernel organizes threads so each warp processes one node, with threads handling different edges within that node's segment. This approach scales naturally across segment sizes through strided iteration. For the block and grid dimensions, I'd use a 2D thread block of (32, 8) to process 8 nodes per block, giving 256 threads total. The grid would then be sized to cover all nodes. However, this structure might be overcomplicating things for small segments.
A simpler approach would be assigning one warp per node directly, using a linear thread layout where each node gets exactly 32 threads. This eliminates the extra complexity of the 2D organization while still providing parallelism within each node's computation. The thread index calculation becomes straightforward: `int tid = blockIdx.x * blockDim.x + threadIdx.x;`, and the node index is derived from `tid / 32`. To handle segments where one thread processes multiple elements, I can use strided loops within each thread's work. This works for both small segments where threads might handle no elements, and larger segments where threads process multiple values.
For the kernel launch configuration, I'm organizing threads into warps of 32 with multiple warps per block. Each warp handles one node independently, so the total number of warps needed equals the number of nodes. With 512 nodes, I'd need 512 warps total, distributed across blocks of 8 warps each.
The grid would need `ceil(512 / 8) = 64` blocks. However, since blockDim.x is fixed at 32 threads, I should adjust the grid dimensions accordingly: `(num_nodes + WARPS_PER_BLOCK - 1) / WARPS_PER_BLOCK` blocks with each block containing 256 threads total.
For the kernel itself, I need to compute the global thread index and map it to the appropriate node warp, then determine which node that warp processes. Each thread loads data strided across the segment, computes local statistics, then participates in warp-level reductions for the final result. // Normalize
float inv_sum = 1.0f / sum_val;
for (int i = lane_id; i < length; i += 32) {
out[start + i] *= inv_sum;
}
}
The implementation handles the reduction phases efficiently: threads in each warp compute local maxima and sums across their assigned elements, then synchronize through warp shuffles to combine results. After broadcasting the sum back to all threads, normalization proceeds by multiplying each element by the inverse sum rather than dividing—this avoids repeated division operations.
Looking at the data flow, `edge_scores` and `out` use the same indexing pattern where `start + i` accesses the correct elements in both arrays. The loop iteration with `i += 32` ensures each thread in the warp processes distinct elements while covering the entire segment. With `max_val` broadcast to all threads after reduction, the subtraction and subsequent operations apply uniformly across the warp's assigned data.
The segment length calculation from `row_ptr` gives the range for each node, and threads naturally divide this work. The index computation `start + i` maps correctly because `i` advances by 32, matching the warp size, so each thread handles a unique offset from the base address.
For nodes with segments shorter than 32 elements, only the first few threads execute the loop while others remain idle. When segments exceed 32, threads process multiple values sequentially and combine results through reduction operations.
Special cases like zero-length segments and single elements both work correctly—the former exits immediately, the latter has only thread 0 process the value while others skip the loop. The implementation handles all segment sizes appropriately.
call → submit_kernel
callsubmit_kernel
{
"kernel_code": "import torch\nimport torch.nn as nn\nfrom torch.utils.cpp_extension import load_inline\n\n# Compile CUDA kernel at module load time\ncuda_source = r\"\"\"\n#include <cuda.h>\n#include <cuda_runtime.h>\n#include <cmath>\n\n__device__ __forceinline__ float warp_reduce_max(float val) {\n #pragma unroll\n for (int offset = 16; offset > 0; offset >>= 1) {\n val = fmaxf(val, __shfl_down_sync(0xffffffff, val, offset));\n }\n return val;\n}\n\n__device__ __forceinline__ float warp_reduce_sum(float val) {\n #pragma unroll\n for (int offset = 16; offset > 0; offset >>= 1) {\n val += __shfl_down_sync(0xffffffff, val, offset);\n }\n return val;\n}\n\n__global__ void segment_softmax_kernel(\n const int* __restrict__ row_ptr,\n const float* __restrict__ edge_scores,\n float* __restrict__ out,\n int num_nodes\n) {\n // One warp (32 threads) per node\n const int WARP_SIZE = 32;\n int tid = blockIdx.x * blockDim.x + threadIdx.x;\n int warp_id = tid / WARP_SIZE;\n int lane_id = tid % WARP_SIZE;\n \n if (warp_id >= num_nodes) return;\n \n int start = row_ptr[warp_id];\n int end = row_ptr[warp_id + 1];\n int length = end - start;\n \n if (length == 0) return;\n \n // Phase 1: Find max across segment\n float local_max = -INFINITY;\n for (int i = lane_id; i < length; i += WARP_SIZE) {\n float val = edge_scores[start + i];\n local_max = fmaxf(local_max, val);\n }\n float max_val = warp_reduce_max(local_max);\n max_val = __shfl_sync(0xffffffff, max_val, 0); // Broadcast to all lanes\n \n // Phase 2: Compute exp(x - max) and sum\n float local_sum = 0.0f;\n for (int i = lane_id; i < length; i += WARP_SIZE) {\n float val = expf(edge_scores[start + i] - max_val);\n out[start + i] = val;\n local_sum += val;\n }\n float sum_val = warp_reduce_sum(local_sum);\n sum_val = __shfl_sync(0xffffffff, sum_val, 0); // Broadcast to all lanes\n \n // Phase 3: Normalize (multiply by inverse for efficiency)\n float inv_sum = 1.0f / sum_val;\n for (int i = lane_id; i < length; i += WARP_SIZE) {\n out[start + i] *= inv_sum;\n }\n}\n\nvoid launch_segment_softmax(\n const int* row_ptr,\n const float* edge_scores,\n float* out,\n int num_nodes\n) {\n const int WARP_SIZE = 32;\n const int WARPS_PER_BLOCK = 8;\n const int THREADS_PER_BLOCK = WARP_SIZE * WARPS_PER_BLOCK;\n \n int num_blocks = (num_nodes + WARPS_PER_BLOCK - 1) / WARPS_PER_BLOCK;\n \n segment_softmax_kernel<<<num_blocks, THREADS_PER_BLOCK>>>(\n row_ptr, edge_scores, out, num_nodes\n );\n}\n\"\"\"\n\ncpp_source = r\"\"\"\nvoid launch_segment_softmax(\n const int* row_ptr,\n const float* edge_scores,\n float* out,\n int num_nodes\n);\n\"\"\"\n\n# Load and compile the extension\nsoftmax_ext = load_inline(\n name='segment_softmax',\n cpp_sources=cpp_source,\n cuda_sources=cuda_source,\n functions=['launch_segment_softmax'],\n extra_cuda_cflags=['-O3', '--use_fast_math'],\n verbose=False\n)\n\n\nclass ModelNew(nn.Module):\n \"\"\"\n Row-wise softmax over edge scores stored in CSR format.\n \n Optimized with a custom CUDA kernel where each warp processes one node's\n edge segment cooperatively. Uses warp-level reductions for max and sum.\n \"\"\"\n\n def __init__(self):\n super().__init__()\n\n def forward(\n self,\n row_ptr: torch.Tensor,\n edge_scores: torch.Tensor,\n ) -> torch.Tensor:\n \"\"\"\n Args:\n row_ptr: (num_nodes + 1,) CSR row offsets\n edge_scores: (num_edges,) unnormalized attention logits\n Returns:\n (num_edges,) softmax-normalized edge weights per source node\n \"\"\"\n num_nodes = row_ptr.numel() - 1\n out = torch.empty_like(edge_scores)\n \n # Ensure inputs are contiguous and on the same device\n row_ptr = row_ptr.contiguous()\n edge_scores = edge_scores.contiguous()\n out = out.contiguous()\n \n # Launch CUDA kernel\n softmax_ext.launch_segment_softmax(\n row_ptr.data_ptr(),\n edge_scores.data_ptr(),\n out.data_ptr(),\n num_nodes\n )\n \n return out"
}