iT邦幫忙

2026 iThome 鐵人賽

DAY 22
0
Kubernetes

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

【Day 22】Kubernetes 上的 PD 分離與 KV 傳輸

  • 分享至 

  • xImage
  •  

Day 19 我們用 DisaggregatedSet 宣告了一組 prefill 和一組 decode。但那天跑在沒有 GPU 的 kind 上,Pod 裡面是假的工作負載,從頭到尾沒有真的推論。

今天把真的 vLLM 放進去,看拆開之後引擎裡面發生什麼事。


PD 分離

兩個階段卡在不同的地方

prefill 處理的是一段新的序列,通常有很多 token,而且它把這些 token 同時算,所以受算力限制。

decode 每一步只產生一個新的 token,但它的 I/O 量跟 prefill 差不多,所以受 GPU 記憶體頻寬限制。

兩個階段在意的指標也不一樣:

階段 指標 指標的意思
prefill TTFT 第一個 token 多久出來
decode TPOT 除了第一個 token 以外,平均產生一個 token 要多久

TPOT 也叫 ITL,後面都用 ITL。vLLM 的指標裡 vllm:inter_token_latency_seconds 量的就是它。另外有一個名字很像的 vllm:request_time_per_output_token_seconds,那是每個請求的平均,不是每個間隔,抓錯會拿到不同的東西。

擠在一起會互相拖

同一個批次裡,decode 工作必須等比較長的 prefill 做完,所以 TPOT 被拉長,而 prefill 越長拖得越久。

反過來也成立。把 decode 工作加進 prefill 的批次,會拉長 prefill 的完成時間,GPU 已經滿載的時候特別明顯。

分開之後得到什麼

放到不同的實例上有兩個好處。一是 TTFT 和 ITL 可以分開調,兩個階段各自用不同的並行策略,不會拖累對方。二是 prefill 不會打斷 decode。另一種做法是 chunked prefill,把長的 prefill 切成小塊混進 decode 的批次,讓每一次打斷都短一點,但切多大要自己調,而分開之後不會有打斷這件事。

DistServe 那篇論文報出來的結果是:在同樣的延遲限制下能服務 7.4 倍的請求,或者在延遲限制收緊 12.6 倍的情況下還守得住,九成以上的請求都在限制內。

分離不會讓吞吐變好

分離沒有少算任何東西,prefill 還是要算一次、decode 還是要跑一次,只是放到不同的機器上,中間還多一趟搬運。

在 Kubernetes 上誰負責什麼

Day 19 查過 DisaggregatedSet 的 spec 只有三個欄位:roles、slices、placementPolicy。它負責把 prefill 和 decode 兩組 Pod 照宣告的形狀部署出來,每個 role 對應一個 LeaderWorkerSet,後面簡稱 LWS。

但「一個請求要先送去 prefill、拿到 KV 之後再送去 decode」這件事不在它的職責裡。

要做的事 誰負責
宣告有哪些 role、各自什麼形狀、複製幾份 DisaggregatedSet
prefill 算完的 KV 怎麼到 decode 手上 KVConnector
請求先去哪、再去哪 另外擺一個 proxy,或者用帶路由的編排層

KV 傳輸

為什麼分開之後多了這一步

prefill 算出每個 token 的 key 和 value,存在 GPU 記憶體裡,那就是 KV cache。而 decode 每吐一個 token,都要回頭看前面所有 token 的 KV。

所以 KV cache 是 prefill 的產物,同時是 decode 的必要輸入。

不分離的時候兩個階段在同一張卡上跑,KV 本來就躺在那張卡的記憶體裡,decode 直接拿來用。分離之後 prefill 在一張卡、decode 在另一張卡,而兩張卡的記憶體不會自己互通,所以必須有人把那份 KV 搬過去。這一趟就是 KV 傳輸。

一個 token 的 KV 有多大

從兩邊算,算出來要一樣才對。

從今天啟動日誌裡的 KV pool 推:Available KV cache memory: 25.13 GiB、GPU KV cache size: 470,624 tokens,除下來一個 token 是 57,335 bytes,也就是 55.99 KiB。

從模型架構算:Qwen2.5-7B 是 28 層、4 個 KV head、head_dim 128,KV cache 用 bf16。

2 (K 和 V) × 28 層 × 4 個 KV head × 128 × 2 bytes(bf16) = 57,344 bytes = 56 KiB

推到不同長度的 prompt:

prompt 長度 要搬的 KV
512 28 MiB
2,048 112 MiB
4,096 224 MiB
20,480 1.1 GiB

