Documents
Home>Documents>AI>Inference

'안녕하세요 반갑습니다'로 배우는 vLLM — KV Cache와 PagedAttention 완전 해부

28 min readJun 4, 2026Jun 4, 2026

"안녕하세요 반갑습니다"라는 문장이 있다. 이것을 토크나이저가 쪼개면 이렇게 된다.
(물론 실제 tokenizer가 이런 방식으로 쪼개는 것은 아니다. 그냥 임의의 예시이다.)

["안녕", "하", "세요", "반갑", "습", "니다"]

이 여섯 조각이 vLLM 안에서 어떤 여정을 거치는지 — 어떻게 메모리에 올라가고, 왜 KV Cache에 저장되며, PagedAttention이 무엇을 해결하는지 — 를 누구나 이해할 수 있게 좀 쉽게 풀어보자.


1단계: 토크나이징 — 모델이 문장을 읽는 방식

왜 통째로 읽지 않는가

사람은 "안녕하세요"를 한 덩어리로 인식한다. 하지만 언어 모델은 텍스트를 글자 그대로 처리하지 않는다. 모델이 다룰 수 있는 단위인 토큰(token)으로 분해한 뒤 처리한다.

토크나이저는 언어마다, 모델마다 분해 방식이 다르다. 영어는 단어 단위로 비교적 크게 쪼개지는 편이고, 한국어처럼 조사·어미가 붙는 교착어는 더 잘게 나뉘는 경우가 많다.

"안녕하세요 반갑습니다"를 예시 토크나이저로 분해하면:

입력: "안녕하세요 반갑습니다"

토크나이저 출력:
  위치 1 → 토큰: "안녕"   (ID: 12847)
  위치 2 → 토큰: "하"     (ID:   589)
  위치 3 → 토큰: "세요"   (ID:  3301)
  위치 4 → 토큰: "반갑"   (ID: 15622)
  위치 5 → 토큰: "습"     (ID:   441)
  위치 6 → 토큰: "니다"   (ID:  1037)

모델이 실제로 받는 것은 [12847, 589, 3301, 15622, 441, 1037]이라는 숫자 배열이다.

숫자에서 벡터로 — 임베딩

숫자 12847은 "안녕"을 가리키는 ID일 뿐이다. 의미 정보가 없다. 모델은 이 ID를 임베딩(Embedding)이라는 과정을 통해 수백~수천 개의 실수로 이루어진 벡터로 바꾼다.

"안녕" (ID: 12847) → [0.31, -0.77, 1.24, 0.05, -0.92, ... (2048개 숫자)]
"하"   (ID:   589) → [0.11,  0.43, 0.08, 1.33,  0.21, ... (2048개 숫자)]
...

이 벡터가 "안녕"의 의미를 숫자로 표현한 것이다. 비슷한 의미를 가진 단어들은 벡터 공간에서 가까이 위치한다. 이렇게 6개의 토큰이 각각 2048차원 벡터로 변환되면 모델의 본격적인 처리가 시작된다.


2단계: Attention — 토큰들이 서로 대화하는 법

문맥 없이는 의미를 알 수 없다

"반갑"이라는 토큰만 있으면 그게 무슨 맥락인지 알 수 없다. "안녕하세요 반갑"에서 쓰인 "반갑"과 "갑자기 반갑"에서의 "반갑"은 같은 단어지만 의미가 미묘하게 다르다. 모델이 "반갑"을 제대로 이해하려면 앞에 어떤 토큰이 왔는지를 참조해야 한다.

이것을 처리하는 메커니즘이 Attention(어텐션)이다.

각 토큰은 "나는 다른 토큰들과 얼마나 관련이 있나?"를 계산하고, 관련도가 높은 토큰의 정보를 많이 끌어온다.

Q, K, V — 세 가지 역할

Attention의 핵심에는 세 가지 벡터가 있다. Q(Query), K(Key), V(Value)다.

도서관 비유

도서관에서 "인공지능을 쉽게 설명한 책"을 찾는다고 하자.

  • Q (Query): 내가 사서에게 하는 질문. "인공지능을 쉽게 설명한 책 어디 있나요?"
  • K (Key): 각 책의 인덱스 카드에 적힌 키워드. 책마다 "인공지능, 머신러닝", "요리, 베이킹" 같은 라벨이 붙어 있다.
  • V (Value): 책 자체의 내용. 인덱스 카드가 가리키는 실제 정보.

