Холодный старт модели: как убрать паузу перед первым ответом
Вы перезапустили инференс-сервер после обновления модели, деплоя новой версии образа или банального падения процесса — и первый же реальный запрос от пользователя завис на заметную паузу, хотя все следующие отвечают быстро. Это не глюк и не перегрузка GPU — сервису при старте нужно один раз проделать тяжёлую подготовительную работу, прежде чем он будет готов быстро считать токены. Разберём, из чего складывается эта пауза и что реально можно сделать, чтобы она не доставалась живому пользователю.
Содержание
- Из чего складывается пауза при старте
- Почему тормозит именно первый запрос, а не все подряд
- Быстрый диск под веса: почему NVMe, а не сетевое хранилище
- Кеширование скомпилированного графа между перезапусками
- Прогрев (warmup): ловим паузу раньше, чем на неё наткнётся пользователь
- Архитектура: холодный старт должен случаться заранее, а не на глазах у пользователя
Из чего складывается пауза при старте
Когда процесс инференса запускается — с нуля или после рестарта — до того как он сможет обработать первый запрос, происходит несколько последовательных этапов:
- Загрузка весов модели с диска в память. Файлы весов могут занимать от нескольких гигабайт до десятков и даже сотен гигабайт, в зависимости от размера модели и квантования. Их нужно прочитать с диска и разместить в оперативной памяти или памяти видеокарты (VRAM) — это чтение большого объёма данных, и скорость этого чтения напрямую зависит от того, где и как хранятся веса.
- Инициализация движка инференса. Фреймворк (vLLM, TensorRT-LLM, llama.cpp, TGI и другие) должен поднять свои внутренние структуры: выделить память под KV-кеш под заданную длину контекста и число параллельных слотов, инициализировать пул батчинга, настроить тензорный параллелизм, если модель разбита на несколько GPU. Это тоже разовая операция — сделанная один раз при старте, а не при каждом запросе.
- Компиляция и оптимизация вычислительного графа под конкретное железо. Некоторые движки (PyTorch с
torch.compile, TensorRT, TensorRT-LLM, ONNX Runtime с графовыми оптимизациями) не просто исполняют модель «как есть», а строят и оптимизируют граф вычислений под конкретную видеокарту, версию драйвера и форму входных данных. Часть движков дополнительно захватывают CUDA-графы для типовых форм батчей — это ускоряет последующие запросы, но сама подготовка требует времени и обычно происходит на первом реальном проходе через сеть.
Все три этапа — не про то, «сколько модель думает над ответом», а про то, что нужно сделать один раз, прежде чем модель вообще способна быстро отвечать. Именно поэтому пауза видна один раз за жизненный цикл процесса — при первом (пере)запуске — а не на каждом запросе.
Почему тормозит именно первый запрос, а не все подряд
Второй и все последующие запросы не проходят через пункты 1–3 заново: веса уже в памяти, структуры KV-кеша уже выделены, граф уже скомпилирован (если движок его кеширует внутри процесса). Но есть нюанс, который часто удивляет: у части движков компиляция и захват CUDA-графов происходят не при старте процесса как таковом, а именно на первом реальном forward-проходе — то есть формально сервис уже «поднялся» и отвечает на /health, но первый настоящий запрос к генерации всё равно попадает на доп. работу движка. С точки зрения пользователя разницы нет — задержка ощущается ровно на первом запросе к API, независимо от того, случилась она до полной готовности процесса или сразу после неё.
Отсюда следует практический вывод: недостаточно считать сервис готовым по одному лишь факту, что процесс запустился и слушает порт. Готовность к быстрой работе — отдельное состояние, которое наступает позже, и об этом ниже в разделе про архитектуру.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Развернуть ИИ на сервереБыстрый диск под веса: почему NVMe, а не сетевое хранилище
Первый и самый управляемый рычаг — скорость чтения весов с диска. Файлы весов читаются последовательно и почти целиком за один проход при каждом старте процесса, поэтому пропускная способность и задержка диска напрямую входят во время загрузки.
Разница по архитектуре хранения принципиальная:
| Хранилище | Особенность для загрузки весов |
|---|---|
| Локальный NVMe SSD | Данные читаются напрямую с шины PCIe, без сетевого стека — минимальные накладные расходы |
| Локальный SATA SSD | Заметно ниже пропускная способность и выше задержка интерфейса, чем у NVMe |
| Сетевое хранилище (NFS, S3-совместимое, iSCSI) | К времени чтения добавляется сетевая задержка, накладные расходы протокола и часто — ограничение полосы самого канала до хранилища |
Точных цифр «во сколько раз быстрее» намеренно не даём — разница сильно зависит от конкретных дисков, контроллера, загрузки шины и настроек сети, и её стоит измерить у себя, а не полагаться на чужие бенчмарки. Разница между NVMe и SATA SSD на реальном сервере — то, что стоит проверить утилитой fio до того как строить выводы:
fio --name=weights_read --filename=/data/models/test.bin \
--rw=read --bs=1M --iodepth=16 --direct=1 --size=10G
Практический вывод простой: если веса модели лежат на сетевом томе или на медленном диске, а сервис перезапускается регулярно (деплои, автоскейлинг, восстановление после сбоя), время загрузки весов будет заметной и предсказуемо повторяющейся частью холодного старта. Держите веса моделей, которые должны быстро подниматься, на локальном NVMe той же машины, где крутится инференс, а не на смонтированном сетевом ресурсе. Если модель нужно синхронизировать на несколько узлов — синхронизируйте её на локальный NVMe каждого узла заранее, до момента, когда узел должен принять трафик, а не читайте по сети в момент старта.
Отдельно: если сервис перезапускается на той же машине без перезагрузки хоста, часть данных весов может уже сидеть в page cache ОС и подняться быстрее второго раза. Но рассчитывать на это как на основную стратегию не стоит — при перезагрузке хоста, при переезде на новый инстанс или при первом старте на свежей машине page cache пуст, и вся загрузка идёт с диска заново.
Кеширование скомпилированного графа между перезапусками
Если ваш движок инференса делает компиляцию или оптимизацию графа под железо, второй управляемый рычаг — не пересобирать эту компиляцию с нуля при каждом рестарте, если можно переиспользовать результат предыдущей сборки.
Несколько конкретных путей, в зависимости от движка:
- PyTorch с
torch.compile. Скомпилированные артефакты можно кешировать на диск между запусками процесса через переменные окружения кеша инкрементальной компиляции (например, каталог кешаTORCHINDUCTOR_CACHE_DIR, вынесенный на постоянный том, а не во временную директорию контейнера, которая пропадает при каждом пересоздании). Смонтируйте этот каталог как persistent volume в Kubernetes или как bind-mount в Docker, чтобы кеш переживал пересоздание контейнера. - TensorRT / TensorRT-LLM. Здесь оптимизированный движок — это отдельный файл сборки (engine/plan), который явно строится один раз командой сборки и потом просто загружается при старте. Если вы пересобираете этот файл при каждом деплое «на всякий случай» — вы платите временем компиляции без необходимости. Собирайте его отдельно от запуска сервиса и переиспользуйте между рестартами, пересобирая только когда реально меняется модель, форма входа, версия TensorRT или само железо.
- vLLM и подобные движки с захватом CUDA-графов. Если полное включение CUDA-графов даёт заметную нестабильность времени первого запроса в вашем сценарии, можно осознанно отключить захват графов (
--enforce-eagerу vLLM) — вы теряете часть ускорения на устоявшемся трафике, но убираете скачок задержки именно на первых проходах. Это компромисс, а не универсальное решение — оценивайте его на своей нагрузке.
Важная оговорка по всем вариантам: скомпилированный кеш обычно привязан к конкретной модели GPU, версии драйвера и версии библиотеки инференса. При смене видеокарты, обновлении драйвера CUDA или апгрейде фреймворка старый кеш чаще всего станет непригоден и потребует пересборки — это нормально, но стоит закладывать время на пересборку в план любого такого обновления инфраструктуры, а не обнаруживать это в момент аварийного релиза.
Прогрев (warmup): ловим паузу раньше, чем на неё наткнётся пользователь
Даже если веса лежат на быстром NVMe и граф кешируется между рестартами, полностью убрать холодный старт для конкретного свежего процесса чаще всего невозможно — что-то всё равно инициализируется один раз. Отсюда третий, самый универсальный приём: отправить один-два синтетических «прогревочных» запроса сразу после старта сервиса, но до того как на этот экземпляр направлен реальный пользовательский трафик.
Идея простая: пусть всю задержку инициализации поймает тестовый запрос, который никто кроме вашей же системы не видит, а не первый реальный пользователь. Практически это можно сделать так.
Скрипт прогрева, который дергает эндпоинт генерации сразу после старта процесса:
#!/usr/bin/env bash
set -e
HOST="127.0.0.1:8000"
# ждём, пока порт вообще откроется
until curl -sf "http://$HOST/health" >/dev/null; do
sleep 1
done
# синтетический запрос — та самая "прогревочная" генерация
curl -sf -X POST "http://$HOST/v1/completions" \
-H "Content-Type: application/json" \
-d '{"model":"my-model","prompt":"warmup","max_tokens":8}' \
>/dev/null
echo "warmup done"
В Kubernetes логичное место для этого — не просто livenessProbe, а отдельный readinessProbe, который начинает возвращать успех только после того, как прогревочный запрос отработал (например, скрипт прогрева по завершении создаёт файл-маркер /tmp/ready, а readinessProbe проверяет его наличие командой). Под, у которого проходит только liveness, но не readiness, продолжает существовать, но балансировщик не направляет на него трафик — именно то поведение, которое нужно.
readinessProbe:
exec:
command: ["test", "-f", "/tmp/ready"]
initialDelaySeconds: 5
periodSeconds: 3
За пределами Kubernetes та же идея реализуется через health-check вашего балансировщика (nginx upstream с проверкой, HAProxy с health-check-эндпоинтом) — новый инстанс не добавляется в пул апстримов, пока не пройдёт кастомную проверку готовности, которая срабатывает уже после прогрева, а не сразу после того как процесс начал слушать порт.
Архитектура: холодный старт должен случаться заранее, а не на глазах у пользователя
Отсюда главный архитектурный вывод: если холодный старт нельзя убрать полностью, его нужно явно спроектировать так, чтобы он происходил заранее — при запуске нового экземпляра, до того как на него направлен реальный трафик, — а не позволять первому живому пользователю на него наткнуться. Несколько практических следствий этого принципа:
- Готовность ≠ запущенность. Разделяйте состояние «процесс стартовал» и «процесс прогрет и готов к трафику» как два разных сигнала (см. пример с readiness выше). Автомасштабирование, деплой и восстановление после сбоя должны ориентироваться на второй сигнал, а не на первый.
- Держите небольшой запас прогретых инстансов, если трафик непредсказуем. Если ваша нагрузка скачкообразная и вы масштабируетесь строго по факту роста трафика — новый инстанс поднимается уже под нагрузкой, и первые реальные запросы к нему как раз и попадают на холодный старт. Компромисс — держать минимум один-два инстанса «в запасе» сверх текущей необходимости, чтобы автомасштабирование добавляло уже прогретую мощность, а не поднимало её с нуля в момент пиковой нагрузки. Это стоит денег в простое, но убирает пользовательскую задержку в моменты роста — решение зависит от того, что для вас дороже.
- При деплое новой версии — сначала прогрев, потом переключение трафика. Схема blue-green или canary: новая версия модели/сервиса поднимается, проходит собственный прогрев, и только после этого балансировщик переключает на неё долю или весь трафик. Старая версия остаётся живой до полного переключения — пользователь никогда не видит паузу переезда.
- Проверяйте инференс-движки на предмет ошибок инициализации отдельно от обычных ошибок памяти во время работы. Если у вас часто падают попытки поднять новый экземпляр из-за нехватки памяти именно на этапе загрузки — это отдельная категория проблем, разобранная в статье про типичные причины сбоя vLLM при старте, и её стоит чинить в первую очередь, потому что она делает холодный старт не просто медленным, а непредсказуемым.
Если вы разворачиваете инференс на выделенном GPU-сервере, а не в общем облаке, у вас есть дополнительный рычаг — держать веса и кеш компиляции постоянно на локальном NVMe машины, не пересоздавая инстанс с нуля при каждом деплое, что само по себе убирает часть холодного старта, характерного именно для эфемерных облачных инстансов. Это одна из причин, почему для продакшен-инференса часто выбирают именно выделенный GPU-сервер под инференс LLM, а не серию коротких арендных сессий.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Развернуть ИИ на сервереНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Сколько конкретно длится холодный старт?
Зависит от размера модели, скорости диска, объёма VRAM и движка инференса — универсального числа нет, и приводить его без измерения на вашей конкретной связке модели, железа и хранилища было бы нечестно. Измерьте время до первого токена (time to first token) на своём стенде через тот же прогревочный запрос — это и будет ваша реальная цифра.
Поможет ли просто добавить больше оперативной памяти или VRAM?
Само по себе — не обязательно: холодный старт про то, сколько времени занимает загрузка и инициализация, а не про то, хватает ли памяти в принципе. Но если памяти впритык и часть весов вытесняется в своп или процесс падает по OOM при старте, это отдельная и более серьёзная проблема, которую стоит решать в первую очередь — прогрев тут не спасёт.
У меня всего один инстанс, держать запасной дорого — что делать?
Смещайте прогрев по времени: при плановом рестарте (обновление, деплой) поднимайте новый процесс и прогревайте его до того, как остановите старый, а не после. Для непредвиденных падений полностью убрать паузу первого запроса после аварийного рестарта единственным инстансом не получится — здесь работает уже минимизация самой паузы (быстрый диск, кеш компиляции), а не её полное вынесение за пределы пользовательского пути.
Нужно ли прогревать сервис при каждом, даже мелком, перезапуске?
Да, если перезапуск действительно поднимает новый процесс инференса — заново читаются веса и инициализируется движок независимо от того, насколько «мелким» кажется сам факт рестарта. Прогрев стоит встраивать в сам процесс деплоя/рестарта как обязательный шаг, а не как ручную операцию, о которой можно забыть.
Кеш скомпилированного графа можно переносить между разными серверами?
Как правило нет, если серверы отличаются моделью видеокарты, версией драйвера CUDA или версией фреймворка — кеш собирается под конкретную комбинацию и просто не подойдёт другой. Переносить есть смысл только между идентичными по конфигурации узлами, и то стоит проверить, что движок действительно принимает чужой кеш, а не пересобирает его заново.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →