TT Lab
요금제

한국어

시작하기
블로그

블로그

서빙 엔진 안의 KV 캐시: PagedAttention, 연속 배칭, 프리픽스 캐시

상태: 초안 (2026-10-13), 일부 실측. 논문과 공식 문서는 2026-10-13 에 직접 열어 확인했습니다. 실측은 같은 사이트의 「토스 ML/AI Infra 준비」 14편에서 8GB급 GPU 한 장, Qwen3-1.7B, vLLM 0.31.0 으로 잰 값을 다시 인용했습니다. 큰 모델과 여러 GPU 에서의 값은 미실측입니다. 작성 규칙: 실측 데이터가 기본이고, 못 잰 것은 「미실측」, 확인하지 못한 것은 「미확인」으로 표시합니다.

목차

1. 처리량은 결국 배치 크기의 문제다

1편의 결론을 다시 적습니다. 디코드는 토큰 하나를 만들 때마다 가중치 전체를 메모리에서 읽어 와야 해서, 요청 하나만 돌리면 GPU 의 계산기가 대부분 놉니다. 가중치를 한 번 읽을 때 요청 여럿을 함께 디코드하면 같은 읽기로 토큰을 여러 개 만들 수 있습니다. 그래서 배치를 키우면 처리량이 오릅니다.

배치에 든 요청은 저마다 자기 KV 를 GPU 메모리에 두어야 합니다. 가중치를 올리고 남은 메모리를 KV 가 나눠 쓰므로, 같은 메모리에 KV 를 얼마나 빽빽하게 담느냐가 배치 크기, 곧 처리량을 정합니다. 서빙 엔진의 KV 관리 기법은 대부분 이 한 문장을 위한 것입니다.

기법 아끼는 것 처리량에 닿는 길
PagedAttention 예약·단편화로 버리는 KV 메모리 같은 메모리에 더 많은 요청 → 큰 배치
연속 배칭 일찍 끝난 요청의 빈 자리 걸음마다 배치를 가득 채움
청크 프리필 긴 프리필이 디코드를 멈추는 시간 토큰 간격 제약을 지키면서 배치를 키움
프리픽스 캐시 같은 앞부분의 KV 계산과 메모리 프리필 계산을 건너뛰고 블록을 함께 씀

2. PagedAttention: KV 를 블록으로 나누기

KV 메모리를 나누는 두 방법 옛 방식 — 요청마다 최대 길이만큼 이어 붙여 예약 A B A 예약 B 예약 틈 틈 아직 안 쓴 예약과 사이의 틈은 다른 요청이 못 쓴다. vLLM 논문: 기존 시스템은 KV 메모리의 20.4~38.2% 만 실제 토큰에 썼다. PagedAttention — 16토큰 블록과 블록 표 요청 A 요청 B A0A1B0 A2 B1 블록 표 A → 3, 7, 1 B → 3, 5 GPU 메모리 0 1 2 3 공유 4 5 6 7 8 9 10 11 · 블록은 빈 곳 어디에나 놓인다 → 틈이 생기지 않는다. · 낭비는 요청마다 마지막 블록의 빈칸뿐이다. · A0 과 B0 은 같은 프롬프트라 3번 블록 하나를 함께 본다. 블록 열쇠 = (앞 블록 해시, 이 블록의 토큰, LoRA 등 추가 정보) 토큰이 찬 칸 예약만 한 칸 빈 블록
그림: 연속 예약과 블록 나누기 비교

옛 방식의 낭비

vLLM 이전의 서빙 시스템은 요청마다 최대 출력 길이만큼 KV 자리를 한 덩어리로 미리 잡았습니다. 출력이 몇 토큰이 될지 모르니 넉넉히 잡을 수밖에 없습니다. vLLM 논문(Kwon 외, SOSP 2023)은 여기서 생기는 낭비를 셋으로 나눕니다.