사서는 내 질문(Q)과 각 책의 인덱스 카드(K)를 비교해 관련도 점수를 매긴다. 그 점수에 따라 관련 높은 책의 내용(V)을 가중치를 달리해 합산해서 답변을 만들어준다.

Attention이 정확히 이 과정이다.

트랜스포머 레이어에서 Q, K, V 생성

각 토큰의 임베딩 벡터는 트랜스포머 레이어에 들어가면서 Q, K, V라는 세 벡터로 변환된다. 이 변환은 학습된 가중치 행렬과의 곱셈으로 이루어진다.

"안녕" 임베딩 → Q₁, K₁, V₁
"하"   임베딩 → Q₂, K₂, V₂
"세요" 임베딩 → Q₃, K₃, V₃
"반갑" 임베딩 → Q₄, K₄, V₄
"습"   임베딩 → Q₅, K₅, V₅
"니다" 임베딩 → Q₆, K₆, V₆

"니다"가 다른 토큰들을 참조하는 과정

언어 모델은 인과적(causal) 어텐션을 사용한다. 각 토큰은 자신보다 앞에 등장한 토큰들과 자기 자신만 참조할 수 있다. 미래의 토큰은 볼 수 없다.

"니다"(6번)의 Q₆가 각 토큰의 K와 점수를 계산한다.

Q₆ · K₁ → "안녕"과의 유사도:  0.30
Q₆ · K₂ → "하"와의 유사도:    0.08
Q₆ · K₃ → "세요"와의 유사도:  0.05
Q₆ · K₄ → "반갑"과의 유사도:  0.42
Q₆ · K₅ → "습"과의 유사도:    0.88  ← 높음 ("습니다"가 자주 함께 나타남)
Q₆ · K₆ → "니다" 자신의 유사도: 1.00 ← 자기 자신

이 점수에 소프트맥스(softmax)를 적용하면 합이 1이 되는 가중치 배분으로 변환된다.

가중치: [0.12, 0.04, 0.02, 0.17, 0.30, 0.35]

이 가중치로 각 V를 합산한다.

"니다"의 최종 표현 = 0.12×V₁ + 0.04×V₂ + 0.02×V₃ + 0.17×V₄ + 0.30×V₅ + 0.35×V₆

"습"(V₅)과 자기 자신(V₆)의 비중이 가장 높다. "니다"는 "습"과의 연결을 강하게 반영한 표현을 갖게 된다. "습니다"라는 패턴을 학습한 결과다.

이 계산이 모든 토큰에서, 모든 레이어에서 반복된다. Qwen3-4B는 36개 레이어가 있으므로 이 과정이 36번 쌓인다. 위쪽 레이어로 갈수록 더 추상적이고 문맥이 풍부한 표현이 만들어진다.


3단계: KV Cache — 계산 결과를 저장해야 하는 이유

자동회귀 생성의 구조

모델이 응답을 생성할 때 한 번에 모든 단어를 만들어내지 않는다. 토큰 하나씩, 순차적으로 만들어낸다.

"안녕하세요 반갑습니다" 다음에 올 내용을 모델이 생성한다고 가정하자.

스텝 1: [안녕, 하, 세요, 반갑, 습, 니다] → 다음 토큰 예측: "."
스텝 2: [안녕, 하, 세요, 반갑, 습, 니다, "."] → 다음 토큰 예측: <종료>
스텝 3: 종료 토큰 확인 → 생성 완료

KV Cache 없이 생성하면

스텝 1에서 "."를 예측할 때, "니다"가 [안녕, 하, 세요, 반갑, 습]의 K, V와 어텐션 계산을 수행한다.

스텝 2에서 <종료>를 예측할 때, "."가 [안녕, 하, 세요, 반갑, 습, 니다]의 K, V와 어텐션 계산을 수행한다.

그런데 [안녕, 하, 세요, 반갑, 습, 니다]의 K, V는 스텝 1에서 이미 계산한 것과 동일하다. 스텝 1에서 계산한 결과를 버렸기 때문에 스텝 2에서 똑같은 계산을 다시 한다.

생성 길이가 100 토큰이라면, 처음 6개 입력 토큰의 K, V를 100번 반복해서 계산하게 된다. 생성 길이가 길어질수록 낭비가 기하급수적으로 커진다.

