-ot の挙動は将来のバージョンで改善される可能性があります。最新の状況は llama.cpp リポジトリ を確認してください。はじめに
なぜこんなことを試そうと思ったのか
素朴な発想がありました。「LLM の推論を複数台のマシンに分散すれば速くなるのでは?」
Web サーバーやバッチ処理の世界では、マシンを増やせばスループットが上がるのは常識です。LLM の推論も、モデルを複数台に分割して並列に計算すれば速くなるはず — そう考えるのは自然なことでした。しかも GPU VM は高額(CPU VM の 10〜50 倍)なので、安い CPU VM を束ねて同じことができれば一石二鳥です。
この仮説を検証するため、Azure 仮想マシン 3 台クラスタ上で llama.cpp の RPC 機能を使い、Google の Gemma 4 26B-A4B-it(26B パラメータ中 4B のみがアクティブな MoE モデル)を分散推論する実験を行いました。
結論から言うと、1 台の RAM に載るサイズのモデルでは RPC 分散はネットワークオーバーヘッドにより逆効果であることが判明しました。本記事ではその過程で遭遇した複数の技術的問題と、llama.cpp のソースコードレベルでの原因究明を詳しく記録します。
検証環境
インフラ構成
| 項目 | 値 |
|---|---|
| クラウド | Azure (japaneast) |
| VM サイズ | Standard_D16as_v6 × 3 台 |
| vCPU | 16 cores / 台 |
| RAM | 64 GB / 台 |
| OS | Ubuntu 24.04 LTS |
| 内部ネットワーク | 10.0.1.0/24 (VNet 内) |
| 構築 | Terraform v1.14.8 |
ネットワーク構成

