모델 운영

MLOPS / 56번째 글

PagedAttention — KV 캐시를 운영체제처럼 다루기

KV 캐시를 미리 크게 잡으면 대부분이 놉니다. PagedAttention은 캐시를 고정 크기 블록으로 쪼개고 블록 테이블로 이어 붙여 그 낭비를 없앱니다. 계산법과 튜닝 지점을 정리합니다.

PALDYN Team14 MIN READ

지난 글에서 겹치는 앞부분을 재사용하는 이야기를 했는데, 그 재사용이 가능한 이유가 사실 그보다 한 층 아래에 있다. KV 캐시를 블록으로 쪼개 두었기 때문에 남의 것을 가리켜 쓰는 일이 가능해진 것이다. 그 층을 이번에 연다.

PagedAttention은 vLLM이 처음 들고 나온 아이디어이고, 지금은 거의 모든 추론 엔진이 형태를 바꿔 쓴다. 이름에 「페이지」가 들어간 데서 짐작되듯 발상은 운영체제의 가상 메모리에서 왔다.

캐시가 얼마나 큰지부터

먼저 규모 감각이 있어야 왜 이게 문제인지 보인다. 생성 중인 요청은 지금까지 처리한 모든 토큰에 대해 KV 캐시를 들고 있어야 하고, 그 크기는 이렇게 나온다.

바이트=2×L×Hkv×dh×n×b\text{바이트} = 2 \times L \times H_{kv} \times d_h \times n \times b

앞의 2는 키와 값 두 벌, LL은 층 수, HkvH_{kv}는 KV 헤드 수, dhd_h는 헤드 하나의 차원, nn은 토큰 수, bb는 값 하나의 바이트 수다.

80억 규모의 흔한 모델(층 32개, KV 헤드 8개, 헤드 차원 128, fp16)을 넣어 보면 토큰 하나에 128KB다. 4,096토큰짜리 요청 하나가 512MB를 쓴다는 뜻이다. 80GB 카드에 16GB짜리 가중치를 올리고 나면 64GB가 남고, 512MB씩 나눠 주면 128개가 최대다.

여기서 중요한 것은 이 숫자가 동시에 받을 수 있는 요청 수의 상한이라는 점이다. GPU가 아무리 빨라도 캐시 자리가 없으면 요청은 대기열에 선다. 서빙 처리량 이야기가 결국 메모리 이야기가 되는 이유다.

미리 잡으면 대부분이 논다

문제는 요청이 얼마나 길어질지 미리 모른다는 것이다. 답이 30토큰에서 끝날지 900토큰까지 갈지는 다 뽑아 봐야 안다.

그래서 소박한 구현은 「최대 길이만큼」을 미리 잡는다. 자리를 잡아 놓아야 도중에 메모리가 없어 실패하는 일이 없기 때문이다. 결과는 이렇게 된다.

미리 잡으면 대부분이 논다

잡아 둔 자리 가운데 실제로 담긴 것이 4분의 1을 겨우 넘는다. 나머지는 아무것도 없는 채로 다른 요청이 못 쓰게 묶여 있다. 이렇게 할당받은 덩어리 안쪽이 노는 것을 내부 단편화(internal fragmentation)라고 부른다.

낭비는 두 가지 방식으로 더 생긴다.

  • 외부 단편화 — 요청이 들어오고 나가기를 반복하면 빈 자리가 여기저기 조각나서, 총합은 충분한데 연속된 큰 덩어리가 없어 새 요청을 못 받는다
  • 공유 불가 — 같은 앞부분을 쓰는 요청 둘이 각자 잡으면 같은 내용이 메모리에 두 벌 올라간다

세 가지 모두 뿌리가 같다. 캐시를 「요청당 연속된 큰 덩어리」로 다뤘기 때문이다.

블록으로 쪼개고 표로 잇는다

PagedAttention의 답은 단순하다. 캐시를 고정 크기 블록(보통 16토큰)으로 나누고, 요청에는 필요할 때마다 빈 블록을 하나씩 빌려준다.

