4、预计算与缓存策略:如何避免推理时的重复计算。

各位好,我是老张。今天咱们聊聊 RoPE 在端侧部署时,一个非常实在的问题——重复计算

你想想看,端侧设备就那么点算力,电池就那么点电。如果每次推理都去重新算一遍旋转位置编码,那简直是浪费。我刚开始做端侧优化时,就踩过这个坑。当时模型跑在手机上,发热严重,一查才发现 RoPE 计算占了将近 15% 的耗时。嗯,这必须得优化。

4.1 为什么会有重复计算?

RoPE 的核心公式其实很简单:

q_rotated = q * cos(θ) + rotate_half(q) * sin(θ)
k_rotated = k * cos(θ) + rotate_half(k) * sin(θ)

这里面的 cos(θ)sin(θ),只跟位置 pos 和维度索引 i 有关。跟输入的内容 qk 完全没关系。

说白了,只要序列长度固定,这些三角函数值就是固定的。每次推理都重新算一遍,就是做无用功。

核心结论:RoPE 的三角函数值只依赖于位置和维度,与输入数据无关。这是预计算的前提。

4.2 预计算策略:把算过的存起来

我个人习惯的做法是:在模型初始化时,一次性把所有可能用到的 cos/sin 值算好,存起来

具体来说,假设模型支持的最大序列长度是 max_seq_len,隐藏层维度是 dim。那么预计算的缓存就是一个 [max_seq_len, dim] 的矩阵。

代码实现大概是这样的:

class RotaryEmbedding:
    def __init__(self, dim, max_seq_len=2048):
        # 预计算所有位置的 cos 和 sin
        inv_freq = 1.0 / (10000 ** (torch.arange(0, dim, 2) / dim))
        positions = torch.arange(max_seq_len)
        # 外积得到 [max_seq_len, dim/2]
        angles = positions[:, None] * inv_freq[None, :]
        # 拼接成 [max_seq_len, dim]
        self.cos_cached = torch.cos(angles).repeat(1, 2)
        self.sin_cached = torch.sin(angles).repeat(1, 2)

    def forward(self, q, k, pos):
        # 直接从缓存取,不用再算
        cos = self.cos_cached[pos]
        sin = self.sin_cached[pos]
        return apply_rope(q, k, cos, sin)

小技巧:如果内存紧张,可以只缓存 [max_seq_len, dim/2],用的时候再拼接。不过我个人觉得没必要省这点内存,代码清晰更重要。

4.3 缓存策略:什么时候该清,什么时候该留?

预计算解决了「算一次」的问题。但端侧推理还有一个场景——流式推理

比如你做一个语音助手,用户说一句话,模型要一个字一个字地生成。这时候,cos/sin 缓存是随着位置递增的。

我建议的策略是:

  • 静态缓存:模型加载时一次性算好,整个生命周期不变。适合固定长度推理。
  • 动态扩展:流式推理时,按需扩展缓存。比如当前最大位置到了 100,缓存只有 64,那就追加计算 64~100 的部分。

我曾经在项目里犯过一个错:每次生成新 token 都重新算整个序列的 RoPE。结果推理速度慢得离谱。后来改成动态扩展,速度提升了 3 倍。

注意:动态扩展时,不要每次都重新分配内存。最好预分配一个较大的缓冲区,比如 max_seq_len * 2,避免频繁的内存操作。

4.4 量化后的缓存处理

端侧部署经常要做量化。RoPE 的 cos/sin 值范围是 [-1, 1],量化后精度损失很小。

我一般这样处理:

  1. 预计算 float32 的 cos/sin 缓存
  2. 量化为 int8 或 fp16
  3. 推理时直接使用量化后的值

实测下来,int8 量化后精度损失不到 0.1%,但内存占用减少 75%。对于端侧设备来说,这笔买卖很划算。

4.5 知识体系总览

下面这张图,是我自己总结的 RoPE 缓存策略全流程。你可以对照着看:

RoPE 预计算与缓存策略流程图 模型加载 预计算 cos/sin 缓存 推理模式? 静态缓存(固定长度) 动态扩展(流式推理) 推理时直接查表

4.6 实际部署中的避坑指南

最后,分享几个我踩过的坑:

  • 缓存对齐问题:有些 NPU 要求内存地址 16 字节对齐。预计算时要注意 padding,否则推理时会 crash。
  • 多 batch 场景:如果同时处理多个序列,每个序列的位置可能不同。缓存要支持按位置索引,而不是按 batch 索引。
  • 精度问题:fp16 的 cos/sin 在极端位置(比如 pos=0)没问题,但 pos 很大时误差会累积。我建议用 fp32 预计算,推理时再转成 fp16。

一句话总结:RoPE 的预计算和缓存,是端侧部署性价比最高的优化手段之一。代码改动不大,收益却很明显。你值得花 10 分钟把它做好。


公众号:蓝海资料掘金营,微信deep3321