第 17 章:动态 Shape 编译(一)

大纲

17.1 开篇:动态 Shape 的挑战与机遇

  • 动态 Shape 的定义与场景
  • 编译时未知维度的处理
  • 与静态 Shape 编译的本质区别
  • 在自动驾驶和具身智能中的应用

17.2 符号形状推导

  • 符号维度表示
  • 符号算术系统
  • 形状传播算法
  • 约束求解器设计

17.3 Shape 函数与约束

  • Shape 函数的定义与作用
  • 约束表达式语言
  • 约束传播机制
  • 冲突检测与解决

17.4 动态内存管理

  • 运行时内存分配策略
  • 内存池的动态扩展
  • 碎片管理
  • 峰值内存预测

17.5 桶化策略

  • 桶化的基本原理
  • 桶边界选择算法
  • 多维桶化
  • 性能与内存权衡

17.6 本章小结

17.7 练习题

17.8 常见陷阱与错误

17.9 最佳实践检查清单


17.1 动态 Shape 的挑战与机遇

动态 Shape 编译是现代 AI 编译器面临的核心挑战之一。不同于传统的静态 Shape 场景,动态 Shape 允许张量的某些维度在编译时未知,只有在运行时才能确定。这种灵活性在自动驾驶的变长序列处理、具身智能的多模态输入融合等场景中至关重要,但也给编译优化带来了巨大挑战。

动态 Shape 的典型场景

在自动驾驶系统中,激光雷达点云数据的点数会随环境复杂度变化,每帧可能包含 10,000 到 200,000 个点不等。类似地,目标检测后的 RoI (Region of Interest) 数量也是动态的。具身智能机器人需要处理不定长的自然语言指令,同时融合数量可变的传感器输入。这些场景都要求编译器能够高效处理动态 Shape。

编译时的核心问题

动态 Shape 给编译器带来三个核心问题:

  1. 内存规划的不确定性:无法在编译时确定准确的内存需求,静态内存规划算法失效
  2. 优化决策的困难:许多优化(如循环展开、向量化)依赖于具体的维度信息
  3. 调度的复杂性:无法预先确定最优的并行策略和任务分配

符号化处理方法

解决动态 Shape 的核心思想是引入符号维度(symbolic dimension)。我们用符号变量表示未知的维度,建立符号算术系统进行形状推导。例如,批次大小用符号 $N$ 表示,序列长度用 $L$ 表示,则自注意力机制的计算复杂度可表示为 $\mathcal{O}(N \cdot L^2 \cdot D)$,其中 $D$ 是特征维度。

编译策略概览

动态 Shape 编译采用分阶段策略:

编译时:符号分析 → 约束提取 → 模板生成 → 特化准备
运行时:形状具体化 → 快速特化 → 执行优化代码

这种方法在编译时完成尽可能多的分析和准备工作,运行时只需要快速填充具体数值并执行。

17.2 符号形状推导

符号形状推导是动态 Shape 编译的理论基础,它将编译时的形状分析从具体数值域扩展到符号域,使得编译器能够在维度未知的情况下进行正确性验证和优化决策。

17.2.1 符号维度表示

符号维度系统的核心是用代数表达式表示张量的每个维度。我们定义符号维度 $d$ 为:

$$d ::= n \mid s \mid d_1 + d_2 \mid d_1 \cdot d_2 \mid \lfloor d_1 / d_2 \rfloor \mid \max(d_1, d_2) \mid \min(d_1, d_2)$$ 其中 $n \in \mathbb{N}$ 是具体数值,$s$ 是符号变量。每个符号变量可以附加约束,如 $s \in [l, u]$ 表示 $s$ 的取值范围。

17.2.2 符号算术系统

符号算术系统需要支持基本运算的符号化:

加法规则

  • $(a + b) + c = a + (b + c)$ (结合律)
  • $a + 0 = a$ (单位元)
  • $n_1 + n_2 = n_3$ 其中 $n_3$ 是具体数值的和

乘法规则

  • $(a \cdot b) \cdot c = a \cdot (b \cdot c)$ (结合律)
  • $a \cdot 1 = a$ (单位元)
  • $a \cdot (b + c) = a \cdot b + a \cdot c$ (分配律)

除法规则(整数除法):

  • $\lfloor a / 1 \rfloor = a$
  • $\lfloor n_1 / n_2 \rfloor = n_3$ 其中 $n_3 = \lfloor n_1 / n_2 \rfloor$
  • 当 $a < b$ 且都为正时,$\lfloor a / b \rfloor = 0$

17.2.3 形状传播算法

