본문 바로가기

LLM

SGLang PD 분리 배포 완전 가이드 — Prefill/Decode 분리로 처리량 5배 올리기

반응형

LLM 추론에는 두 단계가 있어요.

Prefill은 입력 프롬프트 전체를 한 번에 처리하는 단계로, 연산 집약적이고 보통 수백에서 수천 토큰을 한 번에 처리하면서 KV 캐시를 생성해요.

Decode는 그렇게 만들어진 KV 캐시를 매 스텝마다 읽으면서 토큰을 하나씩 생성하는 단계인데, 연산보다는 메모리에 부담이 크고 요청당 수십에서 수백 번 반복돼요.

전통적인 통합 엔진에서는 이 두 단계가 같은 GPU에서 실행돼요. 그래서 두 가지 심각한 문제가 생겨요.

 

문제 1: Prefill 방해(Prefill Interruption)

기존 통합 엔진에서는 디코딩이 한창 진행 중일 때 새 요청이 들어오면, 디코딩을 멈추고 프리필을 처리한 다음 다시 디코딩을 재개하는 식으로 동작해요. 이게 반복되면 토큰 생성이 계속 끊기면서 TPOT(토큰 생성 시간)가 급증하는 문제로 이어져요.

 

문제 2: DP 어텐션 불균형

DP 어텐션을 2개 워커로 구성한 경우에도 문제가 생겨요. 한 워커는 연산 집약적인 프리필을 처리하고 다른 워커는 메모리 집약적인 디코딩을 처리하는데, 서로 특성이 다르다 보니 동기화를 기다리는 시간이 생기면서 전체 레이턴시가 늘어나요.

PD 분리(Disaggregation)는 이 두 단계를 완전히 다른 서버로 분리해서 각각 최적화하는 방식이에요. 클라이언트 요청이 로드 밸런서를 거쳐 Prefill 서버로 가면, 거기서 KV 캐시를 생성한 다음 RDMA로 Decode 서버에 전송하고, Decode 서버가 토큰을 생성해서 클라이언트로 돌려주는 구조예요. 실제 DeepSeek 모델 96 GPU 배포에서 Prefill 처리량이 3.8배, Decode 처리량이 4.8배 향상된 게 확인됐어요.

PD 분리가 왜 빠른가

Prefill 서버 최적화

Prefill은 연산 집약적이라 빠른 GPU 연산이 필요하고, 여러 요청을 한 번에 묶어서 배치 처리하기에 유리한 특성을 가지고 있어요. KV 캐시를 생성하고 나면 바로 다음 요청으로 넘어가는 구조라서, 고성능 계산용 GPU에 배치하고 텐서 병렬을 크게 설정해서 배치 크기를 최대화하는 게 핵심이에요.

Decode 서버 최적화

Decode는 반대로 메모리 집약적이에요. 매 스텝마다 큰 KV 캐시를 읽어야 하고 토큰을 연속적으로 생성하기 때문에, 메모리가 많은 GPU에 배치해서 KV 캐시 공간을 최대화하고 동시에 많은 요청을 처리할 수 있도록 구성하는 게 좋아요.

RDMA 기반 KV 캐시 전송

Prefill이 KV 캐시를 생성하면 Decode로 넘겨줘야 해요. 이 전송이 빠르지 않으면 PD 분리 효과가 없어요. 일반 네트워크 전송은 GPU 메모리에서 CPU 메모리로, 다시 네트워크를 거쳐 상대편 CPU 메모리와 GPU 메모리로 복사가 네 번이나 일어나고 그 과정에 CPU가 계속 관여해요. SGLang은 RDMA(Remote Direct Memory Access)로 이 과정을 단축하는데, InfiniBand나 RoCE를 통해 GPU 메모리에서 GPU 메모리로 직접 전송하기 때문에 CPU 관여 없이 제로 카피로 전달돼요.

요청 흐름 — 단계별 동작

