Как сократить холодный старт Ollama на Runpod Serverless с 50 до 4,4 секунды
Разбираем медленный serverless-старт гибридной 27B-модели: флаг warmup, три ложных следа и постоянный кэш CUDA JIT, который наконец убрал задержку.
У serverless-GPU есть одна мерзкая привычка. Когда воркер поднимается с нуля и запускает Ollama, первый токен может прийти через 30–60 секунд. Для чата, в который вы заглянули разок, это просто раздражает. В продакшен-пайплайне это ломает всю идею. Ниже — как гибридная Mamba/attention-модель на 27B на Runpod Serverless прошла путь от холодного старта в 50 секунд до 4,4 секунды и, что полезнее, как находили каждое узкое место, ведь именно расследование большинство разборов пропускает.
Исходные условия
- Модель: гибрид на 27 млрд параметров, в основном слои с линейным вниманием (в стиле Mamba) и полное внимание на каждом четвёртом слое, плюс встроенный энкодер изображений, который этой нагрузке вообще не нужен. Запускается как
qwen3.8:27b. - Платформа: Runpod Serverless на GPU класса H100, веса GGUF (квантизация Q4_K_M, около 16 ГБ) лежат на постоянном сетевом томе. H100 — с запасом: в Q4 модель помещается примерно в 18 ГБ VRAM. Запас оставлен намеренно, чтобы, когда холодный старт станет дешёвым, можно было перейти на модель побольше.
- Отправная точка: стандартный образ
runpod-workers/worker-ollamaбез единого изменения.
Шаг первый: стандартный образ
Прямо из коробки лог сообщил:
llama-server started in 50.50 seconds
На ноутбуке это терпимо, а на serverless дорого: каждая секунда холодного старта оплачивается. Логи контейнера показывают, куда ушло время:
- Загрузка весов через mmap с сетевого тома: примерно 9–14 секунд.
- Прогрев основной модели пустым прямым проходом: около 23 секунд.
- Загрузка энкодера изображений и его отдельный прогрев: около 2 секунд.
- Спекулятивное декодирование и прочая инициализация: около 3 секунд.
Прогрев на 23 секунды бросается в глаза сразу, а llama.cpp даже печатает, как его пропустить:
warming up the model with an empty run - please wait ... (--no-warmup to disable)
Загвоздка в том, что Go-обёртка Ollama никогда не передаёт --no-warmup процессу llama-server, который запускает. Опция в движке есть. Ollama её просто не выставляет наружу.
Шаг второй: патчить Ollama, а не заменять её
Соблазнительный ход — обойти оркестрацию Ollama и запускать llama-server самому. Это оказалось ловушкой. Go-код Ollama делает много настоящей работы перед запуском сервера: определяет GPU-бэкенд, собирает LD_LIBRARY_PATH и выбирает правильный GGML_BACKEND_PATH под нужный вариант CUDA. Повторяя всё это вручную, я раз за разом проваливался в CPU-фолбэк: появлялось предупреждение, что пригодная GPU не найдена и опция gpu-layers будет проигнорирована, а токенов в секунду становилось примерно впятеро меньше.
Сработал патч в одну строку в исходниках самой Ollama — остальное делает её проверенная логика:
// llm/llama_server.go
params := []string{
"--model", launch.modelPath,
"--port", strconv.Itoa(port),
// ...
}
// Custom patch: skip llama-server's ~23s empty-run warmup when
// OLLAMA_SKIP_WARMUP=1 is set.
if os.Getenv("OLLAMA_SKIP_WARMUP") == "1" {
params = append(params, "--no-warmup")
}
Сборка дешёвая. llama-server вообще не перекомпилируется. Он остаётся готовым бинарником, взятым из официального Docker-образа ollama/ollama, так что никакой компиляции CUDA или C++. Пересобирается только маленькая Go-обёртка — это быстро и почти без риска.
Результат — 37,2 секунды. Честный выигрыш около 5 секунд от этого патча, но совсем не то, что должно было дать удаление 23 секунд. Время съедало что-то ещё.
Шаг третий: загадочная дыра в 32 секунды
После патча прогрева в логах всё ещё был молчаливый промежуток:
srv load_model: loaded multimodal model, '...'
[ ~32 seconds of nothing ]
cmn common_conte: the context does not support partial sequence removal
Он длился 32–33 секунды при каждом запуске. Именно это постоянство и было настоящей подсказкой. Честные переменные вычисления не повторяются с точностью до десятой доли секунды при разных конфигурациях, так что число, которое никогда не меняется, пахнет фиксированной стоимостью.
Три отдельные гипотезы проверили на фактах, и все три оказались тупиками:
- Спекулятивное декодирование через предсказание нескольких токенов. В модели есть встроенные MTP-слои, которые Ollama включает автоматически. Их отключение через
OLLAMA_DISABLE_MTPподтвердилось в логе — реализаций спекулятивного декодирования нет, — но дыра почти не сдвинулась: с 37,2 до 36,4 секунды. - Кэш промптов. Свежая функция llama.cpp заранее выделяет под кэш промптов арену на 8 ГБ. Отключение через
--cache-ram 0подтвердилось сообщением «prompt cache is disabled», а время осталось 36,4 секунды. - Прогрев энкодера изображений. Строка
get_dummy_batch: warmup with image size = 1472 x 1472появлялась ровно в момент начала дыры. Это отдельный прогрев визуальной модели, и--no-warmupего не покрывает, потому что управляет только текстовой моделью. Раз энкодер изображений здесь не нужен, Ollama пропатчили, чтобы она вообще не загружала мультимодальный проектор. Строк про vision в логе не осталось, но итог по-прежнему 36,2 секунды.
После четырёх изменений всё время возвращалась одна и та же цифра около 36 секунд, неподвижная даже после удаления целых подсистем. Это верный признак, что вы смотрите не на тот слой.
Шаг четвёртый: JIT-компиляция CUDA
Гибридная архитектура модели использует необычные фьюзнутые ядра. Прямо перед дырой в логах печатается строка о разрешении фьюзнутых операций для поддержки DeepSeek V4 HC и Gated Delta Net. Это не обычные трансформерные ядра, и они достаточно экзотичны, чтобы драйвер CUDA, скорее всего, ещё ни разу не компилировал их под эту конкретную GPU.
CUDA этот случай поддерживает. Ядра поставляются в виде PTX — промежуточного представления — и компилируются just-in-time в нативный машинный код (SASS) при первом запуске на данной архитектуре GPU. Скомпилированный результат кэшируется, но по умолчанию этот кэш живёт ровно столько, сколько процесс. Каждый холодный старт в serverless — совершенно новый процесс, так что ничего не переносится.
Решение повторяет приём, который раньше применяли к кэшу torch.compile в vLLM: направить кэш в постоянное хранилище.
# start.sh
export CUDA_CACHE_PATH=/runpod-volume/cuda-jit-cache
mkdir -p "${CUDA_CACHE_PATH}"
/runpod-volume — тот же сетевой том, где лежат веса, так что кэш переживает все будущие холодные старты на любой машине, куда Runpod поставит воркер, а не только на той, где его собрали.
Первый запрос после деплоя, с пустым кэшем, по-прежнему занимает 36,4 секунды. Бесплатного сыра на первом запуске нет. Второй запрос, с прогретым кэшем, показывает:
llama-server started in 4.36 seconds
Весь путь целиком
- Официальный образ без изменений: 50,5 секунды, отправная точка.
- Своя сборка только со структурными изменениями (готовый бинарник, патчей Ollama пока нет): 41,9 секунды.
- Плюс патч no-warmup: 37,2 секунды, потому что пустой прогон основной модели пропускается.
- Плюс кэш CUDA JIT, первый запуск: 36,4 секунды, кэш ещё строится.
- Плюс кэш CUDA JIT, каждый следующий запуск: 4,4 секунды, и так и остаётся.
Генерация всё время полностью шла на GPU, без регрессий: префилл — около 2300 токенов в секунду, декодирование — около 75 токенов в секунду. Если мерить от загрузки контейнера до готовности к первому токену, с прогретым кэшем выходит примерно 12–15 секунд против изначальных 50–65.
Уроки, если будете пробовать
- Читайте строки llama-server, которые печатает Ollama. Часто в них прямо указан нужный флаг: и
--no-warmup, и--cache-ram 0были написаны открытым текстом. - Патчьте одну строку, а не переписывайте оркестрацию подпроцессов. Go-код Ollama делает работу вроде выбора GPU-бэкенда, которую при копировании легко незаметно испортить.
- Если дыра не сжимается, сколько бы вы ни отключали, значит, вы решаете задачу не на том слое. Время ушло на MTP, кэш промптов и vision, прежде чем стало ясно, что постоянству цифры стоило поверить раньше.
- Всё, что обычно кэшируется в рамках процесса, вроде скомпилированных ядер и JIT-артефактов, как правило, можно перенаправить на диск. Так работают и JIT-кэш CUDA, и кэш
torch.compileв PyTorch. Если у платформы есть постоянное хранилище, используйте его для них.
Издержки, о которых стоит сказать честно
Такая схема означает поддержку небольшого патча к Go-исходникам Ollama и его пересборку, что добавляет несколько минут при каждом обновлении версии Ollama. Без обслуживания не обойтись. Сборка делалась из main-ветки Ollama, чтобы получить поддержку этой новой архитектуры модели, так что стабильный релиз с тегом может вести себя немного иначе. И свой образ не получает кэширования слоёв по всему парку, которым пользуется популярный официальный образ, так что первое скачивание образа на совершенно новой машине остаётся отдельной статьёй расходов.
Ничто из этого не перевешивает переход с 50 секунд на 4,4.