iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
Kubernetes

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

【Day 16】vLLM 的引擎優化:量化、PagedAttention、推測解碼

  • 分享至 

  • xImage
  •  

昨天把 vLLM 當黑盒子在用:開機吃掉 91.4% 的記憶體,KV 池 25.13 GiB。

今天打開盒子,看裡面三個東西在做什麼。它們都叫「引擎優化」,但代價完全不同:

改什麼 換來什麼 代價
量化 每個權重用幾個位元存 記憶體,而且搬得更快 準確度
PagedAttention KV cache 怎麼擺 不浪費記憶體 幾乎沒有
推測解碼 token 產生的順序 延遲變低 算力,猜錯就白算

那個「幾個位元」就是精度。Qwen2.5-7B-Instruct 是 16 位元的版本,權重佔 14.25 GiB。今天換成 8 位元和 4 位元的同一個模型,看那個數字變成多少。

三個裡面只有量化需要擔心品質。推測解碼的輸出跟不用它一樣,猜錯的會被駁回。


量化

用更少的位元表示同一組權重,拿精度換記憶體足跡,讓大模型跑得上更多裝置。這次比四種:

位元 量的是什麼
BF16 16 沒量化,基準
FP8 8 權重和啟動值都是 8 bit
AWQ 4 只量權重
GPTQ 4 只量權重

格式跟硬體是綁的。AWQ 要 Turing 以後的卡;GPTQ 從 Volta 到 Hopper 甚至 x86 CPU 都行;FP8 的 W8A8 在 NVIDIA 這邊只有 Ada 和 Hopper。這張 L40S 是 Ada,四種都跑得動。


PagedAttention

PagedAttention 借的是作業系統的分頁:把一個序列的 KV cache 切成固定大小的 block,每個 block 存固定數量 token 的 key 和 value,而這些 block 在記憶體裡不需要連續,靠一張 block table 把邏輯上的順序對到實體位置。

昨天那個 block_size = 16 就是它的實物:一個 block 裝 16 個 token 的 KV。而 0.90 那個池子換算下來是 29,414 塊。

浪費只發生在一個序列的最後一塊,量起來不到 4%。而在它出現之前,既有的做法因為碎片和過度預留會浪費六成到八成的記憶體。


推測解碼

decode 慢是因為每產一個 token 都要把整個模型的權重搬一遍。而關鍵在於:一次 forward pass 的成本幾乎跟它產出幾個 token 無關,因為成本在讀那幾 GB 的權重,不在算。

推測解碼就是利用這件事。做法是先猜再驗:用一個便宜的方式猜出接下來幾個 token,把它們跟當前這個 token 一起塞進同一次 forward pass,讓大模型一次算出每個位置該是什麼。猜的跟算出來的一樣就留著,不一樣就從那裡開始丟掉。

所以運氣好的時候,一次 forward pass 前進好幾個字。

它是演算法上無損的:貪婪取樣開不開這個功能,結果一樣,只有浮點誤差可能讓分佈有細微差異。代價在被駁回的時候,那幾次計算是白做的,高流量下會變成吞吐量的問題。

猜法有好幾種,draft model、EAGLE、MTP 都是。這次用最便宜的 ngram:不需要另一個模型,直接在文字裡找重複出現過的片段,找到就沿用它後面那幾個字。

先說清楚它的定位:ngram 屬於低到中等增益的那一類,輕量、好開,但比不上那些基於模型的方法。等一下會看到 3.41 倍,那是因為輸入被刻意做成完美循環。


實驗環境

k3s 兩節點,各一張 L40S(46068 MiB),卡走 DRA 交出去,映像 vllm/vllm-openai:v0.11.0,--gpu-memory-utilization=0.90(只有分頁那節例外)。

權重放在 PVC 裡,換一種精度就是換一次 --model,其他一字不改。

Deployment 的 args 依序是 --model、--gpu-memory-utilization,所以下面 patch 裡的 args/0 是換模型、args/1 是換預算。

