← 一覧へ
連載 Agentic OS:技術スタックを下から読む の一部です ―― 目次を見る →

Agentic OS 技術スタックを下から読む 第2回:GPU を遊ばせない ―― 非同期の連続バッチ処理

この記事の読み方
前回は、推論のコストをどこから見るかを整理した。大きく効いているのは、計算そのものだけではない。重みを読むメモリ帯域、同時に処理するリクエスト数、途中結果を持ち続けるための領域が、生成の速度と費用を決めていた。

前回は、推論のコストをどこから見るかを整理した。大きく効いているのは、計算そのものだけではない。重みを読むメモリ帯域、同時に処理するリクエスト数、途中結果を持ち続けるための領域が、生成の速度と費用を決めていた。

ただし、コストの構造が見えたことと、GPU を実際に遊ばせず使い切ることは別の問題である。

GPU は高価な部品だ。そこにモデルを載せている以上、計算していない時間も料金に含まれる。生成中に GPU が待っているなら、その待ち時間はそのまま無駄になる。今回扱うのは、モデルを変えず、計算カーネルも変えず、同じハードの使い方だけで生成を速くする方法である。

主題は一つだけだ。

GPU が今のバッチを計算している間に、CPU が次のバッチを準備する。これだけで、生成はかなり速くなる。

連続バッチ処理でも、まだ無駄がある

LLM の生成では、リクエストごとに一つずつ処理すると効率が悪い。あるリクエストは短く、別のリクエストは長い。途中で終わるものもあれば、新しく入ってくるものもある。これを固定長の箱に無理やり詰めると、空白を計算する時間が増える。

そこで使われるのが連続バッチ処理である。

連続バッチ処理とは、生成中のリクエストをひとまとまりにして GPU に流しつつ、終わったリクエストを外し、新しく来たリクエストを空いた場所に入れるやり方だ。毎ステップ、バッチの中身を少しずつ入れ替えながら走らせる。これにより、空の場所をなるべく作らず、GPU に渡す仕事を詰め続ける。

ここまでは自然な工夫である。だが、それだけではまだ無駄が残る。

素朴な実装では、CPU と GPU が交互に働く。

まず CPU が次に処理するバッチを組む。どのリクエストを入れるかを決める。終わったものを外す。新しく入れるものを選ぶ。各リクエストがどの位置のキャッシュを使うかという管理表も更新する。

準備ができると、CPU はその情報を GPU に渡す。GPU は計算を行い、それぞれのリクエストについて次のトークンを選ぶ。結果は CPU に戻る。CPU はその結果を見て、次のバッチをまた組む。

この流れでは、CPU が次の段取りをしている間、GPU は待っている。逆に、GPU が計算している間、CPU は結果待ちになりやすい。両方が同時に意味のある仕事をしている時間が少ない。

連続バッチ処理は、バッチの中の無駄を減らす。だが、CPU と GPU の間にある待ち時間までは、自動では消してくれない。

その待ち時間は小さくない

この待ち時間は、細かい実装上の誤差ではない。

8B 規模のモデルで、約 8千トークンを生成し、32 件のリクエストをまとめて処理した実測では、全体時間の約 24% で GPU が CPU を待っていた。つまり、四分の一近い時間が、モデルの計算ではなく段取り待ちに消えていた。

これはかなり大きい。

GPU が本来 100 秒働けば済む仕事を、CPU との受け渡しのために 130 秒近くかけているようなものだ。もちろん実際の数字はモデル、バッチサイズ、実装、入出力の長さで変わる。だが、ここで重要なのは正確な比率ではない。生成処理では、CPU 側のスケジューリングやデータ準備が、GPU の計算を止めるほど大きくなりうるという点である。

逆に言えば、この待ちを消せれば、同じ GPU でも二割前後速くなる余地がある。

新しい GPU を買う話ではない。モデルを小さくする話でもない。すでにある計算資源を、止めずに使う話である。

狙いは単純である

やりたいことは単純だ。

バッチ N を GPU が計算している間に、CPU がバッチ N+1 を準備する。

これができれば、GPU はバッチ N の計算を終えた直後に、次の仕事へ移れる。CPU の準備をその場で待たなくてよい。GPU の前に、常に次の仕事が置かれている状態に近づく。

このために、モデルの構造を変える必要はない。重みも変えない。サンプリングの方法も変えない。CUDA グラフやメモリプールのような、さらに下の最適化もここでは主役ではない。

問題はもっと手前にある。

CPU が GPU に仕事を投げたあと、すぐ次の準備に戻れるようにするにはどうするか。

CPU の手を返す

GPU に対する操作は、順番に並んだ待ち行列に入る。この待ち行列をストリームと呼ぶ。ストリームに入れた操作は、基本的にはその順番で実行される。

素朴な書き方では、GPU に仕事を出したあと、CPU がその完了を待ってしまう。CPU は「GPU の計算が終わったか」を確認するまで次へ進めない。これでは、GPU に投げたはずの仕事が、CPU の流れまで止めてしまう。

そこで、同期的な流れから外す。

GPU に仕事を投げたら、CPU にはすぐ制御を返す。CPU は GPU の完了をその場では待たない。GPU は裏で計算を進め、CPU は次のバッチを組む。

さらに、GPU まわりの操作を性質ごとに分ける。

一つ目は、CPU から GPU への入力転送である。次に計算するトークンや管理情報を GPU に渡す。

二つ目は、GPU 上の計算である。モデルを進め、次のトークンを出す。

