iT邦幫忙

2026 iThome 鐵人賽

DAY 3
0
AI Engineering

不是模型太慢,是你沒算過這筆帳:地端 LLM 工程實戰 30 天系列 第 3

Day 03 - 壓測不是 for 迴圈:vllm bench serve 的正確用法

  • 分享至 

  • xImage
  •  

Day 03 - 壓測不是 for 迴圈:vllm bench serve 的正確用法

大多數人的第一版壓測是一個 Python for 迴圈:串行打 100 發請求、耗時加總除以 100、宣布「平均延遲 800ms」。我自己也寫過。後來真的有十個人同時來問,TTFT 直接衝破十秒——那個 for 迴圈從頭到尾沒有回答過「幾個人同時用會怎樣」。

先講結論:**壓測的重點不是發幾發請求,而是「怎麼發」。**同樣 100 發,串行發、定速發、固定併發發,量到的是三種不同的東西;選錯發法,數字再漂亮都是自欺。今天把發法一次講清楚,後面每一個實測數字都站在它上面。

vllm bench serve 是發射器,不是 vLLM 專用品

先破除名字造成的誤會:vllm bench serve 雖然掛著 vLLM 的名字,本質卻是一個 load generator(負載發射器)——掛上 --endpoint-type openai-chat --base-url 之後,任何 OpenAI 相容的 endpoint 它都能壓:llama-server、Ollama、mlx_lm.server,甚至雲端 API(來源:vLLM bench CLI 文件)。

所以本系列的角色分工如圖 1:Mac 只當 server,跑 llama-server / Ollama / mlx_lm.server。理由不是「vLLM 上不了 Mac」——官方 installation 文件已把 Apple Silicon 列在 GPU 平台底下,走 vllm-metal(官方組織下的 plugin,底層 MLX)就裝得起來。

湊得齊不等於對等:權重要 MLX-native 量化、Metal 沒有容器 GPU passthrough 只能跑 host,Docker 官方的同機對照:同一顆 Llama 3.2 1B 4-bit,llama.cpp 快 vLLM-Metal 約 1.2–1.3×,後者波動還大得多(134–343 t/s)

發射器永遠跑在受測者對面那台:壓 Mac 時 client 在雙卡機;反過來壓雙卡機時 client 搬回 Mac。

但有個前提:macOS 沒有 vLLM 的預編譯 wheelpip install vllm 在 Mac 上裝不起來【官方逐字:「Currently, there are no pre-built Apple silicon CPU wheels.」】。想留在同一套指令,就裝上面那個 vllm-metal 外掛

不想動環境,Mac 端改用 GuideLLM(pip install guidellm[recommended],官方支援 macOS,一樣打任何 OpenAI 相容端點、一樣報 TTFT/ITL/分位數)【官方,GuideLLM README】。發射器換人,方法論不換。

同機自打也很實際:發射器要吃 CPU 開幾百條 async 連線、算統計,會跟推論引擎的 tokenizer 與 HTTP handler 搶核心,你量到的是兩個程式打架。

https://ithelp.ithome.com.tw/upload/images/20260814/20183550NBJNywq1Jz.png
圖 1:發射器永遠住在受測者對面——同機自打量到的是兩個程式搶 CPU,不是 server 的實力。

for 迴圈的三宗罪

  • **罪一:串行 = 量到延遲,不是吞吐。**迴圈裡同一時間永遠只有一發在飛,GPU 的 batching 能力完全沒被碰到。你量到「一個人用的延遲」,卻拿去回答「能服務幾個人」。
  • **罪二:沒有 warmup。**第一發撞上模型 lazy load、cache 全冷,冷啟動被平均進去,樣本又少,整組數字被拖歪。
  • **罪三:只有平均值。**使用者的體感是 p95、p99 尾延遲。平均 800ms 的服務,p99 可以是 8 秒——平均值會替你把災難藏起來。

vllm bench serve 三罪全解:async 併發發射、正式計時前可先熱身、--percentile-metrics ttft,tpot,itl,e2el 搭配 --metric-percentiles 直接吐分位數。

開環、閉環,和那個害人的 inf

發法由三個參數決定,對應兩種模式(圖 2):

