ds4 / DeepSeek V4 Flash の速度を Macbook Pro M5Max で測る

に公開

この記事の概要

  • antirez/ds4 は DeepSeek V4 Flash 専用の小さなネイティブ推論エンジン(Metal / CUDA バックエンド)。
  • M5 Max / 128GB ユニファイドメモリで DeepSeek-V4-Flash-IQ2XXS-w2Q2K-AProjQ8-SExpQ8-OutQ8 (約 81GB) を載せて検証。
  • デコード 34 tok/s、TTFT 197 ms、KV キャッシュ命中時は 2,000 トークン入力が 0.12 秒で返る
  • KV ディスクキャッシュは LaunchAgent 再起動を跨いで永続し、プレフィックスの部分マッチも効く (8k 入力で 6,144 トークン分が再利用された)。
  • OpenAI / Anthropic / Responses / Completions の 4 系統 API、ストリーミング、tool calling、reasoning_content 分離まで全て動作。
  • この 34 tok/s が速度として妥当か、ボトルネックは帯域か演算か、他モデル・他 GPU と比べてどうかは続編 「M5 Max のローカル LLM ベンチ — MoE は計算律速、Dense は帯域律速、発熱の影響」 で深掘りする。

ds4 とは

antirez/ds4(DwarfStar 4)は DeepSeek V4 Flash に特化した小型ネイティブ推論エンジンです。README から要旨を引くと:

DwarfStar 4 is a small native inference engine specific for DeepSeek V4 Flash. It is intentionally narrow: not a generic GGUF runner.

  • バックエンド: Metal (macOS) / CUDA (Linux)
  • コンテキスト長: 最大 1M トークン(圧縮 KV キャッシュをディスクに退避可能)
  • API: OpenAI 互換 (/v1/chat/completions, /v1/completions, /v1/responses) と Anthropic 互換 (/v1/messages) を同梱

汎用 GGUF ランナー(llama.cpp 等)と違い、DeepSeek V4 Flash の MoE 構造に特化した実装になっているのが特徴です。

検証環境

項目
マシン Apple M5 Max (6 Super + 12 Performance コア)
メモリ 128 GB ユニファイドメモリ
OS macOS (Darwin 25.4.0)
バックエンド Metal
ds4 リビジョン 560662d (2026-05-20 時点最新)
モデル DeepSeek-V4-Flash-IQ2XXS-w2Q2K-AProjQ8-SExpQ8-OutQ8-chat-v2-imatrix.gguf (81 GB)
サーバ起動 ds4-server --ctx 300000 --kv-disk-dir ~/Library/Caches/ds4-kv --kv-disk-space-mb 131072 --port 8000
運用 macOS LaunchAgent (com.kamo.ds4) による常駐

今回使用したモデルの量子化スキームは少し独特で、

  • 本体 weight: IQ2XXS (≒ 2.06 bpw)
  • 一部 dense weight: Q2_K
  • Attention projection / Shared experts / Output: Q8_0(精度を犠牲にしたくない部位だけ高ビット)
  • imatrix で校正済み(chat-v2)

という構成。284B-a13B(総パラメータ 284B、トークンあたり active 13B)の DeepSeek V4 Flash を、2bpw 中心の量子化でトータル 81GB まで圧縮したものになります。この 81GB を mmap で読み込み、プロセスの RSS は数百MB ながら VSZ は 542 GB(必要なエキスパートだけページインされる設計)。

$ ps -axo pid,rss,vsz,command | grep ds4-server
 6712 1059040 542001120 /Users/kamo/llm/ds4/ds4-server --ctx 300000 ...

この 2-bit 中心の量子化が ds4 のキモです。README によれば、この特殊な量子化のおかげで 128GB RAM の MacBook で動作し、96GB のマシンでも(しかも 250k コンテキストで)動いたという報告があるとのこと。プロジェクト自体「96/128GB 以上のハイエンド個人マシンや Mac Studio で信用できるローカル推論」を狙いに掲げており、本検証の 128GB マシン / 81GB モデルという構成はその想定ど真ん中にあたります。


エンドポイント疎通

ds4-server は 4 系統 の API を喋ります。

