Werkplaats / De werkplaats
In jouw eigen tempo
← Taalmodellen
MODULE 05 · LES 12

Hergebruik keys en values zonder voorspellingen te veranderen

Bouw een echte KV-cache voor de eigen mini-GPT en controleer iedere incrementele stap tegen volledige prefixberekening.

90 min3 checkpoints
DIT GA JE ONTDEKKEN
  • Je berekent cachegeheugen uit lagen, tokens, heads en datatype.
  • Je toont numeriek aan dat gecachte inferentie dezelfde logits geeft binnen het contextvenster.

Bij ieder nieuw teken berekent je mini-GPT nu de hele prefix opnieuw. Toch mag een eerdere token door het causale masker niets van dat nieuwe teken weten. Kun je het oude rekenwerk bewaren en precies dezelfde volgende voorspelling houden?

Een key-valuecache (KV-cache) bewaart de geprojecteerde keys en values van eerdere tokens. Alleen de nieuwe token krijgt een nieuwe query, key en value. Dit werkt hier tijdens inferentie met vaste gewichten, hetzelfde positiebeleid en zonder dropout.

1. Geheugen betalen voor hergebruik

Stel dat één laag twee heads met elk twee componenten gebruikt. Voor vijf tokens bevat iedere head vijf keys en vijf values. Met float64 van acht bytes per component betaal je 2×1×1×5×2×2×8=320 bytes.

M=2LBTHdb

M telt alleen de payload in bytes. L is het aantal lagen, B de batchgrootte, T het aantal opgeslagen posities, H het aantal KV-heads, d hun breedte en b bytes per waarde. De factor twee staat voor keys én values.

size = kv_bytes(1,1,5,2,2,8)
print(size)  # 320

Maak kv_bytes met exacte positieve Python-ints; alleen T mag nul zijn. Modelgewichten, metadata, tijdelijke buffers en geheugenbeheer vallen buiten deze berekening. Bij grouped-query attention kan het aantal KV-heads kleiner zijn dan het aantal query-heads.

REKENWERK EN GEHEUGENBewaar het verleden, bereken de nieuwe token

Voorbeeld met 4 KV-heads, 64 componenten per head en 2 bytes per waarde.

T − 1 positieskeys en values uit de cache
1 nieuwe positiebereken Q, K en V
Deze stap · één reeks · één laag · T = 128, inclusief nieuwe token. Dichte scorematrix, ook gemaskeerde cellen.
BerekeningVolledige prefixMet cache
Posities voor Q/K/V1281
Scorecellen65.536512
KV-payload · alle lagen en reeksen524.288 bytesIn MiB0.50

2 × 4 lagen × 1 reeksen × 128 tokens × 4 heads × 64 × 2 bytes. Exclusief gewichten, buffers en overhead. Dit is geen snelheidsmeting.

Verdubbel T in de visualisatie. De cachepayload verdubbelt, terwijl een volledig opgebouwde scorematrix kwadratisch groeit. Dit vergelijkt aantallen waarden en scorecellen, geen gemeten versnelling op je apparaat.

01
CHECKPOINTReken cachegeheugen los van modelgewichten uit

2. Vul eerst het verleden

De eerste volledige promptpass heet prefill. Het gegeven gpt_forward berekent alle causale promptposities en bewaart geprojecteerde K/V in zijn attentioncache. Kopieer die arrays en neem de laatste logitrij: die voorspelt de eerste nog te kiezen nieuwe token.

params = init_gpt(7,5,4,18)
ids = np.array([1,2])
last,cache = prefill(ids,params,2)
print(last.shape)  # (7,)
print(cache['k'].shape)
# (1,2,2,2)

De cachevorm is (batch,heads,tijd,headbreedte). Hier is er één reeks, twee heads, twee promptposities en breedte twee per head. De keys komen uit de genormaliseerde, geprojecteerde tokenvectoren; losse token-ID’s of ongeprojecteerde embeddings zijn geen vervanging.

Maak prefill met exact k,v,length. Gebruik gpt_forward voor ID- en contextcontrole en maak echte kopieën, zodat latere cachebewerkingen de tijdelijke forward-cache niet wijzigen. Een meerlaags model bewaart per laag zijn eigen K/V; dit oefenmodel heeft één laag.

