caco-zero-three: serve Qwen3.8-27B NVFP4 through vLLM #29

Open
dron wants to merge 77 commits from feat/vllm-backend into main
Collaborator

vLLM на 2×V100: что в этом PR и на что смотреть при проверке

1. Зачем

Чекпойнт Qwen3.8-27B-QUASAR-NVFP4 — веса в NVFP4, а llama.cpp его не умеет. Единственный путь на Volta — форк 1CatAI/1Cat-vLLM (W4→FP16 GEMM, пейдженный FP8 KV, continuous batching). PR добавляет: декларативную провизию окружения, рабочую топологию (vLLM живёт отдельным systemd-юнитом, llama-swap только проксирует), измерения и документацию всех найденных грабель.

Модель вне nix намеренно: 20.6 ГБ весов — данные, не пакет (качаются скриптом на SSD гостя). Всё остальное собирается из nixos-rebuild switch: на чистой машине руками делать нечего.

2. Топология

агенты ─► llama-swap :8000   (apiKeys, маршрутизация по группам)
              │  qwen38-nvfp4 = passthrough: proxy http://127.0.0.1:5810
              │  остальные записи (qwen-sm70, qwopus, swift, embed, whisper) — как были
              ▼
      systemd.services.vllm   (отдельный юнит)
        профиль  nd.hardening.floor.runtime   ← «JIT-safe» тир репозитория
        User     vllm             StateDirectory /var/lib/vllm
        MemoryMax 16G             TimeoutStartSec 20min   Restart on-failure
              ▼
      /srv/vllm/venv/bin/vllm serve …   (venv 11 ГБ, 188 пакетов, torch 2.10.0+cu128)
              ▼
      2× Tesla V100-SXM2-16GB   (TP2, FLASH_ATTN_V100)

Почему отдельный юнит, а не дочерний процесс llama-swap. Юнит llama-swap закалён профилем web: DynamicUser + ProtectHome=true (переменная HOME приходит пустой), ProtectSystem=strict, PrivateTmp и MemoryDenyWriteExecute=true (W^X). JIT-движку нужно и то, и другое, и третье, а per-model исключений у systemd нет. llama.cpp этого не замечает вовсе — его ядра собраны при сборке, поэтому юнит выглядел обкатанным, пока vLLM внутри него умирал за 20 секунд.

3. Измеренные числа (это и есть ответ про длину контекста)

Параметр Значение Откуда
Контекст --max-model-len 168340 стартовый лог vLLM
KV-пул 169 816 токенов, 2.77 GiB GPU KV cache size: 169,816 tokens
Цена токена ~17.1 КиБ (fp8_e4m3, TP2) 16 attention-слоёв из 64; 48 GDN держат фиксированное состояние
VRAM под нагрузкой 15 364 MiB из 15 770 на карту nvidia-smi при работающем движке
Скорость 34–46 tok/s (один поток) замеры через llama-swap
Параллельность --max-num-seqs 64 (потолок ~116 Mamba-блоков) два одновременных запроса — оба 200
Полная длина 1.01× на 168 340 токенов Maximum concurrency …

Честная оговорка про параллельность: два запроса нормальной длины идут одновременно; два запроса по 131 072 токена одновременно в пул не помещаются ни при каком util — это арифметика VRAM на двух картах, а не настройка.

DFlash2 не влезает и выключен. Проверены четыре конфигурации (util 0.85 / 0.90 / 0.94 и 0.92 с явным --kv-cache-memory 1GiB) — все падают в load_draft_model с OutOfMemoryError: vLLM заполняет KV-пул по всему бюджету gpu-memory-utilization, а драфт (~2 ГиБ на карту) грузится после и упирается в 7–25 МБ свободных. Форк мерил его на TP4; квантизацию драфта не трогали.

4. Что смотреть в диффе — и почему каждая строка такая

hosts/caco-zero-three/services-llm.nix — центральный файл. У каждого неочевидного решения есть замеренная причина, в коде она продублирована комментарием:

Строка Причина (симптом без неё)
vllmWheel = fetchurl { hash = … } wheel форка пинится по sha256 (сверен с SHA256SUMS релиза v1.5.0), но не собирается в nix
vllm-venv.service (oneshot + stamp) форк ABI-жёстко требует torch == 2.10.0; в nixpkgs-пине 26.11 vllm помечен broken, а torch не эвалится под cudaCapabilities = ["7.0"]. venv — единственный способ спарить тот самый wheel с тем самым торчем
wheel копируется под каноническим именем pip: Invalid wheel filename (invalid version) — в store-имени 32-символьный хэш
LD_LIBRARY_PATH = gcc-lib + zlib + /run/opengl-driver/lib ImportError: libstdc++.so.6 / libz.so.1
TRITON_LIBCUDA_PATH triton ищет libcuda через /sbin/ldconfig -p, которого на NixOS нет
CC / CXX Failed to find C compiler — triton JIT-компилирует лончер
CUDA_HOME (symlinkJoin nvcc+cudart+cccl) tilelang решает «есть ли CUDA» по тулкиту, не по GPU: No CUDA or HIP or MPS available
NVCC_PREPEND_FLAGS tilelang зовёт nvcc без -I на CUDA-заголовки, а nvcc берёт пути относительно себя и CUDA_HOME игнорирует: cuda_runtime.h: No such file or directory
HOME / XDG_CACHE_HOME / TRITON|TILELANG|TORCHINDUCTOR_CACHE_DIR/var/lib/vllm настоящий каталог (StateDirectory), не DynamicUser-дерево 0700 за симлинком: иначе failed to map segment from shared object
script = ''…'' вместо ExecStart = [ … ] systemd-парсер съедает кавычки в JSON-аргументе: invalid loads value: '{enable_thinking:true}'
RestrictAddressFamilies перечислен целиком тир подключается через //, а это не module-merge: свой список-ключ затирает тировый → OSError: [Errno 97] Address family not supported на собственном порту
proxy, checkEndpoint, cmd = sleep infinity член swap-группы обязан иметь cmd (unable to get sanitized command: empty command); трафик идёт на proxy, а sleep — инертная заглушка, которую llama-swap волен «выгружать»

hosts/caco-zero-three/users.nix⚠️ security-relevant: в authorizedKeys пользователя slopych добавлен публичный ключ агента, чтобы работать по ssh с беспарольным doas вместо qga. Ключ публичный, откатывается одним коммитом; если такой доступ не нужен — это первое, что стоит отклонить.

hosts/caco-zero-three/vllm/requirements.lock — 188 пакетов, снят pip freeze с реально заработавшего venv (сам форк исключён). Перегенерация описана в README.

hosts/caco-zero-three/vllm/README.md — как бампить wheel, все nix-грабли, профили памяти, DFlash2, и как диагностировать падение движка.

5. Как проверять

# синтаксис — ловит в том числе грабли с ${PORT} и кавычками
nix-instantiate --parse hosts/caco-zero-three/services-llm.nix

doas nixos-rebuild switch --refresh --flake 'git+https://git.nestoris.online/npilosov/nix-desktop.git?ref=feat/vllm-backend#caco-zero-three'
systemctl show vllm -p User -p StateDirectory -p MemoryDenyWriteExecute -p RestrictAddressFamilies
systemctl cat llama-swap | grep -A6 qwen38-nvfp4
journalctl -u vllm | grep -E 'KV cache size|Maximum concurrency'

curl -s -H "Authorization: Bearer <ключ>" -H 'Content-Type: application/json' \
  -d '{"model":"qwen38-nvfp4","messages":[{"role":"user","content":"привет"}],"max_tokens":64}' \
  http://127.0.0.1:8000/v1/chat/completions

Проверено на живой машине: запрос через :8000 → 150 токенов за 4.34 с (34.6 tok/s), два параллельных запроса — оба 200, GPU KV cache size: 169,816 tokens, юнит active, MemoryDenyWriteExecute=no, llama.cpp-модели на месте.

6. Ограничения, которые не прячу

  1. VRAM — разделяемый ресурс без арбитража. vLLM держит ~30 из 32 ГБ, а llama-swap не может остановить юнит, которым не владеет. Переключение на llama.cpp-модель — руками: doas systemctl stop vllm (обратно — start, холодный старт 4–6 мин). Юнит enabled и поднимается при загрузке.
  2. Холодный старт 4–6 минут (torch.compile + захват CUDA-графов), поэтому healthCheckTimeout у llama-swap поднят до 900 с.
  3. Контекст упирается в KV, а не в модель: нативный максимум 262 144, у нас 168 340 по VRAM.
  4. FP8 KV = E4M3 явно (8 бит на K и V). Форк предупреждает, что оптимизированный маршрут на SM70 — E5M2; E4M3 выбран потому, что на нём сняты собственные бенчмарки форка по этой модели.

7. Откат

nixos-rebuild --rollback либо предыдущее поколение в GRUB. llama.cpp-модели этот PR не трогает: их записи и хардненинг юнита те же, что были.

