Naar inhoud springen
13/30Hoofdstuk 13 van 30

Inference goedkoop maken: KV cache, batching en quantization

Hetzelfde model antwoordt in 8,8 en 78,9 seconden identiek. Daarna INT4, op drie manieren gemeten in plaats van beweerd.

Op deze pagina

Hetzelfde model, op dezelfde machine, dat dezelfde vraag beantwoordt met dezelfde 48 tokens. De twee outputs zijn identiek, token voor token — gecontroleerd, niet aangenomen.

TEXT
with a key-value cache:     8.85 s   ( 6.01 tokens/second)
without a key-value cache: 78.95 s   ( 0.60 tokens/second)

Eén argument veranderde: use_cache=False. Niets aan het model, de prompt, de sampling of de rekenkunde is anders, en de tweede run is niet nauwkeuriger voor al die moeite. Hij is negen keer trager voor niets.

Dat is de vorm van dit hoofdstuk. Alles erin — de cache, de batch, de gequantizeerde weights — is een poging om niet te betalen voor werk dat het antwoord niet verandert, of om uit te vinden wat een goedkoper antwoord kost. Hoofdstuk 10 zette de prijslijst voor training neer. Dit is de prijslijst voor de kant waarvoor je voor altijd betaalt: een gedeployed model besteedt grofweg 2N2N FLOPs voor elke token die het uitstuurt, bij elke request, voor de rest van zijn leven.

Om een token te genereren neemt een decoder-only transformer de hele sequence tot dan toe, voert die door elke layer en leest de kansverdeling af op de laatste positie. Daarna voegt hij de gekozen token toe en doet hij het opnieuw. Die beschrijving klopt, en dat is wat de trage run doet.

Het is ook enorm verspillend, en de reden is het causal mask uit Hoofdstuk 9. De key- en value-vectoren van positie 7 worden berekend uit de input van positie 7 en de posities ervoor. Wanneer positie 8 arriveert, kan positie 7 die niet zien — dat is wat causal betekent — dus de key en value van positie 7 zijn exact dezelfde getallen als eerder. De trage run berekent ze toch opnieuw, bij elke stap.

Sla ze dus op. Die opslag is de key-value cache, de meest bepalende optimalisatie in language-model serving:

generate.pyPYTHON
out = model(prompt_ids, use_cache=True)          # prefill: the whole prompt
past = out.past_key_values                        
nxt = out.logits[:, -1].argmax(-1, keepdim=True)

for _ in range(n - 1):
    out = model(nxt, past_key_values=past, use_cache=True)   
    past = out.past_key_values                                
    nxt = out.logits[:, -1].argmax(-1, keepdim=True)

Kijk wat binnen de loop aan het model wordt gevoerd: nxt, één token. Niet de sequence. De query van de nieuwe token attends against elke cached key, en de cached keys zouden nooit veranderen. Dit is geen benadering — de identieke-outputcheck hierboven is precies het punt. De cache ruilt geen kwaliteit voor snelheid; hij verwijdert redundante rekenkunde.

Om de schaal helder te zien, haal je de transformer weg en time je één attention head met d=64d = 64, één generation-stap op beide manieren berekend:

tokens in contextalles herberekenenmet een cacheverhoudingscore matrix
1280,59 ms0,062 ms10x65.536 B vs 512 B
2561,20 ms0,163 ms7x262.144 B vs 1.024 B
5127,03 ms0,078 ms90x1.048.576 B vs 2.048 B
102417,31 ms0,114 ms152x4.194.304 B vs 4.096 B
204859,83 ms0,214 ms279x16.777.216 B vs 8.192 B
4096236,18 ms0,284 ms832x67.108.864 B vs 16.384 B

De rechterkolom is de oorzaak. Herberekenen bouwt bij elke stap de volledige n×nn \times n attention matrix — de O(n2)O(n^2) uit het asymptotische-notatievak van Hoofdstuk 9, betaald per token. Met de cache bouw je in plaats daarvan een 1×n1 \times n rij: bij 4.096 tokens, 67 MB aan scores tegenover 16 KB.

Multiply-accumulates tellen in plaats van milliseconden haalt de machine uit het argument. Om TT tokens vanuit een koude start te genereren:

gegenereerde tokensmet een cacheherberekenenverhouding
1282,6 M192,0 M73x
51223,1 M7,36 G318x
2048293,7 M392,6 G1.336x

Per stap is de cached versie lineair in de context en de uncached versie kwadratisch; opgeteld over een generation, O(T2)O(T^2) tegenover O(T3)O(T^3), waarbij de verhouding onbeperkt groeit. Het negenvoudige verschil in de opening werd gemeten over 48 tokens — korter dan de eerste rij van die tabel.

De cache verandert ook wat er in memory moet staan. Op een laptop-GPU van 8 GB die 256 tokens in fp16 genereert, met de piek van de allocator minus de resident weights:

piek working memory
met een cache21,8 MB
herberekenen181,7 MB

8,3 keer meer memory, besteed om dezelfde tokens trager te produceren. Dit is de belofte uit Hoofdstuk 5, maar dan vanuit een onverwachte richting: daar moest reverse-mode autodiff elke intermediate in leven houden voor de backward pass, en activations domineerden training memory. Bij inference is er geen backward pass en hoeft daar niets voor bewaard te blijven — dus wat memory in plaats daarvan domineert is de cache, en dat is een bewuste keuze in plaats van een onvermijdelijke kost.