02
CHECKPOINTVul de cache tijdens de eerste volledige promptpass

3. Voeg uitsluitend de nieuwe positie toe

Waarom zijn oude keys én values nodig? Neem een speelgoedquery q=1, keys [1,2,3] en values [10,20,30], met headbreedte één. De scores zijn [1,2,3]; gewichten ongeveer [.0900,.2447,.6652] maken de uitvoer 25.7521.

q = np.array([[1.]])
k = np.array([[1.],[2.],[3.]])
v = np.array([[10.],[20.],[30.]])
mask = np.ones((1,3),bool)
y,_ = attention(q,k,v,mask)
print(round(float(y[0,0]),4))
# 25.7521

De oude twee keys en values mogen uit de cache komen; hun invloed op de nieuwe uitvoer verdwijnt niet. Voor cachelengte t krijgt de nieuwe token positie t, waarna alleen zijn nieuwe q/k/v worden berekend. De nieuwe query ziet alle aanwezige keys, inclusief zichzelf.

new,extended = cached_step(
    1,params,cache,2)
full,_ = gpt_forward(
    np.array([[1,2,1]]),params,2)
print(np.allclose(new,full[0,-1]))
# True
print(cache['length'],
      extended['length'])  # 2 3

Maak cached_step met dezelfde outputprojectie, residuals, feedforward en eindnorm als de volledige berekening. Retourneer een nieuwe cache en laat de oude intact. Bij ongeldige vormen of een volle positietabel geef je ValueError.

Een veelgemaakte fout is oude cacheposities wegknippen en opnieuw vanaf nul nummeren. Dan horen opgeslagen keys bij oude positie-embeddings en verandert de berekening. Deze cache geldt alleen voor hetzelfde model en dezelfde prefix; bij een gewijzigd venster bereken je hier opnieuw.

03
CHECKPOINTBereken alleen de nieuwe query en voeg één KV-paar toe

Wat neem je mee?

  • Een cache spaart eerdere K/V-projecties tegen extra geheugen.
  • Prefill voorspelt de eerste nieuwe token; daarna groeit de cache per stap.
  • Gecachte en volledige logits moeten numeriek overeenkomen binnen hetzelfde contextvenster.

Hierna maak je expliciet welke tokens en posities een venster of batch werkelijk bevat.

DE VOLGENDE STAPBeheer context, padding en zichtbare posities
JOUW EXPERIMENT

Probeer het zelf

Python
main.py

Python start zodra jij code uitvoert.

UITVOER

Je uitvoer verschijnt hier.
Alles rekent in je eigen browser.

Jouw checkpoints0 / 3
01
Reken cachegeheugen los van modelgewichten uit

Schrijf kv_bytes(layers,batch,tokens,kv_heads,head_dim,bytes_per_value). Alle argumenten zijn exacte Python-ints; alleen tokens mag nul zijn. Retourneer het aantal bytes, anders ValueError. De eerdere modelhelpers zijn gegeven.

Een hint

Valideer type(x) is int, zodat booleans geen aantallen worden. Vermenigvuldig met twee voor K en V.

02
Vul de cache tijdens de eerste volledige promptpass

Schrijf prefill(ids,params,heads=4). ids is een niet-lege 1D-tokenreeks. Retourneer (laatste_logits,cache), met exact k,v,length; length is de promptlengte. Gebruik gpt_forward voor verdere ID- en contextcontrole.

Een hint

Maak van ids een batch met ids[None,:]. De laatste logitvector is logits[0,-1].

03
Bereken alleen de nieuwe query en voeg één KV-paar toe

Schrijf cached_step(token,params,cache=None,heads=4) met (logits,new_cache). None start met lege K/V van vorm (1,H,0,d). Valideer token, headindeling, cachevormen, eindigheid en beschikbare context. Gebruik de gegeven helpers; weiger volle of ongeldige caches met ValueError.

Een hint

De nieuwe x heeft vorm (1,1,C). De attentionmasker-vorm is (1,t+1), overal True. Hergebruik layernorm_forward, split_heads, attention, merge_heads en feedforward.

