Inicio / Artículos / Cómo bajar los arranques en frío de Ollama en Runpod Serverless de 50 s a 4,4 s

Cómo bajar los arranques en frío de Ollama en Runpod Serverless de 50 s a 4,4 s

Seguimos el lento arranque serverless de un modelo híbrido de 27B a través del flag de warmup, tres pistas falsas y una caché JIT de CUDA persistente que por fin acabó con el retraso.

1477 palabras

Las GPU serverless tienen una costumbre fea. Cuando un worker arranca desde cero y ejecuta Ollama, el primer token puede tardar entre 30 y 60 segundos. Para un chat que tocas una vez, solo es molesto. En un pipeline de producción, rompe la idea entera. Así es como un modelo híbrido Mamba/atención de 27B en Runpod Serverless pasó de un arranque en frío de 50 segundos a 4,4 segundos y, lo que es más útil, cómo se encontró cada cuello de botella, porque la investigación es justo la parte que la mayoría de los artículos se salta.

El montaje

  • Modelo: un híbrido de 27.000 millones de parámetros, sobre todo capas de atención lineal (estilo Mamba) con atención completa cada cuatro capas, más un codificador de visión incluido que esta carga de trabajo nunca usa. Se ejecuta como qwen3.8:27b.
  • Plataforma: Runpod Serverless en GPU de clase H100, con los pesos GGUF (cuantización Q4_K_M, unos 16 GB) en un volumen de red persistente. La H100 es más de lo que el modelo necesita, ya que en Q4 cabe en unos 18 GB de VRAM. El margen es intencionado, para poder pasar a un modelo mayor cuando los arranques en frío salgan baratos.
  • Punto de partida: la imagen estándar runpod-workers/worker-ollama, sin cambiar nada.

Paso uno: la imagen estándar

Recién sacado de la caja, el log decía:

llama-server started in 50.50 seconds

En un portátil es tolerable; en serverless sale caro, porque cada segundo de arranque en frío se factura. Los logs del contenedor muestran adónde se fue el tiempo:

  • Carga de pesos con mmap desde el volumen de red: entre 9 y 14 segundos, más o menos.
  • Calentamiento del modelo principal con un forward pass vacío: unos 23 segundos.
  • Carga del codificador de visión y su calentamiento aparte: alrededor de 2 segundos.
  • Decodificación especulativa y otra inicialización: unos 3 segundos.

El calentamiento de 23 segundos salta a la vista enseguida, y llama.cpp incluso imprime cómo saltárselo:

warming up the model with an empty run - please wait ... (--no-warmup to disable)

La pega es que el wrapper en Go de Ollama nunca pasa --no-warmup al proceso llama-server que lanza. La opción existe en el motor. Ollama simplemente no la expone.

Paso dos: parchear Ollama en vez de sustituirlo

La tentación es saltarse la orquestación de Ollama y lanzar llama-server tú mismo. Resultó ser una trampa. El código Go de Ollama hace mucho trabajo real antes de arrancar el servidor: detecta el backend de GPU, construye LD_LIBRARY_PATH y elige el GGML_BACKEND_PATH adecuado para la variante correcta de CUDA. Rehacer todo eso a mano acababa una y otra vez en el modo de respaldo por CPU, que se notaba en el aviso de que no se había encontrado una GPU utilizable y de que se ignoraría la opción gpu-layers, además de una caída de unas cinco veces en tokens por segundo.

Lo que funcionó fue un parche de una línea en el propio código fuente de Ollama, que deja que su lógica ya probada haga el resto:

// 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")
}

La compilación es barata. llama-server no se recompila en absoluto. Sigue siendo un binario precompilado sacado de la imagen Docker oficial ollama/ollama, así que no hay compilación de CUDA ni de C++. Solo se reconstruye el pequeño wrapper en Go, lo cual es rápido y de bajo riesgo.

El resultado fue 37,2 segundos. Una ganancia real de unos 5 segundos gracias a este parche, nada que ver con lo que debería haber dado eliminar 23 segundos. Algo más se estaba comiendo el tiempo.

Paso tres: un hueco misterioso de 32 segundos

Tras el parche del warmup, los logs seguían mostrando un tramo en silencio:

srv load_model: loaded multimodal model, '...'
                     [ ~32 seconds of nothing ]
cmn common_conte: the context does not support partial sequence removal

Duraba entre 32 y 33 segundos en cada ejecución. Esa regularidad era la verdadera pista. Un cálculo variable de verdad no se repite hasta la décima de segundo con configuraciones distintas, así que un número que nunca se mueve huele a coste fijo.

