ミニAI、LLMでCPUもGPUも遊ばせたくなかったので、4DテンソルLLMに cpu_engine / hybrid_engine / gpu_engine / scheduler を足した
注釈
※本記事は個人開発中の実装・設計ログをもとにした記録です。
※特定の製品・フレームワーク・企業を否定する意図はありません。
※記載内容は執筆時点の実装状況に基づいており、今後ディレクトリ構成や名称、役割は変更される可能性があります。
※計測結果や負荷の傾向は環境差の影響を受けるため、同一の結果を保証するものではありません。
なぜ cpu_engine / hybrid_engine / gpu_engine を分けたのか
GPU化すれば速くなる。
最初は、かなり本気でそう思っていました。
でも実際に、自分でミニAI、LLMをC++フルスクラッチで追い、4Dテンソルで構成し、CPU版・CPU/GPUハイブリッド版・GPU版を並べて見ていくと、話はそんなに単純ではありませんでした。
一部だけGPUに寄せても、途中でCPUに戻る。
CPUで処理して、またGPUに返す。
するとGPUが待つ。
逆にCPU側も、処理の合間に遊ぶ。
結果として、CPUもGPUもどちらも中途半端に待ち時間を抱える構造になってしまう。
今回やりたかったのは、単なるGPU化ではありません。
CPUもGPUも、できるだけ遊ばせず、空いている実行資源に仕事を流す構成を作ることでした。
そのために、もともと持っていた
0_0_CPU_mini_llm
0_0_mini_llm_CPUGPU_hybrid
0_0_gpu_LLM
をそのまま活かしつつ、その上に
cpu_engine
cuda_engine
gpu_engine
hybrid_engine
scheduler
を足しました。
さらに、単に呼び分けるだけではなく、
Working Set
CPU使用率
GPU使用率
VRAM使用量
WPR / WPA によるトレース
を見ながら、どの engine に流すかを決める土台まで整理しました。
今回は、その話を書いておきます。
GPU化すれば終わり、ではなかった
LLMを作っていると、どうしても「GPUに載せれば速い」という発想になりやすいです。
もちろんそれ自体は間違いではありません。
ただ、実際に中を掘ると、問題は「GPUを使っているかどうか」ではなく、実行経路がどうなっているかでした。
たとえば、
Embedding はGPU
途中の一部はCPU
その先はまたGPU
出力処理はCPU
というような構成になっていると、見た目はGPU対応でも、実態としてはCPUとGPUの間を何度も行き来することになります。
この往復が入ると、
コピー待ち
同期待ち
CPU側のボトルネック
GPU側の空転
メモリ圧迫による失速
が起きやすくなります。
つまり、GPUを使っていることと、GPUをうまく使えていることは別でした。
ここで見えてきたのは、
モデル本体の実装だけではなく、どの経路で流すかを制御する層が必要だということでした。
既存の3系統を、そのまま活かしたかった
今回、全部を一から作り直したかったわけではありません。
すでに手元には、性格の違う3系統がありました。
0_0_CPU_mini_llm
0_0_mini_llm_CPUGPU_hybrid
0_0_gpu_LLM
CPUで押し切る構成、CPU/GPUのハイブリッド構成、GPU寄りの構成。
それぞれ、役割が違います。
だから必要だったのは、「どれか一つに統一すること」ではなく、
状況に応じてどれを使うかを選べるようにすることでした。
そのために、実行エンジンを分けました。
ai_pair_scheduler
├─ cpu_engine -> 0_0_CPU_mini_llm
├─ hybrid_engine -> 0_0_mini_llm_CPUGPU_hybrid
├─ cuda_engine -> 0_0_gpu_LLM
└─ gpu_engine -> 0_0_gpu_LLMこれで、上位から見ると「一つのランタイム」だけれど、実際の処理は中で適切な engine に流し分ける、という構成にできます。
この形にしたことで、
「CPU版」「ハイブリッド版」「GPU版」が別々に存在しているだけの状態から、
一つの scheduler 配下で統合的に扱える状態に寄せることができました。
cpu_engine / hybrid_engine / gpu_engine を分けた理由
ここはかなり重要でした。
なぜ engine を分けたのかというと、
処理の性質によって、向いている実行経路が違うからです。
たとえば、
軽い推論
短いプロンプト
GPUが混んでいる
CPUコアに余裕がある
なら、CPUで回した方が全体として綺麗に流れることがあります。
逆に、
学習処理
長い系列長
行列計算が重い
GPUに余裕がある
なら、GPU側に寄せた方が良い。
また、その中間にあたるもの、たとえば一部はCPUで持ちつつGPUも使いたい処理なら、ハイブリッドの経路が向いていることもあります。
つまり今回必要だったのは、
「どのモデルを動かすか」より、
「どの実行経路に流すか」を決める仕組みでした。
そのために、
cpu_engine
hybrid_engine
cuda_engine
gpu_engine
を明示的に分けました。
ここを曖昧にすると、後から見たときに「結局どこで詰まっているのか」が分からなくなります。
分けておけば、
どの engine が選ばれたか
どの実行ファイルが呼ばれたか
どこで混んでいるか
どこが遊んでいるか
を追いやすくなります。
4DテンソルLLMだからこそ、実行経路も自分で握りたかった
今回のLLMは、C++フルスクラッチ、4Dテンソル前提で進めています。
ここでいう4Dは、自分の中では B, T, H, D のように、バッチ・時系列・ヘッド・次元の扱いを最初から意識して統一している、という意味合いが大きいです。
この構造にしている理由はいくつかありますが、その一つは、
内部表現と実行経路を自分で握りたいからです。
既存のフレームワークは便利です。
ただ、その便利さの裏で、どのタイミングでCPUが動き、どのタイミングでGPUが待ち、どこでメモリを抱えているかが見えにくくなることがあります。
今回はそこをブラックボックスにしたくなかった。
テンソルの持ち方
バッファの流れ
CPUとGPUの受け渡し
実行先の選択
スケジューリング
メモリ観測
これらを、自分で見える形にしたかった。
だからモデル本体だけではなく、その周りにある engine と scheduler の層まで自分で設計する必要がありました。
Working Set を見るようにした理由
CPU使用率やGPU使用率だけを見ていても、本当に資源を活かせているかは分かりません。
実際に詰まるのは、メモリのほうだったりします。
そこで今回かなり意識したのが、Working Set です。
Working Set は、その時点で実際に物理メモリ上に載っているページの集合を見る上で重要です。
巨大なテンソルや中間バッファを持つ処理では、この Working Set が膨らむことで他の処理に悪影響が出やすくなります。
CPUもGPUも遊ばせたくないと言っても、無理にタスクを投げ込み続ければいいわけではありません。
メモリ側が詰まれば、かえって全体が遅くなります。
だから scheduler では、engine を選ぶときに
CPU使用率
GPU使用率
VRAM使用量
inflight 数
Working Set
を見て、無理な投入を避けるようにしました。
必要に応じて、自プロセスの Working Set を trim する処理も入れています。
もちろん、trim すれば何でも解決するわけではありません。
ただ少なくとも、不要にメモリを抱え続ける状態は減らせます。
ここは、単なる「CPUが空いている」「GPUが空いている」ではなく、
メモリまで含めた実行資源の整理という意味で重要でした。
WPR / WPA を入れた理由
今回、かなり大きかったのはここです。
自分の中で一番避けたかったのは、
「なんとなく速くなった気がする」
「GPUが動いているように見える」
で終わることでした。
それでは設計として弱い。
だから、WPRでトレースを取り、WPAで後からちゃんと確認できるようにするところまで入れました。
やりたい流れはシンプルです。
scheduler 実行前に WPR を開始
タスクを流す
実行後に ETL を保存
WPA で開いて確認する
これで、
CPU側のスレッドが本当に走っていたのか
GPUに継続的に仕事が渡っていたのか
どこで待ちが発生したのか
Working Set の揺れがどうだったのか
どの engine に偏っていたのか
を後から見られるようになります。
ここはかなりDBA的な感覚に近いです。
「速いはず」ではなく、
どこで詰まっているのかを観測する。
LLMを作っていても、この視点はやはり強いと思っています。
CPUコア数を意識して、非同期に仕事を流したかった
もう一つ大きかったのが、CPUコアを遊ばせないことでした。
CPU版の engine があるのに、毎回単発実行だけではコアがかなり空きます。
それではもったいない。
なので今回は、複数タスクを非同期 dispatch できる形を前提にしました。
考え方としては、
CPU側はコア数ベースで上限を決める
engine ごとに同時実行数を持たせる
GPU側も inflight を管理する
空きがあるところへ次の仕事を流す
というものです。
CPUは hardware_concurrency() を意識しながら並列度を調整し、
全部を埋め切るのではなく、OSや周辺処理の余地は少し残す。
GPUも、無限投入ではなく、
使用率
VRAM
現在の inflight 数
を見ながら投げる。
ここで大事なのは、
100%に張り付けることが目的ではないということです。
本当にやりたいのは、
遊びを減らす
片側だけが待つ状態を減らす
詰まりを減らす
全体として綺麗に流す
ことです。
CPUとGPUを同時に見る、というのは単に高負荷にすることではなく、
全体の流れを止めにくくすることだと思っています。
0_0_CPU_mini_llm / hybrid / 0_0_gpu_LLM を、一つの scheduler から呼ぶ意味
今回、自分の中でかなり大きかったのは、
複数の系統が「別々の実験フォルダ」から、
一つの運用構造の中の実行エンジンとして見えるようになったことです。
これまでは、
CPU版
ハイブリッド版
GPU版
がそれぞれ存在していても、あくまで別々の実行物でした。
でも scheduler を挟むことで、
どのジョブを
どの engine に
どの条件で流すか
を統一的に考えられるようになった。
これはかなり大きいです。
単に「複数の実装がある」状態と、
「複数の実装を統合的に運用できる」状態の間には、かなり差があります。
今回の作業は、たぶんその差を埋める作業だったと思っています。
ここでようやく、“実行基盤” が見えてきた
LLMの本体を作るのは、もちろん大変です。
4Dテンソル、Embedding、Attention、KV cache、学習、推論、保存、読み込み。
やることは本当に多い。
でも、本当に使える形に寄せていこうとすると、そこで終わりません。
必要になるのは、
実行経路の分離
scheduler
resource 管理
Working Set 観測
CPU/GPU使用率観測
WPR/WPAによる検証
非同期 dispatch
同時実行数制御
といった、モデルの外側の層です。
今回やっていたのは、まさにそこでした。
モデル本体だけではなく、
どう動かし、どう観測し、どう遊ばせないか。
ここまで触り始めて、ようやく「ただの実験コード」から「実行基盤」らしい姿が見えてきた気がしています。
OSレベルでのメモリ管理への着目(Working Set / WPA / WPR)
本プロジェクトでは、単にモデル内部の最適化に留まらず、
OSレベルのメモリ管理挙動まで含めた最適化に取り組んでいます。
特に注目しているのは以下です:
Working Set(プロセスに割り当てられた実メモリ)
Memory Compression
Hard Fault(ページフォルト)
スケジューラによるCPU割り当て
これらは、Windowsのバージョン(例:24H2 / 25H2)や設定によって挙動が変化し、
LLMのようなメモリ帯域依存の処理に直接影響を与えます。
WPA / WPR による実測ベースの分析
私は、推測ではなく
👉 WPR(Windows Performance Recorder)によるトレース取得
👉 WPA(Windows Performance Analyzer)による詳細分析
を行い、
CPU待機時間
GPU待機時間
メモリ圧縮の発生タイミング
Hard Faultの頻度
スレッドのスケジューリング状態
を可視化しています。
問題意識
LLMの性能問題は、単なるアルゴリズムやCUDAの問題ではなく、
👉 OSのメモリ管理とスケジューリングに強く依存する
と考えています。
例えば:
Working Setが不足するとページングが発生
ページングによりGPUへの供給が遅延
結果としてGPUが待機状態になる
これは
👉 「GPUを使っているのに遅い」状態の本質
です。
まだ終わっていない部分
もちろん、これで完成ではありません。
まだ残っているものはたくさんあります。
たとえば、
CPU側実行ファイルの整備
engine ごとの引数の整理
学習ジョブと推論ジョブの振り分け最適化
queue や優先度制御
長時間運転時のメモリ揺れの観測
runtime と world/state 管理の接続
より細かい負荷ベースの dispatch
などです。
ただ、それでも今回の一歩は大きかったと思っています。
CPU版、ハイブリッド版、GPU版が、ただ並んでいるだけではなく、
一つの scheduler の下で呼び分けられる状態に近づいた。
これは今後を考えるうえで、かなり意味がある変化でした。
まとめ
今回やりたかったのは、単にGPU化することではありませんでした。
本当にやりたかったのは、
CPUもGPUもできるだけ遊ばせず、空いている実行資源に仕事を流す仕組みを作ることでした。
そのために、
0_0_CPU_mini_llm
0_0_mini_llm_CPUGPU_hybrid
0_0_gpu_LLM
を活かしつつ、
cpu_engine
hybrid_engine
cuda_engine
gpu_engine
scheduler
を足しました。
さらに、
Working Set
CPU使用率
GPU使用率
VRAM使用量
WPR / WPA
を見ながら、実行経路を切り替える土台も入れました。
LLMは、モデル本体だけを作れば終わりではありません。
本当に運用可能な形に寄せようと思うと、
その周辺にある実行制御や観測基盤のほうが、むしろ重要になってくる場面があります。
今回やっていたのは、たぶんその話です。
モデルだけではなく、
どこで動かすのか。
どう詰まりを見るのか。
どう遊ばせないのか。
4DテンソルLLMの中身を作るのと同じくらい、
その外側の runtime を作ることも大事だった。
今回は、その土台を一歩進めた記録として残しておきます。
最近思っていること
CPU + RAGで応答
GPUで裏学習
WPA/WPRとWorking Setで観測
schedulerで優先度制御
重みは段階反映
この構成は、
小さい資源でAIを止めずに育てる設計です。
実際に設計するとこうなる
フロント側
ユーザー入力受け取り
軽量推論
RAG検索
CPUで候補整形
すぐ返せる回答を返す
バック側
ログ蓄積
頻出クエリ集計
夜間バッチ
GPUで蒸留/追加学習
重み更新候補生成
制御側
scheduler A: 応答優先
scheduler B: 学習優先
Working Set監視
CPU/GPU使用率監視
メモリ圧迫時の学習停止
低優先度ジョブの退避
これなら、
AIが返答しながら裏で賢くなる
という構造にできる。