Prefill en decode zijn twee verschillende machines

Link naar de sectie: Prefill en decode zijn twee verschillende machines

Kijk opnieuw naar de snelle run: de eerste token gedroeg zich anders dan de andere zevenenveertig.

TEXT
prefill, 40 prompt tokens : 1.0224 s   ->  25.6 ms per token
decode,  47 steps         : 0.1665 s mean per step

De prompt kostte 25,6 ms per token en elke gegenereerde token kostte 166 ms. Zelfde model, zelfde hardware, zelfde weights, een zesvoudig verschil per token — en het gaat de kant op die de meeste mensen niet verwachten. De prompt is het goedkope deel. Generation valt uiteen in twee fasen met werkelijk verschillende fysica:

Eén forward pass over de hele prompt. Elke token wordt parallel verwerkt, dus elke weight matrix wordt één keer uit memory geladen en vermenigvuldigd met een matrix van honderden token-vectoren — een matrix-matrixproduct, met veel rekenwerk per verplaatste byte, precies waar een GPU voor gebouwd is. Prefill is compute-bound, en de kost is grofweg lineair in de promptlengte.

Eén forward pass per token, batch van één en sequence van één. Elke weight matrix wordt nog steeds volledig uit memory geladen, en vermenigvuldigd met een enkele vector — een matrix-vectorproduct, met bijna geen rekenwerk per verplaatste byte. Decode is memory-bandwidth-bound, en de kost per token hangt nauwelijks af van de lengte van de context.

Beide helften zijn meetbaar. Prefill, één pass over PP tokens:

prompt tokenssecondenms per token
160,351521,97
320,525416,42
641,049116,39
1281,655212,93
2563,096512,10

Decode, één token tegen een cache van CC:

cached tokensms voor één token
16110,05
6497,57
256108,53
1024103,86

Lees de tweede tabel twee keer. Van 16 tokens context naar 1.024 gaan — vierenzestig keer meer geschiedenis om over te attend — veranderde de kost van een stap met niets meetbaars. Attention tegen de cache is echt werk, maar het valt in het niet bij de vaste kost van een half miljard weights door de memory bus slepen om één vector te produceren. Die vaste kost is de reden voor alles in de volgende sectie.

Deze twee fasen zijn de oorsprong van de twee getallen die elk serving-systeem rapporteert. Time to first token is in wezen prefill, en groeit met de prompt, waardoor een lang gesprek traag aanvoelt bij de start. Tokens per second is 1/decode step1/\text{decode step}, en is grofweg constant, waardoor het antwoord daarna gelijkmatig binnenstroomt. Een chat die traag start en daarna soepel streamt is geen renderingtruc. Het zijn deze twee tabellen.

De cache ruilt rekenkunde voor memory, en de memory die hij wil is niet klein. Voor elke token in de context houdt elke layer één key-vector en één value-vector per key-value head vast:

bytes per token=2×L×Hkv×dhead×bytes per element\text{bytes per token} = 2 \times L \times H_{kv} \times d_{\text{head}} \times \text{bytes per element}

De 2 is voor keys en values; al het andere is de architectuur. Voor het model dat in dit hoofdstuk wordt gemeten — 24 layers, 14 query heads, 2 key-value heads, head dimension 64 — is dat in fp16 2×24×2×64×2=12,2882 \times 24 \times 2 \times 64 \times 2 = 12{,}288 bytes per token.

Formules in dit veld hebben de gewoonte er een factor twee naast te zitten, dus controleer het tegen de allocator in plaats van het te geloven:

TEXT
KV cache tensors per layer: (1, 2, 295, 64) float16
measured: 3,624,960 bytes for 295 tokens = 12,288 bytes/token
formula : 2 * 24 * 2 * 64 * 2                = 12,288 bytes/token

Exact, en het blijft exact over elke geteste shape:

batchcontextgemeten cachevoorspeldpiek working memory
15126,0 MB6,0 MB15,4 MB
116.384192,0 MB192,0 MB207,3 MB
165.536768,0 MB768,0 MB793,7 MB
84.096384,0 MB384,0 MB401,5 MB
322.048768,0 MB768,0 MB794,2 MB
641.024768,0 MB768,0 MB797,0 MB
128512768,0 MB768,0 MB816,4 MB

De laatste drie rijen verdienen een tweede blik. Tweeëndertig gebruikers met elk 2.048 tokens, vierenzestig met 1.024, honderdtwintig plus acht met 512 — de cache is telkens 768 MB, omdat alle drie 65.536 tokens bevatten. De cache hangt alleen af van het totale aantal resident tokens, niet van hoe ze over gebruikers verdeeld zijn. Dat feit is de basis van de sectie over batching.

Hoofdstuk 9 introduceerde multi-query en grouped-query attention en stelde de reden uit tot dit hoofdstuk. De reden is die formule, en specifiek de HkvH_{kv} erin.

Standaard multi-head attention geeft elke query head zijn eigen key- en value-heads. Het model hier heeft 14 query heads; met volledige multi-head attention zou zijn cache 2×24×14×64×2=86,0162 \times 24 \times 14 \times 64 \times 2 = 86{,}016 bytes per token zijn — 84 KB in plaats van 12 KB, exact zeven keer meer, de verhouding tussen query heads en key-value heads.