논문의 측정에서 기존 시스템은 KV 메모리의 20.4~38.2% 만 실제 토큰 저장에 썼습니다. 같은 논문의 그림에 따르면 A100 에서 13B 모델을 서빙할 때 메모리의 약 65% 가 가중치, 30% 가까이가 KV 입니다. KV 몫이 원래 작은데 그 가운데 3분의 2 이상을 버린 셈입니다.

블록과 블록 표

PagedAttention 은 운영체제의 가상 메모리처럼 풉니다. KV 를 고정 크기 블록(기본 16토큰)으로 나누고, 요청마다 「논리 블록 → 실제 블록」의 블록 표를 둡니다. 블록은 GPU 메모리의 빈 곳 아무 데나 놓이고, 토큰이 블록 하나를 다 채우면 그때 새 블록을 하나 받습니다.

어텐션 커널은 블록 표를 따라가며 흩어진 블록에서 K·V 를 읽습니다. 연속된 배열이 아니어도 계산은 같습니다.

블록을 함께 쓰기

블록 단위로 관리하면 여러 요청이 같은 블록을 가리킬 수 있습니다. 한 프롬프트에서 답을 여러 개 뽑는 병렬 샘플링이나 빔 서치는 프롬프트 부분의 KV 가 똑같으므로, 블록에 참조 수를 세어 두고 함께 씁니다. 누군가 그 블록에 덧붙여 써야 할 때만 복사합니다(copy-on-write). 논문의 측정에서 이것으로 줄인 KV 메모리는 다음과 같습니다.

데이터셋 병렬 샘플링 빔 서치
Alpaca 6.1~9.8% 37.6~55.2%
ShareGPT 16.2~30.5% 44.3~66.3%

결과

논문은 같은 지연 수준에서 처리량이 FasterTransformer·Orca 보다 2~4배 높다고 보고합니다(OPT-13B·66B·175B 와 LLaMA-13B, A100 40GB 1장~A100 80GB 8장). ShareGPT 부하에서 감당하는 요청률은 FasterTransformer 보다 최대 22배였습니다. 비교 대상과 부하에 따라 배율이 크게 달라지므로 「2~4배」를 대표 값으로 읽으면 됩니다.

3. 연속 배칭: 빈 자리를 걸음마다 채우기

GPU 한 걸음마다 배치에 무엇이 들었나 가로 = 시간, 줄 = 배치의 자리, 칸 하나 = 한 걸음 (가) 정적 배치 — 모두 끝나야 다음 배치 자리 1자리 2자리 3 빈 자리, 새 요청 대기 (나) 연속 배칭 — 빈 자리에 바로 새 요청 자리 1자리 2자리 3 긴 걸음 자리 1·3 의 토큰이 늦다 (다) 청크 프리필 — 긴 프리필을 잘라 디코드와 섞기 자리 1자리 2자리 3 고르게 나온다 프리필 디코드 토큰 하나 빈 자리 긴 칸일수록 그 걸음이 오래 걸린다. 모양은 설명용이고 길이는 실측이 아니다.
그림: 정적 배치, 연속 배칭, 청크 프리필

메모리를 아껴도 배치를 짜는 방식이 나쁘면 자리가 빕니다. 요청마다 출력 길이가 다르기 때문입니다. 정적 배치는 요청 몇 개를 묶어 시작하고 모두 끝날 때까지 새 요청을 받지 않습니다. 10토큰짜리 답은 일찍 끝나도 그 자리는 500토큰짜리 답이 끝날 때까지 놉니다.

Orca(Yu 외, OSDI 2022)는 스케줄을 모델의 한 걸음(iteration) 단위로 바꿨습니다. 걸음이 끝날 때마다 끝난 요청은 바로 돌려보내고, 기다리던 요청을 다음 걸음부터 넣습니다. 이것이 지금 대부분의 엔진이 쓰는 연속 배칭입니다. 논문은 GPT-3 175B 급 모델(A100 40GB 16장)에서 토큰당 지연 중앙값을 190ms 로 맞췄을 때 FasterTransformer 가 초당 0.185 요청, Orca 가 6.81 요청을 처리했다고 보고합니다(36.9배). 이 비교의 기준선은 정적 배치이고, 이 차이에는 연속 배칭뿐 아니라 Orca 의 다른 설계도 함께 들어 있습니다.

