Fuwari Banner
지니제스트Tech Archive
AI8분 소요

PyTorch CUDA OOM 에러와 메모리 파편화 해결

ggeniezst

충분한 VRAM이 남아있음에도 발생하는 PyTorch CUDA OOM 에러의 원인을 분석하고, Caching Allocator의 메모리 파편화 구조와 PYTORCH_CUDA_ALLOC_CONF 설정을 통해 해결하는 실무 가이드입니다.

Sponsored

대형 언어 모델(LLM) 파인튜닝이나 이미지 생성 모델을 학습시킬 때, nvidia-smi 상으로는 아직 수 기가바이트(GiB) 이상의 VRAM 여유가 남아있음에도 갑작스럽게 torch.cuda.OutOfMemoryError가 발생하며 프로세스가 중단되는 현상을 마주합니다. 많은 엔지니어가 배치 사이즈를 무작정 줄이거나 불필요하게 torch.cuda.empty_cache()를 루프마다 삽입하여 학습 처리량을 심각하게 떨어뜨리는 실수를 범합니다.

이 문제는 실제 물리 메모리가 부족해서가 아니라, PyTorch의 메모리 할당 엔진인 Caching Allocator 내부에서 발생하는 외부 파편화(External Fragmentation) 때문에 발생합니다. PyTorch의 내부 메모리 풀 아키텍처와 CUDA 가상 메모리 관리 메커니즘을 이해하면 코드 수정 없이 환경 변수 설정만으로도 파편화로 인한 OOM 에러를 완벽하게 차단할 수 있습니다.


CUDA OOM 에러와 시스템 로그 분석

PyTorch에서 메모리 할당 실패가 발생하면 런타임은 단순한 에러 메시지 외에 현재 GPU 메모리 상태를 요약한 진단 로그를 콘솔에 출력합니다. 이 로그를 정확히 해석하는 것이 문제 해결의 시작점입니다.

실제 OOM 에러 로그 패턴

대표적인 파편화 기반 OOM 로그는 다음과 같은 형태로 나타납니다.

TEXT
torch.cuda.OutOfMemoryError: CUDA out of memory. Tried to allocate 512.00 MiB (GPU 0; 23.69 GiB total capacity; 18.24 GiB already allocated; 4.12 GiB free; 1.33 GiB reserved in total by PyTorch) If reserved memory is >> allocated memory try setting max_split_size_mb to avoid fragmentation.  See documentation for Memory Management and PYTORCH_CUDA_ALLOC_CONF

로그에 나타난 수치를 분석하면 시스템의 실질적인 모순을 발견할 수 있습니다.

  • total capacity: GPU의 전체 물리 VRAM 크기 (24GB)
  • already allocated: PyTorch 텐서들이 실제로 점유하여 사용 중인 활성 메모리 (18.24 GiB)
  • free: 시스템 전체 관점에서 어떤 프로세스도 점유하지 않은 순수 빈 물리 메모리 (4.12 GiB)
  • reserved in total by PyTorch: PyTorch 캐싱 할당자가 CUDA 드라이버로부터 미리 선점해둔 메모리 (1.33 GiB)

요청된 크기는 불과 512.00 MiB이며, 남아있는 여유 메모리(free 4.12 GiB)와 캐시 여유 공간의 합은 요청 크기를 훨씬 상회합니다. 그럼에도 불구하고 에러가 발생한 이유는 할당기가 요구하는 512 MiB 크기의 연속된 단일 가상 주소 블록(Contiguous Block)을 확보하지 못했기 때문입니다.

메모리 점유 상태 점검

장애 발생 시 쉘에서 GPU 하드웨어 상태와 프로세스 점유 현황을 확인합니다.

BASH
# GPU 메모리 및 실행 중인 프로세스 PID 확인
nvidia-smi --query-gpu=index,name,memory.total,memory.used,memory.free --format=csv