誰負責搬

搬 KV 這件事 vLLM 自己不做,交給一個叫 KVConnector 的元件,換一個 connector 就換一條搬運路線。設定寫在 --kv-transfer-config 裡,這天用到三個欄位:

欄位 內容
kv_connector 用哪一個 connector
kv_role 這個實例的角色
kv_connector_extra_config 該 connector 自己的設定

可以選的 connector 有這些:

connector 走哪條路
NixlConnector 走 NIXL,這天用的
SharedStorageConnector 經過共用儲存交接
P2pNcclConnector 走 NCCL 點對點
LMCacheConnectorV1 走 LMCache
MultiConnector 把幾個 connector 串起來
OffloadingConnector 卸載到 CPU 記憶體

走的路不同,頻寬也不同,而那會直接影響分離式划不划算。這一天只量 NixlConnector 那一條。


實驗環境

今天用 Day 1 架設的真卡環境,兩台單卡機器,用 DisaggregatedSet 部署一組 prefill 和一組 decode,各占一台,engine 用 vLLM,跨節點傳 KV 用 NixlConnector,然後測試三件事。

實驗 問題 怎麼算成立
一 prefill 和 decode 有沒有真的分開 同一個請求進來,兩邊各自做了什麼 prefill 那邊的 generation_tokens_total 停在 1、request_decode_time_sum 是 0
二 跨節點傳一次 KV 要多久 分離和不分離的端到端時間差 差值除以 token 數要是常數;再跟網路本身的上限比,落差多少記下來
三 分離換到什麼,付出什麼 併發下的 ITL、吞吐、TTFT ITL 的 p99 要比不分離低,吞吐和 TTFT 的變化要記下來

機器和叢集

ip-172-31-26-39   g6e.2xlarge   L40S 46068 MiB   server
ip-172-31-30-5    g6e.2xlarge   L40S 46068 MiB   agent

叢集是 k3s 加 NVIDIA 的 DRA driver,gpu.nvidia.com 的 ResourceSlice 已經在了。

裝 LWS

helm upgrade --install lws oci://registry.k8s.io/lws/charts/lws \
  --version=0.11.1 --namespace lws-system --create-namespace --wait
kubectl get crd | grep -E "disaggregatedset|leaderworkerset"
disaggregatedsetrolescalers.disaggregatedset.x-k8s.io
disaggregatedsets.disaggregatedset.x-k8s.io
leaderworkersets.leaderworkerset.x-k8s.io

VLLM_NIXL_SIDE_CHANNEL_HOST 必須設成 Pod IP

- name: VLLM_NIXL_SIDE_CHANNEL_HOST
  valueFrom:
    fieldRef: { fieldPath: status.podIP }

prefill 算完之後要告訴 decode 去哪裡拿這份 KV,報出去的位址就是這個環境變數。預設值 localhost 只在同一台機器上成立,跨 Pod 的話 decode 會連回自己,所以要換成這個 Pod 的位址。

namespace、ResourceClaimTemplate、PVC

mkdir -p ~/pd
# ~/pd/00-base.yaml
apiVersion: v1
kind: Namespace
metadata: { name: pd }
---
apiVersion: resource.k8s.io/v1
kind: ResourceClaimTemplate
metadata: { name: one-gpu, namespace: pd }
spec:
  spec:
    devices:
      requests:
        - name: gpu
          exactly:
            deviceClassName: gpu.nvidia.com
            allocationMode: ExactCount
            count: 1
---
# 兩個 PVC 而不是一個:k3s 的 local-path 綁節點,兩個角色在不同機器上
apiVersion: v1
kind: PersistentVolumeClaim
metadata: { name: hf-prefill, namespace: pd }
spec:
  accessModes: [ReadWriteOnce]
  resources: { requests: { storage: 80Gi } }
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata: { name: hf-decode, namespace: pd }
spec:
  accessModes: [ReadWriteOnce]
  resources: { requests: { storage: 80Gi } }
kubectl apply -f ~/pd/00-base.yaml

DisaggregatedSet:兩個角色

