Skip to content

Lec 2 Bentley 程序优化的法则

算法设计可以显著减少解决问题所需的工作量,例如用 Θ(n log n) 时间复杂度的排序替代 Θ(n²) 时间复杂度的排序。 然而,减少程序的工作量并不一定会自动减少其运行时间,这是由于计算机硬件的复杂特性,包括

  • 指令级并行(ILP)
  • 缓存
  • 向量化
  • 推测执行和分支预测等等

尽管如此,减少工作量仍然是减少整体运行时间的一个有效启发式方法。

我们这门课的的优化是,是与体系结构相关的优化。

总览

image-20250612022621022

  • 数据结构
  • 循环
  • 逻辑
  • 函数

数据结构

打包和编码

打包(Packing)的思路是将多个数据值存储在一个机器字中,而编码(coding)的想法是数据值转换为需要更少位数的表示方式。

示例:编码日期

字符串形式的日期为“2018年9月11日”会占用 18 个字节,超过两个 64 位(double word)的机器字大小。每当我们操作日期时,必须移动这些字节,增加了开销。假设我们只需要存储从公元前 4096 年到公元 4096 年之间的日期。这大约有 365.25 × 8192 ≈ 300 万个日期。使用二进制表示时,编码这些日期大约需要 ⎡log₂(3 × 10⁶)⎤ ≈ 22 位,因此可以轻松地存储在一个 32 位的机器字中

如何紧凑地表示日期,以便快速确定年、月、日?

Solution: 我们可以通过位域(bit-field)方式,把日期的 year/month/day 打包到一个结构体中,只用 22 位就能表示,而且访问单个字段(例如只取出 month)也非常快,因为编译器会生成高效的访问代码。

增强

增强(Augmentation)数据结构是指,通过向数据结构中添加额外信息,使常见操作所需的工作量减少,从而提高操作效率。

示例:单链表拼接。假设我们有两个单链表,要将一个链表(A)拼接到另一个链表(B)的末尾。由于单链表只有指向下一个节点的指针,没有直接指向尾部的指针,因此我们需要遍历链表 A 的所有节点,直到找到它的末尾(空指针位置),然后将这个空指针指向链表 B 的开头。这个过程的时间复杂度是 O(n),其中 n 是链表 A 的长度。

增强链表:如果我们在链表结构中增加一个尾指针(tail pointer),直接指向链表的最后一个节点,那么拼接操作就可以直接找到链表 A 的末尾,无需遍历整个链表。这样拼接操作可以在常数时间(O(1))内完成,大大提高效率

image-20250612022556524

预计算

预计算(Precomputation)的思想是提前进行计算,以避免在“任务关键”的时刻进行计算。

示例:二项式系数。(nk)=n!k!(nk)!,求解二项式稀疏非常昂贵(需要大量的乘法),并且比较中间的n和k值都有可能导致整数溢出。

💡关键思想:在程序初始化时提前计算出一张二项式系数表(比如一个二维数组),在运行时通过查表直接获取结果,而不再进行实时计算。

第一步: Pascal's 三角

image-20250612023527256

c
int choose(int n, int k) {
  if (n < k) return 0;
  if (k == 0) return 1;
  return choose(n-1, k-1) + choose(n-1, k);
}

第二步: 预计算Pascal

c
#define CHOOSE_SIZE 100
int choose[CHOOSE_SIZE][CHOOSE_SIZE];

void init_choose() {
	for (int n = 0; n < CHOOSE_SIZE; ++n) {
  	choose[n][0] = 1;
    choose[n][n] = 1;
 }
	for (int n = 1; n < CHOOSE_SIZE; ++n) {
    choose[0][n] = 0;
    for (int k = 1; k < n; ++k) {
    	choose[n][k] = choose[n-1][k-1] + choose[n-1][k];
    	choose[k][n] = 0;
    }
	}
}

编译期间初始化

编译时初始化的思想是在编译时存储常量的值,从而在程序执行时节省工作量。

c
#define N 100
int main(int argc, const char *argv[]) {
  init_choose();
  printf("#define N %3d\n”, N);
  printf("int choose[N][N] = {\n");
  for (int a = 0; a < N; ++a) {
  	printf(" {");
    for (int b = 0; b < N; ++b) {
      printf("%3d, ", choose[a][b]);
    }
    printf("},\n");
  }
  printf("};\n");
}

元编程(metaprogramming)是对程序进行编程的程序,可以通过这种技巧

缓存

缓存(cache)的思想是将最近访问过的结果存储起来,以便程序不必再次计算它们。

image-20250612022954272

稀疏性

稀疏性的思想是避免存储和计算零值。简而言之,就是“最快的计算方式是不进行计算

示例: CSR(压缩稀疏行矩阵表示法)

逻辑

循环

循环不变量外提

循环不变量外提(Hoisting)是一种编译器优化技术,目的是将循环内部不变的计算(Loop-Invariant Code)移到循环外部,避免在每次迭代中重复计算相同值,从而提升性能

image-20250612031031977

循环融合

循环融合(Loop Fusion, 也称Loop Jamming)是一种编译器优化技术,目的是将多个相邻的、遍历相同索引范围的循环合并为一个循环,从而减少循环控制的开销(如循环变量更新、条件判断等),并提升数据局部性。

image-20250612030745153

消除无效迭代

消除无效迭代(Eliminating Wasted Iterations)的思想是,通过调整循环边界(loop bounds),跳过那些循环体内实际没有有效操作的迭代

image-20250612030922568

函数

内联

内联(Inlining)的关键思想子阿姨,减少函数调用开销,用函数体替换调用。

image-20250612025533637

也可以通过将square声明为内联函数,内联函数有着比肩宏(macros)的性能,并且他们更安全和更好的结构化(宏只是简单的替换)。

尾递归消除

尾递归消除(Tail-Recursion Elimination)的理念是消除函数最后一步递归调用的开销。最后一步的递归调用被替换为跳转到函数开头,更新参数的值(即复用栈帧)

image-20250612030447519

粗化递归

粗化递归(Coarsening Recursion)的理念是增加基准条件的大小,并使用更高效的代码来处理它,从而避免函数调用的开销。

image-20250612025417964