兩個取數腳本

q.sh 量一個精度的權重、池子、token 和頁:

#!/bin/sh
echo "--- 啟動日誌 ---"
kubectl -n vllm logs deploy/vllm | grep -iE "non-default args|Model loading took|Available KV cache|GPU KV cache size"
echo "--- 頁 ---"
kubectl -n vllm exec deploy/vllm -- python3 -c "
import urllib.request, re
t = urllib.request.urlopen('http://localhost:8000/metrics').read().decode()
for l in t.splitlines():
    if l.startswith('vllm:cache_config_info'):
        m = dict(re.findall(r'(\w+)=\"([^\"]*)\"', l))
        print('block_size =', m.get('block_size'), '  num_gpu_blocks =', m.get('num_gpu_blocks'))
"

snap.sh 取 metrics 快照,兩次相減就是單一請求的數字:

#!/bin/sh
kubectl -n vllm exec deploy/vllm -- python3 -c "
import urllib.request
t = urllib.request.urlopen('http://localhost:8000/metrics').read().decode()
want = ['prompt_tokens_total','generation_tokens_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',
        'spec_decode_num_drafts','spec_decode_num_draft_tokens','spec_decode_num_accepted_tokens']
for w in want:
    for line in t.splitlines():
        if line.startswith('vllm:' + w) and '{' in line:
            print('%-42s %s' % (w, line.split()[-1])); break
"

這裡用 startswith 而不是精確比對是刻意的:Prometheus 會給 counter 加 _total 後綴,實際的行是 vllm:spec_decode_num_drafts_total{...}。

四個精度都送同一個請求:

curl -s http://vllm.vllm.svc.cluster.local:8000/v1/completions \
  -H 'Content-Type: application/json' \
  -d '{"model":"<MODEL>","prompt":"Kubernetes is","max_tokens":128,"temperature":0}'

實驗一:量化,四個精度四組數字

BF16(基準)

~/vllm-lab/q.sh
non-default args: {'model': 'Qwen/Qwen2.5-7B-Instruct'}
Model loading took 14.2488 GiB and 151.460893 seconds
Available KV cache memory: 25.13 GiB
GPU KV cache size: 470,624 tokens
block_size = 16   num_gpu_blocks = 29414

送標準請求之後的快照(前一次全是 0):

prompt_tokens_total                        3.0
generation_tokens_total                    128.0
e2e_request_latency_seconds_sum            2.7071049213409424
time_to_first_token_seconds_sum            0.06346559524536133
time_per_output_token_seconds_count        127.0
request_prefill_time_seconds_sum           0.062057808000076875
request_decode_time_seconds_sum            2.643800454999905

AWQ(int4)

kubectl -n vllm patch deploy vllm --type=json \
  -p '[{"op":"replace","path":"/spec/template/spec/containers/0/args/0","value":"--model=Qwen/Qwen2.5-7B-Instruct-AWQ"}]'
kubectl -n vllm rollout status deploy/vllm --timeout=20m
~/vllm-lab/q.sh
non-default args: {'model': 'Qwen/Qwen2.5-7B-Instruct-AWQ'}
Model loading took 5.2037 GiB and 25.371667 seconds
Available KV cache memory: 34.18 GiB
GPU KV cache size: 639,984 tokens
block_size = 16   num_gpu_blocks = 39999
request_decode_time_seconds_sum            1.0170826739999939
time_per_output_token_seconds_count        127.0
time_to_first_token_seconds_sum            0.07025814056396484
e2e_request_latency_seconds_sum            1.0871047973632812

GPTQ-Int4

kubectl -n vllm patch deploy vllm --type=json \
  -p '[{"op":"replace","path":"/spec/template/spec/containers/0/args/0","value":"--model=Qwen/Qwen2.5-7B-Instruct-GPTQ-Int4"}]'
