R9700 と EVO-X3 の 8060S で70〜80tok/s程度を保証する高速安定分担; Strata パッチ

この記事の本文は、人間の作業や指示をベースに 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
内蔵 GPURadeon 8060S。メインメモリを共有する
外付け GPURadeon 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 などモデル名を指定すると子プロセスとして起動する。

問題はプロンプトの処理だった。毎ターン、わずか 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 を計算する。

GPU の間を通るのは activation と結果だけR9700dense 層、KV、MTPよく使う expert 約 23 GiB自分の expert を計算する8060S残りの expert 約 49 GiBメインメモリを共有自分の expert を計算するactivation結果 (重み付き和)PCIe 4.0 x4、ホスト経由生成は --remote-expert-opt、プロンプトは STRATA_HELPER_PREFILL
今回の分担 · 2 つの GPU と間を流れるもの

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 秒
最初の応答 31K17.8 秒23.1 秒18.8 秒14.7 秒
最初の応答 130K62.3 秒88.7 秒72.2 秒65.6 秒
長い文脈の生成48–52 tok/s68–82 tok/s67–83 tok/s35–38 tok/s
コード生成59–65 tok/s100 tok/s101 tok/s71–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.cpplucebox今回の構成
思考つきの長い回答 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 / 10213 秒125 秒821,1570
lucebox (q38fn-luce)10 / 10199 秒97 秒818,4510
llama.cpp (q38fn)10 / 10296 秒210 秒1121,6560

各 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 秒。

douro

Software Developper, Security Researcher more


2026-11-08