# ~/pd/02-disaggset.yaml
apiVersion: disaggregatedset.x-k8s.io/v1
kind: DisaggregatedSet
metadata: { name: pd, namespace: pd }
spec:
  slices: 1
  placementPolicy:
    type: None
  roles:
    - name: prefill
      spec:
        replicas: 1
        leaderWorkerTemplate:
          size: 1
          workerTemplate:
            spec:
              enableServiceLinks: false
              nodeSelector:
                kubernetes.io/hostname: ip-172-31-26-39
              resourceClaims: [{ name: gpu, resourceClaimTemplateName: one-gpu }]
              volumes:
                - { name: hf, persistentVolumeClaim: { claimName: hf-prefill } }
                - { name: shm, emptyDir: { medium: Memory, sizeLimit: 4Gi } }
              containers:
                - name: vllm
                  image: vllm/vllm-openai:v0.11.0
                  args:
                    - --model=Qwen/Qwen2.5-7B-Instruct
                    - --gpu-memory-utilization=0.90
                    - --max-model-len=8192
                    - --kv-transfer-config
                    - '{"kv_connector":"NixlConnector","kv_role":"kv_both"}'
                  env:
                    - { name: HF_HOME, value: /hf }
                    - { name: VLLM_NIXL_SIDE_CHANNEL_PORT, value: "5600" }
                    - name: VLLM_NIXL_SIDE_CHANNEL_HOST
                      valueFrom:
                        fieldRef: { fieldPath: status.podIP }
                    - { name: UCX_TLS, value: "tcp,self,sm,cuda_copy" }
                    - { name: UCX_NET_DEVICES, value: "all" }
                  ports:
                    - { containerPort: 8000, name: http }
                    - { containerPort: 5600, name: nixl }
                  volumeMounts:
                    - { name: hf, mountPath: /hf, subPath: hf }
                    - { name: shm, mountPath: /dev/shm }
                  resources: { claims: [{ name: gpu }] }
                  startupProbe:
                    httpGet: { path: /health, port: 8000 }
                    periodSeconds: 10
                    failureThreshold: 180
    - name: decode
      # 跟 prefill 的 spec 完全一樣,只有三處不同:
      #   name              decode
      #   nodeSelector      ip-172-31-30-5
      #   claimName         hf-decode
kubectl apply -f ~/pd/02-disaggset.yaml

DisaggregatedSet 自己不管 Pod。一個 role 對應一個 LeaderWorkerSet,每個 LeaderWorkerSet 底下再是一個 StatefulSet,Pod 由最底下那層建出來。

kubectl -n pd get disaggregatedset,lws,sts
NAME                                            AGE
disaggregatedset.disaggregatedset.x-k8s.io/pd   5m4s

NAME                                                             READY   DESIRED   UP-TO-DATE   AGE
leaderworkerset.leaderworkerset.x-k8s.io/pd-0-abefde6f-decode    1       1         1            5m4s
leaderworkerset.leaderworkerset.x-k8s.io/pd-0-abefde6f-prefill   1       1         1            5m4s

NAME                                     READY   AGE
statefulset.apps/pd-0-abefde6f-decode    1/1     5m3s
statefulset.apps/pd-0-abefde6f-prefill   1/1     5m3s

一份 DisaggregatedSet 展開成兩個 LeaderWorkerSet 加兩個 StatefulSet。名稱的組成是 DisaggregatedSet 名稱、slice 編號、revision 的 hash,最後接 role 名。

設定改一次,hash 就換一個,底下所有物件的名字跟著換。LWS 自動建的 Service 也在裡面,所以照名字指過去的東西,設定一改就斷。

穩定的 Service

LWS 自動建的 Service 不能用:名稱帶 revision(pd-0-5d2f1d21-prefill),rollout 一次就斷,而且 PORT(S) 是 <none>,不宣告 port。

自己建兩個,selector 只用三個不含 revision 的標籤:

# ~/pd/03-stable-svc.yaml
apiVersion: v1
kind: Service
metadata: { name: prefill, namespace: pd }
spec:
  selector:
    disaggregatedset.x-k8s.io/name: pd
    disaggregatedset.x-k8s.io/role: prefill
    disaggregatedset.x-k8s.io/slice: "0"
  ports: [{ port: 8000, targetPort: 8000 }]
---
# decode 那個一樣,只有 name 和 role 換成 decode
kubectl apply -f ~/pd/03-stable-svc.yaml
kubectl -n pd get endpointslices -o custom-columns='SVC:.metadata.labels.kubernetes\.io/service-name,IP:.endpoints[*].addresses,NODE:.endpoints[*].nodeName'
SVC                     IP              NODE
decode                  [10.42.1.190]   ip-172-31-30-5
pd-0-5d2f1d21-decode    [10.42.1.190]   ip-172-31-30-5
pd-0-5d2f1d21-prefill   [10.42.0.129]   ip-172-31-26-39
prefill                 [10.42.0.129]   ip-172-31-26-39

