У нас есть четыре Tesla V100 2017 года, и на них в проде работает плотная Qwen3.8-27B с окном 262 тысячи токенов. Официальный vLLM на этих картах 4-битную модель даже не запускает. Рассказываю, на каком форке это работает, куда утекает память, что показал бенчмарк MoE против dense и почему имя модели нельзя менять никогда.
Зачем вообще V100
Эта модель — zubr в ZuBrIQ: единственная наша модель, запросы к которой не покидают Беларусь. Промпт, документ и ответ остаются на нашем железе в Минске. Для банков, госсектора и всех, кто работает с персональными данными, это обязательное условие, а не опция.
Железо — 4× Tesla V100 SXM2 по 16 ГБ, всего 64 ГБ. Новые карты в Беларусь привезти сложно и дорого, а эти уже стоят в стойке. Задача — выжать из них максимум.
Проблема в том, что V100 — это архитектура Volta, compute capability 7.0 (SM70). На ней нет:
bfloat16 — а большинство современных моделей распространяются именно в нём;
FP8 — ни для весов, ни для KV-кеша;
4-битных ядер в официальном vLLM — он отвечает
min capability 75, current 70и не стартует.
Стек: форк, systemd и никакого Docker
Мы используем 1Cat-vLLM — форк vLLM для SM70 под лицензией Apache-2.0. В нём есть 4-битные ядра для Volta (Marlin и TurboMind), бэкенд внимания FLASH_ATTN_V100 и CUDA Graph. У нас версия 1.2.1 на Python 3.12 и драйвере 580.
Модель работает в LXC-контейнере на Proxmox с пробросом всех четырёх GPU. vLLM запущен нативно через systemd, без Docker: так проще с драйверами и пробросом устройств.
Первая грабля всплыла ещё до запуска модели. Форк ставится пакетом 1cat-vllm, а CLI vLLM спрашивает version("vllm") и падает с PackageNotFoundError. Мы чиним это однократно в setup.sh:
# 1. фиктивные метаданные пакета «vllm»
mkdir -p "$SP/vllm-1.2.1.dist-info"
printf 'Metadata-Version: 2.1\nName: vllm\nVersion: 1.2.1\n' > "$SP/vllm-1.2.1.dist-info/METADATA"
# 2. хардкод версии в CLI
for f in "$SP/vllm/entrypoints/cli/main.py" "$SP/vllm/entrypoints/cli/run_batch.py"; do
[ -f "$f" ] && sed -i 's/importlib.metadata.version("vllm")/"1.2.1"/g' "$f"
done
# 3. сбросить старый байткод, иначе подхватится прежний .pyc
find "$SP/vllm" -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
Модель и квант
Мы взяли cyankiwi/Qwen3.8-27B-AWQ-INT4. Это плотная 27B с гибридным вниманием (linear и full в пропорции 3:1), мультимодальная. В INT4 веса весят около 21 ГБ — примерно 5,3 ГБ на карту при tensor parallel 4.
Две детали, без которых модель не стартует:
Это не классический AWQ. Квант сохранён в формате compressed-tensors W4A16 (group 128, symmetric). На SM70 он идёт через те же ядра TurboMind, что и AWQ, но флаг для него выключен по умолчанию. Без
VLLM_SM70_COMPRESSED_TENSORS_TURBOMIND=1схема требует capability 75, и модель не грузится.dtype модели — bfloat16. На Volta его нет, поэтому
--dtype float16обязателен.
Юнит целиком:
[Service]
Environment=VLLM_SM70_COMPRESSED_TENSORS_TURBOMIND=1
ExecStart=/opt/vllm-env/bin/vllm serve /opt/models/Qwen3.8-27B-AWQ-INT4 \
--served-model-name zubr zubr-2 qwen3.8-27b \
--tensor-parallel-size 4 \
--dtype float16 \
--gpu-memory-utilization 0.50 \
--kv-cache-memory-bytes 4600000000 \
--max-model-len 262144 \
--max-num-seqs 24 \
--enable-auto-tool-choice \
--tool-call-parser qwen3_xml \
--reasoning-parser qwen3 \
--host 0.0.0.0 --port 80
Restart=always
TimeoutStartSec=600
Холодный старт с компиляцией и захватом CUDA-графов занимает 4–5 минут, поэтому TimeoutStartSec=600.
Куда утекает память
Веса не вычитаются из бюджета KV
В обычном vLLM бюджет KV-кеша считается так: total × gpu_memory_utilization − веса − активации. В форке загрузчик для SM70 сообщает размер весов почти нулём, и они из бюджета не вычитаются. В итоге при --gpu-memory-utilization 0.50 процесс занимал не 8, а почти 14 ГБ на карту, и соседям не оставалось места.
Решение — задать KV явно через --kv-cache-memory-bytes. --gpu-memory-utilization после этого работает только как проверка свободной памяти при старте.
Размер KV посчитали не на глаз, а от факта. Движок сам сообщил, что окну 131 072 токена нужно 2,08 ГиБ, — это около 17,1 КБ на токен. Для окна 262 144 нужно примерно 4,4 ГиБ. Взяли 4,6 ГБ — это около 268 тысяч токенов с небольшим запасом.
Бюджет считается по самой тесной карте. Если на одной из GPU живёт что-то ещё, например сервис эмбеддингов, KV ограничен свободной памятью именно на ней.
CUDA-графы съедают память вне учёта
Летом у нас стояло --max-num-seqs 96: свип показал, что это потолок пропускной способности. Но CUDA-графы захватываются под каждый размер батча, и на 96 последовательностей они съедали около 5 ГБ сверх бюджета. Мы снизили до 24 — пропускная способность почти не просела, а память вернулась.
Два инстанса на одних картах
До сентября рядом с основной моделью работала отдельная Qwen3-VL-4B для зрения. Квантов для неё на SM70 не нашлось: VL-кванты в compressed-tensors требовали SM75+. Поэтому она шла в fp16 с --enforce-eager.
CI перезапускал оба юнита с разницей в секунды. Пик инициализации двух процессов разом превышал 16 ГБ на карту, и один воркер ловил OOM. Хуже того, основной инстанс после этого зависал на NCCL watchdog. systemd показывал active, а порт был закрыт: снаружи модель «работала», но не отвечала.
Мы вылечили это сериализацией старта: второй юнит ждал health первого.
ExecStartPre=-/bin/sh -c 'timeout 600 sh -c "until curl -sf -m 2 http://localhost:80/v1/models >/dev/null; do sleep 5; done"'
В сентябре проблема ушла совсем. Qwen3.8-27B видит изображения сама, и на нашем тесте с чтением таблиц она справилась в 3 случаях из 4, а отдельная 4B — в 0 из 3. Мы погасили 4B, освободили около 8 ГБ и подняли окно основной модели со 131 072 до 262 144 токенов.
MoE против dense: бенчмарк
До 18 сентября под этим именем работала MoE Qwen3.6-35B-A3B: 35 млрд параметров, около 3 млрд активных. Мы прогнали обе модели одним скриптом vllm bench serve на одном боксе с теми же соседями. Нагрузка — случайные промпты 512 токенов на вход и 256 на выход, 300 запросов, 24 одновременных клиента.
Метрика | Qwen3.6-35B-A3B (MoE) | Qwen3.8-27B (dense) |
|---|---|---|
Генерация, ток/с | 200,2 | 218,5 (+9 %) |
Время прогона, с | 383,6 | 351,6 |
TTFT, медиана | 0,59 с | 1,31 с |
TTFT, P99 | 2,3 с | 14,2 с |
TPOT, медиана | 111,6 мс | 95,9 мс (−14 %) |
TPOT, P99 | 126,0 мс | 139,9 мс |
Мы ждали, что плотная модель окажется кратно медленнее. Не оказалась: на генерации она быстрее на 9 %, каждый токен печатается на 14 % быстрее. Похоже, MoE-ядра на SM70 просто не вылизаны.
Платим первым токеном: медиана 1,31 с против 0,59 с, хвост P99 — 14,2 с против 2,3 с. Причина понятна: на prefill плотная модель прогоняет по промпту все 27 млрд параметров, а MoE считала около 3 млрд.
Честная оговорка: мы замерили скорость, а не ум. Качество проверено только смоук-запросами, сравнения 3.6 и 3.8 по качеству мы не делали.
Имя модели — вечное
18 сентября, меняя веса, мы убрали старое имя qwen3.6-35b — оно врало про модель. Через несколько минут наш чат-бот поддержки начал на каждое сообщение отвечать «подождите оператора».
Бот ходил в vLLM напрямую, мимо шлюза, по старому имени. Он получал 404, а в режиме «только локальная модель» любая ошибка означает эскалацию на оператора.
С тех пор правило такое:
первое имя в
--served-model-name— вечное:zubr. Его зовут все клиенты, и оно не меняется при смене весов;версионные имена (
zubr-2,qwen3.8-27b) описывают конкретные веса и могут меняться;vLLM возвращает в поле
modelответа запрошенный алиас, а не настоящие веса. Источник истины — полеrootв/v1/models.
Следующая смена весов не потребует правки ни одного конфига у клиентов — только юнита.
Парсеры: инструменты и рассуждения
--tool-call-parser qwen3_xml— Qwen3.5 и новее вызывают инструменты в XML-формате, иhermesих не разбирает.--reasoning-parser qwen3мы добавили только в октябре. Без него рассуждения модели приenable_thinkingприходили прямо вcontentвместе с тегом</think>, и переключатель «Размышления» в чате не работал. С парсером они идут отдельным полемreasoning_content.
Деплой
Веса лежат во внутреннем MinIO. ExecStartPre юнита забирает их оттуда, а если их там нет — скачивает с Hugging Face и заливает в MinIO. Если веса уже на диске, шаг ничего не делает. Прежняя модель остаётся на диске как откат: вернуть её — значит откатить один коммит в юните.
Деплой — GitLab CI на push в main: git pull на боксе, обновление юнита, systemctl restart vllm. Правки только документации рестарт не вызывают.
Что в итоге
4× V100 2017 года тянут плотную 27B в INT4 с окном 262K и зрением, около 218 ток/с генерации на 24 параллельных запросах.
Главные грабли — учёт памяти: веса не вычитаются из бюджета KV, CUDA-графы живут вне него, два процесса нельзя инициализировать одновременно.
Имя модели для клиентов не меняется никогда; версия весов — это отдельное имя.
Эту модель можно попробовать бесплатно — это zubr в ZuBrIQ, тариф Free, без карты. По API она доступна по OpenAI-совместимому адресу:
curl https://api.zubriq.ai/v1/chat/completions \
-H "Authorization: Bearer $ZUBRIQ_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model": "zubr", "messages": [{"role": "user", "content": "Привет! Где ты работаешь?"}]}'
В первой статье цикла — как на этой модели и GLM-5.3 работает Claude Code. В следующей — как мы маскируем персональные данные до того, как запрос уйдёт во внешнюю модель. Если вы тоже держите модели на V100, делитесь в комментариях, на каком стеке и с какими цифрами.

Комментарии 0