Van neuron tot taalmodel

Deel II · het plan

Een klein taalmodel lokaal draaien in pure Java

Kan je zonder Maven, zonder Gradle en zonder één externe bibliotheek een open-source LLM op je eigen CPU laten draaien — en er onderweg begrijpen hoe zo'n ding werkt? Deze studie beantwoordt dat met gemeten cijfers van een concrete machine.

Kort antwoord

Ja — en niet met hangen en wurgen. Alles wat je nodig hebt zit sinds JDK 22 in de standaard-JDK zelf: geheugenmapping voorbij 2 GB, SIMD-instructies, fp16-conversie en een startprogramma dat rechtstreeks broncode draait.

Op jouw Air heb ik de rekenkern die zo'n model 90 % van de tijd uitvoert nagebouwd en gemeten. Een model van 1 miljard parameters haalt daar een plafond van ongeveer 35 tokens per seconde. Realistisch, met alle overige bewerkingen erbij, houd je 15 à 25 tokens/s over — sneller dan je leest.

0
externe bibliotheken nodig
3,1×
winst van de Vector API t.o.v. scalaire Java
37 G/s
gewichten per seconde, 8 threads
~1,3 GB
geheugen voor een 1B-model met 4k context

Sectie 01Waarom dit uitgerekend nu kan

Vijf jaar geleden was dit antwoord "technisch ja, praktisch nee". Vier dingen in de moderne JDK hebben dat omgedraaid — en drie ervan zijn nog geen vier jaar oud.

Geheugenmapping voorbij de 2 GB-grens

De klassieke weg, MappedByteBuffer, loopt vast op Integer.MAX_VALUE: 2 GB. Modelbestanden zijn groter. De Foreign Function & Memory API (definitief sinds JDK 22) geeft je MemorySegment met 64-bits adressering. Je mapt het volledige modelbestand één keer, buiten de heap, en laat het besturingssysteem de pagina's beheren.

Dat is niet alleen een limiet die wegvalt. Het verandert het geheugenprofiel van je toepassing volledig: de gewichten — verreweg het grootste stuk — belasten je Java-heap nooit, en de garbage collector heeft er geen werk aan.

SIMD zonder C te schrijven

De Vector API (in JDK 25 als JEP 508, de tiende incubatieronde) laat je expliciet vectorinstructies uitdrukken: NEON op Apple Silicon, AVX2 of AVX-512 op x86. Ik heb dat gemeten, niet aangenomen: 3,1× sneller dan dezelfde lus scalair geschreven. Zonder die API is pure Java te traag om leuk te zijn.

Let op — incubatiestatus

De Vector API incubeert al tien rondes en wacht op Project Valhalla; ondertussen staat ze in JDK 27 voor een twaalfde ronde gepland. Praktisch betekent dat: je hebt --add-modules jdk.incubator.vector nodig, je krijgt een waarschuwing bij het starten, en het pakket kan tussen JDK-versies wijzigen. Voor een studieproject is dat prima. Isoleer de vectorcode wel achter een handvol methodes, zodat je bij een wijziging op één plek repareert.

Broncode draaien zonder bouwstap

Sinds JDK 22 compileert en start java Main.java ook alle andere .java-bestanden ernaast, in het geheugen. Geen pom.xml, geen javac-stap, geen target/-map. Je bewerkt een bestand en draait het. Dat is precies de lus die je wil terwijl je iets bestudeert.

De benchmark verderop in deze studie bestaat uit twee bestanden en wordt zo gestart. Er is nergens een bouwgereedschap aan te pas gekomen.

Halve precisie als eersteklas burger

Gekwantiseerde modellen slaan hun schaalfactoren op als 16-bits floats. Sinds JDK 20 zet Float.float16ToFloat(short) die om, met een intrinsic die de JIT naar een enkele machine-instructie vertaalt. Zelf bits schuiven hoeft niet meer.

Alle bouwstenen komen uit de JDK zelf. De rechterkolom is de reden dat er geen enkele afhankelijkheid nodig is.
NodigJDK-voorzieningSinds
Modelbestand van 1–5 GB lezenMemorySegment, Arena, FileChannel.mapJDK 22
Snelle rekenkernjdk.incubator.vectorJDK 16 (incubatie)
fp16-schalen decoderenFloat.float16ToFloatJDK 20
Draaien zonder bouwgereedschapjava Main.javaJDK 22
Parallellisme over de kernenjava.util.concurrentJDK 5
UTF-8, bestanden, CLIjava.basealtijd

