Skip to content

[GPU] SGLang으로 영상생성 추론 최적화하기 ​

개요 ​

이전 글에서 MiniMax H3 영상생성 모델을 ComfyUI 워크플로로 띄우는 방법을 다뤘습니다. 이번 글은 같은 모델을 SGLang으로 서빙하면서 추론 성능을 실제로 튜닝해본 기록입니다.

성능을 이야기할 때 먼저 나눠야 하는 두 가지가 있습니다. 영상 한 편을 만드는 데 걸리는 지연(latency)과, 서버 전체가 동시에 처리할 수 있는 처리량(throughput)입니다. 이 둘은 같은 방향으로 움직이지 않습니다. GPU 4장을 하나의 추론 작업에 몰아주면 영상 한 편은 빨리 나오지만 동시 요청은 하나밖에 못 받고, 반대로 GPU를 인스턴스별로 쪼개면 동시 처리량은 늘지만 각 인스턴스의 단건 지연은 늘어납니다.

이 글에서는 실제로 손댈 수 있는 구성 축을 세 가지로 나눠서 각각 돌려봤습니다.

구성 축선택지바뀌는 것
가중치 실행 정밀도BF16 / FP8메모리, 커널, 품질과 속도
샘플링 가속기본 / Turbo LoRA / LightX2V요청 단계 수와 결과 품질
GPU 배치4장 한 인스턴스 / 2장씩 두 개 / 1장씩 네 개단건 지연, 전체 처리량, 장애 격리

세 축은 서로 독립적으로 조합할 수 있지만, 조합마다 실제로 켜야 하는 플래그와 주의할 함정이 다릅니다. 아래에서 하나씩 실행하면서 확인한 내용을 정리합니다.

생성 모드부터 다시 확인 ​

SGLang 쪽으로 넘어가기 전에, 이 모델이 지원하는 생성 모드를 다시 짚었습니다.

bash
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로 띄웠습니다. 단건 지연을 최소화하는 게 목표인 구성입니다.

bash
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를 얹어서 요청 단계 수를 줄이는 구성도 돌려봤습니다.

bash
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가 실제로 적용됐는지는 로그로 확인했습니다.

bash
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개가 미적용이라는 사실 자체가 곧 오류를 뜻하지는 않습니다.

요청은 이렇게 보냈습니다.

bash
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 가속도 시험했습니다.

bash
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 30010

Turbo LoRA와 비교하면 세부 설정이 확실히 다릅니다.

항목LightX2V 설정이유
파일minimax_h3_fl2v_turbo_4step_v1.0_768p_comfyui_bf16.safetensors이 환경에 맞는 ComfyUI 형식. v0.1 PEFT 형식과 혼용 금지
강도0.0625alpha / rank = 8 / 128. 1.0으로 바꾸면 과하게 적용될 수 있음
병합dynamicFP8 TP 가중치 분할과의 정적 병합 충돌 방지
요청 단계5종료 지점을 포함한 5단계로 실제 디노이징은 4회
지원 모드FL2VARef2VA는 별도 실험 검증 필요

--lora-scale이 Turbo LoRA에서는 1.0이었는데 여기서는 0.0625로 완전히 다릅니다. LoRA의 alpha/rank 비율(8/128)에서 나온 값이라, 다른 LoRA를 가져다 쓸 때 강도 값을 그대로 복사하면 안 된다는 걸 보여주는 사례입니다.

적용 로그도 별도로 확인했습니다.

bash
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단계로 보냈습니다.

bash
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장씩 두 개의 독립 인스턴스로 쪼개는 구성도 실제로 띄워봤습니다. 목표는 단건 지연이 아니라 동시 처리량입니다.

bash
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 30010
bash
sudo 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도 인스턴스마다 겹치지 않게 따로 지정했습니다. 실제로 포트가 열렸는지는 이렇게 확인했습니다.

bash
ss -ltnp | grep 3001