non-default args: {'model': 'Qwen/Qwen2.5-7B-Instruct-GPTQ-Int4'}
Model loading took 5.1799 GiB and 29.204276 seconds
Available KV cache memory: 34.20 GiB
GPU KV cache size: 640,432 tokens
block_size = 16   num_gpu_blocks = 40027
request_decode_time_seconds_sum            1.0051540740000746
time_per_output_token_seconds_count        127.0
time_to_first_token_seconds_sum            0.03680682182312012
e2e_request_latency_seconds_sum            1.041719913482666

FP8

kubectl -n vllm patch deploy vllm --type=json \
  -p '[{"op":"replace","path":"/spec/template/spec/containers/0/args/0","value":"--model=RedHatAI/Qwen2.5-7B-Instruct-FP8-dynamic"}]'
non-default args: {'model': 'RedHatAI/Qwen2.5-7B-Instruct-FP8-dynamic'}
Model loading took 8.1426 GiB and 103.426267 seconds
Available KV cache memory: 30.98 GiB
GPU KV cache size: 580,096 tokens
block_size = 16   num_gpu_blocks = 36256
request_decode_time_seconds_sum            1.7172635249999075
time_per_output_token_seconds_count        127.0
time_to_first_token_seconds_sum            0.02740025520324707
e2e_request_latency_seconds_sum            1.7443838119506836

四個擺在一起

精度 權重 KV 池 tokens 頁 TTFT TPOT E2E
BF16 14.25 GiB 25.13 GiB 470,624 29,414 63.5 ms 20.82 ms 2707.1 ms
AWQ int4 5.20 GiB 34.18 GiB 639,984 39,999 70.3 ms 8.01 ms 1087.1 ms
GPTQ int4 5.18 GiB 34.20 GiB 640,432 40,027 36.8 ms 7.91 ms 1041.7 ms
FP8 8.14 GiB 30.98 GiB 580,096 36,256 27.4 ms 13.52 ms 1744.4 ms

TPOT 是 request_decode_time_seconds_sum ÷ time_per_output_token_seconds_count,四次的分母都是 127。

換算成倍數:

精度 權重 頁與 tokens TPOT
AWQ 0.37× 1.36× 0.38×
GPTQ 0.36× 1.36× 0.38×
FP8 0.57× 1.23× 0.65×

量化不是「讓模型變小」,是「讓池子變大」。 同一張卡,權重從 14.25 GiB 掉到 5.20 GiB,省下的九個 GiB 全部進了 KV cache,多買到一萬多頁。

而頁乘 16 等於 token 數這件事四個精度全部整除:29414、39999、40027、36256 各乘 16,就是那四個 token 數。

TPOT 幾乎跟權重大小成正比

用 BF16 和 AWQ 這兩點拉一條線:

TPOT = 1.4161 × 權重(GiB) + 0.640 ms

拿它去預測另外兩個:

精度 預測 實測 差
GPTQ int4 7.97 ms 7.91 ms −0.06 ms(0.8%)
FP8 12.17 ms 13.52 ms +1.35 ms(11%)

GPTQ 落在線上,誤差 0.8%。

這驗證了系列一開始講的那句話:推論慢不是因為算得慢,是因為每產一個 token 都要把整個模型的權重搬過一遍。decode 的每 token 成本幾乎只取決於要搬多少,而 1.4161 ms/GiB 就是這張 L40S 搬一個 GiB 權重的時間。

FP8 是唯一偏離的,慢了 11%。原因我沒查,可能是反量化的開銷,也可能跟它是第三方權重有關。沒驗證之前不寫成結論。

AWQ 和 GPTQ:能量到的維度全部一樣

AWQ GPTQ 差
權重 5.2037 GiB 5.1799 GiB 0.5%
KV 池 34.18 GiB 34.20 GiB 0.06%
tokens 639,984 640,432 0.07%
頁 39,999 40,027 0.07%
TPOT 8.01 ms 7.91 ms 1.2%

同樣是 int4,省下的空間相同,跑起來也一樣快。所有可量的維度都一樣,所以差別只剩準確度。

