Startseite / Artikel / Ollama-Kaltstarts auf Runpod Serverless von 50 auf 4,4 Sekunden drücken

Ollama-Kaltstarts auf Runpod Serverless von 50 auf 4,4 Sekunden drücken

Wir verfolgen den langsamen Serverless-Start eines hybriden 27B-Modells über das Warmup-Flag, drei falsche Spuren und einen persistenten CUDA-JIT-Cache, der die Verzögerung schließlich behob.

1477 Wörter

Serverless-GPUs haben eine hässliche Angewohnheit. Wenn ein Worker von null hochfährt und Ollama startet, kann das erste Token zwischen 30 und 60 Sekunden dauern. Für einen Chat, den man einmal anstupst, ist das bloß lästig. In einer Produktionspipeline zerstört es die ganze Idee. Hier steht, wie ein hybrides Mamba/Attention-Modell mit 27B auf Runpod Serverless von einem 50-Sekunden-Kaltstart auf 4,4 Sekunden kam – und, nützlicher noch, wie jeder Engpass gefunden wurde, denn genau diese Untersuchung lassen die meisten Berichte aus.

Das Setup

  • Modell: ein Hybrid mit 27 Milliarden Parametern, überwiegend Linear-Attention-Schichten (im Mamba-Stil) mit voller Attention auf jeder vierten Schicht, dazu ein mitgelieferter Vision-Encoder, den dieser Workload nie anrührt. Es läuft als qwen3.8:27b.
  • Plattform: Runpod Serverless auf GPUs der H100-Klasse, die GGUF-Gewichte (Q4_K_M-Quantisierung, rund 16 GB) liegen auf einem persistenten Netzwerk-Volume. Die H100 ist mehr, als das Modell braucht, denn bei Q4 passt es in etwa 18 GB VRAM. Der Puffer ist Absicht, damit ein größeres Modell einziehen kann, sobald Kaltstarts billig sind.
  • Ausgangspunkt: das Standard-Image runpod-workers/worker-ollama, völlig unverändert.

Schritt eins: das Standard-Image

Direkt nach dem Auspacken meldete das Log:

llama-server started in 50.50 seconds

Auf einem Laptop ist das erträglich, auf Serverless teuer, weil jede Sekunde Kaltstart abgerechnet wird. Die Container-Logs zeigen, wohin die Zeit ging:

  • Laden der Gewichte per mmap vom Netzwerk-Volume: ungefähr 9 bis 14 Sekunden.
  • Aufwärmen des Hauptmodells mit einem leeren Forward Pass: etwa 23 Sekunden.
  • Laden des Vision-Encoders und sein separates Aufwärmen: rund 2 Sekunden.
  • Spekulatives Decoding und sonstige Initialisierung: etwa 3 Sekunden.

Das 23-sekündige Warmup fällt sofort ins Auge, und llama.cpp druckt sogar aus, wie man es überspringt:

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

Der Haken: Ollamas Go-Wrapper übergibt --no-warmup nie an den llama-server-Prozess, den er startet. Die Option gibt es in der Engine. Ollama reicht sie nur nicht nach außen durch.

Schritt zwei: Ollama patchen statt ersetzen

Verlockend ist es, Ollamas Orchestrierung zu umgehen und llama-server selbst zu starten. Das erwies sich als Falle. Ollamas Go-Code erledigt vor dem Start des Servers eine Menge echter Arbeit: Er erkennt das GPU-Backend, baut LD_LIBRARY_PATH zusammen und wählt den passenden GGML_BACKEND_PATH für die richtige CUDA-Variante. Das alles von Hand nachzubauen, landete immer wieder im CPU-Fallback – erkennbar an der Warnung, es sei keine nutzbare GPU gefunden worden und die gpu-layers-Option werde ignoriert, dazu etwa ein Fünftel der Tokens pro Sekunde.

Funktioniert hat ein einzeiliger Patch an Ollamas eigenem Quellcode, der den Rest der erprobten Logik überlässt:

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

Der Build ist billig. llama-server wird überhaupt nicht neu kompiliert. Er bleibt ein vorgefertigtes Binary aus dem offiziellen Docker-Image ollama/ollama, also keine CUDA- oder C++-Kompilierung. Neu gebaut wird nur der kleine Go-Wrapper, das geht schnell und ist risikoarm.

Ergebnis: 37,2 Sekunden. Ein echter Gewinn von rund 5 Sekunden durch diesen Patch, aber bei Weitem nicht das, was das Streichen von 23 Sekunden hätte bringen müssen. Irgendetwas anderes fraß Zeit.

Schritt drei: eine rätselhafte Lücke von 32 Sekunden

Nach dem Warmup-Patch zeigten die Logs immer noch eine stille Strecke:

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

Sie dauerte bei jedem Lauf 32 bis 33 Sekunden. Diese Gleichförmigkeit war der eigentliche Hinweis. Echte variable Berechnungen wiederholen sich nicht auf die Zehntelsekunde genau über verschiedene Konfigurationen hinweg; eine Zahl, die sich nie bewegt, riecht nach Fixkosten.

