iT邦幫忙

2026 iThome 鐵人賽

DAY 15
0
Kubernetes

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

【Day 15】Kubernetes 上的 vLLM:從 KV cache 到 prefill 與 decode

  • 分享至 

  • xImage
  •  

這個系列在 Day 2 時曾經講過 AI Infra 的基礎知識,包括為什麼需要 KV cache、prefill 和 decode 差在哪、vLLM 是拿來做什麼的。

今天實際在 Kubernetes 上起 vLLM,把那些概念搬到真的卡上,看它們各自變成什麼數字。

而第一個數字就很不客氣:服務起來、一個請求都還沒送,nvidia-smi 說 91.4%。


vLLM 是什麼

一個 LLM 的推論與服務引擎,最早在加州大學柏克萊分校的 Sky Computing Lab 做出來,現在是有兩千多個貢獻者的開源專案。

它做的事可以拆成兩半。

一半是把一張卡的記憶體管好。 招牌是 PagedAttention,把 KV cache 切成固定大小的 block 來管,另外還有 continuous batching、chunked prefill、prefix caching,以及 FP8、INT4、GPTQ、AWQ 那一整排量化選項。

另一半是把它變成一個服務。 直接吃 Hugging Face 上的模型,起來之後是一個 OpenAI 相容的 API server,支援串流輸出、多 LoRA、以及張量與管線等各種平行切法。

搬到 Kubernetes 上可以用 Helm chart,也可以交給 KServe、KubeRay、KubeAI 這些框架部署。今天用最原始的一條:自己寫一個 Deployment 跑它的官方映像。


實驗環境

沿用第一天那個真卡環境:k3s 兩節點,各一張 L40S(46068 MiB),卡走 DRA 交出去。

模型是 Qwen/Qwen2.5-7B-Instruct,映像 vllm/vllm-openai:v0.11.0。


一、把模型變成一個 HTTP endpoint

kubectl create ns vllm
namespace/vllm created

先把 claim 範本、放權重的 PVC 和 Service 建好:

kubectl apply -f - <<'EOF'
apiVersion: resource.k8s.io/v1
kind: ResourceClaimTemplate
metadata: { name: one-gpu, namespace: vllm }
spec:
  spec:
    devices:
      requests:
        - name: gpu
          exactly:
            deviceClassName: gpu.nvidia.com
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata: { name: hf-cache, namespace: vllm }
spec:
  accessModes: [ReadWriteOnce]
  resources:
    requests: { storage: 50Gi }
---
apiVersion: v1
kind: Service
metadata: { name: vllm, namespace: vllm }
spec:
  selector: { app: vllm }
  ports:
    - port: 8000
      targetPort: 8000
EOF
resourceclaimtemplate.resource.k8s.io/one-gpu created
persistentvolumeclaim/hf-cache created
service/vllm created

然後是 Deployment:

mkdir -p ~/vllm-lab && cat > ~/vllm-lab/deploy.yaml <<'YAML'
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:
      enableServiceLinks: false
      resourceClaims:
        - name: gpu
          resourceClaimTemplateName: one-gpu
      volumes:
        - name: hf
          persistentVolumeClaim: { claimName: hf-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 }
          ports:
            - containerPort: 8000
          volumeMounts:
            - { name: hf,  mountPath: /hf }
            - { name: shm, mountPath: /dev/shm }
          resources:
            claims: [{ name: gpu }]
          startupProbe:
            httpGet: { path: /health, port: 8000 }
            periodSeconds: 10
            failureThreshold: 60
          readinessProbe:
            httpGet: { path: /health, port: 8000 }
            periodSeconds: 10
          livenessProbe:
            httpGet: { path: /health, port: 8000 }
            periodSeconds: 10
YAML
echo ok
ok
kubectl apply -f ~/vllm-lab/deploy.yaml
deployment.apps/vllm created
kubectl -n vllm get pods
NAME                    READY   STATUS    RESTARTS   AGE
vllm-57bccdf88c-srvk7   1/1     Running   0          6m4s

看它落在哪、卡拿到了沒:

kubectl -n vllm get pods -o wide --no-headers; kubectl -n vllm get resourceclaims
vllm-57bccdf88c-srvk7   1/1   Running   0   6m50s   10.42.1.38   ip-172-31-30-5

NAME                              STATE                AGE
vllm-57bccdf88c-srvk7-gpu-7tkt7   allocated,reserved   6m50s

二、那 42 GB 是怎麼來的

