Day 19 我們用 DisaggregatedSet 宣告了一組 prefill 和一組 decode。但那天跑在沒有 GPU 的 kind 上,Pod 裡面是假的工作負載,從頭到尾沒有真的推論。
今天把真的 vLLM 放進去,看拆開之後引擎裡面發生什麼事。
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 還是要跑一次,只是放到不同的機器上,中間還多一趟搬運。
Day 19 查過 DisaggregatedSet 的 spec 只有三個欄位:roles、slices、placementPolicy。它負責把 prefill 和 decode 兩組 Pod 照宣告的形狀部署出來,每個 role 對應一個 LeaderWorkerSet,後面簡稱 LWS。
但「一個請求要先送去 prefill、拿到 KV 之後再送去 decode」這件事不在它的職責裡。
| 要做的事 | 誰負責 |
|---|---|
| 宣告有哪些 role、各自什麼形狀、複製幾份 | DisaggregatedSet |
| prefill 算完的 KV 怎麼到 decode 手上 | KVConnector |
| 請求先去哪、再去哪 | 另外擺一個 proxy,或者用帶路由的編排層 |
prefill 算出每個 token 的 key 和 value,存在 GPU 記憶體裡,那就是 KV cache。而 decode 每吐一個 token,都要回頭看前面所有 token 的 KV。
所以 KV cache 是 prefill 的產物,同時是 decode 的必要輸入。
不分離的時候兩個階段在同一張卡上跑,KV 本來就躺在那張卡的記憶體裡,decode 直接拿來用。分離之後 prefill 在一張卡、decode 在另一張卡,而兩張卡的記憶體不會自己互通,所以必須有人把那份 KV 搬過去。這一趟就是 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 已經在了。
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
- name: VLLM_NIXL_SIDE_CHANNEL_HOST
valueFrom:
fieldRef: { fieldPath: status.podIP }
prefill 算完之後要告訴 decode 去哪裡拿這份 KV,報出去的位址就是這個環境變數。預設值 localhost 只在同一台機器上成立,跨 Pod 的話 decode 會連回自己,所以要換成這個 Pod 的位址。
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
# ~/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 也在裡面,所以照名字指過去的東西,設定一改就斷。
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 做的事 | 為什麼不能省 |
|---|---|
打 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
在跑任何請求之前,先抓同一批 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 合起來做出來的,模型那一層看不到。
一個 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