最近 X で DGX Spark の話題をよく見かけます。128GB のユニファイドメモリで大きなモデルがローカルで動く、という話です。実際おもしろそうだなと思って眺めていました。
自宅には Mac mini M4 Pro(ユニファイドメモリ 64GB) があります。普段ローカル LLM を試すには十分なのですが、今回使いたい DeepSeek V4 Flash の GGUF は 128GiB。さすがに 64GB には収まりません。
目を付けたのが RTX 4000 Ada ×4(VRAM 合計 80GB)に RAM 196GB という構成です。ただ、VRAM だけ見れば 80GB なので、これでも 128GiB は載りません。
「足りない分を RAM に逃がせないか」と Claude Code に聞いてみたところ、llama.cpp に --n-cpu-moe という「MoE のエキスパートを CPU 側に置く」オプションがあると教えてくれました。
GPU に収まらない MoE エキスパートを RAM 側へ逃がせるなら、128GiB のモデルでも動かせるのではないか、と考えました。なお、OS 上で認識された RAM は 192GB でした。
というわけで今回は、この構成で 284B パラメータの MoE モデル DeepSeek V4 Flash がどこまで動くのか試しました。
結論から書くと、512K コンテキストを確保した構成で生成 22.6 tok/s。チャット用途なら十分実用的な速度が出ました。
なお、今回の環境構築や OOM の原因切り分けは、Claude Code と壁打ちしながら進めました。エラーログを見せて仮説を立て、設定を変えてもう一度試す、という繰り返しです。
以下、設定値とハマりどころを全部書きます。
Akamai Cloud で利用できる GPU
まず選択肢を整理します。Akamai Cloud Pricing ページで公開されているプランは次の通りです。
| プラン | GPU | 時間単価 |
|---|---|---|
g2-gpu-rtx4000a1-s |
RTX 4000 Ada × 1(RAM 16GB / 4 vCPU) | $0.52/h |
g2-gpu-rtx4000a2-s |
RTX 4000 Ada × 2(RAM 32GB / 8 vCPU) | $1.05/h |
g2-gpu-rtx4000a4-s |
RTX 4000 Ada × 4(RAM 128GB / 32 vCPU) | $2.96/h |
g2-gpu-rtx4000a4-m |
RTX 4000 Ada × 4(RAM 196GB / 48 vCPU) | $3.57/h |
価格や提供プランは変更される可能性があるため、最新情報は Pricing ページと Compute Instance Plan Typesを確認してください。
RTX PRO 6000 Blackwell Server Editionも利用できます。1 枚あたり $3/h です。世代が新しく NVFP4 に対応しているため、今回のようなワークロードでは RTX 4000 Ada より高い性能が期待できます。
価格だけ見ると、今回使った RTX 4000 Ada ×4($3.57/h)とほぼ変わりません。むしろ 1 枚で足りるなら安いくらいです。
なお、RTX PRO 6000 は現時点では Limited Availability(LA) として提供されています。
つまり選択肢はこうなります。
- RTX PRO 6000: 高い性能が期待でき、価格も悪くない
- RTX 4000 Ada: 世代は古いが、4 枚構成では VRAM 合計 80GB + RAM 196GB を使える
今回は GPU の世代よりも RAM 容量を優先して、この RTX 4000 Ada ×4(VRAM 合計 80GB) でどこまでいけるかを見ます。
環境
| 項目 | 内容 |
|---|---|
| GPU | NVIDIA RTX 4000 Ada × 4(20,475 MiB × 4 = 約 80GB) |
| CPU | 48 vCPU |
| RAM | 192GB |
| ディスク | 2TB |
| OS | Ubuntu 24.04.4 LTS |
| ドライバ | nvidia-driver-595-open |
| インスタンス |
g2-gpu-rtx4000a4-m($3.57/h) |
モデルは unsloth/DeepSeek-V4-Flash-0731-GGUF の UD-IQ4_XS(128GiB)を使いました。無印版でも試したので、後で比較を出します。
GGUF のメタデータから読み取れる構造は以下の通りです。
block_count = 43
expert_count = 256
expert_used_count = 6
expert_shared_count = 1
embedding_length = 4096
attention.head_count = 64
attention.head_count_kv = 1 ← MLA
KV ヘッド数が 1 という点が後で効いてきます。
なぜ vLLM ではなく llama.cpp なのか
最初は vLLM で動かすつもりでしたが、量子化のサイズを調べた時点で見送りました。
| 形式 | サイズ | 80GB に載るか |
|---|---|---|
| ネイティブ FP4+FP8 | 約 158GB | ✗ |
| FP8 | 約 284GB | ✗ |
| AWQ INT4(vLLM で最小) | 約 142GB | ✗ |
| NVFP4 | 約 80GB | ✗(Blackwell 専用) |
NVFP4 版はサイズ的には収まりますが、NVFP4 は Blackwell 世代でハードウェアサポートされる FP4 形式なので、Ada Lovelace である RTX 4000 Ada では利用できません。設定で回避できる話ではなく、対応世代の違いによるものです。(RTX PRO 6000 ならこの選択肢が使えます)
DeepSeek V4 Flash は 284B パラメータのうち、1 トークンあたり 13B しか活性化しません。最初は「それなら 13B モデル相当のメモリで動くのでは?」と思ったのですが、調べてみると、そう単純ではありませんでした。
MoE は一部のエキスパートだけを実行することで、総パラメータ数に対する計算量を抑える仕組みです。一方で、モデル全体の重みそのものが小さくなるわけではありません。今回の llama.cpp 構成では、量子化後 128GiB の重みを GPU VRAM または CPU RAM のどこかに保持する必要があります。
つまり、「1 トークンあたり 13B が活性化する」ことと、「13B モデル相当のメモリで動く」ことは別の話でした。
そこで llama.cpp + GGUF ルートに切り替えました。結果的にこれが正解でした。
MoE オフロードという考え方
llama.cpp には --n-cpu-moe N というオプションがあります。「最初の N 層の MoE エキスパートを CPU 側に置く」という指定です。
アテンション・正規化・ルーター → GPU(小さい)
MoE エキスパート → CPU(大きい)
MoE と相性が良いのはここです。1 トークンあたり 13B しか活性化しないので、エキスパートを RAM に置いても CPU 側で実際に走る計算は 13B 相当で済みます。密モデルを同じようにオフロードするのとは話が違います。
メモリの使い分けはこうなります。
モデル 128GiB
├─ アテンションなどと一部の MoE エキスパート → GPU VRAM
└─ オフロードした MoE エキスパート → CPU RAM
VRAM と RAM がひとつの共有メモリ空間になるわけではありませんが、GPU に収まらない重みの一部を CPU RAM に置くことで、128GiB のモデルを動かせます。 今回のインスタンスの構成は RAM に余裕があるため、このオフロードを試すには都合がよさそうでした。
実測結果
同一プロンプト(LRU キャッシュの実装依頼)を投げた結果です。VRAM 使用量はいずれも 64GB / 80GB 程度でした。
無印版(DeepSeek-V4-Flash)
| 構成 | 生成 | ドラフト採用率 |
|---|---|---|
| 32K・投機的デコードなし | 16.8 tok/s | — |
| 32K・DSpark 投機的デコード | 19.3 tok/s | 82.7% |
| 最大 512K・DSpark 投機的デコード | 19.9 tok/s | 51.7% |
0731 版(DeepSeek-V4-Flash-0731)
本体とドラフターを同じリポジトリのペアに揃えたものです(最大コンテキスト 512K、5 回測定の平均)。
| 構成 | 生成 | ドラフト採用率 |
|---|---|---|
| 最大 512K・DSpark 投機的デコード | 22.6 tok/s | 61.2% |
無印版と比べて 生成 +13.4%、採用率も 51.7% → 61.2% と改善しました。
当初は「ペアを揃えても採用率が上がるだけで、速度は +5% 程度だろう」と予想していたので、これは想定を超える結果でした。採用率の改善幅(+9.5pt)だけでは説明しきれない伸びなので、0731 版そのものの最適化も効いているのかもしれませんが、そこまでは切り分けられていません。
同じ量子化・同じ設定でも、モデルのリビジョンで 1 割以上変わるということです。
測定時の注意
--load-mode none を使うと 128GiB をメモリに読み込むため、ロード直後はページフォルトで極端に遅くなります。
実際、ウォームアップなしで測ったときは 21.25 → 3.52 tok/s と 6 倍もぶれました。必ず 2 回ほど投げてから測ってください。
また、ディスク上に複数のモデルを置いたままにするとページキャッシュを圧迫します。使わないモデルは消したほうが安定します。
最大コンテキストを 32K から 512K にしても生成速度はほぼ変わらなかった
無印版で比較すると、最大コンテキストを 32K → 512K と 16 倍にしても、生成速度は 19.3 → 19.9 tok/s とほぼ変わりませんでした。
これは MLA(Multi-head Latent Attention)で KV ヘッド数が 1 だからです。KV キャッシュが極端に小さいので、最大コンテキストを長く確保しても負担を抑えられます。実際、最大コンテキスト 512K に対応する KV キャッシュは最初から VRAM に収まっていました。
VRAM 80GB で最大 512K のコンテキストを確保できるというのは、正直かなり意外でした。
採用率 82.7% でも速度は 1.15 倍
これは無印版・32K で比較した数字です。DSpark(DeepSeek 公式の投機的デコード)を有効にすると 16.8 → 19.3 tok/s(+14.5%)。ドラフト採用率は 82.7% と高いのに、速度向上は 1.15 倍でした。
採用率 82.7% / ドラフト長 4 なら理論上は 2 倍以上の速度が出てもおかしくありません。そうならないのは、検証側(本体モデルで 4 トークンまとめて確認する処理)が CPU 側のエキスパート計算で律速しているためだと考えられます。
投機的デコードはメモリ帯域律速の環境で真価を発揮する手法なので、CPU オフロード構成では効き方が変わる、ということのようです。
なお内容によって効果は大きく変わり、「1 から 30 まで数える」のような予測しやすいタスクでは 28.2 tok/s(1.7 倍)まで伸びました。
プロンプト処理は約 80 tok/s
生成速度だけでなく、プロンプト処理(渡したテキストを読み込む処理)の速度も測りました。
ここは測り方に落とし穴がありました。llama-server はリクエストごとにログへ tok/s を出してくれるのですが、数十トークンしかない短いリクエストでは 20〜40 tok/s と表示されます。これはリクエストあたりの固定オーバーヘッドが支配しているだけで、処理速度を表していません。同じログでも数千トークンのリクエストでは 80〜100 tok/s 出ていました。短いリクエストの行を拾うと、実際の 3 分の 1 くらいに見誤ります。
きちんと測るため、llama.cpp 付属の llama-batched-bench を使いました。本番と同じオフロード設定(--n-cpu-moe 28 -ts 29,6,4,4 -ub 256)で、投機的デコードは無効です。
| 最大コンテキスト | プロンプト長 | 並列数 | プロンプト処理 | 生成 |
|---|---|---|---|---|
| 16K | 512 | 1 | 79.9 tok/s | 17.4 tok/s |
| 16K | 2048 | 1 | 78.4 tok/s | 17.1 tok/s |
| 16K | 2048 | 4 | 79.2 tok/s | 33.6 tok/s |
| 512K | 2048 | 1 | 78.2 tok/s | 17.4 tok/s |
| 512K | 16384 | 1 | 76.7 tok/s | 16.4 tok/s |
分かったことが 3 つあります。
プロンプト処理は約 79 tok/s でほぼ一定です。 最大コンテキストを 512K 確保しても、16K の深さまで読み進めても、2% ほどしか落ちません。並列数を 4 倍にしても 79 で頭打ちなので、GPU 側ではなく CPU 側にオフロードしたエキスパートが律速している、という可能性を示唆しています。
生成の素の値は 17.4 tok/s です。 本番構成の 22.6 tok/s との比は 1.30 で、投機的デコードの採用率 61.2% とも整合します。前述の 1.15 倍という数字は無印版・32K での比較なので、0731 版ではもう少し効いていることになります。
プロンプト処理と生成の比は約 4.6 倍でした。 公式の batched-bench の例ではプロンプト処理が生成の約 29 倍(バッチサイズ 1)出ています。読み込みのほうが速いという関係は変わらないものの、本来つくはずの差はかなり詰まっている、というのが今回の構成の特徴です。
なおこのベンチは VRAM を全部使うので、llama-server を止めてから実行する必要があります。
ハマりどころ
ここからが本題です。同じことを試す人が同じところでハマらないように、実際につまずいたポイントをまとめます。
1. GPU0 だけ +7.8GB の固定オーバーヘッドがある
一番苦戦したのがこれです。
デフォルトのレイヤ分割(--split-mode layer)はレイヤ数を均等に配分します。ところが --n-cpu-moe を使うと、前半の層は軽く、後半の層は重い(前半はエキスパートが CPU、後半は GPU)という不均等な状態になります。
結果、後半の層を担当する GPU に負荷が集中して OOM しました。
allocating 34007.44 MiB on device 2: cudaMalloc failed: out of memory
1 枚 20GB しかないのに 34GB 要求しています。
-ts(tensor split)で傾斜配分して解決しました。
-ts 31,4,4,4 # GPU0 に軽い層を集中させ、重い層を残り 3 枚へ散らす
さらに実測から、GPU0 だけは埋め込み・出力テンソルで約 7.8GB の固定オーバーヘッドがあることが分かりました。GPU0 に載せられるエキスパート層は他より少なくなります。
2. 自動フィッティングが無効化される条件
llama.cpp には空き VRAM に合わせて自動配置する機能がありますが、以下を指定すると無効になります。
-
-nglを明示すると無効 --n-cpu-moeも内部でtensor_buft_overridesを立てるので無効
failed to fit params to free device memory: n_gpu_layers already set by user to 999, abort
failed to fit params to free device memory: model_params::tensor_buft_overrides already set by user, abort
つまり --n-cpu-moe を使う時点で自動フィッティングは諦めて、-ts で手動配分することになります。
3. フラグが改名されている
投機的デコードのフラグは新しい llama.cpp で変わっています。
--draft-max → --spec-draft-n-max
--draft-min → --spec-draft-n-min
古い記事の通りに書くと起動しません。エラーメッセージが新名称を教えてくれるのですぐ気づけます。
4. ドラフター GGUF はリポジトリによって中身が違う
DSpark のドラフターを探すと複数のリポジトリが見つかりますが、中身が違います。
あるリポジトリのものは、ロード時にいきなりクラッシュしました。
gguf.cpp:1114: GGML_ASSERT(key_id >= 0 && key_id < gguf_get_n_kv(ctx)) failed
→ common_speculative_types_from_gguf
調べたところ、メタデータが 14 キーしかありませんでした。llama.cpp が必要とするキーが無く、アサーションで落ちていたわけです。
別のリポジトリのものは 62 キーあり、こちらは正しく認識されました。
auto-detected speculative type 'draft-dspark' from the draft model metadata
ロード前に確認するのが安全です。
from gguf import GGUFReader
r = GGUFReader("drafter.gguf")
print(len([f.name for f in r.fields.values()]))
5. ドラフターの置き場所
ドラフター(5.5GB)は GPU0 に入りません。本体で埋まっているためです。
failed to allocate CUDA0 buffer of size 5412887808
空いている GPU に逃がします。
--spec-draft-device CUDA3
6. 512K で詰まるのは KV キャッシュではない
最大コンテキスト 512K の構成に挑戦したとき、OOM したのは KV キャッシュではなく プロンプト処理用の計算バッファでした。
allocating 3105.26 MiB on device 0: cudaMalloc failed: out of memory
→ graph_reserve: failed to allocate compute buffers
-ub(ubatch)を 512 → 128 に下げても 2.9GB までしか減らず、最大コンテキスト 512K の設定では、確保するコンテキスト長自体がバッファサイズを支配していました。
最終的には -ts を調整して GPU0 からエキスパート 2 層を隣に移し、約 2.9GB の空きを作ることで通りました。
-ts 31,4,4,4 → -ts 29,6,4,4
詰まっているのは VRAM の総量ではなく配分、という良い例でした。
最終的な設定
[Unit]
Description=llama.cpp server - DeepSeek V4 Flash
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=1800
StartLimitBurst=3
[Service]
Type=simple
ExecStart=/bin/bash -c '/root/llama.cpp/build/bin/llama-server \
--model /root/models/.../DeepSeek-V4-Flash-0731-UD-IQ4_XS-00001-of-00004.gguf \
--alias deepseek-v4-flash \
--host 0.0.0.0 --port 8000 \
--api-key "$(cat /root/llama-api-key.txt)" \
--n-cpu-moe 28 \
-ts 29,6,4,4 \
--load-mode none \
-c 524288 -ub 256 -b 512 \
--cache-type-k q8_0 --cache-type-v q8_0 \
--flash-attn on --parallel 1 \
--model-draft /root/models/.../dspark-...-IQ2_XXS-exps.gguf \
-ngld 99 --spec-draft-device CUDA3 \
--spec-draft-n-max 4 --spec-draft-n-min 1 \
--metrics'
Restart=on-failure
RestartSec=15
TimeoutStartSec=1800
[Install]
WantedBy=multi-user.target
ビルドは CUDA 有効で、Ada 世代なので sm_89 を指定します。
cmake -B build -DGGML_CUDA=ON -DCMAKE_BUILD_TYPE=Release \
-DLLAMA_CURL=ON -DCMAKE_CUDA_ARCHITECTURES=89
cmake --build build --config Release -j $(nproc)
公開する場合は --api-key を必ず設定し、Cloud Firewall でアクセス元も絞っておきます。LLM のエンドポイントを認証なしで外に出すのは避けましょう。
まとめ
- RTX 4000 Ada ×4(VRAM 80GB)と RAM 196GB プラン(OS 上では 192GB)で、284B の MoE は動く
- 最大 512K のコンテキストを確保した構成で生成 22.6 tok/s。短いチャットなら十分使える
- 鍵は
--n-cpu-moeによるエキスパートの CPU オフロード - MLA(KV ヘッド 1)のおかげで長いコンテキストのための KV キャッシュを抑えられる
- 今回詰まったのは VRAM の総量ではなく、4 枚の GPU への配分
押さえておきたいのは「読み込み」の速度です。プロンプト処理(渡したコードを読む処理)は約 80 tok/s で、生成の 4.6 倍。llama.cpp の公式ベンチマーク例では約 29 倍(バッチサイズ 1 の場合)なので、本来つくはずの差がかなり詰まっています。CPU にオフロードした MoE エキスパートがボトルネックになっている可能性が高そうですが、オフロードなしの構成と比較できていないため断定はできません。
これが効いてくるのは、入力と出力でトークン数が桁違いだからです。1 万トークンのコードを渡すと、読み込みだけで約 2 分。対して 300 トークンの返答を書くのは約 13 秒です。待ち時間の大部分は読み込みに費やされます。
ターミナルで動くコーディングエージェント OpenCode からも使ってみました。20 行ほどの小さな Python ファイルでキャッシュの追い出し方式を FIFO から LRU に変えるタスクが 約 2 分半、12 ファイル・約 2,600 行の Go 製 Web API に「既存のやり方に合わせて」新しい API エンドポイントを追加させるタスクが 約 5 分でした(いずれも 2 回試して 1〜2 割ぶれます)。
出てきたコードはどちらもそのまま動くものです。後者ではルーティングを登録する場所を自分で探し、ハンドラの書き方も既存コードの流儀に揃え、読み込み済みのライブラリを再利用しています。品質は十分で、あとは速度をどう見るかという話でした。
実際、後者でサーバ側のログを見ると、7,543 トークンと 7,608 トークンの読み込みがそれぞれ約 87 秒。全体 5 分弱のうち 3 分近くが読み込みに費やしていました。
テンポよく修正と確認を繰り返す使い方には待たされることが多くなりますが、夜にまとまったタスクを渡してバッチ処理のように進めるなら十分に使えます。
クラウド GPU だから特別というより、手元の環境ではメモリが足りないモデルを試すのに、今回の構成がちょうどよかったというのが正直なところです。
参考リンク
- Akamai Cloud Pricing — Asia Pacific
- Akamai Cloud: Compute Instance Plan Types
- NVIDIA RTX 4000 Ada Generation
- NVIDIA RTX PRO 6000 Blackwell Server Edition
- DeepSeek-V4-Flash-0731
- unsloth/DeepSeek-V4-Flash-0731-GGUF
- vLLM documentation
- llama.cpp
- llama.cpp server options
- GGUF specification
- DeepSeek FlashMLA
- DeepSeek-V4-Flash-DSpark
- Akamai Cloud Firewalls
- OpenCode