Softmax Implementation
From naive three-pass to online single-pass β numerically stable softmax with progressive optimization, leading to the online algorithm that enables FlashAttention.
Softmax appears in every attention computation and at the output of every LLM. A naive implementation requires three passes over the data (max, sum, normalize) β reading from HBM three times. The online algorithm does it in one pass, and this same trick is what makes FlashAttention possible.
1. The Softmax Operation
Numerical Stability Problem
Direct computation of overflows for (FP32) or (FP16). Fix: subtract the maximum first.
The Three-Pass Algorithm
Pass 1: Find max β m = max(x) (read all N elements)
Pass 2: Compute sum β s = Ξ£ exp(x_i - m) (read all N elements)
Pass 3: Normalize β y_i = exp(x_i - m) / s (read all N, write all N)
Three reads of the entire array from HBM. For attention with and 32 heads: thatβs 3 Γ 32 Γ 4096Β² Γ 2 bytes = 3 GB of HBM traffic just for softmax.
2. V0: Naive (One Thread Per Row)
__global__ void softmaxV0(float* input, float* output, int N) {
int row = blockIdx.x;
float* x = input + row * N;
float* y = output + row * N;
// Pass 1: max
float m = -INFINITY;
for (int i = 0; i < N; i++) m = fmaxf(m, x[i]);
// Pass 2: sum of exp
float s = 0.0f;
for (int i = 0; i < N; i++) s += expf(x[i] - m);
// Pass 3: normalize
for (int i = 0; i < N; i++) y[i] = expf(x[i] - m) / s;
}
Problems: One thread does all work for a row (no parallelism). Three sequential passes over HBM.
3. V1: Parallel Three-Pass with Block Reduction
Use a full block to process one row in parallel:
__global__ void softmaxV1(float* input, float* output, int N) {
__shared__ float smax, ssum;
int row = blockIdx.x;
int tid = threadIdx.x;
float* x = input + row * N;
// Pass 1: Parallel max reduction
float local_max = -INFINITY;
for (int i = tid; i < N; i += blockDim.x)
local_max = fmaxf(local_max, x[i]);
// Warp shuffle + shared memory reduction for max
local_max = warp_reduce_max(local_max);
if (tid % 32 == 0) shared_warp_max[tid/32] = local_max;
__syncthreads();
if (tid == 0) {
float m = -INFINITY;
for (int i = 0; i < blockDim.x/32; i++) m = fmaxf(m, shared_warp_max[i]);
smax = m;
}
__syncthreads();
// Pass 2: Parallel exp-sum
float local_sum = 0.0f;
for (int i = tid; i < N; i += blockDim.x)
local_sum += expf(x[i] - smax);
// Reduce sum (similar pattern)
// ...
__syncthreads();
// Pass 3: Normalize
for (int i = tid; i < N; i += blockDim.x)
output[row * N + i] = expf(x[i] - smax) / ssum;
}
Better: Parallelized within each pass. But still three passes over HBM.
4. Online Softmax: One Pass
The Key Insight
We can compute max and sum simultaneously by maintaining a running correction:
When we see a new element thatβs larger than our current max :
- Update max:
- Correct the running sum:
The correction factor rescales all previous exponentials to the new max.
The Online Algorithm
// Single pass: compute max and sum simultaneously
float m = -INFINITY; // running max
float s = 0.0f; // running sum (corrected)
for (int i = 0; i < N; i++) {
float new_m = fmaxf(m, x[i]);
s = s * expf(m - new_m) + expf(x[i] - new_m); // correct and add
m = new_m;
}
// Final normalization pass (still needed)
for (int i = 0; i < N; i++) {
y[i] = expf(x[i] - m) / s;
}
Two passes instead of three β and the first pass does max + sum simultaneously. 33% fewer HBM reads.
Why This Matters for FlashAttention
FlashAttention processes attention in tiles. Within each tile, it needs softmax statistics (max and sum) that are updated as new tiles are processed. The online algorithm enables this:
- Process tile 0: compute partial max and partial sum
- Process tile 1: update , correct
- Continue for all tilesβ¦
Without online softmax, FlashAttention would need to read the entire attention matrix to find the global max first β defeating the purpose of tiling.
5. V2: Parallel Online Softmax
Parallelize the online algorithm across a block:
__global__ void softmaxOnline(float* input, float* output, int N) {
int row = blockIdx.x;
int tid = threadIdx.x;
float* x = input + row * N;
// Each thread computes local online max and sum
float m = -INFINITY;
float s = 0.0f;
for (int i = tid; i < N; i += blockDim.x) {
float new_m = fmaxf(m, x[i]);
s = s * expf(m - new_m) + expf(x[i] - new_m);
m = new_m;
}
// Combine across threads: need to merge (m, s) pairs
// Use warp shuffle to merge pairs
for (int offset = 16; offset > 0; offset >>= 1) {
float other_m = __shfl_down_sync(0xFFFFFFFF, m, offset);
float other_s = __shfl_down_sync(0xFFFFFFFF, s, offset);
float new_m = fmaxf(m, other_m);
s = s * expf(m - new_m) + other_s * expf(other_m - new_m);
m = new_m;
}
// Block-level merge via shared memory (same pattern)
// ...
// Final normalize pass
for (int i = tid; i < N; i += blockDim.x) {
output[row * N + i] = expf(x[i] - m) / s;
}
}
The merge operation: when combining two partial results and :
This is associative β so it works with tree reduction, warp shuffle, etc.
6. Fused Softmax + Cross-Entropy
In training, softmax is almost always followed by cross-entropy loss. Fusing them:
- Avoids writing softmax output to HBM (just compute it inline)
- Uses the numerically stable
log_softmaxform:
And the gradient has the beautiful simple form:
PyTorchβs F.cross_entropy uses this fused implementation β you should too.
7. Performance Summary
| Version | Passes | Key optimization | Relative speed |
|---|---|---|---|
| V0 Naive (single thread) | 3 | None | 1Γ |
| V1 Parallel three-pass | 3 | Block reduction | ~10Γ |
| V2 Parallel online | 2 | Online max+sum | ~15Γ |
| + Fused kernel | 1 | Avoid intermediate write | ~20Γ |
PyTorch F.cross_entropy | 1 | All of the above | Production speed |
Self-Check
- Explain why naive overflows and how subtracting max fixes it
- Explain the three-pass algorithm and why three HBM reads is expensive
- Derive the online softmax correction:
- Explain why online softmax enables FlashAttentionβs tiled computation
- Implement the merge operation for combining two (max, sum) pairs
- Explain why fused softmax + cross-entropy is both faster and more numerically stable