LLMの推論サーバーを運用する際、GPUメモリの使い方が性能を大きく左右します。例えば、LLaMA-13Bを1つのリクエストに対して推論する場合、KVキャッシュは系列長2048で約1.6GBを消費します。しかし、リクエストの実際の系列長は事前にわかりません。128トークンで終わるかもしれないし、2048トークンに達するかもしれません。
従来のLLM推論システムは、この不確実性に対処するために最大系列長分のメモリを事前確保していました。その結果、実際には使われないメモリが大量に生じ、GPU全体の60〜80%のメモリが無駄になっていたのです。
この問題を解決するために、Kwon et al.(2023)が提案したのがPagedAttentionです。OSの仮想メモリ管理で使われるページングの仕組みをKVキャッシュに適用し、メモリの断片化と無駄を大幅に削減します。PagedAttentionを実装したLLM推論エンジンvLLMは、HuggingFace Transformersと比較して2〜4倍のスループットを達成しました。
PagedAttentionを理解することは、以下のような場面で直接役立ちます。
- LLM推論サーバーの構築: vLLMやTGIなどの推論フレームワークの設定を最適化する際に、内部動作の理解が役立ちます
- バッチサイズの最大化: メモリ効率の改善により同時処理可能なリクエスト数が増え、スループットが向上します
- Prefix Cachingの理解: 共通プレフィックスの再利用などの発展的な最適化の基盤です
本記事の内容
- KVキャッシュのメモリ管理の問題点
- OSの仮想メモリとページングの概要
- PagedAttentionの設計と数式
- ブロックテーブルの仕組み
- メモリ共有(Copy-on-Write)
- Pythonでのシミュレーション実装
前提知識
この記事を読む前に、以下の記事を読んでおくと理解が深まります。
KVキャッシュのメモリ問題
KVキャッシュのサイズ
LLMの自己回帰生成では、過去に計算したKeyとValueをキャッシュに保存します。1つのリクエストが消費するKVキャッシュのサイズは:
$$ \text{KVキャッシュサイズ} = 2 \times L \times n_h \times d_h \times s \times b $$
ここで、$L$ は層数、$n_h$ はヘッド数、$d_h$ はヘッド次元、$s$ は系列長、$b$ はバイト数(FP16なら2)です。
LLaMA-13Bの場合($L=40, n_h=40, d_h=128$):
$$ \text{1トークンあたり} = 2 \times 40 \times 40 \times 128 \times 2 = 819,200 \text{ bytes} \approx 0.8 \text{ MB} $$
系列長2048では $0.8 \times 2048 \approx 1.6$ GBです。A100(80GB)にモデル自体が約26GBを占めるため、残り54GBでKVキャッシュを管理すると、同時に処理できるリクエストは約33個です。
3種類のメモリ浪費
従来のKVキャッシュ管理では、以下の3種類のメモリ浪費が発生していました。
1. 予約の浪費(Reservation Waste): 最大系列長分のメモリを事前確保するため、実際の系列長が短い場合に大量の未使用メモリが生じます。例えば最大2048トークンで確保したが実際は256トークンだった場合、87.5%が無駄になります。
2. 内部断片化(Internal Fragmentation): 連続したメモリブロックを確保する必要があるため、ブロックの末尾に使われない端数が生じます。
3. 外部断片化(External Fragmentation): リクエストの開始・終了が繰り返されると、メモリの空き領域が細かく分断され、大きな連続領域を確保できなくなります。
vLLMの論文では、HuggingFace Transformersの実装でメモリ全体の60.4〜80.3%が浪費されていると報告されています。
OSが同様の問題をどのように解決してきたか見てみましょう。
OSの仮想メモリとの類似性
ページングの基本概念
OSのメモリ管理は、LLMのKVキャッシュ管理と驚くほど似た問題を抱えていました。プロセスが必要とするメモリは事前にわからず、連続した物理メモリの確保は断片化を引き起こします。
OSはこの問題を仮想メモリとページングで解決しました。
- 仮想アドレス空間: 各プロセスは連続した仮想アドレス空間を持つ。物理メモリ上のアドレスとは独立
- ページ: メモリを固定サイズのブロック(ページ、通常4KB)に分割する
- ページテーブル: 仮想ページ番号→物理フレーム番号の対応表。MMU(Memory Management Unit)が高速に変換する
- オンデマンド割当: ページは実際にアクセスされたときに初めて物理メモリが割り当てられる
KVキャッシュへの応用
PagedAttentionは、この仮想メモリの概念をKVキャッシュに直接適用します。
| OS概念 | PagedAttention対応 |
|---|---|
| 仮想アドレス空間 | 各リクエストの論理的なKVキャッシュ列 |
| ページ(4KB) | KVブロック($B$ トークン分のKV) |
| ページテーブル | ブロックテーブル |
| 物理フレーム | GPU上のKVブロック格納領域 |
| オンデマンド割当 | トークン生成時に新しいブロックを割当 |
この対応を理解すれば、PagedAttentionの設計は自然に理解できます。次に具体的な仕組みを見ていきましょう。
PagedAttentionの設計
KVブロック
PagedAttentionでは、各リクエストのKVキャッシュを固定サイズのKVブロックに分割します。1ブロックは $B$ トークン分のKeyとValueを格納します(典型的には $B = 16$)。
1ブロックのサイズ:
$$ \text{ブロックサイズ} = 2 \times n_h \times d_h \times B \times b $$
LLaMA-13B、$B=16$ の場合:
$$ 2 \times 40 \times 128 \times 16 \times 2 = 13,107,200 \text{ bytes} \approx 12.5 \text{ MB} $$
ブロックテーブル
各リクエストはブロックテーブルを持ちます。これはOSのページテーブルに対応し、論理ブロック番号→物理ブロック番号のマッピングを管理します。
例えば、6トークンを生成したリクエスト($B=4$)のブロックテーブルは:
論理ブロック 0 → 物理ブロック 7 (トークン 0, 1, 2, 3)
論理ブロック 1 → 物理ブロック 3 (トークン 4, 5, _, _)
物理ブロック7と3はGPUメモリ上で連続している必要はありません。これが外部断片化を解決するポイントです。
Attention計算の変更
標準のAttention計算では、KVキャッシュは連続したメモリ上にあると仮定しています。PagedAttentionでは、KVキャッシュがブロック単位で散在しているため、Attention計算を以下のように変更します。
クエリ $\bm{q} \in \mathbb{R}^d$(新しいトークン)に対して、$n$ ブロックのキャッシュがある場合:
$$ \text{Attention}(\bm{q}) = \frac{\sum_{j=0}^{n-1} \sum_{k=0}^{B_j-1} e^{\bm{q}^T \bm{k}_{j,k} / \sqrt{d}} \cdot \bm{v}_{j,k}}{\sum_{j=0}^{n-1} \sum_{k=0}^{B_j-1} e^{\bm{q}^T \bm{k}_{j,k} / \sqrt{d}}} $$
ここで $\bm{k}_{j,k}, \bm{v}_{j,k}$ は物理ブロック $j$ の $k$ 番目のスロットに格納されたKeyとValueです。$B_j$ はブロック $j$ の有効トークン数(最後のブロックのみ $B$ 未満になりうる)です。
実装上は、FlashAttentionのブロック処理と自然に組み合わせることができます。FlashAttentionがK/Vをブロック単位で処理する際に、ブロックテーブルを参照して物理ブロックのアドレスを取得します。
メモリの動的割当
PagedAttentionのメモリ管理は、OSのページフレームアロケータと同様に動作します。
- フリーリスト: 未使用の物理ブロック番号のリストを維持する
- 割当: 新しいブロックが必要になったとき、フリーリストから物理ブロックを1つ取得し、ブロックテーブルに追加する
- 解放: リクエストが完了したとき、そのリクエストの全ブロックをフリーリストに返却する
この仕組みにより、以下の問題が全て解決されます。
- 予約の浪費: トークン生成に合わせてオンデマンドでブロックを割り当てるため、未使用メモリが最小化される
- 内部断片化: 最後のブロックにのみ端数が生じ、浪費は最大でも $B-1$ トークン分/リクエスト
- 外部断片化: 物理ブロックは連続している必要がないため、任意の空きブロックが使える
vLLMの論文では、この最適化によりメモリ浪費が4%以下に削減されたと報告されています。
Copy-on-Write によるメモリ共有
Parallel Samplingの問題
LLMの推論では、1つのプロンプトに対して複数の応答候補を生成するparallel sampling(beam searchやbest-of-Nなど)がよく使われます。
例えば、1つのプロンプトに対して4つの応答を並列に生成する場合、プロンプト部分のKVキャッシュは4つのリクエストで完全に同一です。従来の実装では、このKVキャッシュを4回コピーしていました。
Copy-on-Write(COW)
PagedAttentionは、OSのCopy-on-Write機構を取り入れてこの問題を解決します。
- 共有: 複数のリクエストが同じプロンプト部分を持つ場合、物理ブロックを共有する。各リクエストのブロックテーブルは同じ物理ブロックを指す
- 参照カウント: 各物理ブロックに参照カウントを持たせる。共有されているブロックは参照カウント > 1
- コピー: あるリクエストが共有ブロックを変更する必要がある場合(最後のブロックに新しいトークンを追加する場合)、初めてブロックのコピーを作成する
Parallel sampling($k=4$)の場合のメモリ節約:
$$ \text{節約率} = \frac{(k-1) \times \text{プロンプト長} \times \text{ブロック数}}{k \times \text{総ブロック数}} \approx \frac{(k-1) \times s_{\text{prompt}}}{k \times s_{\text{total}}} $$
プロンプトが1024トークン、応答が256トークンの場合:
$$ \text{節約率} = \frac{3 \times 1024}{4 \times 1280} \approx 60\% $$
これにより、beam searchの同時実行数を大幅に増やすことができます。
次に、PagedAttentionの仕組みをPythonで実装して動作を確認しましょう。
Pythonによるシミュレーション
ブロックマネージャーの実装
まず、物理ブロックの割当・解放を管理するブロックマネージャーを実装します。
import numpy as np
import matplotlib.pyplot as plt
class BlockManager:
"""KVブロックの物理メモリ管理を行うクラス。
OSのページフレームアロケータに相当する。"""
def __init__(self, num_blocks, block_size, num_layers, num_heads, head_dim):
self.num_blocks = num_blocks
self.block_size = block_size
self.num_layers = num_layers
self.num_heads = num_heads
self.head_dim = head_dim
# フリーリスト
self.free_blocks = list(range(num_blocks))
# 参照カウント
self.ref_count = [0] * num_blocks
# 物理ブロックストレージ(簡略化: 1層1ヘッドのみ)
# 実際は全層・全ヘッド分
self.k_cache = np.zeros((num_blocks, block_size, head_dim))
self.v_cache = np.zeros((num_blocks, block_size, head_dim))
def allocate(self):
"""フリーリストから物理ブロックを1つ割り当てる。"""
if not self.free_blocks:
raise RuntimeError("No free blocks available")
block_id = self.free_blocks.pop(0)
self.ref_count[block_id] = 1
return block_id
def free(self, block_id):
"""物理ブロックを解放する(参照カウントを減らす)。"""
self.ref_count[block_id] -= 1
if self.ref_count[block_id] == 0:
self.free_blocks.append(block_id)
def add_ref(self, block_id):
"""参照カウントを増やす(COW用)。"""
self.ref_count[block_id] += 1
def copy_on_write(self, block_id):
"""参照カウント>1のブロックをコピーする。"""
if self.ref_count[block_id] <= 1:
return block_id # コピー不要
# 新しいブロックを割り当ててコピー
new_block_id = self.allocate()
self.k_cache[new_block_id] = self.k_cache[block_id].copy()
self.v_cache[new_block_id] = self.v_cache[block_id].copy()
# 元のブロックの参照カウントを減らす
self.ref_count[block_id] -= 1
return new_block_id
@property
def num_free(self):
return len(self.free_blocks)
@property
def utilization(self):
return 1.0 - len(self.free_blocks) / self.num_blocks
リクエストとブロックテーブルの管理
class Request:
"""1つの推論リクエストを管理するクラス。"""
def __init__(self, request_id, block_manager, block_size):
self.request_id = request_id
self.block_manager = block_manager
self.block_size = block_size
self.block_table = [] # 論理ブロック → 物理ブロックのマッピング
self.num_tokens = 0
self.slot_in_last_block = 0
def append_token(self, key, value):
"""新しいトークンのKVをキャッシュに追加する。"""
bm = self.block_manager
if self.slot_in_last_block == 0 or self.slot_in_last_block >= self.block_size:
# 新しいブロックが必要
if self.block_table and self.slot_in_last_block >= self.block_size:
pass # 既存ブロックは満杯
new_block = bm.allocate()
self.block_table.append(new_block)
self.slot_in_last_block = 0
# 最後のブロックにKVを書き込み
physical_block = self.block_table[-1]
# COWチェック
if bm.ref_count[physical_block] > 1:
physical_block = bm.copy_on_write(physical_block)
self.block_table[-1] = physical_block
bm.k_cache[physical_block, self.slot_in_last_block] = key
bm.v_cache[physical_block, self.slot_in_last_block] = value
self.slot_in_last_block += 1
self.num_tokens += 1
def get_all_kv(self):
"""全てのKVキャッシュを論理順序で取得する。"""
bm = self.block_manager
keys = []
values = []
for i, block_id in enumerate(self.block_table):
if i < len(self.block_table) - 1:
# 満杯のブロック
keys.append(bm.k_cache[block_id])
values.append(bm.v_cache[block_id])
else:
# 最後のブロック(部分的に埋まっている)
keys.append(bm.k_cache[block_id, :self.slot_in_last_block])
values.append(bm.v_cache[block_id, :self.slot_in_last_block])
return np.concatenate(keys, axis=0), np.concatenate(values, axis=0)
def release(self):
"""全ブロックを解放する。"""
for block_id in self.block_table:
self.block_manager.free(block_id)
self.block_table = []
self.num_tokens = 0
def paged_attention(query, request, scale=None):
"""PagedAttentionによるAttention計算。"""
keys, values = request.get_all_kv()
d = query.shape[0]
if scale is None:
scale = 1.0 / np.sqrt(d)
# Attention scores
scores = keys @ query * scale # (num_tokens,)
# Softmax
scores_max = np.max(scores)
exp_scores = np.exp(scores - scores_max)
attn_weights = exp_scores / np.sum(exp_scores)
# 重み付き和
output = values.T @ attn_weights # (d,)
return output
メモリ効率の比較シミュレーション
複数のリクエストを同時に処理する場合のメモリ効率を、従来方式とPagedAttentionで比較します。
def simulate_memory_usage(num_requests, max_seq_len, actual_lengths,
block_size=16, head_dim=128):
"""従来方式とPagedAttentionのメモリ使用量を比較する。"""
# 従来方式: 最大系列長分を事前確保
traditional_per_request = max_seq_len * head_dim * 2 * 2 # KV, FP16
traditional_total = num_requests * traditional_per_request
traditional_used = sum(l * head_dim * 2 * 2 for l in actual_lengths)
traditional_waste = traditional_total - traditional_used
# PagedAttention: ブロック単位でオンデマンド割当
paged_blocks_used = 0
paged_internal_waste = 0
for length in actual_lengths:
blocks_needed = (length + block_size - 1) // block_size
paged_blocks_used += blocks_needed
waste_in_last = (blocks_needed * block_size - length)
paged_internal_waste += waste_in_last
block_bytes = block_size * head_dim * 2 * 2
paged_total_used = paged_blocks_used * block_bytes
paged_waste = paged_internal_waste * head_dim * 2 * 2
return {
'traditional_total': traditional_total,
'traditional_used': traditional_used,
'traditional_waste': traditional_waste,
'paged_total': paged_total_used,
'paged_used': traditional_used,
'paged_waste': paged_waste,
}
# シミュレーション
np.random.seed(42)
num_requests = 32
max_seq_len = 2048
# 実際の系列長は対数正規分布(短いリクエストが多い)
actual_lengths = np.random.lognormal(mean=5.0, sigma=1.0, size=num_requests).astype(int)
actual_lengths = np.clip(actual_lengths, 32, max_seq_len)
results = simulate_memory_usage(num_requests, max_seq_len, actual_lengths)
# 可視化
fig, axes = plt.subplots(1, 3, figsize=(18, 5))
# 左: 系列長の分布
axes[0].hist(actual_lengths, bins=20, color='#00d4ff', alpha=0.7, edgecolor='white')
axes[0].axvline(x=max_seq_len, color='#ef5350', linestyle='--',
label=f'Max reserved ({max_seq_len})')
axes[0].axvline(x=np.mean(actual_lengths), color='#ffa726', linestyle='--',
label=f'Mean actual ({np.mean(actual_lengths):.0f})')
axes[0].set_xlabel('Actual Sequence Length')
axes[0].set_ylabel('Number of Requests')
axes[0].set_title('Distribution of Actual Sequence Lengths')
axes[0].legend()
axes[0].grid(True, alpha=0.3)
# 中央: メモリ使用量の比較
to_mb = lambda x: x / (1024**2)
categories = ['Traditional', 'PagedAttention']
used = [to_mb(results['traditional_used']), to_mb(results['paged_used'])]
waste = [to_mb(results['traditional_waste']), to_mb(results['paged_waste'])]
bars_used = axes[1].bar(categories, used, label='Used', color='#66bb6a', alpha=0.8)
bars_waste = axes[1].bar(categories, waste, bottom=used, label='Wasted',
color='#ef5350', alpha=0.8)
axes[1].set_ylabel('Memory (MB)')
axes[1].set_title('Memory Usage Comparison')
axes[1].legend()
axes[1].grid(True, alpha=0.3, axis='y')
# 使用率を表示
trad_util = results['traditional_used'] / results['traditional_total'] * 100
paged_util = results['paged_used'] / results['paged_total'] * 100
axes[1].text(0, to_mb(results['traditional_total']) * 0.5,
f'{trad_util:.1f}%', ha='center', va='center', fontsize=14,
fontweight='bold', color='white')
axes[1].text(1, to_mb(results['paged_total']) * 0.5,
f'{paged_util:.1f}%', ha='center', va='center', fontsize=14,
fontweight='bold', color='white')
# 右: ブロックサイズによる内部断片化
block_sizes = [4, 8, 16, 32, 64, 128]
internal_wastes = []
for bs in block_sizes:
total_waste = 0
for length in actual_lengths:
blocks = (length + bs - 1) // bs
total_waste += blocks * bs - length
waste_ratio = total_waste / sum(actual_lengths) * 100
internal_wastes.append(waste_ratio)
axes[2].plot(block_sizes, internal_wastes, 'o-', color='#ffa726', markersize=8)
axes[2].set_xlabel('Block Size (tokens)')
axes[2].set_ylabel('Internal Fragmentation (%)')
axes[2].set_title('Internal Fragmentation vs Block Size')
axes[2].grid(True, alpha=0.3)
axes[2].set_xscale('log', base=2)
plt.tight_layout()
plt.savefig('paged_attention_memory.png', dpi=150, bbox_inches='tight')
plt.show()
print(f"従来方式 メモリ利用率: {trad_util:.1f}%")
print(f"PagedAttention メモリ利用率: {paged_util:.1f}%")
print(f"メモリ削減率: {(1 - results['paged_total']/results['traditional_total'])*100:.1f}%")
左のグラフから、実際の系列長は短いリクエストに偏っており(対数正規分布)、最大系列長2048に達するリクエストはほとんどないことがわかります。中央のグラフでは、従来方式のメモリ利用率が低い(赤い浪費部分が大きい)のに対し、PagedAttentionではほぼ全てのメモリが有効に使われていることが確認できます。
右のグラフは、ブロックサイズと内部断片化の関係を示しています。ブロックサイズが大きいほど内部断片化が増加しますが、$B=16$ では数%程度に抑えられています。一方、ブロックサイズが小さすぎるとブロックテーブルのオーバーヘッドが増えるため、実用上は $B=16$ がよいバランスです。
Copy-on-Writeのシミュレーション
並列サンプリングにおけるCOWの効果を確認します。
# COWシミュレーション
block_size = 4
head_dim = 8 # 簡略化
num_total_blocks = 100
bm = BlockManager(num_total_blocks, block_size, 1, 1, head_dim)
# プロンプト部分の処理(16トークン = 4ブロック)
prompt_request = Request(0, bm, block_size)
for t in range(16):
key = np.random.randn(head_dim)
value = np.random.randn(head_dim)
prompt_request.append_token(key, value)
prompt_blocks = prompt_request.block_table.copy()
print(f"プロンプト後のブロック: {prompt_blocks}")
print(f"使用ブロック数: {num_total_blocks - bm.num_free}")
# 並列サンプリング: 4つの応答候補を生成
# 各候補はプロンプトのブロックを共有
parallel_requests = []
for i in range(4):
req = Request(i + 1, bm, block_size)
req.block_table = prompt_blocks.copy()
req.num_tokens = 16
req.slot_in_last_block = block_size # 最後のブロックは満杯
# 共有ブロックの参照カウントを増やす
for block_id in prompt_blocks:
bm.add_ref(block_id)
parallel_requests.append(req)
blocks_after_share = num_total_blocks - bm.num_free
print(f"\n共有後の使用ブロック数: {blocks_after_share}(COWなし: {4 * 4 + 4} = 20)")
# 各候補に独自のトークンを追加
for i, req in enumerate(parallel_requests):
for t in range(8): # 8トークンずつ追加
key = np.random.randn(head_dim) * (i + 1)
value = np.random.randn(head_dim) * (i + 1)
req.append_token(key, value)
blocks_after_generation = num_total_blocks - bm.num_free
naive_blocks = 4 * (4 + 2) # 4候補 × (プロンプト4 + 応答2ブロック)
print(f"生成後の使用ブロック数: {blocks_after_generation}(COWなし: {naive_blocks})")
print(f"メモリ節約率: {(1 - blocks_after_generation/naive_blocks)*100:.1f}%")
このシミュレーションから、COWによりプロンプト部分のKVキャッシュが物理的に1セットしか保存されないため、並列サンプリングのメモリコストが大幅に削減されることが確認できます。4つの候補で応答を生成しても、プロンプト部分のメモリは共有されているため、追加のメモリは応答部分のみです。
Continuous Batchingとの組み合わせ
従来のStatic Batching
従来のバッチ処理では、バッチ内の全リクエストが終了するまで待ちます。短いリクエストが先に完了しても、最長のリクエストが終わるまでGPUリソースが無駄になります。
Continuous Batching
vLLMでは、Continuous Batching(Iteration-Level Scheduling)を採用しています。各生成ステップ(イテレーション)ごとに:
- 完了したリクエストをバッチから除外
- 待機キューから新しいリクエストをバッチに追加
- 残りのリクエストに対してAttention計算を実行
PagedAttentionのブロック単位メモリ管理と組み合わせることで、リクエストの追加・除去が高速かつ柔軟に行えます。リクエストが完了したら、そのブロックを即座に解放して新しいリクエストに使えます。
まとめ
本記事では、PagedAttentionとvLLMの仕組みを解説しました。
- 従来のKVキャッシュ管理では60〜80%のメモリが浪費されていた。予約の浪費・内部断片化・外部断片化の3つが原因
- PagedAttentionはOSの仮想メモリ管理(ページング)をKVキャッシュに適用し、ブロック単位のオンデマンド割当でメモリ浪費を4%以下に削減
- Copy-on-Writeにより、並列サンプリング時にプロンプト部分のKVキャッシュを物理的に共有し、メモリコストをさらに削減
- Continuous Batchingと組み合わせることで、リクエストの動的な追加・除去が可能になり、GPUの利用率が最大化される
PagedAttentionはvLLM、TGIなどの主要な推論フレームワークに実装されており、LLMの推論サービスのデファクトスタンダードとなっています。
次のステップとして、以下の記事も参考にしてください。