FlashAttention Algorithm › Online Softmax Computation

Michael BrenndoerferUpdated June 29, 202513 min read
Language AI HandbookAll chapters
Part 17 of 67Efficient Attention
Chapter 124FlashAttention Algorithm › Online Softmax Computation
Reading section 4 of 12 · Online Softmax Computation · 13 minSection 4 of 12 · 13 min

Book contents

Section 4 of 12 · 13 min read

FlashAttention Algorithm › Online Softmax ComputationOne section · Sections in this chapter4 / 12 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

Tiling creates an elegant solution for memory but introduces a subtle mathematical challenge. Softmax requires global information: to normalize any single attention score, you must know the sum of exponentials across the entire row. How can we compute softmax incrementally when we only see one block of keys at a time?

The Challenge: Softmax Needs Global Information

Consider the standard softmax formula:

softmax(xi)=exi∑j=1nexj\text{softmax}(x_i) = \frac{e^{x_i}}{\sum_{j=1}^{n} e^{x_j}}

where:

  • xix_i: the ii-th attention score in a row (the score we want to normalize)
  • exie^{x_i}: the exponential of that score. This keeps a positive value
  • ∑j=1nexj\sum_{j=1}^{n} e^{x_j}: the sum of exponentials over all nn scores in the row, serving as the normalizing constant
  • nn: the total number of positions in the sequence

The denominator ∑j=1nexj\sum_{j=1}^{n} e^{x_j} presents our problem: we need to sum over all positions, but we're processing keys in blocks. After seeing only the first block, we don't know what scores the remaining blocks will contribute. How can we produce a valid softmax output without waiting until all blocks are processed?

The answer lies in a clever reformulation: rather than computing softmax in one pass, we maintain running statistics that allow us to incrementally update our computation as new blocks arrive. This technique, called online softmax, is the mathematical heart of FlashAttention.

Building Intuition: What Statistics Do We Need?

Before diving into formulas, let's think about what information we need to maintain across blocks. Softmax has two key properties we must preserve:

  1. Numerical stability: Exponentiating large values causes overflow. The standard trick is to subtract the maximum value before exponentiating: exi−max⁡(x)e^{x_i - \max(x)} instead of exie^{x_i}.

  2. Normalization: The outputs must sum to 1, which requires knowing the total sum of exponentials.

This suggests we need to track two running statistics:

  • The maximum score seen so far (for numerical stability)
  • The running sum of exponentials (for normalization)

But here's the subtlety: when we see a new block with a larger maximum, we need to retroactively adjust our previous contributions. The exponentials we computed earlier were relative to the old maximum, and now they need to be relative to the new maximum.

The Online Softmax Algorithm

After processing block jj, we maintain two statistics:

  • m(j)m^{(j)}: the maximum value seen so far (over all blocks 1 through jj)
  • L(j)L^{(j)}: the sum of exponentials ∑i=1jexi−m(j)\sum_{i=1}^{j} e^{x_i - m^{(j)}}, where each exponential is computed relative to the current maximum

When block j+1j+1 arrives with new attention scores {xi}\{x_i\}, we update both statistics in sequence. First, we update the running maximum:

m(j+1)=max⁡(m(j),max⁡i(xi))m^{(j+1)} = \max(m^{(j)}, \max_i(x_i))

where:

  • m(j+1)m^{(j+1)}: the updated maximum after seeing block j+1j+1
  • m(j)m^{(j)}: the previous maximum (from blocks 1 through jj)
  • max⁡i(xi)\max_i(x_i): the largest score in the new block

This simply takes the larger of the old maximum and the new block's maximum. The maximum can only increase or stay the same, never decrease.

The running sum L(j)L^{(j)} was computed relative to m(j)m^{(j)}, but we need all exponentials relative to m(j+1)m^{(j+1)}. We must rescale the previous sum:

L(j+1)=L(j)⋅em(j)−m(j+1)+∑iexi−m(j+1)L^{(j+1)} = L^{(j)} \cdot e^{m^{(j)} - m^{(j+1)}} + \sum_i e^{x_i - m^{(j+1)}}

where:

  • L(j+1)L^{(j+1)}: the updated sum of exponentials after block j+1j+1
  • L(j)L^{(j)}: the previous sum (computed with respect to m(j)m^{(j)})
  • em(j)−m(j+1)e^{m^{(j)} - m^{(j+1)}}: the rescaling factor that adjusts the old sum for the new maximum
  • ∑iexi−m(j+1)\sum_i e^{x_i - m^{(j+1)}}: the contribution from the new block, computed relative to the new maximum

Why the Rescaling Factor Works

The rescaling factor em(j)−m(j+1)e^{m^{(j)} - m^{(j+1)}} deserves careful attention. It's the key that makes online softmax mathematically exact.

Consider what happens in two cases:

Case 1: The new block has a larger maximum (m(j+1)>m(j)m^{(j+1)} > m^{(j)})