三つ目は、GPU から CPU への結果転送である。選ばれたトークンや終了判定に必要な情報を CPU 側に戻す。

これらを同じ流れに直列に詰めると、すべてが順番待ちになる。そこで、転送、計算、取り出しを別々の流れに分ける。すると、あるバッチの計算中に、別のバッチの入力転送や CPU 側の準備を重ねられる。

ただし、流れを分けるだけでは正しく動かない。

順序は印で保証する

流れを分けると、それぞれが独立に進む。これは速くするためには必要だが、危険でもある。

入力転送が終わる前に計算が始まると、GPU はまだ届いていないデータを読むことになる。計算が終わる前に結果を取り出すと、CPU は未完成の値を読むことになる。速くなっても、結果が壊れては意味がない。

そこで、流れの間に印を打つ。

入力転送が終わったところに、「ここまで終わった」という印を置く。計算の流れは、その印を待ってから始める。計算が終わったところにも印を置く。結果を取り出す流れは、その印を待ってから動く。

この印をイベントと呼ぶ。

大事なのは、CPU が細かく見張らないことだ。CPU が毎回「転送は終わったか」「計算は終わったか」と確認していたら、結局そこで止まってしまう。順序の保証は GPU 側の流れの中で行う。CPU は、最後に必要な結果を受け取るところでだけ待てばよい。

これで、CPU の手が空く。

GPU がバッチ N を計算している間、CPU はバッチ N+1 を組み始められる。

空いた CPU で次を組むときの落とし穴

ここまで来ると、やることは一見簡単に見える。GPU に N を投げる。CPU はすぐ N+1 を組む。GPU が N を終えたら、N+1 を渡す。

しかし、この重ね合わせには落とし穴がある。

一つ目は、データの上書きである。

バッチ N と N+1 が同じ入力領域を使っていると、CPU が N+1 の準備を始めた瞬間に、GPU がまだ読んでいる N のデータを書き換えてしまう可能性がある。GPU から見ると、読んでいる最中に中身が変わる。そうなれば、計算結果は信用できない。

この問題は、入力用の領域を二組用意して避ける。

片方を GPU が読んでいる間、もう片方を CPU が次の準備に使う。次のステップでは役割を入れ替える。いわゆる二重バッファである。名前は難しくない。作業台を二つ置き、一方で料理している間に、もう一方で次の材料を並べるようなものだ。

当然、メモリは余分に必要になる。ただ、ここで増えるのは主に入力や管理情報のための領域であり、モデル本体を二重に持つわけではない。多くの場合、この代償は待ち時間を消す効果に見合う。

二つ目は、生成トークンの持ち越しである。

生成では、バッチ N で出たトークンが、そのまま次の入力になる。まだ続くリクエストにとって、N の出力は N+1 の入力である。

ところが、CPU が N+1 を組み始める時点では、GPU 上の N の計算はまだ終わっていない。つまり、本物の次トークンが手元にない。

ここで CPU が完全に待ってしまうと、重ね合わせが壊れる。そこで、いったん仮の値で N+1 を組む。リクエストの並びや管理情報は先に作っておく。N の計算が終わり、本物のトークンが得られた直後に、その部分だけを差し替える。

全体を待つのではなく、最後に必要な小さな値だけを埋める。これにより、CPU 側の大部分の準備を GPU 計算と重ねられる。

同じハードで待ちがほぼ消える

この段取りに変えると、CPU と GPU は交互ではなく並行に働く。

CPU は次のバッチを組む。GPU は今のバッチを計算する。入力転送、計算、結果取り出しは、それぞれの順序だけを守りながら重なる。必要な場所にはイベントで印を打つ。使う入力領域は二組に分け、まだ読まれているデータを上書きしない。次に必要なトークンは、最後に本物へ差し替える。

これだけで、GPU の待ち時間は大きく減る。

先ほどの条件では、GPU の稼働率は約 76% から 99% 台まで上がった。生成時間は約 300 秒から約 235 秒になった。二割強の短縮である。

ここで起きていることは、魔法ではない。

GPU が速くなったわけではない。モデルが軽くなったわけでもない。一回の計算が安くなったわけでもない。変わったのは段取りである。

これまでは、CPU が準備し、GPU が計算し、CPU がまた準備していた。今は、GPU が計算している裏で、CPU が次を準備している。高価な部品を待たせないように、仕事の渡し方を変えただけである。

Agentic OS への含意

エージェントや推論モデルは、短い応答を一回返して終わるとは限らない。考え、調べ、書き、評価し、また書き直す。長い生成を何度も走らせる。複数の候補を並べて、あとから選ぶこともある。

生成が長くなるほど、GPU の待ち時間を消す価値は大きくなる。ひとつひとつの待ちは小さく見えても、トークンごと、リクエストごと、試行ごとに積み上がる。

Agentic OS の上の層から見ると、これはただの低レベル最適化に見えるかもしれない。だが、土台のスループットは、上の設計を静かに縛る。何体のエージェントを同時に走らせられるか。評価を何回回せるか。探索の幅をどこまで広げられるか。そうした判断の背後には、GPU をどれだけ止めずに使えているかがある。

今回見たのは、モデルの中身ではなく、モデルを走らせる段取りだった。

次回は、もう一段下と横を見る。一つのモデルが一枚の GPU に収まらないとき、それをどう分け、どう配るのか。大きなモデルの配り方に入る。

← 一覧へ