MiniGPT (Part 7)

Neil Haddley • October 5, 2026

Reading a longer row in the same memory: why attention's cost grows with the square of the row, how a sliding window caps it, why writing the window as a mask saves nothing, and a secret-word test of what a long row is for, and of how far a window really reaches, with a follow-along notebook

AIsliding-window-attentionlong-contextmlxattentionmachine-learning

Part 1 warned that a longer row of positions is expensive, mostly because of attention. This post measures exactly how expensive, on the modern machine from Part 5, and then fixes it with the trick Mistral's models use: let each hidden state look back only over a fixed window of recent positions.

The code is in part7-sliding-window/, with a follow-along notebook for a Mac, minigpt_follow_along_7.ipynb.

This post's machine
What changeda longer row, read through a sliding window
TextTinyStories
Piecesmy 8,192
Blocks6 of Llama's design, from Part 5
Vector size384
Positions1,024, each hidden state looking back at most 256; no position embeddings
EngineMLX
Size12.6 million numbers
Score0.6727 bits per byte

The big picture, in plain English

Why a longer row costs so much

In attention, every hidden state matches its query against the key of every earlier position, and itself. So the number of matches grows much faster than the row.

The follow-along notebook in Jupyter on my Mac, counting the matches for one head. Full attention's count quadruples with each doubling of the row; the window's only doubles

The follow-along notebook in Jupyter on my Mac, counting the matches for one head. Full attention's count quadruples with each doubling of the row; the window's only doubles

Every one of those matches is a number the machine has to hold while it trains, in every head of every block. Double the row and you need about four times the memory for attention. On a machine with one fixed pool of memory, that is the wall you hit first.

A sliding window

The fix is to let each hidden state look back only over a fixed window, here the last 256 positions, however long the row is. Then each hidden state makes at most 256 matches, and the total grows only in step with the row: twice the row, twice the matches.

In principle, through the blocks. In block 1, a hidden state gathers information from up to 256 positions back. In block 2, it looks at hidden states that have already gathered from their own windows, so it could reach up to about 512 back, and so on. With 6 blocks, information could travel about 1,500 positions, one window per block, much as Part 1's four blocks let each hidden state reach further back than the one before. But could is not does: the secret-word test below found that my machine never learned to do it.

Writing the window as a mask saves nothing

The obvious way to build the window is the way the earlier-positions rule is built: a grid of "allowed" and "not allowed", here also blocking anything more than 256 positions back. It gives exactly the right behaviour. But it saves no memory at all: the machine still works out every match in the full row-by-row grid, and only then throws most of them away.

To actually save memory, the machine must never build the full grid. So the row is cut into chunks of 256 positions, and each chunk only looks at itself and the chunk before it. That covers every hidden state's window, and the biggest grid ever built is 256 by 512, however long the row.

The same window, built two ways, for a row of 16 positions. The mask builds the whole grid and throws most of it away; the chunks never build the squares that would be thrown awayScroll sideways, or tap the diagram to open it full size

The same window, built two ways, for a row of 16 positions. The mask builds the whole grid and throws most of it away; the chunks never build the squares that would be thrown away

The notebook checked that the chunks give the right answers: against full attention when the window covers the whole row, and against the mask when it does not. Both differences are rounding

The notebook checked that the chunks give the right answers: against full attention when the window covers the whole row, and against the mask when it does not. Both differences are rounding

The memory race

mem_sweep.py trains the 12.6-million-number modern machine for a few steps at each row length, three ways, and records the most memory each used:

Row lengthFull attentionWindow, as a maskWindow, in chunks
5122.55 GB2.60 GB2.67 GB
2,04811.5 GB11.5 GB7.7 GB
4,09635.5 GB35.4 GB15.1 GB
8,192not run: about 120 GBnot run30.0 GB
16,384not run: about 450 GBnot run59.4 GB
Most memory used, against row length. Full attention and the masked window are the same line; the chunked window climbs much more gently

Most memory used, against row length. Full attention and the masked window are the same line; the chunked window climbs much more gently

The notebook's shorter race, up to 2,048 positions. Already at 2,048, the chunks use 7.2 GB against full attention's 11.2 GB

The notebook's shorter race, up to 2,048 positions. Already at 2,048, the chunks use 7.2 GB against full attention's 11.2 GB

  • The mask column matches full attention at every length, exactly as the last section predicted.
  • At short rows, everything is close. Most of the memory then goes on the MLPs and on scoring against 8,192 rows of lm_head, and both of those grow only in step with the row. Attention takes over at about 2,048 positions.
  • At 4,096, full attention needs 35.5 GB, more than half the Mac, and 1.9 seconds a step. The chunked window needs 15.1 GB and 0.8 seconds. At 8,192, full attention would need more memory than the Mac has. The chunked window reaches 16,384 positions in 59.4 GB.
