Mariusz Tober

Jak naprawdę wykorzystać GPU do inferencji LLM

6 min czytania#gpu#inferencja#llama.cpp#optymalizacja

Optymalizując inferencję lokalnych modeli językowych, szybko przekonałem się, że sama szybkość generowania tokenów mówi niewiele o wydajności całego systemu.

Model może generować tekst błyskawicznie, ale wcześniej przez kilka minut przetwarzać instrukcje agenta. Może mieścić się w VRAM, ale wykonywać część obliczeń na CPU. Może też przyspieszać w pojedynczym teście, a zwalniać, gdy pojawi się drugie zapytanie.

Sprawdzałem to na lokalnej RTX 3060 oraz wynajmowanych A6000 i L40S. Testowałem różne modele, sposoby zarządzania pamięcią, cache i dekodowanie spekulatywne. Część pomysłów wdrożyłem, część odrzuciłem, a kilka pozostaje na papierze.

Najważniejsza lekcja: optymalizacja jednej części systemu nie musi poprawiać wydajności całości.

Najpierw uporządkowałem zarządzanie modelami

Aplikacje łączą się z bramą LiteLLM, a nie bezpośrednio z serwerami GPU. Korzystają ze stałych nazw ról, więc mogę podmieniać modele bez zmiany kodu aplikacji.

Za bramą działa llama.cpp, a przełączaniem modeli zajmuje się llama-swap lub wbudowany router llama-server.

Na RTX 3060 pozwalam załadować tylko jeden model naraz. Kolejne modele wchodzą do pamięci na żądanie, a bezczynne są wyładowywane. Dzięki temu na niewielkiej karcie mogę korzystać z kilku modeli, choć nie jednocześnie.

Na L40S sytuacja jest trudniejsza, bo karta obsługuje również ComfyUI. Przed generowaniem ciężkiego wideo trzeba zwolnić pamięć zajętą przez LLM. Sam przełącznik potrafił jednak zostawić model w VRAM, blokując następny. Kończyło się to błędami HTTP 500. Rozwiązaniem okazało się jawne wyładowywanie konkretnego modelu.

Sprawdziłem również współdzielenie pamięci KV przez równoległe zapytania.

Zwykłe --parallel 2 dzieli kontekst na dwie części. Z --kv-unified oba sloty korzystają ze wspólnej puli. Na A6000 mogłem dzięki temu przetwarzać dużą sesję agenta i jednocześnie szybko odpowiadać na krótkie pytanie.

To działa, ale nie gwarantuje miejsca dla każdego zapytania. Przy przepełnieniu puli drugie zadanie zwykle czekało, ale raz obie sesje dostały błąd. Wspólna pamięć poprawia elastyczność, nie usuwa ograniczeń pojemności.

Największy koszt znalazłem przed pierwszym tokenem

Agent kodujący wysyła do modelu znacznie więcej niż samo polecenie użytkownika.

Instrukcje, pamięć projektu, lista skilli i definicje narzędzi MCP trafiają do kontekstu jeszcze przed rozpoczęciem pracy. W moim przypadku pierwsze zapytanie opencode zawierało około 26 tysięcy tokenów.

Na lokalnym modelu Qwen3.6-35B samo przetworzenie takiego wejścia oznaczało kilka minut oczekiwania na pierwszą odpowiedź.

Sprawdziłem więc, co się stanie po wyłączeniu skilli i MCP. Rozmiar początkowego żądania zmniejszył się o 39%, a czas pierwszej odpowiedzi spadł z ponad trzech minut do niespełna dwóch.

To był konkretny zysk, choć okupiony usunięciem części funkcjonalności agenta.

Próbowałem również innych metod oszczędzania tokenów. Kompresowanie promptów potrafiło znacząco zmniejszyć ich rozmiar, ale niszczyło kod. Eksperyment z własnymi skróconymi tokenami sterującymi praktycznie nic nie dał. Wyłączenie rozumowania też niewiele przyspieszyło pracę, a model zaczął popełniać błędy.

Wniosek był prosty: zanim zacznę kompresować kontekst, powinienem przestać wysyłać niepotrzebne informacje.

Cache zmienia wszystko, dopóki działa

Jeszcze większą różnicę zauważyłem przy ponownym wykorzystaniu przetworzonego promptu.

Pierwsza tura rozmowy z lokalnym modelem trwała ponad trzy minuty. Kolejne, dzięki cache prefiksu, kończyły się po kilku sekundach.

Problem pojawił się po otwarciu nowego projektu.

Prompt systemowy agenta zawiera ścieżkę katalogu roboczego. Jej zmiana powodowała utratę dopasowania do zapisanego prefiksu. Model ponownie przetwarzał cały początek rozmowy.

Sprawdziłem również cache promptów przechowywany w RAM. Pozwalał odzyskać wcześniej przetworzony prompt nawet po nadpisaniu slotu. Przy krótszych promptach skracało to prefill z kilku sekund do ułamka sekundy.

Nie wdrożyłem jeszcze stabilnego promptu systemowego niezależnego od katalogu. Nie zmierzyłem też, jak cache zachowa się przy wielu użytkownikach wypychających wzajemnie swoje sesje.

W projektowanej platformie firmowej pozostaje dodatkowy problem: izolacja danych. Cache KV współdzielony między projektami może ujawniać informacje przez różnice czasów odpowiedzi. Dlatego w MVP przewiduję jego wyłączenie do czasu zapewnienia izolacji.

VRAM to nie tylko pojemność

Na RTX 3060 każdy gigabajt ma znaczenie. Szczególnie przy długim kontekście i dużych modelach.

