AMDのNPU(XDNA2)でローカルLLMを動かす FastFlowLM(flm) を実機で回したときに、
実際に踏んだ罠をまとめます。Ryzen AI Max+ 395(Strix Halo)/Windows 11 での話です。
速度や電力の実測そのものは別記事に書いたので、こちらはエラーで止まったときの切り分け専用です。
結論(症状から引く)
| 症状 | 原因 | 対処 |
|---|---|---|
| APIが404 | パス接頭辞が違う | FLMとLM Studioは /v1、Lemonade Serverは /api/v1
|
flm run が返ってこない |
対話モードに入っている |
flm serve + API で叩く |
| TTFTが40秒超と出る | 思考トークンを数えていない |
reasoning_content も1トークンとして扱う |
| ドライバ版数が要件より小さく見える | 採番体系が変わった | 大小比較せず flm validate に判定させる |
| NPUに2つ目のモデルが載らない | 排他ロック仕様 | 同時に保持できるのは1つだけ |
| NPU使用率が取れない | 専用カウンターが存在しない | タスクマネージャで見る |
| 大きいモデルがNPUに載らない | NPUの共有メモリ上限 | 上限はOS側メモリの半分。BIOSのVRAM配分を見直す |
.ps1 が文字化けして構文エラー |
PowerShell 5.1 の既定エンコード | BOM付きUTF-8で保存する |
0. 前提の確認(ここで弾かれる人がいる)
FastFlowLM が対応するのは XDNA2 世代です。
flm version # FLM v1.0.4
flm validate
flm validate の出力。
[Windows] NPU: XDNA2
[Windows] NPU dirver version: 32.0.20102.3930
XDNA2 と出れば対応世代です。XDNA1世代(Ryzen AI 7000/8000/200番台)は非対応なので、
ここで弾かれる場合はソフト側では解決しません。
なお出力の dirver は FastFlowLM 側の誤字で、実害はありません。
インストール直後にコマンドが見つからない場合
同じセッションではPATHが更新されていないことがあります。
$env:Path = [System.Environment]::GetEnvironmentVariable('Path','Machine') + ';' +
[System.Environment]::GetEnvironmentVariable('Path','User')
1. ドライバ版数を要件と比較して悩まない
FastFlowLM は NPUドライバ 32.0.203.311 以降を要求すると書かれています。
一方、実機のドライバはこうでした。
Get-CimInstance Win32_PnPSignedDriver |
Where-Object { $_.DeviceName -match 'NPU|Neural|AI Accel|IPU|XDNA' } |
Select-Object DeviceName, DriverVersion, DriverDate
結果は NPU Compute Accelerator Device / 32.0.20102.3930 / 2026-05-07。
第3フィールドが要件の 203 に対して実機は 20102 です。
これはAMDがAdrenalinのビルド番号に揃えた新方式に移行したためで、
体系の違うバージョン文字列を大小比較しても意味がありません。
「要件を満たしていないのでは」と悩む必要はなく、判定は flm validate に任せるのが正解です。
弾かれたときだけ、Windows Update → AMDサポートのRyzen AI用ドライバ、の順で対処します。
2. APIが404になる(/v1 か /api/v1 か)
OpenAI互換APIのパス接頭辞が実装ごとに違います。ここが混同しやすい。
| 実装 | 接頭辞 | 例 |
|---|---|---|
FastFlowLM(flm serve) |
/v1 |
http://127.0.0.1:52625/v1/models |
| LM Studio | /v1 |
http://127.0.0.1:1234/v1/models |
| Lemonade Server | /api/v1 |
http://localhost:8000/api/v1/models |
FLMに対して /api/v1 を叩くと404です。Lemonade Serverの記事を見ながらFLMを叩くと必ず踏みます。
ポート番号も確認しておきます。
flm port # 実測環境では 52625
疎通確認はこれだけで足ります。
Invoke-RestMethod http://127.0.0.1:52625/v1/models | ConvertTo-Json -Depth 4
自分でクライアントを書くなら、両方試して通ったほうを使うのが楽です。
CANDIDATE_PATHS = ["/v1", "/api/v1", ""] # LM Studioを先に見つけるため /v1 を先頭に置く
def find_base(port: int) -> str:
for prefix in CANDIDATE_PATHS:
url = f"http://127.0.0.1:{port}{prefix}/models"
try:
with urllib.request.urlopen(url, timeout=5) as res:
if res.status == 200:
return f"http://127.0.0.1:{port}{prefix}"
except (urllib.error.URLError, OSError):
continue
raise SystemExit(f"ポート {port} で models が見つかりません")
3. flm run <model> -i <file> が返ってこない
ファイルを渡して一発で流したいときに、これをやると対話モードに入って戻ってきません。
バッチやベンチのスクリプトから呼ぶと固まったように見えます。
非対話で回すなら、サーバを立ててAPIを叩きます。
# 別ウィンドウで起動しておく
flm serve gemma4-it:e2b --ctx-len 4096
あとは通常のOpenAI互換クライアントで投げられます。
4. TTFTが40秒超と出る(gpt-oss の思考トークン)
これが一番はまりました。gpt-oss:20b で計測したら TTFT 42秒 / 12 tok/s という数字が出ます。
実際の正しい値は TTFT 3.76秒です。
原因は、FastFlowLM が思考と回答を別のフィールドで流すことです。
| フィールド | 中身 |
|---|---|
reasoning_content |
思考(先に流れてくる) |
content |
回答本文(思考の後) |
content だけを数えると、思考が終わるまでの時間がまるごとTTFTに乗ります。
思考トークンは実測で200〜939トークンを占め、回答本文より多いこともあります。
ストリームを解析する側は両方を見てください。
delta = obj.get("choices", [{}])[0].get("delta", {}) or {}
reasoning = delta.get("reasoning_content") or ""
content = delta.get("content") or ""
if not reasoning and not content:
continue
now = time.perf_counter() - t0
if ttft is None:
ttft = now # 最初の「何らかの」トークン = 本当のTTFT
if content and ttfa is None:
ttfa = now # 回答本文が始まった時刻は別に持つと親切
TTFT(最初のトークン)と TTFA(回答本文の開始)を分けて持つと、
「反応はあるが答えが始まらない」体感を数字で説明できます。
生のストリームを一度覗いておくと確実です。フィールド名は実装で変わります。
with urllib.request.urlopen(req, timeout=1800) as res:
for i, raw in enumerate(res):
print(raw.decode("utf-8", "replace").strip())
if i > 30:
break
5. LM Studio と数字が揃わない
NPU(FLM)と iGPU(LM Studio)を比べるとき、出力形式が完全に同一ではありません。
-
LM Studio は
reasoning_contentを返しません(思考トークン0と報告される) - 総トークン数は近い値になる(実測で971〜1025)が、内訳は違う
同一モデル名でも比較の前提が揃わないので、記事や社内報告に出すなら断り書きが必要です。
もう1点、1回目のTTFTはJITのモデルロードを含みます。
実測では1回目14.7秒、ウォーム状態1.25秒でした。公平に比べるならウォーム側の値を使ってください。
6. NPUに2つ目のモデルが載らない
NPUは排他ロックです。同時に保持できるNPUモデルは1つだけで、
切り替えるとロードとアンロードの待ちが入ります。
「小型を常駐させつつ別の小型も」という構成は組めません。
一方で NPUに1つ+iGPUに1つは同時に動きます(こちらは別記事で実測しました)。
7. NPU使用率が数値で取れない
パフォーマンスカウンターを探しても見つかりません。
Get-Counter -ListSet *NPU* # 空
WindowsにNPU専用のカウンターセットは存在しません(NPUはMCDMデバイスとして扱われます)。
\GPU Engine(*)\Utilization Percentage にもNPUは出てきません。
数値で確認したいなら タスクマネージャの「パフォーマンス」タブを使います。
NPUの項目を選ぶと使用率・共有メモリ・ドライバ版数まで出ます。
自動化したいログ用途には向かないので、ここは割り切るしかありません。
参考までに、消費電力のほうは標準カウンターで取れます。
Get-Counter '\Energy Meter(RAPL_Package0_PKG)\Power' # 値はミリワット
APUなのでCPU・iGPU・NPUを合わせたパッケージ全体の値です。HWiNFO等の導入は要りません。
8. 大きいモデルがNPUに載らない
タスクマネージャのNPUパネルに 「共有メモリ 5.7 / 15.8 GB」 のように出ます。
この 15.8GB が NPU が使えるメモリの上限で、実機ではOS側メモリ31.6GBのちょうど半分でした。
ここが効いてくるのは、統合GPU機でVRAMを大きく固定しているときです。
搭載128GB
├─ GPU専用 96GB(BIOSで固定)
└─ OS側 31.6GB
└─ NPUが使えるのはこの半分 = 15.8GB
実測では gpt-oss:20b(13.48GB)をロードすると flm serve プロセスのワーキングセットが
16.08GB に達し、OS側の空きが1.3GBまで低下しました。
プロセスのワーキングセットとNPUの共有メモリ枠は厳密には別の指標ですが、
どちらから見ても20B級がこの構成の上限に近いという結論になります。
NPUに大きいモデルを載せたいなら、BIOSでGPU専用メモリを削ることになります。
NPUの枠がOS側メモリの半分だったので、96GBを64GBに削ればNPU側は約31.6GBまで広がる計算です
(筆者はBIOSを変えての検証はしていません。表示値からの推測です)。
「NPUに載らない」をflm側の設定で解こうとすると詰まります。メモリ配分の問題です。
9. 計測スクリプトを書くときのPowerShellの罠
本題から少し外れますが、切り分け用のスクリプトを書く段階で2つ踏みました。
.ps1 は BOM付き UTF-8 で保存する
PowerShell 5.1 は BOMなしUTF-8 の .ps1 を ANSI(CP932)として読みます。
日本語のコメントや文字列が壊れ、終端記号 " がありません のような構文エラーになります。
エディタで「UTF-8 with BOM」を選ぶか、Pythonから書くならこうします。
p.write_bytes(b"\xef\xbb\xbf" + text.encode("utf-8"))
PowerShell 7 では起きません。5.1 で動かす前提なら必須です。
Bitmap.Save には絶対パスを渡す
画面キャプチャを保存しようとして「GDI+ で汎用エラーが発生しました」と出る場合、
多くはパスの問題です。.NET のカレントディレクトリは PowerShell の現在位置とは別なので、
相対パスは意図した場所を指しません。
$OutDir = (Resolve-Path $OutDir).Path # 絶対パスにしてから渡す
$bmp.Save((Join-Path $OutDir 'shot.png'), [System.Drawing.Imaging.ImageFormat]::Png)
切り分けチェックリスト
止まったら、この順で確認します。
-
flm validateで XDNA2 と出るか(出なければハード側の問題) -
flm portでポートを確認したか - 叩いているパスは
/v1か(Lemonade Serverの記事を見ていないか) -
flm runではなくflm serveを使っているか - TTFTが異常に大きいなら
reasoning_contentを数えているか - モデルが載らないなら タスクマネージャでNPUの共有メモリ上限を見たか
- スクリプトが構文エラーなら
.ps1のBOMを疑ったか
次の一歩
-
NPUとiGPUの速度・電力・同時稼働の実測(この記事の姉妹編):
EVO-X2でNPUとiGPUを同時に回す:Strix HaloのローカルLLM実測と使い分けの地図(Zenn) -
AMD統合GPUでローカルAIを動かす選択肢の地図:
AMD統合GPUでローカルAIを動かす選択肢まとめ(Zenn) -
BIOS・UMA・ROCm・LM Studioまで手順を一冊で(有料):
EVO-X2で始めるローカルAI実践ガイド(Zenn本)
まとめ
- 対応世代の判定はバージョン文字列の比較ではなく
flm validate - APIは
/v1。/api/v1は Lemonade Server 側 - 非対話で回すなら
flm serve+ API -
gpt-oss系の計測ではreasoning_contentを数える。忘れるとTTFTが10倍以上ずれる - NPU使用率はタスクマネージャしかない。電力は標準カウンターで取れる
- 「NPUに載らない」は
flmの設定ではなく メモリ配分の問題 -
.ps1は BOM付きUTF-8
エラーメッセージが出ずに固まる/数字がおかしいという形で出るものが多いので、
先に上のチェックリストを通したほうが早いです。