Tutorial · benchmark · KServe

Optimize KServe cold starts: 134 s → 9 s with warmctl

Kernel caches are the usual answer to slow vLLM starts on KServe. We measured what they save and what they cannot, then made every new LLMInferenceService replica a restore of a warm engine. One preset, three lines in your service.

vLLM cold start134 s
kernel cache, warm node33 s
warmctl restore9.1 s

Qwen3-8B on one L40S, the same vLLM each time, start to /health 200.

14.1 sa new KServe replica, restored by warmctl
9.1 swarmctl restore, start to /health 200
33 sbest stock start: kernel cache, weights in memory
134 svLLM cold start, same GPU

00 · THE QUESTIONIsn’t a kernel cache enough?

Every new vLLM replica starts its processes, loads the weights, compiles the model and captures CUDA graphs before its first token. On Kubernetes each scale-out pays that again. The usual fix is a kernel cache: keep what torch.compile and Triton built, ship it in the image or mount it as a folder. KServe ships one as KernelCache, and you hear that vLLM then starts in seconds.

We measured it on one VM with two L40S, the same vLLM image and flags every time, and then ran the winner as an ordinary KServe LLMInferenceService.

01 · WHERE IT GOESWhat a kernel cache saves, and what it can’t

Qwen3-8B on one L40S · start to /health 200phases from the logs
vLLM, coldnothing cached, nothing in memory
134 s
vLLM, weights in memoryno kernel cache
70 s
kernel cache, coldcompile caches from an earlier start
103 s
kernel cache, warm nodecaches and weights in memory
33 s
warmctl up, coldsnapshot on a 0.44 GiB/s disk
63 s
warmctl up, warm nodesnapshot in memory
9.1 s
start processes, import, init CUDAload weightstorch.compilecapture CUDA graphsprofile memory, allocate KV cacheread the snapshotrestore processes and GPU state

The kernel cache removes compilation: torch.compile drops from 20 s to under a second. With the weights in memory too, vLLM starts in 33 s. That is the best stock start here, and most of it is what no cache reaches: 23 s to start vLLM’s processes and CUDA, then weights, CUDA graphs and memory profiling.

warmctl skips the start itself. Its snapshot is the engine after all of that, processes and GPU memory included, so it serves in 9.1 s: 3.7× faster than the best kernel-cache start, and at the same speed afterwards (46 tokens/s for one stream, 900 for 32).

Start, to /health 200Qwen3-8B · 1×L40SQwen3-32B · 2×L40S
vLLM, cold134 s251 s
vLLM, weights in memory70 s87 s
kernel cache, cold103 s210 s
kernel cache, weights in memory33 s47 s
warmctl, cold63 s183 s
warmctl, snapshot in memory9.1 s13.8 s

A cold disk is the catch. On a node that has never read the model, every start waits for the bytes, and a snapshot is more bytes than the weights: on this 0.44 GiB/s disk warmctl is only 1.7× faster for 8B and 1.1× for 32B. Keep snapshots on the nodes (step 6) or on a fast shared filesystem: GLM-5.3-Flash reads from one at 7 GiB/s and restores on 8×H100 in 58 s, against a 650 s cold start (benchmark).

02 · KSERVEKServe + warmctl =

one preset, the rest is KServeevery new pod · created → Ready
your servicebaseRefs: [warmctl]
KServe, as it isrouting · scaling
a new podwarmctl up
vllm serve70 sstart processes · load weights · compile · capture graphs
warmctl up21 srestore the warm engine

KServe’s main object for LLMs is the LLMInferenceService, and it is easy to customize: a preset can replace the command its pods run. Swap vllm serve for warmctl up, and every new replica restores a warm engine instead of cold-starting one. Routing, scheduling and autoscaling stay exactly as KServe does them.

03 · HOW IT WORKSHow warmctl works

The idea is simple. Take any model you have and the inference engine you run it on (vLLM, today), and start it once with warmctl. When the engine is warm, with its weights loaded, kernels compiled and CUDA graphs captured, warmctl takes a snapshot of it, processes and GPU memory included, and ships it as an ordinary OCI image. From then on you don’t start replicas, you hand out copies of the warm one.

warm up oncereplicate in seconds
once · warmctl build, push
your model
weightsvLLM engine
any model you serve
warm up, once
loadcompilegraphs
134 s, paid once
snapshot
processesGPU memoryweights
one OCI image
every replica
warmctl up
node 19.1 s
node 29.1 s
node 39.1 s

04 · TUTORIALRun it

