27B-модель на четырёх V100 в 2026 году: форк vLLM, INT4 на Volta и контекст 262K. awq.. awq. DevOps.. awq. DevOps. gpu.. awq. DevOps. gpu. inference.. awq. DevOps. gpu. inference. llm.. awq. DevOps. gpu. inference. llm. qwen.. awq. DevOps. gpu. inference. llm. qwen. v100.. awq. DevOps. gpu. inference. llm. qwen. v100. vllm.. awq. DevOps. gpu. inference. llm. qwen. v100. vllm. zubriq.. awq. DevOps. gpu. inference. llm. qwen. v100. vllm. zubriq. искусственный интеллект.. awq. DevOps. gpu. inference. llm. qwen. v100. vllm. zubriq. искусственный интеллект. Машинное обучение.

У нас есть четыре 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.1nName: vllmnVersion: 1.2.1n' > "$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.

Две детали, без которых модель не стартует:

  1. Это не классический AWQ. Квант сохранён в формате compressed-tensors W4A16 (group 128, symmetric). На SM70 он идёт через те же ядра TurboMind, что и AWQ, но флаг для него выключен по умолчанию. Без VLLM_SM70_COMPRESSED_TENSORS_TURBOMIND=1 схема требует capability 75, и модель не грузится.

  2. 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, делитесь в комментариях, на каком стеке и с какими цифрами.

Автор: ZubikIT

Источник