The Math Behind LLMs · Part 5 of 16

Softmax and Log-Sum-Exp: From Scores to Probabilities, Safely

Why softmax uses the exponential, how one subtraction stops it overflowing, what log-sum-exp measures, and the online softmax inside FlashAttention.

On this page

TL;DR

Softmax turns scores into probabilities: raise ee to each score and divide by the total. The exponential is the only smooth choice for which a fixed gap between two scores always means the same odds; a gap of 1 means 2.718 times as likely. Because only gaps matter, you can subtract the largest score first, which is how every framework keeps softmax from overflowing. The log of the total, log-sum-exp, acts as a smooth maximum and gives stable log-probabilities. And a running maximum plus a running total let you compute softmax block by block, the recurrence at the heart of FlashAttention.

Why it matters

Take the 151,936 scores Qwen2.5-1.5B-Instruct produces for the word after “I opened the fridge and took out a”. Feed them to softmax written exactly as the textbook formula, in 16-bit floats, and not one usable probability comes back. The 438 highest come out as nan, “not a number”, and the other 151,498 as exactly 0.

The top score is only 18.44. But e18.44e^{18.44} is about 102 million, and the largest number a 16-bit float can hold is 65,504. The 438 scores above 11.09 overflow to infinity, the total becomes infinity, and dividing by it wrecks everything.

The fix is a single subtraction, and it’s exact. The same idea, pushed one step further, lets a GPU compute attention over a long context without ever holding a full row of attention scores in memory. Part 4 used softmax as a black box. This part opens it.

The intuition

Chess ratings work like softmax. The Elo system turns the gap between two players’ ratings into expected results: every 400 points of advantage makes the stronger player’s expected score ten times the weaker one’s. Only the gap matters, so a 1,600 player facing a 1,200 player has the same prospects as a 2,400 facing a 2,000. And gaps compound: 800 points means 10 × 10 = 100 times.

Softmax is the same rule with a different scale. Every point of score advantage multiplies a token’s odds by e≈2.718e \approx 2.718, and the contest runs among all 151,936 tokens at once.

The analogy breaks in two places. Elo ratings persist from game to game; a model’s scores are computed fresh at every position and mean nothing on their own. And Elo compares two players at a time, while softmax shares one pot of probability among everyone, so a strong new candidate lowers every other token’s probability, though not the ratios between them.

Step 1: deriving softmax

What a scores-to-probabilities map needs

A model’s last layer produces one raw score per token, a logit, which can be any number (Fundamentals Part 4 shows where they come from). Turning logits into a probability distribution needs four things:

  1. Every probability is positive. No token should be ruled out completely.
  2. They add up to 1.
  3. Order is kept: a higher score always means a higher probability.
  4. Small changes in a score cause small, smooth changes in the probabilities, because training adjusts scores in tiny steps.

Three recipes that fail

Try some obvious recipes on three scores, 1, -1 and 0.5:

RecipeScore 1Score -1Score 0.5What goes wrong
Divide by the total2.000-2.0001.000negative, and bigger than 1
Square, then divide0.4440.4440.111the lowest score ties the highest
Subtract the lowest, then divide0.5710.0000.429the lowest token always gets 0
Raise e to each, then divide0.5740.0780.348nothing

The last row is softmax: e1=2.718e^1 = 2.718, e−1=0.368e^{-1} = 0.368 and e0.5=1.649e^{0.5} = 1.649, which total 4.735, and each one divided by 4.735 gives its row entry. Exponentials are always positive, they grow with the score, they’re smooth, and dividing by the total makes them add up to 1.

Why the exponential, and not something else

Plenty of positive, increasing, smooth functions would pass that table. One more requirement picks out the exponential: the odds between two tokens should depend only on the gap between their scores, as in Elo.

Write the recipe with an unknown positive function ff: token ii gets probability f(zi)f(z_i) divided by the total of all the ff values, where ziz_i is its score. The total cancels in a ratio, so the odds of token aa against token bb are f(za)/f(zb)f(z_a) / f(z_b). Asking that this depend only on the gap means that for any score xx and gap cc:

f(x+c)f(x)=g(c)\frac{f(x + c)}{f(x)} = g(c)

for some function gg of the gap alone. Now step up by cc and then by dd. The two ratios multiply, so

g(c+d)=g(c) g(d)g(c + d) = g(c)\, g(d)

In words, adding gaps multiplies odds. The continuous functions that turn addition into multiplication are exactly the exponentials, g(c)=eβcg(c) = e^{\beta c} for some constant β\beta, and then f(x)=f(0) eβxf(x) = f(0)\, e^{\beta x}. The constant f(0)f(0) cancels when you divide by the total, and β\beta only sets the scale, the knob Part 4 called temperature (β=1/T\beta = 1/T). With β=1\beta = 1:

softmax(z)i=ezi∑j=1nezj\text{softmax}(\mathbf{z})_i = \frac{e^{z_i}}{\sum_{j=1}^{n} e^{z_j}}

Here z\mathbf{z} is the list of nn scores, ziz_i is the score of token ii, and the sum in the denominator runs over all nn tokens. In words: raise ee to every score, then divide each result by their total.

The property we asked for now holds by construction:

papb=eza−zb\frac{p_a}{p_b} = e^{z_a - z_b}

where pap_a is the probability of token aa. A gap of 0.693 means twice as likely, 1 means 2.718 times, and 2.303 means 10 times. Qwen’s logits after the fridge prompt are 18.437 for ” bottle” and 17.902 for ” can”, a gap of 0.535, and e0.535=1.71e^{0.535} = 1.71: the “1.71 times as likely” from Part 4, read straight off the scores.

A worked example to keep: scores [2, 1, 0]. The exponentials are 7.389, 2.718 and 1, which total 11.107, so softmax gives 0.665, 0.245 and 0.090.

With only two tokens, softmax becomes the logistic curve. Divide the top and bottom of ea/(ea+eb)e^{a} / (e^{a} + e^{b}) by eae^{a} and you get 1/(1+e−(a−b))1 / (1 + e^{-(a - b)}), a function of the gap alone. Elo’s formula is this curve, with base 10 and a scale of 400 points. Physicists wrote the same formula in 1868 as the Boltzmann distribution, with energies divided by a gas’s temperature, which is where the sampling knob’s name comes from. The machine learning name dates from 1989, when John Bridle called it the “normalised exponential (softmax)”.

Step 2: shifts, overflow and the max trick

Only the gaps matter

Add the same number cc to every score. Since ez+c=eceze^{z + c} = e^c e^z, every term picks up the same factor ece^c, top and bottom:

ezi+c∑jezj+c=ec eziec∑jezj=ezi∑jezj\frac{e^{z_i + c}}{\sum_j e^{z_j + c}} = \frac{e^{c}\, e^{z_i}}{e^{c} \sum_j e^{z_j}} = \frac{e^{z_i}}{\sum_j e^{z_j}}

The factor cancels, so the probabilities don’t move, and that one line is the proof. It’s why GPT-2’s logits can all sit near -100, as Fundamentals Part 4 found, and still give sensible probabilities. It also means nn scores carry only n−1n - 1 numbers’ worth of information: fix any one score and the gaps determine everything.

Where the textbook formula breaks

Floating-point numbers have a largest value, and exe^x passes it quickly:

FormatBitsLargest numbere^x overflows when x is above
float32 (FP32)323.40 × 10^3888.72
bfloat16 (BF16)163.39 × 10^3888.72
float16 (FP16)1665,50411.09

The thresholds are the natural logs of the largest numbers, computed from NumPy’s and PyTorch’s finfo. At the other end, e−104e^{-104} rounds to exactly 0 in float32, the underflow Part 4 met.

That gives the textbook formula two ways to fail. If any score is large, its exponential becomes infinity, and infinity divided by infinity is nan. If every score is very negative, every exponential rounds to 0, and 0 divided by 0 is also nan. Here are both, on Qwen’s real fridge logits. They come from Part 4’s last snippet, saved with np.save("fridge_logits.npy", z.numpy()):