curl -s http://127.0.0.1:8000/v1/models | jq
{
  "object": "list",
  "data": [{
    "id": "deepseek-v4-flash",
    "owned_by": "ds4.c",
    "context_length": 300000,
    "supported_parameters": [
      "tools","tool_choice","max_tokens","temperature",
      "top_p","top_k","min_p","stop","seed","stream","reasoning_effort"
    ]
  }]
}
Endpoint プロトコル 状態
GET /v1/models OpenAI OK
GET /v1/models/deepseek-v4-flash OpenAI OK
POST /v1/chat/completions OpenAI OK(streaming / tools / seed すべて)
POST /v1/completions OpenAI legacy OK
POST /v1/responses OpenAI Responses OK
POST /v1/messages Anthropic OK(thinking + text ブロック分離)

例: Anthropic 互換エンドポイント

/v1/messages はちゃんと Anthropic 形式で thinkingtext を分離して返します。

curl -s -X POST http://127.0.0.1:8000/v1/messages \
  -H "Content-Type: application/json" \
  -d '{"model":"deepseek-v4-flash","max_tokens":100,
       "messages":[{"role":"user","content":"Reply: hi"}]}'
{
  "id": "chatcmpl-5",
  "type": "message",
  "role": "assistant",
  "model": "deepseek-v4-flash",
  "content": [
    {"type": "thinking", "thinking": "Hmm, the user just said \"hi\"...", "signature": "chatcmpl-5"},
    {"type": "text", "text": "Hi there! How can I help you today?"}
  ],
  "stop_reason": "end_turn",
  "usage": {"input_tokens":0,"output_tokens":68,
            "cache_read_input_tokens":0,"cache_creation_input_tokens":7}
}

Tool calling

OpenAI 形式の tool schema を渡すと、内部で DeepSeek の DSML 形式に変換してくれます。

curl -s -X POST http://127.0.0.1:8000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model":"deepseek-v4-flash",
    "messages":[{"role":"user","content":"What is the weather in Tokyo? Use the tool."}],
    "max_tokens":200,"temperature":0,"seed":1,
    "tools":[{"type":"function","function":{
      "name":"get_weather",
      "description":"Get weather for a city",
      "parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}
    }}],
    "tool_choice":"auto"
  }'
{
  "choices": [{
    "message": {
      "role": "assistant",
      "content": "",
      "reasoning_content": "The user asks about the weather in Tokyo. I should use the get_weather tool with city parameter \"Tokyo\".",
      "tool_calls": [{
        "id": "call_c9651c8c6476c4176663de24a0ae912b",
        "type": "function",
        "function": {"name": "get_weather", "arguments": "{\"city\":\"Tokyo\"}"}
      }]
    },
    "finish_reason": "tool_calls"
  }]
}

reasoning_content に思考プロセスが分離され、tool_callsarguments も JSON 文字列としてきちんと整っています。

Reasoning content の扱いに注意

DeepSeek V4 Flash は reasoning モデルなので、応答に必ず reasoning_content が乗ります。これは max_tokens を消費するので、短く答えさせるつもりでも max_tokens をケチるとトークが思考の途中で切れます。

# ダメな例: 16 トークンでは思考の途中で打ち切られる
curl -s -X POST http://127.0.0.1:8000/v1/chat/completions \
  -d '{"model":"deepseek-v4-flash",
       "messages":[{"role":"user","content":"Reply with just: pong"}],
       "max_tokens":16}'
# → content: "We need to reply with just \"pong\". The user said \"Reply with"
#   finish_reason: "length"

# 良い例: 余裕を持たせる
curl -s -X POST http://127.0.0.1:8000/v1/chat/completions \
  -d '{"model":"deepseek-v4-flash",
       "messages":[{"role":"user","content":"Say the word pong and nothing else."}],
       "max_tokens":200}'
# → content: "pong"
#   reasoning_content: "We need to output only the word 'pong'..."
#   finish_reason: "stop"

実運用では max_tokens を十分大きめに取り、reasoning_content をフロントに出さないようサーバ/クライアント側でフィルタするのが安全です。


性能計測

