서론: 80%에 달하는 메모리 소모
이전 심층 분석(From model.generate() to the Roofline Model: The Physics of Prefill and Decode Divergence)에서 우리는 자가회귀(autoregressive) 디코드 단계가 근본적으로 **메모리 대역폭에 의해 제한됨(memory-bandwidth bound)**을 1원칙부터 도출했습니다. 효과적인 GPU 컴퓨팅 활용률을 극대화하고 메모리 트래픽을 상각하기 위한 유일한 방법은 가능한 한 높은 동시 배치 크기(concurrent batch sizes)로 확장하는 것입니다.
하지만 초기 서빙 스택(예: 초기 FasterTransformer 또는 순수 PyTorch 서빙 스크립트)이 동시성을 확장하려고 할 때, 시스템 엔지니어들은 극복할 수 없는 벽에 부딪혔습니다:
80GB A100 또는 H100 가속기에서, 단 12개의 동시 요청만으로도 치명적인 CUDA Out of Memory (OOM) 오류로 인해 시스템이 자주 충돌했습니다. 이상하게도, 실제로 생성되는 토큰의 KV 캐시 물리적 크기를 계산하면 실제 메모리 사용량은 20GB 미만이었습니다.
나머지 60GB에 달하는 프리미엄 고대역폭 메모리(HBM)는 어디로 사라진 것일까요?
UC Berkeley 연구원들이 SOSP 2023에서 발표한 기념비적인 논문, _"Efficient Memory Management for Large Language Model Serving with PagedAttention,"_에 따르면, 레거시 추론 런타임은 총 GPU 메모리의 60%에서 80%를 낭비했습니다.
이 물리적 장벽을 극복하기 위해, vLLM은 PagedAttention을 도입했습니다. 이 글에서는 운영 체제 설계의 핵심 개념인 **가상 메모리 페이징(virtual memory paging)**이 어떻게 GPU 가속기에 적용되어 현대 LLM 추론 아키텍처를 재편했는지 탐구합니다.
1. 연속 할당: 세 가지 메모리 결함
PagedAttention 이전에는 딥러닝 프레임워크(PyTorch 및 TensorFlow 포함)가 근본적인 가정에 의존했습니다: 텐서는 반드시 연속적인 물리 메모리를 차지해야 한다.
동적이고 토큰별 자가회귀 생성 과정에서, 이 연속 메모리 모델은 세 가지 주요 메모리 낭비 원인을 만듭니다:
graph TD
subgraph Waste["연속 메모리 할당의 세 가지 결함 (60%-80% 낭비)"]
W1["<b>내부 단편화(Internal Fragmentation)</b><br/>max_model_len에 대한 정적 사전 할당 (예: 4096).<br/>사용자가 50 토큰에서 완료되면, 나머지는 잠금 상태로 유지됨.]
...
1. 내부 단편화 (Internal Fragmentation)
대규모 언어 모델(LLM)은 <EOS> 토큰을 방출할 때 비결정적으로 종료되므로, 서비스 엔진은 주어진 요청이 몇 개의 토큰을 생성할지 예측할 수 없습니다.
중간 생성 중 충돌을 방지하기 위해 기존 시스템들은 모델의 최대 컨텍스트 창(max_model_len, 예: 4096 또는 8192 토큰) 크기로 연속적인 메모리 청크를 사전 할당했습니다.
- 만약 사용자가 100개의 입력 토큰과 50개의 출력 토큰만을 필요로 하는 짧은 질문을 제출하더라도,
- 나머지 약 4000개 토큰 슬롯은 요청이 지속되는 동안 예약되고 잠겨 다른 쿼리에는 사용할 수 없습니다.
2. 예약 낭비 (Reservation Waste)
엔진이 점진적 할당(progressive allocation)을 시도하더라도(예: 128 토큰 단위로 메모리를 예약), 연속적인 텐서 확장은 다른 곳에 더 큰 버퍼를 할당하고 기존 캐시를 복사해야 합니다.
잦은 메모리 복사로 인한 처리량 페널티를 피하기 위해, 메모리 할당자들은 대부분 유휴 상태로 남아 있는 큰 안전 버퍼를 유지했습니다.
3. 외부 단편화 (External Fragmentation)
동시 서비스 환경에서 요청들은 비동기적으로 도착하고 완료됩니다. 요청 A가 256 토큰을 소비하고, 요청 B가 2048 토큰을, 그리고 요청 C가 512 토큰을 소비할 수 있습니다. 크기가 다른 연속적인 버퍼들이 할당되고 해제되면서, GPU 물리 메모리는 고립된 청크들로 단편화됩니다.
GPU 전체에 걸쳐 총 15GB의 집계 메모리가 사용 가능하더라도, 2GB 연속적 버퍼를 요구하는 들어오는 요청은 해당 크기의 연속적인 블록이 존재하지 않으면 OOM(Out of Memory) 오류를 유발할 수 있습니다.
2. 가상 메모리 페이징: PagedAttention의 핵심 설계
⚠️ [TIP]
인터랙티브 계산기: 프로덕션 KV 캐시 할당량 및 최대 동시성 제한을 산정해야 한다면, 내장된 LLM VRAM 계산기를 사용하여 다양한 컨텍스트 길이 하에서 동적 메모리 풀 동작을 시뮬레이션하십시오.
메모리 단편화를 제거하기 위해 컴퓨터 과학은 이미 운영 체제 설계에서 해결책을 개발했습니다: 가상 메모리 페이징(virtual memory paging).
현대 운영 체제에서 프로세스는 연속적인 가상 주소 공간 내에서 작동하며, 물리적 메모리는 이산적인 페이지 프레임(page frames) (일반적으로 4KB)으로 나뉩니다. 하드웨어 메모리 관리 장치(MMU)는 **페이지 테이블(page tables)**을 사용하여 연속적인 가상 주소를 비연속적인 물리 페이지에 투명하게 매핑합니다.
vLLM은 이 아키텍처를 GPU KV 캐시에 적용하여 PagedAttention을 만들었습니다:
flowchart LR
subgraph LogicalSpace["논리적 시퀀스 뷰 (연속적)"]
L0["논리적 블록 0<br/>토큰 0 ~ 15"]
...
1. 주요 아키텍처 추상화
- 블록 크기 ($B$): 메모리 할당의 기본 단위입니다. 하나의 블록은 모든 레이어에 걸쳐 고정된 수의 토큰에 대한 키(key) 및 값(value) 텐서를 저장합니다 (일반적으로 $B = 16$ 또는 $B = 32$).
- 논리적 블록(Logical Block): 시퀀스에 제시되는 가상적이고 연속적인 추상화입니다. 어텐션 레이어의 관점에서 볼 때, 시퀀스는 토큰 0부터 토큰 $L-1$까지 표준적인 연속 배열처럼 보입니다.
- 물리적 블록(Physical Block): 엔진 초기화 중, GPU HBM의 크고 연속적인 영역이 할당되어 고정 크기의 물리적 블록 슬롯으로 나뉩니다. 각 물리적 블록은 고유한 전역
block_number를 가집니다.
- 블록 테이블(Block Table): 요청별 조회 테이블로, 각 논리적 블록 인덱스를 GPU 메모리의 해당 물리적 블록 번호에 매핑하며, 활성 블록의 채움 수준(
filled_count)을 추적합니다.
2. 단편화 감소 정량화
PagedAttention 설계 하에서:
– 외부 단편화(External fragmentation) 제거: 모든 물리적 블록이 동일한 치수(예: 정확히 16개의 토큰 슬롯)를 공유하기 때문에, 메모리는 불일치한 간극을 만들지 않고 균일한 단위로 할당 및 해제됩니다.
– 내부 단편화(Internal fragmentation) 최소화: 메모리 낭비는 시퀀스의 마지막 논리적 블록에서만 발생합니다. 블록 크기가 $B = 16$인 경우, 요청당 최대 낭비는 15개의 토큰 슬롯입니다: $$\text{평균 낭비} = \frac{B}{2} \times \text{1 토큰 KV 캐시 크기}$$ 요청당 메모리 낭비가 60%–80%에서 4% 미만으로 떨어져, 엔진이 동일한 GPU 메모리 풀에 2배에서 4배 더 많은 동시 시퀀스를 수용할 수 있게 합니다.
3. 동적 메모리 풀 생명주기 (Dynamic Memory Pool Lifecycle)
vLLM과 같은 프로덕션 엔진에서는 호스트 측의 **블록 관리자(Block Manager)**가 메모리 관리를 조정합니다:
sequenceDiagram
autonumber
actor Client as Client Request
...
GPU 상의 물리적 텐서 레이아웃 (Physical Tensor Layout on the GPU)
엔진은 수천 개의 개별 GPU 텐서 할당을 관리하는 대신, 시작 시점에 두 개의 크고 연속적인 물리적 텐서인 key_cache와 value_cache를 할당합니다.
반정밀도(BF16/FP16)에서, 물리적 키 캐시 텐서는 **응집된 메모리 접근(coalesced memory access)**을 가능하도록 배치됩니다:
# vLLM의 물리적 키 캐시 텐서 레이아웃
# 차원은 128바이트 워프 메모리 정렬을 보장하기 위해 순열됩니다
key_cache = torch.empty(
...
블록을 할당하는 것은 CPU 메모리에서 정수 인덱스를 업데이트하는 것만 필요합니다. 생성 과정 중에는 런타임 cudaMalloc 호출이 발생하지 않아, 서브 마이크로초(sub-microsecond) 단위의 블록 관리가 가능해집니다.
4. Copy-on-Write 및 브랜칭 생성 (Copy-on-Write and Branching Generation)
PagedAttention은 또한 시퀀스 간에 **Copy-on-Write (CoW)**를 통한 제로 카피 메모리 공유를 가능하게 합니다.
이는 여러 출력이 공통된 프롬프트 접두사(prompt prefixes)를 공유하는 워크로드에서 특히 유용합니다:
- 병렬 샘플링 (Parallel Sampling): 단일 프롬프트에서 다양한 샘플링 온도를 사용하여 $N$개의 후보 응답을 생성하는 방식.
- 빔 서치 (Beam Search): 동일한 접두사 이력(prefix histories)을 공유하는 상위-$k$개 부분 가설을 유지하는 방식.
- 에이전트 분기 (Agentic Branching) (Tree-of-Thoughts / MCTS): 공통의 의사 결정 상태에서 대체적인 도구 사용 궤적(tool-use trajectories)들을 평가하는 방식.
연속 할당 모델(contiguous allocation models) 하에서는 프롬프트의 KV 캐시가 각 분기(branch)마다 중복되어야 했습니다. PagedAttention은 **참조 카운팅 (reference counting)**을 통해 이를 관리합니다:
flowchart TD
Prompt["입력 프롬프트 (2개의 물리 블록 소비)"] --> PB0["물리 블록 #10<br/>(참조 횟수 = 2)"]
Prompt --> PB1["물리 블록 #11<br/>(참조 횟수 = 2)"]
...
Copy-on-Write 워크플로우
- 초기 공유 상태 (Initial Shared State): Branch A와 Branch B가 동일한 프롬프트에서 분기될 때, 두 블록 테이블 모두 물리 블록
#10과 #11을 참조합니다. 이 블록들의 참조 카운터는 2로 증가합니다.
- 공유 읽기 (Shared Reads): 어텐션 계산 중, GPU 실행 장치(GPU execution units)들은 데이터 중복 없이 물리 블록
#10과 #11에서 두 분기 모두에 걸쳐 동시에 읽기를 수행합니다.
- Copy-on-Write: Branch A가
ref_count > 1인 부분적으로 채워진 블록에 새로운 토큰을 쓸 때, 엔진은 새로운 물리 블록(#45)을 할당하고, 기존 내용을 복사하며, Branch A의 매핑 포인터를 재지정하고, 원래 블록의 참조 카운터를 감소시킵니다.
이 메커니즘은 SGLang의 RadixAttention tree cache와 같은 고급 접두사 캐싱 시스템을 위한 아키텍처적 기반 역할을 합니다.
5. 비연속 CUDA 커널 (Non-Contiguous CUDA Kernels)
표준 어텐션 커널(예: cuBLAS GEMM 또는 표준 FlashAttention)은 키와 값(keys and values)이 연속적인 물리 메모리 버퍼에 존재해야 합니다.
데이터를 복사하지 않고 비연속적인 물리 블록에 걸쳐 어텐션을 계산하기 위해, PagedAttention은 주소를 동적으로 해결하는 사용자 정의 CUDA 커널을 사용합니다:
커널 내부의 블록 테이블 간접 참조 (Block Table Indirection Inside the Kernel)
디스패치(dispatch) 과정에서 스케줄러는 block_tables를 2D 정수 텐서로 GPU 커널에 전달합니다:
// PagedAttention CUDA Kernel 실행 개념
template<typename scalar_t, int BLOCK_SIZE>
__global__ void paged_attention_kernel(...)
간접 참조가 메모리 처리량(Memory Throughput)을 저하시키지 않는 이유
엔지니어들은 종종 블록 조회 시 발생하는 포인터 간접 참조(pointer indirection)가 캐시 미스(cache misses)를 통해 GPU 메모리 효율성을 떨어뜨리는지 질문합니다:
- 블록 내 메모리 응집화 (Intra-Block Memory Coalescing): 16개 또는 32개의 토큰으로 구성된 각 물리적 블록 내에서 데이터는 연속적으로 저장됩니다. 32스레드 GPU 워프(warp)는 128바이트 트랜잭션에 맞춰 정렬된 데이터를 접근하여 높은 메모리 버스 효율성을 유지합니다.
- 메타데이터 지역성 (Metadata Locality): $B = 16$인 4096개 토큰 시퀀스의 경우, 블록 테이블은 단지 32비트 정수 256개(1KB의 메모리)만 포함합니다. 이 메타데이터는 고속 GPU L1/L2 캐시에 상주하며, 수 기가바이트 HBM 스트리밍 작업에 비해 최소한의 오버헤드만을 발생시킵니다.
6. 아키텍처 요약 (Architectural Summary)
GPU 소프트웨어에서 가상 메모리 개념을 구현함으로써 PagedAttention은 초기 서빙 엔진의 주요 메모리 비효율성을 해결했습니다:
- 메모리 낭비를 80%에서 4% 미만으로 감소;
- 단일 노드 서빙 처리량(throughput)을 2배에서 4배 증가;
- 메모리 단편화 OOM 오류 제거;
- 프리픽스 공유(prefix sharing, RadixAttention) 및 연속 배치(continuous batching)를 위한 기본 원시 요소 제공.
하지만 메모리 효율성은 퍼즐의 첫 번째 조각일 뿐입니다. 메모리 할당이 최적화되면, 스케줄러는 파이프라인 정지(pipeline stalls)를 유발하지 않으면서 다양한 길이의 요청을 관리해야 합니다.
다음 장에서는 실행 스케줄링에 대해 살펴볼 것입니다: Continuous Batching and Chunked Prefill: Eliminating Head-of-Line Blocking.
자주 묻는 질문 (FAQ)
Q1: 블록 크기를 16으로 설정해야 할까요, 아니면 32로 설정해야 할까요? 왜 1이나 128이 아닌가요?
블록 크기(Block size)는 **내부 단편화(internal fragmentation)**와 GPU 메모리 버스 정렬(GPU memory bus alignment) 사이의 엔지니어링 트레이드오프를 나타냅니다:
- 매우 작은 블록($B = 1$ 또는 $2$): 내부 단편화는 0으로 떨어지지만, 물리적 블록이 너무 작아져서 128바이트의 병합(coalesced) 메모리 트랜잭션을 포화시키기 어렵습니다. 메모리 버스 효율성이 저하되고, 블록 테이블은 한 자릿수 크기로 확장되어 캐시 압력(cache pressure)을 증가시킵니다.
- 매우 큰 블록($B = 128$): 마지막 블록의 사용되지 않은 슬롯은 시퀀스당 평균 64 토큰의 낭비를 초래하여, 짧은 프롬프트 워크로드에서 상당한 단편화를 재도입합니다.
- 프로덕션 표준(Production standard): A100 및 H100 하드웨어 전반에 걸쳐, **$B = 16$ 또는 $B = 32$**는 워프 메모리 트랜잭션 폭과 공유 메모리 뱅크 구성을 일치시키면서 96% 이상의 메모리 활용률을 제공합니다.
Q2: PagedAttention이 메모리 단편화를 제거한다면, 서비스 시스템은 왜 여전히 OOM(Out-of-Memory) 오류를 경험하는가?