kubectl -n vllm logs deploy/vllm | grep -iE "Model loading|KV cache|GPU blocks|Graph captur|non-default"
(APIServer pid=1) INFO 09-28 20:43:37 [utils.py:233] non-default args: {'model': 'Qwen/Qwen2.5-7B-Instruct'}
(EngineCore_DP0 pid=92) INFO 09-28 20:46:45 [gpu_model_runner.py:2653] Model loading took 14.2488 GiB and 166.346876 seconds
(EngineCore_DP0 pid=92) INFO 09-28 20:47:15 [gpu_worker.py:298] Available KV cache memory: 25.13 GiB
(EngineCore_DP0 pid=92) INFO 09-28 20:47:15 [kv_cache_utils.py:1087] GPU KV cache size: 470,624 tokens
(EngineCore_DP0 pid=92) INFO 09-28 20:47:21 [gpu_model_runner.py:3480] Graph capturing finished in 6 secs, took 0.59 GiB
(EngineCore_DP0 pid=92) INFO 09-28 20:47:21 [core.py:210] init engine (profile, create kv cache, warmup model) took 36.46 seconds

六行各自在說一件事:

這一行 在說什麼
non-default args 只列跟預設值不一樣的參數。yaml 裡那個 --gpu-memory-utilization=0.90 剛好就是預設值,所以沒出現在這裡
Model loading took 14.2488 GiB and 166.3 s 權重佔掉多少記憶體、載了多久
Available KV cache memory: 25.13 GiB 扣掉權重和其他開銷之後,還有多少切給 KV cache
GPU KV cache size: 470,624 tokens 那塊地換算成能裝幾個 token
Graph capturing ... took 0.59 GiB CUDA graph 另外吃掉的記憶體
init engine ... 36.46 seconds 量記憶體、建池子、暖機這三件事合起來的時間

同時間在那台節點上看:

nvidia-smi --query-gpu=memory.used,memory.total,utilization.gpu --format=csv
memory.used [MiB], memory.total [MiB], utilization.gpu [%]
42097 MiB, 46068 MiB, 0 %

91.4% 的記憶體,0% 的使用率,而且一個請求都還沒送。

帳拆得完整

42097 MiB 換算是 41.11 GiB。權重 14.25、KV cache 池 25.13、CUDA graph 0.59,加起來 39.97,剩下的 1.14 是 CUDA context。

Graph capturing ... took 0.59 GiB 那行不在 KV cache 的段落裡,很容易漏掉,少算它就會多出半個 GiB 對不起來。

單位是 token,不是 GB

池子的容量不是用 GB 報的,是用 token 報的。

25.13 GiB 除以 470,624,每個 token 大約 57.3 KB,這是這個模型每存一個 token 的 K 和 V 要付的空間。

而這個數字決定同時能服務幾個請求:每個在跑的請求都佔著池子裡的一段,佔多少看它的 prompt 加上已經產出的長度。


三、取數的方法

底下的數字都來自 vLLM 的 /metrics,取兩次快照相減:

cat > ~/vllm-lab/snap.sh <<'EOF'
#!/bin/sh
kubectl -n vllm exec deploy/vllm -- python3 -c "
import urllib.request
t = urllib.request.urlopen('http://localhost:8000/metrics').read().decode()
want = ['num_requests_running','num_requests_waiting','kv_cache_usage_perc',
        'prompt_tokens_total','generation_tokens_total',
        'prefix_cache_queries_total','prefix_cache_hits_total',
        'e2e_request_latency_seconds_count','e2e_request_latency_seconds_sum',
        'time_to_first_token_seconds_sum',
        'time_per_output_token_seconds_count','time_per_output_token_seconds_sum',
        'request_prefill_time_seconds_sum','request_decode_time_seconds_sum',
        'request_queue_time_seconds_sum']
for w in want:
    for line in t.splitlines():
        if line.startswith('vllm:' + w + '{'):
            print('%-42s %s' % (w, line.split()[-1])); break
"
EOF
chmod +x ~/vllm-lab/snap.sh && echo ok
ok

第一次快照,服務已經起來,但還沒有人用它:

~/vllm-lab/snap.sh
num_requests_running                       0.0
num_requests_waiting                       0.0
kv_cache_usage_perc                        0.0
prompt_tokens_total                        0.0
generation_tokens_total                    0.0
prefix_cache_queries_total                 0.0
prefix_cache_hits_total                    0.0
e2e_request_latency_seconds_count          0.0
e2e_request_latency_seconds_sum            0.0
time_to_first_token_seconds_sum            0.0
time_per_output_token_seconds_count        0.0
time_per_output_token_seconds_sum          0.0
request_prefill_time_seconds_sum           0.0
request_decode_time_seconds_sum            0.0
request_queue_time_seconds_sum             0.0