실제 요청이 들어왔을 때 동작 순서를 보면 이해가 쉬워요. 클라이언트가 Decode 서버로 요청을 보내면, Decode는 먼저 Prefill 쪽에 bootstrap_room ID를 요청해서 KV 캐시를 받을 공간을 미리 예약해두고 GPU 메모리 페이지를 할당해요. 그 다음 요청을 Prefill로 포워딩하면 Prefill이 프롬프트 전체를 처리해서 KV 캐시를 생성하고, 이 KV 캐시를 RDMA로 Decode의 GPU 메모리에 직접 기록해요. 전송이 끝나면 Prefill이 완료 신호를 보내고 메타데이터를 정리하는데, Decode는 KV 캐시 수신을 확인한 뒤 토큰 생성을 시작해서 생성된 토큰을 스트리밍으로 클라이언트에 전달해요.

설치 — 전송 백엔드 준비

PD 분리에는 KV 캐시 전송 백엔드가 필요해요. Mooncake 또는 NIXL 중 하나를 사용하는데, 어떤 GPU 환경인지에 따라 고르면 돼요.

Mooncake 설치 (AMD GPU 포함 모든 환경 권장)

Mooncake은 AMD GPU를 포함해서 거의 모든 환경에서 쓸 수 있는 범용 백엔드예요. pip로 바로 설치하거나 소스에서 직접 빌드할 수 있어요.

pip install mooncake-transfer-engine

# 또는 소스에서 빌드
git clone https://github.com/kvcache-ai/Mooncake.git
cd Mooncake
pip install -e .

# 설치 확인
python -c "import mooncake; print(mooncake.__version__)"

설치가 끝나면 마지막 줄처럼 버전을 출력해봐서 정상적으로 임포트되는지 확인하는 게 좋아요. 에러 없이 버전이 뜨면 바로 다음 단계로 넘어가도 돼요.

NIXL 설치 (NVIDIA GPU 권장, RDMA 최적화)

NVIDIA GPU만 쓰는 환경이라면 NIXL이 전송 레이턴시 면에서 더 유리해요. 마찬가지로 pip 설치와 소스 빌드 두 가지 방법이 있어요.

# pip 설치
pip install nixl

# 소스 빌드 (UCX가 이미 설치된 경우 필요)
git clone https://github.com/ai-dynamo/nixl.git
cd nixl
pip install .

소스 빌드는 UCX가 이미 깔려 있는 환경에서만 필요한 경로라서, 일반적인 경우엔 pip 설치만으로 충분해요.

어느 걸 쓸까?

Mooncake은 AMD GPU를 포함한 거의 모든 GPU에서 동작하고 설치도 간단한 편이에요. InfiniBand나 RoCE 네트워크도 지원하기 때문에 AMD MI300X처럼 멀티벤더 환경에서 특히 적합해요. NIXL은 NVIDIA GPU에 특화된 최적화가 들어가 있고 CUDA IPC를 지원하면서 NVIDIA 환경에서는 전송 레이턴시가 더 낮아요. 그래서 H100이나 A100, GB200처럼 순수 NVIDIA 환경이라면 NIXL을 선택하는 게 유리해요.

단일 노드 PD 분리 (기본 설정)

가장 간단한 구성이에요. 같은 서버에서 GPU만 나눠서 Prefill과 Decode를 분리하면 멀티 노드 구성 없이도 효과를 볼 수 있어요. 아래는 GPU 0,1을 Prefill에, GPU 2,3을 Decode에 배정하는 예시예요.

# Prefill 서버 (GPU 0, 1)
CUDA_VISIBLE_DEVICES=0,1 python -m sglang.launch_server \
  --model-path meta-llama/Llama-3.1-8B-Instruct \
  --disaggregation-mode prefill \
  --disaggregation-transfer-backend mooncake \
  --host 0.0.0.0 \
  --port 30000 \
  --tp 2

# Decode 서버 (GPU 2, 3)
CUDA_VISIBLE_DEVICES=2,3 python -m sglang.launch_server \
  --model-path meta-llama/Llama-3.1-8B-Instruct \
  --disaggregation-mode decode \
  --disaggregation-transfer-backend mooncake \
  --host 0.0.0.0 \
  --port 30001 \
  --tp 2

두 명령어를 각각 다른 터미널에서 실행하면 같은 서버 안에서 Prefill과 Decode가 독립적으로 동작해요. 다만 이 구성은 같은 머신의 GPU를 나눠 쓰는 거라 GPU가 4개 이상은 있어야 의미가 있어요.

로드 밸런서 설정