開環(open-loop):--request-rate N——由時鐘決定何時發,每秒 N 發,不管前一發回來了沒。這模擬真實世界:使用者不會因為你的 server 慢就少來。server 追不上時隊伍越排越長,排隊時間灌進 TTFT——這正是你要看的「超載長什麼樣」。

注意陷阱:這個參數預設值是 inf,意思是把全部請求在第 0 秒一次全發(來源:vLLM bench CLI 文件)。不帶參數跑一次就以為量到吞吐,其實 TTFT 全是排隊時間,什麼服務水準都推不出來——這是最常見的誤用。搭配 --burstiness 可以調到達分布:1.0 是 Poisson 到達,小於 1 更陣發、大於 1 更均勻

閉環(closed-loop):--request-rate inf --max-concurrency M——常駐 M 條併發,收到回覆才補下一發。server 變慢,發射自動跟著慢,所以永遠看不到排隊爆炸;它量的是「穩定壓力下的極限吞吐」。

https://ithelp.ithome.com.tw/upload/images/20260814/20183550bshU4CHWfx.png
圖 2:開環量使用者的真實體感(含排隊),閉環量 server 的極限吞吐——兩個都要跑,但別把閉環數字當成使用者體感。

兩種都有用,危險的是拿錯:capacity planning 要開環掃出「到達率多少開始崩」;比較引擎極限用閉環

沒有「業界標準 SLO」這種東西

壓完會得到一堆分位數,下一個問題是「多少才算合格」。誠實說:「業界標準 SLO」沒有一手出處,我能找到的只有三個公開錨點,而且性質一個比一個弱:

錨點 數字 性質
Anyscale 文件 TTFT < 500ms、TPOT < 15ms、E2E < 2s 【官方(示例)】官方自己標明是示例,不是標準(來源:Anyscale benchmarking 文件)
Databricks blog TPOT 100ms = 每人 10 tok/s ≈ 450 words/min,快過一般人閱讀 【官方 blog】「夠用」的論證,不是標準(來源:Databricks LLM inference 最佳實務)
Spheron blog 互動式聊天 TTFT p99 300ms/ITL p99 50ms;RAG 聊天 400ms/80ms 【引用】昨天引過的那份 GPU 雲廠商 blog,作者自陳目標「來自使用者感知,不是技術可達性」——當起點可以,當標準不行

所以正確姿勢不是抄別人的 SLO,而是用 --goodput KEY:VALUE 把自家草案寫進壓測:例如 --goodput ttft:2000 tpot:100,意思是「TTFT 2 秒內且 TPOT 100ms 內的請求才算數」,報表直接告訴你達標比率。吞吐的定義從「每秒吐幾個 token」升級成「每秒服務幾個達標請求」——這才是能對老闆交代的數字。

讀圖法也補一課:SemiAnalysis 的 InferenceX(前身 InferenceMAX)每晚重跑各引擎 × 各硬體,畫的是「總吞吐 × 每人互動速度(tok/s/user)」的 Pareto 前緣【引用】(來源:SemiAnalysis InferenceX)。

讀法只有一句:右上角不存在,你永遠在「多服務幾個人」和「每個人快一點」之間選一個點。

我們的 request-rate 掃描,就是在畫自己機器的這條線。

12GB 怎麼瓜分:為什麼主角是 8B

雙卡機單卡只有 12GB,壓測主角我選 8B 的 AWQ(4-bit)版,FP8 當對照。這是算出來的:gpu_memory_utilization 的預設值會隨版本漂移(自己跑 --help 對一次),本篇一律顯式指定 0.9,只拿 12GB 的九成 ≈ 10.8GB。

這 10.8GB 要養三張嘴:

  • 權重:8B AWQ 約 5.7GB【推算:約 7B 參數 × 0.5 byte + embedding 與 lm_head 保留 FP16 約 2GB + 量化 scale 開銷】;FP8 版 8B × 1 byte ≈ 8GB
  • activations:vLLM 啟動時先跑一次 dummy forward 實測峰值,經驗值抓 0.5–1GB。
  • KV cache:剩下全歸它。Llama 3.1 8B 每 token 的 KV = 2(K+V)× 32 層 × 8 個 KV head × 128 維 × 2 byte = 128KB