Sectie 02Drie routes, en waarom er maar één bij die vraag past

De vraag was er eigenlijk twee tegelijk: kan het, én hoe zit zo'n toepassing in elkaar. Dat tweede deel bepaalt de keuze, want twee van de drie routes leren je niets over het model.

Route A aanbevolen
Alles zelf schrijven in Java

Je leest het GGUF-bestand zelf, bouwt de tokenizer, en schrijft de forward pass met de hand. Ongeveer 1000 à 1500 regels Java voor een werkende versie.

Leerwaarde: maximaal. Elke matrixvermenigvuldiging, elke normalisatie en elke aandachtskop passeert door jouw code. Je kan er niets in laten zitten dat je niet begrijpt, want dan werkt het niet.

Route B
Via de FFM API naar llama.cpp

Je schrijft Java-bindings naar de gecompileerde C-bibliotheek. Sneller dan alles wat je zelf schrijft, en je leert Project Panama grondig kennen.

Maar: de transformer zelf blijft een zwarte doos achter een C-functieaanroep. En je hebt weer een C-toolchain nodig — het bouwgereedschap dat je wou vermijden komt langs de achterdeur binnen.

Route C
HTTP naar een draaiende server

Ollama of llama-server starten en er met java.net.http.HttpClient mee praten. Dertig regels, vanavond klaar.

Maar: over hoe een LLM werkt leer je exact nul. Prima als eindbestemming voor een toepassing, waardeloos als studieobject.

De rest van deze studie gaat over route A. Route C is wel nuttig als meetlat: installeer llama.cpp erbij, niet om te gebruiken, maar om je eigen uitvoer tegen af te toetsen. Zonder referentie weet je bij een fout in de forward pass nooit of het aan je matrixcode ligt of aan je tokenizer.


Sectie 03De anatomie van zo'n toepassing

Zes onderdelen, en ze zijn verrassend ongelijk verdeeld. Eén stukje — de rekenkern van twintig regels, verstopt ín de forward pass — bepaalt 90 % van je snelheid. Een ander — de tokenizer — is saai maar zorgt voor de meeste bugs.

Vooraf: het voorbeeldmodel

De voorbeelden in deze studie rekenen met Llama-3.2-1B (16 lagen, 2048 breed, woordenschat 128 256). In de praktijk werd Qwen2.5-0.5B het werkpaard van het project — 24 lagen, 896 breed, woordenschat 151 936, en al op schijf voor wie ollama gebruikt. De hoofdstukken 12 tot 15 gebruiken dus andere getallen dan de voorbeelden hier; het principe is overal identiek.

model.gguf op schijf 0,7 – 4 GB mmap ONDERDEEL 1 GGUF-lader header, metadata, tensorregister MemorySegment buiten de heap ONDERDEEL 2 Tokenizer (BPE) tekst ↔ token-ids ONDERDEEL 3 Gewichten Q4/Q8 blokken van 32 ONDERDEEL 4 Forward pass 16× transformerblok per token opnieuw 90 % van de tijd ONDERDEEL 5 KV-cache groeit per token logits ONDERDEEL 6 Sampler temperatuur, top-p gekozen token wordt de volgende invoer
De gewichten worden nooit gekopieerd: de forward pass leest ze rechtstreeks uit het gemapte bestand. De enige structuur die tijdens het genereren groeit, is de KV-cache.

3.1 De GGUF-lader

GGUF is het bestandsformaat van llama.cpp en de de-factostandaard voor gekwantiseerde modellen. Het is bewust simpel: een korte header, een blok sleutel-waardeparen met alle hyperparameters én de volledige tokenizer, een register van tensoren, en dan de ruwe gewichten. Alles klein-endisch.

De structuur van een GGUF-bestand. Een string is een uint64 lengte gevolgd door UTF-8-bytes — in versie 1 was dat nog uint32, wat een klassiek struikelblok is.
DeelInhoudOpmerking
Header"GGUF", version, n_tensors, n_kv24 bytes
Metadatan_kv × (key, type, value)13 waardetypes, inclusief arrays
Tensorregisternaam, n_dims, dims[], type, offsetoffset is relatief t.o.v. het datablok
Opvullingtot general.alignmentstandaard 32 bytes
Datade gewichten zelfhier wijs je alleen naar, je kopieert niet

De lader is nauwelijks meer dan een leescursor over een MemorySegment. Merk op dat de byte-volgorde expliciet staat: op ARM en x86 is die toevallig dezelfde, maar reken daar niet op.

