↓快轉到主要內容
  1. 教學文章/

MLX-LM Speculative Decoding:Draft Model、加速條件與品質 Benchmark

·10 分鐘· loading · loading · ·
Mlx MLX-LM LLM Speculative Decoding Draft Model Apple-Silicon Benchmark Local AI
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
科技觀點 - 本文屬於一個選集。
§ 27: 本文

featured

一. 前言:多跑一個模型,為什麼反而可能更快?
#

本地 LLM 的 decode 很像一位資深編輯逐字審稿。 每產生一個 token,大模型都要做一次昂貴的 forward pass。 Speculative decoding 的想法是:

  1. 先讓較小的 draft model 快速猜幾個 token;
  2. 再請原本的 target model 一次驗證整段猜測;
  3. 接受吻合的前綴,從第一個不吻合處改由 target model 決定。 如果 draft 猜得準,target 一次 forward 就能推進多個 token。 如果猜得差,額外跑 draft model 的成本可能比省下來的還多。 所以它不是打開 --draft-model 就免費加速的按鈕。 真正要回答的是:
  • draft 和 target 是否相容;
  • 平均可以接受多少 draft tokens;
  • num_draft_tokens 設多少才划算;
  • 長 prompt、短回答和長回答的收益是否一致;
  • 開啟後的文字品質是否仍符合預期。 今天拍拍君會做一套可重跑的 benchmark,而不是只截一張 tokens/s 當結論。 如果你還沒跑過 MLX-LM,先看本機模型推論入門。

二. Speculative Decoding 的驗證流程
#

先用簡化例子建立直覺。 假設 draft model 猜出:

拍拍君 / 今天 / 要 / 測試

target model 驗證後認為前三個 token 可接受,但第四個應該是「量測」。 這一輪會輸出:

拍拍君 / 今天 / 要 / 量測

接著兩個模型從新的位置繼續。 MLX-LM 的 speculative generation 會維護 target 與 draft 各自的 KV cache。 猜錯時還要把未採用部分回退,所以 cache 必須支援 trim。 這也解釋兩個限制:

  • draft model 不能只是「任何比較小的模型」;
  • 某些自訂或 hybrid cache 組合可能不能使用這條路徑。 理想情況下,draft 很便宜、分布又接近 target。 最糟情況則是 draft 每輪都猜錯,還多佔記憶體與時間。

三. 建立乾淨的測試環境
#

建立獨立專案:

uv init mlx-spec-decode-lab
cd mlx-spec-decode-lab
uv add mlx-lm
mkdir -p prompts results

先記錄版本與 CLI 介面:

uv run python -c "import importlib.metadata as m; print(m.version('mlx-lm'))"
uv run mlx_lm.generate --help | tee results/generate-help.txt

MLX-LM 更新很快。 文章寫作時,CLI 使用這兩個參數:

--draft-model MODEL_ID
--num-draft-tokens N

如果你安裝的版本沒有它們,先不要硬抄後面的指令。 把環境資訊一起保存:

system_profiler SPHardwareDataType > results/hardware.txt
sw_vers > results/macos.txt
uv pip freeze > results/requirements-lock.txt

Benchmark 沒有版本與硬體資訊,就很難重現。

四. Draft Model 的第一條規則:Tokenizer 必須相容
#

MLX-LM 的 stream_generate() 文件明確要求 draft model 與 target model 使用相同 tokenizer。 只有 vocab size 一樣仍不夠保險。 還要檢查相同文字是否得到相同 token IDs。 建立 check_tokenizers.py:

from mlx_lm import load
TARGET = "mlx-community/your-target-model"
DRAFT = "mlx-community/your-draft-model"
_, target_tokenizer = load(TARGET)
_, draft_tokenizer = load(DRAFT)
samples = [
    "拍拍君今天要測試 speculative decoding。",
    "def hello(name: str) -> str:",
    "JSON: {\"ok\": true, \"count\": 3}",
    "emoji 🔭 and whitespace\n\nnext line",
]
for text in samples:
    target_ids = target_tokenizer.encode(text)
    draft_ids = draft_tokenizer.encode(text)
    if target_ids != draft_ids:
        raise SystemExit(f"tokenizer mismatch: {text!r}")
print("tokenizer samples match")

這不是形式檢查。 Speculative decoding 比較的是 token,不是解碼後看起來差不多的字串。 優先選同一模型家族、同一代 tokenizer、較小參數量的 draft。 不要因為兩個模型都叫 instruct,就假設它們能配對。

五. Draft Model 不是越小越好
#

挑選 draft model 時要同時看兩件事:

