分布式基础:缓存策略、迁移与淘汰
背景
前面已经学习了几组基础:
操作系统、网络、并发与存储。
RPC 和远程调用。
状态机、分片、路由与元数据。
副本和一致性。
故障处理。
这些内容更偏系统正确性:对象状态要清楚,远程调用要可靠,副本要一致,故障后要能恢复。
接下来进入更贴近 KV Cache 工作内容的一组:缓存策略、迁移与淘汰。
KV Cache 系统的核心矛盾是:
GPU HBM 很快,但容量小。
CPU Memory 容量更大,但访问慢。
SSD 容量更大,但延迟更高。
远端存储可以扩展容量,但网络访问更贵。
所以系统必须不断做决策:
哪些 KV Cache 应该留在 GPU HBM?
哪些可以下沉到 CPU Memory?
哪些可以放到 SSD 或远端?
哪些应该预取回来?
哪些应该淘汰?
哪些不能动,因为正在被请求使用?
这篇文章的目标是:理解 KV Cache / BlockGroup 系统如何在多级存储之间做取舍,以及迁移和淘汰为什么必须和状态机、元数据、ref_count、pin_count、epoch 一起设计。
学习目标
这一组需要掌握:
cache hit / miss
多级缓存
cache placement
cache migration
cache eviction
cache prefetch
LRU
LFU
TTL
Cost-aware eviction
watermark
pin/ref_count
热点和冷数据
迁移成本
重算成本
局部性
对 KV Cache 场景,要能回答:
显存满了,应该删哪个 BlockGroup?
远端有 cache,本地没有,要不要拉回来?
请求马上要 decode,cache 却正在迁移,怎么办?
以 block 还是 block group 为单位迁移?
什么时候从 GPU HBM 下沉到 CPU Memory?
什么时候从 CPU Memory 下沉到 SSD?
哪些 cache 不能淘汰?
如何根据 ref_count 和 pin_count 判断是否安全?
Cache Hit 和 Cache Miss
缓存系统最基础的两个概念是:
cache hit:要访问的数据在缓存里。
cache miss:要访问的数据不在缓存里。
在 KV Cache 场景里,假设 worker 要读取 bg_1:
如果 bg_1 在本地 GPU HBM:本地热缓存命中。
如果 bg_1 在本地 CPU Memory:本地温缓存命中,但需要搬回 GPU。
如果 bg_1 在远端节点:本地 miss,需要远程读取。
如果 bg_1 在 SSD:本地 miss 到慢层,需要加载。
如果 bg_1 已经被淘汰:彻底 miss,可能需要重算。
不同 miss 的成本不一样。
可以粗略理解为:
GPU HBM hit:最快。
CPU Memory hit:需要跨 PCIe/NVLink 搬运。
Local SSD hit:需要 I/O,延迟更高。
Remote Memory hit:需要网络。
Remote SSD hit:网络 + 存储,成本更高。
Evicted miss:需要重新计算或返回失败。
因此 cache hit rate 不能只看一个总数,还要区分命中的层级。
多级缓存
KV Cache 管理通常不是单层缓存,而是多级缓存。
常见层级:
GPU HBM
CPU Memory
Local SSD
Remote GPU HBM
Remote CPU Memory
Remote SSD / Distributed Storage
每一层的特点不同:
GPU HBM:最快,容量最小,最贵。
CPU Memory:容量较大,速度比 HBM 慢。
Local SSD:容量大,适合冷数据。
Remote Memory:可扩展,但受网络影响。
Remote SSD:容量更大,延迟更高。
多级缓存的核心问题是 placement:
某个 BlockGroup 当前应该放在哪一层?
这个决策要考虑:
访问频率。
未来是否很快会访问。
BlockGroup 大小。
重算成本。
迁移成本。
当前 HBM 水位。
请求优先级。
是否正在 decode。
Block 和 BlockGroup 的粒度选择
KV Cache 可以按 block 管理,也可以按 BlockGroup 管理。
block:粒度小,适合细粒度分配和释放。
BlockGroup:粒度更大,适合调度、迁移、淘汰和预取。
如果完全按 block 管理:
优点:非常灵活。
缺点:元数据多,调度开销大,迁移请求碎片化。
如果完全按很大的 BlockGroup 管理:
优点:元数据少,迁移和预取更容易批量处理。
缺点:不够精细,可能把仍然有用的数据一起淘汰。
所以 BlockGroup 的设计要匹配访问局部性:
经常一起访问的 block 放在同一个 group。
生命周期相近的 block 放在同一个 group。
迁移和淘汰时希望一起处理的 block 放在同一个 group。
粒度设计会直接影响后面的迁移和淘汰质量。
Cache Placement
cache placement 是决定缓存放在哪里。
对一个 BlockGroup,可以有这些位置:
GPU HBM
CPU Memory
SSD
Remote Memory
Remote SSD
Evicted
一个简单规则可以是:
正在 decode 使用:放 GPU HBM。
短期可能再次访问:放 CPU Memory。
重算成本高但短期不访问:放 SSD 或远端。
重算成本低且空间紧张:直接淘汰。
更实际的决策通常要打分。
例如:
placement_score = access_probability
+ recompute_cost
+ priority
+ locality_value
- size_cost
- migration_cost
这个公式不是固定实现,只是表达一种思路:
越可能访问、越难重算、优先级越高、局部性越强,越应该放在快层。
越大、迁移越贵、短期越冷,越适合下沉或淘汰。
Cache Migration
迁移是把缓存从一个位置移动到另一个位置。
常见迁移方向:
GPU HBM -> CPU Memory
CPU Memory -> GPU HBM
CPU Memory -> SSD
SSD -> CPU Memory
Local Node -> Remote Node
Remote Node -> Local Node
迁移发生的原因:
GPU HBM 不够,需要下沉。
请求即将访问,需要上升。
节点负载过高,需要迁出。
调度器把请求调到另一个节点。
节点即将下线,需要 drain。
副本修复需要复制数据。
迁移看起来只是数据拷贝,但在分布式系统里它是一个状态操作。
它至少涉及:
数据复制。
元数据更新。
状态切换。
版本校验。
失败恢复。
源副本释放。
客户端旧路由刷新。
迁移状态机
一个简化的迁移状态机:
LOCAL
-> MIGRATING_OUT
-> REMOTE
REMOTE
-> MIGRATING_IN
-> LOCAL
更完整一些:
LOCAL
MIGRATING_OUT
MIGRATING_IN
REMOTE
EVICTING
EVICTED
FAILED
迁移时要注意:
只有允许迁移的状态才能迁移。
pin_count > 0 通常不能迁移。
ref_count > 0 时迁移要非常谨慎。
每次状态变化都要带 epoch。
迁移任务要有 operation_id。
目标校验成功后才能切换 location。
源副本不能过早删除。
一个保守流程:
1. 选择要迁移的 block_group_id。
2. 检查 state、epoch、ref_count、pin_count。
3. CAS state: LOCAL -> MIGRATING_OUT。
4. 目标节点创建 MIGRATING_IN。
5. 复制数据。
6. 校验 size、checksum、token_range、epoch。
7. 更新 metadata: location -> target,epoch += 1。
8. 目标状态变为 LOCAL。
9. 源节点延迟释放旧副本。
迁移成本
迁移不是免费的。
成本包括:
网络带宽。
PCIe/NVLink 带宽。
CPU 拷贝开销。
GPU HBM 拷贝开销。
序列化和校验开销。
目标空间占用。
元数据更新成本。
对在线请求的干扰。
如果迁移太频繁,会出现:
网络带宽被占满。
在线 decode 延迟升高。
cache 还没用上就又被迁走。
后台任务影响前台请求。
P99 变差。
因此迁移要有节制。
常见控制方式:
限制并发迁移任务数。
限制迁移总带宽。
后台迁移低优先级。
只迁移预计收益大于成本的 BlockGroup。
对热点数据避免频繁来回迁移。
Cache Prefetch
prefetch 是预取:在真正访问前,提前把数据拉到更快的位置。
例如:
预测 bg_1 很快会被 decode 使用。
提前从 CPU Memory 拉到 GPU HBM。
真正访问时就能 hit。
预取的好处:
降低关键路径延迟。
减少在线请求等待。
提高 GPU 利用率。
预取的问题:
预测不准会浪费带宽和 HBM。
预取太多会挤掉真正热的数据。
后台预取可能影响在线请求。
KV Cache 中可以基于这些信号预取:
请求即将进入 decode。
调度器已经确定 worker。
block group 是连续 token range 的下一段。
历史访问模式显示即将使用。
系统 prompt cache 被大量复用。
预取也要和 pin/ref_count 配合:
预取完成不代表一定 pin。
只有真正进入请求关键路径时才 pin。
预取数据如果长期不用,仍然可以淘汰。
Cache Eviction
淘汰是当空间不够时,选择一部分缓存释放。
KV Cache 淘汰的难点是:不能只看“最近有没有用”。
还要看:
是否正在被请求使用。
是否被 pin。
是否有 ref_count。
是否可重算。
重算成本高不高。
是否还有其他副本。
是否属于高优先级请求。
是否很快会再次访问。
基本安全规则:
pin_count > 0:不能淘汰。
ref_count > 0:不能释放。
state = MIGRATING:不能直接淘汰。
state = EVICTING:不要重复淘汰。
state = EVICTED:重复 evict 返回成功。
淘汰本身也应该是状态机:
LOCAL
-> EVICTING
-> EVICTED
如果淘汰过程中又被访问,可以考虑:
取消淘汰,回到 LOCAL。
拒绝新读,让请求 miss。
等待淘汰完成后从下一级恢复。
但无论选择哪种策略,都必须明确状态转移规则。
LRU
LRU 是 Least Recently Used,淘汰最近最少使用的数据。
思想:
最近访问过的数据,未来可能还会访问。
很久没访问的数据,未来访问概率较低。
优点:
简单。
工程上常用。
适合有时间局部性的访问模式。
缺点:
不能识别访问频率。
一次扫描可能污染缓存。
不考虑数据大小。
不考虑重算成本。
不考虑迁移成本。
在 KV Cache 中,单纯 LRU 不一定够用。
例如:
一个很大的 BlockGroup 最近访问过一次,但未来不再访问。
一个较小的系统 prompt cache 很久没访问,但未来可能被大量复用。
LRU 可能做出错误选择。
LFU
LFU 是 Least Frequently Used,淘汰访问频率最低的数据。
优点:
能保留高频热点数据。
适合长期热点明显的场景。
缺点:
历史热点可能已经不热。
计数维护有开销。
需要衰减机制。
KV Cache 中,LFU 可以用于系统 prompt cache、共享前缀 cache 等长期复用数据。
但对单个会话的临时 KV Cache,LFU 未必合适,因为它的生命周期可能很短。
TTL
TTL 是 Time To Live,超过一定时间就过期。
适合:
会话级缓存。
临时预取数据。
不希望长期占空间的数据。
例如:
request 结束后,相关 BlockGroup 保留 30s。
如果 30s 内没有复用,就淘汰。
TTL 的问题:
TTL 太短:复用机会变少。
TTL 太长:占用空间过多。
不同业务可能需要不同 TTL。
TTL 通常不能单独使用,要和 LRU、优先级、空间水位一起用。
Cost-aware Eviction
对 KV Cache 来说,更合理的是 cost-aware eviction。
它不只看最近访问时间,还看综合成本。
可以考虑:
size_bytes:占用空间。
last_access_time:最近访问。
access_count:访问频率。
recompute_cost:重算成本。
migration_cost:迁移成本。
priority:请求或租户优先级。
replica_count:是否还有副本。
future_access_probability:未来访问概率。
locality_value:和当前调度的局部性。
一个简化打分:
evict_score = coldness
+ size_cost
+ low_priority
+ low_recompute_cost
- future_access_probability
- locality_value
含义:
越冷、越大、优先级越低、越容易重算,越适合淘汰。
未来越可能访问、局部性价值越高,越不适合淘汰。
实际系统不一定写成一个公式,但思想是一样的:淘汰决策要考虑成本。
Watermark
watermark 是水位线。
常见设计:
high watermark:达到高水位开始淘汰。
low watermark:降到低水位停止淘汰。
例如:
GPU HBM 使用率 > 90%:开始淘汰。
GPU HBM 使用率 < 75%:停止淘汰。
为什么需要两个水位?
如果只有一个阈值,系统可能频繁抖动:
达到 90% -> 淘汰一点 -> 低于 90% -> 停止
又分配一点 -> 达到 90% -> 再淘汰
高低水位可以减少频繁触发。
不同层级都可以有水位:
GPU HBM watermark
CPU Memory watermark
SSD watermark
Remote Store watermark
热点问题
热点是缓存系统里的常见问题。
热点可能来自:
某个系统 prompt 被大量复用。
某个长会话占用大量 KV Cache。
某个模型突然请求量升高。
某个租户流量激增。
某个节点持有太多热 BlockGroup。
热点会导致:
单节点网络打满。
HBM 长期高水位。
元数据 key 被频繁访问。
迁移任务排队。
P99 延迟升高。
处理方式:
增加只读副本。
热点 BlockGroup 复制到更多节点。
调度时考虑 cache locality。
对热点租户限流。
把大 BlockGroup 拆分。
调整分片策略。
热点不能靠感觉判断,要看指标。
迁移、淘汰和调度的关系
迁移和淘汰不能单独看,它们会影响调度。
例如:
worker_a GPU 空闲,但需要的 cache 在 worker_b。
把请求调到 worker_a,需要远程拉取 cache。
把请求调到 worker_b,可以复用本地 cache,但 GPU 较忙。
调度器要权衡:
计算等待时间。
cache 迁移成本。
cache locality。
GPU HBM 余量。
网络带宽。
请求优先级。
有时候“最空闲的 GPU”不是最好的选择。
如果 cache 很大,调到已有 cache 的节点可能更快。
一次 BlockGroup 访问路径
假设 worker 要读取 bg_1:
1. 查询本地路由缓存。
2. 发现 bg_1 在本地 GPU HBM。
3. 检查 state 和 epoch。
4. ref_count += 1。
5. 读取 KV Cache。
6. ref_count -= 1。
这是理想路径。
如果本地 HBM miss:
1. 查询元数据服务。
2. 发现 bg_1 在本地 CPU Memory。
3. 判断是否值得搬到 GPU HBM。
4. 如果值得,发起迁移。
5. 迁移完成后读取。
如果远端才有:
1. 查询元数据服务。
2. 发现 bg_1 在 remote node。
3. 判断读取远端、迁移本地,还是重新计算。
4. 发起数据传输。
5. 校验 epoch/checksum。
6. 更新本地缓存。
7. 读取。
每一步都可能失败,所以它和前面学习的 RPC、状态机、元数据、故障处理是连在一起的。
常见错误设计
只用 LRU
错误做法:
显存满了,直接淘汰最久没访问的 BlockGroup。
问题:
不考虑重算成本。
不考虑大小。
不考虑未来访问。
不考虑是否有副本。
不考虑请求优先级。
更好的方式:
LRU 作为基础信号,再结合 cost-aware 策略。
不检查 pin/ref_count 就淘汰
错误做法:
if memory_usage > threshold:
evict(bg_1)
问题:
bg_1 可能正在被 decode 使用。
更好的方式:
只有 state 允许,且 ref_count == 0,pin_count == 0,才能释放。
迁移过程中直接切路由
错误做法:
开始复制数据后,立刻把 location 改成目标节点。
问题:
目标数据可能还没复制完整。
客户端可能读到脏数据。
更好的方式:
目标校验成功后再切换 location。
源副本过早删除
错误做法:
location 一切到目标节点,源节点马上删除数据。
问题:
客户端可能还持有旧路由。
目标节点可能刚切换就故障。
更好的方式:
源副本延迟释放。
旧 epoch 请求返回 STALE_METADATA。
确认安全后再删除。
后台迁移影响在线请求
错误做法:
后台迁移不限制带宽。
问题:
在线 decode 远程读取变慢。
P99 延迟升高。
更好的方式:
后台任务限流。
在线请求优先。
迁移有独立带宽预算。
如何排查缓存问题?
指标
需要关注:
cache_hit_rate
cache_miss_rate
cache_hit_rate_by_tier
gpu_hbm_used_bytes
cpu_memory_used_bytes
ssd_used_bytes
eviction_count
eviction_latency
migration_count
migration_bytes
migration_latency
prefetch_count
prefetch_hit_rate
remote_read_latency
stale_metadata_count
pin_count
ref_count
watermark_trigger_count
日志字段
日志里最好有:
request_id
operation_id
block_group_id
state
epoch
source_tier
target_tier
source_node
target_node
size_bytes
ref_count
pin_count
eviction_reason
migration_reason
latency_ms
排查问题
当 P99 变差时,可以问:
cache hit rate 是否下降?
miss 主要发生在哪一层?
是否大量从远端读?
是否大量从 SSD 拉取?
是否触发频繁淘汰?
是否后台迁移占用带宽?
是否 BlockGroup 太大导致迁移慢?
是否 pin_count 长期不释放?
是否调度没有考虑 cache locality?
面向 KV Cache 的检查清单
看组内代码或设计文档时,可以检查:
1. 是否区分 GPU HBM、CPU Memory、SSD、Remote Store?
2. cache hit rate 是否按层级统计?
3. BlockGroup 粒度如何确定?
4. placement 策略考虑哪些因素?
5. 迁移是否有状态机?
6. 迁移是否带 epoch 和 operation_id?
7. 目标校验成功前是否禁止切换 location?
8. 源副本是否延迟释放?
9. 淘汰前是否检查 ref_count 和 pin_count?
10. 淘汰是否幂等?
11. 是否有 high/low watermark?
12. 后台迁移和预取是否限流?
13. 是否有 cost-aware eviction?
14. 预取命中率是否可观测?
15. 调度是否考虑 cache locality?
推荐学习顺序
这一组可以按下面顺序学:
1. cache hit / miss:理解缓存命中和未命中的成本差异。
2. 多级缓存:理解 HBM、CPU Memory、SSD、Remote Store。
3. Block 和 BlockGroup 粒度:理解管理粒度。
4. cache placement:理解数据应该放在哪里。
5. cache migration:理解数据如何移动。
6. 迁移状态机:理解迁移为什么不是简单拷贝。
7. prefetch:理解提前拉取和预测风险。
8. eviction:理解空间不足时删谁。
9. LRU、LFU、TTL:理解基础淘汰策略。
10. cost-aware eviction:理解 KV Cache 为什么需要综合打分。
11. watermark:理解水位触发和抖动控制。
12. 热点处理:理解高频数据和局部性。
13. 结合调度:理解为什么 cache locality 会影响调度决策。
最后要能回答:
这个 BlockGroup 现在应该放在哪一层?
如果显存满了,应该淘汰谁?
这个 BlockGroup 能不能迁移?
迁移收益是否大于成本?
预取是否值得?
远端读取、迁移本地、重新计算,哪个更好?
总结
缓存策略、迁移与淘汰,是 KV Cache 系统里最贴近性能和资源效率的一组内容。
核心不是简单地“命中就快,未命中就慢”,而是要理解多级存储之间的取舍:
HBM 快但小。
CPU Memory 大但慢。
SSD 更大但延迟高。
远端存储可扩展但受网络影响。
迁移能提升局部性,但有成本。
淘汰能释放空间,但可能增加 miss 或重算。
预取能降低延迟,但预测错误会浪费资源。
对 BlockGroup 来说,最重要的原则是:
1. 正在使用的数据不能淘汰。
2. 迁移和淘汰必须经过状态机。
3. 每次关键状态变化都要带 epoch。
4. 源副本不要过早删除。
5. 淘汰策略要考虑重算成本、大小、热度、优先级和局部性。
6. 后台任务不能抢占在线请求资源。
掌握这一组之后,下一步可以继续学习调度和负载均衡。因为缓存放在哪里、请求调到哪里、是否迁移数据,本质上是一个联合决策问题。