モデル
| 項目 | 値 |
|---|---|
| モデル | unsloth/gemma-4-26B-A4B-it-GGUF |
| ファイル | gemma-4-26B-A4B-it-UD-Q4_K_M.gguf |
| サイズ | 15.7 GB (Q4_K_M 量子化) |
| パラメータ | 26B total / 4B active |
| Expert 数 | 128 (同時使用 8) |
| レイヤー数 | 30 |
ソフトウェア
| 項目 | 値 |
|---|---|
| llama.cpp | b8849 (commit d5b780a, -DGGML_RPC=ON) |
| バイナリ | vm-1: llama-server, llama-cli / vm-2,3: rpc-server |
なぜ llama.cpp を選んだのか
LLM をローカルで動かす推論エンジンは複数存在します。その中で llama.cpp を選択した理由は以下の通りです。
候補との比較
| 推論エンジン | 特徴 | 不採用理由 |
|---|---|---|
| vLLM | GPU 特化の高スループット推論サーバー。PagedAttention による効率的な KV キャッシュ管理 | GPU 前提の設計。CPU-only 環境では実用的な速度が出ない |
| Ollama | llama.cpp のラッパー。簡単にモデルをダウンロード・実行可能 | 内部的には llama.cpp だが、RPC 分散のような低レベル制御ができない |
| llama.cpp | C/C++ 実装の軽量推論エンジン。CPU/GPU 両対応、GGUF 量子化対応 | — |
llama.cpp を選んだ決め手
- CPU-only で実用的な速度が出る: 純粋な C/C++ 実装で、SIMD 最適化(AVX2/AVX-512)により CPU でも高速に推論できる
- RPC による分散推論を標準サポート: ビルド時に
-DGGML_RPC=ONを指定するだけで、ネットワーク越しのテンソル演算分散が可能 - GGUF 量子化の豊富な選択肢: Q2_K から Q8_0 まで多段階の量子化形式に対応し、メモリと品質のトレードオフを細かく制御できる
-otによるテンソル単位の配置制御: MoE モデルの expert テンソルを任意のデバイスに配置できる。この柔軟性は他のエンジンにはない- GPU 無し環境のコスト優位性: Azure の GPU VM(NC/ND シリーズ)は CPU VM の 10〜50 倍のコスト。CPU-only で検証できることに意味がある
今回の検証テーマ「GPU 無しの複数 VM で LLM を分散推論できるか?」に対して、RPC 分散を標準サポートする llama.cpp は唯一の現実的な選択肢でした。
llama.cpp RPC の仕組み
llama.cpp の RPC (Remote Procedure Call) 機能は、GPU を持たないマシン同士でもネットワーク経由でテンソル演算を分散できる仕組みです。
[llama-server (vm-1)]
│
├── テンソル演算リクエスト ──→ [rpc-server (vm-2)]
│ └── CPU で演算 → 結果返送
│
└── テンソル演算リクエスト ──→ [rpc-server (vm-3)]
└── CPU で演算 → 結果返送
ワーカー側は rpc-server バイナリを起動するだけで、モデルファイルの配置は不要です。メインサーバーがモデルを読み込み、テンソルデータをワーカーに転送します。
検証の流れと遭遇した問題
問題 1: レイヤーが一切オフロードされない
症状
RPC サーバーが正常に起動し接続も確立されているにもかかわらず、offloaded 0/31 layers to GPU と表示され、全レイヤーが CPU 上で処理されていました。
# vm-2, vm-3 で rpc-server 起動
LD_LIBRARY_PATH=~/llama-rpc nohup ~/llama-rpc/rpc-server -H 0.0.0.0 -p 50052
# vm-1 で llama-server 起動(この時点では -ngl を明示指定せず)
LD_LIBRARY_PATH=~/llama-rpc ~/llama.cpp/build/bin/llama-server \
-m ~/models/gemma-4-26B-A4B-it-UD-Q4_K_M.gguf \
--rpc 10.0.1.4:50052,10.0.1.6:50052
# ログ出力:
# offloaded 0/31 layers to GPU
原因究明: llama.cpp ソースコード解析
-ngl (n_gpu_layers) のデフォルト値は -1(auto)です。ソースコードを追跡しました。
common/arg.cpp L2335-2353: CLI パーシング
// -ngl のパーシング
if (value == "auto") {
params.n_gpu_layers = -1; // auto
} else if (value == "all") {
params.n_gpu_layers = -2;
} else {
params.n_gpu_layers = std::stoi(value);
}
// デフォルトは -1 (auto)
src/llama.cpp L175-400: fit アルゴリズム (llama_params_fit_impl)
-ngl が auto (-1) の場合、fit アルゴリズムが最初に実行されます。このアルゴリズムは各デバイスの空きメモリを調べ、最適なレイヤー配分を決定します。
fit algorithm の挙動:
1. RPC デバイスの空きメモリを検出 → 64GB(十分)
2. デバイスメモリ使用量を推定 → 0 MiB と予測
3. 「変更不要」と判断 → n_gpu_layers = 0 のまま
L375 付近で重要な挙動を発見:
if n_gpu_layers set by user, throws exception and aborts fit
つまり -ngl を明示指定すると fit がスキップされ、指定した値がそのまま使われます。
src/llama-model.cpp L2958-3060: load_tensors
n_gpu_layers = this->n_gpu_layers();
// n_gpu_layers() は params.n_gpu_layers >= 0 ? params.n_gpu_layers : hparams.n_layer + 1
act_gpu_layers = devices.empty() ? 0 : std::min(n_gpu_layers, n_layer + 1);
fit が n_gpu_layers = 0 を決定した後、act_gpu_layers = min(0, 31) = 0 となり、全レイヤーが CPU に配置されます。
解決策
# -fit off で fit アルゴリズムをバイパス
# -ngl 999 で全レイヤーを GPU/RPC にオフロード
# --no-mmap でメモリマップを無効化(RPC 転送に必要)
LD_LIBRARY_PATH=~/llama-rpc ~/llama.cpp/build/bin/llama-cli \
-m ~/models/gemma-4-26B-A4B-it-UD-Q4_K_M.gguf \
--rpc 10.0.1.4:50052,10.0.1.6:50052 \
-fit off -ngl 999 --no-mmap \
-p "Hi" -n 1
これで全 30 レイヤーが正しく分配されることを確認:
レイヤー 0-15 → RPC0 (vm-2: 10.0.1.4)
レイヤー 16-30 → RPC1 (vm-3: 10.0.1.6)
問題 2: MoE Expert テンソルの配置問題
症状
レイヤーのオフロードは成功したものの、MoE モデル特有の expert テンソル(モデルの大部分を占める)が RPC デバイスに配置されない可能性が指摘されました。
Expert テンソルの構造
llama-gguf ツールでモデル内のテンソル名を確認:
~/llama.cpp/build/bin/llama-gguf ~/models/gemma-4-26B-A4B-it-UD-Q4_K_M.gguf r n \
| grep 'ffn.*exps'
tensor[11]: blk.0.ffn_down_exps.scale (512 B)
tensor[12]: blk.0.ffn_down_exps.weight (190 MB)
tensor[16]: blk.0.ffn_gate_up_exps.weight (285 MB)
tensor[33]: blk.1.ffn_down_exps.scale (512 B)
tensor[34]: blk.1.ffn_down_exps.weight (190 MB)
tensor[38]: blk.1.ffn_gate_up_exps.weight (285 MB)
...
(30 レイヤー × 3 テンソル = 合計 360 個、約 13.4 GB)
各レイヤーに以下の expert テンソルが存在:
| テンソル名 | サイズ/レイヤー | 説明 |
|---|---|---|
blk.N.ffn_down_exps.weight |
~190 MB | Expert FFN Down |
blk.N.ffn_down_exps.scale |
512 B | スケール値 |
blk.N.ffn_gate_up_exps.weight |
~285 MB | Expert FFN Gate+Up |
-ot (Override Tensor) による明示配置
llama.cpp には -ot (--override-tensor) オプションがあり、テンソル名の正規表現パターンで配置先バッファを指定できます。
-ot, --override-tensor <tensor name pattern>=<buffer type>,...
問題 3: -ot の buffer type 名が不明
症状
-ot ".*ffn_.*_exps.*=RPC0,RPC1" のように指定しようとしましたが、正しい buffer type 名がわかりませんでした。
発見方法: 意図的にエラーを発生させる
不正な buffer type 名を指定すると、利用可能な buffer type の一覧が表示されます:
LD_LIBRARY_PATH=~/llama-rpc ~/llama.cpp/build/bin/llama-server \
-m ~/models/gemma-4-26B-A4B-it-UD-Q4_K_M.gguf \
--rpc 10.0.1.4:50052,10.0.1.6:50052 \
-ngl 0 --no-mmap \
-ot "dummy=INVALID"
出力:
Available buffer types:
CPU
RPC0[10.0.1.4:50052]
RPC0[10.0.1.6:50052]
重要な発見: buffer type 名は RPC0, RPC1 ではなく、RPC0[10.0.1.4:50052] のように IP:port を含む完全な名前でした。
ソースコード確認
common/arg.cpp L244-273 の parse_tensor_buffer_overrides 関数を確認:
static void parse_tensor_buffer_overrides(
const std::string & value,
std::vector<llama_model_tensor_buft_override> & overrides
) {
// 全デバイスから buffer type 名を収集
std::map<std::string, ggml_backend_buffer_type_t> buft_list;
for (size_t i = 0; i < ggml_backend_dev_count(); ++i) {
auto * dev = ggml_backend_dev_get(i);
auto * buft = ggml_backend_dev_buffer_type(dev);
if (buft) {
buft_list[ggml_backend_buft_name(buft)] = buft;
}
}
// カンマ区切りで複数のオーバーライドルールをパース
for (const auto & override : string_split<std::string>(value, ',')) {
// "pattern=buftype" の形式
std::string::size_type pos = override.find('=');
std::string tensor_name = override.substr(0, pos);
std::string buffer_type = override.substr(pos + 1);
if (buft_list.find(buffer_type) == buft_list.end()) {
printf("Available buffer types:\n");
for (const auto & it : buft_list) {
printf(" %s\n", ggml_backend_buft_name(it.second));
}
throw std::invalid_argument("unknown buffer type");
}
overrides.push_back({tensor_name.c_str(), buft_list.at(buffer_type)});
}
}
問題 4: -ot のカンマ区切りの意味
誤解
# ❌ これは "RPC0 と RPC1 に自動分散" という意味ではない
-ot ".*ffn_.*_exps.*=RPC0,RPC1"
正しい理解
カンマは複数のオーバーライドルールの区切り:
# ✅ 正しい構文: pattern1=buft1,pattern2=buft2
-ot "blk\.0\.ffn.*=RPC0[10.0.1.4:50052],blk\.1\.ffn.*=RPC0[10.0.1.6:50052]"
最終的な起動コマンド
方式 A: -ngl 0 + -ot (expert テンソルのみ RPC)
# rpc-server (vm-2, vm-3)
LD_LIBRARY_PATH=~/llama-rpc \
~/llama-rpc/rpc-server -t $(nproc) -c -H 0.0.0.0 -p 50052
# llama-server (vm-1)
OT_RULES="blk\.[0-9]\.ffn.*exps=RPC0[10.0.1.4:50052]"
OT_RULES+=",blk\.1[0-4]\.ffn.*exps=RPC0[10.0.1.4:50052]"
OT_RULES+=",blk\.1[5-9]\.ffn.*exps=RPC0[10.0.1.6:50052]"
OT_RULES+=",blk\.2[0-9]\.ffn.*exps=RPC0[10.0.1.6:50052]"
LD_LIBRARY_PATH=~/llama-rpc ~/llama.cpp/build/bin/llama-server \
-m ~/models/gemma-4-26B-A4B-it-UD-Q4_K_M.gguf \
-ngl 0 \
--rpc 10.0.1.4:50052,10.0.1.6:50052 \
--ctx-size 8192 -t $(nproc) --no-mmap \
-ot "$OT_RULES"
方式 B: -ngl 999 -fit off (全レイヤーオフロード)
LD_LIBRARY_PATH=~/llama-rpc ~/llama.cpp/build/bin/llama-server \
-m ~/models/gemma-4-26B-A4B-it-UD-Q4_K_M.gguf \
-ngl 999 -fit off \
--rpc 10.0.1.4:50052,10.0.1.6:50052 \
--ctx-size 8192 -t $(nproc) --no-mmap
方式 C: CPU-only (RPC 無し / ベースライン)
~/llama.cpp/build/bin/llama-server \
-m ~/models/gemma-4-26B-A4B-it-UD-Q4_K_M.gguf \
-ngl 0 --ctx-size 8192 -t $(nproc) --no-mmap
方式 D: -ngl 999 -fit off + -ot (組み合わせ)
LD_LIBRARY_PATH=~/llama-rpc ~/llama.cpp/build/bin/llama-server \
-m ~/models/gemma-4-26B-A4B-it-UD-Q4_K_M.gguf \
-ngl 999 -fit off \
--rpc 10.0.1.4:50052,10.0.1.6:50052 \
--ctx-size 8192 -t $(nproc) --no-mmap \
-ot "$OT_RULES"
ベンチマーク結果
テスト条件: 200 tokens 生成 (max_tokens=200, temperature=0.7)
速度比較
| 方式 | 生成速度 (tok/s) | prompt 速度 (tok/s) | 合計時間 |
|---|---|---|---|
| C: CPU-only (RPC 無し) | 10.2 | 60.9 | 20.0s |
B: -ngl 999 -fit off |
10.7 | 49.5 | 19.2s |
D: -ngl 999 -fit off -ot |
9.9 | 49.4 | 20.7s |
A: -ngl 0 -ot |
9.1 | 40.6 | 22.8s |
メモリ配分
| 方式 | vm-1 (メイン) | vm-2 (RPC) | vm-3 (RPC) |
|---|---|---|---|
| C: CPU-only | 全てローカル | 未使用 | 未使用 |
B: -ngl 999 -fit off |
2.4 GB | 10.2 GB | 9.9 GB |
D: -ngl 999 -fit off -ot |
2.3 GB | 9.8 GB | 10.4 GB |
A: -ngl 0 -ot |
5.9 GB | 8.3 GB | 8.3 GB |
速度分析
なぜ RPC 分散が速くならないのか
1. ネットワーク帯域幅がボトルネック
LLM の推論はメモリバンド幅律速です。RPC 分散では、レイヤー境界で隠れ状態 (hidden state) をネットワーク越しに転送する必要があります。
RPC で転送されるデータ:
- モデルロード時(一度だけ): メインサーバーがモデル重みを RPC ワーカーに転送(Qwen2.5-72B なら約 43 GB を分割送信)
- 推論時(トークンごと): レイヤー境界で隠れ状態を転送。サイズは
hidden_dim × sizeof(float)で、生成時は 10〜32 KB/トークン、prompt 処理時はトークン数倍(100 トークンなら 1〜3.2 MB)
メモリ帯域幅 vs ネットワーク帯域幅:
| 接続方式 | 帯域幅 | DDR5 比 |
|---|---|---|
| DDR5 メモリ (VM 内) | ~50 GB/s | 1x(基準) |
| Azure VNet (D16as_v6) | ~1.5 GB/s (12 Gbps) | 33x 遅い |
| 25GbE NIC | ~3 GB/s | 17x 遅い |
| InfiniBand HDR (200 Gbps) | ~25 GB/s | 2x 遅い |
| InfiniBand NDR (400 Gbps) | ~50 GB/s | 同等 |
| NVLink (H100 間) | 900 GB/s | 18x 速い |
CPU RPC 分散がメモリ帯域と同等の性能を出すには、最低でも InfiniBand NDR (400 Gbps) クラスのネットワークが必要です。今回の Azure VNet は DDR5 の 1/33 の帯域しかなく、ここが圧倒的なボトルネックです。
なお、GPU VM を RPC で分散しても状況は悪化します。GPU の HBM 帯域(A100: 2 TB/s、H100: 3.35 TB/s)はネットワークの 100 倍以上速いため、GPU が速く計算を終えるほどネットワーク待ちの比率が上がります。GPU の恩恵を受けるには、NVLink で直結された 1 台のマルチ GPU マシンに載せるのが正解です。
Azure VNet 内の通信レイテンシ自体は低い(< 1ms)ですが、トークンごとに数十回の往復が積み重なると無視できない遅延になります。
2. 方式 A が最も遅い理由
-ngl 0 -ot 方式では、attention は vm-1 の CPU で実行し、expert テンソルは RPC に送る構成です。1 レイヤーの処理中に「CPU → RPC → CPU」の往復が発生し、最も通信回数が多くなります。
Layer N の処理フロー (方式 A):
[vm-1 CPU] attention 計算
↓
[vm-2 RPC] expert テンソル演算 ← ネットワーク往復
↓
[vm-1 CPU] 結果統合 → 次レイヤーへ
3. 方式 B が RPC 構成で最速の理由
全レイヤーが RPC 側で実行されるため、デバイス間転送はレイヤー分割境界(1 箇所)のみです。
Layer 0-15 の処理フロー (方式 B):
[vm-2 RPC] 全テンソル演算(ローカルで完結)
↓ (1 回だけネットワーク転送)
[vm-3 RPC] Layer 16-30 の全テンソル演算
↓
[vm-1] 最終出力受信
4. CPU-only が実用上最速である理由
このモデル (15.7 GB) は vm-1 の 64 GB RAM に余裕で収まります。ネットワーク通信が一切発生しないため、prompt 処理が圧倒的に速く (60.9 tok/s)、生成速度もほぼ同等です。
CPU-only の利点:
✅ ゼロネットワークオーバーヘッド
✅ メモリバンド幅をフル活用(DDR5 なら 50+ GB/s)
✅ prompt バッチ処理が最も効率的
RPC の恩恵:
❌ 演算並列化 < ネットワークレイテンシ (このモデルサイズでは)
ソースコード解析は意味があったのか
本検証では llama.cpp のソースコードを直接読み込み、挙動の原因を特定しました。この作業に意味があったのかを振り返ります。
解析なしでは解決できなかった問題
問題 1(レイヤーが 0/31 offload)は、ソースコード解析なしでは根本原因にたどり着けなかった。
- ログには
offloaded 0/31 layers to GPUとしか出ない -ngl 999を指定すればオフロードはされるが、なぜデフォルトで 0 になるのかがわからない- fit アルゴリズム (
llama_params_fit_impl) が RPC デバイスのメモリ使用量を 0 MiB と予測する挙動は、ソースコード L375 付近を読んで初めて判明した - ドキュメントには
-fit offの存在すら記載がなかった
問題 3(buffer type 名の発見)は、ソースコード解析がショートカットになった。
parse_tensor_buffer_overrides()関数を読むことで、「不正な名前を渡せば一覧が出る」ことが事前にわかった- ソースを読まなくても試行錯誤で発見できたかもしれないが、確信を持って操作できた
解析しなくても解決できた問題
問題 4(カンマの意味)は、ソースを読まなくてもエラーメッセージから推測できた。
-otのパーシングコードを読んで確認したが、実際には構文例を試せばわかる程度の問題
結論: ソースコード解析は「保険」として有効
| 観点 | 評価 |
|---|---|
| 問題解決への直接的貢献 | 問題 1 の根本原因特定には必須だった |
| 時間コスト | grep + コード追跡で各問題 10〜30 分。試行錯誤で同じ時間を消費するよりは効率的 |
| 再現性 | 「なぜそうなるか」を理解しているため、バージョンアップ時に同じ問題が再発しても即座に対応できる |
| 記事の説得力 | 「ソースコードでこう書かれている」は「こうしたら動いた」より強い根拠になる |
llama.cpp は活発に開発が進んでおり、ドキュメントが挙動に追いつかないことがあります。「公式ドキュメントに書いていない → ソースを読む」というアプローチは、特に RPC のような比較的新しい機能では有効でした。ただし、全てのケースでソースコード解析が必要だったわけではなく、試行錯誤で済む問題もありました。
結論と学び
RPC 分散が有効なケース
モデルサイズ > 1 台の RAM容量 の場合:
例: 70B Q4_K_M (約 40GB) を 32GB RAM マシン 2 台で動かす
→ メモリ不足のため RPC 分散が必須
→ 動かないよりは遅くても動く方が価値がある
RPC 分散が逆効果なケース(今回)
モデルサイズ < 1 台の RAM容量 の場合:
例: 26B MoE Q4_K_M (15.7GB) を 64GB RAM マシンで動かす
→ 1 台で十分載る
→ ネットワークオーバーヘッドで遅くなるだけ
技術的な学び
| 学び | 詳細 |
|---|---|
| fit アルゴリズムの罠 | -ngl auto (デフォルト) では fit が RPC デバイスのメモリ使用量を 0 と予測し、オフロードしない判断をする。-fit off または -ngl の明示指定が必要 |
| MoE の expert テンソル | レイヤー単位のオフロード (-ngl) だけでは expert テンソルが配置されない場合がある。-ot で明示指定が必要 |
-ot の buffer type 名 |
RPC0, RPC1 ではなく RPC0[IP:port] 形式。不正な名前を渡すと一覧が表示される |
-ot のカンマの意味 |
「複数デバイスへの分散」ではなく「複数ルールの区切り」 |
--no-mmap の必要性 |
RPC 転送にはメモリマップではなく実メモリへの読み込みが必要 |
rpc-server の -t -c フラグ |
-t $(nproc) で全 CPU スレッド使用、-c でローカルファイルキャッシュ有効化 |
速度改善の指針
今回の構成(CPU-only、GPU 無し)で速度を上げるには:
- より大きな VM を 1 台使う: D16as_v6 → D64as_v6 (64 vCPU) にすれば
-t 64で演算スループットが 4 倍に - GPU 付き VM 1 台に載せる: NC/ND シリーズの GPU VM なら llama.cpp の CUDA バックエンドで大幅高速化(A100 80GB なら 100+ tok/s も可能)。ただし GPU を RPC で分散しても、ネットワークがボトルネックになる点は CPU と同じ — むしろ GPU の演算が速い分、通信待ちの比率が上がり逆効果が悪化する
- より小さい量子化を使う: Q4_K_M (15.7GB) → Q2_K 等でメモリバンド幅の負荷を軽減(品質とのトレードオフ)
- RPC は 1 台に載らない大型モデル専用と割り切る: 70B, 120B クラスのモデルで初めて真価を発揮
付録: llama.cpp ソースコード参照箇所
本検証で解析した主要なコード箇所:
| ファイル | 行番号 | 内容 |
|---|---|---|
common/arg.cpp |
L244-273 | parse_tensor_buffer_overrides() — -ot のパーシング |
common/arg.cpp |
L2335-2353 | -ngl の CLI パーシング (auto/all/数値) |
common/common.cpp |
L1419 | mparams.n_gpu_layers = params.n_gpu_layers |
src/llama.cpp |
L175-400 | llama_params_fit_impl — fit アルゴリズム |
src/llama.cpp |
L982-1140 | デバイス追加ロジック (RPC デバイス含む) |
src/llama-model.cpp |
L2958-3060 | load_tensors — レイヤー割り当てロジック |
src/llama-model.cpp |
L8065-8066 | n_gpu_layers() — デフォルト値の解決 |
使用したツール・コマンド一覧
# GGUF ファイルのテンソル名ダンプ
~/llama.cpp/build/bin/llama-gguf model.gguf r n | grep 'ffn.*exps'
# 利用可能な buffer type 名を表示させるトリック
llama-server -m model.gguf --rpc IP:50052 -ot "dummy=INVALID"
# RPC サーバー起動(全スレッド + キャッシュ有効)
rpc-server -t $(nproc) -c -H 0.0.0.0 -p 50052
# SSH トンネルで WebUI にアクセス
ssh -L 8080:localhost:8080 -N azureuser@<public-ip>
追加検証: Qwen3-30B-A3B でも試してみた
Gemma 4 だけでは「たまたまこのモデルで遅かっただけでは?」という疑問が残るため、同じ MoE アーキテクチャの Qwen3-30B-A3B (Q4_K_M, 17.3 GB) でも 1VM vs 3VM を比較しました。
起動コマンド
# CPU-only (1VM)
llama-server -m Qwen3-30B-A3B-Q4_K_M.gguf \
--ctx-size 8192 -t 16 --no-mmap --host 127.0.0.1 --port 8080
# RPC 3VM (-ngl 999 -fit off)
LD_LIBRARY_PATH=~/llama-rpc llama-server -m Qwen3-30B-A3B-Q4_K_M.gguf \
-ngl 999 -fit off --rpc 10.0.1.4:50052,10.0.1.6:50052 \
--ctx-size 8192 -t 16 --no-mmap --host 127.0.0.1 --port 8080
結果
| 方式 | 生成速度 (tok/s) | prompt 速度 (tok/s) | 合計時間 |
|---|---|---|---|
| CPU-only (1VM) | 18.2 | 69.5 | 11.3s |
RPC 3VM (-ngl 999 -fit off) |
18.7 | 47.3 | 11.1s |
生成速度はほぼ同じ (18.2 → 18.7 tok/s) ですが、prompt 処理は RPC で 32% 低下 (69.5 → 47.3 tok/s) しています。Gemma 4 と同じ傾向で、1 台に載るモデルを分散しても速くはなりません。
なお Qwen3-30B-A3B は MoE (30B パラメータ中 3B がアクティブ) のため、Gemma 4 (26B/4B アクティブ) よりも生成が速い結果になりました。
Dense モデルでの動作確認: qwen2.5-7b-instruct
MoE モデルだけでなく、Dense(非 MoE)モデルでも同じ手順で動作することを確認するため、qwen2.5-7b-instruct-q3_k_m.gguf(3.6 GB, Q3_K_M 量子化)でもテストしました。
# CPU-only (1VM)
llama-server -m qwen2.5-7b-instruct-q3_k_m.gguf \
--ctx-size 8192 -t 16 --no-mmap --host 127.0.0.1 --port 8080
# RPC 3VM (-ngl 999 -fit off)
LD_LIBRARY_PATH=~/llama-rpc llama-server -m qwen2.5-7b-instruct-q3_k_m.gguf \
-ngl 999 -fit off --rpc 10.0.1.4:50052,10.0.1.6:50052 \
--ctx-size 8192 -t 16 --no-mmap --host 127.0.0.1 --port 8080
| 方式 | 生成速度 (tok/s) | prompt 速度 (tok/s) | 合計時間 |
|---|---|---|---|
| CPU-only (1VM) | 10.8 | 46.2 | 9.9s |
RPC 3VM (-ngl 999 -fit off) |
10.9 | 38.9 | 8.8s |
| 方式 | vm-1 (メイン) | vm-2 (RPC) | vm-3 (RPC) |
|---|---|---|---|
| CPU-only | 全てローカル (3.6 GB) | 未使用 | 未使用 |
| RPC 3VM | 0.2 GB | 1.6 GB | 1.8 GB |
Dense モデルでも RPC 分散は問題なく動作します。ただし 3.6 GB と軽量なため、ネットワークオーバーヘッドの影響が顕著で、prompt 速度は 16% 低下 (46.2 → 38.9 tok/s) しています。生成速度はほぼ同等で、合計時間は生成トークン数が少なかった(85 tokens で stop)ため RPC 側が短くなっていますが、実質的な差はありません。
-ot による expert テンソル配置の問題は発生しません。-ngl 999 -fit off だけで全レイヤーが正しく RPC に配置されます。メモリ限界への挑戦: Qwen2.5-72B-Instruct (Dense 72B)
ここまでのテストでは全てのモデルが 1 台の 64 GB RAM に余裕をもって載っていました。RPC 分散が真に活きるのは「1 台に載りきらない」場合のはずです。そこで、Dense 72B パラメータの Qwen2.5-72B-Instruct (Q4_K_M 量子化, 約 43 GB) で限界に挑みました。
--no-mmap でロードすると空きメモリが 619 MB まで減少するため、OOM リスクがあります。# CPU-only (1VM) — メモリぎりぎり
llama-server -m qwen2.5-72b-instruct-q4_k_m-00001-of-00012.gguf \
--ctx-size 2048 -t 16 --no-mmap --host 127.0.0.1 --port 8080
# RPC 3VM — モデルを 3 台に分散
LD_LIBRARY_PATH=~/llama-rpc llama-server \
-m qwen2.5-72b-instruct-q4_k_m-00001-of-00012.gguf \
-ngl 999 -fit off --rpc 10.0.1.4:50052,10.0.1.6:50052 \
--ctx-size 2048 -t 16 --no-mmap --host 127.0.0.1 --port 8080
ベンチマーク結果
| 方式 | 生成速度 (tok/s) | prompt 速度 (tok/s) | 合計時間 |
|---|---|---|---|
| CPU-only (1VM) | 0.91 | 6.71 | 97.8s |
RPC 3VM (-ngl 999 -fit off) |
0.95 | 4.25 | 90.4s |
メモリ配置
| 方式 | vm-1 (メイン) | vm-2 (RPC) | vm-3 (RPC) |
|---|---|---|---|
| CPU-only | 44.2 GB (空き 619 MB) | 未使用 | 未使用 |
| RPC 3VM | 0.7 GB | 20.7 GB | 20.8 GB |
CPU-only では 64 GB 中 44.2 GB を消費し、空きメモリがわずか 619 MB まで低下しました。一方 RPC 3VM では vm-1 はわずか 0.7 GB で済み、モデルの大部分が vm-2/vm-3 に分散されています。
考察
生成速度は 0.91 → 0.95 tok/s とほぼ同等(誤差範囲)ですが、prompt 処理は 37% 低下 (6.71 → 4.25 tok/s) しており、他のモデルと同じ傾向です。
注目すべきは、CPU-only でメモリが極限まで逼迫していたにもかかわらず、スワップは発生せず生成速度も低下しなかった点です。Linux のページキャッシュ管理が --no-mmap で直接確保されたメモリとうまく共存し、実質的なパフォーマンス劣化を防いでいました。
つまり、「メモリが足りないから RPC で分散して速くしよう」という戦略は、モデルが辛うじて 1 台に載る限り機能しません。 RPC の真価は、物理的に 1 台のメモリでは載りきらないモデル(例: この VM で 80B+ や量子化なしの 70B クラス)を動かす場合に発揮されます。
おわりに
「3 台で分散すれば速くなるはず」という素朴な期待とは裏腹に、ネットワーク越しの CPU 分散推論は 1 台の RAM に載るモデルでは遅くなるという結果でした。Qwen2.5-72B (43 GB) で 64 GB VM のメモリをほぼ使い切る極限状態まで試しましたが、それでも CPU-only の方が prompt 処理は速く、生成速度は同等でした。
llama.cpp の RPC 機能自体は正しく動作しており、テンソルの配置制御 (-ot) も柔軟です。しかし、CPU-only 環境での LLM 推論はメモリバンド幅律速であり、ネットワーク帯域はメモリバンド幅に遠く及びません。
RPC 分散が真に活きるのは:
- 1 台のメモリに物理的に載らない巨大モデルを動かす場合(速度向上ではなく「動かせること自体」が価値)
- GPU 付きノードを複数束ねる場合(PCIe/NVLink 並みの帯域)
という限定的な場面であることが、4 モデル (Gemma 4 27B / Qwen3-30B / qwen2.5-7b / Qwen2.5-72B) の実測データとソースコード解析の両面から確認できました。
結局のところ、メモリは増えるがCPU だろうが GPU だろうが、ネットワーク越しに分散する限りネットワークがボトルネックになって遅くなる🐑