Trefon.
← Wszystkie notatki 8 min

Kontekst 262K na jednej RTX 4090: co dodałem do llama.cpp-turboq

Silnik już trzymał 4,125-bitowy cache KV wewnątrz flash attention. Żeby agenci kodujący mogli na nim pracować cały dzień, potrzebne były asynchroniczne checkpointy, mniej przechwyceń grafu CUDA, seria poprawek MTP i strojenie samplera warte +31%.

Jacek Trefon
  • inferencja
  • llama.cpp
  • CUDA
  • MTP
  • cache KV
Kod ↗

Moja lokalna maszyna do inferencji to jedna RTX 4090 z 24 GiB VRAM. Cały dzień serwuje moim harnessom agentowym model 27B z kontekstem 262 144 tokenów. Ta notatka jest o tym, ile pracy wymagało, żeby to stało się nudne.

Konfiguracja

  • GPU: jedna RTX 4090, 24 GiB, wszystkie warstwy na karcie.
  • Model: Qwopus3.6-27B-v2-MTP, dostrojony Qwen3.6 27B z głowicami predykcji wielu tokenów, Q4_K_M.
  • Kontekst: 262 144 tokeny, z 4-bitowym cache KV w TBQ4.

Serwer produkcyjny działa z:

--temp 0.6 --top-p 0.95 --min-p 0.05     # koniec pętli wywołań narzędzi, dekodowanie 62 → 82 tok/s
--reasoning on --reasoning-budget 8192
--jinja
--spec-type mtp --spec-draft-n-max 3      # dekodowanie spekulatywne MTP
--spec-adapt                              # adaptacyjna głębokość szkicu
-ctk tbq4_0 -ctv tbq4_0                   # KV 4,125 bpv, 4,1 GiB przy 262K
--flash-attn on
-c 262144 -ngl 99

Od czego zacząłem

Moje repozytorium to fork Indras-Mirror/llama.cpp-turboq-mtp, który sam jest forkiem llama.cpp. Najważniejsza funkcja to ich praca, nie moja: jądro flash attention, które czyta surowe bloki TBQ4 prosto z pamięci globalnej i dekwantyzuje je w wewnętrznej pętli. TBQ4 zapisuje 128 wartości w 66 bajtach: 2 bajty skali plus 64 bajty 4-bitowych indeksów, czyli 4,125 bita na wartość. Atencja działa w obróconej dziedzinie (Hadamarda), więc nie ma osobnego przebiegu dekwantyzacji ani bufora F16.

To dzięki temu jądru kontekst w ogóle się mieści. Przy 262K tokenów cache TBQ4 zajmuje około 4,1 GiB. Cache w FP16 zająłby 16 GiB, czyli dwie trzecie karty, zanim załadujesz choć jedną wagę.

Nie napisałem więc tego jądra. Moja praca zaczyna się tam, gdzie kończy się benchmark: kiedy agent kodujący godzinami obciąża serwer długimi promptami, wywołaniami narzędzi i kontekstem bliskim limitu.

1. Checkpointy blokowały prefill

Serwer zapisuje checkpointy promptu w trakcie prefillu, żeby kolejne zapytanie mogło użyć cache zamiast przetwarzać cały prompt od nowa. Przy prefillu 262K oznacza to 32 odczyty stanu po około 149 MiB. Każdy robił pełne ctx->synchronize(), a potem blokujące ggml_backend_tensor_get dla każdego tensora stanu. Strumień obliczeń stał, a host kopiował pamięć.

Przerobiłem to na zapis asynchroniczny:

create_checkpoint (pętla prefillu, bez blokowania)
   └─ asynchroniczne D2H do przypiętego slotu   (cudaStreamPerThread)
   └─ cudaLaunchHostFunc: slot → zwykła pamięć, zwolnienie slotu

kolejne zapytanie (GPU bezczynne)
   └─ jeśli kopia jeszcze trwa: cudaStreamSynchronize   // tanio, GPU stoi
   └─ przywrócenie checkpointu

Bufor ma dwa sloty, więc przypięta pamięć hosta nigdy nie przekracza około 300 MiB, niezależnie od liczby checkpointów. Przypięcie wszystkich 32 wymagałoby około 4,7 GB, a ten host nie ma tyle wolnej pamięci. Dane są synchronizowane dopiero wtedy, gdy checkpoint jest faktycznie używany, a wtedy GPU i tak stoi.