import numpy as np

z = np.load("fridge_logits.npy")    # Qwen2.5-1.5B's 151,936 next-token logits (float32)

def softmax_naive(z):
    e = np.exp(z)
    return e / e.sum()

def softmax_stable(z):
    e = np.exp(z - z.max())         # the largest term becomes e^0 = 1
    return e / e.sum()

print(z.max())                                        # the top logit, " bottle"
print(softmax_naive(z).max())                         # float32: fine
print(softmax_naive(z + 100).max())                   # same distribution, shifted up
print(softmax_naive(z - 200).max())                   # same distribution, shifted down
print(softmax_naive(z.astype(np.float16)).max())      # 16-bit floats
print(softmax_stable(z + 100).max(), softmax_stable(z.astype(np.float16)).max())
18.437366
0.15383023
nan
nan
nan
0.15382974 0.1538

In float32 the raw logits happen to be safe, since 18.44 is far below 88.72. Shift them up by 100, which mathematically changes nothing, and the top terms overflow. Shift them down by 200 and every term underflows. In float16, the unshifted logits already fail.

The max trick

Shift invariance says you may subtract any number you like. Subtract the largest score, m=max⁡jzjm = \max_j z_j:

softmax(z)i=ezi−m∑jezj−m\text{softmax}(\mathbf{z})_i = \frac{e^{z_i - m}}{\sum_{j} e^{z_j - m}}

Now the largest exponent is exactly 0, so the largest term is e0=1e^0 = 1 and every other term lies between 0 and 1. Nothing can overflow. And because one term is exactly 1, the denominator lies between 1 and nn, so it can never be 0. The last line of the output shows the stable version giving 0.1538 for every input. (The 0.15382974 differs in its last digits because adding 100 in float32 rounds each logit to a multiple of about 0.0000076.)

Goodfellow, Bengio and Courville’s Deep Learning puts it this way: subtracting the maximum “results in the largest argument to exp being 0, which rules out the possibility of overflow.” Milakov and Gimelshein reported in 2018 that TensorFlow, PyTorch, MXNet, the Microsoft Cognitive Toolkit and Chainer all used this safe version. One problem is left: a tiny term can still underflow to 0, which is harmless for a probability but fatal for its log.

Step 3: log-sum-exp, the smooth maximum

Log-softmax

Take the log of softmax. The log of a ratio is a difference, and ln⁡ezi=zi\ln e^{z_i} = z_i:

ln⁡pi=zi−ln⁡∑jezj\ln p_i = z_i - \ln \sum_{j} e^{z_j}

The second term is the same for every token. It has a name, the log-sum-exp:

LSE(z)=ln⁡∑j=1nezj\text{LSE}(\mathbf{z}) = \ln \sum_{j=1}^{n} e^{z_j}

In words: exponentiate every score, add them up, take the log. So every log-probability is the token’s logit minus one shared number. For the fridge prompt, LSE is 20.309, and ” bottle” has logit 18.437, so its log-probability is 18.437−20.309=−1.87218.437 - 20.309 = -1.872 and its probability e−1.872=0.1538e^{-1.872} = 0.1538. Part 4’s log_softmax calls computed exactly this.

The max trick works here too. Pull eme^m out of the sum:

LSE(z)=m+ln⁡∑jezj−m\text{LSE}(\mathbf{z}) = m + \ln \sum_{j} e^{z_j - m}

The sum is between 1 and nn, so its log is safe. Log-softmax computed as zi−m−ln⁡∑jezj−mz_i - m - \ln \sum_j e^{z_j - m} never takes the log of a number that underflowed.

Why it’s called a smooth max

The sum ∑jezj\sum_j e^{z_j} contains the term eme^m, so it’s at least eme^m. It has nn terms, none bigger than eme^m, so it’s at most nemn e^m. Take logs of both bounds:

m≤LSE(z)≤m+ln⁡nm \le \text{LSE}(\mathbf{z}) \le m + \ln n

Check it on [2, 1, 0]: the LSE is ln⁡11.107=2.408\ln 11.107 = 2.408, which sits between the max, 2, and 2+ln⁡3=3.0992 + \ln 3 = 3.099. The upper bound is reached when all scores tie: the LSE of [2, 2, 2] is exactly 3.099.

So log-sum-exp is the maximum plus a bonus of at most ln⁡n\ln n. For Qwen’s vocabulary, ln⁡151,936=11.93\ln 151{,}936 = 11.93, but the fridge logits give a bonus of only 20.309−18.437=1.87220.309 - 18.437 = 1.872. That bonus has a meaning: the top token’s probability is em−LSEe^{m - \text{LSE}}, so the bonus is exactly −ln⁡ptop-\ln p_{\text{top}}. A bonus of 0 means the model is certain; a bonus of ln⁡n\ln n means every token is equally likely.

Temperature makes the max sharp

Divide the scores by a temperature TT before taking the log-sum-exp, and multiply by TT afterward. The same bounds become m≤T⋅LSE(z/T)≤m+Tln⁡nm \le T \cdot \text{LSE}(\mathbf{z}/T) \le m + T \ln n, so as TT shrinks toward 0, the smooth max squeezes onto the true max. Meanwhile the softmax itself heads for the argmax, the position of the max, written as a one-hot list (1 for the winner, 0 for everyone else). In ratio form, pi/ptop=e(zi−m)/Tp_i / p_{\text{top}} = e^{(z_i - m)/T}: a gap of -1 at T=0.1T = 0.1 becomes e−10=0.0000454e^{-10} = 0.0000454.

import numpy as np

def lse(z):
    m = z.max()
    return m + np.log(np.exp(z - m).sum())

def softmax(z):
    e = np.exp(z - z.max())
    return e / e.sum()

z = np.array([2.0, 1.0, 0.0])
print(f"LSE = {lse(z):.4f}, bounds {z.max():.4f} to {z.max() + np.log(len(z)):.4f}")
print(f"LSE of [2, 2, 2] = {lse(np.array([2.0, 2.0, 2.0])):.4f}")

np.set_printoptions(precision=4)
for T in [10, 1, 0.5, 0.1]:
    print(f"T = {T:>4}: softmax {softmax(z / T)}, T * LSE(z/T) = {T * lse(z / T):.4f}")
LSE = 2.4076, bounds 2.0000 to 3.0986
LSE of [2, 2, 2] = 3.0986
T =   10: softmax [0.3672 0.3322 0.3006], T * LSE(z/T) = 12.0194
T =    1: softmax [0.6652 0.2447 0.09  ], T * LSE(z/T) = 2.4076
T =  0.5: softmax [0.8668 0.1173 0.0159], T * LSE(z/T) = 2.0715
T =  0.1: softmax [9.9995e-01 4.5398e-05 2.0611e-09], T * LSE(z/T) = 2.0000

At T=0.1T = 0.1 the smooth max is 2.0000 and softmax is one-hot to four decimals. At T=10T = 10 the softmax is nearly flat and the smooth max overshoots to 12.02, inside its bound of 2+10ln⁡3=12.992 + 10 \ln 3 = 12.99. Two tokens tied for the top score split the probability evenly in the limit. Part 4 showed what these limits do to real sampling.

Log-sum-exp and softmax are also linked by a derivative. The slope of LSE with respect to score ziz_i is ezi/∑jezje^{z_i} / \sum_j e^{z_j}, which is exactly pip_i. Softmax is the gradient of the smooth max. The Deep Learning book notes that it “would perhaps be better to call the softmax function ‘softargmax’”, since it softens the argmax rather than the max.

How sensitive softmax is

When score jj rises a little, probability ii changes at the rate

∂pi∂zj=pi(δij−pj)\frac{\partial p_i}{\partial z_j} = p_i (\delta_{ij} - p_j)

