Znasz ten moment, kiedy model ładnie wszedł na kartę, zostało ci jeszcze ze dwa giga
zapasu, odpalasz dłuższą rozmowę albo wklejasz wielki plik do streszczenia — i po
kilkuset tokenach terminal wita cię tym samym, co zawsze: CUDA out of memory. Cała
na biało. I człowiek stoi zdziwiony, bo przecież model się mieścił. Wagi się nie
rozrosły. Karta nie zmalała. To co, u licha, zeżarło resztę pamięci między jednym
tokenem a drugim?
To wręcz podręcznikowy scenariusz — ktoś odpala 7B na karcie 8 GB, chwali się, że działa, a dwa dni później wraca z płaczem, że „przy dłuższym kontekście się sypie". I zawsze pada to samo pytanie w komentarzach: a ustawiłeś sobie cache? No właśnie. Bo obok wag, które raz się ładują i grzecznie leżą, siedzi drugi żłob na VRAM — taki, który rośnie z każdym słowem, jakie model przeczyta albo napisze. Nazywa się KV-cache i jest głównym podejrzanym w prawie każdej sprawie „mieściło się, a potem przestało".
Skąd w ogóle bierze się ten cache
Cofnijmy się o krok. Model generuje tekst token po tokenie — jeden na raz, a każdy kolejny patrzy wstecz na całą dotychczasową historię. Żeby policzyć uwagę (attention) dla nowego tokenu, potrzebuje wektorów K (key) i V (value) dla wszystkiego, co było wcześniej. I teraz klucz do zrozumienia: te wektory się nie zmieniają. K i V dla trzeciego tokenu są takie same, kiedy generujesz token dziesiąty, jak wtedy, gdy generowałeś czwarty.
Skoro się nie zmieniają — po co je liczyć od nowa przy każdym kroku? No i nie liczymy. KV-cache to dokładnie ten bufor, w którym model odkłada raz wyliczone wektory K i V dla każdego przetworzonego tokenu, w każdej warstwie, żeby przy generowaniu następnego nie przemielać całej historii od zera.1 To sprytny handel: dokładasz pamięci, żeby oszczędzić obliczenia. Bez niego każdy nowy token oznaczałby przeliczanie całego kontekstu w kółko — decode zamieniłby się w mękę.
Cache jest więc dobry. Problem w tym, że za oszczędność obliczeń płacisz pamięcią. A ta pamięć nie jest stała.
Dlaczego on rośnie, a wagi nie
Tu jest sedno całej sprawy, więc rozłóżmy to powoli. Wagi modelu to wartość stała. Ładujesz plik GGUF raz, siada na karcie i tyle — czy generujesz jeden token, czy dziesięć tysięcy, wagi zajmują dokładnie tyle samo. KV-cache zachowuje się odwrotnie: puchnie z każdym tokenem, jaki wpadnie w okno kontekstu.1
Ile dokładnie puchnie? Mechanizm da się złapać jednym wzorem, który krąży po całej scenie:
rozmiar_KV = 2 × n_layer × n_kv_heads × head_dim × długość_kontekstu × bajty_na_element
Rozbierzmy to na palcach. Dwójka na przedzie to osobno tensor K i osobno tensor V —
trzymasz dwa, stąd współczynnik.1 n_layer to liczba warstw transformera; cache
odkłada się w każdej z osobna. n_kv_heads to liczba głów KV, head_dim to wymiar
jednej głowy. A długość_kontekstu — no i tu jest pies pogrzebany: to jedyny człon,
który rośnie w trakcie. Reszta to stałe architektury danego modelu.
Zauważ, co z tego wynika: rozmiar cache rośnie liniowo z długością kontekstu. Podwoisz kontekst — podwoisz cache. I liniowo z liczbą warstw oraz głów KV. Ani razu w tym wzorze nie pada rozmiar wag.1 To dwa niezależne budżety pamięci.
Częsty mit z forów: „KV-cache rośnie kwadratowo z kontekstem". Nie. Sam bufor rośnie liniowo — podwajasz tokeny, podwajasz cache. Kwadratowy jest koszt obliczeniowy samej operacji attention w naiwnej implementacji (i pamięć pośrednia, którą ona zżera po drodze) — i to właśnie ten kwadratowy narzut łagodzi flash attention. Pamięć samego cache to prosta linia w górę.1
Skoro cache rośnie z kontekstem, a wagi stoją w miejscu, to przy dostatecznie długim
oknie następuje przecięcie: KV-cache zaczyna dominować zużycie VRAM, a przy naprawdę
długim kontekście potrafi przerosnąć rozmiar samych wag.1 I stąd twoje out of memory w połowie rozmowy — nie model spuchł, tylko jego pamięć krótkotrwała napęczniała
do rozmiarów, których karta nie udźwignęła. Jeśli chcesz policzyć ten budżet z wyprzedzeniem,
zamiast zgadywać, sam wzór na VRAM rozkładaliśmy krok po kroku tutaj.
Jak go ścisnąć — trzy dźwignie
Dobra wiadomość: skoro wiesz, z czego składa się wzór, wiesz też, gdzie przyłożyć. Masz trzy dźwignie, każda ciągnie za inny człon.
Kwantyzacja cache — najprostszy zysk
Spójrz na ostatni człon wzoru: bajty_na_element. Domyślnie cache K i V w llama.cpp
siedzą w f16, czyli po 2 bajty na element2 — a czym f16 różni się od bf16, tłumaczymy tutaj. A nikt nie powiedział, że musi
tak zostać. llama.cpp daje dwie flagi — -ctk (--cache-type-k) i -ctv
(--cache-type-v) — którymi ustawiasz typ danych osobno dla cache K i cache V.
Dozwolone wartości to f32, f16, bf16, q8_0, q4_0, q4_1, iq4_nl, q5_0,
q5_1; domyślnie oba stoją na f16.2
Matematyka jest tu boleśnie prosta. q8_0 to 1 bajt na element zamiast dwóch — czyli
z grubsza dwa razy mniej pamięci na cache. q4_0 to pół bajta — jakieś cztery
razy mniej niż f16.3 Nie podam ci, ile to konkretnie megabajtów, bo to zależy
od n_layer, n_kv_heads, head_dim i długości kontekstu twojego modelu — kto rzuca
sztywnym „zaoszczędzisz 3 GB", ten zgaduje. Ale proporcja trzyma się zawsze: ścinasz
typ, ścinasz cache w tym samym stosunku.
llama-server -m model-q4_k_m.gguf -c 8192 -fa on -ctk q8_0 -ctv q8_0
I tu ważne rozróżnienie, na którym potyka się pół forum: to nie jest to samo, co
kwantyzacja wag. Wagi kwantyzujesz przy pakowaniu GGUF-a (te wszystkie Q4_K_M
i spółka — nazewnictwo rozbieraliśmy tutaj).
Cache kwantyzujesz flagami runtime, w locie. To dwa niezależne mechanizmy. Ściśnięcie
wag do 4 bitów ani o bajt nie zmniejszy KV-cache, i odwrotnie. Ktoś odpala model
w Q4_K_M i dziwi się, że kontekst dalej zjada pamięć — no bo cache dalej siedzi
w f16, dopóki mu tego ręcznie nie zmienisz.
Flash attention — i warunek wstępny
Druga dźwignia to -fa (--flash-attn). Flaga przyjmuje on, off albo auto,
domyślnie stoi na auto.2 Co robi flash attention? To nie żadne przybliżenie
uwagi — i tu kolejna pułapka do ominięcia. FlashAttention (Dao i in., NeurIPS 2022)
to algorytm dokładny (exact): liczy dokładnie to samo, co naiwna uwaga, wynik
liczbowy jest identyczny.4 Sztuczka jest gdzie indziej — jest „IO-aware", czyli
ogranicza liczbę odczytów i zapisów między wolną pamięcią HBM karty a szybką pamięcią
on-chip SRAM. Efekt: szybciej i z mniejszym narzutem pamięci pomocniczej niż naiwna
implementacja uwagi.
Ale flash attention ma tu jeszcze jedną, praktyczną rolę — jest warunkiem wstępnym
kwantyzacji cache V. W llama.cpp nie da się skwantyzować cache V bez włączonego flash
attention; implementacja zwyczajnie odmawia startu, rzucając runtime error w rodzaju
quantized V cache was requested, but this requires Flash Attention.5 Czyli jeśli chcesz -ctv q8_0, musisz najpierw dać -fa on.
Bez tego dostaniesz błąd, nie oszczędność.
GQA — dźwignia, której nie przełączysz
Trzeci człon wzoru to n_kv_heads. Im mniej głów KV, tym mniejszy cache — wprost,
liniowo. I dokładnie na to celuje Grouped-Query Attention (GQA, Ainslie i in.,
EMNLP 2023).6 Idea: zamiast tylu głów KV, ile jest głów zapytań (pełna uwaga
wielogłowowa, MHA), albo tylko jednej wspólnej (multi-query attention, MQA), bierzesz
liczbę pośrednią. Kilka głów zapytań dzieli jedną głowę KV. Skoro n_kv_heads w naszym
wzorze spada — spada i cache, przy jakości bliskiej pełnemu MHA i szybkości bliskiej
MQA.6
Co ciekawe, model GQA nie powstaje od zera. Bierze się istniejący checkpoint MHA i „doucza" (uptraining) przy jakichś 5% oryginalnego budżetu treningu — tanio, bez budowania nowej architektury od podstaw.6
Jest tylko jeden haczyk, o którym musisz pamiętać: GQA to nie przełącznik. To cecha architektury zapieczona w modelu podczas treningu. Nie ma flagi CLI, która „włączy GQA" na dowolnym modelu. Możesz co najwyżej wybrać model, który już ma GQA — a większość nowszych 7B/8B ma — i taki z natury nosi mniejszy cache przy tej samej długości kontekstu co stary model z pełnym MHA. To kryterium przy pobieraniu, nie pokrętło w terminalu.
Zrób to sam: kolejność ratowania pamięci
No dobra, dość teorii — masz out of memory i chcesz uratować kontekst. Kolejność
ma znaczenie, bo pierwsze ruchy kosztują cię grosze na jakości, a ostatnie bolą.
- Najpierw
-fa on. Ścina narzut pamięci samej operacji uwagi i — co równie ważne — odblokowuje kwantyzację cache V. To krok zero, nie negocjujemy.5 - Potem skwantyzuj cache:
-ctk q8_0 -ctv q8_0.q8_0to bezpieczny wybór — połowa pamięci cache, strata jakości ledwo wyczuwalna. Dopiero gdy VRAM dalej piszczy, schodź do-ctk q4_0 -ctv q4_0— ćwiartka pamięci, ale za to już płacisz odczuwalniej jakością.3 - Wybierając model, celuj w te z GQA. Tego nie zrobisz flagą po fakcie — to decyzja przy pobieraniu. Model z GQA startuje z mniejszym cache, zanim jeszcze dotkniesz jakiegokolwiek przełącznika.6
# krok 1 + 2 razem — tak wygląda ratunek w praktyce
llama-server -m model-q4_k_m.gguf -c 16384 \
-fa on -ctk q8_0 -ctv q8_0
Dopiero kiedy to wszystko wyczerpiesz, a i tak nie wchodzi — wtedy tnij samą długość
kontekstu (-c) albo schodź z warstw na GPU (-ngl). Ale to już ostatnia deska
ratunku, bo pierwsze odbiera modelowi pamięć, a drugie prędkość. Sam zestaw flag i to, co która
robi, przeszliśmy po kolei w osobnym wpisie o flagach llama.cpp.
Pomyśl o tym tak. Wagi modelu to biblioteka — stoi w regale, zajmuje swoje półki i tyle, nieważne, ile razy do niej zajrzysz. KV-cache to biurko, na którym rozkładasz wszystko, co akurat czytasz. Im dłużej pracujesz nad jednym tematem, tym więcej kartek ląduje na blacie — i w pewnym momencie nie ma gdzie postawić kubka, choć regał ani drgnął. Kwantyzacja cache to składanie tych kartek na pół, flash attention to sprzątanie w locie, GQA to od razu mniejszy stos notatek na tę samą robotę.
Karta się nie skurczyła. To po prostu twoja rozmowa urosła — a teraz już wiesz,
który człon wzoru za to odpowiada i za które pokrętło pociągnąć, zanim znów zobaczysz
to out of memory.
Przypisy · źródła
Mechanizm KV-cache: bufor par klucz-wartość liczonych raz na token i warstwę, o rozmiarze
2 × n_layer × n_kv_heads × head_dim × długość_kontekstu × bajty_na_element— rośnie liniowo z kontekstem, niezależnie od rozmiaru wag; kwadratowy jest jedynie koszt obliczeniowy naiwnej uwagi. Wyprowadzenie mechanizmu spójne z definicją flash attention w FlashAttention (Dao i in.), arXiv:2205.14135. ↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎llama.cpp, definicje flag
-ctk/--cache-type-k,-ctv/--cache-type-v(dozwolone typy:f32,f16,bf16,q8_0,q4_0,q4_1,iq4_nl,q5_0,q5_1; domyślnief16) oraz-fa/--flash-attn(on/off/auto, domyślnieauto), common/arg.cpp. Te same parametry w dokumentacji serwera. ↩︎ ↩︎ ↩︎q8_0= 1 bajt na element (ok. 2× mniej niżf16),q4_0= pół bajta (ok. 4× mniej niżf16); dokładne MB zależą odn_layer,n_kv_heads,head_dimi długości kontekstu konkretnego modelu. Typy zdefiniowane w llama.cpp, common/arg.cpp. ↩︎ ↩︎FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness (Dao, Fu, Ermon, Rudra, Ré, NeurIPS 2022) — dokładny, IO-aware algorytm uwagi redukujący odczyty/zapisy między HBM a SRAM GPU; nie jest aproksymacją uwagi, arXiv:2205.14135. ↩︎
Zależność wymuszona w kodzie llama.cpp: przy wyłączonym flash attention skwantyzowany cache V rzuca runtime error
quantized V cache was requested, but this requires Flash Attention— sprawdzenie w konstruktorze kontekstu, src/llama-context.cpp. ↩︎ ↩︎GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints (Ainslie i in., EMNLP 2023) — grouped-query attention jako interpolacja między MHA a MQA, redukcja liczby głów KV, uptraining z ok. 5% oryginalnego budżetu treningu, arXiv:2305.13245. ↩︎ ↩︎ ↩︎ ↩︎