見出し画像

ミニ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が返答しながら裏で賢くなる
という構造にできる。


いいなと思ったら応援しよう!