GPU バブルの解消:パイプラインデコーディングによる効率化
Moondream は、CPU と GPU の同期待ちによる非効率を解消する「パイプラインデコード」技術により、GPU バブルを解消し推論速度を大幅に向上させるPhotonエンジンを公開した。
キーポイント
GPU バブルの発生メカニズム
自己回帰型モデルのトークン生成において、CPU の管理作業(リクエスト選択やメタデータ設定)が完了するまで GPU が待機状態となる現象を「GPU バブル」と定義している。
パイプラインデコードによる解決
次のトークンの計算開始と、直前のトークンに関する CPU の管理作業を並行実行することで、GPU のアイドル時間を排除する技術を実装した。
Photon エンジンの実証結果
NVIDIA B200 上で約 33ms の推論遅延を実現し、従来の手法と比較してデコードスループットを最大 35% 向上させることに成功した。
影響分析・編集コメントを表示
影響分析
この技術は、大規模な AI モデルを運用する際のインフラコスト削減とレスポンス速度の改善に直結する重要な進展です。特に VLM のような複雑な処理において、ハードウェア性能を最大限引き出すためのソフトウェア最適化の方向性を示しており、推論エンジン開発の新たな標準となり得る可能性があります。
編集コメント
ハードウェアの性能向上だけでなく、ソフトウェア側のアーキテクチャ最適化が推論速度に与える影響を浮き彫りにした興味深い事例です。
Moondream Engineering
Photon、Moondream の推論エンジンにより、ほぼリアルタイムの VLM 推論(NVIDIA B200 で約 33ms)が実現されています。これは、GPU の動作を最適化することで最大 35% のデコードスループット向上を実現する仕組みへの一瞥です。
2026 年 6 月 4 日
AI モデルを可能な限り高速に実行するにはどうすればよいのか?これが私たちが Moondream HQ で執拗に追求している問いです。GPU はモデル推論に関わるすべての計算を担当するため、一見するとそれほど複雑なものではないように思えます:ただ指示を出して答えを待つだけです。しかし、内部の仕組みを詳しく見てみると、GPU が作業不足ではなく、CPU が次に何をすべきかをまだ指示していないためにアイドル状態になっていることがわかります。この現象はGPU バブルと呼ばれます。
一般的な AI モデルがテキストを生成する際、一度に 1 つのトークン(数文字程度のテキストの断片)を出力します。各トークンはその前のトークンに依存しており、これを*自己回帰的*な性質と呼びます。そのため、生成は逐次的に行われます。2 つ目のトークンが完成する前に 3 つ目を計算することはできません。このデコードループには CPU と GPU の間の往復通信が含まれます。GPU は実際のモデルを実行し、次のトークンを生成するために数十億回の算術演算を行うなど、主要な処理を担当します。しかし、CPU も驚くほど多くの作業を行っています。次に実行するリクエストの選択、GPU が必要とするメタデータのセットアップ、モデル出力から実際にトークンを選択して記録するなどです。
課題は、1 トークンの GPU 処理量は *小さい* のに対し、CPU の家計管理(housekeeping)は毎回発生する固定コストである点にあります。GPU が次のトークンを開始する前にこの家計管理を待たなければならない場合、ループのたびに一部の間、アイドル状態になってしまいます。これが GPU バブルが発生する理由です。
本稿では、Photon が *パイプラインデコーディング*(pipelined decoding)と呼ばれる技術を用いてこれらのバブルをどのように隠蔽するかについて詳しく掘り下げていきます。その考え方は、2 種類の処理を重畳させることにあります:CPU が最後のトークンの処理を終えている間に、次のトークンに対する GPU 処理を開始します。
バブルの正体
問題の形状は以下の通りです。
ブロッキング版(上図)では、すべてのステップがバトンの受け渡しと同じです。CPU が計画を立ててフォワード(forward)を実行し、GPU がそれを実行します。その後、CPU は *同期* して結果の到着を待ち、コミットを行い、初めて次のステップの計画を開始します。これは、計画が選択したトークンに依存するためです。例えば、モデルが回答完了を示した場合、キューから新しい保留中のリクエストをスケジュールする必要があります。GPU は CPU がコミット・計画・実行の作業を終えるのを待ってアイドル状態になります。
解決策はループをパイプライン化することです。現在のステップのトークンが戻ってきてコミットされている間に、次のフォワードを実行します。これがパイプライン版(下図)です:フォワード処理が連続して実行され、その下に CPU の作業が重畳されます。
私たちがこれを実現できる理由は、サンプリングしたトークンが GPU から離れる必要がないからです。次のフォワードパスでは、そのトークンをそのまま GPU メモリから入力として読み取ります。最終的には CPU 上にコピーを残す必要がありますが、これはデトクナイズ(逆変換)やストリーミング、リクエスト完了の判定を行うためです。しかし、これらは後でバックグラウンドで行える事務処理であり、次のフォワードパスは待たずに実行できます。このコピーを待たないことが、バブル(待ち時間)を解消する鍵となります。
これを安全に行うには三つの要素が必要です。これは本稿の後半で解説します:ステップバッファの衝突を防ぐこと(ピンポンスロット)、制約付きデコーディングにおけるサンプリング順序の正しさ(フォワードを先に行い、サンプリングは後で行う)、リクエスト完了後のクリーンアップ(ゾンビ処理)です。
メカニズム 1: ピンポンスロット
デコードステップを実行するには、GPU が作業セットとしてバッファ群を必要とします。入力(最後に生成されたトークンとそのシーケンス内の位置)を一時的に格納する場所、モデルが出力(語彙の各単語ごとのスコアである*ロジット*)を書き込む場所、サンプリングされたトークンを格納する場所、そしてアテンションカーネルが各シーケンスのキャッシュ済みキーと値(KV キャッシュ)を見つけるために必要な事務処理用データです。両端には*pinned*(ページロック)されたホストバッファを保持し、GPU への転送およびからの転送をバックグラウンドの DMA(直接メモリアクセス)転送として実行することで、CPU のブロックを防ぎます。
これらのバッファは一度割り当てられ、すべてのステップで再利用されます。ランタイムでの GPU メモリ割り当てを避けるために努力しており、これはデバイス同期を引き起こし、バブル(待ち時間)を導入する可能性があるためです。また、デコードステップを一度キャプチャして CUDA graph として再生し、カーネル起動のオーバーヘッドを削減するために、固定されたバッファアドレスが必要です。このバンドルを DecodeSlot と呼びます。
これは機能しますが、パイプライン化に対するブロッカーを導入します。バッファはステップが完了するまで使用されたままになるため、現在のステップが終了するまで次のステップを開始できません。2 つのステップをオーバーラップさせるには、2 番目のステップに独自のワーキングセットが必要であり、そうでなければ CPU がそれらを読み取る前に最初のステップの結果を上書きしてしまう可能性があります。そのため、2 つのスロットを保持し、ピンポン方式で交互に切り替えます。
起動に関する注意点として、CPU から起動コマンドを発行した瞬間にカーネルを実行するわけではありません。代わりに、それらを *ストリーム*(GPU が順序通りに処理する順序付きキュー)にエンキューします。同じストリーム上の作業は逐次実行されますが、異なるストリームの作業は重なり合って実行できます。両方のスロットのフォワード計算は同じ計算ストリーム上に配置されます。スロット自体は GPU の並列化のためのものではありません。CPU が一方のスロットの結果を処理している間に、GPU が他方スロットのフォワード計算を実行できるようにするために存在しています。
すべてのフォワード計算は同じ計算ストリームを共有しますが、コピー操作はそうではありません。各ステップごとのデバイスからホストへのコピー(サンプリングされたトークンを記録のために戻すもの)は、*別々の* コピーストリーム上で行われるため、GPU が次のフォワード計算で忙しい間も並行して実行できます。これにより、待機する必要がなくなります。コピー操作は、ステップの出力が書き込まれた瞬間に記録されるイベントに紐付けられており、そのステップの作業のみを待ち、その後にキューされた作業は一切待ちません。
スロットは、GPU が処理を終了しただけでは解放されず、結果が読み込まれて初めて利用可能になります。そのスロットに割り当てられた固定ホストバッファは、まだ転送中のコピーの着地点となるため、スロットを新しいステップに早すぎると割り当てると、転送途中のコピーを上書きして、デバッグが困難なデータ破損バグを引き起こします。そのため、スロットはその結果を読み込むコミット処理を通じて予約され続け、そのコミット処理が完了するまで解放されません。
メカニズム 2:フォワード計算を先に行い、サンプリングは後で行う
次のフォワードは、CPU が最後のトークンに対して行う処理に依存しないため、先行して実行できます。ただし、次のステップに関する 2 つの事柄は、直前のステップで確定した結果に依存します。1 つ目はバッチ内にまだ残っているシーケンスです:リクエストが完了した場合、それは次のフォワードには含めるべきではありません。これが次のセクション(ゾンビ)の内容です。もう 1 つは、次のステップで実際にサンプリングが許可されるトークンであり、これが今回のセクションの主題です。
これは制約付きデコードに由来します。Moondream の空間処理能力は、自由なテキストではなく構造化された出力を返します:point は座標を返し、detect はボックスを返し、segment は輪郭を返します。これらは、モデルが各ステップで生成できるトークンを制限することで、同じデコードループから得ています:許可されないトークンのスコア(ロジット)はサンプリング前に負の無限大に強制し、サンプリングを行います。point ステップでは座標を出力する必要があり、detect リクエストでは x, y, size のサイクルをたどり、というように、どのトークンが許可されるか(マスク)は、これまでに生成された内容に依存するため、ステップ t+1 におけるマスクは、t でサンプリングしたトークンに依存します。
この依存関係はフォワードではなく、サンプリングにあります。
各スケジューラティック(スロット)は 3 つのフェーズを経ます:起動、確定、完了です:
- t+1 のフォワードを起動します。マスクに依存しないため、即座に実行されます。
- ステップ t を確定します:進行中のコピーを待機し、リクエストのデコード状態を進めます。これは t+1 に対するマスクを決定するために必要です。
- t+1 のサンプリングを完了する:現在の状態を用いてマスクを構築し、サンプリングを実行します。
サンプリングは *t* のコミット後に *t+1* が実行されるためです。なぜなら、コミットこそが *t+1* 用のマスクを正しくするためだからです。これを「最終化前のコミット」順序と呼びます。GPU は *t+1* をステップ 2 と 3 に沿って実行するため、コミットはクリティカルパスから外れます。
プレーンテキストの場合、マスクは存在しないため、フォワード処理とサンプリングの両方を 1 ステップ先行して実行できます。制約付きシーケンスの場合でもフォワード処理は先行しますが、サンプリングは直前のコミットを待機するため、特別なケース分けなしにどれほど先行できるかは制限されます。一つのループで両方のケースを処理します。
メカニズム 3:ゾンビ:早期に最終化し、遅く解放する
「フォワードを今実行し、サンプリングは後で行う」のセクションで、次のステップが直前のステップのコミット結果に依存する 2 つの方法を指摘しました。1 つ目はサンプリングマスクでした。2 つ目はバッチメンバーシップであり、これを正しく処理するには少し注意が必要です。
ステップ *t+1* を起動するには、まずそのバッチ(どのシーケンスが含まれるか)を決定する必要があり、これはステップ *t* のコミット前に行われます。では、シーケンスが *t* でストップトークンに到達したにもかかわらず、すでに *t+1* のフォワード処理に含まれてしまった場合どうなるでしょうか?GPU での作業を中止することはできません。そのシーケンスは完了しているのに、実行中のバッチ内に物理的に存在し続けます。
Photon はこれをゾンビと呼び、キャンセルロジックを追加するのではなく、2 つのシーケンスごとのフィールドからこの振る舞いが自然に生じるようにしています:
- finalized: シーケンスが EOS(End of Sequence)または長さ制限に到達した後に True となる。
- inflight_refs: このシーケンスをまだ参照している進行中のステップ数(0、1、または 2)。
ステップ *t* がコミットされ EOS を検出すると、そのシーケンスは完了済みとしてマークされ結果が出力されます。しかし、inflight_refs がまだゼロではないため(ステップ *t+1* がこれを参照している)、破棄は行われません。ステップ *t+1* のコミット時には、そのシーケンスはすでに完了済みの状態にあるため、コミット処理はスキップされます:トークンは追加されず、状態も変更されません。このゾンビは害なく同行しただけで、自分のスロットを占有し、誰も読まない KV を書き込むだけでした。inflight_refs がついに 0 に達した時点で初めて、その KV ページと LoRA スロットが解放されます。
この「早期完了・遅延解放」というダンスは、本来なら「進行中の行をキャンセルする」といった特殊ケースの複雑な網の目を置き換える、ごくわずかな参照カウント処理です。
Prefill も同じパイプラインを利用
これまではデコードステップに関する話でしたが、実際のサービングループでは常に 2 つの異なる種類の作業を同時に行っています。1 つはprefill(新しいリクエストのプロンプトと画像を処理する、多数のトークンにわたる高価なワンショット順方向計算)であり、もう 1 つはdecode(すでに実行中のすべてのユーザーに対して 1 トークンずつ処理を行うこと)です。
Photon はそれらを分離しません。prefill は、同じ 2 スロットのパイプラインにおける別の kind="prefill" の起動に過ぎません。パイプラインが気にするのはスロットが空いているかどうかだけであり、最後にそのスロットで使用された作業の種類は問わないため、一方のスロットからのデコードステップがまだコミットされている間、他方のスロットに prefill フォワードを起動でき、その逆も可能です。高コストな prefill フォワードは GPU で実行される一方で、CPU はデコード結果のコミットを行います。次のデコードフォワードは、直近で prefill されたリクエストの受付を CPU が完了している間に実行されます。同じコミットの順序付け(および同じ inflight_refs のブックキーピング)が両方の種類の間で正しさを保つため、「飛行中の prefill はどうなるか」という特殊なケースに対してゾンビや制約付きデコードロジックに特別な処理は必要ありません。
これは出力が短い場合に最も重要です。3 つのトークンを生成するリクエストは、その寿命のほとんどを prefill と受付で過ごし、デコードでは過ごしません。したがって、多くの短縮リクエストによるワークロードは、わずかなデコードが散りばめられた prefill のストリームにほかなりません。1 つのパイプラインを共有することで、このストリームが自身の CPU ブックキーピングと並列実行でき、prefill をデコードの背後で直列化し、再び逆方向へという非効率を防ぐことができます。
バブルのコストモデル
パイプライン処理は実際にどれほどの利益をもたらすのでしょうか?デコードステップの構成要素から予測を立て、その予測を実測値と比較することで確認できます。
デコードステップは 3 つの作業部分で構成されます:
- フォワード:重い GPU の行列積演算。デコード時にはメモリ帯域幅にボトルネックが生じます:各トークンがコアを通じて重みセット全体をストリーミングするため、重みバイト数 / メモリ帯域幅に近い下限値が存在します。これはメモリが高速化したりモデルが小さくなったりすると縮小します。
- サンプリング:スコアを実際のトークンに変換する処理です。制約付きデコードマスク、argmax/サンプリング、空間的(グラウンディング)デコード、そして結果のデバイスからホストへのコピーが含まれます。これらはすべて GPU 上の作業です。
- 事務処理:その周囲を担う CPU の役割です。次のバッチを選択(計画)、グラフを開始(起動)、前ステップを確定(コミット)します。
ブロッキングループではこれら 3 つが順次実行されるため、GPU は事務処理の間アイドル状態になります—これが「バブル」です。パイプライン化により、あるステップの事務処理を次のステップの*フォワード+サンプリング*の下にスライドさせることで、周期はフォワード+サンプリングに近づき、バブルは消滅します。ステップごとの測定では、まさにこの通りで—GPU はほぼ全周期稼働しています(定常状態の中央値、moondream2、ms):
forward (ms) sampling (ms) period (ms)
3090 · 1 ストリーム 4.87 0.20 5.10
8 ストリーム 6.66 0.27 6.97
32 ストリーム 10.24 0.26 10.52
B200 · 1 ストリーム 2.45 0.14 2.63
8 ストリーム 3.12 0.14 3.30
32 ストリーム 3.80 0.14 3.98
forward + sampling ≈ period; 残りの GPU アイドル時間は 0.05 ms 未満です。では、それを隠していたものは何だったのでしょうか?それは二つの要素の綱引きに帰着します—どれだけ多くのステップを隠し込めるかという点と、先回りして実行することによるわずかなペナルティとの間の競合です。
speedup = T_block / T_pipe × (1 − z)
└─ bubble hidden ─┘ └─ zombie tax ─┘
2 つの記号、2 つの概念。最初の項は勝利であり、これが GPU の高速化の物語全体です:ステップがブロックされる時間(T_block)をパイプライン処理される時間(T_pipe)で割ったもの、つまり、裏方の事務処理が隠れた後、そのステップがどれだけ速く実行されるかを示します。
2 番目の z は、先回りして実行することの代償です。これはメカニズム 3 における「ゾンビ税」です。t をコミットする前に t+1 の起動ステップを実行すると、直ちに完了したシーケンスでも前方計算(フォワード)が飛行中(実行中)のままになります:無駄なステップです。単一のストリームでは、リクエストが生成した L トークンごとに 1 つの無駄なフォワードが発生し、L ≈ 110 の場合約 1% となります。ただし、バッチを構成すれば、それはほぼ消滅します。ゾンビはすでに重みをストリーミングするために完全な価格を支払っているステップにおける単なる 1 行に過ぎないため、ほとんど無料で同行できるからです。この税は単一ストリームで最も強く効き、スループットが存在する場所で正確に薄れていきます。そのため、これを予測するには L とバッチサイズの両方が必要です。
これがそのステップの測定結果です。ブロック方式では、CPU が最後のトークンをコミットして再起動する間に各ステップがアイドル状態になります。一方、パイプライン処理では、その作業(および非同期マスクのアップロード)を前方計算の下で実行するため、前方計算は決して止まりません:
実際に数値を当てはめてみましょう。各要素(2 つのステップ時間と L)を個別に測定し、モデルの予測がベンチマークの実績値(深さ 1 のブロッキング対深さ 2 のパイプライン化、他は一切変更なし)に合致するはずです:
blocking (ms) pipelined (ms) L predicted observed
3090 · 1 stream 5.44 5.10 104 +5.7% +6.5%
8 streams 7.52 6.97 113 +7.6% +7.8%
32 streams 11.74 10.52 113 +11.1% +11.6%
B200 · 1 stream 3.11 2.63 115 +17.2% +17.6%
8 streams 4.04 3.30 115 +22.2% +21.9%
32 streams 5.55 3.98 104 +39.1% +35.4%
ここから読み取れる 3 つのポイントがあります:
- GPU の速度が上がるほど、その恩恵も大きくなります。同じワークロードでも、3090 では +12% ですが、B200 では 32 ストリームの条件下で +35% です。オーバーヘッド(bookkeeping)は GPU の速度に依存しないため、フォワードパスが短縮される(メモリが高速化されるか、モデルが小さくなる)ほど、バブル(GPU がアイドルになる時間)がステップ全体の占める割合が大きくなります。パイプライン化は、GPU がより高速化する未来に対する保険であり、私たちが目指す「モデルの小型化」と同じ意味を持ちます。
- ゾービ税は実在しますが小さく、償却されます。ストリーム 1 つの場合、ゾンビーは完全に無駄なフォワード処理となり、L≈110 で約 1% のロスになります。バッチ処理では、メモリ帯域が重み付けに依存するステップにおいて行カウントには依存しないため、追加の 1 行分のコストもほぼゼロです:32 ストリームの条件下で、RTX 3090 が観測した +11.6% の増加は、ゾンビーなしのケースとのステップ比と完全に一致します。この税負担は単一ストリームで効き、スループットが最大化される領域では消滅します。(B200 の 32 ストリームにおける行数は、より地味な理由により予測値を数ポイント下回っています:1 ステップあたり約 4 ミリ秒という速度では、全体の処理時間が半秒未満となり、プリフィルと実行終了時のバッチ減速が壁時間に対して目立つ割合を占めるためです。)
- これはバブルが実際に隠蔽可能になった時点で初めて利益を生みます。(実はこれがバグを発見したきっかけでもあります:パイプライン化された数値がブロッキング速度で出力され、制約付きデコードマスク構築中に偶発的な同期コピーが行われていたことが原因でした。これをコピーストリームへ移動させたことで、3090 では +11%、B200 では +34% の向上を実現しました。)
決して単一の要因ではない
これが全体としての技術です:ポーンポン方式のスロットにより 2 つのステップが衝突しないようにし、フォワード処理とサンプリングを分離することで制約付きデコードでさえ先行実行可能とし、わずかなゾンビー参照カウント管理によって完了したリクエストがきれいにテardown(終了)できるようにしています。GPU は CPU の待機から解放され、数%から 3 分の 1 に相当する性能回復を得られます。アクセラレータやモデルの速度が速いほど、その効果は大きくなります。
しかし、Photon が高速なのは、この一つの技術や単一の技術のおかげではありません。高速なのは、提供スタック全体にわたって数十のこうした詳細が積み重なるからです:入力時に画像をリサイズしてタイル化する仕組み、モデルを実行するカーネル、ここで採用されるスケジューラの順序、ホットパスから削除した同期ポイントなど。どの一つの要素だけが全てを語るわけではありません。これらの多くが揃ったときに、スタック全体が高速になります。
私たちは、スタックの一角ずつを順に解説し続けていきます。次の記事を見逃さないよう、Twitter でフォローしてください。また、まもなく登場する Photon 2.0 にもご注目ください:詳細はまだお伝えできませんが、これは大きなアップデートです。
原文を表示
Moondream Engineering
Photon, Moondream's inference engine, achieves near-realtime VLM inference (~33ms on NVIDIA B200). This is a peek into how it delivers up to 35% higher decode throughput by optimizing how the GPU works.
June 4, 2026
How do you make an AI model run as fast as possible? This is a question we obsess over at
Moondream HQ. The GPU handles all the math involved in model inference, so at first glance it
doesn't seem like there's much to it: just tell it what to do and wait for the answer. But if
you start looking at how it actually works under the hood, you find that the GPU often sits
idle, not for lack of work, but because the CPU hasn't told it what to do next yet. This
phenomenon is called a GPU bubble.
When a typical AI model generates text, it produces one token at a time (a token is a
chunk of text, roughly a few characters). Each token depends on the tokens before it, a
property called *autoregressive*, so generation is sequential. You can't compute the third
token before you have the second. This decode loop involves a round trip between the CPU and
GPU. The GPU does most of the heavy lifting to run the actual model, performing billions of
arithmetic operations to produce the next token. But there's also a surprising amount of work
done by the CPU. It selects which requests to run next, sets up the metadata the GPU needs for
them, picks the actual token out of the model's output and records it, and more.
The challenge is that one token's worth of GPU work is *small*, while the CPU housekeeping is a
fixed cost paid on every trip. If the GPU has to wait for that housekeeping before it can start
the next token, it sits idle for part of every loop. This is why we get GPU bubbles.
In this post we're going to dive into how Photon hides these bubbles using a
technique called *pipelined decoding*. The idea is to overlap the two kinds of work: we start
GPU work on the next token while the CPU is still finishing the last one.
The bubble
Here's the shape of the problem.
In the blocking version (top), every step is a baton pass. The CPU plans and launches a
forward, the GPU runs it, then the CPU *synchronizes*, waits for the results to land,
commits them, and only then starts planning the next step. This is because the plan depends
on the token we select. For example, if the model indicates it has finished answering,
then we need to schedule a new pending request from our queue. The GPU sits idle waiting
for the CPU to finish its commit-plan-launch work.
The fix is to pipeline the loop. Launch the next forward
while the current step's token is still coming back and being committed. That's the
pipelined version (bottom): the forwards run back-to-back, and the CPU work is overlapped
underneath them.
The reason we can is that the token we just sampled doesn't have to leave the GPU. The next
forward reads it straight from GPU memory as its input. We still want a copy on the CPU
eventually, to detokenize it, stream it, and decide whether the request is done, but that is
bookkeeping we can do a moment later, in the background, while the next forward already runs.
Not waiting on that copy is the move that removes the bubble.
Making it safe requires three things, that we cover in the rest of this post: keeping step
buffers from colliding (ping-pong slots), getting the sampling order right for constrained decoding
(forward now, sample later), and cleaning up after a request finishes (zombies).
Mechanism 1: ping-pong slots
To run a decode step, the GPU needs a working set of buffers: a place to stage the input (the
last generated token and its position in the sequence), a place for the model to write its
output (the *logits*, one score per word in the vocabulary), a place to land the sampled token,
and some bookkeeping the attention kernel needs to find each sequence's cached keys and values
(its KV cache). We keep *pinned* (page-locked) host buffers on both ends, so the copies on and
off the GPU run as background DMA (direct memory access) transfers instead of blocking the CPU.
These buffers are allocated once and reused on every step. We work hard to avoid performing
GPU memory allocations at runtime, because they can cause device synchronization and introduce
bubbles. Fixed buffer addresses are also needed for capturing the decode step once as a
CUDA graph and replaying it,
reducing kernel launch overhead. We call this bundle a DecodeSlot.
This works, but introduces a blocker for pipelining. The buffers stay in use until the step is
done, so we cannot start the next step until the current one finishes. To overlap two steps,
the second step needs its own working set, otherwise it can overwrite the results of the first
step before the CPU has read them. So we keep two slots and alternate between them, ping-pong
style.
One thing to note about launch: we don't execute kernels the instant we issue a launch from CPU.
Instead, we enqueue them onto a *stream* -- an ordered queue that the GPU drains in order. Work
on the same stream runs sequentially, while work on separate streams can overlap. Both slots put
their forwards onto the same compute stream. The slots are not for GPU parallelism. They only
exist so the CPU can process one slot's results while the GPU runs the other slot's forward.
The forwards all share that one compute stream, but the copies do not. Each step's
device-to-host copy, the one that brings the sampled token back for bookkeeping, goes on a
*separate* copy stream, so it can run while the GPU is busy with the next forward. That is what
lets us not wait for it. We anchor the copy to an event recorded the instant the step's outputs
are written, so it waits on exactly that step's work and nothing queued behind it.
A slot only becomes free once its results have been read, not just once the GPU is done with it.
Its pinned host buffer is the landing site for a copy that may still be in flight, so handing
the slot to a new step too early would overwrite a copy mid-transfer, creating a hard-to-debug
corruption bug. So the slot stays reserved through the commit that reads it, and is released
only once that commit has finished.
Mechanism 2: forward now, sample later
The next forward can run ahead because it doesn't depend on anything the CPU does with the last
token. But two things about the *next* step do depend on the last step's committed result. One
is which sequences are still in the batch: if a request just finished, it shouldn't be in the
next forward. That is the next section (zombies). The other is what tokens the next step is even
allowed to sample, and that one is this section.
It comes from *constrained decoding*. Moondream's spatial skills return structured output
instead of free text: point returns a coordinate, detect returns boxes, segment returns
an outline. We get those from the same decode loop by restricting which tokens the model may
produce at each step: we force the scores (the *logits*) of the disallowed ones to negative
infinity before we sample. A point step has to emit a coordinate, a detect request walks an
x, y, size cycle, and so on. Which tokens are allowed, the *mask*, depends on what has been
produced so far, so the mask for step *t+1* depends on the token we sampled at *t*.
The dependency is in *sampling*, not in the forward.
Each scheduler tick goes through three phases: launch, commit, and finalize:
- Launch the forward for t+1. It doesn't depend on the mask, so it goes immediately.
- Commit step t: wait on the in-flight copy and advance the request's decode state. That
is needed to decide the mask for t+1.
- Finalize sampling for t+1: with the state current, build the mask and sample.
Sampling *t+1* lands after committing *t* because the commit is what makes *t+1*'s mask correct.
We call this "commit-before-finalize" ordering. The GPU runs the *t+1* forward through steps 2
and 3, so the commit disappears from the critical path.
For plain text there is no mask, so forward and sampling can both run a step ahead. For
constrained sequences the forward still runs ahead, but sampling waits on the previous commit,
which caps how far ahead we get with no special-casing. One loop handles both.
Mechanism 3: zombies: finalize early, release late
Back in *forward now, sample later* we flagged two ways the next step depends on the last
step's committed result. The sampling mask was one. Batch membership is the other, and it
takes a bit of care to handle right.
To launch step *t+1* we first decide its batch, which sequences are in it, and we do that
before committing step *t*. So what happens when a sequence hits its stop token at *t*, but is
already baked into *t+1*'s forward? You can't un-launch GPU work. The sequence is finished, yet
still physically present in a batch that's executing.
Photon calls these zombies, and instead of bolting on cancellation logic, it lets the
behavior emerge from two per-sequence fields:
- finalized: True after the sequence has hit EOS or its length cap.
- inflight_refs: the number of in-flight steps that still reference this sequence (0, 1, or 2).
When step *t* commits and detects EOS, the sequence is marked finalized and its result is
emitted — but it isn't torn down, because inflight_refs is still nonzero (step *t+1*
references it). At step *t+1*'s commit, the sequence is already finalized, so the commit
is skipped: no token is appended, no state mutates. The zombie was harmlessly along for
the ride — it occupied its slot and wrote some KV that nobody will read. Only when
inflight_refs finally hits 0 are its KV pages and LoRA slot released.
This finalize-early, release-late dance is a small amount of refcounting that replaces what
would otherwise be a thicket of "cancel this row mid-flight" special cases.
Prefill rides the same pipeline
So far this has all been about decode steps, but a real serving loop is constantly doing two
*different* kinds of work: prefill (processing a new request's prompt + image, the
expensive one-shot forward over many tokens) and decode (one token at a time for everyone
already running).
Photon doesn't separate them. A prefill is just another kind="prefill" launch in the
*same* two-slot pipeline. Because the pipeline only cares that a slot is free, not what kind
of work last used it, a prefill forward can be launched into one slot while a decode step
from the other slot is still being committed, and vice versa. The expensive prefill forward
runs on the GPU while the CPU commits decode results; the next decode forward runs while the
CPU finishes admitting the just-prefilled request. The same commit ordering (and the same
inflight_refs bookkeeping) keeps everything correct across the two kinds, so none of the
zombie or constrained-decode logic needs a special case for "what if a prefill is in flight."
This matters most when outputs are short. A request that emits three tokens spends
almost all of its life in prefill and admission, not decode, so a workload of many short
requests is really a stream of prefills with a little decode sprinkled in. Sharing one
pipeline is what lets that stream overlap its own CPU bookkeeping instead of serializing
prefill behind decode and back again.
A cost model for the bubble
How much should pipelining actually buy you? You can predict it from the parts of a decode
step, and then check the prediction against measurement.
A decode step is three pieces of work:
- forward: the heavy GPU matmuls. At decode this is memory-bandwidth bound: every token
streams the whole weight set through the cores, so it has a floor near
weight_bytes / memory_bandwidth. It shrinks as memory gets faster or as the model gets smaller.
- sampling: turning the scores into a committed token: the constrained-decode mask, the
argmax/sample, the spatial (grounding) decode, and the device→host copy of the result. All
GPU work.
- bookkeeping: the CPU around it. Choose the next batch (plan), launch the graph
(launch), commit the previous step (commit).
A blocking loop runs the three in series, so the GPU sits idle through the bookkeeping — that
idle is the bubble. Pipelining slides the bookkeeping of one step underneath the *forward +
sampling* of the next, so the period collapses toward forward + sampling and the bubble
disappears. Measured per step, pipelined, that's exactly what we see — the GPU is busy for
essentially the whole period (steady-state medians, moondream2, ms):
forward (ms)sampling (ms)period (ms)
3090 · 1 stream4.870.205.10
8 streams6.660.276.97
32 streams10.240.2610.52
B200 · 1 stream2.450.142.63
8 streams3.120.143.30
32 streams3.800.143.98
forward + sampling ≈ period; the leftover GPU idle is under 0.05 ms. So what was hiding it
worth? It comes down to a tug-of-war between two things — how much of a step you manage to tuck
away, against a small penalty for running ahead:
speedup = T_block / T_pipe × (1 − z)
└─ bubble hidden ─┘ └─ zombie tax ─┘
Two symbols, two ideas. The first term is the win, and it's the whole GPU-speed story: how long
a step takes blocking (T_block) over how long it takes pipelined (T_pipe) — i.e. how much
faster the step runs once the bookkeeping is tucked underneath it.
The second, z, is the price of running ahead — the zombie tax from Mechanism 3. Launch step
*t+1* before committing *t*, and a sequence that just finished still has a forward in flight: a
wasted step. On a single stream that's one wasted forward for every L tokens the request
generated, so about 1% at L ≈ 110. Pack a batch, though, and it nearly vanishes — the zombie is
just one more row in a step that's already paying full price to stream the weights, so it rides
along almost free. The tax bites hardest at one stream and fades exactly where throughput lives,
which is why predicting it needs both L and the batch size.
Here's that step, measured both ways — blocking idles each step while the CPU commits the last
token and re-launches; pipelining runs that work (and the async mask upload) underneath the
forward, so the forwards never stop:
Now put real numbers in it. Measure each piece on its own — the two step times and L — and the
model's prediction should land on what the benchmark actually delivers (depth-1 blocking vs
depth-2 pipelined, nothing else changed):
blocking (ms)pipelined (ms)Lpredictedobserved
3090 · 1 stream5.445.10104+5.7%+6.5%
8 streams7.526.97113+7.6%+7.8%
32 streams11.7410.52113+11.1%+11.6%
B200 · 1 stream3.112.63115+17.2%+17.6%
8 streams4.043.30115+22.2%+21.9%
32 streams5.553.98104+39.1%+35.4%
Three things to read out of it:
- The win grows with GPU speed. Same workload, +12% on a 3090 but +35% on a B200 at 32
streams. The bookkeeping is GPU-speed-independent, so as the forward shrinks — faster memory,
or a smaller model — the bubble is a bigger share of the step. Pipelining is insurance against
the GPU getting faster, which for us is the same thing as the model getting smaller.
- The zombie tax is real but small, and it amortizes. At one stream the zombie is a whole
wasted forward — about 1% at L≈110. At batch it's one extra row in a step that's
memory-bound on the weights, not the row count, so it costs almost nothing: at 32 streams the
3090's observed +11.6% lands right on the no-zombie per-step ratio. The tax bites at a single
stream and fades exactly where throughput lives. (The B200's 32-stream row sits a few points
under prediction for a duller reason — at ~4 ms/step the whole run is under half a second, so
prefill and the end-of-run batch ramp-down are a visible slice of the wall.)
- It only pays once the bubble is actually hideable. (This is how we caught a bug, in fact:
the pipelined numbers came out at blocking speed, traced to an accidental synchronous copy
while building the constrained-decode mask. Moving it to the copy stream was worth +11% on the
3090 and +34% on the B200.)
It's never just one thing
That's the whole technique: ping-pong slots so two steps don't collide, a forward/sampling split
so even constrained decoding can run ahead, and a little zombie refcounting so finished requests
tear down cleanly. The GPU stops waiting on the CPU, and you get back anywhere from
a few percent to a third; more the faster your accelerator/model is.
But Photon isn't fast because of this one technique, or any single technique. It's fast because
dozens of these details compound across the serving stack: how we resize and tile images on the
way in, the kernels that run the model, the scheduler ordering here, and the synchronization
points we remove from the hot path. No one piece is the whole story; the stack gets fast when
enough of them line up.
We'll keep writing these up, one corner of the stack at a time. Follow us on Twitter
so you don't miss the next one. And keep an eye out for Photon 2.0, coming soon: we can't share
details yet, but it's a big one.
関連記事
今日のまとめ
AI日報で今日の重要ニュースをまとめ読み