KV Cache가 하는 것

각 토큰의 K와 V를 처음 계산하는 순간 메모리에 저장해둔다.

스텝 1 처리 중:
  안녕 K₁, V₁ → 저장
  하   K₂, V₂ → 저장
  세요 K₃, V₃ → 저장
  반갑 K₄, V₄ → 저장
  습   K₅, V₅ → 저장
  니다 K₆, V₆ → 저장
  "."  K₇, V₇ → 계산 후 저장, 다음 토큰 예측

스텝 2 처리 중:
  [안녕~니다] K/V → 캐시에서 바로 읽어옴 (재계산 없음)
  "."  K₇, V₇ → 캐시에서 읽어옴
  <종료> K₈, V₈ → 새로 계산

이것이 KV Cache다. 이미 계산한 K, V를 GPU 메모리에 보관해두고 이후 스텝에서 재사용한다. 계산량이 크게 줄어 응답 속도가 빨라진다.

KV Cache는 GPU 메모리에 얼마나 차지하나

KV Cache의 크기는 생각보다 크다. 계산해 보면 실감이 온다.

LLaMA-2-13B 기준 예시:

  • 레이어 수: 40
  • 어텐션 헤드 수: 40
  • 헤드 차원: 128
  • 데이터 타입: FP16 (2바이트)

토큰 하나당 KV Cache:

2(K, V) × 40레이어 × 40헤드 × 128차원 × 2바이트
= 2 × 40 × 40 × 128 × 2
≈ 819,200 바이트 ≈ 0.78 MB

max_model_len = 4096 토큰으로 설정 시:

0.78 MB × 4096 토큰 ≈ 3.2 GB

모델 가중치 자체가 A100 40GB에서 약 26GB를 차지하므로, 남은 14GB 중 상당 부분이 KV Cache로 쓰인다. 동시에 처리할 수 있는 요청 수(시퀀스 수)가 KV Cache 크기에 직접적으로 제한된다.


4단계: 기존 시스템의 메모리 낭비

연속 메모리를 통째로 예약하는 방식

기존 LLM 서빙 시스템은 요청이 들어오면, 그 요청이 앞으로 최대 길이까지 자랄 수 있다는 가정 하에 메모리를 미리 통째로 잡아뒀다.

비유: 결혼식장 대관

하객이 최대 500명 올 수 있다고 가정하고, 실제 인원을 모르니 500명짜리 연회장을 통째로 빌린다. 막상 당일에 50명만 왔다. 나머지 450자리는 비어 있지만 다른 행사가 쓸 수 없다. 이 낭비가 매일 반복된다.

기존 KV Cache 예약 방식이 정확히 이렇다.

max_model_len = 2048 토큰으로 설정했을 때:

요청 1: "안녕하세요 반갑습니다" (6토큰)
  → 2048 토큰 공간 즉시 예약
  → 실제 사용: 6토큰
  → 낭비: 2042 토큰 분량

요청 2: 20토큰짜리 질문
  → 2048 토큰 공간 즉시 예약
  → 실제 사용: 20토큰
  → 낭비: 2028 토큰 분량

요청 3: 15토큰짜리 질문
  → 2048 토큰 공간 즉시 예약
  → 낭비: 2033 토큰 분량

GPU에서 처리 중인 요청이 10개라면, 10개 모두 2048 토큰 분량의 공간을 선점한다. 실제 사용량이 얼마든 관계없이. 이것이 GPU 메모리의 60~80%가 낭비되던 이유다.

왜 연속 메모리가 필요했나

GPU의 어텐션 연산은 K, V 배열이 메모리에 연속하게 배치되어 있어야 빠르게 처리된다. 마치 배열(array)처럼 시작 주소부터 끝까지 연속된 주소를 가져야 GPU가 한 번에 쭉 읽어올 수 있었다.

만약 중간에 다른 데이터가 끼어 있거나, 조각난 공간 여기저기에 흩어져 있으면 GPU가 여러 번 따로 접근해야 해서 속도가 급격히 느려진다.

그래서 선택지가 없었다. "이 요청이 나중에 2048 토큰까지 자랄 수 있으니, 지금 당장 2048 토큰 분량의 연속 공간을 확보해 둔다." 이 방식만이 가능했다.