形状传播算法通过数据流图传播符号形状信息。对于每个算子 $op$,我们定义形状传播函数 $f_{op}: Shape_{in} \rightarrow Shape_{out}$。

矩阵乘法的形状传播

给定输入形状 $A: [M, K]$ 和 $B: [K, N]$,输出形状为 $C: [M, N]$。当维度是符号时,需要添加约束 $K_A = K_B$。

卷积的形状传播

对于 2D 卷积,输入 $X: [N, C_{in}, H_{in}, W_{in}]$,卷积核 $K: [C_{out}, C_{in}, K_h, K_w]$,输出形状为: $$Y: [N, C_{out}, H_{out}, W_{out}]$$ 其中: $$H_{out} = \lfloor \frac{H_{in} + 2p_h - K_h}{s_h} \rfloor + 1$$ $$W_{out} = \lfloor \frac{W_{in} + 2p_w - K_w}{s_w} \rfloor + 1$$ $p_h, p_w$ 是 padding,$s_h, s_w$ 是 stride。

17.2.4 符号简化与规范化

符号表达式需要简化以提高效率和可读性。简化规则包括:

  1. 常量折叠:$2 + 3 \rightarrow 5$
  2. 代数简化:$a + a \rightarrow 2a$,$a - a \rightarrow 0$
  3. 约束传播:如果 $a \in [1, 10]$ 且 $b \in [5, 15]$,则 $a + b \in [6, 25]$
  4. 符号消除:$\lfloor a \cdot b / a \rfloor \rightarrow b$ (当 $a > 0$)

17.2.5 约束求解器设计

约束求解器负责验证符号约束的可满足性。我们使用 SMT (Satisfiability Modulo Theories) 求解器处理整数线性约束。

约束类型

  • 相等约束:$d_1 = d_2$
  • 不等约束:$d_1 \leq d_2$, $d_1 < d_2$
  • 整除约束:$d_1 \mod d_2 = 0$
  • 范围约束:$l \leq d \leq u$

约束求解算法

输入:约束集合 C = {c_1, c_2, ..., c_n}
输出:可满足性判定及符号变量赋值

1. 将约束转换为标准形式
2. 构建约束图,节点为符号变量,边为约束关系
3. 使用 Bellman-Ford 算法检测负环(不可满足)
4. 若可满足,使用线性规划求解可行域
5. 返回可行解或报告冲突

17.3 Shape 函数与约束

Shape 函数是动态 Shape 编译中的关键抽象,它显式地描述了算子的输入输出形状关系,并生成相应的约束条件。这种机制使得编译器能够在符号层面验证程序的正确性,并为后续优化提供依据。

17.3.1 Shape 函数的定义

Shape 函数 $\mathcal{S}$ 是一个从输入形状到输出形状的映射,同时生成约束集合: $$\mathcal{S}: \text{Shape}^n \rightarrow \text{Shape}^m \times \text{Constraints}$$ 其中 $n$ 是输入张量数量,$m$ 是输出张量数量。每个 Shape 函数都必须满足:

  1. 确定性:相同输入产生相同输出
  2. 单调性:输入维度增大时,输出维度不减小
  3. 可组合性:多个 Shape 函数可以串联

17.3.2 Shape 函数的表示语言

我们定义一个专门的 DSL (Domain Specific Language) 来表示 Shape 函数:

shape_func ::= λ(inputs)  (outputs, constraints)
inputs ::= tensor_shape*
tensor_shape ::= [dim_expr*]
dim_expr ::= symbol | constant | binary_op(dim_expr, dim_expr)
constraints ::= constraint*
constraint ::= dim_expr rel_op dim_expr
rel_op ::= = |  | < |  | > |  (mod n)

17.3.3 常见算子的 Shape 函数

广播机制的 Shape 函数

广播是动态 Shape 中最复杂的操作之一。给定两个张量 $A$ 和 $B$,广播后的形状计算如下:

broadcast_shape(A: [d₁, d₂, ..., dₙ], B: [e₁, e₂, ..., eₘ])  C, constraints
其中:
  rank_C = max(n, m)
  对于 i = 1 to rank_C:
    if dᵢ exists and eᵢ exists:
      if dᵢ = eᵢ: Cᵢ = dᵢ
      elif dᵢ = 1: Cᵢ = eᵢ
      elif eᵢ = 1: Cᵢ = dᵢ
      else: 添加约束 dᵢ = eᵢ  dᵢ = 1  eᵢ = 1
    elif only dᵢ exists: Cᵢ = dᵢ
    elif only eᵢ exists: Cᵢ = eᵢ

Reshape 的 Shape 函数