nvidia-smi 說 42 GB,kv_cache_usage_perc 說 0。

那不是「用掉了」,是「準備好了」。 換句話說,記憶體用量在推論服務上不是負載指標,它反映的是你的啟動參數。


四、第一個請求

開一個 Pod 用 curl 打那個 endpoint,確認它真的會回話。然後再取一次快照,看這一個請求在計數器上留下什麼。

kubectl -n vllm run q1 --restart=Never --image=curlimages/curl:8.11.1 -- \
  curl -s http://vllm.vllm.svc.cluster.local:8000/v1/completions \
  -H 'Content-Type: application/json' \
  -d '{"model":"Qwen/Qwen2.5-7B-Instruct","prompt":"Kubernetes is","max_tokens":128,"temperature":0}'
pod/q1 created
sleep 15 && kubectl -n vllm logs q1 && kubectl -n vllm delete pod q1

原始輸出是擠成一行的 JSON,排版之後長這樣:

{
  "id": "cmpl-34ac6be45a7940b587ce55d8962e989e",
  "object": "text_completion",
  "created": 1790659573,
  "model": "Qwen/Qwen2.5-7B-Instruct",
  "choices": [
    {
      "index": 0,
      "text": " a popular open-source container orchestration platform that automates the deployment, scaling, and management of containerized applications. It provides a robust and scalable solution for managing containerized workloads and services, with built-in features such as self-healing, load balancing, and automatic rolling updates.\n\nHere are some key concepts and components in Kubernetes:\n\n1. Pods: A pod is the smallest deployable unit in Kubernetes, representing a collection of containers that share storage and network resources. Each pod has its own IP address and can be considered as a single entity.\n\n2. Nodes: Nodes are the worker machines in a Kubernetes cluster where the actual containers run",
      "logprobs": null,
      "finish_reason": "length",
      "stop_reason": null,
      "token_ids": null,
      "prompt_logprobs": null,
      "prompt_token_ids": null
    }
  ],
  "service_tier": null,
  "system_fingerprint": null,
  "usage": {
    "prompt_tokens": 3,
    "total_tokens": 131,
    "completion_tokens": 128,
    "prompt_tokens_details": null
  },
  "kv_transfer_params": null
}
pod "q1" deleted from vllm namespace

prompt_tokens: 3、completion_tokens: 128,而 finish_reason 是 length:請求裡的 max_tokens: 128 是上限,模型產到第 128 個就被喊停,所以最後一句斷在半路。

再取一次快照:

~/vllm-lab/snap.sh
num_requests_running                       0.0
num_requests_waiting                       0.0
kv_cache_usage_perc                        0.0
prompt_tokens_total                        3.0
generation_tokens_total                    128.0
prefix_cache_queries_total                 3.0
prefix_cache_hits_total                    0.0
e2e_request_latency_seconds_count          1.0
e2e_request_latency_seconds_sum            2.6776700019836426
time_to_first_token_seconds_sum            0.031028032302856445
time_per_output_token_seconds_count        127.0
time_per_output_token_seconds_sum          2.6469696029962506
request_prefill_time_seconds_sum           0.029417270998237655
request_decode_time_seconds_sum            2.6469696029962506
request_queue_time_seconds_sum             0.00010083300003316253

一個請求就能把整條時間線拆開:

算法 值
TTFT 0.031028 31.0 ms
prefill 0.029417 29.4 ms
decode 2.646970 2647.0 ms
E2E 2.677670 2677.7 ms
TPOT 2.646970 ÷ 127 20.84 ms
prefill 佔 E2E 29.4 ÷ 2677.7 1.10%

三個字先講清楚:TTFT 是送出請求到看到第一個字的時間,TPOT 是之後平均每產一個字要多久,E2E 是整個請求從頭到尾。

這個請求的 2.68 秒裡,有 2.65 秒在 decode,prefill 只佔 1.10%。一個字一個字吐出來,才是時間真正花掉的地方。


五、prefill 和 decode

同一個服務,只換請求的形狀,送兩個極端。

A 是長 prompt、短輸出,1602 個字進去只要 8 個字出來,逼 prefill 做滿。B 反過來,短 prompt、長輸出,3 個字進去要 512 個字出來,幾乎全是 decode。

兩邊各取一次快照,就能看出這兩段在時間上差多少。

A:長 prompt、短輸出