5단계: PagedAttention — 운영체제의 페이징을 GPU에

운영체제는 같은 문제를 이미 풀었다

수십 년 전 컴퓨터 운영체제도 같은 문제를 겪었다. 프로그램마다 메모리를 "미리 연속으로 확보"해주면 낭비와 충돌이 심각해진다. 두 프로그램이 같은 주소 범위를 필요로 하면 공존할 수 없다.

OS는 가상 메모리(Virtual Memory)와 페이징(Paging)으로 이 문제를 풀었다.

OS 페이징의 아이디어:

물리 메모리를 "페이지"라는 고정 크기 단위로 나눈다. 각 프로그램은 "논리 주소"를 사용하고, 페이지 테이블(Page Table)이 논리 주소를 실제 물리 주소로 변환한다. 물리적으로 흩어진 공간도 논리적으로는 연속처럼 보인다.

프로그램 A가 보는 논리 주소:  [0x0000 ~ 0x3FFF]
실제 물리 메모리 위치:
  논리 0x0000~0x0FFF → 물리 페이지 47번
  논리 0x1000~0x1FFF → 물리 페이지 12번 (떨어진 곳)
  논리 0x2000~0x2FFF → 물리 페이지 89번 (또 다른 곳)

프로그램은 연속된 공간인 것처럼 쓰고, OS가 분산된 물리 위치를 추적한다.

vLLM의 PagedAttention

PagedAttention(Kwon et al., 2023)은 이 아이디어를 KV Cache 관리에 그대로 적용했다.

GPU 메모리를 KV Block이라는 고정 크기 단위로 나눈다. vLLM의 기본 블록 크기는 16 토큰의 K, V를 담을 수 있는 크기다.

GPU 메모리 전체를 블록 단위로 분할:

[블록 0][블록 1][블록 2][블록 3]...[블록 N]
각 블록: 16토큰의 KV 저장 가능

블록 테이블(Block Table)이 각 요청의 논리 블록 번호를 실제 물리 블록으로 매핑한다.

"안녕하세요 반갑습니다"(6토큰) 요청 처리:

물리 블록 42번 할당:
  슬롯 0: 안녕 K₁, V₁
  슬롯 1: 하   K₂, V₂
  슬롯 2: 세요 K₃, V₃
  슬롯 3: 반갑 K₄, V₄
  슬롯 4: 습   K₅, V₅
  슬롯 5: 니다 K₆, V₆
  슬롯 6~15: 비어 있음 (이후 생성 토큰을 위해 대기)

이 요청의 블록 테이블:
  논리 블록 0 → 물리 블록 42

같은 GPU에서 동시에 다른 요청이 들어오면:

요청 B의 블록 테이블:
  논리 블록 0 → 물리 블록 7   (어디든 빈 블록에 할당)
  논리 블록 1 → 물리 블록 23  (물리 블록들이 연속할 필요 없음)
  논리 블록 2 → 물리 블록 61

물리 블록이 어디에 있든 블록 테이블이 위치를 추적하므로 연속 배치가 불필요하다.

기존 방식과 PagedAttention 메모리 사용 비교

기존 방식 (연속 예약):

GPU 메모리 (100블록 분량, max_model_len=2048, 블록=16토큰):
┌──────────────────────────────────┬──────────────────────────────────┐
│  요청 A: 블록 0~63 예약           │  요청 B: 블록 64~127 예약         │
│  실제 사용: 블록 0번 일부 (6토큰)  │  실제 사용: 블록 64번 일부 (8토큰) │
│  낭비: 63.something 블록          │  낭비: 63.something 블록           │
└──────────────────────────────────┴──────────────────────────────────┘
                  메모리 낭비 > 98%

PagedAttention:

GPU 메모리 (100블록):
┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
│A:0│B:0│빈 │B:1│빈 │빈 │C:0│빈 │빈 │B:2│
└───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
각 요청은 필요한 만큼만, 빈 블록 어디든 할당

블록 테이블:
  요청 A: [논리0 → 물리0]
  요청 B: [논리0 → 물리1] [논리1 → 물리3] [논리2 → 물리9]
  요청 C: [논리0 → 물리6]

낭비가 4% 미만인 이유

PagedAttention에서 낭비는 오직 한 곳에서만 발생한다. 각 요청의 마지막 블록이다.

