この記事の本文は、人間の作業や指示をベースに Claude Code が書き、人間が調整したものです。English version
はじめに
GMKtec EVO-X3 (Strix Halo、128GB) の内蔵 GPU Radeon 8060S と、OCuLink でつないだ Radeon AI PRO R9700 32GB に、125B 級の MoE モデル Qwen3.8-Flash-Next を分担させたら、約 8 万トークンの文脈でも 80 tok/s 台、上限近くの 13 万トークンでも 70 tok/s 前後で生成できるようになった。
普通に考えると、遅いリンクの先にある GPU と内蔵 GPU の組み合わせは扱いにくい。この記事は、どこが詰まって、どう避けたかの記録である。既存の Strata に小さなパッチを当てるだけで済んだのが、いちばんの収穫だった。
末尾の付録には、今回の構成、lucebox、llama.cpp の 3 つで同じ論理パズルを解かせた実行結果の比較を置いた。12 本のセッションを同時に再生し、トークンの積み上がり方と速度の違いを並べて見られる。
着想のもとは、xct さんの Local AI with Strix Halo & R9700, Part 2: Writing Our Own Engine である。似た構成 (Strix Halo と OCuLink 接続の R9700) で同じ Qwen3.8-Flash-Next を自作のエンジンで動かし、約 10 万トークンの文脈でも 82 tok/s を出している。ここではエンジンを書くのではなく、既存の Strata に手を入れるという別の道を取った。それでも、このハードで長い文脈にどこまでの性能が期待できるかを考える種にした。R9700 の電力上限を 210 W に下げるなど、騒音を抑える設定もこの記事を参考にした。
構成
主役は 2 つの GPU である。速いが小さい R9700 と、遅いが大きい 8060S に、expert を分けて載せる。
| 部品 | 内容 |
|---|---|
| 本体 | GMKtec EVO-X3 (Strix Halo)、メモリ 128GB |
| 内蔵 GPU | Radeon 8060S。メインメモリを共有する |
| 外付け GPU | Radeon AI PRO R9700 32GB。DEG1 と OCuLink (PCIe 4.0 x4) で接続、電力は 210W に制限 |
| エンジン | Strata (ソースビルド、gfx1201 と gfx1151 の両対応) |
| モデル | Qwen3.8-Flash-Next UD-Q4_K_XL、1 層 512 expert、top-10 |
モデルの expert は全部で約 72 GiB ある。R9700 に約 23 GiB、8060S に約 49 GiB を置くと、全 expert が GPU に載る。入口は llama-server のルーターで、q38fn-strata などモデル名を指定すると子プロセスとして起動する。
壁: OCuLink の細いリンク
問題はプロンプトの処理だった。毎ターン、わずか 1,000 トークンの追加でも約 31 GiB の expert を R9700 へ送っていた。
Strata には別の GPU に expert を持たせるヘルパー機能がある。これで 8060S に入るだけ載せると、生成は 100 tok/s まで伸びた。しかしヘルパーが手伝うのは生成だけで、プロンプトは R9700 だけで処理する。つまり毎ターン、全 expert を流し直す。
OCuLink の実効速度は約 7.2 GB/s なので、次のターンの応答に約 6 秒かかった。エージェントのように何十ターンも回す用途では、この時間が効いてくる。ちなみに expert を少しだけ持たせると逆効果で、2,000 個では分担なしより遅くなった。
パッチの要点
元のプロンプト処理では、GPU の間を通るのは expert の重みそのものだった。8060S に置いた分も含め、毎ターン expert を丸ごと R9700 へ送り直していた。方針は単純で、expert の重みは持ち主の GPU に置いたまま送らず、GPU の間で送るのは各層の入力 (activation) の行と計算結果の行だけにする。プロンプトでも、各 GPU が自分の持つ expert を計算する。
Strata にはプロンプトも分担する --peer-device がもともとある。ただしヘルパーとは併用できず、生成を速くする --remote-expert-opt が効かない。そこで、生成はヘルパーと --remote-expert-opt のままにし、プロンプトではヘルパーのキャッシュを peer として使うようにした。
PrefillPeerの追加: プロンプト処理が peer に求めるのはhas()とslot_ptr()など数個だけだった。この窓口を切り出し、ヘルパーのキャッシュも同じように見せた。STRATA_HELPER_PREFILL=1: ヘルパーをプロンプトの peer にする。STRATA_LEND_WITH_HELPERS=1: R9700 がプロンプト用に貸し出すスロットの中身を RAM に残す。
GPU 間の直接転送は使えなかった。HIP は「可能」と答えるが、試験コピーが壊れる。Strata のホスト経由の経路がそのまま動いたので、結果として問題にならなかった。行数の上限 --peer-prefill-rows も、最終的には指定しなくてよくなった。ヘルパーを peer にするときの既定を全部の行にしたので、上限を超えて直接転送に回る行が出ない。
コードの要所
改造の中心は、プロンプト処理がもう 1 枚の GPU に求めるものを小さな窓口に切り出したことだ。新しいファイルはこれ 1 つである。
// include/strata/core/prefill_peer.hpp
class PrefillPeer {
public:
virtual ~PrefillPeer() = default;
virtual bool valid() const = 0;
virtual int device() const = 0;
/// Whether the primary and this GPU can read each other's memory directly; false takes the mapped host route.
virtual bool p2p() const = 0;
virtual bool has(int64_t layer, int64_t expert) const = 0;
/// The device address of (layer, expert)'s blob on this GPU, or null when it is not held.
virtual const uint8_t* slot_ptr(int64_t layer, int64_t expert) = 0;
};
もともと peer だった PeerExperts に加えて、生成時に 8060S で expert を計算するヘルパーのキャッシュ RemoteExperts にもこの窓口を持たせた。中身は、キャッシュがその expert を持っているかと、その置き場所を返すだけである。P2P は動かないので、常にホスト経由の経路を使う。
-class RemoteExperts {
+class RemoteExperts : public PrefillPeer {
...
+ /// STRATA_HELPER_PREFILL=1: the prompt path computes this cache's experts here (always the mapped host route).
+ bool valid() const override { return device_ >= 0; }
+ int device() const override { return device_; }
+ bool p2p() const override { return false; }
+ bool has(int64_t layer, int64_t expert) const override { return holds(layer, (int32_t) expert); }
+ const uint8_t* slot_ptr(int64_t layer, int64_t expert) override {
+ const int32_t slot = cache_.slot_of(layer, expert);
+ return slot >= 0 ? cache_.device_slot(slot) : nullptr;
+ }
プロンプト処理の側 (Prefill::set_peer とその中の PeerPrefill) は、受け取る型を PeerExperts* から PrefillPeer* に変えた。計算は、本家の peer の処理をそのまま使う。あとは起動時に、peer がないときだけヘルパーを peer として渡す。
+ if (helper_prefill && !peer.valid() && o.peer_prefill_rows != 0 && remote_experts[0].valid()) {
+ const int64_t rows = o.peer_prefill_rows > 0 ? o.peer_prefill_rows : o.prefill_chunk * K;
+ if (!sp.set_peer(&remote_experts[0], rows, err)) {
+ std::fprintf(stderr, "strata serve: %s - the helper's experts stream to the primary GPU\n", err.c_str());
+ err.clear();
+ (void) cudaGetLastError();
+ } else {
+ std::fprintf(stderr, "strata serve: the prompt path computes the experts of the helper cache on GPU %d "
+ "(STRATA_HELPER_PREFILL, up to %lld rows per layer)\n",
+ remote_experts[0].device(), (long long) rows);
+ }
+ }
STRATA_LEND_WITH_HELPERS は 1 行の条件の変更である。ヘルパーがあるときも、R9700 がプロンプト用にスロットを貸し出す領域を使うようにした。これで貸し出したスロットの expert は RAM に残り、プロンプトのかたまりごとにファイルから読み直さなくなった。本家では、ヘルパーがあるとこの領域を使わない。
- const bool lend = lend_from_slot >= 0 && lend_from_slot < n_slots && additional_gpu_pairs.empty();
+ const bool lend = lend_from_slot >= 0 && lend_from_slot < n_slots &&
+ (additional_gpu_pairs.empty() || (lend_with_helpers && stage_lend_regions_.empty()));
このほかに、MXFP4 の派生モデルへの対応と、サーバ側 (serve/server.py) の小さな変更がある。サーバ側は、思考を打ち切るときの一文を環境変数で差し替える変更と、付録の論理パズルで触れる会話の再利用の修正である。抜粋は Strata (MIT License、Copyright (c) 2026 Niko1221 and the Strata contributors) の 146df3c4 に対する変更である。
結果
次のターンは約 6 秒から約 2 秒に縮み、生成は分担なしの約 1.5 倍を保った。130K の最初の読み込みだけは、分担なしより 1 割ほど遅い。
| 項目 | 分担なし | ヘルパーのみ | 今回の構成 | lucebox |
|---|---|---|---|---|
| 次のターン | 2.9–3.6 秒 | 6.0–6.7 秒 | 1.6–2.1 秒 | 1.8–2.7 秒 |
| 最初の応答 31K | 17.8 秒 | 23.1 秒 | 18.8 秒 | 14.7 秒 |
| 最初の応答 130K | 62.3 秒 | 88.7 秒 | 72.2 秒 | 65.6 秒 |
| 長い文脈の生成 | 48–52 tok/s | 68–82 tok/s | 67–83 tok/s | 35–38 tok/s |
| コード生成 | 59–65 tok/s | 100 tok/s | 101 tok/s | 71–79 tok/s |
今回の構成のプロンプト処理 (prefill) の速度は、31K で 1,670 tok/s、74K で 1,710 tok/s、130K で 1,810 tok/s である。文脈が長くなっても落ちず、むしろ少し上がる。
各条件 1〜2 回の測定なので、大雑把な目安として見てほしい。ヘルパーのみの列は、読み出し設定 STRATA_PREFILL_HELPER_SOURCE=1 を使った値である。
控えめに見ても、長い文脈での生成は 70 tok/s の水準を下回らない。表の値は控えめな結果である。手元でも計測はしているが、公開できて、なおかつ自然で乱雑なデータを使ったベンチマークを用意するのが少し手間だったので、それは載せていない。実際の利用では 100 tok/s で走ることもあり、上限近くの 13 万トークンのデータを入れても 80 tok/s 前後は出て、100 tok/s を超えることもある。
最終的な作業時間で見ると、長い回答は llama.cpp の約 6 割の時間で終わる。コード修正課題はターン数の揺れが大きく、速度差が出る前に終わる回もあるので差がわかりにくい。それでも数ターン重ねれば、すぐにこのくらいの差になる。プロンプト処理 (prefill) の時間などが積み重なるので、長い作業では倍程度の差を期待できる。
| 作業 | llama.cpp | lucebox | 今回の構成 |
|---|---|---|---|
| 思考つきの長い回答 1 件 (約 1 万〜1.4 万トークン) | 215–292 秒 | 162–221 秒 | 140–181 秒 |
| コード修正課題 R1 1 回 | 545–616 秒 | 426–899 秒 | 403–638 秒 |
llama.cpp は 8060S だけで IQ4_XS を動かした値で、R9700 を使っていない。R1 は合格した回だけを比べた。回数は llama.cpp が 4 回、lucebox が 7 回、今回の構成が 3 回で、条件も完全には揃っていない。
派生モデルも動かす
同じ構造の派生モデルは、パックを作るだけで済む場合がある。形式が新しい場合だけ、エンジンに手を入れる。
- Uncensored (IQ4_XS): 追加の実装は不要だった。expert が小さい分、本家より速く、130K の読み込みは 62.7 秒だった。
- CYBER-FROST (MXFP4): エンジンが形式に対応していなかった。IQ4_NL と同じ形で計算処理を足し、MMQ と PLE 表も対応させた。
最後に 3 モデルを回帰テストにかけた。約 44K トークンからの抽き出し、続きのターン、ツールの往復、ストリームの中断、同時の 2 リクエストは、すべて合格した。
ハマりどころ: 消えた 70 GiB
モデルを外したのに、約 70 GiB がどのプロセスにも属さず残った。正体は 8060S 側の TTM ページプールで、リークではなくドライバの再利用用の保持だった。
この保持は時間では減らず、MemAvailable にも現れない。8060S に載るモデルはプールを再利用するので実害はない。しかし普通のメモリを 26 GiB 確保しただけで、MemAvailable は 11.9 GiB まで下がった。プールには 84 GiB 残ったままで、回収が追いつかない。自作のメモリ監視が誤って止める水準だった。
ttm.page_pool_size を 16 GiB (4,194,304 ページ) に下げると、モデルを外した後の MemAvailable は約 105 GiB に戻った。起動パラメータに入れていた値が 116 GiB だったのが原因なので、同じ設定の Strix Halo 機は確かめる価値がある。
残っていること
次に試すのは、R9700 と 8060S の expert の配分である。今は単純に上位を R9700 に置いているので、STRATA_PEER_HOT のように両 GPU の処理時間を揃えると、130K の読み込みはまだ縮むかもしれない。CYBER-FROST の答案の品質も、もう少し回数を見たい。
パッチは手元の実験用で、上流には送っていない。
付録: 注文ルールを直す課題
3 構成ともテスト 10 件を通し、今回この課題では lucebox が最も短かった。課題は、注文の状態遷移と送料計算を、仕様書どおり Rust の enum と match で直すものである。初期状態ではテストがすべて失敗する。
| 構成 | テスト | 全体 | 読み込みを除く作業 | ターン | 総トークン | 壊れた呼び出し |
|---|---|---|---|---|---|---|
| 今回の構成 (q38fn-strata) | 10 / 10 | 213 秒 | 125 秒 | 8 | 21,157 | 0 |
| lucebox (q38fn-luce) | 10 / 10 | 199 秒 | 97 秒 | 8 | 18,451 | 0 |
| llama.cpp (q38fn) | 10 / 10 | 296 秒 | 210 秒 | 11 | 21,656 | 0 |
各 1 回で、モデルの解き方による揺れを含む。全体にはモデルの読み込みが入る。
今回の構成が書いた apply は次のとおりである。状態とイベントの組を 1 つの match で読める。
pub fn apply(state: OrderState, event: Event) -> Result<OrderState, Rejected> {
use {Event as E, OrderState as S, Rejected as R};
match (state, event) {
(S::Cart, E::Place { total: 0 }) => Err(R::EmptyOrder),
(S::Cart, E::Place { total }) => Ok(S::Placed { total }),
(S::Placed { total }, E::Pay { amount, .. }) if amount != total => {
Err(R::AmountMismatch { expected: total, got: amount })
}
(S::Placed { total }, E::Pay { payment, .. }) => Ok(S::Paid { total, payment }),
(S::Placed { .. }, E::Cancel) => Ok(S::Cancelled { refund: 0 }),
(S::Paid { .. }, E::Ship { tracking }) if tracking.trim().is_empty() => {
Err(R::MissingTracking)
}
(S::Paid { total, payment }, E::Ship { tracking }) => {
Ok(S::Shipped { total, payment, tracking })
}
(S::Paid { total, payment }, E::Cancel) => {
Ok(S::Cancelled { refund: cancel_refund(total, payment) })
}
(S::Shipped { total, payment, .. }, E::Deliver) => Ok(S::Delivered { total, payment }),
(S::Delivered { total, payment }, E::Return { days, opened }) => {
return_refund(total, payment, days, opened).map(|refund| S::Returned { refund })
}
_ => Err(R::WrongState),
}
}
付録: 論理パズル 2 問を道具ありで解く
ベンチマーク用に作ったオリジナルの論理パズル 2 問を omp の道具ありで解かせ、3 構成を 4 本ずつ並べた。見るのは答えの中身ではなく、トークンの積み上がり方と速度である。下の再生ページでは、12 本のセッションの文脈の長さと速度が動く様子を並べて見られる (別タブで開く)。
モデルはどれも Qwen3.8-Flash-Next で、Strata(patched) と lucebox は UD-Q4_K_XL、llama.cpp は UD-IQ4_XS を使った。llama.cpp は 2 本が R9700 と 8060S の層分割 (3:7)、2 本が 8060S だけで、R9700 を足しても生成速度は変わらなかった。
Strata(patched) は、ファイルを書いた次のターンでトークンの区切りが食い違い、会話の再利用が切れる問題を直した後の結果である。履歴のバイト列が前回の生成と同じなら、前回のトークンをそのまま使うようにした。各本の上限は 900 秒。