where δij\delta_{ij} is 1 when i=ji = j and 0 otherwise (Part 7 builds these rates of change from scratch). For [2, 1, 0], raising the first score by 0.001 moved the probabilities by 0.000223, -0.000163 and -0.000060, matching 0.665×0.3350.665 \times 0.335, −0.245×0.665-0.245 \times 0.665 and −0.090×0.665-0.090 \times 0.665 per unit. A token’s own rate, p(1−p)p(1 - p), shrinks toward 0 as pp nears 1, which is why a nearly one-hot softmax stops learning (Fundamentals Part 5 shows this inside attention). Part 11 covers the full grid of rates.

Why frameworks fuse log-softmax with the loss

The training loss for one token is −ln⁡p-\ln p of the correct token (Part 6 names it). You could compute softmax, then take the log. Or you could compute zcorrect−LSE(z)z_{\text{correct}} - \text{LSE}(\mathbf{z}) in one step. Here’s a token that trails the leader by 120 points but is the right answer:

import torch
import torch.nn.functional as F

z = torch.tensor([0.0, -120.0], requires_grad=True)   # token 1 trails token 0 by 120
target = torch.tensor(1)                              # ...and token 1 is the right answer

two_step = -torch.log(torch.softmax(z, dim=0))[target]
fused = F.cross_entropy(z.unsqueeze(0), target.unsqueeze(0))
print(torch.softmax(z, dim=0))
print(two_step.item(), fused.item())

two_step.backward(); print(z.grad); z.grad = None
fused.backward();    print(z.grad)
tensor([1., 0.], grad_fn=<SoftmaxBackward0>)
inf 120.0
tensor([nan, nan])
tensor([ 1., -1.])

The probability e−120e^{-120} is about 10−5210^{-52}, below float32’s smallest number, so softmax rounds it to 0. The two-step loss is then −ln⁡0=∞-\ln 0 = \infty, and its gradient, the signal training needs most, is nan. The fused version never forms the tiny probability, so it returns the exact loss, 120, and a clean gradient. PyTorch’s documentation for log_softmax says that while it is “mathematically equivalent to log(softmax(x)), doing these two operations separately is slower and numerically unstable.” Its cross_entropy takes raw logits for this reason, and is documented as “equivalent to applying LogSoftmax on an input, followed by NLLLoss.”

The gradient [1, -1] is each probability minus the target’s one-hot vector: [1 - 0, 0 - 1]. Bridle’s 1989 paper already noted this “difference between the corresponding output and a one-from-N target”; Part 8 derives it.

Step 4: online softmax, the core of FlashAttention

Counting passes

The safe softmax from Step 2 reads its input three times: once to find the max, once to add up the exponentials, and once to divide. Milakov and Gimelshein’s Online normalizer calculation for softmax (2018) counts that as 4 memory accesses per element, 3 loads and a store. Their idea folds the first two passes into one. Inference Part 7 showed it running on a worked example; here is the derivation, and why it can’t drift.

The recurrence, derived

Process the scores one at a time, keeping two numbers. After seeing kk scores, mkm_k is the largest so far and lkl_k is the sum of exponentials measured against that max:

lk=∑j=1kezj−mkl_k = \sum_{j=1}^{k} e^{z_j - m_k}

When score zk+1z_{k+1} arrives, the max becomes mk+1=max⁡(mk,zk+1)m_{k+1} = \max(m_k, z_{k+1}). The old sum is measured against the old max, so convert it. Multiplying each of its terms by emk−mk+1e^{m_k - m_{k+1}} gives

ezj−mk⋅emk−mk+1=ezj−mk+1e^{z_j - m_k} \cdot e^{m_k - m_{k+1}} = e^{z_j - m_{k+1}}

because exponents add. Rescale the old sum, then add the new term:

lk+1=lk emk−mk+1+ezk+1−mk+1l_{k+1} = l_k \, e^{m_k - m_{k+1}} + e^{z_{k+1} - m_{k+1}}