結帳:AWQ 版剩 10.8 − 5.7 − 0.6 ≈ 4.5GB ≈ 36K token 的 KV,8K context 下約 4 條滿額併發槽;FP8 版只剩約 2.2GB ≈ 18K token,滿額槽剩 2 條。

這就是為什麼主角是 8B AWQ——14B 的權重會把 KV 擠到沒地方站,壓測還沒開始就先輸在容量。
這套帳第之後會展開成完整公式。

今天的壓測指令,直接抄

兩台都能當受測者,方法一模一樣,換的只有 server 那行和 --base-url

今天先跑 Mac:它的槽數是我自己宣告的,容量線遠遠用不完,正好單獨看吞吐線長什麼樣。雙卡機那輪等模型與顯存的帳都算完再開。

# 受測端:Mac 起 llama-server(8B Q4_K_M)
llama-server -m Llama-3.1-8B-Instruct-Q4_K_M.gguf \
  --host 0.0.0.0 --port 8080 -c 32768 -np 4 -fa on --metrics
#   -c 是「總 context」,-np 4 均分後每槽 8K
#   --metrics 開 Prometheus 端點,壓的時候順便側錄水位

# (雙卡機那輪的起法,一併放這裡備查)
# vllm serve hugging-quants/Meta-Llama-3.1-8B-Instruct-AWQ-INT4 \
#   --max-model-len 8192 --gpu-memory-utilization 0.9 --port 8000

# 發射端(跑在受測者對面那台) ── 步驟 ①:先量單流,把容量算出來
vllm bench serve --endpoint-type openai-chat \
  --base-url http://<mac-ip>:8080 --endpoint /v1/chat/completions \
  --model Llama-3.1-8B-Instruct-Q4_K_M \
  --tokenizer NousResearch/Meta-Llama-3.1-8B-Instruct \
  --dataset-name random --random-input-len 1024 --random-output-len 256 \
  --num-prompts 16 --max-concurrency 1 --request-rate inf --seed 42 --ignore-eos

# 步驟 ②:用單流的 E2E 算網格中心
#   容量上限 = 槽數 ÷ 每請求佔槽時間 = 4 ÷ 5.77s ≈ 0.69 req/s
#   ⚠️ 分母要用 E2E,不是「輸出長度 ÷ tok/s」——prefill 那段也佔著槽
#   網格從上限的 1/4 鋪到 4 倍才夾得住膝點
for r in 0.17 0.35 0.7 1.4 2.8; do    # ← 換成你自己算出來的中心值
  vllm bench serve --endpoint-type openai-chat \
    --base-url http://<mac-ip>:8080 --endpoint /v1/chat/completions \
    --model Llama-3.1-8B-Instruct-Q4_K_M \
    --tokenizer NousResearch/Meta-Llama-3.1-8B-Instruct \
    --dataset-name random --random-input-len 1024 --random-output-len 256 \
    --num-prompts $(python3 -c "print(max(40,int($r*150)))") \
    --request-rate $r --burstiness 1.0 --ignore-eos \
    --percentile-metrics ttft,tpot,itl,e2el --metric-percentiles 50,95,99 \
    --goodput ttft:2000 tpot:100 --seed 42
done
#   num-prompts 跟著 rate 縮放,讓每檔都跑約兩分半——固定 200 發在低 rate 要等 20 分鐘

# 閉環對照:固定 4 條併發打到飽(槽數多少就開多少)
#(同上,把 --request-rate $r 換成 --request-rate inf --max-concurrency 4)

三個我實際踩到的坑,照抄之前先看一眼

  • --backend 這個參數在新版已經無效,要用 --endpoint-type。寫錯不會報錯,它會默默拿 /v1/completions 的格式去打 chat 端點,然後丟一句沒頭沒尾的 Bad Request
  • 打 Ollama 的話,這張表會是廢的。 vLLM 送的是新版欄位 max_completion_tokens,而 Ollama 只認 max_tokens——同一顆模型、同一個 prompt,要求 16 個 token,max_tokens 老實給 16,max_completion_tokens 給了 1004 個。壓測時每個請求都吐到 context 滿為止(我 log 裡看到 n_decoded 衝到一萬三),跑一小時都跑不完,而且 TPOT 全是假的。llama-server 沒這問題。Day 02 說 Ollama 底層是 llama.cpp——底層一樣,但殼不一樣,這就是代價。
  • meta-llama/... 是 gated repo--tokenizer 指過去會要你登入;換成 NousResearch/Meta-Llama-3.1-8B-Instruct 這個鏡像,同一份 tokenizer,免登入。

