iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0
Kubernetes

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

【Day 17】從 vLLM 遷移到 SGLang:RadixAttention 怎麼運作的

  • 分享至 

  • xImage
  •  

前兩天都在 vLLM 上打轉:開機吃掉 91.4% 的記憶體、用分頁管 KV cache、用量化把權重壓到 5.20 GiB。

今天換一個引擎玩玩,SGLang。畢竟我們三十天目的是廣學嘛,當然不能錯過 vLLM 的競品。

SGLang 的招牌是 RadixAttention:把算過的 KV cache 存成一棵樹,下一個請求只要開頭一樣就直接拿來用,不必重算。

今天用五個實驗把那棵樹看出來。


SGLang 是什麼

SGLang 是一個開源的推論框架,支援 LLM 與多模態模型,設計目標鎖定 agentic 工作負載、RL rollout 和大規模服務。它由兩個部分組成:一個嵌在 Python 裡的前端語言,提供生成與平行控制的原語;以及一個執行期,用 RadixAttention 重用 KV cache。

它的出發點是 LM Program(語言模型程式):LLM 的實際應用往往不是單次的「一個 prompt 換一個回覆」,而是需要多次生成呼叫、控制流程、結構化的輸入輸出。

這類程式有一個共同的結構特徵:每一步的 prompt 都是前一步的 prompt 加上一點新東西。


RadixAttention

SGLang 把所有請求的 KV cache 維持成一個 LRU 快取,放在一棵基數樹裡,用這棵樹做比對、插入與驅逐。

基數樹是字首樹的空間效率版本,邊可以代表一串 token 而不是單一 token。樹上的節點對應到一段 token 序列和它的 KV cache 張量。新請求進來時,在樹上找最長的共同前綴,找到就直接重用,從分歧點之後才開始算。

驅逐只考慮可驅逐的葉節點:KV 還在、沒有被進行中的請求鎖住、底下沒有仍持有 KV 的子節點。根節點永遠不可驅逐。策略只負責評分,分數最低的先被丟,而且策略不能把 KV 釘在記憶體裡,被保護的節點在其他東西全丟光還不夠時照樣會被丟。

預設策略是 lru,--radix-eviction-policy 可以選 lru、lfu、slru、priority 四個。

和 vLLM 的 prefix caching 比

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 的作法比

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


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

尚未有邦友留言

立即登入留言