兩個 Service 各指到正確的節點,而且經過一輪 delete 和 recreate、物件名稱全換之後仍然正確。

proxy

分離之後不能把請求直接丟給任一邊,而這個 proxy 也不是換個負載平衡器就能頂的,它做了三件改請求內容的事:

proxy 做的事 為什麼不能省
打 prefill 的時候把 max_tokens 改成 1 prefill 只要算 KV,不要真的生 token
在打 prefill 的請求裡加上 kv_transfer_params 宣告這一筆要做 KV 傳輸
把 prefill 回應裡的 kv_transfer_params 抄進打 decode 的請求 裡面是 NIXL 的握手資訊:remote_engine_id、remote_host、remote_port

nginx 或 Kubernetes Service 只會把請求轉給後端,不會動內容。換成它們,prefill 不知道這一筆要做 KV 傳輸,decode 也拿不到該去哪裡拿 KV 的位址。

git clone https://github.com/vllm-project/vllm.git /tmp/vllm
kubectl -n pd create configmap pd-proxy \
  --from-file=toy_proxy_server.py=/tmp/vllm/tests/v1/kv_connector/nixl_integration/toy_proxy_server.py
# ~/pd/04-proxy.yaml
apiVersion: apps/v1
kind: Deployment
metadata: { name: proxy, namespace: pd }
spec:
  replicas: 1
  selector: { matchLabels: { app: proxy } }
  template:
    metadata: { labels: { app: proxy } }
    spec:
      containers:
        - name: proxy
          image: vllm/vllm-openai:v0.11.0
          command: ["python3", "/opt/proxy/toy_proxy_server.py"]
          args:
            - --host=0.0.0.0
            - --port=8192
            - --prefiller-hosts=prefill.pd.svc.cluster.local
            - --prefiller-ports=8000
            - --decoder-hosts=decode.pd.svc.cluster.local
            - --decoder-ports=8000
          ports: [{ containerPort: 8192, name: http }]
          volumeMounts:
            - { name: src, mountPath: /opt/proxy }
          resources:
            requests: { cpu: "500m", memory: 1Gi }
      volumes:
        - name: src
          configMap: { name: pd-proxy }
---
apiVersion: v1
kind: Service
metadata: { name: proxy, namespace: pd }
spec:
  selector: { app: proxy }
  ports: [{ port: 8192, targetPort: 8192 }]
寫法 為什麼
command: 覆蓋 entrypoint vLLM 映像的 entrypoint 是 python3 -m vllm.entrypoints.openai.api_server,不覆蓋會開成第三個 vLLM server
完全沒有 resourceClaims proxy 只轉發 HTTP,可以跟 prefill 擠同一個節點,不占第三張卡
host 和 port 是兩個分開的參數 argparse 用 zip(hosts, ports) 配對,不吃 host:port 字串
kubectl apply -f ~/pd/04-proxy.yaml
kubectl -n pd rollout status deploy/proxy --timeout=180s

一、prefill 和 decode 有沒有真的分開

基準

在跑任何請求之前,先抓同一批 Pod 的指標。

kubectl -n pd run m1 --restart=Never --image=curlimages/curl:8.11.1 --command -- sh -c '
for s in prefill decode; do
  echo "=== $s ==="
  curl -s http://$s.pd.svc.cluster.local:8000/metrics | grep -E "^vllm:(request_prefill_time_seconds_sum|request_decode_time_seconds_sum|prompt_tokens_total|generation_tokens_total|request_success_total\{engine=\"0\",finished_reason=\"(stop|length)\")"
done'
kubectl -n pd wait --for=jsonpath='{.status.phase}'=Succeeded pod/m1 --timeout=60s && kubectl -n pd logs m1
kubectl -n pd delete pod m1

指標在跑過第一筆請求之前就存在,值是 0.0,不是「不存在、等第一筆才出現」。

基準必須跟後面的量測是同一批 Pod。中間如果 delete 或 recreate 過,基準就失效,要重抓。

發請求

kubectl -n pd run req3 --restart=Never --image=curlimages/curl:8.11.1 --command -- sh -c '
curl -sS -X POST http://proxy.pd.svc.cluster.local:8192/v1/completions \
  -H "Content-Type: application/json" \
  -d "{\"model\":\"Qwen/Qwen2.5-7B-Instruct\",\"prompt\":\"Explain what Kubernetes is in two sentences.\",\"max_tokens\":64,\"temperature\":0}"'