ここからが本題。計測はすべて同一マシンの localhost に対して実施。サーバは LaunchAgent (com.kamo.ds4) 経由で起動した状態、GPU 温度は計測直前に 40°C まで冷却した状態でのものです。

計測スクリプト

import json, time, urllib.request

def call(body):
    req = urllib.request.Request(
        "http://127.0.0.1:8000/v1/chat/completions",
        data=json.dumps(body).encode(),
        headers={"Content-Type": "application/json"})
    t0 = time.time()
    r = json.loads(urllib.request.urlopen(req).read())
    return r, time.time() - t0

1. Decode スループット(持続)

512 トークン生成 × 3 種類のプロンプトで計測。

prompts = [
    "Write a 400-word essay about the ocean. Just the essay, no preamble.",
    "Explain how a CPU pipeline works in 400 words. Just the explanation.",
    "List 50 random English nouns, one per line.",
]
for p in prompts:
    r, dt = call({"model":"deepseek-v4-flash",
        "messages":[{"role":"user","content":p}],
        "max_tokens":512, "temperature":0, "seed":1})
    u = r["usage"]
    print(f"prompt={u['prompt_tokens']:4d} out={u['completion_tokens']:3d} "
          f"time={dt:5.2f}s decode={u['completion_tokens']/dt:5.1f} tok/s")

結果:

prompt=  21 out=512 time=14.11s  decode= 36.3 tok/s  finish=length
prompt=  19 out=512 time=15.26s  decode= 33.6 tok/s  finish=length
prompt=  15 out=175 time= 5.39s  decode= 32.5 tok/s  finish=stop

平均 34.1 tok/s、レンジ 32.5–36.3 tok/s で安定。

2. TTFT(Time To First Token)

body = json.dumps({"model":"deepseek-v4-flash",
    "messages":[{"role":"user","content":"Write a haiku about ocean."}],
    "max_tokens":150,"stream":True}).encode()
req = urllib.request.Request("http://127.0.0.1:8000/v1/chat/completions",
    data=body, headers={"Content-Type":"application/json"})
t0 = time.time(); first = None
with urllib.request.urlopen(req) as resp:
    for line in resp:
        line = line.decode().strip()
        if line.startswith("data: ") and line[6:] != "[DONE]":
            if first is None:
                first = time.time() - t0
                break
print(f"TTFT = {first*1000:.0f} ms")
TTFT = 197 ms

体感的にはほぼ即応。

3. Prefill スループット(プロンプト長を変化)

max_tokens=4 に絞って、純粋にプロンプト処理速度を測ります。

ここで注目すべきは、ds4 は KV ディスクキャッシュをまたいでプレフィックス部分マッチしてくれる点です。同じ繰り返し文字列を長さを変えて投入すると、後段のプロンプトは前段の prefix を再利用します。

for mult in (50, 200, 800, 2000):
    big = "The quick brown fox jumps over the lazy dog. " * mult
    r, dt = call({"model":"deepseek-v4-flash",
        "messages":[{"role":"user","content":big+"\nReply OK."}],
        "max_tokens":4, "temperature":0, "seed":1})
    u = r["usage"]
    pt = u['prompt_tokens']
    cached = u['prompt_tokens_details']['cached_tokens']
    print(f"prompt={pt:6d} cached={cached:5d} time={dt:6.2f}s "
          f"effective={pt/dt:7.0f} tok/s")
prompt=   508 cached=    0 time=  2.20s  effective=    231 tok/s   ← 純粋な cold prefill
prompt=  2008 cached= 2008 time=  0.12s  effective=  16868 tok/s   ← 完全ヒット
prompt=  8008 cached= 6144 time=  6.27s  effective=   1278 tok/s   ← 6144 tok 分が再利用
prompt= 20008 cached=10240 time= 34.83s  effective=    574 tok/s   ← 10240 tok 分が再利用

純粋な cold prefill は 500 tok 入力で 231 tok/s。部分マッチが効く運用では実効スループットが数倍に跳ね上がることが見えます。

4. KV キャッシュ再利用(同一プロンプト二連投)