kubectl -n vllm run qa --restart=Never --image=curlimages/curl:8.11.1 -- sh -c '
P=$(awk "BEGIN{for(i=0;i<200;i++) printf \"Kubernetes is a container orchestration system. \"}")
curl -s -o /dev/null -w "%{time_total}s\n" http://vllm.vllm.svc.cluster.local:8000/v1/completions \
  -H "Content-Type: application/json" \
  -d "{\"model\":\"Qwen/Qwen2.5-7B-Instruct\",\"prompt\":\"$P\",\"max_tokens\":8,\"temperature\":0}"'
pod/qa created
sleep 10 && ~/vllm-lab/snap.sh
num_requests_running                       0.0
num_requests_waiting                       0.0
kv_cache_usage_perc                        0.0
prompt_tokens_total                        1605.0
generation_tokens_total                    136.0
prefix_cache_queries_total                 1605.0
prefix_cache_hits_total                    0.0
e2e_request_latency_seconds_count          2.0
e2e_request_latency_seconds_sum            2.941380262374878
time_to_first_token_seconds_sum            0.14685964584350586
time_per_output_token_seconds_count        134.0
time_per_output_token_seconds_sum          2.794951678995858
request_prefill_time_seconds_sum           0.14350886300962884
request_queue_time_seconds_sum             0.00016567499551456422

這些都是累計值,減掉上一次快照才是這個請求:

減法 值
prompt 1605 − 3 1602 tok
輸出 136 − 128 8 tok
TTFT 0.146860 − 0.031028 115.8 ms
prefill 0.143509 − 0.029417 114.1 ms
decode 2.794952 − 2.646970 148.0 ms
E2E 2.941380 − 2.677670 263.7 ms
TPOT 0.147982 ÷ (134 − 127) 21.14 ms
prefill 佔 E2E 114.1 ÷ 263.7 43.26%

B:短 prompt、長輸出

kubectl -n vllm run qb --restart=Never --image=curlimages/curl:8.11.1 -- \
  curl -s -o /dev/null -w "%{time_total}s\n" http://vllm.vllm.svc.cluster.local:8000/v1/completions \
  -H 'Content-Type: application/json' \
  -d '{"model":"Qwen/Qwen2.5-7B-Instruct","prompt":"Kubernetes is","max_tokens":512,"temperature":0}'
pod/qb created
sleep 25 && ~/vllm-lab/snap.sh
prompt_tokens_total                        1608.0
generation_tokens_total                    648.0
prefix_cache_queries_total                 1608.0
prefix_cache_hits_total                    0.0
e2e_request_latency_seconds_count          3.0
e2e_request_latency_seconds_sum            13.604886054992676
time_to_first_token_seconds_sum            0.17076444625854492
time_per_output_token_seconds_count        645.0
time_per_output_token_seconds_sum          13.434589963988401
request_decode_time_seconds_sum            13.434589963988401
request_prefill_time_seconds_sum           0.1661976370087359
request_queue_time_seconds_sum             0.00024336799106094986
減法 值
prompt 1608 − 1605 3 tok
輸出 648 − 136 512 tok
TTFT 0.170764 − 0.146860 23.9 ms
prefill 0.166198 − 0.143509 22.7 ms
decode 13.434590 − 2.794952 10639.6 ms
E2E 13.604886 − 2.941380 10663.5 ms
TPOT 10.639638 ÷ (645 − 134) 20.82 ms
prefill 佔 E2E 22.7 ÷ 10663.5 0.21%

三個請求擺在一起

prompt 輸出 TTFT prefill decode E2E TPOT prefill 佔 E2E
基準 3 128 31.0 ms 29.4 ms 2647.0 ms 2677.7 ms 20.84 ms 1.10%
A 長進短出 1602 8 115.8 ms 114.1 ms 148.0 ms 263.7 ms 21.14 ms 43.26%
B 短進長出 3 512 23.9 ms 22.7 ms 10639.6 ms 10663.5 ms 20.82 ms 0.21%

prefill 佔整個請求的比例,從 43.26% 到 0.21%,同一張卡、同一個模型,差 200 倍。所謂「這個服務慢」,慢在哪一段完全看你的流量長什麼樣。

一、TPOT 三次幾乎一樣:20.84、21.14、20.82 毫秒。 decode 每產一個 token 的成本是固定的,prompt 一千六百個 token 和三個 token,吐字速度一樣。

二、prefill 有一段固定開銷。 3 個 token 花 22.7 毫秒,1602 個 token 花 114.1 毫秒。兩點連線,固定成本大約 23 毫秒,之後每個 prompt token 大約 0.057 毫秒。

所以短 prompt 的 prefill 幾乎全是固定開銷,長 prompt 才開始付內容的錢。


六、prefix cache 只重用得到兩塊

到目前為止 prefix_cache_hits_total 一直是 0,因為前面每個 prompt 都不一樣。現在把同一個 prompt 送三次。