# GPU 인스턴스 상세 정보 및 프로세스 점유 맵 확인
nvidia-smi pmon -s m -c 1

PyTorch Caching Allocator와 메모리 파편화 구조

PyTorch가 OS 커널 및 CUDA 드라이버와 상호작용하는 방식을 파악해야 파편화의 근본 원인을 이해할 수 있습니다.

Caching Allocator 도입 배경

CUDA 드라이버가 제공하는 원시 메모리 할당 함수인 cudaMalloccudaFree는 호출 시 상당한 오버헤드를 발생시킵니다. 이 함수들은 GPU 디바이스 전체의 동기화(Synchronization)를 유발하고 커널 모드 전환에 따른 레이턴시를 발생시키므로, 밀리초 단위로 수만 개의 텐서가 생성되고 소멸하는 딥러닝 런타임에서 직접 호출하면 성능이 급격히 저하됩니다.

이를 방지하기 위해 PyTorch는 c10::cuda::CUDACachingAllocator를 구현하여 메모리 풀링 기법을 사용합니다. 큰 단위의 메모리 청크(Chunk)를 cudaMalloc으로 한 번에 확보한 뒤, 애플리케이션 내부에서 요청하는 크기만큼 잘라서 텐서에 할당하고, 텐서가 해제되더라도 OS에 반환하지 않고 내부 캐시 리스트에 보관하여 재사용합니다.

크기별 블록 분할과 외부 파편화

캐싱 할당자는 요청 크기에 따라 두 개의 메모리 풀을 운영합니다.

  1. 소형 풀(Small Pool): 1 MiB 미만의 작은 텐서들을 관리하는 풀
  2. 대형 풀(Large Pool): 1 MiB 이상의 큰 텐서들을 관리하는 풀

가변 길이 시퀀스(Dynamic Sequence Length)를 처리하는 트랜스포머 모델이나 동적 배치 처리를 수행할 때, 다양한 크기의 텐서가 생성되고 해제되는 과정이 반복됩니다. 이때 기존의 큰 블록이 작은 조각으로 분할(Split)되고, 일부 조각만 반환되어 중간중간 구멍(Hole)이 뚫린 상태가 됩니다.

총합 메모리로는 수 기가바이트가 남아있더라도, 조각난 여유 공간들이 물리적으로 연속되어 있지 않으면 단일 텐서 크기(예: 512 MiB의 KV 캐시 또는 어텐션 매트릭스)를 충족할 수 없어 할당 실패가 일어납니다.


PYTORCH_CUDA_ALLOC_CONF와 가상 메모리 확장 설정

과거 PyTorch 버전에서는 블록 분할 크기를 인위적으로 제한하는 max_split_size_mb 옵션을 주로 사용했으나, 이는 튜닝이 까다롭고 근본적인 해결책이 되지 못했습니다. PyTorch 2.1 이상에서는 CUDA 가상 메모리 관리(VMM) API를 활용한 expandable_segments 기능이 도입되어 파편화 문제를 완전히 종식시켰습니다.

expandable_segments 메커니즘

CUDA 11.7 이상 및 Linux 환경에서 지원되는 expandable_segments:True 옵션은 물리 메모리와 가상 메모리 주소 공간을 분리하여 매핑합니다.

물리적으로 분산되어 있는 불연속 페이지들을 하나의 연속된 가상 메모리 주소 공간으로 동적 매핑(Remapping)함으로써 외부 파편화로 인한 OOM을 원천 차단합니다.

즉, 메모리 풀에 흩어져 있는 작은 빈 조각들을 모아 가상 주소상에서 하나의 거대한 512 MiB 블록으로 엮어내므로 cudaMalloc을 통한 추가 할당이나 OOM 에러 없이 즉시 처리가 가능해집니다.

환경 변수 설정 및 적용

애플리케이션을 구동하기 전 쉘 환경 변수나 실행 스크립트에 설정을 등록합니다.

BASH
# 쉘 환경 변수로 설정 후 스크립트 실행
export PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True
python3 train.py

