Post

🚀 GPU 메모리가 줄줄 샌다고요? vLLM과 PagedAttention이 LLM 서빙의 판을 엎은 진짜 이유

🚀 GPU 메모리가 줄줄 샌다고요? vLLM과 PagedAttention이 LLM 서빙의 판을 엎은 진짜 이유

🔥 The Hook & TL;DR: 왜 우리는 vLLM에 열광하는가?

솔직히 까놓고 얘기해 봅시다. 사내에서 PoC 단위로 LLM 띄울 때는 다들 행복합니다. 7B, 13B 모델 하나 올려놓고 “와, 대답 잘하네요!” 하며 박수 치죠. 그런데 이걸 실제 프로덕션에 올리고, 동시 접속자가 10명, 50명, 100명으로 늘어나는 순간 인프라팀에 지옥이 펼쳐집니다. AWS 청구서는 폭발하고, GPU는 OOM(Out of Memory)을 뱉으며 장렬히 전사합니다.

“도대체 왜 메모리가 부족한 걸까요? 모델 사이즈는 14GB밖에 안 되는데, 왜 80GB짜리 A100을 꽂아도 뻗어버리는 걸까요?”

개발자는 코드로 말하지만, 경영진은 청구서로 말하죠. 이 간극을 메우지 못하면 프로젝트는 드랍됩니다. 결론부터 말씀드리면, 병목의 진짜 범인은 모델 가중치(Weights)가 아니라 ‘KV 캐시(KV Cache)’입니다. 그리고 이 악랄한 메모리 낭비를 1960년대 OS의 고전적인 페이징(Paging) 기법으로 우아하게 해결해버린 녀석이 바로 오늘 밑바닥까지 뜯어볼 vLLMPagedAttention입니다.

💡 한 마디로 요약하면? “미리 메모리를 크게 잡아놓고 버리는 기존 방식(Static Allocation)을 버리고, 16토큰 단위의 ‘블록’으로 쪼개어 필요할 때만 할당(On-demand)함으로써 GPU 메모리 낭비를 80%에서 4%로 줄인 혁명.”


🛠️ Deep Dive (Under the Hood): KV 캐시는 왜 GPU의 식충이가 되었나?

기존 아키텍처의 멍청함(?)을 명확히 알지 못하면 vLLM의 진가를 알 수 없습니다. 우리가 간과하기 쉬운 사실이 하나 있습니다. LLM 추론은 두 단계로 나뉩니다. 프롬프트를 한 번에 읽어 들이는 ‘Prefill’ 단계와, 한 글자씩 뱉어내는 ‘Decode’ 단계죠. Prefill은 GPU의 연산 코어(ALU)를 풀가동하는 Compute-bound 작업이지만, 실제 우리가 기다리는 시간이 긴 Decode 단계는 철저하게 Memory-bound 작업입니다.

트랜스포머(Transformer) 모델이 다음 토큰을 생성할 때, 이전까지의 컨텍스트를 매번 재연산하는 것은 엄청난 낭비입니다. 그래서 이전 토큰들의 Key와 Value 텐서를 메모리에 캐싱해두는데, 이를 KV Cache라고 부릅니다. 현업에서 많이 쓰는 LLaMA-2 13B 모델을 예로 들어 수학을 좀 해볼까요?

  • 토큰 1개당 KV 캐시 크기 = 40(Layers) × 40(Heads) × 128(Dim) × 2(K, V) × 2 Bytes(FP16) ≈ 0.8MB
  • 최대 시퀀스 길이(2048)를 가정한 1개 요청의 KV 캐시 = 0.8MB × 2048 ≈ 1.6GB
  • 동시 요청(Batch Size)이 64개라면? = 1.6GB × 64 = 102.4GB

A100 80GB 하나로는 택도 없는 수치입니다. 여기서 더 기가 막힌 사실이 있습니다. HuggingFace의 generate()나 초창기 서빙 프레임워크들은 이 1.6GB를 ‘요청이 들어오는 순간 연속된(Contiguous) 메모리 공간에 미리 할당’해버립니다. 사용자가 달랑 “안녕?”이라는 2토큰짜리 질문을 던졌어도 무조건 최대치인 2048토큰만큼의 자리를 차지하고 있는 거죠.