先記下基準:

~/vllm-lab/snap.sh
prefix_cache_queries_total                 1608.0
prefix_cache_hits_total                    0.0
kubectl -n vllm run p3 --restart=Never --image=curlimages/curl:8.11.1 -- sh -c '
for i in 1 2 3; do
  curl -s -o /dev/null http://vllm.vllm.svc.cluster.local:8000/v1/completions \
    -H "Content-Type: application/json" \
    -d "{\"model\":\"Qwen/Qwen2.5-7B-Instruct\",\"prompt\":\"The history of Kubernetes began at Google with an internal system called Borg, which managed containerized workloads at massive scale for over a decade before Kubernetes was open sourced in 2014. Borg taught Google a great deal about scheduling.\",\"max_tokens\":16,\"temperature\":0}"
  echo sent $i
done'
pod/p3 created
sleep 15 && ~/vllm-lab/snap.sh && kubectl -n vllm logs p3 && kubectl -n vllm delete pod p3
prompt_tokens_total                        1752.0
generation_tokens_total                    696.0
prefix_cache_queries_total                 1752.0
prefix_cache_hits_total                    64.0
e2e_request_latency_seconds_count          6.0
...
sent 1
sent 2
sent 3
pod "p3" deleted from vllm namespace
減法 值
queries 1752 − 1608 144
hits 64 − 0 64
命中率 64 ÷ 144 44.4%

144 就是 48 個 token 送三次。而 44.4% 這個數字可以整除。

block 大小是 16,prompt 48 個 token 剛好三塊:

第幾次 命中
第一次 0
第二次 32(兩塊)
第三次 32(兩塊)
合計 64 / 144

最後一塊永遠不命中,因為它還在被寫入。 只有寫滿的 block 才會被重用,所以三塊只重用得到兩塊。

queries 的口徑是每個 prompt token 算一次,不是每個請求算一次。證據是它跟 prompt_tokens_total 完全同步,兩邊都是 1752。

而 prefix caching 預設就開著,不需要加任何旗標。這次從頭到尾沒有寫 --enable-prefix-caching,它一樣在算命中。

這次是循序送的,第二次進來時快取已經填好;如果三個請求同時送出去,命中率會是另一個數字。


七、只改一個數字

kubectl -n vllm patch deploy vllm --type=json \
  -p '[{"op":"replace","path":"/spec/template/spec/containers/0/args/1","value":"--gpu-memory-utilization=0.50"}]'
deployment.apps/vllm patched

這裡要等新的 Pod 真的起來再看日誌,不然讀到的還是舊 Pod 的 25.13 GiB:

kubectl -n vllm rollout status deploy/vllm --timeout=15m
Waiting for deployment "vllm" rollout to finish: 0 of 1 updated replicas are available...
deployment "vllm" successfully rolled out
kubectl -n vllm logs deploy/vllm | grep -iE "Model loading|KV cache|GPU blocks|non-default"
(APIServer pid=1) INFO 09-28 22:50:42 [utils.py:233] non-default args: {'model': 'Qwen/Qwen2.5-7B-Instruct', 'gpu_memory_utilization': 0.5}
(EngineCore_DP0 pid=91) INFO 09-28 22:51:05 [gpu_model_runner.py:2653] Model loading took 14.2488 GiB and 3.195686 seconds
(EngineCore_DP0 pid=91) INFO 09-28 22:51:34 [gpu_worker.py:298] Available KV cache memory: 7.38 GiB
(EngineCore_DP0 pid=91) INFO 09-28 22:51:35 [kv_cache_utils.py:1087] GPU KV cache size: 138,128 tokens
(EngineCore_DP0 pid=91) INFO 09-28 22:51:40 [core.py:210] init engine took 35.61 seconds

non-default args 這次列出了 gpu_memory_utilization: 0.5。參數有沒有吃到,這一行就看得出來。

Model loading 從 166.3 秒變成 3.2 秒,因為權重已經在 PVC 裡了。這筆帳最後一節再算。

nvidia-smi --query-gpu=memory.used,memory.total --format=csv
memory.used [MiB], memory.total [MiB]
23897 MiB, 46068 MiB

51.9%,不是 50%。

block 數對得上

kubectl -n vllm exec deploy/vllm -- python3 -c "
import urllib.request
t=urllib.request.urlopen('http://localhost:8000/metrics').read().decode()
for l in t.splitlines():
    if l.startswith('vllm:cache_config_info'): print(l)
"
vllm:cache_config_info{block_size="16",cache_dtype="auto",...,enable_prefix_caching="True",
engine="0",gpu_memory_utilization="0.5",...,num_gpu_blocks="8633",...,
prefix_caching_hash_algo="sha256",...} 1.0