블록들이 GPU 메모리에서 연속일 필요가 없다. 요청마다 블록 테이블을 하나 두고 「이 요청의 몇 번째 블록은 물리적으로 몇 번 블록」을 적어 두면, 어텐션을 계산할 때 그 표를 보고 흩어진 자리를 찾아 읽는다.

블록 테이블이 흩어진 자리를 하나로 이어 준다

이것이 운영체제가 프로세스에 메모리를 주는 방식과 정확히 같다. 프로세스는 0번지부터 쭉 이어진 주소 공간을 본다고 믿지만 실제 물리 메모리는 페이지 단위로 흩어져 있고, 페이지 테이블이 그 사이를 옮겨 준다. 여기서는 요청이 프로세스, 16토큰 블록이 페이지, 블록 테이블이 페이지 테이블이다.

세 가지 낭비가 한꺼번에 사라진다.

낭비 사라지는 이유
내부 단편화 남는 것은 마지막 블록의 빈칸뿐 — 요청당 최대 15토큰
외부 단편화 블록 크기가 다 같으므로 빈 블록은 어느 요청에나 맞는다
공유 불가 블록 테이블 두 개가 같은 물리 블록을 가리키면 한 벌로 끝난다

공유는 표를 베끼는 일이 된다

마지막 줄이 지난 글과 이어지는 지점이다. 같은 시스템 프롬프트로 시작하는 요청 100개가 있으면, 그 앞부분에 해당하는 블록은 한 벌만 두고 표 100개가 그것을 가리킨다.

여기서 지켜야 할 것이 하나 있다. 공유 중인 블록에 새 토큰을 써 넣으면 남의 캐시까지 망가진다. 그래서 마지막 블록처럼 아직 채워지는 중인 자리에 쓰기가 필요해지면, 그 블록만 복사해 자기 것으로 만들고 표를 고쳐 가리킨다. 운영체제에서 같은 이름으로 부르는 쓸 때 복사(copy-on-write) 그대로다.

이 구조가 값을 하는 자리는 접두사 공유만이 아니다.

  • 한 입력에서 답을 여러 개 뽑는 경우 — 갈라지기 전까지가 통째로 공유된다
  • 빔 서치 — 후보들이 앞부분을 공유하고, 후보가 죽으면 그 표만 지우면 된다
  • 에이전트의 누적 대화 — 걸음마다 앞이 계속 같다

블록 크기는 어떻게 정하나

기본값 16이 대부분의 경우 적당하고, 손대야 할 이유가 생겼을 때만 만진다. 방향은 이렇다.

  • 크게 잡으면(32, 64) 표가 짧아지고 읽는 오버헤드가 줄지만, 마지막 블록의 빈칸이 커져 내부 단편화가 다시 는다. 답이 짧은 요청이 많은 부하에서 특히 손해다
  • 작게 잡으면(8) 낭비는 더 줄지만 표가 길어지고 커널이 블록을 찾아다니는 비용이 는다

답이 대부분 수십 토큰에서 끝나는 분류·추출 부하라면 작은 쪽이, 긴 문서를 생성하는 부하라면 큰 쪽이 조금 낫다. 다만 이 값을 만져서 얻는 이득은 다른 설정에 비하면 작다. 메모리 튜닝에서 먼저 볼 것이 여럿 있다.

그래도 넘칠 때 — 선점

블록 방식이라도 메모리는 유한하다. 돌고 있는 요청들이 계속 길어져서 빌려줄 블록이 떨어지면, 엔진은 요청 하나를 선점(preemption)해 자리를 비운다. 되돌리는 방법이 둘이다.

  • 재계산(recompute) — 그 요청의 블록을 통째로 반납하고, 나중에 차례가 오면 프리필부터 다시 한다. 버리는 것은 계산, 아끼는 것은 메모리
  • 스왑(swap) — 블록을 CPU 메모리로 옮겨 두었다가 나중에 도로 올린다. 계산은 안 버리지만 PCIe로 오가는 시간이 붙는다

짧은 요청은 재계산이 싸고 긴 요청은 스왑이 싸다. 실무에서 더 중요한 것은 선점이 자주 일어나면 그 자체가 신호라는 점이다. 로그에 선점 횟수가 계속 찍히면 튜닝할 자리가 아니라 용량이 모자란 것이고, --max-num-seqs를 낮춰 애초에 덜 받거나 카드를 늘려야 한다.