Let’s do it on a GPU cluster with two L40S. You need privileged pods allowed for the model and warmctl v2.0.0-beta.11 or newer: earlier runtime images refuse port 8000 (#29). The same steps, kept current, are the Serve on KServe tutorial.

1 · Install KServe

$ curl -sL https://github.com/kserve/kserve/releases/download/v0.21.0/llmisvc-full-install-with-manifests.sh | bash

2 · Add the warmctl preset

Once per cluster. Pods get warmctl’s runtime image and run warmctl up with the snapshot from the model URI. warmctl answers on port 8000 of the pod’s IP, where KServe expects vLLM; root is for CRIU, and pulled snapshots stay on the node.

warmctl-preset.yaml
apiVersion: serving.kserve.io/v1alpha2
kind: LLMInferenceServiceConfig
metadata:
  name: warmctl
  namespace: kserve
spec:
  storageInitializer: {enabled: false}
  router: {gateway: {}, route: {}, scheduler: {}}
  template:
    containers:
      - name: main
        image: registry.warmctl.io/warmctl/runtime:latest
        command: [/usr/local/bin/tini, --, /bin/sh, -c,
          "exec warmctl up --listen ${POD_IP}:8000 ${WARMCTL_SNAPSHOT#warmctl://}"]
        env:
          - name: POD_IP
            valueFrom: {fieldRef: {fieldPath: status.podIP}}
          - name: WARMCTL_SNAPSHOT
            value: "{{ .Spec.Model.URI }}"
        securityContext:
          privileged: true
          allowPrivilegeEscalation: true
          runAsNonRoot: false
          runAsUser: 0
          seccompProfile: {type: Unconfined}
        startupProbe: {periodSeconds: 1, failureThreshold: 3600}
        volumeMounts: [{name: snapshots, mountPath: /var/lib/warmctl}]
    volumes:
      - name: snapshots
        hostPath: {path: /var/lib/warmctl}
$ kubectl apply -f warmctl-preset.yaml

3 · Deploy a model

An ordinary LLMInferenceService with two differences: baseRefs names the preset and the model URI names a snapshot from the catalog.

qwen3-8b.yaml
apiVersion: serving.kserve.io/v1alpha2
kind: LLMInferenceService
metadata:
  name: qwen3-8b
spec:
  baseRefs:
    - name: warmctl
  model:
    uri: warmctl://qwen3-8b
    name: qwen3-8b
  template:
    containers:
      - name: main
        resources:
          limits:
            nvidia.com/gpu: "1"
$ kubectl apply -f qwen3-8b.yaml

4 · Send a request

$ URL=$(kubectl get llmisvc qwen3-8b -o jsonpath='{.status.url}')
$ curl -s $URL/v1/chat/completions -H 'Content-Type: application/json' \
    -d '{"model": "qwen3-8b", "messages": [{"role": "user", "content": "Hi"}]}'

5 · Scale

$ kubectl patch llmisvc qwen3-8b --type merge -p '{"spec":{"replicas":2}}'

The second replica was Ready in 14.1 s. spec.scaling with KEDA or HPA works as for stock vLLM.

6 · Keep snapshots on the nodes

Optional. The first pod on a node pulls its snapshot; this pulls it on every node ahead of time and reads it once into memory.

prefetch.yaml
apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: warmctl-prefetch
spec:
  selector: {matchLabels: {app: warmctl-prefetch}}
  template:
    metadata: {labels: {app: warmctl-prefetch}}
    spec:
      initContainers:
        - name: pull
          image: registry.warmctl.io/warmctl/runtime:latest
          command: [/bin/sh, -c, "warmctl pull qwen3-8b:1xl40s-48gb-580.173.02 &&
            find -L /var/lib/warmctl/pulled -type f -exec cat {} + > /dev/null"]
          volumeMounts: [{name: snapshots, mountPath: /var/lib/warmctl}]
      containers:
        - name: hold
          image: registry.warmctl.io/warmctl/runtime:latest
          command: [/usr/local/bin/tini, --, sleep, infinity]
      volumes:
        - name: snapshots
          hostPath: {path: /var/lib/warmctl}

05 · THROUGH KSERVEThe same starts as LLMInferenceServices

Pod created → Readywarm nodecold node
stock vLLM70 s131 s
stock vLLM + kernel cache40 s100 s
warmctl21 s70 s

KServe v0.21.0 on k3s on the same VM, one L40S per replica, Qwen3-8B. Stock services run the same vLLM image with the weights on a PersistentVolume; the kernel cache is mounted from a hostPath, as KernelCache does. Scaling a running warmctl service from one to two replicas: 14.1 s to Ready.

Two things to know. KServe’s startup probe runs every 10 s and readiness waits for it, so a pod that restores in 12 s became Ready at 21 s; the preset probes every second, which took the scale-out from 21 s to 14.1 s. And a brand-new service also waits for KServe’s own scheduler to start: 14.8 s more from kubectl apply, paid once.

06 · YOUR MODELSYour models, your registry

The catalog is a shortcut, not a requirement. warmctl warms any model you serve on vLLM: your fine-tunes, your settings, your vLLM image. Build the snapshot once per model and GPU type in your own pipeline, push it to the registry you already run, and point model.uri at it. Nothing leaves your network, so it works the same on-premises.

bring your ownbuild once, restore anywhere
inside your network · once per model and GPU
your model
Hugging Facefine-tuneS3 / NFS
weights + your vLLM image
your CI/CD
$ warmctl build$ warmctl push
GitLab, GitHub Actions · GPU runner
your registry
my-model:l40s
HarborGHCRECR
any OCI registry
every cluster
warmctl up
produp
on-premup
stagingup
$ sudo warmctl build --profile my-model.yaml --model-dir /models/my-model \
    --output /snaps/my-model
$ sudo warmctl push /snaps/my-model registry.example.com/llm/my-model

Then uri: warmctl://registry.example.com/llm/my-model in the service. A step-by-step build shows the profile and what goes into the image.

07 · NEXTWhat does not work yet

What we are improving next:

warmctl is open source on GitHub. If you run KServe and try it, tell us how it went.