import java.lang.foreign.*;
import java.nio.channels.FileChannel;

final class Gguf {
    static final ValueLayout.OfInt   U32 = ValueLayout.JAVA_INT_UNALIGNED.withOrder(LITTLE_ENDIAN);
    static final ValueLayout.OfLong  U64 = ValueLayout.JAVA_LONG_UNALIGNED.withOrder(LITTLE_ENDIAN);

    private final MemorySegment file;
    private long p;                              // leescursor

    Gguf(Path path) throws IOException {
        try (FileChannel ch = FileChannel.open(path, READ)) {
            // Arena.ofAuto(): de mapping blijft leven nadat het kanaal sluit
            this.file = ch.map(READ_ONLY, 0, ch.size(), Arena.ofAuto());
        }
        if (u32() != 0x46554747) throw new IOException("geen GGUF-bestand");
        int  version   = u32();
        long nTensors  = u64();
        long nKv       = u64();
        // ... nKv sleutel-waardeparen, dan nTensors registeringangen
    }

    private int  u32() { int  v = file.get(U32, p); p += 4; return v; }
    private long u64() { long v = file.get(U64, p); p += 8; return v; }

    private String str() {
        int n = (int) u64();
        byte[] b = new byte[n];
        MemorySegment.copy(file, ValueLayout.JAVA_BYTE, p, b, 0, n);
        p += n;
        return new String(b, UTF_8);
    }
}

Uit de metadata haal je alles wat je nodig hebt om het model op te bouwen. De sleutels zijn voorspelbaar: llama.block_count, llama.embedding_length, llama.attention.head_count, llama.attention.head_count_kv, llama.feed_forward_length, llama.rope.freq_base. De tensoren heten blk.N.attn_q.weight, blk.N.ffn_gate.weight, enzovoort.

Eerste mijlpaal, meteen toetsbaar

Een lader die alleen de metadata afdrukt is al een volwaardig programma. Vergelijk je uitvoer met gguf_dump.py uit llama.cpp of met de modelkaart op Hugging Face. Klopt het aantal lagen en koppen, dan staat je fundament recht.

3.2 De tokenizer

Byte-pair encoding: begin met losse bytes en voeg herhaaldelijk het paar samen dat de hoogste prioriteit heeft in de samenvoeglijst. Beide staan volledig in het GGUF-bestand, als tokenizer.ggml.tokens en tokenizer.ggml.merges.

Conceptueel is dit het simpelste onderdeel. In de praktijk kost het de meeste tijd, omdat de randgevallen talrijk zijn en de fouten stil zijn: een verkeerd gesplitste prompt levert geen foutmelding op, alleen een model dat onzin uitkraamt. Denk aan de speciale tokens (<|begin_of_text|> en verwanten) die nooit door BPE mogen gaan, de byte-fallback voor onbekende tekens, en het feit dat een enkel token best een halve UTF-8-tekencode kan zijn.

3.3 Gewichten en kwantisatie

Hier zit de kern van de zaak. Gewichten worden niet als floats opgeslagen maar per blok van 32 gekwantiseerd: één gedeelde schaalfactor plus 32 kleine gehele getallen.

Q8_0 — 34 bytes per 32 gewichten (8,5 bit/gewicht) fp16 d q0 q1 q2 q3 . . . . . . q31 2 bytes 32 × int8 gewicht[i] = d × q[i] de schaalfactor staat op een oneven byte-positie: lees ze met JAVA_SHORT_UNALIGNED Q4_0 — 18 bytes per 32 gewichten (4,5 bit/gewicht) fp16 d q16 q0 q17 q1 . . . . . . . . . . . . q31 q15 16 bytes, elk twee nibbles hoge nibble lage nibble gewicht[i] = d × (nibble − 8) tweemaal kleiner dan Q8_0 — maar zie sectie 4
De twee eenvoudigste blokformaten. De K-varianten (Q4_K, Q6_K) voegen een tweede niveau van schaalfactoren per subblok toe; begin daar niet mee.

Belangrijk om te beseffen: in de snelle kernen pak je deze blokken nooit volledig uit naar floats in het geheugen. Dat zou het hele voordeel tenietdoen. De dequantisatie gebeurt binnen in het inwendig product, per blok van 32, terwijl de bytes toch al in de cache zitten. (De doorzichtige referentieversie uit hoofdstuk 14 pakt bewust wél uit, rij voor rij — eerst juist, dan snel.)

3.4 De rekenkern