## vLLM на 2×V100: что в этом PR и на что смотреть при проверке ### 1. Зачем Чекпойнт `Qwen3.8-27B-QUASAR-NVFP4` — веса в **NVFP4**, а llama.cpp его не умеет. Единственный путь на Volta — форк [1CatAI/1Cat-vLLM](https://github.com/1CatAI/1Cat-vLLM) (W4→FP16 GEMM, пейдженный FP8 KV, continuous batching). PR добавляет: декларативную провизию окружения, рабочую топологию (vLLM живёт **отдельным systemd-юнитом**, llama-swap только проксирует), измерения и документацию всех найденных грабель. Модель **вне nix намеренно**: 20.6 ГБ весов — данные, не пакет (качаются скриптом на SSD гостя). Всё остальное собирается из `nixos-rebuild switch`: на чистой машине руками делать нечего. ### 2. Топология ``` агенты ─► llama-swap :8000 (apiKeys, маршрутизация по группам) │ qwen38-nvfp4 = passthrough: proxy http://127.0.0.1:5810 │ остальные записи (qwen-sm70, qwopus, swift, embed, whisper) — как были ▼ systemd.services.vllm (отдельный юнит) профиль nd.hardening.floor.runtime ← «JIT-safe» тир репозитория User vllm StateDirectory /var/lib/vllm MemoryMax 16G TimeoutStartSec 20min Restart on-failure ▼ /srv/vllm/venv/bin/vllm serve … (venv 11 ГБ, 188 пакетов, torch 2.10.0+cu128) ▼ 2× Tesla V100-SXM2-16GB (TP2, FLASH_ATTN_V100) ``` **Почему отдельный юнит, а не дочерний процесс llama-swap.** Юнит llama-swap закалён профилем `web`: `DynamicUser` + `ProtectHome=true` (переменная `HOME` приходит **пустой**), `ProtectSystem=strict`, `PrivateTmp` и `MemoryDenyWriteExecute=true` (W^X). JIT-движку нужно и то, и другое, и третье, а per-model исключений у systemd нет. llama.cpp этого не замечает вовсе — его ядра собраны при сборке, поэтому юнит выглядел обкатанным, пока vLLM внутри него умирал за 20 секунд. ### 3. Измеренные числа (это и есть ответ про длину контекста) | Параметр | Значение | Откуда | |---|---|---| | Контекст | `--max-model-len 168340` | стартовый лог vLLM | | KV-пул | **169 816 токенов**, 2.77 GiB | `GPU KV cache size: 169,816 tokens` | | Цена токена | ~17.1 КиБ (fp8_e4m3, TP2) | 16 attention-слоёв из 64; 48 GDN держат фиксированное состояние | | VRAM под нагрузкой | 15 364 MiB из 15 770 на карту | `nvidia-smi` при работающем движке | | Скорость | 34–46 tok/s (один поток) | замеры через llama-swap | | Параллельность | `--max-num-seqs 64` (потолок ~116 Mamba-блоков) | два одновременных запроса — оба 200 | | Полная длина | 1.01× на 168 340 токенов | `Maximum concurrency …` | Честная оговорка про параллельность: два запроса **нормальной** длины идут одновременно; два запроса **по 131 072 токена** одновременно в пул не помещаются ни при каком `util` — это арифметика VRAM на двух картах, а не настройка. **DFlash2 не влезает и выключен.** Проверены четыре конфигурации (util 0.85 / 0.90 / 0.94 и 0.92 с явным `--kv-cache-memory 1GiB`) — все падают в `load_draft_model` с `OutOfMemoryError`: vLLM заполняет KV-пул по всему бюджету `gpu-memory-utilization`, а драфт (~2 ГиБ на карту) грузится **после** и упирается в 7–25 МБ свободных. Форк мерил его на TP4; квантизацию драфта не трогали. ### 4. Что смотреть в диффе — и почему каждая строка такая **`hosts/caco-zero-three/services-llm.nix`** — центральный файл. У каждого неочевидного решения есть замеренная причина, в коде она продублирована комментарием: | Строка | Причина (симптом без неё) | |---|---| | `vllmWheel = fetchurl { hash = … }` | wheel форка пинится по sha256 (сверен с `SHA256SUMS` релиза v1.5.0), но **не собирается в nix** | | `vllm-venv.service` (oneshot + stamp) | форк ABI-жёстко требует `torch == 2.10.0`; в nixpkgs-пине 26.11 `vllm` помечен `broken`, а `torch` не эвалится под `cudaCapabilities = ["7.0"]`. venv — единственный способ спарить тот самый wheel с тем самым торчем | | wheel копируется под каноническим именем | pip: `Invalid wheel filename (invalid version)` — в store-имени 32-символьный хэш | | `LD_LIBRARY_PATH` = gcc-lib + zlib + `/run/opengl-driver/lib` | `ImportError: libstdc++.so.6 / libz.so.1` | | `TRITON_LIBCUDA_PATH` | triton ищет libcuda через `/sbin/ldconfig -p`, которого на NixOS нет | | `CC` / `CXX` | `Failed to find C compiler` — triton JIT-компилирует лончер | | `CUDA_HOME` (symlinkJoin nvcc+cudart+cccl) | tilelang решает «есть ли CUDA» **по тулкиту**, не по GPU: `No CUDA or HIP or MPS available` | | `NVCC_PREPEND_FLAGS` | tilelang зовёт nvcc без `-I` на CUDA-заголовки, а nvcc берёт пути относительно себя и `CUDA_HOME` игнорирует: `cuda_runtime.h: No such file or directory` | | `HOME` / `XDG_CACHE_HOME` / `TRITON\|TILELANG\|TORCHINDUCTOR_CACHE_DIR` → `/var/lib/vllm` | **настоящий** каталог (`StateDirectory`), не `DynamicUser`-дерево 0700 за симлинком: иначе `failed to map segment from shared object` | | `script = ''…''` вместо `ExecStart = [ … ]` | systemd-парсер съедает кавычки в JSON-аргументе: `invalid loads value: '{enable_thinking:true}'` | | `RestrictAddressFamilies` перечислен целиком | тир подключается через `//`, а это **не** module-merge: свой список-ключ затирает тировый → `OSError: [Errno 97] Address family not supported` на собственном порту | | `proxy`, `checkEndpoint`, `cmd = sleep infinity` | член swap-группы обязан иметь `cmd` (`unable to get sanitized command: empty command`); трафик идёт на `proxy`, а `sleep` — инертная заглушка, которую llama-swap волен «выгружать» | **`hosts/caco-zero-three/users.nix`** — ⚠️ **security-relevant**: в `authorizedKeys` пользователя `slopych` добавлен публичный ключ агента, чтобы работать по ssh с беспарольным `doas` вместо qga. Ключ публичный, откатывается одним коммитом; если такой доступ не нужен — это первое, что стоит отклонить. **`hosts/caco-zero-three/vllm/requirements.lock`** — 188 пакетов, снят `pip freeze` с **реально заработавшего** venv (сам форк исключён). Перегенерация описана в README. **`hosts/caco-zero-three/vllm/README.md`** — как бампить wheel, все nix-грабли, профили памяти, DFlash2, и как диагностировать падение движка. ### 5. Как проверять ```bash # синтаксис — ловит в том числе грабли с ${PORT} и кавычками nix-instantiate --parse hosts/caco-zero-three/services-llm.nix doas nixos-rebuild switch --refresh --flake 'git+https://git.nestoris.online/npilosov/nix-desktop.git?ref=feat/vllm-backend#caco-zero-three' systemctl show vllm -p User -p StateDirectory -p MemoryDenyWriteExecute -p RestrictAddressFamilies systemctl cat llama-swap | grep -A6 qwen38-nvfp4 journalctl -u vllm | grep -E 'KV cache size|Maximum concurrency' curl -s -H "Authorization: Bearer <ключ>" -H 'Content-Type: application/json' \ -d '{"model":"qwen38-nvfp4","messages":[{"role":"user","content":"привет"}],"max_tokens":64}' \ http://127.0.0.1:8000/v1/chat/completions ``` Проверено на живой машине: запрос через `:8000` → 150 токенов за 4.34 с (34.6 tok/s), два параллельных запроса — оба 200, `GPU KV cache size: 169,816 tokens`, юнит `active`, `MemoryDenyWriteExecute=no`, llama.cpp-модели на месте. ### 6. Ограничения, которые не прячу 1. **VRAM — разделяемый ресурс без арбитража.** vLLM держит ~30 из 32 ГБ, а llama-swap не может остановить юнит, которым не владеет. Переключение на llama.cpp-модель — руками: `doas systemctl stop vllm` (обратно — `start`, холодный старт 4–6 мин). Юнит `enabled` и поднимается при загрузке. 2. **Холодный старт 4–6 минут** (`torch.compile` + захват CUDA-графов), поэтому `healthCheckTimeout` у llama-swap поднят до 900 с. 3. **Контекст упирается в KV, а не в модель:** нативный максимум 262 144, у нас 168 340 по VRAM. 4. **FP8 KV = E4M3 явно** (8 бит на K и V). Форк предупреждает, что оптимизированный маршрут на SM70 — E5M2; E4M3 выбран потому, что на нём сняты собственные бенчмарки форка по этой модели. ### 7. Откат `nixos-rebuild --rollback` либо предыдущее поколение в GRUB. llama.cpp-модели этот PR не трогает: их записи и хардненинг юнита те же, что были.
caco-zero-three: serve Qwen3.8-27B NVFP4 through vLLM
All checks were successful
Check / check (push) Successful in 4m46s
5b9890ec25
llama.cpp cannot run NVFP4 on Volta at all, and its two slots plus the FIFO
RAM prompt cache are the wrong shape for many small concurrent agent
requests. 1CatAI's SM70 vLLM fork dequantises W4 weights to FP16 GEMMs,
pages an FP8 E4M3 KV cache and batches continuously.

TP2 on 2xV100 leaves ~10.3G of the 20.6G of weights and only ~2.5-3G of KV
per card: E4M3 KV (32KiB/token) buys ~170-200k tokens where FP16's 64KiB
buys ~85-100k, so max-model-len starts at 128K. The ceiling is the per-card
KV budget, not the model's 262144.

The venv lives outside nix -- Python 3.12 plus the fork's cp312 wheel from
its own GitHub release, on the guest's SSD; nix-ld is what lets those pip
wheels run under NixOS, and LD_LIBRARY_PATH exposes the driver's
libcuda.so.1 that the pip torch needs.
caco-zero-three: provision the vLLM venv from nix
Some checks failed
Check / check (push) Failing after 10s
ea28b21116
The venv stops being a manual step: nix pins the fork's wheel by hash
(pkgs.fetchurl) and the environment by version (vllm/requirements.lock,
frozen from the venv that actually worked on this box), and
vllm-venv.service materialises both into /srv/vllm/venv on boot. After a
rebuild on a clean machine the qwen38-nvfp4 backend is there with no manual
work; the weights stay outside nix as data.

libstdc++: pip's torch is built against PyPI's toolchain, so the model entry
and the provisioning unit both carry nix's gcc lib on LD_LIBRARY_PATH (plus
the driver's, for libcuda.so.1). Without it the import dies with
"libstdc++.so.6: cannot open shared object file".

Verified on caco-zero-three: wheel sha256 == the release's SHA256SUMS;
torch 2.10.0+cu128 and vllm 1.5.0 import, 2x Tesla V100-SXM2-16GB visible.
caco-zero-three: admit the operator's agent key on slopych
Some checks failed
Check / check (push) Failing after 10s
034f78ee7a
Box deploys, model bring-up and journal reading run over SSH, but the key
that opens the Proxmox host does not reach this guest, and dron's PC key is
passphrase-protected (unusable under BatchMode). slopych is already the
wheel-carrying operator account, so the agent key joins it there.
caco-zero-three: give the vLLM wheel its canonical filename
Some checks failed
Check / check (push) Failing after 9s
202fb7ef72
First deploy proved the failure: pip parses a wheel's filename for its
distribution and version, and a nix store path prefixes that name with a
32-char hash -> "Invalid wheel filename (invalid version):
'rya8jrd891nr7s5j7i1xxmnnhyz37g6q-1cat_vllm-1.5.0-cp312-cp312-linux_x86_64'"
and vllm-venv.service exited 1. A tiny runCommand copies the fetchurl output
under the canonical name before pip sees it.
caco-zero-three: give vLLM the libraries and the triton knob it needs
Some checks failed
Check / check (push) Failing after 10s
14564d5d40
Second wall on the way up: torch and triton link against a distro toolchain,
so libz.so.1 joins libstdc++.so.6 on LD_LIBRARY_PATH, and triton looks for
libcuda by parsing "/sbin/ldconfig -p" -- absent on NixOS, and its
FileNotFoundError killed model inspection before the engine started. The
TRITON_LIBCUDA_PATH knob short-circuits that lookup.

Verified on the box: triton 3.6.0 imports, libcuda_dirs() =
['/run/opengl-driver/lib'], torch.cuda.device_count() = 2.
caco-zero-three: hand triton a compiler
Some checks failed
Check / check (push) Failing after 9s
76c60b169d
Third wall: vLLM's engine init JIT-compiles triton's launcher and NixOS has
no cc in PATH, so the worker died with "Failed to find C compiler. Please
specify via CC environment variable". The model env now carries CC/CXX
pointing at nixpkgs' cc-wrapper (verified: it compiles a test program on the
box).
caco-zero-three: give tilelang a CUDA toolkit
Some checks failed
Check / check (push) Failing after 9s
f9313b1ca1
The first real request killed the engine: flash_qla's GDN kernel JITs through
tilelang, whose check_cuda_availability() is nvcc.find_cuda_path() -- it looks
for a TOOLKIT, not a GPU, and returns $CUDA_HOME or raises. Driver-only boxes
therefore report "No CUDA or HIP or MPS available on this system" and the
engine dies with a scheduler KeyError. CUDA_HOME now points at a symlinkJoin
of nixpkgs' cuda_nvcc + cuda_cudart + cuda_cccl from the same 12.8 pin
llama.cpp builds against.