指標 太差會發生什麼事
draft 每 token 成本 draft 本身吃掉加速收益
draft 與 target 的吻合度 acceptance 太低,target 無法一次推進
極小模型雖然很快,但若語言分布差太多,可能每一兩個 token 就被拒絕。 較大的 draft 猜得準,卻可能已經貴到失去意義。 實務上先準備兩個候選:
  • 同家族的最小可用模型;
  • 同家族中稍大、但仍明顯小於 target 的模型。 用同一組 prompts 測試後再決定。 不要只拿一句「你好」做選型。 程式碼、繁體中文、JSON 與長篇解釋的 acceptance pattern 可能不同。

六. 先跑 Plain Baseline
#

先建立不使用 draft model 的基準:

export TARGET_MODEL="mlx-community/your-target-model"
uv run mlx_lm.generate \
  --model "$TARGET_MODEL" \
  --prompt "請用繁體中文解釋 Python generator,附一個短例子。" \
  --max-tokens 256 \
  --temp 0 \
  --seed 20260928 \
  2>&1 | tee results/plain.txt

保留完整輸出與 verbose 統計。 至少記錄:

  • prompt tokens;
  • prompt tokens/s;
  • generation tokens;
  • generation tokens/s;
  • peak memory;
  • 從啟動到完整答案的 wall time。 第一次執行可能包含模型下載、檔案 cache 與 Metal 初始化。 因此先 warm up,再跑正式回合。 不要把冷啟動和熱執行混成同一個平均值。

七. 開啟第一組 Speculative Decoding
#

指定 draft model:

export DRAFT_MODEL="mlx-community/your-draft-model"
uv run mlx_lm.generate \
  --model "$TARGET_MODEL" \
  --draft-model "$DRAFT_MODEL" \
  --num-draft-tokens 3 \
  --prompt "請用繁體中文解釋 Python generator,附一個短例子。" \
  --max-tokens 256 \
  --temp 0 \
  --seed 20260928 \
  2>&1 | tee results/spec-k3.txt

3 是合理的起點,不是宇宙常數。 如果每輪常常全數接受,可以再試較大的值。 如果第一、第二個 token 就經常失敗,增加 draft 長度只會多做白工。 同一回合裡不要順便改模型量化、prompt 與輸出長度。 一次只變一個因素,結果才可解釋。

八. Acceptance Rate 要怎麼理解?
#

最直觀的定義是:

accepted draft tokens / proposed draft tokens

但單一百分比還不完整。 假設兩組測試都是 70%:

  • A 經常連續接受 3 個 token;

  • B 常在第一個失敗,偶爾整段全收。 它們對 target forward 次數的影響可能不同。 最好同時保存:

  • proposed tokens;

  • accepted tokens;

  • acceptance rate;

  • 每輪連續接受長度的分布;

  • 最後的 end-to-end tokens/s。 若目前 CLI 沒有直接印出 acceptance 統計,別從最終文字硬猜。 可以用 Python API 讀取每個 token 的 from_draft 標記。

九. 用 Python API 收集 Draft 接受資訊
#

建立 acceptance_probe.py:

from collections import Counter
from mlx_lm import load, stream_generate
TARGET = "mlx-community/your-target-model"
DRAFT = "mlx-community/your-draft-model"
PROMPT = "請列出五個讓 Python CLI 更容易維護的原則。"
model, tokenizer = load(TARGET)
draft_model, draft_tokenizer = load(DRAFT)
if tokenizer.encode(PROMPT) != draft_tokenizer.encode(PROMPT):
    raise SystemExit("tokenizer mismatch")
counts = Counter()
parts: list[str] = []
for response in stream_generate(
    model,
    tokenizer,
    prompt=PROMPT,
    draft_model=draft_model,
    num_draft_tokens=3,
    max_tokens=256,
):
    parts.append(response.text)
    counts["all_output_tokens"] += 1
    counts["accepted_draft_tokens"] += int(response.from_draft)
print("".join(parts))
print(dict(counts))

from_draft=True 表示該輸出 token 來自已被 target 接受的 draft 預測。 它可以幫你比較候選模型與 num_draft_tokens。 但要注意:

accepted draft tokens / all output tokens

不等於嚴格的 proposed-token acceptance rate。 因為被拒絕的提案不會成為輸出 token。 如果需要完整分母與每輪分布,應在固定版本的 speculative_generate_step() 周圍加 instrumentation,並把 patch 一起保存。

十. Sweep Draft Length,不要只試一個值
#

先比較 1、2、3、5、8:

for k in 1 2 3 5 8; do
  /usr/bin/time -p uv run mlx_lm.generate \
    --model "$TARGET_MODEL" \
    --draft-model "$DRAFT_MODEL" \
    --num-draft-tokens "$k" \
    --prompt "$(< prompts/explain-generator.txt)" \
    --max-tokens 512 \
    --temp 0 \
    --seed 20260928 \
    > "results/spec-k${k}.txt" 2>&1