블록 크기 16토큰에서 6토큰짜리 요청:

마지막 블록 (블록 0):
  사용 슬롯: 6개 (안녕, 하, 세요, 반갑, 습, 니다)
  빈 슬롯:  10개 ← 이것만 낭비

한 요청이 32토큰이라면:

블록 0: 16토큰 꽉 참 → 낭비 없음
블록 1: 16토큰 꽉 참 → 낭비 없음
총 낭비: 0

한 요청이 17토큰이라면:

블록 0: 16토큰 꽉 참 → 낭비 없음
블록 1: 1토큰 사용, 15토큰 빈 슬롯 → 낭비
최대 낭비: 블록 크기 - 1 = 15 슬롯

전체 블록 개수 대비 낭비는 요청 수가 많아질수록 평균적으로 블록 크기의 절반 미만으로 수렴한다. 이것이 낭비를 4% 미만으로 유지할 수 있는 이유다. 기존 방식의 60~80%에 비하면 압도적인 차이다.


6단계: Prefill과 Decode — 처리의 두 국면

Prefill: 입력을 한 번에 처리하는 단계

입력 토큰 전체를 병렬로 처리하는 단계다. GPU는 여러 계산을 동시에 수행할 수 있으므로, 6개 토큰을 순서대로 하나씩 처리하는 게 아니라 한 번에 처리한다.

입력: [안녕, 하, 세요, 반갑, 습, 니다]
       ↓    ↓    ↓    ↓    ↓    ↓
    (6개 토큰 병렬 처리 — 36개 레이어)
       ↓
KV Cache에 K₁~K₆, V₁~V₆ 저장
       ↓
첫 출력 토큰 예측 (".")

계산 집약적(compute-bound) 단계다. GPU의 계산 능력을 최대로 활용한다.

블록 할당은 Prefill 시작 시 이루어진다. 6토큰이면 1개 블록(16토큰 크기)을 할당한다. ⌈6 ÷ 16⌉ = 1블록.

Decode: 한 번에 하나씩 생성하는 단계

첫 출력 토큰이 나온 뒤, 이후 토큰들은 한 번에 하나씩 생성된다. 이전 단계의 출력이 다음 단계의 입력이 된다.

스텝 1:
  새 토큰 "."의 Q₇ × KV Cache의 K₁~K₆ → 어텐션 계산
  다음 토큰 예측
  K₇, V₇ → KV Cache에 추가 (블록 42의 슬롯 6번에 저장)

스텝 2:
  새 토큰의 Q₈ × KV Cache의 K₁~K₇ → 어텐션 계산
  ...

블록 42 슬롯 사용 현황:
  [0:안녕][1:하][2:세요][3:반갑][4:습][5:니다][6:.][7:새토큰][비어있음...]

Decode는 메모리 집약적(memory-bound) 단계다. 매 스텝마다 KV Cache 전체를 GPU 메모리에서 읽어와야 하기 때문이다. GPU의 연산 능력보다 메모리 대역폭이 병목이 된다.

Decode 중 KV Cache가 블록 크기를 초과하면:

현재 토큰 수가 16개를 넘는 순간 → 새 물리 블록 할당
블록 테이블에 논리 블록 1 → 물리 블록 X 매핑 추가
생성 계속

블록을 미리 한꺼번에 잡지 않고, 필요한 시점에만 할당한다.


7단계: vLLM이 "안녕하세요 반갑습니다"를 처리하는 전체 흐름

지금까지 배운 개념들을 묶어 처음부터 끝까지 한 번에 추적한다.

전체 처리 순서

1. API 요청 수신

POST http://localhost:8000/v1/chat/completions
{
  "model": "Qwen/Qwen3-4B",
  "messages": [{"role": "user", "content": "안녕하세요 반갑습니다"}]
}

2. 토크나이징

"안녕하세요 반갑습니다"
    ↓
[12847, 589, 3301, 15622, 441, 1037]
(안녕,   하,  세요,  반갑,  습,  니다)

3. 스케줄러 — 블록 할당

vLLM의 스케줄러가 이 요청을 처리할 블록을 할당한다.

블록 풀에서 빈 블록 1개 찾음: 물리 블록 42번
블록 테이블 생성:
  이 요청의 논리 블록 0 → 물리 블록 42번