That’s the whole algorithm, and it’s exact: the rescaling rewrites the same sum against a new reference point. Every exponent is zero or negative, so nothing overflows, and ll stays between 1 and kk. The paper proves it by induction and notes that a float32 ll is safe for up to 1.7×10371.7 \times 10^{37} elements.

Trace it on [1, 3, 2]:

ScoreRunning maxRescale old sum byRunning sum
11nothing yete^0 = 1
33e^(1 - 3) = 0.13531 × 0.1353 + e^0 = 1.1353
23e^0 = 11.1353 + e^(-1) = 1.5032

The end state is m=3m = 3, l=1.5032l = 1.5032, and m+ln⁡l=3+0.4076=3.4076m + \ln l = 3 + 0.4076 = 3.4076, the log-sum-exp. The pair (m,l)(m, l) is LSE stored in two safe pieces. These are the scores [2, 1, 0] reshuffled and shifted up by 1, and the LSE is the earlier 2.4076 plus 1. A second pass then writes each probability as ezi−m/le^{z_i - m} / l: 3 memory accesses per element instead of 4, which ran up to 1.3 times faster on an NVIDIA Tesla V100 in the paper’s benchmarks.

A block of scores works the same way: take the block’s max, rescale once, add the block’s terms. Here it is over Qwen’s 151,936 logits in blocks of 4,096:

import numpy as np

def online_softmax_stats(z, block):
    m, l = -np.inf, 0.0                     # running max, running sum of e^(z - m)
    for start in range(0, len(z), block):
        zb = z[start:start + block]
        m_new = max(m, zb.max())
        l = l * np.exp(m - m_new) + np.exp(zb - m_new).sum()
        m = m_new
    return m, l

z = np.load("fridge_logits.npy").astype(np.float64)   # 151,936 logits
m, l = online_softmax_stats(z, block=4096)            # 38 blocks, one pass
p_online = np.exp(z - m) / l                          # second pass: the probabilities

p_full = np.exp(z - z.max()) / np.exp(z - z.max()).sum()
print(m, l, m + np.log(l))                            # max, sum, log-sum-exp
print(np.abs(p_online - p_full).max())
18.437366485595703 6.500672788630996 20.309272163084152
8.326672684688674e-17

The running sum ends at 6.5007, which is 1/0.153831 / 0.15383, one over the top token’s probability, as it should be: the top term is e0=1e^0 = 1 and ll is the total. The largest difference from the full softmax is 8×10−178 \times 10^{-17}, rounding in the last digit of a 64-bit float.

Merging two partial results

The pass needn’t run in order. Process two halves separately, ending with (ma,la)(m_a, l_a) and (mb,lb)(m_b, l_b), and combine them with M=max⁡(ma,mb)M = \max(m_a, m_b):

l=la ema−M+lb emb−Ml = l_a \, e^{m_a - M} + l_b \, e^{m_b - M}

Milakov and Gimelshein show this merge is associative and commutative: you can group and order the pieces any way you like. In log-sum-exp language, the LSE of the whole is the LSE of the two halves’ LSEs. That’s what lets Flash-Decoding split a long cache across many processors and merge their answers at the end.

Adding the values: the attention recurrence

Attention only needs the weighted average of the value vectors (the vectors each token passes on). For one query with scores ziz_i and values vi\mathbf{v}_i, the output is o=∑ipivi\mathbf{o} = \sum_i p_i \mathbf{v}_i. Carry a third running quantity, an un-normalized output, and rescale it by the same factor:

acck+1=acck emk−mk+1+∑i∈blockezi−mk+1 vi\mathbf{acc}_{k+1} = \mathbf{acc}_k \, e^{m_k - m_{k+1}} + \sum_{i \in \text{block}} e^{z_i - m_{k+1}} \, \mathbf{v}_i