Multi-query attention1 voert dit tot de limiet: alle query heads delen één key-value head. Grouped-query attention2 is het compromis dat won — een handvol key-value heads, elk gedeeld door een groep query heads — omdat het kwaliteitsverlies van MQA echt was en dat van GQA niet. Geen van beide koopt rekenwerk. Ze bestaan om die formule door een integer te delen, en ze verspreidden zich door de industrie zodra lange contexts de cache tot bindende constraint maakten.

Dat gebeurt snel. Voor een model uit de 7B-klasse met 32 layers en 8 key-value heads met dimension 128 is de cache in fp16 128 KB per token:

context tokenséén gebruiker8 gebruikers64 gebruikers
4.0000,49 GB3,91 GB31,2 GB
32.0003,91 GB31,25 GB250,0 GB
128.00015,62 GB125,00 GB1.000,0 GB
1.000.000122,07 GB976,56 GB7.812,5 GB

De eigen weights van dat model zijn 13,0 GB in fp16, het getal in de tabel aan het einde van dit hoofdstuk. Dus bij een context van 128.000 tokens is de cache van één gebruiker groter dan het model. Dit is de rekenkunde die Hoofdstuk 16 in geld omzet, en het is waarom een lang gesprek niet alleen traag is — het bezet een vast stuk van een machine zolang de request leeft.

Batching: het getal dat omhooggaat en het getal dat omlaaggaat

Link naar de sectie: Batching: het getal dat omhooggaat en het getal dat omlaaggaat

Decode is memory-bound: de weights worden door de bus gesleept om één token te produceren, en de rekeneenheden staan idle. Stop dus meer werk in dezelfde stap. Run meerdere requests tegelijk, en de weights, één keer gelezen, bedienen ze allemaal. Gemeten op hetzelfde model, waarbij elke request een 64-token cache vasthoudt en één token decode:

batchlatency per stapthroughputlatency vs B=1
10,1286 s7,78 tok/s1,00x
20,1839 s10,88 tok/s1,43x
40,1909 s20,95 tok/s1,49x
80,2781 s28,76 tok/s2,16x
160,3430 s46,64 tok/s2,67x
320,6302 s50,78 tok/s4,90x

Lees de twee rechterkolommen naast elkaar, want ze zijn het hele punt. Van één request naar zestien gaan vermenigvuldigt throughput met 6,0 en vermenigvuldigt de wachttijd voor elke individuele request met 2,67. De batch maakte de server beter en elke gebruiker slechter.

Dat is geen bug die je kunt weg-tunen; het is de trade-off zelf, met aan elke kant een naam. Latency is wat iemand die op een antwoord wacht ervaart. Throughput is waardoor de factuur wordt gedeeld. Geen enkele instelling verbetert beide.

Let ook op waar het stopt. Van 16 naar 32 stijgt throughput met 9 % terwijl latency bijna verdubbelt: de stap is niet langer memory-bound maar compute-bound, en voorbij die knie koopt de batch niets. Elke deployment heeft zo'n knie; de locatie ervan moet je op de jouwe meten, maar het bestaan ervan niet.

Static batching verspilt het grootste deel van wat het wint

Link naar de sectie: Static batching verspilt het grootste deel van wat het wint

De naïeve manier om te batchen is BB requests verzamelen, ze samen runnen en teruggeven wanneer ze allemaal klaar zijn. Maar ze zijn niet tegelijk klaar: sommige antwoorden zijn twintig tokens en andere vijfhonderd. Een vaste batch runt tot het langste lid klaar is, en elke voltooide request blijft tot dan zijn slot bezetten en padding bijdragen.

Neem 64 requests met een realistische scheefheid in outputlengtes — mediaan 18 tokens, langste 231, 1.874 in totaal — en simuleer beide policies met de gemeten per-step-kost voor acht slots:

policywall clockthroughputgemiddelde latency per requestverspilde slot-steps
static batches van 8176,9 s10,6 tok/s83,2 s3.214
continuous, 8 slots109,0 s17,2 tok/s8,1 s0

Throughput verbetert met 1,6x. Gemiddelde latency verbetert met meer dan tien keer, omdat bij static batching een request die in vier stappen klaar was nog steeds wacht op een buur van 231 tokens voordat iemand er iets van hoort.

Continuous batching3 is de oplossing, en die is zo simpel als hij klinkt: de batch is geen groep maar een set slots, en een slot dat vrijkomt laat bij de eerstvolgende stap de volgende queued request toe. De scheduler werkt op de granulariteit van één token in plaats van één request. Elke serving-stack in productie doet dit nu.

Het heeft een tweede helft, en dat is de cache. Slots die komen en gaan laten cache memory gefragmenteerd achter, en voor elk slot zijn maximale mogelijke context reserveren verspilt het grootste deel van de reservering. PagedAttention4 leent het antwoord van operating systems: sla de cache op in blokken van vaste grootte met een block table per sequence, zodat de cache van een sequence fysiek verspreid kan liggen terwijl hij logisch contiguous blijft — waardoor twee sequences met een gedeelde prefix ook de blocks kunnen delen die die prefix bevatten. Daarop is vLLM gebouwd, en daarom is een serving engine een memory allocator met een transformer eraan vast.

