Grafy CUDA w dekodowaniu LLM: narzut uruchamiania jąder i dlaczego zmiany kształtów kosztują
Krok dekodowania to setki małych jąder, a uruchomienie każdego z nich kosztuje czas CPU. Jak grafy CUDA przechwytują i odtwarzają krok, dlaczego rosnący cache KV ciągle je unieważnia i co w moim forku zmieniło podniesienie minimum dopełnienia KV do 4096.
Przy 82 tokenach na sekundę na krok dekodowania przypada 12,2 ms. Większość tego czasu zajmuje strumieniowanie wag. Część, jeśli nie uważasz, idzie na to, że CPU mówi GPU, co ma zrobić dalej. Grafy CUDA istnieją po to, żeby tę część usunąć, a to, jak często trzeba je budować od nowa, okazuje się mieć znaczenie.
Śmierć od tysiąca uruchomień
Krok dekodowania w głębokim modelu to długi ciąg małych jąder w każdej warstwie: normalizacje, mnożenia macierzy, RoPE, atencja, aktywacje, dodawania połączeń rezydualnych. Każde uruchomienie kosztuje CPU kilka mikrosekund. Model z kilkudziesięcioma warstwami może łatwo uruchamiać około tysiąca jąder na token. Przy około 4 µs na uruchomienie to 4 ms, jedna trzecia budżetu 12,2 ms, wydana, zanim doliczysz jitter po stronie CPU.
Ta liczba zależy od modelu i backendu, więc traktuj ją jako rząd wielkości. Ale charakter problemu jest realny: jądra dekodowania są krótkie, więc stały koszt uruchomienia to duża część czasu każdego z nich.
Przechwyć raz, odtwarzaj wiele razy
Graf CUDA zapisuje ciąg uruchomień jąder, razem z ich argumentami i zależnościami, i odtwarza całość jednym wywołaniem. Koszt kroku po stronie CPU spada z setek uruchomień do jednego, a GPU może szeregować pracę bez przerw.
Ograniczenie polega na tym, że przechwycony graf jest niezmienny. Te same jądra, te same kształty, te same adresy w pamięci. Niektóre parametry można zaktualizować w miejscu, ale jeśli zmienia się topologia albo kształty, graf trzeba przechwycić od nowa, a przechwycenie nie jest darmowe.
Dlaczego dekodowanie ciągle psuje grafy
Cache KV rośnie o jeden token w każdym kroku, więc jądra atencji w każdym kroku widzą inną długość sekwencji. Żeby grafy dało się używać ponownie, llama.cpp dopełnia widok KV do wielokrotności stałego rozmiaru i maskuje nieużywaną końcówkę. Dzięki temu kształty zmieniają się tylko wtedy, gdy kontekst przekroczy granicę dopełnienia, i dopiero wtedy graf trzeba przechwycić ponownie.
W upstreamie to dopełnienie jest wpisane na sztywno: 256 tokenów. Długa sesja agenta przekracza granicę co 256 tokenów, a każde przekroczenie kosztuje przechwycenie. W moim forku dodałem konfigurowalne minimum, LLAMA_KV_PAD_MIN, domyślnie 4096. To 16× mniej ponownych przechwyceń niż w upstreamie.
Ceną wyższego minimum jest więcej zamaskowanych pozycji w dopełnionym widoku. Najbardziej ma to znaczenie przy krótkich kontekstach, a przy długich jest szumem.
Gdzie CPU wciąż ma znaczenie
Grafy zmniejszają pracę CPU, ale jej nie usuwają: próbkowanie, szeregowanie, księgowość dekodowania spekulatywnego i pętla serwera wciąż działają na jednym gorącym wątku. W desktopowych procesorach z hybrydowym układem rdzeni trafienie tego wątku na rdzeń energooszczędny widać w opóźnieniu. Mój fork zawiera propozycję przypinania gorącego wątku do rdzenia wydajnościowego (P-core), napisaną pod i9-12900K.
Jak to zobaczyć
- Sprofiluj dekodowanie w Nsight Systems (
nsys profile). Przerwy między jądrami na osi czasu GPU to narzut CPU, za który płacisz. - Porównaj z wyłączonymi grafami. Backend CUDA w llama.cpp respektuje
GGML_CUDA_DISABLE_GRAPHS=1, co daje szybki test A/B tego, ile grafy oszczędzają na Twoim modelu. - Obserwuj ponowne przechwycenia w długiej sesji. Jeśli liczba tokenów na sekundę spada w regularnych odstępach, jednym z podejrzanych są granice dopełnienia.
Zasady, których się trzymam
- Zmierz narzut uruchomień, zanim zaczniesz optymalizować jądra. W małych albo mocno skwantyzowanych modelach może być większym kawałkiem niż którekolwiek pojedyncze jądro.
- Utrzymuj stabilne kształty. Dopełnienie, stałe rozmiary partii i wstępnie zaalokowane bufory istnieją po to, żeby grafy pozostawały ważne.
- Daj gorącemu wątkowi szybki rdzeń. To najtańsza poprawka opóźnienia na desktopowej maszynie.
Źródła: NVIDIA, CUDA Graphs programming guide. Minimum dopełnienia KV i propozycja dla P-core są w moim forku. Liczby dotyczące narzutu uruchomień to szacunek poglądowy, a nie wynik profilowania konkretnego modelu.