Prefill과 Decode 서버 앞단에는 라우터를 놓아야 클라이언트가 두 서버를 하나의 엔드포인트처럼 쓸 수 있어요. SGLang 공식 라우터를 쓰면 별도 구현 없이 바로 띄울 수 있어요.

# pip 설치
pip install sglang-router

# 라우터 실행
python -m sglang_router.launch_router \
  --pd-disaggregation \
  --prefill-hosts http://localhost:30000 \
  --decode-hosts http://localhost:30001 \
  --host 0.0.0.0 \
  --port 8080

라우터가 뜨면 8080번 포트가 실제 서비스 엔드포인트가 되고, 내부적으로 요청을 Prefill과 Decode 서버로 알아서 나눠줘요.

# 클라이언트에서는 라우터 주소만 사용
from openai import OpenAI

client = OpenAI(
    base_url="http://localhost:8080/v1",
    api_key="EMPTY"
)

response = client.chat.completions.create(
    model="meta-llama/Llama-3.1-8B-Instruct",
    messages=[{"role": "user", "content": "안녕"}]
)

클라이언트 코드는 base_url만 라우터 주소로 바꿔주면 끝이라서, 기존에 OpenAI 호환 방식으로 짜둔 코드를 그대로 재사용할 수 있어요.

멀티 노드 PD 분리 (대규모 배포)

GPU가 한 서버에 다 들어가지 않는 규모라면 여러 노드에 걸쳐 Prefill과 Decode를 분리해야 해요.

Llama 70B — 2노드 PD 분리

70B급 모델을 2개 노드로 나눠서 배포하는 예시예요. 노드 0이 Prefill, 노드 1이 Decode를 맡고 별도 노드에서 라우터를 띄워요.

# ===== Prefill 노드 (노드 0) =====
python -m sglang.launch_server \
  --model-path meta-llama/Llama-3.3-70B-Instruct \
  --disaggregation-mode prefill \
  --disaggregation-transfer-backend mooncake \
  --host 0.0.0.0 \
  --port 30000 \
  --tp 4 \
  --dtype bfloat16 \
  --mem-fraction-static 0.85

# ===== Decode 노드 (노드 1) =====
python -m sglang.launch_server \
  --model-path meta-llama/Llama-3.3-70B-Instruct \
  --disaggregation-mode decode \
  --disaggregation-transfer-backend mooncake \
  --host 0.0.0.0 \
  --port 30001 \
  --tp 4 \
  --dtype bfloat16 \
  --mem-fraction-static 0.88  # Decode는 KV 캐시 공간 더 확보

# ===== 라우터 (별도 노드 또는 마스터 노드) =====
python -m sglang_router.launch_router \
  --pd-disaggregation \
  --prefill-hosts http://prefill-node-ip:30000 \
  --decode-hosts http://decode-node-ip:30001 \
  --host 0.0.0.0 \
  --port 8080

mem-fraction-static 값을 Decode 쪽에 더 높게 잡은 이유는 Decode가 KV 캐시 공간을 더 많이 필요로 하기 때문이에요. 노드별 IP만 실제 환경에 맞게 바꿔주면 그대로 배포할 수 있어요.

DeepSeek-V3 — 대규모 PD 분리 (96 GPU)

실제 LMSYS가 공개한 DeepSeek V3 96 GPU 배포 설정이에요. Prefill과 Decode가 각각 2개 노드씩 차지하고, dp 어텐션과 deepep 백엔드까지 함께 설정돼요.

# ===== Prefill 서버 — 노드 0 (마스터) =====
python -m sglang.launch_server \
  --model-path deepseek-ai/DeepSeek-V3-0324 \
  --disaggregation-mode prefill \
  --disaggregation-transfer-backend mooncake \
  --disaggregation-ib-device mlx5_0  # InfiniBand NIC 이름 (ibstat로 확인)
  --host ${local_ip} \
  --port 30000 \
  --trust-remote-code \
  --dist-init-addr ${prefill_master_ip}:5000 \
  --nnodes 2 \
  --node-rank 0 \
  --tp 16 \
  --dp 8 \
  --enable-dp-attention \
  --moe-a2a-backend deepep \
  --mem-fraction-static 0.80