Reshape 操作需要保持元素总数不变:

reshape(X: [d₁, d₂, ..., dₙ], target: [e₁, e₂, ..., eₘ])  Y: target, constraints
约束:∏dᵢ = eⱼ
特殊情况:当 target 中存在 -1 时,该维度计算为 dᵢ / (eⱼ where eⱼ  -1)

动态 Slice 的 Shape 函数

slice(X: [d₁, ..., dₙ], start: [s₁, ..., sₙ], end: [e₁, ..., eₙ])  Y, constraints
Y: [min(e₁-s₁, d₁-s₁), ..., min(eₙ-sₙ, dₙ-sₙ)]
约束:∀i: 0  sᵢ < dᵢ, sᵢ  eᵢ  dᵢ

17.3.4 约束表达式语言

约束表达式需要支持复杂的逻辑关系:

基本约束类型

  1. 线性约束:$a₁x₁ + a₂x₂ + ... + aₙxₙ ≤ b$
  2. 乘积约束:$x₁ \cdot x₂ = c$
  3. 整除约束:$x \mod n = 0$
  4. 条件约束:$\text{if } p \text{ then } q \text{ else } r$

约束的逻辑组合

  • 合取:$c₁ \land c₂$ (两个约束都必须满足)
  • 析取:$c₁ \lor c₂$ (至少一个约束满足)
  • 蕴含:$c₁ \Rightarrow c₂$ (如果 $c₁$ 满足则 $c₂$ 必须满足)

17.3.5 约束传播机制

约束传播通过数据流图将局部约束扩展为全局约束:

前向传播算法

1. 初始化将输入张量的约束加入约束集 C
2. 对于拓扑排序的每个节点 n:
   a. 获取 n 的输入形状可能包含符号
   b. 应用 n  Shape 函数生成输出形状和新约束
   c. 将新约束加入 C
   d. 简化 C 中的约束

3. 返回最终约束集 C

反向传播算法(用于推断输入约束):

1. 从输出开始已知输出形状约束
2. 对于反向拓扑排序的每个节点 n:
   a. 根据输出约束和 Shape 函数推断输入约束
   b. 传播约束到前驱节点

3. 验证推断的输入约束与实际输入约束的兼容性

17.3.6 冲突检测与解决

约束冲突是动态 Shape 编译中的常见问题:

冲突类型

  1. 直接冲突:$x = 5 \land x = 10$
  2. 传递冲突:$x < y \land y < z \land z < x$
  3. 域冲突:$x \in [1, 5] \land x > 10$

冲突检测算法

1. 构建约束依赖图
2. 检测负环使用 Bellman-Ford
3. 使用 SMT 求解器验证可满足性
4. 如果不可满足提取最小不可满足核心MUC

冲突解决策略

  1. 放松约束:将硬约束转为软约束,允许一定误差
  2. 插入转换:自动插入 reshape、pad 等操作消除冲突
  3. 分支特化:为不同约束条件生成不同代码路径
  4. 运行时检查:将约束检查延迟到运行时

17.4 动态内存管理

动态 Shape 带来的最大挑战之一是内存管理的不确定性。传统的静态内存规划在这里不再适用,需要设计新的动态内存管理机制,既要保证正确性,又要优化性能。

17.4.1 运行时内存分配策略

动态内存分配需要在效率和灵活性之间权衡:

分配策略分类

  1. 即时分配(Eager Allocation): - 每个张量在创建时立即分配内存 - 优点:简单直接,易于调试 - 缺点:内存峰值高,可能导致 OOM

  2. 延迟分配(Lazy Allocation): - 推迟到首次使用时才分配 - 优点:减少不必要的分配 - 缺点:增加运行时开销

  3. 预分配池(Pre-allocated Pool): - 预先分配大块内存池 - 优点:避免频繁的系统调用 - 缺点:可能浪费内存

自适应分配算法

输入张量请求 T历史统计 H
输出内存块 M

1. 估算大小范围
   size_min = eval_min(T.shape_constraints)
   size_max = eval_max(T.shape_constraints)

2. 查询历史模式
   pattern = H.find_pattern(T.op_type, T.shape_pattern)
   size_likely = pattern.percentile(95)

3. 选择分配策略
   if size_max - size_min < threshold:
     M = allocate_fixed(size_max)  // 差异小,直接分配最大
   elif pattern.variance < threshold:
     M = allocate_fixed(size_likely)  // 历史稳定,按经验分配
   else:
     M = allocate_growable(size_min, size_max)  // 可增长分配

4. 更新统计H.update(T, actual_size)

17.4.2 内存池的动态扩展

