상태: 초안 (2026-10-07). 이 글의 수치는 모두 필자가 직접 잰 값입니다. 환경은 8GB급 GPU 한 장, 0.6B 모델, Dynamo 1.6.0.dev20261006(개발 빌드)입니다. 6편이 읽은 문서는 v1.5.0 이라서 플래그와 기본값이 다를 수 있습니다. GPU 가 둘 이상인 환경과 RDMA 연결은 미실측 입니다. 작성 규칙: 실측 데이터가 기본이고, 못 잰 것은 「미실측」으로 표시합니다.
목차
- 1. 환경과 방법
- 2. 프런트엔드를 거치는 비용
- 3. KV 인식 라우팅
- 4. 한 장 위의 prefill/decode 분리
- 5. 겪은 일
- 6. 한계와 미실측
- 7. 운영에 가져갈 점검 목록
- 참고 자료
6편은 Dynamo 를 문서로 읽고 정리한 글이었습니다. 문서가 말하는 것이 실제로 그런지 확인하려고 GPU 한 장짜리 환경에 직접 설치해 보았습니다. 이 글은 네 가지를 답합니다.
- Dynamo 프런트엔드를 앞에 두면 요청마다 얼마나 늘어나는가.
- KV 인식 라우팅은 같은 접두사를 다시 보낸 요청을 얼마나 같은 워커로 보내는가, 라운드 로빈과 얼마나 다른가.
- 라우터가 KV 이벤트를 받지 못하면 어떻게 되는가.
- prefill 과 decode 를 프로세스 둘로 나누면 한 장 위에서 첫 토큰이 어떻게 달라지는가.
짧은 답. 프런트엔드의 비용은 서버가 한가할 때 약 13 ms 이고 바쁘면 보이지 않았습니다. KV 인식 라우팅은 같은 접두사를 다시 보낸 60번 가운데 53~60번을 같은 워커로 보냈고 라운드 로빈은 35~43번이었습니다. 라우터를 이벤트 모드로 켰는데 워커가 이벤트를 내보내지 않으면 적중이 무작위와 같은 수준으로 내려가는데도 오류는 없었고 로그에 경고 한 줄만 남았습니다. 한 장 위의 분리 서빙은 모든 조건에서 첫 토큰이 4.7~7.2배 느렸습니다. 두 워커가 같은 GPU 를 나눠 쓰는데 KV 를 옮기는 값이 더해졌기 때문이고, 이것은 분리 서빙의 이득을 잰 것이 아닙니다.
1. 환경과 방법
- GPU: 8GB급 한 장. 모델: Qwen3-0.6B(층 28, KV 헤드 8, 헤드 크기 128, bfloat16, 토큰당 KV 114,688 바이트).
- 소프트웨어: ai-dynamo 1.6.0.dev20261006(pip 개발 빌드), vLLM 0.30.0, NIXL 1.3.2.
- 쿠버네티스 없이 파이썬 가상환경 하나에서 돌렸고, 워커 발견은
--discovery-backend file(공유 파일 하나)로 대신했습니다. 요청 평면은 TCP 기본값입니다. - 워커마다
--gpu-memory-utilization 0.38, 최대 길이 4,096, 즉시 실행(--enforce-eager)입니다. 워커 둘이 GPU 한 장을 나눠 쓰므로 한 워커가 쓸 수 있는 KV 자리는 14,816 토큰이었습니다. - 측정 클라이언트는 직접 만든 것이고 요청은 한 번에 하나씩 보냈습니다(분리 서빙 표에만 동시 4 가 있습니다). 스크립트와 원자료는 저장소의
docs/experiments/2026-10-07-nvidia-dynamo/에 있습니다.
2. 프런트엔드를 거치는 비용
같은 모델을 vLLM 만 띄운 경우(solo)와 Dynamo 프런트엔드 뒤에 둔 경우(dyn)를 입력 512 토큰, 출력 128 토큰으로 세 번씩 비교했습니다. 값은 첫 토큰 시간(TTFT)의 가운데 값입니다.
| 동시 요청 | vLLM 단독 | Dynamo 뒤 |
|---|---|---|
| 1 | 29~39 ms | 42~43 ms |
| 8 | 129~130 ms | 129~141 ms |
| 32 | 272~274 ms | 272~274 ms |
동시성 1 에서 가운데 값은 30 ms 에서 43 ms 로 약 13 ms 늘었습니다. 동시성 8 과 32 에서는 구별되지 않았고 처리량도 같았습니다(동시성 32 에서 초당 1,507 대 1,509 토큰). 프런트엔드(HTTP, 토큰화, 라우팅 결정)의 비용은 서버가 한가할 때만 보이는 고정 비용이고 바쁘면 묻혔습니다. 이 모델은 토큰이 아주 빨라서 13 ms 가 보이는 것이고, 큰 모델이면 상대 비중은 더 작아질 것으로 예상하지만 미실측 입니다.
3. KV 인식 라우팅
3.1 설정
워커 둘을 같은 모델로 띄우고 프런트엔드의 --router-mode 만 바꿨습니다.
| 이름 | 프런트엔드 옵션 | 워커 쪽 |
|---|---|---|
| 라운드 로빈 | --router-mode round-robin |
KV 이벤트 끔 |
| 무작위 | --router-mode random |
KV 이벤트 끔 |
| KV 인식, 근사 | --router-mode kv --no-router-kv-events |
KV 이벤트 끔 |
| KV 인식, 이벤트 모드인데 이벤트 없음 | --router-mode kv |
KV 이벤트 끔 |
| KV 인식, 이벤트 모드 | --router-mode kv |
KV 이벤트 켬(ZMQ 로 발행) |
부하는 접두사(약 1,500 단어, 합성)를 12종 만들어 각각 6번씩 섞어 보내고 매번 꼬리 24 단어를 바꿨습니다. 요청은 72개이고 접두사를 두 번째 이후로 보낸 재요청이 60개입니다. 재요청의 첫 토큰이 처음 보낸 요청(약 80 ms)의 60% 미만이면 캐시에 적중한 것으로 셌습니다. 적중하면 약 15 ms, 놓치면 약 80 ms 였습니다.
3.2 결과
| 라우팅 | 재요청 60번 중 적중(세 번) | 재요청 첫 토큰 평균 |
|---|---|---|
| 라운드 로빈 | 35, 40, 43 | 34~42 ms |
| 무작위 | 44, 31, 45 | 32~46 ms |
| KV 인식, 근사 | 60, 53, 60 | 15~26 ms |
| KV 인식, 이벤트 모드인데 이벤트 없음 | 39, 44, 41 | 32~38 ms |
| KV 인식, 이벤트 모드 | 60, 60, 60 | 15 ms |
같은 구성을 앞서 한 번 더 돌린 결과(라운드 로빈 35, 40, 43, 무작위 37, 37, 41, 근사 60, 60, 60, 이벤트 없음 41, 44, 37, 이벤트 모드 60, 60, 60)도 방향이 같았습니다. 근사 방식은 첫 실행에서 세 번 모두 60번이었는데 재실행에서 한 번 53번이 나왔으므로, 스스로 기억하는 방식이 이벤트 방식보다 조금 덜 안정할 수 있다고 보지만 확정하지는 못합니다. 이벤트 방식은 여섯 번이 모두 60번이었습니다.
3.3 조용한 실패
표의 네 번째 줄이 이 실험에서 가장 중요한 결과입니다. --router-mode kv 로 이벤트 모드를 켰는데 워커가 KV 이벤트를 내보내지 않으면 라우터의 색인이 영원히 비어 있고, 모든 요청이 겹침 0 이 되어 부하만으로 워커를 고르게 됩니다. 적중은 무작위 수준(39, 44, 41)으로 내려갔는데 서버는 정상으로 응답했고 오류도 없었습니다. 프런트엔드 로그에 아래 경고가 한 줄 있었고, 이벤트를 내보내는 워커로 바꾼 실행에는 없었습니다.
WARN ... KV-event publishing is disabled for a role that requires KV events
diagnostic_code="kv_event_publisher_disabled" ... requirement="cache_aware_routing"
적중률이 무작위와 같다는 것을 알아채려면 로그의 경고를 보거나, 같은 접두사를 다시 보냈을 때 첫 토큰이 줄어드는지를 따로 재야 합니다. 라우터를 켜기만 하고 적중을 재지 않으면 이 상태로 운영될 수 있습니다.
3.4 시뮬레이터와 실측의 차이
이 라우터의 규칙을 순수 파이썬으로 만들어 같은 부하를 돌려 보면(앞에서부터 이어서 겹치는 블록이 가장 많은 워커, 비기면 일이 적은 워커) KV 인식은 60 중 60 을 맞히고 이벤트를 알리지 않으면 라운드 로빈과 같은 값이 나옵니다. 방향은 실측과 같습니다. 다만 시뮬레이터의 라운드 로빈은 60 중 49 로 실측(35~43)보다 높습니다. 시뮬레이터의 워커는 한 번 처리한 접두사를 영원히 기억하지만 실제 워커는 KV 자리가 14,816 토큰이라 접두사 12종(약 18,000 토큰)을 다 담지 못하고 오래된 것부터 밀려납니다. KV 인식 라우팅은 접두사를 워커에 나눠 각 워커가 6종(약 9,000 토큰)만 담으므로 밀려나지 않습니다. 접두사 라우팅의 이득에는 "같은 곳으로 보낸다" 외에 "각 워커의 작업 집합이 작아진다" 가 더해진다는 뜻입니다.
4. 한 장 위의 prefill/decode 분리
4.1 설정
프리필 전용 워커와 디코드 전용 워커를 프로세스 둘로 같은 GPU 에 올리고(각각 GPU 메모리의 38%, KV 전송은 NIXL), 한 프로세스가 둘 다 하는 구성과 같은 부하로 비교했습니다. 출력은 64 토큰이고 값은 반복 세 번의 TTFT 가운데 값입니다.
4.2 결과
| 입력(단어) | 동시 요청 | 한 프로세스(ms) | 분리(ms) | 배율 |
|---|---|---|---|---|
| 1,500 | 1 | 78 | 406 | 5.2 |
| 1,500 | 4 | 190 | 1,359 | 7.2 |
| 3,000 | 1 | 171 | 812 | 4.7 |
| 3,000 | 4 | 398 | 2,442 | 6.1 |
모든 칸에서 분리가 느렸습니다. 워커의 KV 전송 지표로 보면 동시성 1, 입력 1,500 단어에서 요청 하나당 평균 196 ms(전송당 119.3 MB, 처리량 608 MB/s)가 걸렸고, 동시성 4 에 입력 3,000 단어이면 평균 711~1,723 ms 였습니다. 첫 토큰이 한 프로세스보다 약 330 ms 늦은 동시성 1 의 경우, 전송 196 ms 가 그 가운데 큰 부분입니다. 나머지 약 130 ms 가 무엇인지는 분해해서 재지 않았습니다(미실측).
4.3 이 결과를 읽는 법
이것은 "분리 서빙이 느리다" 가 아닙니다. 두 워커가 같은 GPU 한 장을 나눠 쓰므로 계산 자원이 늘지 않았고, 분리가 해결하려는 문제(긴 prefill 이 다른 요청의 decode 를 방해하는 것)를 풀 장치가 하나뿐인 구성입니다. 거기에 KV 를 옮기는 비용만 더해졌습니다. GPU 가 둘 이상이고 NVLink 나 RDMA 로 연결된 환경에서 이득이 나는지는 이 글이 재지 못했습니다(미실측).
토큰당 KV 가 114,688 바이트이므로 1,500 토큰은 약 164 MiB 입니다. 6편이 인용한 문서의 경고(느린 경로로 KV 를 옮기면 TTFT 가 약 98초까지 늘 수 있다)와 같은 방향의 결과입니다. 다만 그 98초는 다른 모델, 다른 입력 길이, 다른 연결의 값이라 이 실험과 크기를 견줄 수는 없습니다. 608 MB/s 로 1,500 토큰을 옮기면 약 280 ms 이고, 서버 사이 TCP 처럼 0.1 GB/s 대이면 1.5 초가 넘습니다.
4.4 KV 전송이 왜 느렸나
원인은 확정하지 못했습니다. 전송 방식을 바꿔 보았고, 결과는 아래와 같습니다.
| UCX 설정 | 결과 |
|---|---|
| 기본(제한 없음) | 전송 처리량 608 MB/s, 첫 토큰 379~384 ms |
| 디버그 로그를 켠 기본 | 615 MB/s, 첫 토큰 387~389 ms |
TCP 계열로 제한(UCX_TLS=tcp,cuda_copy,sm,self) |
621 MB/s, 첫 토큰 381~384 ms |
| TCP 와 장치 안 IPC 를 함께 허용 | 첫 토큰 383~384 ms(처리량 줄은 로그에 남지 않음) |
장치 안 IPC 만 허용(UCX_TLS=cuda_ipc,cuda_copy,sm,self) |
엔진이 시작하지 못함 |
IPC 만 허용했을 때의 오류는 no active messages transport ... cuda_ipc/cuda - no am bcopy 와 Destination is unreachable 였습니다. NIXL 의 UCX 백엔드가 초기화에 능동 메시지(active message)를 보낼 수 있는 전송을 요구하는데 IPC 전송은 그것을 제공하지 않는 것으로 읽힙니다. 따라서 장치 안 전송을 쓰고 싶어서 UCX_TLS 를 IPC 로만 좁히는 설정은 이 조합에서 쓸 수 없습니다. 기본 설정에서 UCX 가 만든 인터페이스는 tcp/eth0, tcp/lo, cuda_ipc/cuda 였습니다.
전송 방식을 바꿔도 속도가 달라지지 않았으므로, 병목이 UCX 의 전송 선택이 아닐 수 있습니다. 어디가 병목인지(요청 단위 직렬화, 블록 단위 전송 기술자 28개, 복사 경로 등)는 분해해서 재지 않았습니다(미실측).
5. 겪은 일
- 메모리 한도로 프로세스가 죽었다. 워커 둘과 프런트엔드를 번갈아 띄우다가 메모리 감시에 걸려 엔진 코어 프로세스가 남은 채 다음 실험이 시작되었습니다. 매 실행 전에
pkill -9 EngineCore로 정리하도록 스크립트에 넣었습니다. - 첫 라우터 재실험이 무효였다. 둘째 워커가 모델 다운로드 잠금(
.lock)을 잡지 못해 시작하지 못했는데, 스크립트는 둘이 떴다고 가정하고 끝까지 돌았습니다. 세 전략이 모두 워커 한 대에 대고 돌았고 그 결과는 의미가 없었습니다. 워커가 둘 모두 KV 자리를 잡았는지 확인한 뒤에만 시험을 시작하고, 시작 전에 모델을 한 번 내려받아 두도록 고쳤습니다. 무효였던 로그는 그대로 남겼습니다. - UCX 전송 방식을 좁힌 첫 시도가 무효였다. 프런트엔드가 워커를 발견하지 못한 채 요청을 보내 측정이 의미가 없었고 스크립트를 고쳐 다시 돌렸습니다.
- 다른 서버로 옮기려 했으나 드라이버가 맞지 않았다. 드라이버가 570(CUDA 12.8)인 서버에서 vLLM 0.30.0 이
Error 804: forward compatibility was attempted on non supported HW로 엔진을 시작하지 못했습니다. 595 드라이버의 서버에서는 같은 설치가 그대로 동작했습니다. 나머지 서버의 GPU 는 운영 서비스가 쓰고 있어서 시험하지 않았습니다. 같은 가상환경 설치본을 서버마다 옮겨 쓰려면 드라이버가 설치본의 CUDA 빌드를 받아 주는지부터 확인해야 합니다. - 전송 방식을 좁혔더니 엔진이 시작하지 못했다. 4.4절의 IPC 전용 설정입니다. 오류 줄을 길게 찍도록 스크립트를 고쳐 원인을 읽었고, 전체 로그를 남겼습니다.
6. 한계와 미실측
- GPU 가 둘 이상인 환경, NVLink, RDMA, 서버 사이 KV 전송은 재지 않았습니다. 분리 서빙의 이득은 미실측 입니다. 다른 서버의 GPU 로 옮겨 재현하려 했지만 하지 못했습니다(5절). 파드 사이 TCP 처리량은 112 MB/s(1GbE 급)였으므로, 서버 사이에서 KV 를 옮긴다면 이 값이 상한입니다.
- 모델이 0.6B 로 작고 접두사는 합성(난수 낱말)입니다. 큰 모델과 실제 문서에서의 적중과 첫 토큰 격차는 다를 수 있습니다.
- 라우터 시험은 요청을 하나씩 보냈습니다(동시성 1). 요청이 겹칠 때 라우터가 부하와 겹침을 어떻게 저울질하는지는 재지 않았습니다.
- 입력 1,500 은 단어 수이고 토큰 수는 따로 세지 않았습니다. 전송당 119.3 MB 를 토큰당 KV 로 나누면 약 1,040 토큰이라 단어 수와 정확히 맞지 않습니다.
- 반복은 칸마다 세 번이고 표준편차를 구하지 않았습니다. 방향은 확실해도 소수점 이하는 믿지 않는 편이 좋습니다.
- 개발 빌드(1.6.0.dev)로 쟀고 6편이 읽은 v1.5.0 과 플래그, 기본값이 다를 수 있습니다.
7. 운영에 가져갈 점검 목록
- KV 인식 라우팅을 켰다면 워커가 KV 이벤트를 내보내는지 먼저 확인합니다. 프런트엔드 로그에
kv_event_publisher_disabled경고가 없는지 봅니다. - 라우팅을 바꾼 뒤에는 "같은 접두사를 다시 보냈을 때 첫 토큰이 줄어드는가" 를 직접 잽니다. 오류 없이 무작위 수준으로 내려갈 수 있습니다.
- 접두사가 한 워커의 KV 자리보다 많이 다양하면 라우팅이 워커 사이에 접두사를 나누는 효과가 더해집니다. 워커의 KV 자리(서버 로그의
GPU KV cache size)와 접두사 합계를 견주어 봅니다. - 분리 서빙은 GPU 가 둘 이상이고 KV 이동 경로(전송 처리량)를 따로 잰 뒤에 검토합니다. 토큰당 KV 크기(층 x KV 헤드 x 헤드 크기 x 2 x 바이트)에 입력 길이를 곱하면 한 요청이 옮길 바이트가 나오고, 그것을 실제 전송 처리량으로 나누면 첫 토큰에 더해질 시간입니다.
- 매 실행 전에 이전 프로세스(엔진 코어)가 남아 있지 않은지, 모델 다운로드 잠금이 남아 있지 않은지 확인합니다.
- 서버마다 NVIDIA 드라이버 버전이 다르면, 설치한 vLLM 과 torch 의 CUDA 빌드를 그 드라이버가 받아 주는지 먼저 확인합니다. 받아 주지 않으면 서버 로그가 아니라 엔진 시작 단계에서 CUDA 오류로 죽습니다.
참고 자료
- Dynamo 저장소와 문서: https://github.com/ai-dynamo/dynamo
- NIXL: https://github.com/ai-dynamo/nixl
- vLLM 의 분리 prefill 와 NIXL 커넥터: https://docs.vllm.ai/en/latest/features/disagg_prefill/ , https://docs.vllm.ai/en/latest/features/nixl_connector_usage/
- Qwen3-0.6B 설정(층, KV 헤드, 헤드 크기, 자료형): https://huggingface.co/Qwen/Qwen3-0.6B/raw/main/config.json
- 이 시리즈의 6편(분산 추론과 Dynamo, 문서 기준), 20편(측정 방법)