Day15 的最後我們觀察到:同一份權重第一次載要 166 秒,第二次只要 3 秒,差 52 倍,而差別只在權重有沒有留在 PVC 上。
今天會走一次完整的冷啟動流程,看一個 Pod 從 kubectl apply 到能發送請求之前經歷了什麼、各階段耗時多久,以及這段過程可以怎麼優化。
冷啟動指的是從一個推論服務被啟動,到它能回應第一個請求,中間經過的那段時間。
對 vLLM 來說,這段時間花在兩件事上。一是把權重搬進卡裡,十幾 GB 的檔案平常不在機器上,得先下載再載入記憶體。二是編譯與錄製 CUDA graph,vLLM 會先把模型的運算編譯過一遍,再把其中幾段錄成 CUDA graph,讓之後每一次推論不必重新把指令一條條送給 GPU。編譯的產物可以快取,命中的時候整段會被跳過。
把 vLLM 跑在 Kubernetes 上,啟動時間裡會再多出幾段。而最前面那一段最容易被忘掉,因為它發生在 Pod 還沒開始跑之前。
第零段是等機器。GPU 節點如果不是一直開著,Pod 排不進去就得先向雲端要一台新的,而那台機器要開機、要裝好 NVIDIA 驅動,才會變成一個 Ready 的節點。驅動如果沒有預先準備好,開機之後還得現場編譯。
第一段是拉映像。推論引擎的映像動輒十幾 GB,而 kubelet 預設是一個一個拉,要並行得自己調。
第二段是等 sandbox 就緒。容器要等這一步完成才開始跑,而在那之前 kubelet 得跨多個元件協調:volume 插件、CSI 插件,以及 container runtime,而 container runtime 又會再去呼叫 CNI 插件把網路接上。這些階段全部完成,Pod 才處在可以啟動容器的狀態。
第三段是權重不在映像裡。映像只帶程式,權重是 process 起來之後才去抓的,所以抓到哪裡、下次還在不在,完全取決於你掛了什麼 volume。
理想狀況是沒人用的時候 GPU 關機省錢,有人用的時候再自動開回來。但現實是,使用者點下「送出對話」,系統才手忙腳亂地開始調度機器、拉十幾 GB 的映像、下載大模型、錄 CUDA graph。使用者就在螢幕前盯著畫面發呆,而這期間請求早就逾時斷線了。
環境是 k3s 的一張 L40S(46068 MiB),卡走 DRA(gpu.nvidia.com),映像 vllm/vllm-openai:v0.11.0,模型 Qwen/Qwen2.5-7B-Instruct(BF16,權重 14.25 GiB)。
今天跑五組設定,每一組只改一件事,量同一個數字。
| 狀態 | 總時間 | init engine | 比上一列省 | |
|---|---|---|---|---|
| 一 | 完全冷啟動 | 526.9 | 36.4 | |
| 二 | +映像已在節點 | 254.9 | 50.3 | 272.0 |
| 三 | +權重已在 PVC | 87.8 | 35.2 | 167.1 |
| 四 | +編譯快取也在 PVC | 51.7 / 51.6 | 12.5 | 36.1 |
| 五 | +CUDA graph 只錄 7 份 | 41.8 | 8.6 | 9.9 |
272 → 167 → 36 → 10,每一項省下來的都比前一項少。
| 映像在節點 | 權重在 PVC | 編譯快取在 PVC | graph 只錄 7 份 | |
|---|---|---|---|---|
| 一 完全冷啟動 | ❌ | ❌ | ❌ | ❌ |
| 二 | ✅ | ❌ | ❌ | ❌ |
| 三 | ✅ | ✅ | ❌ | ❌ |
| 四 | ✅ | ✅ | ✅ | ❌ |
| 五 | ✅ | ✅ | ✅ | ✅ |
但第一列跑完之後,映像和權重其實都已經在機器上了。所以第二列要量「只有映像有了」這一格,就得再刪一次 namespace 把權重丟掉,只留映像。從第三列開始才真的什麼都不刪,一路往上疊。
apiVersion: v1
kind: Namespace
metadata: { name: vllm }
---
apiVersion: resource.k8s.io/v1
kind: ResourceClaimTemplate
metadata: { name: one-gpu, namespace: vllm }
spec:
spec:
devices:
requests:
- name: gpu
exactly:
deviceClassName: gpu.nvidia.com
allocationMode: ExactCount
count: 1
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata: { name: cache, namespace: vllm }
spec:
accessModes: [ReadWriteOnce]
resources: { requests: { storage: 80Gi } }
---
apiVersion: apps/v1
kind: Deployment
metadata: { name: vllm, namespace: vllm }
spec:
replicas: 1
strategy: { type: Recreate } # 單卡,不能新舊並存
selector: { matchLabels: { app: vllm } }
template:
metadata: { labels: { app: vllm } }
spec:
nodeSelector:
kubernetes.io/hostname: ip-172-31-26-39 # 釘在同一個節點
enableServiceLinks: false
resourceClaims: [{ name: gpu, resourceClaimTemplateName: one-gpu }]
volumes:
- { name: cache, persistentVolumeClaim: { claimName: cache } }
- { name: shm, emptyDir: { medium: Memory, sizeLimit: 2Gi } }
containers:
- name: vllm
image: vllm/vllm-openai:v0.11.0
args:
- --model=Qwen/Qwen2.5-7B-Instruct
- --gpu-memory-utilization=0.90
env:
- { name: HF_HOME, value: /hf }
volumeMounts:
- { name: cache, mountPath: /hf, subPath: hf }
- { name: shm, mountPath: /dev/shm }
resources: { claims: [{ name: gpu }] }
startupProbe:
httpGet: { path: /health, port: 8000 }
periodSeconds: 10
failureThreshold: 120
---
apiVersion: v1
kind: Service
metadata: { name: vl, namespace: vllm } # 不能叫 vllm,見 Day 15
spec:
selector: { app: vllm }
ports: [{ port: 8000, targetPort: 8000 }]
一個 PVC 用 subPath 切兩塊:hf 放權重,第四列才加上 vllmcache 放編譯快取。一開始只掛 /hf,所以第三列和第四列才分得開。
nodeSelector 是必要的。crictl rmi 和清檔案快取都只對本機有效,所以完全冷啟動只有那一台造得出來,而五列要互相比較就必須全部在同一台。
date +%s.%N | tee /tmp/t0
kubectl apply -f /tmp/vllm.yaml
kubectl -n vllm wait --for=condition=Ready pod -l app=vllm --timeout=40m
date +%s.%N | tee /tmp/t1
awk 'NR==FNR{a=$1;next}{printf "%.1f 秒\n", $1-a}' /tmp/t0 /tmp/t1
kubectl -n vllm get pod -l app=vllm \
-o jsonpath='{range .items[0].status.conditions[*]}{.type}{" "}{.lastTransitionTime}{"\n"}{end}'
Pod 的各個 condition,管排程到容器開始跑那幾段。
kubectl -n vllm get events --sort-by=.lastTimestamp \
| grep -iE "Scheduled|Pulling|Pulled|Created|Started"
拉映像的耗時,Pulled 那條訊息自己就帶著秒數和映像大小。而 events 預設一小時就被回收,第一列跑完要當場抓下來。
kubectl -n vllm logs deploy/vllm --timestamps | grep -iE \
"platform cuda|API server version|Starting to load model|downloading weights|Loading weights took|Model loading took|Dynamo|Compiling a graph|Directly load|torch.compile takes|Available KV cache|Graph capturing|init engine|Application startup"
容器裡面那幾段:下載權重、載入權重、編譯、錄 CUDA graph、init engine、服務起來。
vLLM 自己印的一行,而它是一個總和:
init engine (profile, create kv cache, warmup model) took 36.35 seconds
裡面包含 torch.compile、錄 CUDA graph、KV cache profiling 和 warmup。所以不能把它跟 torch.compile 相加,會重複計算。
# 第一列 完全冷啟動:映像、權重、檔案快取三個都清掉
kubectl delete namespace vllm # PVC 跟著走,權重沒了
sudo k3s crictl --timeout 10m rmi vllm/vllm-openai:v0.11.0 # 映像沒了
sudo k3s crictl images | grep -c vllm # 要是 0
sync && echo 3 | sudo tee /proc/sys/vm/drop_caches # 檔案快取沒了
kubectl apply -f /tmp/vllm.yaml
# 第二列 映像留著,只把權重丟掉
kubectl delete namespace vllm
kubectl apply -f /tmp/vllm.yaml
# 第三列 什麼都不刪
kubectl -n vllm rollout restart deploy/vllm
# 第四列 先把編譯快取的路徑掛上去
kubectl apply -f /tmp/vllm4.yaml # 這一次是「寫入」快取,不算
kubectl -n vllm rollout restart deploy/vllm # 這一次才量得到命中
# 第五列 加上 --cuda-graph-sizes
kubectl apply -f /tmp/vllm5.yaml
init engine,不要看總時間。Pulled Successfully pulled image "vllm/vllm-openai:v0.11…
Image size: 12454045526 bytes.
Time spent downloading weights: 185.493367 seconds
Loading weights took 14.76 seconds
Model loading took 14.2488 GiB and 201.220095 seconds
Graph capturing finished in 6 secs, took 0.59 GiB
init engine ... took 36.35 seconds
| 段 | 秒 | 佔比 |
|---|---|---|
| 拉映像 | 220.2 | 42% |
| 下載權重 | 185.5 | 35% |
| 從磁碟載入權重 | 14.8 | 3% |
| init engine | 36.4 | 7% |
其他(排程、啟動容器、import torch 兩次、啟動 process、等 probe) |
66.1 | 13% |
五段相加是 523.0,跟 526.9 差 3.9 秒,那是各段之間沒有獨立時間戳的零頭。
拉映像加下載權重等於 405.7 秒,佔了 77%。 這兩段都是快取掉就沒了的東西,所以後面兩組設定處理的就是它們。
從容器印出第一行到真的開始載模型是 10.2 + 19.7 + 6.5 = 36.4 秒,而這段期間一個 byte 都沒搬。Automatically detected platform cuda 在裡面出現兩次,因為 EngineCore 是獨立的 process,整個 torch 要再 import 一遍。
Pulled Container image "vllm/vllm-openai:v0.11.0" already present on machine
Time spent downloading weights: 144.389314 seconds
Loading weights took 2.33 seconds
init engine ... took 50.33 seconds
省 272.0 秒,其中 220.2 是拉映像。
Loading weights 只花 2.33 秒,因為權重剛剛才下載完,還躺在檔案快取裡,等於從記憶體讀。
這一列的 torch.compile 是 31.68 秒,而其他沒命中快取的幾次是 18.77、19.92、20.31、21.09。這是一個離群值,原因不明,而它會讓下一列看起來省得比實際多。
Loading weights took 2.22 seconds
Compiling a graph for dynamic shape takes 14.54 s
init engine ... took 35.21 seconds
Time spent downloading weights 整行消失。第三列的 2.22 秒同樣來自檔案快取,上一次啟動剛讀過一遍。
真正從 PVC 上把 14.25 GiB 讀進來要多久,這一列量不到。
省了 167.1 秒,但這 167 要拆成兩塊看:
不用下載 144.4 秒
init engine 從 50.3 到 35.2 15.1 秒
─────
159.5 秒(其餘落在雜訊裡)
只有 144.4 秒是這一項的功勞。 另外那 15.1 秒大部分來自上一列那個離群的編譯,不是快取造成的。
編譯快取在 /root/.cache/vllm 底下,而 PVC 只掛在 /hf,所以那份東西一直待在容器裡。第三列的日誌:
Cache the graph for dynamic shape for later use
它在寫入,不在讀取。 寫完跟著容器消失,每次都重寫一遍。多掛一個 subPath:
volumeMounts:
- { name: cache, mountPath: /hf, subPath: hf }
- { name: cache, mountPath: /root/.cache/vllm, subPath: vllmcache }
- { name: shm, mountPath: /dev/shm }
套上去之後第一次是寫入,所以那一次跟第三列一樣沒有命中。要第二次重啟才量得到命中。 兩次:51.7 / 51.6。
Directly load the compiled graph(s) for dynamic shape fro…
判準是這一行,不是秒數。
| 第三列 | 第四列 | 省 | |
|---|---|---|---|
| init engine | 35.2 | 12.5 | 22.7 |
| 總時間 | 87.8 | 51.7 | 36.1 |
kubectl -n vllm exec deploy/vllm -- du -sh /root/.cache/vllm
5.9M /root/.cache/vllm
5.9 MB 換 22.7 秒,這是整組實驗裡最划算的一項。
加一個 --max-model-len=20480 讓快取鍵對不上,再起一次:
Compiling a graph for dynamic shape takes 14.70 s
init engine ... took 35.27 seconds
Directly load 不見了,Compiling a graph 回來,init engine 回到 35.3,也就是第三列的那個 35.2。那 22.7 秒確實來自命中。
而快取目錄從 5.9M 變 18M,多出兩份:前面先試過 16384,重驗的時候那份還在,所以換成沒用過的 20480。快取鍵是設定的雜湊,一種設定一份。
GPU 自己不會主動做事,是 CPU 一個一個把運算交辦過去。一次推論有幾百上千個這種交辦動作,每一個都有固定的溝通成本。錄 CUDA graph 就是把這一整串交辦動作錄成一個巨集,下次遇到一樣的事情,CPU 只要說「播放那個巨集」,GPU 就自己照順序跑完。而 vLLM 只錄注意力運算之間那幾段,注意力本身跑 eager。
錄的時候每個步驟的輸入大小是寫死在裡面的,所以 batch 16 錄的那份拿去跑 batch 17 對不上,得另外錄一份。vLLM 預設把幾十種可能的批次大小各錄一次,加起來 102 份,而每一份都要實際跑一遍才錄得下來,還要佔一塊記憶體存著。
這一列做的事就是叫它只錄 7 份,而 CUDA graph 還是在用,只是手上的巨集從 102 份變 7 份。代價是某個批次大小沒有對應的巨集時,那一次就沒東西可以播,而那會慢多少,這一列沒量。
vllm serve --help 只有 31 行,這個旗標要 --help=all 才看得到:
kubectl -n vllm exec deploy/vllm -- sh -c 'vllm serve --help=all 2>&1 | grep -in "cuda-graph" | head'
892: --cuda-graph-sizes CUDA_GRAPH_SIZES [CUDA_GRAPH_SIZES ...]
預設錄 102 個形狀:
Capturing CUDA graphs (mixed prefill-decode, PIECEWISE): 67/67 [00:03]
Capturing CUDA graphs (decode, FULL): 35/35 [00:01]
砍成七個。這是 nargs='+' 的旗標,YAML 裡每個值要獨立一行,不能寫成 1,2,4,8:
- --cuda-graph-sizes
- "1"
- "2"
- "4"
- "8"
- "16"
- "32"
- "64"
Graph capturing finished in 1 secs, took 0.11 GiB
init engine ... took 8.60 seconds
| 第四列 預設 102 個 | 第五列 七個 | |
|---|---|---|
| graph capture | 5 秒 | 1 秒 |
| graph 用掉的記憶體 | 0.59 GiB | 0.11 GiB |
| init engine | 12.5 | 8.6 |
| 總時間 | 51.7 | 41.8 |
總時間省了 9.9 秒,但 init engine 只省 3.9。差額落在雜訊裡,所以這一項記 4 秒,不是 10 秒。
完全冷啟動 526.9 秒,四項改動之後 41.8 秒,差 12.6 倍。
省最多的兩項和推論引擎無關:映像 220 秒、權重 144 秒,佔了七成。它們是 Kubernetes 層的事,解法也在 Kubernetes 層,映像留在節點上、權重留在 PVC 上。
引擎自己能省的在後面:編譯快取 22.7 秒,而那份快取只有 5.9 MB;CUDA graph 從 102 份砍到 7 份,再省 4 秒。
Reduce vLLM GPU cold start time on Kubernetes