AI 编译缓存:命中同一张图之前,先确认输入形状稳定
AI 编译缓存:命中同一张图之前,先确认输入形状稳定
一、编译缓存能省时间,也能缓存错误假设
AI 编译器会把计算图优化成更适合目标硬件的执行计划。编译过程昂贵,所以服务端常加编译缓存。相同模型、相同图、相同形状直接复用 plan。问题在于,动态图和可变 batch 很容易让缓存 key 失真。
如果 key 只包含模型版本,不包含输入形状、dtype、目标后端和优化开关,就可能复用不兼容的 plan。轻则性能异常,重则输出错误。编译缓存的第一原则不是命中率,而是正确性。
二、缓存 key 要表达所有影响 plan 的变量
一个稳定 key 至少包含图哈希、输入形状签名、dtype、后端版本、优化级别和算子实现版本。
flowchart TD A[计算图] --> F[Compile Key] B[输入形状] --> F C[dtype] --> F D[后端版本] --> F E[优化开关] --> F F --> G{缓存命中} G -->|是| H[复用执行计划] G -->|否| I[重新编译] I --> J[写入缓存]动态维度可以归一化,但必须有明确规则。比如只允许 batch 动态,序列长度分桶。不能把所有-1都当成同一种形状。
三、Rust 中用结构化 key 避免字符串拼接错误
缓存 key 不要靠手写字符串拼接。结构化后再序列化和哈希,更容易审计。
#[derive(Debug, serde::Serialize)] pub struct CompileKey<'a> { pub graph_hash: &'a str, pub shape_signature: &'a str, pub dtype: &'a str, pub backend: &'a str, pub opt_level: u8, } pub fn cache_key(key: &CompileKey<'_>) -> anyhow::Result<String> { let bytes = serde_json::to_vec(key)?; let digest = blake3::hash(&bytes); Ok(format!("compile:{}", digest.to_hex())) }这样新增字段时,代码评审能清楚看到 key 变化。字符串拼接很容易漏字段。
结构化 key 的另一个好处是支持部分匹配策略。在实际部署中,同一计算图可能因不同优化级别存在多个 plan 变体:opt_level=0的 plan 在opt_level=2查询中不应命中,但 LRU 驱逐可以按graph_hash做分组,对同图的所有变体使用共享容量配额,避免某模型因多次形状变化吃光全部缓存。blake3 的选择也值得说明:相比 SHA256,blake3 在 x86 上有 SIMD 加速、单次计算微秒级,适合频繁 key 生成;相比 XXH3,它是密码学哈希、不需要担心恶意碰撞攻击。但对千万级 key 规模,文件系统层碰撞仍需考虑——建议在 key 之外保留 raw 字段副本在get_or_insert时做二次确认,避免哈希碰撞返回错误的执行计划。
四、缓存失效要跟发布流程绑定
后端编译器升级、算子实现替换、优化 pass 调整,都应该触发失效。不能只靠 TTL。否则旧 plan 会在新运行时里继续被使用,问题很难定位。
还要记录编译失败原因。某些形状不支持,应该回退解释执行或拒绝请求,而不是无限尝试编译。失败缓存同样有价值,可以避免同一个非法形状反复打爆编译线程。
最后,缓存命中要分桶看。总体命中率高,可能掩盖长尾形状持续编译。服务端要监控编译耗时、失败率和 key 基数。key 基数失控,说明输入形状没有被治理。
缓存容量也要有限制。编译 plan 往往不小,长尾形状如果无限写入,会把内存或磁盘打满。可以按模型版本和后端分区设置 LRU,并给高频形状预热。预热失败也要阻断发布,否则流量进来后会集中触发编译。
多进程部署还要考虑缓存一致性。每个进程各自编译,启动时会造成编译风暴。可以使用共享磁盘缓存或编译服务,但写入必须原子化。半写入的 plan 被另一个进程读取,比未命中更危险。
五、总结
AI 编译缓存要先保证 key 正确,再追求命中率。图哈希、形状、dtype、后端版本和优化开关都应进入结构化 key。缓存失效要绑定编译器发布流程,失败也要被记录。编译缓存不是简单的性能层,它是执行正确性的一部分。