De andere helft van de rekening zijn de weights zelf. Een half miljard parameters op vier bytes per stuk is 1,98 GB; op twee bytes, 0,99 GB; op één byte, 0,49 GB. Minder bits per weight maakt het model kleiner op disk, kleiner in memory, en — omdat decode bandwidth-bound is — maakt elke stap sneller, omdat er minder bytes te verplaatsen zijn.

Het eenvoudigste schema is symmetrische absolute-maximum quantization, en het past in drie regels:

quantize.pyPYTHON
qmax  = 2 ** (bits - 1) - 1
scale = W.abs().max() / qmax                        
Wq    = torch.round(W / scale).clamp(-qmax - 1, qmax)
W_hat = Wq * scale                                  # dequantized

Kies een scale zodat de grootste weight op de grootste integer mapped, deel, rond af, sla de integers en de scale op. Reconstrueer door terug te vermenigvuldigen. Er is niets slims aan, en het werkt — totdat het niet werkt.

Gemeten op de echte weights van het model: alle 168 projection matrices, 357,8 miljoen parameters, relatieve fout WW^/W\lVert W - \hat{W}\rVert / \lVert W \rVert:

schemagemiddelde relatieve foutslechtste matrix
INT8, één scale voor de hele matrix0,04000,1487
INT8, één scale per outputrij0,01000,0149
INT4, één scale voor de hele matrix0,60260,9931
INT4, één scale per outputrij0,17900,2589
INT4, één scale per groep van 1280,13230,1992
NF4, één scale per block van 640,09520,1205
INT3, één scale per groep van 1280,30440,4123
INT2, één scale per groep van 1280,77900,8076

De vierde rij is de instorting. Een relatieve fout van 0,99 op de slechtste matrix betekent dat de reconstructie in wezen niets van het origineel behoudt — de matrix is vervangen door noise van ongeveer de juiste grootte. De oorzaak is zichtbaar in hetzelfde experiment op één matrix:

TEXT
model.layers.12.mlp.down_proj.weight   (896 x 4864)
mean |w| 0.01386   std 0.01822   max |w| 0.43945   max/std 24.1
weights beyond 6 sigma: 692 of 4,358,144   (0.016 %)

Eén weight op zesduizend ligt voorbij zes standaarddeviaties, en de grootste ligt 24 verder. Met één scale voor de hele matrix bepaalt die ene weight de stapgrootte voor alle 4,3 miljoen weights. Bij 8 bits zijn er 256 stappen en landt de typische weight nog steeds op een betekenisvolle. Bij 4 bits zijn er 16, de buitenste gereserveerd voor een waarde die bijna niets heeft, en de gewone weights — dus allemaal — ronden af naar twee of drie verschillende niveaus.

Alles na die rij is dezelfde reparatie op verschillende granulariteiten: geef de scale een kleiner gebied. Per outputrij deelt de fout door 3,4; per groep van 128 opeenvolgende weights deelt hem opnieuw. De kost is bookkeeping — een 16-bit scale per groep van 128 is 4+16/128=4.1254 + 16/128 = 4.125 bits per weight in plaats van 4 — en het wint het grootste deel van het gat terug.

NF4 benadert het van de andere kant.5 De niveaus hoeven niet gelijkmatig verdeeld te zijn. Weights binnen een block zijn ongeveer normaal verdeeld, dus kies de zestien niveaus als de kwantielen van een normale verdeling: dicht bij nul waar de weights werkelijk zijn, dun in de tails waar ze niet zijn. Zelfde vier bits, zelfde block scaling, met een kleiner block — 4,25 bits per weight tegenover 4,125 bij group-128 — en de gemeten fout daalt van 0,1323 naar 0,0952, 28 % lager. Een deel daarvan is het fijnere block en de rest is de niveaus plaatsen waar de massa zit; de twee scheiden zou een derde rij vereisen.

Het floating-pointvak van Hoofdstuk 2 eindigde met een belofte: dat dit hoofdstuk weights zou quantizen naar 8 en 4 bits en een handvol outlier features zou vinden die weigeren samengedrukt te worden. Hier zijn ze, en ze verklaren waarom "gewoon de getallen afronden" nooit zou werken op activations.

De weights hierboven gedroegen zich slecht. De activations spelen in een andere klasse. Neem een gewone prompt van 84 tokens, vang de residual stream bij elke layer, en meet de grootste magnitude die elk van de 896 dimensions bereikt:

layergrootste |h|grootste |h| van mediane dimensionverhoudingdimensions boven 6x de mediaan
16,190,33918x2
41543,481,550996x34
81571,631,4981049x36
121575,031,5461019x34
161579,601,617977x32
201577,982,361668x24
24204,4410,76019x12

Dimension 62 bereikt 1.579,6 terwijl de mediane dimension nooit boven 1,6 komt. Het is geen toeval van één token of één layer: dezelfde dimension is er bij layer 4 en nog steeds bij layer 20, met bijna dezelfde waarde. Dit zijn de outlier features,6 en ze zijn systematisch — een eigenschap van het getrainde model, niet van de input.

Het histogram van die 896 maxima per dimension bij layer 16 maakt de vorm onmiskenbaar:

TEXT
     0 -      1 | ######################################## 254
     1 -      2 | ######################################## 283
     2 -      4 | ######################################## 226
     4 -      8 | ######################################## 93
     8 -     16 | ##################                       18
    16 -     32 | #########                                9
    32 -     64 | #######                                  7
    64 -    128 | #####                                    5
   128 -    256 |                                          0
   256 -    512 |                                          0
   512 -   1024 |                                          0
  1024 -   4096 | #                                        1

Negenhonderd dimensions in een keurige stapel onder 8, helemaal niets gedurende drie octaven, en dan één dimension alleen aan het verre uiteinde. Quantize die tensor nu naar INT8 en tel wat er gebeurt:

schemarelatieve foutgebruikte verschillende integerniveaus, hele tensor
één scale voor de hele tensor0,108314 van 256
één scale per token (per rij)0,0433158
hele tensor, 1 outlier dimension behouden in fp320,044248
hele tensor, 4 outlier dimensions behouden in fp320,027957
hele tensor, 16 outlier dimensions behouden in fp320,0085102

Veertien niveaus van de 256. De scale werd ingesteld door 1.579,6, dus elke stap is 12,44 breed, en de typische activation — mediane magnitude 0,26, negenennegentigste percentiel 2,51 — heeft nergens om te landen. Per dimension is het nog scherper:

TEXT
single tensor-wide scale = 12.4378
  dim 826 (max |h| = 4.77):  1 distinct level out of 256
  dim 336 (max |h| = 1.62):  1 distinct level out of 256
  dim  96 (max |h| = 0.69):  1 distinct level out of 256

after excluding the top 4 dimensions, scale = 0.5749  (22x smaller)
  dim 826: 8 levels    dim 336: 4 levels    dim  96: 3 levels

Eén niveau. De hele dimension, elke token, gequantized naar hetzelfde getal. Acht bits werden toegewezen en ruwweg nul werden gebruikt, en het model dat die activations leest krijgt een constante aangereikt.

Die meting is de rechtvaardiging voor elke techniek die mensen daadwerkelijk gebruiken:

Houd de outliers erbuiten. LLM.int8()6 splitst de matrix multiply op: de dimensions met extreme magnitudes worden in 16 bits berekend, al het andere in INT8, en de helften worden opgeteld. De tabel hierboven is het bewijs — vier dimensions verwijderen verlaagt de fout met bijna een factor vier. SmoothQuant7 verplaatst in plaats daarvan de moeilijkheid: deel de activations door een per-channel factor en vermenigvuldig de bijbehorende weight column ermee, waardoor het product onveranderd blijft en de outlier uit de tensor wordt verplaatst die hem niet kan absorberen naar de tensor die dat wel kan.

Kies de afronding, rond niet zomaar af. Niets hierboven vraagt waar de matrix voor is. GPTQ8 quantize kolom voor kolom en past na elke kolom de resterende full-precision columns aan om te compenseren voor de fout die al is gemaakt — waarbij de fout van de output van de layer op echte inputs wordt geminimaliseerd, niet die van zijn weights. AWQ9 merkt op dat een kleine fractie weight channels veel belangrijker is dan de rest, vindt ze uit activation-statistieken, en schaalt ze op vóór quantization zodat ze op fijnere niveaus landen. Beide hebben een calibratieset nodig; geen van beide heeft gradients nodig.

Details tonen

GGUF, en wat een bestandsformaat hiermee te maken heeft.

GGUF is geen quantization-methode; het is de container die llama.cpp gebruikt, en de verwarring in gguf vs gptq-vergelijkingen komt doordat de twee als hetzelfde soort ding worden behandeld. GGUF bevat tensors, tokenizer, architectuurmetadata en chat template in één memory-mappable bestand, en draagt daarbinnen een familie van block schema's — namen als Q4_K_M coderen bits per weight, block size, en of sommige tensors op hogere precision worden gehouden.

Het engineeringverschil dat ertoe doet: GPTQ en AWQ produceren weights die geoptimaliseerd zijn voor een GPU kernel, terwijl GGUF-schema's goedkoop worden decoded op een CPU met het bestand gemapped in plaats van geladen. Daarom bestaat hetzelfde nominale "4-bit 7B-model" in beide werelden met verschillende groottes en verschillende kwaliteit, en daarom is de eerlijke vergelijking nooit het format — het is de meting hieronder, uitgevoerd op je eigen taak.

Bijna elk artikel over quantization stopt bij de vorige sectie: het legt de methode uit, citeert een compressieverhouding, en beweert dat kwaliteit "grotendeels behouden" blijft. Hoofdstuk 4 ging over jezelf niet voor de gek houden, dus laten we het uitzoeken.

Hetzelfde model, weights in-place gequantized met elk schema, daarna drie metingen: perplexity op 2.048 tokens held-out Engelse proza — hier de conceptversie van deze cursus, en daarom vervangt de repository die door een vast public-domain boek en print hij een tabel met dezelfde vorm maar andere getallen — een batterij van 16 korte feitelijke vragen met bekende antwoorden onder greedy decoding, en de fractie tokens waarop het gequantizeerde model overeenkomt met het full-precision model bij identieke context.