실무에서 만지는 지점

  • --gpu-memory-utilization은 총량이지 캐시 몫이 아니다. 가중치를 올리고 남은 자리가 블록 풀이 되므로, 같은 비율이라도 큰 모델일수록 실제 블록 수가 적다
  • --max-model-len이 동시 처리량을 깎는다. 블록 방식이라 미리 잡지는 않지만, 이 값이 요청당 예약 상한 역할을 해서 스케줄러가 보수적으로 요청을 받는다. 로그에서 길이 분포를 보고 99백분위 근처로 정한다
  • 블록 수를 로그에서 확인한다. 엔진이 뜰 때 KV 캐시로 잡은 블록 수를 찍는다. 이 값을 블록당 토큰 수와 곱하면 동시에 담을 수 있는 총 토큰 수가 나오고, 평균 요청 길이로 나누면 실제 동시 처리 가능 요청 수가 나온다
  • 양자화는 가중치만이 아니라 캐시에도 걸 수 있다. KV 캐시를 8비트로 두면 위 수식의 bb가 절반이 되어 담을 수 있는 요청이 두 배가 된다. 품질 영향은 따로 재야 한다

정리

  • KV 캐시는 토큰 하나에 수십~수백 KB다. 4,096토큰짜리 요청 하나가 수백 MB를 쓴다
  • 그 크기가 동시에 받을 수 있는 요청 수의 상한이 된다. 처리량 문제가 메모리 문제인 이유다
  • 답 길이를 미리 모르므로 소박한 구현은 최대 길이만큼 잡고, 그러면 잡아 둔 자리의 대부분이 논다
  • 내부 단편화·외부 단편화·공유 불가 세 가지가 모두 「연속된 큰 덩어리」로 다룬 탓에 생긴다
  • PagedAttention은 캐시를 16토큰짜리 고정 블록으로 쪼개고 블록 테이블로 이어 붙인다. 운영체제의 페이지 테이블과 같은 구조다
  • 남는 낭비는 마지막 블록의 빈칸뿐이고 요청당 최대 15토큰이다
  • 표 여럿이 같은 블록을 가리키면 공유가 공짜가 된다. 채워지는 중인 블록에 쓸 때만 복사해 갈라진다
  • 블록 크기는 기본 16이 적당하다. 키우면 오버헤드가 줄고 단편화가 늘며, 줄이면 반대다
  • 그래도 넘치면 요청을 선점해 재계산하거나 CPU로 스왑한다. 선점이 잦으면 튜닝이 아니라 용량 문제다
  • --gpu-memory-utilization은 총량, --max-model-len은 사실상 요청당 예약 상한이다. KV 캐시 양자화는 담을 수 있는 요청을 두 배로 늘린다

읽어주셔서 감사합니다. 😊

LATEST

모델 운영의 최신 글

모델 운영2026.09.04

KV 캐시 양자화 — 가중치보다 이쪽이 먼저 넘친다

긴 문맥에서 GPU 메모리를 실제로 잡아먹는 것은 가중치가 아니라 KV 캐시입니다. 캐시 크기를 계산하는 법, K와 V를 다르게 다뤄야 하는 이유, 어디까지 줄여도 되는지를 정리합니다.

16 MIN
모델 운영2026.09.04

양자화 보정 — 데이터 128개가 모델 품질을 정한다

양자화에서 스케일을 정하는 절차가 보정입니다. 무엇을 재는지, 데이터를 어디서 몇 개 뽑아야 하는지, 자르는 지점을 어떻게 고르는지, 그리고 잘못된 보정이 어떤 모양으로 드러나는지를 정리합니다.

21 MIN
모델 운영2026.09.03

과제 특화 증류 — 큰 모델의 답을 작은 모델에 옮긴다

범용 성능이 아니라 우리 과제 하나만 잘하는 작은 모델을 만드는 방법입니다. 교사에게 무엇을 받아야 하는지, 데이터를 어떻게 모으고 거르는지, 손익 분기가 어디인지를 정리합니다.

15 MIN