- Published on
Transformers 로컬 LLM 속도 2배 - vLLM+PagedAttention
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 한 대에서 로컬 LLM을 돌리다 보면, 처음에는 transformers 파이프라인만으로도 충분해 보입니다. 하지만 동시 요청이 늘거나 컨텍스트 길이가 길어지는 순간, GPU 연산보다 먼저 무너지는 지점이 등장합니다. 바로 KV 캐시(Key-Value cache) 메모리 관리입니다.
이 글은 transformers 기반 추론을 유지하되, 추론 엔진을 vLLM으로 바꿔 체감 속도를 2배 수준으로 끌어올리는 접근을 다룹니다. 핵심은 PagedAttention 기반 KV 캐시 페이징, 그리고 continuous batching(연속 배칭)으로 GPU를 더 오래, 더 효율적으로 바쁘게 만드는 것입니다.
추가로 운영 단계에서 자주 부딪히는 OOM, 지연 스파이크, 배포 시 설정 포인트는 아래 글도 함께 참고하면 연결이 잘 됩니다.
왜 transformers 추론이 느려지는가
로컬 추론 성능을 단순화해서 보면 대략 두 가지 축입니다.
- 연산(Compute): 디코딩 단계에서 토큰을 한 개씩 생성하며 matmul이 반복됩니다.
- 메모리/캐시(Memory): 각 토큰마다 attention을 위해 과거 토큰의 K/V를 읽고, 새 토큰의 K/V를 저장합니다.
특히 동시 요청이 늘면 KV 캐시가 빠르게 커지는데, 일반적인 구현에서는 KV 캐시가 연속(Contiguous) 메모리로 잡히는 경우가 많습니다. 그러면 다음 문제가 생깁니다.
- 요청 길이가 제각각이면 캐시 공간이 파편화되고, 재할당 또는 큰 블록 확보가 어려워집니다.
- 배칭을 크게 잡고 싶어도, KV 캐시가 먼저 VRAM을 잡아먹어 OOM이 납니다.
- 프리필(prefill)과 디코딩(decode)이 섞일 때, 짧은 요청이 긴 요청에 끌려가며 GPU utilization이 떨어집니다.
결론적으로 “GPU는 놀고 있는데 VRAM은 꽉 차고 지연은 튄다” 같은 현상이 발생합니다.
vLLM이 주는 2가지 핵심: Continuous Batching + PagedAttention
vLLM은 단순히 “더 빠른 구현”이 아니라, 추론 서버로서 병목을 줄이는 구조를 제공합니다.
1) Continuous batching(연속 배칭)
일반 배칭은 “같은 시점에 들어온 요청을 묶어 한 번에 처리”합니다. 하지만 LLM 디코딩은 토큰 단위로 진행되므로, 요청마다 생성 길이가 다르면 배치가 금방 깨집니다.
vLLM은 디코딩 루프에서 매 스텝마다 배치를 재구성합니다. 즉, 새 요청이 들어오면 다음 스텝부터 자연스럽게 합류하고, 끝난 요청은 빠집니다. 이 방식은 특히 다음 상황에서 효과가 큽니다.
- 동시 요청이 꾸준히 들어오는 챗봇/에이전트 서버
- 응답 길이가 들쑥날쑥한 서비스
- 작은 배치가 계속 생겨 GPU가 놀기 쉬운 환경
2) PagedAttention(KV 캐시 페이징)
PagedAttention은 KV 캐시를 OS의 가상 메모리처럼 페이지 단위로 쪼개 관리하는 접근입니다.
- KV 캐시를 큰 연속 메모리로 잡지 않고, 고정 크기 페이지(블록)들로 관리
- 요청별로 필요한 만큼 페이지를 할당하고, 끝나면 회수
- 파편화가 줄고, 메모리 재사용이 쉬워짐
이 구조 덕분에 같은 VRAM에서 더 많은 동시 요청을 처리하거나, 같은 동시 요청에서 OOM 없이 더 긴 컨텍스트를 다루는 방향으로 여유가 생깁니다. 처리량(throughput) 관점에서 체감 2배 수준의 개선이 나오는 케이스가 흔한 이유가 여기에 있습니다.
어떤 경우에 “2배”가 잘 나오나
정량 수치는 모델, GPU, 요청 패턴에 따라 달라집니다. 다만 경험적으로 아래 조건에서 개선 폭이 큽니다.
- 동시 요청이
4이상이고, 요청 길이 분산이 큼 - 컨텍스트가 길거나(예:
4k이상), 프리필 비중이 큼 - 기존
transformers서버가 배치 최적화가 거의 없고 단건 위주로 처리
반대로 단일 요청만 처리하고, 컨텍스트도 짧고, GPU가 이미 높은 utilization이라면 개선 폭은 제한적일 수 있습니다.
빠른 시작: vLLM로 로컬 서버 띄우기
가장 간단한 형태는 OpenAI 호환 API 서버로 띄우는 것입니다.
설치
pip install -U vllm
서버 실행
아래 예시는 Qwen2.5-7B-Instruct 같은 HF 모델을 로컬에서 바로 서빙합니다.
python -m vllm.entrypoints.openai.api_server \
--model Qwen/Qwen2.5-7B-Instruct \
--host 0.0.0.0 \
--port 8000 \
--gpu-memory-utilization 0.90 \
--max-model-len 8192
--gpu-memory-utilization: vLLM이 사용할 VRAM 상한 비율입니다. 너무 높이면 커널/드라이버 여유가 줄어 불안정해질 수 있어0.85에서 시작해 올리는 편이 안전합니다.--max-model-len: 모델이 처리할 최대 컨텍스트 길이입니다. 이 값이 커질수록 KV 캐시 상한도 커질 수 있어, 실제 트래픽에 맞게 잡는 게 중요합니다.
transformers 코드에서 vLLM로 바꾸는 최소 변경
기존에 transformers로 이렇게 쓰고 있었다고 가정해봅시다.
from transformers import AutoTokenizer, AutoModelForCausalLM
import torch
model_id = "Qwen/Qwen2.5-7B-Instruct"
tok = AutoTokenizer.from_pretrained(model_id)
model = AutoModelForCausalLM.from_pretrained(
model_id,
torch_dtype=torch.float16,
device_map="cuda",
)
prompt = "Explain KV cache in LLM inference."
inputs = tok(prompt, return_tensors="pt").to("cuda")
out = model.generate(
**inputs,
max_new_tokens=256,
do_sample=False,
)
print(tok.decode(out[0], skip_special_tokens=True))
vLLM로 바꾸면 핵심은 LLM과 SamplingParams로 교체하는 것입니다.
from vllm import LLM, SamplingParams
model_id = "Qwen/Qwen2.5-7B-Instruct"
llm = LLM(
model=model_id,
max_model_len=8192,
gpu_memory_utilization=0.90,
)
params = SamplingParams(
temperature=0.0,
max_tokens=256,
)
prompt = "Explain KV cache in LLM inference."
outputs = llm.generate([prompt], params)
print(outputs[0].outputs[0].text)
여기서 성능 차이는 “단일 호출”보다 “여러 요청을 동시에 넣는 상황”에서 두드러집니다.
동시 요청 시뮬레이션 예제
아래는 여러 프롬프트를 한 번에 넣어, vLLM이 내부적으로 효율적인 스케줄링을 하도록 유도하는 예시입니다.
from vllm import LLM, SamplingParams
llm = LLM(
model="Qwen/Qwen2.5-7B-Instruct",
max_model_len=8192,
gpu_memory_utilization=0.90,
)
params = SamplingParams(temperature=0.7, max_tokens=256)
prompts = [
"Summarize the CAP theorem in 5 bullets.",
"Write a SQL query to find duplicate emails.",
"Explain how PagedAttention works.",
"Give me a checklist for debugging GPU OOM.",
"Draft an API design for chat completions.",
]
outs = llm.generate(prompts, params)
for i, o in enumerate(outs):
print("#", i)
print(o.outputs[0].text)
print()
이런 형태가 실제 서비스의 동시 요청 패턴과 더 유사하고, vLLM의 장점이 잘 드러납니다.
성능을 좌우하는 파라미터 체크리스트
max_model_len을 과하게 키우지 않기
“혹시 모르니 최대로”가 가장 위험합니다. max_model_len이 커지면 KV 캐시 상한이 커지고, 동시성에서 VRAM을 급격히 잡아먹습니다.
- 실제 트래픽의
p95컨텍스트 길이를 기준으로 설정 - 길이가 긴 요청은 별도 큐/별도 인스턴스로 분리하는 것도 방법
gpu_memory_utilization은 보수적으로 시작
너무 공격적으로 잡으면 다음 문제가 생길 수 있습니다.
- 드라이버/커널 여유 부족으로 불안정
- 순간 피크에서 OOM
- 다른 프로세스와 VRAM 경합
운영에서는 0.85로 시작해 관측하면서 올리는 방식이 안전합니다.
배치 효과를 극대화하려면 “요청을 모아 넣는 구조”가 필요
vLLM이 continuous batching을 하더라도, 애플리케이션 레벨에서 다음을 고려하면 처리량이 더 좋아집니다.
- 짧은 시간 윈도우(예: 수 ms) 동안 들어온 요청을 큐에 모아 전달
- 스트리밍 응답은 사용자 경험을 지키되, 서버 내부적으로는 스텝 스케줄링을 방해하지 않게 설계
자주 겪는 문제와 해결 방향
1) OOM이 여전하다
PagedAttention이 만능은 아닙니다. OOM이 난다면 보통 아래 중 하나입니다.
max_model_len이 과도함- 동시 요청 상한이 없음(무제한 동시 처리)
- 프롬프트가 지나치게 길거나, 시스템 프롬프트가 비대함
해결은 “메모리 상한을 낮추는 것”과 “동시성 제어”를 함께 해야 합니다. 운영 관점의 단계별 접근은 아래 글에서 더 자세히 다뤘습니다.
2) 지연이 들쑥날쑥하다
지연 분산이 커지는 대표 원인은 다음입니다.
- 긴 요청이 짧은 요청과 같은 자원을 공유
- 프리필이 몰리는 순간 GPU 스케줄이 흔들림
- 토크나이저가 CPU에서 병목
대응 전략은 다음처럼 나뉩니다.
- 긴 컨텍스트 요청을 별도 워커로 라우팅
- 입력 길이 제한 또는 요금/쿼터 정책
- 토크나이저 병렬화, 또는 입력 전처리 최적화
3) transformers 기능이 꼭 필요한데 vLLM로 대체가 되나
대부분의 일반적인 생성 작업(챗, 요약, 코드 생성)은 vLLM로 충분합니다. 다만 아래처럼 “모델 내부를 커스터마이즈”하는 워크로드는 검토가 필요합니다.
- 커스텀 attention 구현
- 특수한 logits processor 체인
- 실험적 decoding 알고리즘을 빠르게 바꿔 끼우는 연구 코드
이런 경우에도 “실험은 transformers”, “서빙은 vLLM”로 분리하는 패턴이 실무적으로 가장 흔합니다.
로컬 벤치마크를 제대로 하는 방법
“2배”를 확인하려면 단일 프롬프트 1회 실행 시간만 재면 안 됩니다. 아래 지표를 함께 봐야 합니다.
- 처리량: 초당 생성 토큰 수(tokens per second)
- 지연: TTFT(time to first token), TPOT(time per output token)
- 동시성: 동시 요청 수에 따른 처리량 곡선
간단한 측정 스크립트 예시입니다.
import time
from vllm import LLM, SamplingParams
llm = LLM(
model="Qwen/Qwen2.5-7B-Instruct",
max_model_len=4096,
gpu_memory_utilization=0.90,
)
params = SamplingParams(temperature=0.0, max_tokens=256)
prompts = ["Explain PagedAttention in simple terms."] * 32
t0 = time.perf_counter()
outs = llm.generate(prompts, params)
t1 = time.perf_counter()
total_tokens = sum(len(o.outputs[0].token_ids) for o in outs)
print("elapsed_sec=", t1 - t0)
print("total_out_tokens=", total_tokens)
print("tok_per_sec=", total_tokens / (t1 - t0))
이 값을 기존 transformers 기반 서버(특히 동시 요청 처리 코드가 있는 형태)와 같은 조건으로 비교해야 의미가 있습니다.
정리: vLLM+PagedAttention은 “메모리 관리 최적화”가 본질
로컬 LLM 추론에서 성능이 막히는 지점은 의외로 연산 자체가 아니라 KV 캐시와 배칭 효율인 경우가 많습니다. vLLM은 PagedAttention으로 KV 캐시를 페이지 단위로 관리해 파편화와 재할당 비용을 줄이고, continuous batching으로 디코딩 단계의 GPU 활용도를 끌어올립니다.
결과적으로 같은 GPU에서 더 많은 동시 요청을 처리하거나, 같은 동시성에서 지연을 낮추는 형태로 “체감 2배”가 나오기 쉬운 구조입니다.
다음 단계로는 (1) 실제 트래픽 분포에 맞춘 max_model_len 튜닝, (2) 동시성 상한과 큐잉 전략, (3) 배포 환경에서 OOM과 지연 스파이크를 줄이는 운영 설정을 함께 가져가면, 로컬 LLM이 서비스 수준의 추론 서버로 올라옵니다.