Dit is de code die er echt toe doet. Alles wat een transformer doet, komt neer op "vermenigvuldig een matrix met een vector" — en tijdens het genereren van één token gebeurt dat een paar honderd keer. Hieronder de Q8_0-versie zoals ik ze voor deze studie geschreven en gemeten heb.

static final VectorSpecies<Float> FS = FloatVector.SPECIES_128;   // NEON: 4 lanes
static final VectorSpecies<Byte>  BS = ByteVector.SPECIES_128;    // 16 bytes per load

static float dotRowSimd(MemorySegment w, long rowBase, float[] x, int k) {
    int nb = k / 32;
    FloatVector total = FloatVector.zero(FS);
    for (int b = 0; b < nb; b++) {
        long bb = rowBase + (long) b * 34;                   // 34 bytes per blok
        float d = Float.float16ToFloat(w.get(JAVA_SHORT_UNALIGNED, bb));
        FloatVector acc = FloatVector.zero(FS);
        for (int half = 0; half < 2; half++) {
            ByteVector bv = ByteVector.fromMemorySegment(BS, w, bb + 2 + half * 16L, LITTLE_ENDIAN);
            for (int part = 0; part < 4; part++) {
                FloatVector wv = (FloatVector) bv.castShape(FS, part);   // int8 naar float
                FloatVector xv = FloatVector.fromArray(FS, x, b*32 + half*16 + part*4);
                acc = wv.fma(xv, acc);                                   // fused multiply-add
            }
        }
        total = acc.fma(FloatVector.broadcast(FS, d), total);            // schaal pas op het eind
    }
    return total.reduceLanes(VectorOperators.ADD);
}
Twintig regels. De schaalfactor wordt bewust buiten de binnenlus gehouden: 32 vermenigvuldigingen worden er één. Deze code is voor deze studie ook echt gedraaid, en gecontroleerd tegen een scalaire referentie.

Voor Q4_0 komt er één stap bij: de 16 bytes bevatten elk twee gewichten, dus je maakt er twee vectoren van met een masker en een schuifbewerking.

ByteVector packed = ByteVector.fromMemorySegment(BS, w, bb + 2, LITTLE_ENDIAN);
ByteVector lo = packed.and((byte) 0x0F).sub((byte) 8);                       // gewichten 0..15
ByteVector hi = packed.lanewise(LSHR, 4).and((byte) 0x0F).sub((byte) 8);  // gewichten 16..31

3.5 De forward pass

Met die kern op zak is de rest van de transformer eigenlijk boekhouding. Per token doorloop je alle lagen; per laag doe je twee blokken werk, elk met een residuverbinding eromheen.

AANDACHT — kijkt naar alle vorige tokens FEED-FORWARD — kijkt alleen naar dit token x RMSNorm geen bias Q, K, V 3 matvec RoPE positie Aandacht softmax over de KV-cache W_out 1 matvec + residu RMSNorm gate, up 2 matvec SwiGLU silu(gate) * up W_down 1 matvec + x voor de volgende laag 7 matvec-oproepen per laag Llama-3.2-1B: 16 lagen = 112 per token, plus de eindprojectie naar 128 256 logits alles daarbuiten is verwaarloosbaar
Eén transformerblok. De oranje kaders zijn matrixvermenigvuldigingen — daar gaat vrijwel alle tijd naartoe. Merk op dat de normalisaties zonder bias werken en de activatie SwiGLU is, niet ReLU: dat is de Llama-familie.

De ondersteunende bewerkingen zijn allemaal een handvol regels. RMSNorm, bijvoorbeeld, deelt door de wortel van het gemiddelde kwadraat en schaalt met een aangeleerd gewicht:

static void rmsnorm(float[] out, float[] x, float[] weight, float eps) {
    float ss = 0f;
    for (float v : x) ss += v * v;
    float scale = (float) (1.0 / Math.sqrt(ss / x.length + eps));
    for (int i = 0; i < x.length; i++) out[i] = x[i] * scale * weight[i];
}

En de rotatie-inbedding RoPE draait paren van getallen over een hoek die van de positie afhangt. Dit is de manier waarop het model weet wat het hoeveelste token is:

static void rope(float[] vec, int pos, int nHeads, int headDim, float base) {
    for (int h = 0; h < nHeads; h++) {
        int off = h * headDim;
        for (int i = 0; i < headDim; i += 2) {
            double freq = 1.0 / Math.pow(base, (double) i / headDim);
            float cos = (float) Math.cos(pos * freq), sin = (float) Math.sin(pos * freq);
            float v0 = vec[off + i], v1 = vec[off + i + 1];
            vec[off + i]     = v0 * cos - v1 * sin;
            vec[off + i + 1] = v0 * sin + v1 * cos;
        }
    }
}

