Skip to content
Published on

vLLM 내부 구조 (2) — PagedAttention은 KV 캐시를 왜 페이지로 쪼갰는가

공유하기
Authors

페이지 이야기를 왜 다시 꺼내는가

1편에서 요청이 지나는 경로를 그렸습니다. 이번 편은 그 경로에서 GPU 메모리를 가장 많이 먹는 부분, 즉 KV 캐시를 봅니다.

생성 중인 요청 하나는 지금까지 본 모든 토큰의 키와 값 텐서를 들고 있어야 합니다. 이걸 버리면 토큰을 하나 더 만들 때마다 문장 전체를 다시 계산해야 하니까요. 문제는 이 덩어리가 요청마다 크기가 다르고, 생성이 진행될수록 계속 자란다는 점입니다. 얼마나 자랄지는 미리 알 수 없습니다. 모델이 언제 끝맺을지 모르기 때문입니다.

크기를 모르는 데이터를 계속 이어 붙여야 하는 문제. 컴퓨터 과학이 이 문제를 푼 적이 있습니다. 운영체제의 가상 메모리와 페이징입니다. PagedAttention 논문은 정확히 그 발상을 어텐션에 가져왔다고 스스로 밝히고 있습니다.

내용은 2026-08-12에 공식 문서·소스에서 확인했습니다. vLLM은 변화가 빠르니 설정값과 동작은 사용 중인 버전의 문서로 다시 확인하세요.

연속 할당이 만드는 두 가지 낭비

페이징 이전 방식을 먼저 봐야 왜 필요했는지가 보입니다. 소박한 구현은 요청이 들어오면 "이 요청이 최대로 쓸 수 있는 길이"만큼 연속된 메모리를 미리 잡습니다. 안전하고 구현이 쉽습니다. 그리고 대단히 낭비가 심합니다.

최대 2048 토큰을 가정하고 요청마다 연속 공간을 미리 예약한 경우

요청 A: [■■■■■□□□□□□□□□□□□□□□□□□□]  실제 사용 320 / 예약 2048
요청 B: [■■□□□□□□□□□□□□□□□□□□□□□□]  실제 사용 96  / 예약 2048
요청 C: [■■■■■■■■■■■■■■□□□□□□□□□□]  실제 사용 1180 / 예약 2048

■ 실제로 쓰는 자리   □ 잡아 두고 안 쓰는 자리 (내부 단편화)

여기서 생기는 낭비가 두 종류입니다.

첫째는 내부 단편화입니다. 위 그림의 네모 빈칸 전부입니다. 요청 B는 96 토큰만 쓰고 끝났는데 2048 토큰 자리를 붙들고 있었습니다. 짧은 답변이 많은 서비스일수록 이 손실이 커집니다. 그리고 이 자리는 다른 요청이 쓸 수 없습니다. 이미 B의 소유이기 때문입니다.

둘째는 외부 단편화입니다. 요청들이 끝나면서 여기저기 빈 구멍이 생기는데, 각 구멍은 작고 새 요청은 큰 연속 공간을 요구합니다. 전체 여유 메모리를 합치면 충분한데도 "메모리가 없다"는 말이 나오는 상황이 이렇게 만들어집니다.

블록 테이블: 논리적으로 연속, 물리적으로 흩어짐

PagedAttention의 해법은 요청에게 연속된 큰 땅을 주지 않는 것입니다. KV 캐시를 고정 크기 블록으로 잘라 두고, 요청에는 필요할 때마다 블록을 한 장씩 나눠 줍니다. 물리적으로 어디에 있든 상관없습니다.

그러면 어텐션 커널은 흩어진 조각을 어떻게 하나의 문장으로 볼까요. 블록 테이블이 그 사이를 메웁니다. 요청마다 "내 3번째 블록은 물리 블록 41번"이라는 대응표를 들고 있고, 커널은 이 표를 보고 필요한 블록을 찾아갑니다. 운영체제가 페이지 테이블로 하는 일과 같습니다.

요청 A가 보는 세계 (논리)        블록 테이블        실제 GPU 메모리 (물리)
┌───────────────────────┐                       ┌────┬────┬────┬────┐
│ 논리블록 0 1 2 3      │   0 → 물리 7          │ 0  │ 1  │ 2  │ 3  │
│ 하나의 이어진 문장     │   1 → 물리 2          ├────┼────┼────┼────┤
└───────────────────────┘   2 → 물리 19         │ 4  │ 5  │ 6  │ 7  │
                            3 → 물리 4          ├────┼────┼────┼────┤
                                                │ …  │ 19 │ …  │ …  │
                                                └────┴────┴────┴────┘