The exponent m(j)−m(j+1)m^{(j)} - m^{(j+1)} is negative, so the factor is less than 1. This correctly shrinks the previous contributions. Why? Because we originally computed exi−m(j)e^{x_i - m^{(j)}}, but now we need exi−m(j+1)e^{x_i - m^{(j+1)}}. By the properties of exponents:

exi−m(j+1)=exi−m(j)⋅em(j)−m(j+1)e^{x_i - m^{(j+1)}} = e^{x_i - m^{(j)}} \cdot e^{m^{(j)} - m^{(j+1)}}

Case 2: The new block doesn't change the maximum (m(j+1)=m(j)m^{(j+1)} = m^{(j)})

The exponent is zero, so the factor equals 1. The previous contributions remain unchanged, which is correct since no rescaling is needed.

This rescaling mechanism is what allows us to process blocks incrementally while guaranteeing the exact same result as computing softmax over all values at once.

Out[10]:
Visualization
Line plot showing rescaling factor decreasing as the difference between old and new maximum increases.
Effect of the rescaling factor as the running maximum increases. When a new block contains a larger maximum, the rescaling factor shrinks previous contributions (factor < 1). When the maximum stays the same, previous contributions are unchanged (factor = 1).

The plot shows how the rescaling factor em(j)−m(j+1)e^{m^{(j)} - m^{(j+1)}} behaves. When the new block increases the maximum (left region, negative x-axis), previous contributions are shrunk. When the maximum doesn't change (x = 0), the factor equals 1. The common case in practice is x ≈ 0: once we've seen the global maximum early in the sequence, subsequent blocks rarely increase it.

Let's verify this mathematically. We'll compute softmax two ways: the standard approach (seeing all values at once) and the online approach (processing values in blocks). If our reasoning is correct, the results should be identical.

In[11]:
Code
def online_softmax_demo(scores_blocks):
    """
    Demonstrate online softmax computation.

    Args:
        scores_blocks: List of arrays, each representing a block of scores

    Returns:
        Final softmax values (concatenated across all blocks)
    """
    all_scores = np.concatenate(scores_blocks)
    n = len(all_scores)

    # Standard softmax for comparison
    max_val = np.max(all_scores)
    exp_scores = np.exp(all_scores - max_val)
    standard_softmax = exp_scores / exp_scores.sum()

    # Online softmax
    m = -np.inf  # Running maximum
    ell = 0.0  # Running sum of exp(x - m)

    # Track per-block contributions for verification
    block_exp_contributions = []

    for block in scores_blocks:
        block_max = np.max(block)

        # Update running maximum
        m_new = max(m, block_max)

        # Rescale previous sum and add new block
        ell = ell * np.exp(m - m_new) + np.sum(np.exp(block - m_new))
        m = m_new

        # Store rescaled block exponentials
        block_exp_contributions.append(np.exp(block - m))

    # Final softmax values
    online_softmax = (
        np.concatenate([exp * np.exp(0) for exp in block_exp_contributions])
        / ell
    )

    # Recompute properly with final m
    all_exp = np.exp(all_scores - m)
    online_softmax_correct = all_exp / ell

    return {
        "standard": standard_softmax,
        "online": online_softmax_correct,
        "match": np.allclose(standard_softmax, online_softmax_correct),
    }


# Test with random scores split into blocks
full_scores = np.random.randn(16) * 2
blocks = [full_scores[i : i + 4] for i in range(0, 16, 4)]
result = online_softmax_demo(blocks)
Out[12]:
Console
Online Softmax Verification:
  Standard softmax sum: 1.000000
  Online softmax sum: 1.000000
  Results match: True

First 4 values comparison:
  Standard: [0.04238614 0.01190388 0.05732691 0.33011885]
  Online:   [0.04238614 0.01190388 0.05732691 0.33011885]

Both softmax sums equal 1.0, and the individual values match exactly. This confirms that online softmax is not an approximation. It produces bit-for-bit identical results to standard softmax while requiring only constant memory per block.

From Softmax to Attention: Extending to Weighted Sums

Online softmax handles the normalization challenge, but attention requires something more: we don't just compute softmax weights, we use those weights to compute a weighted sum of value vectors. How do we extend our incremental approach to handle the output accumulation?

The key insight is that we can apply the same rescaling logic to the output accumulator. Just as we rescale the running sum LL when the maximum changes, we also rescale the running output OO.

For each query position, we maintain three running statistics:

  • mim_i: the maximum attention score seen so far
  • LiL_i: the running sum of exponentials (for normalization)
  • oio_i: the running output (unnormalized weighted sum of values)

When processing a new key/value block, the algorithm performs these steps in sequence:

  1. Compute attention scores: Calculate sijs_{ij} for each query ii against each key jj in this block
  2. Update the maximum: Find mnew=max⁡(mold,max⁡j(sij))m_{\text{new}} = \max(m_{\text{old}}, \max_j(s_{ij})) for each query
  3. Rescale previous contributions: Multiply the output accumulator by emold−mnewe^{m_{\text{old}} - m_{\text{new}}}
  4. Add the current block's contribution: Compute ∑jesij−mnewvj\sum_j e^{s_{ij} - m_{\text{new}}} v_j and add it to the output
  5. Update the running sum: Apply the same rescaling to LiL_i and add the new block's exponentials