Drei getrennte Theorien wurden mit Belegen getestet, und alle drei waren Sackgassen:

  1. Spekulatives Decoding per Multi-Token-Prediction. Das Modell hat eingebaute MTP-Schichten, die Ollama automatisch aktiviert. Das Abschalten mit OLLAMA_DISABLE_MTP bestätigte das Log mit der Meldung, es gebe keine Implementierungen für spekulatives Decoding – doch die Lücke bewegte sich kaum, von 37,2 auf 36,4 Sekunden.
  2. Der Prompt-Cache. Eine neuere llama.cpp-Funktion reserviert vorab eine 8-GB-Arena für den Prompt-Cache. Das Abschalten mit --cache-ram 0 wurde mit der Meldung „prompt cache is disabled“ bestätigt, und die Zeit blieb bei 36,4 Sekunden.
  3. Das Warmup des Vision-Encoders. Eine Zeile get_dummy_batch: warmup with image size = 1472 x 1472 tauchte genau dann auf, als die Lücke begann. Das ist ein separates Warmup für das Vision-Modell, das --no-warmup nicht abdeckt, weil es nur das Textmodell steuert. Da der Vision-Encoder hier nicht gebraucht wird, wurde Ollama so gepatcht, dass der multimodale Projektor gar nicht erst geladen wird. Es blieben keine Vision-Zeilen im Log, aber die Summe lag weiter bei 36,2 Sekunden.

Nach vier Änderungen kam immer wieder dieselbe Zahl von rund 36 Sekunden heraus, unbewegt selbst nach dem Entfernen ganzer Subsysteme. Das ist das Zeichen, dass man auf die falsche Ebene schaut.

Schritt vier: CUDA-JIT-Kompilierung

Das hybride Design des Modells nutzt ungewöhnliche fusionierte Kernel. Direkt vor der Lücke druckt das Log eine Zeile, die fusionierte Ops für die Unterstützung von DeepSeek V4 HC und Gated Delta Net auflöst. Das sind keine gewöhnlichen Transformer-Kernel, und sie sind exotisch genug, dass der CUDA-Treiber sie für genau diese GPU wahrscheinlich noch nie kompiliert hat.

CUDA unterstützt diesen Fall. Kernel werden als PTX ausgeliefert, eine Zwischendarstellung, und beim ersten Lauf auf einer bestimmten GPU-Architektur just in time in nativen Maschinencode (SASS) übersetzt. Das Kompilat wird zwar gecacht, doch standardmäßig lebt dieser Cache nur so lange wie der Prozess. Jeder Serverless-Kaltstart ist ein brandneuer Prozess, also wird nie etwas übernommen.

Die Lösung spiegelt einen Trick, der zuvor schon für den torch.compile-Cache von vLLM verwendet wurde: den Cache auf persistenten Speicher umleiten.

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

/runpod-volume ist dasselbe Netzwerk-Volume, auf dem die Gewichte liegen, also überlebt der Cache jeden künftigen Kaltstart – auf jeder Maschine, auf die Runpod den Worker setzt, nicht nur auf der, die ihn gebaut hat.

Die erste Anfrage nach dem Deployment, mit leerem Cache, dauert weiterhin 36,4 Sekunden. Beim ersten Lauf gibt es nichts geschenkt. Die zweite Anfrage, mit warmem Cache, meldet:

llama-server started in 4.36 seconds

Der ganze Verlauf

  • Offizielles Image, unverändert: 50,5 Sekunden, der Ausgangswert.
  • Eigener Build nur mit strukturellen Änderungen (vorgefertigtes Binary, noch keine Ollama-Patches): 41,9 Sekunden.
  • Dazu der No-Warmup-Patch: 37,2 Sekunden, weil der Leerlauf des Hauptmodells entfällt.
  • Dazu der CUDA-JIT-Cache, erster Lauf: 36,4 Sekunden, da der Cache noch aufgebaut wird.
  • Dazu der CUDA-JIT-Cache, jeder weitere Lauf: 4,4 Sekunden, und das bleibt so.

Die Generierung lief durchgehend vollständig auf der GPU, ohne Regressionen: Prefill mit etwa 2.300 Tokens pro Sekunde, Decoding mit etwa 75 Tokens pro Sekunde. Gemessen vom Container-Boot bis zur Bereitschaft für das erste Token ergibt ein warmer Cache etwa 12 bis 15 Sekunden, statt ursprünglich rund 50 bis 65.

Lehren, falls Sie das ausprobieren

  • Lesen Sie die llama-server-Zeilen, die Ollama ausgibt. Oft nennen sie genau das Flag, das Sie brauchen – sowohl --no-warmup als auch --cache-ram 0 standen im Klartext da.
  • Patchen Sie eine Zeile, statt die Orchestrierung von Subprozessen nachzubauen. Ollamas Go-Code erledigt Arbeit wie die Auswahl des GPU-Backends, die beim Kopieren leicht subtil schiefgeht.
  • Eine Lücke, die nicht schrumpft, egal wie viel Sie abschalten, heißt: Sie arbeiten auf der falschen Ebene. Es ging Zeit für MTP, den Prompt-Cache und Vision drauf, bevor klar wurde, dass man der Konstanz der Zahl früher hätte trauen sollen.
  • Alles, was normalerweise pro Prozess gecacht wird, etwa kompilierte Kernel und JIT-Artefakte, lässt sich meist auf die Platte umleiten. Der JIT-Cache von CUDA und der torch.compile-Cache von PyTorch funktionieren beide so. Hat Ihre Plattform persistenten Speicher, nutzen Sie ihn dafür.

Kosten, die man ehrlich nennen sollte

Das Setup bedeutet, einen kleinen Patch gegen Ollamas Go-Quellcode zu pflegen und neu zu bauen, was bei jedem Ollama-Versionssprung ein paar Minuten kostet. Wartungsfrei ist es nicht. Der Build stammt aus Ollamas Main-Branch, um Unterstützung für diese neue Modellarchitektur zu bekommen, ein stabiles getaggtes Release könnte sich also leicht anders verhalten. Und das eigene Image profitiert nicht vom flottenweiten Layer-Caching, das das beliebte offizielle Image genießt, sodass das erste Ziehen des Images auf einer brandneuen Maschine ein eigener Kostenpunkt bleibt.

Nichts davon wiegt den Sprung von 50 auf 4,4 Sekunden auf.