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

AI 모델 추론 서비스 GPU 메모리 누수 및 성능 저하 심층 분석

•
ggeniezst

AI 모델 서빙 환경에서 GPU 메모리 누수로 인한 OOM 및 추론 성능 저하 문제를 진단하고, CUDA 메모리 관리, 컨테이너 리소스 격리, 모델 로딩 전략 최적화를 통해 해결하는 실무 가이드.

Sponsored

프로덕션 환경에서 AI 모델 추론 서비스를 운영하던 중, 특정 시점부터 GPU 메모리 사용량이 지속적으로 증가하다가 결국 OOM(Out Of Memory) 오류와 함께 서비스가 중단되는 현상이 발생했다. 초기에는 순간적인 트래픽 증가로 인한 문제로 판단했으나, 트래픽이 안정적인 상황에서도 메모리 누수 패턴이 관찰되어 심각성을 인지하게 되었다. 주로 배치 추론(Batch Inference) 작업이나 장시간 운영되는 스트리밍 추론 서비스에서 이러한 문제가 두드러졌다. nvidia-smi 명령어로 GPU 메모리 사용량을 모니터링하면, 추론 요청이 처리될 때마다 미미하게나마 메모리 사용량이 증가하고, 이는 서비스 재시작 없이는 회복되지 않는 패턴을 보였다. 결국, GPU 리소스가 고갈되어 새로운 추론 요청을 처리하지 못하게 되면서 서비스 전체의 가용성이 저하되는 치명적인 문제로 이어졌다.

이러한 현상은 단순히 모델 파일 크기가 크거나 배치 사이즈가 크다고 해서 발생하는 문제가 아니었다. 모델 로딩 및 추론 과정에서 GPU 메모리가 제대로 해제되지 않거나, CUDA 컨텍스트 관리, PyTorch/TensorFlow와 같은 딥러닝 프레임워크의 내부 동작 방식에 대한 이해 부족에서 비롯될 수 있었다. 특히, 동적으로 모델을 로드하거나 여러 모델을 번갈아 사용하는 복잡한 AI 서빙 아키텍처에서 더욱 빈번하게 발생할 수 있는 유형의 문제였다.


GPU 메모리 누수 현상 진단 및 로그 분석

장애 발생 시 가장 먼저 확인한 것은 nvidia-smi 출력과 서비스 로그였다. nvidia-smi는 GPU의 상태를 실시간으로 보여주는 필수 도구이며, 여기서 Used 메모리 값이 지속적으로 증가하는 것을 육안으로 확인할 수 있었다.

BASH
# nvidia-smi 출력 예시 (문제 발생 중)
# GPU 메모리 사용량이 지속적으로 증가하는 것을 확인할 수 있음
nvidia-smi
# Thu Oct 26 10:30:00 2023
# +-----------------------------------------------------------------------------+
# | NVIDIA-SMI 525.105.17   Driver Version: 525.105.17   CUDA Version: 12.0     |
# |-------------------------------+----------------------+----------------------+
# | GPU  Name        Persistence-M| Bus-Id        Disp.A | Volatile Uncorr. ECC |
# | Fan  Temp  Perf  Pwr:Usage/Cap|         Memory-Usage | GPU-Util  Compute M. |
# |                               |                      |               MIG M. |
# |===============================+======================+======================|
# |   0  NVIDIA A100...      On   | 00000000:01:00.0 Off |                    0 |
# | N/A   35C    P0    62W / 300W |  23456MiB / 40960MiB |      0%      Default |
# |                               |                      |                  N/A |
# +-------------------------------+----------------------+----------------------+
#
# (이후 시간이 지남에 따라 23456MiB 값이 계속 증가하여 결국 40960MiB에 도달)

서비스 로그에서는 주로 RuntimeError: CUDA out of memory. Tried to allocate X GiB (GPU 0; Y GiB total capacity; Z GiB already allocated; W GiB free; A GiB reserved in total by PyTorch)와 같은 메시지를 확인할 수 있었다. 이는 PyTorch 환경에서 GPU 메모리 부족으로 인해 발생하는 전형적인 OOM 오류 메시지이다. TensorFlow의 경우 ResourceExhaustedError: OOM when allocating tensor with shape [...]와 유사한 메시지가 출력된다. 이러한 로그는 애플리케이션이 GPU 메모리를 요청했으나, 사용 가능한 메모리가 없어 할당에 실패했음을 명확히 보여준다.


