LLM 스터디 7주차 - llm-d P/D Disaggregation — prefill과 decode를 떼어놓는 이유

LLM 추론은 성격이 전혀 다른 두 단계로 이루어진다. 이 둘을 같은 GPU에서 돌리면 서로를 방해한다.
P/D Disaggregation은 그 둘을 아예 다른 서버로 떼어놓는 방식이다. llm-d는 이 기능을 개념편에서 정리한 EPP에 기본으로 내장하고 있다.
이 글은 llm-d 공식 가이드와 저장소의 실제 매니페스트를 근거로 구조를 정리한다. 가이드의 기준 구성은 GPU 16장이지만, 마지막 절에서는 RTX 3050 6GB 한 장을 논리적으로 둘로 나눠 실제로 띄워본 결과를 함께 싣는다.
1. 용어정리
| 용어 | 쉬운 설명 |
|---|---|
| Prefill | 입력 프롬프트 전체를 한 번에 읽어서 이해하는 단계 |
| Decode | 답변을 한 글자씩 만들어내는 단계 |
| ISL / OSL | Input/Output Sequence Length. 입력이 10,000 토큰, 출력이 1,000 토큰이면 10:1 |
| TTFT | 첫 글자가 나오기까지 걸린 시간. 주로 prefill이 좌우한다 |
| ITL | 글자와 글자 사이 간격. 주로 decode가 좌우한다 |
2. Prefill과 Decode비교
| Prefill | Decode | |
|---|---|---|
| 하는 일 | 프롬프트 전체를 한 번의 forward pass로 처리 | KV 캐시에서 토큰을 하나씩 생성 |
| 병목 | 연산(compute-bound) GPU flops | 메모리 대역폭(memory-bandwidth-bound) HBM에서 on-chip으로 데이터를 얼마나 빨리 옮기는가 |
| 성격 | 짧고 폭발적 | 길고 지속적 |
| 영향 지표 | TTFT | ITL |
한 GPU에 섞어두면 문제가 생긴다. 긴 프롬프트의 prefill이 들어오는 순간 GPU 연산이 거기에 묶이고, 이미 답변을 뱉고 있던 decode 요청들이 그동안 멈춘다.
사용자 입장에서는 잘 나오던 글자가 갑자기 뚝 끊기는 현상이다. 남이 보낸 긴 프롬프트 때문에 내 답변이 끊기는 셈이다.
3. 떼어놓으면 무엇이 좋아지나
첫째, 간섭이 사라진다. prefill 전용 서버와 decode 전용 서버가 나뉘므로, 긴 프롬프트가 들어와도 decode는 자기 속도를 유지한다. ITL이 안정되고 체감 품질이 올라간다.
둘째, 각자에게 맞는 모양으로 배치할 수 있다.
| 권장 배치 | 이유 | |
|---|---|---|
| Prefill | replica 많이, 병렬 적게 (예: TP=1 × 8개) | 짧고 폭발적인 작업이라 개수로 받아내는 편이 낫다 |
| Decode | replica 적게, 병렬 많이 (예: TP=4 × 2개) | 넓게 쪼갤수록 KV 캐시에 쓸 메모리가 늘어난다 |
셋째, 모델 사본 수가 준다. decode를 넓은 병렬로 묶으면 같은 GPU 수에서 모델 복사본이 줄고, 그만큼 KV 캐시에 돌릴 메모리가 늘어난다.
4. 쓰지 말아야 할 때는 언제인가
공식 가이드는 모든 워크로드의 정답이 아니라고 분명히 말하고 있다**.** 권장 조건은 세 가지다.
- 중대형 모델 (예:
gpt-oss-120b) - 긴 입력 (예: 10k ISL / 1k OSL. 200 ISL / 200 OSL 같은 짧은 요청은 대상이 아니다)
- Sparse MoE 구조 — wide expert parallelism의 여지가 있는 경우
짧은 프롬프트에 짧은 답변이라면 prefill 자체가 가벼워서 간섭이 문제되지 않는다. 오히려 KV를 네트워크로 옮기는 비용만 추가된다.
5. 요청 순서도
sequenceDiagram
participant C as 클라이언트
participant P as Proxy
participant E as EPP
participant S as decode 파드의
routing sidecar
participant PF as prefill 파드
participant D as decode 엔진
C->>P: 요청
P->>E: ext_proc
Note over E: prefill용·decode용
프로필을 각각 실행
E-->>P: decode 주소 + prefill 주소(헤더)
P->>S: decode 파드로 전달
S->>PF: 먼저 prefill 에 프롬프트 전달
PF-->>S: KV 블록을 어디서 가져갈지 알려주는 메타데이터
S->>D: decode 시작 요청
D->>PF: NIXL 로 KV 블록을 당겨온다
D-->>C: 토큰 스트리밍
눈여겨볼 곳은 최종 목적지가 decode라는 점이다. EPP는 두 개의 엔드포인트를 고르지만, 프록시가 실제로 연결하는 곳은 decode 파드다.
prefill 주소는 헤더로 주입되고, decode 파드에 함께 떠 있는 routing sidecar가 그 주소를 보고 원격 prefill을 조율한다.
그리고 KV는 decode가 당겨온다(pull). prefill이 밀어 넣는 게 아니라, prefill이 “여기 있다"는 메타데이터만 주고 decode가 NIXL로 가져간다.
6. EPP는 둘을 어떻게 구분하나
라벨로 구분한다. 두 Deployment는 같은 InferencePool에 속하면서 llm-d.ai/role 값만 다르다.
그리고 EPP 설정에서 프로필 두 개를 각각 실행한다.
plugins:
- type: always-disagg-pd-decider
- type: disagg-profile-handler # 프로필 2개를 실행하는 핸들러
parameters:
deciders:
prefill: always-disagg-pd-decider
- type: prefill-filter # role=prefill 만 남긴다
- type: decode-filter # role=decode 만 남긴다
- type: prefix-cache-affinity-filter
parameters:
peakPrefillThroughput: 33821 # 하드웨어별로 다시 재야 하는 값
- type: token-load-scorer
- type: active-request-scorer
schedulingProfiles:
- name: prefill
plugins:
- pluginRef: prefill-filter
- pluginRef: prefix-cache-affinity-filter # 캐시가 있는 prefill 우선
- pluginRef: token-load-scorer
- pluginRef: max-score-picker
- name: decode
plugins:
- pluginRef: decode-filter
- pluginRef: active-request-scorer # 덜 바쁜 decode 우선
- pluginRef: max-score-picker두 프로필이 보는 기준이 다르다는 점이 중요하다. prefill 쪽은 프리픽스 캐시 적중과 토큰 부하를 보고, decode 쪽은 지금 처리 중인 요청 수를 본다.
각 단계의 병목이 다르니 판단 기준도 달라야 한다. 그리고 prefix-cache aware 라우팅이 그대로 얹힌다. 분리했다고 캐시 최적화를 포기하지 않는다.
7. 실제 매니페스트
가이드의 기준 구성은 openai/gpt-oss-120b 기준 prefill 8개(TP=1) + decode 2개(TP=4) 다.
spec:
replicas: 8
template:
spec:
containers:
- name: modelserver
args:
- "openai/gpt-oss-120b"
- "--tensor-parallel-size=1"
- "--block-size=128"
- "--kv-transfer-config"
- '{"kv_connector":"NixlConnector","kv_role":"kv_both",
"kv_buffer_device":"cuda","kv_connector_extra_config":{"backends":["UCX"]}}'
env:
- name: VLLM_NIXL_SIDE_CHANNEL_HOST # 자기 Pod IP 를 알려준다
valueFrom:
fieldRef: {fieldPath: status.podIP}
- name: VLLM_HTTP_TIMEOUT_KEEP_ALIVE # 사이드카 idle timeout(90s)보다 길게
value: "120"
resources:
limits: {nvidia.com/gpu: "1"}spec:
replicas: 2
template:
spec:
containers:
- name: modelserver
args:
- "--tensor-parallel-size=4"
- "--port=8200" # 8000 은 사이드카가 쓴다
resources:
limits: {nvidia.com/gpu: "4"}decode 파드에는 라우팅 사이드카가 함께 뜬다. initContainer로 선언하되 restartPolicy: Always를 주는 native sidecar 방식이다.
spec:
template:
spec:
initContainers:
- name: routing-proxy
args:
- --port=8000
- --kv-connector=nixlv2
restartPolicy: Always # native sidecar
ports:
- containerPort: 8000포트 배치를 보면 구조가 읽힌다. 사이드카가 8000을 차지하고 vLLM은 8200으로 물러나 있다. 외부에서 오는 요청은 전부 사이드카를 먼저 거치고, 사이드카가 prefill과 decode의 순서를 조율한 뒤 로컬 vLLM에 넘긴다.
VLLM_HTTP_TIMEOUT_KEEP_ALIVE: "120" 도 실전에서 나온 값이다. vLLM 기본 keep-alive가 5초인데 사이드카의 idle timeout은 90초라, 연결을 재사용할 때 TCP RST가 발생한다. 서버 쪽을 더 길게 잡아 막는다.
8. KV를 옮기는 방법 (NIXL)
prefill이 만든 KV 블록은 어떻게든 decode로 가야 한다. llm-d는 두 가지 전송 방식을 지원한다.
| 커넥터 | 전송 | 특징 |
|---|---|---|
| NixlConnector (기본) | UCX (RDMA 또는 TCP) | prefill과 decode의 TP가 달라도 된다 |
| MooncakeConnector | Mooncake Transfer Engine (RDMA) | prefill과 decode의 TP가 같아야 한다. InfiniBand 환경용 |
TCP로도 동작한다. 다만 공식 문서는 프로덕션에서는 고대역폭 네트워크(IB, RoCE, EFA)를 강하게 권한다. KV 블록은 작지 않고, 매 요청마다 옮겨야 한다.
주의할 점도 문서에 명시돼 있다. NixlConnector는 TP 비율의 방향에 따른 제약이 있고 prefill 파드가 재시작되면 상대 정보가 오래된 채로 남는(stale agent) 문제가 알려져 있다.
네트워크 정책도 챙겨야 한다. HTTP 8000·8200 외에 prefill ↔ decode 사이 TCP 5600(NIXL side channel) 이 열려 있어야 한다.
9. 운영에서 고려하는 부분
P/D는 두 풀이 독립적으로 스케일되고 독립적으로 고장난다. 그래서 지표를 하나로 합쳐 보면 원인을 못 찾는다.
| 신호 | 왜 보나 |
|---|---|
vllm:num_requests_running{pod=~".*prefill.*"} | prefill 포화 = 프롬프트가 decode 시작 전부터 밀린다 → TTFT 상승 |
vllm:kv_cache_usage_perc{pod=~".*decode.*"} | decode는 생성 내내 KV를 쥔다. 0.9를 넘으면 보통 여기가 진짜 병목 |
llm_d_epp_pd_decision_total | EPP가 실제로 P/D를 나누고 있는지. 0으로 수렴하면 통합 서빙으로 되돌아간 것 |
vllm:time_to_first_token_seconds vs inter_token_latency_seconds | TTFT가 나쁘면 prefill 또는 KV 전송, ITL이 나쁘면 decode |
실패 패턴은 세 가지로 요약된다.
TTFT만 나쁘고 decode는 멀쩡하다 → prefill 포화이거나 KV 전송 지연이다. prefill이 한가한데도 TTFT가 높으면 NIXL 전송을 의심한다.
ITL만 나쁘고 prefill은 멀쩡하다 → decode가 병목이다. KV 캐시 사용률이 1.0에 붙어 있으면 replica를 늘리거나 TP를 키운다.
둘 다 한가한데 느리다 → 모델 서버가 아니라 라우팅 문제다. P/D 결정 비율과 EPP 스케줄러 지연을 먼저 본다.
10. GPU 1장으로 흉내내보기
앞선 실습에 쓴 장비는 RTX 3050 6GB 한 장이다. 가이드 기준 구성은 prefill 8장 + decode 8장으로 총 16장이고 모델도 gpt-oss-120b라 그대로는 올라가지 않는다.
한 장을 논리적으로 두 장처럼 보이게 한다
prefill과 decode는 각각 nvidia.com/gpu: 1을 요구한다. 물리 GPU가 하나이므로 Kubernetes에 GPU가 두 개인 것처럼 보이게 해야 한다.
NVIDIA device plugin의 time-slicing이 가장 간단하다. ConfigMap 하나로 끝나고 스케줄러를 건드리지 않는다.
version: v1
flags:
migStrategy: none
nvidiaDriverRoot: "/"
plugin:
deviceListStrategy:
- cdi-cri
sharing:
timeSlicing:
resources:
- name: nvidia.com/gpu
replicas: 2 # 물리 1장을 논리 2장으로 광고$ kubectl get node gpu-lab-control-plane -o jsonpath='{.status.allocatable.nvidia\.com/gpu}'
2HAMi로도 같은 일을 할 수 있고 메모리 격리까지 얹어준다. 다만 스케줄러를 교체해야 하고 CUDA 호출을 가로채는 계층이 NIXL과 어떻게 얽힐지 미지수라, 변수를 하나씩 넣는 쪽을 택했다.
모델과 메모리를 조인다
6GB 중 데스크톱이 273 MiB를 상주로 쓰므로 실제 가용은 약 5,870 MiB다. 둘로 나누면 한쪽에 약 2,900 MiB다.
args:
- "Qwen/Qwen3-0.6B"
- "--tensor-parallel-size=1"
- "--max-model-len=1024" # 2048 → 1024 로 KV 요구량을 줄인다
- "--gpu-memory-utilization=0.30" # 한 장을 둘이 나눠 쓴다
- "--enforce-eager" # CUDA 그래프 캡처 메모리를 아낀다
- "--kv-transfer-config"
- '{"kv_connector":"NixlConnector","kv_role":"kv_both",
"kv_buffer_device":"cuda","kv_connector_extra_config":{"backends":["UCX"]}}'실제 점유는 프로세스당 2,636 MiB, 합계 5,272 MiB로 예산 안에 들어왔다.
time-slicing에는 메모리 격리가 없다는 점이 중요하다. 두 파드가 똑같이 6GB를 본다고 착각하므로, --gpu-memory-utilization을 손으로 잡지 않으면 한쪽이 다른 쪽을 OOM으로 밀어낸다.
실제로 갈라졌는지 확인한다
로그 세 줄이면 충분하다.
INFO: 10.244.0.27:47198 - "POST /v1/completions HTTP/1.1" 200 OK10.244.0.27은 decode 파드의 사이드카다. 클라이언트가 아니라 decode 쪽에서 prefill을 호출했다는 뜻이고, 5절의 순서도 그대로다.
NIXL compatibility check passed (hash: f0f99e93...)
Transfer plan: TransferTopology(tp_ratio=1, num_kv_heads=8, local_tp=1,
remote_tp=1, remote_block_len=65536)remote_tp=1과 remote_block_len이 찍혔다는 것은 원격 워커와의 KV 전송 계획이 실제로 수립됐다는 뜻이다.
Proxy configuration: {"Port":"8000","KVConnector":"nixlv2",
"DecoderURL":"http://localhost:8200"}7절에서 본 포트 배치가 그대로 확인된다.
그런데 2배 느리다
같은 프롬프트로 10회를 재고, 통합 서빙일 때의 값과 비교했다.
| 구성 | TTFT mean | E2E mean |
|---|---|---|
| 통합 서빙 (EPP 경유, 파드 1개) | 34.8 ms | 930 ms |
| P/D 분리 (논리 2장) | 68.9 ms | 1,025 ms |
약 2배다. 이유는 처음부터 예상된 것이다.
time-slicing도 HAMi도 SM을 공간적으로 쪼개지 않는다. 두 파드는 같은 연산 유닛을 번갈아 쓴다. 물리적으로 SM을 분할하는 MIG는 A100·H100급 기능이고 3050에는 없다.
즉 P/D를 나누는 본래 이유인 “prefill이 decode를 막지 않게 한다"가 성립하지 않는다. 간섭은 그대로인데 KV를 옮기는 단계와 홉만 늘었으니 느려지는 게 당연하다.
그래서 이 실험으로 확인되는 것은 효과가 아니라 메커니즘이다. 라벨로 역할이 갈리고, EPP가 프로필 두 개를 돌리고, 요청이 사이드카를 거쳐 prefill을 먼저 들르고, KV가 NIXL로 넘어온다 — 여기까지는 GPU 한 장에서도 전부 관찰된다.
트러블슈팅
CPU가 먼저 바닥났다. EPP 파드 하나가 CPU 8코어를 요청한다(Envoy --concurrency 8). 12코어 노드에서는 EPP 두 개가 공존하지 못해, 기존 릴리스를 내려야 했다.
11. 정리
P/D 분리는 성격이 다른 두 작업을 떼어놓는 것이다. prefill은 연산에 묶이고 decode는 메모리 대역폭에 묶인다. 같이 두면 긴 prefill이 decode를 끊는다.
이득은 간섭 제거와 전문화다. prefill은 replica를 늘리고 decode는 병렬을 넓히는 비대칭 배치가 가능해지고, decode 쪽 KV 캐시 여유가 늘어난다.
대신 만능이 아니다. 중대형 모델과 긴 입력(10:1 수준)이 전제이며, 짧은 요청에서는 KV를 네트워크로 옮기는 비용만 남는다.
llm-d에서는 라벨 두 개와 프로필 두 개로 표현된다. llm-d.ai/role로 역할을 나누고, disagg-profile-handler가 prefill용·decode용 스코어링을 각각 돌린다. 여기에 prefix-cache aware 라우팅이 그대로 얹힌다.
운영은 두 풀을 짝으로 본다. TTFT가 나쁘면 prefill과 KV 전송, ITL이 나쁘면 decode, 둘 다 한가한데 느리면 라우팅이다.
GPU 한 장으로도 구조는 확인된다. 다만 이점은 확인되지 않는다. time-slicing으로 논리 2장을 만들면 라벨 분리부터 NIXL 전송까지 전부 동작하지만, SM을 공유하는 한 TTFT는 오히려 2배가 된다. 이점을 재려면 물리적으로 다른 GPU가 필요하다.