昨天把 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 借的是作業系統的分頁:把一個序列的 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}'
~/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
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
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
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 數。
用 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 | 差 | |
|---|---|---|---|
| 權重 | 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,省下的空間相同,跑起來也一樣快。所有可量的維度都一樣,所以差別只剩準確度。
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 長度下量不出東西。
昨天 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,不然後面的數字對不起來。
要看的是同一個請求在開跟關之下,產出 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