Kwantyzacja KV cache: mieści się w pamięci, liczy 230 razy wolniej
Przy długim kontekście najwięcej pamięci karty zjada nie model, tylko KV cache. Przy 256 tys. tokenów i kilkudziesięciu warstwach uwagi różnica między f16 a 4-bitowym cache to gigabajty. Naturalny ruch to więc zejść z precyzją KV tak nisko, jak się da, i zmierzyć, ile kontekstu się zmieści. Ten ruch dwa razy dał mi rekomendację, która wyglądała świetnie i była bezużyteczna.
Objaw
Serwer wstaje bez błędu. Model ładuje się, odpowiada poprawnie, także na pytania z obrazem. W logu nie ma ostrzeżenia. VRAM ma zapas. Tylko generacja idzie w tempie, które wygląda na awarię sieci.
Pierwszy raz: model 27B na karcie A6000, KV q5_1, kontekst 524 288 przy dwóch slotach, 8,3 GB zapasu pamięci.
| KV | prefill | generacja |
|---|---|---|
f16 |
~1140 tok/s | 44–47 tok/s |
q5_1 |
37,2 tok/s | 1,9–5,4 tok/s |
Drugi raz, ponad miesiąc później, inny model 27B (ternarny, 2 bity na wagę), ta sama klasa karty, kontekst 262 144. Szukałem maksymalnego kontekstu w budżecie 11 GB i zarekomendowałem iq4_nl, bo pozwalał na 224 tys. tokenów:
| KV | generacja |
|---|---|
q4_0 |
62,3 tok/s |
q8_0 |
62,8 tok/s |
iq4_nl |
0,28 tok/s |
230 razy wolniej, bez jednego słowa w logu. Gorzej: wniosek z pierwszego razu był zapisany w moich notatkach. Nie zajrzałem do nich przed rekomendacją.
Przyczyna w kodzie
llama.cpp ma flagę kompilacji GGML_CUDA_FA_ALL_QUANTS, domyślnie wyłączoną. W ggml/src/ggml-cuda/fattn.cu funkcja sprawdzająca, czy typ KV ma jądro flash attention, zwraca wtedy false dla q4_1, q5_0 i q5_1, a osobny warunek wymaga K->type == V->type. Z typem iq4_nl jest tak samo. Przy domyślnej kompilacji zostają cztery kombinacje: f16, bf16, q4_0, q8_0, za każdym razem z tym samym typem dla K i V.
Brak jądra nie jest błędem. Wybór kończy się na „żadne jądro”, a operacja idzie ścieżką rezerwową na procesorze. Stąd poprawne odpowiedzi i katastrofalne tempo.
Przebudowa z -DGGML_CUDA_FA_ALL_QUANTS=ON kompiluje 49 par typów zamiast 4, a libggml-cuda.so rośnie ze 197,6 MB do 263,9 MB. Czy tempo q5_1 na takiej binarce wraca do normy, tego nie zmierzyłem: problem przestał być potrzebny, zanim doszedłem do pomiaru. Rozmiar biblioteki to poszlaka, nie dowód. Sprawdzenie symboli przez nm -D też niczego nie rozstrzyga, bo jądra CUDA siedzą w sekcjach fatbin, nie w tablicy symboli dynamicznych. Zanim użyję typu spoza czwórki, sprawdzam konfigurację kompilacji:
grep FA_ALL_QUANTS build/CMakeCache.txt
Wniosek, który zmienia sposób mierzenia
Drugi pomiar dał coś cenniejszego niż sam alarm: q4_0 i q8_0 różnią się tempem o 0,5%. Jeśli typ KV ma jądro, nie wpływa na szybkość. Kwantyzację KV wybiera się więc wyłącznie pod pamięć, a jedynym kryterium tempa jest pytanie binarne „czy jądro istnieje”.
Reguła, którą teraz stosuję przy każdym przeglądzie konfiguracji: bramka tempa stoi obok bramki pamięci. Wynik „mieści się” nie przechodzi, dopóki ten sam przebieg nie pokaże generacji w oczekiwanym rzędzie wielkości. Wystarczy krótki prompt i odczyt tok/s z odpowiedzi serwera, kilkanaście sekund na wariant.
Ten sam rachunek na małej karcie
Na domowej karcie z 12 GB VRAM prowadziłem model MoE Qwen3.6-35B-A3B w kwantyzacji Q4_K_M (plik 22,1 GB, czyli prawie dwa razy więcej niż karta). Cel: 256 tys. tokenów kontekstu, KV q4_0, i jak najwięcej warstw ekspertów na GPU.
W MoE to się dobrze dzieli. Uwaga, normalizacje i ekspert współdzielony są małe i liczone dla każdego tokena, więc zostają na karcie. Eksperci routowani to większość wag, ale na token pracuje ich tylko kilku, więc mogą leżeć w RAM-ie. llama.cpp ma do tego --n-cpu-moe N: wagi ekspertów z N pierwszych warstw idą do pamięci hosta. Zakres liczy się od 0 do liczby bloków z metadanych GGUF, tu 40.
| warstwy ekspertów na CPU | VRAM zajęte | VRAM wolne | generacja |
|---|---|---|---|
| 40 (wszystkie) | 6 589 MiB | 5 527 MiB | 18,3–19,8 tok/s |
| 32 | ~10 400 MiB | ~1 700 MiB | 22,7 tok/s |
| 30 | 11 282 MiB | 834 MiB | 23,5 tok/s |
Każda warstwa ekspertów przeniesiona na kartę kosztuje około 480 MiB. Zysk szybko się spłaszcza: z 40 na 32 tempo rośnie o 24%, z 32 na 30 już tylko o 3,6%. Wybrałem 32. 834 MiB wolnego to za mało, bo pod WSL2 karta jest współdzielona z pulpitem Windows, a zużycie po stronie hosta waha się w granicach gigabajta.
Poprawności nie sprawdzałem samym health-checkiem, tylko prawdziwym zapytaniem: arytmetyka (17 × 24 = 408) i kilka zdań spójnego tekstu po polsku. Health-check odpowiada „ok” także wtedy, gdy model generuje śmieci.
Lista kontrolna
- Typ KV spoza
f16,bf16,q4_0,q8_0przy domyślnej kompilacji = podejrzany, dopóki nie zmierzysz tempa. - K i V w tym samym typie, inaczej też tracisz jądro.
- Między
q4_0aq8_0decyduje pamięć, nie szybkość. - W MoE na małej karcie najpierw cała uwaga na GPU, potem dokładaj warstwy ekspertów, aż przyrost tempa przestanie być wart ryzyka braku pamięci.
- Każdą rekomendację „zmieści się” sprawdź jednym przebiegiem z odczytem tok/s.