Docker Compose 환경에서 Docker로 Ollama와 Open WebUI 구축하기와 같은 컨테이너형 AI 파이프라인을 운영 중이라면, 환경 변수 섹션에 다음과 같이 주입합니다.

YAML
version: "3.8"

services:
  llm-trainer:
    image: pytorch/pytorch:2.4.0-cuda12.1-cudnn9-runtime
    container_name: pytorch-training-service
    environment:
      - NVIDIA_VISIBLE_DEVICES=all
      - PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True
    ipc: host
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: all
              capabilities: [gpu]
    volumes:
      - ./data:/workspace/data
      - ./models:/workspace/models
    command: python3 -m torch.distributed.run --nproc_per_node=2 train_llm.py

ipc: host 옵션을 명시하여 파이토치 DataLoader의 멀티프로세싱 워커 간 공유 메모리 고갈로 인한 묵시적 크래시를 사전 예방합니다.


코드 레벨 메모리 최적화와 누수 방지

환경 변수 설정 외에도 파이썬 코드 레벨에서 불필요하게 텐서 참조를 유지하거나 계산 그래프를 메모리에 누적시키는 실수를 점검해야 합니다.

평가 및 추론 시 inference_mode 적용

단순 추론이나 검증 루프에서는 역전파를 위한 연산 그래프를 생성할 필요가 없습니다. 기존의 torch.no_grad()보다 오버헤드가 적은 torch.inference_mode()를 사용하면 텐서의 버전 추적 메타데이터까지 제거되어 추가 메모리 확보가 가능합니다.

PYTHON
# memory_optimized_inference.py
import os
import torch
import torchvision.models as models

# 환경 변수가 코드 최상단에서 적용되도록 설정 (임포트 직후 권장)
os.environ["PYTORCH_CUDA_ALLOC_CONF"] = "expandable_segments:True"

def run_inference():
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    model = models.resnet50(weights=models.ResNet50_Weights.DEFAULT).to(device)
    model.eval()

    dummy_input = torch.randn(64, 3, 224, 224, device=device)

    # torch.inference_mode 컨텍스트 사용으로 불필요한 메타데이터 제거
    with torch.inference_mode():
        outputs = model(dummy_input)
        predictions = torch.argmax(outputs, dim=-1)

    return predictions.cpu()

if __name__ == "__main__":
    preds = run_inference()
    print(f"추론 완료: {preds.shape}")

파이썬 변수 참조 해제와 명시적 클린업

학습 루프 내부에서 손실(loss) 값을 로깅할 때 loss 텐서 자체를 리스트에 append하면 해당 배치와 연결된 전체 계산 그래프가 에폭이 끝날 때까지 VRAM에 상주하게 됩니다. 반드시 .item() 메서드를 호출하여 파이썬 스칼라 값으로 변환해야 합니다.

PYTHON
# 잘못된 예: 계산 그래프 전체가 VRAM에 유지됨
# losses.append(loss)

# 올바른 예: 순수 파이썬 float 값만 저장하여 메모리 해제 보장
losses.append(loss.item())

메모리 할당 통계 진단과 실시간 모니터링

문제가 지속되거나 모델의 한계 배치를 테스트할 때는 PyTorch가 제공하는 메모리 스냅샷 및 통계 API를 활용하여 실제 할당 양상을 추적합니다.

상세 메모리 요약 리포트 추출

다음 파이썬 스크립트를 통해 현재 Caching Allocator의 내부 풀 상태와 파편화 정도를 즉시 진단할 수 있습니다.

PYTHON
# check_memory_stats.py
import torch