Vastgelopen? Bekijk de voorbeeldoplossing
import numpy as np

def masked_softmax(scores,allowed):
    scores=np.asarray(scores,float)
    allowed=np.asarray(allowed)
    if scores.ndim<2 or scores.shape[-1]==0 or allowed.dtype.kind!='b' or not np.isfinite(scores).all():
        raise ValueError('Gebruik eindige scores en een booleaans masker')
    allowed=np.broadcast_to(allowed,scores.shape)
    masked=np.where(allowed,scores,-np.inf)
    maximum=np.max(masked,axis=-1,keepdims=True)
    maximum=np.where(np.isfinite(maximum),maximum,0.)
    weights=np.exp(masked-maximum)
    total=weights.sum(axis=-1,keepdims=True)
    return np.divide(weights,total,out=np.zeros_like(weights),where=total>0)

def attention(q,k,v,allowed):
    q,k,v=(np.asarray(value,float) for value in (q,k,v))
    if min(q.ndim,k.ndim,v.ndim)<2 or q.shape[:-2]!=k.shape[:-2] or q.shape[:-2]!=v.shape[:-2] or q.shape[-1]!=k.shape[-1] or k.shape[-2]!=v.shape[-2] or q.shape[-1]==0:
        raise ValueError('Vormen van Q, K en V passen niet')
    scores=q@np.swapaxes(k,-2,-1)/np.sqrt(q.shape[-1])
    weights=masked_softmax(scores,allowed)
    return weights@v,{'q':q,'k':k,'v':v,'weights':weights}