内存池需要支持动态扩展以适应变化的需求:

分层内存池设计

┌─────────────────────────────────────┐
│         L0: 小对象池 (<1KB)          │ ← 高频分配
├─────────────────────────────────────┤
│         L1: 中对象池 (1KB-1MB)       │ ← 常规张量
├─────────────────────────────────────┤
│         L2: 大对象池 (1MB-100MB)     │ ← 大型激活
├─────────────────────────────────────┤
│         L3: 巨对象池 (>100MB)        │ ← 模型参数
└─────────────────────────────────────┘

动态扩展策略

  1. 指数增长:每次扩展为当前大小的 2 倍
  2. 线性增长:每次增加固定大小
  3. 自适应增长:根据使用率和预测需求调整

内存池扩展算法

扩展决策:
growth_factor = 1.0 + min(0.5, allocation_rate / deallocation_rate)
new_size = current_size * growth_factor

扩展时机:

- 可用内存 < 总量的 20%
- 连续分配失败次数 > 阈值
- 预测未来 N 步需求超过当前容量

17.4.3 碎片管理

动态分配容易产生内存碎片,需要专门的管理策略:

碎片类型

  1. 内部碎片:分配的内存大于实际需求
  2. 外部碎片:空闲内存被分割成小块

碎片度量: $$\text{碎片率} = \frac{\text{总空闲内存} - \text{最大连续空闲块}}{\text{总空闲内存}}$$ 反碎片化策略

  1. 伙伴系统(Buddy System): - 将内存分成 2^n 大小的块 - 合并相邻的空闲块 - 碎片率控制在 50% 以内

  2. 内存压缩(Memory Compaction): - 定期移动活跃内存块 - 创建大的连续空闲区域 - 需要更新所有引用

  3. 分离适配(Segregated Fit): - 不同大小类别使用不同的空闲列表 - 减少大小不匹配导致的碎片

17.4.4 峰值内存预测

准确预测峰值内存对于避免 OOM 至关重要:

静态分析方法

基于符号形状的最坏情况分析: $$M_{peak} = \max_{t \in [0, T]} \sum_{v \in \text{live}(t)} \text{size}_{max}(v)$$ 其中 $\text{live}(t)$ 是时刻 $t$ 的活跃张量集合,$\text{size}_{max}(v)$ 是张量 $v$ 的最大可能大小。

动态预测方法

使用历史数据和机器学习模型:

特征提取:

- 输入形状的统计特征(均值、方差、最大值)
- 算子类型和参数
- 历史执行的内存使用模式

预测模型:
peak_memory = α * input_size + β * model_complexity + γ * history_pattern + ε

其中系数通过在线学习持续更新

17.4.5 内存共享与别名

动态 Shape 下的内存共享需要更谨慎的分析:

共享条件

  1. 生命周期不重叠
  2. 内存布局兼容
  3. 大小约束可满足

动态别名分析

can_alias(T1, T2):
  // 检查生命周期
  if overlaps(T1.lifetime, T2.lifetime):
    return false

  // 检查大小兼容性
  if T1.size_max > T2.allocated_size:
    return false

  // 检查 stride 兼容性
  if not compatible_stride(T1.stride_pattern, T2.stride):
    return false

  return true

17.4.6 异常处理机制

动态内存管理需要健壮的异常处理:

OOM 预防策略

  1. 早期检测:在分配前检查可用内存
  2. 渐进式回收:逐步释放缓存和临时数据
  3. 优雅降级:切换到更节省内存的算法

恢复机制

处理 OOM:

1. 触发垃圾回收
2. 压缩内存池
3. 卸载非关键数据到磁盘
4. 如果仍然失败,触发重新编译(使用更保守的内存策略)

17.5 桶化策略

桶化(Bucketing)是处理动态 Shape 的实用技术,通过将连续的形状空间离散化为有限的桶,在编译时生成多个特化版本,运行时选择最合适的版本执行。这种方法在保持性能的同时,避免了完全动态带来的开销。

17.5.1 桶化的基本原理

桶化的核心思想是将无限的形状空间 $\mathcal{S}$ 划分为有限个互不相交的子空间(桶): $$\mathcal{S} = \bigcup_{i=1}^{n} B_i, \quad B_i \cap B_j = \emptyset \text{ for } i \neq j$$ 每个桶 $B_i$ 对应一个编译后的特化版本,运行时根据实际形状选择对应的桶。

桶的表示

每个桶用一个多维区间表示: $$B = \{[l_1, u_1] \times [l_2, u_2] \times ... \times [l_d, u_d]\}$$ 其中 $[l_i, u_i]$ 是第 $i$ 维的范围。

