512×512 이미지의 픽셀 공간(pixel space)에서 확산(diffusion)을 돌리는 것은 계산적으로 너무 비쌉니다. Rombach et al. (2022)는 이미지 하나를 생성하는 데 786k 차원(dimension) 전체가 필요한 것은 아니라고 보았습니다. 의미 구조(semantic structure)를 담을 만큼의 잠재 표현(latent)과 나머지를 복원할 별도의 디코더(decoder)만 있으면 충분합니다. 즉, VAE의 잠재 공간(latent space) 안에서 확산을 돌리는 것입니다. 이 한 가지 아이디어가 바로 Stable Diffusion입니다.
유형: Build
언어: Python
선수 지식: Phase 8 · 02 (Autoencoder와 VAE), Phase 8 · 06 (확산 모델 — DDPM 직접 구현), Phase 7 · 09 (비전 트랜스포머 (ViT))
예상 시간: 약 75분
문제
512² 픽셀 공간에서 확산을 돌린다는 것은 U-Net이 [B, 3, 512, 512] 형태의 텐서(tensor)에서 동작해야 한다는 뜻입니다. 5억(500M) 파라미터(parameter)짜리 U-Net 기준으로 샘플링(sampling) 한 스텝(step)이 약 100 GFLOPS이고, 50 스텝이면 이미지 한 장에 5 TFLOPS가 필요합니다. 10억 장의 이미지로 학습한다고 가정하면 계산 비용은 터무니없이 커집니다.
이 연산량의 대부분은 지각적으로 중요하지 않은(perceptually unimportant) 세부, 다시 말해 손실 압축(lossy) VAE가 충분히 압축해도 무방한 고주파 텍스처(high-frequency texture)를 신경망 안에서 밀어 넣는 데 쓰입니다. Rombach의 아이디어는 VAE를 먼저 학습하고(이것이 1단계, first stage), 그 VAE를 동결(freeze)한 뒤, 4×64×64 잠재 공간(즉 second stage)에서만 확산을 돌리는 것입니다. U-Net 자체는 동일하지만 처리할 픽셀 수는 1/16에 불과하고, 비슷한 품질을 내는 데 필요한 연산량(FLOPs)은 대략 64배 줄어듭니다.
이것이 바로 Stable Diffusion 레시피(recipe)입니다. SD 1.x / 2.x는 64×64×4 잠재 위에서 860M U-Net을 사용했고, SDXL은 128×128×4 잠재 위에서 2.6B U-Net을 사용했습니다. SD3는 U-Net을 흐름 매칭(flow matching)을 사용하는 확산 트랜스포머(Diffusion Transformer; DiT)로 바꿨고, Flux.1-dev(Black Forest Labs, 2024)는 12B 파라미터 규모의 DiT-MMDiT를 배포했습니다. 이 모델들은 모두 동일한 2단계 기반(two-stage substrate) 위에 올라가 있습니다.
사전 테스트
2문제 · 이 강의를 시작하기 전에 얼마나 알고 있는지 확인해보세요
1.Stable Diffusion이 512x512x3 픽셀 공간 대신 64x64x4 잠재 공간(latent space)에서 확산을 실행하는 이유는 무엇인가요?
2.Stable Diffusion에서 텍스트가 생성 이미지에 영향을 주는 방식은 무엇인가요?
0/2 답변 완료
개념
두 단계를 따로 학습합니다.
1단계 — VAE. 인코더(encoder) E(x) → z와 디코더(decoder) D(z) → x로 구성합니다. 목표 압축률은 공간 축마다 8배씩 다운샘플(downsample)하고 채널(channel) 수를 조정해 전체 잠재 표현 크기를 픽셀 수의 약 1/16로 만드는 것입니다. 손실(loss)은 복원 항(reconstruction; L1 + LPIPS 지각 손실)과 KL 항을 더한 형태입니다. z를 정확한 가우시안(Gaussian)으로 만들 필요까지는 없으므로 KL 가중치는 작게 둡니다. 디코딩된 이미지가 선명(sharp)해지도록 적대적 손실(adversarial loss)을 함께 쓰는 경우가 많습니다.
2단계 — z 위의 확산.z = E(x_real)을 데이터로 취급합니다. U-Net 또는 DiT를 학습해 z_t로부터 잡음을 제거(denoise)합니다. 추론(inference) 시에는 확산으로 z_0를 표본 추출(sample)한 뒤 x = D(z_0)로 다시 디코딩합니다.
텍스트 조건화(Text conditioning). 두 가지 구성 요소가 더 추가됩니다. 하나는 동결된 텍스트 인코더(frozen text encoder)이고(SD 1.x는 CLIP-L, SD 2/XL은 CLIP-L+OpenCLIP-G, SD3와 Flux는 T5-XXL을 사용합니다), 다른 하나는 교차 어텐션 주입(cross-attention injection)입니다. U-Net 블록마다 [Q = 이미지 특징, K = V = 텍스트 토큰]으로 어텐션을 수행해 두 흐름을 섞습니다. 텍스트가 이미지에 영향을 주는 통로는 오직 이 토큰뿐입니다.
손실 함수(loss function)는 Lesson 06과 동일합니다. DDPM 또는 흐름 매칭에서 쓰는 잡음(noise)에 대한 MSE 그대로입니다. 단지 데이터 도메인이 픽셀에서 잠재로 바뀔 뿐입니다.
아키텍처 변형(Architecture variants)
모델
연도
백본(Backbone)
잠재 형태
텍스트 인코더
파라미터
SD 1.5
2022
U-Net
64×64×4
CLIP-L (77 토큰)
860M
SD 2.1
2022
U-Net
64×64×4
OpenCLIP-H
865M
SDXL
2023
U-Net + 리파이너(refiner)
128×128×4
CLIP-L + OpenCLIP-G
2.6B + 6.6B
SDXL-Turbo
2023
증류(Distilled)
128×128×4
동일
1-4 스텝 샘플링
SD3
2024
MMDiT (멀티모달 DiT)
128×128×16
T5-XXL + CLIP-L + CLIP-G
2B / 8B
Flux.1-dev
2024
MMDiT
128×128×16
T5-XXL + CLIP-L
12B
Flux.1-schnell
2024
MMDiT 증류
128×128×16
T5-XXL + CLIP-L
12B, 1-4 스텝
전반적인 추세는 U-Net을 잠재 패치(latent patch) 위에서 동작하는 트랜스포머인 DiT로 교체하고, 텍스트 인코더를 더 크게 키우며(프롬프트 추종도에서 T5가 CLIP보다 우위입니다), 잠재 채널 수를 4에서 16으로 늘려 디테일 표현 여유(detail headroom)를 확보하는 방향입니다.
직접 만들기
code/main.py는 장난감(toy) 1차원 "VAE"(시연용 항등 인코더+디코더이며 실제 VAE는 합성곱 신경망(conv net)으로 만듭니다)를 Lesson 06의 DDPM 위에 쌓고, 분류기 없는 가이던스(classifier-free guidance; CFG)를 사용한 클래스 조건화(class conditioning)를 추가합니다. 핵심은 원본 1차원 값 위에서 돌리든 인코딩된 값 위에서 돌리든 동일한 확산 손실이 그대로 작동한다는 점을 직접 보여준다는 데 있습니다.
Step 1: 인코더와 디코더
defencode(x): return x * 0.5# 더 작은 스케일로 옮기는 장난감 "압축"defdecode(z): return z * 2.0
실제 VAE에는 학습된 가중치(trained weights)가 있습니다. 여기서는 학습 목적상, 확산이 원본 데이터 공간이 아니라 z 위에서 동작한다는 사실을 보여 주기에 이 선형 사상(linear map)만으로도 충분합니다.
Step 2: z 공간에서의 확산
Lesson 06의 DDPM과 같은 흐름입니다. 신경망이 보는 데이터는 z = E(x)입니다. z_0를 표본으로 뽑은 다음 D(z_0)로 복원합니다.
Step 3: 분류기 없는 가이던스(classifier-free guidance)
학습 도중 클래스 레이블(class label)을 10% 확률로 떨어뜨리고(drop) 널 토큰(null token)으로 바꿉니다. 추론 시에는 조건부 잡음 예측 ε_cond와 무조건부 잡음 예측 ε_uncond를 모두 계산한 뒤 다음과 같이 결합합니다.
eps_cfg = (1 + w) * eps_cond - w * eps_uncond
w = 0은 가이던스가 없는 상태로 다양성(diversity)이 가장 크고, w = 3은 기본값, w = 7+는 채도가 과하거나(saturated) 과도하게 날카로운(over-sharp) 영역에 들어갑니다.
Step 4: 텍스트 조건화 (코드가 아니라 개념 수준)
클래스 레이블 자리를 동결된 텍스트 인코더의 출력으로 바꿉니다. 그 텍스트 임베딩(text embedding)을 교차 어텐션으로 U-Net에 주입합니다.
h = h + CrossAttention(Q=h, K=text_embed, V=text_embed)
클래스 조건부(class-conditional) 확산 모델과 Stable Diffusion 사이의 실질적인 차이는 결국 이 한 줄로 요약됩니다.
자주 빠지는 함정(Pitfalls)
VAE 스케일 불일치(VAE-scale mismatch). SD 1.x 계열 VAE는 인코딩 직후 적용하는 스케일링 상수(scaling_factor ≈ 0.18215)를 사용합니다. 이를 잊으면 U-Net이 분산(variance)이 완전히 어긋난 잠재 위에서 학습됩니다. 모든 체크포인트(checkpoint)에 이 값이 함께 들어 있습니다.
텍스트 인코더가 조용히 잘못 설정됨. SD3는 T5-XXL과 128 이상의 토큰을 필요로 하고, CLIP만 쓰는 대체 경로(fallback)는 손실이 있습니다. use_t5=True인지 항상 확인하지 않으면 프롬프트 충실도(prompt fidelity)가 급격히 떨어집니다.
잠재 공간 섞기. SDXL, SD3, Flux는 서로 다른 VAE를 씁니다. SDXL 잠재에서 학습한 LoRA는 SD3에서 동작하지 않습니다. Hugging Face Diffusers 0.30 이상은 호환되지 않는 체크포인트를 아예 불러오지 못하게 막아 줍니다.
CFG 값이 너무 큼.w > 10은 채도가 과한, 기름진(oily) 느낌의 이미지를 만들고 프롬프트에 과적합하면서 다양성을 잃습니다. 적정 구간(sweet spot)은 w = 3-7입니다.
부정 프롬프트(negative prompt) 누수. 부정 프롬프트가 비어 있으면 그대로 널 토큰이 되지만, 내용이 채워진 부정 프롬프트는 ε_uncond가 됩니다. 둘은 같지 않으며, 일부 파이프라인(pipeline)은 조용히 널 토큰을 기본값으로 사용해 버립니다.
사용해보기
2026년 시점의 프로덕션 스택은 다음과 같이 정리할 수 있습니다.
목표
권장 백본
좁은 도메인, 짝지어진 데이터, 처음부터 모델 학습
SDXL 파인튜닝(LoRA / 전체) — 배포까지 가장 빠름
오픈 도메인 텍스트-투-이미지(text-to-image), 공개 가중치(open weights)
outputs/skill-sd-prompter.md로 저장합니다. 이 스킬은 텍스트 프롬프트와 타깃 스타일을 받아 모델 + 체크포인트, CFG 스케일, 샘플러(sampler), 부정 프롬프트, 해상도, 선택적인 ControlNet/IP-Adapter 조합, 그리고 스텝별 QA 체크리스트를 함께 출력합니다.
연습문제
쉬움.code/main.py를 가이던스 w ∈ {0, 1, 3, 7, 15}로 실행합니다. 클래스별 평균 표본을 기록하고, 어떤 w에서 클래스별 평균이 실제 데이터 평균을 지나치는지 확인합니다.
중간. 장난감 선형 인코더를 tanh-MLP 인코더/디코더 쌍으로 교체하고 복원 손실로 함께 학습합니다. 새 잠재 위에서 확산을 다시 학습했을 때 표본 품질이 어떻게 달라지는지 확인합니다.
어려움. Diffusers로 실제 Stable Diffusion 추론을 구성합니다. sdxl-base를 불러와 CFG=7, Euler 30 스텝으로 실행해 시간을 측정합니다. 그다음 sdxl-turbo로 바꿔 4 스텝, CFG=0으로 실행합니다. 동일한 주제에서 품질이 어떻게 달라졌는지, 왜 그런지 설명합니다.
핵심 용어
용어
흔한 설명
실제 의미
1단계(First stage)
"VAE"
학습된 인코더/디코더 쌍이며 512²를 64²로 압축한다.
2단계(Second stage)
"U-Net"
잠재 공간 위에서 동작하는 확산 모델이다.
CFG(Classifier-Free Guidance)
"가이던스 스케일(guidance scale)"
(1+w)·ε_cond - w·ε_uncond로 조건화 강도를 조절한다.
널 토큰(null token)
"빈 프롬프트 임베딩(empty prompt embed)"
ε_uncond에 사용되는 무조건부 임베딩이다.
교차 어텐션(cross-attention)
"텍스트가 들어오는 방식"
U-Net 블록이 텍스트 토큰을 K, V로 삼아 어텐션한다.
DiT(Diffusion Transformer)
"확산 트랜스포머"
U-Net을 잠재 패치 위에서 동작하는 트랜스포머로 대체한 구조이며 확장성이 더 좋다.
MMDiT(Multi-modal DiT)
"멀티모달 DiT"
SD3의 구조이며, 텍스트와 이미지 두 스트림이 결합 어텐션(joint attention)을 수행한다.
VAE 스케일링 팩터(VAE scaling factor)
"마법의 숫자"
잠재를 약 5.4로 나눠 확산이 단위 분산(unit-variance) 공간에서 동작하도록 한다.
레퍼런스 Flux 통합(integration)은 "내 컨슈머 GPU에서도 이걸 배포할 수 있나?"라는 질문에 대한 표준 레시피입니다. 핵심은 프로덕션 추론 문헌에서 자주 등장하는 세 가지 손잡이(knob)를 확산 DiT에 그대로 적용한다는 점입니다.
단계별 로딩(staggered loading). Flux에는 동시에 VRAM에 올라가 있을 필요가 없는 네 가지 신경망이 있습니다. T5-XXL 텍스트 인코더(fp32 기준 약 10 GB), CLIP-L(작음), 12B 규모의 MMDiT, 그리고 VAE입니다. 먼저 프롬프트를 인코딩한 다음 인코더를 제거하고, DiT를 올려 잡음 제거를 수행한 뒤 DiT도 제거하고, 마지막으로 VAE를 올려 디코딩합니다. 컨슈머 8GB GPU에는 한 번에 한 단계만 올라갈 수 있기 때문입니다.
bitsandbytes 4-bit 양자화(quantization). T5 인코더와 DiT 모두에 BitsAndBytesConfig(load_in_4bit=True, bnb_4bit_compute_dtype=torch.bfloat16)를 적용합니다. 메모리는 약 8배 줄어들고, 텍스트-투-이미지의 품질 저하는 Aritra의 벤치마크(노트북에 링크)에 따르면 체감하기 어려울 정도입니다.
CPU 오프로드(offload).pipe.enable_model_cpu_offload()를 호출하면 각 순방향 패스(forward pass)가 진행될 때마다 모듈을 CPU와 GPU 사이에서 자동으로 교환해 줍니다. 지연(latency)은 10-20% 늘지만, 파이프라인이 실행 자체가 가능해집니다.
메모리 회계를 정리하면 다음과 같습니다. T5 10 GB / 8 = 1.25 GB로 양자화되고, 12B 파라미터 × 0.5 바이트 = 약 6 GB로 양자화된 DiT, 여기에 활성값(activations)이 더해집니다. stas00의 표현을 빌리면 이는 모델 병렬화 없이(TP=1) 양자화를 극단까지 끌어쓴 추론입니다. 프로덕션이라면 H100 위에서 TP=2 또는 TP=4를 쓰겠지만, 1인용 개발 노트북에서는 이 레시피가 사실상의 표준입니다.