GPU로 LLM을 서빙하는 글을 몇 번 썼다. 쿠버네티스에 vLLM을 올리는 것부터 EAGLE3 같은 추측 디코딩으로 처리량을 끌어올리는 것까지. 그런데 막상 운영에 넣어 보면 반복해서 걸리는 지점이 비슷하다. GPU 하나에 긴 프롬프트 요청과 긴 생성 요청이 뒤섞이면 서로 자리를 빼앗는다. 긴 프롬프트가 들어온 순간 뒤에 줄 선 요청들의 토큰 생성이 같이 느려진다.
NVIDIA Dynamo는 이 두 작업을 아예 다른 GPU 풀에서 돌린다. 프롬프트를 한꺼번에 읽어 KV 캐시를 만드는 prefill과, 토큰을 한 개씩 뽑는 decode를 별도 워커로 분리하고, 그 사이의 KV 캐시를 NIXL이라는 전송 라이브러리로 GPU 간에 옮긴다. 두 풀을 독립적으로 늘리고 줄일 수 있으니, 첫 토큰 지연과 토큰 간 지연을 따로 맞출 수 있다. 쿠버네티스에서는 DynamoGraphDeployment라는 CRD 하나로 이 구조를 선언한다.
1. prefill과 decode는 부하 성격이 다르다
LLM 추론은 두 단계로 나뉜다. 먼저 prefill은 입력 프롬프트 전체를 한 번에 모델에 통과시켜 첫 토큰과 KV 캐시(각 토큰의 key/value 텐서를 저장해 둔 것)를 만든다. 프롬프트가 길수록 연산량이 커지는, GPU 연산기(FLOPs)를 꽉 채우는 단계다. 반대로 decode는 직전까지 만든 KV 캐시를 읽어 토큰을 하나씩 생성한다. 토큰 한 개당 연산량은 작지만 매 스텝 모델 가중치와 KV 캐시를 메모리에서 읽어야 해서, GPU 연산기보다 메모리 대역폭에 묶인다.
성격이 다른 두 작업을 한 GPU에서 섞으면 손해가 난다. 긴 프롬프트의 prefill이 돌아가는 동안 같은 GPU에 올라탄 decode 요청들이 끼어들지 못해 토큰 생성이 끊긴다. 사용자 체감으로는 첫 토큰 지연(TTFT)과 토큰 간 지연(ITL)이 서로 간섭한다.
disaggregated serving은 prefill 전용 워커와 decode 전용 워커를 분리해 이 간섭을 끊는다. prefill 풀은 연산 집약적이라 요청이 몰리는 시간대에 레플리카를 늘리고, decode 풀은 긴 생성이 많은 트래픽에 맞춰 따로 늘린다. 한쪽을 늘려도 다른 쪽 지연에 영향을 주지 않는다는 게 핵심 이득이다.
2. 쿠버네티스에 두 워커 풀을 올리기
Dynamo는 쿠버네티스 오퍼레이터와 CRD로 배포한다. 오퍼레이터를 먼저 설치하고 나면 DynamoGraphDeployment 리소스 하나에 Frontend, prefill 워커, decode 워커를 한꺼번에 선언한다.
모델을 받으려면 HuggingFace 토큰이 필요하니 시크릿부터 만든다.
$ kubectl create secret generic hf-token-secret \
--from-literal=HF_TOKEN=hf_xxxxxxxxxxxxxxxxx \
-n your-namespace
secret/hf-token-secret created
매니페스트는 아래 형태다. apiVersion은 nvidia.com/v1alpha1이고, spec.services는 리스트가 아니라 이름을 키로 갖는 맵이다. 각 서비스의 역할은 componentType으로 지정하고(frontend 또는 worker), 워커는 subComponentType으로 prefill과 decode를 가른다. 컨테이너 정의는 extraPodSpec.mainContainer 아래에 둔다.
apiVersion: nvidia.com/v1alpha1
kind: DynamoGraphDeployment
metadata:
name: vllm-disagg
namespace: your-namespace
spec:
services:
Frontend:
dynamoNamespace: vllm-disagg
componentType: frontend
replicas: 1
extraPodSpec:
mainContainer:
image: nvcr.io/nvidia/ai-dynamo/vllm-runtime:0.5.1
VllmPrefillWorker:
dynamoNamespace: vllm-disagg
envFromSecret: hf-token-secret
componentType: worker
subComponentType: prefill
replicas: 2
resources:
limits:
gpu: "1"
sharedMemory:
size: 16Gi
extraPodSpec:
mainContainer:
image: nvcr.io/nvidia/ai-dynamo/vllm-runtime:0.5.1
workingDir: /workspace/components/backends/vllm
command:
- /bin/sh
- -c
args:
- "python3 -m dynamo.vllm --model Qwen/Qwen3-0.6B --is-prefill-worker"
VllmDecodeWorker:
dynamoNamespace: vllm-disagg
envFromSecret: hf-token-secret
componentType: worker
subComponentType: decode
replicas: 2
resources:
limits:
gpu: "1"
sharedMemory:
size: 16Gi
extraPodSpec:
mainContainer:
image: nvcr.io/nvidia/ai-dynamo/vllm-runtime:0.5.1
workingDir: /workspace/components/backends/vllm
command:
- /bin/sh
- -c
args:
- "python3 -m dynamo.vllm --model Qwen/Qwen3-0.6B"
prefill 워커에는 --is-prefill-worker 플래그가 붙고 decode 워커에는 붙지 않는다. 이 한 줄 차이가 같은 이미지를 두 역할로 갈라 놓는다. GPU는 워커마다 resources.limits.gpu: "1"로 하나씩 잡았고, 레플리카를 각각 2로 두면 2 prefill + 2 decode + Frontend, 합쳐서 GPU 4장이 든다.
$ kubectl apply -f disagg.yaml -n your-namespace dynamographdeployment.nvidia.com/vllm-disagg created $ kubectl get dynamographdeployment -n your-namespace NAME AGE vllm-disagg 40s $ kubectl get pods -n your-namespace NAME READY STATUS RESTARTS AGE vllm-disagg-frontend-7c9d8b6f4-nz2qk 1/1 Running 0 2m vllm-disagg-vllmprefillworker-0 1/1 Running 0 2m vllm-disagg-vllmprefillworker-1 1/1 Running 0 2m vllm-disagg-vllmdecodeworker-0 1/1 Running 0 2m vllm-disagg-vllmdecodeworker-1 1/1 Running 0 2m
워커 파드가 Pending에서 안 올라오면 거의 GPU 스케줄 문제다. kubectl describe pod로 Insufficient nvidia.com/gpu가 보이면 노드에 붙은 GPU 수보다 많이 요청한 것이니 레플리카를 줄이거나 GPU 노드를 늘려야 한다. 이 부분은 예전에 쓴 파드 Pending 글과 진단 방법이 같다.
3. NIXL이 KV 캐시를 옮기는 방식
prefill 워커가 만든 KV 캐시는 decode 워커의 GPU로 넘어가야 한다. 이 전송을 맡는 게 NIXL(NVIDIA Inference Xfer Library)이다. NIXL은 양쪽 GPU의 위치를 보고 전송 수단을 알아서 고른다. 한 노드 안의 GPU끼리는 CUDA IPC로 메모리를 직접 공유하고, NVLink가 있으면 그 대역폭을 탄다. 노드가 다르면 UCX를 거쳐 RDMA 같은 네트워크 전송으로 넘어간다. 워커 기동 로그에 Backend UCX was instantiated가 찍히면 NIXL이 전송 계층을 정상적으로 올린 것이다.
여기서 한 번 걸린다. NIXL은 전송 메타데이터를 파드의 /dev/shm(공유 메모리)을 거쳐 준비하는데, 쿠버네티스 파드의 /dev/shm 기본 크기는 64MB로 작다. 그대로 두면 KV 전송이 초기화 단계에서 막히거나 워커가 중간에 죽는다. 그래서 매니페스트의 워커마다 sharedMemory.size: 16Gi를 넣었다. 이 필드가 Dynamo CRD가 공유 메모리를 키우는 공식 방법이다.
직접 podTemplate에 emptyDir medium: Memory 볼륨을 끼워 넣는 식으로 우회하려 들지 마라. DynamoGraphDeployment 스키마는 그런 경로를 받지 않아 적용되지 않고, 워커는 여전히 64MB /dev/shm으로 뜬다. 공유 메모리는 sharedMemory.size로만 지정한다.
$ kubectl exec vllm-disagg-vllmdecodeworker-0 -n your-namespace -- df -h /dev/shm Filesystem Size Used Avail Use% Mounted on shm 16G 0 16G 0% /dev/shm
4. 라우팅과 지연 측정
요청은 Frontend로 들어온다. Frontend는 OpenAI 호환 엔드포인트를 열고, 어느 prefill 워커와 decode 워커로 보낼지 라우팅한다. Dynamo의 KV 인지 라우팅은 들어온 프롬프트가 기존에 캐시된 접두어와 겹치면 그 KV 캐시를 가진 워커로 보내, prefill을 다시 돌리지 않고 재사용한다. 접두어가 공통인 요청이 많은 워크로드일수록 이 재사용 이득이 크다.
Frontend를 로컬로 끌어와 호출해 본다.
$ kubectl port-forward svc/vllm-disagg-frontend 8000:8000 -n your-namespace &
$ curl -s http://localhost:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "Qwen/Qwen3-0.6B",
"messages": [{"role": "user", "content": "disaggregated serving을 한 줄로 설명해줘"}],
"max_tokens": 64
}' | jq -r '.choices[0].message.content'
처리량과 지연은 환경마다 다르니 숫자를 외워 쓰기보다 직접 재는 게 맞다. genai-perf로 같은 모델을 분리 전과 후로 돌려 TTFT와 토큰 간 지연을 비교한다.
$ genai-perf profile \
-m Qwen/Qwen3-0.6B \
--service-kind openai \
--endpoint-type chat \
--url http://localhost:8000 \
--streaming \
--concurrency 50
볼 지표는 두 개다. prefill 분리가 잘 먹었다면 동시 요청이 많을 때 TTFT(첫 토큰 지연)의 꼬리 지연이 눈에 띄게 줄고, decode 풀이 따로 도니 토큰 간 지연이 긴 프롬프트에 흔들리지 않는다. 개선 폭은 모델 크기, 프롬프트 길이 분포, GPU 종류, 노드 간 대역폭에 따라 크게 달라진다. 짧은 프롬프트에 짧은 생성만 들어오는 트래픽이라면 분리로 얻는 게 적고 오히려 KV 전송 비용이 더 들 수 있다. 긴 프롬프트와 긴 생성이 섞인 트래픽에서 효과가 크다.
풀 크기는 측정값을 보고 조정한다. TTFT가 목표보다 높으면 VllmPrefillWorker의 replicas를, 토큰 간 지연이 높으면 VllmDecodeWorker의 replicas를 올린다. 두 숫자를 따로 돌릴 수 있다는 것이 한 GPU에 몰아넣는 집약 배포와의 가장 큰 차이다.