17.5.2 桶边界选择算法

桶边界的选择直接影响性能和资源消耗:

等间隔划分

简单地将每个维度等分:

对于维度 d ∈ [min_d, max_d]:
  桶边界 = {min_d + i * (max_d - min_d) / n | i = 0, 1, ..., n}

基于分布的划分

根据历史数据的分布选择边界:

1. 收集历史形状数据 H = {s_1, s_2, ..., s_m}
2. 对每个维度 d
   a. 构建累积分布函数 CDF_d
   b. 选择分位点作为边界
      boundaries_d = {CDF_d^(-1)(i/n) | i = 0, 1, ..., n}

自适应划分算法

使用聚类算法自动发现最优边界:

输入:历史形状集合 H,目标桶数 K
输出:桶边界集合 B

1. 初始化:使用 K-means++ 选择 K 个初始中心
2. 迭代优化:
   repeat:
     a. 分配:将每个形状分配到最近的桶
     b. 更新:重新计算每个桶的边界
     c. 评估:计算总体代价函数
   until 收敛或达到最大迭代次数

3. 后处理:合并过小的桶,分裂过大的桶

代价函数设计: $$\text{Cost} = \alpha \cdot \text{CompilationOverhead} + \beta \cdot \text{RuntimeOverhead} + \gamma \cdot \text{MemoryUsage}$$ 其中:

  • CompilationOverhead = 桶数量 × 单个版本编译时间
  • RuntimeOverhead = 平均填充率 × 执行时间
  • MemoryUsage = 桶数量 × 平均代码大小

17.5.3 多维桶化

处理多个动态维度时,需要考虑维度间的相关性:

独立桶化

每个维度独立划分,总桶数为各维度桶数的乘积:

总桶数 = ∏(每个维度的桶数)

优点:简单直接 缺点:桶数量呈指数增长

联合桶化

考虑维度间的相关性,减少总桶数:

1. 构建维度相关性矩阵
   Corr[i,j] = correlation(dim_i, dim_j)

2. 识别强相关维度组
   使用谱聚类将维度分组

3. 对每组联合桶化
   组内维度一起考虑组间独立

稀疏桶化

只为常见的形状组合创建桶:

1. 统计形状组合频率
2. 选择累积频率达到阈值 95%)的组合
3. 为这些组合创建桶
4. 其余使用通用的动态版本

17.5.4 性能与内存权衡

桶化需要在多个目标间权衡:

性能模型: $$T_{total} = T_{dispatch} + T_{execution} + T_{padding}$$ 其中:

  • $T_{dispatch}$:选择桶的开销
  • $T_{execution}$:特化代码执行时间
  • $T_{padding}$:由于桶化导致的填充开销

内存模型: $$M_{total} = M_{code} \times N_{buckets} + M_{runtime}$$ 优化目标: $$\min_{B} \lambda_1 T_{total}(B) + \lambda_2 M_{total}(B)$$

受约束于:

  • $N_{buckets} \leq N_{max}$(桶数量上限)
  • $\text{Coverage}(B) \geq \theta$(覆盖率要求)

17.5.5 运行时桶选择

高效的运行时桶选择对性能至关重要:

决策树方法

构建决策树快速选择桶:

       shape[0] < 128?
          /        \
        Yes         No
        /            \
   shape[1] < 64?   shape[1] < 256?
      /    \           /      \
   Bucket1 Bucket2  Bucket3  Bucket4

哈希表方法

将形状映射到桶索引:

bucket_index = hash(quantize(shape)) % num_buckets

最近邻方法

选择距离最近的桶中心:

min_distance = infinity
selected_bucket = null
for each bucket in buckets:
  distance = compute_distance(shape, bucket.center)
  if distance < min_distance:
    min_distance = distance
    selected_bucket = bucket

17.5.6 桶化的自动调优

使用机器学习自动优化桶化策略:

在线学习框架

1. 初始化使用默认桶化策略
2. 执行监控
   - 记录每次执行的形状和性能
   - 统计桶的命中率和效率
3. 定期优化
   - 识别性能瓶颈桶
   - 调整桶边界或增加新桶
4. 增量更新
   - 只重新编译受影响的桶
   - 平滑切换到新策略

反馈驱动优化

性能反馈循环:

1. 收集运行时统计:
   - 每个桶的使用频率
   - 填充开销
   - 实际执行时间

2. 分析优化机会:
   - 识别热点桶(需要细分)
   - 识别冷桶(可以合并)
   - 发现新的形状模式