근본 원인 분석: CUDA 컨텍스트 및 프레임워크 메모리 관리

GPU 메모리 누수의 근본 원인은 여러 가지가 있을 수 있지만, AI 모델 서빙 환경에서는 주로 다음과 같은 원인들을 의심해 볼 수 있다.

  1. CUDA 컨텍스트 및 스트림 미해제: CUDA는 GPU 작업을 관리하기 위해 컨텍스트(Context)와 스트림(Stream)을 사용한다. 애플리케이션이 GPU 작업을 시작할 때 새로운 컨텍스트나 스트림을 생성하고, 작업 완료 후 이를 명시적으로 해제하지 않으면 메모리 누수로 이어질 수 있다. 특히 JIT(Just-In-Time) 컴파일이나 동적 GPU 커널 로딩을 사용하는 경우 더욱 주의해야 한다.
  2. 딥러닝 프레임워크의 캐시 및 버퍼: PyTorch, TensorFlow와 같은 딥러닝 프레임워크는 성능 최적화를 위해 내부적으로 GPU 메모리 캐싱 메커니즘을 사용한다. 예를 들어, PyTorch는 torch.cuda.empty_cache()와 같은 함수를 제공하여 사용하지 않는 캐시를 비울 수 있도록 한다. 명시적으로 캐시를 비우지 않으면, 이전에 사용했던 메모리 블록이 재사용되지 않고 계속 점유될 수 있다.
  3. 모델 및 데이터 로딩/언로딩 오류: 모델을 동적으로 로드하고 언로드하는 과정에서 이전 모델의 가중치나 중간 활성화 텐서가 GPU 메모리에서 완전히 해제되지 않는 경우가 있다. 특히 모델을 CPU로 이동(to('cpu'))시키거나 del 키워드로 객체를 삭제하더라도, 파이썬의 가비지 컬렉터가 즉시 GPU 메모리를 해제하지 않을 수 있다.
  4. 컨테이너 환경의 리소스 격리 문제: Docker, Kubernetes와 같은 컨테이너 환경에서 GPU를 사용할 때, 컨테이너 종료 시 GPU 리소스가 완전히 반환되지 않거나, 여러 컨테이너가 동일 GPU를 공유하는 과정에서 메모리 관리가 복잡해질 수 있다.

실무 조치 방법 및 검증 절차

1. 딥러닝 프레임워크 메모리 캐시 명시적 비우기

PyTorch의 경우, 추론 작업이 완료된 후 torch.cuda.empty_cache()를 호출하여 사용되지 않는 GPU 메모리 캐시를 즉시 비워주는 것이 중요하다. TensorFlow는 명시적인 empty_cache 함수는 없지만, 모델을 언로드하고 세션을 닫는 과정에서 메모리가 해제되도록 설계되어 있다.

PYTHON
# PyTorch 추론 코드 예시 (메모리 누수 방지)
import torch

def inference_with_cleanup(model, input_data):
    # 모델을 GPU로 이동
    model.to('cuda') # 모델을 GPU로 이동
    input_data = input_data.to('cuda') # 입력 데이터도 GPU로 이동

    with torch.no_grad(): # 추론 시에는 그라디언트 계산 비활성화
        output = model(input_data)

    # 추론 완료 후 GPU 메모리 정리
    del input_data # 입력 데이터 객체 삭제
    del output # 출력 데이터 객체 삭제
    model.to('cpu') # 모델을 CPU로 이동하여 GPU 메모리 해제 시도 (필요시)
    
    # 가비지 컬렉터 호출 및 CUDA 캐시 비우기 (핵심!)
    import gc
    gc.collect() 
    torch.cuda.empty_cache() 
    
    return output.to('cpu') # 결과는 CPU로 반환 (필요시)

# 사용 예시
# model = YourModel()
# input_tensor = torch.randn(1, 3, 224, 224)
# result = inference_with_cleanup(model, input_tensor)

이 코드는 추론 후 불필요한 텐서 객체를 del로 삭제하고, gc.collect()를 통해 파이썬 가비지 컬렉터를 명시적으로 호출한다. 가장 중요한 부분은 torch.cuda.empty_cache()를 호출하여 PyTorch의 GPU 메모리 캐시를 비우는 것이다.

2. 컨테이너 리소스 격리 및 GPU 리소스 관리

