vLLM
LLM 서빙은 보통 연산력보다 메모리 관리(KV 캐시)에서 병목이 걸린다. vLLM의 핵심은 PagedAttention: KV 캐시를 고정 크기 블록(페이지)으로 쪼개 비연속 메모리에 놓고, 블록 테이블로 논리 순서를 보장하는 방식이다. 이 관점 전환 하나로 배치가 자연스럽게 커지고, 길이가 제각각인 요청이 섞여 들어와도 메모리를 촘촘하게 쓸 수 있다.
Prefill / Decode — 캐시가 서빙 성능을 만든다
LLM 추론은 크게 두 단계다.
Prefill(프롬프트 인코딩)
사용자 프롬프트를 한 번에 통과시키며 레이어·헤드별로 생성된 K(키) / V(밸류)를 KV 캐시에 저장한다. 이때 프롬프트 길이만큼 K/V가 한꺼번에 쌓인다.
Decode(토큰 생성)
매 스텝에서 Q(쿼리)만 새로 만들고, 캐시에 쌓여 있는 과거의 K/V와 어텐션을 수행해 다음 토큰을 낸다. 그리고 방금 생성된 토큰의 K/V를 캐시에 append한다.
캐시가 없다면 과거 토큰의 K/V를 매 스텝 다시 계산해야 해서 계산량이 폭증한다.
서빙 관점에서 TTFT(첫 토큰 대기시간)은 prefill에, 토큰당 처리량은 decode 단계의 캐시 접근 패턴·메모리 대역폭·배치 효율에 좌우된다. 즉, 캐시를 얼마나 덜 낭비하고, 얼마나 많이 공유하느냐가 처리량을 가른다.
기존(연속 할당) 방식의 한계 — 왜 공유가 막히나
기존 시스템은 보통 요청 단위로 큰 연속 버퍼를 미리 예약해 KV 캐시를 저장한다. 단순하고 구현이 쉽지만, 실전 트래픽에선 다음 문제가 겹친다.
예약 낭비 & 내부 단편화
요청마다 최대 길이(예: 2k/4k/8k)를 기준으로 연속 버퍼를 선점한다. 실제 생성이 더 짧아도 요청이 끝날 때까지 버퍼 전체를 붙잡고 있어, 뒤쪽 빈칸을 다른 요청이 못 쓴다.
외부 단편화
길이가 제각각인 요청이 들락날락하며 메모리 풀에 크기 다른 구멍이 생긴다. 총 여유는 충분해도 “큰 연속 버퍼 하나”를 새로 잡지 못해 배치를 못 키우는 상황이 잦다. 서빙 중 대규모 compaction은 사실상 어렵다.
공유 실패의 구조적 이유(핵심)
병렬 샘플링·빔서치에서는 여러 시퀀스가 같은 접두 프롬프트를 공유한다. 그러나 시퀀스마다 별개 연속 버퍼에 저장되면
- 접두 K/V를 시퀀스 수만큼 중복 복사해야 하고,
- 연속 버퍼 특성상 맨 끝에만 append할 수 있어 시퀀스별 성장 속도가 달라지면 금방 충돌,
- Copy-on-Write로 돌려도 중간 끼워넣기/재배치가 필요한 순간 대량 복사가 발생한다.
- 결론적으로, “연속” 제약 때문에 ‘접두 공유 + 분기 시 COW’라는 이상적인 패턴이 구조적으로 어렵다.
사이드 노트: OPT-13B 기준 토큰당 KV가 약 800KB라서, 2048 토큰이면 ~1.6GB가 한 요청의 KV로 든다. 이 정도면 메모리 관리가 곧 배치 상한이다.
PagedAttention — OS에서 배운 해법
운영체제의 가상 메모리 + 페이징을 그대로 응용한다.
블록화(페이지)
KV 캐시를 고정 크기 블록(예: B=16 토큰)으로 나누고, 각 시퀀스는 “논리 블록 → 물리 블록”을 가리키는 블록 테이블을 갖는다. 블록은 GPU 메모리 여기저기 비연속으로 흩어져 있어도 된다.
온디맨드 할당
prefill/decoder에서 필요해지는 순간에만 블록을 붙인다. 요청이 중간에 끝나면 즉시 반납되어 다른 시퀀스가 재사용한다. → 예약 낭비·내부 단편화가 급감.
외부 단편화 제거
모든 블록 크기가 같으니, 풀에는 동일 규격의 조각만 돌고 큰 연속 공간이 필요 없다.
블록 단위 공유 + COW
공유 가능한 접두 블록은 여러 시퀀스가 함께 참조(참조 카운트)하고, 분기 지점의 마지막 블록만 COW로 새로 할당한다. 병렬 샘플링·빔서치에서도 접두 블록 묶음은 단 한 번만 저장해두고 N개 시퀀스가 함께 읽는다.
커널 레벨 지원(블록 어텐션)
어텐션 커널은 “연속 K/V”를 가정하지 않고, 필요 블록만 모아(attention gather) 연산한다. 비연속 저장이어도 계산이 깔끔하게 돈다.
블록 크기 트레이드오프: 블록에 토큰을 여러 개 담으면 병렬성↑·지연↓, 대신 단편화↑. 워크로드에 맞춰 균형점(B)을 잡는 게 포인트.
디코딩별 공유 패턴 — 샘플링, 빔서치, 혼합
병렬 샘플링
접두 블록들은 전부 공유. 각 샘플은 마지막 블록만 COW로 갈라진다. 샘플 수가 클수록 중복이 크게 줄어든다.
빔서치
빔 후보들은 긴 접두를 공유하는 경향이 크다. PagedAttention에서는 공통 블록은 그대로 공유하고, 분기 블록만 복제한다. 매 스텝 상위 빔만 남기면서 참조 카운트가 0이 된 물리 블록은 즉시 해제한다. 기존 방식의 대량 메모리 복사가 사라진다.
혼합 디코딩
샘플링·빔·그리디가 섞여 있어도 공통 매핑층이 공유 복잡성을 숨겨준다. 모델/커널은 매 시퀀스의 물리 블록 ID 리스트만 보면 된다. 서로 다른 디코딩을 같은 배치로 섞어 처리하기 유리해진다.
스케줄링과 선점 — “누굴 내보내고, 어떻게 복구할까”
요청이 몰려 KV 블록이 모자라면 vLLM은 FCFS(선착순)로 공정성을 지키며, 가장 늦게 도착한 쪽부터 선점한다. 또한 빔 후보처럼 서로 공유 가능성이 있는 시퀀스들은 시퀀스 그룹으로 묶어 gang-schedule한다(함께 밀고 함께 복귀).
복구 전략은 두 가지다.
- Swap-out: 밀어낸 블록을 CPU RAM으로 보관한다. CPU 스왑 총량은 GPU KV 풀 총량을 넘지 않도록 상한이 묶인다.
- Recompute: 다시 필요해질 때 재계산한다. 디코딩 중간까지 나온 토큰을 프롬프트에 붙여서 한 번에 prefill하므로, 원래보다 더 짧은 지연으로 복구되기도 한다.
블록이 작으면 스왑은 잦은 전송으로 대역폭 낭비가 커져 재계산이 유리한 구간이 있고, 크면 반대로 스왑이 유리해진다. 워크로드에 맞춰 B를 잡고, 선점 정책과 맞물려 처리량을 안정화한다.
H100에 직접 서빙한 예시
세팅
- GPU: H100 92GB
- 엔진: vLLM (--gpu-memory-utilization 0.90~0.95)
- 모델: Gemma-3-27B-IT
- 요청 길이: 컨텍스트 ~4096 근처
왜 PagedAttention이 체감되나
연속 예약은 요청당 큰 연속 버퍼를 미리 잡아두기 때문에, 짧게 끝난 요청의 빈칸이 끝날 때까지 묶인다. 예약 낭비 + 내부/외부 단편화로 이어져 TTFT/throughput이 흔들린다.
PagedAttention은 블록 단위 온디맨드 할당이라 중간 종료가 많아도 바로 회수·재사용된다. 프롬프트 공통 구간은 여러 시퀀스가 공유, 분기 지점만 COW로 복사한다.
숫자로 찍은 “한 요청”의 무게(체감치)
Gemma-3는 GQA(+슬라이딩 윈도우) 구조라 KV 헤드 수가 적고, 다수 레이어가 최근 윈도우(예: 1024)만 유지한다. 내 계산 기준으로 4k 토큰짜리 요청 1개가 차지하는 KV는 대략 ~0.73 GiB 안팎이었다. 이 수치 덕에 동일 VRAM에서 동시 상주 수를 비교적 높게 가져갈 수 있었다.
운영 팁(간단): KV 피크가 버거우면 --max-num-seqs나 --max-num-batched-tokens를 줄여 급한 불을 끈다. 프롬프트가 긴 병렬 샘플링·빔에서는 접두 공유로 체감 이득이 크다.
결론 — 메모리 관리가 곧 스케줄링
vLLM은 연속 할당의 족쇄를 풀고, 블록 기반의 PagedAttention으로 공유·재사용·선점을 시스템 차원에서 가능하게 만든다. 그래서 배치가 자연스레 커지고, 요청이 많아져도 처리량이 덜 흔들린다. 특히 길이 편차가 큰 실트래픽, 긴 프롬프트, 병렬 샘플링/빔에서 체감 차이가 크다.
내 환경(H100 92GB, util 0.90~0.95, Gemma-3-27B-IT)에서도 4k 컨텍스트 기준 동시 상주를 수십 개 규모로 안정적으로 유지하며, 기존 연속 예약 방식에서 보이던 “보이지 않는 낭비”를 눈에 띄게 줄일 수 있었다.
'AI' 카테고리의 다른 글
| OpenAI Responses API vs Chat Completions API (1) | 2026.07.11 |
|---|---|
| Claude Skills (0) | 2025.11.02 |
| RAGAS 평가 지표 (3) | 2025.07.13 |