這一行同時證明三件事:block 大小是 16、prefix caching 是 True、池子有 8633 塊。

而 8633 × 16 = 138,128,跟日誌裡的 token 數一模一樣。回頭套 0.90 那次,470,624 ÷ 16 = 29,414 塊。

兩次擺在一起

KV cache tokens blocks 預算 實際 超出
0.90 25.13 GiB 470,624 29414 41461 MiB 42097 MiB +636 MiB(91.4%)
0.50 7.38 GiB 138,128 8633 23034 MiB 23897 MiB +863 MiB(51.9%)

三件事對得上:

權重完全沒變(兩次都是 14.2488 GiB),縮掉的全是池子。

num_gpu_blocks × block_size 等於 token 數。

KV cache 的比例是 3.41 倍(25.13 ÷ 7.38),token 數的比例也是 3.41 倍(470624 ÷ 138128)。

但兩次都超出預算。gpu_memory_utilization 是 vLLM 給自己那個池子的預算,CUDA context 坐在預算外面。

所以如果在同一張卡上開兩個 process、各設 0.45,加起來不會剛好是 0.90:每個 process 都會在自己的預算之外再帶一份 context。


八、池子被用起來的樣子

池子現在是 0.50 那版的 138,128 tokens。丟 16 個並行的長請求進去:

kubectl -n vllm run load --restart=Never --image=curlimages/curl:8.11.1 -- sh -c '
for i in $(seq 1 16); do
  curl -s -o /dev/null http://vllm.vllm.svc.cluster.local:8000/v1/completions \
    -H "Content-Type: application/json" \
    -d "{\"model\":\"Qwen/Qwen2.5-7B-Instruct\",\"prompt\":\"Write a long detailed essay about distributed systems, request $i.\",\"max_tokens\":2048,\"temperature\":0.7}" &
done
wait
echo ALLDONE'
pod/load created

一邊跑一邊輪詢:

for i in 1 2 3 4 5 6 7 8; do
  printf '%s  ' "$(date +%T)"
  ~/vllm-lab/snap.sh | grep -E "kv_cache_usage_perc|num_requests_running|num_requests_waiting" | tr '\n' ' '
  echo
  sleep 4
done
06:07:30  running 16.0  waiting 0.0  kv_cache_usage_perc 0.009267840593141785
06:07:35  running 16.0  waiting 0.0  kv_cache_usage_perc 0.033364226135310426
06:07:40  running 16.0  waiting 0.0  kv_cache_usage_perc 0.053753475440222465
06:07:44  running 16.0  waiting 0.0  kv_cache_usage_perc 0.07599629286376275
06:07:49  running 16.0  waiting 0.0  kv_cache_usage_perc 0.09638554216867468
06:07:53  running 14.0  waiting 0.0  kv_cache_usage_perc 0.10055607043558856
06:07:57  running 10.0  waiting 0.0  kv_cache_usage_perc 0.08456904541241894
06:08:02  running  9.0  waiting 0.0  kv_cache_usage_perc 0.08758109360518995

kv_cache_usage_perc 是 0 到 1 的比例,不是百分比。乘上池子容量就是實際用掉幾個 token:

時間 比例 約等於幾個 token
06:07:30 0.93% 1,280
06:07:35 3.34% 4,609
06:07:40 5.38% 7,425
06:07:44 7.60% 10,497
06:07:49 9.64% 13,314
06:07:53 10.06% 13,890(峰值)

這條線就是 decode 的實體形狀。 每產一個 token 就往池子裡存一份 KV,所以用量是隨時間線性爬上去的,不是一開始就配好一整塊。

峰值只有 10.06%,num_requests_waiting 全程是 0。16 個並行完全沒讓池子吃緊,限制這台機器的不是 KV cache。

跑完之後:

sleep 30 && kubectl -n vllm logs load && ~/vllm-lab/snap.sh
ALLDONE
num_requests_running                       0.0
num_requests_waiting                       0.0
kv_cache_usage_perc                        0.0
prompt_tokens_total                        215.0
generation_tokens_total                    24319.0
prefix_cache_queries_total                 215.0
prefix_cache_hits_total                    0.0
e2e_request_latency_seconds_count          16.0
e2e_request_latency_seconds_sum            595.6802620887756
time_to_first_token_seconds_sum            0.983177661895752
time_per_output_token_seconds_count        24303.0
time_per_output_token_seconds_sum          594.7074836750398
request_prefill_time_seconds_sum           0.5459433619398624
request_decode_time_seconds_sum            594.7074836750398
request_queue_time_seconds_sum             0.0006960180762689561

