KV 인식 라우팅과 분리 서빙: 요청을 어디로 보낼 것인가
한 줄 요약
서버가 여러 대일 때 요청을 아무 서버에나 보내면 이미 계산해 둔 KV 캐시를 버리게 된다. NVIDIA Dynamo 의 KV 인식 라우터는 요청의 앞부분이 가장 많이 겹치는 워커로 보내고, 이 측정에서는 같은 접두사를 다시 보낸 60번이 거의 모두 캐시에 적중했다(이벤트 방식은 여섯 번 모두 60번, 근사 방식은 60, 53, 60번이었고 라운드 로빈은 35–43번). 반대로 프리필과 디코드를 다른 프로세스로 나누는 분리 서빙은 같은 GPU 한 장에서 첫 토큰이 4.7–7.2배 느려졌다. KV 를 옮기는 값을 치르기 때문이다.
왜 이게 필요했나
LLM 서버는 같은 앞부분(접두사)을 가진 요청의 KV 캐시를 재사용한다. 시스템 프롬프트나 긴 문서를 앞에 붙이고 질문만 바꾸는 요청이라면 앞부분의 계산을 건너뛸 수 있다. 문제는 서버가 한 대가 아닐 때다. 워커가 둘이고 요청을 번갈아(라운드 로빈) 보내면, 어제 워커 0 이 계산해 둔 문서가 오늘은 워커 1 로 가서 다시 계산된다. 캐시는 워커 안에 있고, 요청을 나누는 쪽은 그것을 모르기 때문이다.
두 번째 질문은 일의 성격이 다른 두 단계를 한 프로세스에 둘 것인가다. 프롬프트를 읽는 프리필은 계산이 몰리고, 토큰을 하나씩 내는 디코드는 메모리를 읽는 일이 많다. 둘을 같은 GPU 에서 섞으면 긴 프리필이 들어올 때 다른 요청의 토큰 간격이 늘어난다. 그래서 프리필 전용 워커와 디코드 전용 워커를 따로 두고 KV 를 옮기는 분리 서빙이 나왔다. 이 모듈은 두 질문을 직접 만든 시뮬레이터와 실측으로 본다.
어떻게 동작하나
Dynamo 는 요청을 받는 프런트엔드(HTTP, 토큰화, 라우팅)와 모델을 돌리는 워커를 나눈다. 프런트엔드는 워커를 발견(discovery)으로 찾는다. 쿠버네티스에서는 쿠버네티스 자체를 쓰고, 이 측정에서는 파일 하나로 대신했다.
라우터의 아이디어는 세 줄이다.
- 프롬프트를 일정한 크기(이 실습은 16 토큰)의 블록으로 나누고, 블록마다 앞에서부터 그 블록까지 전체를 가리키는 이름(해시)을 붙인다. 앞 블록이 다르면 같은 내용의 블록도 다른 이름이 된다.
- 워커마다 어떤 블록 이름을 가지고 있는지 색인을 둔다. 요청의 블록을 앞에서부터 세어 이어서 가진 만큼이 그 워커의 겹침이다.
- 겹침이 가장 큰 워커로 보낸다. 비기면 일이 적은 쪽이다.
색인을 채우는 방법이 둘이다. 하나는 라우터가 자기가 보낸 곳을 기억하는 근사 방식이다. 다른 하나는 워커가 KV 이벤트로 "이 블록을 가지고 있다" 고 알려 주는 방식이다. 두 번째가 정확하지만 워커가 이벤트를 내보내야 한다. 라우터를 이벤트 모드로 켰는데 워커가 이벤트를 내보내지 않으면 색인이 비어 있고, 그 순간 라우터는 로그에 경고를 하나 남기고 조용히 부하 분산만 하는 라우터가 된다. 실습의 5단계가 이 함정을 직접 재현한다.
분리 서빙에서는 프리필 워커가 계산한 KV 를 디코드 워커가 NIXL(전송 라이브러리)로 받는다. 그래서 첫 토큰이 나오기 전에 KV 를 옮기는 시간이 더해진다.
실측 결과
환경은 8GB급 GPU 한 장, 모델은 Qwen3-0.6B, NVIDIA Dynamo 1.6.0.dev20261006(개발 빌드), vLLM 0.30.0, NIXL 1.3.2 이고, 요청은 모두 한 번에 하나씩 보냈다(분리 서빙 표만 동시성 4 칸이 있다). 원자료와 스크립트는 docs/experiments/2026-10-07-nvidia-dynamo/ 에 있다.
프런트엔드를 거치는 비용. 같은 모델을 vLLM 만 띄운 경우와 Dynamo 프런트엔드 뒤에 둔 경우를 입력 512 토큰, 출력 128 토큰으로 세 번씩 비교했다. 동시성 1 에서 첫 토큰 시간의 가운데 값이 29–39 ms 에서 42–43 ms 로 약 13 ms 늘었다. 동시성 8 과 32 에서는 130 ms 대 129–141 ms, 272 ms 대 272–274 ms 로 같았고 처리량도 같았다(초당 1,507 대 1,509 토큰, 동시성 32). 프런트엔드의 비용은 서버가 한가할 때 보이는 고정 비용이고, 바쁘면 묻힌다.
라우터. 워커 둘(각각 KV 자리 약 14,800 토큰), 접두사 12종을 각 6번씩 섞어 보냈다(요청 72개, 접두사를 두 번째 이후로 보낸 재요청 60개, 접두사는 약 1,500 단어). 재요청 중 첫 토큰 시간이 처음 보낸 요청의 60% 미만이면 캐시에 적중한 것으로 보았다(적중하면 약 15 ms, 아니면 약 80 ms 였다).
| 라우팅 | 재요청 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번이었다.
KV 인식 라우팅은 두 방식 모두 거의 모든 재요청을 적중시켰고, 이벤트를 내보내지 않는 구성은 무작위와 구별되지 않았다. 마지막 두 줄이 5단계의 교훈이다. 같은 --router-mode kv 인데 워커 쪽 설정 하나로 결과가 갈렸고, 어느 쪽도 오류를 내지 않았다. 프런트엔드 로그에는 경고 한 줄(kv_event_publisher_disabled, 이 역할이 캐시 인식 라우팅에는 KV 이벤트가 필요한데 발행이 꺼져 있다는 뜻)이 있었고, 이벤트를 내보내는 워커로 바꾼 실행에는 없었다.
실습의 시뮬레이터는 이 표를 방향까지 같게 만든다(KV 인식 60 중 60, 라운드 로빈과 무작위는 그보다 적음). 다만 숫자는 다르다. 시뮬레이터의 라운드 로빈은 60 중 49 인데 실측은 35–43 이다. 시뮬레이터는 워커가 한 번 처리한 접두사를 영원히 기억하지만, 실제 워커의 KV 자리는 약 14,800 토큰이라 접두사 12종(합계 약 18,000 토큰)을 다 담지 못하고 오래된 것부터 밀려난다. KV 인식 라우팅은 접두사를 워커에 나눠 두어 각 워커가 접두사 6종(약 9,000 토큰)만 담으므로 밀려나지 않았다. 라우팅이 적중을 높이는 이유가 하나 더 있다는 뜻이고, 이 분량은 시뮬레이터가 그리지 못한다.
분리 서빙. 같은 GPU 한 장에 프리필 워커와 디코드 워커를 프로세스 둘로 올리고(각각 GPU 메모리의 38%), 한 프로세스가 둘 다 하는 구성과 같은 부하로 견주었다. 출력은 64 토큰이고 값은 반복 세 번의 가운데 값이다.
| 입력 | 동시성 | 한 프로세스(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 |
분리하면 모든 칸에서 첫 토큰이 4.7–7.2배 느렸다. 워커의 KV 전송 지표로는 요청 하나를 옮기는 데 평균 196 ms(동시성 1, 전송당 119.3 MB, 처리량 608 MB/s)였고, 동시성 4 에서 입력이 3,000 이면 평균 711–1,723 ms 였다. 이 환경에서 분리 서빙이 얻을 것은 없었다. 두 워커가 한 GPU 를 나눠 쓰니 계산 자원이 늘지 않는데 KV 이동 비용이 더해졌고, 전송이 같은 장치 안에서 이루어졌는데도 608 MB/s 에 그쳤다.
전송이 느린 이유는 확정하지 못했다. 전송 방식을 바꿔 보았다. UCX 가 쓸 전송을 TCP 계열로 제한한 설정은 처리량이 621 MB/s, 디버그 로그를 켠 기본 설정은 615 MB/s 로 기본 설정(608 MB/s)과 같았고, TCP 와 같은 장치 안의 IPC 전송(cuda_ipc)을 함께 허용해도 첫 토큰이 같았다. IPC 전송만 허용하면 NIXL 의 UCX 백엔드가 "능동 메시지를 보낼 전송이 없다" 는 오류로 엔진을 시작하지 못했다. 그러니 전송 방식을 고르는 것으로는 이 속도가 바뀌지 않았고, 병목이 어디인지는 미실측 이다.
이 숫자를 어디까지 믿을 것인가
- 이 환경에서는 분리 서빙의 이득을 재지 못했다. 프리필 워커와 디코드 워커가 GPU 를 나눠 쓰므로 분리의 이점(서로 다른 장치에서 서로의 지연을 간섭하지 않음)이 나올 수 없는 구성이다. "분리 서빙이 느리다" 가 아니라 "한 장에서는 값만 치른다" 로 읽어야 한다.
- 프롬프트의 토큰 수를 따로 세지 않았다. 입력 1,500 은 단어 수이고, 전송당 119.3 MB 는 토큰 하나당 KV 114,688 바이트(층 28 x 헤드 8 x 128 x 2 x 2 바이트)로 나누면 약 1,040 토큰에 해당해 단어 수와 정확히 맞지 않는다.
- 라우터 시험은 동시성 1 이다. 요청이 겹치면 라우터는 겹침 점수에 부하를 더해 고르는데, 이 실습의 규칙(겹침 우선, 비기면 부하)은 그것을 단순화한 것이다.
- 모델이 0.6B 로 작고 접두사가 합성(난수 낱말)이다. 첫 토큰 80 ms 와 15 ms 의 격차는 이 모델과 GPU 의 값이다.
- 반복은 칸마다 세 번이고 표준편차는 구하지 않았다. 방향은 확실해도 소수점 이하는 믿지 않는 편이 좋다.
- 개발 빌드(1.6.0.dev)로 쟀고, 플래그와 기본값이 바뀔 수 있다.
현장에서 만나는 모습
KV 인식 라우팅은 접두사가 길고 자주 다시 쓰이는 서비스(시스템 프롬프트, 긴 문서 질의, 에이전트의 반복 맥락)에서 이득이 크다. 라우터가 이벤트 모드일 때는 워커가 이벤트를 내보내는지부터 확인한다. 이 측정에서는 확인하지 않으면 적중률이 무작위 수준으로 내려가도 겉으로는 정상이었다. 분리 서빙은 GPU 가 둘 이상이고 KV 를 옮기는 연결(NVLink 나 RDMA)이 충분히 빠를 때 검토할 대상이다. 7단계의 계산이 보여 주듯, 연결이 느리면 전송이 프리필 자체보다 오래 걸린다.
다음 실습에서 할 것
GPU 없이 순수 파이썬으로 이 흐름을 만든다. 프롬프트를 블록 이름으로 나누고, 앞에서부터 이어서 겹치는 블록을 세고, 가장 많이 겹치는 워커로 보내는 라우터를 만든다. 라운드 로빈, 무작위, KV 인식 세 전략의 적중률을 비교한 뒤, 워커가 이벤트를 알리지 않으면 라우터가 무엇으로 되돌아가는지 확인한다. 그다음 KV 의 크기와 전송 시간을 계산하는 함수를 만들어 대역폭에 따라 달라지는 비용을 보고, 마지막으로 위 분리 서빙 표의 원본인 측정 파일에서 배율을 직접 계산한다.