Does the windowed machine still learn?

A window is only useful if the machine still works. I trained the modern machine with a row of 1,024 positions, four times the length used in Parts 3 to 6, once with full attention and once with a 256-position window, for 1,500 steps each:

AttentionBits per byteMinutesMost memory used
full0.68059.88.39 GB
256-position window0.67279.07.66 GB
Bits per byte at a 1,024-position row. The windowed machine is not behind

Bits per byte at a 1,024-position row. The windowed machine is not behind

The windowed machine came out slightly ahead, but only by 0.008, about the size of the luck between two random starts (see Part 5), so the two are level. It also trained faster and in less memory. And its 0.6727 is inside the band of Part 5's 256-position machine (0.672 to 0.678, over three random starts): on these stories, a longer row did not help at all, because most stories are only a few hundred tokens long, so there is nothing further back worth seeing. TinyStories is the wrong text to show what a long row is for, so the next section builds a test that is not. The point here is the memory: on these stories, the windowed machine learns just as well, while its attention cost stays flat as the row grows.

The windowed machine, with a 1,024-position row, continuing "Once upon a time"

The windowed machine, with a 1,024-position row, continuing "Once upon a time"

A bird named Bob, a fish that helps him, and "they became good friends": the same shape of story as every machine in this series. The window cost it nothing you can see.

What is a long row for? The secret word

TinyStories cannot show what a long row is for, so I built a test that can. Each test row hides a secret near its start, such as " Lily's secret word is apple.", then carries on with ordinary stories, and ends by asking for it again: " Lily's secret word is". There are 8 names and 24 words, so a machine that cannot see the secret can only guess, right 1 time in 24. Half of each training batch was rows like these, with the secret anywhere from 20 to 1,000 pieces back; the other half was ordinary stories. I trained three machines on exactly this mix, 1,500 steps each, and then asked each one for the word from five distances, 400 times at each:

Machine100 back200 back400 back700 back970 back
256-position row100%100%4.2%4.2%4.2%
1,024-position row, full attention100%100%100%100%100%
1,024-position row, 256-position window100%100%3.8%3.8%3.8%
the same window machine, trained 4 times longer100%100%4.5%4.5%4.5%
The same results as a picture. Inside 256 pieces, all three machines find the word every time; beyond it, only full attention doesScroll sideways, or tap the diagram to open it full size

The same results as a picture. Inside 256 pieces, all three machines find the word every time; beyond it, only full attention does

The window machine's own run: it learned the trick close up, and guessed beyond its window

The window machine's own run: it learned the trick close up, and guessed beyond its window

  • All three learned the trick. Whenever the secret was inside what they could see, all three got it right every time.
  • The 256-position row cannot see past its row. Beyond 256 pieces, the secret has fallen off the front, and it can only guess.
  • Full attention can. With 1,024 positions, it found the word 970 pieces back every time. This is what a long row is for.
  • The window did not reach past its window. It behaved exactly like the 256-position row. Through six blocks, the word could have hopped 256 positions at a time to the end, but in 1,500 steps of training the machine never learned to pass it along. Training it four times longer, 6,000 steps, only made it surer of the word close up (96% of the chance on the right word, up from 89%); beyond its window, it still guessed.

My reading of why: to pass the word along, some hidden state in the middle of an unrelated story would have to pick the secret up in one block and hold it for a later block to collect, and no part of that chain is rewarded until the whole chain works. Whatever the reason, the lesson is the same: the reach that the blocks allow in principle is not reach the machine has learned. It is one reason why many models that use windows, such as Google's Gemma 2, also keep some blocks with full attention.

The jargon decoder

The terms for the whole series are collected in one table, the series glossary.

What I called itWhat the experts call it
the length of the rowthe context length, or sequence length
looking back only a fixed number of positionssliding-window attention
the grid of allowed matchesthe attention mask
growing with the square of the rowquadratic, or O(T²), cost
growing in step with the rowlinear, or O(T × W), cost
how far information could travel through the blocksthe receptive field
some blocks with a window, and some with full attentioninterleaved, or local and global, attention

The code, in the order it runs

The windowed attention lives in Part 5's model_llama.py, behind a window setting, and part7-sliding-window/ measures it.

The mask that saves nothing: sliding_window_mask
PYTHON
1i = mx.arange(T)[:, None]
2j = mx.arange(T)[None, :]
3keep = (j <= i) & (i - j < w)          # earlier positions only, and at most w back
4return mx.where(keep, mx.array(0.0, dtype), mx.array(-mx.inf, dtype))

This builds the full T-by-T grid, and the attention call still works out every match before the mask removes most of them.

