優化 Transformer 模型服務參數 – Apple Silicon GPU 的案例
2026年7月11日
一台 MacBook Pro M4 Max、一個開源 MoE 模型,針對推論優化的一次實戰:為什麼讓 LLM 吞吐量崩跌 100 倍的是 prefill(而不是 decode),以及兩個 prefill 側的關鍵優化。
GPU 佔用時間軸:當 benchmark 的 prefill 區塊佔住 GPU 時,LLM 的 decode 速度從每秒 65 個 token 崩跌到每秒 0.67 個。
我有一台 MacBook Pro M4 Max,它是我的 portable LLM/VLM lab。
一開始只是無傷大雅地在上面跑跑各家開源模型,純 vibe 感受效果。但事情越玩越大:我開始在本機 host LLM/VLM 來驅動 coding agent,還同時跑大型 benchmark——幾百道題,每題帶著 10–25k+ token 的 prompt,好幾個 workers 同時發問。某天晚上,我的聊天突然慢到不行,token 像瀝青在滴,慢到我可以跟著唸。實際量出來的數字比體感還糟:每秒 0.67 個 token,而這台機器在沒有其他負載時,decode 速度是 65 tok/s。
跌了 100 倍,沒有 crash、沒有錯誤,GPU 還明顯一直在忙。這篇文章記錄的就是這次的診斷與解法。背後的原理是教科書等級的:prefill 卡算力(compute-bound),decode 卡記憶體頻寬(memory-bandwidth-bound)。但幾乎所有討論這件事的文章,都假設你人在資料中心,可以往問題丟更多 GPU。這篇要講的是另一個版本的故事:一台 Metal 機器、兩種工作負載,以及一個決心把它物盡其用的我。
環境
模型是一個 35B 參數的 mixture-of-experts,活躍參數約 3B,量化成 mxfp8(每個 output token 大約要讀 3 GB 的權重),跑在一個支援 batching 的 MLX 引擎(mlx-vlm)上,對外提供 OpenAI 相容的 endpoint。硬體是 M4 Max:128 GB 統一記憶體,頻寬 546 GB/s。
兩種性質完全不同的租戶共用這台機器:
- Agent 聊天。 多輪、會呼叫工具的 session。每一輪都會重送到目前為止的完整對話,所以一個 session 會累積出一條很長的 warm prefix;我的通常在 23k token 左右。這種情境下,我就坐在螢幕前看著 token 即時流出來,延遲一拖長,整個體驗就垮了。
- Benchmark。 一輪跑分約 150 道題,每題都是全新的 10–25k+ token prompt(長文件加檢索上下文),由好幾個 workers 並行送出。以刷榜實驗的情境來說,人不需要在旁邊等,分數出來時再通知我就好。
「聊天」只是我手上的具體案例,它代表任何互動式串流:agent 迴圈、coding assistant,任何有人在另一端等 token 的東西;benchmark 則代表任何批次工作。
以一整天平均下來,這台機器每輸出 1 個 token,就要先讀進 77 個 token:77:1 的輸入輸出比。對這顆 GPU 來說,工作壓倒性地是讀 prompt,不是寫 token。先記住這個比例,後面會再回來談它。
兩種工作,兩種瓶頸
Transformer 處理一個請求分成兩個階段,而這兩個階段壓的是機器完全不同的部位。
Decode 速度隨記憶體頻寬變動。 每個 decode step 都要把模型的活躍權重從記憶體完整讀一遍,然後替每一條進行中的 sequence 各產出一個 token。算術上就是一道除法:
理論上限: 546 GB/s ÷ 每步約 3 GB ≈ 180 tok/s
實際量測: 60–70 tok/s (大約只有理論值的三分之一)
不過這道天真的除法漏算了很多東西:
- KV cache 的讀取。 這是一個 hybrid 模型:只有大約四分之一的層用 full attention(其餘是線性 attention),所以只有那些層會留下一份會長大的 KV cache(整體約每 token 20 KB),並在每一步整段重讀。一場累積到 23k token 的聊天,每一步就要多讀約 0.5 GB(23k token × 20 KB),相當於再讀六分之一份權重。kipply 稱它為「sometimes-significant factor」;DeepMind 的 scaling book 則直接把它放進下限公式:minimum step time = (batch × KV cache + parameters) ÷ bandwidth。
- Kernel 跑不到頻寬峰值。 kipply 估算時只給真實 kernel 理論頻寬的 72–90%;即使是手工調校過的 FasterTransformer,在 A100 上 decode 一個 13B 模型,實測也要每 token 22 ms(約 45 tok/s),而純算術的預測是 18.5 ms(約 54 tok/s)。
- 矩陣乘法之間的所有小操作。 softmax、layernorm、residual:這些都是 memory-bound 的小型 pass,只除以權重的那道算式完全沒有計入。
- 這套 stack 自己的稅。 mlx-vlm 每一步的固定 overhead,加上 MoE 的 experts 散落在記憶體各處,沒辦法一次連續讀完。
所以實際值落在理論值的三分之一,一點都不奇怪。這個 65 tok/s 大約是理想化「有效位元組」roofline 的 36%;剩下的差距是撞到頻寬牆、還是 kernel、routing、啟動的低效,我說不準,因為我沒量 achieved-bandwidth 計數器。速率確實隨頻寬變動;但它是不是被頻寬鎖死在 roofline 上,是一個我還沒證實的假設。
天花板由頻寬決定,跟容量無關:我用到的整個記憶體(權重 + KV cache + prefix cache)尖峰也只佔 128 GB 裡的 40–50 GB,買更多記憶體並不會讓任何單一串流變快。那要怎麼同時服務好幾個人?靠的不是更多記憶體,而是共享同一個 step:batch 開到 N 的時候,attention 和 shared expert 的權重讀一次,就讓 N 條 sequence 各前進一個 token。這是個 mixture-of-experts,被 route 到的 experts 是整個 batch 選擇的聯集、不是固定一組,所以 decode batching 便宜、但不是免費;不過還是夠便宜,讓 decode 共享從來就不是多租戶的問題所在。但這種流量真正的成本從來不只出現在 decode——這正是接下來整篇文章要談的。
Prefill 卡在算力。 一個請求要 decode 出第一個 token 之前,整段 prompt 都得先推過模型一次。這是橫跨整條 sequence 的大型矩陣乘法(40 個 GPU 核心全部吃滿),以 2,048 token 為一個 chunk 處理;文件等級的 prompt,每個 chunk 都要跑一到兩秒。對多租戶情境來說,最致命的是:prefill chunk 在跑的期間,誰都不能 decode。
核心問題:交錯運算
引擎交錯這兩個階段的方式,是在 prefill chunk 之間大約放行一個 decode step。當 GPU 大部分時間都在 decode 時(Agent 聊天情境),你根本不會察覺這件事。但一旦有 benchmark 等級的任務搬進來,GPU 就再也不是大部分時間在 decode 了:
Benchmark 任務本質上就是連續 prefill。每道題都是全新內容,永遠不會命中 prefix cache:保證 cache miss、保證完整 prefill,一輪跑分重複 N 次,通常還有多個 workers 並行把 queue 填滿。任何時刻幾乎都有某個 2,048 token 的 chunk 佔著 GPU,batch 裡的每個 decoder 只能靠 chunk 之間放行的那一步勉強前進。這個案例裡我直接量了一次:單一個 60k token 的 prefill 進行期間,一條並行的聊天串流只剩 0.67 tok/s。體感大概是這樣:
模型服務的文獻對這個現象有個名字,叫 generation stalls(生成停滯),而且文獻裡的數字跟這個案例對得上:prefill 優先的排程器,可以把進行中的 decode 停住好幾秒。Sarathi-Serve 論文(OSDI ‘24)在 vLLM 裡診斷出的正是這個問題,並提出 stall-free batching:限制每次迭代的 prefill 預算,讓 prefill chunk 和進行中的 decode 合併成同一個 batch,而不是把 decode 擠掉。帶著 decode 意識排程的 chunked prefill,後來成了 vLLM V1 引擎的預設,SGLang 也內建同樣的技術。
長駐 KV cache 的代價
還有一個比較隱晦的效應,值得單獨拆出來。每個 decode step 重讀的不只是權重,還有每條進行中 sequence 的 KV cache,按前面說的每 token 約 20 KB 計。聊天等級的上下文還算溫和,但一條停在 130k token 的 benchmark sequence,會讓每一步、每個人都多付 2.6 GB 的讀取(130k token × 20 KB):
最直覺的優化是 KV cache 量化:每步少讀一些 KV 位元組,代價也少一些。但在我固定版本的 mlx-vlm、以及它的並行 multi-row hybrid 路徑上,量化 KV 和 APC(automatic prefix cache)還沒辦法完全並存,開了其中一個就得放棄另一個。對聊天這個租戶來說,選擇很明確:多輪 session 每一輪都會重新命中同一段約 23k token 的 prefix,跳過那段 prefill 的價值遠超過 decode 時省下的 KV 讀取,所以我保留了 prefix cache。mlx-vlm 即將支援兩者並存,見 #1559。如果你的輸入輸出比反過來、以 decode 為主,那量化 KV cache 正是你該伸手拿的那個優化。
兩個關鍵優化
所以真正有效的優化,全發生在 prefill 這一側。這也是整個案例的結論:decode 共享從來不是問題,保護 decode 靠的是控制 prefill 什麼時候、在哪裡發生。
1. 把 prefix cache 加大。 聊天要攤掉自己的 prefill,前提是 cache 裡還留著它的 prefix。我把 prefix cache 的容量調大,讓大約 6 個並行 session 同時保持暖機;現在第 N+1 輪對話能在一秒內還原約 23k token 的 prefix,而不是重新 prefill 幾十秒。還原回來的 prefix 在請求 decode 期間仍然佔用 runtime KV,所以更大的 cache 是多花記憶體、不是省記憶體;它換到的是省下的 prefill 和更低的 TTFT。Apple Silicon 大容量統一記憶體真正的優勢是這個:不是更快的 decode(參考前面那道頻寬除法),而是一個大到能讓你跳過 prefill 的 cache。
2. 幫文件等級的 prefill 加一道 admission gate。 一次只放行一個大型 prefill,而且排程器會守住 chunk 之間的 decode 空隙,不讓排隊中的下一個 prefill 把空隙接走。這樣不管 prefill queue 排得多深,互動式串流都持續拿得到自己的 decode step,精神上很接近 Sarathi stall-free batching 的政策層想法。
兩個優化都到位之後,即使 benchmark 在跑,聊天也能在 prefill chunk 之間的空檔以 60–70 tok/s 串流,而不是龜速爬行,互動的體感回來了;不過整輪 benchmark 期間,GPU 大部分時間其實還在 prefill。GPU 從頭到尾都沒有變快,它只是不再被要求在錯的時間做錯的事。受 DistServe 和 Splitwise 把 prefill 和 decode 拆到不同硬體的想法啟發,長遠來看,不同性質的工作負載放到各自專屬的運算單元上才是正解(有錢的話 🤑)。
TTFT 與 ITL 的取捨
Prefill 和 decode 這場 latency 拉鋸的兩端,在 serving 領域各有正式名稱。TTFT(time to first token)是一個請求等到第一個 token 出現要多久:排隊時間,加上把整段 prompt 推過模型的 prefill 運算,也就是隨 prompt 長度成長的矩陣乘法與 attention。ITL(inter-token latency,也寫作 TPOT,time per output token;Sarathi 論文裡叫 TBT,time between tokens)則是串流開始 decode 之後,token 與 token 之間的間隔:decode step 的節奏。
用這兩個概念重讀一遍這次事故:benchmark 這個租戶的 TTFT 工作,把聊天租戶的 ITL 從約 15 ms 拉長到約 1.5 s(將近 100 倍的退化),而總吞吐量和 GPU 使用率這兩個指標把它整個蓋住了。前面兩個優化也直接對應到這兩個概念:加大 prefix cache 是 TTFT 修正(命中 cache 的回合一秒內就能開始),admission gate 是 ITL 修正(不管 prefill queue 多深,聊天最壞的 token 間隔都被限制在大約一個 chunk 的時間)。
這兩個指標之所以重要,是因為它們在同一顆 GPU 上互相拉扯,而主要的調節桿是兩個 decode step 之間放行多少 prefill,也就是 chunk 的大小(decode 配額、到達率、cache 狀態也都有影響)。chunk 越大,prompt 越快跑完(TTFT 越好),但每條並行串流的 token 間隔也被拉長(ITL 越差)。反過來把 chunk 切細,並行的互動式串流就能喘口氣(ITL 變好),代價是每多一個 chunk 邊界都要付一次固定開銷(kernel 啟動、重讀前面的 KV),讓那個大 prompt 自己反而跑更久(TTFT 變差);推到極端,decode 優先的排程甚至會讓 prefill 完全動彈不得。
舉個具體的例子:下圖的 TTFT 曲線是某個文件等級 prompt 自己的 TTFT。它的第一個 token 得等全部 60,000 個 prompt token 都 prefill 完才會出現,所以不管 chunk 切多小,都低不過約 44 秒的下界;chunk size 只決定它要在下界之上多付多少開銷,以及這段期間其他串流要等多久。chunk size 的兩個極端通常都不是我們要的,重點是針對你的使用情境,把甜蜜點找出來:
以這個案例來說,舒服的甜蜜區間大約在每 chunk 512–1,024 token:並行聊天的 token 間隔維持在約 0.4–0.8 秒,60k token 的 prompt 則比約 44 秒的下界多付 15–30% 的 TTFT。我目前設定的 2,048 token 落在這個區間的右邊:刻意偏向 benchmark 的完成時間,代價是最壞情況下,聊天的 decode 會出現 1.5 秒(0.67 tok/s)的間隔。而這正是 admission gate 的意義:它保證每兩個 prefill chunk 之間都留一段 decode window,讓聊天在那段時間以完整的 60–70 tok/s 串流。所以聊天的體感是:正常速度的串流,中間夾著週期性的短暫停頓,而不是開頭那種一路 0.67 tok/s 的龜速。
延伸討論:市場算力天花板
前面的分析全都受制於同一個數字:546 GB/s。既然整篇文章都在主張「單串流 decode 就是一道頻寬除法」,那把同一道除法套用到真正為推論而生的硬體上(表 1),就能一次看清兩件事:資料中心的錢究竟買到多少頻寬,以及這台筆電在整條光譜上落在哪裡。
| Hardware | Memory | Bandwidth | vs. M4 Max | Indicative decode, this model* |
|---|---|---|---|---|
| Apple M4 (MacBook Pro) | ≤ 32 GB unified | 120 GB/s | 0.2× | ~14 tok/s |
| Apple M5 | ≤ 32 GB unified | 153 GB/s | 0.3× | ~18 tok/s |
| Apple M4 Max (this post) | 128 GB unified | 546 GB/s | 1× | 65 tok/s (measured) |
| Apple M5 Max, 40-core GPU (Mar 2026) | 128 GB unified | 614 GB/s | 1.1× | ~73 tok/s |
| Apple M3 Ultra (Mac Studio, current top) | ≤ 512 GB unified | 819 GB/s | 1.5× | ~98 tok/s |
| Apple M5 Ultra (Mac Studio, rumored late 2026) | ≤ 768 GB unified, rumored | ~1.2 TB/s† | ~2.2× | ~145 tok/s |
| NVIDIA GeForce RTX 3090 | 24 GB GDDR6X | 936 GB/s | 1.7× | ~110 tok/s |
| NVIDIA GeForce RTX 4090 | 24 GB GDDR6X | 1.0 TB/s | 1.8× | ~120 tok/s |
| NVIDIA GeForce RTX 5090 | 32 GB GDDR7 | 1.8 TB/s | 3.3× | ~215 tok/s |
| NVIDIA A100 (SXM, 80 GB) | 80 GB HBM2e | 2.0 TB/s | 3.7× | ~240 tok/s |
| NVIDIA H100 (SXM) | 80 GB HBM3 | 3.35 TB/s | 6.1× | ~400 tok/s |
| NVIDIA H200 | 141 GB HBM3e | 4.8 TB/s | 8.8× | ~570 tok/s |
| NVIDIA B100 / B200 (Blackwell) | 192 GB HBM3e | 8 TB/s | ~15× | ~950 tok/s |
| NVIDIA B300 (Blackwell Ultra) | 288 GB HBM3e | 8 TB/s | ~15× | ~950 tok/s |
| NVIDIA Rubin (late 2026, HBM4) | 288 GB HBM4 | 13 TB/s | ~24× | ~1,500 tok/s |
| Groq LPU | 230 MB SRAM per chip | 80 TB/s on-die | ~146× | ~9,500 tok/s on paper (see below) |
| SSD offload tier (e.g. Phison aiDAPTIV+) | TB-scale NAND | ~7–14 GB/s | ~0.01–0.03× | ~1–2 tok/s (see below) |
* 用我實測的 65 tok/s 按頻寬比例直接外推:同一個每步約 3 GB 的 MoE、batch 1、軟體效率假設不變。真實數字會隨 serving stack 與 batch 深度移動,而且記憶體較小的那幾列(24–32 GB)根本裝不下這個約 40–50 GB 的部署;這一欄請當成「純頻寬的反事實推算」,不是 benchmark 成績。
† 尚未發表。Apple 跳過了 M4 Ultra,所以目前 Mac Studio 的頂規停在 M3 Ultra;M5 Ultra 這一列假設 UltraFusion 會把 M5 Max 的 614 GB/s 翻倍——之前每一代 Ultra 都是這個模式。如果真的照這樣出貨,最高階的 Mac 會落在 A100 頻寬的六成左右。
這張表可以讀出四件事。
Decode 速度與頻寬成正比。 公開的 SOTA 數字也印證這件事。Groq 在 GroqCloud 上以每使用者 460+ tok/s 跑 Llama 4 Scout;NVIDIA 則靠 DGX B200(每顆 GPU 8 TB/s)加上 EAGLE-3 speculative decoding,寫下每使用者 1,000 tok/s 的 Llama 4 Maverick 紀錄。speculative decoding 看起來是軟體技巧,骨子裡解的還是頻寬問題:先便宜地起草一批 token,讓每一次昂貴的全權重讀取一口氣驗證好幾個,而不是只驗證一個。所以下次看到廠商宣傳驚人的 tokens per second,第一個該問的不是「用了什麼排程器」,而是「用了什麼記憶體系統、每個 token 要讀幾個位元組」。而且這招不是資料中心的專利:mlx-vlm 也支援 speculative decoding(EAGLE-3 也在內)。
Groq 把容量與頻寬的取捨推到極限。 80 TB/s 來自直接把權重放在晶片內的 SRAM,但每顆晶片只有約 230 MB,所以一個模型得橫跨幾百顆晶片,排成一條 deterministic pipeline。這等於把取捨整個反過來:每顆晶片幾乎沒有記憶體,對僅有的那點記憶體卻有誇張的頻寬,disaggregation 不是後來的優化,而是這個架構與生俱來的前提。也因此表格裡那個 ~9,500 tok/s 純屬紙上數字:沒有任何模型塞得進單顆晶片,而權重一旦鋪到幾百顆晶片上,單顆 SRAM 的頻寬就不再是你拿來除的那個數字;實際步調由 interconnect hops 和 pipeline 深度決定,GroqCloud 在這個量級的模型上,實測是每秒幾百個 token,不是幾千。統一記憶體的 Mac 是同一個設計空間的另一個極端:一整池 128 GB、一顆晶片,每個位元組都一樣慢——546 GB/s。
SSD offload 是同一道除法,只是分子小得可憐,而且 decode 首當其衝。 Phison 的 aiDAPTIV+ 這類方案用 NAND 把 GPU 記憶體往外擴:容量上看 TB 級,但一顆高速 NVMe 的讀取速度只有約 7–15 GB/s(PCIe Gen4 到 Gen5),拼個小陣列也不過每秒幾十 GB。一旦把「每步都要讀」的位元組放上去,除法毫不留情:14 GB/s ÷ 每步約 3 GB,天花板只有約 5 tok/s,還沒算軟體效率,比最入門的 M4 都慢。對這個模型來說,prefill 也不是避風港:一個 2,048 token 的 chunk 會把 token 路由到每層幾乎所有的 experts,所以它碰到的是整個模型幾十 GB 裡的一大部分,不是單一 token 的那 ~3 GB。從 SSD 把這些串流進來得花好幾秒,跟 chunk 本身約 1.5 秒的運算時間相當、甚至更久,所以 offload 層連 prefill 都卡。規則因此很簡單:SSD 只放「每回合讀一次」的位元組,絕不放「每步都要讀」的位元組。下放到 SSD 的 prefix cache 正是好例子:還原一個 23k token 的 prefix(約 460 MB 的 KV)遠低於 100 ms,換到的卻是省下幾十秒的 prefill;至於每個 token 都要碰的權重,就得乖乖留在快的記憶體裡。這條分界也解釋了為什麼這類方案主打 fine-tuning 和批次任務,而不是互動式 serving。
這台筆電真正的罩門不是頻寬,是 prefill 算力。 以每瓦頻寬來看,一台整機牆插功耗約一百瓦的筆電有 546 GB/s,而一張光模組就要 700 W 的 H100 有 3.35 TB/s,兩者其實在同一個量級。資料中心硬體真正拉開差距的是矩陣乘法吞吐量:H100 的 tensor core 大約是 M4 Max 可用矩陣乘法速率的 15–30 倍,頻寬優勢卻只有約 6 倍。這個不對稱,正好解釋了為什麼這個案例的失效模式是 prefill 而不是 decode;也解釋了為什麼 Apple 在 M5 世代把 Neural Accelerators 做進每個 GPU 核心、對準 AI 算力(Apple 宣稱相對 M4 有約 4 倍的 GPU AI 峰值算力),補的正是這種工作負載最痛的地方。同時,Mac 的老本行沒變:每塊錢買到的容量。GeForce 那幾列從另一個方向講了同一件事:社群最愛拿來架 LLM 的顯示卡,頻寬是 M4 Max 的兩三倍,但 24 GB 的 RTX 4090 連 128 GB 的五分之一都不到,裝得下的模型根本是另一個量級。而就像前面兩個優化展示的:容量買到 cache,cache 讓你跳過 prefill。
結論
- Decode 速度是一道頻寬除法,不是容量的函數。 546 GB/s ÷ 每步位元組數,天花板就定在那裡。在 Apple Silicon 上,能移動天花板的升級是 Ultra 級晶片(頻寬約 819 GB/s,大約 +50%),不是加 RAM。
- 記憶體頻寬替低 batch 的 decode 定出 roofline,但它不是故事的全部。 從 SSD offload 層的約 10 GB/s、MacBook 的 546 GB/s LPDDR、Blackwell 的 8 TB/s HBM3e,到 Groq 的 80 TB/s SRAM,單串流 decode 的速度跟著記憶體系統走(但在差異這麼大的硬體之間並非線性);實際的 tok/s 還取決於每個被接受的 token 要讀幾個位元組、kernel、speculation 和 interconnect。排程的巧思讓你逼近天花板;要移動天花板,只能換硬體。
- 多租戶的傷害來自 prefill。 decode 共享從來不是元凶;一個文件等級的 prefill,就能把所有 decoder 擠到只剩約 1 tok/s。
- TTFT 和 ITL 互相拉扯,chunk size 是主要的調節桿。 chunk 大,偏向 prompt 的完成時間;chunk 小,偏向串流的流暢度。用自己的機器把兩條曲線畫出來,有意識地選甜蜜點,而不是繼承預設值。
- 記憶體容量買到 cache,cache 讓你跳過 prefill。 整條價值鏈就這一句話。先把 prefix cache 的大小對齊你的並行 session 數,再考慮其他優化。
- 優化要對準使用情境。 要不要量化 KV cache,取決於 prefix 的重用率:重用高(多輪聊天不斷命中同一段 prefix)就讓 prefix cache 勝出;重用低、但活躍上下文很長,量化 KV 才划算。
- 資料中心的解法,可以先用政策模擬。 chunked prefill 加 admission control,近似 stall-free batching;把批次負載導去別的硬體,近似 P/D disaggregation。