連続バッチ処理における非同期性の解放
Hugging Face は、連続バッチ処理における非同期性を解き放つ技術的進歩を発表し、LLM サーバーの推論スループットとリソース効率を大幅に向上させる手法を詳述している。
キーポイント
同期バッチ処理のボトルネック解消
従来の同期バッチ処理では、すべてのリクエストが完了するまで待機する必要があり、短時間のリクエストでも長時間かかるリクエストに引きずられる非効率性が指摘されている。
CUDA ストリームを活用した並列化
複数の CUDA ストリームを併用することで、異なるバッチやトークン生成ステップを非同期で実行し、GPU の計算リソースを常に稼働させる仕組みが提案されている。
動的なリクエスト管理の実装
リクエストの到着タイミングや完了速度に応じて柔軟にバッチを再構成するアルゴリズムにより、システム全体のレイテンシとスループットを最適化するアプローチが示されている。
実用化への具体的な技術的貢献
この手法は Hugging Face の推論サーバーや関連ライブラリでの実装を通じて、大規模言語モデルの運用コスト削減と応答速度向上に直結する実践的な解決策となる。
影響分析・編集コメントを表示
影響分析
この記事は、LLM 推論サーバーの設計思想における重要な転換点を示しており、従来の厳密な同期モデルから非同期・並列処理への移行を加速させる契機となる。特に、リソース効率と応答速度の両立が求められる実運用環境において、コスト削減とユーザー体験向上に直接的なインパクトを与える技術的基盤を提供するものである。
編集コメント
LLM の実運用におけるボトルネック解消策として、CUDA ストリームを活用した非同期処理の導入は極めて重要です。特にリソース効率を最大化したい開発者やインフラ担当者にとって、即座に検討すべき技術的知見が凝縮されています。
- 同期バッチ処理
- 並行性の作成 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 に 100% の時間計算を行わせるためには、これらのギャップを排除する必要があります。
これを実現するために、非同期バッチ処理(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 秒で、そのうち GPU がアイドル状態になり CPU の完了を待っている時間が全体の 24.0% を占めています。GPU の視点から見れば、生成時間のほぼ 4 分の 1 が無駄になっています。これは悲観的な見方です。
楽観的な見方をすれば、CPU オーバーヘッドを完全に排除できれば、生成時間は 300 秒から 228 秒に短縮され(無料で 24% の高速化が可能!)ます。これには新しいカーネルやモデルの変更は不要で、ハードウェアの慎重な調整だけで実現できます。
根本的な考え方は単純です:バッチ N が計算を行っている間に、バッチ N+1 のバッチ準備をどのように実行するかを考えればよいのです。しかし、このシンプルなアイデアの背後にはいくつかの技術的課題が隠されています:
- GPU で何かを実行し、制御権を CPU に返すにはどうすればよいか?
- 各タスクが起動する時点で、CPU または GPU のタスクに必要なデータが確実に準備されているようにするにはどうすればよいか?
- バッチ N+1 がバッチ N の予測結果に基づいている場合、どのようにしてバッチ N+1 を準備できるのか?
これらの質問に答えることで、非同期バッチ処理をゼロから構築していきます。この実装は、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 上で実行される 3 つの明確な操作を特定できます。
- CPU から GPU への入力転送
- GPU 上での計算
- GPU から CPU への出力転送
これは、計算用ストリーム、CPU から GPU への転送用ストリーム、GPU から CPU への転送用ストリームの 3 つが必要であることを意味します。転送は独立して行われるため、それらを直列化する理由はなく、それぞれが独自のストリームを持ちます。
用語に関する注記:CPU と GPU を語る際、CUDA ドキュメント全体で使用されている慣習では、CPU をホスト、GPU をデバイスと呼びます。これ以降もこの慣習に従います。CPU から GPU への転送はホストからデバイス(H2D)転送と呼ばれ、GPU から CPU への転送はデバイスからホスト(D2H)転送と呼ばれます。したがって、3 つのストリームとは、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 はこれら一連の処理を順にキューに追加し、その後次の処理へ移ります。どの時点でも 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 コピーがまだ進行中である間にコピー元のソースを再利用すると、転送自体が破損します。
解決策は、2 つのテンソルセットを使用し、それらを交互に切り替えることです。GPU がスロット A からバッチ N を処理している間、CPU はバッチ N-1 の結果を用いてリクエストの状態を更新します。次に CPU は入力スロット B でバッチ N+1 の準備を行います。次のステップで、これらを入れ替えます。これは以下の図に示されています:
もちろん、これにはコストが伴います:入出力テンソルを保存するために使用される RAM と VRAM の量が 2 倍になります。これは FlashAttention を使用する際に特に許容できるトレードオフです。なぜなら、FlashAttention は注意マスク(アテンションマスク)を必要としないためで、これが現在までに存在する最大の入力テンソルだからです。
しかし、2 つのスロットがあることは別の問題も生み出します。推論では通常、レイテンシを削減するためにCUDA グラフを使用します。要約すると、CUDA グラフとは、事前に記録された CUDA 演算のシーケンスです。これは特定のメモリアドレスに対して記録されます:スロット A 用にキャプチャされたグラフは、スロット B のバッファに対して再生することはできません。したがって、2 つのグラフが必要です。そして、各グラフが独自のメモリバッファを持つ場合、VRAM はさらに 2 倍になります。
解決策はメモリプールです。これは両方のグラフが割り当てる共有メモリーバッファであり、唯一の制約は同じプール内の 2 つのグラフが同時に実行されてはならないことです。バッチ N が完了するまでバッチ N+1 は開始できないため、この条件は常に満たされます。実際には、両方のグラフを合わせても 1 つのグラフと同じ程度の VRAM を使用します。初期化時に 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 への継続処理(carry-over)は、この計算の一部であり、通常のフォワードパスの前に発生します。スロット A とスロット B は独立しているため、これらすべての処理が自由にオーバーラップして実行されます。
一方、CPU はバッチ 0 の出力が到着するまで d2h_done_event.synchronize() でブロックされます。その後、出力を処理し、バッチ 0 に含まれていたすべてのリクエストの状態を更新して、バッチ 2 のスケジューリングを開始します。ループは現在実行中で、その後のすべてのステップがまさに同じパターンに従います。
以下の図で全体のワークロードを示します。各スロットには CPU および GPU の操作とイベント(これらもスロット固有です)に対して専用の色が割り当てられています。可読性の観点から、CPU による GPU 操作の起動(計算やデータ転送など)は表示していませんが、実際にはこれらの操作も行われています。これは、GPU 操作の起動にかかるレイテンシが示されている操作に比べて無視できるほど小さいためです。
バッチ N+1 の入力がバッチ N の完了時に GPU に準備されていれば、GPU はバッチ間でアイドル状態になることはありません。唯一の問題は、CPU が計算を完了する前に自身の作業を終了できるかどうかです。通常はその通りで、モデルの規模が拡大し続ける一方でバッチスケジューリングのコストは比較的安価なままなので、ボトルネックとなるのは GPU の計算であり、CPU ではありません。
実際に機能するのか?
その答えを知るために、以前と同じ実験を行います: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日報で今日の重要ニュースをまとめ読み