After processing all key/value blocks, the final output for query ii is simply oi/Lio_i / L_i, the unnormalized weighted sum divided by the normalization constant.

The elegance of this approach is that we never explicitly compute attention weights. Instead, we accumulate weighted outputs and divide at the end. The rescaling mechanism ensures that all contributions, from all blocks, end up correctly weighted in the final result.

Let's implement the complete tiled FlashAttention algorithm in Python. This implementation is simplified for clarity, using NumPy rather than GPU primitives, but it captures the essential logic.

In[13]:
Code
def tiled_flash_attention(Q, K, V, block_size):
    """
    Simplified FlashAttention implementation demonstrating the algorithm.

    Args:
        Q: Query matrix (n, d)
        K: Key matrix (n, d)
        V: Value matrix (n, d)
        block_size: Size of blocks for tiling

    Returns:
        Output matrix (n, d)
    """
    n, d = Q.shape
    num_blocks = (n + block_size - 1) // block_size

    # Initialize outputs
    O = np.zeros((n, d))
    m = np.full(n, -np.inf)  # Running max per query
    ell = np.zeros(n)  # Running sum per query

    scale = 1.0 / np.sqrt(d)

    # Iterate over query blocks
    for i in range(num_blocks):
        q_start = i * block_size
        q_end = min((i + 1) * block_size, n)
        Q_block = Q[q_start:q_end]

        # Initialize block outputs
        O_block = np.zeros((q_end - q_start, d))
        m_block = np.full(q_end - q_start, -np.inf)
        ell_block = np.zeros(q_end - q_start)

        # Iterate over key/value blocks
        for j in range(num_blocks):
            k_start = j * block_size
            k_end = min((j + 1) * block_size, n)
            K_block = K[k_start:k_end]
            V_block = V[k_start:k_end]

            # Compute attention scores for this block pair
            S_block = Q_block @ K_block.T * scale  # (Br, Bc)

            # Online softmax update
            m_block_new = np.maximum(m_block, S_block.max(axis=1))

            # Rescale previous accumulator
            scale_factor = np.exp(m_block - m_block_new)
            O_block = O_block * scale_factor[:, None]
            ell_block = ell_block * scale_factor

            # Add current block contribution
            P_block = np.exp(S_block - m_block_new[:, None])
            O_block = O_block + P_block @ V_block
            ell_block = ell_block + P_block.sum(axis=1)

            m_block = m_block_new

        # Store final normalized output for this query block
        O[q_start:q_end] = O_block / ell_block[:, None]

    return O

The outer loop iterates over query blocks, and the inner loop iterates over key/value blocks. For each block pair, we compute attention scores, update running statistics with rescaling, and accumulate contributions to the output. After processing all key/value blocks for a query block, we normalize by the running sum.

Now let's verify that this tiled implementation produces identical results to standard attention:

In[14]:
Code
# Verify correctness against standard attention
n, d = 32, 16
Q = np.random.randn(n, d)
K = np.random.randn(n, d)
V = np.random.randn(n, d)


def standard_attention(Q, K, V):
    """Standard attention for comparison."""
    scale = 1.0 / np.sqrt(Q.shape[1])
    scores = Q @ K.T * scale
    weights = np.exp(scores - scores.max(axis=1, keepdims=True))
    weights = weights / weights.sum(axis=1, keepdims=True)
    return weights @ V


standard_output = standard_attention(Q, K, V)
flash_output = tiled_flash_attention(Q, K, V, block_size=8)
Out[15]:
Console
Tiled FlashAttention Verification:
  Maximum difference from standard: 3.33e-16
  Outputs match: True

The maximum difference is on the order of 10−1510^{-15}, essentially machine precision for 64-bit floating point. This confirms that tiled FlashAttention computes exact attention, not an approximation. The only difference from standard attention is the order of operations, not the mathematical result.

Out[16]:
Visualization
Dual-axis line plot showing a nondecreasing running maximum and a max-relative log sum that rises between rescaling events and drops when the maximum changes.
Evolution of online-softmax statistics as blocks are processed. The running maximum (blue) is nondecreasing. The log of the max-relative running sum (amber) may drop when a new maximum forces rescaling, then both statistics end at their exact full-sequence values.

The visualization reveals how the online algorithm updates. The running maximum (blue) increases only when a new block contains a higher score. The max-relative log running sum (amber) usually rises as blocks add contributions, but it can drop when a new maximum forces all previous contributions onto a smaller scale. By the final block, both statistics equal the values from a single pass over all data.

Notice that most updates happen in the first few blocks. Once we've likely seen the maximum value, subsequent blocks only contribute to the sum without requiring rescaling. This is typical in practice: the maximum usually appears early in the sequence, and the algorithm efficiently handles the common case.