# ===== Prefill 서버 — 노드 1 (워커) =====
python -m sglang.launch_server \
  --model-path deepseek-ai/DeepSeek-V3-0324 \
  --disaggregation-mode prefill \
  --disaggregation-transfer-backend mooncake \
  --disaggregation-ib-device mlx5_0 \
  --host ${local_ip} \
  --port 30000 \
  --trust-remote-code \
  --dist-init-addr ${prefill_master_ip}:5000 \
  --nnodes 2 \
  --node-rank 1 \
  --tp 16 \
  --dp 8 \
  --enable-dp-attention \
  --moe-a2a-backend deepep \
  --mem-fraction-static 0.80

# ===== Decode 서버 — 노드 2 (마스터) =====
python -m sglang.launch_server \
  --model-path deepseek-ai/DeepSeek-V3-0324 \
  --disaggregation-mode decode \
  --disaggregation-transfer-backend mooncake \
  --disaggregation-ib-device mlx5_0 \
  --host ${local_ip} \
  --port 30001 \
  --trust-remote-code \
  --dist-init-addr ${decode_master_ip}:5001 \
  --nnodes 2 \
  --node-rank 0 \
  --tp 16 \
  --dp 8 \
  --enable-dp-attention \
  --moe-a2a-backend deepep \
  --mem-fraction-static 0.85

# ===== Decode 서버 — 노드 3 (워커) =====
python -m sglang.launch_server \
  --model-path deepseek-ai/DeepSeek-V3-0324 \
  --disaggregation-mode decode \
  --disaggregation-transfer-backend mooncake \
  --disaggregation-ib-device mlx5_0 \
  --host ${local_ip} \
  --port 30001 \
  --trust-remote-code \
  --dist-init-addr ${decode_master_ip}:5001 \
  --nnodes 2 \
  --node-rank 1 \
  --tp 16 \
  --dp 8 \
  --enable-dp-attention \
  --moe-a2a-backend deepep \
  --mem-fraction-static 0.85

노드 4개를 동시에 띄워야 하는 만큼 dist-init-addr과 node-rank를 정확히 맞춰주는 게 가장 중요해요. 하나라도 어긋나면 분산 초기화 단계에서 멈춰버려요.

PD 분리 전용 파라미터 상세

PD 분리를 쓸 때 자주 마주치는 핵심 플래그들을 따로 정리해봤어요.

--disaggregation-mode

서버 역할을 지정하는 플래그예요. prefill로 두면 Prefill 전용 서버가 되고 decode로 두면 Decode 전용 서버가 돼요. 아무 값도 지정하지 않으면 기존 통합 모드가 기본값으로 적용돼요.

# Prefill 전용 서버
--disaggregation-mode prefill

# Decode 전용 서버
--disaggregation-mode decode

이 플래그 하나만으로 같은 명령어가 완전히 다른 역할의 서버가 된다는 점이 핵심이에요.

--disaggregation-transfer-backend

KV 캐시를 전송하는 백엔드를 지정하는 플래그예요. mooncake은 범용으로 쓸 수 있고 AMD GPU도 포함되며, nixl은 NVIDIA 전용이지만 속도가 더 빨라요.

# Mooncake (범용, AMD 포함)
--disaggregation-transfer-backend mooncake

# NIXL (NVIDIA 전용, 더 빠름)
--disaggregation-transfer-backend nixl

GPU 환경에 따라 둘 중 하나만 선택하면 되고, 두 백엔드를 섞어 쓸 수는 없어요.

--disaggregation-ib-device

InfiniBand NIC 이름을 지정하는 플래그예요. 따로 명시하지 않으면 자동으로 감지되지만, NIC이 여러 개거나 자동 감지가 실패하는 환경에서는 직접 지정해주는 게 안전해요.

# InfiniBand NIC 목록 확인
ibstat

# 특정 NIC 지정
--disaggregation-ib-device mlx5_0

# 여러 NIC 사용
--disaggregation-ib-device mlx5_0,mlx5_1

ibstat으로 먼저 사용 가능한 NIC 이름을 확인한 다음 그 값을 그대로 넣어주면 돼요.

--disaggregation-prefill-dp-size

Prefill 전용 DP 크기를 따로 지정하는 플래그예요. Prefill과 Decode가 서로 다른 DP 크기를 가질 수 있다는 게 특징이에요.

