前兩天都在 vLLM 上打轉:開機吃掉 91.4% 的記憶體、用分頁管 KV cache、用量化把權重壓到 5.20 GiB。
今天換一個引擎玩玩,SGLang。畢竟我們三十天目的是廣學嘛,當然不能錯過 vLLM 的競品。
SGLang 的招牌是 RadixAttention:把算過的 KV cache 存成一棵樹,下一個請求只要開頭一樣就直接拿來用,不必重算。
今天用五個實驗把那棵樹看出來。
SGLang 是一個開源的推論框架,支援 LLM 與多模態模型,設計目標鎖定 agentic 工作負載、RL rollout 和大規模服務。它由兩個部分組成:一個嵌在 Python 裡的前端語言,提供生成與平行控制的原語;以及一個執行期,用 RadixAttention 重用 KV cache。
它的出發點是 LM Program(語言模型程式):LLM 的實際應用往往不是單次的「一個 prompt 換一個回覆」,而是需要多次生成呼叫、控制流程、結構化的輸入輸出。
這類程式有一個共同的結構特徵:每一步的 prompt 都是前一步的 prompt 加上一點新東西。
SGLang 把所有請求的 KV cache 維持成一個 LRU 快取,放在一棵基數樹裡,用這棵樹做比對、插入與驅逐。
基數樹是字首樹的空間效率版本,邊可以代表一串 token 而不是單一 token。樹上的節點對應到一段 token 序列和它的 KV cache 張量。新請求進來時,在樹上找最長的共同前綴,找到就直接重用,從分歧點之後才開始算。
驅逐只考慮可驅逐的葉節點:KV 還在、沒有被進行中的請求鎖住、底下沒有仍持有 KV 的子節點。根節點永遠不可驅逐。策略只負責評分,分數最低的先被丟,而且策略不能把 KV 釘在記憶體裡,被保護的節點在其他東西全丟光還不夠時照樣會被丟。
預設策略是 lru,--radix-eviction-policy 可以選 lru、lfu、slru、priority 四個。
vLLM 用的是基於雜湊的作法,把每個 block 用「block 裡的 token」加上「前面的前綴 token」一起雜湊,比對時算雜湊查表。而且它只快取填滿的 block。
| vLLM | SGLang | |
|---|---|---|
| 資料結構 | 雜湊表 | 基數樹 |
| 快取單位 | 填滿的 block(預設 16 個 token) | 樹上的節點 |
| 驅逐 | 區塊層級 | 可驅逐的葉節點,預設 LRU |
什麼時候用哪個:
| 請求之間沒有共用前綴 | 兩邊一樣,選你熟的 |
| 共用一段長前綴(RAG、長 system prompt、多輪對話) | 兩邊都幾乎全命中,差距只剩零頭 |
| 共用的前綴短、分支多(agent 的淺層決策、大量短對話) | SGLang 的細粒度才吃得到 |
實驗二會把這個差距量出來。
今天要驗的是 RadixAttention 這棵樹到底存不存在、怎麼運作。五個角度:樹上長不長得出分支、共用幾個 token 就命中幾個、快取跨請求活不活著、把樹清掉命中會不會消失、以及命中有沒有真的省到計算。
k3s、一張 L40S(46068 MiB),卡走 DRA(gpu.nvidia.com),模型 Qwen/Qwen2.5-7B-Instruct-AWQ,映像 lmsysorg/sglang:v0.5.18。
kubectl create namespace sglang
kubectl apply -f - <<'EOF'
apiVersion: resource.k8s.io/v1
kind: ResourceClaimTemplate
metadata: { name: one-gpu, namespace: sglang }
spec:
spec:
devices:
requests:
- name: gpu
exactly:
deviceClassName: gpu.nvidia.com
allocationMode: ExactCount
count: 1
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata: { name: hf-cache, namespace: sglang }
spec:
accessModes: [ReadWriteOnce]
resources: { requests: { storage: 60Gi } }
EOF
apiVersion: apps/v1
kind: Deployment
metadata: { name: sglang, namespace: sglang }
spec:
replicas: 1
strategy: { type: Recreate }
selector: { matchLabels: { app: sglang } }
template:
metadata: { labels: { app: sglang } }
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: sglang
image: lmsysorg/sglang:v0.5.18
command: ["python3", "-m", "sglang.launch_server"] # 映像沒有現成 entrypoint
args:
- --model-path=Qwen/Qwen2.5-7B-Instruct-AWQ
- --mem-fraction-static=0.90 # 不填它會自己挑一個保守值
- --host=0.0.0.0 # 預設 127.0.0.1,不改 Service 連不到
- --port=30000
- --enable-metrics # 預設 False,不開什麼都抓不到
- --enable-mfu-metrics # 預設 False,FLOPs 那組指標靠它
env: [{ name: HF_HOME, value: /hf }]
volumeMounts:
- { name: hf, mountPath: /hf }
- { name: shm, mountPath: /dev/shm }
resources: { claims: [{ name: gpu }] }
startupProbe:
tcpSocket: { port: 30000 }
periodSeconds: 10
failureThreshold: 90 # 開機要四分鐘
---
apiVersion: v1
kind: Service
metadata: { name: sgl, namespace: sglang }
spec:
selector: { app: sglang }
ports: [{ port: 30000, targetPort: 30000 }]
kubectl -n sglang rollout status deploy/sglang --timeout=25m
kubectl -n sglang run t --rm -i --restart=Never --image=curlimages/curl:8.11.1 -- \
curl -sS http://sgl.sglang.svc:30000/v1/models
{"object":"list","data":[{"id":"Qwen/Qwen2.5-7B-Instruct-AWQ","object":"model",
"owned_by":"sglang","max_model_len":32768}]}
先確認 radix cache 確實啟用:
kubectl -n sglang logs deploy/sglang \
| grep -o "impl=RadixCache\|disable_radix_cache=False\|radix_eviction_policy='lru'" | sort -u
disable_radix_cache=False
impl=RadixCache
radix_eviction_policy='lru'
池子的形狀:
max_total_num_tokens 638602
page_size 1
num_pages 638602
kv_evictable_tokens 474736
kv_available_tokens 163866
page_size、num_pages、max_total_num_tokens 三個數字說的是同一件事:一頁裝一個 token。
全程的觀測工具是 scheduler 日誌。每一批 prefill 會印兩個數字:
kubectl -n sglang logs deploy/sglang | grep -oE "#new-token: [0-9]+, #cached-token: [0-9]+"
#new-token 是真的要算幾個,#cached-token 是從樹上取得幾個。
要看的是「樹」和「一張命中清單」的差別:同一段共同開頭底下,能不能長出兩條深度不同的路徑。
三個 prompt,A 和 B 共用開頭 P,C 是 A 的延伸:
P = ' Zephyr audit note zf732150a: the operator shall keep every record.'
A = P + ' Question one: how long is the period?'
B = P + ' Request two: who may open the archive?'
C = A + ' Answer in one word.'
依序送 A、B、C:
A prompt_tokens = 30
B prompt_tokens = 30
C prompt_tokens = 35
#new-token: 25, #cached-token: 5
#new-token: 9, #cached-token: 21
#new-token: 5, #cached-token: 30
三個數字互相驗證:
B 的命中 21 = P 的長度
21 + 9 = 30 = A 的 prompt_tokens ✅
C 的命中 30 = A 的完整長度 ✅
B 停在 21,C 走到 30。 B 跟 A 只共用 P,命中到分歧點就停;C 是 A 的延伸,沿著 A 那條分支一路命中到尾端。同一棵樹上兩條不同深度的路徑。
page_size 是 1,所以理論上任何長度的共同前綴都留得住。掃過幾個長度確認。
每組兩筆請求,共用一段前綴(標記加上 N 個 alpha),尾巴分別是 aa 和 bb:
alpha x0 prompt_tokens = 11, 11
alpha x2 prompt_tokens = 13, 13
alpha x7 prompt_tokens = 18, 18
alpha x20 prompt_tokens = 32, 32
#new-token: 10, #cached-token: 1, ... cuda graph: True
#new-token: 1, #cached-token: 10, ... cuda graph: False
#new-token: 4, #cached-token: 9, ... cuda graph: True
#new-token: 1, #cached-token: 12, ... cuda graph: False
#new-token: 9, #cached-token: 9, ... cuda graph: True
#new-token: 1, #cached-token: 17, ... cuda graph: False
#new-token: 22, #cached-token: 10, ... cuda graph: True
#new-token: 1, #cached-token: 31, ... cuda graph: False
| 標記+alpha | prompt | ①命中 | ②要算 | ②命中 | prompt − 1 |
|---|---|---|---|---|---|
| 0 | 11 | 1 | 1 | 10 | 10 ✅ |
| 2 | 13 | 9 | 1 | 12 | 12 ✅ |
| 7 | 18 | 9 | 1 | 17 | 17 ✅ |
| 20 | 32 | 10 | 1 | 31 | 31 ✅ |
四組都是「命中 = prompt − 1」。 第二筆只剩尾巴那一個 token 要算,前面全部從樹上取得。
①命中那一欄是計畫外的觀察:四組的標記開頭 qv732150b 共用 9 個 token,所以第二組之後的第一筆就直接命中 9 個。三組實驗自己在樹上長成了一個分支。
vLLM 只快取填滿的 block,所以它留住 16 × floor(N ÷ 16)。四組短前綴下來的差距:
| 共用前綴 N | SGLang 命中 | vLLM 命中 | 差距 | N mod 16 |
|---|---|---|---|---|
| 13 | 13 | 0 | 13 | 13 ✅ |
| 20 | 20 | 16 | 4 | 4 ✅ |
| 43 | 43 | 32 | 11 | 11 ✅ |
| 50 | 50 | 48 | 2 | 2 ✅ |
共用 13 個 token 的時候,vLLM 一個都省不到。 差距精確等於前綴長度除以 16 的餘數。
但差距有上限,永遠不超過 15 個 token。前綴四千個 token 的時候,15 個只佔 0.4%。
另外看最後一欄:命中之後只剩 1 個 token 要算,而 CUDA graph 的最小形狀是 4,所以第二筆的日誌寫著 cuda graph: False。開機時錄的那 50 個形狀,在高命中率的時候用不上。
前兩個實驗的請求都是連續送的。這次確認快取不是只在一批之內有效。
送一筆,等三十秒,再送同一筆:
第一次 prompt_tokens = 24
第二次 prompt_tokens = 24
#new-token: 20, #cached-token: 4
#new-token: 1, #cached-token: 23
整段 24 個 token,三十秒後重送只要算 1 個。
指標那邊也對得上。送之前和送之後各讀一次:
prompt_tokens_histogram_sum 5239767 → 5239815 +48 = 24 × 2 ✅
uncached_prompt_tokens_histogram_sum 456283 → 456304 +21
kv_evictable_tokens 474844 → 474884 +40
kv_available_tokens 163725 → 163693 −32
cache_hit_rate 0.9688 → 0.9583 = 23 ÷ 24 ✅
兩筆新增的 KV 算得出來:(24+8) + (1+8) = 41,實測加了 40。
cache_hit_rate 這次是對的(23 ÷ 24 = 0.9583)。但它只記最近一批,算完就被下一批蓋掉,所以讀到什麼要看時機。要穩定的命中率,用 prompt_tokens_histogram_sum 減 uncached_prompt_tokens_histogram_sum,這兩個是只增不減的累計值。
池子 638,602
可丟掉的前綴 474,884 (74.4%)
空的 163,693 (25.6%)
跑了五百多萬個 prompt token 之後,池子裡四分之三裝的是歷史。那些 KV 沒有任何進行中的請求在用,但也沒被丟掉,等下一個請求來取。
前面三個實驗都在證明命中,這次反過來:把樹清掉,看命中會不會消失。
先確認端點存在:
curl -s http://localhost:30000/openapi.json | python3 -c "..."
['/clear_hicache_storage_backend', '/flush_cache',
'/hicache/storage-backend', '/hicache/storage-backend/clear']
送一筆、flush、再送同一筆:
送一筆 prompt_tokens = 24
flush_cache 回應: Cache flushed.
Please check backend logs for more details. (When there are running or waiting
requests, the operation will not be performed.)
再送同一筆 prompt_tokens = 24
#new-token: 1, #cached-token: 23 ← flush 之前,整段命中
#new-token: 24, #cached-token: 0 ← flush 之後,全部重算
同一段文字,23 變 0。 這證明前面所有的命中真的來自那棵樹,不是別的機制。
flush 是 best-effort,回應裡明寫有請求在跑或在排隊的時候不會執行。
前面都是看日誌和計數器。這次看 GPU 真的少做了事。
一段約 4,000 個 token 的前綴、50 筆併發、max_tokens=1;對照組是 50 筆各自帶不同的 4,000 個 token 前綴。
[共用前綴] prompt 201,141 命中 192,830 (95.87%) 花了 3.775 s FLOPs 1.38871e+14
[各自不同] prompt 201,282 命中 523 ( 0.26%) 花了 19.542 s FLOPs 2.7827e+15
estimated_flops_per_gpu_total 累計的是這段期間做了多少次浮點運算。拿常用的估算式對一次(Transformer 推論每個 token 大約 2 × 參數量 次,這個模型 7.62e9 個參數):
共用 真的要算 8,311 個 token → 預測 1.267e14 差 +9.6%
各自不同 真的要算 200,750 個 token → 預測 3.059e15 差 −9.1%
兩邊都在一成以內,指標可以用。把 FLOPs 除以同一輪花的時間:
共用 1.38871e14 ÷ 3.775 s = 36.8 TFLOPS
各自不同 2.7827e15 ÷ 19.542 s = 142.4 TFLOPS
共用那組只跑到四分之一的速度,但卡沒有變慢。 50 筆請求共 201,141 個 prompt token,其中 192,830 個直接命中,只有 4% 真的要算。
在 vLLM 和 SGLang 之間,比較實際的問法不是誰比較快,而是你的請求長什麼樣。
如果流量是一筆一筆互不相干的請求(翻譯、摘要、隨機問答這類單次任務),vLLM 是比較穩的選擇。它的生態最成熟,新模型的支援來得快,量化格式和部署工具的相容性也最廣,遇到問題時資料也最多。
而當請求之間開始互相重疊:agent 每一步都疊在前一步上、RAG 反覆拿同一份文件去問不同問題、多輪對話每一輪都帶著完整歷史,SGLang 的 RadixAttention 就有了發揮的空間。它把 KV cache 存成一棵基數樹,共用的那段不必重算,而且不受 block 邊界限制。
一句話:請求彼此獨立就留在 vLLM,請求之間有共用前綴才值得換 SGLang。
SGLang: Efficient Execution of Structured Language Model Programs(NeurIPS 2024)
sgl-project/sglang
SGLang Documentation
SGLang: Server Arguments
SGLang: Radix Cache Eviction Policies
vLLM: Automatic Prefix Caching(設計文件)
Qwen/Qwen2.5-7B-Instruct-AWQ