At the end, o=acc/l\mathbf{o} = \mathbf{acc} / l. The scores are used once and thrown away; the state is two numbers and one vector per query. Here it is against standard attention for one query, 8,192 keys and a head size of 128, plus a two-way split merged by LSE, as if the halves ran on two SMs (streaming multiprocessors, a GPU’s processing units):

import numpy as np

rng = np.random.default_rng(0)
n, d = 8192, 128                                  # 8,192 keys, head size 128
q = rng.standard_normal(d)
K = rng.standard_normal((n, d))
V = rng.standard_normal((n, d))
s = K @ q / np.sqrt(d)                            # one query's 8,192 scores

# Standard: all scores at once
w = np.exp(s - s.max())
o_full = (w / w.sum()) @ V

# Online: blocks of 256 keys, never more than 256 scores held at once
def attend(s, V, block=256):
    m, l, acc = -np.inf, 0.0, np.zeros(V.shape[1])
    for i in range(0, len(s), block):
        sb, Vb = s[i:i + block], V[i:i + block]
        m_new = max(m, sb.max())
        scale = np.exp(m - m_new)                 # e^(m_old - m_new)
        e = np.exp(sb - m_new)
        l = l * scale + e.sum()
        acc = acc * scale + e @ Vb                # un-normalized output
        m = m_new
    return acc / l, m + np.log(l)                 # output and log-sum-exp

o_online, L = attend(s, V)
print(np.abs(o_online - o_full).max())