연속 배칭과 PagedAttention 은 서로를 필요로 합니다. 걸음마다 요청이 들고 나려면 KV 자리를 그때그때 잡고 풀어야 하는데, 덩어리 예약으로는 틈만 늘어납니다. 블록 단위 할당이 그것을 가능하게 했습니다.

새 문제: 긴 프리필이 디코드를 멈춘다

연속 배칭에는 그림 (나)의 문제가 남습니다. 새 요청이 들어오면 그 요청의 프리필을 한 걸음에 해야 하는데, 프롬프트가 길면 그 걸음이 오래 걸리고 그동안 배치 안의 다른 요청은 다음 토큰을 받지 못합니다. Sarathi-Serve 논문(Agrawal 외, OSDI 2024)은 이것을 generation stall 이라 부르고, Yi-34B·A100 2장 조건에서 이런 멈춤이 수 초까지 이어질 수 있다고 보고합니다.

해법은 그림 (다)처럼 긴 프리필을 청크로 잘라 여러 걸음에 나눠 넣고, 걸음마다 디코드 토큰과 섞는 것입니다(청크 프리필). 같은 논문의 표 4(Yi-34B, A100 2장, 한 걸음 토큰 예산 1,024)에서 P99 토큰 간격은 섞기만 할 때 0.68초, 청크와 「디코드 먼저 채우기」를 함께 쓰면 0.14초였습니다. 꼬리 지연 제약 아래 감당하는 부하는 vLLM 대비 Mistral-7B(A100 1장)에서 2.6배, Yi-34B(A100 2장)에서 최대 3.7배였습니다(논문 초록).

우리 실측. 8GB급 GPU 에서 Qwen3-1.7B 로 300토큰을 디코드하는 요청 하나가 도는 동안 3,500토큰짜리 입력 3개를 끼워 넣고, 한 걸음에 넣는 입력 토큰 수(--max-num-batched-tokens)를 바꿔 끼운 구간의 토큰 간격을 쟀습니다.

한 걸음 입력 토큰 상한 끼운 구간 최대 토큰 간격 긴 입력 자체의 첫 토큰
512 69ms 481~494ms
1,024 129ms 468~493ms
2,048 (이 판의 기본) 227ms(3회 227·226·226) 466~480ms
8,192 (실효 3,500) 445ms 461~474ms

청크 프리필은 간격을 줄일 뿐 없애지는 못합니다. 프리필과 디코드가 같은 GPU 를 나눠 쓰는 한 서로를 방해합니다. 4편의 분리 서빙이 이 문제를 다른 방식으로 풉니다.

4. 프리픽스 캐시: 같은 앞부분은 한 번만 계산

요청 사이에도 같은 앞부분이 많습니다. 모든 대화에 붙는 시스템 프롬프트, 같은 문서를 두고 여러 번 묻는 RAG, 이전 턴을 그대로 다시 보내는 다회전 대화가 그렇습니다. 1편에서 본 것처럼 앞부분이 같으면 그 KV 도 같습니다. 그러니 이미 GPU 에 있는 블록을 찾아 그대로 쓰면 그만큼의 프리필을 건너뜁니다.

vLLM 의 자동 프리픽스 캐시(설계 문서, 2026-10-13 열람)는 블록마다 열쇠를 만듭니다.

우리 실측. 3,000토큰의 공통 접두사에 요청마다 다른 32토큰 꼬리를 붙여 하나씩 보냈습니다.

설정 첫 요청 첫 토큰 이후 요청 첫 토큰(중앙)
프리픽스 캐시 켬(기본) 394ms 28ms
끔 395ms 393ms