big = "The quick brown fox jumps over the lazy dog. " * 200
for i in (1, 2):
    r, dt = call({"model":"deepseek-v4-flash",
        "messages":[{"role":"user","content":big+"\nReply OK."}],
        "max_tokens":4, "temperature":0, "seed":1})
    print(f"pass {i}: cached={r['usage']['prompt_tokens_details']['cached_tokens']} "
          f"time={dt:.2f}s")
pass 1: cached=2008 time=0.12s   ← 前のテストから既に乗っている
pass 2: cached=2008 time=0.12s

usage.prompt_tokens_details.cached_tokens でヒット数も正しく報告されます。LaunchAgent でプロセスを再起動しても、ディスクに退避された KV はそのまま残り続けるため、同一プレフィックスの再投入はほぼ即返ります。RAG やエージェント用途では効果絶大。

5. Tool calling

finish_reason=tool_calls  time=3.91s
tool_call: get_weather({"city":"Tokyo"})

JSON arguments も含め完全に動作。

計測結果まとめ

指標
Decode (持続, 512tok 生成) 34.1 tok/s (32.5–36.3)
TTFT (streaming) 197 ms
Prefill 500 tok (cold) 231 tok/s
Prefill 2k (cached) 16,868 tok/s
Prefill 8k (6144 hit / 8008) 1,278 tok/s
Prefill 20k (10240 hit / 20008) 574 tok/s
KV キャッシュヒット (2010 tok) 0.12 s
KV disk 使用 15 GB / 128 GB 確保

ハマりどころと運用 Tips

max_tokens は reasoning 込みで見積もる

前述の通り、reasoning_content がトークンを消費します。短答用途でも max_tokens >= 200 程度を確保しておくのが安全。

KV ディスクキャッシュは積極的に有効化

--kv-disk-space-mb で大きめに確保すると、長コンテキストのプレフィックスを再利用できます。エージェントやコードベース投入のような 同一プレフィックス + 末尾差し替え パターンで効きます。さらに ds4 は LaunchAgent でプロセスを再起動しても KV ディスクキャッシュが保持されるので、運用上の再起動コストはほぼゼロ。

LaunchAgent で常駐させる

本検証では com.kamo.ds4 ラベルの LaunchAgent でサーバを管理しました。KeepAlive=true にしておくと、何らかの理由で落ちても 10 秒以内に再起動されます。バイナリを更新したときは launchctl kickstart -k gui/$(id -u)/com.kamo.ds4 で差し替えできます。

短プロンプトの prefill オーバーヘッド

500 tok 以下では prefill の効率が落ちます。バッチ処理する場合は、ある程度プロンプトをまとめて投げると勝率が上がります。

4 系統 API は中身共通

/v1/chat/completions/v1/messages も最終的には同じパイプラインを叩いており、応答 ID が chatcmpl-N で連番なのが見えます。クライアント側の SDK 都合で好きなのを選べばよさそう。


まとめ

  • antirez/ds4 は「DeepSeek V4 Flash 専用」を売りにしているだけあって、M5 Max / 128GB で 81GB の量子化版を 34 tok/s で素直に走らせることができた。
  • API 4 系統、ストリーミング、tool calling、reasoning 分離、KV ディスクキャッシュまで完備でフル機能で動作
  • 特筆すべきは KV キャッシュの永続性とプレフィックス部分マッチ: 2k トークンプロンプトが 0.12 秒、部分マッチで 8k 入力が 1,278 tok/s と桁違いに速くなる。RAG やエージェントとは抜群に相性が良い。
  • 1M context 対応 + KV ディスクキャッシュ + LaunchAgent 常駐の組み合わせで、ローカルで長文 RAG / エージェントを組むには魅力的な基盤。

汎用性は捨てているが、DeepSeek V4 Flash を Apple Silicon で動かしたい人にとってはほぼ唯一解に近いプロジェクトです。READMEの "intentionally narrow" の意味がよくわかる結果でした。

なお「この 34 tok/s は計算律速か帯域律速か」「同じ M5 Max の他モデルや RTX 5090 と比べてどうか」というベンチマーク面の深掘りは、続編 「M5 Max のローカル LLM ベンチ — MoE は計算律速、Dense は帯域律速、発熱の影響」 でまとめています。


参考リンク

GitHubで編集を提案

Discussion