状态:草稿 (2026-10-07)。 本文的所有数值均由作者亲自实测。环境为:1张8GB级GPU、0.6B模型、Dynamo 1.6.0.dev20261006(开发版)。第6篇阅读的文档是v1.5.0,因此标志和默认值可能会有所不同。两台不同GPU服务器之间(1GbE级网络)的测试在4.5节进行,而NVLink和RDMA连接是 未实测 。 编写规则:以实测数据为基础,未能测出的标记为“未实测”。
目录
第6篇是通过阅读文档整理Dynamo的文章。为了验证文档所说的是否属实,我将其直接安装在了单GPU环境中。本文将回答以下四个问题。
- 在前面放置Dynamo前端后,每个请求会增加多少开销。
- KV感知路由将重复发送相同前缀的请求发送到同一工作进程(worker)的概率有多大,与轮询(Round-Robin)相比有何区别。
- 如果路由器收不到KV事件会发生什么。
- 如果将prefill和decode分为两个进程,单GPU上的首字延迟(TTFT)会有什么变化。
- 如果将其拆分到配备不同GPU的两台服务器上(1GbE级网络),会有什么变化。
简短的回答。 在服务器空闲时,前端的开销约为13 ms,而在繁忙时则不明显。在重复发送相同前缀的60次请求中,KV感知路由有53到60次被发送到同一工作进程,而轮询为35到43次。当开启路由器的事件模式但工作进程未发出事件时,即使命中率降至与随机选择相同的水平,也不会报错,日志中仅留下一条警告。在单GPU上进行分离推理时,在所有条件下首字延迟都慢了4.7到7.2倍。这是因为两个工作进程共享同一GPU,并且叠加了传输KV的开销,这并不代表分离推理带来的收益。如果将其拆分到不同GPU的两台服务器上,1,500个单词输入的首字延迟为1.7秒,3,000个单词为3.4到3.5秒,比独立推理(44到172 ms)增加了20多倍,增加的时间与KV字节数除以链路吞吐量(104 MB/s)的结果相符。
1. 环境与方法
- GPU:1张8GB级。模型:Qwen3-0.6B(层数28,KV头数8,头大小128,bfloat16,每个Token的KV大小为114,688字节)。
- 软件:ai-dynamo 1.6.0.dev20261006(pip开发版),vLLM 0.30.0,NIXL 1.3.2。
- 在没有Kubernetes的情况下,于单个Python虚拟环境中运行,工作进程的发现由
--discovery-backend file(单个共享文件)替代。请求平面使用TCP默认值。 - 每个工作进程拥有
--gpu-memory-utilization 0.38,最大长度4,096,立即执行(--enforce-eager)。由于两个工作进程共享一张GPU,因此单个工作进程可使用的KV槽位为14,816个Token。 - 在跨服务器测试(4.5节)中,额外使用了一台配备24GB消费级GPU的服务器。由于该GPU与其它服务进行分时复用,每个工作进程仅分配了0.18的GPU内存比例(约4.4GB),且驱动程序针对CUDA 12.8,因此单独安装了CUDA 12.9构建版本(第5节)。两台服务器Pod之间的TCP吞吐量为112~113 MB/s。
- 测试客户端为自研,请求逐个发送(仅分离推理表中包含并发4的情况)。脚本和原始数据位于仓库的
docs/experiments/2026-10-07-nvidia-dynamo/中。
2. 穿过前端的开销
对比了仅运行vLLM的模型(solo)和放在Dynamo前端背后的模型(dyn),输入512个Token,输出128个Token,各进行三次测试。数值为首字延迟(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个Token)。前端(HTTP、Token化、路由决策)的开销仅在服务器空闲时是可见的固定开销,繁忙时会被掩盖。由于该模型生成Token的速度非常快,因此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发布) |
负载制作了12种前缀(约1,500个单词,合成数据),每种混合发送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事件,路由器的索引将永远为空,所有请求的重叠度(overlap)变为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 模拟器与实测的差异
如果用纯Python编写该路由器的规则并运行相同的负载(选择从头开始连续重叠块最多的工作进程,若为空则选择工作量较少的工作进程),KV感知可以达到60次全中,而在不通知事件的情况下则会得出与轮询相同的值。趋势与实测结果一致。不过,模拟器中的轮询命中率为60分之49,高于实测值(35~43)。模拟器中的工作进程会永远记住处理过的前缀,但实际工作进程的KV槽位只有14,816个Token,无法容纳全部12种前缀(约18,000个Token),较早的会被挤出。KV感知路由将前缀分发给各个工作进程,使得每个工作进程只需容纳6种(约9,000个Token),因此不会被挤出。这意味着前缀路由的收益除了“发送到相同位置”之外,还叠加了“每个工作进程的工作集变小了”。
3.5 两台服务器前的路由器
将工作进程分别放在拥有不同GPU的两台服务器上(KV槽位分别为14,816个Token和27,088个Token),并通过服务器之间的网络发送相同的负载。60次重复请求中的命中次数分别为:轮询40、47、50,随机47、40、52,KV感知近似60、60、60,KV感知事件模式43、60、60。即使在两个GPU环境不同的情况下,KV感知路由也朝着相同的方向命中了。事件模式第一次迭代只有43次的原因并未深入解析(可能是因为在事件订阅建立之前的请求,但未能证实)。由于每台服务器的首次请求速度不同(44ms和80ms),这也带来了一定的局限性,即“首次请求的60%以下”这一命中判定标准在该测试中显得不够干净利落。
4. 单GPU上的prefill/decode分离
4.1 配置
将Prefill专用工作进程和Decode专用工作进程作为两个进程部署在同一个GPU上(分别占用38%的GPU内存,KV传输使用NIXL),并与单个进程完成两项任务的配置在相同负载下进行对比。输出为64个Token,数值为三次重复测试中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 如何解读这些结果
这并非“分离服务很慢”。由于两个 Worker 共用同一张 GPU,计算资源并未增加,且该配置中只有一个装置用来解决分离旨在解决的问题(长 prefill 干扰其他请求的 decode)。其中只凭空增加了迁移 KV 的成本。分为不同 GPU 的两台服务器的情况已在 4.5 节中进行过测量。本文未能测出在 NVLink 或 RDMA 连接的环境中是否存在收益(未实测).
由于每个 Token 的 KV 为 114,688 字节,因此 1,500 个 Token 约为 164 MiB。这与第 6 篇引用的文档中的警告(通过慢速路径迁移 KV 会将 TTFT 延长至约 98 秒)方向一致。不过,由于那 98 秒对应的是其他模型、其他输入长度以及其他连接的数值,因此无法与本实验的规模进行直接对比。以 608 MB/s 的速度迁移 1,500 个 Token 大约需要 280 ms,而如果是类似服务器间 TCP 的 0.1 GB/s 级别,则需要超过 1.5 秒。
4.4 为什么 KV 传输会很慢
未能确定原因。我们尝试更改了传输方式,结果如下。
| UCX 配置 | 结果 |
|---|---|
| 默认(无限制) | 传输吞吐量 608 MB/s,首个 Token 379~384 ms |
| 开启调试日志的默认配置 | 615 MB/s,首个 Token 387~389 ms |
限制为 TCP 系列(UCX_TLS=tcp,cuda_copy,sm,self) |
621 MB/s,首个 Token 381~384 ms |
| 同时允许 TCP 与设备内 IPC | 首个 Token 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 个块级传输描述符、复制路径等)(未实测).
4.5 拆分为两台服务器的分离服务(不同 GPU,1GbE 级别网络)
其中一台服务器是上述 8GB 级 GPU,另一台是 24GB 级消费级 GPU(与其它服务分时复用),Pod 间的 TCP 吞吐量为 113 MB/s。Worker 发现通过同一个 Pod 内的 etcd 进行,KV 传输使用 NIXL 默认(UCX)。采用相同的负载(输出 64,每个栏重复 3 次的首个 Token 中位数)。
| 配置 | 1,500 词并发 1 | 1,500 并发 4 | 3,000 并发 1 | 3,000 并发 4 |
|---|---|---|---|---|
| 8GB 级服务器单独运行 | 78ms | 190ms | 172ms | 421ms |
| 24GB 级服务器单独运行 | 44ms | 92ms | 83ms | 181ms |
| 8GB 级 prefill,24GB 级 decode | 1,752ms | 5,818ms | 3,533ms | 12,476ms |
| 24GB 级 prefill,8GB 级 decode | 1,721ms | 5,637ms | 3,407ms | 12,222ms |
- 拆分为两台服务器后,速度比单独服务慢了 20 多倍。 在并发 1 下,1,500 个词耗时从 78ms 增至 1.75 秒(相比 24GB 级单独运行的 44ms 慢了 40 倍),3,000 个词耗时从 172ms 增至 3.5 秒。
- 增加的时间完全可以由链路来解释。 单个 3,000 词请求的传输量为 351.75 MB,耗时 3,374 ms,带宽为 104 MB/s。由于每个 Token 114,688 字节乘以 3,000 个 Token 等于 344 MB,因此该请求的词数与 Token 数几乎为 1 比 1。将 prefill 的 83ms 加上 351.75 MB ÷ 104 MB/s(约 3.4 秒),结果约为 3.4 秒,与测得的 3.4~3.5 秒相符。1,500 个词的约 176 MB ÷ 104 MB/s 约为 1.7 秒,与测得的 1.72~1.75 秒相符。在这种配置下,分离服务的成本可以提前通过「KV 字节 ÷ 链路吞吐量」计算得出。
- 哪个 GPU 负责 prefill 几乎没有影响(1,752 对 1,721,3,533 对 3,407ms)。由于耗时受限于链路而非 GPU 计算,因此看不出 GPU 之间的速度差异。
- 在并发 4 下,四个传输任务均分了同一条链路。 单个传输的吞吐量下降至 26~28 MB/s(四者之和约为 112 MB/s),单个传输平均耗时 12.5~13.1 秒。当只有一条链路时,并发度越高,传输排队现象越严重,TTFT 几乎与请求数量成正比增加。
- 本测试使用的是 1GbE 级别链路、0.6B 模型(每个 Token 的 KV 为 114,688 字节且 prefill 仅需 80ms 的廉价情况),属于对分离服务极其不利的极端情况。如果链路快得多(如 RDMA 等)或者模型很大导致 prefill 成本极高,其收益可以通过相同的方式计算,但我们 未进行测量(未实测).
5. 遇到的问题
- 进程因内存超限而崩溃。 在交替启动两个 Worker 和 Frontend 时触发了内存监控,导致引擎核心进程残留的同时开始了下一轮实验。我们在脚本中加入了在每次运行前使用
pkill -9 EngineCore清理的逻辑。 - 第一次路由器重新测试无效。 由于第二个 Worker 未能获取模型下载锁(
.lock)而无法启动,但脚本假设两者均已启动并一路执行到底。三种策略都在一台 Worker 上运行,其结果毫无意义。我们将其修改为:仅在确认两个 Worker 都已成功分配 KV 位置后才开始测试,并在开始前预先下载一次模型。无效的日志则按原样保留。 - 缩小 UCX 传输方式的首次尝试无效。 由于 Frontend 在未发现 Worker 的情况下发送了请求,导致测量毫无意义,我们修改脚本后重新运行了测试。
- 试图迁移至其他 GPU 服务器,但驱动不匹配(分三层解决)。 在驱动版本为 570(CUDA 12.8)的服务器上,默认安装包(CUDA 13 构建版)的 vLLM 0.30.0 因
Error 804: forward compatibility was attempted on non supported HW而无法启动引擎。在 595 驱动的服务器上,同样的安装包可以直接运行。解决此问题需要三个步骤:(1) 先安装 vLLM 的 CUDA 12.9 构建版 Wheel 和 torch 2.13.0+cu129,并将 NIXL 也更换为 CUDA 12 版本(12.9 运行时可在 570 驱动上运行的小版本兼容性)。(2) 尽管如此,仍然报出了相同的错误 804。这是因为容器镜像内的ldconfig优先于宿主机注入的 libcuda 指向了镜像内兼容 CUDA 13 的libcuda.so.1。在 Pod 内删除该兼容性设置(/etc/ld.so.conf.d/00-compat-*.conf)并重新运行ldconfig后,GPU 即可被识别。(3) 引擎在确定 KV 位置后的首次采样中,flashinfer 用 CUDA 13 运行时编译的内核因cudaErrorInsufficientDriver (35)崩溃,因此我们将采样器通过VLLM_USE_FLASHINFER_SAMPLER=0关闭。若要在不同服务器之间迁移复用相同的虚拟环境安装包,首先必须确认驱动程序是否支持该安装包的 CUDA 构建版。 - 缩小传输方式后引擎无法启动。 即 4.4 节的仅 IPC 配置。我们修改脚本以打印完整的错误行来排查原因,并保留了完整的日志。
6. 局限性与未实测项
- 未测量通过 NVLink、RDMA 连接的环境。分离服务能够带来收益的条件(高速链路、大模型、长 prefill)属于 未实测 项。两台服务器的测试(4.5 节)仅使用了一条 1GbE 级别链路,且服务器间测试的路由器和分离服务仅涉及两个不同的 GPU。在服务器间迁移 KV 时,吞吐量的上限由链路(112~113 MB/s)决定。
- 模型较小(0.6B),且前缀是合成的(随机词)。对于大模型以及真实文档中的命中率和首个 Token 差距,结果可能会有所不同。
- 路由器测试是逐个发送请求的(并发度 1)。未测量在请求重叠时路由器如何权衡负载与重叠。
- 输入 1,500 指的是词数,未单独统计 Token 数。服务器间测试的 3,000 词请求每次传输为 351.75 MB(除以每个 Token 114,688 字节约为 3,070 个 Token),因此词数与 Token 数几乎为 1 比 1。而在单卡测试中,平均每次传输 119.3 MB 按此比例换算应约为 1,040 个 Token(占 1,500 词的 70% 左右),与该对应关系不符。我们仅怀疑该 10 秒区间的平均值中混入了较小的传输,但未确认具体原因(未实测)。因此,4.2 节中的平均传输值最好不要作为单个请求的大小来解读。
- 每个栏目重复三次,且未计算标准差。尽管趋势显而易见,但小数点后的数值不宜轻信。
- 测量使用的是开发版本(1.6.0.dev),其标志(Flags)和默认值可能与第 6 篇读取的 v1.5.0 不同。
7. 运维检查清单
- 若开启了感知 KV 的路由,请首先确认 Worker 是否在发出 KV 事件 。检查 Frontend 日志中是否存在
kv_event_publisher_disabled警告。 - 更改路由后,直接测量“再次发送相同前缀时首个 Token 是否减少”。即使没有错误,它也可能会降至随机水平。
- 如果前缀的变体多于单个 Worker 的 KV 容量,则会产生路由在 Worker 之间分散前缀的效果。请对比 Worker 的 KV 容量(服务器日志中的
GPU KV cache size)与前缀总和。 - 分离服务应在单独测量 KV 传输路径(传输吞吐量)后进行评估。在本实验中,如果链路为 1GbE 级别,则迁移时间与计算结果几乎完全一致(4.5 节)。将每个 Token 的 KV 大小(层数 × KV 头数 × 头大小 × 2 × 字节数)乘以输入长度即可得出单个请求要迁移的字节数,将其除以实际传输吞吐量即为需要叠加到首个 Token 上的时间。
- 每次运行前,请确认没有残留的旧进程(引擎核心)以及未清除的模型下载锁。
- 如果各服务器的 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 篇(测量方法)