# Decode DP보다 Prefill DP를 더 크게 설정
python -m sglang.launch_server \
  --model-path meta-llama/Llama-3.1-70B-Instruct \
  --disaggregation-mode prefill \
  --tp 4 \
  --dp 4 \
  --disaggregation-prefill-dp-size 4

워크로드에 따라 Prefill 쪽 DP를 더 크게 잡으면 동시에 처리할 수 있는 프리필 요청 수가 늘어나요.

환경 변수로 타임아웃 조정

느린 네트워크 환경에서는 기본 타임아웃 값이 너무 짧을 수 있어요. 이럴 때는 환경 변수로 직접 늘려줄 수 있어요.

# Bootstrap 타임아웃 (기본 30초, 느린 네트워크에서 늘리기)
export SGLANG_DISAGGREGATION_BOOTSTRAP_TIMEOUT=600  # 10분

# 대기 타임아웃
export SGLANG_DISAGGREGATION_WAITING_TIMEOUT=600

타임아웃을 너무 길게 잡으면 장애 상황에서 문제를 늦게 감지하게 되니, 네트워크 상태에 맞춰 적당한 값을 찾는 게 좋아요.

Prefill : Decode 비율 설정

워크로드 특성에 따라 Prefill과 Decode 서버의 비율을 다르게 잡아야 해요. 코드 생성처럼 짧은 프롬프트에 긴 응답이 나오는 작업이라면 Decode 서버가 더 많은 GPU를 필요로 해서 Prefill 1 대 Decode 3~4 정도의 비율이 적당해요. 반대로 문서 요약처럼 긴 프롬프트에 짧은 응답이 나오는 작업이라면 Prefill 쪽에 GPU가 더 필요해서 Prefill 2~3 대 Decode 1 비율이 맞고, 챗봇처럼 균형 잡힌 워크로드라면 Prefill 1 대 Decode 1~2 정도면 충분해요.

아래는 짧은 프롬프트·긴 응답 워크로드를 가정해서 Prefill 1개에 Decode 3개를 배치한 예시예요.

# 짧은 프롬프트, 긴 응답 워크로드
# Prefill 1개 서버 (GPU 2개)
CUDA_VISIBLE_DEVICES=0,1 python -m sglang.launch_server \
  --model-path meta-llama/Llama-3.1-8B-Instruct \
  --disaggregation-mode prefill \
  --tp 2 --port 30000

# Decode 3개 서버 (GPU 6개)
CUDA_VISIBLE_DEVICES=2,3 python -m sglang.launch_server \
  --disaggregation-mode decode --tp 2 --port 30001

CUDA_VISIBLE_DEVICES=4,5 python -m sglang.launch_server \
  --disaggregation-mode decode --tp 2 --port 30002

CUDA_VISIBLE_DEVICES=6,7 python -m sglang.launch_server \
  --disaggregation-mode decode --tp 2 --port 30003

# 라우터에서 여러 Decode 서버 등록
python -m sglang_router.launch_router \
  --pd-disaggregation \
  --prefill-hosts http://localhost:30000 \
  --decode-hosts http://localhost:30001 http://localhost:30002 http://localhost:30003 \
  --port 8080

라우터에 decode-hosts를 여러 개 나열하면 자동으로 부하를 분산해주기 때문에 Decode 서버를 늘릴 때마다 라우터 설정만 갱신해주면 돼요.

Docker Compose로 PD 분리 배포

직접 명령어를 하나씩 띄우는 대신 Docker Compose로 한 번에 관리하는 방법도 있어요. 아래는 Prefill, Decode, 라우터를 각각 컨테이너로 분리한 구성이에요.

# docker-compose.pd.yml
version: "3.8"