Docker 컨테이너 환경에서는 nvidia-docker 또는 nvidia-container-toolkit을 사용하여 GPU를 컨테이너에 할당한다. 컨테이너 종료 시 GPU 리소스가 제대로 반환되도록 보장해야 한다. 또한, 여러 컨테이너가 동일 GPU를 사용한다면, 각 컨테이너에 할당될 GPU 메모리 상한을 설정하여 한 컨테이너의 메모리 누수가 전체 GPU를 마비시키는 것을 방지할 수 있다.

YAML
# Docker Compose 예시 (GPU 메모리 제한)
# services.yaml 파일
version: '3.8'
services:
  ai-inference-service:
    image: your-ai-inference-image:latest
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1 # 하나의 GPU 사용
              capabilities: [gpu]
    # GPU 메모리 제한은 Docker Compose v3에서는 직접 지원하지 않으므로,
    # nvidia-docker 런타임 옵션이나 CUDA 환경 변수를 통해 간접적으로 제어하거나,
    # Kubernetes의 resource limit를 사용해야 함.
    # 아래는 CUDA 환경 변수를 통한 메모리 사용량 제한 예시 (TensorFlow 등 일부 프레임워크에서 지원)
    environment:
      - CUDA_VISIBLE_DEVICES=0 # 특정 GPU 지정
      - TF_GPU_ALLOCATOR=cuda_malloc_async # TensorFlow 2.x 비동기 메모리 할당
      - TF_GPU_ALLOCATOR_MAX_SPLIT_SIZE_MB=128 # 최대 스플릿 사이즈
      # PyTorch는 일반적으로 명시적 제한보다는 empty_cache와 같은 API를 활용

Docker Compose 자체에서 GPU 메모리 사용량을 직접적으로 제한하는 기능은 제한적이다. 대신, CUDA_VISIBLE_DEVICES 환경 변수로 특정 GPU를 지정하거나, 딥러닝 프레임워크 자체의 메모리 관리 옵션을 활용하는 것이 일반적이다. Kubernetes 환경에서는 Pod의 resources.limits.nvidia.com/gpu와 같은 설정을 통해 GPU 리소스 할당량을 명확히 지정할 수 있다.

3. 모델 로딩 전략 최적화 및 동적 로딩 주의

AI 모델을 동적으로 로드하고 언로드하는 경우, 각 모델 객체가 GPU 메모리에서 완전히 해제되는지 확인해야 한다. 모델을 로드할 때마다 새로운 메모리 공간을 할당하고, 언로드 시 이를 제대로 반환하지 않으면 누수가 발생한다.

PYTHON
# 모델 동적 로딩/언로딩 시 주의사항
import torch

class ModelManager:
    def __init__(self):
        self.current_model = None
        self.model_name = None

    def load_model(self, model_path, model_type):
        if self.current_model:
            # 기존 모델이 있다면 CPU로 이동 후 삭제하여 GPU 메모리 해제 시도
            self.current_model.to('cpu')
            del self.current_model
            self.current_model = None
            import gc
            gc.collect()
            torch.cuda.empty_cache() # 캐시 비우기

        # 새 모델 로드
        if model_type == "type_A":
            self.current_model = torch.load(model_path + "/model_A.pth").to('cuda')
        elif model_type == "type_B":
            self.current_model = torch.load(model_path + "/model_B.pth").to('cuda')
        self.current_model.eval() # 추론 모드 설정
        self.model_name = model_type
        print(f"Model {self.model_name} loaded to GPU.")

    def unload_model(self):
        if self.current_model:
            self.current_model.to('cpu')
            del self.current_model
            self.current_model = None
            import gc
            gc.collect()
            torch.cuda.empty_cache()
            print(f"Model {self.model_name} unloaded from GPU.")
            self.model_name = None

    def infer(self, input_data):
        if not self.current_model:
            raise ValueError("No model loaded.")
        input_data = input_data.to('cuda')
        with torch.no_grad():
            output = self.current_model(input_data)
        del input_data
        return output.to('cpu')

# manager = ModelManager()
# manager.load_model("./models", "type_A")
# # 추론 수행
# manager.unload_model()

ModelManager 클래스는 모델을 로드하기 전에 기존 모델을 CPU로 이동시키고 명시적으로 삭제하며, 캐시를 비우는 과정을 포함한다. 이는 동적으로 모델을 교체하는 환경에서 GPU 메모리 누수를 방지하는 데 필수적이다.


사이드 이펙트 방지 트러블슈팅 FAQ

