reasoning
The task is to replace PyTorch's `cross_entropy` with a custom CUDA kernel. Let's analyze the problem:
1. Input: `predictions` of shape `(batch_size, num_classes)` = `(32768, 4096)`
2. Targets: `targets` of shape `(batch_size,)` with values in `[0, num_classes)`
3. Output: scalar cross entropy loss
The cross_entropy function computes:
- log_softmax(predictions) = predictions - log(sum(exp(predictions - max(predictions))))
- negative log-likelihood: -log_softmax[batch_idx, target[batch_idx]]
- mean over batch
So the kernel needs to:
1. For each row (batch element), find the max logit
2. Compute log(sum(exp(logit - max)))
3. Compute the loss for that row: -(logit[target] - max - log_sum_exp)
4. Sum/mean over all rows
Since batch_size = 32768 and num_classes = 4096, this is compute-bound (lots of exp operations).
We need to write a fused CUDA kernel. Let's think about the optimization strategy:
**Grid/Block layout:**
- We can have one block per row (batch element). With 32768 rows, that's plenty of parallelism.
- Each block processes 4096 elements. We can use a block size of 1024 threads, with each thread processing 4 elements.
**Algorithm per block:**
1. Load logits into shared memory (or just keep in registers if possible)
2. Find max logit (reduction)
3. Compute exp(logit - max) and sum them (reduction)
4. Compute log(sum_exp)
5. Compute loss contribution: -(logit[target] - max - log_sum_exp)
6. AtomicAdd to global accumulator
Wait, actually, we don't need to load all 4096 elements into shared memory. We can:
1. Read elements in a coalesced manner
2. Compute local max per thread
3. Block reduction for global max
4. Compute local sum of exp(logit - max)
5. Block reduction for global sum
6. Compute loss locally if target is in this thread's range, or load target logit separately
Let me think about step 6. The target is a single index per row. We could either:
- Have one thread load the target logit after computing max and log_sum_exp
- Or compute everything and then the thread that owns the target element contributes
Actually, for simplicity and efficiency:
- After computing max and log_sum_exp, have thread 0 (or the appropriate thread) load the logit at index `target[row]`
- Compute `loss = -(logit_target - max - log_sum_exp)`
- Atomic add to global output
Alternative: We can avoid shared memory altogether and just have each thread work on a subset of elements, do warp-level reductions, then block-level reduction.
Let me write a kernel with these optimizations:
```cuda
__global__ void cross_entropy_kernel(const float* __restrict__ predictions,
const long* __restrict__ targets,
float* __restrict__ out,
int batch_size,
int num_classes) {
int row = blockIdx.x;
if (row >= batch_size) return;
const float* row_ptr = predictions + row * num_classes;
// Step 1: Find max
float local_max = -INFINITY;
for (int i = threadIdx.x; i < num_classes; i += blockDim.x) {
float val = row_ptr[i];
if (val > local_max) local_max = val;
}
// Warp reduction for max
#pragma unroll
for (int offset = 16; offset > 0; offset /= 2) {
float other = __shfl_down_sync(0xFFFFFFFF, local_max, offset);
if (other > local_max) local_max = other;
}
// Block reduction for max
__shared__ float block_max;
if (threadIdx.x % 32 == 0) {
// thread has warp max
}
// Actually, let me use shared memory for block reduction
}
```
Hmm, for block reduction with shared memory approach:
Actually, I think using shared memory for the block reduction is cleaner and possibly faster. With 1024 threads, we need a small shared memory array.
Let me reconsider. With 4096 elements per row and 1024 threads, each thread handles 4 elements. We can store these 4 elements in registers to avoid reloading from global memory. Then:
1. First pass: load 4 values, track local max
2. Warp reduction for max, block reduction for max -> store in shared memory
3. Second pass: compute exp(val - max) for the 4 values, track local sum
4. Warp reduction for sum, block reduction for sum -> store in shared memory
5. Read target index, get the target value (need to reload or we could have kept it)
6. Compute loss and atomic add
Wait, step 5 - if we kept the 4 values in registers from step 1, we still need the target value. We can either:
- Reload just the target value from global memory
- Or keep all values and check if any matches target
Since num_classes = 4096 and each thread has 4 values, reload makes more sense - it's just one global memory read.
Actually, even better: in step 1, we can also check if the loaded value corresponds to the target and keep it. But the target might not be in our 4 values. So after the block reduction, one thread reads the target value.
Let me write this more carefully:
```cuda
__global__ void cross_entropy_kernel(
const float* __restrict__ predictions,
const long* __restrict__ targets,
float* __restrict__ out,
int batch_size,
int num_classes) {
int row = blockIdx.x;
if (row >= batch_size) return;
const float* row_ptr = predictions + row * num_classes;
long target_idx = targets[row];
// Each thread processes num_classes/blockDim.x elements
float local_max = -1e30f;
// First pass: find max
#pragma unroll 4
for (int i = threadIdx.x; i < num_classes; i += blockDim.x) {
float val = row_ptr[i];
if (val > local_max) local_max = val;
}
// Warp reduction for max
#pragma unroll
for (int offset = 16; offset > 0; offset /= 2) {
float other = __shfl_down_sync(0xFFFFFFFF, local_max, offset);
if (other > local_max) local_max = other;
}
__shared__ float s_max[32]; // one per warp
if (threadIdx.x % 32 == 0) {
s_max[threadIdx.x / 32] = local_max;
}
__syncthreads();
// Block reduction for max
if (threadIdx.x < 32) {
local_max = s_max[threadIdx.x];
#pragma unroll
for (int offset = 16; offset > 0; offset /= 2) {
float other = __shfl_down_sync(0xFFFFFFFF, local_max, offset);
if (other > local_max) local_max = other;
}
if (threadIdx.x == 0) s_max[0] = local_max;
}
__syncthreads();
float row_max = s_max[0];
// Second pass: compute sum of exp
float local_sum = 0.0f;
#pragma unroll 4
for (int i = threadIdx.x; i < num_classes; i += blockDim.x) {
local_sum += expf(row_ptr[i] - row_max);
}
// Warp reduction for sum
#pragma unroll
for (int offset = 16; offset > 0; offset /= 2) {
local_sum += __shfl_down_sync(0xFFFFFFFF, local_sum, offset);
}
__shared__ float s_sum[32];
if (threadIdx.x % 32 == 0) {
s_sum[threadIdx.x / 32] = local_sum;
}
__syncthreads();
// Block reduction for sum
if (threadIdx.x < 32) {
local_sum = s_sum[threadIdx.x];
#pragma unroll
for (int offset = 16; offset > 0; offset /= 2) {
local_sum += __shfl_down_sync(0xFFFFFFFF, local_sum, offset);
}
if (threadIdx.x == 0) s_sum[0] = local_sum;
}
__syncthreads();
float log_sum_exp = logf(s_sum[0]);
// Read target value and compute loss
if (threadIdx.x == 0) {
float target_val = row_ptr[target_idx];
float loss = -(target_val - row_max - log_sum_exp);
atomicAdd(out, loss);
}
}
```
Wait, this has an issue - the output needs to be divided by batch_size for mean. We can either do that in a separate kernel or initialize out to 0 and divide after. Actually, PyTorch's cross_entropy defaults to `reduction='mean'`.
So we need to either:
1. Have a 2-pass version where we write per-row losses and then reduce
2. Or have the kernel write per-row losses, and then do a separate reduction
Let me think... The atomicAdd approach works but might have contention. With 32768 atomic adds to the same location, that's a lot of contention!
Better approach: Write per-row losses to an intermediate buffer, then reduce in a second kernel. Or even better, write them out and let PyTorch handle the mean. But the function should return the scalar mean.
Actually, with atomicAdd on H100, 32768 atomics might not be terrible, but let's do a 2-kernel approach or use a per-block accumulator then grid-level reduction.
Alternative: allocate workspace of size batch_size. Each block writes its loss. Then launch a sum reduction kernel or just use PyTorch's sum and divide.
But to keep it simple and avoid extra CUDA launch overhead (which might be significant for this relatively fast kernel), let me try the atomicAdd first and measure.
Actually, wait - we can also have each warp do its atomicAdd in parallel by using warp-level loss computation? No, each row has exactly one target, so each block only produces one loss value.
A better approach for the mean:
- Initialize output to 0
- Each block computes its loss and does atomicAdd
- After all kernels complete, divide by batch_size on CPU or with a tiny kernel
With 32768 blocks and one atomicAdd per block, the contention isn't too bad actually. It's serialized but with very few operations per atomic, so maybe ~10-20 microseconds of overhead. Given that the kernel itself might take ~100 microseconds, this could be acceptable.
Let me reconsider the memory bandwidth. For 32768 * 4096 floats = 134,217,728 floats = ~512 MB of reads. At 3.35 TB/s, that's about 153 microseconds just for memory bandwidth. But we read the data twice (once for max, once for sum_exp), so ~306 microseconds memory-bound. Unless we can fuse the reads? We can't compute sum_exp before knowing the max.
Wait, we CAN use the online softmax algorithm which does it in one pass! The online softmax algorithm maintains both max and sum in a single pass, updating them when a new max is found.
The online softmax algorithm:
```
max_0 = x_0
sum_0 = 1
for i = 1 to n:
if x_i > max_{i-1}:
sum_i = sum_{i-1} * exp(max_{i-1} - x_i) + 1
max_i = x_i
else:
sum_i = sum_{i-1} + exp(x_i - max_{i-1})
max_i = max_{i-1}
```
This requires only one pass! Let me think about how to parallelize this...
For parallel online softmax, we can do:
1. Each thread does online softmax on its local chunk, producing local (max, sum)
2. We need to combine these (max, sum) pairs across threads
The combination of two chunks (max1, sum1) and (max2, sum2):
```
if max2 > max1:
sum = sum2 + sum1 * exp(max1 - max2)
max = max2
else:
sum = sum1 + sum2 * exp(max2 - max1)
max = max1
```
This is associative! So we can do a tree reduction using warp shuffles.
Let's write this out. For each thread's local chunk of 4 elements:
```cuda
float local_max = -INFINITY;
float local_sum = 0.0f;
#pragma unroll 4
for (int i = threadIdx.x; i < num_classes; i += blockDim.x) {
float x = row_ptr[i];
if (x > local_max) {
local_sum = local_sum * expf(local_max - x) + 1.0f;
local_max = x;
} else {
local_sum += expf(x - local_max);
}
}
```
Then warp-level reduction of (max, sum) pairs:
```cuda
#pragma unroll
for (int offset = 16; offset > 0; offset /= 2) {
float other_max = __shfl_down_sync(0xFFFFFFFF, local_max, offset);
float other_sum = __shfl_down_sync(0xFFFFFFFF, local_sum, offset);
if (other_max > local_max) {
local_sum = other_sum + local_sum * expf(local_max - other_max);
local_max = other_max;
} else {
local_sum = local_sum + other_sum * expf(other_max - local_max);
}
}
```
Then block-level reduction using shared memory:
```cuda
__shared__ float s_max[32];
__shared__ float s_sum[32];
if (threadIdx.x % 32 == 0) {
s_max[threadIdx.x / 32] = local_max;
s_sum[threadIdx.x / 32] = local_sum;
}
__syncthreads();
if (threadIdx.x < 32) {
float block_max = s_max[threadIdx.x];
float block_sum = s_sum[threadIdx.x];
if (threadIdx.x >= blockDim.x / 32) {
block_max = -INFINITY;
block_sum = 0.0f;
}
// warp reduce again
#pragma unroll
for (int offset = 16; offset > 0; offset /= 2) {
float other_max = __shfl_down_sync(0xFFFFFFFF, block_max, offset);
float other_sum = __shfl_down_sync(0xFFFFFFFF, block_sum, offset);
if (other_max > block_max) {
block_sum = other_sum + block_sum * expf(block_max - other_max);
block_max = other_max;
} else {
block_sum = block_sum + other_sum * expf(other_max - block_max);
}
}
if (threadIdx.x == 0) {
s_max[0] = block_max;
s_sum[0] = block_sum;
}
}
__syncthreads();
```
Then:
```cuda
float log_sum_exp = logf(s_sum[0]);
float row_max = s_max[0];
```
And finally the target:
```cuda
if (threadIdx.x == 0) {
float target_val = row_ptr[target_idx];
float loss = -(target_val - row_max - log_sum_exp);
atomicAdd(out, loss / batch_size); // divide here
}
```
This is beautiful - single pass over the data! This will roughly halve the memory bandwidth and should be significantly faster.
Wait, one issue with the online softmax approach when doing the warp/block reduction: if local_max is -INFINITY (because a warp has no valid elements, e.g., in the second reduction where some lanes are inactive), then expf(local_max - other_max) could be expf(-inf) = 0, which is fine. And if other_max is -INFINITY, expf(other_max - local_max) = 0, also fine. If both are -INFINITY, we'd get block_sum = 0 and log(0) = -inf. But we always have at least some valid data since num_classes > 0.
Let me double check. For the second reduction, if blockDim.x = 1024, we have 32 warps, so all 32 lanes in s_max/s_sum are valid. If blockDim.x is smaller, we'd need to handle inactive lanes. I'll make blockDim.x = 1024 for simplicity.
Actually, for safety in the second reduction, we should handle edge cases. But with 1024 threads and exactly 4096 elements (which is 4 * 1024), every thread has exactly 4 valid elements, so no inactive warps.
But wait, I should parameterize this and not hardcode blockDim.x. Let me make the kernel robust.
For the second reduction step, when loading from shared memory into our warp:
```cuda
if (threadIdx.x < blockDim.x / 32) {
block_max = s_max[threadIdx.x];
block_sum = s_sum[threadIdx.x];
} else {
block_max = -INFINITY;
block_sum = 0.0f;
}
```
This handles cases where we have fewer than 32 warps.
Also, I realize that num_classes = 4096 and blockDim.x = 1024 gives exactly 4 elements per thread. The `#pragma unroll 4` would only work if we know blockDim.x at compile time. Actually, I can use `for (int i = threadIdx.x; i < num_classes; i += blockDim.x)` and the compiler might unroll it if it can figure out the trip count. To be safe, I could pass num_classes as a template parameter or just let the compiler handle it. For 4096 with blockDim.x=1024, the compiler should be smart enough.
Actually, let me not use `#pragma unroll 4` with a variable blockDim.x since that might not work. I'll use template for block size, or just let the compiler optimize.
Let me also consider: should I use `__ldg` for read-only global memory loads? On H100 this can help with caching. Predictions and targets are read-only from the kernel's perspective.
Also, for mean reduction, instead of atomicAdd, I could write to a per-row output and then do a device-wide reduction. For 32768 rows, writing 32768 floats and then reducing might be faster than 32768 atomicAdds. But the atomicAdd is simpler. Let me think about this more...
With atomicAdd to a single float, we have 32768 atomic operations. Each one is atomic on L2 cache (since all SMs share the same L2), so it's serialized to some degree. With 132 SMs on H100, we can do some parallelization, but it's still going to be a bottleneck.
A better approach: have each SM accumulate into a separate counter, then reduce. We can use a grid-stride approach or just write per-block outputs.
Actually, a simple and effective approach: write per-block outputs to a workspace (size = number of blocks = batch_size * gridDim.y? Just batch_size). Wait, each block corresponds to one row. So we need batch_size outputs. That's 32768 floats = 128 KB. Very small. Then we can use a simple reduction kernel or even PyTorch's `mean()` on the CPU side.
Let me do this: write per-row loss to an intermediate buffer. Then in a second small kernel or even in a final pass, sum and divide.
Actually, the simplest fast approach: write per-row losses, then use `torch.mean()` in Python. That adds a cudaMemcpy and some GPU reduction overhead, but for 32768 elements it's negligible.
Or: launch a second sum reduction kernel. That's two kernel launches, but the second one is tiny.
Hmm, let me benchmark the atomicAdd approach vs two-kernel. I'll try atomicAdd first since it's simpler.
Actually, another idea: we can have a per-SM atomic counter? No, that's complicated.
Let me think about how PyTorch implements this. PyTorch's cross_entropy calls nll_loss(log_softmax(...)). The log_softmax kernel likely writes an intermediate tensor of size batch_size * num_classes, then nll_loss reads it. That's 512 MB written and 512 MB read (for the target indices), total ~1.5 GB memory traffic!
Our fused kernel does ~512 MB read only, single pass with online softmax. That's a 3x reduction in memory traffic! So even with atomicAdd overhead, we should be much faster.
But we can optimize further. Let me write a complete kernel. I'll use the single-pass approach with atomicAdd for simplicity, and if needed switch to two-pass later.
Actually, wait. For numerical stability, we might want to ensure the online softmax is done correctly. Let me verify:
Starting with empty state: max = -inf, sum = 0
First element x:
- x > -inf, so sum = 0 * exp(-inf - x) + 1 = 0 + 1 = 1, max = x
Second element y:
- If y > x: sum = 1 * exp(x - y) + 1, max = y
- If y <= x: sum = 1 + exp(y - x), max = x
This is correct! The sum afterwards is sum_i exp(x_i - max_i), so log_sum_exp = log(sum) + max.
Good.
Now, let me think about register pressure. Each thread needs:
- local_max: 1 register
- local_sum: 1 register
- loop variable i: 1 register
- temporary for loaded value: 1 register
- temporaries for exp: a few registers
So maybe 8-12 registers per thread. With 1024 threads, that's 8K-12K registers, well under 64K per SM.
One more optimization: since we know num_classes = 4096, we could use float4 or vectorized loads. 4096 * 4 bytes = 16384 bytes per row. If batch_size = 32768, total is 512 MB. With 1024 threads and float4 loads, each thread loads 1 float4 per iteration (4 elements), and we have 1024 iterations total, but wait - 4096 / 4 = 1024, so with float4 and 1024 threads, each thread loads exactly 1 float4. That's perfect!
But float4 requires 16-byte alignment. Is predictions guaranteed to be 16-byte aligned? With PyTorch, tensors are typically aligned to at least 16 bytes for the start of each row when the row size is a multiple of 16 bytes, which 4096 * 4 = 16384 is. However, we don't know if the tensor itself is 16-byte aligned at the base pointer. Actually, PyTorch CUDA tensors are typically 256-byte aligned. So we can safely use float4.
Wait, but targets is a `torch.LongTensor` (int64). We need to be careful with types.
For the kernel signature:
```cuda
__global__ void cross_entropy_kernel(
const float* __restrict__ predictions,
const int64_t* __restrict__ targets,
float* __restrict__ out,
int batch_size,
int num_classes)
```
For float4 loads:
```cuda
const float4* pred_vec = reinterpret_cast<const float4*>(row_ptr);
int vec_num_classes = num_classes / 4;
#pragma unroll
for (int i = threadIdx.x; i < vec_num_classes; i += blockDim.x) {
float4 val4 = pred_vec[i];
// process val4.x, val4.y, val4.z, val4.w
}
```
This would load 4 floats at once, improving memory bandwidth utilization. However, it complicates the online softmax slightly because we process 4 elements at a time. But it's manageable.
Let me write it out:
```cuda
float local_max = -1e30f; // Can't use INFINITY with float4 easily? Actually can use -INFINITY
float local_sum = 0.0f;
#pragma unroll
for (int i = threadIdx.x; i < vec_num_classes; i += blockDim.x) {
float4 v = __ldg(&pred_vec[i]);
// process v.x
if (v.x > local_max) {
local_sum = local_sum * expf(local_max - v.x) + 1.0f;
local_max = v.x;
} else {
local_sum += expf(v.x - local_max);
}
// process v.y, v.z, v.w similarly
}
```
This is clean. And with 1024 threads and 1024 float4s per row, each thread processes exactly 1 float4. The `#pragma unroll` on the loop would unroll once since the trip count is 1! Actually, the compiler should be smart enough to figure this out when blockDim.x = 1024 and vec_num_classes = 1024.
But we don't want to assume the block size is always 1024. I'll launch with 1024 threads though, so the compiler might optimize it.
Let me also consider: do we need to handle `num_classes` not divisible by 4? The reference says `num_classes = 4096`, so it is divisible by 4. But to be safe, I can handle both.
Actually, for a robust kernel, I should handle the tail. But since we know the exact dimensions from the problem statement, I can optimize for 4096.
Hmm, but the evaluation might test with different sizes? No, the problem says batch_size = 32768 and num_classes = 4096. I should make it work for general sizes though.
Let me write a general kernel but with the vectorized path for when num_classes % 4 == 0.
Actually, you know what, let me start simple with a scalar-only version first, get it working, then optimize with vector loads if needed.
Let me draft the Python code:
```python
import torch
from torch.utils.cpp_extension import load_inline
cuda_source = '''
#include <cuda_runtime.h>
#include <cuda_fp16.h>
#include <math.h>
#include <float.h>
__global__ void cross_entropy_kernel(
const float* __restrict__ predictions,
const long* __restrict__ targets,
float* __restrict__ out,
int batch_size,
int num_classes) {
int row = blockIdx.x;
if (row >= batch_size) return;
const float* row_ptr = predictions + row * num_classes;
long target_idx = targets[row];
float local_max = -INFINITY;
float local_sum = 0.0f;
for (int i = threadIdx.x; i < num_classes; i += blockDim.x) {
float x = row_ptr[i];
if (x > local_max) {
local_sum = local_sum * expf(local_max - x) + 1.0f;
local_max = x;
} else {
local_sum += expf(x - local_max);
}
}
// Warp reduce
#pragma unroll
for (int offset = 16; offset > 0; offset /= 2) {
float other_max = __shfl_down_sync(0xFFFFFFFF, local_max, offset);
float other_sum = __shfl_down_sync(0xFFFFFFFF, local_sum, offset);
if (other_max > local_max) {
local_sum = other_sum + local_sum * expf(local_max - other_max);
local_max = other_max;
} else {
local_sum = local_sum + other_sum * expf(other_max - local_max);
}
}
__shared__ float s_max[32];
__shared__ float s_sum[32];
int lane = threadIdx.x % 32;
int warp_id = threadIdx.x / 32;
if (lane == 0) {
s_max[warp_id] = local_max;
s_sum[warp_id] = local_sum;
}
__syncthreads();
// Block reduce
if (warp_id == 0) {
float block_max = (lane < (blockDim.x + 31) / 32) ? s_max[lane] : -INFINITY;
float block_sum = (lane < (blockDim.x + 31) / 32) ? s_sum[lane] : 0.0f;
#pragma unroll
for (int offset = 16; offset > 0; offset /= 2) {
float other_max = __shfl_down_sync(0xFFFFFFFF, block_max, offset);
float other_sum = __shfl_down_sync(0xFFFFFFFF, block_sum, offset);
if (other_max > block_max) {
block_sum = other_sum + block_sum * expf(block_max - other_max);
block_max = other_max;
} else {
block_sum = block_sum + other_sum * expf(other_max - block_max);
}
}
if (lane == 0) {
s_max[0] = block_max;
s_sum[0] = block_sum;
}
}
__syncthreads();
float row_max = s_max[0];
float log_sum_exp = logf(s_sum[0]);
if (threadIdx.x == 0) {
float target_val = row_ptr[target_idx];
float loss = -(target_val - row_max - log_sum_exp);
atomicAdd(out, loss / batch_size);
}
}
'''
cpp_source = '''
torch::Tensor cross_entropy_cuda(torch::Tensor predictions, torch::Tensor targets) {
auto batch_size = predictions.size(0);
auto num_classes = predictions.size(1);
auto out = torch::zeros({}, predictions.options());
dim3 grid(batch_size);
dim3 block(1024);
cross_entropy_kernel<<<grid, block>>>(
predictions.data_ptr<float>(),
targets.data_ptr<long>(),
out.data_ptr<float>(),
batch_size,
num_classes
);
return out;
}
'''
```
Wait, I need to be careful. The `long` type in CUDA might be 32 or 64 bits depending on the platform. On Linux x86_64, it's 64 bits, same as int64_t. But `targets.data_ptr<long>()` in PyTorch's ATen with `torch::Tensor` - what does targets.dtype give us? In the problem, targets are created with `torch.randint(0, num_classes, (batch_size,))` which creates a `torch.int64` tensor. So `targets.data_ptr<int64_t>()` is the safe choice.
Let me also think about `targets.data_ptr<long>()` - in ATen's C++ API, `long` is typically `int64_t` on 64-bit platforms. But to be safe, I'll use `int64_t`.
Also, I need to think about the return type. PyTorch's cross_entropy returns a scalar (0-dim tensor). My `out` should be `torch::zeros({}, predictions.options())` which is correct.
Let me also think about numerical precision. The online softmax with expf and float32 should give results close to PyTorch's implementation. PyTorch uses a similar approach in its log_softmax, but might use slightly different precision. We need to match within 1e-4.
One potential issue: `-INFINITY` from `<math.h>` is a double constant. In CUDA, `__int_as_float(0xff800000)` gives -inf for float, or we can use `-CUDART_INF_F`. Or we can just define `float local_max = -1e30f;` which is more than sufficient since logits are typically in reasonable ranges. But `-INFINITY` is safer. Let me use `-CUDART_INF_F` or just `-(1.0f/0.0f)`? Actually in CUDA, `INFINITY` from `math.h` works as a float when assigned to float. And `-INFINITY` should too. Let me check... Actually `INFINITY` is a macro that expands to `__builtin_inff()` in GCC, which is float. So `-INFINITY` is negative infinity float. But if `math.h` defines it as `1e309` or something for double, we might have issues. Actually, in CUDA, `INFINITY` is float infinity. For double infinity, it's `HUGE_VAL` or `DBL_INFINITY`.
Actually, to be completely safe, I can use `__int_as_float(0x7f800000)` for +inf and `__int_as_float(0xff800000)` for -inf. But `-INFINITY` from `<math.h>` should work fine in CUDA. Let me just use `-1e30f` to be extra safe and avoid any macro issues. The logits are from `torch.rand()` so they're in [0, 1], so -1e30f is effectively -inf relative to the data range.
Wait, `torch.rand()` gives [0, 1), so max logit will be around 1. -1e30f is definitely low enough.
One more issue: when `local_sum` starts at 0 and we encounter the first element, `local_sum * expf(local_max - x)` where `local_max = -1e30f` and x is around 0-1. `expf(-1e30f)` underflows to 0, so `local_sum = 0 * 0 + 1 = 1`. That's correct!
However, `expf(-1e30f)` might underflow to 0, which is fine. But if we have a case where `local_max` is very negative and `x` is also very negative but not equal, `expf(local_max - x)` where `local_max - x` could be positive but also very large, leading to overflow. But with our data, local_max and x are both from [0, 1], so their difference is at most 1. No overflow issues.
Actually wait, `local_max` starts at -1e30f. When we process the first element, `local_max - x` is approximately -1e30f. `expf(-1e30f)` underflows to 0. That's fine.
But what if we use `-INFINITY`? `expf(-INFINITY)` is 0. `0 * NAN`... wait, `expf(-INFINITY)` is 0, and `local_sum` starts at 0, so `0 * 0 + 1 = 1`. Good.
Actually, I realize there's a subtle numerical issue. If we use a very negative number like -1e30f instead of -INFINITY, then when the first element comes:
`local_sum = 0 * expf(-1e30f - x0) + 1 = 0 * 0 + 1 = 1`
This is fine. But what if all elements are -1e30f? Then we'd get sum = num_classes, and max = -1e30f, and log_sum_exp = log(num_classes) + (-1e30f) which is approximately -1e30f. That's fine.
For the block reduction, if some warps have only -1e30f as max, when combining with a warp that has actual data:
`other_max` (actual) > `local_max` (-1e30f), so:
`local_sum = other_sum + local_sum * expf(-1e30f - other_max) = other_sum + local_sum * 0 = other_sum`
This is correct!
And if both are -1e30f (all data is -1e30f):
`local_sum = local_sum + other_sum * expf(-1e30f - (-1e30f)) = local_sum + other_sum * 1 = local_sum + other_sum`
This is also correct since all values are equal to -1e30f, and exp(0) = 1. But log(num_classes * 1) + (-1e30f) = log(num_classes) - 1e30f, which is slightly off from -1e30f. In practice, this doesn't matter for normal data.
Actually, I just realized a potential issue with -1e30f. In the warp reduction, if we have a mix of -1e30f and actual values, the result is correct. But in the edge case where blockDim.x > num_classes, some threads might process nothing. Their local_max stays at -1e30f and local_sum stays at 0. When combined with a warp that has actual data: `local_sum = other_sum + 0 * exp(...) = other_sum`. Correct!
What if all threads in a warp have local_max = -1e30f and local_sum = 0? Then in the block reduction, this warp contributes `block_sum = 0` and `block_max = -1e30f`. When combined with a valid warp: `block_sum = valid_sum + 0 * exp(...) = valid_sum`. Correct!
But what if ALL warps have no data? Then `block_sum = 0` and `block_max = -1e30f`, and `logf(0)` gives -inf. This would only happen if num_classes = 0, which is invalid.
OK so using -1e30f is safe enough. But let me use `-CUDART_INF_F` just to be mathematically correct. Actually, `CUDART_INF_F` requires `#include <cuda_runtime_api.h>` or just use `__int_as_float(0xff800000)`. I'll just use `-1e30f` for simplicity, it's safe for the given data range.
Wait, I want to make sure about something. `torch.rand` generates values in [0, 1). But during evaluation, they could potentially test with any float values. Let me use `-INFINITY` properly. The safe approach is:
```cuda
float local_max = -INFINITY;
```
In CUDA C++, `INFINITY` is defined in `math.h` as a float constant. Let me verify by compiling. If it doesn't work, I'll use a large negative float constant.
For the block reduction inactive lanes, I should use the same convention. Let me think again about whether I should handle inactive lanes:
```cuda
float block_max = (lane < num_warps) ? s_max[lane] : -INFINITY;
float block_sum = (lane < num_warps) ? s_sum[lane] : 0.0f;
```
Where `num_warps = (blockDim.x + 31) / 32`. This is good.
OK let me write the actual Python code now.
One more thought: Instead of atomicAdd, what if we write per-row results and then use a separate simple reduction? For 32768 rows, a simple tree reduction kernel would be:
```cuda
__global__ void sum_reduce_kernel(const float* in, float* out, int n) {
// naive implementation
float sum = 0.0f;
for (int i = threadIdx.x; i < n; i += blockDim.x) {
sum += in[i];
}
// block reduce ...
if (threadIdx.x == 0) *out = sum;
}
```
But wait, the first kernel writes per-row losses. That's 32768 floats. Then the sum kernel reads them and sums. This avoids all atomic contention. The overhead is:
- 32768 floats written = 128 KB
- 128 KB read by second kernel
- Very little for the second kernel itself
128 KB is absolutely nothing. This is probably faster than atomicAdd, especially for this problem where we have many blocks.
Let me do this: first kernel writes per-row loss. Second kernel sums them and divides by batch_size. Or actually, I can do the division in the first kernel and just sum in the second.
Actually, even simpler: in the first kernel, write the un-divided loss (so -(target_val - max - log_sum_exp)). Then in Python, I can just do `out.mean()`! Wait, no, `out` is a tensor of shape (batch_size,), so `out.mean()` gives the right answer.
But that requires an intermediate tensor and an extra `mean()` call. The overhead of creating the tensor and launching `mean()` in PyTorch is probably negligible compared to the main kernel, but it exists.
Alternatively, I can do a two-pass approach in CUDA:
1. First kernel computes per-row loss and stores in workspace
2. Second kernel reduces workspace to scalar
Both approaches are fine. The atomicAdd approach has the appeal of being a single kernel. Let me try atomicAdd first.
Wait, I just realized something. With H100's asynchronous copy and L2 cache atomics, 32768 atomicAdds might not be that bad. Let me stick with atomicAdd for the first version and see.
Actually, a compromise: use `atomicAdd` with block-level aggregation. Wait, each block already computes exactly one value, so there's no block-level aggregation to do - each block has one loss value.
OK, let me just write the kernel with atomicAdd and see.
Hmm, let me also consider: what if we use `blockDim.x` smaller than 1024? Like 512 or 256? Would that help? With 4096 elements, 1024 threads gives 4 elements per thread. 512 threads gives 8 elements per thread. 256 threads gives 16 elements per thread.
More elements per thread means more register pressure but fewer warps, which might mean faster block-level operations. But 1024 threads gives us 32 warps = full occupancy for a block. With fewer threads, we might be able to fit more blocks per SM. But we're launching 32768 blocks, so we have plenty of parallelism.
Actually, with 1024 threads per block and 64K registers per SM, and assuming ~16 registers per thread, that's 16K registers, so ~4 blocks per SM. With 132 SMs, that's ~528 blocks in flight. With 32768 total blocks, we have ~62 waves. That's plenty.
With 512 threads, ~8 blocks per SM, ~1056 blocks in flight, ~31 waves.
With 256 threads, ~16 blocks per SM, ~2112 blocks in flight, ~15 waves.
More threads per block generally helps with memory coalescing and utilization. I'll use 1024.
But wait, I want to use persistent threads or grid-stride loops to amortize kernel launch overhead? Actually no, we have one block per row which is natural.
Let me think about one more thing. Can I use Tensor Cores for this? No, cross entropy doesn't matmul.
What about using `__half` loads? If predictions is float32, we can't. But we could consider doing the loads as float4 to maximize bandwidth.
Let me write a float4 version since 4096 / 4 / 1024 = 1. So each thread loads exactly 1 float4. This is perfect:
```cuda
__global__ void cross_entropy_kernel(
const float* __restrict__ predictions,
const long* __restrict__ targets,
float* __restrict__ out,
int batch_size,
int num_classes) {
int row = blockIdx.x;
if (row >= batch_size) return;
const float* row_ptr = predictions + row * num_classes;
long target_idx = targets[row];
float local_max = -INFINITY;
float local_sum = 0.0f;
int vec_num_classes = num_classes / 4;
const float4* row_ptr4 = reinterpret_cast<const float4*>(row_ptr);
#pragma unroll
for (int i = threadIdx.x; i < vec_num_classes; i += blockDim.x) {
float4 v = __ldg(&row_ptr4[i]);
// v.x
if (v.x > local_max) {
local_sum = local_sum * expf(local_max - v.x) + 1.0f;
local_max = v.x;
} else {
local_sum += expf(v.x - local_max);
}
// v.y
if (v.y > local_max) {
local_sum = local_sum * expf(local_max - v.y) + 1.0f;
local_max = v.y;
} else {
local_sum += expf(v.y - local_max);
}
// v.z
if (v.z > local_max) {
local_sum = local_sum * expf(local_max - v.z) + 1.0f;
local_max = v.z;
} else {
local_sum += expf(v.z - local_max);
}
// v.w
if (v.w > local_max) {
local_sum = local_sum * expf(local_max - v.w) + 1.0f;
local_max = v.w;
} else {
local_sum += expf(v.w - local_max);
}
}
// Handle remaining elements if num_classes % 4 != 0
for (int i = threadIdx.x + vec_num_classes * 4; i < num_classes; i += blockDim.x) {
float x = row_ptr[i];
if (x > local_max) {
local_sum = local_sum * expf(local_max - x) + 1.0f;
local_max = x;
} else {
local_sum += expf(x - local_max);
}
}
// ... rest of reduction
}
```
This looks good. But I need to be careful about alignment. `row_ptr` points to `predictions + row * num_classes`. Since `predictions` is aligned (PyTorch aligns to 256 bytes typically) and `row * num_classes * 4` is a multiple of 16 when `num_classes` is even (4096 * 4 = 16384 is divisible by 16), each row is 16-byte aligned. So `reinterpret_cast<const float4*>(row_ptr)` is safe.
However, for generality, if `num_classes` is not divisible by 4, the starting address of the vector path is still aligned if the tensor is aligned and `row * num_classes` is divisible by 4. For `num_classes = 4096`, yes. For arbitrary `num_classes`, we might need to do scalar loads first to get to alignment. But again, we know num_classes = 4096.
Let me write a cleaner version that handles both aligned and unaligned cases, just to be robust:
Actually, I'll start with a scalar version first since the compiler might already vectorize the loads. On H100, scalar loads with L1 cache might not be much slower than float4 loads if the access pattern is good. Let me try the scalar version first for simplicity.
Actually, for 4096 elements per row and 1024 threads, with coalesced access, the memory controller can combine 4 consecutive float32 loads into a 128-bit transaction anyway. So float4 might not give much benefit over well-coalesced scalar loads. Let me try scalar first.
But wait, with the online softmax, we're doing significant compute (expf) per load, so the kernel is compute/memory balanced. The exact memory bandwidth utilization depends on the ratio of compute to memory. For 4 elements per thread, we do 4 expf calls, 4 comparisons, and some arithmetic. That's enough compute to hide memory latency.
OK let me just write the kernel and test it.
I also want to think about one more optimization: using `log1pf` for some cases? No, not applicable here.
Another thing: PyTorch's cross_entropy by default uses `reduction='mean'`, but it also supports `reduction='sum'`. Our task says to match the reference implementation, which uses default `reduction='mean'`. So we need to divide by batch_size.
Let me check: `torch.nn.functional.cross_entropy(predictions, targets)` uses `reduction='mean'`. So yes, divide by batch_size.
Another thought: instead of atomicAdd, I can have thread 0 in each block write to `out[row]`, creating a per-row output tensor. Then in the Python wrapper, I can do `out.mean()`. This is probably the cleanest and might be faster. Let me think...
If I write per-row output:
- No atomics
- 32768 float writes = 128 KB write
- Then `out.mean()` which is a reduction kernel in PyTorch
- The reduction kernel reads 128 KB and produces 1 float
Total extra memory: 128 KB write + 128 KB read = 256 KB. At 3.35 TB/s, that's 0.076 microseconds. Negligible!
And no atomic contention. This is definitely the way to go.
So my plan:
1. CUDA kernel writes per-row loss to `out[row]`
2. Python returns the tensor, then `.mean()` is applied
Wait, but the reference returns a scalar. My `forward()` should return a scalar. So in `ModelNew.forward()`, I should call the CUDA kernel to get per-row losses, then call `.mean()` on it.
Actually, I can also do the mean in a second CUDA kernel to avoid PyTorch overhead, but PyTorch's mean on a 128KB tensor is extremely fast. Let me just do the mean in CUDA to be safe and avoid any extra overhead.
OK, I'll write it as:
1. First kernel: compute per-row loss, write to intermediate tensor
2. Second kernel: sum the intermediate and divide by batch_size
Or even simpler: I'll still do atomicAdd but divide by batch_size inside the atomicAdd. Let me go with this for a single kernel.
Actually, no! Let me do per-row output. Here's why: with per-row output, if there's any numerical discrepancy, I can compare per-row losses to debug. Also, it's cleaner.
Plan:
- C++ function `cross_entropy_cuda(predictions, targets)` returns a tensor of shape (batch_size,) with per-row losses
- `ModelNew.forward()` calls this and returns `.mean()`
Wait, but that does a `.mean()` operation which is another kernel launch and read of 128KB. Very minor, but let's avoid it.
Actually wait, let me reconsider atomicAdd. With 32768 atomic adds and H100, how bad can it be? Each atomic add is ~1 instruction. But they're all serialized through L2. If each one takes, say, 10 cycles, that's 327680 cycles total. At ~2 GHz, that's 164 microseconds. And that's assuming perfect pipelining. In reality with contention, it's worse. So atomicAdd could add 100-500 microseconds.
With per-row output + `mean()` in PyTorch:
- The mean kernel is highly optimized and does a tree reduction. For 32768 elements, it might take 5-10 microseconds.
- Plus the 128KB write in the first kernel, which is essentially free since we're already memory-bound.
So per-row output + mean() is probably faster!
But to minimize overhead further, I can write a tiny second kernel that does the sum:
```cuda
__global__ void sum_kernel(const float* in, float* out, int n) {
float sum = 0.0f;
for (int i = threadIdx.x; i < n; i += blockDim.x) {
sum += in[i];
}
// block reduce
__shared__ float s_sum[1024];
s_sum[threadIdx.x] = sum;
__syncthreads();
for (int s = blockDim.x / 2; s > 0; s /= 2) {
if (threadIdx.x < s) {
s_sum[threadIdx.x] += s_sum[threadIdx.x + s];
}
__syncthreads();
}
if (threadIdx.x == 0) {
*out = s_sum[0] / n;
}
}
```
This launches with 1 block of 1024 threads. For 32768 elements, each thread sums 32 values, then block reduces. Should be very fast, maybe 2-5 microseconds.
Alternatively, I can use PyTorch's sum. Let me just do it all in CUDA for maximum control. I'll have one function that does both.
Actually, I can combine them! Have the first kernel write per-row losses. Then immediately launch the sum kernel. From Python's perspective, it returns a scalar.
Let me write this out:
```python
cpp_source = '''
torch::Tensor cross_entropy_cuda(torch::Tensor predictions, torch::Tensor targets) {
auto batch_size = predictions.size(0);
auto num_classes = predictions.size(1);
auto options = predictions.options();
auto row_losses = torch::empty({batch_size}, options);
auto out = torch::zeros({}, options);
dim3 grid(batch_size);
dim3 block(1024);
cross_entropy_row_kernel<<<grid, block>>>(
predictions.data_ptr<float>(),
targets.data_ptr<int64_t>(),
row_losses.data_ptr<float>(),
batch_size,
num_classes
);
dim3 sum_block(1024);
dim3 sum_grid(1);
cross_entropy_sum_kernel<<<sum_grid, sum_block>>>(
row_losses.data_ptr<float>(),
out.data_ptr<float>(),
batch_size
);
return out;
}
'''
```
This is clean and avoids atomics.
Alternatively, I can just compute the per-row losses, return them, and do `.mean()` in Python. The C++ wrapper is simpler that way. But the overhead of `.mean()` in Python is the same - it just launches a kernel.
Let me do it in C++ to return a scalar directly.
Actually, thinking about it more, PyTorch's `.mean()` on a contiguous tensor of 32768 elements is extremely well optimized. It probably uses multiple blocks for the reduction to saturate the GPU. My single-block sum kernel might actually be slower than PyTorch's `mean()`. Let me use PyTorch's `mean()` and just return the row_losses. Wait no, I want `forward()` to return a scalar.
Let me do: C++ computes per-row losses and stores in a tensor. Then in `ModelNew.forward()`, I call the C++ function and then `.mean()` on the result.
Wait, but I can have the C++ function return the scalar directly if I do the sum in C++. Let me not overthink this and just use PyTorch's mean.
Hmm, but the overhead of Python calling `.mean()` might be measurable if the main kernel is already very fast. Let me stick with the C++ two-kernel approach.
Actually, I realize I can be even smarter. Instead of writing per-row losses and then summing, I can use an intermediate buffer of size `batch_size` but partitioned by SMs or something to reduce atomics. But that's overkill.
OK, simplest approach that avoids the atomic contention problem: write per-row losses, then use a tree reduction. But PyTorch's default sum/mean is very optimized. Let me just use `torch.mean()` on the per-row loss tensor. It will be fast enough.
Wait, one more idea: what if I launch a single kernel that has cooperative groups do a grid-level reduction? That's possible with `cg::grid_group` in newer CUDA, but it requires grid synchronization and special launch configuration. Probably not worth the complexity.
OK, let me finalize my approach:
1. C++ kernel computes per-row cross-entropy and writes to output tensor of shape (batch_size,)
2. Python wrapper calls the C++ function and returns `.mean()`
Actually, I feel like a single kernel with atomicAdd might be simpler and potentially faster if the atomic contention is low. Let me think about this more carefully.
H100 has atomic operations that can be performed in L2 cache. With 132 SMs, we can have up to 132 atomics in flight simultaneously to different cache lines. But they're all writing to the same float, which is 4 bytes, so it's the same cache line. Even with L2, multiple SMs contending for the same cache line will serialize.
If each block takes ~1000 cycles to execute, and we have 132 SMs each running 4 blocks simultaneously (with 1024 threads), that's ~528 blocks in flight. Each block, when it finishes, tries to atomicAdd to the same location. Since blocks take ~1000 cycles, we'd have an atomicAdd once every ~2 cycles on average. If each atomic takes ~10 cycles, we'd have a queue of ~5 atomics on average. The total atomic time would be ~32768 * 2 cycles = 65536 cycles at ~2GHz = 32 microseconds. That's not too bad!
But this is a rough estimate. Let me think about actual kernel duration. For 4096 elements with single-pass online softmax:
- 4096 loads
- ~4096 * 5 FLOPs (exp, compare, add, multiply)
- Block reduction: ~100 cycles
- Target load: 1 load
- Final computation: ~10 FLOPs
- AtomicAdd: variable
At 3.35 TB/s, 4096 * 4 bytes = 16 KB per block. 16 KB / 3.35 TB/s = 4.8 ns per block in pure memory bandwidth terms. But with compute, it's longer. Maybe 500-1000 cycles per block? At 2 GHz that's 250-500 ns.
Hmm, actually 4096 loads at memory latency of ~300 cycles each, but coalesced into 128 loads (4 bytes * 32 threads = 128 bytes per warp transaction, 4096 / 128 = 32 transactions). With 32 warps in a block, and each warp doing 1 transaction... wait, with 1024 threads and coalesced access, the 4096 elements are loaded in 4096/1024 = 4 loads if we use float4, or 16 loads if we use float32 (each warp transaction covers 128 bytes = 32 floats, 4096/32 = 128 transactions, with 32 warps that's 4 transactions per warp).
Actually for scalar float loads with 1024 threads: warp 0 loads elements 0-31, warp 1 loads 32-63, etc. With perfect coalescing, each warp does 1 128-byte transaction. 1024 threads = 32 warps. But we have 4096 elements and each thread processes 4 elements (with the loop `i += blockDim.x`). So warp 0 processes elements 0, 1024, 2048, 3072. These are NOT contiguous!
Oh no, this is a strided access pattern! With `for (int i = threadIdx.x; i < num_classes; i += blockDim.x)`:
- Thread 0: 0, 1024, 2048, 3072
- Thread 1: 1, 1025, 2049, 3073
- ...
- Thread 31: 31, 1055, 2079, 3103
Within a warp, threads 0-31 access elements 0-31 in the first iteration (coalesced). But in the second iteration, they access 1024-1055, which is also coalesced but a different segment. So each warp does 4 transactions of 128 bytes each, for a total of 128 transactions per block. Still quite efficient since we're reading the full 4096 elements.
The memory layout is: row_ptr[0], row_ptr[1], ..., row_ptr[4095]. With the strided pattern, each warp reads 128 contiguous bytes per iteration. So 4 iterations * 32 warps = 128 transactions of 128 bytes = 16384 bytes total. But we only need 4096 * 4 = 16384 bytes. So we're reading exactly the right amount with no waste. Good!
But the memory access pattern within a warp is contiguous each time, so coalescing is perfect.
OK so memory bandwidth is well utilized.
Now, the key question: atomicAdd or per-row output + mean()?
For simplicity and avoiding the mean kernel, let me try atomicAdd first. If it's a bottleneck, I can switch. But I suspect for this problem, atomicAdd will be fine.
Actually, I just realized another approach: use `gridDim.x` blocks, and within each block, write to an SM-local accumulator using the `__shared__` memory, then have just 1 thread per block atomicAdd to global. That's what I already planned - each block computes 1 row and produces 1 loss value. The atomic is done by only thread 0 of each block.
So the number of atomics = number of blocks = batch_size = 32768. That's what I estimated.
Let me also think about an optimization: instead of atomicAdd to a global float, we can have each block write to a unique location in global memory, and then do a parallel reduction. But that requires a second kernel.
Actually, I just realized: what if I use `float` atomicAdd on H100, and the compiler generates an L2 atomic? The latency might not be terrible because:
1. The atomic is infrequent (one per ~1000 cycles of block execution)
2. We have many blocks executing in parallel
3. The atomicAdd on H100 with float might use hardware atomics
For 32768 blocks, each doing 1 atomicAdd to the same location, on 132 SMs:
- If each block takes T cycles and we can run N blocks per SM simultaneously
- The atomic throughput is roughly 1 per SM per few cycles
- Total atomic time is roughly 32768 / (132 / few_cycles_factor)
If each SM can retire 1 atomic per cycle and we have 132 SMs, that's 132 atomics per cycle. 32768 / 132 = ~248 cycles. At 2 GHz, that's 124 nanoseconds = 0.124 microseconds. That's incredibly small!
But wait, atomics to the SAME address are serialized. Even across SMs, atomics to the same cache line are serialized. So it's not 132 atomics per cycle, it's more like 1 atomic per few cycles globally for the same cache line.
If it's 1 atomic per 10 cycles globally:
32768 * 10 cycles = 327680 cycles. At 2 GHz = 164 microseconds.
164 microseconds of pure atomic overhead. Is the rest of the kernel faster or slower than that?
If the kernel memory bandwidth is ~512 MB read, and we have 3.35 TB/s bandwidth, but we're doing single-pass so it's 512 MB:
Time = 512 MB / 3.35 TB/s = 153 microseconds for memory-bound.
But we're also doing compute (exp). Memory bandwidth might not be fully utilized because exp takes time. Empirically, a kernel like this might take 200-400 microseconds. Adding 164 microseconds of atomic overhead is significant!
So per-row output + separate reduction is likely better.
Let's go with per-row output and a second kernel for the reduction. Or, I'll compute per-row output and then just use `torch.mean()` in Python. The overhead of `torch.mean()` on a 128KB tensor should be 10-20 microseconds at most.
Let me do per-row output + `torch.mean()` in Python. This is the cleanest approach.
Actually wait, I just had another idea. What if we write per-row losses, and then in the same C++ function, call `at::mean()` or `torch::mean()`? That adds another kernel launch but it's the same as doing it in Python.
OK, my final decision: the C++ function returns per-row losses, and Python does `.mean()`.
Wait, but then the gradient computation (backward) won't work properly because we're not creating a proper autograd graph. But wait, the problem only asks to replace the forward computation! It says "Replace the forward computation". So we only need to worry about forward.
Actually, is backward used in the evaluation? The problem says "Replace the forward computation" so probably only forward is evaluated for timing and correctness. Let me check... The evaluation might just call `forward()`. Yes, I think we only need to implement forward.
OK, so my approach is:
1. C++ kernel writes per-row cross entropy loss
2. Python calls the C++ extension and returns `out.mean()`
But actually, let me do the complete scalar return in C++. I'll add a sum reduction kernel. Let me write a proper one:
```cuda
__global__ void reduce_sum_kernel(const float* __restrict__ in, float* __restrict__ out, int n) {
float sum = 0.0f;
for (int i = threadIdx.x; i < n; i += blockDim.x) {
sum += in[i];
}
__shared__ float sdata[1024];
sdata[threadIdx.x] = sum;
__syncthreads();
for (int s = blockDim.x / 2; s > 0; s >>= 1) {
if (threadIdx.x < s) {
sdata[threadIdx.x] += sdata[threadIdx.x + s];
}
__syncthreads();
}
if (threadIdx.x == 0) {
*out = sdata[0] / n;
}
}
```
This is a simple single-block reduction. For 32768 elements with 1024 threads, each thread reads 32 elements. Should be fast.
But actually, a single block might not saturate memory bandwidth for reading 128 KB. With 1024 threads and 128 KB, each thread reads 128 bytes. With 128-byte transactions, that's 1 transaction per thread. We have 32 warps, so 32 transactions total. 32 transactions * 128 bytes = 4096 bytes per iteration, but we have 32768 elements so 32768/1024 = 32 iterations per thread... wait, no. Each thread reads 32 elements (128 bytes total). So each warp reads 32 * 32 * 4 = 4096 bytes per iteration. There are 32 warps, but they're all reading different parts, so the total memory traffic per iteration is 4096 bytes * 32 warps... wait, no.
Let me think again. Each thread reads 32 elements with stride 1024.
Thread 0: elements 0, 1024, 2048, ..., 31744 (32 elements)
Thread 1: elements 1, 1025, 2049, ..., 31745
Within warp 0 (threads 0-31), in the first iteration (i = threadIdx.x):
Thread 0 reads in[0], thread 1 reads in[1], ..., thread 31 reads in[31]. Coalesced, 128 bytes.
Second iteration (i = threadIdx.x + 1024):
Thread 0 reads in[1024], thread 1 reads in[1025], etc. Coalesced, 128 bytes.
...
32 iterations later, warp 0 has read 32 * 128 = 4096 bytes.
All 32 warps do the same pattern but offset by their warp ID * 32. So warp 1 reads elements 32-63, 1056-1079, etc.
Total memory transactions: 32 warps * 32 iterations = 1024 transactions of 128 bytes = 131072 bytes = 128 KB. Perfect coalescing! And with lots of warps, memory latency is hidden.
A single block of 1024 threads can saturate memory bandwidth for this small amount of data? Probably not, but it doesn't need to because 128 KB is small. Even at 10% of peak bandwidth, it takes 0.38 microseconds.
Actually, I should just use PyTorch's sum for this. It's simpler and definitely fast enough.
Hmm, but I want to minimize Python function call overhead and kernel launch overhead. Let me do the sum in C++.
Actually, you know what, for maximum performance and cleanliness, let me write a grid-level reduction kernel. No wait, that's overkill. Single block is fine.
Let me finalize the code. I'll use two kernels in C++.
Wait, actually there's an even simpler approach. What if I have each block's thread 0 accumulate to an array in global memory (one float per block), and then I run a second kernel to sum that array? No wait, I already have one float per row (one block per row). So it's the same thing.
OK here's my final FINAL plan:
1. `cross_entropy_row_kernel`: computes per-row loss, writes to `row_losses[row]`
2. `cross_entropy_reduce_kernel`: sums `row_losses` and divides by `batch_size`, writes to scalar `out`
3. C++ function launches both and returns `out`
Let me write this.
One more thing: I should check if `targets` dtype is `torch.long` or `torch.int64`. In PyTorch, `.dtype` of the randint tensor is `torch.int64`. In C++, I should use `int64_t` for the pointer type. But PyTorch's C++ API ` Tensor::data_ptr<int64_t>()` works. Or I can just use `data_ptr<long>()` which on Linux x86_64 is also 64-bit. Let me use `int64_t` to be safe.
Also, I need to handle the case where `targets` is on CPU and predictions is on GPU? The reference implementation has both on the same device (default CUDA). I'll assume both are CUDA tensors.
Let me write the kernel code now.
For the reduction, I'll use 1024 threads in a single block. For 32768 elements, this is fine.
Actually, I just want to verify my understanding of the online softmax reduction is correct. Let me trace through a simple example:
Data: [1.0, 2.0, 3.0]
Thread 0: processes 1.0
- max = 1.0, sum = 1.0
Thread 1: processes 2.0
- max = 2.0, sum = 1.0
Thread 2: processes 3.0
- max = 3.0, sum = 1.0
Warp reduction:
Lane 0 (max=1.0, sum=1.0) combines with lane 1 (max=2.0, sum=1.0):
- other_max=2.0 > 1.0, so sum = 1.0 + 1.0 * exp(1.0 - 2.0) = 1.0 + exp(-1.0) ≈ 1.368
- max = 2.0
Then combines with lane 2 (max=3.0, sum=1.0):
- other_max=3.0 > 2.0, so sum = 1.0 + 1.368 * exp(2.0 - 3.0) = 1.0 + 1.368 * exp(-1.0) ≈ 1.0 + 0.503 = 1.503
- max = 3.0
log(1.503) + 3.0 ≈ 0.407 + 3.0 = 3.407
log(sum(exp(x - 3.0))) + 3.0 = log(exp(-2) + exp(-1) + exp(0)) + 3.0
= log(0.135 + 0.368 + 1.0) + 3.0
= log(1.503) + 3.0 ≈ 3.407
Correct!
Now let me verify the loss. If target is index 1 (value 2.0):
Loss = -(2.0 - 3.0 - log(1.503)) = -(-1.0 - 0.407) = 1.407
Standard cross entropy: -log(exp(2.0) / (exp(1.0) + exp(2.0) + exp(3.0)))
= -(2.0 - log(exp(1.0) + exp(2.0) + exp(3.0)))
= -(2.0 - log(2.718 + 7.389 + 20.085))
= -(2.0 - log(30.192))
= -(2.0 - 3.407)
= 1.407
Correct!
Great, the algorithm is sound. Now let me write the actual code.
I need to think about the `functions` parameter of `load_inline`. It should be a list of C++ function names. I'll have one function `cross_entropy_cuda`.
Also, I need to make sure the C++ code compiles with the right includes. For `torch::Tensor` and `at::Tensor`, I typically don't need special includes when using `load_inline` since PyTorch headers are already included. But I'll add the right includes in case.
Let me write the complete kernel file:
Actually, before writing the full ModelNew, let me think about one more optimization: we can use warp shuffle for both reductions, but for the second reduction (block-level), we need shared memory to communicate between warps. We can also use a single warp for the second reduction if the number of warps is <= 32, which it is for blockDim.x <= 1024.
One more thing: for the second reduction, since we're using warp shuffle, the `__shfl_down_sync` mask should use all threads in the warp. But in the second reduction, I'm reducing among warp leaders (lane 0 of each warp). So I'll load the data into the first warp's registers and do the shuffle there.
Let me write the code carefully:
```cuda
__global__ void cross_entropy_row_kernel(
const float* __restrict__ predictions,
const int64_t* __restrict__ targets,
float* __restrict__ row_losses,
int batch_size,
int num_classes) {
int row = blockIdx.x;
if (row >= batch_size) return;
const float* row_ptr = predictions + row * num_classes;
int64_t target_idx = targets[row];
float local_max = -1e30f;
float local_sum = 0.0f;
for (int i = threadIdx.x; i < num_classes; i += blockDim.x) {
float x = row_ptr[i];
if (x > local_max) {
local_sum = local_sum * expf(local_max - x) + 1.0f;
local_max = x;
} else {
local_sum += expf(x - local_max);
}
}
// Warp reduction
#pragma unroll
for (int offset = 16; offset > 0; offset >>= 1) {
float other_max = __shfl_down_sync(0xFFFFFFFF, local_max, offset);
float other_sum = __shfl_down_sync(0xFFFFFFFF, local_sum, offset);
if (other_max > local_max) {
local_sum = other_sum + local_sum * expf(local_max - other_max);
local_max = other_max;
} else {
local_sum = local_sum + other_sum * expf(other_max - local_max);
}
}
__shared__ float s_max[32];
__shared__ float s_sum[32];
int lane = threadIdx.x % 32;
int warp_id = threadIdx.x / 32;
if (lane == 0) {
s_max[warp_id] = local_max;
s_sum[warp_id] = local_sum;
}
__syncthreads();
// Block reduction using first warp
if (warp_id == 0) {
float block_max = (lane < (blockDim.x + 31) / 32) ? s_max[lane] : -1e30f;
float block_sum = (lane < (blockDim.x + 31) / 32) ? s_sum[lane] : 0.0f;
#pragma unroll
for (int offset = 16; offset > 0; offset >>= 1) {
float other_max = __shfl_down_sync(0xFFFFFFFF, block_max, offset);
float other_sum = __shfl_down_sync(0xFFFFFFFF, block_sum, offset);
if (other_max > block_max) {
block_sum = other_sum + block_sum * expf(block_max - other_max);
block_max = other_max;
} else {
block_sum = block_sum + other_sum * expf(other_max - block_max);
}
}
if (lane == 0) {
s_max[0] = block_max;
s_sum[0] = block_sum;
}
}
__syncthreads();
float row_max = s_max[0];
float log_sum_exp = logf(s_sum[0]);
if (threadIdx.x == 0) {
float target_val = row_ptr[target_idx];
row_losses[row] = -(target_val - row_max - log_sum_exp);
}
}
```
For the reduction kernel:
```cuda
__global__ void reduce_mean_kernel(const float* __restrict__ in, float* __restrict__ out, int n) {
float sum = 0.0f;
for (int i = threadIdx.x; i < n; i += blockDim.x) {
sum += in[i];
}
__shared__ float sdata[1024];
sdata[threadIdx.x] = sum;
__syncthreads();
for (int s = blockDim.x / 2; s > 0; s >>= 1) {
if (threadIdx.x < s) {
sdata[threadIdx.x] += sdata[threadIdx.x + s];
}
__syncthreads();
}
if (threadIdx.x == 0) {
*out = sdata[0] / n;
}
}
```
Let me now put this into a complete Python module.
Wait, I need to think about `-1e30f` more carefully. If the logits are all -1e30f, then:
- `local_sum` for each thread would be `num_classes_per_thread`
- After reduction, `s_sum[0]` = `num_classes`
- `log_sum_exp` = `log(num_classes) - 1e30f` ≈ `-1e30f`
- Then if target value is also `-1e30f`, loss = `-(-1e30f - (-1e30f) - log(num_classes))` = `log(num_classes)`
But standard cross entropy on uniform -1e30f would be `log(num_classes)`. So it's actually correct!
Wait, really? If all logits are the same value L, then:
softmax = uniform = 1/num_classes for each class
CE = -log(1/num_classes) = log(num_classes)
With our computation:
max = L
log_sum_exp = log(num_classes * exp(L - L)) + L = log(num_classes) + L
loss = -(L - L - log(num_classes)) = log(num_classes)
Correct! So even with -1e30f, if all values are the same, it works.
What if values are all very negative but not exactly the same? Say [-1e30, -1e30+1]. Then:
max = -1e30 + 1
log_sum_exp = log(exp(-1) + 1) + (-1e30 + 1) ≈ 0.31 - 1e30 + 1 = -1e30 + 1.31
Target = -1e30 (worst case):
loss = -(-1e30 - (-1e30 + 1) - 0.31) = -( -1 - 0.31 ) = 1.31
Wait, standard cross entropy:
exp(-1e30) / (exp(-1e30) + exp(-1e30+1)) ≈ 0 / (0 + 0) which is NaN. But in practice with float32, exp(-1e30) is 0, and we'd get 0/0 = NaN.
But our online softmax with -1e30f instead of -inf:
If local_max starts at -1e30f and we encounter -1e30f (first element), sum becomes 1 and max becomes -1e30f.
Second element -1e30f + 1 = -999999999999999875848587653015808.0 (not representable in float32, so it's effectively -inf or some very negative number). Wait, in float32, -1e30f is just a very negative number. Adding 1 to it is still -1e30f because of precision. So both values are the same.
This is getting into edge cases that won't matter for the actual data.
OK, let me just write the code and compile it.
Actually, I realize I should also check if there are issues with the `expf` function. CUDA's `expf` is accurate and fast. On H100, it might map to a fast approximation intrinsic, but for cross entropy we need enough accuracy.
Let me write the complete kernel now.