kubectl -n pd wait --for=jsonpath='{.status.phase}'=Succeeded pod/req3 --timeout=180s && kubectl -n pd logs req3
{"id":"cmpl-0a0bd4ce-...","choices":[{"text":" Kubernetes is an open-source container orchestration system that automates the deployment, scaling, and management of containerized applications. It enables users to manage their application deployments across a cluster of machines.","finish_reason":"stop"}],
 "usage":{"prompt_tokens":9,"total_tokens":48,"completion_tokens":39},
 "kv_transfer_params":null}

請求跑完之後再看一次兩個 Pod,AGE 要繼續長、RESTARTS 要是 0。decode 被重建就是握手失敗。

回應裡的 "kv_transfer_params": null 表示 decode 這一跳不用再往下傳,到這裡結束。prefill 的回應裡才有值,那一份被 proxy 轉給了 decode。

怎麼看出分開了

把前面那條指令再跑一次(換個 Pod 名),比對前後:

                              prefill        decode
prompt_tokens_total              9.0            9.0
generation_tokens_total          1.0           39.0
request_decode_time_sum          0.0          0.7909
request_prefill_time_sum      0.0298         0.0444
finish_reason=length             1              0
finish_reason=stop               0              1

看兩個數字就夠。generation_tokens_total 一邊是 1、一邊是 39,prefill 只生了一個 token,因為 proxy 打過去的時候把 max_tokens 改成 1 了。request_decode_time_sum 一邊是 0、一邊是 0.7909,decode 這個階段整段發生在另一台。

finish_reason 那一列是另一回事。prefill 回的是 length,decode 回的是 stop。對 prefill 來說,它只是正常處理完一個 max_tokens=1 的請求,它不知道自己在做 PD 分離。分離這件事是 proxy 和 connector 合起來做出來的,模型那一層看不到。


二、跨節點傳一次 KV 要多久

一個 token 的 KV 是 56 KiB,所以 512、2048、4096 這三個長度要搬的量是 28、112、224 MiB。

對照:同一張卡不分離

decode 那個 Pod 本身就是完整的 vLLM。直接打它,不經 proxy、請求裡也沒有 kv_transfer_params,那就是「同一張卡、同一個模型、同一個行程」的基準。

single   HTTP 一跳,同一個 GPU 做 prefill 加 decode
1P1D     HTTP 兩跳,prefill 在另一台算完,KV 跨節點傳過來,decode 續寫

下面腳本裡和輸出裡的 1P1D 就是分離那一組,下表改用「分離」。

量測上有兩個前提。一是 prompt 要用隨機 token id:enable_prefix_caching 預設是開的,從 vllm:cache_config_info 確認過,512、2048、4096 如果用同一個 token 重複填,短的會變成長的前綴,第二次之後直接命中快取、傳輸量歸零。prompt 直接給 list[int],長度就精準,不用猜幾個字等於幾個 token。

二是直接打 Pod IP。節點本身連得到 Pod IP,包含跨節點的那一台,所以腳本從主機跑,不用為了每次往返開一個 Pod。

# ~/pd/exp2b.py
import json, random, statistics, time, urllib.request

D, PROXY = "10.42.1.190:8000", "10.42.0.128:8192"
MODEL = "Qwen/Qwen2.5-7B-Instruct"

def post(url, payload):
    req = urllib.request.Request(url, data=json.dumps(payload).encode(),
                                 headers={"Content-Type": "application/json"})
    t0 = time.perf_counter()
    r = json.loads(urllib.request.urlopen(req, timeout=600).read().decode())
    return time.perf_counter() - t0, r["usage"]["prompt_tokens"]

REP = 3
for N in (512, 2048, 4096):
    rows = {}
    for tag, url in (("single", f"http://{D}/v1/completions"),
                     ("1P1D",   f"http://{PROXY}/v1/completions")):
        ts = []
        for _ in range(REP):
            ids = [random.randrange(1000, 100000) for _ in range(N)]
            dt, got = post(url, {"model": MODEL, "prompt": ids,
                                 "max_tokens": 8, "temperature": 0})
            assert got == N, (got, N)
            ts.append(dt); time.sleep(1)
        rows[tag] = ts
    s, p = statistics.median(rows["single"]), statistics.median(rows["1P1D"])
    print(f"=== N={N} ===")
    print(f"  single  {[f'{x:.3f}' for x in rows['single']]}  median {s:.3f}")
    print(f"  1P1D    {[f'{x:.3f}' for x in rows['1P1D']]}  median {p:.3f}")
    print(f"  diff    {p-s:+.3f} s   ({(p-s)/N*1000:.3f} ms/token)")
