Long context
Efficient attention and million-token windows.
Create a free account Try part of it here
Why it matters
In 2020, a model's context window held about 2,000 tokens, a few pages of text. Today's windows hold hundreds of thousands of tokens, and some hold a million or more. That's enough for a whole codebase, a stack of contracts or years of campaign reports in one prompt. It changed how AI apps are built. When you know what makes long context hard, you know what it costs and where models still struggle with it. You'll also know when retrieval (RAG) is still the better choice.
What you'll be able to do
- Explain why attention's cost grows with the square of the context length
- Implement tiled attention with a running softmax, the idea behind FlashAttention
- Describe sliding-window attention and how models are extended to longer contexts
- Judge when a long context is the right tool and how to lay out a long prompt
What it covers
- Everyone checks in with everyone
- Idea 1: never store all the scores (FlashAttention)
- Worked example: softmax in two blocks
- The same trick at full size
- Idea 2: don't look at everything (sliding windows)
- Idea 3: stretch the positions the model knows
- Long context or RAG?
Everyone checks in with everyone
Picture a meeting where each person must check in with every person in the room, themselves included. With 4 people, that's 4 × 4 = 16 check-ins. With 8 people, it's 64. Double the room, and the work goes up four times.
Attention works the same way. Each token sends out a query, and every token offers a key. In every layer, each head scores every query against every key. Let be the number of tokens in the context. Then each head, in each layer, makes this many scores:
In plain words: the number of scores is the token count multiplied by itself. You write it and say "T squared".
Try it with small numbers. 4 tokens give 16 scores. 1,000 tokens give 1,000,000 scores. Double that to 2,000 tokens and you get 4,000,000 scores. Ten times the tokens means a hundred times the scores.
Compare the KV cache from the last lesson. It keeps one key and one value per token. So twice the tokens means twice the cache. Growth in step like this is called linear. Growth like is called quadratic, and it's the core problem of long context.
The code below puts real sizes on it. Each score is stored as a 16-bit number, which takes 2 bytes. A GiB (a gibibyte) is 1,024 × 1,024 × 1,024 bytes, about 1.07 billion bytes. The last column is the whole KV cache of an 8-billion-parameter model, every layer and head included.
GiB = 1024 ** 3 print(f"{'tokens':>9} {'attention scores':>18} {'as 16-bit numbers':>17} {'KV cache (8B model)':>19}") for T in [4_000, 32_000, 128_000, 1_000_000]: scores = T * T # one head, one layer print(f"{T:>9,} {scores:>18,} {scores * 2 / GiB:>13,.2f} GiB {2 * 32 * 8 * 128 * T * 2 / GiB:>15,.1f} GiB")
Look at the 128,000-token row. The scores for one head in one layer would take about 30 GiB. That's about twice the whole model's KV cache. And a model has dozens of heads in each of dozens of layers. At a million tokens, one head's scores alone pass 1,800 GiB. Stored the obvious way, long context is impossible. Making it work took three ideas.
Go deeper Where the KV cache numbers come from
The last column uses the formula from the KV cache lesson. Multiply 2 (a key and a value) by 32 layers, 8 KV heads, 128 numbers per head, the token count and 2 bytes per number. Those are the sizes of Llama 3 8B. Every factor except the token count is fixed, so the cache grows linearly.
Ten times the tokens means a hundred times the scores. That's why FlashAttention avoids storing them, and sliding windows avoid computing them.
print(10 * 10, 20 * 20, 40 * 40)
Show the answer
100 400 1600
Each time the tokens double, the scores go up four times: 100, then 400, then 1,600. That's quadratic growth.
Idea 1: never store all the scores (FlashAttention)
Recall how softmax turns a row of scores into weights. It raises e (about 2.718) to the power of each score. Then it divides each result by the row's total. To stop the numbers from overflowing, it first subtracts the row's largest score. That's the max trick from the softmax lesson.
So softmax seems to need the whole row at once. You can't finish until you know the largest score and the total. It turns out you don't need the whole row.
Think of adding up a huge pile of receipts. You don't spread them all on the table at once. You go through one box at a time and keep a running total. Attention can work the same way. It reads the keys one block at a time. For each query, it keeps three running numbers:
- the largest score seen so far;
- the running total of the powers of e, the bottom line of the softmax;
- the running sum of values, each multiplied by its power of e. This is the top line.
There's one twist. The old totals were worked out against the old largest score. When a new block brings a larger score, you shrink the old totals by a correction factor first. Then you add the new block. This trick is called online softmax. It gives exactly the same answer as the ordinary softmax.
Go deeper Why the correction factor works
Call the old largest score . Call the new one . Each power of e already in the total was worked out as , where is that key's score. Then the total is updated like this:
In plain words: shrink the old total by the correction factor, then add the new block's powers of e. The running sum of values gets the same correction.
It works because powers of e multiply by adding their exponents. So . After the correction, every old power looks as if the new largest score had been known all along. The worked example below runs these exact steps with small numbers.
Worked example: softmax in two blocks
One query looks at four keys. Their scores are 1, 2, 3 and 1, and their values are 10, 20, 30 and 40. Read the keys in two blocks of two. Keep the running numbers, and check the answer against the ordinary softmax.
import math blocks = [([1.0, 2.0], [10.0, 20.0]), # (scores, values) for keys 0-1 ([3.0, 1.0], [30.0, 40.0])] # (scores, values) for keys 2-3 m, total, acc = float("-inf"), 0.0, 0.0 for scores, values in blocks: m_new = max(m, max(scores)) rescale = math.exp(m - m_new) powers = [math.exp(s - m_new) for s in scores] total = total * rescale + sum(powers) acc = acc * rescale + sum(p * v for p, v in zip(powers, values)) m = m_new print(f"max {m}, rescale {rescale:.3f}, total {total:.3f}, weighted sum {acc:.2f}") print("tiled answer:", round(acc / total, 2)) scores, values = [1.0, 2.0, 3.0, 1.0], [10.0, 20.0, 30.0, 40.0] powers = [math.exp(s) for s in scores] print("full answer: ", round(sum(p * v for p, v in zip(powers, values)) / sum(powers), 2))
Block 1. The largest score is 2. The powers are e to the −1 and e to the 0, so 0.368 and 1. Their total is 1.368. The weighted sum is 0.368 × 10 + 1 × 20 = 23.68. There's nothing to correct yet, so the rescale is 0.
Block 2. A larger score, 3, turns up. The correction factor is e to the (2 − 3), which is 0.368. The old total shrinks to 1.368 × 0.368 = 0.503. The new powers are 1 and 0.135, so the total becomes 1.639. The weighted sum becomes 23.68 × 0.368 + 30 + 5.41 = 44.12.
Answer. Divide the weighted sum by the total: 44.12 ÷ 1.639 is about 26.9. The code keeps more digits and prints 26.93. The ordinary softmax, with all four scores at once, gives 26.93 too. The blocks never needed all four scores at the same time.
The same trick at full size
The code below compares the two ways for 1,000 tokens. Q, K and V are random queries, keys and values with 32 numbers each. attention_full builds all the scores at once. attention_tiled reads the keys 100 at a time and keeps the three running numbers. In the code they're called m, total and acc.
import numpy as np rng = np.random.default_rng(0) T, d, block = 1000, 32, 100 Q, K, V = (rng.normal(size=(T, d)) for _ in range(3)) def attention_full(Q, K, V): S = Q @ K.T / np.sqrt(d) # all T × T scores at once W = np.exp(S - S.max(axis=1, keepdims=True)) return (W / W.sum(axis=1, keepdims=True)) @ V def attention_tiled(Q, K, V, block): m = np.full((len(Q), 1), -np.inf) # running max score for each query total = np.zeros((len(Q), 1)) # running sum of exp(score - m) acc = np.zeros((len(Q), V.shape[1])) # running weighted sum of values for start in range(0, len(K), block): S = Q @ K[start:start + block].T / np.sqrt(d) # only T × block scores m_new = np.maximum(m, S.max(axis=1, keepdims=True)) rescale = np.exp(m - m_new) # correct the old sums to the new max P = np.exp(S - m_new) total = total * rescale + P.sum(axis=1, keepdims=True) acc = acc * rescale + P @ V[start:start + block] m = m_new return acc / total full, tiled = attention_full(Q, K, V), attention_tiled(Q, K, V, block) print("largest difference:", float(np.abs(full - tiled).max())) print(f"scores held at once: {T * T:,} (full) vs {T * block:,} (tiled)")
The largest difference is a tiny number, below 0.000000000000001. That's rounding noise from the computer's arithmetic, not a real difference. The answer is the same attention. The tiled version never held more than 100,000 scores at once, a tenth of the full million.
FlashAttention (Dao et al., 2022) does this on the GPU. A GPU has a small, very fast memory on the chip and a large, slower main memory. FlashAttention splits the queries into tiles too, so each tile is worked out in the fast memory. The full grid of scores never touches the slow memory.
The result is exact and faster, and the memory it needs grows linearly with . The amount of arithmetic is still quadratic, though. A prompt 10 times longer still takes roughly 100 times the attention work to prefill. (Prefill is the first pass that reads the whole prompt.)
Show the answer
How it's computed: in tiles with a running softmax, so the full grid of scores is never stored. The result is the same.
It's an exact way to compute attention that saves memory. The amount of arithmetic is still quadratic.
Idea 2: don't look at everything (sliding windows)
Think of a long line of people passing news along. Each person only talks to the few people right next to them. Yet the news still travels down the whole line, a few places per round.
Sliding-window attention limits which keys each query sees. Each token looks only at itself and the tokens right before it, up to tokens in all. Here is the window size. The first picture below shows 8 tokens with a window of 3. Each row is a query and each column is a key. A ■ means "may look", and a · means "blocked".
import numpy as np def sliding_window_mask(T, window): i, j = np.indices((T, T)) return (j <= i) & (i - j < window) for row in sliding_window_mask(8, 3): print(" ".join("■" if allowed else "·" for allowed in row)) for layers in [1, 2, 4, 32]: print(f"{layers:>2} layer{'s' if layers > 1 else ' '} of window 4,096: information can travel {layers * 4095:,} tokens back")
Each token now makes at most scores, not . So the cost grows linearly with the length of the text.
Information still travels further than one window. Each layer reads what the layer before it already gathered. So news moves back one window per layer. A window of tokens includes the token itself, so one layer reaches tokens back:
In plain words: each layer lets information hop back one window, minus one for the token itself. Multiply by the number of layers.
Take a window of 3 and 2 layers. In layer 1, token 7 sees tokens 5 and 6, and token 5 sees tokens 3 and 4. In layer 2, token 7 reads what token 5 gathered, so it reaches token 3. That's 2 × (3 − 1) = 4 tokens back. The code above does the same sum for a window of 4,096.
Mistral 7B used a 4,096-token window in 32 layers. In theory that reaches 131,040 tokens, about 131,000. In practice, information that hops through many layers fades, like a message in a game of telephone. So many recent models mix the two kinds: some layers with sliding windows, some with full attention.
Researchers have also built models that avoid quadratic attention entirely. One family is state-space models, and Mamba is the best known. Several recent models are hybrids of these layers and attention.
Show the answer
Keys 3, 4 and 5
Row 5 reads · · · ■ ■ ■ · ·. It sees itself and the two tokens before it, never a later one.
Idea 3: stretch the positions the model knows
From the positional encoding lesson, you know most models use rotary position embeddings (RoPE). They rotate each query and key by an angle that depends on its position. A model trained on 8,000-token documents has never seen position 100,000. It's like a ruler that only has marks up to 8,000.
Labs fix this by stretching the rotation frequencies. Long distances then land in the range the model already knows. The simplest version is called position interpolation. Say you want 32,000 tokens from a model trained on 8,000. Multiply every position by 8,000 ÷ 32,000, which is 0.25. Position 32,000 then looks like position 8,000. Refinements such as YaRN stretch some frequencies more than others.
After the stretch, the model trains briefly on long documents. Most long-context models are pretrained at a moderate length and extended this way at the end.
Long context in practice
A long window doesn't mean the model uses all of it equally well.
- Lost in the middle. A 2023 study (Liu et al.) tested facts placed at different points in a long prompt. Models used facts near the start and the end better than facts in the middle. Newer models do much better, but position still matters.
- Needles are easy; reasoning is hard. Finding one planted fact in a long document is now routine. This test is called "needle in a haystack". Tasks that combine many facts spread across the context are harder. Benchmarks such as NVIDIA's RULER (2024) found that many models' effective context was shorter than advertised.
- You pay per token. A 500,000-token prompt costs 500,000 input tokens every time, and it takes longer to prefill. Prompt caching helps if the start of the prompt repeats.
Show the answer
Models use facts near the start and end of a long prompt better than those in the middle
A fact being in the window doesn't mean it will be used. That's worth testing with your own documents.
Long context or RAG?
RAG (retrieval-augmented generation) looks up the passages that matter and puts only those in the prompt. So which should you use?
Put everything in the prompt when the material fits in the window. It also suits work that uses the material as a whole, such as "review this contract" or "compare these ten reports". And it suits material that changes with each request.
Retrieve when the collection is much bigger than the window. Retrieve when only a few passages matter for each question. Retrieve when cost and speed matter because you send many requests. Many systems combine both. They retrieve generously, then let a long context hold everything they found.
For marketers: when you paste long material into a prompt, put the documents first. Put your question and instructions at the end. Ask the model to quote the passages it relied on. That layout tends to improve answers on long inputs, and the quotes make answers easy to check.
Show the answer
Comparing ten quarterly reports that together fit in the window
The reports fit, and the task uses them as a whole. The help center is far bigger than the window, and the high-volume questions each need only one passage, so retrieval fits those better. Retrieval isn't always better, though: it suits some jobs and not others.
Practice what you just read
- 4 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
Mistral 7B uses a 4,096-token sliding window. How can information from 20,000 tokens back still influence a prediction?