Q. torch.cuda.empty_cache()를 호출했는데도 GPU 메모리가 즉시 줄어들지 않아요.

A. torch.cuda.empty_cache()는 PyTorch가 내부적으로 캐싱해 둔 메모리 블록을 해제하지만, OS나 CUDA 드라이버 레벨에서 즉시 메모리를 OS로 반환하는 것은 아닐 수 있습니다. 이는 GPU 메모리 할당 해제 메커니즘의 특성상 발생할 수 있는 현상입니다. 메모리가 완전히 해제되지 않더라도, 해당 메모리 공간은 PyTorch에 의해 '재활용 가능한' 상태가 되므로 다음 할당 요청 시 사용될 수 있습니다. 중요한 것은 새로운 메모리 할당 요청 시 OOM이 발생하지 않도록 충분한 가용 공간을 확보하는 것입니다. 장기적으로 메모리 누수 패턴이 사라졌다면 성공적으로 해결된 것입니다.

Q. del 키워드와 gc.collect()를 사용해도 메모리 누수가 계속됩니다.

A. del과 gc.collect()는 파이썬 객체에 대한 참조를 제거하고 가비지 컬렉션을 강제하는 역할을 합니다. 하지만 GPU 메모리는 파이썬 힙이 아닌 CUDA 힙에서 관리되므로, 파이썬 객체가 삭제되더라도 CUDA 텐서가 GPU 메모리에서 즉시 해제되지 않을 수 있습니다. 이 경우, 해당 텐서가 더 이상 사용되지 않음을 CUDA 드라이버나 딥러닝 프레임워크에 명시적으로 알려주는 작업이 필요합니다. PyTorch의 tensor.detach()나 tensor.cpu()를 통해 GPU 메모리에서 분리하거나, with torch.no_grad(): 블록을 사용하여 불필요한 계산 그래프 생성을 막는 것이 중요합니다. 또한, 모델 자체를 CPU로 이동(model.to('cpu'))시키는 것도 효과적인 방법입니다.

Q. 여러 AI 서비스가 하나의 GPU를 공유하는데, 한 서비스의 메모리 누수가 다른 서비스에 영향을 줍니다.

A. 이는 멀티테넌트 환경에서 매우 흔한 문제입니다. 가장 확실한 해결책은 각 서비스에 전용 GPU를 할당하는 것입니다. 하지만 리소스 제약이 있다면, 컨테이너 오케스트레이션 도구(예: Kubernetes)를 사용하여 각 컨테이너/Pod에 할당될 GPU 메모리 상한을 resource.limits로 명시적으로 설정해야 합니다. 또한, CUDA_VISIBLE_DEVICES 환경 변수를 사용하여 각 서비스가 특정 GPU만 사용하도록 강제하고, 프레임워크별로 GPU 메모리 사용률 제한 옵션(tf.config.experimental.set_memory_growth(gpu, True) for TensorFlow)을 활용하여 필요한 만큼만 메모리를 할당하도록 설정하는 것이 좋습니다. 이를 통해 한 서비스의 오작동이 전체 GPU를 마비시키는 것을 방지할 수 있습니다.


결론 및 추가 고려사항

AI 모델 추론 서비스의 GPU 메모리 누수는 서비스 가용성과 안정성에 치명적인 영향을 미치는 문제이다. 이러한 문제를 해결하기 위해서는 딥러닝 프레임워크의 내부 메모리 관리 메커니즘에 대한 깊은 이해와 함께, CUDA 컨텍스트, 컨테이너 환경의 리소스 격리, 그리고 모델 로딩/언로딩 전략을 종합적으로 고려해야 한다.

핵심은 불필요한 GPU 메모리 사용을 최소화하고, 사용 완료된 메모리는 명시적으로 해제하며, 캐싱된 메모리도 주기적으로 비워주는 것이다. 또한, 프로덕션 환경에서는 GPU 메모리 사용량을 실시간으로 모니터링하고, 임계치 초과 시 알림을 발생시켜 선제적으로 대응할 수 있는 시스템을 구축하는 것이 중요하다. nvidia-smi를 주기적으로 실행하여 로그를 남기거나, Prometheus, Grafana와 같은 모니터링 스택과 연동하여 시각화하고 알림을 설정하는 것이 좋은 방법이다. 궁극적으로는 안정적인 AI 서비스를 제공하기 위한 필수적인 운영 기술이라 할 수 있다.

Sponsored