python3 -I ~/pd/exp2b.py
=== N=512 ===
  single  ['0.211', '0.188', '0.187']  median 0.188
  1P1D    ['0.326', '0.317', '0.319']  median 0.319
  diff    +0.131 s   (0.256 ms/token)
=== N=2048 ===
  single  ['0.289', '0.289', '0.291']  median 0.289
  1P1D    ['0.726', '0.724', '0.855']  median 0.726
  diff    +0.437 s   (0.214 ms/token)
=== N=4096 ===
  single  ['0.444', '0.444', '0.440']  median 0.444
  1P1D    ['1.664', '1.663', '1.625']  median 1.663
  diff    +1.220 s   (0.298 ms/token)
N 不分離 分離 倍數 diff 每 token 等效頻寬
512 0.188 s 0.319 s 1.70 倍 0.131 s 0.256 ms 213.7 MiB/s
2048 0.289 s 0.726 s 2.51 倍 0.437 s 0.214 ms 256.3 MiB/s
4096 0.444 s 1.663 s 3.75 倍 1.220 s 0.298 ms 183.6 MiB/s

最後一欄是把要搬的位元組數除以那個差值得到的,所以叫等效頻寬:真正搬出去多少 byte 量不到,只能用推算的量除以量到的時間。

每 token 的成本大致是常數,0.21 到 0.30 毫秒,所以差值的主體是跟資料量成正比的傳輸,不是固定的 HTTP 開銷。

而單筆延遲上分離式全面輸,而且越長越輸。

這兩台之間的網路有多快

三個長度的等效頻寬都在 200 MiB/s 上下。這個數字可能是 NIXL 本身的效率,也可能是這兩台之間的網路本來就只有這麼快。兩種解釋的結論完全相反,所以直接量一次網路。

量法是不經過 vLLM、不經過 HTTP、也不碰 GPU:在 decode 那台開一個 socket 一直收,從 prefill 那台一直塞 byte 過去,看每秒能塞多少。測出來的就是這條網路最好的情況。

# ~/pd/90-bw.yaml
apiVersion: v1
kind: Pod
metadata: { name: bw-srv, namespace: pd }
spec:
  nodeSelector: { kubernetes.io/hostname: ip-172-31-30-5 }
  containers:
    - name: s
      image: vllm/vllm-openai:v0.11.0
      command: ["python3", "-c"]
      args:
        - |
          import socket
          s = socket.socket()
          s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
          s.bind(("0.0.0.0", 9999)); s.listen(1)
          print("listening", flush=True)
          while True:
              c, a = s.accept(); n = 0
              while True:
                  b = c.recv(1 << 20)
                  if not b: break
                  n += len(b)
              print("received", n, flush=True); c.close()
      ports: [{ containerPort: 9999 }]
kubectl apply -f ~/pd/90-bw.yaml
kubectl -n pd wait --for=condition=Ready pod/bw-srv --timeout=120s
kubectl -n pd get pod bw-srv -o wide
# ~/pd/bw.py
import socket, threading, time
HOST, PORT, TOTAL = "10.42.1.194", 9999, 2 << 30

def one(nbytes, out, i):
    buf = b"x" * (1 << 20)
    s = socket.create_connection((HOST, PORT))
    t0 = time.perf_counter(); sent = 0
    while sent < nbytes:
        sent += s.send(buf[: min(len(buf), nbytes - sent)])
    s.shutdown(socket.SHUT_WR); s.close()
    out[i] = (sent, time.perf_counter() - t0)

for streams in (1, 4):
    per = TOTAL // streams; out = [None] * streams
    ts = [threading.Thread(target=one, args=(per, out, i)) for i in range(streams)]
    t0 = time.perf_counter()
    for t in ts: t.start()
    for t in ts: t.join()
    wall = time.perf_counter() - t0
    mib = sum(o[0] for o in out) / (1 << 20)
    print(f"streams={streams}  {mib:.0f} MiB  {wall:.3f} s  "
          f"{mib/wall:.1f} MiB/s  {mib*8/wall/1024:.2f} Gib/s")
    time.sleep(2)
python3 -I ~/pd/bw.py
streams=1  2048 MiB  3.478 s  588.8 MiB/s  4.60 Gib/s
streams=4  2048 MiB  3.474 s  589.6 MiB/s  4.61 Gib/s
kubectl -n pd logs bw-srv
listening
received 2147483648
received 536870912
received 536870912
received 536870912
received 536870912

收到的 byte 數跟送出的一致。

