PagedAttention:KVキャッシュの仮想メモリ技術が推論効率を向上
本文の状態
日本語全文を表示中
詳細モードで約25分の本文を読めます。
同じ出来事の情報源
この情報源を基点に整理
TLDR AI
PagedAttention は、オペレーティングシステムの仮想メモリ技術を LLM の KV キャッシュに応用し、メモリの断片化を解消して GPU あたりの同時処理能力を大幅に向上させる技術である。
AI深層分析を開く2026年8月22日 22:00
AI深層分析
キーポイント
KV キャッシュのメモリ効率問題
従来の推論エンジンではリクエストごとのシーケンス長が事前に不明なため、安全のために最大長分のメモリを確保し、結果として GPU メモリの大部分が断片化して浪費される。
仮想メモリの転用
1960 年代から OS が採用している仮想メモリの概念を KV キャッシュに移植することで、メモリを非連続なページに分割し、断片化を解消する。
推論エンジンの性能向上
この技術により、従来の単純なアプローチと比較して、1 つの GPU で同時に処理できるユーザー数を 2〜4 倍に増やすことが可能になる。
内部断片化と外部断片化の問題
リクエストごとに最大コンテキストサイズを予約する従来の方式では、未使用のメモリが大量に発生し、他のリクエストで利用できない内部断片化が生じる。また、異なるタイミングで完了するリクエストによって生じた非連続な空き領域は、形状が合わないため外部断片化を引き起こす。
仮想メモリによる解決
OS が数十年前に解決したのと同じ問題であり、オペレーティングシステムで採用された仮想メモリの概念を KV キャッシュに適用することで断片化問題を解消する。
重要な引用
PagedAttention is one of those moments. The idea is just virtual memory, the thing operating systems have been doing since the 1960s, transplanted onto the KV cache of a language model.
That's it. That single observation is why a modern inference engine can serve two to four times more users per GPU than the naive approach.
The first is internal fragmentation. A request that only generates 47 tokens still holds onto 2,048 slots of cache.
The second is external fragmentation. Requests show up and leave at different times.
編集コメントを表示
編集コメント
この技術は、OS の古典的な概念を現代の AI インフラに応用した秀逸な例であり、実装コストを抑えながら劇的な性能向上をもたらす。推論システムの設計において、メモリ管理戦略を見直す際の重要な指針となるだろう。
Source Article
元記事を日本語で読む
本文に関係しない購読案内、埋め込み通知、サイト内プロモーションは除いています。
Arc 6: 推論エンジンとサービングシステム
目次 (9 件中 3)
- 断片化されたメモリと GPU の無駄
- これは単なる仮想メモリの話です
- PagedAttention を一枚の図で理解する
- 小さな具体例
- コピー・オン・ライト:2 つのリクエストが共通接頭辞を共有する場合
- アテンションカーネルがなぜ機能するのか
- スケジューラーの概要
- よくある誤解
- 今後の展望
- さらに読む・見る (追加資料)
私は頻繁に考えることがあります。ごく稀に、コンピュータサイエンスの異なる分野から生まれたアイデアが、まるで部屋を横切って別の分野に歩み寄り、肩を叩くようにして現れ、すべてを変えてしまう瞬間があります。PagedAttention はまさにそんな瞬間の一つです。
そのアイデアの本質は極めてシンプルです。1960 年代からオペレーティングシステムが実装してきた「仮想メモリ」という概念を、言語モデルの KV キャッシュ(Key-Value Cache)へと移植しただけのことです。これだけです。この単一の洞察こそが、現代の推論エンジンが、単純なアプローチに比べて GPU あたりで 2〜4 倍ものユーザーを捌けるようになった理由なのです。
この記事は、その交差点について語るものです。なぜデフォルトで KV キャッシュがこれほど大量のメモリを消費するのかという直観的な理解を得ること、なぜ仮想メモリという概念がまさに適切な比喩となるのか、そして PagedAttention がアテンションカーネルが依然として機能する形でこのアイデアを実装しているのかについて解説します。
さらに進む前に、もし直前の KV キャッシュの基礎から の記事を読んだばかりでなければ、簡単な復習をしておきましょう。KV キャッシュとは、リクエストごとに保存されるアテンションのキーと値です。これにより、モデルはデコーディングの各ステップで再計算する必要がなくなります。しかし、このキャッシュはシーケンス長に比例して成長し、長いコンテキストではモデル重みそのものよりも多くの GPU メモリを消費します。つまり、これが限られた資源なのです。本シリーズにおけるサービングシステムのあらゆる革新は、いずれもこの一つの資源をより良く管理することにかかっています。
断片化されたメモリと無駄になる GPU#
LLM をサービス提供する場合、各リクエストがどの程度の長さになるかを事前に知ることはできません。ユーザー A は 25 トークンを生成するかもしれませんが、ユーザー B は 2,000 トークンに及ぶかもしれません。サービス側は「1 リクエストあたり最大 2,048 トークンをサポートします」と約束していても、どのユーザーがその上限まで使うかはわかりません。そこで、素朴なサービングシステムでは当然と思われる対応を行います。つまり、アクティブな各リクエストに対して max_context 分の KV キャッシュスロットを事前に確保するのです。モデルが 70B でコンテキスト長が 32k トークンの場合、1 リクエストあたりギガバイト単位の GPU メモリが事前確保され、その大半は未使用のままになります。
ここでは二つの問題が発生し、互いに悪影響を及ぼし合います。
まず、内部断片化の問題があります。47 トークンしか生成しないリクエストであっても、2,048 スロットのキャッシュを確保したままになります。その間、実質的に 2,001 スロットが空いたままです。これが同時に 30 件のリクエストで発生すると、合計しておそらく 1,000〜2,000 トークン分しか使っていないのに、60,000 トークン分のスロットを予約することになります。これは他のリクエストに譲渡できない無駄なメモリです。すでに特定の用途に割り当てられてしまっているからです。
次に外部断片化の問題があります。リクエストは異なるタイミングで発生し、終了します。リクエスト A が完了すると、その連続した 2,048 スロットの領域が解放されます。しかし、もし新たに 2,048 スロットを必要とするリクエストが現れ、利用可能な空き領域が「A から生じた 1,500 スロットの穴」と「他の誰かから生じた 800 スロットの穴」しかない場合、これらをまとめて使うことはできません。空きメモリの総量は正しいサイズですが、形状が合っていないのです。断片化を解消するためにデータを移動させる(これは高価でシステムに disruptive な処理です)か、使えないメモリを抱えたままにするかの二者択一になります。
2,048 トークンのコンテキスト予算に対して、長さが 25、47、12 の 3 つのリクエストを例にとりましょう。連続割り当てでは、実質的な 84 トークンに対して 6,144 スロットを予約することになります。一方、ページング割り当てではブロック境界に合わせて切り上げられ、96 スロットしか使用しません。この単純な例でも 64 倍もの差が生じますが、これは珍しくもありません。
Kwon らによる vLLM の初期論文では、実際の数値でこの無駄が示されています。本番環境のワークロードにおいて、KV キャッシュの利用率は 20〜40% 程度でした。つまり、クラスター内で最も高価な GPU 上の最も高価なメモリの 60〜80% が、何もしないまま放置されていたことになります。これが PagedAttention が解決しようとした課題です。
これは単なる仮想メモリの話
vLLM の論文を読んで「なるほど!」と気づいた瞬間、その解決しようとしている問題が、実は OS が数十年も前にすでに解決していたのと同じ問題だと知った時でした。
マルチプログラミングの黎明期には、すべてのプロセスが RAM 上の連続した領域を要求しました。OS はそれをどこに配置するかを決める必要がありました。異なるサイズのプロセスが行き来するうちに、空きメモリは使えない断片のジグソーパズルのようになっていきました。その結果、総量の空きメモリが十分にあっても、プログラムを実行できない事態が発生します。
この問題に対する解決策として考案されたのが「仮想メモリ」です。マンチェスター大学の Atlas で先駆的に導入され、1970 年代までに洗練されました。
仮想メモリの仕組みを簡単に説明するとこうなります。各プロセスは連続した仮想アドレス空間を見ているように感じますが、OS はそれを固定サイズのページ(通常は 4 KB)に分割し、物理メモリ上の好きな場所に格納します。各プロセスごとのページテーブルが、仮想ページ番号を物理フレーム番号に変換してマップしています。プロセスが仮想アドレス 0x1000 を読み出すと、CPU は MMU の支援を受けてページテーブルで変換先を検索し、実際にそのデータを保持している散在する物理フレームからデータを読み出します。プロセス側にはページが連続していないことは一切わかりません。OS 側は物理メモリを好きなように詰め込むことができます。
この段落をもう一度読み、"process" を "request" に、「page」を「KV ブロック」に、「physical frame」を「GPU の KV キャッシュプール内の物理ブロック」に、「page table」を「ブロックテーブル」に置き換えてみてください。それが PagedAttention です。これは完全に 1 対 1 の対応関係になっています。
OS virtual memory → PagedAttention
------------------------------ -----------------------------
process → request (one prompt + generation)
page (4 KB) → KV block (e.g. 16 tokens)
page table → block table (logical → physical block idx)
physical frame → physical KV block in the shared pool
page fault / alloc → allocate a new block on demand
copy-on-write → copy-on-write for shared prefixes
私はこれが本記事全体で最も有用な思考モデルだと考えています。「KV キャッシュのための仮想メモリだ」と覚えておけば、仕組みのほとんどをナプキンに書き起こして再構築できます。
PagedAttention を一枚の図で#
仕組みは以下の通りです。KV キャッシュはもはやリクエストごとに巨大な連続配列ではありません。代わりに:
- GPU メモリには、固定サイズの物理ブロックからなる共有プールが用意されています。一般的なブロックサイズは 16 トークンで、各ブロックには 1 つのリクエストにおける連続する 16 位置分の K と V のベクトルを保持できます。ブロックは、リクエストが必要になった際にプールから割り当てられ、不要になるとプールへ返却されます。
- 各リクエストには、L0、L1、L2……という順序で並んだ論理ブロックの列が存在します。最初の 16 トークンは L0 に、次の 16 トークンは L1 に格納され、以下同様に続きます。
- 各リクエストには、論理ブロックインデックスから物理ブロックインデックスへのマッピングを行う小さなブロックテーブルが用意されています。
table[0] = 7は「このリクエストの論理ブロック 0 が、プールの物理ブロック 7 に存在する」ことを意味します。
3 つのリクエストが、共有された物理ブロックプールにマッピングされます。各リクエストのブロックテーブルは非常に小さく(整数を数個程度)、そのリクエストが指し示す物理ブロックはプール内に散在しています。無関係なリクエスト同士も、無関係なプロセスがランダムに割り当てられた RAM を共有するように、散らばった物理メモリを共有します。
アテンション演算を実行する際、カーネルはキーと値が連続しているとは仮定しません。ブロックテーブルを順次走査し、「論理ブロック 0 は物理ブロック 7、論理ブロック 1 は物理ブロック 3……」という対応関係を確認しながら、K と V のベクトルを実際に格納されている場所から収集します。ブロックごとにわずかな間接参照のコストがかかりますが、ブロック自体のサイズは十分大きく(16 トークン × ヒドゥン次元)、この固定コストは多数の演算処理に分散されるため、オーバーヘッドは数%台に抑えられます。
最大の利点は、使用した分だけしか割り当てないことです。割り当て量はブロックサイズに合わせて切り上げられます。47 トークンのリクエストには 3 ブロック(48 スロット)が割り当てられ、1 リクエストあたりの無駄は BLOCK_SIZE - 1 トークン以内に収まります。その結果、システム全体の無駄な領域は最大コンテキスト長に比例して増大するのではなく、小さく一定の値に抑えられます。Kwon らの研究では、本番環境に近いワークロードでキャッシュ利用率が約 96% に達し、従来の 20–40% から大幅に改善されたことが測定されています。
具体的な計算例#
先ほどの比較で挙げた 3 つのリクエストを使って、具体的に説明しましょう。
物理ブロックが 64 ブロックあり、それぞれが KV キャッシュの 16 トークンを保持できると仮定します。ここに 3 つのリクエストが到着しました。 (原文の技術表記: max_context)
- リクエスト A: 25 トークン。
ceil(25 / 16) = 2ブロックが必要となります。スケジューラーはブロック P3 と P7 を割り当てます。A のブロックテーブルは[3, 7]です。最初の 16 トークンは P3 に、次の 9 トークンは P7 に格納されます(P7 には 7 つのトークン分のパディングが含まれます)。
- リクエスト B: 47 トークン。ブロックが 3 つ必要です。P0, P5, P9 が割り当てられます。ブロックテーブルは
[0, 5, 9]です。47 = 16 + 16 + 15 なので、P9 にはパディングとして 1 スロット分が含まれます。
- リクエスト C: トークン数 12。ブロック 1 つが必要で、P4 が割り当てられる。ブロックテーブルは
[4]。パディングに 4 スロットが使用される。
物理的に使用されたスロットの合計は 96(16×2 + 16×3 + 16×1)で、生成されたトークン 84 を保持している。無駄なスロットは 12 で、約 12% に相当する。一方、連続割り当ての場合(max_context = 2,048)では、予約されるスロットが 3 × 2,048 = 6,144 となるのに対し、実際に使用されているのは 84 のみで、無駄は 98.6% に達する。
ページング方式を採用すれば、プールには 64 − 6 = 58 ブロックがまだ空き状態です。これにより、平均的な長さのリクエストをさらに 30 件以上も、ためらうことなく受け入れることができます。一方、連続割り当て方式では max_context = 2,048 の設定下で、さらに 2,048 スロットが空いていない限り、1 つのリクエストすら追加できません。ページング割り当てはメモリそのものを増やすわけではありません。重要なのは、「すべてのリクエストが最悪ケースを必要とする」という前提をやめさせる点にあります。
次に、リクエスト B がさらに 20 トークンを生成して 4 番目のブロックにまたがるケースを考えてみましょう。スケジューラはプールから新しいブロック(例:P11)を 1 つ割り当て、B のブロックテーブルに追加します。その結果、次のステップでのアテンション計算では [0, 5, 9, 11] という配列が使用されます。これは単一のテーブル書き込みであり、データのコピーやデフラグメンテーションは一切発生しません。
おさらいしましょう。デコードステップごとに 1 行ずつ増え続けるのが KV キャッシュです。PagedAttention はキャッシュに格納される中身を変えるのではなく、メモリ上の配置方法だけを変えます。16 トークンごとのチャンクは、プール内の物理ブロック 1 つとして扱われます。
ここで「コピー・オン・ライト(COW)」という OS の仕組みが二重の効果を発揮します。プロセスをフォークする際、OS はメモリ全体を複製するのではなく、親プロセスのページ子を共有し、読み取り専用としてマークします。子プロセスが実際に書き込みを行う瞬間まで、そのページは共有されたままです。誰も変更を加えていなければ、メモリは共有状態が続きます。これは実際にはほとんどのケースに当てはまります。
この仕組みは LLM サービングにもそのまま適用できます。多くのリクエストで共通のプレフィックスが存在します。チャットアプリでは、すべてのユーザーメッセージの前に同じ 800 トークンのシステムプロンプトを付加することがあります。並列サンプリング(同一のプロンプトから複数の続行文を生成)では、最初のサンプリングトークンまでの全トークンを共有します。ビームサーチも同様で、より細かな粒度で、直近の分岐トークン以外すべてが共有されます。もしこれらの共通プレフィックスそれぞれに KV キャッシュのコピーを用意すれば、同じ計算を何度も繰り返すことになり、非効率です。
PagedAttention を採用すると、スケジューラーは複数のブロックテーブルを同じ物理ブロックに指し示すことができます。各物理ブロックには参照カウントが保持されており、この値が 1 より大きい間は、誰一人としてそのブロックに対してインプレースでの書き込みを行うことができません。
あるリクエストが書き込みを必要とした瞬間(例えば、他のリクエストと共有されているブロック内で新しいトークンを生成しようとしている場合)、エンジンはそのブロックをクローンします。元のブロックの参照カウントはデクリメントされ、書き込みを行うリクエスト用のテーブルはクローンされたブロックを指すように更新されます。それ以外のすべてのリクエストは引き続き元のブロックを指し続けます。
この仕組みの直感的な理解は以下の通りです。「共有されるプレフィックスは、誰かが書き込むまで共有されたまま維持される」。システムプロンプトに対して A と B がどちらも P0 と P1 を参照している場合でも、B が新しいトークンを生成する際、書き込み対象となるブロック(P1)のみがクローンされ(P1 → P2)、A のビューは全く変化しません。キャッシュ全体をコピーするのではなく、ページ単位でコピーすればよいのです。
この仕組みの実践的な意義は、共有プレフィックスが実際に共有されており(誰もそこに書き込んでいない限り)、そのコストはそれを利用するすべてのリクエストを通じて正確に 1 回しか発生しないという点です。これはプリフィックスキャッシングの背後にあるメカニズムでもあります。Kwon らの研究では、COW(Copy-On-Write)によるビームサーチや並列サンプリングの解放が示されていますが、本番環境において最も大きな恩恵をもたらすのはシステムプロンプトの処理です。
チャットボットのシステムプロンプトが 2,000 トークンで、同時に 100 ユーザーを捌いている場合、PagedAttention と COW を組み合わせることで、単純なアプローチと比較して約 20 万トークンのキャッシュ量を節約できます。70B モデルを fp16 で動作させる場合、これは数十ギガバイトに相当します。
COW(Copy-On-Write)は、基本的なページングアイデアの改良版です。この機能なしでも、PagedAttention の恩恵の大部分は得られます。ただし、プロンプトの再利用が頻繁なワークロードにおいては、COW が「すでに良好」な状態を「極めて良好」なものに変える鍵となります。
SGLang の RadixAttention はこれをさらに一般化し、トークン接頭辞でインデックス付けされたラジックスツリーにすべてのキャッシュブロックを整理します。これにより、明示的に宣言されていなくても、任意の共有接頭辞を検出して再利用できるようになります。
アテンションカーネルが機能する理由#
論文を読み始めた際、私を悩ませた点がありました。アテンションは密行列演算(dense matrix operation)です。それが非連続メモリ上でも破綻せずに動作し得るのか、という疑問です。
答えは、カーネルレベルのアテンションがすでにキーと値をチャンクごとに順次処理するループ構造になっている点にあります。例えば FlashAttention では、K と V の行列を SRAM に収まるブロックにタイル分割し、反復処理を行います。PagedAttention は、このカーネルが使用するタイルサイズに合わせてブロックサイズを調整しています。各ブロック内ではメモリは連続しており、ブロック間をまたぐ際は、ブロックテーブルから次の物理ブロックアドレスを参照して処理を続行します。カーネル側で行うのは、わずかなポインタ演算の追加だけです。アテンションをゼロから書き直す必要はありません。
間接参照にはコストが伴います。ブロックサイズが小さい場合、ブロックテーブルの参照オーバーヘッドが相対的に大きくなり、メモリアクセスもよりランダムになります。逆にブロックサイズが大きすぎると、内部フラグメンテーションが再び問題となります。実用上の最適解は、多くの現代 GPU とモデルにおいて 1 ブロックあたり約 16 トークンです。これは vLLM、TGI、TensorRT-LLM がすべて合意した値でもあります。
同じ 47 トークンのリクエストを異なるブロックサイズで比較します。ブロックサイズが 4 の場合、12 回の参照が必要ですが無駄になるのは 1 スロットのみです。一方、ブロックサイズが 64 の場合は 1 回の参照で済みますが、17 スロット(26%)が無駄になります。ブロックサイズ 16 はその中間に位置し、3 回の参照と 1 つの無駄なスロットというバランスを実現しています。これがエコシステム全体で合意された最適解です。
スケジューラの概要#
コード上でどのように機能するかをイメージしてもらうため、ページ化 KV スケジューラのおおよその構造を示します。実際のエンジンとして実行可能ではありませんが、構造的には正確なスケッチです。
本物の vLLM スケジューラはより複雑で、ページングポリシー、エビクション(退避)、プリエンプション、GPU カーネルとの統合などを扱いますが、ここでは骨格となる部分のみを捉えています。ブロックテーブルは単なる整数のリストであり、割り当てはフリーリストからのポップ操作に過ぎません。COW(Copy-On-Write)は参照カウントで管理されます。特別な処理が必要な部分はアテンションカーネル内部に限られており、そこでも変更は局所的なものです。
誤解#
「PagedAttention はモデルの出力を変える」という話ですが、実際にはそうではありません。この技術は計算式そのものには一切手を加えません。K と V のテンソルが連続して配置されていようとも、プール内で散らばっていようとも、アテンションが生成するロジット(logits)は全く同じです。変化するのは、カーネルがメモリをどのように取得するかという点だけです。出力トークンやサンプリング確率など、すべての結果はビット単位で完全に一致します(GPU 上の任意の還元演算に影響を与える通常の浮動小数点の不確定性を除けば)。
「ブロックサイズを小さくすれば常に無駄が減る」という考えも一概には言えません。これはトレードオフの問題です。確かにブロックサイズを小さくすると内部断片化は減り、各リクエストの最後のブロックにおけるパディングが少なくて済みます。しかし一方で、ブロックテーブルのサイズが増大し、アテンションカーネル内でブロックごとのオーバーヘッドが発生します。また、メモリアクセスがよりランダムになるため、キャッシュ性能が悪化するリスクもあります。
Kwon 氏らの研究では、幅広いモデルに対して 16 トークンを強力なデフォルト値として見出しており、主要な推論エンジンもこの数値を採用しています。ただし、短いシーケンスが中心のワークロードではより小さなブロックサイズが有利になる場合があり、逆に一様に長いシーケンスを扱う場合は 32 や 64 を好むケースもあります。この数値に特別な魔力があるわけではなく、単に多くのケースでよく調整された結果なのです。
「コピー・オン・ライトは、ビームサーチの最適化に過ぎない」という主張がありますが、論文ではビームサーチが強調されています。しかし実際の運用環境では、共有されるプレフィックス(共通部分)の主要な発生源はシステムプロンプトです。
1 つのプロンプトを多数のユーザーが使い、500〜2000 トークンから始まる同じ内容で多数のリクエストが同時に発生します。この場合、コピー・オン・ライトを採用すれば、キャッシュされたプレフィックスは GPU メモリ上に 1 回だけ保存されます。これがサービスワークロードにおける最大の節約効果をもたらすポイントです。
次のステップ#
次の記事では、単一の割り当て戦略から視野を広げ、GPU メモリの全体予算(重み、KV キャッシュ、活性化値、CUDA オーバーヘッド)と、実際に処理できる同時リクエスト数の算出式について解説します。まずは LLM のメモリ管理 から始めてください。
追加の資料(読み物・動画)#
- Kwon, W., Li, Z., Zhuang, S., Sheng, Y., Zheng, L., Yu, C. H., Gonzalez, J. E., Zhang, H., & Stoica, I. (2023). PagedAttention を用いた大規模言語モデルサービスの効率的なメモリ管理. *SOSP 2023*. PagedAttention を初めて紹介した論文です。私が引用している「キャッシュ利用率が 20〜40% から 96% に向上」「トークンブロックサイズは 16」「スループットが 2〜4 倍」といった数値は、すべてこの論文から得られています。
Kilburn, T., Edwards, D. B. G., Lanigan, M. J., & Sumner, F. H. (1962). One-Level Storage System. *IRE Transactions on Electronic Computers*. ページング方式の仮想メモリを初めて記述したアトラス論文。PagedAttention がその仕組みから借用している源流となる文献です。
Silberschatz, A., Galvin, P. B., & Gagne, G. (2018). *Operating System Concepts*, 10th ed., Chapter 10: Virtual Memory。ページング、ページテーブル、TLB(Translation Lookaside Buffer)、コピーオンライトの標準的な解説が載った教科書です。OS の仕組みを深く理解したい場合は、ここが最も信頼できる情報源となります。
Zheng, L., et al. (2023). SGLang: Efficient Execution of Structured Language Model Programs。arXiv:2312.07104。PagedAttention のコピーオンライトによるプレフィックス共有を、キャッシュされたブロックのラジックスツリーに一般化した RadixAttention を紹介しています。
Dao, T., Fu, D. Y., Ermon, S., Rudra, A., & Ré, C. (2022). FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness。*NeurIPS 2022*。間接アドレス指定を付加して拡張した、ブロックタイル型アテンションカーネルの背景知識となります。
vLLM Project. (2023–2026). vLLM: Easy, Fast, and Cheap LLM Serving with PagedAttention(ブログ発表)およびvLLM documentation。ブロックサイズのデフォルト値、自動プレフィックスキャッシュ機能、本番環境での導入ガイドラインに関する参照資料です。
Hugging Face. (2024). Text Generation Inference (TGI) — PagedAttention adoption. TGI のドキュメントでは、ページ化された KV キャッシュの実装と、従来の連続型キャッシュと比較した性能向上について解説されています。
Yu, G.-I., Jeong, J. S., Kim, G.-W., Kim, S., & Chun, B.-G. (2022). Orca: A Distributed Serving System for Transformer-Based Generative Models. *OSDI 2022*. イテレーションレベルのスケジューリングに関する先行研究であり、vLLM のスケジューラー設計の基礎となりました。
Pope, R., Douglas, S., Chowdhery, A., Devlin, J., Bradbury, J., Levskaya, A., Heek, J., Xiao, K., Agrawal, S., & Dean, J. (2022). Efficiently Scaling Transformer Inference. arXiv:2211.05102. 長文コンテキスト推論において KV キャッシュのメモリがボトルネックとなる背景を説明しており、その管理がいかに重要かを理解する上で役立ちます。
Denning, P. J. (1970). Virtual Memory. *ACM Computing Surveys*. 「ページ」「ページテーブル」「ワーキングセット」「スラッシング」といった用語の語源となった古典的な調査論文です。OS の仕組みについてさらに深く掘り下げたい場合に参照すべき文献です。
原文を表示
Arc 6: Inference Engines & Serving Systems
Contents3 of 9- Fragmented memory, wasted GPU
- This is just virtual memory
- PagedAttention in one picture
- A small worked example
- Copy-on-write: when two requests share a prefix
- Why the attention kernel works at all
- A sketch of the scheduler
- Misconceptions
- What's next
- Additional reading (and watching)
Here's a thing I think about a lot: very occasionally, an idea from one subfield of computer science walks across the room, taps another subfield on the shoulder, and changes everything. PagedAttention is one of those moments. The idea is just virtual memory, the thing operating systems have been doing since the 1960s, transplanted onto the KV cache of a language model. That's it. That single observation is why a modern inference engine can serve two to four times more users per GPU than the naive approach.
This post is about that crossover. I want to build the intuition for why the KV cache wastes so much memory by default, why virtual memory is exactly the right analogy, and how PagedAttention implements the idea in a way that attention kernels can still work with.
A quick refresher before we go further, in case you didn't just come from the KV cache post: the KV cache is the per-request store of attention keys and values. It lets the model avoid recomputing them at every decoding step. It grows linearly with sequence length and at long contexts it eats more GPU memory than the model weights themselves. So it's the scarce resource. Every serving-system innovation in this arc is, in one way or another, about managing this one resource better.
Fragmented memory, wasted GPU#
When you serve an LLM, you don't know in advance how long each request will be. User A might generate 25 tokens. User B might keep going for 2,000. Your service promises to support, say, 2,048 tokens per request, and you have no idea which user will use all of it. So a naive serving system does the obvious thing: reserve max_context slots of KV cache for each active request. If the model is 70B and the context is 32k tokens, that's gigabytes of GPU memory per request, reserved up front, most of it unused.
Two things go wrong here, and they compound.
The first is internal fragmentation. A request that only generates 47 tokens still holds onto 2,048 slots of cache. 2,001 slots sit empty the entire time. Multiply by 30 concurrent requests and you've reserved 60k token slots for what, in aggregate, is maybe 1–2k filled. That's unused memory you can't hand to another request, because it's already spoken for.
The second is external fragmentation. Requests show up and leave at different times. When request A finishes, its contiguous 2,048-slot chunk becomes available. But if you have a new request that also wants 2,048 slots, and the only free regions are a 1,500-slot hole from A and an 800-slot hole from someone else, you can't use them together. The free memory is the right size in total but the wrong shape. Either you defragment (copy stuff around, which is expensive and disruptive) or you sit on unusable memory.
Three requests, lengths 25, 47, and 12 tokens, at a 2,048-token context budget. Contiguous allocation reserves 6,144 slots for 84 real tokens. Paged allocation rounds up to block boundaries and uses 96. That's a 64× difference for this toy, and it's not atypical.
Kwon et al.'s original vLLM paper puts real numbers on the waste. On production workloads they measured KV-cache utilization around 20–40%, which means 60–80% of the most expensive memory on the most expensive GPU in the cluster was sitting there doing nothing. That's the problem PagedAttention set out to fix.
This is just virtual memory#
The moment the vLLM paper clicked for me was when I realized the problem it's solving is *exactly* the same problem operating systems solved decades ago.
In the early days of multiprogramming, every process asked for a contiguous chunk of RAM. The OS had to decide where to put it. Processes of different sizes came and went. Over time the free memory turned into a jigsaw of unusable fragments. Programs couldn't fit even when there was plenty of free RAM in total. The solution, pioneered on the Atlas at Manchester and refined by the 1970s, was virtual memory.
Here's how virtual memory works, boiled down: each process sees a contiguous virtual address space, but the operating system chops it into fixed-size pages (typically 4 KB) and stores them wherever it likes in physical memory. A per-process page table maps virtual page numbers to physical frame numbers. When the process reads virtual address 0x1000, the CPU (with help from the MMU) looks up the translation in the page table and fetches the data from whatever scattered physical frame is actually holding it. The process never knows the pages aren't contiguous. The OS gets to pack physical memory however it likes.
Now read that paragraph again and replace "process" with "request", "page" with "KV block", "physical frame" with "physical block in the GPU KV-cache pool", and "page table" with "block table". That's PagedAttention. The whole thing is a one-to-one correspondence.
OS virtual memory → PagedAttention
------------------------------ -----------------------------
process → request (one prompt + generation)
page (4 KB) → KV block (e.g. 16 tokens)
page table → block table (logical → physical block idx)
physical frame → physical KV block in the shared pool
page fault / alloc → allocate a new block on demand
copy-on-write → copy-on-write for shared prefixes
I think this is the most useful mental model for the entire post. If you remember "it's virtual memory for the KV cache", you can reconstruct most of the mechanics on a napkin.
PagedAttention in one picture#
Here's the machinery. The KV cache is no longer one big contiguous array per request. Instead:
- GPU memory holds a shared pool of fixed-size physical blocks. A typical block is 16 tokens. Each block can hold the K and V vectors for 16 consecutive positions of one request. Blocks are allocated from the pool as requests need them and freed back to the pool when they're done.
- Each request has a logical sequence of blocks: L0, L1, L2, ... arranged in order. The first 16 tokens go in L0, the next 16 in L1, and so on.
- Each request has a small block table that maps logical block index → physical block index. table[0] = 7 means "logical block 0 of this request lives at physical block 7 in the pool".
Three requests mapped into a shared physical block pool. Each request's block table is tiny (a handful of integers), and the physical blocks it points at are scattered through the pool. Unrelated requests share scattered physical memory, just like unrelated processes share scattered RAM.
When attention runs, the kernel doesn't assume keys and values are contiguous. It walks the block table: "logical block 0 is physical block 7, logical block 1 is physical block 3, ..." and gathers the K and V vectors from wherever they actually live. There's a little indirection cost per block, but because blocks are substantial (16 tokens × hidden dimension), the fixed cost of the lookup is amortized across many arithmetic operations. The overhead ends up in the single-digit percent range.
The payoff is that you only ever allocate what you use, rounded up to block size. A request of 47 tokens takes three blocks (48 slots). Waste per request is bounded by BLOCK_SIZE - 1 tokens, so total waste across the system is small and constant instead of growing with max_context. Kwon et al. measured the resulting cache utilization at ~96% on production-like workloads, up from 20–40%.
A small worked example#
Let me make this concrete with the three requests from the comparison above.
Say we have a pool of 64 physical blocks, each holding 16 tokens of KV cache. Three requests arrive:
- Request A: 25 tokens. Needs ceil(25 / 16) = 2 blocks. The scheduler hands it blocks P3 and P7. Block table for A: [3, 7]. First 16 tokens live in P3, next 9 in P7 (with 7 token slots of padding in P7).
- Request B: 47 tokens. Needs 3 blocks. Gets P0, P5, P9. Block table: [0, 5, 9]. 47 = 16 + 16 + 15, so 1 slot of padding in P9.
- Request C: 12 tokens. Needs 1 block. Gets P4. Block table: [4]. 4 slots of padding.
Total physical slots used: 2×16 + 3×16 + 1×16 = 96 slots, to hold 84 generated tokens. Waste: 12 slots, about 12%. Compare to the contiguous case at max_context = 2,048: 3 × 2,048 = 6,144 slots reserved, only 84 in use, 98.6% waste.
With the paged approach, the pool has 64 − 6 = 58 blocks still free. We can admit another 30+ requests of average length without thinking twice. In the contiguous approach we couldn't admit a single additional request at max_context = 2,048 unless we had another 2,048 slots free. Paged allocation doesn't make memory bigger. It just stops pretending every request needs the worst case.
Now say request B generates another 20 tokens and crosses into a 4th block. The scheduler allocates one more block from the pool (say P11), appends it to B's block table, and attention on the next step uses [0, 5, 9, 11]. That's a single table write, no copying and no defragmentation.
As a quick refresher: this is the KV cache growing one row per decode step. PagedAttention doesn't change what goes in the cache, only how it's laid out in memory. Each 16-token chunk becomes one physical block in the pool.
Here is where the analogy pays off twice. Operating systems have a lovely trick called copy-on-write (COW): when a process forks, instead of duplicating its entire memory, the kernel shares the parent's pages with the child and marks them read-only. The child only gets its own copy of a page the instant it writes to one. Memory stays shared as long as nobody actually modifies it, which is most of the time.
This trick transfers directly. In LLM serving, many requests share a prefix. A chat app might prepend the same 800-token system prompt to every user message. Parallel sampling (the same prompt, multiple sampled continuations) shares every token up to the first sample token. Beam search is the same, at a finer grain: beams share everything except their most recent divergent token. If every one of those shared prefixes gets its own copy of the KV cache, you're paying for the same work many times over.
With PagedAttention, the scheduler can point multiple block tables at the same physical block. Each physical block maintains a reference count. As long as the count is greater than 1, nobody is allowed to write into the block in place. The moment one request needs to write (say, because it's about to generate a new token in a block still shared with another request), the engine clones that one block, decrements the refcount on the original, and updates the writing request's table to point at the clone. Everybody else keeps pointing at the original.
The intuition: shared prefixes stay shared until someone writes. A and B both point at P0 and P1 for the system prompt; when B generates a new token, only the one block being written gets cloned (P1 → P2). A's view never moves. Copy a page, not the whole cache.
The practical implication is that as long as your shared prefix is *actually* shared (nobody writes into it), you pay for it exactly once across all the requests that use it. This is the mechanism behind prefix caching as well. Kwon et al. demonstrate COW unlocking beam search and parallel sampling, but the much bigger win in production turns out to be system prompts. If your chatbot has a 2k-token system prompt and you're serving a hundred concurrent users, PagedAttention + COW saves you roughly 200k tokens of cache versus the naive approach. On a 70B model in fp16, that's tens of gigabytes.
Note that COW is a refinement of the basic paging idea. You can get most of PagedAttention's win without it. COW is what turns the "already good" into "extremely good" for workloads with heavy prompt reuse. SGLang's RadixAttention generalizes this further by organizing all cached blocks into a radix tree indexed by token prefix, so arbitrary shared prefixes get found and reused even when they aren't explicitly declared.
Why the attention kernel works at all#
One thing that bothered me when I first read the paper was: attention is a dense matrix operation. How can it possibly work on non-contiguous memory without falling apart?
The answer is that attention, at the kernel level, is already a loop that walks over keys and values one chunk at a time. FlashAttention, for example, tiles the K and V matrices into blocks that fit in SRAM and processes them iteratively. PagedAttention aligns its block size with the tile size the kernel uses anyway. Inside each block, memory is contiguous. Across blocks, you just look up the next physical block address from the block table and continue. The kernel does a little more pointer arithmetic. It doesn't rewrite attention from scratch.
The indirection does have a cost. For small block sizes, the overhead of block-table lookups gets proportionally larger, and memory accesses get more random. For huge block sizes, internal fragmentation creeps back in. The sweet spot in practice is around 16 tokens per block for most modern GPUs and models, which is what vLLM, TGI, and TensorRT-LLM all converged on.
The same 47-token request at three block sizes. Block = 4 needs 12 lookups but wastes only 1 slot. Block = 64 needs 1 lookup but wastes 17 slots (26%). Block = 16 sits in the middle: 3 lookups, 1 wasted slot. That's the sweet spot the ecosystem converged on.
A sketch of the scheduler#
To get a feel for how this plays out in code, here's a stripped-down sketch of what a paged-KV scheduler looks like. Not runnable as a real engine, but structurally honest.
The real vLLM scheduler is more involved (paging policies, eviction, preemption, GPU kernel integration), but this captures the bones. A block table is just a list of integers. Allocation is just popping from a free list. COW is a refcount. The only fancy part lives inside the attention kernel, and even there the change is local.
Misconceptions#
"PagedAttention changes the model output." No. It doesn't touch the math at all. Attention produces the exact same logits regardless of whether the K and V tensors are laid out contiguously or scattered through a pool. The only change is in how the kernel fetches memory. Output tokens, sampling probabilities, everything is bit-for-bit identical (modulo the usual floating-point non-determinism that affects any reduction on a GPU).
"Smaller blocks are always better because they reduce waste." It's more of a tradeoff. Smaller blocks reduce internal fragmentation (less padding in the last block per request) but inflate the block-table size and add per-block overhead inside the attention kernel. They also spread memory accesses more randomly, which can hurt cache performance. Kwon et al. found 16 tokens to be a strong default for a wide range of models, and that's where the major engines settled. Some workloads with mostly short sequences can benefit from smaller blocks; some with uniformly long sequences prefer 32 or 64. The number isn't magic, just well-tuned.
"Copy-on-write is mostly a beam-search optimization." The paper emphasizes beam search, but in production the dominant source of shared prefixes is system prompts. One prompt, many users, many concurrent requests all starting with the same 500–2000 tokens. COW there means the cached prefix lives exactly once in GPU memory across all of them. That's where most of the savings come from in a serving workload.
What's next#
The next post zooms out from one allocation strategy to the full GPU memory budget (weights, KV cache, activations, CUDA overhead) and the formula for how many concurrent requests you can actually serve. Start with Memory Management for LLMs.
Additional reading (and watching)#
- Kwon, W., Li, Z., Zhuang, S., Sheng, Y., Zheng, L., Yu, C. H., Gonzalez, J. E., Zhang, H., & Stoica, I. (2023). Efficient Memory Management for Large Language Model Serving with PagedAttention. SOSP 2023. The paper that introduced PagedAttention. The numbers I cite for 20–40% vs 96% cache utilization, 16-token blocks, and 2–4× throughput come from here.
- Kilburn, T., Edwards, D. B. G., Lanigan, M. J., & Sumner, F. H. (1962). One-Level Storage System. IRE Transactions on Electronic Computers. The Atlas paper. First description of paged virtual memory, cited as the ancestor PagedAttention is borrowing from.
- Silberschatz, A., Galvin, P. B., & Gagne, G. (2018). Operating System Concepts, 10th ed., Chapter 10: Virtual Memory. The standard textbook treatment of paging, page tables, TLBs, and copy-on-write. If you want the OS-side intuition in full, this is where it lives.
- Zheng, L., et al. (2023). SGLang: Efficient Execution of Structured Language Model Programs. arXiv:2312.07104. Introduces RadixAttention, which generalizes PagedAttention's copy-on-write prefix sharing into a radix tree of cached blocks.
- Dao, T., Fu, D. Y., Ermon, S., Rudra, A., & Ré, C. (2022). FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness. NeurIPS 2022. Background on the block-tiled attention kernel that PagedAttention extends with indirect addressing.
- vLLM Project. (2023–2026). vLLM: Easy, Fast, and Cheap LLM Serving with PagedAttention (blog announcement) and vLLM documentation. Reference for block size defaults, automatic prefix caching, and production deployment guidance.
- Hugging Face. (2024). Text Generation Inference (TGI) — PagedAttention adoption. TGI's documentation covering its paged KV-cache implementation and the performance improvements over its earlier contiguous cache.
- Yu, G.-I., Jeong, J. S., Kim, G.-W., Kim, S., & Chun, B.-G. (2022). Orca: A Distributed Serving System for Transformer-Based Generative Models. OSDI 2022. The precursor work on iteration-level scheduling that set the stage for vLLM's scheduler design.
- Pope, R., Douglas, S., Chowdhery, A., Devlin, J., Bradbury, J., Levskaya, A., Heek, J., Xiao, K., Agrawal, S., & Dean, J. (2022). Efficiently Scaling Transformer Inference. arXiv:2211.05102. Background on KV-cache memory dominating long-context inference — the reason managing it well matters so much.
- Denning, P. J. (1970). Virtual Memory. ACM Computing Surveys. The classic survey article that established the vocabulary PagedAttention borrows (pages, page tables, working sets, thrashing). Useful when you want to pull on the OS thread a bit further.
関連記事
今日のまとめ
AIデイリーブリーフで今日の重要ニュースをまとめ読み