def inspect_cuda_memory():
    if not torch.cuda.is_available():
        print("CUDA 장치를 사용할 수 없습니다.")
        return

    # 바이트 단위 수치를 기가바이트(GiB)로 환산하는 헬퍼 함수
    to_gib = lambda bytes_val: bytes_val / (1024 ** 3)

    allocated = to_gib(torch.cuda.memory_allocated())
    reserved = to_gib(torch.cuda.memory_reserved())
    max_allocated = to_gib(torch.cuda.max_memory_allocated())

    print("=== PyTorch CUDA 메모리 점유 현황 ===")
    print(f"현재 할당된 텐서 메모리 (Allocated): {allocated:.2f} GiB")
    print(f"캐싱 할당자 점유 메모리 (Reserved) : {reserved:.2f} GiB")
    print(f"역대 최대 할당 메모리 (Max Allocated): {max_allocated:.2f} GiB")

    # 파편화 의심 지표 계산 (Reserved와 Allocated의 격차)
    cached_free = reserved - allocated
    print(f"할당자 내부 유휴 캐시 (Cached Free) : {cached_free:.2f} GiB")

    print("\n=== 상세 메모리 서머리 ===")
    print(torch.cuda.memory_summary(device=None, abbreviated=True))

if __name__ == "__main__":
    inspect_cuda_memory()

출력 결과에서 ReservedAllocated의 차이가 비정상적으로 크고(Cached Free가 수 GiB 이상), 그 상태에서 OOM이 발생한다면 메모리 파편화가 발생한 명백한 증거입니다.


PyTorch 메모리 관리 실무 FAQ

배치마다 torch.cuda.empty_cache()를 호출하면 왜 안 되나요?

torch.cuda.empty_cache()는 Caching Allocator가 들고 있는 유휴 메모리 블록을 OS와 CUDA 드라이버에 강제로 반환하는 명령입니다. 이 명령을 호출하면 GPU 파이프라인의 모든 비동기 커널 실행이 강제로 동기화(Sync)되며, 다음 텐서 연산 시 cudaMalloc 시스템 콜이 반복되어 처리량이 30%에서 50% 이상 급락합니다. 장애가 임박한 극단적인 예외 상황이 아니라면 정기 학습 루프 내에서 호출하지 않는 것이 원칙입니다.

expandable_segments 설정 적용 시 주의해야 할 제약 조건이 있나요?

expandable_segments:True 기능은 CUDA 드라이버 수준의 가상 메모리 관리(VMM) 기능을 사용하므로 CUDA 11.7 이상, PyTorch 2.1 이상의 버전이 필요합니다. 또한 32비트 운영체제나 가상 주소 공간이 제한된 구형 커널 환경에서는 동작하지 않을 수 있습니다. 최신 Ubuntu 22.04 LTS 또는 24.04 LTS 환경에서 공식 PyTorch 컨테이너를 사용할 때는 아무런 부작용 없이 안정적으로 동작합니다.

max_split_size_mb와 expandable_segments 중 무엇을 써야 하나요?

과거 레거시 버전(PyTorch 2.0 이하)에서는 max_split_size_mb:128과 같은 방식으로 블록 분할을 억제하는 방식을 썼으나, 이는 메모리 낭비를 유발하고 워크로드마다 최적값을 일일이 찾아야 하는 단점이 있었습니다. PyTorch 2.1 이상을 사용 중이라면 max_split_size_mb는 완전히 배제하고 expandable_segments:True 하나만 설정하는 것이 공식 권장 모범 사례입니다.

DataLoader 사용 중 Bus error나 공유 메모리 크래시가 발생할 때의 해결책은 무엇인가요?

멀티 워커(num_workers > 0)를 사용하는 DataLoader는 텐서를 프로세스 간에 넘길 때 리눅스의 /dev/shm 공유 메모리를 사용합니다. Docker 컨테이너의 기본 shm 크기는 64MB에 불과하므로, 고해상도 이미지나 긴 텍스트 배치를 전송하는 즉시 Bus error나 무응답 OOM이 발생합니다. Docker 구동 시 --shm-size=8g 또는 --ipc=host 옵션을 지정하여 호스트의 공유 메모리를 넉넉하게 할당해야 합니다.

Sponsored