Published on

SDXL+ControlNet OOM 해결로 VRAM 40% 줄이기

Authors

SDXL에 ControlNet을 결합하면 이미지 품질과 제어력은 좋아지지만, VRAM 사용량이 급격히 늘어 GPU OOM이 자주 발생합니다. 특히 1024x1024 기본 해상도, 여러 ControlNet 동시 사용, 높은 num_inference_steps, 큰 배치, 고해상도 리파이너까지 얹으면 12GB~16GB급 카드에서 바로 한계에 닿습니다.

이 글은 “왜 터지는지”를 메모리 관점에서 분해하고, Diffusers 기반 파이프라인에서 OOM을 막으면서 VRAM을 의미 있게(체감상 30~40%) 줄이는 실전 옵션 조합을 정리합니다. 목표는 단순히 해상도를 낮추는 게 아니라, 품질을 최대한 유지하면서 메모리 피크를 깎는 것입니다.

또한 대규모 모델 추론을 다룬다는 점에서, 로컬 LLM 가속에서 KV 캐시를 다루듯이(피크 메모리 관리가 핵심) 접근하면 도움이 됩니다. 관련 글: Transformers 로컬 LLM 2배 가속 - KV 캐시·SpecDecode

SDXL+ControlNet에서 OOM이 나는 구조적 이유

SDXL ControlNet 구성의 메모리 피크는 대체로 다음이 합쳐져 생깁니다.

  1. UNet 활성화(activation) 메모리

    • SDXL UNet은 크고, 1024x1024에서 feature map이 큽니다.
    • classifier-free guidance 때문에 내부적으로 조건/무조건을 함께 돌리며(구현에 따라) 메모리/연산이 증가합니다.
  2. ControlNet 추가 경로

    • ControlNet은 UNet에 주입되는 잔차(residual) 피처를 만들기 위해 별도 네트워크 경로를 탑니다.
    • ControlNet 개수를 늘리면(예: Canny + Depth 동시) 거의 선형으로 VRAM이 늘어납니다.
  3. VAE 디코딩/인코딩 피크

    • 결과를 디코딩하는 순간, 특히 배치가 크거나 output_typepil로 변환되며 CPU/GPU 버퍼가 겹치면 피크가 튈 수 있습니다.
  4. 정밀도와 텐서 포맷

    • float32는 그냥 OOM 지름길입니다.
    • float16bfloat16은 카드/드라이버 조합에 따라 성능/안정성이 다릅니다.

핵심은 “평균 VRAM”이 아니라 피크 VRAM입니다. OOM은 대부분 특정 스텝(예: 첫 스텝, 디코딩 직전)에서 터집니다.

먼저 확인할 것: OOM 원인 로그와 메모리 측정

OOM을 줄이기 전에, 현재 파이프라인의 피크를 수치로 잡아두면 개선 효과를 재현하기 쉽습니다.

import torch

def report_mem(tag=""):
    if not torch.cuda.is_available():
        return
    torch.cuda.synchronize()
    alloc = torch.cuda.memory_allocated() / 1024**2
    reserved = torch.cuda.memory_reserved() / 1024**2
    peak = torch.cuda.max_memory_allocated() / 1024**2
    print(f"[{tag}] alloc={alloc:.0f}MB reserved={reserved:.0f}MB peak={peak:.0f}MB")

# 사용 예
# torch.cuda.reset_peak_memory_stats()
# report_mem("before")
# ... inference ...
# report_mem("after")
  • allocated는 실제 사용 중인 텐서 메모리
  • reserved는 CUDA 캐싱 할당자가 잡아둔 풀
  • peak는 실행 중 최고점

개선은 peak를 얼마나 낮추는지로 판단하는 게 정확합니다.

VRAM 40%↓를 노리는 핵심 전략 6가지

아래 조합은 “한 방”이 아니라, 여러 작은 최적화로 피크를 깎아 결과적으로 큰 절감을 만드는 방식입니다.

1) 정밀도는 기본 float16 또는 bfloat16

  • NVIDIA RTX 계열 대부분은 float16이 무난합니다.
  • A100/H100 같은 환경은 bfloat16이 안정적일 때가 많습니다.
import torch
from diffusers import StableDiffusionXLControlNetPipeline, ControlNetModel

dtype = torch.float16  # 또는 torch.bfloat16

controlnet = ControlNetModel.from_pretrained(
    "diffusers/controlnet-canny-sdxl-1.0",
    torch_dtype=dtype,
)

pipe = StableDiffusionXLControlNetPipeline.from_pretrained(
    "stabilityai/stable-diffusion-xl-base-1.0",
    controlnet=controlnet,
    torch_dtype=dtype,
)
pipe = pipe.to("cuda")

여기서 torch_dtype를 빼먹으면 일부 서브모듈이 float32로 남아 피크가 크게 튈 수 있습니다.

2) xFormers 또는 SDPA로 어텐션 메모리 줄이기

어텐션은 SDXL에서 큰 메모리 구간입니다. 환경에 따라 다음 중 하나를 선택합니다.

  • PyTorch 2.x의 SDPA(Scaled Dot Product Attention)
  • xFormers 메모리 효율 어텐션
