Dekodowanie spekulatywne, wczesne zakończenie i kwantyzacja: szybsze dekodowanie LLM
W jaki sposób dekodowanie spekulatywne i wczesne zakończenie skracają opóźnienie wytwarzania wyników przez LLM, kiedy przyjęcie wstępne przynosi korzyści, oraz jak łączą się one z kwantyzacją i paginacją pamięci KV.
Źródła: **Foundations of LLM Inference Optimization: Speculative Decoding and Early Exit | Part 3D**
Proces inferencji LLM przebiega w dwóch fazach. Prefill przetwarza całe zapytanie równolegle i może utrzymać GPU w aktywności. Decode wypuszcza po jednym tokenie na raz, a każdy token zależy od poprzedniego, więc sprzęt przeważnie czeka. To sekwencyjne uzależnienie jest powodem, dla którego opóźnienia w interakcjach pozostają duże, nawet jeśli liczba operacji FLOPs wydaje się duża na papierze.
Prefill phase:
Input: [token_1, token_2, ..., token_512] → all 512 tokens processed in parallel
Matrix shape: [batch, 512, 4096]
GPU utilization: high — large matrix, full Tensor Core throughput
Decode phase (one step):
Input: [token_513] → one token processed
Matrix shape: [batch, 1, 4096]
GPU utilization: low - tiny matrix, most CUDA cores idle
Dlaczego dekodowanie znajduje się na krytycznej ścieżce
Na każdym kroku dekodowania model wysyła zapytania do coraz większej pamięci cache typu KV. Obciążenie na krok rośnie wraz z długością kontekstu, a w przypadku czatu nie można swobodnie grupować zapytań, ponieważ wskaźniki SLO dotyczące opóźnień określają maksymalny czas, jaki żądanie może czekać na odpowiedź od firmy. Obliczenia pokazują, jak mało pamięci chipu faktycznie wykorzystuje pojedynczy krok:
One attention head, decode step:
Query Q: [8, 1, 128] → 8 × 128 = 1,024 elements
Keys K: [8, 2048, 128] → 8 × 2048 × 128 = 2M elements
Values V: [8, 2048, 128] → same
Matrix multiply (QKᵀ) per head:
Shape: [8, 1, 128] × [8, 128, 2048] → [8, 1, 2048]
FLOPs: 8 × 1 × 128 × 2048 ≈ 2.1M FLOPs per head
For 32 heads: ≈ 67M FLOPs per layer
For 32 layers: ≈ 2.1B FLOPs total per decode step
A100 peak at BF16: ~312 TFLOPS = 312 × 10¹² FLOPs/sec
Time to execute 2.1B FLOPs at 100% utilization:
2.1 × 10⁹ / 312 × 10¹² ≈ 0.0067 ms
Actual observed decode latency per step: ~10–30ms
Effective compute utilization: < 0.1%
Poniżej 0,1% efektywnego wykorzystania oznacza, że dekodowanie jest ograniczane przez ruch pamięci i liczbę kroków, a nie samym obliczaniem. Istnieją dwa sposoby na radzenie sobie z tym problemem: wytworzenie większej liczby akceptowalnych tokenów na kosztowny krok lub wykonywanie mniej pracy na krok. Dekodowanie spekulatywne realizuje pierwszą z tych strategii, natomiast wczesne wyjście – drugą.
Dekodowanie spekulatywne: projekt, a potem weryfikacja
Niewielki model roboczy proponuje kilka przyszłych tokenów. Model docelowy sprawdza je wszystkie w jednej równoległej transmisji, zachowuje najdłuższy wspólny prefiks i ponownie próbuje przy pierwszej odrzuceniu. Każdy akceptowany token roboczy to token, za który nie zapłaciłeś pełnej ceny kroku modelu docelowego.
Without speculative decoding:
Generate 5 tokens: 5 × 30ms = 150ms
With speculative decoding (K=4):
Draft 4 tokens: 4 × 1.5ms = 6ms
1 target verification pass: ~35ms (slightly longer than decode,
processes K+1=5 positions)
Expected accepted tokens per round:
(1 - 0.80^5) / (1 - 0.80) ≈ 3.36 tokens
Time per round: 6ms + 35ms = 41ms
Time per token: 41ms / 3.36 ≈ 12.2ms
Speedup: 30ms → 12.2ms ≈ 2.5×
Szybkość zależy wyłącznie od tego, jak często projekt i tekst docelowy są zgodne. Prosty tekst o niskiej entropii przyjmuje długie wersje projektów; nieoczekiwany token skraca rundę. Oto pętla w formie koncepcyjnej (rzeczywiste implementacje zarządzają również pamięcią cache KV w obu modelach):
import torch
import torch.nn.functional as F
def speculative_decode(target_model, draft_model, input_ids,
max_new_tokens, K=4, temperature=1.0):
"""
Conceptual speculative decoding loop.
Real implementations handle KV cache management across both models.
"""
generated = input_ids.clone()
while generated.shape[1] - input_ids.shape[1] < max_new_tokens:
# --- Draft phase ---
draft_tokens = []
draft_probs = []
draft_input = generated.clone()
for _ in range(K):
with torch.no_grad():
draft_logits = draft_model(draft_input).logits[:, -1, :]
q = F.softmax(draft_logits / temperature, dim=-1)
token = torch.multinomial(q, num_samples=1)
draft_tokens.append(token)
draft_probs.append(q)
draft_input = torch.cat([draft_input, token], dim=1)
# --- Verify phase: one target forward pass over all K+1 positions ---
verify_input = torch.cat([generated] + draft_tokens, dim=1)
with torch.no_grad():
target_logits = target_model(verify_input).logits
# target_logits[:, -K-1:, :] covers all K draft positions + bonus
# --- Accept/reject ---
accepted = 0
for i in range(K):
p = F.softmax(target_logits[:, -(K+1)+i, :] / temperature, dim=-1)
q = draft_probs[i]
token = draft_tokens[i]
# Acceptance probability
accept_prob = torch.min(
torch.ones_like(p.gather(1, token)),
p.gather(1, token) / (q.gather(1, token) + 1e-9)
)
if torch.rand(1) < accept_prob:
generated = torch.cat([generated, token], dim=1)
accepted += 1
else:
# Sample corrected token and stop this round
corrected_dist = F.relu(p - q)
corrected_dist = corrected_dist / corrected_dist.sum(dim=-1, keepdim=True)
corrected_token = torch.multinomial(corrected_dist, num_samples=1)
generated = torch.cat([generated, corrected_token], dim=1)
break
else:
# All K accepted - take bonus token
bonus_logits = target_logits[:, -1, :]
p_bonus = F.softmax(bonus_logits / temperature, dim=-1)
bonus_token = torch.multinomial(p_bonus, num_samples=1)
generated = torch.cat([generated, bonus_token], dim=1)
return generated
Jeśli to możliwe, wybierz wersję roboczą z tej samej rodziny, np. wersję destylowaną lub kwantyzowaną, i mierz wskaźnik akceptacji na własnym ruchu, a nie na przykładach z publicznych blogów.
Gdy wersje robocze się różnią
Jeśli rozkład wersji roboczej odbiega od rozkładu docelowego, wskaźnik akceptacji spada, a ty płacisz za tę wersję praktycznie za nic. Monitoruj akceptację stale i wróć do zwykłego dekodowania, gdy spadnie.
Szybka weryfikacja przy zaokrąglonych liczbach: załóżmy, że jeden krok docelowy kosztuje 10 ms, a wersja robocza proponuje 5 tokenów, z których średnio przyjmuje się około 3 (60%). Koszt za przyjęty token jest niższy w porównaniu z krokami składającymi się z jednego tokena, nawet po uwzględnieniu dodatkowych kosztów wersji roboczej, o ile wskaźnik akceptacji pozostaje wysoki. Jeśli akceptacja spadnie do poziomu około jednego tokena, ten schemat przegrywa. To właśnie ta wrażliwość sprawia, że tablice kontrolne są lepsze od anegdot.
Wczesne zakończenie: pomijanie głębszych warstw przy prostych tokenach
Część architektur pozwala tokenowi opuścić sieć na warstwie pośredniej, gdy poziom pewności jest wysoki, co oszczędza zasoby obliczeniowe przy obsłudze interpunkcji, artykułów oraz innych przewidywalnych tokenów.
Layer distribution of exits:Exit at layers 1–8 (very easy tokens like punctuation, articles): 15%
Exit at layers 9–16 (medium tokens, common continuations): 35%
Exit at layers 17–24 (harder tokens, named entities, numbers): 30%
Exit at layers 25–32 (full computation required): 20%Weighted average layers executed:
0.15 × 6 + 0.35 × 12 + 0.30 × 20 + 0.20 × 32
= 0.90 + 4.20 + 6.00 + 6.40
= 17.5 layers averageSpeedup vs always running 32 layers:
32 / 17.5 ≈ 1.83×
W odróżnieniu od dekodowania spekulatywnego, wczesne zakończenie faktycznie zmienia wyniki, gdy pomija określone warstwy, zgodnie z założeniami. Ofiarowuje jakość na rzecz szybkości i w dużej mierze zależy od tego, jak dobrze skalibrowana jest pewność wyników, dlatego należy uwzględnić różnicę w dokładności i ją zmierzyć.
Łączenie z kwantyzacją, grupowaniem i pamięcią cache KV
Dekodowanie spekulatywne modyfikuje pętlę proponowania tokenów poprzez dodanie drugiego modelu; wczesne zakończenie zmienia głębokość przetwarzania w obrębie jednego modelu. Oba napotykają powiązane wąskie gardła w różny sposób, a czasami można je łączyć z kwantyzacją, ciągłym grupowaniem danych oraz paginacją pamięci cache KV:
Combined stack example:
Target: 7B model, BF16, FlashAttention, PagedAttention
→ Model: ~14 GB, memory-efficient attention, paged KV cache
Draft: 70M model, INT4, FlashAttention
→ Model: ~35 MB, near-zero memory overhead
Speculative decode:
→ with K=4, α=0.80
→ ~2.5× token generation speedup on long outputs
Full stack speedup vs FP32 no-optimization baseline:
- Quantization: 2–2.3× throughput
- FlashAttention: 20–40% attention latency reduction
- Speculative decoding: 2–2.5× decode speedup (on eligible requests)
Combined: 5–8× improvement in end-to-end tokens/second
Metoda układania się wzajemnie uzupełnia. Długość szkiców wpływa na założenia ciągłego planera grupowania zadań, a słaby planer powoduje fragmentację grup zadań i obniża wykorzystanie zasobów. Polityki usuwania z pamięci cache KV wymagają dostosowania, aby długie rozmowy nie powodowały nadmiernego obciążenia systemu. Caching promptów po stronie prefill również ma znaczenie – w przeciwnym razie „naprawiasz proces dekodowania”, ale nadal marnujesz zasoby przy prefillu. Ponadto łączenie wcześniejszego zakończenia procesu z agresywną kwantyzacją może szybko prowadzić do pojawiania się halucynacji. Spójny projekt obsługi jest lepszy niż pojedyncza metoda przeniesiona bezpośrednio do kluczowych ścieżek przetwarzania.
Kiedy każda z tych technik przynosi korzyści
Dekodowanie spekulatywne jest przydatne, gdy stopień zgodności szkiców jest wysoki, a czas oczekiwania jest determinowany głównie przez liczba kroków przetwarzania. Nie działa skutecznie, gdy szkice rzadko się pokrywają lub dodatkowe koszty generowania szkiców przyćmiewają uzyskane korzyści. Wcześniejsze zakończenie procesu pomaga, gdy wiele tokenów jest łatwych do przetworzenia i budżet dokładności na to pozwala; nie działa skutecznie przy trudnych tokenach lub gdy poziom pewności jest źle skalibrowany.
Przykład w praktyce: model o pojemności 7 miliardów parametrów służący do rozmów z odpowiedziami składającymi się z 50 do 150 tokenów. Wypełnianie uprzednio promptu systemowego zawierającego 2 tysiące tokenów jest pracochłonne, ale zdarza się rzadko na każdą turę, natomiast kroki dekodowania są główną przyczyną opóźnień, które zauważają użytkownicy. Redukcja liczby kroków dekodowania poprzez podejście spekulatywne lub zmniejszenie pracy na krok dzięki wczesnemu zakończeniu to właśnie to, co faktycznie odczuwają użytkownicy.
Czy to ma znaczenie dla małych modeli działających bezpośrednio na urządzeniu? Zazwyczaj mniej niż w przypadku dużych modeli. Czy wersja wstępna może działać na procesorze CPU? Czasami; trzeba to sprawdzić. Jeśli chodzi o kolejność priorytetów, najpierw należy naprawić mechanizmy grupowania i buforowania, potem dodać dekodowanie spekulatywne, a na końcu wczesne zakończenie, jeśli taka możliwość istnieje w używanym stacku.
Wdrożenie w produkcji do inferencji LLM
- Najpierw sprawdź bazową wartość opóźnienia p95 oraz jakość działania modelu, zanim cokolwiek zmienisz.
- Umieść model w wersji roboczej oraz model docelowy na tym samym węźle. Przenoszenie wersji roboczych między serwerami powoduje fluktuacje sieciowe, które mogą zniwelować uzyskane korzyści, a dwa modele wraz z pamięcą KV mogą wyczerpać pamięć urządzenia, które wcześniej bez problemu pomieściło tylko jeden model.
- Zapisuj histogramy akceptacji, średnią długość tekstów przyjętych, różnice w ocenie jakości, wykorzystanie GPU oraz opóźnienie na poziomie p95.
- Obserwuj równowagę między CPU a GPU podczas wykonywania wersji roboczych na różnych urządzeniach.
- Porównuj jakość w wariantach A/B w zestawach faktograficznych. Przed globalnym wdrożeniem przetestuj ustalone prompty w zadaniach faktograficznych, związanych z kodowaniem oraz sytuacjami odrzucenia, i porównaj wskaźniki identyczności tokenów przy konfiguracji dekodowania spekulatywnego w celu uzyskania dokładnego dopasowania rozkładu. Aby przyspieszyć proces testowania, śledź wskaźnik zwycięstw w zadaniach ocenianych punktowo oraz preferencje ludzkie, jeśli są dostępne.
- Każdą optymalizację umieść za flagą funkcji z mechanizmem wyłączenia powrotu do standardowego dekodowania oraz przećwicz proces cofania zmian.
- Sprawdź ponownie wszystko po zmianie tokenera lub jego dostrojeniu.
Prawidłowo zaimplementowany dekoder spekulatywny powinien odpowiadać docelowej rozkładowi, ale należy to zweryfikować za pomocą testów parowych, zamiast mu ufać. Częstymi przyczynami błędów są używanie niepowiązanego projektu, ignorowanie wpływu temperatury na akceptację wyników lub ogłaszanie sukcesu na podstawie wskaźników wydajności bez sprawdzania jakości, a każda z tych sytuacji jest mierzalna.
Zespoły platformowe powinny przejąć kontrolę nad tymi optymalizacjami w warstwie serwowania, aby każdy zespół aplikacji nie wymyślał własnych metod wyboru projektów. Należy koordynować działania z administratorami serwowania zamiast modyfikować kod aplikacji, udostępniać nagłówki lub atrybuty śledzenia wskazujące, czy zastosowano dekodowanie spekulatywne lub wczesne zakończenie, oraz oznaczać koszty tokenów według rodzaju optymalizacji, aby dział finansowy mógł ocenić korzyści.
Wąskie gardło, które pozostaje
Gdy dekodowanie stanie się szybsze, użytkownicy mogą nadal czekać na wywołania narzędzi, pobieranie danych lub renderowanie Markdown po stronie klienta. Pozostałym ograniczeniem może być przepustowość pamięci, sieć lub sam klient, a nie liczba operacji FLOP, dlatego należy przeanalizować proces od początku do końca i stworzyć profil przed zastosowaniem wszystkich technik opisanych w publikacjach naukowych. Dekodowanie spekulatywne również nie zastępuje skutecznego pobierania danych ani dobrych promptów; jedynie obniża koszt kolejnych tokenów, gdy model już wie, co chce powiedzieć.
Sekwencyjne dekodowanie stanowi strukturalny koszt generacji autoregresywnej. Dekodowanie spekulatywne oraz wczesne zakończenie procesu zmniejszają ten koszt w warunkach, które można zmierzyć, więc należy je wdrożyć jak każdą funkcję produkcyjną: z metrykami, flagami, możliwością cofnięcia zmian oraz wyraźnym odpowiedzialnym w zespole platformy serwisowej.
Literatura pokrewna
- Optymalizacja procesu inferyjnego LLM: prefill, dekodowanie oraz enterprise LLMOps: Problemy związane z prefill i dekodowaniem, ciągłe grupowanie zadań, FlashAttention, kwantyzacja, PagedAttention, dekodowanie spekulatywne, prefill w formie fragmentów oraz serwowanie rozproszone.
- Ciągłe grupowanie zadań i prefill w formie fragmentów w vLLM i SGLang: Statyczne grupy zadań LLM marnują zasoby GPU na wypełnianie dodatkowych danych. Planowanie na poziomie iteracji oraz prefill w formie fragmentów zapewniają płynne dekodowanie, podczas gdy nowe zapytania nadal są przetwarzane.