De duurste bug die je gaat maken

Er bestaan twee conventies voor welke getallen een paar vormen: de aangrenzende (0&1, 2&3, ...) of de gehalveerde (0&32, 1&33, ...). Hugging Face gebruikt de tweede, maar het conversiescript van llama.cpp herschikt de Q- en K-gewichten van llama-modellen zo dat die GGUF-bestanden de eerste nodig hebben; qwen-bestanden blijven onverschoven en gebruiken de tweede (zie hoofdstuk 14). Kies je verkeerd, dan krijg je geen foutmelding — alleen vloeiend geformuleerde onzin. Toets dit expliciet met een referentie-uitvoer.

3.6 De KV-cache

Zonder cache zou je bij elk nieuw token de aandacht over de hele geschiedenis opnieuw uitrekenen. Met cache bewaar je de K- en V-vectoren van elk gezien token en voeg je er per stap één toe. Dat is het verschil tussen kwadratisch en lineair werk.

De prijs is geheugen, en die schaalt lineair met de context:

bytes = 2 × lagen × contextlengte × kvKoppen × kopDim × 4

Voor Llama-3.2-1B (16 lagen, 8 kv-koppen, kopdimensie 64) is dat 256 MiB bij een context van 4096 tokens. Bij de volledige 131 072 tokens die het model aankan zou het 8 GiB zijn — meer dan je hele machine. Begrens de context expliciet; dat is geen tekortkoming van je implementatie maar een bewuste keuze. In fp16 opslaan halveert dit nog eens.

3.7 De sampler en de lus

De forward pass eindigt in een vector logits, één getal per woord in de woordenschat. De sampler kiest daar een token uit: deel door de temperatuur, softmax, en trek uit de kansverdeling — eventueel beperkt tot de kleinste groep die samen kans p dekt (top-p). Zet de temperatuur op nul en je krijgt gewoon het maximum, wat handig is want dan is de uitvoer reproduceerbaar en kan je ze token voor token vergelijken met llama.cpp.

De hoofdlus is dan verrassend kort: token erin, forward pass, sampler, token eruit, en dat token wordt de invoer voor de volgende ronde.


Sectie 04Wat het kost op jouw machine

Ik heb de kern uit sectie 3.4 gebouwd en op de Air laten lopen tegen 512 MiB aan gekwantiseerde gewichten — ongeveer de omvang van een echt model. Twee bestanden, gestart met java --add-modules jdk.incubator.vector Roofline.java, geen bouwgereedschap.

Bij het genereren van tekst wordt elk gewicht precies één keer gelezen per token. Daarom vertaalt de doorvoer van deze kern zich rechtstreeks naar tokens per seconde. Dat maakt het een bruikbaar plafond.

Gemeten op MacBook Air M3 (4 prestatie- + 4 efficiëntiekernen), OpenJDK 25.0.1, beste van vijf metingen na opwarming. De SIMD-uitvoer is gecontroleerd tegen de scalaire; het verschil van 1,2·10⁻⁵ is uitsluitend afrondingsdrift door een andere sommatievolgorde.
Variant Q8_0
GB/s
Q8_0
G gewicht/s
Q4_0
GB/s
Q4_0
G gewicht/s
scalair, 1 thread2,92,71,42,5
Vector API, 1 thread8,98,44,27,5
Vector API, 2 threads17,516,58,314,8
Vector API, 4 threads34,532,516,228,8
Vector API, 8 threads39,537,220,035,6

Het onverwachte resultaat

Kijk naar de twee kolommen "G gewicht/s". Q4_0 verplaatst half zoveel bytes als Q8_0, en zou dus dubbel zo snel moeten zijn. Dat is het niet: 37,2 tegenover 35,6 miljard gewichten per seconde — nagenoeg identiek.

Dat betekent dat deze kern niet door de geheugenbandbreedte begrensd wordt. Je M3 heeft ongeveer 100 GB/s te bieden en we halen er 39,5 uit. De rem zit in het uitpakken zelf: elke vier gewichten vragen een conversie van bytes naar floats voordat de vermenigvuldiging kan beginnen, en die conversiestap loopt vol lang voordat het geheugen dat doet.

Twee praktische gevolgen

Kies Q4 om te pássen, niet om sneller te zijn. Op 8 GB is dat een reëel argument — maar verwacht er in deze Java-kern geen snelheidswinst van.