Se pusieron a prueba tres teorías distintas, con evidencias, y las tres fueron callejones sin salida:

  1. Decodificación especulativa mediante predicción de varios tokens. El modelo tiene capas MTP integradas que Ollama activa automáticamente. Desactivarlas con OLLAMA_DISABLE_MTP quedó confirmado en el log, que informaba de que no había implementaciones de decodificación especulativa, pero el hueco apenas se movió, de 37,2 a 36,4 segundos.
  2. La caché de prompts. Una función reciente de llama.cpp reserva de antemano un área de 8 GB para la caché de prompts. Desactivarla con --cache-ram 0 se confirmó con el mensaje «prompt cache is disabled», y el tiempo siguió en 36,4 segundos.
  3. El calentamiento del codificador de visión. Una línea que decía get_dummy_batch: warmup with image size = 1472 x 1472 aparecía justo cuando empezaba el hueco. Es un calentamiento aparte para el modelo de visión, que --no-warmup no cubre porque solo controla el modelo de texto. Como aquí no hace falta el codificador de visión, se parcheó Ollama para que no cargara en absoluto el proyector multimodal. No quedó ninguna línea de visión en el log, pero el total seguía en 36,2 segundos.

Tras cuatro cambios volvía una y otra vez la misma cifra de unos 36 segundos, inmóvil incluso después de quitar subsistemas enteros. Esa es la señal de que estás mirando la capa equivocada.

Paso cuatro: compilación JIT de CUDA

El diseño híbrido del modelo usa kernels fusionados poco habituales. Justo antes del hueco, los logs imprimen una línea que resuelve operaciones fusionadas para el soporte de DeepSeek V4 HC y Gated Delta Net. No son kernels de transformer corrientes, y son lo bastante exóticos como para que el driver de CUDA probablemente no los haya compilado nunca para esta GPU concreta.

CUDA contempla ese caso. Los kernels se distribuyen como PTX, una representación intermedia, y se compilan just in time a código máquina nativo (SASS) la primera vez que se ejecutan en una arquitectura de GPU dada. El resultado compilado sí se guarda en caché, pero por defecto esa caché solo vive lo que vive el proceso. Cada arranque en frío en serverless es un proceso nuevo, así que nunca se conserva nada.

La solución replica un truco usado antes con la caché de torch.compile de vLLM: apuntar la caché a almacenamiento persistente.

# start.sh
export CUDA_CACHE_PATH=/runpod-volume/cuda-jit-cache
mkdir -p "${CUDA_CACHE_PATH}"

/runpod-volume es el mismo volumen de red que guarda los pesos, así que la caché sobrevive a todos los arranques en frío futuros, en cualquier máquina en la que Runpod coloque el worker, no solo en la que la generó.

La primera petición tras el despliegue, con la caché vacía, sigue tardando 36,4 segundos. En la primera ejecución nadie regala nada. La segunda petición, con la caché caliente, informa:

llama-server started in 4.36 seconds

La evolución completa

  • Imagen oficial, sin cambios: 50,5 segundos, el punto de partida.
  • Compilación propia solo con cambios estructurales (binario precompilado, todavía sin parches de Ollama): 41,9 segundos.
  • Con el parche no-warmup: 37,2 segundos, porque se salta la ejecución vacía del modelo principal.
  • Con la caché JIT de CUDA, primera ejecución: 36,4 segundos, ya que la caché aún se está construyendo.
  • Con la caché JIT de CUDA, cada ejecución posterior: 4,4 segundos, y así se mantiene.

La generación se mantuvo entera en la GPU todo el tiempo, sin regresiones: el prefill iba a unos 2.300 tokens por segundo y la decodificación a unos 75 tokens por segundo. Medido de extremo a extremo, desde el arranque del contenedor hasta estar listo para el primer token, con la caché caliente salen entre 12 y 15 segundos, frente a los 50 a 65 de partida.

Lecciones si lo pruebas

  • Lee las líneas de llama-server que imprime Ollama. A menudo nombran exactamente el flag que necesitas: tanto --no-warmup como --cache-ram 0 aparecían en texto plano.
  • Parchea una línea en lugar de reconstruir la orquestación de subprocesos. El código Go de Ollama hace trabajo, como elegir el backend de GPU, que es fácil estropear sutilmente al copiarlo.
  • Un hueco que no se reduce por mucho que desactives significa que estás resolviendo la capa equivocada. Se perdió tiempo con MTP, la caché de prompts y la visión antes de ver que la constancia del número era la pista en la que había que confiar antes.
  • Todo lo que normalmente se cachea por proceso, como kernels compilados y artefactos JIT, suele poder redirigirse a disco. La caché JIT de CUDA y la caché de torch.compile de PyTorch funcionan así. Si tu plataforma tiene almacenamiento persistente, úsalo para ellas.

Costes que hay que reconocer

Este montaje implica mantener un pequeño parche sobre el código Go de Ollama y recompilarlo, lo que añade unos minutos cada vez que sube la versión de Ollama. No es mantenimiento cero. La compilación salió de la rama main de Ollama para tener soporte de esta nueva arquitectura de modelo, así que una versión estable etiquetada podría comportarse algo distinto. Y la imagen propia no disfruta del cacheo de capas a nivel de toda la flota que tiene la popular imagen oficial, de modo que la primera descarga de la imagen en una máquina nueva sigue siendo un coste aparte.

Nada de eso pesa más que pasar de 50 segundos a 4,4.