3. 调整策略:
   - 重新分配编译资源
   - 更新桶边界
   - 调整特化策略

17.6 本章小结

本章深入探讨了动态 Shape 编译的核心技术,这是现代 AI 编译器必须解决的关键挑战。我们从符号形状推导开始,建立了处理未知维度的理论基础,然后通过 Shape 函数和约束系统实现了编译时的正确性验证。动态内存管理部分解决了运行时的资源分配问题,而桶化策略提供了实用的性能优化方案。

关键要点

  1. 符号化是基础:通过符号维度和符号算术,将静态分析扩展到动态场景
  2. 约束是保障:Shape 函数和约束系统确保程序的正确性
  3. 内存管理需要自适应:动态场景下需要更智能的内存分配和管理策略
  4. 桶化平衡灵活性与性能:通过有限的特化版本覆盖无限的形状空间
  5. 运行时与编译时协同:将工作合理分配到编译时和运行时

核心公式回顾

  • 符号维度定义:$d ::= n \mid s \mid d_1 + d_2 \mid d_1 \cdot d_2 \mid \lfloor d_1 / d_2 \rfloor$
  • Shape 函数:$\mathcal{S}: \text{Shape}^n \rightarrow \text{Shape}^m \times \text{Constraints}$
  • 峰值内存预测:$M_{peak} = \max_{t \in [0, T]} \sum_{v \in \text{live}(t)} \text{size}_{max}(v)$
  • 桶化空间划分:$\mathcal{S} = \bigcup_{i=1}^{n} B_i, \quad B_i \cap B_j = \emptyset$
  • 优化目标:$\min_{B} \lambda_1 T_{total}(B) + \lambda_2 M_{total}(B)$

17.7 练习题

基础题

练习 17.1 符号维度计算

给定两个张量 A: [N, 128, K] 和 B: [K, M, 256],其中 N, K, M 是符号维度。计算以下操作的输出形状:

  1. C = matmul(A, B)(对最后两维做矩阵乘法)
  2. D = reshape(A, [N*128, K])
  3. E = broadcast(A, B)(如果可能)

Hint: 考虑矩阵乘法的维度匹配要求和广播规则。

答案
  1. C = matmul(A, B): - A 的形状调整为 [N, 128, K] - B 的形状为 [K, M, 256] - 批量矩阵乘法要求批次维度相同或可广播 - 这里需要广播,结果形状为 [N, K, max(128, M), 256] - 添加约束:最后一维的 K 必须相等

  2. D = reshape(A, [N128, K]): - 直接重塑,输出形状为 [N128, K] - 约束:总元素数保持不变 N128K = N128K ✓

  3. E = broadcast(A, B): - A: [N, 128, K] - B: [K, M, 256] - 无法直接广播,因为 A 的最后维度 K 与 B 的第一维度 K 位置不匹配 - 需要先进行维度对齐或转置

练习 17.2 约束求解

给定以下约束系统,判断是否可满足:

s1 + s2 = 100
s1 > 2 * s2
s2 ≥ 20
s1, s2 ∈ ℕ

Hint: 将不等式约束转换为等式形式,使用替换法求解。

答案

从约束系统:

  1. s1 + s2 = 100
  2. s1 > 2 * s2
  3. s2 ≥ 20

从约束 1:s1 = 100 - s2 代入约束 2:100 - s2 > 2 * s2 100 > 3 * s2 s2 < 33.33

结合约束 3:20 ≤ s2 < 33.33

因此 s2 ∈ {20, 21, ..., 33} 对应 s1 ∈ {80, 79, ..., 67}

验证:当 s2 = 20, s1 = 80 时:

  • s1 + s2 = 100 ✓
  • 80 > 40 ✓
  • 20 ≥ 20 ✓

系统可满足。

练习 17.3 内存峰值计算

某模型有三个张量,生命周期如下:

  • T1: [N, 1024],生命周期 [0, 5]
  • T2: [N, 2048],生命周期 [3, 8]
  • T3: [N, 512],生命周期 [6, 10]

假设 N ∈ [32, 128],float32 类型,计算最坏情况下的峰值内存(MB)。

Hint: 找出所有张量同时活跃的时间点。

答案

分析各时间点的活跃张量:

  • t ∈ [0, 3): 只有 T1
  • t ∈ [3, 5]: T1 和 T2
  • t ∈ [5, 6): 只有 T2
  • t ∈ [6, 8]: T2 和 T3
  • t ∈ [8, 10]: 只有 T3

峰值出现在:

  • [3, 5]: T1 + T2 = N × (1024 + 2048) = N × 3072
  • [6, 8]: T2 + T3 = N × (2048 + 512) = N × 2560