4. Prefill 실행

6개 토큰 병렬 처리 (GPU에서):
  임베딩: [12847, 589, 3301, 15622, 441, 1037] → 각각 2048차원 벡터
  36개 레이어 × 어텐션 계산
  
각 레이어의 K, V → 물리 블록 42에 저장:
  슬롯 0: 안녕 K/V
  슬롯 1: 하   K/V
  슬롯 2: 세요 K/V
  슬롯 3: 반갑 K/V
  슬롯 4: 습   K/V
  슬롯 5: 니다 K/V
  슬롯 6~15: 비어 있음

첫 출력 토큰 예측: "."

5. Decode 시작

스텝 1 — "." 처리:
  "."의 Q × 블록 42의 슬롯 0~5의 K → 어텐션 스코어
  어텐션 계산 완료
  "."의 K/V → 블록 42 슬롯 6에 저장
  다음 토큰 예측: <EOS(종료 토큰)>

블록 42 상태:
  [안녕][하][세요][반갑][습][니다][.][빈][빈]...[빈]

6. 종료 확인

EOS 토큰이 예측됨 → 생성 종료.

7. 응답 반환 및 블록 해제

응답 반환: "안녕하세요 반갑습니다."
물리 블록 42 해제 → 블록 풀로 반환
→ 다른 요청이 즉시 이 블록을 재사용 가능

8단계: Continuous Batching — 여러 요청을 어떻게 동시에 처리하나

기존 방식 — 배치가 끝날 때까지 기다림

기존 배치 처리는 "한 배치 안의 모든 요청이 완료될 때까지 새 요청을 받지 않는다"는 방식이었다.

배치 1: [요청A(5토큰 생성), 요청B(50토큰 생성), 요청C(3토큰 생성)]

A 완료(금방) → 기다림
C 완료(금방) → 기다림
B 완료(오래 걸림) → 배치 1 종료

이 동안 대기 중인 요청 D, E, F는 B가 끝날 때까지 처리 불가
GPU는 A, C가 완료된 뒤 유휴 상태

B가 긴 응답을 생성하는 동안 GPU 슬롯이 절반 놀고 있다.

vLLM의 Continuous Batching

완료된 요청의 슬롯에 즉시 새 요청을 채워 넣는다.

배치 슬롯: [요청A, 요청B, 요청C]
         ↓
A 완료 → 슬롯 즉시 해제 → [요청D, 요청B, 요청C]
         ↓
C 완료 → 슬롯 즉시 해제 → [요청D, 요청B, 요청E]
         ↓
D 완료 → 슬롯 즉시 해제 → [요청F, 요청B, 요청E]
         ↓
         ...계속 채워 넣음

GPU 슬롯이 쉬는 시간 없이 새 요청으로 채워진다.

PagedAttention이 이를 가능하게 한다. 각 요청의 KV Cache가 독립적인 물리 블록에 저장되므로, 요청 A가 완료되면 A의 블록만 해제하고 그 공간에 새 요청 D를 올릴 수 있다. 기존 방식처럼 요청별로 연속 공간을 통째로 잡고 있지 않기 때문이다.

이 구조가 이전 시스템 대비 최대 23배 처리량 향상의 핵심 동인 중 하나다.


9단계: Prefix Caching — 공통 프롬프트를 공유하다

같은 시스템 프롬프트를 여럿이 쓸 때

실제 서비스에서는 모든 사용자가 같은 시스템 프롬프트를 공유한다.

시스템 프롬프트: "당신은 친절한 AI 어시스턴트입니다. 항상 한국어로 답변하세요."
  → 토크나이징 결과: [당신은, 친절한, AI, 어시스턴트, 입니다, 항상, 한국어로, 답변하세요] (8토큰)

사용자 A: 시스템 프롬프트 + "안녕하세요 반갑습니다"
사용자 B: 시스템 프롬프트 + "오늘 날씨가 어때요?"
사용자 C: 시스템 프롬프트 + "파이썬 코드 좀 도와줘"

시스템 프롬프트 8토큰의 KV Cache는 세 요청 모두에서 동일하다. 매 요청마다 이 8토큰을 독립적으로 계산하고 저장하는 것은 낭비다.

Copy-on-Write로 안전하게 공유

