Lokalne LLM na ThinkPadzie: model 30B na samym CPU szybszy niż 24B na GPU
Na laptopie z iGPU i jednokanałowym RAM-em intuicja „większy model = wolniejszy, GPU = szybsze niż CPU" rozpada się całkowicie. Model Qwen3-Coder o 30 miliardach parametrów, uruchomiony na samym procesorze, generuje 12–17 tokenów na sekundę. Mistral Small o 24 miliardach parametrów, w całości załadowany na GPU, generuje 0,62 tokena na sekundę — dwadzieścia razy wolniej, przy mniejszym pliku. Różnicy nie robi ani backend, ani kwantyzacja, ani zasilanie z kabla. Robi ją architektura: mixture-of-experts kontra dense.
Teza artykułu
Przy inferencji LLM na sprzęcie konsumenckim bez dedykowanej karty graficznej jedynym parametrem, który realnie przewiduje prędkość generacji, jest liczba aktywnych parametrów na token — nie liczba parametrów całkowitych, nie rozmiar pliku GGUF, nie to, czy model liczy się na CPU czy na zintegrowanym GPU. Model MoE 30B z ~3 miliardami aktywnych parametrów jest na tej maszynie w pełni używalny. Model dense 24B jest bezużyteczny. Poniżej pomiary, które do tego doprowadziły, i pułapki konfiguracyjne, które po drodze kosztowały najwięcej czasu.
Sprzęt i stos
Wszystkie pomiary pochodzą z jednej maszyny:
- Laptop: ThinkPad L16 Gen 2
- CPU: AMD Ryzen AI 5 PRO 340 (Zen 5), 6 rdzeni / 12 wątków, do 4,9 GHz, z AVX-512, AVX-VNNI i AVX512-BF16
- RAM: 32 GiB DDR5-5600, jeden moduł — czyli single-channel, teoretycznie ~44,8 GB/s
- iGPU: Radeon 840M (RDNA 3.5, gfx1152)
- System: Fedora 44, KDE,
/homena LUKS + btrfs - Stos: RamaLama na rootless Podmanie, pod spodem
llama-serverz endpointem zgodnym z OpenAI API
Metodologia pomiaru: ramalama serve -d na porcie innym niż produkcyjny, potem curl na /v1/chat/completions i odczyt pola timings.predicted_per_second z odpowiedzi JSON. To liczby serwera, nie stoper. Zawsze jeden model naraz — przy plikach 11–19 GB równoległe testy realnie grożą wyczerpaniem RAM-u i przycięciem sesji graficznej.
Ważny szczegół, który zaważył na wszystkim: pula pamięci dostępna dla iGPU to nie 32 GB RAM-u. Odczyt z mem_info_vram_total i mem_info_gtt_total daje 2 GiB VRAM + ~14,5 GiB GTT, czyli około 16,5 GiB sufitu — i to dzielonego z pulpitem.
Wyniki: dziewięć modeli, jedna zależność
| Model | Architektura | Aktywne param. | Backend | Generacja |
|---|---|---|---|---|
| granite-4.0-h-tiny Q4_K_M | MoE hybrydowy | ~1 B | Vulkan (GPU) | ~25 tok/s |
| gpt-oss-20b Q4_K_M | MoE 21 B | ~3,6 B | Vulkan (GPU) | 15,25 tok/s |
| ERNIE-4.5-21B-A3B Q4_K_M | MoE 21 B | ~3 B | Vulkan (GPU) | ~15 tok/s |
| Qwen3-Coder-30B-A3B Q4_K_M | MoE 30 B | ~3 B | CPU | 12,5–17,4 tok/s |
| Nemotron-3.5-Lightning-30B-A3B | MoE 33 B | ~3 B | CPU | 10,6 tok/s |
| Dolphin3.0-Llama3.1-8B Q4_K_M | dense 8 B | 8 B | Vulkan (GPU) | 5,8 tok/s |
| Phi-4-reasoning-plus Q5_K_M | dense 14 B | 14 B | Vulkan (GPU) | 3,81 tok/s |
| Dolphin3.0-8B Q4_K_M | dense 8 B | 8 B | CPU (-t 12) | 2,77 tok/s |
| Mistral-Small-3.2-24B Q4_K_M | dense 24 B | 24 B | Vulkan (GPU) | 0,62 tok/s |
Tabela posortowana po prędkości układa się dokładnie według liczby aktywnych parametrów, a nie według rozmiaru modelu ani backendu. Model 30B na CPU wyprzedza model 8B na GPU o czynnik ponad dwa. Model 24B na GPU przegrywa z modelem 8B na CPU czterokrotnie.
Dlaczego dense przegrywa: arytmetyka, nie konfiguracja
Model dense przy generowaniu każdego kolejnego tokena musi odczytać z pamięci komplet swoich wag. Dla Mistral Small 24B w Q4_K_M to 13,34 GiB na token. Przy jednokanałowym DDR5-5600 daje to sufit rzędu dwóch tokenów na sekundę nawet gdyby wszystko było rezydentne w RAM i nie działo się nic innego. Zmierzone 0,65 tok/s to ten sufit dodatkowo obciążony presją pamięciową: vmstat w trakcie generacji pokazywał stały swap-in i ciągłe doczytywanie stron wag z pliku GGUF.
Model MoE aktywuje na token tylko wycinek ekspertów. Przy Qwen3-Coder-30B-A3B to około 3 miliardy parametrów zamiast trzydziestu — dziesięciokrotnie mniej danych do przeczytania z RAM-u na każdy token. Plik nadal waży 17,3 GiB i nadal musi się zmieścić w pamięci, ale przepustowość przestaje być wąskim gardłem, bo czyta się z niego ułamek.
To jest cała różnica. Nie ma tu żadnego triku konfiguracyjnego do odkrycia.
Zasilanie z kabla nie pomaga — i to jest dowód
Przy Phi-4 (dense 14B, 3,9 tok/s na GPU) padła naturalna hipoteza: to bateria throttluje iGPU. Sprawdzone wprost — przełączenie na zasilacz, platform_profile=performance, CPU podbity do 4,85 GHz. Wynik: 3,81 tok/s, czyli szum pomiarowy względem 3,9.
Jeśli podniesienie limitu mocy i zegarów nie zmienia nic, to układ nie liczy — układ czeka na pamięć. Warto o tym pamiętać, zanim zwali się wolną inferencję na oszczędzanie energii.
Sufit 16,5 GiB: co się mieści, a co wywala sterownik
Pula Vulkan/GTT jest twardą granicą i objawia się nieprzyjemnie. Gemma 4 26B-A4B w Q4_K_M ma 16 147 MiB samych wag — zostawia to około 750 MiB marginesu, czyli za mało nawet na narzut submitowania komend Vulkana, nie mówiąc o KV cache. Efekt:
.radv/amdgpu: Not enough memory for command submission
llama_model_load: error loading model: vk::Queue::submit: ErrorDeviceLost
Próba z kwantyzowanym KV cache (--cache-type-k q8_0 --cache-type-v q8_0) i wymuszonym --ngl 99 nic nie dała — problem jest wcześniej w łańcuchu, KV cache nie ma nawet szansy się policzyć. Flaga --cpu-moe (eksperci na CPU, warstwy dzielone na GPU) uruchamia model bez crasha, ale daje 1,66 tok/s — narzut przełączania GPU↔CPU dla każdej z 31 warstw przy każdym tokenie zjada cały zysk ze sparsity.
Warto odnotować jedno realne ryzyko. Przy Restart=always bez limitów usługa wpadła w crash-loop i wykonała 38 kolejnych restartów z ErrorDeviceLost, zanim została zatrzymana ręcznie. Sterownik amdgpu odzyskiwał się za każdym razem i nie doszło do twardego resetu na poziomie jądra, ale to ten sam układ, który renderuje pulpit. Każda jednostka systemd uruchamiająca model powinna mieć StartLimitIntervalSec=60 i StartLimitBurst=3.
ROCm kontra Vulkan: lepsze liczby przegrały ze stabilnością
ramalama info autodetekcją wybiera na tej maszynie backend hip (ROCm) i natywny llama-server z pakietu Fedory faktycznie inicjalizuje kartę bez zarzutu — found 1 ROCm devices: Device 0: AMD Radeon 840M Graphics, gfx1152 — osiągając 13,8–14,17 tok/s na modelu 8B, czyli ponad dwa razy więcej niż Vulkan.
Problem ujawnia się dopiero przy realnym zapytaniu czatu. Serwer przetwarza prompt, dochodzi do prompt done i pada przy samplowaniu pierwszego tokena:
/bits/random.tcc:2795: void std::discrete_distribution<>::param_type::_M_initialize():
Assertion '__sum > 0' failed.
Powtarzalne za każdym razem, potwierdzone przez systemd-coredump, sprawdzone dwukrotnie — ze standardowym samplerem i z jawnym --temp 0.8. Rozkład prawdopodobieństwa dochodzący do samplera jest zdegenerowany (zera albo NaN-y). Układ gfx1152 nie ma oficjalnego wsparcia ROCm i tak to właśnie wygląda w praktyce: inicjalizacja przechodzi, benchmark się liczy, produkcja się wywraca.
Wniosek operacyjny: na tej maszynie zawsze --backend vulkan jawnie. Wyższe tok/s jest bez znaczenia, jeśli serwer nie odpowiada na drugie pytanie.
Cztery pułapki, które kosztowały najwięcej czasu
1. Domyślny --ctx-size to pełne okno modelu — i gwarantowany OOM
Najdroższa lekcja. ramalama serve bez jawnego --ctx-size ustawia kontekst na pełne okno modelu, co dla Qwen3-Coder-30B oznacza n_ctx = 252160, a pod to alokuje:
CPU KV buffer size = 23640.00 MiB (pamięć anonimowa, nieodzyskiwalna)
CPU_REPACK buffer = 13432.50 MiB (repack wag pod AVX-512, też anonimowa)
Razem ~37 GiB pamięci anonimowej przy 29 GiB dostępnego RAM-u. Proces ginie z EXIT=137, deterministycznie, niezależnie od tego, ile RAM-u było wolne przed startem. Same wagi nie były tu problemem — są mmapowane (mmap = true), czyli odzyskiwalne.
Naprawa to jedna flaga: --ctx-size 16384 zbija KV cache z 23 640 MiB do 1536 MiB. KV cache skaluje się liniowo z kontekstem, więc to jest właściwe pokrętło — a nie dokładanie RAM-u.
ramalama serve --device none -n qwen3-coder -d -t 5 --port 8082 \
--ctx-size 16384 --host 127.0.0.1 \
file:///home/user/local-llm/models/Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf
Uwaga diagnostyczna: ramalama serve -d przy crashu startowym maskuje prawdziwy błąd komunikatem Command '['podman', 'logs', ...]' returned non-zero exit status 125, bo kontener z --rm znika, zanim logi da się przeczytać. Żeby zobaczyć przyczynę, trzeba raz uruchomić w foreground.
2. ramalama serve domyślnie wystawia się na całą sieć
Domyślny --host to ::, czyli wszystkie interfejsy. Bez jawnej flagi ss -tlnp pokazuje *:8080, a podman ps — 0.0.0.0:8080->8080/tcp. Lokalny model z web UI i bez uwierzytelnienia, dostępny dla każdego w tej samej sieci Wi-Fi.
Dopisanie --host 127.0.0.1 załatwia sprawę, ale weryfikacja jest obowiązkowa po każdej zmianie konfiguracji — w jednym z przebiegów, w trybie --device none, flaga została zignorowana i w logu serwera pojawiło się żądanie z adresu LAN. Sprawdzenie zajmuje sekundę:
ss -tlnp | grep 8080 # musi być 127.0.0.1:8080, nie *:8080
3. Pobieranie dużych GGUF-ów i weryfikacja sumy kontrolnej
ramalama pull hf://... dla plików powyżej ~4 GB kończy się w wersji 0.21.0 błędem Checksum mismatch, a fallback na hf CLI jest w tym transporcie zaszyty jako NotImplementedError — ślepy zaułek, nie ma sensu ponawiać. Ten sam plik pobrany ręcznie ma poprawny hash od razu.
Przy plikach 10–19 GB zwykły curl też regularnie się rozłącza (exit 92, exit 56). Działa dopiero pętla ze wznawianiem — curl -L -C - --http1.1 powtarzane aż rozmiar pliku zrówna się z oczekiwanym; -C - wznawia od miejsca zerwania, więc żadna próba nie marnuje wcześniejszego postępu. Gotowy plik wprowadza się do store przez ramalama pull file:///pełna/ścieżka.gguf.
Sama pętla wznawiająca nie wystarczy, bo awarie są dwie, nie jedna. Pierwsza to opisane wyżej zerwanie: curl kończy się błędem, pętla go restartuje, wszystko działa. Druga to zastój — połączenie TCP żyje, proces curl jest w stanie działania, a przepustowość wynosi zero. Zmierzone na transferze 17 GB: licznik zamarł na 3,02 GB i nie drgnął przez 21 minut. Ani --retry, ani pętla wznawiająca nie mają czego złapać, bo curl nie zwraca żadnego błędu — po prostu wisi. --connect-timeout też nie pomoże, bo pokrywa wyłącznie nawiązanie połączenia, nie martwy transfer w trakcie.
Lekarstwem jest wymuszenie minimalnej przepustowości:
curl -L -C - --http1.1 --retry 5 --retry-delay 5 \
--connect-timeout 30 --speed-limit 102400 --speed-time 60 \
-o "$FILE.part" "$URL"
--speed-limit 102400 --speed-time 60 przerywa transfer, jeśli średnia prędkość spadnie poniżej 100 KB/s przez minutę. curl kończy się wtedy z exit 28, pętla wznawia od ostatniego bajtu i transfer rusza dalej. Objaw, po którym rozpoznasz zastój bez czekania: rozmiar pliku .part nie rośnie między dwoma wywołaniami stat -c%s w odstępie 20 sekund, mimo że proces curl żyje.
I błąd metodologiczny, który kosztował trzy fałszywe alarmy: oczekiwaną sumę sha256 trzeba brać z API Hugging Face, z pola siblings[].lfs.sha256 pod https://huggingface.co/api/models/{repo}?blobs=true. Nagłówek ETag z curl -I dla tych plików nie odpowiada hashowi LFS — najpewniej generuje go CDN. Trzy na trzy pliki zostały uznane za uszkodzone, choć były w pełni poprawne.
4. Na btrfs du kłamie, a usuwanie modelu wymaga dwóch kroków
RamaLama wprowadza plik do swojego store jako osobny inode, ale z reflinkiem — kopia CoW dzieląca ekstenty z oryginałem. Na btrfs kosztuje to zero bajtów, ale du -sh tego nie rozumie i liczy każdą referencję w całości, pokazując dwukrotność realnego zużycia. Prawdę pokazuje btrfs filesystem du -s:
Total Exclusive Set shared Filename
12.42GiB 0.00B 12.42GiB models/ERNIE-4.5-21B-A3B-PT-Q4_K_M.gguf
12.42GiB 0.00B 12.42GiB store/.../ERNIE-4.5-21B-A3B-PT-Q4_K_M.gguf
Exclusive = 0 po obu stronach oznacza, że żadna z nich nie ma własnych danych. Praktyczna konsekwencja: ekstenty zwalniają się dopiero po usunięciu ostatniej referencji, więc skasowanie samego pliku z models/ nie odzyskuje ani bajta — w store zostaje martwa kopia tej samej wielkości. Usuwanie jest dwuetapowe:
ramalama rm "file:///home/user/local-llm/models/model.gguf" # czyści store
rm /home/user/local-llm/models/model.gguf # usuwa oryginał
ramalama rm przy sukcesie nie wypisuje nic, więc trzeba zweryfikować przez ramalama list.
Bonus — wersja obrazu kontenera ma znaczenie. Nemotron-3.5 (opublikowany 13 sierpnia 2026) w obrazie ramalama:0.21 z maja pada na wrong number of tensors; expected 417, got 408, mimo że string architektury jest obecny w libllama.so. W obrazie 0.24 ładuje się bez problemu. Sama obecność nazwy architektury w bibliotece niczego nie gwarantuje — liczy się data builda llama.cpp względem daty publikacji modelu.
Czy warto dołożyć drugą kość RAM?
Naturalne pytanie przy jednokanałowym module. Uczciwa odpowiedź: dla samej prędkości prawdopodobnie nie, dla pojemności być może.
Argument za tym, że dual-channel niewiele zmieni: test skalowania kwantyzacji na modelu 8B pokazał, że 74-procentowy wzrost rozmiaru wag (4,58 → 7,95 GB przy przejściu z Q4_K_M na Q8_0) dał raptem 14-procentowy spadek prędkości (13,80 → 11,86 tok/s). Gdyby inferencja uderzała w sufit przepustowości, spadek byłby proporcjonalny do rozmiaru.
Zastrzeżenie: ten pomiar dotyczy małego modelu na iGPU i nie przenosi się wprost na dense 24B na CPU, gdzie objawy wskazują wyraźnie na ścianę pamięciową. Drugi moduł nie został przetestowany, więc traktuję to jako hipotezę, nie wynik.
Natomiast argument pojemnościowy jest realny: 64 GB pozwoliłoby uruchamiać większe modele MoE, których dziś po prostu nie da się zmieścić. To jest sensowny powód do rozbudowy — nie chęć przyspieszenia tego, co już działa.
Co z tego wynika przy wyborze modelu
Praktyczna reguła doboru na sprzęt tej klasy — laptop, iGPU, jednokanałowa pamięć:
- Szukaj MoE z małą liczbą aktywnych parametrów. W nazwach plików widać to jako
-A3B(3 miliardy aktywnych) czy-A4B. To pojedynczy najsilniejszy predyktor tego, czy model będzie używalny. - Nie pobieraj modeli dense powyżej ~14 mld parametrów. Nie zmieszczą się użytecznie w puli iGPU, a na CPU są bandwidth-bound. Obniżenie kwantyzacji zmniejsza presję pamięci, ale nie rusza sufitu przepustowości.
- Model MoE, który nie mieści się w 16,5 GiB puli GPU, uruchamiaj na CPU (
--device none) — nie kombinuj z--cpu-moe. Czysty CPU dał 12,5–17,4 tok/s tam, gdzie hybryda dawała 1,66. - Zawsze ustawiaj
--ctx-sizejawnie. Domyślny to pełne okno modelu i przy dużych kontekstach oznacza kilkadziesiąt GiB na sam KV cache. - Zawsze
--host 127.0.0.1i zawsze weryfikacja przezss -tlnp.
Zaskakujący wniosek końcowy jest taki, że na tym sprzęcie CPU nie jest fallbackiem awaryjnym. Dla modeli MoE to pełnoprawna, produkcyjnie użyteczna ścieżka, dorównująca zintegrowanemu GPU i pozwalająca uruchomić modele grubo większe, niż pomieściłaby pula Vulkana. Fallbackiem awaryjnym CPU pozostaje wyłącznie dla modeli dense — i to takim, którego lepiej nie używać.
W drugiej części opiszę drugą stronę tego samego materiału: jak te modele wypadły jakościowo, gdy dostały te same dwa specjalistyczne pytania z PostgreSQL i bezpieczeństwa aplikacji mobilnych. Streszczenie: prędkość i jakość nie korelują ze sobą w żaden użyteczny sposób, a najszybszy model okazał się najgorszy.