2. Mniej przechwyceń grafu CUDA

Upstream dopełnia cache KV do sztywnych 256. Dodałem konfigurowalne minimum, LLAMA_KV_PAD_MIN, domyślnie 4096. To 16× mniej ponownych przechwyceń grafu CUDA niż w upstreamie.

3. Agenci w pętli i +31% z samplera

Pod obciążeniem agentowym model wpadał w pętle wywołań narzędzi. Zatrzymały je trzy zmiany:

  • Sampler: --temp 0.6 --top-p 0.95 --min-p 0.05 zatrzymało pętle i podniosło dekodowanie z 62 do 82 tok/s, czyli o 31%.
  • Stochastyczny sampler szkicu: sampler szkicu MTP nie jest już zachłanny.
  • Adaptacyjny bezpiecznik: bezpiecznik jednej próbki łapie pętle, które mimo to się zaczną.

--spec-adapt ogranicza głębokość szkicu MTP, gdy akceptacja spada poniżej 35%, żeby trudny fragment tekstu nie palił obliczeń na szkice, które i tak zostaną odrzucone.

82 tok/s to szczytowe dekodowanie przy stabilnym generowaniu. Nie na takich ustawieniach pracowałbym cały dzień.

4. Poprawność w długich sesjach

Żadna z tych rzeczy nie wychodzi w krótkim benchmarku. Wychodzą w długich sesjach agentowych:

  • Kontekst cienia MTP: poprawiona synchronizacja z głównym kontekstem i wyścig danych między nim a asynchronicznymi checkpointami.
  • Dryf stanu rekurencyjnego: poprawiony dryf w Gated DeltaNet przy dużym kontekście, który kończył się martwą pętlą.
  • Wyjście poza zakres: poprawiony crash tensora MTP, a pełny kontekst jest teraz obsługiwany łagodnie zamiast awarią.
  • Wycofywanie przy M-RoPE: wycofywanie odrzuconych szkiców MTP działa dla modeli takich jak Qwen3.8-27B, ze wzmocnioną ścieżką 1D.
  • Regresja pętli: oczekujący bufor MTP synchronizuje się ponownie przy każdej zmianie pamięci modelu docelowego.
  • Szybkość prefillu: połączone jądro chunk pipeline dla prefillu DeltaNet.

5. Serwowanie dla harnessów

Harness po drugiej stronie oczekuje API zgodnego z OpenAI i nie obchodzi go, co jest pod spodem, więc serwer musi się zachowywać:

  • Podmiana modelu: modele podmieniają się w tym samym procesie przez rejestr presetów, bez osobnego routera.
  • Reasoning effort: reasoning_effort przyjmuje słowo albo surowy budżet tokenów, a zapytanie nadpisuje konfigurację serwera.
  • Przycinanie promptu: przycinanie uwzględnia wiadomości już na etapie parsowania i nigdy nie usuwa ostatniej.

6. Ten sam cache na Apple silicon

Przeniosłem obsługę cache KV TBQ3 i TBQ4 na Metal, poprawiając przy okazji błąd pakowania 3-bitowego. Dodałem też dekwantyzację w obróconej dziedzinie dla atencji, więc ścieżka Metal również obywa się bez osobnego przebiegu dekwantyzacji.

Liczby

CoWartość
Kontekst262 144 tokeny na 24 GiB
Cache KVTBQ4, 4,125 bpv, 4,1 GiB przy 262K
Dekodowanie, szczyt przy stabilnym generowaniu82 tok/s
Strojenie samplera62 → 82 tok/s (+31%)
Ponowne przechwycenia grafu CUDA16× mniej
Przypięta pamięć na checkpointy≤ ~300 MiB zamiast ~4,7 GB

Co dalej

Akceptację MTP ogranicza teraz głowica szkicu, a nie silnik. Następna dźwignia jest po stronie modelu: ponowne wytrenowanie głowicy MTP z celem FastMTP. Opis jest w repozytorium.

Kod, commit po commicie: github.com/jtrefon/llama.cpp-turboq-mtp.

Masz podobny problem?

Opisz swój problem

30-minutowa rozmowa techniczna z inżynierem, który to buduje. Bez działu sprzedaży, bez slajdów.