Er zit nog rek op. llama.cpp gebruikt op ARM de SDOT-instructie, die vier int8-producten in gehele getallen optelt zonder ooit naar float te converteren. De Vector API stelt die niet beschikbaar. Daar zit het grootste deel van het resterende verschil met llama.cpp — en dat is precies het soort inzicht dat je alleen krijgt door het zelf te bouwen en te meten.

Het tweede dat opvalt: van 4 naar 8 threads levert nog maar 14 % op. De vier efficiëntiekernen dragen weinig bij. Vier werkthreads is voor deze machine het juiste getal, en dat scheelt ook in energieverbruik.

Vertaald naar echte modellen

Plafond: de gemeten 35,6 miljard Q4_0-gewichten per seconde, gedeeld door de (afgeronde) modelgrootte. Reken in de praktijk op 50 à 70 % daarvan: er komt aandacht over de KV-cache bij, de softmax over 128 256 logits, en de synchronisatie tussen threads bij elke van de 112 matrixvermenigvuldigingen per token.
ModelParametersQ4_0PlafondRealistisch
SmolLM2-135M135 M0,08 GB263 tok/s~150
Qwen2.5-0.5B0,5 B0,28 GB71 tok/s~45
Llama-3.2-1B1,24 B0,70 GB35 tok/s~20
Llama-3.2-3B3,2 B1,8 GB12 tok/s~7
Llama-3.1-8B8 B4,5 GB4 tok/sniet op 8 GB

Ter oriëntatie: comfortabel voorleestempo ligt rond 8 tokens per seconde. Alles tot en met Llama-3.2-1B komt daar ruim boven. En inmiddels gemeten: de fp32-referentie uit hoofdstuk 14 haalt al zo'n 5 tokens per seconde op het 0,5B-model — nog vóór de Vector API eraan te pas komt. Mét de kernen staat de teller inmiddels op 36, zeven keer zoveel.


Sectie 05Bouwen en draaien, zonder bouwgereedschap

De concrete opdrachten. Op de testmachine stond JDK 25.0.1 via Homebrew; elke JDK vanaf 22 volstaat.

# één bestand — compileert in het geheugen en draait
java --add-modules jdk.incubator.vector Llm.java model.gguf

# meerdere bestanden in dezelfde map (JEP 458) — nog steeds geen bouwstap
java --add-modules jdk.incubator.vector Main.java model.gguf

# wil je toch een verplaatsbare jar
javac --add-modules jdk.incubator.vector -d out $(find src -name '*.java')
jar --create --file llm.jar --main-class Main -C out .
java --add-modules jdk.incubator.vector -jar llm.jar model.gguf

Een paar vlaggen die de moeite lonen:

  • --add-modules jdk.incubator.vector — verplicht, anders vindt hij de Vector API niet.
  • -Xmx512m — mag klein blijven. De gewichten liggen buiten de heap, in het gemapte bestand.
  • -XX:+UseSerialGC — in de hete lus alloceer je niets; een eenvoudige collector volstaat en scheelt achtergrondthreads.

Meteen te proberen

De benchmark uit sectie 4 staat klaar als twee bestanden, Roofline.java en Kernels.java. Ze zitten bij dit boek in code/benchmark/ (en in de broncode-zip); start ze vanuit die map met java --add-modules jdk.incubator.vector -Xmx1g Roofline.java. Het is meteen mijlpaal M0 — je kent je plafond voordat je een regel modelcode schrijft.


Sectie 06Een traject in zeven stappen