done

常見曲線不是「越大越快」,而是先升、到甜蜜點、再下降。 原因很直接:

  • k 太小:target 每輪推進幅度有限;
  • k 適中:draft 成本和接受長度取得平衡;
  • k 太大:尾端更容易猜錯,浪費 draft 計算。 最好的 k 也可能依 prompt 類型改變。 若線上服務混合多種工作,選穩定的中間值通常比追單一案例最高分可靠。

十一. Benchmark Dataset 要包含哪些題目?
#

至少準備四類 prompts:

  1. 繁體中文知識解釋;
  2. Python 程式碼生成;
  3. 固定 schema 的 JSON;
  4. 長篇摘要或改寫。 每類準備 5 到 10 題,避免單題運氣。 JSONL 可以長這樣:
{"id":"zh-01","kind":"explain","prompt":"解釋 context manager,附短例子。","max_tokens":256}
{"id":"code-01","kind":"python","prompt":"寫一個有 timeout 的 async retry helper。","max_tokens":384}
{"id":"json-01","kind":"json","prompt":"輸出三筆測試案例的 JSON array,不要加說明。","max_tokens":256}
{"id":"sum-01","kind":"summary","prompt":"把下列規格整理成六點:...","max_tokens":512}

固定每題的:

  • chat template;
  • temperature 與 seed;
  • max tokens;
  • stop conditions;
  • target 與 draft artifacts。 否則你比較到的是多個變因的混合物。

十二. 分開看 TTFT 與 Decode Throughput
#

Speculative decoding 主要改善生成階段。 它不會讓超長 prompt 的 target prefill 消失。 所以要分開觀察:

指標 回答什麼問題
TTFT 使用者多久看到第一個 token
decode tokens/s 持續生成有多快
end-to-end latency 整份答案多久完成
peak memory 兩個模型與兩份 cache 是否值得
若 prompt 很長、答案只有 20 tokens,總時間可能幾乎都在 prefill。 這時 decode 就算快 30%,使用者也不一定感覺得到。 相反地,長篇程式碼或文章生成通常更容易看到差異。
不要用 decode tokens/s 代替整體延遲。

十三. 算 Speedup,也要算 Break-even
#

最基本的 speedup:

plain elapsed / speculative elapsed

大於 1 才是真正變快。 建立簡單比較:

def speedup(plain_seconds: float, spec_seconds: float) -> float:
    if plain_seconds <= 0 or spec_seconds <= 0:
        raise ValueError("times must be positive")
    return plain_seconds / spec_seconds

cases = [
    ("short", 3.8, 3.6),
    ("medium", 12.4, 9.1),
    ("long", 31.0, 20.5),
]
for name, plain, spec in cases:
    print(name, f"{speedup(plain, spec):.2f}x")

也要把額外記憶體列入決策。 若只快 3%,卻讓系統開始 swap,實際服務反而可能更不穩。 一個實用的上線條件可以是:

  • median end-to-end speedup 至少 1.15x;
  • p95 latency 不惡化;
  • peak memory 留有安全餘裕;
  • 品質檢查全部通過。 門檻應由你的產品需求決定,不是照抄拍拍君的數字。

十四. Greedy 品質一致性要逐字比較
#

理論上,正確實作的 greedy speculative decoding 應保留 target model 的決策。 實務上仍要驗證你使用的版本、模型與 cache 組合。 MLX-LM 的公開 issue tracker 曾出現特定組合輸出分歧或 token 異常的回報。 所以 temperature=0 時應做 byte-for-byte 比較:

from pathlib import Path
plain = Path("results/plain-output.txt").read_text(encoding="utf-8")
spec = Path("results/spec-output.txt").read_text(encoding="utf-8")
if plain != spec:
    raise SystemExit("greedy outputs diverged")
print("greedy outputs match")

比較前先移除兩邊不同的 CLI 統計行,但不要 normalize 模型正文。 換行、空白與標點差異也要保留,才抓得到真正分歧。 若輸出不同,先記錄可重現案例並停止宣稱「無損加速」。

十五. 常見失敗一:Tokenizer 看似相同,其實不同
#

症狀可能包括:

  • acceptance 異常低;
  • 輸出突然缺字;
  • 中英混合或特殊符號附近出錯;
  • server 只警告 vocab size,卻沒有完全拒絕。 處理方式:
  1. 比較多種文字的 token IDs;
  2. 檢查 tokenizer config 與特殊 token;
  3. 優先回到同家族明確配對;
  4. 保存失敗 prompt,不要只記模型名稱。 「都能載入」不代表「可以安全 speculative」。