The chunks that do save: chunked_swa
PYTHON
1qc = q.reshape(B, H, nc, w, D)                                            # queries, in chunks of w
2kc = mx.stack([kpad[:, :, i * w:i * w + 2 * w] for i in range(nc)], axis=2)  # each chunk's keys, plus the chunk before
3vc = mx.stack([vpad[:, :, i * w:i * w + 2 * w] for i in range(nc)], axis=2)
4scores = (qc @ kc.transpose(0, 1, 2, 4, 3)) * scale                       # never bigger than w by 2w
5scores = mx.where(mask[None, None], scores, -mx.inf)
6out = mx.softmax(scores, axis=-1) @ vc

The biggest grid is [batch, heads, chunks, w, 2w], which grows in step with the row. With a window as long as the row, it gives exactly the same numbers as full attention.

Choosing between them: Attention in model_llama.py
PYTHON
1if self.window <= 0:
2    out = mx.fast.scaled_dot_product_attention(q, k, v, scale=self.scale, mask="causal")
3elif self.naive_window:
4    m = sliding_window_mask(T, self.window, x.dtype)
5    out = mx.fast.scaled_dot_product_attention(q, k, v, scale=self.scale, mask=m)
6else:
7    out = chunked_swa(q, k, v, self.window, self.scale)   # after sharing out the key and value heads
The secret word: secret_word.py

secret_word.py builds each secret row from Part 3's stories, mixes them half and half with ordinary stories, trains one machine, and then asks for the word from 100 to 970 pieces back. A 256-position machine sees only the last 256 pieces of each row:

PYTHON
1secret = tok.encode(f" {name}'s secret word is {word}.")
2question = tok.encode(f" {name}'s secret word is") + tok.encode(f" {word}")
3row = np.concatenate([ids[s:s + before], secret, ids[s + before:s + before + gap], question])

run_secret.sh trains the three machines: --block-size 256, --block-size 1024, and --block-size 1024 --window 256, and then the window machine again with --iters 6000.

The race: mem_sweep.py

For each row length from 512 to 16,384, and each of the three ways, mem_sweep.py builds the machine, trains it for 5 steps on random tokens, and records the most memory used and the time per step. It skips full attention and the mask past 4,096 positions, where they would run the Mac out of memory.

The sweep's own output, and the two training runs

The sweep's own output, and the two training runs

The series

Seven parts, from a character-level GPT in a borrowed notebook to a modern small model in MLX:

  1. Running it: a trained MiniGPT taken apart while it writes.
  2. Growing it: training the same machine from random numbers.
  3. Pieces, not letters: three tokenisers, scored fairly in bits per byte.
  4. A faster engine: the same machine in Apple's MLX.
  5. The modern block: Llama's four changes, one at a time. Only RoPE mattered.
  6. Learning from a teacher: distillation, and why the most helpful teacher was not the best storyteller.
  7. Reading further: sliding-window attention.

Every machine here is tiny, and none of them is good. That was the point. The tokeniser, the training loop, the engine, and the tricks, distillation, shared keys and values, and windowed attention, are all things you can build and run in an afternoon on one Mac. What separates them from the models I use every day is scale: more text, more numbers, and more computing, applied to substantially the same design.

The series glossary

Every idea in this series in plain words, next to the name the experts use, and the part whose jargon decoder it first appears in. Each term is explained in that part.