Bij elke stap hoort een controle die hard slaagt of faalt. Dat is bij dit soort werk geen luxe: een LLM die half kapot is, produceert nog steeds vloeiende zinnen. Zonder toetsen weet je niet of je klaar bent.

  1. De rekenkern meten Bouw de gekwantiseerde matvec en meet hem, scalair en met de Vector API. Geslaagd als je weet hoeveel tokens/s je model maximaal kan halen. Al gedaan — zie sectie 4.
  2. GGUF-metadata lezen Header, sleutel-waardeparen en tensorregister afdrukken. Nog geen enkele berekening. Geslaagd als lagen, koppen en woordenschat overeenkomen met gguf_dump.py of de modelkaart. Status: afgerond. 74 zelfcontroles; ollama geeft exact dezelfde waarden. Het bestandsformaat wordt in hoofdstuk 12 opengemaakt.
  3. Tokenizer BPE met de samenvoeglijst uit het bestand, inclusief speciale tokens. Geslaagd als decode(encode(s)) == s voor een paar duizend zinnen, én je token-ids identiek zijn aan die van llama-cli --verbose-prompt. Status: afgerond. 20 068 zelfcontroles; 201/201 teksten en 220/220 tokengrenzen identiek aan llama.cpp. Zie hoofdstuk 13.
  4. Forward pass in fp32 Eén token, positie 0, alles in gewone floats. Traag mag. Geslaagd als je top-5 logits dezelfde tokens in dezelfde volgorde geven als de referentie. Status: afgerond. Zie hoofdstuk 14: 8 van 8 prompts exact gelijk aan llama.cpp, veertig tokens lang.
  5. Gekwantiseerde gewichten Wissel de fp32-kern om voor Q8_0, daarna Q4_0. Geslaagd als de top-5 nog steeds klopt en de logits binnen enkele procenten blijven. Status: afgerond, zij het anders dan gepland — de referentie rekent van meet af aan réchtstreeks op de gekwantiseerde blokken, rij voor rij. De snelle kernen zijn er inmiddels ook — zie deel 8 van hoofdstuk 14: 5 tot 6 keer sneller per rij.
  6. KV-cache en generatie De lus sluiten: cache, sampler, meerdere tokens achter elkaar. Geslaagd als je bij temperatuur 0 en een vaste prompt tientallen tokens lang exact hetzelfde produceert als llama.cpp. Status: afgerond, samen met mijlpaal M3 — dit is precies de toets die in hoofdstuk 14 beschreven staat.
  7. Snelheid en afwerking Threads, hergebruik van scratchbuffers, chatsjabloon, streaming uitvoer. Geslaagd als je gemeten tokens/s in de buurt van het plafond uit stap 1 komt, en de uitvoer vloeiend verschijnt. Status: afgerond. De snelheid: 36 tokens per seconde met de Vector API-kernen (hoofdstuk 14, deel 8). De afwerking — sampling, streaming, het echte gesprekssjabloon en een interactieve chat — is hoofdstuk 15. Daarmee is het traject rond.

De stappen 2 tot 4 zijn het meeste werk; daarna gaat het snel. Reken op een paar avonden voor een werkende versie als je de vergelijking met een referentie-implementatie erbij houdt.


Sectie 07Valkuilen

Verzameld uit de plekken waar deze implementaties standaard stukgaan. De meeste geven geen foutmelding, en dat is precies wat ze gevaarlijk maakt.

Stil fout — je merkt het pas aan de uitvoer

  • RoPE-conventie. Aangrenzende paren of gehalveerde? Zie de waarschuwing in 3.5.
  • Kopindeling bij GQA. Met 32 vraagkoppen en 8 sleutelkoppen hoort kop h bij sleutelkop h / 4. Verkeerd gedeeld en het model praat wartaal.
  • Gebonden inbeddingen. Veel kleine modellen hebben geen output.weight: de eindprojectie hergebruikt token_embd.weight. Ontbreekt de tensor, dan is dat geen fout maar een aanwijzing.
  • Chatsjabloon. Elke modelfamilie heeft haar eigen speciale tokens rond de beurten. Wijk je af, dan blijft het model doorbabbelen of antwoordt het naast de kwestie.
  • BF16 is geen FP16. Zelfde grootte, andere indeling van exponent en mantisse.

Luidruchtig fout — je krijgt tenminste een uitzondering

  • De 2 GB-grens. MappedByteBuffer loopt over bij grotere modellen. Gebruik MemorySegment.
  • Uitlijning. Blokken van 34 bytes zetten de fp16-schaal op oneven posities. Zonder JAVA_SHORT_UNALIGNED krijg je een IllegalArgumentException.
  • Byte-volgorde. GGUF is klein-endisch; zet de volgorde expliciet in je layouts.

Traag — alles werkt, het duurt alleen te lang

  • Alloceren in de hete lus. Maak alle scratchbuffers één keer aan en hergebruik ze. Een new float[dim] per laag per token is dodelijk.
  • Een verse threadpool per matrixvermenigvuldiging. Er zijn er 112 per token. Gebruik één vaste pool voor de hele sessie.
  • Meten voor de opwarming. De eerste tokens draaien nog geïnterpreteerd. Gooi de eerste paar altijd weg.
  • Te veel threads. Op deze M3 is vier het optimum, niet acht.

Sectie 08Waar je kan afkijken

Java