Pierwsza pułapka to automatyczne dopasowywanie modelu do dostępnej pamięci. llama.cpp potrafi przenieść część obliczeń na CPU, nie informując o tym w oczywisty sposób.

W jednym z testów model wykorzystywał zaledwie część GPU i działał wolno. Wymuszenie umieszczenia warstw na GPU przez -ngl 999, wraz ze zmianą typu KV, dało kilkukrotny wzrost szybkości.

W WSL2 przekroczenie VRAM bywa jeszcze bardziej podstępne. Zamiast błędu pojawia się ciche korzystanie z pamięci systemowej. Generacja zwalnia, komputer zaczyna reagować z opóźnieniem, a krótki test może nie wykazać problemu.

Dlatego zostawiam zapas na pulpit Windows i sprawdzam zachowanie przy długim kontekście, nie tylko po załadowaniu modelu.

Kwantyzacja KV potrafi pomóc albo zaszkodzić

Przejście z f16 na q8_0 lub q4_0 pozwala znacznie zmniejszyć pamięć zajmowaną przez KV cache.

Na RTX 3060 dzięki temu mogłem uruchamiać modele z długimi oknami kontekstu, które przy f16 nie działały poprawnie.

Ale mniejszy KV nie zawsze oznacza lepszy wynik.

Niektóre typy kwantyzacji nie miały odpowiednich jąder flash-attention w użytej kompilacji. Obliczenia trafiały na CPU, a generacja stawała się nawet około 230 razy wolniejsza. Bez wyraźnego ostrzeżenia w logach.

Na A6000 zauważyłem inny problem. q4_0 działało dobrze przy krótkim kontekście, ale przy bardzo długim spowalniało generację o około jedną czwartą.

Wróciłem więc do q8_0, również z obawy o jakość. Nie mam jednak pomiaru potwierdzającego, że ten powrót poprawił jakość odpowiedzi lub usunął spadek szybkości.

Podobny kompromis dotyczy wag. Mały model w Q4_K_M wyraźnie częściej wpadał w pętle niż w Q8_0, mimo podobnej trafności na pytaniach testowych. Dlatego małe modele wolę uruchamiać w wyższej precyzji.

Dekodowanie spekulatywne: więcej nie znaczy szybciej

Kolejnym obszarem eksperymentów było MTP, czyli przewidywanie kilku następnych tokenów i ich wspólna weryfikacja.

Na RTX 3060 dawało to wyraźne przyspieszenie, szczególnie dla przewidywalnego tekstu i kodu. Swobodna proza zyskiwała mniej.

Na A6000 zacząłem sprawdzać, czy głębsza spekulacja poprawi wynik.

Okazało się, że model akceptował więcej tokenów na krok, ale całkowita szybkość spadała. Serwer był mocno obciążony, a odrzucone propozycje zużywały zasoby potrzebne drugiemu slotowi.

To obserwacja z różnych okresów ruchu i nierównych próbek, więc traktuję ją jako wskazówkę, nie ostateczny dowód. Pokazuje jednak ważny mechanizm: więcej zaakceptowanych tokenów na krok nie gwarantuje większej przepustowości serwera.

Testowałem też bezmodelowe n-gramy. Przy przepisywaniu fragmentów kodu dawały ogromne przyspieszenie, ale przy zwykłej prozie nie pomagały. Wdrożyłem je dla lokalnego modelu kodującego.

Klasyczna spekulacja z osobnym modelem pomocniczym wypadła gorzej. Draft był zbyt duży względem modelu głównego i koszt jego uruchamiania przewyższał korzyści.

Nie wszystko, co zaprojektowałem, już działa

Mam również projekt routera kierującego proste zadania do słabszych modeli, a trudne do mocniejszych. Nie jest jeszcze wdrożony.

Praktyczny przykład pokazał jednak sens takiego podziału. Agent kodujący używał głównego modelu nawet do generowania tytułu sesji, za każdym razem przetwarzając ogromny prompt. Przeniesienie tego zadania na inny model usunęło niepotrzebny koszt.

Ważne, by mierzyć koszt poprawnie wykonanego zadania, a nie samego wywołania. Tani model, po którym trzeba uruchomić drogi, może okazać się droższym rozwiązaniem.

Napisałem też dyspozytor nocnych benchmarków. Ma czekać na bezczynność serwera, zwalniać modele i uruchamiać zadania wsadowe.

Bieg wycofałem przed startem ze względu na zmianę benchmarku. Skrypt istnieje, ale nie mam wyników potwierdzających jego skuteczność.

Najważniejszy okazał się sposób mierzenia

Raz wyciągnąłem błędny wniosek, bo inny proces obciążał GPU podczas testu. Na współdzielonej karcie nawet generowanie audio potrafiło radykalnie obniżyć szybkość LLM.

Teraz przed pomiarem sprawdzam obciążenie w nvidia-smi, powtarzam próby i korzystam z metryk konkretnego zadania. Pilnuję też, żeby cache nie fałszował wyników testów, które mają mierzyć pełny prefill.

Nie szukam już jednej najlepszej konfiguracji llama.cpp.

Innych ustawień wymaga pojedyncza długa sesja agenta, innych wiele krótkich zapytań, a jeszcze innych karta współdzielona z generowaniem wideo.

Największe oszczędności nie zawsze pochodzą z szybszego modelu. Czasem wystarczy nie wysyłać zbędnych tokenów, zachować przetworzony prefiks albo nie uruchamiać dużego modelu do banalnego zadania.

Ostatecznie liczy się nie liczba tokenów na sekundę w pustym teście, lecz czas wykonania rzeczywistej pracy, jej jakość i zachowanie systemu pod obciążeniem.

← Wszystkie wpisy