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.
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.
| Nodig | JDK-voorziening | Sinds |
|---|---|---|
| Modelbestand van 1–5 GB lezen | MemorySegment, Arena, FileChannel.map | JDK 22 |
| Snelle rekenkern | jdk.incubator.vector | JDK 16 (incubatie) |
| fp16-schalen decoderen | Float.float16ToFloat | JDK 20 |
| Draaien zonder bouwgereedschap | java Main.java | JDK 22 |
| Parallellisme over de kernen | java.util.concurrent | JDK 5 |
| UTF-8, bestanden, CLI | java.base | altijd |
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.
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.
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.
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.
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.
| Deel | Inhoud | Opmerking |
|---|---|---|
| Header | "GGUF", version, n_tensors, n_kv | 24 bytes |
| Metadata | n_kv × (key, type, value) | 13 waardetypes, inclusief arrays |
| Tensorregister | naam, n_dims, dims[], type, offset | offset is relatief t.o.v. het datablok |
| Opvulling | tot general.alignment | standaard 32 bytes |
| Data | de gewichten zelf | hier 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.
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);
}
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.
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.
| Variant | Q8_0 GB/s |
Q8_0 G gewicht/s |
Q4_0 GB/s |
Q4_0 G gewicht/s |
|---|---|---|---|---|
| scalair, 1 thread | 2,9 | 2,7 | 1,4 | 2,5 |
| Vector API, 1 thread | 8,9 | 8,4 | 4,2 | 7,5 |
| Vector API, 2 threads | 17,5 | 16,5 | 8,3 | 14,8 |
| Vector API, 4 threads | 34,5 | 32,5 | 16,2 | 28,8 |
| Vector API, 8 threads | 39,5 | 37,2 | 20,0 | 35,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
| Model | Parameters | Q4_0 | Plafond | Realistisch |
|---|---|---|---|---|
| SmolLM2-135M | 135 M | 0,08 GB | 263 tok/s | ~150 |
| Qwen2.5-0.5B | 0,5 B | 0,28 GB | 71 tok/s | ~45 |
| Llama-3.2-1B | 1,24 B | 0,70 GB | 35 tok/s | ~20 |
| Llama-3.2-3B | 3,2 B | 1,8 GB | 12 tok/s | ~7 |
| Llama-3.1-8B | 8 B | 4,5 GB | 4 tok/s | niet 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.
- 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.
- GGUF-metadata lezen
Header, sleutel-waardeparen en tensorregister afdrukken. Nog geen enkele berekening.
Geslaagd als lagen, koppen en woordenschat overeenkomen met
gguf_dump.pyof de modelkaart. Status: afgerond. 74 zelfcontroles; ollama geeft exact dezelfde waarden. Het bestandsformaat wordt in hoofdstuk 12 opengemaakt. - Tokenizer
BPE met de samenvoeglijst uit het bestand, inclusief speciale tokens.
Geslaagd als
decode(encode(s)) == svoor een paar duizend zinnen, én je token-ids identiek zijn aan die vanllama-cli --verbose-prompt. Status: afgerond. 20 068 zelfcontroles; 201/201 teksten en 220/220 tokengrenzen identiek aan llama.cpp. Zie hoofdstuk 13. - 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.
- 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.
- 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.
- 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 hergebruikttoken_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.
MappedByteBufferloopt over bij grotere modellen. GebruikMemorySegment. - Uitlijning. Blokken van 34 bytes zetten de fp16-schaal op oneven posities. Zonder
JAVA_SHORT_UNALIGNEDkrijg je eenIllegalArgumentException. - 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
| Model | Q4_0 | Licentie | Waarvoor |
|---|---|---|---|
| SmolLM2-135M-Instruct | 0,08 GB | Apache 2.0 | ontwikkelen en debuggen |
| Qwen2.5-0.5B-Instruct | 0,28 GB | Apache 2.0 | eerste echte gesprekken |
| Llama-3.2-1B-Instruct | 0,70 GB | Llama Community | dagelijkse doelstelling |
| Llama-3.2-3B-Instruct | 1,8 GB | Llama Community | merkbaar 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.