(這是換過池子之後重啟的服務,所以計數器從零開始。)

使用率掉回 0.00%,而 nvidia-smi 的 23897 MiB 一動也不動。池子還在那裡,只是空了。

順手量到的 continuous batching

減法 值
16 個請求平均 E2E 595.680 ÷ 16 37.2 s
平均 TTFT 0.983178 ÷ 16 61.4 ms
平均 prefill 0.545943 ÷ 16 34.1 ms
平均排隊 0.000696 ÷ 16 0.044 ms
TPOT 594.707484 ÷ 24303 24.47 ms

跟單一請求那次比:

單一請求 16 並行
TPOT 20.82 ms 24.47 ms
吞吐(估) 48 tok/s 約 347 tok/s

一個人用的時候每個 token 要 20.82 毫秒,16 個人同時用變成 24.47 毫秒。人多了 16 倍,每個人的吐字速度只慢 18%。

之所以可以這樣,是因為 decode 卡在記憶體頻寬上:每產一個 token 都要把整個模型的權重讀過一遍,而讀進來之後,batch 裡有幾個請求就一起算幾個。多塞人進去幾乎不用另外付錢,直到頻寬或池子被吃滿為止。

吞吐那個 347 是拿 24,319 個 token 除以大約 70 秒的牆鐘時間估的,不是精確量測,所以硬數字是 TPOT 那一列。

這 16 個請求的 prefix_cache_hits 是 0,因為每個 prompt 都帶了不同的編號,是故意做成不同的。


九、拿掉 startupProbe

那份 Deployment 給了 startupProbe 十分鐘的耐心。真的需要嗎?把那五行拿掉就知道。

kubectl -n vllm patch deploy vllm --type=json \
  -p '[{"op":"replace","path":"/spec/template/spec/containers/0/args/1","value":"--gpu-memory-utilization=0.90"}]'
kubectl -n vllm rollout status deploy/vllm --timeout=15m
deployment.apps/vllm patched
Waiting for deployment "vllm" rollout to finish: 0 of 1 updated replicas are available...
deployment "vllm" successfully rolled out
kubectl -n vllm patch deploy vllm --type=json \
  -p '[{"op":"remove","path":"/spec/template/spec/containers/0/startupProbe"}]'
deployment.apps/vllm patched

livenessProbe 和 readinessProbe 都留著。

kubectl -n vllm get pods -w
NAME                    READY   STATUS             RESTARTS     AGE
vllm-5b5d74d756-4blln   0/1     Running            0            15s
vllm-5b5d74d756-4blln   0/1     Running            1 (1s ago)   61s
vllm-5b5d74d756-4blln   0/1     Running            2 (1s ago)   2m1s
vllm-5b5d74d756-4blln   0/1     Running            3 (1s ago)   3m1s
vllm-5b5d74d756-4blln   0/1     Running            4 (1s ago)   4m1s
vllm-5b5d74d756-4blln   0/1     Running            5 (1s ago)   5m1s
vllm-5b5d74d756-4blln   0/1     CrashLoopBackOff   5 (1s ago)   6m1s

每 60 秒被殺一次,六次之後進 CrashLoopBackOff。

log 裡什麼都沒有

kubectl -n vllm logs deploy/vllm --previous --tail=30 | tail -20
(EngineCore_DP0 pid=91) INFO 09-28 23:20:05 [gpu_model_runner.py:2602] Starting to load model Qwen/Qwen2.5-7B-Instruct...
(EngineCore_DP0 pid=91) INFO 09-28 23:20:05 [cuda.py:366] Using Flash Attention backend on V1 engine.
Loading safetensors checkpoint shards: 100% Completed | 4/4 [00:02<00:00,  1.89it/s]
(EngineCore_DP0 pid=91) INFO 09-28 23:20:08 [default_loader.py:267] Loading weights took 2.23 seconds
(EngineCore_DP0 pid=91) INFO 09-28 23:20:09 [gpu_model_runner.py:2653] Model loading took 14.2488 GiB and 3.178745 seconds
(EngineCore_DP0 pid=91) INFO 09-28 23:20:13 [backends.py:559] Dynamo bytecode transform time: 4.02 s
(EngineCore_DP0 pid=91) INFO 09-28 23:20:28 [backends.py:218] Compiling a graph for dynamic shape takes 14.77 s

日誌就斷在這裡,最後一行是一句完全正常的 INFO。沒有 traceback,沒有錯誤,什麼都沒有。

常見的說法是這種情況容器 log 會留下 KeyboardInterrupt: terminated,這次沒有。連一個像錯誤的東西都沒有,只是斷在半路。