이 구조가 앞의 낭비 두 가지를 한꺼번에 없앱니다. 외부 단편화는 정의상 사라집니다. 모든 블록이 같은 크기라서 어떤 빈 블록이든 어떤 요청에나 줄 수 있기 때문입니다. 내부 단편화는 마지막 블록의 남는 자리로만 줄어듭니다. 요청 하나당 최대 블록 크기에서 1을 뺀 만큼입니다. 논문이 KV 캐시 낭비를 거의 없앴다고 표현한 근거가 이것입니다.

vLLM 설계 문서는 이 블록이 GPU 스레드 블록과는 다른 개념이라고 따로 못박아 둡니다. 문서를 읽을 때 헷갈리기 쉬운 지점이라 저자가 미리 구분해 둔 것입니다.

블록 크기의 트레이드오프

그럼 블록 크기는 어떻게 정할까요. vLLM에는 --block-size 인자가 있습니다. 다만 소스의 CacheConfig에서 이 값은 기본이 지정되지 않은 상태로 선언되어 있고, 문서에도 값을 주지 않으면 기본값을 쓴다고만 적혀 있습니다. 실제로 적용되는 숫자는 플랫폼과 어텐션 백엔드가 정합니다. 그래서 여기서 특정 숫자를 기본값이라고 말하지 않겠습니다. 기본값은 버전과 환경에 따라 다르니 기동 로그와 사용 중인 버전의 문서를 확인하세요.

대신 방향은 분명합니다.

블록을 크게 잡으면 블록 개수가 줄어 관리 비용과 테이블 조회가 가벼워집니다. 대신 마지막 블록에서 버려지는 자리가 커집니다. 그리고 5편에서 볼 접두사 캐시는 꽉 찬 블록만 재사용하기 때문에, 블록이 크면 프롬프트가 조금만 달라도 통째로 놓치기 쉬워집니다.

블록을 작게 잡으면 낭비가 줄고 캐시 적중이 잘게 맞습니다. 대신 같은 문장을 표현하는 데 블록이 많아지고 블록 테이블도 길어집니다.

실무에서 이 값을 먼저 만질 일은 거의 없습니다. 대개는 --gpu-memory-utilization과 길이 한도를 먼저 조정하는 편이 효과가 큽니다. 다만 접두사 캐시 적중률이 기대보다 낮을 때 블록 크기가 용의자 목록에 오른다는 것은 기억해 둘 만합니다.

공유: 낭비를 줄이는 두 번째 효과

블록 단위로 관리하면 따라오는 이득이 하나 더 있습니다. 같은 내용을 담은 블록을 여러 요청이 함께 가리킬 수 있다는 것입니다. 논문도 이 유연한 공유를 핵심 성과로 나란히 내세웁니다.

시스템 프롬프트가 같은 요청 백 개가 동시에 들어오는 상황을 떠올려 보면 됩니다. 연속 할당 방식에서는 같은 앞부분을 백 번 계산하고 백 벌 저장합니다. 블록 방식에서는 그 앞부분에 해당하는 블록들을 한 벌만 두고 백 개의 블록 테이블이 같은 물리 블록을 가리키면 됩니다. 이 아이디어를 요청 사이로 확장한 것이 접두사 캐싱이고, 5편의 주제입니다.

원 논문이 실제로 주장한 것

과장을 피하기 위해 출처를 정확히 적어 둡니다. 원 논문은 arXiv:2309.06180이고 제목은 Efficient Memory Management for Large Language Model Serving with PagedAttention입니다. Woosuk Kwon 등이 쓴 이 논문이 PagedAttention과 vLLM을 함께 처음 제시했습니다.

논문 초록이 밝힌 성과는 KV 캐시 메모리 낭비를 거의 없앴다는 것, 요청 안팎에서 KV 캐시를 유연하게 공유한다는 것, 그리고 같은 수준의 지연에서 당시 최신 시스템 대비 처리량을 2배에서 4배로 높였다는 것입니다.

여기서 조심할 점이 있습니다. 저 2~4배는 2023년 논문이 당시 비교 대상과 견준 수치입니다. 지금 여러분의 배포에서 나올 숫자가 아닙니다. 그 사이 vLLM 자체도 크게 재작성되었고 비교 대상도 전부 발전했습니다. 이 논문은 성능 보증서가 아니라 설계의 근거로 읽는 편이 정확합니다.

직접 해보기

참고 자료