- Published on
Transformers 로컬 LLM, CUDA OOM 없이 4bit 가속
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
로컬에서 LLM을 돌리다 보면 가장 먼저 부딪히는 벽이 CUDA out of memory 입니다. 특히 7B~13B 급 모델은 FP16만으로도 VRAM을 빠르게 소모하고, 조금만 설정이 어긋나도 로딩 단계에서 바로 터집니다.
이 글에서는 Hugging Face Transformers에서 bitsandbytes 4bit 양자화(QLoRA 계열 로딩 방식)를 사용해 CUDA OOM 없이 로컬 LLM을 가속하는 방법을 실전 중심으로 정리합니다. 단순히 “4bit로 로드한다”를 넘어, OOM이 나는 진짜 이유(캐시, 디바이스 매핑, 컨텍스트 길이, 로더 설정)와 안정적으로 운영하는 체크리스트까지 다룹니다.
또한 장애를 추적하는 관점은 리소스 고갈 문제와 본질이 같습니다. 디스크 inode 고갈을 진단하듯 VRAM도 “무엇이 얼마나 먹는지”를 쪼개서 봐야 합니다. 비슷한 진단 접근이 필요하다면 리눅스 디스크 100%? inode 고갈 진단·복구 실전도 함께 참고하면 도움이 됩니다.
왜 4bit가 OOM을 줄이나: 메모리 모델부터
LLM 추론에서 VRAM을 잡아먹는 구성 요소는 크게 3가지입니다.
- 가중치(weights): 모델 파라미터 자체
- KV 캐시: 토큰을 길게 생성할수록 늘어나는 캐시(레이어 수, 헤드 수,
seq_len에 비례) - 활성화/버퍼(activations, temporary buffers): attention 계산, layernorm, matmul 등 중간 버퍼
4bit 양자화는 주로 가중치 메모리를 크게 줄입니다. 예를 들어 7B 모델은 FP16 기준 대략 14GB 수준의 가중치 메모리가 필요하지만, 4bit로 줄이면 이론상 1/4 근처까지 내려갑니다(실제로는 스케일/제로포인트, 메타데이터, 일부 레이어 FP16 유지 등으로 약간 더 사용).
하지만 여기서 흔한 함정이 있습니다.
- “가중치를 4bit로 줄였는데도 OOM”은 대개 KV 캐시 또는 컨텍스트 길이가 원인입니다.
- 또는
device_map설정이 잘못되어 특정 GPU에만 몰리거나, CPU offload가 의도대로 되지 않아 한쪽에 피크가 생깁니다.
따라서 4bit 로딩은 시작일 뿐이고, KV 캐시와 디바이스 매핑을 같이 다뤄야 OOM을 안정적으로 피할 수 있습니다.
준비물: 권장 버전과 설치
필수 패키지
transformersacceleratebitsandbytes- (선택)
flash-attn또는 PyTorch SDPA 최적화
다음은 일반적인 설치 예시입니다.
pip install -U transformers accelerate bitsandbytes
환경에 따라 bitsandbytes가 CUDA 버전과 맞지 않아 로딩이 실패할 수 있습니다. 이 경우 오류 메시지에 나온 CUDA/GLIBC 요구사항을 확인하고, 필요하면 PyTorch 및 CUDA 런타임을 맞추는 편이 빠릅니다.
Transformers에서 4bit 로딩: 가장 안전한 기본 템플릿
아래 코드는 로컬 LLM을 4bit로 로딩하는 “기본 골격”입니다. 핵심은 BitsAndBytesConfig와 device_map="auto" 조합입니다.
주의: 본문에서 부등호 문자 노출은 MDX 빌드 에러를 유발할 수 있어, 관련 표현은 인라인 코드로 표기합니다.
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", # 일반적으로 NF4 권장
bnb_4bit_use_double_quant=True, # 메모리 추가 절감
bnb_4bit_compute_dtype=torch.float16, # GPU가 좋으면 bfloat16도 고려
)
tokenizer = AutoTokenizer.from_pretrained(model_id, use_fast=True)
model = AutoModelForCausalLM.from_pretrained(
model_id,
quantization_config=bnb_config,
device_map="auto", # VRAM에 맞춰 자동 분산
torch_dtype=torch.float16, # 일부 레이어 dtype 힌트
)
model.eval()
prompt = "로컬 LLM을 4bit로 돌릴 때 OOM을 줄이는 방법을 설명해줘."
inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
with torch.inference_mode():
out = model.generate(
**inputs,
max_new_tokens=256,
do_sample=True,
temperature=0.7,
top_p=0.9,
)
print(tokenizer.decode(out[0], skip_special_tokens=True))
nf4와 double quant를 쓰는 이유
nf4는 LLM 가중치 분포에 맞춘 4bit 양자화 방식으로, 단순 int4보다 품질/안정성이 좋습니다.bnb_4bit_use_double_quant=True는 양자화 스케일을 한 번 더 양자화해 메모리를 조금 더 줄입니다.
OOM의 1순위 원인: KV 캐시와 컨텍스트 길이
4bit로 가중치를 줄였는데도 추론 중 OOM이 나는 경우는 대부분 아래 중 하나입니다.
1) max_new_tokens만 줄여서는 해결이 안 되는 경우
생성 토큰 수(max_new_tokens)도 중요하지만, 실전에서는 **입력 길이(프롬프트 길이)**와 대화 히스토리 누적이 더 위험합니다.
- 대화형 앱에서 이전 대화가 계속 누적되면
seq_len이 커지고 KV 캐시가 폭증합니다. - 특히 8K, 16K 컨텍스트 모델은 “가능”과 “안정적 운영”이 다릅니다.
2) use_cache=True가 기본이라는 점
Transformers의 causal LM은 기본적으로 KV 캐시를 사용합니다. 속도는 빨라지지만 메모리는 늘어납니다. 메모리가 빠듯하면 캐시를 끄는 것도 선택지입니다(속도는 느려짐).
gen_out = model.generate(
**inputs,
max_new_tokens=256,
use_cache=False,
)
3) 배치 크기
로컬에서 종종 놓치는 부분이 배치입니다.
- 단일 프롬프트라도 서버 코드에서 요청을 묶어 배치 처리하면 KV 캐시가 배치 크기만큼 늘어납니다.
- “로컬 테스트는 되는데 서버에서 OOM”은 배치/동시성 문제일 확률이 큽니다.
비슷한 유형의 리소스 누수/동시성 문제를 파고드는 방식은 asyncio에서도 자주 발생합니다. 비동기 작업이 쌓여 메모리가 터지는 패턴을 다룬 글로 Python asyncio Task was destroyed but it is pending 경고 원인 5가지와 완벽 해결법을 함께 보면 문제 접근 방식에 도움이 됩니다.
device_map과 오프로딩: “한 GPU에 몰림” 방지
device_map="auto"는 편하지만, 아래 케이스에서는 수동 제어가 필요할 수 있습니다.
- GPU가 2장 이상인데도 한 장에만 적재되는 경우
- CPU offload가 의도대로 되지 않는 경우
- 특정 레이어만 GPU에 올리고 나머지는 CPU로 내리고 싶은 경우
CPU 오프로딩(메모리 부족 시 응급 처치)
model = AutoModelForCausalLM.from_pretrained(
model_id,
quantization_config=bnb_config,
device_map="auto",
offload_folder="./offload",
)
offload_folder는 CPU/RAM 또는 디스크로 내릴 때 임시 저장소로 사용됩니다.- 속도는 느려질 수 있지만, “일단 돌아가게” 만드는 데는 효과적입니다.
속도까지 챙기기: SDPA/Flash Attention와 주의점
4bit는 VRAM을 줄여주지만, “항상 더 빠르다”는 보장은 없습니다. 속도는 attention 구현과 커널 최적화에 크게 좌우됩니다.
PyTorch SDPA 사용
PyTorch 2.x에서는 SDPA가 기본 최적화로 동작하는 경우가 많습니다. Transformers에서는 모델/환경에 따라 자동 선택되지만, 문제가 생기면 attention 구현을 바꿔보는 것이 좋습니다.
또한 FP16 환경에서 출력이 NaN으로 튀는 등의 수치 이슈가 있으면 커널/정밀도/스케일링을 의심해야 합니다. 비슷한 디버깅 관점은 Triton FP16 배포에서 출력 NaN 디버깅 가이드에서 더 깊게 다룹니다.
실전 OOM 디버깅 체크리스트
OOM은 “최종적으로 터진 지점”만 보면 원인을 놓치기 쉽습니다. 아래 순서로 쪼개서 확인하는 것이 효율적입니다.
1) 로딩 단계 OOM vs 생성 단계 OOM 구분
- 로딩 단계에서 OOM: 가중치 적재/디바이스 매핑/중복 로딩 문제
- 생성 중 OOM: KV 캐시/컨텍스트 길이/배치/동시성 문제
2) 메모리 사용량 로깅
import torch
def report(tag: str):
if torch.cuda.is_available():
allocated = torch.cuda.memory_allocated() / 1024**2
reserved = torch.cuda.memory_reserved() / 1024**2
print(f"[{tag}] allocated={allocated:.1f}MiB reserved={reserved:.1f}MiB")
report("before load")
# model load
report("after load")
# generate
report("after generate")
reserved가 크게 남아 있는 것은 캐싱/풀링 영향일 수 있습니다. 프로세스가 계속 살아있는 서버에서는 이 패턴이 누적되어 “가끔 OOM”으로 나타나기도 합니다.
3) 컨텍스트 길이 상한을 명시적으로 관리
대화형 서비스라면 입력 토큰을 강제로 자르는 정책이 필요합니다.
max_input_tokens = 2048
enc = tokenizer(prompt, return_tensors="pt", truncation=True, max_length=max_input_tokens)
inputs = enc.to(model.device)
4) 동시성 제한
로컬 서버에서 동시에 여러 요청을 처리하면, 각 요청이 KV 캐시를 들고 있기 때문에 VRAM이 선형으로 증가합니다.
- 간단히는 동시 요청 수를 제한
- 또는 요청을 큐잉하고 배치 크기를 통제
이건 DB 커넥션 풀이 고갈되는 문제와 구조가 유사합니다. 리소스를 “무한정 병렬화”하면 결국 고갈됩니다.
4bit로도 터질 때의 우선순위 대응
- 입력 길이/대화 히스토리 축소: KV 캐시 폭증 방지
- 동시성 제한: 서버에서 “가끔 OOM”의 주범
use_cache=False고려: 속도 대신 안정성- 더 작은 모델로 다운스케일: 13B에서 7B, 또는 7B에서 3B
- CPU offload 또는 멀티 GPU 분산: 최후의 안전장치
마무리: 4bit는 시작이고, 운영은 KV 캐시가 좌우한다
Transformers에서 bitsandbytes 4bit 양자화를 적용하면 로컬에서도 훨씬 큰 모델을 현실적으로 다룰 수 있습니다. 다만 OOM을 “완전히” 피하려면 가중치만 줄이는 것으로는 부족하고, **KV 캐시(컨텍스트 길이, 배치, 동시성)**를 함께 통제해야 합니다.
정리하면 다음 3가지만 기억해도 시행착오가 크게 줄어듭니다.
- 4bit 로딩은
BitsAndBytesConfig+device_map="auto"로 시작 - OOM의 대부분은 컨텍스트/동시성에 의해 KV 캐시가 커져서 발생
- 로딩 OOM과 생성 OOM을 구분하고, 메모리 로깅으로 병목을 특정
원하면 다음 글에서는 accelerate 설정 파일로 멀티 GPU에 안정적으로 분산하는 방법, 그리고 실제 서비스 형태(REST/WebSocket)에서 동시성 제한을 어떻게 거는지까지 이어서 정리할 수 있습니다.