2 GPU 구성에서는 --layerwise-offload-components text_encoder를 명시했습니다. text encoder만 계층별로 CPU에 오프로드하고 VAE는 GPU에 계속 상주시키는 설정인데, 영상 디코딩 과정에서 VAE까지 매번 CPU로 옮기면 그 전송 자체가 GPU 연산보다 더 큰 지연 요인이 되기 때문입니다. 다만 이 옵션을 그대로 단일 GPU 구성에 가져가면 로딩 시 OOM이 날 수 있다는 점도 함께 확인했습니다. 오프로드 전략은 GPU 배치에 따라 다시 계산해야 합니다.

이 2 GPU 구성에 Turbo LoRA를 추가할 때는 아래 조각을 두 인스턴스 모두의 명령에 합쳐 넣었습니다.

bash
# 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장씩 네 개의 완전히 독립된 인스턴스로 나누는 구성도 확인했습니다.

bash
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 같은 옵션만 바꿔서 성능을 개선하려는 시도는 안정적인 개선책으로 보지 않았습니다. 이 구성의 실제 병목은 연산이 아니라 메모리와 전송이기 때문에, 그 병목부터 먼저 확인하는 게 순서입니다. 그리고 한 호스트에 네 인스턴스를 올리는 이 구성은 프로세스 단위 장애는 서로 격리하지만, 호스트 자체의 장애까지 격리해주지는 않습니다.

정리 ​

  1. GPU 배치, 정밀도, 샘플링 가속은 서로 독립적인 세 개의 선택 축이고, 목표(단건 지연 vs. 처리량 vs. 장애 격리)에 따라 조합이 달라집니다. GPU 4장을 한 인스턴스에 몰면 단건 지연이 가장 짧고, 2장씩 두 인스턴스나 1장씩 네 인스턴스로 쪼개면 동시 처리량과 장애 격리가 늘어나는 대신 각 인스턴스의 단건 지연은 늘어납니다.
  2. FP8 TP 경로에서 LoRA를 쓸 때는 --lora-merge-mode dynamic이 사실상 필수입니다. 기본값인 정적 병합(auto)은 TP로 분할된 가중치와 LoRA 행렬의 차원이 맞지 않아 RuntimeError로 실패합니다.
  3. Turbo LoRA(259/266 계층 적용, 9단계)와 LightX2V(208 계층 적용, 5단계)는 적용 대상 계층 수와 권장 --lora-scale 값이 서로 다릅니다. 한 LoRA의 설정값을 다른 LoRA에 그대로 복사하면 안 되고, 로그에서 applied to N layers를 직접 확인해야 실제로 가속이 걸렸는지 알 수 있습니다.
  4. num_inference_steps는 요청마다 명시해야 하는 값입니다. LoRA를 로딩했다고 해서 기본 20단계가 자동으로 줄어들지 않고, 요청 본문에서 직접 지정하지 않으면 기본값으로 돌아갑니다.
  5. 2 GPU 이중 인스턴스 구성은 두 엔드포인트 모두에 요청을 분산해야 실질적인 처리량 이득이 있습니다. 포트 번호도 HTTP 다음 번호를 내부 브로커가 쓰는 구조라서 연속 번호로 잡으면 충돌합니다.
  6. 단일 GPU 복제 구성의 실제 병목은 연산 최적화 옵션이 아니라 로딩 시점의 메모리 피크(약 62GiB)입니다. text encoder/VAE 오프로드 전략은 GPU 배치가 바뀔 때마다 다시 계산해야 하고, --pin-cpu-memory를 잘못 걸면 GPU가 아니라 호스트 자체가 죽을 수 있습니다.

이어지는 실측은 다음 글에서 다룹니다. 지금까지는 SGLang 엔드포인트에 직접 curl로 요청을 보냈는데, 다음 글에서는 이 영상생성 모델을 정식 Agent API와 Python 클라이언트로 감싸서 제출/조회/다운로드 흐름을 정리합니다.

基于 VitePress 构建 · 部署于腾讯云 EdgeOne Pages