def split_heads(x,heads):
    if x.ndim!=3 or type(heads) is not int or heads<=0 or x.shape[-1]%heads:
        raise ValueError('Aantal heads moet de modelbreedte delen')
    b,t,c=x.shape
    return x.reshape(b,t,heads,c//heads).transpose(0,2,1,3)

def merge_heads(x):
    if x.ndim!=4:
        raise ValueError('Verwacht (batch,heads,tijd,headbreedte)')
    b,h,t,d=x.shape
    return x.transpose(0,2,1,3).reshape(b,t,h*d)

def multihead(x,params,heads,allowed):
    x=np.asarray(x,float)
    params={name:np.asarray(value,float) for name,value in params.items()}
    q,k,v=(split_heads(x@params[name],heads) for name in ['wq','wk','wv'])
    out,acache=attention(q,k,v,allowed)
    joined=merge_heads(out)
    return joined@params['wo'],{'x':x,'heads':heads,'attention':acache,'joined':joined}

def layernorm_forward(x,gamma,beta,epsilon=1e-5):
    x=np.asarray(x,float);gamma=np.asarray(gamma,float);beta=np.asarray(beta,float)
    inv=1/np.sqrt(x.var(axis=-1,keepdims=True)+epsilon)
    norm=(x-x.mean(axis=-1,keepdims=True))*inv
    return norm*gamma+beta,{'norm':norm,'inv':inv,'gamma':gamma}

def feedforward(x,params):
    x=np.asarray(x,float)
    params={name:np.asarray(value,float) for name,value in params.items()}
    z=x@params['w1']+params['b1']
    hidden=np.maximum(z,0)
    return hidden@params['w2']+params['b2'],{'x':x,'z':z,'hidden':hidden}

def transformer_block(x,params,heads,allowed):
    x=np.asarray(x,float)
    n1,c1=layernorm_forward(x,params['g1'],params['b1n'])
    a,ca=multihead(n1,params,heads,allowed)
    residual=x+a
    n2,c2=layernorm_forward(residual,params['g2'],params['b2n'])
    f,cf=feedforward(n2,params)
    return residual+f,{'norm1':c1,'attention':ca,'residual':residual,'norm2':c2,'ff':cf}

def init_block(width=4,seed=7):
    rng=np.random.default_rng(seed)
    params={name:rng.normal(0,.2,(width,width)) for name in ['wq','wk','wv','wo']}
    params.update({'g1':np.ones(width),'b1n':np.zeros(width),'g2':np.ones(width),'b2n':np.zeros(width),
                   'w1':rng.normal(0,.2,(width,2*width)),'b1':rng.normal(0,.2,2*width),
                   'w2':rng.normal(0,.2,(2*width,width)),'b2':np.zeros(width)})
    return params

def init_gpt(vocab_size,context=16,width=24,seed=9):
    rng=np.random.default_rng(seed)
    params=init_block(width,seed)
    params.update({'tokens':rng.normal(0,.1,(vocab_size,width)),
                   'positions':rng.normal(0,.1,(context,width)),
                   'gf':np.ones(width),'bf':np.zeros(width),
                   'head':rng.normal(0,.1,(width,vocab_size)),'bias':np.zeros(vocab_size)})
    return params

def gpt_forward(ids,params,heads=4):
    params={name:np.asarray(value,float) for name,value in params.items()}
    ids=np.asarray(ids)
    if ids.ndim!=2 or ids.dtype.kind not in 'iu' or ids.size==0 or ids.shape[1]>len(params['positions']) or ids.min()<0 or ids.max()>=len(params['tokens']):
        raise ValueError('Ongeldige tokenreeks of contextlengte')
    x=params['tokens'][ids]+params['positions'][:ids.shape[1]]
    allowed=np.tril(np.ones((ids.shape[1],ids.shape[1]),bool))
    h,bc=transformer_block(x,params,heads,allowed)
    n,nc=layernorm_forward(h,params['gf'],params['bf'])
    return n@params['head']+params['bias'],{'ids':ids,'block':bc,'finalnorm':nc,'n':n}

def kv_bytes(layers,batch,tokens,kv_heads,head_dim,bytes_per_value):
    values=[layers,batch,tokens,kv_heads,head_dim,bytes_per_value]
    if any(type(v) is not int for v in values) or tokens<0 or min(layers,batch,kv_heads,head_dim,bytes_per_value)<=0:
        raise ValueError('Ongeldige cacheafmetingen')
    return 2*layers*batch*tokens*kv_heads*head_dim*bytes_per_value

def prefill(ids,params,heads=4):
    ids=np.asarray(ids)
    if ids.ndim!=1 or ids.size==0:
        raise ValueError('Gebruik een niet-lege tokenlijst')
    logits,cache=gpt_forward(ids[None,:],params,heads)
    acache=cache['block']['attention']['attention']
    return logits[0,-1].copy(),{'k':acache['k'].copy(),'v':acache['v'].copy(),'length':len(ids)}

def cached_step(token,params,cache=None,heads=4):
    params={name:np.asarray(value,float) for name,value in params.items()}
    width=params['tokens'].shape[1]
    if type(heads) is not int or heads<=0 or width%heads or type(token) is not int or not 0<=token<len(params['tokens']):
        raise ValueError('Ongeldige token of heads')
    if cache is None:
        cache={'k':np.zeros((1,heads,0,width//heads)),'v':np.zeros((1,heads,0,width//heads)),'length':0}
    if not isinstance(cache,dict) or set(cache)!={'k','v','length'} or type(cache['length']) is not int:
        raise ValueError('Ongeldig cachecontract')
    length=cache['length']
    expected=(1,heads,length,width//heads)
    if length<0 or length>=len(params['positions']) or any(not isinstance(cache[k],np.ndarray) or cache[k].shape!=expected or not np.isfinite(cache[k]).all() for k in ['k','v']):
        raise ValueError('Cachevorm of contextlengte ongeldig')
    x=(params['tokens'][token]+params['positions'][length])[None,None,:]
    n1,_=layernorm_forward(x,params['g1'],params['b1n'])
    q,k,v=(split_heads(n1@params[name],heads) for name in ['wq','wk','wv'])
    keys=np.concatenate([cache['k'],k],axis=2);values=np.concatenate([cache['v'],v],axis=2)
    mixed,_=attention(q,keys,values,np.ones((1,length+1),bool))
    residual=x+merge_heads(mixed)@params['wo']
    n2,_=layernorm_forward(residual,params['g2'],params['b2n'])
    transformed=residual+feedforward(n2,params)[0]
    final,_=layernorm_forward(transformed,params['gf'],params['bf'])
    logits=final@params['head']+params['bias']
    return logits[0,0],{'k':keys,'v':values,'length':length+1}

Voer de code uit en verklaar voor jezelf waarom elke stap werkt.