# 1) xFormers가 설치되어 있다면
pipe.enable_xformers_memory_efficient_attention()

# 2) 또는 PyTorch SDPA를 강제하고 싶다면(환경별로 다름)
# pipe.unet.set_default_attn_processor()  # diffusers 버전에 따라 API 상이

체감상 이 옵션 하나로도 OOM이 “가끔 나던 것”이 “안 나는 것”으로 바뀌는 경우가 많습니다.

3) VAE 타일링 + 슬라이싱으로 디코딩 피크 낮추기

SDXL에서 디코딩 순간 메모리 피크가 튀는 케이스가 많습니다. vae를 타일링하면 디코딩을 조각내서 VRAM 피크를 낮춥니다.

pipe.enable_vae_slicing()
pipe.enable_vae_tiling()
  • 타일링은 속도를 일부 희생하지만, OOM 방지에 매우 효과적입니다.
  • 특히 배치가 2 이상이거나, 후처리까지 GPU에서 이어갈 때 도움이 큽니다.

4) CPU 오프로딩: “느려져도 살아남기” 버튼

VRAM이 정말 빡빡하면 오프로딩이 가장 확실합니다.

  • enable_model_cpu_offload()는 모듈을 필요할 때만 GPU로 올립니다.
  • enable_sequential_cpu_offload()는 더 공격적으로 오프로딩하지만 더 느릴 수 있습니다.
# accelerate 설치 필요
pipe.enable_model_cpu_offload()
# 또는
# pipe.enable_sequential_cpu_offload()

이 방식은 VRAM을 크게 줄이지만, PCIe 전송 비용으로 속도가 느려집니다. “OOM을 절대 내면 안 되는 배치 작업”에서 유용합니다.

5) ControlNet을 여러 개 쓰면: 해상도/강도/개수부터 설계

ControlNet은 개수만큼 메모리 증가가 큽니다. 멀티 ControlNet을 꼭 써야 한다면 다음을 순서대로 고려하세요.

  • ControlNet 개수를 줄이기(가능하면 1개)
  • Control 이미지 해상도를 낮추기(전처리 단계에서 다운스케일)
  • controlnet_conditioning_scale을 올려 “적은 수로 더 강하게”
from PIL import Image

def resize_control(img: Image.Image, max_side=768):
    w, h = img.size
    scale = max_side / max(w, h)
    if scale >= 1:
        return img
    return img.resize((int(w*scale), int(h*scale)))

control_image = resize_control(control_image, max_side=768)

out = pipe(
    prompt="...",
    image=control_image,
    controlnet_conditioning_scale=0.8,
    num_inference_steps=30,
)

SDXL 출력은 여전히 1024로 가되, Control 입력만 줄여도 피처 경로의 부담이 줄어드는 조합이 종종 있습니다(모델/프리프로세서에 따라 효과 차이 있음).

6) 배치, 스텝, 가이던스는 “곱”으로 피크를 만든다

OOM을 만드는 흔한 조합은 다음입니다.

  • batch_size2 이상
  • num_inference_steps40 이상
  • guidance_scale가 높고 구현상 조건/무조건을 함께 처리

권장 접근:

  • 배치는 먼저 1로 고정
  • 스텝은 20~30에서 시작
  • 가이던스는 4~7 범위를 기본으로

품질이 아쉬우면 스텝부터 늘리되, 피크 메모리를 측정하며 조정합니다.

Diffusers 실전 예제: OOM 방지 옵션 풀세트

아래 코드는 SDXL Base + ControlNet(Canny) 기준으로, VRAM을 줄이는 대표 옵션을 한 번에 적용합니다.

import torch
from diffusers import StableDiffusionXLControlNetPipeline, ControlNetModel
from diffusers.utils import load_image

torch.backends.cuda.matmul.allow_tf32 = True  # Ampere 이상에서 속도/안정성 도움

dtype = torch.float16

controlnet = ControlNetModel.from_pretrained(
    "diffusers/controlnet-canny-sdxl-1.0",
    torch_dtype=dtype,
)

pipe = StableDiffusionXLControlNetPipeline.from_pretrained(
    "stabilityai/stable-diffusion-xl-base-1.0",
    controlnet=controlnet,
    torch_dtype=dtype,
    variant="fp16",  # 리포지토리 구성에 따라 유효
)

# 메모리 최적화 옵션
pipe.enable_xformers_memory_efficient_attention()
pipe.enable_vae_slicing()
pipe.enable_vae_tiling()

# VRAM이 빡빡하면 아래를 사용(속도 희생)
# pipe.enable_model_cpu_offload()

pipe = pipe.to("cuda")

prompt = "ultra detailed product photo, studio lighting"
control_image = load_image("./canny.png")

torch.cuda.reset_peak_memory_stats()

image = pipe(
    prompt=prompt,
    image=control_image,
    num_inference_steps=28,
    guidance_scale=5.5,
    controlnet_conditioning_scale=0.75,
).images[0]

torch.cuda.synchronize()
peak = torch.cuda.max_memory_allocated() / 1024**2
print(f"peak VRAM: {peak:.0f} MB")

