- Published on
Transformers 로컬 LLM CUDA OOM 줄이는 7가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
로컬 GPU에서 Transformers로 LLM을 돌리다 보면 한 번쯤은 CUDA out of memory를 맞습니다. 문제는 “모델이 너무 커서”만이 아니라, 로딩 방식·dtype·KV 캐시·배치/시퀀스 길이·파편화 같은 운영 요소들이 겹쳐서 터진다는 점입니다.
이 글은 로컬 LLM 추론(또는 간단한 서빙)에서 OOM을 줄이는 7가지 방법을, 바로 적용 가능한 코드 중심으로 정리합니다. 특히 transformers + accelerate 조합을 기준으로 설명합니다.
참고: OOM은 한 번 발생하면 파이썬 프로세스가 살아 있어도 CUDA 메모리 상태가 꼬일 수 있습니다. 재현/튜닝 시엔 프로세스를 깔끔히 재시작하는 편이 더 빠를 때가 많습니다.
0) 먼저: 어디서 얼마나 쓰는지부터 측정
OOM 최적화는 감이 아니라 측정으로 시작합니다. 아래는 현재/최대 사용량을 확인하는 최소 코드입니다.
import torch
def cuda_mem(prefix=""):
if not torch.cuda.is_available():
print(prefix, "CUDA not available")
return
alloc = torch.cuda.memory_allocated() / 1024**3
reserved = torch.cuda.memory_reserved() / 1024**3
peak = torch.cuda.max_memory_allocated() / 1024**3
print(f"{prefix} alloc={alloc:.2f}GB reserved={reserved:.2f}GB peak={peak:.2f}GB")
cuda_mem("start")
추론 루프에서 cuda_mem("after generate") 같은 식으로 찍어보면, 모델 로딩이 문제인지(KV 캐시/출력 토큰이 문제인지) 빠르게 갈라집니다.
1) torch_dtype를 낮추고, 가능하면 bfloat16/float16로 고정
가장 즉효는 dtype입니다. FP32는 로컬 LLM에선 거의 금지 수준으로 비쌉니다.
- Ampere 이상(A100/RTX30/RTX40 등)에서
bfloat16은 안정성이 좋고, 메모리도 FP32 대비 절반 float16은 더 넓게 지원되지만, 일부 모델/연산에서 수치 불안정이 있을 수 있음
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
model_id = "mistralai/Mistral-7B-Instruct-v0.2"
tokenizer = AutoTokenizer.from_pretrained(model_id, use_fast=True)
model = AutoModelForCausalLM.from_pretrained(
model_id,
torch_dtype=torch.bfloat16, # 또는 torch.float16
device_map="cuda"
)
추가로, torch.set_default_dtype 같은 전역 변경은 예기치 않은 메모리/정확도 문제를 만들 수 있어 권장하지 않습니다. 모델 로딩 시점에 명시하는 편이 안전합니다.
2) 4-bit/8-bit 양자화(bitsandbytes)로 가중치 메모리 압축
VRAM이 부족한 로컬 환경에서 가장 강력한 옵션이 양자화입니다. 특히 4-bit는 “돌아가게 만드는” 수준의 차이를 만듭니다.
설치(환경에 맞게 CUDA 버전 확인 필요):
pip install -U transformers accelerate bitsandbytes
4-bit 로딩 예시:
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,
)
tokenizer = AutoTokenizer.from_pretrained(model_id, use_fast=True)
model = AutoModelForCausalLM.from_pretrained(
model_id,
quantization_config=bnb_config,
device_map="auto"
)
주의할 점:
- 양자화는 VRAM을 크게 줄이지만, 속도/정확도/호환성이 모델과 GPU에 따라 달라집니다.
- 특정 연산이 FP16/BF16으로 올라오면서 예상보다 메모리가 늘 수 있으니,
compute_dtype를 명시하고 측정하세요.
3) device_map="auto" + CPU 오프로딩으로 “완전 OOM”을 피하기
모델이 VRAM에 안 들어갈 때, 일부 레이어를 CPU로 내리는 방식이 있습니다. 속도는 느려지지만, 로컬에서 “일단 동작”이 목표라면 매우 유용합니다.
from transformers import AutoModelForCausalLM, AutoTokenizer
model_id = "mistralai/Mixtral-8x7B-Instruct-v0.1"
tokenizer = AutoTokenizer.from_pretrained(model_id, use_fast=True)
model = AutoModelForCausalLM.from_pretrained(
model_id,
torch_dtype="auto",
device_map="auto",
offload_folder="./offload", # 오프로딩 파일 저장
)
팁:
- NVMe SSD가 있다면 오프로딩 체감이 훨씬 낫습니다.
accelerate가 설치되어 있어야device_map이 제대로 동작합니다.
4) max_new_tokens와 입력 길이(max_length)를 줄여 KV 캐시 폭발 막기
추론 OOM의 주범 중 하나는 KV 캐시입니다. 생성 토큰이 늘어날수록 레이어별 K/V 텐서가 누적되고, 배치가 커질수록 곱으로 증가합니다.
- 입력 프롬프트가 길다: 이미 KV 캐시가 큰 상태에서 시작
max_new_tokens가 크다: 생성 중 계속 증가- 배치가 크다: 요청을 묶을수록 선형 이상으로 커짐
import torch
prompt = "긴 문서를 요약해줘..."
inputs = tokenizer(prompt, return_tensors="pt", truncation=True, max_length=2048).to(model.device)
with torch.inference_mode():
out = model.generate(
**inputs,
max_new_tokens=256, # 필요 이상으로 크게 잡지 않기
do_sample=False,
use_cache=True, # 기본적으로 켜져 있음
)
print(tokenizer.decode(out[0], skip_special_tokens=True))
실전 기준으로는:
- “요약/질의응답”은
max_new_tokens를 128~512 범위에서 시작 - 입력은
truncation=True와max_length를 반드시 명시
추론 품질을 유지하면서 길이를 줄이는 전략으로는 RAG가 대표적입니다. 로컬 LLM에서도 “문서 전체를 다 넣는” 대신 검색으로 컨텍스트를 줄이면 VRAM과 지연이 같이 줄어듭니다. 관련 패턴은 CoT 누출 없이 추론 강화하는 RAG+검증자 패턴도 참고할 만합니다.
5) 배치/동시성을 줄이고, 토크나이저 패딩을 최적화
여러 요청을 한 번에 처리하려고 배치를 키우면, KV 캐시와 어텐션 연산이 같이 커져 OOM이 빨리 옵니다. 로컬에서는 특히 “동시성 1~2”가 체감상 안정적인 경우가 많습니다.
또한 패딩이 비효율적이면 짧은 문장도 긴 문장 길이에 맞춰 메모리를 먹습니다. 가능한 한 배치 내 길이를 비슷하게 묶거나, 패딩 전략을 신경 써야 합니다.
texts = ["짧은 질문", "조금 더 긴 질문..." * 200]
batch = tokenizer(
texts,
return_tensors="pt",
padding=True, # 배치 패딩
truncation=True,
max_length=1024,
)
batch = {k: v.to(model.device) for k, v in batch.items()}
with torch.inference_mode():
out = model.generate(**batch, max_new_tokens=128)
가능하면:
- 길이가 비슷한 요청끼리 묶기(서빙이면 큐에서 정렬)
- 배치 크기를 줄이고, 대신 캐시/스루풋은 다른 방식으로 확보
이때 “캐시”라는 관점은 GPU 메모리뿐 아니라 빌드/배포 파이프라인에도 통합니다. 모델 이미지/의존성 빌드가 느려서 자주 재시작이 어렵다면, Docker 빌드 70% 단축 - 멀티스테이지·캐시 전략처럼 운영 시간을 줄여 튜닝 반복을 빠르게 만드는 것도 도움이 됩니다.
6) 어텐션 최적화: SDPA/FlashAttention로 메모리 압박 완화
PyTorch 2.x에서는 SDPA(Scaled Dot-Product Attention)가 기본 최적화를 제공하며, 환경에 따라 FlashAttention 계열 커널이 사용됩니다. 이 경로는 어텐션의 중간 텐서 사용량을 줄여 OOM 가능성을 낮출 수 있습니다.
Transformers에서는 설정으로 켤 수 있습니다.
from transformers import AutoModelForCausalLM
model = AutoModelForCausalLM.from_pretrained(
model_id,
torch_dtype=torch.bfloat16,
device_map="cuda",
attn_implementation="sdpa", # 지원 모델에서 효과
)
주의:
- 모든 모델/버전에서 동일하게 동작하지 않습니다.
- 드라이버, PyTorch, CUDA 조합에 따라 커널 선택이 달라집니다.
- 문제가 생기면
attn_implementation을 제거하고 기본값으로 되돌려 비교하세요.
7) 메모리 파편화/캐시 관리: PYTORCH_CUDA_ALLOC_CONF와 안전한 정리
“분명 여유가 있어 보이는데 OOM”은 파편화일 수 있습니다. PyTorch의 CUDA allocator가 큰 블록을 못 잡아 실패하는 경우가 있습니다.
가장 흔한 처방은 max_split_size_mb 조정입니다. 실행 전에 환경변수로 지정합니다.
export PYTORCH_CUDA_ALLOC_CONF="max_split_size_mb:128"
워크로드에 따라 64/128/256 등으로 바꿔가며 재현 테스트를 해보세요.
또한, 추론 서버/노트북에서 모델을 여러 번 갈아끼우며 실험하면, 텐서 참조가 남아 메모리가 반환되지 않는 경우가 흔합니다. 실험용으로는 아래 패턴이 유용합니다.
import gc
import torch
def cleanup_cuda():
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
torch.cuda.ipc_collect()
# 모델 교체/실험 사이에 호출
cleanup_cuda()
단, empty_cache()는 “사용 중인 텐서를 줄이는” 게 아니라 캐싱된 블록을 반환하는 성격이라, 근본 해결은 여전히 “큰 텐서를 만들지 않는 설정”입니다.
자주 터지는 조합과 빠른 처방 요약
로딩 단계에서 바로 OOM
- FP32로 로딩됨
- 모델이 VRAM보다 큼
처방:
torch_dtype=torch.bfloat16또는torch.float16- 4-bit/8-bit 양자화
device_map="auto"+ 오프로딩
생성 도중 OOM(몇 토큰 뽑다가 터짐)
max_new_tokens가 큼- 입력이 너무 김
- 배치가 큼
처방:
max_new_tokens축소truncation=True, max_length=...적용- 동시성/배치 축소, 길이 비슷한 요청끼리 묶기
“여유 있어 보이는데” OOM
- 파편화/캐시 블록 문제
처방:
PYTORCH_CUDA_ALLOC_CONF의max_split_size_mb튜닝- 실험 중간에 참조 제거,
gc.collect()후 캐시 정리
결론: OOM은 ‘모델 크기’가 아니라 ‘운영 변수’의 곱
로컬 LLM에서 CUDA OOM을 줄이는 핵심은 다음 순서로 접근하는 것입니다.
- dtype과 양자화로 가중치 메모리를 먼저 줄이고
max_new_tokens/입력 길이/배치로 KV 캐시 성장을 제한하며- 필요하면 오프로딩과 어텐션 최적화로 마진을 확보하고
- 마지막으로 allocator 튜닝으로 파편화 리스크를 낮춥니다.
위 7가지를 적용하면, 같은 GPU에서도 “겨우 로딩”에서 “안정적으로 추론”까지 체감이 크게 바뀝니다. 다음 단계로는 vLLM/TensorRT-LLM 같은 서빙 스택으로 넘어가며 KV 캐시/페이지드 어텐션을 최적화하는 선택지도 검토해볼 만합니다.