Mariusz Tober

Kwantyzacja KV cache: mieści się w pamięci, liczy 230 razy wolniej

4 min czytania#llm#llama.cpp#kwantyzacja#gpu

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

← Wszystkie wpisy