TTFT 這一欄看不出東西

BF16 63.5 ms    AWQ 70.3 ms    GPTQ 36.8 ms    FP8 27.4 ms

沒有規律。更糟的是,同一個請求先前量到的是 31.0 毫秒,同一組設定跨執行差兩倍。

原因是這個 prompt 只有 3 個 token,prefill 幾乎全是固定開銷,大約 23 毫秒。這個實驗設計量不出精度對 prefill 的影響,所以這一欄只是照實貼出來,不拿它說事。

TPOT 不是這樣。昨天同一個請求量到 20.84 毫秒,今天 BF16 量到 20.82,跨兩天跨兩次重啟差 0.1%。decode 的成本穩定到可以拉迴歸線,prefill 在這個 prompt 長度下量不出東西。


實驗二:PagedAttention,把池子灌到 100%

昨天 16 個並行只用掉池子的 10.06%,全程沒有人排隊,那時候完全看不出這個池子是分頁的。

所以這次反過來,把池子縮到很小再灌爆它。趁 AWQ 還在(權重只有 5.2 GiB,池子才縮得下去),把預算砍到 0.20:

kubectl -n vllm patch deploy vllm --type=json \
  -p '[{"op":"replace","path":"/spec/template/spec/containers/0/args/1","value":"--gpu-memory-utilization=0.20"}]'
kubectl -n vllm rollout status deploy/vllm --timeout=15m
~/vllm-lab/q.sh
non-default args: {'model': 'Qwen/Qwen2.5-7B-Instruct-AWQ', 'gpu_memory_utilization': 0.2}
Model loading took 5.2037 GiB and 2.219690 seconds
Available KV cache memory: 3.10 GiB
GPU KV cache size: 58,128 tokens
block_size = 16   num_gpu_blocks = 3633

3,633 頁、58,128 個 token。然後灌 32 個 max_tokens: 4096 的請求:

kubectl -n vllm run fill --restart=Never --image=curlimages/curl:8.11.1 -- sh -c '
for i in $(seq 1 32); 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-AWQ\",\"prompt\":\"Write an extremely long and detailed essay, number $i.\",\"max_tokens\":4096,\"temperature\":0.7}" &
done
wait
echo DONE'

一邊跑一邊輪詢:

for i in $(seq 1 12); do
  printf '%s  ' "$(date +%T)"
  kubectl -n vllm exec deploy/vllm -- python3 -c "
import urllib.request
t=urllib.request.urlopen('http://localhost:8000/metrics').read().decode()
for w in ['num_requests_running','num_requests_waiting','kv_cache_usage_perc','num_preemptions_total']:
    for l in t.splitlines():
        if l.startswith('vllm:'+w+'{'): print(w, l.split()[-1], end='   '); break
print()"
  sleep 5
done
06:09:07  running 31  waiting  0   35.0%   preempt  0
06:09:12  running 30  waiting  0   52.9%   preempt  0
06:09:18  running 27  waiting  0   66.2%   preempt  0
06:09:24  running 27  waiting  0   84.7%   preempt  0
06:09:30  running 26  waiting  1   97.9%   preempt  0
06:09:36  running 22  waiting  4   96.1%   preempt  1
06:09:42  running 20  waiting  6   99.7%   preempt  1
06:09:48  running 17  waiting  9   95.5%   preempt  1
06:09:54  running 16  waiting 10  100.0%   preempt  1
06:09:59  running 14  waiting 12   95.6%   preempt  1
06:10:05  running 12  waiting  0   60.3%   preempt 14
06:10:10  running 11  waiting  0   61.3%   preempt 14

(kv_cache_usage_perc 是 0 到 1 的比例,上面已經換算成百分比。)

三幕:

時間點 發生什麼
爬到 97.9% waiting 從 0 變 1,開始有人排隊
到 100.0% preempt 開始累加,vLLM 把某些請求的頁扔掉
最後兩格 preempt 從 1 跳到 14、waiting 從 12 掉回 0、用量從 95.6% 掉到 60.3%