events 才是真相

kubectl -n vllm describe pod -l app=vllm | sed -n '/Events:/,$p' | tail -15
Events:
  Type     Reason     Age                     From               Message
  Normal   Scheduled  7m15s                   default-scheduler  Successfully assigned vllm/vllm-5b5d74d756-4blln to ip-172-31-30-5
  Warning  Unhealthy  5m5s (x7 over 7m5s)     kubelet            Liveness probe failed: Get "http://10.42.1.46:8000/health": dial tcp: connect: connection refused
  Normal   Pulled     2m15s (x6 over 7m15s)   kubelet            Container image "vllm/vllm-openai:v0.11.0" already present on machine
  Normal   Created    2m14s (x6 over 7m15s)   kubelet            Container created
  Normal   Started    2m14s (x6 over 7m14s)   kubelet            Container started
  Warning  Unhealthy  2m14s (x41 over 7m14s)  kubelet            Readiness probe failed: Get "http://10.42.1.46:8000/health": dial tcp: connect: connection refused
  Normal   Killing    105s (x6 over 6m45s)    kubelet            Container vllm failed liveness probe, will be restarted

readiness 失敗 41 次,一次都沒殺它;liveness 失敗 7 次,殺了 6 次。 兩個探針的差別,這組數字講完了。一個只是把 Pod 從 Service 的端點名單上拿掉,另一個會動手。

kubectl -n vllm get pods -o custom-columns='NAME:.metadata.name,RESTARTS:.status.containerStatuses[0].restartCount,LASTSTATE:.status.containerStatuses[0].lastState.terminated.reason,EXIT:.status.containerStatuses[0].lastState.terminated.exitCode'
NAME                    RESTARTS   LASTSTATE   EXIT
vllm-5b5d74d756-4blln   6          Error       137

137 等於 128 加 9,也就是 SIGKILL。

時間到底花在哪

這一次模型是熱的,權重已經在 PVC 裡,載入只花 3.18 秒。但它還是被殺了六次。

秒
載權重(熱,PVC 快取) 3.2
Dynamo bytecode transform 4.0
編譯動態形狀的圖 14.8
profile、建 KV cache、warmup 36.5
合計 約 58.5
livenessProbe 出手 約 60

兇手是 torch.compile 加上 warmup,不是讀檔案。

這也推翻了「模型大所以載得慢」那個直覺:權重快取進 PVC 之後只要 3 秒,但那救不了它。

把那五行補回去:

kubectl apply -f ~/vllm-lab/deploy.yaml && kubectl -n vllm rollout status deploy/vllm --timeout=15m
deployment.apps/vllm configured
Waiting for deployment "vllm" rollout to finish: 0 out of 1 new replicas have been updated...
deployment "vllm" successfully rolled out

同一份 YAML,只差那五行 startupProbe。

kubectl delete ns vllm
namespace "vllm" deleted

順手量到的冷啟動

同一行日誌,兩次跑出來差 52 倍:

第一次   Model loading took 14.2488 GiB and 166.346876 seconds
第二次   Model loading took 14.2488 GiB and   3.195686 seconds

第一次要從 Hugging Face 下載,第二次 PVC 裡已經有了。而這只是 Model loading 那一段,還不含拉 11.6 GB 的映像。冷啟動那天再回來算這筆帳。


小結

今天在 Kubernetes 上跑了一個 vLLM,把 Day 2 講過的東西實際看了一遍。

KV cache 是開機當下就圈好的一塊地,大小由 gpu-memory-utilization 決定,容量用 token 算;prefill 和 decode 在同一個請求裡各花多少時間,/metrics 分開記帳,而且兩者的成本結構完全不一樣。

至於 Kubernetes,它看到的是一個 Pod、一張卡、一個健康檢查回 200。


參考資料

vLLM Documentation
vLLM: Using Kubernetes
vLLM: Production Metrics
vLLM: Metrics 設計文件
vLLM: Automatic Prefix Caching
vLLM: Automatic Prefix Caching 設計文件
vLLM: Easy, Fast, and Cheap LLM Serving with PagedAttention
Efficient Memory Management for Large Language Model Serving with PagedAttention
Kubernetes: Configure Liveness, Readiness and Startup Probes


上一篇
【Day 14】GPU 高效能網路:從節點內到跨節點,再到 Kubernetes
下一篇
【Day 16】vLLM 的引擎優化:量化、PagedAttention、推測解碼
系列文
凌晨四點,女友帶著 GPU 來我家學習 Kubernetes:打造 K8s AI Infra 的 30 夜 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言