남는 공간은 다른 요청이 쓰지도 못합니다. 이를 내부 단편화(Internal Fragmentation)라고 부르며, 논문에 따르면 이로 인해 무려 60~80%의 GPU 메모리가 증발합니다. 비싼 밥 먹여놨더니 식당 테이블 10개 중 8개를 예약석 팻말만 올려놓고 비워두는 꼴입니다.

PagedAttention의 등장: OS의 지혜를 빌리다

이 미친 낭비를 해결하기 위해 UC Berkeley의 vLLM 연구팀은 ‘가상 메모리 페이징’을 GPU 위로 끌고 왔습니다. 연속된 긴 메모리를 잡는 대신, KV 캐시를 고정된 크기의 ‘물리적 블록(Physical Block, 기본값 16토큰)’으로 잘게 쪼갰습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 기존: 최대 길이만큼 통째로 할당 (단편화의 주범)
kv_cache = torch.zeros((batch_size, num_heads, max_seq_len, head_dim))

# vLLM (PagedAttention) 핵심 의사코드 (Pseudo-code)
class BlockAllocator:
    def __init__(self, block_size=16):
        self.free_physical_blocks = initialize_gpu_pool()
        self.block_table = {} # [Request ID] -> List[Physical Block ID]

    def allocate_on_demand(self, request_id, token):
        if is_block_full(request_id):
            # 필요할 때만 새로운 물리 블록을 팝(pop)해서 논리 테이블에 매핑
            new_block = self.free_physical_blocks.pop()
            self.block_table[request_id].append(new_block)

이렇게 하면 연속된 메모리일 필요가 없습니다. 흩어져 있는 물리적 블록들을 Attention 연산 시 커널 단에서 모아서 계산해버리는 PagedAttention CUDA 커널을 직접 짠 것이죠. 결과적으로 낭비되던 80%의 메모리가 4% 이하(블록 내 자투리)로 줄어듭니다. 남는 VRAM 공간에 더 많은 요청(Batch)을 밀어넣으니, Throughput(처리량)이 2~4배 뻥튀기되는 건 당연한 이치입니다.

비교 지표기존 서빙 아키텍처 (HF, 초기 TGI)vLLM (PagedAttention)
메모리 할당 방식요청 단위 / Max Seq 정적 할당16토큰 블록 단위 / 동적 할당 (On-demand)
메모리 낭비율60% ~ 80% (OOM 발생의 주원인)4% 미만 (극강의 효율)
동시 처리(Batching)Static Batching (긴 문장 끝날때까지 대기)Continuous Batching (토큰 단위로 즉시 넣고 뺌)
프롬프트 공유불가능 (각자 메모리 공간 점유)가능 (블록 레퍼런스 카운트 공유, Prefix Caching)

🎯 Pragmatic Use Cases: 현업 트러블슈팅, 이럴 때 쓰면 빛을 발한다

1. 대규모 트래픽 스파이크 대처 (Continuous Batching의 마법) 점심시간마다 사내 챗봇에 트래픽이 몰리나요? 기존 방식에서는 배치(Batch) 내에 1000토큰짜리 긴 요청이 하나 끼어있으면, 10토큰짜리 짧은 요청들은 빈둥거리며 앞사람이 끝날 때까지 기다려야 했습니다. 하지만 vLLM은 Continuous Batching (In-flight Batching)을 통해 매 스텝마다 처리 끝난 요청은 빼고, 큐에 대기 중인 새 요청을 밀어넣습니다. 쏟아지는 트래픽 속에서도 GPU 연산기(ALU)를 100% 혹사시키며 묵묵히 버텨내는 모습을 보면 인프라 엔지니어 입장에서는 눈물이 날 지경입니다.

2. RAG 파이프라인의 구세주 (Prefix Caching) 최근 현업에서는 RAG(Retrieval-Augmented Generation)가 필수죠. “다음 문서들을 바탕으로 대답해: [수천 자의 문서]…” 이런 System Prompt가 모든 사용자 요청마다 중복해서 들어갑니다. vLLM 설정에서 --enable-prefix-caching을 켜보세요. 똑같은 프롬프트의 KV 캐시 블록을 단 한 번만 물리적 메모리에 올려두고, 다른 요청들이 이를 포인터(Reference)로 공유합니다. 연산 속도(TTFT)가 극단적으로 빨라집니다.

