- Published on
Transformers 로컬 LLM VRAM 부족 - 4bit·KV캐시 튜닝
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
로컬 GPU에서 Transformers로 LLM을 돌리다 보면 가장 흔한 실패가 CUDA out of memory 입니다. 특히 7B, 13B급 모델을 8GB~12GB VRAM에서 돌릴 때는 “가중치만 줄이면 되겠지”라고 접근했다가, 실제로는 KV 캐시가 길이와 배치에 비례해 폭증하면서 OOM이 나는 경우가 많습니다.
이 글에서는 VRAM 사용량을 가중치(Weights), KV 캐시, 활성화(Activations) 로 나눠 이해하고, Transformers에서 바로 적용 가능한 4bit 양자화(bitsandbytes) 와 KV 캐시 튜닝(use_cache, max_new_tokens, sliding window 등) 을 중심으로 로컬 추론을 안정화하는 방법을 정리합니다.
추가로 운영 환경에서 OOM이 반복될 때는 “한 번 터지고 끝”이 아니라 프로세스 재시작, 워커 크래시로 이어질 수 있으니, 장애 관점 진단 루틴도 함께 가져가면 좋습니다. 비슷한 맥락의 운영 팁은 Ray Serve 배포 시 모델 로딩 지연·OOM 해결법, 컨테이너가 반복 재시작되는 상황은 K8s CrashLoopBackOff 10분 원인별 진단법도 같이 참고하면 연결이 됩니다.
VRAM이 어디서 새는지: 3가지 덩어리로 쪼개기
1) 가중치 메모리(Weights)
- 모델 파라미터가 GPU에 올라가며 차지하는 메모리
- FP16/BF16 기준 대략
params * 2 bytes - 7B는 FP16이면 대략 14GB 수준(여기에 오버헤드 포함)
즉 12GB VRAM에서 7B를 FP16으로는 원천적으로 어렵고, 4bit/8bit 양자화가 사실상 필수입니다.
2) KV 캐시(Key/Value cache)
- 디코딩(생성) 단계에서 과거 토큰의 attention을 재사용하기 위해 저장하는 캐시
- 시퀀스 길이(프롬프트 길이 + 생성 길이) 에 비례해 증가
- 배치 크기(동시 요청 수)에도 비례
체감상 “프롬프트가 길어질수록 갑자기 OOM”이 나는 주범이 KV 캐시입니다.
3) 활성화 메모리(Activations)
- forward 중간 텐서(특히 학습/미세튜닝에서 큼)
- 순수 추론에서도 배치가 커지면 증가
torch.no_grad()혹은torch.inference_mode()로 줄일 수 있음
먼저 확인할 것: 지금 OOM이 가중치인지 KV 캐시인지
OOM을 해결하려면 “줄여야 할 대상”을 먼저 맞춰야 합니다.
- 모델 로딩 직후 OOM: 가중치가 원인일 확률이 큼(4bit/8bit 필요)
- 짧은 프롬프트는 되는데 길어지면 OOM: KV 캐시가 원인일 확률이 큼
- 동시 요청을 늘리면 OOM: KV 캐시와 활성화가 함께 원인일 확률이 큼
런타임에서 VRAM을 대략적으로 관찰하는 최소 코드는 아래처럼 잡을 수 있습니다.
import torch
def vram(msg=""):
if not torch.cuda.is_available():
print("CUDA not available")
return
alloc = torch.cuda.memory_allocated() / 1024**2
reserved = torch.cuda.memory_reserved() / 1024**2
print(f"{msg} allocated={alloc:.1f}MiB reserved={reserved:.1f}MiB")
vram("start")
# 모델 로드
vram("after load")
# 생성 수행
vram("after generate")
allocated는 실제 사용량, reserved는 캐싱 allocator가 잡아둔 영역이라 둘 다 같이 보는 게 좋습니다.
4bit 양자화로 가중치부터 줄이기(Transformers + bitsandbytes)
로컬 LLM 추론에서 가장 실전적인 조합은 다음입니다.
transformersbitsandbytes- (가능하면)
accelerate
핵심은 BitsAndBytesConfig로 4bit 로딩을 지정하는 것입니다.
4bit 로딩 예시(NF4 + double quant)
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
model_id = "meta-llama/Llama-2-7b-chat-hf" # 예시
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_use_double_quant=True,
bnb_4bit_compute_dtype=torch.bfloat16, # GPU가 bf16 지원하면 권장
)
tok = AutoTokenizer.from_pretrained(model_id, use_fast=True)
model = AutoModelForCausalLM.from_pretrained(
model_id,
quantization_config=bnb_config,
device_map="auto",
)
model.eval()
옵션 해설
load_in_4bit=True: 가중치 4bit 양자화 로딩bnb_4bit_quant_type="nf4": 일반적으로 성능-품질 균형이 좋다고 많이 쓰는 방식bnb_4bit_use_double_quant=True: 추가 압축으로 VRAM을 더 줄이는 경우가 많음bnb_4bit_compute_dtype: 연산 dtype.bfloat16이 가능하면 안정적인 편이고, 안 되면float16로
4bit로도 부족할 때 체크리스트
device_map="auto"가 일부 레이어를 CPU로 오프로딩할 수 있음(속도는 감소)- GPU가 8GB 이하라면 7B도 프롬프트 길이에 따라 빡빡할 수 있음(이때는 KV 캐시 튜닝이 더 중요)
KV 캐시가 진짜 폭탄이다: 길이와 동시성의 곱
KV 캐시는 대략적으로 다음에 비례합니다.
- 배치 크기
batch_size - 레이어 수
num_layers - 헤드 수/헤드 차원
- 시퀀스 길이
seq_len(프롬프트 + 생성 토큰) - dtype(보통 FP16/BF16)
즉, 동시 요청을 2배로 늘리면 KV 캐시도 거의 2배가 되고, max_new_tokens를 2배로 늘려도 거의 2배가 됩니다.
그래서 “4bit로 로딩했는데도 OOM”이면, 그 다음은 KV 캐시를 건드려야 합니다.
KV 캐시 튜닝 1: max_new_tokens를 먼저 제한
가장 즉각적인 효과는 생성 길이를 줄이는 것입니다.
inputs = tok("한국어로 짧게 요약해줘: ...", return_tensors="pt").to(model.device)
with torch.inference_mode():
out = model.generate(
**inputs,
max_new_tokens=256, # 1024 같은 큰 값은 KV 캐시 폭증
do_sample=False,
use_cache=True,
)
print(tok.decode(out[0], skip_special_tokens=True))
- 제품/서비스에서는 “무제한 생성”을 허용하면 최악의 경우 한 요청이 GPU를 고갈시킵니다.
- 실제 운영에서는
max_new_tokens상한을 두고, 필요하면 “계속 생성” UX로 분할하는 편이 안전합니다.
KV 캐시 튜닝 2: 긴 프롬프트를 줄이는 전략(요약, RAG, chunk)
프롬프트 길이가 길면, 생성 길이가 짧아도 KV 캐시가 커집니다.
실전에서는 다음이 효과적입니다.
- 대화 히스토리: 최근 N턴만 유지하고 이전은 요약본으로 교체
- 문서 입력: chunking 후 필요한 chunk만 넣는 RAG
- 시스템 프롬프트: 중복되는 지시문을 최소화
이건 “메모리 튜닝”이면서 동시에 latency도 줄여줍니다.
KV 캐시 튜닝 3: use_cache=False는 최후의 수단
use_cache=False는 KV 캐시를 저장하지 않으므로 메모리를 크게 줄일 수 있지만, 매 토큰마다 과거를 다시 계산해 속도가 급격히 느려집니다.
with torch.inference_mode():
out = model.generate(
**inputs,
max_new_tokens=128,
use_cache=False, # 메모리는 줄지만 속도는 크게 손해
)
- 디버깅 또는 “어떻게든 돌아가게” 만들 때는 유용
- 일반적인 채팅/생성 서비스에는 권장하지 않습니다
KV 캐시 튜닝 4: Sliding Window Attention(모델이 지원할 때)
일부 모델/아키텍처는 슬라이딩 윈도우(최근 토큰만 attention)로 KV 캐시 증가를 제한할 수 있습니다.
다만 이건 모델 설정과 구현 지원 여부에 따라 다릅니다. Transformers에서는 모델 config에 sliding_window 같은 필드가 있는지 확인하고, 지원하는 모델에서만 적용해야 합니다.
주의: 지원하지 않는 모델에 억지로 설정을 넣으면 성능 저하가 아니라 동작 자체가 꼬일 수 있습니다.
성능과 VRAM을 같이 잡는 실전 파라미터 조합
아래 조합은 “로컬 GPU 8GB~12GB에서 7B급을 현실적으로”를 목표로 할 때 자주 쓰는 세트입니다.
- 가중치: 4bit(NF4 + double quant)
- 연산 dtype:
bfloat16가능하면 사용, 아니면float16 - 생성 제한:
max_new_tokens를 128~512 등으로 상한 - 프롬프트 제한: 히스토리 요약 + RAG로 입력 길이 관리
- 동시성 제한: 배치/동시 요청 수를 VRAM에 맞춰 제한
예시 코드:
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM, BitsAndBytesConfig
model_id = "mistralai/Mistral-7B-Instruct-v0.2" # 예시
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_use_double_quant=True,
bnb_4bit_compute_dtype=torch.float16,
)
tok = AutoTokenizer.from_pretrained(model_id, use_fast=True)
model = AutoModelForCausalLM.from_pretrained(
model_id,
device_map="auto",
quantization_config=bnb_config,
)
model.eval()
prompt = "다음 요구사항을 5줄로 정리해줘: ..."
inputs = tok(prompt, return_tensors="pt").to(model.device)
gen_kwargs = dict(
max_new_tokens=256,
temperature=0.2,
top_p=0.9,
do_sample=True,
use_cache=True,
)
with torch.inference_mode():
out = model.generate(**inputs, **gen_kwargs)
print(tok.decode(out[0], skip_special_tokens=True))
OOM이 반복될 때 운영 관점 체크(재시작, 단편화, 워커 격리)
로컬 개발이든 단일 서버 서빙이든, OOM은 종종 “한 번의 실패”로 끝나지 않고 프로세스 상태를 망가뜨립니다.
1) PyTorch 캐시 단편화와 reserved 폭증
- OOM 직전
reserved가 과도하게 커져 있으면 캐시 allocator 영향일 수 있습니다. - 요청 패턴이 들쭉날쭉(짧은 요청과 긴 요청이 섞임)하면 단편화가 심해질 수 있습니다.
가능하면:
- 입력 길이/생성 길이를 구간화해서 워커를 분리
- “긴 요청 전용 워커”를 따로 두기
2) 동시 요청 제어가 사실상 필수
KV 캐시는 동시성에 선형 비례합니다. 따라서 서버에서는 다음 중 하나가 필요합니다.
- 큐잉(동시 실행 제한)
- 배치 전략(가능하면 길이가 비슷한 요청끼리)
- 워커 수를 VRAM에 맞게 강제
서빙 환경에서 모델 로딩 지연과 OOM이 같이 난다면 위의 운영 팁을 좀 더 확장한 글인 Ray Serve 배포 시 모델 로딩 지연·OOM 해결법이 직접적으로 도움이 됩니다.
3) 컨테이너/프로세스가 계속 죽는다면 원인 분리
OOM은 결과이고, 원인은 “요청 길이 폭주”, “동시성 폭주”, “모델 교체 시 메모리 누수” 등 다양합니다. 쿠버네티스에서 반복 재시작으로 보이면 K8s CrashLoopBackOff 10분 원인별 진단법처럼 이벤트와 로그를 기준으로 원인부터 분리하는 게 빠릅니다.
문제 해결 순서(추천)
- 가중치부터: 4bit 로딩으로 “모델이 뜨는 상태” 만들기
- 생성 길이 제한:
max_new_tokens상한 설정 - 프롬프트 길이 관리: 히스토리 요약, RAG, chunking
- 동시성 제한: 큐잉 또는 워커 분리
- 그래도 안 되면:
use_cache=False같은 극단 옵션은 마지막에
마무리
Transformers 로컬 LLM에서 VRAM 부족을 해결하려면 “4bit면 끝”이 아니라, KV 캐시가 시퀀스 길이와 동시성에 의해 폭증한다는 구조를 이해하고 제어해야 합니다.
- 4bit 양자화는 가중치를 줄이는 가장 강력한 1차 수단
- KV 캐시는
max_new_tokens, 프롬프트 길이, 동시성 제한으로 다스리는 게 핵심 - 운영에서는 OOM을 장애로 보고 재현 가능한 제한(상한, 큐잉, 워커 분리)으로 설계하는 게 재발을 막습니다
원하는 GPU VRAM(예: 8GB, 12GB, 24GB)과 목표 모델(예: 7B, 14B), 목표 컨텍스트 길이(예: 4k, 8k)를 알려주면, 그 조건에 맞춰 권장 max_new_tokens, 동시성, 프롬프트 정책까지 더 구체적인 튜닝 가이드를 제안할 수 있습니다.