VRChatで顔トラッキングを使うとCPUの1コアが99%占有されてゲームが14fpsに落ちた。「カーネルレベルの問題だ」と確信して、割り込みアフィニティ、MSI-X、バイナリパッチ、コールドブート、USBポート総入れ替え……Windowsカーネルの深淵を6時間さまよった。見つけた答えは「USBハブを抜く」だった。
高度な技術的調査がケーブル抜き差しに帰結する、ITあるあるの極致の記録。
環境
CPU: AMD Ryzen 9 9950X3D(デュアルCCD、V-Cache 96MB)、GPU: RTX 5080、HMD: Bigscreen Beyond(内蔵カメラ2台+多段USBハブの魔窟)、顔トラッキング: Baballonia (ProjectBabble)。デバッグ要員としてClaude Code (Opus) と Grok (Composer 2.5) を投入した。
異変
ある日、Baballoniaで顔トラッキングを有効にしたらCore 1のDPCが99%に張り付いた。
Core 1 DPC: avg=51.2% max=99%
VRChat FPS: 55fps → 14fps → 55fps (20秒周期でガクガク)
犯人はusbvideo.sys。Windows標準のUVCカメラドライバ。「ソフトウェアの問題だ。レジストリで直せる」。そう思った。
割り込みアフィニティという名の幻想
まず、USBコントローラの割り込みをV-Cache CCD (Core 0-15) から追い出そうとした。
# CCD1(Core 16-31)に割り込みを固定する、完璧な計画のはずだった
Set-ItemProperty $path -Name 'DevicePolicy' -Value 4
Set-ItemProperty $path -Name 'AssignmentSetOverride' -Value ([byte[]](0x00,0x00,0xFF,0xFF))
再起動。計測。Core 1のDPC、変わらず。
おかしい。割り込みは確かにCCD1に移動した。なのにDPCだけがCore 1に居座っている。
ここで知った。割り込みとDPCは別物だ。割り込みがどこで処理されようが、usbvideo.sysは「DPCはCore 1でやる」と内部で決めていた。アフィニティはDPCには届かない。
0勝1敗。
MSI-X、お前もか
「MSI-Xを有効にすればアフィニティが効くかもしれない」。レジストリにMSISupported=1を追加してコールドブート。
MSI-Xは確かに有効になった。IRQ番号がMSI-Xベクター範囲に入ったし、割り込みはきれいにCCD1に分散された。
DPCはCore 1に引きこもったまま。
「AssignmentSetOverrideが4バイトなのが悪いのか? 64ビットWindowsだから8バイト要る?」。修正。コールドブート。
変わらず。
0勝3敗。
禁断のバイナリパッチ
方向を変えた。Baballoniaの中を覗くと、OpenCVのDirectShowバックエンドが口カメラを120fps、目カメラを90fpsでストリーミングしていた。合計210 DPC/秒。そりゃCore 1も死ぬわ。
MSMFバックエンドなら30fpsでネゴシエートされる。だがBaballoniaはDirectShowをハードコードしていて、設定ファイルにバックエンド指定はない。
仕方ない。9KBの.NET DLLをバイナリパッチした。
# IL命令 ldc.i4 を直接書き換え: DSHOW(700) → MSMF(1400)
$bytes[634] = 0x78 # was 0xBC
$bytes[635] = 0x05 # was 0x02
口カメラのDPCは激減した。が、目カメラのBigeyeは90fps固定のファームウェア仕様でFPS制限が効かない。Bigeye込みだとまだ60%。
ファームウェアには勝てないのか。
0.5勝3.5敗。半分だけ効いた。
ポートガチャ
「コントローラを変えればDPCのターゲットコアが変わるかも!」
背面Type-AからType-Cに移動したら、コントローラがDEV_15B7からDEV_15B6に変わった。計測……4.9%!
と思ったらカメラのストリーミングがまだ始まってなかっただけだった。始まったら96%に戻った。
USBポートの入れ替えを繰り返して分かったのは、このマザーボードのUSB 3.0 Type-Aポートが全部同じコントローラだということ。別コントローラのポートはUSB 2.0しかない。
2.0に挿したらDPCは下がったけど、HMDに「3.0に挿せ」と怒られた。
0.5勝5.5敗。
2体のAIが「無理」と言った
ここまでにClaude CodeとGrokを並行投入し、カーネルの仕様書を読み漁り、Web検索を回し、X (Twitter) まで漁った。
Grokの最終回答は冷酷だった。
usbvideo.sysのDPCターゲットはドライバ内部の設計。レジストリやOS設定では変更不可。Bigeyeのファームウェアに30fpsモードがない限り、ソフトウェアでの解決は不可能。
Claude Codeも同じ結論。2体のAIが揃って「無理」と言った。
人間の直感
全てのソフトウェア的アプローチが尽きた後、ふと思いついた。
「ハブを経由しないで、直接口のカメラを挿してみよう」
Core 1 DPC: 96% → 4.2%
VRChat FPS: 14fps → 75fps 安定
は?
HMDの中にはUSBハブが5段カスケードされていて、その最深部にあるVIA製ハブ (VID_214B) を通るとisochronous転送にジッターが発生してDPCが爆発していた。
PC ──[USB]──> Microchip Hub x4段
├── Bigeye (目) ← 問題なし
├── Beyond Audio
└── VIA Hub (VID_214B) ← こいつ
└── 口カメラ ← ここを通ると死ぬ
VIAハブを迂回して口カメラをMicrochipハブ側に直接つないだだけ。それだけで全部直った。
6時間の戦果
| やったこと | 効いたか | 所要時間 |
|---|---|---|
| 割り込みアフィニティ | 無駄 | 1時間 |
| MSI-X有効化 | 無駄 | 30分 |
| 8バイトKAFFINITY + コールドブート3回 | 無駄 | 1時間 |
| .NET DLLバイナリパッチ | 半分だけ | 1時間 |
| USBポート総入れ替え | 無駄 | 1時間 |
| Grok相談3回 | 知見は得た | 30分 |
| Web/X検索 | 前例なし | 30分 |
| USBハブを抜く | 完全解決 | 10秒 |
割り込みアフィニティの仕組み、MSI-XとIOAPICの違い、DPCのターゲットコア決定メカニズム、UVCのisochronous転送の帯域制約、.NET ILのオペコード体系。全部勉強になった。なったけど。
次からはまずハブを疑う。
技術的に得たもの
笑い話だけで終わるのはもったいないので、せっかくだから書いておく。
DPCと割り込みは独立している
割り込み(ISR): MSI-Xで複数コアに分散できる
DPC: ドライバが KeSetTargetProcessorDpc で明示指定する → 割り込みアフィニティでは動かない
ユーザー空間からは見えないけど、「割り込みを移動すればDPCも移動する」というのは間違い。ISRが完了したコアで自動的にDPCが走るわけではなく、ドライバが自分でターゲットを指定する。
カメラのFPSがそのままDPC頻度になる
UVCカメラのフレーム完了1回につきusbvideo.sysのDPCが1回走る。120fps + 90fps = 210回/秒。これが1コアに集中すると飽和する。DirectShowはデフォルトでカメラの最大FPSをネゴシエートするが、MSMFは30fpsを選ぶ傾向がある。この違いだけでDPCが7分の1になった。
USBハブの品質がカーネル負荷に直結する
USB 2.0ハブのisochronous帯域は上流480Mbpsを全ポートで共有する。多段カスケード + 複数カメラ + オーディオが重なると、フレーム完了タイミングにジッターが入り、DPC処理時間が膨れる。VIA製ハブ (VID_214B) はUVCカメラとの相性が特に悪かった。
AIデバッグは強いが、ハードウェアは抜き差ししないとわからない
Claude CodeとGrokの2体を投入して、レジストリ操作からバイナリパッチ、USB記述子の解析まで自動化した。カーネル仕様の理解はAIの方が圧倒的に速い。でも最終的に問題を解いたのは「ハブ外してみたら?」という人間の手だった。
AIはソフトウェアの問題空間では無敵に近い。でも「このハブの品質が悪い」なんてことは、物理的に抜いてみないと誰にもわからない。AIとデバッグするなら、ソフトウェアに深く潜る前にハードウェアの変数を先に潰した方がいい。6時間かけて学んだ、一番大きな教訓がこれ。
作業セッション記録をもとにClaudeCodeが作成した記事です。