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.

Dlaczego to ważne dla lokalnego LLM

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ą.

  1. 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
  2. Potem skwantyzuj cache: -ctk q8_0 -ctv q8_0. q8_0 to 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
  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


  1. 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↩︎ ↩︎ ↩︎ ↩︎ ↩︎ ↩︎

  2. 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ślnie f16) oraz -fa/--flash-attn (on/off/auto, domyślnie auto), common/arg.cpp. Te same parametry w dokumentacji serwera↩︎ ↩︎ ↩︎

  3. q8_0 = 1 bajt na element (ok. 2× mniej niż f16), q4_0 = pół bajta (ok. 4× mniej niż f16); dokładne MB zależą od n_layer, n_kv_heads, head_dim i długości kontekstu konkretnego modelu. Typy zdefiniowane w llama.cpp, common/arg.cpp↩︎ ↩︎

  4. 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↩︎

  5. 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↩︎ ↩︎

  6. 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↩︎ ↩︎ ↩︎ ↩︎