最大值为 N × 3072,当 N = 128 时: 峰值 = 128 × 3072 × 4 bytes = 1,572,864 bytes ≈ 1.5 MB

练习 17.4 桶化划分

给定序列长度的历史分布:

  • 50% 的样本长度在 [64, 128]
  • 30% 的样本长度在 [128, 256]
  • 15% 的样本长度在 [256, 512]
  • 5% 的样本长度在 [512, 1024]

设计一个 4 桶划分方案,使 95% 的样本能被覆盖。

Hint: 考虑使用不等间隔划分以优化常见情况。

答案

基于分布的 4 桶划分:

桶 1: [64, 96] - 覆盖短序列的前半部分(约 25%) 桶 2: [96, 160] - 覆盖短序列后半和中等序列前半(约 40%) 桶 3: [160, 384] - 覆盖中等序列后半和较长序列(约 25%) 桶 4: [384, 768] - 覆盖长序列的大部分(约 5%)

这样可以覆盖 95% 的样本,同时对高频区间(64-256)提供更细粒度的划分。

超过 768 的样本(< 5%)使用通用动态版本处理。

挑战题

练习 17.5 符号简化系统设计

设计一个符号表达式简化系统,能够处理以下情况:

  1. (N + M) × K - N × K → M × K
  2. max(N, N + K) → N + K (当 K ≥ 0)
  3. ⌊(N × M) / N⌋ → M (当 N > 0)

描述你的简化规则和实现策略。

Hint: 考虑构建一个规则匹配和重写系统。

答案

符号简化系统设计:

  1. 规则表示
Rule ::= Pattern  Replacement | Condition
  1. 核心规则集: - 分配律:(a + b) × c → a×c + b×c - 结合律:(a × b) × c → a × (b × c) - 消去律:a × b / a → b (when a ≠ 0) - 吸收律:max(a, a + b) → a + b (when b ≥ 0)

  2. 实现策略: - 构建表达式的 AST - 自底向上应用规则 - 使用规范形式避免循环 - 维护约束上下文

  3. 优化顺序: - 先展开(应用分配律) - 再合并(识别公因子) - 最后简化(消去和吸收)

  4. 约束传播: - 跟踪变量的范围约束 - 在简化时利用约束信息 - 生成新的约束

练习 17.6 自适应内存池设计

设计一个自适应内存池系统,要求:

  1. 支持不同大小类别的分层管理
  2. 能够根据使用模式自动调整池大小
  3. 最小化碎片率(目标 < 20%)

给出关键数据结构和算法。

Hint: 结合 slab allocator 和 buddy system 的思想。

答案

自适应内存池设计:

  1. 数据结构
MemoryPool {
  layers: [SmallPool, MediumPool, LargePool]
  statistics: UsageStats
  predictor: SizePredictor
}

Layer {
  size_class: (min, max)
  free_lists: [FreeList]
  allocation_count: int
  fragmentation: float
}
  1. 分配算法
1. 根据请求大小选择层
2. 在层内使用 best-fit 查找
3. 如果失败触发扩展或压缩
4. 更新统计信息
  1. 自适应调整
每 N 次分配后:

1. 计算各层的使用率和碎片率
2. 如果碎片率 > 20%:
   - 触发压缩
   - 调整大小类别边界
3. 根据历史预测未来需求
4. 预分配或释放内存
  1. 反碎片策略: - 延迟合并:批量处理释放操作 - 伙伴合并:相邻空闲块自动合并 - 迁移压缩:移动小对象创建大空闲块

练习 17.7 多维桶化优化

给定一个 Transformer 模型,batch_size ∈ [1, 128],sequence_length ∈ [1, 2048]。设计一个桶化方案,要求:

  1. 总桶数不超过 16
  2. 覆盖 90% 以上的常见组合
  3. 最小化平均填充开销

Hint: 考虑 batch_size 和 sequence_length 的使用模式差异。

答案

多维桶化方案:

  1. 分析维度特性: - batch_size:通常是 2 的幂(1, 2, 4, 8, 16, 32, 64, 128) - sequence_length:集中在特定值(128, 256, 512, 1024, 2048)

  2. 相关性分析: - 小 batch 往往配大 sequence(推理) - 大 batch 往往配小 sequence(训练)

  3. 16 桶方案

推理桶(6个):
[1-2, 128-256], [1-2, 256-512], [1-2, 512-1024],
[1-2, 1024-2048], [4, 128-512], [4, 512-2048]

训练桶(8个):
[8, 128-256], [8, 256-512], [16, 128-256], [16, 256-512],
[32, 128-256], [32, 256-512], [64, 128-256], [128, 128-256]