In plain wordsWhat the experts call itFirst in
the guessing gamenext-token prediction, or language modellingPart 1
a letter: any of the 65 symbols, even the space and the commaa characterPart 1
the thing being guessed: a letter here, a word or piece of a word in big modelsa tokenPart 1
the 65 lettersthe vocabularyPart 1
the chances for each possible next lettera probability distribution, produced by a softmaxPart 1
a letter's row of 128 learned numbersits token embedding (token_embedding): a vectorPart 1
a position's row of 128 learned numbersthe position embedding (position_embedding)Part 1
a token embedding plus its position embedding, before block 1the input embeddingPart 1
the number of positions: how many letters it can see at oncethe context length, or context window (block_size)Part 1
"only look at earlier positions"the causal maskPart 1
what attention matches and collectsthe query, the key, and the value vectorsPart 1
the three tables of weights that make themthe query, key, and value projections (self.query, self.key, self.value)Part 1
keeping the keys and values instead of remaking themthe KV cachePart 1
add, never replacethe residual connectionPart 1
the hidden states, as every block adds to themthe residual streamPart 1
a position's vector after a blocka hidden statePart 1
normalising a hidden state before attention and before the MLPlayer normalisation, or LayerNormPart 1
every fixed number that training setthe parameters, or weightsPart 1
spinning the wheel of chancessamplingPart 1
keeping the biggest k slicestop-k samplingPart 1
keeping the biggest slices until they add up to ptop-p, or nucleus, samplingPart 1
the 65 rows that score each letter that could come nextthe language-model head (lm_head), or output layerPart 1
the scores, before softmaxthe logitsPart 1
a request the model writes for a program to carry outa tool call, or function callPart 1
the program around the modelthe harnessPart 1
a harness letting the model act by itself for many stepsan agentPart 1
working out what the trained parameters meaninterpretabilityPart 1
learning from the text itself, with no people marking answersself-supervised learning, or pre-trainingPart 2
people writing example conversations for the machine to copysupervised fine-tuning (SFT)Part 2
people comparing answers, to train a judgethe reward modelPart 2
practising against the judgereinforcement learning from human feedback (RLHF), often using a method called PPOPart 2
letting another model do some of the comparingreinforcement learning from AI feedback (RLAIF)Part 2
letting an existing model write the examples, or teach its chancesdistillationPart 2
the surprise scorethe loss (cross-entropy loss)Part 2
"as unsure as choosing between N letters"perplexityPart 2
working out which way to turn every parameterbackpropagationPart 2
the 32 snippets for one stepa batch (batch size 32)Part 2
the slopes of all 826,433 parameters, togetherthe gradientPart 2
nudging every number a little in its directionan optimiser step (here, with AdamW)Part 2
how far each nudge goesthe learning ratePart 2
AdamW's running average of recent slopesmomentumPart 2
shrinking every number very slightly on every stepweight decayPart 2
the locked-away exam textthe validation setPart 2
memorising the textbookoverfittingPart 2
keeping the best copycheckpoint selectionPart 2
walking downhill on the surprise-score landscapegradient descentPart 2
the program that cuts text into piecesthe tokeniserPart 3
a piece's row of learned numbers, a word or part of a word as well as a letterits token embeddingPart 3
gluing the most common paira BPE mergePart 3
using the token embeddings as the rows of lm_head tooweight tyingPart 3
halvings of surprise for each byte of textbits per bytePart 3
surprise per tokenthe loss, or cross-entropyPart 3
the enginethe frameworkPart 4
one shared pool of memoryunified memoryPart 4
writing calculations down and running them laterlazy evaluationPart 4
packing a whole step into one jobcompiling, with mx.compilePart 4
capping the slopes if they are unusually biggradient clippingPart 4
copying a batch to the GPUa host-to-device transferPart 4
the simpler normaliseRMSNorm (root mean square normalisation)Part 5
the 2017 normaliseLayerNormPart 5
turning queries and keys by positionrotary position embeddings, or RoPEPart 5
the MLP with a gateSwiGLUPart 5
sharing keys and values between headsgrouped-query attention, or GQAPart 5
a query, key, and value for every headmulti-head attention, or MHAPart 5
turning one change off at a timean ablation studyPart 5
copying a teacher's whole wheellogit distillation, or knowledge distillationPart 6
learning only from the right answertraining on a one-hot target, or hard labelsPart 6
the teacher's wheelsoft targetsPart 6
how different two wheels arethe KL divergence (Kullback–Leibler divergence)Part 6
the raw scores against the rows of lm_headthe logitsPart 6
the softening settingthe distillation temperaturePart 6
a stronger teacher whose wheels the student cannot reachthe capacity gapPart 6
the length of the rowthe context length, or sequence lengthPart 7
looking back only a fixed number of positionssliding-window attentionPart 7
the grid of allowed matchesthe attention maskPart 7
growing with the square of the rowquadratic, or O(T²), costPart 7
growing in step with the rowlinear, or O(T × W), costPart 7
how far information could travel through the blocksthe receptive fieldPart 7
some blocks with a window, and some with full attentioninterleaved, or local and global, attentionPart 7

Try it yourself

  • The follow-along notebook: part7-sliding-window/minigpt_follow_along_7.ipynb, for Jupyter on a Mac with Apple Silicon. It counts the matches, checks that the chunked window gives full attention's numbers when the window covers the whole row, runs a short memory race, and shows the secret-word results. It is saved with the outputs from my own run, so you can read every result on GitHub without a Mac.
The notebook open in Jupyter on my Mac, with the outputs saved from my run

The notebook open in Jupyter on my Mac, with the outputs saved from my run

  • On the command line, after Part 3's prepare_data.py and tokenizers_setup.py:
BASH
1cd minigpt-series/part7-sliding-window
2python mem_sweep.py
3python figures.py
4python ../part5-modern-block/train_llama.py --tag window1024 --block-size 1024 --window 256 --iters 1500
5python ../part5-modern-block/train_llama.py --tag full1024   --block-size 1024 --window 0   --iters 1500
6./run_secret.sh      # the secret-word test: three machines, then the window machine for longer; about 2 hours

MLX needs Apple Silicon.