4、静态显存复用策略:计算图分析、张量生存期推断、静态内存规划算法

各位同学,今天我们来聊聊显存复用里最硬核的一块——静态策略。

说白了,就是让框架在运行你的模型之前,先算清楚:
哪些张量可以“住”在同一个内存地址上?
什么时候可以回收?什么时候必须保留?

我刚开始做框架优化时,总觉得动态分配就够用了。直到跑一个 70B 的大模型,显存直接爆掉。嗯,从那以后,我彻底转向了静态复用。

4.1 计算图分析:先看清全局

静态复用的第一步,是拿到一张完整的计算图。

计算图是什么?
就是你的模型在训练或推理时,所有算子(Op)和数据流(Tensor)构成的有向无环图。

我个人习惯把计算图分成三层来看:

  • 算子节点:Conv、MatMul、ReLU……每个节点代表一次计算
  • 张量边:节点之间的输入输出,每个张量都有大小和类型
  • 控制依赖:比如同步点、梯度裁剪,这些会影响生存期

举个例子,一个简单的两层 MLP:

# 伪代码:计算图结构
x → Linear1 → relu → Linear2 → loss
     ↑          ↑         ↑
   weight1    bias1    weight2

在这个图里,x 只在第一层被用到,relu 的输出只传给第二层。这些信息,就是后续内存规划的“原材料”。

核心要点:计算图分析越细,内存复用空间越大。我见过有些框架只分析算子级别的依赖,结果漏掉了中间张量的复用机会。

4.2 张量生存期推断:谁先死,谁后死

有了计算图,下一步就是推断每个张量的生存期。

生存期怎么定义?
很简单:从张量被创建(某个算子的输出),到最后一次被使用(作为另一个算子的输入)。

我常用的方法是拓扑序扫描:

  1. 对计算图做拓扑排序
  2. 正向扫描:记录每个张量的出生点(第几个算子)
  3. 反向扫描:记录每个张量的死亡点(最后一次被引用的算子)

来看一个实际例子:

算子序号:  1       2       3       4       5
操作:     Conv → ReLU → Pool → FC → Softmax
张量:     t1      t2      t3     t4     t5

生存期推断结果:
t1: 出生=1, 死亡=2  (只被ReLU使用)
t2: 出生=2, 死亡=3  (只被Pool使用)
t3: 出生=3, 死亡=4  (只被FC使用)
t4: 出生=4, 死亡=5  (只被Softmax使用)
t5: 出生=5, 死亡=5  (输出,不再被使用)

你想想看,t1 在算子2之后就死了,t2 在算子3之后也死了。那它们的内存地址,完全可以被后面的张量复用。

避坑指南:我曾经遇到过一个 case,某个张量在反向传播时被多次引用,但正向扫描只记录了第一次使用。结果内存提前释放,导致梯度计算错误。所以,一定要考虑反向传播的依赖。

4.3 静态内存规划算法:把地址“安排”得明明白白

生存期推断完了,接下来就是最核心的——内存规划算法。

目标是什么?
在满足所有张量生存期不冲突的前提下,最小化总显存占用。

这其实是一个区间着色问题:

  • 每个张量是一个“区间”(出生点到死亡点)
  • 每个内存地址是一种“颜色”
  • 重叠的区间不能共享颜色

我常用的算法有三种,各有优劣:

算法 原理 复杂度 适用场景
贪心区间分配 按死亡点排序,先死先复用 O(n log n) 推理场景,图结构简单
最佳适应算法 找最小可用空闲块 O(n²) 训练场景,张量大小差异大
整数线性规划 全局最优解 NP-hard 离线编译,追求极致压缩

我个人最常用的是贪心区间分配,因为它快,而且效果不差。

具体做法:

  1. 把所有张量按死亡点从小到大排序
  2. 维护一个“空闲地址池”
  3. 遍历每个张量:如果空闲池里有地址可用,就分配;否则申请新地址
  4. 张量死亡后,把地址归还到空闲池

来看一个代码示例:

def greedy_interval_alloc(tensors):
    # tensors: [(name, size, birth, death), ...]
    sorted_tensors = sorted(tensors, key=lambda t: t[3])  # 按死亡点排序
    free_pool = []  # 空闲地址列表
    allocation = {}  # 张量名 → 地址偏移

    for name, size, birth, death in sorted_tensors:
        # 尝试从空闲池中找可用地址
        allocated = False
        for i, (addr, addr_size) in enumerate(free_pool):
            if addr_size >= size:
                allocation[name] = addr
                # 更新空闲块大小
                if addr_size > size:
                    free_pool[i] = (addr + size, addr_size - size)
                else:
                    free_pool.pop(i)
                allocated = True
                break
        if not allocated:
            # 申请新地址
            new_addr = sum(t[1] for t in tensors)  # 简化:末尾追加
            allocation[name] = new_addr
            # 实际实现中需要维护当前总大小
        # 张量死亡后,归还地址
        # 这里省略了死亡回调逻辑
    return allocation

注意:贪心算法不是最优的。我遇到过一种情况:两个大张量生存期完全错开,但中间夹了一个小张量,导致大张量无法复用。这时候最佳适应算法反而更好。

4.4 一张图看懂静态显存复用

下面我用一张 SVG 图,把整个流程串起来:

静态显存复用核心流程 计算图分析 解析算子与张量依赖 输出:有向无环图 张量生存期推断 拓扑序扫描 输出:出生点/死亡点 内存规划算法 区间着色/贪心分配 输出:地址映射表 最终结果:显存复用方案 多个张量共享同一物理地址 生存期区间示例 算子1 算子2 算子3 算子4 算子5 t1 t2 t3 t4 t1与t2不重叠 → 可复用 t3与t4不重叠 → 可复用

从这张图可以看得很清楚:
计算图分析 → 生存期推断 → 内存规划,三步走完,就能得到一份高效的显存复用方案。

4.5 实战中的几个坑

最后,分享几个我在项目中踩过的坑:

  • 动态形状:如果你的模型输入尺寸会变(比如 NLP 里的变长序列),静态规划就不好使了。我一般会结合静态+动态混合策略,先预留最大尺寸,再动态调整。
  • 梯度累积:训练时梯度张量的生存期比前向张量长得多。我曾经忘了考虑这一点,结果梯度被覆盖,模型怎么训都不收敛。
  • 碎片化:频繁的分配和释放会导致显存碎片。我建议在规划时对齐到 256 字节,能有效减少碎片。

一句话总结:静态显存复用,就是用编译期的“慢”,换运行时的“省”。计算图越清晰,生存期推断越准,内存规划越优。

好了,这一章就到这里。下一章我们会聊动态显存复用,到时候再对比一下两者的优劣。