Skip to content

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

✨ Learn with Quiz
|

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

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

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 자체도 크게 재작성되었고 비교 대상도 전부 발전했습니다. 이 논문은 성능 보증서가 아니라 설계의 근거로 읽는 편이 정확합니다.

직접 해보기

참고 자료

Inside vLLM (2) — Why PagedAttention Splits the KV Cache Into Pages

Why Bring Up Paging Again

Part 1 mapped out the path a request travels. This part looks at the piece of that path that eats the most GPU memory: the KV cache.

A single request being generated has to hold the key and value tensors for every token it has seen so far. Throw that away, and every additional token would require recomputing the entire sequence from scratch. The problem is that this chunk of memory is a different size for every request, and it keeps growing as generation proceeds. There is no way to know in advance how much it will grow, because there is no way to know in advance when the model will stop.

The problem of continually appending to data whose final size is unknown is not new. Computer science has solved it before, in the form of an operating system's virtual memory and paging. The PagedAttention paper states outright that it brought exactly that idea over to attention.

Everything here was verified against the official documentation and source on 2026-08-12. vLLM moves fast, so re-check settings and behavior against the documentation for the version you are running.

Two Kinds of Waste That Contiguous Allocation Creates

Looking at the pre-paging approach first makes clear why paging was needed. A naive implementation reserves a contiguous block of memory sized to "the maximum length this request could ever use" as soon as the request arrives. It is safe, and it is easy to implement. It is also enormously wasteful.

Assuming a max of 2048 tokens, contiguous space reserved in advance for each request

Request A: [■■■■■□□□□□□□□□□□□□□□□□□□]  used 320  / reserved 2048
Request B: [■■□□□□□□□□□□□□□□□□□□□□□□]  used 96   / reserved 2048
Request C: [■■■■■■■■■■■■■■□□□□□□□□□□]  used 1180 / reserved 2048

■ space actually used   □ reserved but unused (internal fragmentation)

This creates two kinds of waste.

The first is internal fragmentation. It is every empty square in the picture above. Request B finished after using only 96 tokens, yet it held on to a 2048-token slot the whole time. The more a service leans toward short answers, the bigger this loss gets. And that space cannot be used by any other request, because it already belongs to B.

The second is external fragmentation. As requests finish, small holes open up here and there, but each hole is small while a new request needs one large contiguous span. This is how you end up with "out of memory" even though the total free memory, added up, would have been enough.

The Block Table: Logically Contiguous, Physically Scattered

PagedAttention's solution is to stop handing a request one large contiguous span. Instead, the KV cache is cut into fixed-size blocks, and a request is handed one block at a time as it needs them, wherever that block physically happens to sit.

So how does the attention kernel see these scattered pieces as a single sequence? The block table bridges the gap. Each request keeps a mapping like "my block 3 is physical block 41," and the kernel follows this table to find the block it needs. It is the same job an operating system's page table does.

Request A's view (logical)      Block table        Actual GPU memory (physical)
┌───────────────────────┐                       ┌────┬────┬────┬────┐
│ logical block 0 1 2 3 │   0 → physical 7      │ 0  │ 1  │ 2  │ 3  │
│ one unbroken sentence │   1 → physical 2      ├────┼────┼────┼────┤
└───────────────────────┘   2 → physical 19     │ 4  │ 5  │ 6  │ 7  │
                            3 → physical 4      ├────┼────┼────┼────┤
                                                │ …  │ 19 │ …  │ …  │
                                                └────┴────┴────┴────┘

This structure eliminates both kinds of waste at once. External fragmentation disappears by definition, because every block is the same size, so any free block can go to any request. Internal fragmentation shrinks down to just the leftover space in the last block, at most the block size minus one token per request. This is the basis for the paper's claim that it reduces KV cache waste to near zero.

The vLLM design documentation specifically notes that this kind of block is a different concept from a GPU thread block. It is an easy point to confuse when reading the docs, so the authors call out the distinction up front.

The Block Size Trade-off

So how is block size decided? vLLM has a --block-size argument. In the source, though, CacheConfig declares this value without a fixed default, and the documentation only says that a default is used if you do not supply one. The number that actually gets applied is decided by the platform and the attention backend. So this post will not name a specific number as the default. The default varies by version and environment, so check your startup logs and the documentation for the version you are running.

The direction of the trade-off, though, is clear.

Set the block size large, and the number of blocks drops, which lightens management overhead and table lookups. In exchange, more space gets wasted in the last block. And because prefix caching, covered in part 5, only reuses blocks that are completely full, a larger block size makes it easier to miss the whole block over a small difference in the prompt.

Set it small, and waste goes down and cache hits align more finely. In exchange, representing the same sequence takes more blocks, and the block table gets longer.

In practice, this is rarely the first value you touch. Adjusting --gpu-memory-utilization and the length limits first usually has a bigger effect. Still, it is worth remembering that block size belongs on the suspect list when prefix cache hit rate comes in lower than expected.

Sharing: A Second Effect That Cuts Waste

Managing memory in blocks brings one more benefit along with it: multiple requests can point to the same block if it holds identical content. The paper places this flexible sharing alongside its other results as a core contribution.

Picture a hundred requests arriving at once, all sharing the same system prompt. Under contiguous allocation, that same leading segment gets computed a hundred times and stored a hundred times. Under the block scheme, you keep just one copy of the blocks that make up that leading segment, and a hundred block tables all point at the same physical blocks. Extending this idea across requests is prefix caching, the topic of part 5.

What the Original Paper Actually Claims

To avoid overstating anything, here is the source stated precisely. The original paper is arXiv:2309.06180, titled Efficient Memory Management for Large Language Model Serving with PagedAttention. Written by Woosuk Kwon and others, it introduced PagedAttention and vLLM together for the first time.

The results the abstract states are these: it nearly eliminates KV cache memory waste, it shares KV cache flexibly within and across requests, and at the same level of latency it raised throughput 2 to 4 times over the state-of-the-art systems of the time.

One thing to be careful about here. That 2 to 4 times figure comes from a 2023 paper comparing against the baselines of that time. It is not a number your deployment will produce today. vLLM itself has been substantially rewritten since then, and every comparison target has moved forward too. It is more accurate to read this paper as the rationale for a design, not as a performance guarantee.

Try It Yourself

References