schemagemiddelde weight-foutperplexityvragenbatterijeens met fp32
fp32 (referentie)0,000023,0813/16100,0 %
INT8 per tensor0,040023,5813/16
INT8 per rij0,010022,9613/1698,6 %
INT4 per tensor0,6026365.416.0000/16
INT4 per rij0,179046,186/1658,3 %
INT4 group 1280,132331,0810/1671,5 %
NF4 block 640,095224,5511/1684,7 %
INT3 group 1280,3044213,090/165,6 %
INT2 group 1280,779026.325.4360/160,0 %

Vier dingen in die tabel zijn het waard om gewoon te zeggen.

INT8 goed gedaan is gratis. Per-row INT8 scoort 22,96 tegenover 23,08 van de referentie — een gat van één deel op tweehonderd, wat noise is en gelezen moet worden als "identiek". Welke kant de noise op wijst is niet stabiel: op het public-domain corpus van de repository komen dezelfde twee schema's uit op 22,24 tegenover 22,18: de helft van die afstand, en de andere kant op. Het is het eens met het full-precision model op 142 van 144 gegenereerde tokens. Een kwart van de memory tegenover de fp32-referentie, de helft tegenover de fp16 die je daadwerkelijk zou deployen, en geen detecteerbare kost. INT8 onzorgvuldig gedaan is ook bijna gratis: één scale per matrix kost 0,5 perplexitypunten en geen antwoorden in de batterij. Acht bits is vergevingsgezind genoeg dat granulariteit nauwelijks uitmaakt, en dat is precies waarom mensen van INT8 naar INT4 generaliseren en pijn krijgen.

INT4 met één scale per tensor vernietigt het model. Perplexity 365 miljoen: niet gedegradeerd, weggevaagd. Granulariteit is daarna het hele spel — per-tensor 365.416.000, per-row 46,18, per-group-of-128 31,08, NF4 24,55. Dezelfde vier bits per weight, een factor vijftien miljoen tussen slechtst en best.

Perplexity is een grof instrument en de batterij een nog grover. Tussen NF4 en group-128 INT4 is het perplexityverschil 6,5 punten en verschilt de batterij met één vraag — en het betrouwbaarheidsinterval van Hoofdstuk 4 zegt dat één vraag op zestien helemaal niets onderscheidt. Er is een scherpere demonstratie dan het interval: run dezelfde batterij met de standaard repetition penalty van het model uitgeschakeld, wat greedy decoding werkelijk betekent, en die twee rijen wisselen van plek. Eén vraag op zestien is geen klein effect, het is geen effect. De waarschuwing uit Hoofdstuk 8 geldt ook: perplexity is alleen vergelijkbaar tussen modellen die een tokenizer delen, dus een getal uit andermans write-up kan niet met het jouwe worden vergeleken.

De agreement-kolom is de scherpste van de drie, en bijna gratis: run het full-precision model greedily, en vraag dan het gequantizeerde model op elke positie wat het zou hebben gekozen met dezelfde prefix. Het heeft 144 onafhankelijke observaties in plaats van 16, heeft geen ground truth nodig, en degradeert soepel waar de batterij in sprongen degradeert. Het is ook exact de grootheid die de volgende sectie nodig heeft.

Dit is de belofte die Hoofdstuk 1 over dit hoofdstuk maakte, precies op schema: de wiskunde zegt dat een 4-bit model mogelijk is, en de engineering beslist of het bruikbaar is.

Hoofdstuk 12 kondigde dit aan en liet de rekening hier achter.

Het idee komt rechtstreeks uit de prefill/decode-splitsing. Een voorgestelde sequence van γ\gamma tokens verifiëren kost één forward pass over γ\gamma posities — een matrix-matrixproduct, nauwelijks duurder dan de pass over één. Dus:

Een klein, goedkoop model genereert γ\gamma candidate tokens autoregressief.

Het grote model runt één forward pass over alle γ\gamma candidates tegelijk, en produceert wat het op elke positie zou hebben gezegd.

Behoud de langste prefix waarop de twee overeenkomen, plus de token die het grote model gratis levert bij de eerste mismatch. Gooi de rest weg en begin opnieuw.

De output distribution is onveranderd. Met greedy decoding is dat duidelijk — een token wordt alleen geaccepteerd als de target hem zou hebben geproduceerd. Met sampling vereist het een aangepaste acceptatieregel, en Leviathan et al. bewijzen dat de resulterende distribution exact die van de target is.10 Dit is de tweede exacte optimalisatie in dit hoofdstuk.

Alles hangt daarom af van de acceptance rate α\alpha, die meetbaar is — het is de agreement-kolom hierboven, en daarom werd die daar berekend. Elk gequantizeerd model als draft gebruiken voor de full-precision target, over 144 generated positions:

draft modelacceptancelangste geaccepteerde runverwachte tokens per target pass, γ=4\gamma = 4
fp32 (de target zelf)100,0 %485,00
INT8 per rij98,6 %484,86
NF4 block 6484,7 %203,69
INT4 group 12871,5 %132,85
INT4 per rij58,3 %72,24
INT3 group 1285,6 %21,06
INT2 group 1280,0 %01,00

Het verwachte aantal tokens dat per verification pass wordt geaccepteerd, bij draftlengte γ\gamma, is

E[tokens]=1αγ+11α\mathbb{E}[\text{tokens}] = \frac{1 - \alpha^{\gamma+1}}{1 - \alpha}

en de netto speedup deelt dat door de eigen kost van de draft, een fractie cc van de target per token:

acceptancec=0.05c=0.05, γ=4\gamma=4c=0.1c=0.1, γ=4\gamma=4c=0.2c=0.2, γ=4\gamma=4c=0.1c=0.1, γ=8\gamma=8
30 %1,19x1,02x0,79x0,79x
50 %1,61x1,38x1,08x1,11x
70 %2,31x1,98x1,54x1,78x
90 %3,41x2,93x2,28x3,40x

De vette entry is degene om te onthouden: speculative decoding kan generation trager maken. Bij 30 % acceptance met een draft die een vijfde van de target kost, betaal je voor vijf forward passes en behoud je 1,4 tokens. De laatste kolom is de andere valkuil — een langere draft helpt alleen wanneer acceptance hoog is, omdat de tail van een γ\gamma-token guess bijna nooit wordt bereikt. Bij 90 % acceptance is γ=8\gamma = 8 3,40x waard en bij 30 % is hij 0,79x waard: dezelfde configuratie, winst of verlies afhankelijk van een getal dat op je traffic wordt gemeten.

Quantization krimpt een model door dezelfde functie in minder bits op te slaan. Distillation krimpt het door een kleiner model te trainen om een groter model te imiteren11 — een idee dat bijna tien jaar ouder is dan deep learning.12

Het subtiele deel is waarvan de student leert. Niet het juiste antwoord: daarop had hij direct getraind kunnen worden. Wat de teacher toevoegt is de hele distribution. Vraag het model wat volgt op een phrase en kijk voorbij de argmax:

TEXT
"She poured the milk into the"
  ' jug' 0.1355   ' cup' 0.1051   ' bowl' 0.0605   ' large' 0.0380   ' milk' 0.0360

Het hard label zegt jug en niets anders. Het soft label zegt jug, en ook dat cup bijna net zo goed was, bowl plausibel, en large — een bijvoeglijk naamwoord, een volledig andere grammaticale voortzetting — nog steeds live. Dat is het oorspronkelijke argument: dit is een 7, maar hij lijkt behoorlijk op een 1, en die gelijkenis is informatie die het hard label weggooit.

Daarom gebruikt distillation ook een temperature. De logits delen door TT vóór de softmax vlakt de distribution af en verhoogt het relatieve gewicht van de runners-up: bij deze phrase daalt de verhouding tussen de top token en de derde van 2,24 bij T=1T = 1 naar 1,50 bij T=2T = 2 — de wortel van de eerste, en dat is wat het delen van de logits door twee met een verhouding doet. Zelfde ordering, meer attention van de loss op de near misses. De gradient van de student draagt de onzekerheid van de teacher en niet alleen zijn oordeel.

Alles in dit hoofdstuk is nu één som:

memory=N×bytes per weightfixed+T×2LHkvdhead×bytesgrows with every token+runtime overheadcall it 1.5 GB\text{memory} = \underbrace{N \times \text{bytes per weight}}_{\text{fixed}} + \underbrace{T \times 2 L H_{kv} d_{\text{head}} \times \text{bytes}}_{\text{grows with every token}} + \underbrace{\text{runtime overhead}}_{\text{call it 1.5 GB}}

waar TT de totale resident tokens over alle concurrent requests is. Toegepast: de 7B- en 70B-rijen gaan uit van 8 key-value heads met dimension 128, de 13B-rij van volledige multi-head attention met 40 heads, en zo werden die generaties modellen gebouwd — en dat is te zien.

8 GB

modelprecisionweightsvrij na overheadcontext tokens die passen
7Bfp1613,0 GBpast niet
7Bint86,5 GBpast niet
7Bint4 (g128)3,4 GB3,1 GB25.710
13Bint4 (g128)6,2 GB0,3 GB337
70Bint4 (g128)33,6 GBpast niet

16 GB

modelprecisionweightsvrij na overheadcontext tokens die passen
7Bfp1613,0 GB1,5 GB11.972
7Bint86,5 GB8,0 GB65.378
7Bint4 (g128)3,4 GB11,1 GB91.246
13Bint812,1 GB2,4 GB3.136
13Bint4 (g128)6,2 GB8,3 GB10.822

24 GB

modelprecisionweightsvrij na overheadcontext tokens die passen
7Bfp1613,0 GB9,5 GB77.508
7Bint86,5 GB16,0 GB130.914
7Bint4 (g128)3,4 GB19,1 GB156.782
13Bint812,1 GB10,4 GB13.622
13Bint4 (g128)6,2 GB16,3 GB21.308
70Bint4 (g128)33,6 GBpast niet

Kijk naar de 13B-rij in de 8 GB-tabel. De weights passen — 6,2 GB van 8 — dus volgens de gebruikelijke manier van praten "draait een 13B-model op een 8 GB-kaart". Het heeft 337 tokens context, wat geen gesprek is maar amper een prompt. "Past het" is de verkeerde vraag. De juiste is "met hoeveel context, en voor hoeveel gebruikers tegelijk".

Kijk ook naar de twee int8-rijen van 16 GB. De 7B krijgt 65.378 tokens en de 13B krijgt 3.136 — een twintigvoudig verschil door 5,6 GB extra weights, omdat de 13B hier multi-head attention heeft en zijn cache 800 KB per token kost tegenover 128 KB bij de 7B. Twee modellen van vergelijkbare grootte, één onbruikbaar voor long context, om een reden die in de headline van geen enkele model card voorkomt.