PagedAttention은 공통 프롬프트 부분의 물리 블록을 여러 요청이 공유하도록 한다. 블록에 참조 카운트(Reference Count)를 관리한다.

시스템 프롬프트 8토큰 → 물리 블록 5번에 저장
                        참조 카운트: 3 (A, B, C 모두 참조)

요청 A의 블록 테이블: [논리0 → 물리블록5] [논리1 → 물리블록12]
요청 B의 블록 테이블: [논리0 → 물리블록5] [논리1 → 물리블록37]
요청 C의 블록 테이블: [논리0 → 물리블록5] [논리1 → 물리블록61]

블록 5번은 세 요청이 읽기 전용으로 공유한다.

새 토큰이 생성되면 — 즉 블록에 새 내용을 써야 하면 — Copy-on-Write가 발동한다. 쓰기가 발생하는 시점에 그 블록을 복사해서 새 물리 블록에 저장하고, 해당 요청의 블록 테이블을 새 블록으로 업데이트한다. 다른 요청의 블록 5번은 그대로 유지된다.

이 방식으로 병렬 샘플링(같은 프롬프트에서 여러 응답 생성)에서 메모리 사용량을 55% 줄이고 처리량을 2.2배 높일 수 있다.


정리 — 전체 개념 한눈에 보기

개념역할비유
토큰모델이 처리하는 최소 단위레고 블록
임베딩토큰 ID를 의미 벡터로 변환색과 모양으로 블록을 표현
Q (Query)현재 토큰이 찾는 정보도서관 검색 질문
K (Key)각 토큰의 특징 인덱스책의 인덱스 카드
V (Value)각 토큰의 실제 의미 정보책의 내용
인과적 어텐션이전 토큰만 참조 가능이미 읽은 페이지만 참조
KV Cache계산한 K, V를 저장해 재사용독서 중 만든 노트
Prefill 단계입력 토큰 전체를 병렬 처리책 전체를 한 번에 통독
Decode 단계출력 토큰을 하나씩 순차 생성한 글자씩 받아쓰기
기존 연속 메모리 예약최대 길이 분량을 선점50명인데 500명 홀 전체 대관
KV Block고정 크기 메모리 단위주차장 한 칸
블록 테이블논리 블록 → 물리 블록 매핑주차 위치 메모
PagedAttention블록 단위 KV Cache 관리빈 칸에 그때그때 주차
Continuous Batching완료 슬롯에 즉시 새 요청 삽입손님이 나가자마자 새 손님 안내
Prefix Caching공통 블록을 여러 요청이 공유같은 교재를 여럿이 돌려보기
Copy-on-Write공유 블록에 쓰기 발생 시 복사교재에 필기할 때만 내 노트로 옮겨 씀

vLLM이 동일 GPU에서 HuggingFace Transformers 대비 최대 24배 처리량을 내는 이유가 이 구조에 있다. KV Cache 낭비를 4% 미만으로 줄이고, 남은 메모리로 더 많은 요청을 동시에 처리하며, Continuous Batching으로 GPU 유휴 시간을 없애는 세 가지가 맞물린 결과다.


부록: Attention 점수 계산 공식 (수식이 궁금한 분을 위해)

어텐션 스코어는 다음과 같이 계산된다.

Attention(Q, K, V) = softmax( Q × Kᵀ / √d ) × V
  • Q × Kᵀ: 현재 토큰의 Query와 모든 이전 토큰의 Key를 내적(dot product)해 유사도 점수 행렬을 만든다.
  • / √d: 차원 수(d)의 제곱근으로 나눠 스코어 크기를 안정시킨다. 차원이 클수록 내적 값이 커지는 경향을 보정한다.
  • softmax(...): 모든 스코어의 합이 1이 되도록 정규화. 각 토큰에 대한 가중치 분포가 된다.
  • × V: 정규화된 가중치로 Value 벡터를 가중 합산한다.

PagedAttention에서 이 연산은 물리적으로 흩어진 블록들을 블록 테이블로 찾아가며 처리한다. 각 블록을 찾아 Key를 불러오고, 부분 점수를 계산하고, 실행 중인 softmax 정규화를 업데이트하고, 정규화된 가중치와 Value를 곱해 결과에 누적하는 과정을 블록 단위로 순회하며 수행한다.

Tags
vLLMInferenceLLMGPU아키텍처KV CachePagedAttention