最後那一跳是關鍵:一口氣踢掉 13 個請求,把排隊的全部放進來。

被踢掉的代價是那些請求已經算好的 KV 全部丟掉,輪到它們的時候要重算。而這正是分頁跟「一次配一整塊」的差別:整塊配下去只能等別人做完,分頁可以把別人的頁收回來。

池子夠用的時候,你完全看不到它是分頁的。

跑完記得把預算改回 0.90,不然後面的數字對不起來。


實驗三:推測解碼,一次 forward pass 拿六個 token

要看的是同一個請求在開跟關之下,產出 256 個 token 各花了幾次 forward pass。

量法是 /metrics 裡的三個計數器(草稿數、猜測的 token 數、被接受的 token 數),加上 time_per_output_token_seconds_count。最後那個是關鍵:vLLM 每做一次 forward pass 記一筆,所以它直接等於 forward pass 的次數。

先把它開起來:

kubectl -n vllm patch deploy vllm --type=json \
  -p '[{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--speculative-config={\"method\":\"ngram\",\"num_speculative_tokens\":5,\"prompt_lookup_max\":4,\"prompt_lookup_min\":1}"}]'
kubectl -n vllm rollout status deploy/vllm --timeout=15m
kubectl -n vllm logs deploy/vllm | grep -iE "speculative|drafter"
non-default args: {'model': 'Qwen/Qwen2.5-7B-Instruct-AWQ',
  'speculative_config': {'method': 'ngram', 'num_speculative_tokens': 5,
                         'prompt_lookup_max': 4, 'prompt_lookup_min': 1}}
Initializing a V1 LLM engine (v0.11.0) with config: ...
  speculative_config=SpeculativeConfig(method='ngram', model=None, num_spec_tokens=5),
  quantization=awq_marlin, ...
INFO [gpu_model_runner.py:2641] Loading drafter model...

一次猜五個,prompt_lookup 那兩個值是找片段時的視窗大小。

送一個猜得到的請求

ngram 靠的是重複,所以給它最極端的輸入:prompt 就是 1 2 3 4 5 重複十次,99 個 token。

P="1 2 3 4 5 1 2 3 4 5 1 2 3 4 5 1 2 3 4 5 1 2 3 4 5 1 2 3 4 5 1 2 3 4 5 1 2 3 4 5 1 2 3 4 5 1 2 3 4 5"
curl -s .../v1/completions \
  -d "{\"model\":\"...AWQ\",\"prompt\":\"$P\",\"max_tokens\":256,\"temperature\":0,\"repetition_penalty\":1.0}"

輸出是完美的逐字重複:

' 1 2 3 4 5 1 2 3 4 5 1 2 3 4 5 ... 1 2 3'
usage: {'prompt_tokens': 99, 'total_tokens': 355, 'completion_tokens': 256}

請求裡那個 repetition_penalty: 1.0 是必要的。v0.11.0 會跳過任何動過取樣參數的請求,三種 penalty、min_p、logprobs 只要有一個不是預設值就不做推測解碼。而 Qwen2.5 建議的 repetition_penalty 是 1.05,會被 vLLM 當成預設值套上去:

kubectl -n vllm exec deploy/vllm -- sh -c 'cat /hf/hub/models--Qwen--Qwen2.5-7B-Instruct-AWQ/snapshots/*/generation_config.json'
{ ..., "repetition_penalty": 1.05, "temperature": 0.7, "top_k": 20, "top_p": 0.8 }

快照:

prompt_tokens_total                        99.0
generation_tokens_total                    256.0
e2e_request_latency_seconds_sum            2.2906148433685303
time_to_first_token_seconds_sum            1.6780633926391602
time_per_output_token_seconds_count        43.0
request_prefill_time_seconds_sum           1.676389888999438
request_decode_time_seconds_sum            0.6129029120002087
spec_decode_num_drafts                     43.0
spec_decode_num_draft_tokens               215.0
spec_decode_num_accepted_tokens            215.0