1 條和 4 條完全一樣,所以瓶頸不是單條 TCP 連線或中斷處理,而是這台 g6e.2xlarge 的實際可用頻寬大約 4.6 Gib/s。這個機型標的是最高 20 Gb/s,那是突發值。

三層結論

第一層:NIXL 只跑到網路上限的 31% 到 43%。KV 要從 GPU 記憶體複製到主機記憶體,才能丟上網路,到了對面再複製回 GPU,這兩段複製就是 DisaggregatedSet 裡 UCX_TLS 開的 cuda_copy。另外 KV cache 是切成固定大小的 block 存的,每次傳要先交代有哪些 block、傳完要對帳,這些也要時間。

第二層:這個環境的網路本來就只有 4.6 Gib/s,加連線數救不了。

第三層:就算 NIXL 跑滿網路上限,同卡自己算 prefill 還是贏。

N 用滿網路上限搬 KV 同卡自己算 prefill 誰贏
512 0.047 s 0.038987 s 自己算
2048 0.190 s 0.138044 s 自己算
4096 0.380 s 0.299833 s 自己算

右邊那三個數字的出處是 prefill 實例自己的 vllm:request_prefill_time_seconds_sum,取前後差值,而且跟實驗二是同一批請求,不是另外量的一輪。

對帳的方式是拿 single 的端到端減掉 prefill:0.149、0.151、0.144 秒。max_tokens=8 代表 8 個 token 之間有 7 個間隔,除下來是 21.3、21.6、20.6 毫秒,跟實驗三量到的 ITL 中位數 22.2 毫秒是同一個量級。

網路要多快,搬一個 token 的 KV 才會跟同一張卡自己算它一樣快,這個速度就是打平頻寬。它可以直接算,但是一個區間不是單一值,因為每 token 的 prefill 時間本身隨長度變動:

每 token prefill 時間   76.1 / 67.4 / 73.2 µs(N=512 / 2048 / 4096)
打平頻寬                718 到 811 MiB/s(5.61 到 6.34 Gib/s)
實測網路上限            589.6 MiB/s(4.61 Gib/s)

區間的下緣仍然高於網路上限,所以在 L40S 加 4.6 Gib/s 乙太網上,PD 分離在算術上就不成立。

卡越快,每 token 的 prefill 時間越短,打平頻寬的門檻就越高。換一張卡之前要重新量那張卡的每 token prefill 時間,這個算式才有意義。


三、分離換到什麼,付出什麼

一筆一筆發,分離比較慢,實驗二量過了。但分離的好處要好幾筆同時跑才看得到:那時候新來的 prefill 才會卡到其他請求的 decode。

# ~/pd/exp3.py
import json, random, statistics, threading, time, urllib.request

D, PROXY = "10.42.1.190:8000", "10.42.0.128:8192"
MODEL = "Qwen/Qwen2.5-7B-Instruct"
NPROMPT, NGEN, CONC, ROUNDS = 2048, 128, 4, 3

def stream(url, ids):
    body = json.dumps({"model": MODEL, "prompt": ids, "max_tokens": NGEN,
                       "temperature": 0, "stream": True}).encode()
    req = urllib.request.Request(url, data=body,
                                 headers={"Content-Type": "application/json"})
    stamps = []
    t0 = time.perf_counter()
    with urllib.request.urlopen(req, timeout=600) as r:
        for raw in r:
            line = raw.decode().strip()
            if line.startswith("data: ") and not line.endswith("[DONE]"):
                stamps.append(time.perf_counter())
    return t0, stamps

def run(url, tag):
    itls, ttfts, wall0 = [], [], time.perf_counter()
    lock = threading.Lock()
    def worker():
        for _ in range(ROUNDS):
            ids = [random.randrange(1000, 100000) for _ in range(NPROMPT)]
            t0, st = stream(url, ids)
            if len(st) < 2: return
            with lock:
                ttfts.append(st[0] - t0)
                itls.extend(b - a for a, b in zip(st, st[1:]))
    ts = [threading.Thread(target=worker) for _ in range(CONC)]
    for t in ts: t.start()
    for t in ts: t.join()
    wall = time.perf_counter() - wall0
    n = len(itls) + len(ttfts)
    q = statistics.quantiles(itls, n=100)
    print(f"=== {tag} ===")
    print(f"  requests {len(ttfts)}  tokens {n}  wall {wall:.2f}s  throughput {n/wall:.1f} tok/s")
    print(f"  TTFT   mean {statistics.mean(ttfts)*1000:.1f} ms  max {max(ttfts)*1000:.1f} ms")
    print(f"  ITL    mean {statistics.mean(itls)*1000:.2f} ms  p50 {q[49]*1000:.2f}  "
          f"p90 {q[89]*1000:.2f}  p99 {q[98]*1000:.2f}  max {max(itls)*1000:.2f}")