Verified on the box: check_cuda_availability() = True and
determine_target("auto") = arch sm_70.
caco-zero-three: make vLLM's kernels compile and give it time to boot
Some checks failed
Check / check (push) Failing after 30s
ca48d900d8
Two fixes from the first successful bring-up:

NVCC_PREPEND_FLAGS: tilelang calls nvcc for its generated kernels without -I
on the CUDA headers, and nvcc resolves its default include path relative to
its own binary (CUDA_HOME is ignored by nvcc itself), so every GDN kernel
died with 'fatal error: cuda_runtime.h: No such file or directory' and took
the engine down. NVCC_PREPEND_FLAGS is prepended to every nvcc call.

healthCheckTimeout 300 -> 900: vLLM recompiles and recaptures its CUDA graphs
on every start (4-6 min measured here); the 300 s check gave up first.

Verified on the box: 168340-token profile at util 0.94 reports 169,816 KV
tokens, the first request answers (100 tokens in 2.16 s and no engine error).
caco-zero-three: run vLLM at the measured 168340-token profile
Some checks failed
Check / check (push) Failing after 9s
51f2b0cc37
The bring-up answered the sizing question, so the model entry now carries the
measured numbers instead of a guess:

  --max-model-len 168340        plan ceiling; KV pool measures 169,816 tokens
  --gpu-memory-utilization 0.94 14.9 of 15.77 GiB per card in use (~2 GiB left)
  --max-num-seqs 64             each sequence needs a Mamba block; 256 > ~116
  --kv-cache-dtype fp8_e4m3     8-bit K and V, the fork's Qwen3.8 measurements

DFlash2 is documented as unshippable on two V100s: vLLM fills the whole
utilization budget with the KV pool and loads the drafter after it (OOM with
7 MiB free at util 0.85/0.90/0.94); an explicit --kv-cache-memory gets
further, but the ~2 GiB/card drafter plus 12.3 GiB of weights leave no room
for a 131072-token context. vllm/README.md collects all five NixOS pitfalls
the pip toolchain hit and the profile table.
caco-zero-three: record the full DFlash2 measurement
Some checks failed
Check / check (push) Failing after 9s
cc8a04a95b
Four configurations tried (util 0.85/0.90/0.94 with a greedy KV pool, plus
util 0.92 with an explicit --kv-cache-memory 1GiB): every one dies in
load_draft_model with 7 MiB (25 MiB in the explicit-budget case) free, because
vLLM sizes the KV pool to fill the whole utilization budget before the drafter
loads. Per-card arithmetic leaves ~1.4 GiB for KV once the 12.3 GiB of weights
and the ~2 GiB drafter are in place, i.e. a 131072-token context and DFlash2
are mutually exclusive on two V100s.
caco-zero-three: document the orphaned-worker trap
Some checks failed
Check / check (push) Failing after 9s
e977311d45
Killing 'vllm serve' alone leaves VLLM::Worker_TP0/TP1 holding ~11 GiB per
card; llama-swap then cannot start the model and its child dies in ~20 s with
'upstream command exited prematurely' while the child's output stays buffered.
Cost an hour of blind diagnosis tonight -- written down with a bracket-pattern
cleanup that does not kill the ssh session running it.
llama-swap's unit is a DynamicUser with ProtectHome=true, so HOME arrives
empty and nothing can write a JIT cache. vLLM then dies while inspecting the
model architecture -- a subprocess with nothing to fall back on:

  Model architectures ['Qwen3_5ForConditionalGeneration'] failed to be inspected

llama-swap only surfaces 'upstream command exited prematurely' and the child's
output stays buffered, which is why this looked like a llama-swap problem.
llama.cpp never hit it: prebuilt binary, kernel set fixed at build time.

Reproduced and fixed inside an identical sandbox (ProtectSystem=strict,
ProtectHome=true, PrivateTmp, PrivateUsers, MemoryMax=14G): with a writable
HOME the run logs 'Resolved architecture: Qwen3_5ForConditionalGeneration' and
the engine comes up. The caches go to /var/cache/llama-swap, the only writable
tree the unit has (/srv is read-only under ProtectSystem=strict).
caco-zero-three: make vLLM's child log survive a failed start
Some checks failed
Check / check (push) Failing after 9s
bb88149608
llama-swap reports only 'upstream command exited prematurely' when a child
dies during startup; the child's own output stays in a pipe buffer. The entry
now runs through a wrapper that tees to /var/cache/llama-swap/vllm-child.log,
the unit's writable cache tree, so a startup failure is diagnosable from
outside the sandbox instead of blind.
caco-zero-three: move vLLM's caches to a path the loader can map
Some checks failed
Check / check (push) Failing after 9s
5224841a9c
Root cause of the whole llama-swap failure, now read off the child's own log
(the wrapper added in the previous commit):

  RuntimeError: Error raised in subprocess:
  Triton cache error: compiled module cuda_utils.so could not be loaded
  ImportError: /var/cache/llama-swap/triton/<hash>/cuda_utils.cpython-312-x86_64-linux-gnu.so:
    failed to map segment from shared object

/var/cache/llama-swap takes writes (llama.cpp caches HF downloads there) but
it is a symlink into the unit's private 0700 directory, and mapping an .so
through it fails. vLLM's model-architecture inspection runs in a subprocess,
so the loader error ended the process and llama-swap reported only 'upstream
command exited prematurely'.