43 次草稿、215 個猜測、215 個被接受。接受率 100%,因為輸入是完美的五字循環,ngram 不可能猜錯。

不寫那一行再送一次

這次沒有碰 Deployment,--speculative-config 還在,推測解碼一直開著。差別只在請求本身:同一個 prompt,但不寫 repetition_penalty,讓模型建議的 1.05 生效。取差值:

generation_tokens_total                 +256
time_per_output_token_seconds_count     +255
request_decode_time_seconds_sum         +2.087064
time_to_first_token_seconds_sum         +0.011185
e2e_request_latency_seconds_sum         +2.0982
spec_decode_num_drafts                    +0
1.05(模型建議值) 1.0(明寫)
spec_decode_num_drafts 0 43
spec_decode_num_draft_tokens 0 215
spec_decode_num_accepted_tokens 0 215
接受率 100.0%
ITL 樣本數 255 43
decode 總時間 2087.1 ms 612.9 ms
每個 token 8.153 ms 2.394 ms

這一節的「每個 token」是 decode 總時間除以 256 個產出 token,不是除 ITL 樣本數。開了推測解碼之後一個 ITL 樣本涵蓋好幾個 token,除它得不到每 token 的成本。

decode 快 3.41 倍。

兩邊的差別不是「有沒有開推測解碼」,兩邊都開著,是開了之後它有沒有被放行。

而最能說明機制的是 ITL 的樣本數。1.05 那邊是 255 個,也就是 256 個 token 減一,每一步吐一個字。1.0 這邊只有 43 個。

43 次 forward pass 產出 256 個 token,平均每一步 5.95 個。 一次拿到六個:一個真的,加五個猜中的。

第一個請求拿不到這個好處

開了之後第一個請求的 prefill 是 1676.4 毫秒,而這篇其他請求的 prefill 都在幾十毫秒的量級。原因幾乎可以確定是 numba 的即時編譯,ngram proposer 第一次被呼叫時要先編譯,這一條是推論。

所以推測解碼省下來的時間,第一個請求拿不到。


小結

量化把權重從 14.25 GiB 換成 5.20 GiB,省下的全部變成 KV cache,多買到一萬多頁;而 decode 的每 token 成本幾乎只跟權重大小成正比,1.4161 ms/GiB,GPTQ 的預測誤差 0.8%。

分頁在池子夠用的時候完全看不見,灌到 100% 才現身:一口氣踢掉 13 個請求,把排隊的放進來。

推測解碼讓 43 次 forward pass 產出 256 個 token,decode 快 3.41 倍,而這個好處第一個請求拿不到,因為 ngram proposer 第一次被呼叫的時候 numba 要先做即時編譯,那一次的 prefill 花了 1676.4 毫秒。


參考資料

vLLM Documentation
vllm-project/vllm
vLLM: Quantization
vLLM: Speculative Decoding
vLLM: N-Gram Speculation
vLLM: Production Metrics
vLLM v0.11.0: vllm/v1/spec_decode/utils.py
vLLM v0.11.0: vllm/config/speculative.py
vLLM v0.11.0: vllm/v1/spec_decode/ngram_proposer.py
vLLM: Easy, Fast, and Cheap LLM Serving with PagedAttention
Efficient Memory Management for Large Language Model Serving with PagedAttention
Qwen/Qwen2.5-7B-Instruct-AWQ
RedHatAI/Qwen2.5-7B-Instruct-FP8-dynamic


上一篇
【Day 15】Kubernetes 上的 vLLM:從 KV cache 到 prefill 與 decode
下一篇
【Day 17】從 vLLM 遷移到 SGLang:RadixAttention 怎麼運作的
系列文
凌晨四點,女友帶著 GPU 來我家學習 Kubernetes:打造 K8s AI Infra 的 30 夜 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言