1
2
3
4
5
6
# 이렇게 띄우면 RAG 시스템의 인프라 비용이 절반으로 줄어듭니다.
python -m vllm.entrypoints.openai.api_server \
    --model "meta-llama/Llama-2-13b-chat-hf" \
    --gpu-memory-utilization 0.90 \
    --enable-prefix-caching \
    --swap-space 16

💀 Honest Review & Trade-offs: 시니어의 깐깐한 시선으로 본 한계점

자, 찬양은 이쯤 하고 뼈 때리는 현실을 짚어봅시다. 기술에 만병통치약(Silver Bullet)은 없습니다. 처음 도입했을 때 맞닥뜨린 함정들이 꽤 아팠거든요.

첫째, 초저지연(Ultra-low Latency) 시스템에는 독이 될 수 있습니다. vLLM은 철저하게 ‘서버 전체의 처리량(Throughput)’을 높이기 위한 아키텍처입니다. 수많은 요청의 논리적/물리적 블록 매핑을 CPU가 스케줄링하고 갱신하다 보니, 첫 토큰 생성 시간(TTFT, Time To First Token) 자체는 오히려 느려질 수 있습니다. 실시간 콜센터 음성 봇처럼 “사용자 말이 끝나자마자 0.2초 내에 응답”해야 한다면? 차라리 Batch를 포기하고 TensorRT-LLM의 최적화 커널을 쓰거나, 깡성능이 좋은 하드웨어에 배치를 최소화하여 올리는 게 낫습니다.

둘째, 커스텀 모델 도입 시의 ‘벤더 락인(Vendor Lock-in)’ 수준의 고통 AI 연구팀에서 자체적인 Attention 구조를 수정한 혁신적인(?) 모델을 만들었다고 합시다. HuggingFace에서는 잘 돌아가겠죠. 그런데 이걸 vLLM에 올리려면? 커스텀 PagedAttention CUDA 커널과 C++ 스케줄러 코드를 직접 수정해야 합니다. 파이썬 코드를 딥한 시스템 프로그래밍 레벨로 컨버팅해야 하는, MLOps 엔지니어에게는 꽤 끔찍한 진입장벽입니다. 공식 지원되는 모델 목록(Supported Models) 밖을 벗어나는 순간 야근 확정입니다.

셋째, 아슬아슬한 줄타기 (OOM과 CPU Swapping) gpu_memory_utilization을 0.95(95%)로 설정해두면 GPU VRAM을 극한까지 뽑아 먹습니다. 하지만 VRAM이 꽉 찼을 때, vLLM은 OS처럼 남은 KV 블록을 CPU RAM으로 스왑(Swap-out) 시켜버립니다. 이 순간 PCIe 대역폭 병목이 발생하면서 응답 속도가 곤두박질치죠. “서버가 죽지는 않는데, 엄청 버벅인다”는 기획자의 클레임을 받게 됩니다. 트래픽 특성에 맞게 적절한 버퍼를 남겨두는 튜닝 짬바가 반드시 필요합니다.


💡 Closing Thoughts: 결국 최적화의 끝은 ‘기본기(CS)’다

vLLM 논문을 처음 읽고 헛웃음이 났던 기억이 납니다. 수천억 개의 파라미터가 돌아가는 최첨단 딥러닝의 가장 큰 병목을 뚫어낸 것이, 1960년대 운영체제 시간에 배우는 페이징 기법이었다니요. 하늘 아래 새로운 것은 없고, 결국 탄탄한 CS(Computer Science) 기본기가 AI 시대에도 가장 강력한 무기임을 증명한 짜릿한 사례입니다.

현업에서 LLM을 다룬다면, 이제 단순히 프롬프트 엔지니어링이나 파인튜닝만으로는 살아남기 힘듭니다. 모델이 어떻게 메모리에 올라가고 연산되는지, 그 밑바닥을 이해해야만 수천만 원의 인프라 비용을 아끼고 서비스를 안정적으로 런칭할 수 있습니다. 지금 당장 여러분의 서비스 콘솔을 열어보세요. 혹시 지금도 값비싼 GPU VRAM의 절반 이상이 ‘예약석’ 팻말만 단 채 허공에 돈을 뿌리고 있지는 않습니까? 최적화의 첫걸음은, 우리가 무엇을 낭비하고 있는지 뼈저리게 자각하는 것에서 시작됩니다.

References

  • https://github.com/vllm-project/vllm
  • https://arxiv.org/abs/2309.06180
This post is licensed under CC BY 4.0 by the author.