host hit、load-back 与前缀分层复用#

这章解决什么问题#

前面的调度与内存章节已经把 prefix match、lock ref、cache_protected_len 讲出来了,但还有一层对真实缓存命中体验非常关键的机制没有单独讲清:为什么有些前缀虽然“命中了”,系统仍然要再做一次 load-back 或数据搬运?为什么 prefix_indices 里有时会同时混着 device 命中和 host/storage 回灌的部分?

这层问题集中落在几个线索上:

  • host_hit_length
  • prefix_indices 的 device/host 拼接语义
  • load-back 路径
  • 分层缓存或 host-backed cache 的前缀复用

如果不把这一层讲清楚,读者很容易把“prefix hit”误读成一个二元状态:命中就是快,未命中就是慢。但源码实际上表达的是更细的一层现实:

  • 前缀可以部分命中 device
  • 部分命中 host / storage
  • 还可能需要先 load-back,才能真正继续执行

为什么这层值得单独成章#

因为它决定了“缓存命中了但仍然不够快”这类问题该怎么解释。

对现实 inference runtime 来说,最难的常常不是完全 miss,而是这种半命中状态:

  • 逻辑上看已经有 prefix
  • 但物理上不全在当前 device-ready 的位置
  • 因而还需要额外恢复、搬运或等待

这正是优秀技术书应该主动讲透的地方:不是只说“有层级缓存”,而是要说“层级缓存如何改变 prefix reuse 的真实含义”。

一张图:prefix 命中不只是一层,而是可能跨 device / host 分层成立#

这张图解决的理解障碍是:很多读者会把前缀复用想成“树里命中就能直接 forward”,但实际缓存状态可能分布在不同层上。

flowchart LR
    Req["new request"] --> Match["match_prefix(...)"]
    Match --> Dev["device-resident prefix"]
    Match --> Host["host/storage-resident prefix"]
    Host --> Load["load-back / restore"]
    Dev --> Merge["prefix_indices"]
    Load --> Merge
    Merge --> Run["extend / prefill continues"]

图比纯文字多解释的一点是:prefix 命中不一定直接等于“可立即执行的全量 device 前缀”,它可能是多层状态拼接之后的结果。

host_hit_length 为什么是这层最重要的提示量#

schedule_batch.py 里请求状态会同时带着:

  • prefix_indices
  • host_hit_length

这说明系统明确区分:

  • 当前请求总共已经拿到了多少前缀索引
  • 其中有多少是来自 host / storage 层命中并被回灌的

从教学角度看,这个字段非常重要,因为它直接告诉你:

  • prefix_indices 不是单纯“树匹配的 device 结果”
  • 它可能已经混入了回灌后的前缀部分

换句话说,前缀命中结果本身就可能是分层拼接后的视图,而不是原生 device cache 的纯快照。

为什么 prefix_indices 不能再被当成“纯 device 命中结果”#

schedule_batch.py 的注释里,上游已经明确提醒:

  • 到某些时刻,prefix_indices 可能已经扩展过 host data
  • 此时 len(prefix_indices) 代表的是 device original + host loaded 的总和

这非常值得技术书主动拿出来讲。因为一旦进入层级缓存或 load-back 场景,prefix_indices 的语义就变了:

  • 它不再只是“当前树缓存原生命中的那一段”
  • 而是“请求接下来可以当作 prefix 使用的总视图”

这是一个典型的 runtime 语义升级点。

load-back 为什么不该被视为“缓存失效”#

从工程视角看,很多人一看到需要 load-back,就会下意识认为缓存失效了。更准确的说法应该是:

  • 逻辑前缀复用仍然成立
  • 只是物理前缀并不全在当前最便宜的层里

这和“完全 miss”有本质区别。因为系统并不需要重新理解这段前缀,只需要把它从 host/storage 层恢复到 device-ready 状态。

从整本书的逻辑来看,这意味着 prefix reuse 至少应分成三种状态:

  1. 完全 device 命中
  2. 分层命中,需要 load-back
  3. 完全 miss

如果不做这个区分,读者就很难解释很多“命中率看起来不低,但延迟还是上来了”的现象。

ScheduleBatch 在这里承担了什么角色#

ScheduleBatch 不是只负责把请求拼成一批,它还要把:

  • prefix 命中
  • host hit 长度
  • 当前 extend 输入长度
  • 可能的 load-back 代价

一起折进 batch 的真实可执行形态里。

这就是为什么这章属于“调度与内存”,而不是单独放在层级缓存章节里。因为 load-back 不是纯缓存子系统问题,它会直接改变:

  • 当前 batch 的有效前缀长度
  • 需要真正 forward 的剩余 token
  • 甚至请求是否值得继续被接纳

cache_unfinished_req(...) 和层级前缀视图怎样接起来#

在上一章里,我们已经解释了:

  • cache_unfinished_req(...) 会把一部分 prefix 提前写回树
  • 并更新 cache_protected_len

这一章要补充的是:一旦 host-backed prefix 也被拼进来,prefix_indices 的“已可用视图”可能会比“树中原生 device 节点”更长。于是:

  • cache_protected_len 仍然表示树已正式保护的边界
  • prefix_indices 则可能表示“device + host load-back 后的可用前缀视图”

这进一步说明:

  • 保护边界
  • 可用前缀视图
  • 分层命中来源

是三件相关但不同的事。

这层最容易出现的误判#

1. 以为 prefix 命中就是纯 device hit#

在层级缓存路径里,这常常不成立。

2. 以为 load-back 等于缓存完全失效#

它更准确地表示“逻辑命中仍成立,但物理位置需要恢复”。

3. 以为 len(prefix_indices) 就能说明 cache 命中的真实质量#

你还需要结合 host_hit_length 看它到底有多少是回灌出来的。

如果 prefix reuse 看起来“命中了但还是慢”,先怎么查#

建议按这个顺序:

  1. host_hit_length 是否不为零
  2. prefix_indices 当前长度与真正 device original 的差异
  3. 看 load-back 是否频繁发生,导致 latency 被搬运成本放大
  4. 再看 allocator / eviction 是否只是加剧了这种分层命中状态

这样查能避免一种很常见的误判:看到 prefix hit rate 不错,就排除缓存问题;但问题恰恰可能出在“命中主要来自 host 回灌,而不是纯 device reuse”。

工程收益与代价#

收益:

  • 逻辑前缀不必因为 device miss 就完全失效
  • 系统可以把多层缓存状态统一折成请求的可用前缀视图
  • 对长会话或热门上下文更友好

代价:

  • “命中”这个词本身变复杂了
  • 前缀长度、受保护长度、host hit 长度必须分开看
  • 排障时不再能只看一个 cache hit rate 或一个长度字段

小结#

这一章真正要补齐的,是 prefix reuse 这条主线里最容易被忽略的现实层:

  • prefix 命中可能是分层成立的,而不是纯 device 命中
  • host_hit_length 说明有多少前缀来自回灌
  • prefix_indices 表示当前可用视图,不再只等于树的原生命中结果

到这里,调度与内存部分对“缓存命中为什么有时仍然不够快”的解释,就真正落到了运行时语义上,而不是只停留在抽象命中率层面。