# Two halves computed separately (say, on two SMs), merged with their LSEs
o1, L1 = attend(s[:n // 2], V[:n // 2])
o2, L2 = attend(s[n // 2:], V[n // 2:])
L12 = np.logaddexp(L1, L2)                        # LSE of the two LSEs
o_merged = np.exp(L1 - L12) * o1 + np.exp(L2 - L12) * o2
print(L, L12, np.abs(o_merged - o_full).max())
9.367506770274758e-17
9.487242073621518 9.487242073621518 1.2836953722228372e-16

Both match standard attention to about 10−1610^{-16}. In the merge, the weight eL1−Le^{L_1 - L} is the share of the total probability that falls in the first half, so the merged output is a weighted average of the two partial outputs.

A left-to-right loop. Start with m equal to minus infinity, l equal to 0 and acc equal to 0. Read the next block of scores z and values v. Compute the new max M, the larger of m and the block's max. Rescale the old totals l and acc by e to the power m minus M. Add the block: l gains the sum of e to the z minus M, acc gains the sum of e to the z minus M times v, then set m to M. If there are more blocks, loop back to reading the next block; if not, the output is acc divided by l, and the log-sum-exp is m plus the natural log of l.A left-to-right loop. Start with m equal to minus infinity, l equal to 0 and acc equal to 0. Read the next block of scores z and values v. Compute the new max M, the larger of m and the block's max. Rescale the old totals l and acc by e to the power m minus M. Add the block: l gains the sum of e to the z minus M, acc gains the sum of e to the z minus M times v, then set m to M. If there are more blocks, loop back to reading the next block; if not, the output is acc divided by l, and the log-sum-exp is m plus the natural log of l.

Swipe to pan the diagram, or tap it to zoom.

The online softmax loop for one attention query. M is the new running max.

Only the max, the sum and the output vector outlive each block.

That’s the FlashAttention recurrence. The FlashAttention paper (Dao et al., 2022) writes it as a decomposition of the softmax of two concatenated blocks, mm and ℓ\ell included, and its first version divided by the running sum after every block and wrote the result back to memory. FlashAttention-2 (Dao, 2023) made the two tweaks the code above already uses: keep an “un-scaled” output and divide by ℓ\ell only once, when the loop ends, and save only the logsumexp L=m+log⁡(ℓ)L = m + \log(\ell) for the backward pass, instead of both mm and ℓ\ell.

In the real world

  • PyTorch’s GPU softmax kernel for rows of up to 2,048 elements (PersistentSoftmax.cuh) is Step 2 and Step 3 line for line: it finds max_value, adds up std::exp(elements - max_value), takes the log of that sum for log-softmax, and writes elements - max_value - sum. Its running totals use a wider accumulator type, float32 for 16-bit inputs.
  • Mixed-precision training keeps softmax in float32. PyTorch’s autocast lists softmax, log_softmax, cross_entropy and nll_loss among the “CUDA Ops that can autocast to float32”. Hugging Face’s Llama code does the same by hand in its plain attention path: softmax(attn_weights, dim=-1, dtype=torch.float32).to(query.dtype), computing the weights in float32 and converting back.
  • FlashAttention hands log-sum-exp back to you. The library’s interface can return softmax_lse, documented as “the logsumexp of each row of the matrix QK^T * scaling”, one float32 number per query row and head even when the inputs are 16-bit. That’s the LL from Step 4, saved for the backward pass and for merging splits.
  • OpenAI’s gpt-oss models add one learned score per attention head to the softmax. The model card says each head “has a learned bias in the denominator of the softmax”, which lets attention “pay no attention to any tokens”. Hugging Face’s implementation appends that score as an extra column, subtracts the row max (“prevents overflow in BF16/FP16”, says a comment), takes the softmax and drops the column, so a row’s weights can add up to less than 1. The model card compares the design to attention sinks, which Fundamentals Part 5 describes.
  • PaLM kept the log-sum-exp itself near 0. Its training (Chowdhery et al., 2022) added “an auxiliary loss of z_loss = 10^-4 · log^2 Z to encourage the softmax normalizer log(Z) to be close to 0”, which the authors found “increases the stability of training”. Here log⁡Z\log Z is the LSE over PaLM’s 256k-token vocabulary. Shift invariance makes this free: moving every logit up or down changes no probability.

Common misconceptions

  • “Subtracting the max is an approximation.” It’s exact. Adding or subtracting any constant multiplies the top and bottom of every fraction by the same ece^c, which cancels. The only differences are float rounding, as in the 0.15382974 versus 0.15383023 above.
  • “Bigger logits mean the model is more confident.” Only the gaps matter. Logits of [118, 117, 116] give exactly the same probabilities as [2, 1, 0]. Confidence shows in how far the top logit sits above the log-sum-exp, which is −ln⁡ptop-\ln p_{\text{top}}: 1.87 for the fridge prompt.
  • “Log-softmax is just the log of softmax.” In exact math, yes. In float32, a token that trails by 120 gets probability 0 from softmax and a log of minus infinity, while log-softmax returns -120 exactly. That’s why loss functions take raw logits.
  • “Softmax is a smooth version of max.” It’s a smooth version of argmax: it returns a list of weights pointing at the winner. A smooth version of max is log-sum-exp, whose gradient is softmax.

Key takeaways

  • Softmax raises ee to every score and divides by the total. The exponential is the one smooth choice where a fixed gap in scores means a fixed ratio of probabilities: a gap of 1 is a factor of 2.718.
  • Adding the same number to every score changes nothing, so you can subtract the max first. That stops exe^x from overflowing, which happens above 88.72 in float32 and above just 11.09 in float16.
  • Log-sum-exp lies between the max score and the max plus ln⁡n\ln n, and tends to the max as temperature goes to 0. Every log-probability is a logit minus the LSE, and computing it that way, fused with the loss, never takes the log of 0.
  • Online softmax keeps a running max and a running sum, rescaling the sum by emold−mnewe^{m_{\text{old}} - m_{\text{new}}} when the max grows. Add a running weighted sum of values and you have FlashAttention’s inner loop, exact up to rounding.

What’s next

The fused loss in Step 3, minus the log-probability of the right token, is the number every LLM is trained to push down. Part 6 gives it its proper names: surprise, entropy, cross-entropy and perplexity. It explains why a loss of 2 nats means the model is as unsure as if it were choosing among about 7 equally likely tokens, and why predicting text well is the same problem as compressing it.

Further reading

Diagram

Scroll to zoom · drag to pan · double-click to zoom in · Esc to closePinch to zoom · drag to pan · double-tap to zoom in · tap outside to close