Hoofdstuk 14 · de motor van het taalmodel
De forward pass: hoe het model écht rekent
Twee hoofdstukken lang keken we naar stilstaande onderdelen: de gewichten in het bestand, de tokenizer die tekst in nummers knipt. Nu zetten we de motor aan. Wat gebeurt er precies tussen een token erin en een token eruit — en hoe bewijs je dat je het goed hebt nagebouwd?
In één alinea
Eén token gaat erin. 494 miljoen vermenigvuldigingen later komt er een kansverdeling uit over alle 151 936 mogelijke volgende tokens. Kies er één, stop die er weer in, en herhaal. Dat — en niets anders — is wat "tekst genereren" is.
En het is exact dezelfde bewerking als in Deel I: rooster maal vector, een knik erdoor, optellen. RekenNet deed het met 2 229 parameters en 21 hokjes; dit model doet het met 494 miljoen parameters en 151 936 hokjes. Alleen de schaal is anders — plus twee nieuwe ideeën die dit hoofdstuk uitlegt: aandacht en positie.
Deel 01Hetzelfde als RekenNet, alleen groter
Het belangrijkste wat je uit Deel I moet meenemen: er komt hier niets magisch bij. Zet de twee netwerken naast elkaar en je ziet één familie.
| RekenNet (hoofdstuk 5–8) | Qwen2.5-0.5B | |
|---|---|---|
| invoer | one-hot, 14 getallen | token-nummer → rij 896 getallen uit de woordentabel |
| kern | rooster × vector, plus bias | rooster × vector, plus bias — identiek |
| knik | ReLU | SiLU (een gladde ReLU) in een poortconstructie |
| uitvoer | softmax over 21 hokjes | softmax over 151 936 hokjes |
| parameters | 2.229 | 494.032.768 |
| leren | backpropagatie, duizenden epochs | al gebeurd — wij rekenen alleen nog voorwaarts |
| volgorde | — | RoPE: paren getallen draaien met de positie mee |
| terugkijken | — | aandacht over alle vorige tokens (met KV-cache) |
| diepte | 2 verborgen lagen | 24 identieke blokken na elkaar |
Dat laatste punt is het onthouden waard: een taalmodel is geen exotische machine, het is het netwerk uit Deel I, 24 keer gestapeld, met een terugkijkmechanisme erbij. Wie RekenNet heeft gevolgd, heeft het moeilijkste al gehad — backpropagatie. Hier hoeft dat niet meer: de gewichten zijn al geleerd, wij voeren alleen de voorwaartse doorgang uit. Vandaar de naam: forward pass.
Deel 02De reis van één token
Volg één token door het hele model, van nummer tot kansverdeling.
Stap voor stap
1. Opzoeken. Het token-nummer uit hoofdstuk 13 wordt een rij van 896 getallen — letterlijk rij zoveel uit de woordentabel die we in hoofdstuk 12 al zagen liggen. Geen berekening, gewoon opzoeken.
2. Vierentwintig keer hetzelfde blok. Elk blok doet twee dingen, telkens voorafgegaan door een normalisatie (RMSNorm — het "even bijschalen" dat je uit Deel I kent, hier zonder aftrekken van het gemiddelde). Eerst aandacht: het token kijkt terug naar alles wat eerder kwam (deel 3). Dan feed-forward: drie roosters die het token door een poort persen — down( silu(gate(x)) ⋅ up(x) ). Die poortconstructie heet SwiGLU; haar drie roosters (gate, up, down) zag je al in de laagtabel van hoofdstuk 12, en het is waar twee derde van alle gewichten zit. De silu is een gladde variant van de ReLU uit Activatiefuncties: x ⋅ sigmoïde(x).
3. Scores en kansen. Na blok 24 volgt nog één normalisatie en dan de eindprojectie: het inwendig product van de toestand met élke rij van de woordentabel — 151 936 scores, de logits. Softmax maakt er kansen van. Dezelfde formule als bij Optelnet; alleen het aantal hokjes is 7 235 keer zo groot.
4. Kiezen en herhalen. Wij kiezen voorlopig altijd het hoogste hokje ("greedy", temperatuur 0). Dat maakt elke run exact reproduceerbaar — en dat is geen beperking maar een gereedschap, zoals deel 6 laat zien.
Waarom een residu?
De plussen in het schema zijn belangrijker dan ze lijken. Elk blok berekent niet een níeuwe toestand maar een correctie op de bestaande. Daardoor kan informatie ongehinderd van blok 1 naar blok 24 stromen, en kan elk blok zich specialiseren in één klein aspect. Zonder die residuverbindingen zou een netwerk van 24 lagen diep nauwelijks te trainen zijn — het is hetzelfde medicijn tegen diepte-problemen als de He-initialisatie uit hoofdstuk 7, maar dan in de architectuur zelf.
Deel 03Aandacht: het terugkijkmechanisme
RekenNet zag één som tegelijk en had geen verleden. Een taalmodel moet bij het woord "haar" weten over wie het gaat — drie zinnen terug. Dat doet de aandacht, en het idee is verrassend huiselijk.
Elk token maakt uit zijn toestand drie dingen (drie matvecs: Q, K en V):
- een vraag (query) — "waar ben ik naar op zoek?";
- een etiket (key) — "hierop ben ik vindbaar";
- een inhoud (value) — "dit heb ik te bieden".
De vraag van het huidige token wordt vergeleken met de etiketten van álle voorgaande tokens — een inwendig product per paar, gedeeld door √64. Softmax maakt van die scores gewichten, en het resultaat is een gewogen mengsel van alle inhouden. Sterk lijkende etiketten wegen zwaar; de rest verdwijnt in de ruis. Meer is aandacht niet: opzoeken met zachte randen.
Veertien vragen, twee geheugens
Het model splitst zijn 896 getallen in 14 koppen van elk 64: veertien parallelle aandachtsmechanismen die elk hun eigen vraag stellen — de ene kop blijkt na training op zinsbouw te letten, de andere op verwijswoorden. Maar etiketten en inhouden zijn er maar voor 2 koppen: zeven vraagkoppen delen telkens één K/V-paar. Dat heet grouped-query attention (GQA), en het bestaat om een prozaïsche reden: het maakt de KV-cache zeven keer kleiner. Je zag de getallen al in hoofdstuk 12 — attn_q is 896 uitgangen breed, attn_k en attn_v maar 128.
Qwen2 heeft hier nog een eigenaardigheidje
De Q-, K- en V-roosters krijgen elk een bias — de vaste bijtelling uit Deel I. De meeste moderne architecturen hebben die afgeschaft; Qwen2 is een van de weinige die ze op precies deze drie plekken bewaart. Dat zijn 72 kleine F32-tensoren — drie per laag; in de laagtabel van hoofdstuk 12 staan ze als de rijen attn_q/k/v.bias. Wie ze vergeet, krijgt geen foutmelding — alleen een model dat nét naast de waarheid antwoordt.
Deel 04RoPE, en de stilste valkuil van het hele project
Aandacht heeft één blinde vlek: ze ziet geen volgorde. "De hond bijt de man" en "de man bijt de hond" leveren dezelfde etiketten op. Er moet dus positie-informatie in — en de manier waarop is even elegant als verraderlijk.
RoPE (rotary position embedding) draait binnen elke kop paren van getallen over een hoek die met de positie meegroeit — als 32 klokwijzertjes die elk in hun eigen tempo tikken: de snelste maakt bijna een slag per token, de traagste is na duizenden tokens nog nauwelijks bewogen. Het inwendig product tussen een vraag en een etiket gaat daardoor vanzelf van hun afstand afhangen: hoe verder uit elkaar, hoe meer de wijzertjes verdraaid staan. De rotatie wordt op Q en K toegepast, nooit op V.
De valkuil zit in de vraag welke twee getallen een paar vormen. Er bestaan twee conventies:
| Conventie | Paren | Wie |
|---|---|---|
| "normaal" (llama) | (0,1) (2,3) (4,5) … | llama-bestanden — het conversiescript herschíkt de gewichten hiervoor |
| NEOX / rotate-half | (0,32) (1,33) (2,34) … | qwen2 — de gewichten zijn de originele, onverschoven HF-gewichten |
Het plan-hoofdstuk waarschuwde hier al voor als "de duurste bug die je gaat maken". Bij het bouwen is de keuze daarom niet gegokt maar met drie onafhankelijke bewijzen uit de llama.cpp-broncode vastgelegd: de rope-tabel wijst qwen2 expliciet aan NEOX toe, de rekenkernel draait daar element i tegen element i+32, en het conversiescript voor qwen-modellen bevat — anders dan dat voor llama — géén herschikking van de gewichten. Onze implementatie weigert bovendien elk model dat geen qwen2 is: liever luid falen dan stil wartaal produceren.
Deel 05Rekenen op samengeperste gewichten
De gewichten liggen als Q8_0, Q5_0, Q4_K en Q6_K in het bestand — hoofdstuk 12 maakte ze open. Hoe rekent de forward pass daarmee?
Simpel: rij voor rij. Elke keer dat een matvec een rij van een rooster nodig heeft, worden de blokken van die ene rij uitgepakt naar gewone floats in een klein kladbuffertje — 34, 22, 144 of 210 bytes per blok, afhankelijk van het formaat — en meteen daarna gebeurt het inwendig product, in dubbele precisie opgeteld. De volledige tensor wordt nóóit uitgepakt. Daardoor gebruikt het draaiende model nauwelijks meer geheugen dan het bestand zelf (dat via mmap toch al gedeeld in het geheugen ligt), en is elke bewerking exact fp32: geen benaderingen bóvenop de kwantisatie.
Dit is met opzet de trage, doorzichtige versie: elke formule staat er zoals ze in de wiskunde staat, zonder SIMD-trucs. Zij is de referentie waar de snelle kernen van deel 8 tegen getoetst zijn — precies zoals de Python-versie van AddNet naast de Java-versie stond. En zelfs deze referentie haalt over een volgehouden generatie al zo'n vijf tokens per seconde — net onder voorleestempo, nog vóór er ook maar één vectorinstructie aan te pas komt. Die vectorinstructies komen in deel 8.
Deel 06Hoe je bewijst dat het klopt
Dit is de eigenlijke les van dit hoofdstuk. Een kapotte forward pass produceert geen foutmelding maar vloeiende, grammaticale onzin — het gevaarlijkste soort fout dat er bestaat. Hoe toets je zoiets?
Greedy als meetinstrument
Met temperatuur 0 kiest het model altijd het hoogste hokje. Geen toeval, dus exact herhaalbaar — en vergelijkbaar. llama.cpp draait via ollama hetzelfde modelbestand; als onze forward pass klopt, moeten beide kanten bij dezelfde prompt dezelfde reeks tokens kiezen. Eén verkeerde bit-shift in een dequantisatie, één verwisseld RoPE-paar, en de reeksen lopen binnen enkele tokens uiteen. Veertig identieke tokens op rij, over acht heel verschillende prompts, is daarmee een uiterst strenge toets.
Het resultaat: 8 van 8 prompts exact identiek over 40 tokens — Engels proza, Nederlands, Java-code, cijferreeksen, en een volledig gesprek met speciale tokens erdoorheen. Plus 36 bouwsteencontroles met handgemaakte blokken, waarvan elke byte zo gekozen is dat de uitkomst met pen en papier narekenbaar is.
Drie valkuilen die de vergelijking bijna bedierven
- Ollama strafte stiekem mee. Standaard past ollama een repeat penalty van 1,1 toe — óók bij temperatuur 0. Die vervormt de scores vóór de keuze, waardoor je tegen een aangepaste referentie vergelijkt. Expliciet uitzetten dus.
- Het "compatibele" eindpunt plakt er een sjabloon omheen. Ollama's OpenAI-achtige API bleek de prompt stilletjes in een gesprekssjabloon te wikkelen (een prompt van 5 tokens werd er 34). Alleen het eigen
/api/generatemetraw:truegeeft het model exact jouw tokens. - De valkuil uit hoofdstuk 13 — en ja, we trapten er zelf in. Hoofdstuk 13 waarschuwt dat je tokens nooit stuk voor stuk mag decoderen, omdat één token een halve tekencode kan bevatten. Precies die fout zat toch in onze eerste vergelijkingslus. De onafhankelijke nalezing ving hem, met een sierlijk bewijs: het teken 龘 splitst in dit model in twee tokens, en kwam er als twee vraagtekens uit. Sindsdien worden de tokens verzameld en aan het einde in één keer gedecodeerd.
De volgorde die werkt
Eerst de specificatie uit de bron halen (niet uit het geheugen), dan bouwen met controles die met de hand narekenbaar zijn, dan onafhankelijk laten nalezen, en pas dan het eindbewijs draaien tegen een referentie-implementatie op hetzelfde bestand. Elke stap ving fouten die de vorige niet kon zien. Voor code waarvan fouten stil zijn, is dit geen luxe maar de enige weg.
Deel 07Zelf draaien
De volledige broncode zit bij dit boek in de map code/, en hieronder als zip. Eén JDK (22 of nieuwer) is alles wat je nodig hebt; de vlag --add-modules jdk.incubator.vector hoort er sinds de snelle kernen standaard bij.
# een vraag stellen (het model antwoordt als assistent en stopt vanzelf)
java --add-modules jdk.incubator.vector src/Forward.java ~/.ollama/models/blobs/sha256-c5396e06af294bd101b30dce59131a76d2b773e76950acc870eda801d3ab0515 \
--chat --n 200 "What is the capital of France?"
# kale tekstvoortzetting, zonder gesprekssjabloon
java --add-modules jdk.incubator.vector src/Forward.java … --n 30 "Er was eens een draak die"
# alleen de kansverdeling bekijken: wat overweegt het model?
java --add-modules jdk.incubator.vector src/Forward.java … --n 0 --top 10 "1 + 1 ="
De uitvoer toont eerst de top-kandidaten met hun kansen — je ziet het model twijfelen of zeker zijn — en dan het greedy vervolg, met de snelheid erbij. Alles is deterministisch: dezelfde vraag geeft altijd hetzelfde antwoord.
Eerlijke verwachtingen — het Brussel-incident
Dit is een model van een half miljard parameters, en dat voel je. In het Engels antwoordt het keurig "The capital of France is Paris." Dezelfde vraag in het Nederlands kreeg, vol overtuiging: "De hoofdstad van Frankrijk is Brussel." Dat is geen fout in onze code — llama.cpp geeft byte voor byte hetzelfde antwoord, dat is nu juist bewezen. Het model is voor Nederlands gewoon te klein. Stel simpele vragen, liefst in het Engels, en zie elke uitglijder als wat ze is: een les over modelgrootte.
Deel 08Sneller: vier sommen tegelijk
De referentie is bewust traag. Maar het plan-hoofdstuk had al gemeten hoeveel erin zit: met de Vector API bewerkt één processorkern vier floats tegelijk. Tijd om dat te verzilveren.
De versnelling zit op precies één plek: het inwendig product van een gekwantiseerde rij met een vector — de bewerking die per token 169 keer langskomt en meer dan 90 % van de tijd opslokt. Voor elk van de vier formaten uit hoofdstuk 12 is er nu een eigen kern (Kernel.java) die réchtstreeks op de gepakte blokken rekent: de bytes worden met vectorbewerkingen opengevouwen en meteen tegen x vermenigvuldigd, zonder ooit een rij naar een tussenbuffer uit te pakken. (Het plan mat destijds 3,1× winst tegenover een scalaire lus op diezelfde blokken; dat het hier 5 à 6 keer wordt, komt doordat de referentie bovendien elke rij uitpakt — én door de drie trucs hieronder.) Drie keuzes maakten het verschil:
- Eén reductie per rij. Vier parallelle deelsommen samenvouwen tot één getal is de duurste vectorbewerking die er is. De eerste versie deed dat per blok van 32 gewichten — 28 keer per rij van 896. Door de blokschaal méé de vectorbaan in te vouwen hoeft het nog maar één keer, helemaal aan het einde van de rij. Dat alleen maakte de Q6_K-kern nog eens 2,4 keer sneller.
- Bloksommen in plaats van aftrekken. Q4_K en Q6_K trekken per groepje een minimum of vaste offset af. Maar Σ(q−c)·x = Σq·x − c·Σx: bereken de deelsommen van x één keer per matvec voor, en die aftrekterm is voor alle rijen samen bijna gratis.
- De vijfde bits via een shuffle. De 32 losse bits van Q5_0 worden niet bit voor bit uitgepakt: een shuffle smeert de vier bitbytes over de vectorlanes uit, en een masker test ze alle zestien tegelijk.
| fp32-referentie | snelle kernen | |
|---|---|---|
| doorvoer per rij (1 thread) | 0,6–1,1 GB/s | 3,1–7,1 GB/s |
| de hele generatielus | 5,0 tokens/s | 35,9 tokens/s |
| greedy vs llama.cpp, 8 prompts × 40 tokens | 8 van 8 | 8 van 8 |
De les die de determinismetest verdiende
De eerste kernversie faalde prompt op de eis “twee doorlopen geven exact dezelfde logits”. De oorzaak zat diep: de standaardbewerking die vier vectorlanes tot één getal optelt mag dat per compilatielaag anders groeperen — de interpreter telt na elkaar, de JIT-compiler paarsgewijs — waardoor de állereerste doorloop een fractie afweek van alle latere. De reparatie: de vier lanes expliciet in vaste volgorde optellen. Sindsdien is elke run bit-identiek, op welke compilatielaag dan ook. Precies hiervoor bestond die test.
De referentie blijft intussen volledig bestaan en is met één vlag terug te roepen (-Dqllm.kernels=uit) — zoals in Deel I de Python-versie naast de Java-versie stond: de trage waarheid bewaakt de snelle. En het plan-hoofdstuk schatte destijds “realistisch ~45 tokens per seconde” voor dit model; met alleen de matvec gevectoriseerd staat de teller op 36.
Deel 09Wat er nog niet is
- Sampling, het echte sjabloon en streaming — die zijn er inmiddels wél: dat is hoofdstuk 15, waar dit alles samenkomt in een interactieve chat.
- Lange-context-varianten. Modellen met "rope-scaling" (128k context en meer) worden bewust geweigerd in plaats van stil verkeerd doorgerekend.