十六. 常見失敗二:Prompt Cache 無法 Trim
#

Speculative decoding 猜錯時要回退 cache。 如果模型使用不可 trim 的 cache,MLX-LM 會拒絕執行。 這類錯誤不是把 num_draft_tokens 改小就能解決。 先確認:

  • 目前 mlx-lm 版本;
  • target 與 draft 的模型架構;
  • cache 類型;
  • upstream 是否已支援該架構。 不要在不理解 cache 狀態的情況下 monkey-patch 掉檢查。 錯誤的 cache 回退可能讓輸出看似正常,實際 token state 已經錯位。

十七. 常見失敗三:記憶體夠載 Target,卻載不下兩個模型
#

Speculative decoding 同時需要:

  • target 權重;
  • draft 權重;
  • target KV cache;
  • draft KV cache;
  • 暫時 activation 與 MLX allocator 空間。 所以 plain generation 能跑,不代表 speculative generation 也能穩定跑。 若 unified memory 接近上限,先換更小 draft,而不是立即把 target 再量化一輪。 量測 peak memory,也觀察 macOS memory pressure 與 swap。 一旦開始大量 swap,漂亮的理論 tokens/s 很快就會消失。

十八. 一份實用的結果表
#

最後不要只留下終端機截圖。 整理成可以比較的表:

設定 k median tok/s speedup draft share peak GB greedy parity
plain — 18.2 1.00x — 14.1 baseline
draft-small 2 22.4 1.23x 61% 15.0 pass
draft-small 3 24.1 1.32x 68% 15.0 pass
draft-small 5 23.0 1.26x 70% 15.1 pass
表中數字只是格式示範,不是任何特定模型的實測承諾。 你的文章、應用或部署文件應放自己的結果。 同時保存 raw JSONL,讓未來升級 mlx-lm 後能重新比較。

十九. 決策 Checklist
#

準備開啟 speculative decoding 前,逐項確認:

  • target 與 draft 的 tokenizer sample IDs 相同;
  • draft 明顯小於 target,但 acceptance 不差;
  • plain baseline 已 warm up 並重複測量;
  • 至少 sweep 了數個 num_draft_tokens;
  • TTFT、decode、end-to-end latency 分開記錄;
  • peak memory 與 swap 在安全範圍;
  • greedy output parity 通過;
  • sampling 任務有結構化品質指標;
  • 真實 prompt mix 的 median 與 p95 都有改善;
  • 模型或 mlx-lm 升級後會重新驗證。 少了最後一點,今天的最佳設定可能變成下個版本的歷史資料。

結語
#

Speculative decoding 的核心不是「小模型幫大模型寫答案」。 Draft 只是在提出候選,最終仍由 target 驗證。 它能不能加速,取決於三者的平衡:

  1. draft 預測有多便宜;
  2. target 能接受多長的連續前綴;
  3. 額外模型與 cache 是否仍在記憶體預算內。 拍拍君建議從 num_draft_tokens=3 起跑,先量 plain baseline,再做小範圍 sweep。 速度提升要看 end-to-end,品質則要用 greedy parity 與任務測試守住。 這樣得到的不是一個看起來很厲害的參數,而是一套升級後還能重跑的決策流程。🔭

延伸閱讀
#

科技觀點 - 本文屬於一個選集。
§ 27: 本文

相關文章

MLX-LM 模型轉換與量化:4/8-bit、Mixed Quant 與品質評測
·9 分鐘· loading · loading
Mlx MLX-LM LLM Quantization Apple-Silicon Model Conversion Local AI
MLX-LM 長文本聊天實戰:Context、KV Cache 與記憶體取捨
·10 分鐘· loading · loading
Mlx MLX-LM LLM Long Context KV Cache Apple-Silicon Local AI
MLX-LM 批次推論實戰:Prompt Template、抽樣參數與本機評測流程
·9 分鐘· loading · loading
Mlx MLX-LM LLM Batch Inference Apple-Silicon Local AI
MLX-LM 實戰:在 Apple Silicon 上跑本地模型推論
·9 分鐘· loading · loading
Mlx MLX-LM LLM Apple-Silicon Python Local AI
MLX-LM 本機 API Server:OpenAI 相容介面、Prompt Cache 與 Tool Calling
·8 分鐘· loading · loading
Mlx MLX-LM LLM OpenAI API Prompt Cache Tool Calling Apple-Silicon
MLX-LM LoRA 微調入門:Adapter、資料格式與 Apple Silicon 本機評測
·13 分鐘· loading · loading
Mlx MLX-LM LoRA Fine-Tuning Apple-Silicon Local AI