run(f"http://{D}/v1/completions", "single(同一張卡做 prefill 加 decode)")
time.sleep(5)
run(f"http://{PROXY}/v1/completions", "1P1D(分離)")

stream: True 是必要的,不開串流只有一個時間點,量不出 token 之間的間隔。

python3 -I ~/pd/exp3.py
=== single(同一張卡做 prefill 加 decode) ===
  requests 12  tokens 1536  wall 10.21s  throughput 150.4 tok/s
  TTFT   mean 354.0 ms  max 599.3 ms
  ITL    mean 23.90 ms  p50 22.30  p90 22.45  p99 141.04  max 146.43
=== 1P1D(分離) ===
  requests 12  tokens 1412  wall 15.49s  throughput 91.1 tok/s
  TTFT   mean 2597.8 ms  max 2664.6 ms
  ITL    mean 21.96 ms  p50 22.22  p90 22.34  p99 22.66  max 25.94
指標 不分離 分離 變化
throughput 150.4 tok/s 91.1 tok/s 少 39%
TTFT mean 354.0 ms 2597.8 ms 惡化 7.3 倍
ITL p50 22.30 ms 22.22 ms 一樣
ITL p90 22.45 ms 22.34 ms 一樣
ITL p99 141.04 ms 22.66 ms 改善 6.2 倍
ITL max 146.43 ms 25.94 ms 改善 5.6 倍

p50 和 p90 兩組完全一樣,只有尾端不同。

不分離   p50 22.30 → p90 22.45 → p99 141.04    尾端暴衝 6.3 倍
分離     p50 22.22 → p90 22.34 → p99  22.66    幾乎一條平線

分離沒有讓 decode 變快,穩態速度一模一樣,它改變的是 decode 會不會被打斷。不分離那個 p99 等於 141 毫秒就是新請求的 prefill 插進 decode 批次的那一下。141 除以 22.3 約等於 6.3,而一次 2048 token 的 prefill 要 0.138 秒。

TTFT 惡化 7.3 倍的原因是 KV 傳輸發生在第一個 token 之前,而 4 個併發的 prefill 加傳輸都排在同一個 prefill 實例上。0.138 × 4 + 0.437 × 4 ≈ 2.3 秒,跟實測的 2.6 秒對得上。

所以分離在這個環境下的取捨是這樣:

付出    吞吐少 39%、TTFT 惡化 7.3 倍
換到    ITL p99 改善 6.2 倍(141 ms 變 23 ms)

還有一個更誠實的版本。不分離那組只用一張卡,prefill 那張是閒置的;分離那組用了兩張。所以實際上是用兩倍的卡換到 0.61 倍的吞吐,代價比表面的數字更差。

量測的誠實交代

項目 說明
token 數不同(1536 對 1412) temperature=0 加上隨機 token id 的 prompt,有些請求提早撞到 EOS。throughput 已經用 tokens 除以 wall 正規化
只有 4 併發、12 筆請求 p99 是從約 1400 個 ITL 取的。趨勢(6 倍差)遠大於雜訊,但精確數字不要當基準
兩組不同時跑 中間 sleep 5,都在同一張 decode 卡上,不互相干擾

實驗跑完收掉。

kubectl delete namespace pd

小結

今天把真的 vLLM 放進 DisaggregatedSet,prefill 和 decode 確實分開了。

但這個環境划不來。跨節點傳一次 KV 的時間是整段加上去的,而且就算傳輸跑滿網路上限,同一張卡自己算 prefill 還是更快。

分離換到的是 ITL 的 p99 從 141 毫秒降到 23 毫秒,代價是吞吐少 39%、TTFT 惡化 7.3 倍,還多用一張卡。

要划算,缺的是快得多的互連。


參考資料

Disaggregated Prefilling, vLLM
NixlConnector Usage Guide, vLLM
DistServe: Disaggregating Prefill and Decoding for Goodput-optimized Large Language Model Serving
DisaggregatedSetRoleScaler, LeaderWorkerSet


上一篇
【Day 21】Karpenter:寸土寸金的節點,繼 Pod 之後節點的擴縮
下一篇
【Day 23】Envoy AI Gateway:擋在 LLM 前的守衛,進來先檢查 token 額度
系列文
凌晨四點,女友帶著 GPU 來我家學習 Kubernetes:打造 K8s AI Infra 的 30 夜 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言