HOME and every cache root now live under the unit's PrivateTmp /tmp, which is
both writable for the dynamic user and map-friendly -- verified in an
identical sandbox where the architecture resolved and the engine came up.
caco-zero-three: name the last vLLM blocker and how to see it
Some checks failed
Check / check (push) Failing after 9s
8bf42aa30b
The child-log wrapper paid off: vLLM now gets past architecture inspection
(the cache path was the second wall -- /var/cache/llama-swap takes writes but
the loader cannot map an .so through the unit's private 0700 symlink) and the
remaining wall is visible by name:

  PermissionError: [Errno 13] cannot allocate executable memory

MemoryDenyWriteExecute=true on llama-swap's unit forbids W^X, which a JIT
engine needs; llama.cpp never noticed because its kernels are built ahead of
time. Recorded with the recommendation to run vLLM as its own unit (own
MemoryMax too -- the child currently lands in llama-swap's 14G cgroup) and
reach it from llama-swap as an HTTP upstream.
caco-zero-three: serve vLLM from its own unit, proxy it through llama-swap
Some checks failed
Check / check (push) Failing after 11s
ac507be0af
The router's unit cannot host a JIT backend: it carries W^X
(MemoryDenyWriteExecute, from the web hardening tier) and hands its children an
empty HOME, and neither is exemptable per model -- vLLM dies with
'cannot allocate executable memory'. So vLLM becomes systemd.services.vllm:

  * nd.hardening.floor.runtime, the repo's JIT-safe tier (baseline + seccomp
    allow-list, no W^X, device access left to the service), plus AF_NETLINK for
    NCCL's getifaddrs bootstrap (the same fix the llama-swap unit needs);
  * its own user and StateDirectory=/var/lib/vllm -- a real directory, because
    the toolchain compiles .so files and the loader must map them (a
    DynamicUser's 0700 tree behind a symlink gives 'failed to map segment from
    shared object'); the caches persist, so kernels compile once;
  * own MemoryMax 16G, so the router's 14G cgroup no longer has to cover a
    Python backend, and TimeoutStartSec 20min for the 4-6 min cold start;
  * ExecStart as an argv list, which retires the JSON-quoting trap.

llama-swap keeps the qwen38-nvfp4 entry as a passthrough
(proxy: http://127.0.0.1:5810, checkEndpoint: /health) in the same gpu-llm
group; the llama.cpp models keep their hardening untouched.
caco-zero-three: ExecStart wants a list of argv lists
Some checks failed
Check / check (push) Failing after 10s
e6acd191f4
A bare list of strings is a list of ExecStart= lines, and systemd refuses more
than one for Type=exec ('Service has more than one ExecStart= setting, which is
only allowed for Type=oneshot services'), leaving the unit bad-setting and
inactive. The command is one element: ExecStart = [ [ bin arg... ] ].
caco-zero-three: hand the JSON kwarg to a shell, not to systemd's parser
Some checks failed
Check / check (push) Failing after 11s
272eb0977a
The unit came up bad again with

  vllm serve: error: argument --default-chat-template-kwargs:
    invalid loads value: '{enable_thinking:true}'

systemd's unit-file reader strips the double quotes out of the JSON while it
parses the ExecStart word, so argv arrives quote-free. The command is now a
script= line: the shell is the quoting layer that already handles this kwarg
correctly (it is how the llama-swap entry passed it while vLLM was a child).
caco-zero-three: script= is a service option, not a serviceConfig key
Some checks failed
Check / check (push) Failing after 10s
ca866836b3
systemd rejected it as 'Unknown key script in section [Service]' and refused
the unit ('Service has no ExecStart='), which also made the whole switch exit
4. Moved to the service level, where NixOS turns it into ExecStart=/bin/sh -c.
caco-zero-three: restate the whole address-family list
Some checks failed
Check / check (push) Failing after 10s
f9003a85c5
The tier is applied with a plain '//', which is not a module merge, so writing
RestrictAddressFamilies in the override REPLACED the tier's list instead of
extending it. Left with only AF_NETLINK the unit could not even bind its own
port:

  OSError: [Errno 97] Address family not supported by protocol
    socket.socket(family=family, type=socket.SOCK_STREAM)

All four families (the tier's three plus netlink for NCCL) are now spelled out.
caco-zero-three: give the passthrough entry a placeholder command
Some checks failed
Check / check (push) Failing after 10s
66919d3a4c
A member of a swapping group must own a command: llama-swap calls
SanitizedCommand() on every member, and an empty one fails the request with
'unable to get sanitized command: empty command'. The entry now manages an
inert 'sleep infinity' while the traffic goes to proxy: -- so llama-swap
records the swap without owning the vLLM unit, which is the point.
caco-zero-three: README follows the shipped topology
Some checks failed
Check / check (push) Failing after 10s
2037c0e14f
The env table describes the unit's own StateDirectory instead of the /tmp
workaround, the profile table carries the VRAM actually observed (15.4 of
15.77 GiB per card, not the planning figure) and the passthrough shape,
including the placeholder cmd it needs, is written down next to the pitfalls
that produced it.
Measured against the live engine: with enable_thinking=true the model burns
248 tokens and the response comes back with content AND reasoning_content both
empty (the 'qwen3' reasoning parser consumes the whole output), so a client
renders an empty turn and then fabricates continuations -- the 'nonsense' a
WebUI session showed. With enable_thinking=false the same prompt answers in
full, and tool calling still returns finish_reason=tool_calls. Clients that
want the thinking path can still opt in per request; the parser needs its own
investigation before that is usable.
caco-zero-three: stop the reasoning loop with the sampling the model needs
Some checks failed
Check / check (push) Failing after 10s
2e5ba192a0
Root cause of the 'nonsense': with thinking on the model degenerates into a
repetition loop INSIDE its reasoning block and never emits the closing marker,
so the qwen3 parser has nothing to hand back and the answer arrives empty --
the WebUI then renders an empty turn and fabricates continuations. Raw output
captured through /v1/completions (no template, no parser) shows the loop
verbatim: '17 x 23 = 391' repeated ~25 times, finish=stop, 303 tokens, no
closing marker.

The checkpoint's own generation_config.json ships temperature 1.0 / top_k 20 /
top_p 0.95 and no repetition penalty, and the template defaults to
reasoning_effort=xhigh -- longest possible thinking, which is what tips it over.
Measured sweep on the live engine (thinking on, same question):

  temp 0.3                        -> empty answer, 22-line loop
  temp 0.6 + top_p 0.95           -> 40-line loop
  temp 0.6 + top_p 0.95 + rep 1.05-> 391, 72 tokens, no loop   <-- adopted
  effort=low, temp 0.6 + top_p .95-> 391, 67 tokens
  effort=medium, temp 0.7/0.8/1.1 -> 391, 142 tokens

--generation-config stays 'auto', so --override-generation-config merges with
the model's own config instead of replacing it. Thinking stays ON.
caco-zero-three: use the llama.cpp Qwen sampling for the vLLM model
Some checks failed
Check / check (push) Failing after 10s
04835ba99b
The user's call, and it corrects my earlier direction: this file already runs
the Qwen models on llama.cpp with

  --temp 1.0 --top-p 0.95 --top-k 20 --min-p 0.00 --repeat-penalty 1.0 --presence-penalty 0.0

which is also what the checkpoint's generation_config.json asks for. Lowering
temperature instead (0.6) was pushing the wrong way: low temperature with no
repetition penalty is exactly what drives this model into the repetition loop
inside its reasoning block that made the answers arrive empty.

vLLM now gets the same settings via --override-generation-config (merged with
the model config, since --generation-config stays 'auto'), so every client that
does not send sampling parameters inherits them.
Both are chat-template kwargs, and this checkpoint's template reads both:
  - reasoning_effort (xhigh default / medium / low) -- medium shortens the
    thinking, which matters because the template's xhigh is what tipped the
    model into repetition loops;
  - preserve_thinking -- keeps the assistant's <think> block when the history
    is re-rendered. The template already defaults to true; stating it explicitly
    makes the behaviour independent of the client.
vLLM's --reasoning-config has no equivalent knob (only reasoning_parser and
reasoning_parser_plugin), so the template kwarg is the right layer.
caco-zero-three: KV cache fp8_e5m2, as the fork recommends for SM70
Some checks failed
Check / check (push) Failing after 11s
afd68d1d21
The one real deviation from the fork's recommended command line was the KV
cache dtype: we ran fp8_e4m3 (picked earlier because the fork's benchmarks were
taken with it), while their SM70 recommendation is fp8_e5m2 -- on Volta that is
the path they optimise, E4M3 takes an explicit route. Measured on the box before
the change: only 2 of 6 answers came back complete, two arrived empty
(finish=stop, no tokens) and two were truncated mid-list. README updated to
match.
The model's own generation_config ships temperature 1.0 with no repetition
penalty, and 1Cat's production contract keeps that. Their design docs also
document a second, client-visible profile for precise work: 'the official Qwen
model card ... precise coding ... recommends temperature 0.6 with top-p 0.95 /
top-k 20 and no presence penalty', and their quality runs use it with an
8192-token output budget.

Measured on this box (thinking on, raw generation so no parser is involved,
counting how often the model closes its </think> block):

  budget 1500, temp 1.0, rep 1.0  -> 2/5 closed, 1214 tokens burned on average
  budget 1500, temp 0.6, rep 1.05 -> 5/5 closed, 197 tokens on average
  budget  300, temp 1.0  -> 4/8     budget 300, temp 0.6/1.05 -> 6/8

So the two changes belong together: the model loops inside its reasoning block
and never emits the closing marker, which the reasoning parser correctly reports
as 'all reasoning, no answer' -- the empty replies the WebUI showed. Clients
must also give the thinking room (1Cat's own runs used 8192; a 300-token budget
cannot work with thinking enabled).
caco-zero-three: align the vLLM launch with 1Cat's own contract
Some checks failed
Check / check (push) Failing after 11s
893e74c908
Their SM70 NVFP4 acceptance doc fixes the contract for this checkpoint:
temperature 1.0 / top-p 0.95 / top-k 20, thinking on at the template's xhigh
default, prefix caching on, Mamba align, 4096 max batched tokens, 32 sequences,
custom all-reduce off, CUDA graphs off, FP8 E5M2 KV. We were running with
different values for six of those, and this model is a 48-GDN-layer hybrid, so
the Mamba cache mode is not a detail: vLLM's default is 'all', theirs is
'align'.

The symptom we are chasing: in ~1/6 requests (much more at temperature 0.6) the
model degenerates inside its reasoning block -- literal repetition ('17 x 23 =
391' hundreds of times) or vacuous rambling -- burns the whole token budget and
never emits the closing marker, so content comes back empty.

Dropped from our side while hunting this: the temperature 0.6 / repetition 1.05
override (measured no better than the contract 1.0/1.0) and the extra template
kwargs reasoning_effort=medium / preserve_thinking (both are template defaults).

Kept for hardware reasons: TP2, 168340 context, 0.94 utilisation, no DFlash2.
caco-zero-three: drop --mamba-cache-mode align -- it does not fit two cards
Some checks failed
Check / check (push) Failing after 25s
e1e7d40afc
With the contract flags in place the engine refused to start:

  ValueError: To serve at least one request with the model's max seq len
  (168340), 2.73 GiB KV cache is needed, which is larger than available

The Mamba state cache in 'align' mode costs about 1.2 GiB per card (available
KV dropped from 2.77 GiB to 1.59 GiB), which on two V100s takes the context
below our 168340 budget. 1Cat run the same mode at 0.80 utilisation on four
cards with DFlash2 resident, so they have the headroom; we do not.

Everything else from their contract stays: prefix caching on, 4096 max batched
tokens, 32 sequences, custom all-reduce off, CUDA graphs off, temperature 1.0 /
top-p 0.95 / top-k 20, thinking on at the template's xhigh default.
caco-zero-three: drop --enforce-eager, it does not fit our KV budget
Some checks failed
Check / check (push) Failing after 26s
8fa52724e0
Correction to the previous commit: --mamba-cache-mode align was never the
problem. The fork's own SM70 defaults already log 'Prefix caching is enabled
with Mamba cache align mode' without us asking, so our explicit flag was a
no-op. The real blocker is --enforce-eager: on a completely empty GPU (4 MiB
used, no processes) the engine still refused to start with

  ValueError: To serve at least one request with the model's max seq len
  (168340), 2.73 GiB KV cache is needed, which is larger than available

i.e. eager mode reserves enough scratch memory to push our 168340-token
context out of budget on two V100s. 1Cat run it on four cards with a drafter.

Everything else from their contract stays: prefix caching, 4096 max batched
tokens, 32 sequences, custom all-reduce off, temperature 1.0 / top-p 0.95 /
top-k 20, thinking on at the template's xhigh default, E5M2 KV.
caco-zero-three: full 1Cat flag set, context temporarily 81920
Some checks failed
Check / check (push) Failing after 27s
c1d65a8ca9
Per the user's call: keep the complete contract (CUDA graphs off, Mamba align,
4096 batched tokens, 32 sequences, custom all-reduce off, E5M2, contract
sampling) and buy the memory for it by cutting the context from 168340 to
81920. With eager mode the engine needs 2.73 GiB of KV for 168340 tokens and
refuses to start on two V100s; 81920 needs about a third of that, so the
contract fits and we get a clean comparison against 1Cat's configuration.

Restore the context once the reasoning degeneration is understood.
caco-zero-three: swap the checkpoint to RedHatAI/Qwen3.8-27B-INT4 (W4A16)
Some checks failed
Check / check (push) Failing after 10s
8b4a3a5f64
Root cause of the degenerate output, finally documented: the QUASAR NVFP4
checkpoint is W4A4 -- four-bit weights *and activations* -- and its own model
card requires FP4 hardware (Blackwell, compute capability 10.0+). On Volta the
fork emulates that path, and the emulation shows up as repetition loops inside
the reasoning block, empty content and agrammatic Russian in up to a third of
requests. We ruled out everything else first: all 11 reasoning parsers, the
tool parser, KV dtype E4M3 vs E5M2, temperature 1.0 vs 0.6, repetition and
presence penalties, reasoning_effort, output budget 300..8192, template kwargs,
chat vs raw endpoint, and finally 1Cat's own full flag contract (eager, Mamba
align, 4096 batched tokens, 32 sequences, custom all-reduce off).

This swaps in a weight-only INT4 build: activations stay 16-bit, the 192 GDN
(linear_attn) layers are left unquantized, 19.5 GB instead of 20.5, and it
ships model_mtp.safetensors. Everything else -- flags, context 81920, E5M2 KV,
sampling, served name qwen38-nvfp4 -- is untouched, so the comparison against
the 4-of-9-empty baseline is one variable wide.
caco-zero-three: run AWQ-INT4 instead of the NVFP4 checkpoint
Some checks failed
Check / check (push) Failing after 9s
48a66406de
The QUASAR NVFP4 checkpoint is W4A4 and requires FP4 hardware (Blackwell,
cc 10.0+); on Volta the fork emulates it and the output degrades in up to a
third of requests. This switches to a weight-only INT4 checkpoint, which is
the class the fork actually supports on SM70.

Why AWQ and not the RedHatAI compressed-tensors INT4 build, now verified in
the installed wheel rather than guessed:

  compressed_tensors_wNa16.get_min_capability() returns 75 on any non-SM70
  backend -- the comment in the source literally says 'Turing and up' -- and
  returns 70 only when the TurboMind route is enabled. That route needs
  torch.ops._C.uint4_sm70_prepare, which does not exist in our wheel, and
  enabling the env var without it raises at load time. So a compressed-tensors
  INT4 checkpoint cannot load here.

  AWQ has real SM70 code: vllm/_sm70_ops.py, warmup/awq_sm70_warmup.py,
  _SM70_AWQ_PREFILL_DENSE_SHAPES, the VLLM_SM70_AWQ_* tuning family, and an
  SM70 gate that accepts group sizes 32/64/128. Their own V100 benchmark table
  lists Qwen3.6-27B-AWQ.

cyankiwi/Qwen3.8-27B-AWQ-INT4: 21.0 GB, group size 32, activations
unquantized, 192 GDN layers excluded from quantization. Flags, context 81920,
E5M2 KV, sampling and the served name qwen38-nvfp4 are untouched.
Replaces the previous attempt, which was named AWQ but packaged as
compressed-tensors -- and compressed-tensors W4A16 declares min_capability 75
on Volta, so the engine crash-looped instead of loading. Verified before
downloading this time:

  config.json quantization_config = {quant_method: awq, bits 4, group_size
  128, version gemm, zero_point true} -- the real AWQ layout, no
  quantize_config.json needed;
  awq_marlin.py get_min_capability() returns 70 outright and its SM70 gate
  accepts group sizes 32/64/128;
  awq_gemm / awq_marlin_repack / marlin_gemm all present in the wheel;
  chat_template.jinja is the same 8952-char template the QUASAR build ships
  and supports enable_thinking / reasoning_effort / preserve_thinking / tools.

Size 18.73 GB, one shard, 1.3 GB lighter than the alternative AWQ build whose
extra weight is a 0.849 GB MTP head (unusable here) plus 0.475 GB of g64
overhead. Kept identical: flags, context 81920, E5M2 KV, contract sampling,
served name qwen38-nvfp4, and the QUASAR directory on disk for rollback.
caco-zero-three: set VLLM_SM70_QUANT_BACKEND=marlin for AWQ on Volta
Some checks failed
Check / check (push) Has been cancelled
b5916a0e12
awq.py returns min_capability 70 only when sm70_tm.forces_marlin() is true,
and that is exactly VLLM_SM70_QUANT_BACKEND=marlin. Without it the AWQ path
keeps the upstream 'Turing and up' (75) gate and the engine cannot load an AWQ
checkpoint on SM70 at all. With it, awq.py delegates to AWQMarlinConfig, whose
get_min_capability() returns 70 unconditionally, whose SM70 gate accepts group
sizes 32/64/128, and whose kernels (awq_gemm, awq_marlin_repack, marlin_gemm)
are present in this wheel.

Deliberately the opposite of the compressed-tensors route: that one needs
TurboMind kernels (uint4_sm70_prepare) which are absent, this one uses marlin
kernels which are present. Group size of the chosen checkpoint is 128, inside
the accepted set.
caco-zero-three: correct the VLLM_SM70_QUANT_BACKEND rationale
Some checks failed
Check / check (push) Failing after 10s
fdd0e91a83
Measured on the box, not read off the source: AWQConfig.get_min_capability()
returns 70 both with and without VLLM_SM70_QUANT_BACKEND=marlin, because the
fork lowered that gate for SM70 unconditionally. The previous commit message
claimed the variable was a load condition ('without it the AWQ path keeps the
75 gate and cannot load') -- that is false, and it came from reading the
if-branch of get_min_capability and assuming the else-branch by analogy with
compressed-tensors instead of reading it.

The variable stays, for the reason that is actually true: it selects the
kernel. With forces_marlin() true, awq.py delegates to AWQMarlinConfig, i.e.
the marlin kernels (awq_marlin_repack, marlin_gemm) rather than the fork's own
awq_gemm path -- the route the fork's own SM70 whitelists are written against.

Probe used (both runs on the live box):
  forces_marlin=False awq_min_capability=70   (unset)
  forces_marlin=True  awq_min_capability=70   (VLLM_SM70_QUANT_BACKEND=marlin)
caco-zero-three: do not load the vision tower on the AWQ checkpoint
Some checks failed
Check / check (push) Failing after 10s
c46e295f6b
The AWQ checkpoint crash-looped the unit on its first load:

  Qwen3_VisionTransformer -> Qwen3_VisionBlock -> Qwen2_5_VisionAttention
  -> RowParallelLinear -> create_weights
  ValueError: The input size is not aligned with the quantized weight shape

Not the language model: the vision tower. This checkpoint ships with an empty
modules_to_not_convert, so the tower is quantized too, and the vision
attention's row-parallel proj layer cannot be sharded cleanly at TP2 with a
quantized weight shape. We had flagged that empty list earlier as a risk to
watch -- it bit here first.

Fix uses the engine's own mechanism rather than touching the checkpoint:
vLLM's tower marking skips tower modules when a modality's limit is zero, and
--enable-mm-embeds is the documented companion that keeps precomputed
embeddings usable while not loading encoder modules. We serve text only.

  --limit-mm-per-prompt '{"image":0,"video":0}' --enable-mm-embeds
AWQ (mattbucci/Qwen3.8-27B-AWQ, real AWQ, group 128) does load its language
model but crash-loops on the vision tower: RowParallelLinear.create_weights
raises 'The input size is not aligned with the quantized weight shape'. Its
modules_to_not_convert is empty, so the tower is quantized and its row-parallel
proj layer cannot be sharded at TP2. Verified that neither
--limit-mm-per-prompt '{"image":0,"video":0}' nor --enable-mm-embeds stops the
tower from being constructed -- the flag is applied in the log and
self.visual = Qwen3_VisionTransformer(...) still runs. Quasar is restored so
the service works; the vision route needs a checkpoint whose tower is intact,
TP1 (does not fit), or a newer engine.

Also recorded: our wheel is v1.5.0 (2026-09-02) while main is 663 commits
ahead (2026-09-14) and contains the fixes we are looking for -- grouped
attention warp-state ordering, several FP32 precision fixes for SM70, and the
FP32 partials/logits work in docs/design/sm70_dflash2_fp32_defaults.md.
caco-zero-three: remove my two AWQ additions -- they broke QUASAR
Some checks failed
Check / check (push) Failing after 12s
ac13cb8611
My VLLM_SM70_QUANT_BACKEND=marlin was added as a kernel-selection knob and is
not one: it forces the marlin route for every quantized checkpoint, and QUASAR
(NVFP4) dies at load on it:

  torch.ops._C.gptq_marlin_repack(...)
  RuntimeError: size_n = 8240 is not divisible by tile_n_...

which crash-looped the unit five times. I introduced that flag on a reading I
never verified against QUASAR -- the same mistake I called out earlier.

Also dropping --limit-mm-per-prompt '{"image":0,"video":0}' --enable-mm-embeds.
Measured and disproved: the flag is applied ("enable_mm_embeds is True" appears
in the log) yet self.visual = Qwen3_VisionTransformer(...) still runs, so the
tower is always built for this model class. It did not help with AWQ either.

Config returns to the known-good baseline: QUASAR with the flag set as before.
The AWQ findings and the 663-commit gap behind main stay documented above.
Reproduced the empty-content failure on the stock config with official sampling
(temp 1.0 / top_p 0.95 / top_k 20), same prompt class as 1Cat's own reproducer,
max_tokens 6000: 2 of 2 runs ended finish=stop with ZERO content characters
while still inside reasoning (105 and 1250 completion tokens, 502 and 5344
reasoning chars). The model stops mid-thought and never answers.

That is the class 1Cat documents in SM70_MTP_OUTPUT_QUALITY_AUDIT_20260616.md,
where the root is localized to the split of the Qwen GDN forward in the
compile/FULL graph and where the full forward behind one opaque custom-op
boundary is recorded as the only passing localizer:

  'Latest VLLM_SM70_QWEN_GDN_FULL_FORWARD=1 passes two long reproducer runs
   without the repeated-output collapse ... now the selected quality guard for
   SM70 compile/FULL graph.'

So this is not an NVFP4/W4A4-on-Volta effect: they reproduce the same collapse
on 27B-AWQ (W4A16).

Set VLLM_SM70_QWEN_GDN_FULL_FORWARD=1. Our wheel has all five vars of their
later accepted route and all seven QWEN_GDN switches default to False
(verified in vllm/envs.py), so this is the single delta from stock.
Disproved the stop-token hypothesis first: QUASAR's generation_config sets
eos_token_id to ***, which the tokenizer maps to <|im_end|> and
<|endoftext|>. The thinking markers are separate ids (248068 '<think>',
248069 '</think>'), so the engine is NOT cutting generation at the reasoning
boundary. The model emits its end-of-turn token on its own, right after
thinking, without producing content -- finish=stop, content empty, in 2 of 2
runs under official sampling both before and after VLLM_SM70_QWEN_GDN_FULL_FORWARD=1.

That told us why the fork's GDN boundary fixes changed nothing: all of 1Cat's
work in SM70_MTP_OUTPUT_QUALITY_AUDIT and sm70_dflash2_quality_audit is about
the compile/FULL graph (Warp-state ordering, opaque custom-op boundaries,
FP32 partials), and this unit pins --enforce-eager, which the journal confirms
with 'Cudagraph is disabled under eager mode'. We were never on the path they
fixed. Eager was forced in when the KV budget did not fit at 168340; at 81920
there is headroom.

So: remove --enforce-eager, keep FULL_FORWARD=1 and everything else, and
re-measure with the same 3x6000-token reproducer.
Prepared as the next single-variable step, not deployed yet.

Commentting on the current state: the unit currently passes only
enable_thinking=true. The one configuration in this session where the model
actually answered correctly (the 391 arithmetic case, finish=stop WITH content)
ran with reasoning_effort=medium and preserve_thinking=true as well, which is
also what was asked for from the start. The chat template keys its reasoning
scaffolding off these kwargs, so dropping them plausibly changes what the model
sees at the reasoning boundary -- which is exactly where it now emits
<|im_end|> and stops with zero content.

Kept as a separate commit so the no-eager test can finish first: one variable
at a time.
Comments only -- the running system already has this configuration; no deploy
needed for a comment change.

Root cause, measured: the checkpoint's chat_template.jinja derives its reasoning
instruction from reasoning_effort with default 'xhigh' and has NO branch for
'medium'. Omitting the kwarg therefore ordered maximum-effort thinking, and the
model ended its turn (emitting <|im_end|>) inside its own reasoning without ever
writing an answer. Proof: tokenizer ids show <think> 248068 and </think> 248069
are not part of eos_token_id {<|im_end|>, <|endoftext|>}, so no engine-side stop
was involved.

Also recorded: what was excluded with measurements (eager vs compile path,
VLLM_SM70_QWEN_GDN_FULL_FORWARD=1, sampling, KV dtype, the W4A4 theory), the
KV budget gain from dropping eager (91,639 -> 161,063), and the remaining
candidate for long-output robustness (663 commits behind main).
caco-zero-three: test a W4A16/AWQ checkpoint (Qwen3.8-27B-AWQ-MTP)
Some checks failed
Check / check (push) Failing after 20s
9e3a95a8ce
QUASAR measures 18/30 (60%) correct on 10 verifiable questions x3 through the
client path; temperature does not help (0.6 -> 15/30). Two mechanisms are read
from the checkpoints' own configs:

  QUASAR   compressed-tensors, W4A4: ignore = 6 entries, ZERO linear_attn/GDN
           -> the recurrent GDN layers are quantized, at 4-bit ACTIVATIONS on an
           emulated Volta path, on every single token.
  AWQ-MTP  awq, W4A16: 103 exclusions, 96 linear_attn/GDN + 5 vision
           -> GDN state stays FP16 and activations are not quantized at all.

Vision is excluded here, so the TP2 sharding failure that killed
mattbucci/Qwen3.8-27B-AWQ (empty modules_to_not_convert) does not apply.

Attribution note: not the checkpoint 1Cat use -- their docs name
Qwen3.6-27B-AWQ (their own build, generation 3.6). Class matches, artifact does
not. The mechanism above is from config.json, not by analogy.

Checkpoint verified complete before this commit: 18 files, 20,043,146,728 bytes.
caco-zero-three: K8/V4 KV cache, 163840 context, MTP4 verification
Some checks failed
Check / check (push) Failing after 10s
2e139a9997
Three knobs, user-requested, one deploy:

1. --kv-cache-dtype turboquant_k8v4 (K in 8 bits, V in 4). First-class cache
   dtype of this fork: listed beside fp8_e4m3/fp8_e5m2 in vllm/config/cache.py,
   packed as uint8. A 4-bit V buys back pool for the larger context.
2. --max-model-len 81920 -> 163840. Pool was 205494 tokens at 2.51x concurrency
   for 81920, so 163840 fits; checkpoint max_position_embeddings is 262144.
3. --speculative-config with method=mtp, num_speculative_tokens=4,
   use_local_argmax_reduction=true, attention_backend=TRITON_ATTN -- verbatim
   from the fork's SM70_MTP_OUTPUT_QUALITY_AUDIT_20260616.md.

The MTP head is real and readable (model_extra_tensors.safetensors, 15 mtp.*
tensors, BF16, referenced by the index); the UntypedStorage ValueError in the
journal came from another artifact and never blocked loading.

RISK: 4-bit V and MTP both change numerics. Quality must be re-measured; the
baseline is the previous config (fp8_e5m2/81920/no-MTP) at 30/30 twice.
The first attempt crash-looped (NRestarts climbing) with:

  ValueError: Selected backend AttentionBackendEnum.FLASH_ATTN_V100 is not
  valid for this configuration. Reason: ['kv_cache_dtype not supported']

So the turboquant_* cache dtypes are not a free-standing flag: they are served
only by vllm/v1/attention/backends/turboquant_attn.py, whose get_name() is
'TURBOQUANT'. Switching the backend is a CONSEQUENCE of choosing the cache
dtype. arg_utils additionally forces flash_attn_version=2 for turboquant_*
(FA3+ boundary layers assert FlashAttentionImpl) and does that automatically.

The preset is exactly what was asked for, read from the fork's TQ_PRESETS:
turboquant_k8v4 = {key_quant_bits: 8, value_quant_bits: 4,
norm_correction: False}, i.e. K in 8 bits and V in 4.

MTP is deferred to its own step: the fork's MTP4 recipe carries
"attention_backend":"TRITON_ATTN", which almost certainly cannot serve
turboquant_* either, so the pairing must be validated separately.

Context stays at 163840. Baseline for quality: 30/30 twice on
fp8_e5m2/81920/FLASH_ATTN_V100/no-MTP.
The 0.94 attempt crash-looped on the engine's own arithmetic:

  To serve at least one request with the model's max seq len (163840),
  (2.04 GiB KV cache is needed, which is larger than the available KV cache
  memory (1.32 GiB). Based on the available memory, the estimated maximum model
  length is 99840.

So the K8/V4 compression works as intended (163840 tokens cost ~2.04 GiB, about
13 KB per token); the budget was 0.72 GiB short. At 0.94 each V100 gets 15.4 GiB,
the weights plus the SM70 compile graphs take ~14.4 GiB, leaving 0.66 GiB per
card for KV, while 163840 tokens need ~1.02 GiB per card. 0.97 gives ~1.12 GiB
per card = 2.24 GiB total, about 10% headroom.

Fallback if graph capture OOMs: max_model_len 147456 (needs ~1.84 GiB), not a
change of the KV dtype.
caco-zero-three: fp8 + FLASH_ATTN_V100 + 163840 + MTP4 (user's decision)
Some checks failed
Check / check (push) Failing after 10s
c066dad764
This is the pairing the fork's own benchmark table names: 'Qwen3.6-27B-AWQ +
MTP4 | 4x V100 - TP4 - E5M2 KV - Flash-V100 - CUDA Graph'. The turboquant_k8v4
route and MTP are mutually exclusive for now: the MTP4 recipe uses
attention_backend TRITON_ATTN, and TRITON_ATTN reports
supports_kv_cache_dtype('turboquant_k8v4') == False (measured on the box).

Memory arithmetic, corrected after the user's question 'why did memory get
smaller with turboquant': per token the turboquant cache is actually CHEAPER
(1.32 GiB / 99840 tokens = 14.2 KB) than fp8 (3.56 GiB / 205494 = 18.6 KB). The
turboquant attempt looked worse because of its BACKEND overhead -- at the same
0.94 it left 1.32 GiB for KV versus 3.56 GiB with FLASH_ATTN_V100, i.e. ~2.2 GiB
more non-KV memory. At fp8, 163840 tokens cost ~2.98 GiB and fit in 3.56 GiB, so
the proven 0.94 is enough (not the 0.97 that the turboquant layout needed).

Quality must be re-measured: MTP changes the decode path. Baseline: 30/30 twice
on fp8_e5m2/81920/FLASH_ATTN_V100/no-MTP.
MTP costs about 1.17 GiB of the KV budget (engine's own numbers on the 0.94
attempt): 'max seq len (163840), (3.14 GiB KV cache is needed, which is larger
than the available KV cache memory (2.39 GiB). Based on the available memory,
the estimated maximum model length is 117968.' Without speculative decoding the
same setup had 3.56 GiB of KV, so the verifier's cache for drafted positions is
what eats the difference.

0.97 adds 0.48 GiB per card = 0.96 GiB total, bringing KV to ~3.35 GiB against
the 3.14 GiB needed -- fits with ~7% headroom. Fallback if graph capture OOMs:
max_model_len 147456 (needs ~2.83 GiB, ~18% headroom), not dropping MTP.

Also handled: at 0.97 the engine requires 15.29 GiB free at startup, and a
restart race against the previous process (15.1 GiB free) makes it refuse to
start with 'Free memory on device cuda:0 is less than desired'. The deploy now
stops the unit and waits for the GPU to be fully released before switching.
Two hard numbers from the engine's own startup checks:

1. gpu_memory_utilization 0.97 is impossible on this box: 'Free memory on device
   cuda:0 (15.1/15.77 GiB) on startup is less than desired (0.97, 15.29 GiB)'.
   15.1 GiB is NOT a restart race -- it is what torch reports free on a fully
   idle card (15.77 GiB total, the driver keeps the rest). Ceiling = 0.957, and
   0.96 already fails. Working value: 0.95 (14.98 GiB).
2. With MTP the KV budget at 0.95 is 2.55 GiB while 163840 tokens need 3.14 GiB.
   MTP costs ~1.17 GiB that is not recoverable from the slot count (it is the MTP
   head's weights plus verification graphs, not drafted KV slots).

Therefore 163840 and MTP4 cannot coexist on 2x V100 16GB with this checkpoint.
Chosen: keep MTP4, set max_model_len 122880 -- 1.5x the 81920 the client was
hitting its limit on, with ~8% KV headroom. The alternative, if the full 163840
is needed later: drop MTP, which at fp8 measured a 205494-token pool covering
163840 comfortably.
caco-zero-three: target 163840 + MTP4 via three measured memory levers
Some checks failed
Check / check (push) Failing after 10s
d7578ea074
Budget at 0.95 with MTP: available KV 2.55 GiB, 163840 needs 3.14 GiB -- 0.59
short. Levers, all measured on this box:

  --language-model-only   the vision tower is 921 MB FP16 (from safetensors
                          headers), 461 MB per card at TP2 -> 0.92 GiB total.
                          Images are not used. Confirmation is the per-card
                          'Model loading took' figure dropping by ~0.5 GiB and
                          the vision-cache log line disappearing.
  --max-num-seqs 4        batch buffers (incl. the MTP verifier) shrink ~8x
  --max-num-batched-tokens 2048   halves prefill chunk and piecewise buffers

Together ~1.4 GiB freed vs a 0.59 GiB deficit, with the graph capture (0.49 GiB
per card, measured) accounted for. Hard rule satisfied: 2048 >= 4.

User's decision rule: if the KV pool comes out above 150k tokens, keep it.
Per the user: the vision tower must stay (visual analysis is required), and no KV
offloading is to be used. So the only reductions are the two scheduling knobs:

  --max-num-seqs 4 (was 32): the fork generates capture shapes as
    [1, 2, 4] + range(8, 256, 8) + range(256, max+1, 16) with
    max = min(max_num_seqs*2, 512) -- at seqs=32 that meant graphs up to 64
    (exactly the list seen in the log), at seqs=4 it collapses to [1, 2, 4].
  --max-num-batched-tokens 2048 (was 4096): halves prefill chunk buffers.

That is expected to recover most of the 0.93 GiB vLLM estimates for CUDA graphs,
against a 0.59 GiB deficit for 163840 at 0.95 with MTP and the tower loaded.
Hard rule satisfied: 2048 >= 4.

Goal: measure the resulting KV pool. If above 150k tokens, keep it.
caco-zero-three: no MTP, previous seq/batch, util 0.94 -- measurement run
Some checks failed
Check / check (push) Failing after 9s
7bf4b95b7b
Per the user: run without MTP and with the previous scheduler values so that real
chat traffic produces comparable prefill/decode numbers in the journal.

  no --speculative-config   MTP off (as asked). Removing it also frees the
                            1.17 GiB of MTP overhead, which is what makes 163840
                            fit here: 2.98 GiB needed (18.6 KB/token at fp8,
                            measured) vs 3.56 GiB available at 0.94, and the
                            measured pool in this shape is 205,494 tokens.
  --gpu-memory-utilization 0.94   the proven value; 0.95 would eat capture
                            headroom (capture needs 0.49 GiB per card, measured).
  --max-num-seqs 32 and --max-num-batched-tokens 4096   previous values, restored
                            so the throughput figures stay comparable.
  Vision tower stays ON; no --language-model-only, no KV offloading.

Throughput is read from vLLM's own 'Avg prompt throughput / Avg generation
throughput' journal lines while the user drives chats.
caco-zero-three: MTP4 + 8 GiB KV offload to host RAM, context kept at 163840
Some checks failed
Check / check (push) Failing after 11s
744153f856
User's explicit request, both levers on:

  --speculative-config (MTP4)   ~1.17 GiB VRAM (head weights + verify buffers;
                                'Model loading took' goes 9.52 -> 10.69 GiB/card)
  --kv-offloading-size 8        KV spills to host RAM (native backend, summed
                                across TP ranks; host has ~23 GB, ~19 free)

Open question this run answers: does the startup KV check count the offload
buffer as available capacity? In VRAM alone 163840 needs 3.14 GiB vs 2.39 GiB
available at 0.94, so if the check is VRAM-only it will refuse again and name the
number to drop to. util kept at 0.94 (not 0.95) because the graph capture needs
~0.49 GiB per card and 0.95 previously OOM'd during capture.

Vision tower stays ON; scheduler stays at 32/4096.
caco-zero-three: max context WITH MTP4 = 98304, tower on, capture-aware
Some checks failed
Check / check (push) Failing after 10s
2bf4e08841
User asked for the maximum context that fits together with MTP. Measured budget:
with MTP4 at 0.94 the pool reports 2.39 GiB and the engine's ceiling is 117,968
tokens, but vLLM's 0.93 GiB graph estimate is not enough in practice -- that is
what OOM'd at 0.95 with 122,880 (slack 0.02 GiB). Keeping ~0.37 GiB of real slack:

    available 2.39 GiB - 98304 * 20.58 KB/token (2.02 GiB) = 0.37 GiB

98304 (96k) is also the largest round value that still covers the user's longest
real prompt of 96,299 tokens (llama-swap /api/metrics/activity).

  --max-num-seqs 8 (was 32)   capture shapes are generated as
                              [1,2,4] + range(8,256,8) + range(256, min(seqs*2,512)+1,16),
                              so 8 caps capture at 16 shapes instead of 64 -- measured
                              that the list really does collapse. Fewer graphs = the
                              capture fits the slack. 8 concurrent is plenty for one user.
  --max-num-batched-tokens 2048   satisfies the hard rule (>= seqs)

No KV offloading (measured: does not extend the pool). No --language-model-only
(visual analysis required). Fallbacks if capture still OOMs: 90112 (88k), 81920 (80k).
98304 tokens * 20580 B = 1.88 GiB of the measured 2.39 GiB pool, so the free
slack for the graph capture is 0.51 GiB, not the 0.37 GiB / 2.02 GiB I wrote
first (I mixed KiB and KB). The chosen max_model_len is unchanged and correct;
only the comment was wrong, and a wrong comment is worse than none.
caco-zero-three: vision tower to host RAM via UVA -- 163840 + MTP4 together
Some checks failed
Check / check (push) Failing after 11s
cb95d4f0d8
Selective UVA offloading, documented for exactly this case in config/offload.py:
'A tower component name such as "visual" selects that complete multimodal tower,
including parameters whose names are relative to the tower module.'

  --cpu-offload-gb 1 --cpu-offload-params visual

The tower is 921 MB FP16, 461 MB per card at TP2 -> frees 0.92 GiB. With MTP4 at
0.95 the pool was 2.55 GiB, so 2.55 + 0.92 = 3.47 GiB against the 3.14 GiB that
163840 needs (163840 * 20580 B). The remaining ~0.33 GiB is the capture's share,
and seqs=8 caps capture at 16 shapes instead of 64. Vision keeps working; the
only cost is ~921 MB over PCIe per image (~60 ms), nothing for text traffic.

Direct evidence to check in the log: 'Model loading took' should drop from 10.69
to ~10.23 GiB per card (the tower leaving the GPU), and the pool should report
~3.47 GiB.

Fallbacks if the KV check or the capture still fails: 147456, then 131072, then
util 0.94.
caco-zero-three: FINAL -- UVA + MTP4 + 147456 (144k), the config that fits
Some checks failed
Check / check (push) Failing after 10s
81fd351357
User's decision after the measurements. Rests on:
 - tower UVA-offloaded: loading 10.22 GiB/card (was 10.69), pool 3.01 GiB (was 2.55),
   engine ceiling 155,136 tokens; the pool gain is +0.46 GiB, not the tower's 0.92
   (the UVA wrapper takes part back)
 - 163840 needs 3.14 GiB and does not fit; 147456 needs 2.83 GiB of the 3.01 pool
   -> 0.18 GiB free plus vLLM's 0.93 GiB graph estimate being conservative, and
   seqs=8 caps capture at 16 shapes instead of 64
 - 144k suffices because the client's real ceiling is prompt + max_tokens: longest
   real prompt 96,299, real outputs <=3,220, so a client-side max_tokens cap of
   16384 gives 112,683 -- well inside 147,456. Without the cap the client asks for
   65,536 and would need 161,835, which never fits with MTP.

Cost of UVA: ~921 MB over PCIe per image (~60 ms), nothing for text.
Fallbacks if the check or the capture fails: 139264, then 131072, then util 0.94.
User's request. Model verified against HF byte-for-byte (7 shards, 19.55 GiB):
 - compressed-tensors pack-quantized int4 g128, activations 16-bit -- a loader
   path we have not exercised (working model = 'awq', QUASAR = 'nvfp4-pack-quantized')
 - GDN 2.79 GiB quantized + 0.14 BF16, the same profile as the working model
   (2.81 + 0.23), so the VRAM budget should hold
 - vision 0.92 BF16; MTP head 0.85 BF16 in model-nonquant.safetensors (loader
   globs *.safetensors and matches tensor names, so the filename is irrelevant)
 - author's quantize-awq-hybrid.py deliberately drops the AWQ smoothing mappings
   touching linear_attn, noting grouped regex mappings 'corrupt hybrid decoding'

Also adding --kv-offloading-size 10 (user's request): 10 GiB KV spill buffer in
host RAM. Measured earlier that this does NOT extend the startup pool check, so it
is about keeping evicted blocks (prefix cache) alive. Guest RAM is 23 GiB with ~11
in use, so 10 GiB pinned plus the UVA tower is tight -- watch for guest starvation
and drop to 4 if it appears.

Everything else identical to the working config for an apples-to-apples comparison.
caco-zero-three: back to the proven model, with 10 GiB KV spill in host RAM
Some checks failed
Check / check (push) Failing after 11s
c2754498bf
User's order: start the known-good model WITH cacheram enabled.

 - model back to Qwen3.8-27B-AWQ-MTP (30/30 quality, vision 3/3, 77-90 tok/s decode).
   The Swift-Qwen3.8-27B-W4A16-AWQ attempt is recorded as HISTORY in the comment:
   its compressed-tensors pack-quantized int4 scheme requires compute capability
   >= 7.5 and V100 is 7.0, so it can never load here (RuntimeError: Min capability
   75, current 70). Not fixable by flags -- that is a scheme-level restriction.
 - --kv-offloading-size 10 --kv-offloading-backend native kept on (user's request).
   Measured earlier that it does NOT extend the startup pool check, so the point is
   keeping evicted blocks alive for prefix-cache reuse: live traffic showed KV usage
   up to 92% and 35 preemptions, which is the symptom this addresses.
   Revised my own RAM warning: of the ~12 GiB 'used' earlier, ~11 GiB was page cache
   (reclaimable) and only ~4 GiB the engine's own footprint, so a 10 GiB pin fits.
   The deployment timer watches Mem/Swap and we drop to 4 if the guest starves.
 - also removed a duplicated flag block the previous patch left behind, and gated on
   'each flag appears exactly once in the exec block' so this cannot recur silently.
caco-zero-three: KV spill 10 -> 4 GiB -- 10 starved the guest during load
Some checks failed
Check / check (push) Failing after 10s
d30fdd408c
Deployed 10 GiB as requested and it failed: during model loading the engine reads
~20 GiB of weights through page cache, hf_transfer held 2.2 GiB (buffered), and
llama-server (own stack) holds 2.4 GiB -- adding 10 GiB of pinned KV spill pushed
the guest into 6.7 GiB of swap, the kernel killed a worker and systemd restarted
the unit (restarts=1, no clean exception -- an OOM kill).

4 GiB keeps the same spill behaviour (evicted blocks stay resident for prefix-cache
reuse, which is the symptom we are chasing: KV usage 92%, 35 preemptions) with
real headroom. Verified the startup pool check is unaffected either way, so context
capacity is unchanged. If the guest still starves, drop to 0 -- the engine is fine
without it.
caco-zero-three: KV spill 8 GiB (user's setting) + pause download during load
Some checks failed
Check / check (push) Failing after 11s
b6e405cc67
10 GiB was tried and starved the guest during model loading: swap reached 7.3 GiB,
the kernel killed a worker, systemd restarted the unit -- and the log shows it died
right after the KV pool was allocated (152,818 tokens at 20:48), so the failure is
RAM during load, not KV capacity. During load the engine reads ~20 GiB of weights
through page cache while hf_transfer holds ~2.2 GiB and llama-server 2.4 GiB; a large
pin on top does not fit in 23 GiB. User chose 8 GiB; fallback is 4 (or 0).

The deployment now pauses the Qwopus download for the load window and resumes it
right after health=200, so the two do not compete for host RAM.

Measured unchanged: --kv-offloading-size does not extend the startup pool check, so
context capacity is identical either way.
caco-zero-three: switch to Qwopus3.8-27B-Flash-AWQ-MTP (user's target model)
Some checks failed
Check / check (push) Failing after 10s
7b2d3b8c04
Same author and recipe as the previously proven model, applied to the
Qwopus3.8-27B-Flash base. Verified statically before deploying:
 - quantization_config.format = awq (the only scheme our SM70 fork loads;
   pack-quantized needs capability >= 7.5 and fails on V100 with
   'Min capability: 75, current: 70' -- not fixable by flags)
 - byte-for-byte identical tensor layout to the working model: MLP 9.22 GiB
   (I32 8,690,073,600), GDN 3.04 GiB (I32 2,811,494,400 + 0.17 F16), emb+lm_head
   5.09 GiB BF16, vision 0.92 GiB BF16, self_attn 0.90 GiB, MTP 0.85 GiB BF16;
   total 20.02 GiB == the working model, so the VRAM budget cannot shift
 - 15 mtp.* tensors in the index, vision_config present, architecture
   Qwen3_5ForConditionalGeneration (same family)
Served name unchanged (qwen38-nvfp4) so clients need no edits.
KV spill stays at the user's 8 GiB; fallback 4. Before starting, the page cache
must be dropped (documented in the comment) -- otherwise the load swaps and the
kernel kills a worker.
User's idea, and it is the right lever: gpu-memory-utilization only sets a budget and
the pool then swallows whatever is left, leaving the runtime (activations, prefill
chunk buffers, long-context attention workspace) with just the last 5% of the card.
The crash was exactly that: a 20 MiB allocation failing with 7 MiB free, on a 70,695
token request against a 152,818 token pool -- capacity was never the problem.

kv_cache_memory_bytes makes the pool explicit: gpu_worker.determine_available_memory()
short-circuits on it, so the pool is exactly what we ask for and everything else stays
free for PyTorch. Target ~135k tokens (1.43e9 bytes per GPU), therefore max-model-len
131072 (128k) so a full sequence still fits inside the pool -- still above the client's
real maximum of 96,299 + 16,384 = 112,683. This hands ~0.35 GiB per card back to the
workspace, ~18x the 20 MiB that was missing. The flag's unit (per GPU vs total) is
settled by reading the resulting GPU KV cache size line and adjusting if needed.

Also pinning the draft KV cache dtype explicitly. Per the fork docstring the draft
inherits the target dtype when unset, so with a quantized target the draft is already
fp8 and nothing changes -- the measurement decides whether that inheritance is real.

KV spill set to 0: it is the OOM suspect (the config that served 68-70k prompts at 100%
success had no spill), and one change at a time. Re-add 4 and re-measure once the
workspace fix holds.
The user set the pool explicitly: 144,180 tokens * 21,149 B/token = 3,049,262,820 B
total (=1,524,631,410 per GPU). max-model-len must be <= the pool because vLLM
validates that one full sequence fits (the check that rejected 163,840 earlier), so
the context is 143,360 -- just under the pool, still far above the client's real
112,683 (96,299 prompt + 16,384 reserve).

Effect: the pool no longer swallows the whole budget. Previously the pool was
'whatever was left' (3.01 GiB) and the runtime had only the last 5% of the card --
which is why a 20 MiB allocation failed with 7 MiB free and killed the engine on a
70,695-token request. Now the pool is 2.84 GiB and everything above it stays free:
roughly 2.2 GiB per card of workspace instead of ~0.8 GiB.

Also pins the draft KV cache dtype explicitly (inherits fp8 per the fork docstring,
so the pool should be identical -- the measurement decides), and leaves the KV spill
at 0 because it is the OOM suspect and this is one change at a time.
Startup failed with:
  ValueError: To serve at least one request with the model's max seq len (143360),
  (2.8 GiB KV cache is needed, which is larger than the available KV cache memory (1.42 GiB)

Passing 1,524,631,410 came back as 1.42 GiB available, so the engine splits the value
across the TP workers -- the unit is the TOTAL for the engine, not per card. Doubling it
to 3,049,262,820 gives 1.42 GiB per card / 2.84 GiB total, exactly the 144,180 tokens the
user asked for (144180 * 21,149 B/token), which fits the 143,360 context with 1.02x.

The unit is now recorded in the comment as a measured fact rather than an assumption, so
the next person does not have to rediscover it through a failed start.

Also adds ExecStartPre = sync + drop_caches: loading ~20 GiB of weights streams through
the page cache, and when that cache is already full the load dies with status=1 and no
exception in the journal. Doing it in the unit means every start is clean, including the
ones systemd performs on its own. MemoryMax/MemorySwapMax are intentionally untouched.
caco-zero-three: fix broken ExecStartPre, restore the user's 8 GiB KV spill
Some checks failed
Check / check (push) Failing after 11s
ca63f7db0d
Two things, both mine to fix:

1. ExecStartPre was malformed and it is what killed the engine in a loop
   (restarts=23, status=203/EXEC, 'Unable to locate executable c'). systemd does not
   re-assemble ExecStartPre words into a command line: ['-/bin/sh','-c','sync; ...']
   was executed as three separate programs. The script must be ONE argv element:
   ['-<bash>','-c','sync; echo 3 > /proc/sys/vm/drop_caches'].
   Nothing was wrong with the model, the GPU, the RAM or the pool.

2. --kv-offloading-size 8 is restored. I had removed the user's setting on my own
   initiative to test a hypothesis, and the hypothesis was wrong: with the spill off the
   engine still failed exactly the same way (which turned out to be the broken
   ExecStartPre above). A hypothesis gets tested by measurement, not by dropping a
   configuration the user asked for.
Nix lists become separate argv entries, and systemd executes each ExecStartPre entry as
its own program, so ['bash','-c','sync; ...'] ran three commands: bash (ok), c (203/EXEC)
and sync; (203/EXEC). Because the last one has ignore_errors=no, the unit refused to
start at all -- 23 restart cycles, and the real model never got to load.

Canonical form is a single string: "<bash> -c 'sync; echo 3 > /proc/sys/vm/drop_caches'".
After deploying, the acceptance check is on the live unit: exactly three ExecStartPre
records (the bash + its two argv slots) and no 'path=c' anywhere.
The engine died twice with:
  EngineCore encountered a fatal error
    core.py run_busy_loop -> _process_engine_step -> step_with_batch_queue
    scheduler.py schedule -> connector.update_state_after_alloc
    offloading_connector.py -> offloading/scheduler.py update_state_after_alloc
  AssertionError: assert num_locally_computed_tokens <= num_locally_computed_gpu_blocks * gpu_block_size

That check requires every locally-computed token to sit in a hashed block, but our
model is GDN-heavy and runs with --mamba-cache-mode align, which rewrites logical
positions of existing blocks while the offloading connector assumes an append-only
positional mirror (the same file notes requires_exact_boundary_source for exactly this
case). The engine's own log calls the feature experimental ('Initializing
OffloadingSpec. This API is experimental and subject to change').

Symptom pattern: healthy for 13-19 minutes, then EngineDeadError, then 500s to clients.

Removing it costs the KV spill's benefit (preemptions back to ~35 from 1, evicted
blocks no longer reloadable from host RAM) but no correctness. mamba-cache-mode align
is left alone: it does not affect quality (30/30 on two checkpoints with it) and it only
aligns mamba state with attention pages for prefix caching.
caco-zero-three: nixfmt the vLLM unit definitions
Some checks failed
Check / check (push) Failing after 4m34s
53fea626f2
The Check workflow runs `nix run .#lint` before it evaluates anything, and the
run for 0142ec3 failed there: nixfmt wants the `path` and `after` lists one
entry per line, and the two `LD_LIBRARY_PATH` interpolations broken around the
`lib.makeLibraryPath` call.

Whitespace only, and the rest of the gate (deadnix, statix, actionlint, go
vet/test, betterleaks) already passed -- `nix run .#lint` is clean on this
commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
caco-zero-three: pin the vllm account's uid and gid
All checks were successful
Check / check (push) Successful in 10m2s
96045f7c14
check-etc-immutable failed evaluation for this branch: "caco-zero-three: users
with no uid: vllm, groups with no gid: vllm". This host seals /etc, and
tests/etc-immutable.nix requires every account on a shared-layout host to carry
an ID the userborn database can persist outside it -- a dynamically allocated
one lives only in the sealed /etc that is thrown away.

The numbers are not new. userborn already handed the account uid 987 and gid
981 on the box, and /var/lib/vllm is owned 987:981, so pinning anything else
would orphan the state directory on the next deploy. This records what is
already there, the same way the human accounts in users.nix preserve the uids
the box had under ansible.

Verified: `nix build .#checks.x86_64-linux.etc-immutable` passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Context 147456 = 131072 + 16384 (the user's requirement) with MTP4, which fits the
measured engine ceiling of 155136. Pool = 3,130,000,000 bytes total (147456 * 21149 =
3,118,340,544 rounded up), because the pool must be >= one full sequence.

The 8 GiB host-RAM KV spill is restored now that the fork carries the upstream fix for
the AssertionError in offloading/scheduler.py update_state_after_alloc: the scan for
locally computed blocks now starts at the computed boundary
(first_fresh_gpu_block_idx = cdiv(num_locally_computed_tokens, gpu_block_size)) instead of
index 0, so mamba align-mode null placeholders no longer undercount it. Backup of the
original file: /tmp/scheduler.py.bak-20260916; py_compile OK; 21-line diff, one function.

Spill benefit restored with it: preemptions ~35 -> 1 and evicted blocks reloadable from
host RAM.
caco-zero-three: LimitMEMLOCK=infinity (fixes the shm_broadcast deadlock); spill 10 GiB
All checks were successful
Check / check (push) Successful in 4m50s
9abeeb06b7
Root cause of the hang where tokens stop arriving while num_requests_running stays at 2:

  systemctl show vllm -p LimitMEMLOCK     -> 8388608 (8 MiB)
  systemctl show -p DefaultLimitMEMLOCK   -> 8388608
  ulimit -l (root)                        -> 8192 KiB
and nothing overrides it -- not this file, not /etc/security/limits.conf.

With the KV spill plus the UVA vision tower the engine must LOCK host pages; 8 MiB is
far too little, so the driver reports 'NVRM: failed to allocate page table!' from
os_lock_user_pages, the shm_broadcast ring between EngineCore and the TP workers never
gets its blocks ('No available shared memory broadcast block found in 60 seconds'), and
the engine deadlocks. Measured on the live hung engine:

  workers:  state=R, wchan=0, 12.3 s CPU per 8 s wall, GPU 100%   (spinning)
  engine:   state=R, stuck in step_with_batch_queue -> _wait_for_response ->
            dequeue -> acquire_read -> poll (shm_broadcast.py)
  metrics:  generation_tokens and iteration_tokens frozen (3529 / 738 for 45+ s)
            while num_requests_running = 2, num_requests_waiting = 0

LimitMEMLOCK is about pinning pages, not about how much memory the unit may use, so
MemoryMax/MemorySwapMax stay exactly as they were. Spill size is the user's 10 GiB.
caco-zero-three: spill back to 8 GiB (10 GiB cannot fit the 16G cgroup limit)
All checks were successful
Check / check (push) Successful in 4m41s
3b20947847
Measured: with the spill at 10 GiB every start died with
  vllm.service: Main process exited, code=killed, status=9/KILL
  vllm.service: Failed with result 'oom-kill'
  OOM killer killed some processes in this unit.
and the numbers show exactly why:
  MemoryMax  = 17,179,869,184 (16 GiB)
  MemoryPeak = 17,179,869,184 (16 GiB)  <- pinned both caps, no headroom
  MemorySwapPeak = 3,999,334,400 of the 4 GiB MemorySwapMax

The spill pins its whole size in host RAM up front, on top of the ~20 GiB of weights
streamed through the page cache, the shm_broadcast ring and the UVA tower. At 8 GiB the
pinned total fits inside 16 GiB (swap peak was 3.99 of 4 GiB -- tight, but alive); the
extra 2 GiB is what tips it over.

8 GiB is also the configuration that has already run with the upstream offloading fix:
zero AssertionErrors, preemptions 1 instead of ~35, external prefix cache hit rate 68%.
LimitMEMLOCK=infinity stays (it fixes the 8 MiB default that deadlocked shm_broadcast,
and it is unrelated to this cap: MEMLOCK is about pinning, MemoryMax about consumption).
The user's question was why two long requests cannot simply run one after another instead
of thrashing, and the fork has exactly that mechanism -- we just were not using it.

Without it, a request that does not fit is still admitted to running and the scheduler
then preempts in a loop: measured on live traffic 89 x 200 OK alongside
  num_preemptions_total = 158        (1 in healthy runs)
  kv_cache_usage_perc  = 1.0 (100%)
  prefix cache hit rate = 1.1%
  iteration_tokens and generation_tokens frozen, GPU pinned at 100%
with Running: 2, Waiting: 0. The engine spent all its time shuffling KV rather than
producing tokens, which is what the client saw as 'two requests stuck for 700 s'.

--scheduler-reserve-full-isl makes the scheduler check that a whole sequence fits
(scheduler.py: full_sequence_must_fit=self.scheduler_reserve_full_isl, and 'if new_blocks
is None: break') -- so a request that cannot fit is NOT started and waits in the queue
instead of being admitted and then preempted. --scheduling-policy fcfs is stated
explicitly (it is already the default) so in-order service is not left implicit.

Note max_num_partial_prefills and max_long_partial_prefills are already 1 by default, so
parallel prefill chunks were not the problem -- the preemption loop was.
caco-zero-three: ARC eviction for the spill, expandable_segments, max-num-seqs 4
All checks were successful
Check / check (push) Successful in 4m55s
da76cea078
Three changes, from the 1h26m CUDA OOM crash.

1. PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True. The engine died on a 24 MiB
   allocation while 228.79 MiB was 'reserved by PyTorch but unallocated', and the
   tensor that failed was a small layernorm multiply in the GDN path
   (qwen_gdn_linear_attn.py -> layernorm.py:622 'out = out * act_fn(z)'), not a big
   one. That is allocator fragmentation: the memory is ours but split into pieces no
   request can use. It also explains the timing -- fine for 1.5 h, then dead, because
   fragments accumulate while the GPU prefix cache thrashed (hit rate 54.3% -> 0.1%
   over the session, external 4.0% -> 37.9%). expandable_segments lets a segment grow
   instead of being carved, which is the fix PyTorch itself suggests in the OOM text.
   Name matters: vLLM greps PYTORCH_CUDA_ALLOC_CONF (_verify_kv_transfer_compat).
   The pairing with our connector is allowed -- OffloadingConnector declares
   SupportsVmmSafeTransfers, so that check takes the warning path; its ValueError
   targets RDMA-pinning connectors (NIXL, Mooncake), not host-RAM offloading.

2. Spill moves to an explicit kv_transfer_config with eviction_policy=arc, replacing
   --kv-offloading-size/--kv-offloading-backend. Those short flags only fill
   cpu_bytes_to_use and cannot carry a policy (config/vllm.py:1048); the policy is
   read from kv_connector_extra_config by CPUOffloadingSpec (cpu/spec.py:58). ARC
   adapts between recency and frequency, which fits our profile: a stream of mostly
   unique prompts (GPU cache hit rate 0.1%) mixed with repeated long prefixes.
   cpu_bytes_to_use is the same 8 GiB, connector name verified against
   KVConnectorFactory (factory.py:201).

3. --max-num-seqs 8 -> 4. With seqs=8 the pool could admit requests that then did
   not fit as their contexts grew, producing preemptions (31 observed) and PCIe
   thrash; 4 keeps the long-request case inside the pool.

MemoryMax/MemorySwapMax untouched.
All checks were successful
Check / check (push) Successful in 4m55s
This pull request can be merged automatically.
This branch is out-of-date with the base branch
You are not authorized to merge this pull request.
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin feat/vllm-backend:feat/vllm-backend
git switch feat/vllm-backend

Merge

Merge the changes and update on Forgejo.

Warning: The "Autodetect manual merge" setting is not enabled for this repository, you will have to mark this pull request as manually merged afterwards.

git switch main
git merge --no-ff feat/vllm-backend
git switch feat/vllm-backend
git rebase main
git switch main
git merge --ff-only feat/vllm-backend
git switch feat/vllm-backend
git rebase main
git switch main
git merge --no-ff feat/vllm-backend
git switch main
git merge --squash feat/vllm-backend
git switch main
git merge --ff-only feat/vllm-backend
git switch main
git merge feat/vllm-backend
git push origin main
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
npilosov/nix-desktop!29
No description provided.