在 macOS 上跑我那个 Rust 写的 LLM 推理引擎 mini-vllm-rs,我打开了前缀缓存,然后往一个冷缓存里同时丢进去两个完全相同的请求。
![]()
结果两个请求,一个 token 都没复用。两份 prompt,各自从头算了一遍。
这不是缓存坏了。缓存是好的,只是它发布可复用页面的时机,比这两个请求到达的时间晚了一步。
冷缓存下的并发,撞上了发布时机
我的缓存只在请求结束时,才把可复用的页面发布出去。两个请求几乎同时到达,谁都没等到对方发布任何东西,于是谁都找不到可复用的前缀。后面再来的请求,就能正确复用已经完成的前缀了。
要让并发复用成立,得先回答几个问题:缓存页面什么时候该变得可见?另一个请求要不要等它?这个等待又该在什么时候结束?
基准测试把我推向了两个改动:增量发布,和单飞协调。
测试环境是 Mac mini 的 Metal GPU,4-bit 量化的 Qwen2.5 0.5B 模型,缓存页 16 个 token,重复跑 3 次。第一轮复用基准,对比的就是热复用和同时冷到达这两种情况。
让页面在主人还在生成时就发布
我加了增量发布。每次批量前向成功之后,引擎立刻把新完成的、包含已提交 token 的页面发布出去,这样它们在这个请求结束之前就能被复用。
测法是先起一个请求,等它吐出第一个输出 token。在它继续生成剩下 128 个 token 的同时,我再提交几个相同 prompt 的请求,看它们能不能复用上这段前缀。
增量发布让每个跟随者恢复了 493 个 prompt token 里的 480 个。在 2、4、8 个并发请求的分组里,平均 TTFT 相比只在完成时发布,下降了 48% 到 93%。这个平均值算的是每个请求从提交到首个流式输出的等待。
但增量发布并没有消掉同时冷到达时的重复计算。一个请求能复用的,只是它开始执行时已经发布出来的完整前缀页,哪怕另一个请求还在 prefill。它也可能只找到一部分 prompt,或者什么都还没找到,剩下的自己算。复不复用,取决于执行顺序和发布时机。
要避开这个竞态,就得决定谁来算这段前缀。
让一个请求算前缀,其他人等多久?
我加了精确输入的单飞。一个请求当 leader,其他输入 token 完全相同的请求当 follower 等着。这协调的是 prompt 计算,不是答案。每个 follower 照样生成自己的输出,这本来就该如此:相同的 prompt 可以用不同的生成设置,比如 temperature。
我最初的实现,让 follower 一直等到 leader 整个请求结束。复用变得可预测了,但代价是它们得等过整个 decode。
8 个同时到达、各生成 128 个 token 的请求,这个策略相比各自独立执行,平均 TTFT 和最长请求的完成延迟都降了。但只有 2 个这样的请求时,平均 TTFT 反而比独立执行更差,尽管完成延迟是改善的。为了避开重复计算,引入了一个新的等待成本。
prompt 缓存都已经就绪了,为什么还要等过 decode?我加了第二个释放策略。leader 一完成 prefill,引擎就发布它已完成的前缀页,然后让 follower 具备被调度的资格。
对于同时到达、各生成 128 个 token 的相同请求,两个策略恢复的缓存 token 数一样,而在 prefill 后就释放 follower,降低了平均 TTFT。
不过更早释放并不是每个指标都变好。在另一组测试里,第二个请求在原始请求刚进入 decode 阶段时到达。相比完成时释放的单飞,早释放把平均 TTFT 降了 72%,但把最慢请求的完成延迟推高了 14%。
原因在于,原来那个 flight 已经关闭了,第二个请求开了个新 flight,复用了热前缀。两个请求于是并发 decode,而不是顺序 decode,活跃 batch 变了。这可能就是完成时间变长的原因。
早释放策略自己会发布所需的检查点。对这些被协调的精确输入负载来说,额外的增量发布帮助不大。它们在无协调时仍然有用,比如只共享部分前缀的请求。
发布决定数据什么时候可复用。协调决定谁等,等多久。
修完错误,再回头质疑状态机
这些功能是 AI 帮忙实现的。和上一篇文章一样,审它的设计,是构建一个我能理解、能维护的系统的必要环节。
一开始,AI 在释放 follower 的时候就把 flight 删掉了,看起来挺合理,因为等待已经结束了。
但第一次单飞基准就暴露了问题:follower 后来完成时,会通知协调器,而协调器已经不认识它们的 ID 了。AI 提议把未知请求检查删掉。
我拒绝了,因为那会连带把携带真正无效请求 ID 的终止通知也一起藏起来。原设计里没有 FlightState。为了保住校验,我们加了 Active 和 Draining 两个状态,并跟踪已释放的 follower 直到它们终止。
leader 那时已经结束了,所以 Draining 只跟踪被释放的 follower。
更早释放让这个表示变复杂了。leader 现在可能在 follower 被释放之后还在 decode。AI 保留了同样的 Flight 字段,只改了枚举,把 leader 变成可选。
这个可选的 leader 让我停了一下。不同的释放策略需要不同的组合。如果一个策略不该保留 leader,而状态里却有一个,是不是还得再加一个检查?
我退了一步:真的需要 Draining 状态吗?加它,是为了在已释放请求最终终止时能认出它们。那需要记住它们的 ID,但不需要保留它们原来的 flight 归属。为什么不把这些 ID 跨 flight 单独收集?
我让 AI 把这两个职责拆开。我们删掉了 FlightState,单独跟踪已释放的请求 ID。释放时,移除 flight 和它的索引,只留下尚未终止的请求 ID。这样既保住了终止校验,又不用保留那些已经不影响执行的 leader/follower 关系。未知请求 ID 仍然报错,相同 prompt 的新请求会开一个新 flight。
我挑战删除之后,AI 保住了校验。后来它一直在这个解法上继续加东西,直到我开始质疑抽象本身。
人的审查,既要守住一个不变量,也要重新考虑用来守住它的结构。基准测试告诉我请求什么时候可以停止等待。审代码告诉我 flight 什么时候可以不再存在。
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.