先看步驟 ① 在我這台跑出什麼——T1 M4 Max 128GB,llama.cpp(Metal),Llama-3.1-8B-Instruct Q4_K_M,4 槽 × 8K。我跑了三輪(同一組 prompt 重跑一次、換一組 prompt 再跑一次)【實測】:

指標 三輪的中位 三輪全距
TTFT p50 2.0 s 1.9 – 2.1 s
TPOT p50 15.5 ms 13.7 – 17.9 ms(每流 56 – 73 tok/s)
E2E p50 5.8 s 5.3 – 6.6 s

**先講那個全距,它比中位數更值錢。**同一台機器、同一顆模型、連 prompt 都同一組,重跑一次 TPOT 就差了 12%;換一組 prompt 差到 27%。所以任何人跟你報「這張卡 TPOT 15.51 毫秒」——包括我——你都該先問他跑了幾輪。我上面刻意只寫到小數點後一位,因為第二位在這個雜訊裡沒有意義。這也是 Day 02 那句「跑五次、第一次當暖身、取中位數」的實體版:不是儀式,是因為單跑一次真的會騙你。

E2E 那格就是算容量的分母:4 槽 ÷ 5.8 秒 ≈ 0.7 req/s(三輪落在 0.61 – 0.75,量級穩定,這就夠了——網格只需要對到量級)。

順帶跟上面那張 SLO 表對一次:Anyscale 的示例是 TPOT < 15ms,我這台三輪是 13.7 / 15.5 / 17.9——一輪過、兩輪沒過。同一台機器對同一條線可以給出三個不同答案,這就是為什麼那格寫「示例」而不是「標準」。

掃描那五檔加閉環,我留到 Day 14 一起結帳——因為那天才有對答案的工具:Little's Law 會從紙上算出這台機器該在哪個 rate 崩,掃描表則從實機量出它實際在哪裡崩。兩個數字擺在一起,公式才算被驗過。形狀可以先押:rate 低時 TTFT 平坦,過了某個點 p95 先炸、p50 後炸。

今天的實驗需要什麼

一台跑得動 8B 量化模型的機器當 server(NVIDIA 8GB+ 或 Apple Silicon 16GB+),加上第二台當發射器:Linux/容器直接 pip install vllm,Mac 端裝 vllm-metal 外掛或 GuideLLM。真的只有一台的讀者,同機跑也能練指令,但要知道數字會偏——偏多少,分開跑一次對照就知道。

小結

  • vllm bench serve 是 load generator,--endpoint-type--base-url 可以壓任何 OpenAI 相容 server;發射器永遠跑在受測者對面那台。
  • for 迴圈三宗罪:串行量不到吞吐、無 warmup、無分位數。
  • 開環(--request-rate)量含排隊的真實體感,閉環(--max-concurrency)量極限吞吐;--request-rate 預設 inf 是 time-0 全發,不帶參數的數字不可信。
  • 「業界標準 SLO」沒有一手出處,只有 Anyscale 示例、Databricks 論證、Spheron 感知目標三個弱錨點;用 --goodput 把自家 SLO 寫進壓測。
  • 12GB × 0.9 的預算裡,8B AWQ 留得住 4.5GB 的 KV,所以它是本系列 12GB 上的壓測主角。

尺跟發射器都備好了,下一個問題是往槍口前面站的受測者:模型。明天 Day 04〈讀懂模型名字:30B-A3B 到底是 30B 還是 3B〉——名字裡的每一段都在暗示它跑起來的樣子,讀錯一段,容量帳就會差出十倍。

咱們明天見。


上一篇
Day 02 - TTFT、TPOT、ITL:別再說「好慢」,一次請求的時間帳
下一篇
Day 04 - 讀懂模型名字:35B-A3B 到底是 35B 還是 3B
系列文
不是模型太慢,是你沒算過這筆帳:地端 LLM 工程實戰 30 天5
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言