連続バッチ処理における非同期性の解放(20 分読了)
CUDA ストリームとイベントを活用した非同期バッチ処理により、CPU と GPU のアイドル時間を解消し、推論時の GPU 利用率を 22% 向上させる技術的進展。
キーポイント
GPU 効率の劇的向上
非同期バッチ処理を導入することで CPU と GPU のアイドル時間を削減し、推論時の GPU 利用率を最大 22% 改善する効果を確認。
技術実装の仕組み
CUDA ストリームとイベントを使用し、バッチ N の GPU 計算中に CPU が次回のバッチ N+1 を準備することで、処理間のギャップを完全に解消する。
モデル変更不要な最適化
既存のカーネルやモデルを変更することなく、システムレベルの実装のみで生成速度を大幅に向上させることが可能である。
影響分析・編集コメントを表示
影響分析
この技術は、LLM の推論コストを削減し、より高速なレスポンスを実現するための実用的な解決策として業界全体で広く採用される可能性があります。特にバッチ処理が頻繁に行われるクラウド環境や大規模サービスにおいて、インフラ効率化の標準的なベストプラクティスへと定着する見込みです。
編集コメント
モデルの学習やカーネル改修を伴わず、インフラ層での実装のみで劇的な効率化が得られる点は、運用コスト削減を目指す企業にとって極めて魅力的な内容です。
- 同期バッチ処理
- 並行性の創出 CUDA ストリームとは何か?
- デフォルトストリームと非デフォルトストリーム
- 連続バッチ処理へ戻る
- 同期の強制 CUDA イベントとは何か?
- 連続バッチ処理におけるイベントの使用
- 隙間の埋め込み ラスコンディション(競合状態)
- 引き継ぎ
- 完全な非同期ループ
- 実際に機能するのか?
- 結論
*TL;DR: CPU と GPU のワークロードを分離することで、推論において劇的なパフォーマンス向上を実現する方法を解説します。*
これは効率的な大規模言語モデル(LLM)推論に関するシリーズの第 2 弾です。初回記事では、連続バッチ処理を基本原理から解説しました。そこでは、KV キャッシュ、FlashAttention、アテンションマスクなどの概念を紹介し、本稿でそれらに基づいて構築していきます。
H200 の利用料金は Inference Endpoints で時間あたり約 5 ドルです。1 時間なら安価ですが、1 日使用するとすでに 120 ドルになります。この場合、GPU を最大限活用したいものです。私たちは、Continuous Batching(連続バッチ処理)が、パディングに計算リソースを割くことなく密にバッチをスケジューリングすることで GPU の利用率を向上させることを確認しています。しかし、Continuous Batching が解決していない廃棄の第二の原因があります:デフォルトでは同期処理であることです。これは CPU と GPU が交代で動作することを意味します。GPU が計算している間、CPU は待機し、CPU が次のバッチの準備をしている間、GPU は待機します。1 秒間に数百ステップが実行されるループにおいて、これらのアイドル状態のギャップは蓄積され、後ほど示す通り、総実行時間のほぼ 4 分の 1 を占める可能性があります。GPU に常に計算を行わせるためには、これらのギャップを排除する必要があります。
これを実現するために、非同期バッチ処理(asynchronous batching)を使用できます:CPU のバッチ準備と GPU のバッチ計算を分離し、両者を並列で実行できるようにして、常に生産的な GPU を維持します 🔥
同期バッチ処理
これは、単純な同期バッチ処理の動作方法です:
CPU が新しいバッチを準備する際、どのリクエストを含めるかを選択し、KV キャッシュテーブルを更新し、前回の実行で完了したリクエストを削除して、空いたスペースを埋めるために新しいリクエストを受け入れます。これが完了すると、準備された入力データを GPU に転送します。GPU は順次パスを実行し、各リクエストに対して新しいトークン(つまり選択)を行います。結果が CPU に戻され、CPU は各リクエストが直前に生成したトークンを把握し、その後、このサイクルは再び繰り返されます。
右側の赤い注釈に注目してください:GPU が計算を終えた後、アイドル状態になります。次のバッチは、CPU が更新ステップ(出力トークンのサンプリング、リクエスト状態の更新、バッチの再スケジューリング)を完了するまで開始できません。
これが同期バッチ処理における核心的な非効率性です:CPU と GPU は交代で動作します。GPU が計算している間、CPU はアイドル状態です。CPU が更新を行っている間、GPU はアイドル状態です。いかなる状況においても、両者が同時に有用な作業を行うことはありません。単一の順次パスにおいてはこれは小さな代償に思えるかもしれませんが、1 秒間に数百ステップを実行する連続バッチ処理ループでは、これらのアイドルの隙間が蓄積され、実際のスループット損失へと繋がります。
これを示すために、8B モデルを使用してバッチサイズ 32 で 8K トークンを生成する際の CPU と GPU に費やされる時間をプロファイリングしました:
*もし同じようなグラフを生成したい場合は、連続バッチ処理コードにインストルメントを追加して CPU と GPU のアクティビティスパンをダンプし、このスクリプトを使用してください。*
タイムラインは緑(GPU アクティブ、CPU アイドル)と赤(CPU アクティブ、GPU イドール)を交互に繰り返しており、両者が重なることはありません。総生成時間は 300.6 秒で、そのうち 24.0% は GPU がアイドル状態になり CPU の完了を待っている間に費やされています。GPU の視点から見れば、生成時間のほぼ 4 分の 1 が無駄になっています。これは悲観的な見方です。
楽観的な見方をすれば、CPU オーバーヘッドを完全に排除できれば、生成時間は 300 秒から 228 秒に短縮され(無料で 24% の高速化が可能!)ます。これには新しいカーネルやモデルの変更は不要で、ハードウェアの慎重な調整だけで実現できます。
根本的な考え方は単純です:バッチ N+1 の準備を、バッチ N が計算を行っている間に実行する方法を見つける必要があります。しかし、このシンプルなアイデアにはいくつかの技術的課題が隠されています:
- GPU で何かを実行し、制御権を CPU に返すにはどうすればよいでしょうか?
- 各タスクが起動する時点で、CPU または GPU のタスクに必要なデータが準備できていることをどのように保証できるでしょうか?
- バッチ N+1 がバッチ N の予測結果に基づいている場合、それをどのように準備できるでしょうか?
これらの質問に答えることで、非同期バッチ処理をゼロから構築していきます。この実装は、transformers ライブラリにおける連続バッチ処理の一部として行った手順と同じです。コードを確認して比較することもお気軽に行ってください。
並行性の作成
私たちの最終目標は、CPU と GPU の操作を並行して実行することです。どの操作が並列実行可能かを機械に伝えるために、操作を分類する方法が必要です。これを実現するには CUDA ストリーム(CUDA stream)を使用します。
CUDA ストリームとは何か?
CUDA がその操作をどのように順序付けるかを理解するためには、CUDA ストリームについて話す必要があります。ストリームは、GPU 操作(カーネル起動、メモリコピー、同期バリアなど)の順序付きキューであり、提出された順に実行されます。すべての GPU 操作は必ずストリーム内でスケジュールされます。同じストリーム内の操作は逐次的です:GPU は前の操作が完了するまで次の操作を開始しません。一方、*異なる*ストリーム内の操作は互いに独立しており、並行して実行できます。具体例として、3 つの異なるストリームに 3 つの操作を起動した場合の実行順序は以下のようになります:
3 つの操作はすべて同時に開始されます。これは若干の簡略化です:すべての GPU 操作は最終的に CPU によって開始され、その開始にはわずかな時間がかかります。適切なカーネルの特定、呼び出しの実行、コマンドを CPU から GPU へ転送するなどの処理です。これをCPU ランチオーバーヘッドと呼びます。より現実的な図は以下のようになります。
操作自体は並行して実行されますが、各 CPU ランチのコストによって開始時刻がずれます。これらの CPU ランチイベントは実際の時間を要するため、非同期ワークフローに移行する際に「いつ何が開始されたか」を追跡するために引き続き示していきます。例えば、ストリームがフラッシュされているかどうかを頻繁に確認します。これは、ストリーム内のすべての操作が実行済みであることを意味します。
デフォルトおよび非デフォルトストリーム
PyTorch で CUDA ストリームを明示的に使用したことがない場合、その存在自体に驚くかもしれません。典型的な PyTorch スクリプトではこれらに触れられることはなく、GPU 操作が非同期であるという感覚もありません。CPU は GPU の完了を待ってから次に進むように見えます。この感覚は正確であり、デフォルトストリームに由来するものです。
PyTorch の操作をストリームを指定せずに呼び出すと、それはデフォルト・ストリームに割り当てられます。デフォルト・ストリームには 1 つの特別な性質があります:それは同期型であることです。デフォルト・ストリーム上に操作がスケジュールされると、すべての他のストリームがフラッシュされるまで待機します。つまり、GPU 上のすべての作業が完了する前に、デフォルト・ストリームの単一の操作は開始できません。逆もまた真です:どのストリームに属する操作であっても、その起動前にデフォルト・ストリームがフラッシュされるのを待たなければなりません。
したがって、デフォルト・ストリームの操作の結果を CPU に転送する場合でも、CPU に対して非ブロッキングであるはずの転送を行ったとしても、CPU はすべての GPU 操作が完了するまでブロックされ続けます。これは、操作がデフォルト・ストリーム上にスケジュールされているためです。これにより、並行性を構築しようとするあらゆる努力は実質的に無効化されてしまいます。
そのため、非デフォルト・ストリームを使用する必要があります。カーネル起動や非ブロッキングなメモリコピーをキューに追加すると、即座に CPU への制御が返されます。GPU はバックグラウンドで操作を実行しますが、CPU は待機しません。これが最初の質問に対する答えです:GPU 作業を開始した後に CPU の制御を取り戻すには、非デフォルト・ストリームを使用します。
この投稿の残りの部分では、1 つのデバイスからもう一方へのすべてのメモリ転送が非ブロッキングであると仮定します。したがって、それらを我们自己で同期化する必要があります。
連続バッチ処理に戻る
GPU 上のどの操作もデフォルトストリームに配置すべきではないことを確認しました。しかし、疑問は残ります:デフォルトストリームを使用しない場合、どのようなストリームを使用すべきでしょうか?同期バッチ処理の図に戻りましょう。
GPU 上で実行される操作を三つ特定できます。
- CPU から GPU への入力転送
- GPU 上での計算
- GPU から CPU への出力転送
これは、計算用ストリーム、CPU から GPU への転送用ストリーム、および GPU から CPU への転送用のストリームの三つが必要であることを意味します。転送処理は互いに独立しているため、それらを直列化する理由はなく、それぞれが独自のストリームを持ちます。
**用語に関する注記:CPU と GPU を語る際、CUDA ドキュメント全体で使用されている慣習では、CPU を「ホスト」、GPU を「デバイス」と呼びます。これ以降もこの慣習に従います。CPU から GPU への転送は「ホストからデバイス」(H2D) 転送と、GPU から CPU への転送は「デバイスからホスト」(D2H) 転送と呼ばれます。したがって、三つのストリームとは、H2D ストリーム、計算用ストリーム、および D2H ストリームです。
次に、ストリームを使用して GPU でバッチ処理を非同期に起動し、CPU の制御に戻す方法を試してみましょう。CPU 側では以下の手順を実行します。
- CPU 上でバッチ入力データを準備する(ストリームなし、CPU のみの操作)
- H2D ストリームを使用して GPU に転送する
- 計算用ストリームを使用して GPU で計算を実行する
- D2H ストリームを使用してバッチ出力を取得する
- 結果を確認する(ストリームなし)
もしこれを CUDA ストリームのみを使用して行うと、結果はほぼ即座に取得可能になりますが、その内容は誤りとなります。なぜそうなるのかを理解するために、何が起きたかを振り返ってみましょう。
ストリームは互いに独立しているため、3 つの GPU 操作はほぼ同時に起動されました。計算ストリームは H2D(ホストからデバイスへの)転送が完了するのを待たなかったため、フォワードパスはすでに GPU メモリに存在していたデータに対して実行されました。また、D2H(デバイスからホストへの)ストリームも計算の完了を待たなかったため、まだ計算されていない結果が転送されてしまいました。ステップ 5 は即座に返却されました。これは CPU をブロックするものが何もないためです。同期対象となるデフォルト・ストリームが存在しませんでした。
各操作は単独では正しく実行されています。問題は、ストリーム同士が互いに待機するように指示していなかった点にあります。計算は H2D 転送の完了後に開始され、D2H は計算の完了後に開始されるべきことは分かっていますが、その順序を強制していませんでした。異なるストリームの境界を超えて「あの操作が完了するまで、この操作を開始しない」と宣言するためのメカニズムが必要です。
同期の強制
ストリーム間の同期を強制するために、CUDA イベントを使用します。
CUDA イベントとは何か?
CUDA イベントは、ストリームに記録できるマーカーです。GPU が実行中にそのマーカーに到達すると、イベントを完了状態として設定します。その後、他の任意のストリームに対して、次の操作を開始する前にそのイベントを待機するように指示できます。具体的には、2 つの操作があります:stream.record(event) は現在の位置にストリーム内にマーカーを挿入するものであり、stream.wait(event) はイベントが完了したとマークされるまでストリームの進行をブロックするものです。重要なのは、wait がブロックするのは *ストリーム* であり、CPU や並行して実行されている他のストリームではないということです:CPU の呼び出しは即座に返り、待機中のストリームのみが遅延します。
上記の図は、1 つのイベントが 2 つのストリームを同期化している様子を示しています。CPU は 3 つの操作を連続して発行します(3 つの小さなブロック):ストリーム 1 で入力準備の起動、ストリーム 1 でのイベント記録、そしてストリーム 2 にそのイベントを待機させる指示です。その後、CPU は即座に処理を続行します。ストリーム 1 が操作を実行し、完了するとイベントが設定されます。ストリーム 2 は待機マーカーの位置で常に停止しており、イベントが完了したとマークされた時点で計算を開始します。この一連の動作には CPU は関与していません:順序付けは完全に GPU 側で強制されました。
Continuous Batching におけるイベントの使用
私たちのケースに適用すると、この修正は非常に単純です。H2D(ホストからデバイス)転送をキューに入れた後、h2d_stream.record(h2d_done) を呼び出します。これにより、転送が完了した時点でイベントが完了済みとしてマークされます。順次パスの開始前に compute_stream.wait(h2d_done) を呼び出すことで、compute ストリームは h2d_done がセットされるまで待機します。同様の処理を compute と D2H(デバイスからホスト)の間でも行います。model.forward による順次パスを開始した後、compute_stream.record(compute_done) を呼び出し、出力転送をキューに入れる前に d2h_stream.wait(compute_done) を実行します。
その結果、明示的な順序付けを持つパイプラインが構築されます:
- H2D 転送は h2d_stream で実行される
- compute_stream は h2d_done の完了を待機し、その後順次パスを実行する
- d2h_stream は compute_done の完了を待機し、その後出力を転送して戻す
CPU はこれら一連の処理を順番にキューに入れ、次に進みます。どの時点でもブロックすることはありません。GPU はイベントを通じて順序付けを保証し、依存関係が満たされ次第、3 つのストリームすべてが即座にアクティブになります。
上記の図は、このプロセスがどのように展開されるかを示しています。CPU はバッチを準備した後、GPU へのすべての作業(H2D 転送、順方向パス、D2H 転送)をすばやくキューに追加します。各ステージの間には記録と待機呼び出しが挿入されます。その後、CPU は自由になります。GPU が引き継ぎ、依存イベントが設定される順序で各ストリームを実行します。右側の緑色の注釈にご注意ください:D2H 転送が完了すると、CPU が戻ってきて結果を読み取ります。この最終的な同期が、このステップ全体において CPU がブロックする唯一の点です。これを実現するために、出力転送後に D2H ストリームに第 3 のイベントを記録し、CPU 側で d2h_done_event.synchronize() を呼び出します。synchronize は、D2H ストリームがそのマーカーに到達するまで CPU をブロックします。
これは同期バッチングとの決定的な違いです:以前は CPU がすべての操作の後にブロックしていましたが、今では GPU が動作している間に「何か」を行うことができます。この「何か」を特定する必要があります。なぜなら、現状では GPU の利用率という観点からは何も変わっていないからです。
空白を埋める
CPU が利用可能な期間は、バッチ N を GPU にディスパッチしてからバッチ N+1 を GPU にディスパッチするまでの間です。その自然な用途は、バッチ N+1 の入力を準備することであり、それらを GPU にディスパッチして、バッチ N の計算が終了した時点で利用可能にしておくことです。では、どのようにこれを実現できるかを見ていきましょう。
バッチ N+1 の準備には、バッチ N を準備するために使用した同じ CPU 側のオブジェクト(現在のリクエストのリスト、キャッシュの状態、ホスト側のテンソルバッファなど)を再利用できます。ただし、2 つの点に注意する必要があります。
- データ破損:バッチ N+1 のデバイス側入力バッファは、バッチ N と同じにしてはいけません。GPU がまだ読み込んでいるデータを破損させてしまいます。
- データ転送:リクエストがバッチ N とバッチ N+1 の両方に含まれており、そのリクエストがバッチ N の出力で新しいトークンを生成した場合、そのトークンはバッチ N+1 の入力として必要になります。
これらの問題(データ破損とデータ転送)については、次の 2 つのセクションで取り扱います。
レース条件
まず、潜在的なデータ破損の問題に取り組みます。バッチ N とバッチ N+1 が同じデバイス側入力バッファを共有しており、かつバッチ N の計算がまだ完了していない間にバッチ N+1 の入力に対する H2D(ホストからデバイスへの)転送が始まったと想像してください。CPU は GPU が同じメモリからバッチ N を読み込んでいる最中に、バッチ N+1 の入力を上書きしてしまいます。その結果、GPU は部分的に上書きされたデータを取得し、結果が破損します。これがレース条件です。**ホスト側でも同様のリスクが存在します:バッチ N に対する H2D コピーがまだ実行中の間に、コピー元のソースを再利用すると転送が破損してしまいます。
⟦CODE_0⟧
- data corruption: the device-side input buffers for batch N+1 cannot be the same as batch N's: we would corrupt data the GPU is still reading
- data transmission: if a request is in both batch N and N+1, and it produces a new token in the outputs of batch N, that token is needed in the inputs of batch N+1
We address these issues, data corruption and data transmission, in the next two sections.
Race conditions
First, we are going to tackle the potential data corruption issue.
Imagine batch N and batch N+1 share the same device-side input buffers, and that the H2D transfer of batch N+1 inputs starts while batch N is still computing. The CPU may write batch N+1's inputs while the GPU is still reading batch N's from the same memory. So the GPU may pick up partially overwritten data, and the result is corrupted. This is a race condition**. The same risk exists on the host side: reusing the same source for the copy while the H2D copy for batch N is still in flight corrupts the transfer.
解決策は、2 つのテンソルセットを使用し、それらを交互に切り替えることです。GPU がスロット A からバッチ N を処理している間、CPU はバッチ N-1 の結果を用いてリクエストの状態を更新します。次に CPU は入力スロット B でバッチ N+1 の準備を行います。次のステップで、これらを入れ替えます。これは以下の図に示されています:
もちろん、これにはコストが伴います:入出力テンソルを保存するために使用される RAM と VRAM の量が 2 倍になります。これは FlashAttention を使用する際に特に許容できるトレードオフです。なぜなら、FlashAttention は注意マスク(attention mask)を必要としないためで、注意マスクは現在までに存在する中で最大の入力テンソルだからです。
しかし、2 つのスロットを持つことは別の問題も生み出します。推論では通常、レイテンシを削減するためにCUDA グラフを使用します。簡単に言えば、CUDA グラフとは、事前に記録された CUDA 演算のシーケンスです。これは特定のメモリアドレスに対して記録されるため、スロット A に対してキャプチャされたグラフは、スロット B のバッファに対して再生することはできません。したがって、2 つのグラフが必要になります。そして、各グラフが独自のメモリバッファを持つ場合、VRAM はさらに 2 倍になります。
解決策はメモリプールです。これは両方のグラフが割り当てる共有メモリーバッファです。唯一の制約は、同じプール内の 2 つのグラフが同時に実行されてはならないことです。バッチ N はバッチ N+1 が開始する前に完了しなければならないため、この条件は常に満たされます。実際には、両方のグラフを合わせても使用 VRAM の量は 1 つの場合とほぼ同じです。初期化時に 2 回のキャプチャを行うコストのみが発生します。同じプール内に任意の数の CUDA グラフを作成できても、総メモリ使用量はグラフ間の最大値に制限されたままです。これは以下で示されています。
データ破損を防ぐ方法がわかったので、次にバッチ N の出力トークンをバッチ N+1 の入力として取り込むという 2 つ目の課題に取り組みます。
継続(Carry-over)
バッチ N とバッチ N+1 の両方に現れるリクエストを想定してください。バッチ N では、このリクエストは新しいトークンを生成します。そのトークンがバッチ N+1 における入力となります。問題は、バッチ N+1 の入力バッファを準備している時点では、まだそのトークンが存在しないことです。なぜなら、バッチ N はまだ実行中だからです。
これを解決するために、バッチ N+1 を構築する際にはプレースホルダートークンを使用します。後で理由が明らかになるため、プレースホルダーとして 0 を使用します。このプレースホルダーは、バッチ N の計算が完了し、バッチ N+1 が順伝播(フォワードパス)を開始する前に置き換えられます。このステップを継続(carry-over)と呼びます。これは、バッチ N から生成された新しいトークンをバッチ N+1 に引き継ぐためです。継続のアイデアは以下に図示されています:
キャリーオーバーを実行するには、バッチ N の出力トークン ID、バッチ N+1 の入力トークン ID、およびキャリーオーバーの実行方法を指示するテンソルの 3 つだけで十分です。このテンソルをキャリーオーバーマスクと呼びます。これは、キャリーオーバーが必要なトークンの宛先ターゲットを含み、不要なトークンには -1 を格納します。以下にその例を示します。
キャリーオーバー自体は以下の 4 つの操作から構成されます。
- バッチ N の出力からキャリーオーバーするトークンを新しいテンソル T に選択する
- キャリーオーバーしたくないトークンを T でゼロ化する
- T をバッチ N+1 の入力長に合わせて切り詰める
- T をバッチ N+1 の入力 ID に加算する(これがプレースホルダー入力 ID の値が 0 である理由です)
これら 4 つの操作は非常に軽量なため、新しいバッチの開始時に実行し、CUDA グラフ内でキャリーオーバーを捕捉します。もしキャリーオーバーマスクに -1 しか含まれていない場合(-1 という値は:この位置はキャリーオーバーしないことを意味する)、最後のステップはゼロテンソルとの加算になります。これは頻繁には発生しません。なぜなら、複数のバッチにまたがるデコーディングリクエストは通常、連続したバッチでスケジューリングされるからです。
完全な非同期ループ
それでは、すべてを統合して最初の 2 つのステップを追跡してみましょう。
ステップ 0 はコールドスタートです。前のバッチが実行されていないため、CPU がスロット A でバッチ 0 を準備し、同期バッチ処理と同様にディスパッチします。まだオーバーラップは発生しません。
ステップ 1 は非同期ループが始まる箇所です。GPU は現在スロット A でバッチ 0 を実行中であり、CPU は空き状態です。CPU は直ちにスロット B でバッチ 1 の準備を開始します:完了したリクエストの退去、新規リクエストの受付、KV キャッシュルーティングテーブルの更新、継続マスクの構築などです。これらすべてが GPU と完全にオーバーラップして実行されます。バッチ 1 の入力が準備できると、CPU は作業を順次キューに追加します:スロット B に対する H2D(ホストからデバイスへ)転送を開始し、計算および D2H(デバイスからホストへ)ストリーム用のイベントを記録して待機し、その後次の処理へと進みます。
次に GPU 上で並行して 2 つのことが起こります。スロット A では、GPU が計算を終了して compute_done を設定し、バッチ 0 の出力に対する D2H 転送が解放されます。一方、スロット B ではバッチ 1 の入力に対する H2D 転送が実行中です。これが完了すると h2d_done イベントが設定され、バッチ 1 の計算が開始されます。バッチ 0 からバッチ 1 への継続処理は、この計算の一部であり、通常のフォワードパスの前に発生します。スロット A とスロット B は独立しているため、これらすべてが自由にオーバーラップして実行されます。
一方、CPU はバッチ 0 の出力が到着するまで d2h_done_event.synchronize() でブロックされます。その後、出力を処理し、バッチ 0 に含まれていたすべてのリクエストの状態を更新して、バッチ 2 のスケジューリングを開始します。ループは現在実行中で、その後のすべてのステップがまさに同じパターンに従います。
以下の図で全体のワークロードを示します。各スロットには CPU および GPU の操作とイベント(これらもスロット固有です)に対して専用の色が割り当てられています。可読性の観点から、CPU による GPU 操作の起動(計算やデータ転送など)は表示していませんが、実際にはこれらの操作も行われています。これは、GPU 操作の起動にかかるレイテンシが示されている操作に比べて無視できるほど小さいため正当化されます。
バッチ N が完了した時点でバッチ N+1 の入力が GPU 上に準備されていれば、GPU はバッチ間でアイドル状態になることはありません。唯一の問題は、CPU が計算を完了する前に GPU の計算処理が終了するか否かです。通常はそのようなケースになります:モデルの規模は拡大し続ける一方で、バッチスケジューリングのコストは比較的安価なままなので、ボトルネックとなるのは CPU ではなく GPU の計算処理です。
実際に機能するのか?
その答えを知るために、以前と同じ実験を行います:8K トークン、バッチサイズ 32、8B モデル。
タイムラインはほぼ完全に濃い緑色です:CPU と GPU が同時に稼働しています。たまに現れる薄い緑色のスライスは、GPU がアクティブだが CPU はすでに準備を終えて待機している瞬間を示しています。ほとんど見えない赤いマークはバッチ間の同期ポイントであり、ここで CPU がブロックしてバッチ N の出力をサンプリングします。GPU の稼働率は総実行時間の 99.4% に達し、以前の 76.0% から大幅に向上しました。全体の生成時間は 300.6 秒から 234.5 秒へと短縮され、22% の高速化が実現されました。CPU オーバーヘッドを完全に排除した場合の予測値は 24% でした。わずかに残る差は、避けられない同期ポイントによるものです。新しいカーネルもモデルの変更もありません:CPU と GPU が同時に作業できるようにしただけです。
結論として、当初は CPU と GPU が順次動作する同期型ワークロードであり、両者がともに未活用状態にありました。スケジュールベースの依存関係からデータベースの依存関係へ移行し、同期ポイントを最適化することで、CPU と GPU のワークロードを分離することに成功しました。これにより、両ハードウェアの並列実行が可能となりました。その結果、GPU ワークキューを飽和させ、常に稼働状態を保つことが実現できました。これによってモデルの精度を維持したまま、生成速度が大幅に向上しました。ほぼ完璧な成果です。
完全な実装は transformers ライブラリに含まれています。これが実際のコードにどのように変換されるかを確認したい場合は、連続バッチ処理の一般的なエントリーポイントは continuous_batching.py です。より非同期中心のコードは、ContinuousBatchingAsyncIOs クラスに位置しています。
非同期バッチ処理は、強化学習などで見られる 16K 以上の生成長を持つ長期生成における SOTA スループット(State-of-the-Art throughput)を実現するための一歩を踏み出します。ただし、その目標を達成するためには、他にもいくつかの小さな要素が必要です。次の記事では、リクエストのオフロード、デコード専用カーネル、微細粒度コンパイルなどについて詳しく解説します。お楽しみに!
*謝辞:支援と洞察に富んだレビューを提供してくれた Pedro Cuenca 氏および Aritra Roy Gosthipaty 氏に心より感謝いたします。*
原文を表示
- Synchronous batching
- Creating concurrency What is a CUDA stream?
- Default and non-default streams
- Back to Continuous Batching
- Enforcing synchronization What is a CUDA event?
- Using events in Continuous Batching
- Filling the vacuum Race conditions
- Carry-over
- The full async loop
- Does it actually work?
- Conclusion
*TL;DR: we explain how to separate CPU and GPU workloads to get a massive performance boost for inference.*
*This is the second post in a series on efficient LLM inference. The first post covered continuous batching from first principles. It introduces some concepts we build upon: KV cache, FlashAttention, attention masks, etc.*
An H200 costs around $5 an hour on Inference Endpoints. That's cheap for an hour, but use it for a day and you are already paying $120. If this is the case, you want your GPU to be used to its fullest.**We have seen that Continuous Batching improves GPU utilization by scheduling tightly packed batches, so no compute is wasted on padding. But there is a second source of waste that continuous batching does not address: by default, it is synchronous. This means the CPU and GPU take turns: while the GPU computes, the CPU waits. And while the CPU prepares the next batch, the GPU waits. In a loop running hundreds of steps per second, those idle gaps add up, and as we will show, they can account for nearly a quarter of total runtime. To ensure the GPU is busy computing 100% of the time, we need to get rid of those gaps.
To achieve this, we can use asynchronous batching**: we are going to disentangle CPU batch preparation from GPU batch compute, so both can run in parallel and we always have a productive GPU 🔥
Synchronous batching
This is how naive synchronous batching works:
When the CPU prepares a new batch, it selects which requests to include, updates the KV cache table, evicts requests that finished in the previous runs, and admits new ones to fill the freed space. Once that is done, it transfers the prepared inputs to the GPU. The GPU runs its forward pass and samples (i.e. chooses) a new token for each request. The results come back to the CPU, so it knows what token each request just produced, then the whole cycle repeats again.
Notice the red annotation on the right: after the GPU finishes computing, it goes idle. The next batch cannot start until the CPU has gone through its update step: sampling the output tokens, updating request states, re-scheduling the batch.
This is the core inefficiency of synchronous batching: the CPU and GPU take turns. While the GPU is computing, the CPU is idle. While the CPU is updating, the GPU is idle. In no circumstances are they both doing useful work at the same time. For a single forward pass this might seem like a small price to pay, but in a continuous batching loop running hundreds of steps per second, these idle gaps accumulate into real throughput loss.
To showcase this, we profile the time spent on CPU and GPU when generating 8K tokens with a batch size of 32 using an 8B model:
*If you want to produce the same kind of graph, you can instrument the continuous batching code to dump CPU and GPU activity spans and use this script.*
The timeline alternates between green (GPU active, CPU idle) and red (CPU active, GPU idle): the two never overlap. Total generation time is 300.6 seconds, with 24.0% of that spent with an idle GPU waiting for the CPU to finish. Nearly a quarter of all generation time is wasted, from the point of view of the GPU. This is the pessimistic way of viewing things.
The optimistic way is that generation time would drop from 300 to 228 seconds (a free 24% speedup!), if we could eliminate CPU overhead entirely. This requires zero new kernel or model changes, just careful coordination of hardware.
Fundamentally, the idea is simple: we need to figure out how to run batch preparation for batch N+1 while batch N is computing. But this simple idea hides a few technical difficulties:
- How can we launch something on the GPU and get back control to the CPU?
- How can we make sure data is ready, for either CPU or GPU tasks, by the time each task is launched?
- How can we prepare batch N+1 if it is based on the predictions of batch N?
By answering those questions, we are going to build asynchronous batching from scratch. We followed the same steps to implement it as part of continuous batching in the transformers library. Feel free to check the code and compare!
Creating concurrency
Our end goal is to have concurrent execution of CPU and GPU operations. We need a way to categorize our operations, so we can let the machine know which operations can run concurrently. We can achieve this using CUDA streams.
What is a CUDA stream?
To understand how CUDA orders its operations, we need to talk about CUDA streams. A stream is an ordered queue of GPU operations (kernel launches, memory copies, synchronization barriers) that executes in the order they were submitted. Every GPU operation is always scheduled inside a stream. Operations within the same stream are sequential: the GPU will not start the next one until the previous has completed. Operations in *different* streams are independent of each other and can run concurrently. To illustrate, if you launch 3 operations across 3 different streams, execution looks like this:
All three operations start at the same time. This is a slight simplification: every GPU operation is ultimately initiated by the CPU, and that initiation takes a small amount of time: finding the right kernel, issuing the call, transferring the command from CPU to GPU, etc. This is called CPU launch overhead, and a more realistic diagram looks like this:
The operations are still concurrent, but their start times are staggered by the cost of each CPU launch. We will keep showing these CPU launch events throughout because they take real time, and they will help us track "what is launched when" as we move to asynchronous workflows. For instance, we will often check if a stream is flushed: that means that all operations in a stream have been executed.
Default and non-default streams
If you have never explicitly used CUDA streams in PyTorch, you might be surprised they exist at all. A typical PyTorch script never mentions them, and it does not *feel* like GPU operations are asynchronous: the CPU seems to wait for the GPU to finish before moving on. That feeling is accurate, and it comes from the default stream.
When you call a PyTorch operation without specifying a stream, it lands on the default stream. The default stream has one special property: it is synchronizing. If an operation is scheduled on the default stream, it waits for all other streams to be flushed, i.e. all work on the GPU has to be over before a single operation on the default stream can start. The reverse is also true: any operation, regardless of its stream, waits for the default stream to be flushed before it launches.
So if you transfer to the CPU the result of a default stream operation, even with a transfer that is supposed to be non-blocking for the CPU, your CPU will still block until all GPU operations have finished because the operations were scheduled on the default stream. This effectively destroys any effort to build concurrency.
That's why we need to use non-default streams. Enqueuing a kernel launch or a non-blocking memory copy returns control to the CPU immediately. The GPU will run the operation in the background, but the CPU does not wait. This answers our first question: to get back CPU control after launching GPU work, we use a non-default stream.
For the rest of this post, we will assume all memory transfers from one device to the other are non-blocking. We will therefore have to synchronize them ourselves.
Back to Continuous Batching
We established that no GPU operation should land on the default stream. But the question remains: if we are not using the default stream, what streams should we use? Let us go back to the synchronous batching figure:
We can identify three distinct GPU operations:
- Transfer of inputs from CPU to GPU
- Compute on the GPU
- Transfer of outputs from the GPU to the CPU
This means we need three streams: one for compute, one for CPU-to-GPU transfers, and one for GPU-to-CPU transfers. The transfers are independent, so there is no reason to serialize them, and each one gets its own stream.
A note on nomenclature: when talking about CPUs and GPUs, the convention used throughout the CUDA documentation is to call the CPU the host and the GPU the device. We will use that convention from now on. CPU-to-GPU transfers are called host-to-device (H2D) transfers, and GPU-to-CPU transfers are called device-to-host (D2H) transfers. Hence, the three streams are the H2D stream, the compute stream, and the D2H stream.
Let us now try to use streams to asynchronously launch a batch on the GPU and get back CPU control. From the CPU, we do the following:
- Prepare the batch input data on the CPU (no stream, CPU-only operations)
- Transfer it to the GPU (using the H2D stream)
- Run compute on the GPU (using the compute stream)
- Retrieve the batch outputs (using the D2H stream)
- Take a look at the results (no stream)
If we do this using only CUDA streams, the results are available almost instantly and they are incorrect. To understand why, let us look at what happened:
Because streams are independent of each other, all three GPU operations launched at nearly the same time. The compute stream did not wait for the H2D transfer to complete, so the forward pass ran on whatever was already sitting in GPU memory. The D2H stream did not wait for compute to finish, so it transferred results that had not been computed yet. Step 5 returned instantly because nothing was blocking the CPU: there was no default stream to synchronize against.
The operations are all running correctly in isolation. The problem is that we never told the streams to wait for each other. We know that compute must start after H2D completes, and that D2H must start after compute completes, but we did not enforce that ordering. We need a mechanism to say "do not start this operation until that one is done" across stream boundaries.
Enforcing synchronization
To enforce synchronization between the streams, we are going to use CUDA events.
What is a CUDA event?
A CUDA event is a marker that can be recorded into a stream. When the GPU reaches that marker during execution, it sets the event as completed. Any other stream can then be told to wait for that event before starting its next operation. Concretely, there are two operations: stream.record(event), which inserts the marker into a stream at the current position, and stream.wait(event), which blocks a stream from proceeding until the event is marked complete. Importantly, wait blocks the *stream*, not the CPU or other streams running in parallel: the CPU call returns immediately, and only the waiting stream is held back.
The figure above shows a single event synchronizing two streams. The CPU issues three operations in rapid succession (the three small blocks): launch input preparation on stream 1, record the event on stream 1, then tell stream 2 to wait for it. Then the CPU continues immediately. Stream 1 runs its operation, and when it completes, the event is set. Stream 2 is held at the wait marker the whole time, and only starts compute once the event is marked complete. The CPU was not involved in any of this: the ordering was enforced entirely on the GPU side.
Using events in Continuous Batching
Applied to our case, the fix is straightforward. After enqueueing the H2D transfer, we call h2d_stream.record(h2d_done): the event will be marked as completed only when the transfer finishes. Before enqueueing the forward pass, we call compute_stream.wait(h2d_done), so the compute stream will not start until h2d_done is set. We do the same between compute and D2H: after launching the forward pass with model.forward, we call compute_stream.record(compute_done), then d2h_stream.wait(compute_done) before enqueueing the output transfer. The result is a pipeline with explicit ordering:
- H2D transfer runs on h2d_stream
- compute_stream waits for h2d_done, then runs the forward pass
- d2h_stream waits for compute_done, then transfers the outputs back
The CPU enqueues all of this in sequence, then moves on. At no point does it block. The GPU enforces the ordering through the events, and all three streams are active as soon as their dependency is satisfied.
The figure above shows how this unfolds. The CPU prepares the batch, then quickly enqueues all the GPU work: the H2D transfer, the forward pass, the D2H transfer, with record and wait calls inserted between each stage. After that, the CPU is free. The GPU takes over, executing each stream in order as its dependency event is set. Notice the green annotation on the right: once the D2H transfer completes, the CPU comes back and reads the results. This final synchronization is the only point where the CPU blocks in the whole step. To implement it, we record a third event on the D2H stream after the output transfer, then call d2h_done_event.synchronize() on the CPU side. synchronize blocks the CPU until the D2H stream reaches that marker.
This is the key difference from synchronous batching: before, the CPU blocked after every operation. Now, it is free to do "something" while the GPU works.**We need to figure out what that "something" is, because right now nothing changed from a GPU-utilization standpoint.
Filling the vacuum
The window where the CPU is available sits between dispatching batch N and dispatching batch N+1 to the GPU. Its natural use would be to prepare batch N+1's inputs, so we can dispatch them to the GPU and have them be ready once batch N compute is over. Let us see how we can do this.
To prepare batch N+1, we can reuse the same CPU-side objects that prepared batch N: the list of current requests, the state of the cache, the host-side tensor buffers, etc. However, we need to pay attention to two things:
- data corruption: the device-side input buffers for batch N+1 cannot be the same as batch N's: we would corrupt data the GPU is still reading
- data transmission: if a request is in both batch N and N+1, and it produces a new token in the outputs of batch N, that token is needed in the inputs of batch N+1
We address these issues, data corruption and data transmission, in the next two sections.
Race conditions
First, we are going to tackle the potential data corruption issue.
Imagine batch N and batch N+1 share the same device-side input buffers, and that the H2D transfer of batch N+1 inputs starts while batch N is still computing. The CPU may write batch N+1's inputs while the GPU is still reading batch N's from the same memory. So the GPU may pick up partially overwritten data, and the result is corrupted. This is a race condition**. The same risk exists on the host side: reusing the same source for the copy while the H2D copy for batch N is still in flight corrupts the transfer.
The fix is to use two sets of tensors and alternate between them. While the GPU processes batch N from slot A, the CPU updates the requests' state with the results of batch N-1. The CPU next prepares batch N+1 in input slot B. Next step, they swap. This is illustrated in the diagram below:
Of course, this comes with a cost: it doubles the amount of RAM and VRAM used to store the input and output tensors. This is an acceptable tradeoff, especially when using FlashAttention, because it does not require an attention mask, which is by far the largest input tensor.
But having two slots creates another problem. In inference, we usually use CUDA graphs to reduce latency. In a nutshell, a CUDA graph is a pre-recorded sequence of CUDA operations. It is recorded against specific memory addresses: a graph captured for slot A cannot be replayed against slot B's buffers. So we need two graphs. And if each graph has its own memory buffer, that is double the VRAM again.
The solution is a memory pool: a shared memory buffer that both graphs allocate from. The only constraint is that two graphs in the same pool must never execute concurrently. Since batch N must finish before batch N+1 starts, that is always the case. In practice, both graphs together use nearly the same amount of VRAM as one. We only pay for two captures at initialization time.**We can create any number of CUDA graphs in the same pool and the total memory usage is still capped at the maximum across graphs. This is showcased below.
Now that we know how to prevent data corruption, we can address the second issue: getting the output tokens of batch N into the inputs of batch N+1.
Carry-over
Consider a request that appears in both batch N and batch N+1. In batch N, it produces a new token. That token is its input for batch N+1. The problem is that when we are preparing batch N+1's input buffer, we do not have that token yet: batch N is still running.
To address this, we use a placeholder token when building batch N+1. We will use 0 as a placeholder, for reasons that will become apparent later. We replace that placeholder after batch N is done computing and before batch N+1 starts the forward pass. We call that step the carry-over**, because we are carrying over the new tokens from batch N to batch N+1. The idea behind carry-over is illustrated below:
To perform carry-over, we only need three things: the output token ids of batch N, the input token ids of batch N+1, and a tensor with instructions on how to perform carry-over. We will call this tensor the carry-over mask. It contains the target destination for the tokens that need to be carried over, and -1 for the ones that do not. We represent one below:
The carry-over itself consists of four operations:
- we select the tokens to carry over from batch N's output into a new tensor T
- we zero out the tokens we do not want to carry over in T
- we truncate T to match batch N+1's input length
- we add T to the input ids of batch N+1 (that's why placeholder input ids have a value of zero)
Since those four operations are very cheap, we perform them at the start of each new batch and capture the carry-over in the CUDA graph. If the carry-over mask contains only -1 (a value of -1 means: do not carry over this position) then the last step is an addition with a zero tensor. This does not happen often because decoding requests that span more than one batch are typically scheduled in consecutive batches.
The full async loop
Let us put everything together and trace through the first two steps.
Step 0 is a cold start: there is no previous batch running, so the CPU prepares batch 0 in slot A and dispatches it as it would with synchronous batching. No overlap yet.
Step 1 is where the async loop begins. The GPU is now running batch 0 on slot A, and the CPU is free. It immediately starts preparing batch 1 in slot B: evicting finished requests, admitting new requests, updating the KV cache routing table, building the carry-over mask. All of this runs in full overlap with the GPU. Once batch 1's inputs are ready, the CPU enqueues the work in sequence: it launches the H2D transfer for slot B, records and waits events for the compute and D2H streams, then moves on.
Now two things happen in parallel on the GPU. On slot A, the GPU finishes compute and sets compute_done, which releases the D2H transfer of batch 0's outputs. On slot B, the H2D transfer of batch 1's inputs is running. Once it completes, the h2d_done event is set and compute for batch 1 begins. The carry-over from batch 0 to batch 1 is part of that compute: it happens before the regular forward pass. Since slot A and slot B are independent, all of this overlaps freely.
The CPU, meanwhile, blocks on d2h_done_event.synchronize() until batch 0's outputs land. Then it processes the outputs, updates the state of all requests that were in batch 0, and starts scheduling batch 2. The loop is now running, and every subsequent step follows exactly the same pattern.
We illustrate the full workload below. Each slot has a dedicated color for CPU and GPU operations and for events (which are also slot-specific). For readability's sake, we do not show the CPU's launch of GPU operations (like compute or data movement), but they still take place. This is justified because launching a GPU operation has negligible latency compared to the operations shown.
As long as batch N+1's inputs are ready on the GPU when batch N finishes, the GPU never idles between batches. The only question is whether the CPU finishes its work before the GPU finishes compute. That is usually the case: models continue to grow while batch scheduling stays relatively cheap, so GPU compute is the bottleneck, not the CPU.
Does it actually work?
To find out, we run the same experiment as before: 8K tokens, batch size 32, 8B model.
The timeline is almost entirely dark green: CPU and GPU running at the same time. The occasional light green slivers are moments where the GPU is active but the CPU has already finished its prep and is waiting. The near-invisible red marks are the sync points between batches, where the CPU blocks to sample batch N's outputs. The GPU is active for 99.4% of total runtime, up from 76.0%. Total generation time drops from 300.6s to 234.5s, a 22% speedup. We predicted 24% if CPU overhead were fully eliminated. The small remaining gap is that unavoidable sync point. No new kernels, no model changes: letting the CPU and GPU work at the same time.
Conclusion
We started with a synchronous workload where the CPU and GPU worked one after the other, leaving both underused. By moving from schedule-based dependencies to data-based dependencies and refining synchronization points, we managed to disentangle the CPU and GPU workloads, making parallel execution of both hardwares possible. Hence, we were able to saturate the GPU work queue and ensure it is always running. This finally resulted in a large increase of generation speed while maintaining the accuracy of the model. Pretty much a slam dunk.
The full implementation is in the transformers library. If you want to see how this translates to actual code, the general entry point for continuous batching is continuous_batching.py. The more asynchronous-centric code is located in the ContinuousBatchingAsyncIOs class.
Asynchronous batching gets us one step closer to unlocking SOTA throughput for long generation, for generation lengths of 16K+ like in reinforcement learning. But there are still some other, smaller things that are also needed to reach that goal. In the next article, we will go through those: offloading requests, decode-specific kernels or fine-grained compile, among others. Stay tuned!
*Acknowledgements: Many thanks to Pedro Cuenca and Aritra Roy Gosthipaty for their help and insightful reviews.*
関連記事
今日のまとめ
AI日報で今日の重要ニュースをまとめ読み