iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0
Kubernetes

凌晨四點,女友帶著 GPU 來我家學習 Kubernetes:打造 K8s AI Infra 的 30 夜系列 第 18 篇

【Day 18】都快下班了,服務怎麼還沒跑起來:Kubernetes 上 vLLM 的冷啟動與優化

  • 分享至 

  • xImage
  •  

Day15 的最後我們觀察到:同一份權重第一次載要 166 秒,第二次只要 3 秒,差 52 倍,而差別只在權重有沒有留在 PVC 上。

今天會走一次完整的冷啟動流程,看一個 Pod 從 kubectl apply 到能發送請求之前經歷了什麼、各階段耗時多久,以及這段過程可以怎麼優化。


冷啟動

冷啟動指的是從一個推論服務被啟動,到它能回應第一個請求,中間經過的那段時間。

對 vLLM 來說,這段時間花在兩件事上。一是把權重搬進卡裡,十幾 GB 的檔案平常不在機器上,得先下載再載入記憶體。二是編譯與錄製 CUDA graph,vLLM 會先把模型的運算編譯過一遍,再把其中幾段錄成 CUDA graph,讓之後每一次推論不必重新把指令一條條送給 GPU。編譯的產物可以快取,命中的時候整段會被跳過。


上 Kubernetes 之後多了什麼

把 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 把權重丟掉,只留映像。從第三列開始才真的什麼都不刪,一路往上疊。

完整的 YAML

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 和清檔案快取都只對本機有效,所以完全冷啟動只有那一台造得出來,而五列要互相比較就必須全部在同一台。

總時間:apply 到 Pod 變 Ready

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、服務起來。

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

幾件要說清楚的

  • 只有第一列清了作業系統的檔案快取,其餘四列沒有清。那四列是同一台機器重啟一個 Pod,而真實情況下檔案快取是熱的。
  • 第一列只量一次,因為重拉 11.6 GiB 要二十分鐘;第三、四列各量兩到三次。
  • 總時間有正負 10 秒的雜訊,來源是「啟動 EngineCore 這個 process」那一段,六次量下來在 5.4 到 19.7 秒之間跳。所以差 15 秒以內的比較要看 init engine,不要看總時間。

一、完全冷啟動:526.9 秒

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 一遍。


二、映像已在節點:254.9 秒

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。這是一個離群值,原因不明,而它會讓下一列看起來省得比實際多。


三、權重已在 PVC:87.8 秒

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 秒大部分來自上一列那個離群的編譯,不是快取造成的。


四、編譯快取也在 PVC 上:51.7 秒

編譯快取在 /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。快取鍵是設定的雜湊,一種設定一份。


五、把 CUDA graph 從 102 份砍到 7 份:41.8 秒

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


上一篇
【Day 17】從 vLLM 遷移到 SGLang:RadixAttention 怎麼運作的
下一篇
【Day 19】Kubernetes LeaderWorkerSet 與 DisaggregatedSet
系列文
凌晨四點,女友帶著 GPU 來我家學習 Kubernetes:打造 K8s AI Infra 的 30 夜 共 19 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言