Dertien hoofdstukken geleden was dit een perceptron met twee weights en een bias. Nu is het een transformer die is ontworpen, getraind, aligned, geleerd om compute te besteden aan moeilijke vragen, en served tegen een gemeten kost per token — zonder dat er nog een doos in zit die ongeopend is.

Dat eindigt hier, en het eindigt bewust.

Hoofdstuk 14 begint met het model ergens anders. Niet in je process, niet in je memory, niet in een variable die je kunt printen: op een machine die je niet beheert, achter een API key, een port en een rekening. Alles wat hier gemeten is gebeurt nog steeds — de prefill draait nog steeds vóór de eerste token, de cache groeit nog steeds met het gesprek, de batch waarin je zit is nog steeds van iemand anders en beslist nog steeds je latency — maar vanaf nu observeer je het via een stream van Server-Sent Events, een finish_reason, en een HTTP 429 met een Retry-After header. De vragen veranderen met het standpunt: niet hoe wordt deze gradient berekend maar waarom is mijn factuur verdrievoudigd. De taal verandert ook, en Hoofdstuk 14 legt die regel uit in plaats van hem aan te kondigen — tot hier hield de code weights, gradients, logits en tokenizer-bytes vast; vanaf daar houdt hij een connection, een retry, een cancellation en accumulated state vast. De dertien hoofdstukken achter je worden door de overgang niet weggegooid. Ze zijn de beschrijving van wat er aan de andere kant van de port draait.


Twee weglatingen zijn bewust. FlashAttention (Dao et al., arXiv:2205.14135) is geen andere attention — het berekent dezelfde functie door de operatie te tilen zodat de n×nn \times n score matrix nooit naar memory wordt geschreven, en daarom is de 67 MB in de tweede tabel van dit hoofdstuk in de praktijk kleiner dan de rekenkunde suggereert. En de kernels zelf zijn gedelegeerd: college 10 van Stanford's CS336 behandelt inference systems in de diepte die dit niet probeert, en de llama.cpp repository en de GGUF-specificatie zijn de primaire bronnen voor de CPU-kant.

  1. Shazeer, N. Fast Transformer Decoding: One Write-Head is All You Need. arXiv:1911.02150 (2019). De paper is grotendeels een memory-bandwidth-argument, en leest ook zo.

  2. Ainslie, J. et al. GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints. arXiv:2305.13245 (2023). Bevat het uptraining-recept dat een bestaande multi-head checkpoint converteert, en daarom verspreidde GQA zich zo snel.

  3. Yu, G.-I., Jeong, J. S., Kim, G.-W., Kim, S. and Chun, B.-G. Orca: A Distributed Serving System for Transformer-Based Generative Models. OSDI 2022. Introduceert iteration-level scheduling — continuous batching — en selective batching.

  4. Kwon, W. et al. Efficient Memory Management for Large Language Model Serving with PagedAttention. arXiv:2309.06180 (2023), SOSP 2023. De paper waarop vLLM is gebouwd; §3 is de operating-systems-analogie volledig uitgewerkt.

  5. Dettmers, T., Pagnoni, A., Holtzman, A. and Zettlemoyer, L. QLoRA: Efficient Finetuning of Quantized LLMs. arXiv:2305.14314 (2023). NF4 wordt gedefinieerd in §3; de zestien level values die in de meting hierboven worden gebruikt zijn degene die deze paper afleidt.

  6. Dettmers, T., Lewis, M., Belkada, Y. and Zettlemoyer, L. LLM.int8(): 8-bit Matrix Multiplication for Transformers at Scale. arXiv:2208.07339 (2022). De outlier-feature-analyse in §4 is de bron van het hierboven gemeten fenomeen, inclusief de bevinding dat outliers systematisch op schaal ontstaan. 2

  7. Xiao, G., Lin, J., Seznec, M., Wu, H., Demouth, J. and Han, S. SmoothQuant: Accurate and Efficient Post-Training Quantization for Large Language Models. arXiv:2211.10438 (2022).

  8. Frantar, E., Ashkboos, S., Hoefler, T. and Alistarh, D. GPTQ: Accurate Post-Training Quantization for Generative Pre-trained Transformers. arXiv:2210.17323 (2022).

  9. Lin, J. et al. AWQ: Activation-aware Weight Quantization for LLM Compression and Acceleration. arXiv:2306.00978 (2023).

  10. Leviathan, Y., Kalman, M. and Matias, Y. Fast Inference from Transformers via Speculative Decoding. arXiv:2211.17192 (2022). Theorem 1 is het bewijs dat de output distribution onveranderd is; Chen et al. (arXiv:2302.01318) publiceerden hetzelfde idee onafhankelijk.

  11. Hinton, G., Vinyals, O. and Dean, J. Distilling the Knowledge in a Neural Network. arXiv:1503.02531 (2015). De temperature en het "dark knowledge"-argument.

  12. Buciluă, C., Caruana, R. and Niculescu-Mizil, A. Model Compression. KDD 2006. Distillation, negen jaar eerder, voor ensembles in plaats van transformers.

Klaar om LIA te laten kiezen?

Bouw met elk AI-model op één plek — begin vandaag nog gratis.