image.save("./out.png")

위 조합에서 VRAM 40% 절감이 나오는 대표 케이스는 다음과 같습니다.

  • 기존: fp32 + 기본 어텐션 + VAE 기본 디코딩 + 멀티 스텝
  • 개선: fp16 + xFormers(또는 SDPA) + VAE 타일링/슬라이싱

카드/드라이버/파이토치 버전에 따라 절감 폭은 달라지지만, 피크 메모리를 깎는 방향성은 안정적으로 재현됩니다.

자주 터지는 지점별 처방전

첫 스텝에서 바로 OOM

  • torch_dtype 누락으로 일부가 float32일 확률이 큼
  • xFormers/SDPA 미적용
  • ControlNet을 여러 개 로드

처방:

  • 파이프라인/ControlNet 로드에 torch_dtype 강제
  • pipe.enable_xformers_memory_efficient_attention() 적용
  • ControlNet을 1개로 축소

디코딩 직전에 OOM

  • VAE 디코딩 피크

처방:

  • pipe.enable_vae_tiling()
  • pipe.enable_vae_slicing()
  • 배치를 1

여러 번 돌리면 점점 VRAM이 늘다가 OOM

  • 텐서 참조가 남아 GC가 안 되는 패턴이 흔함
  • 이미지/latent를 리스트에 계속 쌓아두는 실수

처방:

import gc, torch

# 루프 내부에서 결과를 저장 후
# GPU 텐서를 들고 있지 않게 정리

del image
# del latents, del out 등 GPU 텐서/파이프 출력 참조 제거

gc.collect()
torch.cuda.empty_cache()  # reserved를 줄이진 않지만, 단편화 완화에 도움될 때가 있음

empty_cache()는 만능이 아니지만, 긴 배치 작업에서 단편화로 인한 OOM을 완화하는 데는 종종 효과가 있습니다.

품질을 덜 깎는 “마지막 한 끗” 옵션들

해상도는 유지하고, 타일링/오프로딩으로 버티기

해상도를 832768로 내리는 건 가장 쉬운 해결책이지만, SDXL의 장점이 줄어듭니다. 먼저 타일링/어텐션 최적화/정밀도부터 적용하고, 그래도 안 되면 해상도를 내리는 순서가 품질 손실을 최소화합니다.

Refiner를 같이 쓰면: 분리 실행

SDXL Refiner까지 한 번에 묶으면 메모리 피크가 크게 증가합니다. 가능하면 Base로 latent를 만든 뒤 Refiner를 별도 파이프로 실행하고, 중간 산출물만 넘기세요. 파이프를 두 개 동시에 GPU에 올리지 않는 것이 포인트입니다.

스케줄러/스텝 튜닝

스텝을 무작정 올리기보다, 스케줄러를 바꾸거나(예: DPM 계열) 20~30 스텝에서 만족스러운 품질을 먼저 확보하는 게 VRAM/시간 모두에 이득입니다.

운영 관점 팁: 재시도와 백오프로 “OOM을 장애로 만들지 않기”

서비스나 배치 파이프라인에서는 OOM이 0%가 되기 어렵습니다. 입력 이미지 크기, ControlNet 종류, 동시 요청 수에 따라 순간 피크가 튈 수 있기 때문입니다. 따라서 OOM을 만났을 때 다음 전략이 실용적입니다.

  • OOM 감지 시 batch_size=1로 강등
  • num_inference_steps를 자동으로 낮춤
  • 오프로딩 모드로 재시도
  • 재시도 간 백오프 적용

이런 “탄력적 추론” 패턴은 과부하 대응 설계와도 닮아 있습니다. 참고: Claude API 529 Overloaded 재시도·백오프 설계

체크리스트: VRAM 40%↓를 위한 적용 순서

  1. torch_dtypefloat16 또는 bfloat16로 고정
  2. xFormers 또는 SDPA 적용
  3. VAE 슬라이싱 + 타일링 적용
  4. 배치 1, 스텝 20~30부터 시작
  5. ControlNet 개수 최소화, Control 입력 해상도 최적화
  6. 그래도 OOM이면 CPU 오프로딩으로 “생존 모드”
  7. peak VRAM을 측정하며 한 항목씩 변경해 재현성 확보

마무리

SDXL+ControlNet OOM은 “모델이 너무 커서”만이 아니라, 어텐션 구현, VAE 디코딩 피크, ControlNet 개수, 정밀도 혼용 같은 요소가 겹쳐서 발생합니다. 위의 옵션들을 순서대로 적용하면 해상도를 크게 내리지 않고도 피크 VRAM을 상당히 줄일 수 있고, 환경에 따라 40% 수준 절감도 충분히 가능합니다.

다음 단계로는 (1) 멀티 ControlNet을 써야 하는 요구사항을 프롬프트/전처리로 대체할 수 있는지, (2) Refiner를 분리 실행해 피크를 분산할 수 있는지, (3) 요청 동시성을 제한하고 OOM 재시도를 설계할 수 있는지까지 같이 보시면 운영 안정성이 크게 올라갑니다.