services:
  prefill:
    image: lmsysorg/sglang:latest
    runtime: nvidia
    environment:
      - NVIDIA_VISIBLE_DEVICES=0,1
    volumes:
      - ~/.cache/huggingface:/root/.cache/huggingface
    ports:
      - "30000:30000"
    command: >
      python -m sglang.launch_server
      --model-path meta-llama/Llama-3.1-8B-Instruct
      --disaggregation-mode prefill
      --disaggregation-transfer-backend mooncake
      --tp 2
      --host 0.0.0.0
      --port 30000
    network_mode: host

  decode:
    image: lmsysorg/sglang:latest
    runtime: nvidia
    environment:
      - NVIDIA_VISIBLE_DEVICES=2,3
    volumes:
      - ~/.cache/huggingface:/root/.cache/huggingface
    ports:
      - "30001:30001"
    command: >
      python -m sglang.launch_server
      --model-path meta-llama/Llama-3.1-8B-Instruct
      --disaggregation-mode decode
      --disaggregation-transfer-backend mooncake
      --tp 2
      --host 0.0.0.0
      --port 30001
      --mem-fraction-static 0.88
    network_mode: host
    depends_on:
      - prefill

  router:
    image: sglang-router:latest
    ports:
      - "8080:8080"
    command: >
      python -m sglang_router.launch_router
      --pd-disaggregation
      --prefill-hosts http://localhost:30000
      --decode-hosts http://localhost:30001
      --host 0.0.0.0
      --port 8080
    network_mode: host
    depends_on:
      - prefill
      - decode

depends_on 설정 덕분에 prefill과 decode가 먼저 뜨고 나서 라우터가 마지막에 시작돼요. 작성한 파일은 아래 명령어 한 줄로 바로 띄울 수 있어요.

docker compose -f docker-compose.pd.yml up -d

-d 옵션으로 백그라운드 실행하면 터미널을 계속 띄워둘 필요 없이 세 컨테이너가 알아서 돌아가요.

성능 비교 — 통합 모드 vs PD 분리

96 GPU에서 DeepSeek V3 기준으로 측정한 실측값이에요.

지표 통합 모드 PD 분리 향상

Prefill 처리량 기준 3.8배 +280%
Decode 처리량 기준 4.8배 +380%
TTFT (첫 토큰) 높음 낮음 대폭 개선
TPOT (토큰 생성) 불규칙 안정적 안정화

PD 분리 사용해야 할 때 vs 아닐 때

GPU가 8개 이상 있고 고트래픽 프로덕션 환경이면서 TTFT와 TPOT에 엄격한 SLA가 걸려 있다면 PD 분리를 쓰는 게 맞아요. InfiniBand 네트워크가 이미 갖춰져 있고 DeepSeek이나 Llama 70B 이상의 대형 모델을 서빙한다면 더 말할 것도 없어요.

반대로 GPU가 4개 이하인 소규모 환경이거나 단순 개발·테스트 환경이라면 PD 분리는 오히려 손해예요. 네트워크 대역폭이 부족해서 KV 캐시 전송이 병목이 될 수 있고, 8B 이하 소형 모델이라면 PD 분리에 드는 오버헤드가 얻는 이득보다 커질 수 있어요.

자주 발생하는 문제와 해결법

실제로 PD 분리를 운영하면서 자주 마주치는 문제들과 해결법을 정리해봤어요.

 

KV 캐시 전송이 타임아웃 나는 경우라면 export SGLANG_DISAGGREGATION_BOOTSTRAP_TIMEOUT=300처럼 타임아웃 값을 늘려주면 해결돼요.

 

InfiniBand NIC을 자동으로 찾지 못하는 경우에는 ibstat으로 사용 가능한 NIC을 먼저 확인하고 --disaggregation-ib-device mlx5_0처럼 수동으로 지정해줘야 해요.

 

Decode 서버에서 OOM이 발생한다면 --mem-fraction-static 0.80처럼 값을 낮춰서 KV 캐시 공간을 줄여주면 되고, 반대로 Prefill 서버가 느리다면 --max-prefill-tokens 32768처럼 최대 프리필 토큰 수를 늘려주는 게 도움이 돼요.

 

멀티 노드 환경에서 데드락이 걸리는 경우는 --disable-cuda-graph로 CUDA 그래프를 비활성화하면 풀리는 경우가 많고, RDMA 연결이 실패한다면 NCCL_IB_DISABLE=0, NCCL_IB_GID_INDEX=3, UCX_TLS=rc,cuda_copy,cuda_ipc 같은 UCX·NCCL 환경 변수를 설정해주면 해결되는 경우가 대부분이에요.

 

공식 문서 링크

 

반응형