적중하면 3,000토큰의 프리필을 건너뛰어 첫 토큰이 14배 빨라졌습니다. 반대로 앞부분이 전혀 겹치지 않는 부하(입력 2,048, 동시 32)에서 켜 둔 비용은 처리량 약 1.2%, 첫 토큰 중앙값 약 8% 였습니다(해시 계산 비용으로 추정, 분해하지 않음).

측정할 때의 함정. 같은 문장을 반복해 성능을 재면 프리픽스 캐시가 적중해 GPU 가 낼 수 없는 속도가 나옵니다. 성능 시험의 입력은 요청마다 달라야 하고, 캐시 효과를 보고 싶을 때만 공통 앞부분을 일부러 만듭니다.

5. KV 자리가 모자라면

배치 크기의 상한이 KV 라는 것은 숫자로도 보입니다. 같은 실측 환경에서 KV 자리는 22,048토큰이었고, 입력 512 + 출력 128 = 640토큰짜리 요청이면 약 34개가 들어갑니다.

동시 요청 처리량 첫 토큰 중앙
1 66토큰/초 73ms
8 383토큰/초 351ms
32 831토큰/초 666ms
64 787토큰/초 5.7초

32에서 64로 늘리자 처리량은 오히려 줄고 첫 토큰이 8.6배 늦어졌습니다. 자리가 없는 요청은 대기열에서 기다립니다. 더 모자라면 이미 돌던 요청이 KV 를 내놓고 쫓겨났다가(선점) 처음부터 다시 프리필됩니다. 입력 2,048, 동시 32 에서 선점이 7번, 입력 1,500·출력 256·동시 64 에서 22번 일어났고 첫 토큰 중앙값은 각각 12.5초, 35.4초였습니다. 같은 KV 를 두 번 계산하는 것이라 처리량은 늘지 않고 지연만 늘어납니다.

KV 자리는 서버를 띄울 때마다 22,048~27,504토큰으로 달랐고, 동시 64 처럼 한계 근처에서는 그 20% 차이가 첫 토큰을 2.7배 바꿨습니다. 운영에서는 서버 로그의 GPU KV cache size 를 기록하고, 동시성 상한을 「평균 요청 길이로 KV 가 담는 요청 수」 근처에 맞추는 것이 좋습니다.

6. 정리: 그리고 GPU 메모리의 벽

기법 근거 확인한 효과
PagedAttention 논문 낭비를 블록 하나 안으로, 같은 지연에서 처리량 2~4배
연속 배칭 논문(Orca) 정적 배치 대비 같은 지연에서 36.9배(175B 급, 다른 설계 포함)
청크 프리필 논문(Sarathi-Serve)·실측 P99 토큰 간격 0.68→0.14초(논문), 최대 간격 445→69ms(실측)
프리픽스 캐시 실측 3,000토큰 접두사 첫 토큰 394→28ms

이 기법들은 모두 GPU 메모리 안에서 일합니다. 프리픽스 캐시는 GPU 자리가 모자라면 오래된 블록부터 지우고, 지운 앞부분이 다시 오면 처음부터 계산합니다. 엔진 인스턴스가 여럿이면 각자 따로 캐시해서, 옆 인스턴스가 이미 계산한 KV 를 쓰지 못합니다. 3편은 KV 를 GPU 밖(CPU 메모리, 디스크, 다른 서버)에 두는 LMCache 를 다룹니다.

티티 한마디. 탁구장 테이블을 「한 팀이 하루 종일」 예약받으면 빈 테이블이 많아요. 30분 단위로 쪼개고, 끝난 자리에 바로 다음 팀을 넣고, 같은 동호회는 한 테이블을 같이 쓰게 하면 같은 체육관에서 훨씬 많은 사람이 칠 수 있죠. PagedAttention 과 연속 배칭이 그 일을 해요.

참고

로그인하면 좋아요를 누를 수 있습니다

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다