[GPU] SGLang으로 영상생성 추론 최적화하기
개요
이전 글에서 MiniMax H3 영상생성 모델을 ComfyUI 워크플로로 띄우는 방법을 다뤘습니다. 이번 글은 같은 모델을 SGLang으로 서빙하면서 추론 성능을 실제로 튜닝해본 기록입니다.
성능을 이야기할 때 먼저 나눠야 하는 두 가지가 있습니다. 영상 한 편을 만드는 데 걸리는 지연(latency)과, 서버 전체가 동시에 처리할 수 있는 처리량(throughput)입니다. 이 둘은 같은 방향으로 움직이지 않습니다. GPU 4장을 하나의 추론 작업에 몰아주면 영상 한 편은 빨리 나오지만 동시 요청은 하나밖에 못 받고, 반대로 GPU를 인스턴스별로 쪼개면 동시 처리량은 늘지만 각 인스턴스의 단건 지연은 늘어납니다.
이 글에서는 실제로 손댈 수 있는 구성 축을 세 가지로 나눠서 각각 돌려봤습니다.
| 구성 축 | 선택지 | 바뀌는 것 |
|---|---|---|
| 가중치 실행 정밀도 | BF16 / FP8 | 메모리, 커널, 품질과 속도 |
| 샘플링 가속 | 기본 / Turbo LoRA / LightX2V | 요청 단계 수와 결과 품질 |
| GPU 배치 | 4장 한 인스턴스 / 2장씩 두 개 / 1장씩 네 개 | 단건 지연, 전체 처리량, 장애 격리 |
세 축은 서로 독립적으로 조합할 수 있지만, 조합마다 실제로 켜야 하는 플래그와 주의할 함정이 다릅니다. 아래에서 하나씩 실행하면서 확인한 내용을 정리합니다.
생성 모드부터 다시 확인
SGLang 쪽으로 넘어가기 전에, 이 모델이 지원하는 생성 모드를 다시 짚었습니다.
h3-use fl2va # 텍스트(t2va) 및 첫 프레임/마지막 프레임(fl2va), 기본 20단계
h3-use fl2va --lora=larryvrh # FL2VA와 Turbo LoRA, 요청 9단계
h3-use ref2va # 다중 참조(ref2va), 영상 참조에도 사용기본 SGLang 경로는 BF16 기준 FSDP×Ulysses4 구성입니다. 아래에서 다루는 FP8 명령들은 별도의 TP 경로이고, --use-fsdp-inference와는 함께 쓰지 않습니다. 같은 모델이라도 실행 경로가 완전히 다르기 때문에, 이 두 경로의 플래그를 섞어 넣으면 그대로 실패합니다.
모든 수동 명령을 실행하기 전에는 h3-use stop으로 기존 GPU 작업을 정리했습니다. 그리고 SGLang은 unix:///run/docker-sglang.sock이라는 별도 Docker daemon 소켓을 쓴다는 점도 실제로 겪은 함정이었습니다. 기본 docker ps만 찍어보면 이 컨테이너가 안 보여서 "왜 컨테이너가 없지"라고 착각하기 쉽습니다. 수동으로 띄우는 모든 명령에는 -H unix:///run/docker-sglang.sock을 붙여야 하고, 0.0.0.0과 --network=host로 여는 포트는 접근 제한을 별도로 걸어야 합니다.
1. 4 GPU FP8 기본 실행: 단건 지연 우선
가장 먼저 GPU 4장을 하나의 인스턴스에 몰아서 FP8로 띄웠습니다. 단건 지연을 최소화하는 게 목표인 구성입니다.
sudo docker -H unix:///run/docker-sglang.sock run -d --name minimax-h3-sglang-fl2va-4gpu \
--gpus all --shm-size 32g --ipc=host --network=host \
-v /usr/local/cuda-13.0:/usr/local/cuda:ro \
-v /srv/minimax-h3/sglang/models/MiniMax-H3:/models/MiniMax-H3:ro \
-v /srv/minimax-h3/models/loras:/models/MiniMax-H3/loras:ro \
-v /srv/minimax-h3/sglang/media:/data/minimax-h3:ro \
-v /srv/minimax-h3/sglang/output:/output \
minimax-h3-sglang:0.5.18-cu130 \
sglang serve \
--model-type diffusion --model-path /models/MiniMax-H3 --model-variant fl2va \
--num-gpus 4 --tp-size 2 --ulysses-degree 2 --quantization fp8 \
--performance-mode speed --enable-torch-compile false \
--host 0.0.0.0 --port 30010--tp-size 2 --ulysses-degree 2로 GPU 4장을 텐서 병렬 2 × Ulysses 시퀀스 병렬 2로 나눈 구성입니다. Ref2VA가 필요하면 --model-variant만 ref2va로 바꾸면 되는데, 이때 기존 컨테이너와 포트/GPU가 겹치지 않도록 먼저 정리해야 합니다. 컨테이너를 띄운 뒤에는 http://127.0.0.1:30010/health로 준비 상태를 확인했습니다.
2. FP8 + Turbo LoRA: 9단계로 축소
기본 FP8 구성 위에 Turbo LoRA를 얹어서 요청 단계 수를 줄이는 구성도 돌려봤습니다.
sudo docker -H unix:///run/docker-sglang.sock run -d --name minimax-h3-sglang-fl2va-fp8-lora \
--gpus all --shm-size 32g --ipc=host --network=host \
-v /usr/local/cuda-13.0:/usr/local/cuda:ro \
-v /srv/minimax-h3/sglang/models/MiniMax-H3:/models/MiniMax-H3:ro \
-v /srv/minimax-h3/models/loras:/models/MiniMax-H3/loras:ro \
-v /srv/minimax-h3/sglang/media:/data/minimax-h3:ro \
-v /srv/minimax-h3/sglang/output:/output \
minimax-h3-sglang:0.5.18-cu130 \
sglang serve \
--model-type diffusion --model-path /models/MiniMax-H3 --model-variant fl2va \
--num-gpus 4 --tp-size 2 --ulysses-degree 2 --quantization fp8 \
--performance-mode speed --enable-torch-compile false \
--dit-cpu-offload false --pin-cpu-memory false \
--lora-path /models/MiniMax-H3/loras \
--lora-weight-name minimax_h3_turbo_v4_step600_ema.safetensors \
--lora-scale 1.0 --lora-nickname h3-larryvrh \
--lora-merge-mode dynamic \
--master-port 30050 --host 0.0.0.0 --port 30010여기서 실제로 걸려 넘어진 부분이 --lora-merge-mode입니다. 기본값인 auto는 LoRA 가중치를 모델에 정적으로 병합하는데, FP8 TP 경로에서는 이 병합이 실패합니다.
RuntimeError: The size of tensor a (10752) must match the size of tensor b (5376)
at non-singleton dimension 1
lora_pipeline.py:547 -> linear.py:243 _merge_lora_into_data원인은 TP로 이미 나눠진 가중치의 분할 축과 LoRA 행렬의 분할 축이 맞지 않기 때문입니다. --lora-merge-mode dynamic으로 바꾸면 병합을 미리 하지 않고 forward 시점에 LoRA delta를 그때그때 계산하는 방식으로 바뀝니다. 연산이 추가되긴 하지만, 정적 병합에서 나는 차원 불일치 자체를 피할 수 있습니다.
LoRA가 실제로 적용됐는지는 로그로 확인했습니다.
sudo docker -H unix:///run/docker-sglang.sock logs minimax-h3-sglang-fl2va-fp8-lora 2>&1 \
| grep -E "Converted .* layers to LoRA|applied to .* layers|lora_merge_mode"Converted 266 layers to LoRA layers
Rank 0: LoRA adapter(s) /models/MiniMax-H3/loras applied to 259 layers (targets: all, strengths: 1.00, merge_mode=dynamic)Converted만 찍히고 applied to가 안 보이면 가속 가중치가 실제로는 적용되지 않았을 가능성이 있습니다. 이 구성에서는 266개 변환 계층 중 259개에 적용된 로그가 정상 기준이고, 7개가 미적용이라는 사실 자체가 곧 오류를 뜻하지는 않습니다.
요청은 이렇게 보냈습니다.
curl -s -X POST http://127.0.0.1:30010/v1/videos \
-H 'Content-Type: application/json' \
-d '{
"model": "MiniMaxAI/MiniMax-H3",
"prompt": "A vast canyon at sunrise, realistic documentary cinematography.",
"task": "t2va",
"target": {"short_edge": 768, "aspect_ratio": "16:9", "duration_seconds": 5.0},
"num_inference_steps": 9
}'LoRA 로딩과 샘플링 단계 수는 별개의 설정이라는 점도 실측으로 확인했습니다. 요청 본문에 num_inference_steps: 9를 명시하지 않으면 기본값인 20단계로 그대로 돌아갑니다. 그리고 Ref2VA에 같은 Turbo LoRA를 적용했을 때 FL2VA와 동일한 결과가 나오는지는 별도로 검증이 필요했습니다. 지원 범위가 같다고 가정하지 않는 게 안전합니다.
3. FP8 + LightX2V: 5단계까지 축소
Turbo LoRA보다 더 공격적으로 단계 수를 줄이는 LightX2V 가속도 시험했습니다.
sudo docker -H unix:///run/docker-sglang.sock run -d --name minimax-h3-sglang-fl2va-lx \
--gpus all --shm-size 32g --ipc=host --network=host \
-v /usr/local/cuda-13.0:/usr/local/cuda:ro \
-v /srv/minimax-h3/sglang/models/MiniMax-H3:/models/MiniMax-H3:ro \
-v /srv/minimax-h3/models/loras:/models/MiniMax-H3/loras:ro \
-v /srv/minimax-h3/sglang/media:/data/minimax-h3:ro \
-v /srv/minimax-h3/sglang/output:/output \
minimax-h3-sglang:0.5.18-cu130 \
sglang serve \
--model-type diffusion --model-path /models/MiniMax-H3 --model-variant fl2va \
--num-gpus 4 --tp-size 2 --ulysses-degree 2 --quantization fp8 \
--performance-mode speed --enable-torch-compile false \
--dit-cpu-offload false --pin-cpu-memory false \
--lora-path /models/MiniMax-H3/loras \
--lora-weight-name minimax_h3_fl2v_turbo_4step_v1.0_768p_comfyui_bf16.safetensors \
--lora-scale 0.0625 --lora-nickname h3-lightx2v \
--lora-merge-mode dynamic \
--master-port 30050 --host 0.0.0.0 --port 30010Turbo LoRA와 비교하면 세부 설정이 확실히 다릅니다.
| 항목 | LightX2V 설정 | 이유 |
|---|---|---|
| 파일 | minimax_h3_fl2v_turbo_4step_v1.0_768p_comfyui_bf16.safetensors | 이 환경에 맞는 ComfyUI 형식. v0.1 PEFT 형식과 혼용 금지 |
| 강도 | 0.0625 | alpha / rank = 8 / 128. 1.0으로 바꾸면 과하게 적용될 수 있음 |
| 병합 | dynamic | FP8 TP 가중치 분할과의 정적 병합 충돌 방지 |
| 요청 단계 | 5 | 종료 지점을 포함한 5단계로 실제 디노이징은 4회 |
| 지원 모드 | FL2VA | Ref2VA는 별도 실험 검증 필요 |
--lora-scale이 Turbo LoRA에서는 1.0이었는데 여기서는 0.0625로 완전히 다릅니다. LoRA의 alpha/rank 비율(8/128)에서 나온 값이라, 다른 LoRA를 가져다 쓸 때 강도 값을 그대로 복사하면 안 된다는 걸 보여주는 사례입니다.
적용 로그도 별도로 확인했습니다.
sudo docker -H unix:///run/docker-sglang.sock logs minimax-h3-sglang-fl2va-lx 2>&1 \
| grep -E "applied to .* layers|lora_merge_mode"Rank 0: LoRA adapter(s) /models/MiniMax-H3/loras applied to 208 layers (... merge_mode=dynamic)LightX2V의 기대 적용 계층 수는 208개로, 앞서 Turbo LoRA의 259개와는 다른 숫자입니다. 이 숫자가 다르다고 문제가 있는 게 아니라, LoRA마다 대상 계층 범위 자체가 다르기 때문입니다. applied to 0 layers처럼 아예 0이 찍히면 그건 가중치 형식이 안 맞다는 신호로 봐야 합니다. 0.5.18 계열에서는 --lora-alpha 8로 같은 스케일을 다르게 표현할 수도 있는데, 이번 검증 구성에서는 위 --lora-scale 값을 그대로 유지했습니다.
요청은 5단계로 보냈습니다.
curl -s -X POST http://127.0.0.1:30010/v1/videos \
-H 'Content-Type: application/json' \
-d '{
"model": "MiniMaxAI/MiniMax-H3",
"prompt": "A vast canyon at sunrise, realistic documentary cinematography.",
"task": "t2va",
"target": {"short_edge": 768, "aspect_ratio": "16:9", "duration_seconds": 5.0},
"num_inference_steps": 5
}'정밀도 차이도 함께 확인했습니다. h3-use fl2va --lora=lightx2v로 띄운 BF16 경로에서 1344×768 해상도, 5초 영상을 생성하는 데 23.6초가 걸렸는데, 이 값은 위에서 설명한 FP8 실행 구성에 해당하는 기준값입니다. 단계 수를 줄일수록 결과 영상의 디테일, 동작의 자연스러움, 프레임 간 일관성도 함께 비교해야 한다는 점은 이 글에서 수치로 다루지는 않았지만 실제 운영에서는 반드시 눈으로 확인해야 하는 부분입니다.
4. 2 GPU 이중 인스턴스: 처리량 우선 분할
GPU 4장을 하나로 묶는 대신, 2장씩 두 개의 독립 인스턴스로 쪼개는 구성도 실제로 띄워봤습니다. 목표는 단건 지연이 아니라 동시 처리량입니다.
sudo docker -H unix:///run/docker-sglang.sock run -d --name minimax-h3-sglang-fl2va-2gpu-a \
--gpus '"device=0,1"' --shm-size 32g --ipc=host --network=host \
-e SGLANG_USE_RUNAI_MODEL_STREAMER=false \
-v /usr/local/cuda-13.0:/usr/local/cuda:ro \
-v /srv/minimax-h3/sglang/models/MiniMax-H3:/models/MiniMax-H3:ro \
-v /srv/minimax-h3/sglang/media:/data/minimax-h3:ro \
-v /srv/minimax-h3/sglang/output:/output \
minimax-h3-sglang:0.5.18-cu130 \
sglang serve \
--model-type diffusion --model-path /models/MiniMax-H3 --model-variant fl2va \
--num-gpus 2 --tp-size 2 --ulysses-degree 1 --quantization fp8 \
--performance-mode memory --enable-torch-compile false \
--dit-cpu-offload false --pin-cpu-memory false \
--layerwise-offload-components text_encoder \
--master-port 30050 --scheduler-port 5555 \
--host 0.0.0.0 --port 30010sudo docker -H unix:///run/docker-sglang.sock run -d --name minimax-h3-sglang-fl2va-2gpu-b \
--gpus '"device=2,3"' --shm-size 32g --ipc=host --network=host \
-e SGLANG_USE_RUNAI_MODEL_STREAMER=false \
-v /usr/local/cuda-13.0:/usr/local/cuda:ro \
-v /srv/minimax-h3/sglang/models/MiniMax-H3:/models/MiniMax-H3:ro \
-v /srv/minimax-h3/sglang/media:/data/minimax-h3:ro \
-v /srv/minimax-h3/sglang/output:/output \
minimax-h3-sglang:0.5.18-cu130 \
sglang serve \
--model-type diffusion --model-path /models/MiniMax-H3 --model-variant fl2va \
--num-gpus 2 --tp-size 2 --ulysses-degree 1 --quantization fp8 \
--performance-mode memory --enable-torch-compile false \
--dit-cpu-offload false --pin-cpu-memory false \
--layerwise-offload-components text_encoder \
--master-port 30052 --scheduler-port 5557 \
--host 0.0.0.0 --port 30012인스턴스 A는 GPU 0/1과 HTTP 30010을, 인스턴스 B는 GPU 2/3과 HTTP 30012를 씁니다. 여기서 실제로 신경 써야 했던 부분은 포트 번호입니다. 이 SGLang 경로는 HTTP 포트 바로 다음 번호를 ZMQ 브로커가 내부적으로 사용하기 때문에, HTTP 포트를 30010, 30011처럼 연속 번호로 잡으면 안 됩니다. master-port와 scheduler-port도 인스턴스마다 겹치지 않게 따로 지정했습니다. 실제로 포트가 열렸는지는 이렇게 확인했습니다.
ss -ltnp | grep 30012 GPU 구성에서는 --layerwise-offload-components text_encoder를 명시했습니다. text encoder만 계층별로 CPU에 오프로드하고 VAE는 GPU에 계속 상주시키는 설정인데, 영상 디코딩 과정에서 VAE까지 매번 CPU로 옮기면 그 전송 자체가 GPU 연산보다 더 큰 지연 요인이 되기 때문입니다. 다만 이 옵션을 그대로 단일 GPU 구성에 가져가면 로딩 시 OOM이 날 수 있다는 점도 함께 확인했습니다. 오프로드 전략은 GPU 배치에 따라 다시 계산해야 합니다.
이 2 GPU 구성에 Turbo LoRA를 추가할 때는 아래 조각을 두 인스턴스 모두의 명령에 합쳐 넣었습니다.
# docker run 볼륨 옵션에 추가
-v /srv/minimax-h3/models/loras:/models/MiniMax-H3/loras:ro
# sglang serve 인수에 아래 LoRA 설정 추가
--lora-path /models/MiniMax-H3/loras \
--lora-weight-name minimax_h3_turbo_v4_step600_ema.safetensors \
--lora-scale 1.0 --lora-nickname h3-larryvrh \
--lora-merge-mode dynamic \두 인스턴스 모두에 볼륨과 LoRA 인수를 넣어야 하고, 요청은 9단계로 보내야 합니다. 그리고 이 구성에서 가장 중요한 운영 포인트는, 애플리케이션이 두 엔드포인트(30010, 30012)에 요청을 분산해서 보내야 한다는 점입니다. 인스턴스를 두 개 띄워놓고 한쪽 포트에만 계속 요청을 보내면, GPU는 4장을 다 쓰고 있어도 실질적으로는 절반의 처리량만 쓰는 셈이 됩니다.
5. 단일 GPU FP8 복제: 장애 격리 우선
마지막으로 GPU 1장씩 네 개의 완전히 독립된 인스턴스로 나누는 구성도 확인했습니다.
sudo docker -H unix:///run/docker-sglang.sock run -d --name minimax-h3-sglang-fl2va-1gpu-0 \
--gpus '"device=0"' --shm-size 32g --ipc=host --network=host \
-e SGLANG_USE_RUNAI_MODEL_STREAMER=false \
-v /usr/local/cuda-13.0:/usr/local/cuda:ro \
-v /srv/minimax-h3/sglang/models/MiniMax-H3:/models/MiniMax-H3:ro \
-v /srv/minimax-h3/sglang/media:/data/minimax-h3:ro \
-v /srv/minimax-h3/sglang/output:/output \
minimax-h3-sglang:0.5.18-cu130 \
sglang serve \
--model-type diffusion --model-path /models/MiniMax-H3 --model-variant fl2va \
--num-gpus 1 --quantization fp8 \
--performance-mode memory --enable-torch-compile false \
--dit-cpu-offload false --pin-cpu-memory false \
--text-encoder-cpu-offload true \
--master-port 30050 --scheduler-port 5555 \
--host 0.0.0.0 --port 30010나머지 세 인스턴스는 GPU 번호(N)를 1, 2, 3으로 바꾸고 컨테이너 이름도 함께 바꿉니다. 포트는 규칙적으로 늘어나는데, HTTP는 30010 + 2N, master는 30050 + 2N, scheduler는 5555 + 2N을 씁니다.
단일 GPU 구성에서 실제로 겪은 병목은 로딩 시점의 메모리 피크였습니다. FP8로 변환하기 전에 BF16 상태의 DiT 전체를 먼저 GPU에 올리는 과정에서 약 62GiB가 필요할 수 있습니다. GPU 1장에는 이걸 다 올리고 VAE까지 상주시킬 여유가 없기 때문에, text encoder는 전체를 CPU로 오프로드(--text-encoder-cpu-offload true)하고 VAE도 계층별로 오프로드하는 구성을 유지했습니다.
--pin-cpu-memory false도 여러 복제 인스턴스를 동시에 띄우는 상황에서는 중요한 설정이었습니다. mlock으로 호스트 메모리를 인스턴스마다 과도하게 잠그면, 이건 GPU OOM으로 끝나는 게 아니라 호스트 자체에 SSH조차 못 들어가는 상황으로 번질 수 있습니다.
단일 GPU 구성에서 torch compile, cache-dit, attention backend 같은 옵션만 바꿔서 성능을 개선하려는 시도는 안정적인 개선책으로 보지 않았습니다. 이 구성의 실제 병목은 연산이 아니라 메모리와 전송이기 때문에, 그 병목부터 먼저 확인하는 게 순서입니다. 그리고 한 호스트에 네 인스턴스를 올리는 이 구성은 프로세스 단위 장애는 서로 격리하지만, 호스트 자체의 장애까지 격리해주지는 않습니다.
정리
- GPU 배치, 정밀도, 샘플링 가속은 서로 독립적인 세 개의 선택 축이고, 목표(단건 지연 vs. 처리량 vs. 장애 격리)에 따라 조합이 달라집니다. GPU 4장을 한 인스턴스에 몰면 단건 지연이 가장 짧고, 2장씩 두 인스턴스나 1장씩 네 인스턴스로 쪼개면 동시 처리량과 장애 격리가 늘어나는 대신 각 인스턴스의 단건 지연은 늘어납니다.
- FP8 TP 경로에서 LoRA를 쓸 때는
--lora-merge-mode dynamic이 사실상 필수입니다. 기본값인 정적 병합(auto)은 TP로 분할된 가중치와 LoRA 행렬의 차원이 맞지 않아RuntimeError로 실패합니다. - Turbo LoRA(259/266 계층 적용, 9단계)와 LightX2V(208 계층 적용, 5단계)는 적용 대상 계층 수와 권장
--lora-scale값이 서로 다릅니다. 한 LoRA의 설정값을 다른 LoRA에 그대로 복사하면 안 되고, 로그에서applied to N layers를 직접 확인해야 실제로 가속이 걸렸는지 알 수 있습니다. num_inference_steps는 요청마다 명시해야 하는 값입니다. LoRA를 로딩했다고 해서 기본 20단계가 자동으로 줄어들지 않고, 요청 본문에서 직접 지정하지 않으면 기본값으로 돌아갑니다.- 2 GPU 이중 인스턴스 구성은 두 엔드포인트 모두에 요청을 분산해야 실질적인 처리량 이득이 있습니다. 포트 번호도 HTTP 다음 번호를 내부 브로커가 쓰는 구조라서 연속 번호로 잡으면 충돌합니다.
- 단일 GPU 복제 구성의 실제 병목은 연산 최적화 옵션이 아니라 로딩 시점의 메모리 피크(약 62GiB)입니다. text encoder/VAE 오프로드 전략은 GPU 배치가 바뀔 때마다 다시 계산해야 하고,
--pin-cpu-memory를 잘못 걸면 GPU가 아니라 호스트 자체가 죽을 수 있습니다.
이어지는 실측은 다음 글에서 다룹니다. 지금까지는 SGLang 엔드포인트에 직접 curl로 요청을 보냈는데, 다음 글에서는 이 영상생성 모델을 정식 Agent API와 Python 클라이언트로 감싸서 제출/조회/다운로드 흐름을 정리합니다.