mukel/llama3.java is precies zo'n toepassing, en dus het belangrijkste vergelijkingsmateriaal: één bestand, geen afhankelijkheden, MIT-licentie, Java 21+. Het bevat een GGUF-lezer, een BPE-tokenizer, aandacht met gegroepeerde koppen en Vector API-kernen voor Q4_0 tot Q8_0. Lees het pas nadat je zelf een eerste poging hebt gedaan — het is een antwoordblad, en de vragen zijn leerzamer.

tjake/Jlama is ambitieuzer en volwassener, maar gebruikt wel Maven en veel meer abstractielagen. Nuttig als naslag voor hoe je dit netjes structureert zodra je verder wil dan één bestand.

Referentie en meetlat

karpathy/llama2.c is de klassieke pedagogische versie: ongeveer 700 regels C die de volledige forward pass bevatten. Het leest in één avond en is nog altijd de helderste uitleg van wat er precies gebeurt.

llama.cpp heb je nodig als meetlat, niet als bibliotheek. Het levert gguf_dump.py om je lader te toetsen, llama-cli --verbose-prompt om je tokenizer te controleren, en een referentie-uitvoer om je logits mee te vergelijken.

Modellen die op 8 GB passen

Begin klein. Een model van 135 M draait snel genoeg om je forward pass in seconden te toetsen in plaats van in minuten — dat versnelt je ontwikkelwerk meer dan welke optimalisatie ook.
ModelQ4_0LicentieWaarvoor
SmolLM2-135M-Instruct0,08 GBApache 2.0ontwikkelen en debuggen
Qwen2.5-0.5B-Instruct0,28 GBApache 2.0eerste echte gesprekken
Llama-3.2-1B-Instruct0,70 GBLlama Communitydagelijkse doelstelling
Llama-3.2-3B-Instruct1,8 GBLlama Communitymerkbaar slimmer, nog vlot

Let bij het downloaden op de kwantisatie in de bestandsnaam. Begin met Q8_0: dat is het eenvoudigste formaat om te implementeren en wijkt nauwelijks af van de volledige precisie, wat handig is als je je eigen uitvoer nog aan het toetsen bent. Stap pas over naar Q4_0 als de rest werkt. De K-varianten (Q4_K_M en verwanten) zijn kwalitatief beter maar hebben een ingewikkelder blokindeling met twee niveaus van schaalfactoren — bewaar die voor later. (In de praktijk liep het anders: het ollama-bestand bleek Q4_K_M, dus het project nam de K-varianten meteen mee — zie hoofdstuk 12.)


Sectie 09Conclusie

Het kan, het is niet eens bijzonder moeilijk, en het is een uitstekend studieobject — juist omdat er zo weinig tussen jou en de wiskunde staat. Geen framework dat beslissingen voor je neemt, geen bouwgereedschap dat een laag toevoegt, geen afhankelijkheid waarvan je de binnenkant niet kent. Je schrijft een bestandslezer, een tokenizer en een paar honderd regels lineaire algebra, en aan het eind praat je computer terug.

De schaal is behapbaar: ongeveer 1000 à 1500 regels voor een werkende versie. Het enige echte gevaar is dat je niet toetst. Een LLM met een fout in de aandachtsberekening produceert nog steeds vloeiende, grammaticaal correcte zinnen — ze slaan alleen nergens op. Houd daarom vanaf mijlpaal M2 een referentie-implementatie naast je en vergelijk getallen, niet indrukken.

En de meting uit sectie 4 laat zien wat dit soort werk oplevert: een gedetailleerd, gemeten begrip van waar de tijd naartoe gaat. Dat Q4 op deze machine geen snelheidswinst geeft, en dat de rem in de byte-naar-float-conversie zit en niet in het geheugen, staat in geen enkele handleiding. Dat weet je pas als je het zelf gebouwd en gemeten hebt.

Toen dit geschreven werd, was het een plan. Inmiddels niet meer: de hoofdstukken 12 tot en met 14 lopen precies deze weg af, tot en met een draaiende forward pass die token voor token gelijk oploopt met llama.cpp — inmiddels aan 36 tokens per seconde. En sinds hoofdstuk 15 kun je er ook echt mee praten.

Studie opgesteld op 14 augustus 2026 — metingen uitgevoerd op MacBook Air M3 (8 GB) met OpenJDK 25.0.1. Alle cijfers in sectie 4 komen uit een benchmark die voor deze studie geschreven en gedraaid is; de SIMD-kern is gecontroleerd tegen een scalaire referentie-implementatie.

Lettertypen: IBM Plex Sans en IBM Plex Serif, SIL Open Font License 1.1, ingesloten in de gedeelde stylesheet van dit boek.