通用桶(2个):
[2-8, 256-1024], [8-128, 512-2048]
  1. 填充开销优化: - 对 batch_size 使用向上取整到 2 的幂 - 对 sequence_length 使用向上取整到 64 的倍数 - 平均填充开销 < 15%

练习 17.8 约束冲突诊断

设计一个算法,当约束系统不可满足时,找出最小冲突集(Minimal Unsatisfiable Core)。

Hint: 使用二分搜索或增量方法。

答案

最小冲突集查找算法:

  1. 删除法(Deletion-based)
输入:不可满足约束集 C
输出:最小冲突集 MUC

1. MUC = C
2. 对每个约束 c in C:
   temp = MUC - {c}
   if is_unsatisfiable(temp):
      MUC = temp

3. 返回 MUC
  1. 增长法(Growth-based)
1. 二分搜索找到临界大小 k
2. 枚举所有大小为 k 的子集
3. 返回第一个不可满足的子集
  1. 优化策略: - 使用增量 SMT 求解器 - 缓存中间结果 - 启发式排序(优先检查相关约束)

  2. 诊断信息生成

对于 MUC 中的每个约束:

1. 标注来源(哪个算子产生)
2. 分析冲突类型
3. 提供修复建议

示例输出: "约束冲突:矩阵乘法要求 K_A = K_B,但 reshape 操作导致 K_A = 2N 而 K_B = N+10"

17.8 常见陷阱与错误

1. 符号维度的过度泛化

陷阱:将所有维度都设为符号变量,导致优化机会丧失。

正确做法

  • 识别真正动态的维度(如 batch_size, sequence_length)
  • 保持模型结构相关的维度为常量(如 hidden_size, num_heads)
  • 使用部分特化技术

2. 约束求解的性能问题

陷阱:在关键路径上进行复杂的约束求解,导致编译时间爆炸。

正确做法

  • 缓存求解结果
  • 使用增量求解技术
  • 设置求解超时,失败时回退到保守策略

3. 内存分配的碎片累积

陷阱:频繁的动态分配释放导致严重的内存碎片。

正确做法

  • 使用内存池而非直接 malloc/free
  • 定期进行碎片整理
  • 实现内存重用机制

4. 桶边界选择不当

陷阱:等间隔划分桶,导致热点区域精度不足。

正确做法

  • 基于实际分布选择边界
  • 使用自适应调整机制
  • 为常见 case 提供专门优化

5. 符号简化的无限循环

陷阱:简化规则相互触发,导致无限循环。

正确做法

  • 定义规范形式
  • 确保每次简化都向规范形式靠近
  • 设置最大迭代次数

6. 动态 Shape 信息丢失

陷阱:在 IR 转换过程中丢失符号信息和约束。

正确做法

  • 在每个 IR 层保留 shape 标注
  • 传播约束信息
  • 验证转换的正确性

7. 运行时开销被忽视

陷阱:过于复杂的运行时决策逻辑,抵消了优化收益。

正确做法

  • 简化运行时检查
  • 使用快速路径
  • 预计算决策表

17.9 最佳实践检查清单

设计阶段

  • [ ] 明确区分静态和动态维度
  • [ ] 定义清晰的符号命名规范
  • [ ] 设计分层的 Shape 抽象
  • [ ] 规划约束的生命周期管理
  • [ ] 考虑与现有静态优化的兼容性

实现阶段

  • [ ] 实现高效的符号算术库
  • [ ] 构建可扩展的约束求解框架
  • [ ] 设计自适应的内存管理系统
  • [ ] 实现智能的桶化策略
  • [ ] 添加全面的 Shape 验证

优化阶段

  • [ ] 识别并消除冗余约束
  • [ ] 实施符号表达式简化
  • [ ] 优化运行时分发逻辑
  • [ ] 减少内存碎片
  • [ ] 平衡编译时和运行时开销

测试阶段

  • [ ] 覆盖边界情况(最小/最大维度)
  • [ ] 测试约束冲突处理
  • [ ] 验证内存管理正确性
  • [ ] 评估桶化效果
  • [ ] 进行性能回归测试

部署阶段

  • [ ] 收集运行时统计信息
  • [ ] 监控内存使用模式
  • [ ] 跟踪桶命中率
  • [ ] 分析性能瓶颈
  • [ ] 准备回滚方案

维护阶段

  • [ ] 定期更新桶化策略
  • [ ] 优化热点路径
  • [ ] 清理过时的约束
  • [ ] 更新文档
  • [ ] 分享最佳实践