Головна / Статті / Як скоротити холодний старт Ollama на Runpod Serverless з 50 до 4,4 секунди

Як скоротити холодний старт Ollama на Runpod Serverless з 50 до 4,4 секунди

Розбираємо повільний serverless-старт гібридної 27B-моделі: прапорець warmup, три хибні сліди й постійний кеш CUDA JIT, який нарешті прибрав затримку.

1477 слів

У 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 секунди під час кожного запуску. Саме ця сталість і була справжньою підказкою. Чесні змінні обчислення не повторюються з точністю до десятої частки секунди за різних конфігурацій, тож число, яке ніколи не змінюється, тхне фіксованою вартістю.

Три окремі гіпотези перевірили на фактах, і всі три виявилися глухими кутами:

  1. Спекулятивне декодування через передбачення кількох токенів. У моделі є вбудовані MTP-шари, які Ollama вмикає автоматично. Їх вимкнення через OLLAMA_DISABLE_MTP підтвердилося в лозі — реалізацій спекулятивного декодування немає, — але діра майже не зрушила: з 37,2 до 36,4 секунди.
  2. Кеш промптів. Нова функція llama.cpp заздалегідь виділяє під кеш промптів арену на 8 ГБ. Вимкнення через --cache-ram 0 підтвердилося повідомленням «prompt cache is disabled», а час лишився 36,4 секунди.
  3. Прогрів енкодера зображень. Рядок 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.