MIT-6-172-软件系统性能工程笔记-全-
MIT 6.172 软件系统性能工程笔记(全)
001:引言与矩阵乘法 🚀

在本节课中,我们将要学习软件性能工程的基本概念,并通过一个经典的矩阵乘法案例,了解性能优化带来的巨大潜力。我们将看到,通过一系列从简单到复杂的优化技术,可以将一个程序的运行速度提升数万倍。
课程概述
性能工程是计算机科学中的一个关键领域。虽然性能本身可能不是软件开发中最优先考虑的因素,但它却是实现其他重要属性(如易用性、安全性、可扩展性)的“货币”。随着硬件发展进入多核时代,性能不再是免费的午餐,软件开发者必须主动进行性能优化,才能充分利用现代硬件的计算能力。
为什么需要性能工程?
上一节我们介绍了性能作为“计算货币”的概念。本节中我们来看看性能工程的历史背景和现实意义。
在计算机发展的早期,机器资源非常有限。程序员必须进行深入的性能优化,才能使程序在有限的内存和极低的时钟频率下运行。然而,随着摩尔定律和登纳德缩放定律的盛行,硬件性能每两年就会翻倍,程序员只需等待几年,程序就会自动变快。因此,像高德纳这样的计算机科学家提出了“过早优化是万恶之源”的著名观点。
这种情况在2004年左右发生了根本性改变。由于功耗和散热问题,处理器的时钟频率停止增长。芯片制造商转而通过增加核心数量(即多核处理器)来提升性能。这意味着,为了获得更好的性能,软件必须能够并行执行。此外,现代处理器还集成了向量单元、复杂的多级缓存等特性。
因此,性能工程在今天变得至关重要。数据显示,自2004年后,开源项目中对“性能”的讨论显著增加,招聘市场中对性能工程技能的需求也在上升。掌握性能优化技能,能让你在职业发展中更具竞争力。
案例研究:矩阵乘法
现在,让我们通过一个具体的例子——矩阵乘法,来直观感受性能优化的威力。我们将在一个现代多核服务器上进行测试。
测试环境
我们使用的是一台AWS上的计算优化型实例,其关键硬件规格如下:
- 架构: Haswell微架构,主频2.9 GHz。
- 核心数: 2个处理器芯片,每芯片9个核心,共18个物理核心。
- 向量单元: 每个核心每周期可执行8个双精度浮点操作(包括融合乘加)。
- 缓存:
- L1缓存: 32 KB(数据),8路组相联。
- L2缓存: 256 KB。
- L3缓存(末级缓存): 25 MB。
- 内存: 60 GB DRAM。
- 理论峰值性能:
2.9 GHz * 2 chips * 9 cores/chip * 16 FLOPs/cycle ≈ 836 GFLOPs。
基准性能:Python实现
我们首先用Python实现一个最朴素的三重循环矩阵乘法(假设矩阵大小为 n=4096)。
import time
n = 4096
A = [[1]*n for _ in range(n)]
B = [[1]*n for _ in range(n)]
C = [[0]*n for _ in range(n)]
start = time.time()
for i in range(n):
for j in range(n):
for k in range(n):
C[i][j] += A[i][k] * B[k][j]
end = time.time()
print(end - start)
运行结果: 约21,000秒(接近6小时)。
性能分析:
- 总操作数约为
2 * n^3 = 2 * 4096^3 ≈ 1.37e11次浮点运算。 - 计算得到的性能约为
1.37e11 ops / 21000 s ≈ 6.25 MFLOPs。 - 这仅达到了机器理论峰值性能(836 GFLOPs)的 0.00075%。效率极低。
优化步骤1:更换编程语言
Python是一种解释型语言,执行每条语句都需要额外的解释和状态管理开销。我们尝试用编译型语言重写。
以下是不同语言实现相同算法的性能对比:
| 实现语言 | 运行时间 | 相对加速(较上一行) | 绝对加速(较Python) | 达到峰值百分比 |
|---|---|---|---|---|
| Python | 21,000 秒 | 1.0x | 1.0x | 0.00075% |
| Java | ~3,000 秒 | ~7x | ~7x | 0.005% |
| C (Clang, -O0) | ~1,100 秒 | ~2.7x | ~19x | 0.014% |
仅仅通过将代码从解释型的Python迁移到编译型的C语言,我们就获得了近20倍的性能提升。这是因为编译后的机器码能更直接、高效地利用硬件。
优化步骤2:循环顺序与缓存局部性
上一节我们通过更换语言获得了显著提升。本节中我们来看看一个更微妙的优化点:循环顺序。
矩阵乘法的三重循环 (i, j, k) 可以有6种不同的排列方式,且计算结果相同。但不同的顺序会导致完全不同的内存访问模式,从而严重影响性能。
关键概念: 缓存局部性。
现代处理器通过缓存来加速内存访问。数据以缓存行(通常64字节)为单位在内存和缓存间传输。如果程序访问的数据在物理地址上相邻(空间局部性),或者不久后会再次访问同一数据(时间局部性),那么缓存命中率会很高,访问速度就快。
在C语言中,多维数组按行优先顺序存储。分析 i-j-k 顺序(即 for i for j for k):
C[i][j]: 在内层k循环中保持不变,具有极好的时间局部性。A[i][k]: 随k连续访问,具有很好的空间局部性。B[k][j]: 随k变化时,访问的是不同行的同一列元素,地址间隔很大(n * sizeof(double)),空间局部性极差,导致大量缓存行被浪费。
通过简单地调整循环顺序为 i-k-j,我们可以让 B[k][j] 的访问也变得连续。实验表明,仅此一项改动就能带来 约6.5倍 的加速。
优化步骤3:编译器优化选项
除了修改代码,我们还可以通过告诉编译器进行更激进的优化来免费提升性能。Clang/GCC 提供了 -O1, -O2, -O3 等优化等级。
在我们的测试中,使用 -O2 选项比默认的无优化 (-O0) 带来了 约3.25倍 的加速。此时性能达到了理论峰值的 0.3%。编译器优化会自动进行循环展开、指令调度、寄存器分配等操作。
优化步骤4:并行化
到目前为止,我们只使用了18个核心中的1个。现代性能工程的核心就是利用并行性。
我们可以使用Cilk Plus(或类似的OpenMP)并行框架。只需在外部循环前添加 cilk_for 关键字,即可将迭代分配到多个核心上执行。
以下是并行不同循环的测试结果:
- 并行化
i循环: ~3.18 秒 (优秀) - 并行化
j循环: 性能下降 (较差) - 同时并行化
i和j循环: 性能不佳
经验法则: 通常应该并行化最外层的循环,以减少任务创建和调度的开销。通过并行化 i 循环,我们获得了接近18倍的加速(因为核心数为18),总加速比达到约 220倍,性能达到峰值的 5%。
优化步骤5:分块(Tiling)优化
尽管并行化带来了巨大提升,但我们仍只利用了5%的峰值性能。瓶颈之一在于缓存未命中。
朴素算法在计算矩阵C的一行时,需要遍历矩阵B的所有列。对于大矩阵,B的数据无法完全放入缓存,导致每次计算新行时,B的数据都需要从慢速内存中重新加载,产生了大量重复访问。
分块算法 通过将大矩阵分解为小块(Tile)来解决这个问题。例如,我们将矩阵划分为 S x S 的子块。计算时,我们专注于一个能放入高速缓存的C子块,并只加载计算该子块所需的A和B的相应子块。这样,在缓存中的数据被重复利用多次后才被换出,极大地提高了缓存命中率。
实现分块后,矩阵乘法会变成六层循环(外层遍历块,内层遍历块内元素)。通过实验调整分块大小 S,我们找到了最佳值(在此例中为32)。分块优化带来了约 1.7倍 的额外加速,总性能达到峰值的 10%。
对于多级缓存(L1, L2, L3),理论上可以进行多层分块,但这会导致代码极其复杂(例如三层缓存需要12层循环!)。一个更优雅的解决方案是使用递归分治算法。
优化步骤6:递归分治与向量化
递归分治算法将矩阵不断四等分,递归计算子矩阵的乘积,最后合并结果。这种结构天然具有良好的缓存局部性,并且易于并行化(可以并行计算独立的子问题)。然而,过深的递归会导致巨大的函数调用开销。
解决方案: 设置一个递归基阈值。当子矩阵尺寸小于该阈值时,不再递归,而是调用一个高度优化的基础版本(例如我们之前调优过的分块并行版本)。通过实验,我们确定最佳阈值约为32。
此时,性能达到了峰值的 12%。
下一步:向量化。
现代CPU拥有SIMD(单指令多数据)向量寄存器,可以一次性对多个数据执行同一操作。我们的处理器支持AVX指令集,每个向量寄存器可容纳4个双精度浮点数。编译器在高级优化模式下(如 -O3 -march=native -ffast-math)会自动尝试向量化循环。-ffast-math 允许编译器为了速度而重新排列浮点运算顺序(牺牲严格的结合律)。
启用向量化后,性能再次翻倍。
优化步骤7:使用内联函数与最终优化
为了榨取最后一点性能,我们可以绕过编译器,直接使用CPU提供的内联函数来手动编写向量化代码。我们还可以进行其他微调,例如:
- 预转置矩阵,优化访问模式。
- 确保数据内存对齐。
- 为递归基编写更精巧的算法。
通过结合手动向量化和其他优化,我们最终将矩阵乘法的性能提升到了机器理论峰值的 41%,总加速比达到惊人的 50,000倍。这个版本甚至在某些情况下超越了英特尔官方的高度优化的数学核心库。
总结与课程范围
本节课中我们一起学习了性能工程的重要性,并通过矩阵乘法的案例,实践了一系列性能优化技术:
- 选择高效的语言: 编译型语言(C)远快于解释型语言(Python)。
- 理解内存层次结构: 循环顺序和分块技术能极大改善缓存局部性。
- 利用编译器: 使用合适的优化标志。
- 并行化: 使用多核处理器是提升性能的关键。
- 算法重构: 递归分治等算法能更好地匹配硬件特性。
- 向量化: 使用SIMD指令集进行数据级并行。



最终,我们见证了将一个运行数小时的程序优化到仅需不到一秒的完整过程。本课程(6.172)将主要专注于多核计算的性能工程,这是理解更广泛性能问题(如GPU、文件系统、网络)的坚实基础。掌握了这些核心技能,你将能够为自己编写的软件“印制”性能货币,而不再完全依赖他人的库。
002:宾利工作优化法则 🚀

在本节课中,我们将要学习一系列旨在减少程序“工作量”的优化法则。这些法则由约翰·路易斯·宾利提出,通过优化数据结构、循环、逻辑和函数,可以显著提升程序的性能。我们将逐一探讨这些技巧,并通过实例理解其应用。
概述
程序的“工作量”定义为程序在特定输入下执行的所有操作的总和。优化工作的核心思想是减少程序需要执行的操作数量,从而提升运行时间和性能。虽然算法设计能带来显著的工作量减少,但本节课将聚焦于许多其他实用的优化技巧。
需要注意的是,减少工作量并不总是直接转化为运行时间的减少,因为现代计算机硬件非常复杂,涉及指令级并行、缓存、向量化等因素。然而,减少工作量是降低程序整体运行时间的一个良好启发式方法。
数据结构优化 📊
上一节我们介绍了工作优化的基本概念,本节中我们来看看如何通过优化数据结构来减少工作量。
1. 数据打包与编码
核心思想:将多个数据值存储在一个机器字中(打包),或将数据值转换为需要更少比特的表示形式(编码)。这可以减少内存访问次数,从而提升性能。

示例:存储日期 “September 11, 2018”。
- 字符串表示:需要18字节(超过2个双字)。
- 编码表示:如果我们只存储公元前4096年到公元4096年之间的日期,大约有300万个日期。使用
ceil(lg(3,000,000)) = 22比特即可表示,这可以轻松放入一个32位字中。

虽然编码节省了空间,但提取月份等信息可能需要更多计算。C语言中的位域(bit-field)提供了一种折中方案,既能节省空间,又能方便地访问各个字段。

struct date_t {
int year:13; // 13 bits for year (8192 possibilities)
int month:4; // 4 bits for month (12 possibilities)
int day:5; // 5 bits for day (31 possibilities)
};
2. 数据结构增强


核心思想:向数据结构中添加额外信息,使常见操作需要更少的工作量。

示例:连接两个单链表。
- 原始结构:只存储头指针。要追加列表,需要遍历第一个列表找到最后一个元素,操作复杂度为 O(n)。
- 增强结构:同时存储头指针和尾指针。现在,追加两个列表可以在常数时间 O(1) 内完成,只需更新尾指针即可。

3. 预计算
核心思想:提前执行一些计算,避免在程序运行的关键时刻进行这些计算。
示例:使用二项式系数。
计算 n choose k 的公式是 n! / (k! * (n-k)!),计算阶乘开销大且可能溢出。我们可以预计算帕斯卡三角形(Pascal‘s Triangle)并存储在表中,运行时直接查表。
// 预计算帕斯卡三角形(示例代码片段)
int choose[SIZE][SIZE];
for (int n = 0; n < SIZE; n++) {
choose[n][0] = choose[n][n] = 1;
for (int k = 1; k < n; k++) {
choose[n][k] = choose[n-1][k-1] + choose[n-1][k];
}
}
// 运行时查表:result = choose[n][k];

为了进一步优化,我们可以使用编译时初始化或元编程(编写程序来生成包含常量表的源代码),从而完全消除运行时的初始化开销。

4. 缓存
核心思想:存储最近访问过的结果,避免重复计算。

示例:计算直角三角形的斜边 hypotenuse = sqrt(a*a + b*b)。平方根运算相对昂贵。我们可以创建一个缓存来存储最近计算过的 (a, b, hypotenuse) 三元组。当再次调用函数时,首先检查输入 (a, b) 是否与缓存匹配,如果匹配则直接返回缓存的斜边值。
虽然示例中的缓存大小仅为1,但在实际应用中,可以维护一个更大的缓存(如最近1000次计算结果)。软件缓存可以与硬件缓存协同工作,进一步提升性能。

5. 利用稀疏性
核心思想:避免存储和计算输入中的零元素,因为对零的操作结果是已知的。
示例:稀疏矩阵向量乘法。
- 稠密方法:进行
n^2次标量乘法,即使很多元素是零。 - 稀疏方法:使用压缩稀疏行(CSR)格式存储矩阵。
CSR 使用三个数组:rows:长度为n+1,存储每行非零元素在columns数组中的起始偏移。columns:存储非零元素的列索引。values:存储非零元素的值。
存储开销为O(n + nnz),其中nnz是非零元素数量。矩阵向量乘法只需进行nnz次操作。
// 稀疏矩阵向量乘法 (SPMV) 伪代码
for (int i = 0; i < n; i++) {
y[i] = 0;
for (int k = rows[i]; k < rows[i+1]; k++) {
int j = columns[k];
y[i] += values[k] * x[j];
}
}
类似地,CSR 格式也可用于高效表示和计算稀疏静态图。
逻辑优化 ⚙️
在优化了数据结构之后,我们来看看如何通过优化程序逻辑来减少不必要的工作。
6. 常量折叠与传播
核心思想:在编译时计算常量表达式,并将结果代入后续表达式中,避免运行时计算。
示例:构建一个数字太阳系模型(Orrery)。当半径 radius 是常量时,直径、周长、横截面积等所有相关量都可以在编译时计算出来。编译器在高级优化模式下通常会自动执行此优化。
7. 公共子表达式消除
核心思想:避免多次计算相同的表达式,改为计算一次并将结果存储起来供后续使用。
示例:
a = b + c;
b = a - d; // 第一次计算 a - d
c = b + c; // 注意:此处的 b 已改变,所以 b+c 不是公共子表达式
d = a - d; // 第二次计算 a - d,可以消除
优化后:
a = b + c;
temp = a - d; // 计算一次并保存
b = temp;
c = b + c;
d = temp; // 使用保存的结果
编译器通常能识别并执行此优化。
8. 利用代数恒等式
核心思想:用计算成本更低的等效代数表达式替换昂贵的表达式。
示例:检测两个球体是否碰撞。原始检查:distance = sqrt(dx^2 + dy^2 + dz^2) <= r1 + r2。由于平方根运算昂贵,我们可以对不等式两边平方:dx^2 + dy^2 + dz^2 <= (r1 + r2)^2。这样就消除了平方根运算。但需注意浮点数精度问题。
9. 短路评估
核心思想:在执行一系列测试时,一旦知道结果就停止后续评估。
示例:检查非负整数数组的和是否超过某个限制。
- 简单方法:求和所有元素,最后比较。总是需要查看所有元素。
- 短路方法:在累加过程中,一旦部分和超过限制,立即返回
true。如果数组元素经常在早期就超过限制,这种方法更快。但若通常需要查看所有或大部分元素,则可能稍慢,因为每次迭代多了条件检查。
C/C++ 中的 &&(逻辑与)和 ||(逻辑或)运算符就是短路运算符。
10. 测试排序
核心思想:将更可能成功(或成本更低)的测试放在前面,以利用短路评估。
示例:检查字符是否为空白字符(空格、换行、制表符、回车)。由于空格在文本中最常见,应首先检查 c == ‘ ‘,然后是 c == ‘\n‘,接着是 c == ‘\t‘,最后是 c == ‘\r‘。
11. 创建快速路径
核心思想:设计一个能提前得出结果的快速检查,避免执行更昂贵的完整计算。
示例:回到球体碰撞检测。在计算精确距离平方之前,可以先进行一个快速的包围盒相交测试:如果两个球体在某个坐标轴上的距离绝对值大于其半径之和,则它们不可能碰撞,可立即返回 false。这个测试只涉及减法和比较,比乘法(平方运算)成本低。
12. 合并测试
核心思想:将一系列测试替换为一个测试或 switch 语句。
示例:实现一个全加器(输入三个比特,输出进位和和位)。与其使用多层嵌套的 if-else 语句检查8种输入组合,不如将三个输入比特组合成一个0到7的整数,然后使用一个 switch 语句。更优的方法是使用查表法,预计算所有结果。
循环优化 🔄
循环是程序中执行最频繁的部分,优化循环能带来显著的性能提升。
13. 循环不变代码外提
核心思想:将循环体内不变的计算移到循环外部,避免每次迭代都重复计算。
示例:
for (i = 0; i < n; i++) {
y[i] = x[i] * exp(sqrt(PI/2)); // exp(sqrt(PI/2)) 是循环不变的
}
优化后:
double factor = exp(sqrt(PI/2)); // 计算一次
for (i = 0; i < n; i++) {
y[i] = x[i] * factor;
}
编译器通常能自动进行此优化。
14. 哨兵
核心思想:在数据结构中放置特殊哑元值,以简化边界条件处理,特别是循环退出测试。
示例:检查非负整数数组求和是否溢出。原始代码每个迭代需要检查循环边界和溢出条件。通过向数组末尾添加两个哨兵值(MAX_INT 和 1),可以修改循环逻辑,使得每个迭代只进行一次比较,并在循环结束后判断溢出原因。
15. 循环展开
核心思想:将循环的多次迭代合并到一次迭代中,减少循环迭代次数和控制指令的执行开销。
- 完全展开:适用于迭代次数少且已知的情况。直接写出所有迭代的代码,消除所有循环控制。
- 部分展开:更常见。例如,以因子4展开循环:
好处:for (j = 0; j < n-3; j += 4) { // 处理 a[j], a[j+1], a[j+2], a[j+3] } for (; j < n; j++) { // 处理剩余元素 }- 减少循环控制开销(检查次数减少)。
- 为编译器提供更大的基本块以进行更多优化(如指令调度、寄存器分配),这通常是更重要的好处。
注意:过度展开会污染指令缓存,可能降低性能。
16. 循环融合
核心思想:将多个遍历相同索引范围的循环合并成一个循环,节省循环控制开销,并改善缓存局部性。
示例:
// 两个独立的循环
for (i=0; i<n; i++) c[i] = min(a[i], b[i]);
for (i=0; i<n; i++) d[i] = max(a[i], b[i]);
融合后:
// 一个融合的循环
for (i=0; i<n; i++) {
c[i] = min(a[i], b[i]);
d[i] = max(a[i], b[i]);
}
融合后,a[i] 和 b[i] 被加载到缓存后,会在同一次循环迭代中用于计算 c[i] 和 d[i],缓存利用率更高。
17. 消除无效迭代
核心思想:修改循环边界,避免执行那些循环体基本为空的迭代。
示例:转置方阵。原始代码遍历所有 i, j,但只在 i > j 时交换 a[i][j] 和 a[j][i]。这导致约一半的迭代是浪费的。可以修改内层循环的边界来消除这些无效迭代:
for (i = 1; i < n; i++) {
for (j = 0; j < i; j++) { // 只遍历下三角部分
swap(&a[i][j], &a[j][i]);
}
}
函数优化 🛠️
最后,我们探讨如何通过优化函数调用来减少开销。
18. 内联
核心思想:通过将函数体直接插入到调用处,避免函数调用的开销(如参数传递、栈帧管理)。
示例:
int square(int x) { return x * x; }
// ... 在循环中调用
for (i=0; i<n; i++) sum += square(a[i]);
内联优化后:
for (i=0; i<n; i++) {
int temp = a[i] * a[i]; // 函数体被直接插入
sum += temp;
}
在C语言中,使用 static inline 关键字声明函数可以建议编译器进行内联。现代编译器非常擅长自动内联。与宏相比,内联函数更安全,能保证参数只被求值一次。
总结
本节课中我们一起学习了宾利工作优化法则,涵盖了四大类共22项优化技巧:
- 数据结构优化:包括打包/编码、增强、预计算、缓存和利用稀疏性,旨在减少存储开销和计算量。
- 逻辑优化:包括常量折叠、公共子表达式消除、代数恒等式、短路评估、测试排序、快速路径和合并测试,旨在消除冗余计算和简化控制流。
- 循环优化:包括代码外提、哨兵、循环展开、循环融合和消除无效迭代,旨在减少循环控制开销并提升指令和缓存效率。
- 函数优化:主要是内联,旨在消除函数调用开销。

重要建议:
- 避免过早优化:首先保证程序的正确性。
- 进行回归测试:建立测试套件,确保优化不会破坏原有功能。
- 理解启发式方法:减少工作量是提升性能的良好启发,但并非绝对,需结合性能分析。
- 善用编译器:现代编译器能自动完成许多低层优化,可以检查汇编代码来验证。


通过应用这些规则,你可以系统地减少程序的工作量,从而为性能提升奠定坚实基础。在后续课程中,我们将学习更多关于计算机体系结构特性的优化方法。
003:位操作技巧 🧠

在本节课中,我们将要学习计算机中数据的基本表示方法——二进制,并探索一系列利用位运算(Bit Hacks)来高效操作和计算数据的技巧。这些技巧是底层性能优化的基础。
二进制表示回顾
上一节我们介绍了课程概述,本节中我们来看看数据在计算机中的基本表示形式。
一个 W 位的字(Word)可以表示如下,我们从最右侧开始,将位编号为 x₀ 到 x_{w-1}。
存储在 X 中的无符号整数值可以通过以下公式计算:
value = Σ (x_k * 2^k), 其中 k 从 0 到 w-1
本质上,它是许多 2 的幂次方的和。如果位置 k 的位是 1,则乘以 2^k;如果是 0,则加 0。
例如,对于一个 8 位字 0b10010110:
- 位置 1(2^1)的位是 1:贡献 2
- 位置 2(2^2)的位是 1:贡献 4
- 位置 4(2^4)的位是 1:贡献 16
- 位置 7(2^7)的位是 1:贡献 128
将所有贡献相加:2 + 4 + 16 + 128 = 150。前缀0b表示这是一个二进制常量。
计算机也需要表示负数,这通过 二进制补码 实现。其计算公式为:
有符号值 = (-x_{w-1} * 2^{w-1}) + Σ (x_k * 2^k), 其中 k 从 0 到 w-2
对于最高有效位(位 w-1),如果为 1,则减去 2^{w-1}。
以前面的 0b10010110 为例:
- 低 7 位的贡献仍然是 2 + 4 + 16 = 22
- 最高位(第7位)为 1,因此减去 2^7 = 128
最终得到的有符号值为:22 - 128 = -106。
最高位因此被称为 符号位。
在二进制补码表示中:
- 全 0 的字表示 0。
- 全 1 的字表示 -1。这是因为求和 Σ2^k (k=0 到 w-2) 等于 2^{w-1} - 1,再减去符号位的 2^{w-1},结果正好是 -1。
由此可以推导出一个重要恒等式:x + (~x) = -1。这里 ~x 是 x 的按位取反(即1的补码)。因为 x 和 ~x 的每一位都相反,相加后每一位都是1,即全1字,也就是 -1。
从这个恒等式可以得出:-x = ~x + 1。这关联了二进制补码和1的补码表示。
例如,x = 0b00101100:
~x = 0b11010011-x = ~x + 1 = 0b11010011 + 1 = 0b11010100
另一种理解方式是:从右向左找到x中第一个为 1 的位,保持该位及其左边的所有位不变,将该位右边的所有位取反。
十六进制表示法
由于二进制书写冗长,我们常用更紧凑的 十六进制(基数为16)表示法。
十六进制使用数字 0-9 和字母 A-F(代表10-15)来表示。每个十六进制数字对应4个二进制位。
转换时,只需将每个十六进制数字查表替换为对应的4位二进制值并拼接即可。
例如,十六进制常量 0xDEADBEEF:
- D -> 1101
- E -> 1110
- A -> 1010
- D -> 1101
- B -> 1011
- E -> 1110
- E -> 1110
- F -> 1111
因此,0xDEADBEEF的二进制表示为1101 1110 1010 1101 1011 1110 1110 1111。在代码中,我们使用0x前缀表示十六进制数。
C语言中的位运算符
了解了表示法后,我们来看看操作它们的工具。C语言提供了一系列位运算符:
| 运算符 | 名称 | 描述 |
|---|---|---|
& |
按位与 (AND) | 两位都为1时结果为1 |
| |
按位或 (OR) | 两位至少一个为1时结果为1 |
^ |
按位异或 (XOR) | 两位不同时结果为1 |
~ |
按位取反 (NOT) | 翻转每一位,1变0,0变1 |
<< |
左移 | 位向左移动,右侧补0 |
>> |
右移 | 位向右移动,对于无符号数,左侧补0 |
例如:
A & B: 对每一位进行与操作。A \| B: 对每一位进行或操作。A ^ B: 对每一位进行异或操作。相同为0,不同为1。~A: 翻转A的所有位。A >> 3: A的位向右移动3位,左侧补0。A << 2: A的位向左移动2位,右侧补0。
注意:对于有符号数的右移 (>>),左侧可能填充符号位(算术右移)。但在位操作中,我们通常使用无符号整数以避免此问题。
常用位操作技巧
掌握了基本运算符,现在我们可以学习一些实用的位操作技巧。以下是几个核心操作:
1. 设置特定位
目标:将字 x 的第 k 位设置为1。
方法:x = x \| (1 << k)
原理:(1 << k) 生成一个只有第 k 位是1的掩码。与 x 进行或操作后,该位被设为1,其他位不变。
2. 清除特定位
目标:将字 x 的第 k 位清零。
方法:x = x & ~(1 << k)
原理:~(1 << k) 生成一个只有第 k 位是0的掩码。与 x 进行与操作后,该位被强制为0。
3. 翻转特定位
目标:将字 x 的第 k 位取反(1变0,0变1)。
方法:x = x ^ (1 << k)
原理:利用异或的特性,与1异或翻转该位,与0异或保持该位不变。
4. 提取位字段
目标:从字 x 中提取从第 p 位开始、长度为 n 的位字段。
方法:(x >> p) & ((1 << n) - 1)
原理:先右移 p 位将目标字段移到最低位,然后与一个低 n 位全为1的掩码进行与操作,以屏蔽掉高位不需要的位。
5. 设置位字段
目标:将字 x 中从第 p 位开始、长度为 n 的字段设置为值 y。
方法:
mask = ((1 << n) - 1) << p; // 创建目标位置为1的掩码
x = (x & ~mask) \| ((y & ((1 << n) - 1)) << p);
原理:首先用 ~mask 清除 x 中的目标字段(设为0),然后将 y 限制在 n 位内并左移到正确位置,最后通过或操作合并到 x 中。
无临时变量交换
一个经典的技巧是如何在不使用临时变量的情况下交换两个整数 x 和 y。
标准方法需要临时变量 t:
t = x; x = y; y = t;
使用位技巧的无临时变量交换代码如下:
x = x ^ y;
y = x ^ y; // 此时 y 变为原来的 x
x = x ^ y; // 此时 x 变为原来的 y
工作原理:
x = x ^ y:将x设置为一个“差异掩码”,其中为1的位表示x和y在该位不同。y = x ^ y:用这个掩码与原来的y异或。由于异或是自身的逆运算,(x^y)^y = x,所以y得到了x的原始值。x = x ^ y:此时y已是原始x,再用掩码与之异或:(x^y)^x = y,所以x得到了y的原始值。
注意:虽然巧妙,但这段代码通常比使用临时变量的版本性能更差,因为它引入了三条顺序依赖的指令,无法利用指令级并行。现代编译器能够很好地优化标准交换代码。不过,它常出现在面试题中。
无分支最小值计算
条件分支(如 if 语句)可能导致处理器分支预测错误,引起性能损失。有时我们可以用位运算避免分支。
计算 x 和 y 的最小值,标准方法是:
if (x < y) r = x; else r = y;
// 或使用三元运算符: r = (x < y) ? x : y;
无分支的位运算方法如下(假设为32位整数):
r = y ^ ((x ^ y) & -(x < y));
工作原理(利用C语言中 true=1, false=0):
- 情况1:
x < y为真。-(x < y)结果为全1字(即 -1)。- 表达式变为
y ^ ((x ^ y) & -1) = y ^ (x ^ y) = x。因为与全1字与操作等于本身,且y ^ y ^ x = x。 - 结果
r = x,正确。
- 情况2:
x >= y为假。-(x < y)结果为全0字。- 表达式变为
y ^ ((x ^ y) & 0) = y ^ 0 = y。 - 结果
r = y,正确。
重要提示:在现代编译器和处理器上,使用 -O3 等优化选项时,简单的条件语句通常会被编译器优化成无分支的 条件移动指令,其性能往往优于手写的位运算技巧。因此,这个技巧主要用于理解原理,或在编译器无法优化时使用。
实际应用:合并有序数组
让我们看一个分支预测影响性能的实际例子:合并两个有序数组(归并排序的子过程)。
标准实现包含一个核心比较分支:if (*a <= *b)。这个分支的结果取决于数据,是不可预测的,容易导致分支预测失败。
我们可以用无分支最小值技巧重写核心部分:
int comp = (*a <= *b);
int min = *b ^ ((*a ^ *b) & -comp);
*c++ = min;
a += comp; // 如果*a较小,comp=1,a前进
b += 1 - comp; // 否则,b前进
na -= comp;
nb -= (1 - comp);
这样就消除了不可预测的分支。但如前所述,现代编译器可能已经为原始代码生成了更优的无分支指令。
更多实用位技巧
尽管有些技巧编译器能做得更好,但了解它们对理解底层优化、处理特殊场景或进行向量化编程仍有价值。
1. 模加法(无分支)
计算 (x + y) mod n,其中 0 <= x, y < n。除法操作 % 较慢。
z = x + y;
r = z - (n & -(z >= n));
// 等价于: if (z >= n) r = z - n; else r = z;
2. 向上取整到2的幂
计算大于等于 n 的最小的2的幂,即 2^ceil(log2(n))。
n--; // 处理n本身就是2的幂的情况
n \|= n >> 1;
n \|= n >> 2;
n \|= n >> 4;
n \|= n >> 8;
n \|= n >> 16; // 对于32位字,扩展到64位需增加 n \|= n >> 32
n++;
原理:通过一系列移位和或操作,将最高位的1“扩散”到其右侧的所有位,最终得到 ...011...1 的形式,加1后即得到 ...100...0(2的幂)。
3. 获取最低有效1位掩码
获取一个只在 x 的最低有效1位处为1的掩码。
mask = x & -x;
原理:-x = ~x + 1。~x 将 x 最低1位左边的位取反,右边(包括该位)先取反后因加1而恢复。与 x 相与时,只有原最低1位处两个操作数都为1,故得到该掩码。
4. 计算二进制中1的个数
也称为“种群计数”。
- 方法A(消除最低1):
while (x) { count++; x &= (x-1); }每次循环清除最低位的1。时间复杂度与1的个数成正比。 - 方法B(查表法):预计算0-255所有数的1的个数。将长字拆分为字节查表累加。速度受限于内存访问。
- 方法C(并行分治):通过巧妙的移位、掩码和加法,以对数步数完成计算。例如:
// 计算32位整数x中1的个数 x = (x & 0x55555555) + ((x >> 1) & 0x55555555); // 每2位一组,计算组内1的个数 x = (x & 0x33333333) + ((x >> 2) & 0x33333333); // 每4位一组 x = (x & 0x0F0F0F0F) + ((x >> 4) & 0x0F0F0F0F); // 每8位一组 x = (x & 0x00FF00FF) + ((x >> 8) & 0x00FF00FF); // 每16位一组 x = (x & 0x0000FFFF) + ((x >> 16) & 0x0000FFFF); // 每32位一组 return x; - 最佳实践:现代CPU通常有硬件指令
POPCNT。在GCC中可使用__builtin_popcount()内建函数,它最快且由编译器处理可移植性。
5. 德布鲁因序列技巧
这是一个非常巧妙的技巧,用于快速计算一个 2的幂 的以2为底的对数(即找到最高位1的位置)。它利用了一个特殊的 德布鲁因序列,该序列是一个循环二进制序列,其中每个可能的 k 位二进制串都作为子串恰好出现一次。
对于 64 位字,使用 k=6 的序列(因为 2^6 = 64)。核心代码如下:
// 预先计算的德布鲁因常数和查找表
const uint64_t de_bruijn = 0x022fdd63cc95386dULL;
const int convert[64] = {
0, 1, 2, 53, 3, 7, 54, 27, 4, 38, 41, 8, 34, 55, 48, 28,
62, 5, 39, 46, 44, 42, 22, 9, 24, 35, 59, 56, 49, 18, 29, 11,
63, 52, 6, 26, 37, 40, 33, 47, 61, 45, 43, 21, 23, 58, 17, 10,
51, 25, 36, 32, 60, 20, 57, 16, 50, 31, 19, 15, 30, 14, 13, 12
};
int log2_of_power_of_two(uint64_t x) { // x必须是2的幂
return convert[(x * de_bruijn) >> 58];
}
原理简述:将2的幂 x(即 1 << n)乘以德布鲁因常数,相当于将德布鲁因序列左移 n 位。取结果的高6位作为索引查表,即可得到 n。这个技巧展示了如何用乘法和查表代替循环或位扫描指令。如今,更简单的方法是使用 popcount(x - 1)。
案例研究:N皇后问题
N皇后问题要求在 N×N 棋盘上放置 N 个皇后,使其互不攻击。回溯法是常见解法。
高效的实现可以使用 位向量 来加速冲突检测:
down:长度为 N 的位向量,记录哪些列已被占用。left:长度为 2N-1 的位向量,记录“左上-右下”对角线是否被占用。位置(r, c)对应的对角线索引为r + c。right:长度为 2N-1 的位向量,记录“右上-左下”对角线是否被占用。位置(r, c)对应的对角线索引为N - 1 - r + c。
放置皇后前,只需一次位运算即可检查位置 (r, c) 是否安全:
if ( !( (down & (1 << c)) \| (left & (1 << (r+c))) \| (right & (1 << (N-1-r+c))) ) ) {
// 位置安全,可以放置
}
这比检查二维数组要快得多。
总结与资源
本节课中我们一起学习了计算机数据的二进制表示、核心的位运算符,以及一系列强大而巧妙的位操作技巧。我们从设置、清除、翻转特定位等基本操作,到无临时变量交换、无分支最小值计算等进阶技巧,再到种群计数、德布鲁因序列等高效算法,最后探讨了位向量在N皇后问题中的应用。
虽然现代编译器和硬件指令有时能自动优化,但深入理解这些位技巧对于进行底层性能工程、理解编译器行为、编写高性能算法(尤其是向量化代码)以及解决某些特殊问题至关重要。它们也是编程面试和计算机科学素养的一部分。
延伸阅读资源:
- Sean Eron Anderson 的 Bit Twiddling Hacks 网站
- 《计算机系统:程序员的视角》(CS:APP)教科书
- 国际象棋编程维基百科上的位操作技巧
- 书籍 Hacker‘s Delight


在接下来的项目一中,你们将有机会实践许多今天学到的位操作技巧。祝各位位运算愉快!
004:汇编语言与计算机体系结构 🖥️

在本节课中,我们将要学习汇编语言与计算机体系结构。理解这些底层知识对于编写高性能代码至关重要,因为它能帮助我们充分利用硬件架构的优势。汇编语言是我们与硬件交互的最佳接口。
编译过程概述
上一节我们介绍了学习汇编语言的重要性,本节中我们来看看代码是如何从高级语言转换为机器可执行指令的。编译过程并非一步完成,它包含四个主要阶段。
以下是编译过程的四个阶段:
- 预处理:处理宏定义和文件包含等指令。可以使用
clang -E命令单独运行此阶段。 - 编译:将预处理后的源代码转换为汇编代码。可以使用
clang -S命令生成汇编文件。 - 汇编:将汇编代码转换为机器码,生成目标文件。
- 链接:将一个或多个目标文件链接在一起,生成最终的可执行文件。通常使用
ld或gold链接器。
汇编代码是机器码的助记符表示,比二进制机器码更易于人类阅读。由于汇编与机器码结构非常相似,我们可以使用 objdump 工具对机器码进行反汇编,将其转换回汇编形式进行分析。
为何需要查看汇编代码
了解编译过程后,一个自然的问题是:为什么我们需要查看程序的汇编代码?这主要有以下几个原因。
以下是查看汇编代码的几个关键原因:
- 揭示编译器行为:汇编代码能精确展示编译器实际执行了哪些优化,以及没有执行哪些优化。
- 调试编译器错误:编译器本身也是软件,可能存在缺陷。当代码行为异常时,查看汇编有助于判断是否是编译器优化错误所致。
- 手动优化:有时无法通过高级语言代码生成期望的汇编指令,此时可以直接编写汇编代码以达到最优性能。
- 逆向工程:在没有源代码的情况下,可以通过分析二进制文件的汇编代码来理解程序的功能。
课程期望与X86-64 ISA简介
在开始深入学习之前,我们先明确本课程对汇编语言掌握程度的期望,并介绍指令集架构的核心概念。
我们期望你能达到以下水平:
- 理解编译器如何用X86指令实现各种C语言结构。
- 能够借助架构手册阅读X86汇编代码。
- 理解常见汇编模式对性能的高级影响。
- 能够使用编译器内置函数来调用特定的汇编指令。
- 在必要时,具备从零开始编写汇编代码的能力。
指令集架构定义了汇编语言的语法和语义,其核心包含四个重要概念:寄存器、指令、数据类型和内存寻址模式。
寄存器
寄存器是处理器存储数据的地方。X86架构有多种寄存器,以下是最重要的几类。
以下是X86-64架构中的主要寄存器类型:
- 通用寄存器:通常为64位宽,用于存储整数和地址。例如
RAX、RBX、RCX、RDX等。 - 标志寄存器:
RFLAGS,用于记录上一条算术运算的结果状态,如是否溢出、结果是否为零等。 - 指令指针:
RIP,指向下一条要执行的指令地址。 - 向量寄存器:
XMM(128位)、YMM(256位)和ZMM(512位),用于单指令多数据流操作和浮点运算。
由于历史原因,X86架构从16位扩展到32位再到64位。为了保持向后兼容,寄存器存在别名。例如,修改32位的 EAX 寄存器也会影响其对应的64位 RAX 寄存器的高32位(在32位操作中,高32位会被清零)。
指令格式与常见操作码
上一节我们介绍了数据的存储位置,本节中我们来看看处理器执行的具体指令格式。X86-64指令的基本格式包含一个操作码和一个操作数列表。
操作数列表通常包含1个、2个或(较少见的)3个操作数,以逗号分隔。通常,所有操作数都是源操作数,而其中一个也可能同时是目的操作数。例如,在 add %edi, %ecx 指令中,操作是加法,操作数是 %edi 和 %ecx,结果存储在第二个操作数 %ecx 中。
需要注意的是,汇编语法有AT&T和Intel两种主要格式。AT&T语法中,最后一个操作数是目的操作数;而Intel语法中,第一个操作数是目的操作数。本课程使用的工具主要采用AT&T语法。
以下是一些非常常见的X86操作码:
mov:将数据从一个位置复制到另一个位置。cmov:条件移动,根据标志寄存器的状态决定是否执行移动。add/sub/mul/div:整数加、减、乘、除运算。and/or/xor/not:位逻辑与、或、异或、非操作。test/cmp:测试和比较操作,用于设置标志寄存器。jmp/jcc:无条件跳转和条件跳转。call/ret:调用子程序和返回。
数据类型与操作码后缀
操作码通常附带后缀,用于指示操作的数据类型或条件码。这对于理解指令的精确行为非常重要。
对于数据移动、算术和逻辑操作,使用单个字符后缀表示数据类型。如果后缀缺失,通常可以从上下文中推断。例如,movq 表示移动一个四字(8字节,64位)数据。
X86-64支持多种数据类型,其汇编后缀与C语言类型的对应关系如下:
- 字节:C类型
char, 汇编后缀b - 字:C类型
short, 汇编后缀w - 双字:C类型
int, 汇编后缀l或d - 四字:C类型
long, 汇编后缀q - 单精度浮点:C类型
float, 汇编后缀ss - 双精度浮点:C类型
double, 汇编后缀sd
条件跳转和条件移动指令也使用后缀来指示所检查的条件码。例如,jne 表示“如果不相等则跳转”,它检查的是 RFLAGS 寄存器中的零标志。
内存寻址模式
除了寄存器,指令还可以直接操作内存中的数据。X86提供了多种灵活的内存寻址模式。
内存寻址模式主要分为直接寻址和间接寻址两大类。一条指令中最多只能指定一个内存地址操作数。
以下是三种直接寻址模式:
- 立即数寻址:操作数是一个硬编码在指令中的常数。例如:
mov $172, %eax - 寄存器寻址:操作数存储在寄存器中。例如:
mov %ecx, %edi - 直接内存寻址:使用一个绝对内存地址。例如:
mov 0x1000, %eax
以下是三种间接寻址模式:
- 寄存器间接寻址:寄存器的内容是一个内存地址。例如:
mov (%rax), %rdi - 基址+偏移寻址:在寄存器内容的基础上加上一个常数偏移量。例如:
mov 8(%rax), %rdi - 指令指针相对寻址:基于当前指令指针加上一个偏移量。
最通用的形式是基址+索引*比例+偏移寻址,其语法为:位移(基址寄存器, 索引寄存器, 比例因子)。例如,mov 0x10(%rdi, %rsi, 4), %eax 计算的地址是 %rdi + %rsi*4 + 0x10。这种模式在访问数组或栈帧中的元素时非常有用。
跳转指令使用标签作为操作数,标签可以是符号、绝对地址或相对于指令指针的地址。间接跳转则使用一个包含目标地址的内存位置或寄存器作为操作数。
汇编语言惯用法
在阅读汇编代码时,会遇到一些常见的惯用模式,理解它们有助于快速把握代码意图。
以下是一些常见的汇编惯用法:
xor %rax, %rax:这条指令将%rax寄存器与自身进行异或操作,结果为零。这是一种快速将寄存器清零的常用技巧。test %rcx, %rcx:这条指令对%rcx自身进行位与操作并丢弃结果,但会根据结果设置标志寄存器。后接je(如果为零则跳转)可用于判断%rcx是否为零。- 空操作指令:如
nop、data16 nop等。编译器有时会插入这些指令以实现代码对齐优化,例如确保下一条指令从缓存行起始处开始,以提高取指效率。
浮点与向量硬件
现代处理器拥有专门用于浮点数和向量计算的硬件单元,理解它们对性能优化至关重要。
标量浮点运算主要通过SSE和AVX指令集(使用 XMM/YMM 寄存器)以及传统的x87指令集来执行。编译器通常更倾向于使用SSE/AVX指令,因为它们更易于优化,且其操作码与常规整数指令类似。浮点指令使用后缀指示精度,例如 movsd 表示移动双精度标量浮点数。
向量硬件通过单指令多数据流的方式提升性能。一个向量寄存器被划分为多个“通道”,一条向量指令会同时对所有通道执行相同的操作。例如,一条 addps 指令可以一次性完成4个单精度浮点数的加法。
X86架构的向量指令集经历了多次扩展:
- SSE:使用128位
XMM寄存器。 - AVX/AVX2:引入256位
YMM寄存器和三操作数指令格式(如vaddps %ymm0, %ymm1, %ymm2)。 - AVX-512:使用512位
ZMM寄存器。
向量操作通常要求内存地址对齐(即地址是向量长度的整数倍),未对齐的访问可能导致性能下降或回退到标量执行。
计算机体系结构基础
为了理解软件性能如何受硬件影响,我们需要了解一些现代处理器体系结构的基本概念。经典的处理器设计采用五级流水线。
五级流水线包括以下阶段:
- 取指:从内存读取指令。
- 译码:解析指令,确定操作和操作数。
- 执行:在算术逻辑单元中执行操作。
- 访存:读写数据内存。
- 写回:将结果写回寄存器。
流水线允许多条指令重叠执行,从而提高吞吐量。然而,当指令间存在依赖关系时,会导致“流水线停顿”。停顿主要由三种“冒险”引起:
- 结构冒险:两条指令争用同一个硬件功能单元。
- 数据冒险:一条指令需要用到前一条指令尚未产生的结果。
- 控制冒险:遇到分支指令,无法确定下一条要执行的指令。
现代处理器(如Intel Haswell)采用更深的流水线(14-19级)和多种技术来减少冒险的影响,提升指令级并行度。
提升指令级并行度的技术
为了克服流水线停顿,现代处理器采用了多种先进技术。
以下是几种关键的技术:
- 超标量设计:每个时钟周期可以取指、译码并发射多条指令到不同的执行单元。
- 乱序执行:处理器动态分析指令间的数据依赖关系,在不影响最终结果的前提下,重新排序指令的执行顺序,以充分利用空闲的执行单元。
- 寄存器重命名:通过动态分配物理寄存器来消除指令间的假数据依赖,从而允许更多的指令并行执行。
- 旁路:将一条指令的执行结果直接转发给需要它的下一条指令,而无需等待结果写回寄存器文件,减少数据冒险的延迟。
- 分支预测与推测执行:处理器预测分支指令的走向,并提前执行预测路径上的指令。如果预测正确,则获得性能提升;如果预测错误,则需要清空推测执行的结果,这会带来约15-20个周期的惩罚。


本节课中我们一起学习了汇编语言的基础知识、X86-64指令集架构的核心概念、以及现代处理器为提升性能所采用的体系结构技术。理解从高级语言到机器指令的转换过程、掌握阅读汇编代码的能力、并知晓底层硬件如何并行执行指令,是进行软件性能工程分析和优化的坚实基础。在后续课程中,我们将运用这些知识来分析和改进程序的性能。
MIT 6.172 软件系统性能工程:第5讲:C语言转汇编 🖥️

在本节课中,我们将学习C语言代码如何被翻译成汇编语言。我们将通过一个中间表示(LLVM IR)来理解这一过程,并最终了解如何生成X86-64汇编代码。课程将涵盖从简单的直线代码到复杂的控制结构(如条件语句和循环)的翻译过程,并解释Linux X86-64调用约定。
概述
理解C语言到汇编的翻译过程对于性能工程至关重要。通过查看汇编代码,我们可以更精确地了解程序的执行细节,发现编译器优化,甚至调试仅在低级别出现的错误。本节课将引导您完成从C代码到LLVM IR,再到最终汇编代码的完整翻译过程。
LLVM IR 简介 📘
LLVM IR(中间表示)是编译器在将C代码转换为汇编之前使用的一种伪汇编语言。它比X86-64汇编更简单,但保留了类似的结构。以下是LLVM IR的基本组件:
- 函数:代码被组织成函数块。
- 指令:每个操作对应一条指令,通常每行一条。
- 寄存器:使用命名的寄存器(如
%0,%1)存储数据,类似于C语言中的变量。 - 类型系统:所有数据都有显式类型(例如,
i64表示64位整数)。
LLVM IR采用静态单赋值形式,这意味着每个寄存器最多只被赋值一次。这简化了编译器分析和代码阅读。
C语言结构到LLVM IR的翻译
直线代码
直线代码是指没有条件分支或循环的连续操作序列。在LLVM IR中,这些操作被直接翻译为一系列指令。例如,C表达式 foo(n-1) + bar(n-2) 会被翻译为计算 n-1,调用 foo,计算 n-2,调用 bar,最后将结果相加的指令序列。
函数
C语言中的函数直接对应LLVM IR中的函数。参数和返回语句的翻译也非常直接。例如,一个返回64位整数的函数在LLVM IR中会有相应的返回指令。
条件语句
C语言中的 if-else 语句在LLVM IR中被翻译为条件分支。这涉及到比较操作和基于结果的跳转。控制流图会形成菱形结构,其中包含条件检查、两个分支路径以及合并点。
循环
循环的翻译更为复杂。C语言中的循环在LLVM IR中表现为控制流图中的循环结构。循环体被翻译为基本块,而循环控制(初始化、条件检查、递增)则通过额外的指令和 phi 指令来处理。phi 指令用于解决在循环入口处变量可能有多重定义的问题。
属性
LLVM IR指令可能附带属性,这些属性提供额外信息,如内存对齐或指针别名限制。这些属性有助于编译器进行优化。
LLVM IR 到汇编的翻译
将LLVM IR翻译为汇编时,编译器需要执行几个关键任务:选择具体的X86-64指令、分配寄存器,以及协调函数调用。这引入了额外的复杂性,主要涉及Linux X86-64调用约定。
Linux X86-64 调用约定
调用约定定义了函数如何调用彼此、如何传递参数、如何返回值,以及如何保存寄存器状态。关键点包括:
- 栈帧:每个函数调用都有自己的栈帧,用于存储局部变量、参数和返回地址。
- 寄存器保存:寄存器分为调用者保存和被调用者保存,以协调函数间的寄存器使用。
- 函数序言和尾声:函数开始和结束时执行特定指令来管理栈帧和寄存器。
案例研究:斐波那契数列
我们通过一个计算斐波那契数的简单C函数来演示整个翻译过程。从C代码开始,我们首先生成LLVM IR,其中包含条件检查和递归调用。然后,我们将LLVM IR翻译为X86-64汇编,展示如何插入函数序言、管理寄存器、执行条件跳转以及处理递归调用。
总结

本节课中,我们一起学习了C语言代码如何通过LLVM IR中间表示被翻译成汇编语言。我们探讨了各种C语言结构(如直线代码、函数、条件语句和循环)的翻译方法,并了解了Linux X86-64调用约定在生成最终汇编代码时的重要性。掌握这些知识将帮助您更好地理解程序在低级别的行为,从而进行有效的性能分析和优化。
006:多核编程

在本节课中,我们将要学习多核编程的基础知识。我们将从了解促使多核处理器发展的硬件背景开始,然后探讨共享内存多核机器面临的硬件挑战,特别是缓存一致性问题。最后,我们将介绍几种不同的并发编程平台,包括Pthreads、Intel TBB、OpenMP和Cilk,并学习如何使用它们来编写并行程序以充分利用多核处理器的性能。
硬件挑战与多核架构
上一节我们介绍了课程背景,本节中我们来看看为什么现代处理器都采用了多核设计。在很长一段时间里,芯片上只有一个核心。那么,为什么如今的半导体厂商开始生产拥有多个处理器核心的芯片呢?
答案源于两个因素。首先是摩尔定律,它指出芯片上可容纳的晶体管数量大约每两年翻一番。其次是时钟频率扩展的终结。在2004到2005年左右,我们无法再继续提高单核的时钟频率了。
下图展示了芯片晶体管数量和处理器时钟频率随时间的变化趋势。请注意,Y轴是对数刻度。蓝线代表了摩尔定律,显示晶体管数量持续稳定增长。然而,时钟频率线在21世纪初快速增长后便趋于平缓。如今,处理器的时钟频率通常被限制在4 GHz左右。
在2004到2005年左右发生了什么?摩尔定律意味着晶体管变得更小,从而可以降低工作电压,进而在保持相同功率密度的前提下提高时钟频率。制造商正是这样做的。但当晶体管小到一定程度,工作电压低到一定程度时,会出现漏电流现象。我们无法在保证可靠开关的同时继续降低电压。如果电压无法再降低,那么为了保持相同的功率密度,时钟频率也就无法再提高。
下图是英特尔在2004年开始生产多核处理器时展示的图表,描绘了功率密度随时间的变化。如果按照每年25%到30%的趋势继续提高时钟频率(这是2004年之前的做法),由于无法继续降低电压,功率密度将会急剧上升,最终会达到核反应堆、火箭喷嘴甚至太阳表面的功率密度。如果芯片的功率密度等于太阳表面,那它实际上已经无法正常工作了。
为了解决这个问题,半导体厂商不再提高时钟频率,但摩尔定律仍在每年提供更多的晶体管。于是,他们决定利用这些额外的晶体管来制造多个核心,并将它们放在同一块芯片上。从2004年左右开始,每芯片的核心数开始超过一个。每一代摩尔定律都可能使芯片上能容纳的核心数量翻倍,因为晶体管数量翻倍了。这个趋势一直持续到今天,并且在摩尔定律终结前还会持续几年。这就是为什么我们今天拥有多核芯片。
抽象多核架构与缓存一致性
现在,让我们来看看多核处理的具体架构。首先,我想介绍抽象的多核架构。这是一个非常简化的版本,但足以说明问题。我们有一组处理器,每个处理器都有缓存(用美元符号表示)。它们通常有私有缓存以及共享的最后一级缓存(例如L3缓存),然后它们都连接到网络。通过网络,它们可以连接到主内存,都能访问相同的共享内存。通常还有一个独立的I/O网络。这种抽象的多核架构被称为芯片多处理器(CMP)。
以下是今天讲座的提纲。首先,我将介绍共享内存多核机器的一些硬件挑战。我们将了解缓存一致性协议。在了解硬件之后,我们将研究一些软件解决方案,以便在这些多核机器上编写并行程序来利用额外的核心。我们将研究以下几种并发平台:Pthreads(一种用于并行运行代码的低级API)、Intel Threading Building Blocks(一种库解决方案),以及两种语言解决方案:OpenMP和Cilk+。Cilk+实际上是我们本课程主要使用的并发平台。
让我们看看缓存是如何工作的。假设内存中某个位置有一个值,例如 x = 3。如果一个处理器要加载 x,它会从主内存读取这个值,将其带入自己的缓存,然后加载到寄存器中。它会在缓存中保留这个值,以便在不久的将来再次访问时,无需访问主内存。
现在,如果另一个处理器也想加载 x,它会做同样的事情:从主内存读取值,带入自己的缓存,然后加载到寄存器。实际上,如果值已经在其他处理器的缓存中,不一定非要访问主内存,有时通过其他处理器的缓存获取值可能更便宜。
现在,如果我们想存储 x,将其值改为其他值,比如这个处理器想设置 x = 5。它会将 x = 5 写入并存储在自己的缓存中。现在,当第一个处理器再次加载 x 时,它看到 x 的值在自己的缓存中,于是直接读取,得到值 3。这里的问题是什么?问题是第一个处理器缓存中的 x 值已经过时了,因为另一个处理器更新了它。现在,第一个处理器缓存中的 x 值无效了。这就是问题所在。多核硬件的主要挑战之一就是解决缓存一致性问题,确保不同处理器缓存中的值在更新后保持一致。
解决这个问题的基本协议之一是MSI协议。在这个协议中,每个缓存行都有一个状态标签,共有三种可能的状态:M(修改)、S(共享)和I(无效)。这是在缓存行粒度上进行的,因为存储这些信息的开销相对较大。我们使用的机器上缓存行大小是64字节,这是当今大多数Intel和AMD机器的典型值。
MSI协议中的三种状态含义如下:
- M(修改):当缓存块处于修改状态时,意味着没有其他缓存可以以M或S状态包含此块。
- S(共享):块是共享的,其他缓存也可以拥有处于共享状态的此块。
- I(无效):缓存块无效,本质上等同于该缓存块不在缓存中。
为了解决缓存一致性问题,当一个缓存修改某个位置时,它必须通知所有其他缓存,它们的值现在已经过时。它会通过将其他缓存中该缓存行的状态从S改为I,来使所有其他副本失效。
让我们看看这是如何工作的。假设第二个处理器要存储 y = 5,而之前 y 的值是17,并且处于共享状态。当执行 y = 5 时,第二个处理器会将其缓存中该缓存行的状态设置为M,然后使所有包含该缓存行的其他缓存中的缓存行失效。现在,第一个和第四个缓存中 y=17 的状态都是I,因为该值已过时。
当加载一个值时,可以首先检查缓存行是M还是S状态。如果是,可以直接读取该值。但如果处于I状态或不存在,则必须从另一个处理器的缓存或主内存中获取该块。
实际上还有许多其他协议,例如MESI(“messy”协议)、MOESI等。有些是专有的,它们的功能各不相同。所有这些协议都非常复杂,很难保证正确性。事实上,形式化验证最早的成功案例之一就是改进这些缓存一致性协议以保证其正确性。
如果两个处理器试图修改同一个值,硬件会处理这个问题,其中一个会先发生。首先实际修改的处理器将使所有其他副本失效,然后第二个修改的处理器再次使所有其他副本失效。当许多处理器试图修改同一个值时,会产生所谓的“失效风暴”,大量失效消息在硬件中传播,这可能导致巨大的性能瓶颈。硬件仍然会保证其中一个处理器最终写入该值,但在编写并行代码时应该意识到这个性能问题。
所有这些都是在硬件中实现的。对于我们的目的,我们不需要理解硬件的所有细节,只需要从高层次理解它在做什么,以便理解何时以及为何会出现性能瓶颈。
并发编程平台简介
前面我们讨论了共享内存硬件,现在让我们看看一些并发平台。今天我们将研究四个平台。
首先,什么是并发平台?编写并行程序非常困难,要保证正确性已属不易,若要优化性能则更加困难。这个过程非常痛苦且容易出错。并发平台抽象了处理器核心,处理同步和通信协议,并执行负载均衡,从而使编程工作变得容易得多。
为了说明这些并发平台,我将使用斐波那契数列的例子。斐波那契数列中每个数字是前两个数字之和,递推关系如下:
F(n) = F(n-1) + F(n-2),其中 F(0)=0, F(1)=1
这是一个递归程序,基本上实现了上一张幻灯片中的递推关系。如果 n 小于2,直接返回 n。否则,计算 fib(n-1) 存入 x,计算 fib(n-2) 存入 y,然后返回 x 和 y 的和。
需要声明的是,这实际上是一个非常糟糕的算法,因为它需要指数时间。有更好的线性时间算法(自底向上计算)甚至对数时间算法(基于矩阵乘法)。尽管如此,它仍然是一个很好的教学示例,因为它能放在一张幻灯片上,并说明我们今天要涵盖的所有并行概念。
fib(4) 的执行树显示,fib(2) 被计算了两次,存在重复计算。对于更大的树(如 fib(40)),会有更多重叠计算。两个递归调用实际上可以并行执行,因为它们是独立的计算。提取并行性的关键思想是同时执行两个递归子调用。事实上,可以递归地进行:fib(3) 的两个子调用也可以并行执行,fib(2) 的两个子调用也可以,依此类推。这就是从该算法中提取并行性的关键思想。
使用 Pthreads 实现并行
现在,让我们看看如何使用 Pthreads 来实现这个简单的斐波那契算法。Pthreads 是线程的标准 API,在所有基于 Unix 的机器上都受支持。如果使用微软产品编程,等效的是 WinAPI 线程。Pthreads 实际上是 POSIX 标准(IEEE 1003.1c-1995)。它基本上是一个“自己动手”的并发平台,类似于并行编程的汇编语言。
它作为一个函数库构建,具有特殊的非C语义,因为仅用C语言无法指定代码的哪些部分应并行执行。Pthreads 提供了一个函数库,允许你在程序中指定并发性。每个线程实现了一个处理器的抽象,这些线程随后被多路复用到实际的机器资源上。创建的线程数量不一定必须与机器上的处理器数量匹配。所有线程通过共享内存进行通信。Pthreads 提供的库函数屏蔽了线程间协调所涉及的协议。
现在,我想看看关键的 Pthreads 函数。第一个是 pthread_create,它接受四个参数:
pthread_t *类型,用于存储新线程的标识符。pthread_attr_t *,设置一些线程属性,我们可以设为 NULL 使用默认属性。- 一个函数指针,指定线程创建后要执行的函数。
void *参数,存储要传递给执行函数的参数。
该函数返回一个错误状态整数。
另一个函数是 pthread_join。它表示我们希望在此处阻塞,直到指定的线程完成。它接受线程标识符作为参数,以及一个用于存储终止线程状态的指针。pthread_join 也返回一个错误状态。
使用 Pthreads 实现斐波那契的代码如下所示。左边是原始的顺序代码 fib 函数。为了使其并行运行,我们添加了许多其他内容。首先,有一个 thread_args 结构体,用于存储传递给线程执行函数的参数。然后,有一个 thread_fun 函数,它从 thread_args 结构体中读取输入参数,调用 fib(i),并将结果存储到结构体的输出字段中,最后返回 NULL。
在右侧,是实际调用左侧 fib 函数的主函数。我们初始化执行这些线程所需的一系列变量。首先检查如果 n 小于30,由于线程创建的开销,并行执行并不划算,因此直接顺序执行程序。这个想法被称为“粗化”。
接下来,它将输入参数 n-1 打包到 args 结构体中。然后调用 pthread_create,传入线程变量、NULL属性、我们定义的 thread_fun 以及 args 结构体。如果线程创建成功,状态将为0,我们可以继续。在继续的同时,我们执行 fib(n-2) 并将其结果存储到 result 变量中。此时,fib(n-1) 和 fib(n-2) 正在并行执行。接着是 pthread_join,它等待我们创建的线程完成,因为我们需要两个子调用的结果才能完成此函数。完成后,检查状态,如果成功,则将参数结构体的输出加到 result 中。
这段代码的并行程度如何?这里只创建了一个线程,所以并行执行两件事。如果在四个处理器上运行此代码,最大加速比只能是2,因为它只在顶层创建一个线程。如果想让它递归创建线程,代码会变得复杂得多。这是使用 Pthreads 实现此代码的缺点之一。
Pthreads 的一些问题包括:
- 高开销:创建线程通常需要超过10^4个周期,这导致非常粗粒度的并发。
- 可扩展性问题:所示的斐波那契代码最多只能获得1.5倍的加速(对于双核)。这是因为两个并行调用的工作量不同(
fib(n-1)和fib(n-2)),其工作量比例约为黄金比例1.6,加上一些开销,所以加速比约为1.5。要利用更多核心,需要重写代码,使其更复杂。 - 模块性问题:斐波那契逻辑没有很好地封装在一个函数中,这使代码缺乏模块性,难以维护。
- 代码复杂:必须进行参数打包,并参与容易出错的负载均衡协议。
为什么这里提到1958年的影子?1958年发生了第一件大事:第一个编译器(Fortran编译器)诞生。在此之前,程序员用汇编语言编写程序,必须手动进行参数打包。第一个编译器的好处在于它为我们完成了所有这些参数打包工作。在 Pthreads 中必须这样做,类似于用汇编语言编写代码。
使用 Intel TBB 实现并行
我们看了 Pthreads,接下来看看 Threading Building Blocks。TBB 是一个库解决方案,由英特尔开发。它作为C++库实现,运行在本机线程之上,但程序员不直接处理线程,而是指定任务。这些任务使用受MIT研究启发的工作窃取算法自动在线程间进行负载均衡。Intel TBB 专注于性能,并且使用 TBB 编写的代码比使用 Pthreads 编写的代码更简单。
在 TBB 中,我们必须创建任务。在斐波那契代码中,我们创建了一个 FibTask 类。在任务内部,我们必须定义 execute 函数,该函数在启动任务时执行计算,这是我们定义斐波那契逻辑的地方。该任务接受参数 n 和 sum(输入和输出)。
在 TBB 中,可以轻松创建提取更多并行性的递归程序。在这里,我们递归创建两个子任务 A 和 B,可以直接将参数传递给 FibTask,而无需自己打包参数。然后,我们设置 ref_count,这是我们必须等待的任务数量加1(加1是等待我们自己)。这里我们创建了两个子任务,加上我们自己,所以是2+1。之后,我们使用 spawn(b) 调用启动任务 B。然后执行 spawn_and_wait_for_all(a),这将启动任务 A 并等待 A 和 B 都完成后再继续。之后,我们可以将结果相加并存入 sum 变量。
这些任务是递归创建的,因此与仅在顶层创建一个线程的 Pthreads 实现不同,这里我们实际上递归创建了更多任务,从而可以从代码中获得更多并行性,并扩展到更多处理器。我们还需要一个主函数来启动程序:创建一个计算 fib(n) 的根任务,然后调用 spawn_root_and_wait(a)。
TBB 除了任务之外还有许多其他功能。它提供了许多C++模板来表达常见模式,例如用于循环并行的 parallel_for、用于数据聚合的 parallel_reduce、用于软件流水线的 pipeline 和 filter。TBB 还提供了许多并发容器类(如哈希表、树、优先队列),允许多个线程安全地并发访问和更新。此外,还有各种互斥库函数,如锁和原子操作。
使用 OpenMP 实现并行
TBB 是并发问题的库解决方案,现在我们将看两种语言解决方案:OpenMP 和 Cilk。让我们从 OpenMP 开始。
OpenMP 是一个行业联盟制定的规范。有多个编译器支持 OpenMP,包括开源和专有的。如今,GCC、ICC、Clang 以及 Visual Studio 都支持 OpenMP。OpenMP 以编译器编译指令的形式为 C/C++ 和 Fortran 提供语言扩展。程序员使用这些编译指令来指定哪些代码段应并行运行。OpenMP 也运行在本机线程之上,但程序员不接触这些线程。OpenMP 支持循环并行(parallel for)、任务并行以及流水线并行。
使用 OpenMP 实现斐波那契的代码如下。整个代码非常简洁,比 Pthreads 实现干净得多,性能也更好。我们有一些编译器指令。创建并行任务的编译指令是 #pragma omp task,我们为 fib(n-1) 和 fib(n-2) 创建 OpenMP 任务。还有 shared 编译指令,指定参数中的两个变量在不同线程间共享。然后,#pragma omp taskwait 表示我们将等待前面的任务完成后再继续。之后,返回 x + y。这就是全部代码。
OpenMP 还提供了许多其他编译指令,例如用于循环并行的 parallel for、reduction,以及用于调度和数据共享的指令。OpenMP 还有各种同步结构,如屏障、原子更新、互斥锁等。OpenMP 也是一种非常流行的编写并行程序的解决方案。
这段代码实际上与处理器数量无关。有一个调度算法决定任务如何映射到不同的处理器。可以拥有比可用处理器更多的任务,调度算法会处理这些任务到处理器的映射,这对程序员是隐藏的,尽管可以使用调度编译指令向编译器提供提示。在底层,这是使用 Pthreads 实现的,Pthreads 必须进行操作系统调用来直接与处理器核心通信并进行多路复用等。
使用 Cilk 实现并行
我们将看的最后一个并发平台是 Cilk。更准确地说,我们将看 Cilk Plus。Cilk Plus 中的 Cilk 部分是对 C/C++ 的一小组语言扩展,支持 fork-join 并行。例如,斐波那契示例就使用了 fork-join 并行。Plus 部分支持向量并行(在作业中已经体验过)。Cilk+ 最初由 Cilk Arts(一家 MIT 衍生公司)开发,该公司于2009年7月被英特尔收购。Cilk+ 的实现基于获得奖项的 MIT Cilk 多线程语言,该语言由 Charles Leiserson 的研究小组在二十年前于 MIT 开发。
它采用了一个可证明高效的工作窃取调度器,允许实现理论高效的算法。Cilk Plus 还提供了一个用于并行化带有全局变量代码的超对象库。在作业四中将有机会使用超对象。Cilk Plus 生态系统还包括有用的编程工具,例如用于检测确定性竞争的 Cilksan 竞争检测器,以及称为 Cilkview 的可扩展性分析器。
但实际上,我们本课程不会使用 Intel Cilk+,而是使用一个更好的编译器,该编译器基于 Tapir-LLVM,并支持 Cilk+ 的 Cilk 子集。Tapir-LLVM 最近由 MIT 的 TB Schardl、William Moses 和 Charles Leiserson 开发。与其他现有的 Cilk 实现相比,Tapir-LLVM 通常能生成更好的代码。它是目前可用的最好的 Cilk 编译器。他们去年就此写了一篇很好的论文,并获得了 PPoPP 的最佳论文奖。Tapir-LLVM 目前使用 Intel Cilk+ 运行时系统,并且支持比现有 Cilk 编译器更通用的功能,例如,除了生成函数,还可以生成不是单独函数的代码块,这使得编写程序更加灵活。
Cilk 的斐波那契代码如下所示。它也非常简单,看起来与顺序程序非常相似,只是代码中多了 cilk_spawn 和 cilk_sync 语句。这些语句的作用是:cilk_spawn 表示命名的子函数(即 cilk_spawn 语句后面的函数)可以与父调用者并行执行。然后,该函数将调用 fib(n-2),现在 fib(n-2) 和 fib(n-1) 可以并行执行。cilk_sync 表示在所有生成的子函数返回之前,控制流不能通过此点。它将等待 fib(n-1) 返回,然后再执行返回 x+y 的返回语句。
需要注意的是,Cilk 关键字授予并行执行的权限,但并不强制命令并行执行。即使这里写了 cilk_spawn,运行时系统也不一定必须并行运行 fib(n-1) 和 fib(n-2)。我只是说这两件事可以并行运行,由运行时系统根据其调度策略决定是否并行运行。
让我们看另一个 Cilk 的例子:循环并行。我们想进行矩阵转置,并且希望就地完成。想法是将对角线以下的元素与其在对角线以上的镜像元素交换。代码如下:我们有一个 cilk_for 循环,从 i=1 到 n-1,内层循环从 j=0 到 i-1,然后交换 A[i][j] 和 A[j][i]。要并行执行 for 循环,只需在 for 关键字前加上 cilk_。就这么简单。在内部,cilk_for 循环被转换为嵌套的 cilk_spawn 和 cilk_sync 调用。编译器将递归地将迭代空间分成两半,生成其中一半并并行执行另一半,然后递归进行,直到迭代范围变得足够小,此时并行执行不再有意义,就顺序执行该范围。
除了循环并行,Cilk 还有其他特性。假设我们有一个 for 循环,每次迭代只是将变量 sum 增加 i,这实际上是计算从0到 n-1 的总和然后打印结果。尝试并行化此代码的一个直接方法是将 for 改为 cilk_for。这段代码能正常工作吗?不一定,因为它不一定给出正确答案。cilk_for 说可以并行执行这些迭代,但它们都在更新同一个共享变量 sum,这存在所谓的确定性竞争。我们将在下一讲详细讨论确定性竞争。
Cilk 有一个很好的方法来解决这个问题,即使用归约器,这是前面提到的超对象的一个例子。使用归约器时,不是将 sum 变量声明为 unsigned long 数据类型,而是使用宏 CILK_C_REDUCER_OPADD 来指定我们要创建一个具有加法函数的归约器,然后注册这个归约器。在 cilk_for 循环内部,我们可以增加归约器视图的 sum。这可以并行执行并给出与顺序运行相同的答案。归约器将为你处理这个确定性竞争。最后,打印结果时,sum 将是期望的值,使用完后用宏 CILK_C_UNREGISTER_REDUCER 注销归约器。
这是处理归约问题的一种方法。实际上,可能希望使用许多其他有趣的归约操作符。通常,可以为幺半群创建归约器。幺半群是具有结合性二元运算和单位元的代数结构。加法运算符就是一个幺半群,因为它是结合的、二元的,单位元是0。Cilk 还有几个其他预定义的归约器,包括乘法、最小值、最大值、或、异或等。也可以定义自己的归约器,在下一个作业中将有机会使用归约器并为列表编写一个归约器。
Cilk 的另一个优点是,程序总是有一个有效的串行解释。Cilk 程序的串行幻象总是一个合法的解释。对于左边的 Cilk 源代码,串行幻象基本上是去掉 cilk_spawn 和 sync 语句后得到的代码,看起来就像顺序代码。请记住,Cilk 关键字授予并行执行的权限,但并不一定命令并行执行。如果在单核上运行此 Cilk 代码,它实际上不会创建这些并行任务,并且会得到与顺序程序相同的答案。串行幻象也是一个正确的解释。与其他解决方案(如 TBB 和 Pthreads)不同,在这些环境中很难得到一个与顺序程序行为一致的程序,因为它们实际上做了许多额外的工作来设置这些并行调用、创建参数结构和其他调度结构。而在 Cilk 中,很容易获得串行幻象,只需将 cilk_spawn 和 cilk_sync 定义为空,将 cilk_for 定义为 for,就能得到一个有效的顺序程序。在调试代码时,可能首先想检查 Cilk 程序的串行幻象是否正确,可以使用宏或编译器标志轻松实现,这提供了一种很好的调试方式,因为不必从并行程序开始,可以先检查串行程序是否正确,然后再调试并行程序。
Cilk 的理念是允许程序员在应用程序中表达逻辑并行性。程序员只需识别代码的哪些部分可以并行执行,而不必确定哪些部分应该并行执行。然后,Cilk 有一个运行时调度器,在运行时自动将执行程序映射到可用的处理器核心上,它使用工作窃取调度算法动态地完成此操作。工作窃取调度器用于在不同处理器之间均衡地分配任务。我们将在未来的讲座中更多地讨论工作窃取调度器,但我想强调的是,与今天看到的其他并发平台不同,Cilk 的工作窃取调度算法在理论上是高效的,而 OpenMP 和 TBB 的调度器在理论上并非高效。这是一个很好的特性,因为它保证你在 Cilk 之上编写的算法也是理论高效的。
下图是 Cilk 生态系统的高层图示。将 Cilk 源代码传递给你喜欢的 Cilk 编译器(Tapir-LLVM 编译器),生成可以在多个处理器上运行的二进制文件。然后将程序输入传递给二进制文件,在任意多个处理器上运行它,从而对程序的并行性能进行基准测试。也可以进行串行测试:获取 Cilk 程序的串行幻象,将其传递给普通的 C/C++ 编译器,生成只能在单处理器上运行的二进制文件,然后在此单线程二进制文件上运行串行回归测试套件。这允许你对串行代码的性能进行基准测试,并调试在顺序运行此程序时可能出现的任何问题。另一种方法是编译原始的 Cilk 代码,但在单处理器上运行。有一个命令行参数告诉运行时系统要使用多少处理器,如果将该参数设置为1,则只使用一个处理器。这允许你对代码的单线程性能进行基准测试,并且在单核上运行的并行程序的行为应与串行幻象的执行完全相同。这是使用 Cilk 的优势之一。因为可以轻松使用 Cilk 平台进行串行测试,这允许你将串行正确性与并行正确性分开。正如前面所说,可以先调试串行正确性以及任何性能问题,然后再转向并行版本。另一个要点是,因为 Cilk 实际上在其任务内部使用串行程序,所以即使编写并行程序,优化串行程序的性能也是有好处的,因为优化串行程序的性能也会转化为更好的并行性能。
Cilk 的另一个好工具是 Cilksan(Cilk 清理器),它将检测代码中的任何确定性竞争,这将极大地帮助你调试正确性和性能问题。如果使用 Cilksan 标志编译 Cilk 代码,它将生成一个插桩的二进制文件,运行时将查找并定位程序中的所有确定性竞争,告诉你竞争发生的位置,以便检查该部分代码并在必要时修复。这是对并行程序进行基准测试的非常有用的工具。
Cilk 还有另一个好工具叫 Cilkview,它是一个性能分析器,将分析程序中可用的并行度以及它所做的工作总量。同样,向编译器传递一个标志以开启 Cilkview,它将生成一个插桩的二进制文件,运行此代码时将提供可扩展性报告。在下一个项目中会发现这些工具非常有用。我们将在下一讲中更多地讨论这两个工具。Cilkview 将分析你的程序扩展到更大机器的能力,基本上会告诉你代码可能利用的最大处理器数量。
总结



本节课中我们一起学习了多核编程的基础。首先,我们看到如今大多数处理器都有多个核心,要获得高性能需要编写并行程序。但并行编程可能非常困难,尤其是如果必须直接在处理器核心上编程并与操作系统交互。Cilk 非常好,因为它从程序员那里抽象了处理器核心,处理同步和通信协议,并执行可证明良好的负载均衡。在下一个项目中,将有机会使用 Cilk 实现自己的并行屏幕保护程序,这是一个非常有趣的项目。
007:竞态与并行性

在本节课中,我们将要学习并行编程中的两个核心概念:竞态条件和并行性分析。我们将了解什么是竞态条件,为什么它们是有害的,以及如何使用工具来检测它们。同时,我们也会学习如何量化程序的并行性,理解工作、跨度等概念,并探讨调度理论如何影响并行程序的性能。
竞态条件
上一节我们介绍了并行编程的基本概念,本节中我们来看看并行编程中一个常见且棘手的问题:竞态条件。
竞态条件是并发编程的祸根,你肯定不希望代码中出现竞态条件。历史上,一些著名的竞态错误曾导致灾难性后果。例如,Therac-25放射治疗机中的软件竞态条件导致三人死亡,多人重伤。2003年北美大停电也是由软件中的竞态错误引起的,导致5000万人断电。这些错误非常难以通过常规测试发现,因为它们并不在每次程序执行时都出现。最难以发现的竞态错误是那些非常罕见的事件,这使得定位和修复它们变得极其困难。
什么是竞态?
确定性竞态是最基本的竞态形式。当两条逻辑上并行的指令访问同一个内存位置,且至少有一条指令执行写操作时,就会发生确定性竞态。
让我们看一个简单的例子。在以下代码中,首先将 x 设为0,然后执行一个包含两次迭代的 cilk_for 循环,每次迭代都递增变量 x,最后断言 x 等于2。
x = 0;
cilk_for (int i = 0; i < 2; i++) {
x++;
}
assert(x == 2);
这个程序中实际上存在竞态。为了理解竞态发生的位置,我们需要查看执行图。我们将每条语句标记为一个字母:语句A是 x = 0,之后由于 cilk_for 循环的两个迭代可以并行执行,我们将有两条并行路径B和C,每条路径都将 x 递增1,最后在D处断言 x 等于2。
这种图被称为依赖图,它指明了哪些指令必须在执行下一条指令之前完成。图中显示B和C必须在A执行后才能进行,但B和C可以并行发生,因为它们之间没有依赖关系,而D必须在B和C完成后才能发生。
为了理解为什么这里存在竞态错误,我们需要仔细查看这个依赖图。当你运行这段代码时,x++ 实际上会被翻译成三个步骤:
- 将
x的值加载到某个处理器寄存器r1中。 - 递增
r1。 - 将
r1的结果存回x。
对于另一个迭代,同样会加载到 r2,递增 r2,然后存回 x。这里存在竞态,因为这两个存储操作 x = r1 和 x = r2 都在向同一个内存位置写入。
这个例子中一个棘手的地方在于,竞态错误并不总是发生。如果其中一个分支在开始另一个分支之前完整执行了它的三个指令,那么 x 的最终结果将是2,这是正确的。因此,竞态错误并不总是显现,这也是这类错误难以发现的原因之一。
竞态的类型
确定性竞态主要有两种类型,如下表所示。假设指令A和指令B都访问某个位置 x,并且A与B并行。
| 操作A | 操作B | 竞态类型 | 结果 |
|---|---|---|---|
| 读 | 读 | 无竞态 | 安全 |
| 读 | 写 | 读-写竞态 | 非确定性结果 |
| 写 | 读 | 读-写竞态 | 非确定性结果 |
| 写 | 写 | 写-写竞态 | 非确定性结果 |
如果两条指令都只是读取该位置,则没有问题。如果其中一条指令写入,而另一条读取,则会发生读-写竞态,程序可能产生非确定性结果,因为最终答案可能取决于读取操作发生在写入操作更新值之前还是之后。如果两条指令都写入同一共享位置,则会发生写-写竞态,同样会导致程序行为非确定性,因为最终答案可能取决于A先写还是B先写。
如果两段代码之间没有确定性竞态,我们就说它们是独立的。这意味着两段计算不能共享一个内存位置,其中一段计算写入,另一段计算读取,或者两段计算都写入该位置。
如何避免竞态
竞态非常糟糕,你应该避免在程序中出现竞态。以下是一些避免竞态的提示:
cilk_for循环的迭代应该是独立的:你应该确保cilk_for循环的不同迭代不会写入相同的内存位置。- 在
cilk_spawn语句和对应的cilk_sync之间,被 spawn 的子函数的代码应该与父函数的代码独立,这包括被 spawn 子函数额外 spawn 或调用的子函数所执行的代码。你应该确保这些代码段之间没有读或写竞态。 - 注意机器字长:在读写打包数据结构时需要警惕竞态。
例如,考虑一个包含两个 char 类型成员 a 和 b 的结构体 X。并行更新 x.a 和 x.b 可能会引起竞态。这是一个棘手的竞态,因为它取决于编译器的优化级别。幸运的是,在本课程使用的 Intel 机器上,这个例子是安全的。但如果你使用非标准类型(例如 C 语言的位域功能,且字段大小不是标准大小),或者并行更新一个字内的各个位,则可能会遇到竞态。
Cilksan 竞态检测器
幸运的是,Cilk 平台有一个非常好的工具叫做 Cilksan 竞态检测器。如果你使用 -fsanitize=cilk 标志编译代码,它将生成一个经过插桩的 Cilksan 程序。如果一个表面上确定性的 Cilk 程序在给定输入下可能表现出与串行执行不同的行为,那么 Cilksan 保证会报告并定位潜在的竞态。
Cilksan 采用回归测试方法,程序员提供不同的测试输入,对于每个测试输入,如果程序中可能存在竞态,它就会报告这些竞态。它会识别涉及竞态的文件名、行号、变量,包括堆栈跟踪,这在调试代码和查找程序中的竞态时非常有帮助。
需要注意的是,你应该确保所有程序文件都经过插桩,因为如果只插桩部分文件,可能会遗漏一些竞态错误。Cilksan 竞态检测器的一个优点是,只要存在潜在的竞态,它总是会报告,这与许多其他“尽力而为”的竞态检测器不同,后者可能只在竞态实际发生时部分时间报告。
Cilksan 是你最好的朋友,在调试作业和项目时请使用它。
并行性分析
上一节我们讨论了竞态条件及其危害,本节中我们来看看如何量化程序的并行性。
什么是并行性?
我们如何定量地定义并行性?当有人说他们的代码高度并行时,这意味着什么?为了形式化地定义并行性,我们首先需要了解 Cilk 执行模型。让我们回顾一下之前看到的计算斐波那契数的代码。
为了分析并行性,我们将程序执行过程建模为一个有向无环图,称为计算 DAG。在这个 DAG 中:
- 每个顶点对应一个串,串是指令序列,不包含
spawn、sync或从 spawn 返回。 - 边分为四种类型:生成边、调用边、返回边和继续边。
假设每个串执行需要单位时间。我们定义:
- 工作:在单处理器上执行程序所需的时间,记作 T₁。它等于计算 DAG 中所有串的数量。
- 跨度:在无限多处理器上执行程序所需的时间,也称为关键路径长度或计算深度,记作 T∞。它等于计算 DAG 中最长路径的长度。
工作定律与跨度定律
有两个定律关联这些量:
- 工作定律:Tₚ ≥ T₁ / P。这是因为在每一步,P 个处理器最多只能完成 P 个单位的工作。
- 跨度定律:Tₚ ≥ T∞。这是因为即使有无限多处理器,程序也必须花费至少 T∞ 的时间来完成最长路径上的计算。
并行度
程序的最大可能加速比由 T₁ / T∞ 给出,这个比值被称为程序的并行度。它表示程序平均每单位跨度可以完成多少工作。
例如,在一个计算 DAG 中,如果 T₁ = 18, T∞ = 9,那么并行度就是 18 / 9 = 2。这意味着无论使用多少个处理器,最大加速比不会超过 2。
并行松弛度
并行松弛度定义为 (T₁ / T∞) / P,即 T₁ / (P * T∞)。它衡量了程序具有的并行性相对于处理器数量的充裕程度。根据经验,通常希望程序的并行松弛度至少为 10,这样才能有效分摊调度机制的开销。
如果并行松弛度很高(远大于 1),那么根据贪婪调度定理,可以获得接近线性的加速比。贪婪调度定理指出,任何贪婪调度器实现的运行时间满足:Tₚ ≤ T₁ / P + T∞。
Cilk 调度器
Cilk 使用工作窃取调度器。在该调度器中,每个处理器维护一个双端队列来存放就绪的串。处理器通常从队列底部操作,就像串行程序中的栈一样。当一个处理器的工作队列为空时,它会随机选择另一个处理器作为“受害者”,并从其队列顶部“窃取”工作。理论证明,工作窃取调度器可以达到期望运行时间:Tₚ = T₁ / P + O(T∞)。
在实践中,只要有足够的并行度(即 T₁ / T∞ >> P),工作窃取发生的频率就很低,从而能够实现接近线性的加速比。
栈空间边界
Cilk 支持 C 语言中关于指针的栈规则:指向栈空间的指针可以从父任务传递给子任务,但不能从子任务传递给父任务。Cilk 使用一种“仙人掌栈”来支持并行函数调用中的多栈视图。
可以证明,P 处理器执行所需的栈空间 Sₚ 满足:Sₚ ≤ P * S₁,其中 S₁ 是串行执行所需的栈空间。这是一个宽松的上界,在实践中通常远小于此值。
总结
本节课中我们一起学习了并行编程中的两个关键主题:竞态条件和并行性分析。
我们首先了解了什么是确定性竞态,包括读-写竞态和写-写竞态,以及它们如何导致程序行为的非确定性。我们学习了如何使用 Cilksan 工具来检测程序中的潜在竞态,这是调试并行代码的宝贵工具。

接着,我们探讨了如何量化程序的并行性。我们引入了工作、跨度和并行度的概念,并使用计算 DAG 模型进行分析。我们了解了工作定律和跨度定律如何为并行程序的运行时间设定下限,以及贪婪调度定理如何提供上限。最后,我们简要介绍了 Cilk 所使用的工作窃取调度器的工作原理及其性能保证。

理解这些概念对于编写正确、高效的并行程序至关重要。在接下来的作业和项目中,请务必使用 Cilksan 来检查竞态,并思考你算法的并行度,以充分利用多核处理器的计算能力。
008:多线程算法分析 📊

在本节课中,我们将学习如何分析任务并行算法和多线程算法。这要求大家具备算法课程的基础知识,特别是分治递归的求解方法。我们将回顾分治递归和主方法,然后将其应用于分析并行循环和矩阵乘法等常见模式。
分治递归与主方法回顾 🔍


上一节我们提到了分析多线程算法需要算法基础。本节中,我们来看看分治递归的标准形式及其求解方法。
分治递归通常具有以下形式:
T(n) = a * T(n/b) + f(n)
这表示解决一个规模为 n 的问题,需要解决 a 个规模为 n/b 的子问题,并且划分和合并结果的代价为 f(n)。通常,当 n 很小时,T(n) 为常数。
我们可以通过递归树来理解这个递推式。根节点的代价为 f(n),它有 a 个孩子,每个孩子对应一个规模为 n/b 的子问题。树的高度为 log_b(n),叶子节点数量为 a^(log_b(n)) = n^(log_b(a))。
求解此类递推式有一个通用方法,称为主方法。它主要处理以下三种常见情况:
以下是主方法的三种情况:
- 情况一:如果
f(n) = O(n^(log_b(a) - ε))(ε > 0),即f(n)比n^(log_b(a))多项式地小,那么递归树的总代价由叶子节点层决定,T(n) = Θ(n^(log_b(a)))。 - 情况二:如果
f(n) = Θ(n^(log_b(a)) * log^k n)(k ≥ 0),即f(n)与n^(log_b(a))增长相似,仅差一个对数因子,那么T(n) = Θ(n^(log_b(a)) * log^(k+1) n)。 - 情况三:如果
f(n) = Ω(n^(log_b(a) + ε))(ε > 0),且满足正则条件a * f(n/b) ≤ c * f(n)(c < 1),即f(n)比n^(log_b(a))多项式地大,那么递归树的总代价由根节点决定,T(n) = Θ(f(n))。


对于大多数多项式或多项式乘以对数项的函数,正则条件都能满足。


让我们通过几个例子来熟悉主方法的应用。




以下是几个递推式求解示例:

T(n) = 4T(n/2) + n:这里a=4, b=2, f(n)=n。n^(log_2(4)) = n^2,n比n^2多项式地小,属于情况一,解为Θ(n^2)。T(n) = 4T(n/2) + n^2:这里f(n)=n^2,与n^2相等(k=0),属于情况二,解为Θ(n^2 log n)。T(n) = 4T(n/2) + n^3:这里n^3比n^2多项式地大,属于情况三,解为Θ(n^3)。T(n) = 4T(n/2) + n^2 / log n:这是一个特例,不满足主方法情况二的条件(k不能为负),主方法不适用。实际解为Θ(n^2 log log n)。

多线程算法分析 🧵


现在我们将工作-跨度分析模型应用于多线程算法。首先分析最常见的并行模式:循环。
分析并行循环
许多程序通过并行化循环来实现并行。我们以一个原地矩阵转置代码为例。
for (int i = 0; i < n; ++i) {
for (int j = 0; j < i; ++j) {
swap(A[i][j], A[j][i]);
}
}
我们并行化外层循环(例如使用 OpenMP 的 #pragma omp parallel for 或 Cilk 的 cilk_for)。注意内层循环的迭代次数随 i 变化(Θ(i)),这意味着负载可能不均衡。
在像 Cilk 这样的运行时系统中,并行循环通常被转换为递归的分治结构。一个 cilk_for 循环会被实现为一个递归函数,该函数将迭代范围不断对半分割,直到达到某个粒度(如单个迭代或一小块迭代),然后并行执行这些小块。
对于上述矩阵转置代码(仅并行化外层循环):
- 工作量
T1:所有迭代的总代价。内层循环代价为Θ(i),求和得Θ(n^2)。递归控制结构增加的内部节点数仅为Θ(n),因此总工作量仍为Θ(n^2)。 - 跨度
T∞:最长路径的代价。递归控制部分的深度为Θ(log n)。但最重的一个叶子任务(对应i = n-1)需要Θ(n)时间。因此,总跨度为Θ(log n) + Θ(n) = Θ(n)。 - 并行度
T1 / T∞:Θ(n^2) / Θ(n) = Θ(n)。对于较大的n,这通常能提供足够的并行度。
如果我们将内层循环也并行化:
- 工作量
T1:仍为Θ(n^2)。 - 跨度
T∞:外层循环控制跨度Θ(log n),内层循环(对于最大的i)控制跨度也是Θ(log n),而叶子任务(单次交换)为Θ(1)。总跨度为Θ(log n) + Θ(log n) + Θ(1) = Θ(log n)。 - 并行度
T1 / T∞:Θ(n^2) / Θ(log n)。这比仅并行化外层循环的并行度更高。
然而,更高的理论并行度并不总是意味着更好的实际性能,因为我们需要考虑开销。只要并行度远大于处理器数量,通常就已足够。过度并行化可能增加不必要的调度和函数调用开销。


循环开销与粒度控制




考虑一个简单的向量加法循环:
cilk_for (int i = 0; i < n; ++i) {
A[i] += B[i];
}
其工作量 T1 = Θ(n),跨度 T∞ = Θ(log n),并行度 Θ(n / log n)。


但递归实现中,每个叶子任务只执行一次加法和内存访问,而生成(spawn)和同步(sync)的函数调用开销 S 可能与之相当甚至更大。这会导致显著的开销。
为了减少开销,我们可以增加粒度(Grain Size)。例如,设置一个粒度 G,使得递归在迭代数小于等于 G 时直接执行一个串行循环。这样,函数调用开销 S 就被分摊到 G 次迭代上。
假设每次迭代的代价为 I,每次生成/返回的开销为 S。
- 工作量
T1:n * I + (n/G - 1) * S。我们希望G足够大,使得(n/G)*S项相对于n*I可忽略,即G >> S/I。 - 跨度
T∞:G * I + log(n/G) * S。我们希望跨度小,这要求G小。这与减少工作量的需求矛盾。





因此,需要在工作量开销和跨度(并行度) 之间进行权衡。通常目标是选择足够大的 G,使得 G >> S/I,从而控制开销,同时确保剩余的并行度仍远大于处理器数量。



另一种不好的实现方式是连续生成许多小任务:
for (int i = 0; i < n; i += G) {
spawn vector_add(A+i, B+i, G); // 处理 G 个元素
}
sync;
如果 G=1,则跨度 T∞ = Θ(n),并行度仅为 Θ(1),这是无效的并行。即使 G>1,其跨度也为 Θ(n/G + G),当 G = √n 时取得最优跨度 Θ(√n),并行度为 Θ(n / √n) = Θ(√n)。这通常不如递归分治法的 Θ(log n) 跨度好。


性能提示

基于以上分析,我们总结一些并行循环的性能提示:



以下是编写高效并行循环的建议:


- 最小化跨度以最大化并行度:因为并行度是工作量与跨度的比值。
- 用并行度换取更低的开销:如果并行度充足(远大于处理器数),可以增加粒度来分摊函数调用等开销。
- 优先使用递归分治或并行循环原语:而非连续生成许多小任务。
- 确保每个生成的任务有足够的工作量:使计算开销远大于任务管理开销。
- 优先并行化外层循环:相对于内层循环,这有助于分摊并行控制开销。
- 注意调度开销:对比两个并行方案:1) 两个大任务并行;2)
n个小任务并行。后者可能产生Θ(n)的调度开销,而前者只有常数开销。
案例研究:矩阵乘法 🧮


上一节我们讨论了循环的通用分析方法,本节中我们来看看一个经典计算密集型任务的并行化:矩阵乘法。
标准的 O(n^3) 矩阵乘法有三层嵌套循环。最容易的并行化方式是并行化最外两层循环(i 和 j),因为最内层 k 循环对 C[i][j] 有写冲突,不能直接并行化。
cilk_for (int i = 0; i < n; ++i) {
cilk_for (int j = 0; j < n; ++j) {
for (int k = 0; k < n; ++k) {
C[i][j] += A[i][k] * B[k][j];
}
}
}
- 工作量
T1:Θ(n^3)。 - 跨度
T∞:两个外层cilk_for贡献Θ(log n),内层串行循环贡献Θ(n),总跨度Θ(log n + log n + n) = Θ(n)。 - 并行度
T1 / T∞:Θ(n^2)。对于n=1000,并行度约为百万级,远超典型处理器数。
另一种策略是使用分治算法(如 Strassen 算法的基础版本)。将 n x n 矩阵划分为四个 n/2 x n/2 子块,然后进行8次子矩阵乘法和矩阵加法。
在实现分治矩阵乘法时,需要注意子矩阵的索引计算。当子矩阵是更大矩阵的一部分(即数据未复制)时,访问子矩阵的 (i, j) 元素不能简单地用 base + i * (n/2) + j,因为内存布局是行优先的。正确的偏移量计算需要考虑父矩阵的完整行宽 n:
M_sub[i][j] 的地址 = M_base + i * n + j
其中 n 是父矩阵的行宽,而不是子矩阵的行宽。

通过分治递归,我们可以实现一个并行度极高的矩阵乘法。但同样,实际中需要设置适当的基线大小(递归停止条件),当子矩阵规模足够小时,改用高效的串行或平铺算法,以避免过度的递归开销。






总结 📝

本节课中我们一起学习了多线程算法的性能分析方法。
我们首先回顾了分治递归和主方法,这是分析算法复杂度的基础工具。接着,我们将工作-跨度模型应用于并行循环的分析,理解了工作量、跨度和并行度的计算,并探讨了粒度控制对平衡开销与并行度的重要性。最后,我们以矩阵乘法为例,分析了不同并行策略的复杂度,并指出了在实现分治算法时需要注意的子矩阵索引问题。

关键要点在于:设计并行算法时,不仅要追求高并行度,更要关注实际开销。通过合理选择算法和调整参数(如循环粒度、递归基线大小),才能在目标机器上获得真正的性能提升。
009:编译器能做与不能做的事 🛠️

在本节课中,我们将要学习编译器在优化代码时能做哪些事,以及它存在哪些局限性。我们将通过具体的例子来了解编译器如何分析并转换代码,以及作为程序员,我们如何编写代码来帮助编译器更好地进行优化。
概述
上一节我们介绍了多线程算法的理论分析。本节中,我们来看看编译器这个“沉默但极其聪明的队友”是如何工作的。编译器通过一系列转换过程来分析并编辑代码,以提升其性能。理解这些过程,能帮助我们写出更高效的代码,并理解为何有时编译器未能达到我们的预期。
编译器优化概述
编译器接收LLVM中间表示作为输入,通过一系列称为“转换过程”的步骤来分析和编辑代码,以优化性能。这些过程会按预定的顺序运行多次。
以下是获取编译器优化报告的方法:
- 使用编译器标志
-Rpass=.*可以要求编译器报告它执行了哪些优化。 - 报告会详细说明哪些转换成功应用,并包含分析结论。
然而,这些报告通常非常冗长且包含大量编译器内部术语,对于初学者来说可能难以理解。此外,并非所有优化过程都会生成报告,因此报告无法展示完整的优化过程。
编译器优化示例
为了理解编译器如何工作,我们将通过几个简单的例子来展示其优化机制。
标量值优化
首先,我们来看编译器如何优化单个标量值。考虑一个简单的向量缩放函数:
vec2 vec_scale(vec2 v, double a) {
return (vec2){v.x * a, v.y * a};
}
在未优化(-O0)的LLVM IR中,标量参数 a 会被存储到栈内存中,然后在乘法操作前再加载出来。编译器可以识别出这种模式,并直接用原始的寄存器参数值替换那些内存加载操作,从而消除不必要的存储和加载指令。
结构体优化
接下来,我们看看编译器如何处理结构体。结构体比标量更复杂,因为它包含多个字段。编译器会尝试将结构体的各个字段也保存在寄存器中,而不是内存里。
对于同一个 vec_scale 函数,其输入结构体 v 的字段在未优化的IR中也会被存储到栈上。编译器会逐个字段进行分析:
- 识别出存储到某个内存地址的值,与后续从同一地址加载的值是相同的。
- 用原始的结构体字段值(已存在于寄存器中)替换掉加载指令。
- 删除因此变得无用的存储、加载和地址计算指令,最终可能连最初的内存分配也一并消除。
这样,整个操作就完全在寄存器中完成,避免了缓慢的内存访问。
函数调用优化(内联)
函数调用会产生开销。编译器的一项重要优化是“函数内联”,即用被调用函数的函数体直接替换调用点。
例如,对于一连串的向量运算函数调用(如 vec_add(vec_scale(..., a), ...)),编译器可以:
- 将
vec_scale的函数体内联到调用处。 - 内联后,可能会暴露出新的优化机会,例如消除仅为打包/解包数据而生的临时操作。
- 继续内联其他函数(如
vec_add),最终将一系列函数调用转换为一连串直接的浮点运算指令。
这消除了函数调用的所有开销。然而,编译器不会无限制地内联所有函数,原因包括:
- 递归调用:通常无法内联自身。
- 跨编译单元:如果函数定义在另一个文件中,编译器可能无法访问其代码。
- 代码膨胀:过度内联会增加代码大小,可能损害性能(例如影响指令缓存命中率)。
编译器使用成本模型来猜测何时内联是划算的,但这个模型并不总是正确。作为程序员,你可以使用属性来指导编译器:
__attribute__((always_inline)):强制内联。__attribute__((noinline)):禁止内联。- 链接时优化(LTO):允许跨文件进行更激进的内联和优化。
循环优化(代码提升)
循环是程序中最耗时的部分之一,因此编译器投入大量精力优化循环。一个常见的优化是“循环不变代码外提”。
考虑一个计算N体间引力的双重嵌套循环。在内部循环中,有些地址计算只依赖于外层循环的索引 i,而与内层循环索引 j 无关。
编译器可以分析并证明这些计算在每次内层循环迭代中结果不变,从而将它们“提升”到外层循环中执行。这样,这些指令的执行次数从 O(N²) 减少到 O(N),显著节省了运行时间。
编译器不能做的事:案例研究
尽管编译器很强大,但它也有局限性。我们通过案例来了解编译器何时会“失手”。
案例:内存别名与向量化
编译器有时会因为无法确定内存访问模式而无法进行优化。例如,一个简单的向量-标量乘加循环:
void daxpy(double *y, double *x, double a, int n) {
for (int i = 0; i < n; i++) {
y[i] += a * x[i];
}
}
这里存在一个关键问题:数组 x 和 y 的内存区域可能重叠(别名)。如果重叠,以向量化方式并行操作多个元素可能会得到错误结果。
编译器无法在编译时静态地确定它们是否重叠。因此,现代编译器(如LLVM)会生成“版本化”的代码:
- 在运行时动态检查
x和y是否别名。 - 根据检查结果,分支到向量化版本的循环(如果无别名)或非向量化版本的循环(如果有别名)。
所以,这个循环既被向量化了,也没有被向量化,这取决于运行时的具体数据。为了帮助编译器,程序员可以使用 restrict 关键字来明确告知编译器指针不会别名。
内存别名的普遍影响
内存别名问题会影响许多优化。例如,在一个矩阵乘法的朴素实现中,编译器可能因为无法确定指向不同矩阵的指针是否会在循环内相互影响,而不敢将某些计算移出循环(循环不变代码外提)。
编译器使用“别名分析”技术来静态推断指针关系,但这个问题在理论上是不可能的。在实践中,编译器会利用类型信息、作用域等元数据进行尽可能好的猜测。
作为程序员,帮助编译器的最佳方式是:
- 使用
restrict关键字指明指针不会别名。 - 使用
const关键字指明数据是只读的。
这些注解能为编译器提供关键信息,解锁更多优化可能。
总结
本节课中我们一起学习了编译器优化的内部机制。我们看到,编译器通过一系列机械的转换过程来优化代码,例如将值保留在寄存器中、内联函数以及提升循环不变代码。这些优化通常与你之前学过的Bentley规则优化有相似之处。
同时,我们也认识到编译器的局限性,尤其是在处理内存别名问题时。编译器必须做出保守假设,这可能导致其无法进行某些优化,或者生成额外的运行时检查代码。


理解编译器能做什么和不能做什么,是成为高效性能工程师的关键。它让你能够编写出既清晰可维护,又能充分发挥硬件潜力的代码。记住,你的“聪明队友”——编译器——需要你通过清晰的代码和恰当的注解来获得最好的发挥。
010:测量与计时 📏⏱️

在本节课中,我们将要学习如何可靠地测量软件性能。我们将探讨如何消除系统中的“噪音”以获得准确的测量结果,介绍几种测量工具,并讨论性能建模的基本概念。理解这些内容对于评估和优化代码至关重要。


系统静默化 🤫
上一节我们介绍了测量性能时可能遇到的挑战,本节中我们来看看如何通过“静默化”系统来减少测量误差。静默化指的是关闭或控制系统中可能干扰测量的各种因素,以获得更稳定、可重复的结果。
在计算机系统中,存在许多导致测量结果波动的因素。以下是一些常见的干扰源:
- 守护进程和后台任务:系统中运行的非必要服务或定时任务会占用资源。
- 中断:例如鼠标移动或网络活动产生的中断,会打断正在测量的程序。
- 代码和数据对齐:代码或数据在内存中的位置(例如是否跨越缓存行或页边界)会影响访问速度。
- 线程放置:在多核机器上,程序运行在哪个核心上会影响性能(例如,核心0通常被系统任务占用较多)。
- 运行时调度器:操作系统的调度策略,特别是带有随机性的调度器,会引入不确定性。
- 超线程(同时多线程):一个物理核心模拟多个逻辑核心,共享功能单元,可能影响性能评估。
- 多租户:在云环境中,其他用户的任务可能与你竞争缓存、网络等资源。
- 动态频率与电压调节:系统为控制功耗和发热,动态调整CPU频率和电压,这会直接影响运行时间。
- 睿频加速:当只有少数核心活跃时,自动提升CPU频率。
- 网络流量:网络活动可能间接影响系统性能。
静默化系统可以显著降低测量结果的方差。例如,在一项实验中,关闭超线程、睿频加速和后台服务后,100次运行的时间差异从最高达25%降低到不足1%。这使得我们能够更可靠地检测出微小的性能改进(例如3%的提升)。
以下是实现系统静默化的一些实用技巧:
- 确保没有其他作业在运行,关闭不必要的守护进程和定时任务。
- 断开网络连接,避免外部干扰。
- 测量时不要移动鼠标等外设。
- 对于串行作业,避免在核心0上运行。
- 在BIOS或系统设置中关闭超线程、DVFS和睿频加速功能。
- 使用
taskset命令将工作线程固定到特定的CPU核心上,防止操作系统调度器将其迁移。
需要注意的是,即使在高度静默化的系统上,由于硬件层面的因素(如DRAM内存的软错误纠正需要额外周期),完全确定性的结果在现代硬件上也是无法保证的。
代码对齐的影响 📐
代码在内存中的对齐方式可能对性能产生意想不到的影响,这种影响有时甚至比编译器优化级别的改变还要大。
一个常见的问题是,对代码的微小修改(例如增加一个字节)可能导致后续所有代码的地址偏移,从而改变其缓存行或页面对齐状态。这可能会显著影响缓存命中率和TLB效率,进而导致性能波动。


另一个更隐蔽的问题是,链接器命令行中目标文件(.o 文件)的顺序也可能影响最终可执行文件中代码段的布局,从而影响性能。
为了缓解代码对齐问题,现代编译器提供了一些支持。例如,可以强制所有函数或基本块在缓存行边界对齐。LLVM编译器就提供了相关选项:
-align-all-functions:强制所有函数在缓存行边界开始。-align-all-blocks:强制所有基本块对齐(可能增加代码大小并引入空操作指令)。-align-blocks-without-fall-through:仅对齐那些没有顺序执行前驱的基本块,这是一种更实用的折中方案。
此外,一些看似无关的因素也可能通过影响数据对齐来改变性能。例如,可执行文件的名称长度会影响环境变量在栈上的布局,进而可能改变关键数据的页面对齐情况。
性能测量工具 🛠️
上一节我们讨论了减少系统噪音的方法,本节中我们来看看几种测量软件性能的具体工具和技术。主要有五种基本方法:
-
外部测量:使用如
time这样的命令行工具来测量整个程序的运行时间。time命令可以报告三种时间:- 实际时间:程序从开始到结束的墙上时钟时间。
- 用户时间:程序在用户态消耗的CPU时间。
- 系统时间:程序在内核态消耗的CPU时间(例如处理系统调用)。
- 注意:用户时间与系统时间之和通常不等于实际时间,因为CPU可能被其他进程占用。
-
程序插桩:在代码中手动插入计时函数调用。
- 推荐使用
clock_gettime(CLOCK_MONOTONIC, ...)。它提供纳秒级精度,耗时约83纳秒,且保证时间值不会回退(这一点很重要,因为其他一些计时器在系统时间同步时可能发生回退)。 - 避免使用
gettimeofday,其精度和准确性可能不足。 - 避免直接使用
RDTSC(读取时间戳计数器)指令。虽然它更快(约32纳秒),但不同核心的计数器可能不同步,且在DVFS启用时,将周期数转换为实际时间很复杂。
- 推荐使用
-
程序中断:通过周期性中断程序并检查其调用栈来了解时间花费在哪里。
- 穷人分析器:手动使用调试器(如GDB)中断程序,多次采样后查看最常见的栈轨迹。
- GProf 等工具自动化了这个过程。但需要注意,如果采样频率不足(例如GProf默认每秒100次),对于短时间运行的程序,统计结果可能不准确。
-
硬件与操作系统支持:利用CPU的性能监控计数器。
- Perf 工具集和 libpfm4 库可以访问大量硬件事件计数器(如缓存命中/失效、分支预测错误、指令执行数等)。
- 需要注意:许多计数器的具体含义文档不全;同时监控过多计数器时,硬件可能会采用时间复用等统计方式,而非精确计数;某些优化(如硬件预取)可能不被特定计数器记录。
-
程序模拟:使用模拟器(如 Cachegrind)运行程序。
- 优点:可以获得完全可重复、详细的底层数据(如精确的缓存模拟结果),并且可以定制收集任何需要的统计信息而不干扰程序运行。
- 缺点:运行速度远慢于真实硬件,且模拟器模型可能无法完全反映真实硬件的所有行为。
三角测量法 是一种最佳实践:永远不要只相信一种测量方式。应该结合多种不同的测量方法,确保它们讲述的是同一个“故事”。如果结果存在差异,就需要深入探究原因。
性能建模与统计 📊
上一节我们介绍了多种测量工具,本节中我们来看看如何解读和分析测量数据,这涉及到性能建模和统计方法。
性能工程的基本流程很简单:有一个程序A,修改后得到A‘,测量A’的性能,如果A‘比A快,则用A’替换A,如此循环。然而,如果无法可靠地测量,就很难判断A‘是否真的优于A。
选择合适的统计量
假设我们在一个有背景噪音的计算机上,对一个确定性程序测量了100次运行时间。哪个统计量最能代表该软件的“原始”性能?
- 算术平均值
- 几何平均值
- 中位数
- 最大值
- 最小值
最小值 通常是最佳选择,因为它最能抵抗噪音的影响。对于确定性程序,任何高于最小值的测量结果都可以被认为是噪音导致的。而中位数和平均值则会包含噪音成分。
然而,选择哪个统计量取决于你的目标:
- 吞吐量:关注算术平均值(如CPU利用率)。
- 实时性:关注最大值或高百分位数(如保证99%的请求在100毫秒内完成)。
- 资源约束:关注最大值(如内存使用峰值不能超过100MB)。
- 能耗成本:关注总和或平均值。
比较两个程序
要比较两个程序A和B哪个更快,一个有效的策略是进行多次“头对头”比较运行。记录A击败B的次数。然后可以使用统计假设检验(如符号检验)来计算p值。p值表示在“A和B性能无差异”(零假设)的前提下,观察到当前或更极端结果的概率。如果p值很小(例如小于0.05),我们就有理由拒绝零假设,认为A和B的性能存在显著差异。这种方法即使在有噪音的环境中也能很好地工作,但需要足够的运行次数。
处理比率数据
在汇总性能提升比率(如“B比A快多少倍”)时需要特别小心。对多个比率直接求算术平均值是错误的,因为它不具备良好的数学性质(例如,mean(B/A) 不等于 1 / mean(A/B))。
正确的做法是使用几何平均值来处理比率。几何平均值通过对数转换,使得比率的聚合具有一致性,即 geometric_mean(B/A) = 1 / geometric_mean(A/B)。对于速率数据(如每秒操作数),则通常使用调和平均值。
模型拟合
有时我们希望通过测量数据来估计一些衍生参数,例如:总时间 = 指令数 × 每指令平均时间 + 缓存失效次数 × 每次失效代价。这可以通过最小二乘法拟合一个线性模型来实现。但需要注意过拟合问题:增加模型参数(基函数)总是可以更好地拟合现有数据,但可能降低模型对新数据的预测能力。判断过拟合的一个方法是检查移除某个参数是否对拟合质量影响不大。


总结:在本节课中,我们一起学习了软件性能测量的核心挑战与解决方案。我们了解到系统噪音(如DVFS、后台任务)会严重影响测量,并掌握了通过静默化系统(关闭干扰功能、固定线程等)来获得可靠数据的方法。我们介绍了几种主要的性能测量工具(外部计时、插桩、采样、硬件计数器、模拟器),并强调了“三角测量”的重要性。最后,我们探讨了如何运用统计学知识(选择合适统计量、假设检验、正确处理比率)来分析和比较性能数据,并提及了通过模型拟合来深入理解性能构成。记住开尔文勋爵的格言:“无法测量,则无法改进。” 可靠的测量是性能优化的基石。
011:存储分配 💾

在本节课中,我们将要学习存储分配,即如何在程序运行时动态地管理内存。我们将从最简单的栈分配开始,逐步深入到更复杂的堆分配机制,包括固定大小和可变大小的分配策略,最后探讨自动内存管理的核心概念——垃圾回收。
栈分配 📚
上一节我们介绍了存储分配的基本概念,本节中我们来看看最简单的存储分配形式:栈。
栈使用一个数组和一个指针来管理内存。数组 a 代表整个内存区域,指针 SP 指向已使用区域的末尾。要分配 X 字节,只需将 SP 指针增加 X。这非常高效,因为所有操作都是常数时间,并且代码通常可以被内联。
然而,栈分配有一个根本性的限制:它遵循后进先出的原则。你只能释放最后分配的内存块,无法释放中间的内存。这限制了栈的适用性,但对于符合这种模式的操作(如函数调用栈)来说,它极其高效。
堆分配概述 🗑️
由于栈的局限性,我们需要更通用的内存管理方式,这就是堆。堆允许更灵活的内存分配和释放,但管理起来也复杂得多。
在C/C++中,我们使用 malloc 和 free(或C++的 new 和 delete)进行堆分配。与Java/Python等带有垃圾回收的语言不同,程序员需要手动管理内存,这带来了内存泄漏、悬垂指针和重复释放等风险。
以下是管理内存时需要注意的几种错误:
- 内存泄漏:分配了内存但忘记释放,导致程序内存占用不断增加。
- 悬垂指针:指向已被释放内存的指针,解引用它会导致未定义行为。
- 重复释放:对同一块内存多次调用
free,同样会导致未定义行为。
固定大小的堆分配 🔢
为了理解堆分配,我们先从简单情况开始:假设所有内存块大小相同。
一种常见的实现方式是使用空闲链表。所有未使用的内存块通过指针连接成一个链表。分配时,从链表头部取出一个块;释放时,将被释放的块放回链表头部。
分配操作的伪代码如下:
x = free; // free指向链表头
free = free->next; // 将free指针移向下一个空闲块
return x; // 返回分配到的内存块
释放操作的伪代码如下:
x->next = free; // 将被释放块指向当前空闲链表头
free = x; // 将free指针指向被释放块
这种方法的分配和释放都是常数时间,并且具有时间局部性(最近释放的块会被优先分配)。但它可能导致外部碎片化,即已使用的内存块分散在虚拟内存空间中,这会增加页表大小、引起磁盘颠簸并降低TLB命中率。
为了缓解外部碎片化,可以将空闲链表按内存页组织,并优先从最满的页进行分配,这样有助于将内存使用集中到更少的页面上。
可变大小的堆分配 📦
实际程序中,我们需要分配不同大小的内存。分箱空闲链表是一种常见策略。
其思想是准备多个“箱子”,每个箱子存放特定大小的内存块。通常,箱子大小按2的幂次设置(如1、2、4、8字节……)。当请求分配 X 字节时:
- 计算
k = ceil(log2(X)),找到对应大小2^k的箱子。 - 如果该箱子非空,则从中分配一块。
- 如果该箱子为空,则寻找下一个更大的非空箱子(如大小为
2^(k')),将其中的一块内存分裂成更小的块(2^(k'-1),2^(k'-2), ...,2^k),放入对应的箱子,并将其中一块2^k大小的内存返回。
这种方案最多浪费一倍的内存(内部碎片化),并且理论证明,其内存使用量在最坏情况下是 M * log M,其中 M 是程序任意时刻使用的最大堆内存。
程序存储布局 🗺️
了解程序在虚拟内存中的布局有助于理解内存管理。一个典型的64位地址空间布局如下(从高地址到低地址):
- 栈:用于函数调用、局部变量,向低地址增长。
- 堆:用于动态内存分配(
malloc/free),向高地址增长。 - BSS段:存放未初始化的全局/静态变量,程序启动时初始化为零。
- 数据段:存放已初始化的全局/静态变量和常量。
- 代码段:存放程序的可执行代码。
栈和堆相向生长,但在64位地址空间中,它们几乎不可能相遇。
垃圾回收 ♻️
垃圾回收旨在自动回收程序不再使用的内存,减轻程序员的负担。其核心是区分根对象(直接可访问,如全局变量、栈变量)、存活对象(从根对象通过指针可达)和死亡对象(不可达,可安全回收)。
引用计数
每个对象维护一个引用计数,记录指向它的指针数量。当计数归零时,立即回收该对象及其引用的对象。
- 优点:简单,可及时回收。
- 缺点:无法处理循环引用,会导致内存泄漏。
标记-清除算法
该算法分为两个阶段:
- 标记阶段:从所有根对象开始,进行图遍历(如广度优先搜索),标记所有可达的存活对象。
- 清除阶段:线性扫描整个堆,回收所有未被标记的对象。
- 缺点:会产生外部碎片,并且需要扫描整个堆。
停止-复制算法
该算法使用两个内存空间:“来源空间”和“目标空间”。
- 程序平时在“来源空间”中分配内存。
- 当“来源空间”满时,暂停程序执行。
- 从根对象开始,将所有存活对象复制到连续的“目标空间”中,并更新所有指向这些对象的指针(通过在每个被移动对象原位置留下“转发指针”)。
- 交换两个空间的角色,程序在“目标空间”中继续运行。
- 优点:消除了外部碎片,只需遍历存活对象,效率更高。
- 缺点:需要双倍内存空间,并且复制存活对象有开销。
总结 📝
本节课中我们一起学习了存储分配的各个方面:
- 栈分配高效但受限,只能以LIFO顺序释放内存。
- 堆分配更灵活但复杂,我们探讨了固定大小(空闲链表)和可变大小(分箱空闲链表)的分配策略。
- 我们了解了外部碎片化(内存块分散)和内部碎片化(块内浪费)对性能的影响。
- 最后,我们介绍了垃圾回收的几种基本算法:引用计数(简单但无法处理循环引用)、标记-清除(可处理循环引用但产生碎片)和停止-复制(可处理循环引用且消除碎片,但需要额外空间)。



高效的内存管理是编写高性能软件系统的关键,理解这些底层机制将帮助你做出更明智的设计和实现选择。
012:并行存储分配 🧠

在本节课中,我们将要学习并行环境下的存储分配。我们将回顾串行存储分配的基础知识,然后深入探讨并行分配策略、堆栈管理以及垃圾收集的相关概念。
概述
存储分配是软件系统性能的关键组成部分。在并行程序中,如何高效、安全地分配和释放内存,同时避免碎片化和锁竞争,是提升性能的重要课题。本节课程将介绍并行存储分配的基本原理、不同策略及其性能权衡。
内存分配原语回顾
首先,我们来回顾一些基本的内存分配原语。
malloc:用于从堆中分配内存。调用malloc(S)会分配并返回一个指向至少包含S字节内存块的指针。返回类型是void*,但良好的编程实践是将其强制转换为所需类型的指针。aligned_alloc:用于进行对齐内存分配。调用aligned_alloc(A, S)会分配并返回一个指向至少包含S字节内存块的指针,且该内存块的起始地址是A的倍数(A必须是2的幂)。对齐分配有助于减少缓存未命中,并满足向量化指令的要求。free:用于将内存释放回堆。传入指向内存块的指针P,即可释放该块内存。- 内存泄漏:指分配了内存但未能释放,导致程序内存使用量持续增长,最终可能耗尽内存并崩溃。
- 双重释放:指对同一块内存多次调用
free。其行为是未定义的,可能导致段错误或程序出现意外行为。
mmap 系统调用
mmap 是一个系统调用,通常用于将磁盘文件映射到内存。但在存储分配的上下文中,它可以用来分配没有后备文件的虚拟内存。
void* ptr = mmap(0, size, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANON, -1, 0);
参数说明:
- 第一个参数为期望的起始地址(0表示由系统决定)。
size为要分配的字节数。PROT_READ | PROT_WRITE表示内存区域可读可写。MAP_PRIVATE | MAP_ANON表示分配进程私有的匿名内存。-1表示没有后备文件。- 最后一个参数为文件偏移量(无后备文件时为0)。
mmap 会在应用程序的地址空间中找到一个足够大的连续未使用区域,并更新页表,使得用户对该区域的访问合法。
mmap 的特性
mmap 是惰性的。当你请求一定量的内存时,它不会立即分配物理内存,而只是在页表中创建指向特殊零页的条目,并将这些页标记为只读。首次写入此类页面时,会触发页错误,此时操作系统才会分配物理内存并建立映射。
这意味着,即使物理内存只有1GB,你也可以 mmap 1TB的虚拟内存。但需要注意,进程可能在调用 mmap 很久之后,因首次触及内存时物理内存耗尽而死亡。
malloc 与 mmap 的区别
malloc是库调用,是C库中堆管理代码的一部分。它使用mmap等系统设施从操作系统获取虚拟地址空间,并负责重用内存以减少碎片和提高局部性。mmap是系统调用,负责从操作系统获取内存。
通常,对于小内存分配,应使用 malloc,因为它更快且能更好地重用内存;对于大内存分配,直接使用 mmap 也是可以的。
地址转换回顾
访问内存时使用虚拟地址。虚拟地址可分为高位(虚拟页号)和低位(页内偏移)。硬件通过查询页表,将虚拟页号转换为物理帧号,再结合偏移得到物理地址。
如果所需虚拟页不在物理内存中,会发生页错误。操作系统会检查权限并可能建立映射,否则会引发段错误。
页表查询(页漫步)开销较大,因此引入了转译后备缓冲器。TLB 缓存最近的页表查询结果。具有局部性(空间或时间)的程序能从 TLB 中获益,减少页表查询次数。
现代机器通常可以并行执行 TLB 访问和 L1 缓存访问,以降低内存访问延迟。
堆栈管理
在串行 C/C++ 程序中,使用线性堆栈来跟踪函数调用和局部变量。
串行线性堆栈
函数调用时,新的栈帧被压入栈顶;函数返回时,栈帧被弹出。指针传递的规则是:父函数可以将指向其栈变量的指针传递给子函数,但子函数不能将指向其局部变量的指针传回给父函数,因为子函数的栈帧在其返回后可能被后续函数调用覆盖。
如果要从子函数传递内存给父函数,可以在父函数的栈上或堆上分配该内存。
并行仙人掌栈
在并行程序中(如使用 Cilk),需要支持堆栈的多个并行视图,这称为仙人掌栈。它允许并行执行的函数看到与串行执行时相同的堆栈视图。
一种简单的实现策略是基于堆的仙人掌栈:每个栈帧都在堆上分配,并包含指向父栈帧的指针。函数调用时从堆分配新帧,返回时释放。
基于堆的仙人掌栈的空间界限
设 S1 为程序串行执行所需的堆栈空间,Sp 为使用 P 个工作线程执行时的堆栈空间。可以证明:Sp ≤ P * S1。
这个界限的推导利用了 Cilk 工作窃取算法的“繁忙叶子”属性:在调用树中,每个叶子节点都有一个工作线程正在处理。
示例:分治矩阵乘法
考虑一个分治矩阵乘法代码,它在递归调用前分配临时空间(大小为 O(n²)),并在返回前释放。
- 工作量:
T1(n) = 8T1(n/2) + O(n²),结果为O(n³)。 - 跨度:
T∞(n) = T∞(n/2) + O(log n),结果为O(log² n)。 - 空间:
S(n) = S(n/2) + Θ(n²),结果为Θ(n²)。
根据前述定理,在 P 个处理器上的空间上界为 O(P * n²)。通过分析算法递归树的结构,可以证明一个更紧的界:O(P^(1/3) * n²)。
基于堆的栈的问题
主要问题在于与遗留串行二进制代码的互操作性。遗留代码可能假设使用传统线性堆栈并进行相关操作,与基于堆的栈不兼容。
Cilk 的实现采用了线性栈池的策略,维护一组线性栈供工作线程使用,这不需要修改操作系统,并能保持空间和时间界限。
堆分配器基础
现在,我们将重点转向堆分配器。
关键定义
- 分配器速度:分配器每秒能维持的分配和释放操作数量。
- 用户足迹:用户程序使用的字节数(已分配未释放)随时间变化的最大值,即峰值内存使用量。
- 分配器足迹:操作系统提供给分配器的字节数随时间变化的最大值。
- 碎片化:定义为
A / U。低碎片化意味着分配器足迹接近用户足迹。 - 空间开销:用于簿记(如存储块大小的头部信息)的空间。
- 内部碎片:由于分配比用户请求更大的块而造成的浪费(例如,请求9字节得到16字节)。
- 外部碎片:由于存储空间不连续而无法使用的浪费。
- 膨胀:并行分配器所需空间超出串行分配器的额外部分,即
T / S。
优化小内存块的分配速度更为重要,因为小内存分配请求更频繁,且分配器开销在总处理时间中的占比更高。
并行堆分配策略
以下是几种常见的并行堆分配策略。
1. 全局堆 🔒
所有线程共享一个受互斥锁保护的全局堆。
- 优点:膨胀为1(空间使用与串行相同)。
- 缺点:锁竞争严重,性能随处理器数量增加而下降。所有处理器争抢同一缓存行,导致缓存一致性流量大,可扩展性差。
2. 局部堆 🏠
每个线程维护自己的堆,分配和释放操作无需同步。
- 优点:分配速度快,无锁竞争。
- 缺点:可能导致内存漂移。如果一个线程分配内存,但由另一个线程释放,那么分配线程的堆可能因看不到其他堆的空闲内存而不断向操作系统申请新内存,导致内存使用量无限增长,膨胀可能无界。
3. 局部拥有权 🏷️
每个分配的内存块都标记有所有者(分配它的线程)。释放时,内存块返回给其所有者的堆。
- 优点:
- 本地对象的分配和释放速度快。
- 释放远程对象只需与所有者线程同步,开销小于全局锁。
- 膨胀有界(≤ P)。
- 有助于减少伪共享。
- 伪共享:多个处理器访问同一缓存行上的不同内存位置,导致缓存行无效并在处理器间“弹跳”,降低性能。局部拥有权使得对象最终会回到其所有者的堆,减少了伪共享的持久性。
4. Hoard 分配器 🧩
Hoard 分配器结合了局部堆和全局堆的思想。内存被组织成大小为 S 的超块(通常是页大小的倍数)。
- 数据结构:
P个局部堆 + 1 个全局堆。 - 分配流程:
- 线程
i调用malloc。 - 首先检查线程
i的局部堆中是否有空闲对象。如果有,则从最满的非满超块中分配(以减少外部碎片)。 - 如果局部堆没有,则检查全局堆。
- 如果全局堆为空,则从操作系统获取新超块。
- 将获得的块的所有者设为
i,并返回对象。
- 线程
- 释放与平衡:释放对象
x(所有者线程i)时,将其放回堆i。Hoard 维护一个不变式,确保每个局部堆的已使用存储U_i不低于某个阈值(与已分配存储A_i相关)。如果某个堆利用率过低(例如低于一半),则将其一个超块移至全局堆。 - 膨胀界限:可以证明,Hoard 的分配器足迹
A满足A = O(U + S*P),因此膨胀率A/U = 1 + O(S*P / U)。这是一个很好的界限。
现代分配器
- jemalloc:广泛应用,对不同分配大小使用独立的全局锁,倾向于分配请求大小中地址最小的对象,并使用
madvise释放空页。 - supermalloc:较新的分配器,在某些基准测试中表现出比 jemalloc 和 Hoard 更快的速度,且代码相对简洁。
总结


本节课我们一起学习了并行存储分配的核心概念。我们从串行分配原语和 mmap 的回顾开始,理解了地址转换和堆栈管理在串行与并行环境下的区别。我们重点探讨了多种并行堆分配策略:全局堆(简单但扩展性差)、局部堆(快但可能内存漂移)、局部拥有权(平衡性能与空间)以及 Hoard 分配器(结合局部与全局堆,具有理论性能保证)。最后,我们简要介绍了现代分配器如 jemalloc 和 supermalloc。理解这些策略的权衡对于设计和实现高性能并行软件至关重要。
013:Cilk运行时系统

在本节课中,我们将深入探讨Cilk运行时系统的工作原理。Cilk是一种并行编程语言,它通过在代码中插入关键字(如cilk_spawn、cilk_sync、cilk_for)来指示并行性。运行时系统的核心任务是动态地将这些逻辑并行任务调度和负载均衡到多核系统的处理器上,其背后是一个随机工作窃取调度器,以确保执行的高效性。
🎯 核心功能与性能考量
上一节我们介绍了Cilk的基本概念。本节中,我们来看看实现Cilk运行时系统需要满足哪些核心功能要求,以及需要考虑哪些性能因素。
Cilk运行时系统需要处理几个关键问题:
- 串行执行:单个工作线程必须能够像执行普通串行程序一样执行Cilk代码。
- 工作窃取:当一个空闲处理器(“窃贼”)从另一个处理器(“受害者”)的队列中窃取任务时,它必须能够跳入一个正在执行的函数的中间,并从中断处继续执行。
- 同步:
cilk_sync语句必须能够实现细粒度的嵌套同步,即一个函数只等待其直接派生的子计算完成,而不是等待整个程序。 - 仙人掌栈:Cilk遵循C的指针规则(子函数可以访问父函数栈帧的指针,反之则不行)。运行时系统需要为所有工作线程实现这种“仙人掌栈”视图,使得不同线程可以共享部分栈视图,同时拥有独立的栈空间。
除了功能,性能也至关重要。Cilk工作窃取调度器的期望运行时间公式为:
T_P = O(T_1 / P + T_∞)
其中,T_1是总工作量,P是处理器数量,T_∞是跨度(关键路径长度)。为了实现相对于原始串行运行时间T_S的线性加速(即T_P ≈ T_S / P),我们需要:
- 充足的并行性:
T_1 / T_∞ >> P - 高工作效率:
T_S / T_1尽可能接近1
Cilk运行时系统遵循 “工作优先”原则:优化普通的串行执行路径,即使这可能会增加工作窃取时的开销。编译器负责处理快速路径(无窃取情况),而运行时系统库则处理并行执行和窃取等慢速路径。
🏗️ 工作线程队列与数据结构实现
了解了需求和目标后,本节我们来看看Cilk运行时系统如何具体实现工作线程的队列(Deque)和核心数据结构。
Cilk将工作线程的队列实现为一个独立于实际调用栈的数据结构。每个工作线程维护一个包含头尾指针的队列,队列中的条目是指向栈帧的指针。可被窃取的栈帧会额外存储一个本地结构,包含窃取所需的信息。
以下是核心数据结构:
- Cilk运行时栈帧:每个可产生并行任务的函数实例化都有一个
CilkRTS-stack-frame结构体,作为局部变量存储在栈上。它包含一个上下文缓冲区(用于保存函数在cilk_spawn或cilk_sync处的状态)、标志位字段和指向父栈帧的指针。 - 工作线程结构:包含指向其队列(头尾指针)的指针,以及指向当前栈帧的指针。
当一个函数(如foo)被编译时,编译器会生成额外的代码和辅助函数。例如,对于cilk_spawn bar(),编译器会:
- 生成一个
spawn helper函数(如spawn_bar),其中包含对bar的实际调用。 - 在
foo中插入对setjmp的调用,将其上下文(指令指针、栈指针、被调用者保存寄存器等)保存到其栈帧的缓冲区中,为可能的窃取做准备。 - 调用
spawn_bar辅助函数。
🔄 生成与串行执行路径
现在,让我们跟随一个工作线程,看看在典型的串行执行(无窃取)路径上,这些数据结构是如何运作的。
假设主函数调用了一个会产生并行任务的函数foo:
foo开始执行,其CilkRTS-stack-frame被初始化,工作线程的当前栈帧指针指向它。- 执行到
setjmp,将当前状态保存到缓冲区,并返回0。 - 条件判断为真,调用
spawn_bar辅助函数。 spawn_bar的栈帧被压入调用栈,其父指针被设置为foo的栈帧,工作线程的当前栈帧指针更新为指向spawn_bar。- 调用
CilkRTS-detach,这会将foo的栈帧(而非当前栈帧)推入工作线程队列的尾部,表示其“延续”部分(即spawn之后的代码)现在可以被窃取。 - 然后执行被派生的函数
bar。 bar返回后,执行spawn_bar中的清理代码,将当前栈帧指针指回父帧foo。- 最后调用
CilkRTS-leaf-frame。这是关键步骤:它尝试从队列底部弹出工作。在串行情况下,队列中有foo的延续,因此弹出成功,执行跳转到foo中spawn之后的延续部分,继续串行执行。
这个流程优化了无窃取时的普通执行路径。
🏃♂️ 工作窃取与状态恢复
上一节我们看了顺利的串行路径。本节中,我们来看看当有空闲处理器试图窃取工作时会发生什么。
当一个“窃贼”工作线程的队列为空时,它会随机选择另一个“受害者”工作线程,并试图从其队列的顶部窃取工作。这涉及到并发访问,因此需要同步协议(如THE协议)。窃贼会锁定受害者的队列,从顶部取出一个或多个栈帧。
窃贼现在拥有了代表某个函数延续的栈帧(例如foo的栈帧)。为了恢复执行,它使用longjmp函数。longjmp接收一个上下文缓冲区(来自刚窃取的栈帧)和一个值。它将缓冲区中保存的寄存器状态加载到窃贼处理器的寄存器中,包括指令指针。效果上,这导致当初在foo中调用的setjmp“再次返回”,但这次返回值为传递给longjmp的值(例如1)。这使得程序计数器跳过了对spawn helper的调用,直接开始执行foo中spawn之后的延续代码。
🌵 仙人掌栈的实现
窃贼现在可以执行代码了,但它和受害者共享着foo函数的部分栈帧状态。如果窃贼需要调用新函数(比如foo的延续中调用了baz),它不能污染受害者的栈。这就是“仙人掌栈”要解决的问题。
解决方案非常巧妙:
- 窃贼将基指针(RBP) 设置为指向受害者栈中共享函数(如
foo)的栈帧。这样它就能以偏移量的方式访问foo的局部变量。 - 窃贼将栈指针(RSP) 设置为指向它自己独立的调用栈顶部。
- 当窃贼需要调用新函数
baz时,它遵循标准调用约定:将当前RBP(指向foo)保存到自己的栈上,更新RBP和RSP以分配baz的新栈帧。这样,baz的调用完全发生在窃贼自己的栈上,不会干扰受害者。
通过这种RBP和RSP的分离使用,Cilk实现了栈视图的共享与独立扩展。需要注意的是,Cilk编译器会为可能被窃取延续的函数禁用“省略帧指针”的优化,以确保RBP可用。
⚙️ 同步的实现
最后,我们简要看看cilk_sync是如何实现的。当一个工作线程遇到cilk_sync时,它需要等待所有直接派生的子计算完成。
运行时系统维护一个完整帧(full frame)树结构来跟踪并行子计算的状态。每个并行活动都有一个完整帧,其中记录父帧、未完成的子计算数量等信息。
当工作线程到达sync时:
- 快速路径(常见情况):如果没有未完成的子计算(标志位显示无需同步),则直接通过,几乎无开销。
- 慢速路径:如果存在未完成的子计算,当前工作线程不能干等。它会将当前完整帧挂起,然后自己转变为窃贼,去窃取其他工作。当最后一个子计算完成时,它会负责恢复其父帧的执行,使其能够通过
sync继续。
这种设计确保了处理器资源在等待时不会被浪费,并且同步仅限于嵌套子计算。
📚 总结
本节课中我们一起学习了Cilk运行时系统的核心机制。我们了解到:
- 系统通过分离的队列数据结构和“工作优先”原则来优化性能。
- 编译器生成辅助函数并使用
setjmp/longjmp来支持工作窃取和状态恢复。 - 通过巧妙的基指针与栈指针管理,实现了共享与独立并存的“仙人掌栈”。
- 同步机制利用完整帧树来高效处理嵌套等待,避免处理器闲置。

Cilk运行时系统是一个复杂的软件层,它优雅地处理了并行任务调度、负载均衡、栈管理和同步等挑战,使得程序员能够通过简单的关键字注释就能获得高效的并行执行。
014:缓存与缓存高效算法 🚀

在本节课中,我们将深入学习缓存的工作原理以及如何设计能够高效利用缓存的算法。我们将从现代机器的缓存硬件架构开始,探讨不同类型的缓存组织方式(如直接映射、全相联和组相联),并分析算法中可能出现的各种缓存缺失。最后,我们将通过矩阵乘法的例子,对比缓存感知和缓存无关算法的设计思路与性能。
缓存硬件架构 🏗️
现代多核机器的缓存层次结构通常如下:每个处理器核心拥有私有的L1指令缓存和数据缓存,以及私有的L2缓存。所有核心共享一个最后一级缓存(LLC或L3缓存)。这些缓存通过内存控制器连接到DRAM,在多芯片系统中,芯片之间通过网络互连。
缓存层次结构的特点如下:
- 大小递增:L1缓存通常为32KB,L2缓存约为256KB,LLC可达数十MB,而DRAM则在GB到TB级别。
- 访问延迟递增:L1缓存访问最快(约2纳秒),L2稍慢(约4纳秒),LLC更慢(约6纳秒),访问DRAM则要慢一个数量级(约50纳秒)。
- 相联度递增:越靠近CPU的缓存,其相联度策略可能不同,这影响了数据在缓存中的存放位置。
由于更快的存储介质更昂贵,因此容量更小。如果我们的程序能充分利用局部性,就能更多地使用这些快速内存。
在并行环境中,由于每个核心有私有缓存,需要通过缓存一致性协议(如MESI协议)来确保所有核心对同一内存地址有一致的视图。设计正确的缓存一致性协议非常复杂。
缓存相联度 🔍
上一节我们概述了缓存层次。本节中,我们来看看数据在缓存中是如何组织和查找的,这主要取决于缓存的相联度。
全相联缓存
在全相联缓存中,一个缓存块可以存放在缓存中的任何位置。查找一个块时,需要搜索整个缓存,因为该块可能在任何地方。
地址被划分为两部分:
- 标记:使用地址中除偏移量外的几乎所有位,用于唯一标识内存块。
- 偏移量:用于定位缓存块内的具体字节。
当缓存已满时,需要根据替换策略(如最近最少使用-LRU)选择一个块进行替换。
缺点:查找速度慢,因为每次都需要搜索整个缓存。
直接映射缓存
在直接映射缓存中,每个内存块只能映射到缓存中一个特定的位置(称为“组”)。
地址被划分为三部分:
- 标记
- 组索引:用于确定块属于哪个组。
- 偏移量
优点:查找速度快,只需检查一个特定位置。
缺点:可能导致冲突缺失。即使缓存中有空闲位置,如果程序反复访问映射到同一组的不同内存块,它们也会相互驱逐,导致性能下降。
组相联缓存
组相联缓存是上述两种方案的折中。缓存被分为多个组,每个组内有多个缓存行(称为K路组相联)。一个内存块只能映射到一个特定的组,但在该组内可以存放在任何一行中。
地址划分与直接映射类似,但组索引的位数更少(因为组数更少)。
查找时,只需搜索一个组内的K个位置。随着K增大,缓存行为越接近全相联;当K=1时,即为直接映射缓存。
缓存缺失的类型 ⚠️
了解了缓存的组成方式后,我们来看看程序运行时可能发生的缓存缺失类型。
以下是四种主要的缓存缺失类型:
- 冷缺失:首次访问某个缓存块时必然发生,因为缓存初始为空。
- 容量缺失:发生因为缓存容量不足,无法容纳所有需要的工作集,即使使用全相联缓存也会发生。可通过提升程序的空间和时间局部性来减少。
- 冲突缺失:在组相联或直接映射缓存中发生,因为太多块映射到同一组,导致相互驱逐,而这些块在全相联缓存中本可共存。可通过数据填充或使用临时空间来缓解。
- 共享缺失:仅在并行环境下发生,当多核访问同一缓存行且至少有一个核进行写操作时触发。
- 真共享缺失:多个核访问同一缓存行内的相同数据。
- 伪共享缺失:多个核访问同一缓存行内的不同数据。
冲突缺失示例:访问大矩阵中一个32x32的子矩阵的某一列时,由于矩阵行字节数恰好是2的幂,导致该列所有元素地址的组索引相同,在组相联缓存中引发严重的冲突缺失。解决方法包括对矩阵进行填充,或在算法中使用临时副本来改变访问模式。
在分析算法缓存性能时,需要注意不同模型可能忽略某些缺失类型。
理想缓存模型 📐
为了理论分析算法的缓存效率,我们引入一个简化的理想缓存模型。
该模型假设:
- 两层存储结构:大小为
M字节的缓存和主内存。 - 缓存块大小为
B字节,因此可容纳M/B个缓存行。 - 缓存是全相联的。
- 使用最优先知替换策略:在需要替换时,能预知未来访问模式并做出最佳选择。
- 访问缓存中的数据成本为零,访问主存中的数据则产生一次缓存缺失。
我们关注两个性能指标:
- 工作量:算法的总操作数,即传统时间复杂度。
- 缓存缺失数:需要在缓存和主存之间传输的缓存行数量。
理想情况是设计出缓存缺失数低且不增加工作量的算法。
LRU引理:如果一个算法在大小为M的理想缓存上产生Q次缓存缺失,那么在一个大小为2M、使用LRU替换策略的全相联缓存上,它最多产生2Q次缺失。这意味着在渐近分析中,可以方便地使用最优替换或LRU替换策略进行分析。
软件工程原则:首先设计在理论上具有良好工作量和缓存复杂度的算法,然后再针对实际硬件细节进行工程优化。
缓存分析工具:缓存缺失引理与高缓存假设 🔧
在分析具体算法前,我们需要两个有用的分析工具。
缓存缺失引理
引理:假设程序需要读取R个数据段,第i个段大小为s_i字节,总大小 N = Σs_i。
若满足:
N < M/3(总数据量小于缓存容量)N/R ≥ B(平均段长不小于缓存块大小)
则将所有段读入缓存所需的缓存缺失数最多为 3N/B。
意义:只要数据段平均足够大,即使数据不连续,访问它们的缓存开销也近似于访问一个单一大数组。
高缓存假设
假设:B² ≤ cM,其中c ≤ 1。即缓存能容纳的行数(M/B)大于缓存块大小(B)。这通常在实践中成立(例如,L1缓存:64²=4096 < 32768)。
意义:该假设确保了当子矩阵的字节数(n²)小于M时,我们也能有效地将其放入缓存,而不会因为缓存行太“宽”导致空间浪费。这对于递归处理子矩阵的算法至关重要。
子矩阵缓存引理:在满足高缓存假设的缓存中,若n x n矩阵满足 cM ≤ n² < M/3,则该矩阵可放入缓存,且读入所有元素最多需要 3n²/B 次缓存缺失。
案例研究:矩阵乘法 🧮
现在,我们运用前面的理论来分析一个经典算法——矩阵乘法的缓存效率。
标准的三重循环矩阵乘法代码如下:
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++)
for (int k = 0; k < n; k++)
C[i*n + j] += A[i*n + k] * B[k*n + j];
其工作量为 Θ(n³)。
我们分析最坏情况下的缓存缺失数(假设LRU替换,行主序存储,满足高缓存假设)。关键在于分析对矩阵B的访问,因为它通常主导缺失数。
情况1:n > cM/B
无法将矩阵B的每一行的一个块放入缓存。遍历B的每一列都会导致所有行被重新加载,每次内层循环(k循环)都对B产生Θ(n)次缺失。总缺失数为 Θ(n³)。
情况2:c√M ≤ n ≤ cM/B
可以将矩阵B的每一行的一个块放入缓存,但整个矩阵放不下。此时,只有第一次遍历B的某一列时会引发缺失,后续相邻列的访问可重用缓存。总缺失数约为 Θ(n³/B)。
情况3:n < c√M
整个矩阵可放入缓存。只需在第一次访问时将矩阵读入,后续访问均命中。总缺失数为 Θ(n²/B)。
循环顺序的重要性:交换内两层循环(j和k)的顺序,使对B的访问变为按行进行,能充分利用空间局部性,将情况1的缺失数从Θ(n³)降至Θ(n³/B)。
然而,Θ(n³/B) 是最优的吗?我们可以通过分块技术做得更好。
缓存感知算法:分块矩阵乘法 🧱
为了进一步提升局部性,我们引入分块技术。
分块矩阵乘法将大矩阵划分为大小为 S x S 的子矩阵(块),并在块上执行乘法。代码变为六层循环:外层三层循环遍历块,内层三层循环在块内计算。
- 工作量:仍为
Θ(n³)。 - 缓存缺失数:通过设置
S = Θ(√M),使得三个块(两个输入块,一个输出块)能同时放入缓存。每个子问题需要Θ(S²/B)次缺失来加载数据。总缺失数为:
(n/S)³ * (S²/B) = n³/(B√M)
这比普通循环版本优了一个 Θ(√M) 因子,来自时间局部性的提升(在驱逐一个块前完成其所有计算)。
问题:S 是一个需要针对特定机器缓存大小M进行调整的“魔术”参数,这导致代码可移植性差。在多级缓存和多任务环境下,参数调优变得复杂且容易失效。
缓存无关算法:递归矩阵乘法 ♻️
为了获得良好性能且无需参数调优,我们转向分治策略,设计缓存无关算法。
递归矩阵乘法将矩阵划分为四个象限,递归计算:
C11 = A11*B11 + A12*B21
C12 = A11*B12 + A12*B22
C21 = A21*B11 + A22*B21
C22 = A21*B12 + A22*B22
- 工作量:递归式
W(n) = 8W(n/2) + Θ(1),解得Θ(n³)。 - 缓存缺失数分析:递归基案变为子矩阵能完全放入缓存的时刻(
n² < cM)。递归式:
通过递归树分析,得到总缺失数为Q(n) = 8Q(n/2) + Θ(1) if n² ≥ cM Q(n) = Θ(n²/B) if n² < cMΘ(n³/(B√M))。
关键:该算法达到了与调优后的分块算法相同的渐近缓存缺失数,但无需任何关于缓存大小M或块大小B的知识。它能自动适应任何级别的缓存,并且在多程序环境中表现稳健。
并行扩展:递归算法易于并行化。理论表明,并行执行时的缓存缺失数界限为串行执行的缺失数加上一个与“窃取”次数相关的附加项。对于跨度低的算法,并行带来的额外缓存开销很小。
总结 📚
本节课我们一起深入探讨了缓存高效算法的设计与分析。
- 缓存基础:了解了现代缓存层次结构、相联度(直接映射、组相联、全相联)及其对性能的影响。
- 缺失分类:学习了冷缺失、容量缺失、冲突缺失和共享缺失,并了解了冲突缺失的成因与缓解方法。
- 分析模型:引入了理想缓存模型、LRU引理、缓存缺失引理和高缓存假设,作为理论分析工具。
- 算法设计:
- 缓存感知:通过分块技术,可以显著提升矩阵乘法的缓存效率,但需要针对硬件调优。
- 缓存无关:通过递归分治设计的矩阵乘法,无需参数调优即可自动适应各级缓存,达到近最优性能,且易于并行化。

核心在于,通过组织计算顺序和数据布局,最大化程序的时间局部性和空间局部性。缓存无关算法提供了一种优雅且可移植的高性能算法设计范式。
015:缓存无关算法

在本节课中,我们将要学习缓存无关算法的概念、原理及其应用。我们将通过具体的例子,如热扩散模拟和排序算法,来理解如何设计能够自动适应不同缓存大小、实现良好缓存效率的算法。
概述
缓存无关算法是一种无需知晓机器具体缓存参数(如缓存大小),就能自动调整以达到良好缓存效率的算法。这与缓存感知算法形成对比,后者需要将缓存大小等参数硬编码在代码中。上节课我们介绍了矩阵乘法的缓存无关算法,本节课我们将探讨其他例子。
热扩散模拟
首先,我们来看一个科学计算中常见的例子:热扩散模拟。这涉及到求解一个著名的偏微分方程——热方程。
热方程简介
二维热方程可以建模为一个微分方程。我们有一个函数 U(t, x, y),其中 t 是时间步,x 和 y 是二维空间的坐标。我们想知道在任何时间点 t,每个 (x, y) 坐标的温度。
二维热方程公式如下:
∂U/∂t = α * (∂²U/∂x² + ∂²U/∂y²)
其中,α 是热扩散率常数。方程左边是 U 对时间 t 的一阶偏导,右边是 U 对空间 x 和 y 的二阶偏导之和。
有限差分近似法
为了用代码模拟这个微分方程,我们通常使用有限差分近似法来近似偏导数。
- 时间偏导近似:
∂U/∂t ≈ [U(t+Δt, x) - U(t, x)] / Δt - 空间一阶偏导近似(采用中心差分,使数学推导更简洁):
∂U/∂x ≈ [U(t, x+Δx/2) - U(t, x-Δx/2)] / Δx - 空间二阶偏导近似(对一阶偏导再次应用近似):
∂²U/∂x² ≈ [U(t, x+Δx) - 2*U(t, x) + U(t, x-Δx)] / (Δx)²
将上述近似代入一维热方程(为简化,先考虑一维情况),并令 Δx = Δt = 1,我们可以得到简化后的计算式:
U(t+1, x) = U(t, x) + α * [U(t, x+1) - 2*U(t, x) + U(t, x-1)]
模板计算
上述方程引导我们进行一种称为“模板计算”的操作。我们将时空网格化,用矩阵的行表示时间 t,列表示空间位置 x。
要计算 U(t+1, x) 的值,我们需要知道 U(t, x)(正下方)、U(t, x+1)(右下方)和 U(t, x-1)(左下方)这三个值。这种依赖模式称为“三点模板”。
一个直观的实现方式是使用两层循环:外层循环遍历时间步,内层循环遍历所有空间位置,逐行计算。这种方法的缓存效率如何呢?
基础循环代码的缓存分析
以下是基础循环实现的伪代码:
for (t = 0; t < T; t++) {
int cur = t % 2;
int next = (t+1) % 2;
for (x = 0; x < N; x++) {
U[next][x] = kernel(&U[cur][x]); // kernel实现上述三点计算
}
}
我们只保留两行数据(当前行和下一行),通过奇偶技巧交换角色。
假设空间大小 N 远大于缓存大小 M。分析其缓存复杂度:
- 计算每一行需要加载约 N/B 个缓存行(B 为缓存行大小)。
- 当计算下一行时,由于行数据太大,上一行所需的缓存块很可能已被逐出,导致再次发生缓存缺失。
- 因此,总缓存缺失次数约为 Θ(N*T / B)。
这种方法的缓存效率不高。虽然可以通过分块技术优化,但我们将直接介绍更简洁的缓存无关算法。
缓存无关的模板算法
我们将采用一种基于梯形区域的递归分治算法。该算法不依赖于任何缓存参数。

理想缓存模型回顾
在分析算法前,先回顾理想缓存模型:
- 两层存储层次:缓存和主存。
- 缓存容量为 M 字节,缓存行大小为 B 字节。
- 缓存是全相联的,采用最优或LRU替换策略。
- 性能指标:工作量(操作总数)和缓存缺失次数。
- 该模型捕获了容量缺失,但未捕获冲突缺失(因全相联假设)和共享缺失(因串行执行假设)。它鼓励我们优化时空局部性。


梯形分治算法
我们定义在时空网格上的一个梯形区域。该梯形具有上底(时间 t1)、下底(时间 t0)和高度 H = t1 - t0。梯形的“宽度”定义在时间中点处。

关键特性是:梯形内部所有点的计算,只依赖于梯形下底边界上的值。因此,我们可以独立地计算各个梯形。
算法采用递归分治:
- 基准情况:当梯形高度为1时,直接使用简单循环计算该行所有点。
- 递归情况:
- 空间切割:如果梯形宽度 ≥ 2倍高度(太宽),则沿斜率为 -1 的线(逆斜率为1)从中间切开。先递归计算左梯形,再计算右梯形(因右梯形依赖左梯形结果)。
- 时间切割:如果梯形宽度 < 2倍高度(太高),则沿水平线从中间切开。先递归计算下梯形,再计算上梯形(因上梯形依赖下梯形结果)。
以下是该算法的C代码框架:
void trapezoid(int t0, int t1, int x0, int dx0, int x1, int dx1) {
int ht = t1 - t0;
if (ht == 1) {
// 基准情况:计算单行
for (int x = x0; x < x1; x++) {
kernel(...);
}
} else if (/* 梯形太宽的条件 */) {
// 空间切割
int xmid = (x0 + x1) / 2;
trapezoid(t0, t1, x0, dx0, xmid, -1);
trapezoid(t0, t1, xmid, -1, x1, dx1);
} else {
// 时间切割
int tmid = t0 + ht / 2;
trapezoid(t0, tmid, x0, dx0, x1, dx1);
trapezoid(tmid, t1, x0, dx0, x1, dx1);
}
}
即使只保留两行数据,该算法也能正确工作,因为计算顺序保证了不会覆盖仍被依赖的数据。
缓存复杂度分析



我们使用递归树方法分析。由于算法在梯形太宽或太高时进行切割,保证了在到达基准情况时,梯形的高度和宽度为同一数量级,即 H = Θ(W)。

- 设叶子节点对应的梯形尺寸满足 W = Θ(M)(即能放入缓存)。每个叶子需要加载其下底数据,产生 Θ(W/B) = Θ(M/B) 次缓存缺失。
- 叶子节点数量为 Θ(NT / (HW)) = Θ(N*T / M²)(因为 H ≈ W ≈ M)。
- 因此,叶子节点的总缓存缺失为 Θ((NT / M²) * (M/B)) = Θ(NT / (M*B))。
- 内部节点仅产生常数级的缓存缺失,可忽略。
- 递归树的高度约为 log M 量级,但每层缺失次数总和与叶子层同阶,故总缓存缺失为 Θ(NT / (MB))。
与基础循环代码的 Θ(N*T / B) 相比,缓存无关算法节省了 M 倍的缓存缺失。对于 D 维问题,缓存复杂度为 Θ(N*T / (M^{1/D} * B)),此界限是最优的。
并行化考虑
将缓存无关算法并行化时,需要注意依赖关系。
- 原始空间切割 产生的两个梯形是串行依赖的,无法并行。
- 并行空间切割:可以修改为“V形切割”,产生三个梯形。左右两个黑色梯形可以并行计算,因为它们互不依赖且只依赖于自己的下底。计算完成后,再计算中间的灰色梯形。对于倒梯形,则先计算中间部分。
- 时间切割 仍然是串行的。
根据上节课的定理,在并行执行中,只要算法的跨度足够小,其缓存缺失次数主要取决于串行执行时的缓存缺失次数。因此,优化串行缓存效率至关重要。
排序算法的缓存效率
接下来,我们探讨如何使排序算法缓存高效。首先分析标准的归并排序。
标准归并排序的缓存分析
归并排序包括“合并”子程序和递归调用。
- 合并两个有序数组:工作量为 Θ(n),缓存缺失为 Θ(n/B)(因为顺序访问)。
- 归并排序递归:
T(n) = 2T(n/2) + Θ(n),工作量为 Θ(n log n)。 - 缓存复杂度:递归树每层合并的缓存缺失总和为 Θ(n/B),树高为 Θ(log(n/M))(当问题小于缓存大小时停止递归分析)。因此,总缓存缺失为 Θ((n/B) * log(n/M))。
- 当 n >> M 时,
log(n/M) ≈ log n,相比工作量只节省了 B 倍因子。 - 当 n ≈ M 时,节省了 B log n 倍。
- 当 n >> M 时,
能否做得更好?答案是肯定的,通过使用多路归并。
多路归并排序
我们不只合并两个子数组,而是合并 R 个子数组(每个大小为 n/R)。这需要使用锦标赛树(或败者树)来高效找出当前最小元素。
- 首次构建锦标赛树:需要 Θ(R) 工作量。
- 后续每次取出最小元素并更新:只需更新从该元素所在叶子到根的路径,工作量 Θ(log R)。
- 合并 n 个元素的总工作量:Θ(n log R)。
在归并排序中使用 R 路归并:
- 递归式:
T(n) = R * T(n/R) + Θ(n log R)。 - 工作量:经过计算,仍然是 Θ(n log n),与二路归并相同。
- 缓存复杂度:关键在于,如果选择 R = Θ(M/B)(在满足“高缓存假设”下,即缓存能容纳锦标赛树和每个子数组的一个缓存块),那么合并 n 个元素的缓存缺失仅为 Θ(n/B)。
- 最终缓存复杂度:递归树每层缺失 Θ(n/B),树高为 log_R(n/M)。代入 R = Θ(M/B),并利用对数换底公式,可得总缓存缺失为 Θ((n log n) / (B log M))。
与二路归并的 Θ((n log n) / B) 相比,多路归并额外节省了 log M 倍。在典型机器上(如 L1 缓存 32KB,log M ≈ 15),这是一个显著的提升。
缓存无关排序
多路归并排序不是缓存无关的,因为它需要根据 M 和 B 来调优参数 R。是否存在缓存无关的排序算法呢?是的,例如 漏斗排序。
漏斗排序的核心思想是递归地将元素分组排序,然后使用一个称为 K-漏斗 的数据结构来合并 K 个有序列表。K-漏斗 本身也是递归构造的。分析表明,漏斗排序的缓存复杂度与调优后的多路归并排序相同,即 Θ((n log n) / (B log M)),并且是渐进最优的,同时它不需要知道任何缓存参数。
除了漏斗排序,还有许多其他的缓存无关算法和数据结构被开发出来,如用于矩阵乘法、快速傅里叶变换、B树维护、优先级队列等。
总结

本节课我们一起学习了缓存无关算法的核心思想。我们通过热扩散模拟的例子,深入探讨了如何将直观的循环算法转化为基于递归分治的缓存无关梯形算法,并分析了其优越的缓存复杂度。我们还分析了归并排序的缓存效率,并引出了通过多路归并以及漏斗排序来进一步优化缓存性能的方法。缓存无关算法的优势在于其可移植性和理论性能保证,是编写高性能、可扩展软件的重要工具。
016:非确定性并行编程

在本节课中,我们将要学习非确定性并行编程。这是一个颇具挑战性的主题,因为非确定性本身非常棘手。我们将探讨什么是确定性、为什么应避免非确定性编程,以及当必须使用非确定性时,如何通过测试策略来管理它。我们还将深入探讨互斥锁、死锁以及事务内存等核心概念。
确定性 vs. 非确定性
上一节我们介绍了课程概述,本节中我们来看看什么是程序的确定性。
我们说,一个程序在给定输入下是确定性的,如果在每次执行中,每个内存位置都以相同的值序列被更新。这意味着程序的行为总是相同的。在并行程序中,不同内存位置的更新顺序可能不同,但每个单独的内存位置看到的更新序列必须一致。
确定性的主要优势在于其可重复性,这在调试时至关重要。如果程序每次运行都产生相同的结果,那么当出现错误时,你可以反复运行程序来定位问题。而非确定性程序的行为可能每次不同,这使得调试变得极其困难。
因此,并行编程的一条黄金法则是:永远不要编写非确定性的并行程序。它们可能表现出异常行为,并且难以调试。

然而,在实践中,有时我们可能不得不违反这条规则。原因可能包括追求更好的性能,或者问题本身的性质(例如处理异步输入)就决定了其非确定性。
于是,我们有了白银法则:如果必须编写非确定性并行程序,务必设计一个测试策略来管理这种非确定性。
以下是管理非确定性的一些典型测试策略:

- 关闭非确定性源:例如,在调试模式下,可以关闭内存地址随机化,或为随机数生成器使用固定的种子。
- 封装非确定性:将非确定性行为隐藏在平台或运行时系统内部,对外提供确定性的接口。
- 使用确定性替代方案:在调试时,用确定性的算法临时替换非确定性的实现。
- 利用分析工具:使用工具来检测和控制程序中的非确定性行为。
- 进行单元测试:隔离并单独测试可能受非确定性影响的部分代码。
在项目四中,管理非确定性将尤为重要,因为我们将编写一个游戏程序,其中处理器会共享游戏状态(如置换表)。能够关闭这种共享功能,对于调试搜索或评估代码至关重要。
互斥与原子性
上一节我们讨论了为何要避免非确定性,本节中我们来看看当需要共享数据时,如何通过互斥来保证正确性。
我们以一个哈希表为例。典型的哈希表通过链表法解决冲突。插入一个元素 x 时,我们计算其键的哈希值以找到对应的槽(slot),然后将 x 插入到该槽链表的头部。
现在考虑两个线程并行插入元素 X 和 Y,且它们哈希到同一个槽。如果两个插入操作交织进行,可能会导致链表损坏,例如 Y 被插入后又被 X 的后续操作覆盖,从而丢失。
标准解决方案是使一部分指令具有原子性。这意味着对于系统的其余部分而言,这些指令要么全部执行完毕,要么都未执行,永远不会看到部分执行的状态。包含原子指令的代码区域称为临界区。
实现原子性的典型方法是使用互斥锁。互斥锁是一个具有 lock 和 unlock 成员函数的对象。当一个线程试图锁定一个已被锁定的互斥锁时,该线程会被阻塞,直到锁被释放。
对于哈希表的例子,我们可以为每个槽关联一个互斥锁。在访问某个槽的链表之前,线程必须先锁定该槽的互斥锁,操作完成后再解锁。这样就确保了插入操作的原子性。
数据竞争与确定性竞争
上一节我们介绍了如何使用互斥锁,本节中我们需要区分两个重要概念:数据竞争和确定性竞争。
回顾一下,确定性竞争发生在两个逻辑上并行的指令访问同一内存位置,且至少有一个是写操作时。
互斥锁可以保证临界区的原子性,但使用锁的代码本身仍然是非确定性的。因为多个线程可能同时尝试获取同一个锁,这本身就是一个确定性竞争(访问锁变量)。然而,这是一个有意为之的、安全的竞争。
数据竞争的定义与确定性竞争类似,但附加了一个条件:两个并行指令没有持有共同的锁。如果它们访问同一内存位置且至少有一个是写操作,则存在数据竞争错误。如果它们持有至少一个相同的锁,则没有数据竞争。
关键区别在于:一个程序可能没有数据竞争,但仍然存在确定性竞争(例如,所有竞争都通过锁妥善管理了)。反之,如果存在数据竞争,则一定存在确定性竞争。
没有数据竞争是否意味着程序没有错误?不一定。例如,考虑以下有缺陷的哈希表插入代码:
- 锁定槽。
- 读取链表头。
- 解锁槽。
- (执行其他操作...)
- 再次锁定槽。
- 将新节点插入链表头部。
- 解锁槽。
这段代码没有数据竞争,因为所有共享访问都受锁保护。但它破坏了原子性:在第3步和第5步之间,链表可能被其他线程修改,导致第6步的插入基于过时的信息,从而出错。这被称为原子性违例。
良性竞争与架构影响
有时,程序中可能存在一些看似无害的竞争,称为良性竞争。例如,统计一个数组中数字0-9的出现次数:
int digits[10] = {0};
parallel_for i from 0 to n-1:
int val = A[i]; // 假设 A[i] 在 0-9 之间
digits[val] = 1; // 标记该数字出现过
如果多个并行迭代都看到相同的数字(比如两个6),它们都会执行 digits[6] = 1。由于写入的是相同的值(1),无论顺序如何,结果看起来都是正确的。这似乎是一个良性竞争。
然而,这种“良性”依赖于架构。在某些架构(如MIPS)上,写入一个字节可能不是原子操作。处理器可能需要读取包含该字节的整个字(word),修改字节,再写回整个字。如果两个线程同时修改同一个字的不同字节,它们的非原子读写操作可能交织,导致最终结果错误。
因此,即使看起来良性的竞争,也可能在特定硬件上引发问题。这就是为什么应尽可能避免非确定性编程。
Cilk Sanitizer 等工具允许为有意为之的竞争关闭竞争检测,但这很危险,因为可能掩盖真正的错误。更好的解决方案(如Intel Cilk Plus中的“假锁”)正在开发中。
互斥锁的实现与性能
上一节我们讨论了竞争的概念,本节中我们深入一层,看看互斥锁是如何实现的,以及不同实现如何影响性能。
互斥锁有几个基本属性:
-
自旋 vs. 让出:
- 自旋锁:当线程尝试获取锁但被阻塞时,它会在一个循环中不断检查锁的状态(“自旋”),消耗CPU周期。
- 让出锁:当线程无法获取锁时,它会主动让出CPU(
yield),允许操作系统调度其他线程运行。这避免了空转,但引入了上下文切换的开销。
-
可重入 vs. 不可重入:
- 可重入锁:允许已经持有该锁的线程再次获取它而不会死锁。
- 不可重入锁:如果线程试图再次获取它已持有的锁,会导致死锁。不可重入锁通常更轻量。
-
公平 vs. 非公平:
- 公平锁:按照线程请求锁的顺序(如FIFO)授予锁,防止饥饿。
- 非公平锁:允许“插队”,刚释放锁时等待的线程可能与新来的线程竞争,可能导致某些线程长时间得不到锁。非公平锁通常吞吐量更高。
一个简单的自旋锁在x86汇编中的实现核心是 lock xchg 指令,这是一个原子交换操作。为了提高性能,代码通常会先以只读方式检查锁是否空闲(cmp 指令),这使缓存行处于共享状态,减少总线流量。只有当锁可能空闲时,才执行昂贵的原子交换操作。
将自旋锁改为让出锁,只需将自旋循环中的 pause 指令替换为对操作系统 yield 的调用。
竞争性互斥锁试图结合自旋和让出的优点。其策略是:先自旋一段时间(例如,一个上下文切换所需的时间,约10毫秒),如果在此期间锁被释放,则能立即获得,性能最优。如果超时后仍未获得锁,则让出CPU。这种策略在最坏情况下的等待时间不会超过最优情况的两倍。有趣的是,存在一种随机化算法,可以达到 e/(e-1) 的竞争比。
了解一些关键的性能数据很有帮助:
- 上下文切换开销:约 10毫秒。
- DRAM访问延迟(未命中缓存):约 150纳秒。
- 磁盘访问延迟:约 5-10毫秒。
死锁
当线程需要持有多个锁时,就可能发生死锁。死锁需要三个条件同时满足:
- 互斥:资源(如锁)一次只能被一个线程持有。
- 不可抢占:线程在完成操作前不会释放已持有的资源。
- 循环等待:存在一个线程环路,每个线程都在等待下一个线程持有的资源。
经典的“哲学家就餐问题”说明了死锁。五位哲学家围坐,每两人之间有一根筷子。哲学家需要两根筷子才能吃饭。如果每位哲学家都先拿起左边的筷子,那么所有人都会拿着左筷等待右筷,导致死锁(饥饿)。
避免死锁的一个有效方法是对锁进行线性排序,并强制所有线程都按此顺序获取锁。例如,将筷子编号0到4,规定哲学家必须先拿编号较小的筷子,再拿编号较大的筷子。这样就不可能形成循环等待。
在Cilk中使用锁要特别小心。一个常见的错误是在Cilk sync 操作期间持有锁。考虑以下代码:
lock(L);
spawn foo(); // foo 内部也尝试 lock(L)
// ... 其他工作 ...
sync;
unlock(L);
如果主线程先获得锁 L,然后派生子线程 foo,foo 会因无法获得锁而阻塞。主线程在执行到 sync 时必须等待 foo 完成,但 foo 又在等待主线程释放锁,这就导致了死锁。教训是:尽量避免在Cilk同步点(sync)上持有锁。一个好的策略是只在线程内部(strand内)持有锁,并尽量缩短持锁时间。
事务内存
最后,我们来看一个更高级的概念:事务内存。它借鉴了数据库事务的思想,允许程序员声明一个代码区域是原子的,而无需显式指定锁。系统会自动处理冲突,必要时中止并重启事务。
考虑一个并发图计算,比如在图上进行高斯消元。在消除一个顶点时,需要移除它,并将其所有邻居两两连接。如果两个相邻的顶点被并行消除,就会发生冲突。
使用事务内存,我们可以将整个消除操作包装在一个事务中。如果两个事务冲突(例如,试图修改相同的边),系统会中止其中一个,并在稍后重启它。重启后,由于图的状态可能已改变,事务可能走不同的代码路径。
事务内存涉及几个关键概念:
- 冲突:两个事务无法同时成功提交。
- 中止与重启:解决冲突的方式之一。
- 冲突解决:决定哪个事务等待、中止或重启的策略。
- 向前进展:避免死锁、活锁和饥饿。
- 吞吐量:最大化并发执行的事务数量。
一个有趣的无全局锁事务内存算法使用了“有限所有权数组”和“释放-排序-重获取”的思想。其核心是:
- 通过一个哈希函数
H,将内存地址映射到一组互斥锁(所有权数组)上。 - 事务尝试按需贪婪地获取锁。
- 如果获取锁
H(x)失败,则事务回滚(但保留已持有的锁)。 - 释放所有索引大于
H(x)的已持有锁。 - 成功获取锁
H(x)。 - 按索引顺序重新获取所有被释放的锁。
- 重启事务。
每次重启,事务都确保自己只持有比当前欲获取锁索引更小的锁,从而避免了死锁,并逐步推进。
另一个需要注意的性能问题是锁护送:当一个持有锁的线程被操作系统调度出去时,其他所有等待该锁的线程都会被阻塞,即使锁很快被释放,它们也要等待被挂起的线程重新被调度。这曾是MIT Cilk早期的一个性能问题。

本节课中我们一起学习了非确定性并行编程的复杂性。我们明确了确定性的定义及其对调试的重要性,并给出了“永不编写非确定性程序,若必须则管理之”的准则。我们深入探讨了使用互斥锁实现原子性、数据竞争与确定性竞争的区别、以及“良性”竞争可能暗藏的风险。我们还剖析了互斥锁的不同实现(自旋/让出、可重入/不可重入、公平/非公平)及其性能影响,分析了死锁的条件和避免方法(如锁排序)。最后,我们简要介绍了事务内存这一高级概念,它提供了另一种管理并发的抽象。理解这些内容对于编写正确、高性能的并行程序至关重要。
017:无锁同步 🔒

在本节课中,我们将学习无锁同步的概念。我们将探讨内存模型,特别是顺序一致性,并了解为什么现代计算机不实现它。我们还将学习如何使用Peterson算法仅通过加载和存储实现互斥,并探讨指令重排序如何破坏这些算法。最后,我们将介绍内存屏障和比较并交换指令,作为在弱内存模型下实现正确同步的工具。
内存模型与顺序一致性 🧠
上一节我们介绍了同步的基本概念。本节中,我们来看看一个关键的理论基础:内存模型。内存模型定义了处理器如何观察内存操作(加载和存储)的顺序。
为了引入这个概念,我们看一个例子。假设有两个变量 A 和 B,初始值都是0。它们存储在内存中。处理器0执行:
- 将1存入
A。 - 将
B的值加载到寄存器EBX。
同时,处理器1执行:
- 将1存入
B。 - 将
A的值加载到寄存器EAX。
问题是:两个处理器都执行完代码后,EBX 和 EAX 是否可能都包含值0?
直觉上,答案似乎是“不可能”。然而,这实际上取决于所谓的内存模型。你刚才的推理基于一个特定的模型:顺序一致性。
顺序一致性由Leslie Lamport定义,其核心思想是:
任何执行的结果,都等同于所有处理器的操作按某种顺序依次执行,并且每个独立处理器的操作在该序列中按照其程序指定的顺序出现。
换句话说,你可以将所有处理器的指令交错排列成一个全局的线性顺序。在这个顺序中,每个处理器的程序顺序得到保持,并且每次加载都读取该线性顺序中对该地址最近一次存储的值。
对于上面的例子,在顺序一致性下,确实不可能出现 EBX 和 EAX 同时为0的情况,因为所有可能的交错顺序都无法产生这个结果。
我们可以通过 happens-before 关系来形式化地推理顺序一致性。这个关系(记为 →)是线性的,意味着对于任意两条不同的指令,要么一条在另一条之前发生,要么反之。这个关系必须尊重处理器的程序顺序,并且加载读取的值必须是 happens-before 顺序中对该地址最近的存储值。
Peterson互斥算法 ⚙️




在并发理论中,一个著名的早期成果是证明了可以在不使用锁或特殊原子指令(如测试并设置)的情况下实现互斥,前提是系统是顺序一致的。Peterson算法是其中最简洁优雅的实现之一。


假设Alice和Bob是两个线程,共享一个“小部件”。Alice想“frob”它,Bob想“bo”它,但这两个操作不能同时进行(互斥)。以下是Peterson算法的伪代码:
// 共享变量
bool A_wants = false, B_wants = false;
int turn; // 可以是 A 或 B
// Alice的代码
A_wants = true;
turn = B;
while (B_wants && turn == B) {
// 空循环,自旋等待
}
// 临界区:frob the widget
A_wants = false;
// Bob的代码
B_wants = true;
turn = A;
while (A_wants && turn == A) {
// 空循环,自旋等待
}
// 临界区:bo the widget
B_wants = false;
算法直觉:如果Alice和Bob都想进入临界区,那么最后设置 turn 变量的那个线程将等待。同时,该算法也保证了无饥饿:一个线程不能连续进入临界区两次,因为每次进入后它都会将 turn 让给对方。
形式化证明(简述):为了证明互斥性,我们假设Alice和Bob同时进入了临界区(反证法)。考虑他们进入前最后执行的指令序列,并假设Bob是最后一个写入 turn 的线程。通过建立 happens-before 关系链,我们可以推导出Bob在自旋循环中读取 A_wants 应为 true 且 turn == A,因此他应该继续等待,而不可能进入临界区,这与假设矛盾。因此,互斥性得证。
弱内存模型与指令重排序 🔄
上一节我们介绍了理想的顺序一致性模型。然而,本节中我们来看看现实:没有现代处理器实现顺序一致性。它们都实现了某种形式的弱(宽松)内存一致性模型。
为了提高性能,硬件(和编译器)会主动重排序指令。例如,考虑以下两条指令:
Store A
Load B
硬件或编译器可能会将其重排序为:
Load B
Store A
为什么?因为加载操作通常需要等待内存响应,会导致处理器流水线停顿。如果先将不依赖前面存储结果的加载提前执行,就可以掩盖部分内存访问延迟,从而提高性能。只要 A 和 B 是不同的内存地址,并且没有其他并发线程访问它们,这种重排序在单线程视角下是完全安全的。
硬件如何实现重排序:现代处理器使用存储缓冲区。存储指令可以快速进入缓冲区,而不必等待缓慢的内存系统。加载指令则通常绕过存储缓冲区直接访问内存(或缓存),以更快地获得数据。如果加载的地址恰好在存储缓冲区中有待处理的写入,则直接从缓冲区返回值。这种机制使得加载操作可能在逻辑上先于更早的存储操作完成。
X86的总存储顺序模型:X86实现了一种称为“总存储顺序”的弱内存模型,其规则包括:
- 加载与加载之间不会重排序。
- 存储与存储之间不会重排序。
- 存储不会与之前的加载重排序。
- 但是,加载可以与之前对不同地址的存储进行重排序。
正是最后这条规则,破坏了顺序一致性。
重排序对同步的影响:回到最初的双变量例子,在弱内存模型下,处理器0和1的指令可能被重排序,导致 EBX 和 EAX 都读到0的执行结果成为可能。同样,Peterson算法也可能因重排序而失效。例如,如果 while 循环中的加载 B_wants 被重排序到 A_wants = true 之前执行,那么Alice可能过早地看到 B_wants 为 false,从而错误地与Bob同时进入临界区。
内存屏障与原子操作 🛡️
既然指令重排序会破坏无锁同步,我们如何编写正确的并发代码呢?硬件提供了内存屏障指令作为“补丁”。
内存屏障是一种硬件操作,强制屏障前后的指令满足某种排序约束。在X86中,对应的指令是 MFENCE。编译器也提供了相应的函数,如 atomic_thread_fence。在屏障处,处理器会确保所有在屏障之前的加载和存储操作完成后,才执行屏障之后的加载和存储操作。
为了让Peterson算法在弱内存模型下工作,我们需要:
- 插入内存屏障:例如,在设置
turn变量之后和进入自旋循环之前插入屏障,防止循环中的加载被重排序到设置turn之前。 - 使用
volatile关键字:防止编译器将共享变量(如A_wants,B_wants,turn)优化到寄存器中,导致线程看不到其他线程的修改。 - 可能需要编译器屏障:防止临界区内的操作被移出临界区。
然而,仅使用加载/存储和屏障来实现多线程互斥算法,其空间复杂度是O(n)。为了更高效地实现通用互斥,现代处理器提供了原子读-修改-写指令。
比较并交换与无锁编程 ⚡
最常用的原子指令之一是比较并交换。其语义如下:
bool CAS(int* addr, int oldval, int newval) {
if (*addr == oldval) {
*addr = newval;
return true; // 成功
} else {
return false; // 失败
}
}
整个操作是原子的,并且隐含了内存屏障。它检查内存位置 addr 的值是否等于 oldval,如果是,则自动将其替换为 newval 并返回成功;否则,不做任何修改并返回失败。
使用CAS,我们可以用常数空间实现一个简单的自旋锁:
// 锁变量,false表示未上锁
std::atomic<bool> lock = false;
void acquire_lock() {
bool expected = false;
while (!lock.compare_exchange_strong(expected, true)) {
// 交换失败,说明锁已被他人持有,继续重试
expected = false; // 重置期望值
}
// 成功获取锁
}
void release_lock() {
lock.store(false);
}
无锁算法的优势:考虑一个并行求和问题。使用锁保护累加变量时,如果一个线程在持有锁时被操作系统挂起,所有其他线程都会被阻塞。而使用CAS实现的无锁累加则没有这个问题:即使一个线程在更新时被挂起,其他线程仍然可以继续尝试更新,整个系统仍然有进展。
CAS的ABA问题:CAS存在一个经典陷阱——ABA问题。线程1读取共享变量值为A,准备将其CAS为C。在此期间,线程2将值从A改为B,然后又改回A。当线程1执行CAS时,发现当前值仍是A,于是成功更新为C。但这可能是不正确的,因为中间状态B可能代表某种重要的变化(例如,链表节点被释放并重新分配)。解决ABA问题通常需要引入版本号或使用更强大的原子指令。


本节课中我们一起学习了无锁同步的复杂世界。我们从理想的顺序一致性模型出发,理解了Peterson算法如何仅用加载/存储实现互斥。然后,我们直面现实,看到了现代处理器为性能而进行的指令重排序如何破坏这些算法,并学习了使用内存屏障和 volatile 关键字来恢复正确性。最后,我们介绍了强大的比较并交换原子指令,它是实现高效无锁数据结构的基石,但也需要警惕如ABA问题这样的陷阱。无锁编程是一个强大但危险的领域,需要严谨的推理和对硬件模型的深刻理解。
018:领域特定语言与自动调优


在本节课中,我们将学习领域特定语言和自动调优的概念。我们将探讨如何通过DSL简化特定领域的编程,以及如何利用自动调优技术自动寻找程序的最佳性能配置。课程将结合GraphIt和Halide两个具体的DSL案例,分析它们在处理图算法和图像处理管道时的优势,并揭示其背后共通的性能工程原理。
课程概述
本节课由麻省理工学院的Saman Amarasinghe教授主讲。他是EECS系的教授兼副系主任,在编译器、领域特定语言和自动调优方面是专家,也是大家在作业中使用的OpenTuner框架的设计者。他将介绍其在领域特定语言和自动调优方面的最新工作。
领域特定语言简介
我们日常使用的都是通用编程语言,它们被设计用来捕获编程中可能需要的各种广泛功能。
然而,很多时候存在特定的领域或模式,你希望实现具有某些有趣属性的代码。在通用语言中,描述这些属性非常困难,并且从编译器的角度来看,尤其难以利用这些属性,因为它必须为所有人工作。
领域特定语言具有很多工程上的优势,因为如果你知道你构建的东西具有某种特定的形态和属性集,而语言能够捕获这些信息,那么构建起来就会容易得多。它能带来更高的清晰度,更容易维护和测试。另一个好处是它更容易理解,因为领域非常明确。你可以构建一个库,但有人可能会在库中做一些奇怪的事情。如果它被内置于语言中,它就固化了,你不能随意更改。这使得多人编程更容易。
从我的角度来看,我真正喜欢的领域特定语言是那些我知道可以利用领域专家的知识来获得极佳性能的语言。
很多时候,领域表达允许进行特定的代数简化。这种代数可能只在该领域有效,很难将其复杂的代数规则放入C++中。但在该领域内,我可以编写代码,让你写出任何我可以简化的表达式。
此外,每个领域都有很多惯用法。例如,某个领域可能表示一个图。在普通的C++中,你可能使用一堆类来做非常复杂的事情,惯用法隐藏在其中。首先,C++不知道要寻找图。即使你寻找图,你也可以用数百万种方式尝试表示图。但如果语言提供了一流的支持,我就不需要费力地去提取它,我可以很容易地看到它,这样我的编译器大部分时间就可以在那里做有用的事情。
大多数时候,另一个好处是,如果你构建了一个领域特定语言,你可以将复杂的底层决策留给编译器。在C++中,你可能会忍不住说“我知道一些优化,让我在这里做点什么”。我一生都在研究优化,很多时候当你编写编译器优化过程时,你一半以上的时间都在撤销程序员所做的疯狂优化。
就像你们正在学习,你们认为自己更懂,然后去做一些事情,当时可能运行得很好。但相信我,那段代码20年后还存在,而20年后那看起来像是做了件非常愚蠢的事情。然后你看着它说,现在我必须在编译器中撤销一切,为当前的架构做正确的事情。因此,如果你捕获了正确的抽象层次,我会让编译器在这里完成工作。然后随着架构不断成熟,问题不断变化,我不必担心,也不必在这里撤销这些部分。
所以,我来到性能工程课,告诉你们把性能留给编译器,但好处是如果编译器能完成大部分工作,那会好得多。所以,不要怀疑编译器。
我将在这里讨论三个部分。这是三种不同的编程语言,领域特定语言GraphIt,然后是Halide,它不仅仅是一种语言,还是一个框架。在GraphIt和Halide之间,你会看到一些模式。然后我们将看看你是否发现了我们正在研究的模式。
GraphIt:图算法的DSL
GraphIt是我和Julian合作的一个产品。所以如果今天之后你对GraphIt有任何问题,你绝对可以问Julian,他可能比这个星球上的任何人都更了解图和GraphIt。他是讨论图的绝佳资源。
图无处不在
如果你去像Google这样的地方进行搜索,Google将整个互联网的知识表示为一个巨大的图,背后有大量的图处理,这就是指导你搜索的方式。
或者如果你再去地图或像Uber这样的应用。它会为你找到路线。整个道路网络就是一个图,它试图在这个图中找到最短路径来给你地图。
如果你去推荐引擎获取电影推荐,如果你得到一部你真正喜欢的酷电影,那是因为在观看电影的所有人之间有一个巨大的图,他们观看和喜欢这些电影,系统正在将你与他们进行比较并推荐。所有这些都可以看作是图。
即使你去ATM机尝试进行交易。背后有一个非常快速的图分析,来判断这是否是欺诈交易。所以在钱实际从你的ATM机弹出之前的时间里,系统已经测试了一堆图处理来理解这似乎是一笔好的交易,所以实际上会给你钱。有时你会收到其他消息,这意味着图处理认为那里可能发生了一些奇怪的事情。所以很多事情,比如地图和交易,都有非常严格的延迟要求。你必须正确完成这件事,你必须非常快地得到下一个方向,尤其是在你转错弯之前。所以这些事情必须有效。其他事情,如推荐和Google搜索,是巨大的图,他们构建了整个网络或所有推荐,需要进行大量的处理。所以性能很重要。
在这些应用中,性能非常重要。
深入理解图处理
让我更深入地探讨一下图意味着什么,图处理意味着什么。
一个非常著名的图算法叫做PageRank。有人知道PageRank的历史吗?你们中有多少人听说过PageRank?好的。PageRank中的“Page”代表什么?Larry Page。所以Google做的第一个算法(我认为当时Google还不存在)就是这个PageRank算法。它追踪这些网页,但它是由Larry Page开发的,所以它要么是网页,要么是Larry Page,我们不知道,但人们认为是Larry Page,所以叫PageRank。如果你有一个图,这个图算法做的是进行一些迭代,直到达到最大迭代次数或某个收敛条件。

它首先做的是遍历所有邻居。然后基本上根据所有邻居计算一个新的排名。这意味着我的邻居有多好?他们的排名是什么?他们对我的贡献是什么?这意味着被一个好人认识并与一个非常知名的人(在这种情况下是网页)有联系,意味着我的排名很高。所以我更有影响力,因为我更接近某个事物。它基本上做的是计算某个值并传播给所有邻居,并聚合这些信息。整个图都参与其中。然后发生的是,每个节点将通过查看旧排名来计算其新排名,它会根据新排名进行一些修改,然后交换所有排名和新排名。这是你迭代进行的两个计算,你必须为整个图做这件事。
当然,如果你运行这个,它会运行得非常非常慢。所以如果你想获得性能,你就编写这段代码。这段代码基本上是巨大的,它在双核机器上比之前的图代码运行快23倍。它基本上是多线程的,所以获得了并行性能。它是负载均衡的,因为如你所知,图是非常不平衡的。如果你有非统一内存访问的机器,比如多插槽机器,它会利用这一点。它利用了缓存,这段代码中发生了很多事情。
但当然,你知道编写这段代码很难,但更糟的是,你可能不知道什么是正确的优化。你可能想尝试集成,你可能尝试很多事情,这非常困难。每次你改变一些东西,如果你说我想做一点不同的事情,我必须编写一个非常复杂的代码,让它全部正确,让一切正常工作,然后我才能在这里测试。
这就是为什么我们可以为这个使用DSL。
图算法的分类
让我稍微谈谈图算法,说说人们用图做什么?当我看到图算法时,我将稍微深入一点,向你展示表示图的类型。
有一类图算法被称为拓扑驱动算法,这意味着整个图都参与计算。
例如,Google搜索。在你进行Google搜索之前,它会收集所有网络链接,构建这个巨大的图,并进行大量处理,以便能够在这里进行搜索。推荐引擎也是如此,可能每隔几周或无论需要多长时间收集所有人的推荐数据,拥有这个巨大的数据,然后你将处理它并运行这个推荐引擎。这是应用于整个图的,有时需要数十亿甚至数万亿个节点参与计算。
另一类算法被称为数据驱动算法。这意味着你从某些节点开始,然后不断访问其邻居和邻居的邻居,在那里处理数据。
适合此类别的算法包括,如果你有一张地图,如果我必须找到最短路径,那意味着我可能有两个点。我不需要从这里的某个方向到波士顿,我不需要经过纽约的节点。我只需要经过我连接的邻居。所以我基本上是在某个区域及其连接上进行操作。
所以这些是数据驱动算法,我可能有一个巨大的图,但我的计算可能只作用于图的一个小区域或一小部分。
图遍历与优化策略
当你在图中遍历时,有多种方式进行图遍历,这就是优化困难的原因,因为有很多不同的方式,每种方式都有不同的结果集。
我假设很多图算法需要从我的邻居那里获取一些东西。从邻居那里获取东西的一种方式是,我可以计算我所有邻居可能需要什么,然后提供给其他邻居。
在那里,我可以去改变所有邻居来更新我的值。那么你认为这是好方法吗?如果我想更新每个人,我会计算我想做什么,然后去改变我所有的邻居。
这不是它可能达到的最并行状态。你正在做同样的事情。有趣。是的,但这是一个很好的观点。所以如果你正在进行数据驱动,如果我这样做不好,但如果每个人都对他们的邻居这样做,那么他们就有并行性。所以每个人可能都在更新他们的邻居,每个人都在更新他们的邻居。那么现在出现了另一个问题。如果每个人都试图更新他们的邻居,会出现什么问题?
那里存在竞争条件,因为每个人都要写入。所以如果你想正确完成这件事,你有一堆问题,你需要进行原子更新,因为你需要锁定那个东西,所以它必须原子性地更新。这很好,但我不需要遍历任何东西,因为我需要更新的每个人,我实际上去更新。这是一种很好的方式,特别是如果它不是全局性的。所以如果我正在传播,我将更新我的邻居,并且我可以将其传播下去。
另一种方法是拉取调度。这意味着如果每个人都询问他们的邻居:你有什么要给我的吗?我从所有邻居那里收集一切,然后更新我自己。
那么现在有竞争条件吗?有多少人说有竞争?有多少人说没有竞争?发生的情况是,我从所有邻居那里读取。每个人都从邻居那里读取,但我只更新我自己。因此,我是唯一写入我自己的人,所以我没有竞争。这很好,你没有竞争。但是,如果我正在进行数据驱动的转换,我可能不知道我需要被更新,因为更新来自那个人,这意味着我可能被安排任务,或者问你有什么要发送的吗?你可能会说没有。所以从这个意义上说,我可能基本上做了很多不必要的额外计算,因为我可能不知道我有数据需要获取,但我必须问你,我是否应该这样做?但我基本上不需要进行同步。
另一个有趣的事情是,我可以获取这个图。我可以基本上对图进行分区。一旦我对图进行了分区,我基本上可以说,这个核心获取这个图,这个核心获取这个图,这个处理器获取这个图。对图进行分区有什么优势?为什么我想将一个大图分割成小块?当然,你必须进行良好的分区。你不能进行任意分区。那么如果我进行良好的分区会发生什么?我不说出那个词,因为答案就在那里。好的,让我看看还有谁想回答。来吧,Bob,你上过课。如果我获取两个不同的组,将它们分开,并将这个给一个人,那个给另一个人,我会得到什么?我得到了并行性,但如果我有很多连接的东西进入你,这些连接的东西进入那个人,我还能得到什么?局部性,你在课上学过局部性吗?所以这意味着分区意味着我正在处理的东西,我只处理一小部分,如果我幸运的话,它可能在我的缓存中,那将非常好,每个人都需要访问这里的每个节点。
所以如果我正确分区,我会在这里获得良好的局部性,实际上那里已经写了,哎呀,好的。所以我的答案就在那里,所以包括局部性。但当然,现在我可能有一点额外的开销,因为我可能必须复制一些节点,因为它在两边都有。
在印度。所以,图的另一个有趣属性是,当你查看数据结构时,到目前为止,像数组这样的东西,大小很重要,不同的数组适合缓存等等。图在这里有一些其他属性。如果你去社交网络。社交网络是一个图。你在社交网络中观察到的有趣属性是什么?连通性。有像我这样的人,可能只有20个朋友,连接数非常少,然后有一些名人,他们有数百万的连接。有趣的是,如果你看一个社交网络图,你有一个称为幂律关系的关系,这意味着存在指数关系。有一些人,比如非常知名的名人,可能有数百万和数百万的用户连接,邻居数量巨大。所以像我这样坐在这里的人,与世界的连接非常少。人们通常观察到,大型社交网络类型的图具有这种指数关系。所以网络具有指数关系,社交网络在那里有这种关系。所以当你处理这些图时,你必须做非常有趣的事情,因为某些连接很重要,某些节点影响很大,比其他节点影响大得多。
然后还有其他图具有有界的度分布,比如道路网络。最大的连接可能是一个有六条道路交汇的十字路口,你不会有一百万条道路连接到一个地方,所以这不会发生。所以这更平坦,更多是有界度分布的图。由于当然剑桥的所有道路可能都连接在一起,但剑桥的道路可以与纽约市的道路分开,所以这些图具有良好的局部性。
所以,即使你说图的大小相同,图的形状在计算中很多时候都很重要。
性能权衡空间
那么,当你想对这些图进行操作时,你必须考虑三个有趣的属性。
一个属性是,我的算法试图对这个图做什么,将获得多少并行性。
这就像金发姑娘原则,你不想要太多的并行性。如果你说我的算法有巨大的并行性,但如果我不能利用它,那就没有用。所以你需要获得足够好的并行性,以便我实际可以使用它。
然后我真的很喜欢有局部性。因为如果我有局部性,我的缓存就会工作,一切都会在附近,我可以快速运行。如果我每次都必须从主内存获取东西,那可能会非常慢。所以我想要局部性。
但关于图的有趣之处在于,为了获得局部性和其中一些特性,你可能需要做一些额外的工作。所以如果你看到图被分成两个不同的图,我必须在其中添加额外的节点,我可能有一些额外的数据结构,所以做一些额外的计算。所以我可能必须在这里做一些额外的工作。所以在某些情况下,我可能没有那么高效。
所以我可能获得非常好的性能和局部性,但我做了太多的工作。例如,如果我想找到一个节点的邻居。获得良好并行性的方法是每个人都找到他们的邻居,但这效率不高。我的意思是大部分计算没有用。所以你可以做一些比必要更多的工作,然后可以获得更快的其他东西,但你必须小心这样做。所以你在这里有这个平衡。某些算法将适合这个权衡空间中的不同位置。所以推送算法将适合这里。例如,如果你去像拉取算法这样的东西,你可能会发现你做的效率较低,因为你可能多做了一点工作,但它在局部性和并行性方面可能更好,因为你不需要在这里加锁。
然后你做一些像分区的事情,在分区中获得非常好的局部性,但你正在做额外的工作,而且因为当你分区时,你可能会限制你的并行性。所以你可能有更少的并行性,但你获得了非常好的局部性。所以所有这些基本上都是一个大的权衡空间,然后当你不断添加更多可以做的事情时,它就适合这个大的权衡空间。




那么,你如何决定在权衡空间中做什么是一个非常重
019:莱瑟国际象棋项目代码解读 🏁

在本节课中,我们将一起学习课程最终项目——莱瑟国际象棋(Laser Chess)的代码结构与核心机制。这是一个包含约4500行代码的复杂项目,我们将对其进行详细解读,帮助你理解游戏规则、代码组织、搜索算法以及性能优化的关键点。
项目概述与游戏规则 🎮
首先,我们来了解莱瑟国际象棋的基本规则。游戏中有两种棋子:兵(三角形) 和 王(皇冠)。双方(橙色和淡紫色)各有7个兵和1个王,初始布局如图所示。每个棋子有四个朝向(上、下、左、右)。
游戏开始时,橙色方先行,之后双方轮流行动。每回合包含两个部分:
- 移动:玩家选择自己的一个棋子(王或兵)进行移动。
- 发射激光:移动结束后,玩家的王必须发射激光。激光在棋盘上反射,若击中敌方兵的短边或敌方王,则消灭该棋子。
胜利条件是击毙对方的王。平局条件包括:双方各走15步且无兵被消灭、同一局面重复出现、或双方同意和棋。
游戏使用菲舍尔计时法,例如 60+0.5 表示初始时间60秒,每步棋完成后增加0.5秒。
代码表示与接口 📐
为了便于调试和表示游戏状态,项目使用了一种边缘表示法。这是一种用字符串描述棋盘位置的表示方法。
例如,起始位置的字符串表示如下(/分隔行,1代表空位,字母代表特定朝向的兵):
1a1aaa1a/11111111/11111111/11111111/11111111/11111111/11111111/A1AAA1A1 w
字符串末尾的 w 表示轮到白方(橙色)走棋。这种表示法允许我们快速设置任何棋盘状态进行测试。
游戏记录(棋谱)使用类似 E6E7(从E6移动到E7)、B3L(将B3的棋子向左旋转90度)、D5E4F5(D5的棋子与E4的棋子交换,然后将来自E4的棋子移动到F5)的格式记录每一步。
项目组织结构与测试 🗂️
项目代码库包含多个目录,其组织结构如下:
doc/:包含游戏规则和接口文档。autotester/:Java本地自动测试器。当云端测试队列较长时,可用于本地或夜间测试。pgm-stats/:解析自动测试结果,生成胜率等统计数据。tests/:测试配置文件目录。你可以在此指定要运行的测试对局数、使用的AI程序、时间控制等。player/:这是你需要优化的核心部分,包含游戏AI的代码。webgui/:本地Web图形界面。你可以用它来观看对局,甚至亲自与AI对战,以更好地理解游戏。
你可以通过云端自动测试器进行大规模测试。在Athena终端中运行类似以下命令:
autotest-run blitz 100 ./laserchess-ref ./laserchess-mybranch
这条命令会使用“快棋”时间控制,运行100局比赛,对战双方分别是参考AI和你分支上的AI。测试完成后,你会获得一个链接查看结果和ELO等级分。
此外,还有练习服务器,你可以在此挑战其他同学或助教的AI,实时观看对局。
棋盘表示与走法生成 ♟️
上一节我们介绍了项目的整体结构,本节中我们来看看AI如何“看到”和“思考”棋盘。
棋盘表示
参考实现使用一个 16x16 的数组来表示 8x8 的实际棋盘。外围的深色格子是哨兵,用于便捷地检测棋盘边界。内部 8x8 的格子才是真正的棋盘。
棋盘状态由一个 Position 结构体表示,其关键字段包括:
board:存储棋盘的数组。history:到达当前局面的历史记录。key:用于置换表的哈希键。ply:步数索引,偶数表示白方(橙色)回合,奇数表示黑方(淡紫色)回合。last_move:上一步棋。victim:被激光消灭的棋子。king_square[2]:双方王的位置。
走法表示与生成
一个“走法”使用28位整数打包表示,包含以下信息:
- 棋子类型(空、兵、王、无效)
- 棋子朝向
- 起始格
- 中间格(用于交换走法)
- 目标格
走法生成在 mgen.c 中实现。给定一个局面,AI会遍历整个棋盘,为当前行棋方找到所有可能的走法。走法生成必须保持正确性,任何优化都不能改变生成的合法走法集合。
调试走法生成可以使用 Perft 函数,它枚举指定深度内的所有走法节点数,是验证走法生成正确性的重要工具。
静态局面评估 ⚖️
生成了所有可能走法后,AI需要判断哪个走法更好。这通过静态评估函数(eval.c 中的 eval 函数)实现。该函数根据一系列启发式规则为局面打分。
以下是参考实现中的主要启发式规则(建议先优化现有评估,再尝试创造新规则):
与王相关的评估:
K_FACED:己方王朝向对方王时获得奖励。K_AGGRESSIVE:己方王靠近棋盘中心时获得奖励。MOBILITY:计算己方王周围空闲格子的数量,可移动性越高越好。
与兵相关的评估:
P_CENTRAL:兵靠近棋盘中心时获得奖励。P_BETWEEN:在以双方王为对角形成的矩形框内,己方兵的数量越多越好。
基于激光覆盖的评估:
L_COVERAGE:模拟所有可能走法后的激光路径,评估己方对棋盘的控制能力。这是最复杂的启发式规则。
当前评估函数通过遍历整个棋盘来计算分数,这是一个明显的性能瓶颈。优化思路包括:改用更高效的数据结构(如位棋盘或棋子位置列表)来避免全盘遍历。
搜索算法:极小化极大与Alpha-Beta剪枝 🌳
知道了如何评估一个静止局面后,AI需要通过搜索未来的可能走法序列来做出决策。这就是游戏搜索树。
最朴素的搜索是展开所有可能走法直到深度D,然后在叶子节点进行评估。但这样需要评估的节点数是分支因子B的D次方,计算量巨大。
极小化极大搜索
双方交替行棋,一方(Max)试图最大化评估分数,另一方(Min)试图最小化评估分数。从根节点(当前局面)开始,递归地进行“我方选择最大分数,对方选择最小分数”的推导。
Alpha-Beta剪枝
Alpha-Beta剪枝是对极小化极大搜索的优化。它在搜索时维护一个窗口 [alpha, beta]:
alpha:当前层Max方至少能保证得到的分值。beta:当前层Min方至多允许Max方得到的分值。
在搜索Min节点时,如果某个子节点的返回值 v <= alpha,说明Min方在这个分支可以迫使局面价值不超过alpha(这对Max方不够好),因此可以停止搜索该节点的其他子节点(Beta截断)。
伪代码如下:
int alphaBeta(Position pos, int depth, int alpha, int beta) {
if (depth == 0) return evaluate(pos);
generateMoves(pos);
for (each move) {
makeMove(pos, move);
int score = -alphaBeta(pos, depth-1, -beta, -alpha); // 注意取负和参数交换
unmakeMove(pos, move);
if (score >= beta) return beta; // Beta截断
if (score > alpha) alpha = score;
}
return alpha;
}
理论证明,在最佳走法优先排序的情况下,Alpha-Beta搜索能将搜索节点数从 B^D 减少到大约 B^(D/2),相当于将有效搜索深度翻倍。

主要变例搜索
这是Alpha-Beta的进一步优化,也称为PVS或** Scout搜索。其核心思想是:假设第一个搜索的走法是最好的(主要变例),然后用一个零窗口搜索**([alpha, alpha+1])快速检验其他走法是否可能更好。如果零窗口搜索返回的值超过窗口,则证明该走法更好,需要对其进行完整窗口的重新搜索;否则,可以安全地剪掉该分支。
搜索优化技术 🚀
为了进一步提升搜索效率,项目实现了多种优化技术:
- 置换表:象棋程序在搜索中经常会重复遇到相同的局面。置换表将这些局面的搜索结果(估值、最佳走法、搜索深度等)存储起来,避免重复搜索。它使用 Zobrist哈希 来快速生成局面的唯一哈希键,并且哈希值可以随着走棋/悔棋而增量更新。
- 杀手启发:记录在特定深度引发过Beta截断的“杀手”走法,在后续搜索同深度时优先尝试这些走法。
- 历史启发:根据历史对局信息,给那些在不同局面中频繁引发截断的走法赋予更高优先级。
- 空着裁剪:先尝试“不走棋”,并进行一次浅层搜索。如果结果仍然很好,说明即使让对手连走两步,局面也对己方有利,因此可以裁剪掉这个分支的深层搜索。
- ** futility裁剪**:在搜索深度很浅时,如果某个走法即使能获得最大可能收益( futility边际),其估值仍无法超过当前alpha值,则提前裁剪该走法。
- 迟后走法削减:对排序靠后的走法(认为其质量较低)使用更浅的深度进行搜索。
开局库与残局库 📚
除了优化搜索过程,预先计算好的知识库也能极大提升AI强度。
- 开局库:存储经过预先分析和评估的初始阶段走法序列。使用开局库可以避免在开局阶段进行耗时的深度搜索,并直接引导AI进入有利的中局。参考实现提供了一个基础的开局库,但内容有限,扩展它是很好的优化方向。
- 残局库:对于棋子所剩无几的残局局面,预先计算并存储最优走法和结果(赢、输、和棋及步数)。由于残局搜索深度可能很深,残局库能提供精确的指导。项目提供了一个C文件框架供你实现残局库。
测试、调试与性能优化建议 🔧
对于这样一个复杂的项目,建立稳健的测试流程至关重要。
- 频繁测试:每做一项优化,都应立即测试其正确性。错误很容易被引入,并且可能在深度搜索时才暴露。
- 确定性测试:尽可能使用固定深度搜索(如
go depth 5)进行测试,并比较搜索的节点数。纯粹的性能优化不应改变节点数,只会改变搜索速度(节点/秒)。使用 Perft 验证走法生成的正确性。 - 功能对比测试:保留优化前版本的函数,与新版本在相同输入下对比输出,确保逻辑不变。
- 控制非确定性:并行搜索和置换表会引入非确定性。为了调试,需要能够关闭这些功能,使程序行为完全确定。
- 逐步重构:例如,当改变棋盘表示法时,可以同时维护新旧两种表示,并编写断言确保它们一致,待完全正确后再移除旧表示。
UCI调试接口
AI通过通用象棋接口(UCI)与自动测试器通信。你也可以直接运行AI二进制文件,进入UCI命令行进行调试:
position fen [FEN字符串]:设置特定局面。go depth [数字]:执行固定深度搜索。perft [数字]:执行Perft测试。- 你还可以在
uci.c中添加自定义命令来辅助调试。
在 uci.c 的 UCI_Option 表中,你可以找到所有可调整的启发式权重参数。你可以通过UCI命令动态修改这些参数(如 setoption name PawnValue value 125),而无需重新编译,方便进行参数调优。
总结 📝
本节课中我们一起学习了莱瑟国际象棋最终项目的完整框架。我们从游戏规则和代码结构开始,深入探讨了AI的核心组件:棋盘表示、走法生成、静态评估、以及关键的搜索算法(极小化极大、Alpha-Beta剪枝、主要变例搜索)。我们还介绍了多种搜索优化技术(置换表、杀手启发等)和知识库(开局库、残局库)的使用。最后,我们强调了建立严格测试流程的重要性,并介绍了UCI调试接口的使用方法。



这个项目为你提供了一个绝佳的机会,将课程中学到的性能工程理念应用于一个接近真实的复杂软件系统中。通过有步骤地分析、测试和优化,你将能够显著提升AI的强度,并深刻理解高性能计算程序的设计与调试技巧。祝你项目顺利,玩得开心!
020:推测并行性与Leiserchess


在本节课中,我们将要学习推测并行性的概念,并探讨如何将其应用于Leiserchess这类具有内在串行性的算法中,以实现性能提升。我们将从简单的阈值求和示例入手,逐步深入到复杂的并行Alpha-Beta搜索,并讨论在实现过程中需要注意的数据结构、优化策略和潜在陷阱。
概述
推测并行性是一种通过猜测某些并行工作在未来会被需要,从而提前执行这些工作以提升性能的技术。然而,如果猜测错误,就会造成计算资源的浪费。本节课的核心在于理解如何明智地使用推测并行性,特别是在像Alpha-Beta搜索这样存在大量剪枝、本质上串行的算法中。
推测并行性基础
上一节我们介绍了推测并行性的基本概念。本节中我们来看看一个具体的简单示例:并行化的阈值求和循环。
串行阈值求和
首先,我们有一个串行代码,用于计算数组元素之和,并在总和超过某个限制时提前返回。
int threshold_sum_serial(unsigned int *A, int n, unsigned int limit) {
unsigned int sum = 0;
for (int i = 0; i < n; i++) {
sum += A[i];
if (sum > limit) {
return 1; // 超过阈值
}
}
return 0; // 未超过阈值
}
一个简单的优化是使用条带挖掘(Strip Mining),减少条件检查的频率。
int threshold_sum_serial_opt(unsigned int *A, int n, unsigned int limit) {
unsigned int sum = 0;
for (int i = 0; i < n; i += 4) {
for (int j = 0; j < 4 && i + j < n; j++) {
sum += A[i + j];
}
if (sum > limit) {
return 1;
}
}
return 0;
}
并行阈值求和与提前终止
现在,我们希望并行化这个循环,同时保留提前终止的能力。这引入了“短路循环”并行化的挑战。
解决方案是引入一个全局的“中止标志”(abort_flag)。每个并行任务在计算前和计算中定期检查这个标志。如果某个任务发现总和已超限,它会设置中止标志。其他任务看到标志被设置后,就会提前结束自己的计算。
以下是使用类似Cilk并行任务模型的伪代码思路:
// 全局变量,需谨慎处理竞态条件
volatile int abort_flag = 0;
void parallel_sum_range(unsigned int *A, int start, int end, unsigned int limit, unsigned int *partial_sum) {
if (abort_flag) return; // 检查中止标志
if (end - start <= GRAIN_SIZE) { // 达到粒度阈值,串行计算
unsigned int sum = 0;
for (int i = start; i < end; i++) {
sum += A[i];
if (sum > limit) {
// 在设置标志前检查,避免不必要的缓存行争用
if (!abort_flag) abort_flag = 1;
*partial_sum = sum;
return;
}
}
*partial_sum = sum;
return;
}
int mid = (start + end) / 2;
unsigned int sum1, sum2;
// 递归并行计算左右两半
spawn parallel_sum_range(A, start, mid, limit, &sum1);
parallel_sum_range(A, mid, end, limit, &sum2);
sync; // 等待子任务完成
*partial_sum = sum1 + sum2;
// 检查合并后的总和是否超限
if (*partial_sum > limit && !abort_flag) {
abort_flag = 1;
}
}
关键点:
- 良性竞态:多个任务可能同时尝试将
abort_flag从0设置为1。由于它们都写入相同的值,这是一个良性竞态。但先检查再设置可以避免持续的缓存行无效化,提升性能。 - 内存栅栏:在此例中,由于标志的值只会从假(0)变为真(1),且算法的正确性不依赖于值变化的确切时机,因此不需要昂贵的内存栅栏来强制同步。
- 非确定性:这类代码的执行路径是非确定性的,取决于任务调度顺序。这增加了调试难度,因此需要能够关闭推测机制以进行确定性测试。
何时使用推测并行性
推测并行性的基本规则是:除非并行机会很少,且被推测的工作有很大概率被需要,否则不要生成推测性任务。
滥用推测并行性(例如,为寻找最小值而盲目生成大量任务)可能导致工作量远超串行算法,甚至无法获得加速。存在一个理论界限(课程中提及但未在幻灯片详细展示),用于指导何时推测是有效的。
并行Alpha-Beta搜索
上一节我们了解了推测并行性的通用模式。本节中我们将其应用于Leiserchess的核心——并行Alpha-Beta搜索(或其主要变体搜索PVS)。
挑战与Young Siblings Wait算法
Alpha-Beta搜索通过剪枝(Beta截断)来减少需要评估的节点数。在最佳走法顺序下,Knuth和Moore的分析表明,搜索的节点数约为 B^(ceil(D/2)) + B^(floor(D/2)) - 1,其中B是分支因子,D是深度。这相比朴素的B^D搜索,工作量大约开了平方根。
然而,剪枝引入了严格的依赖关系:只有当一个子节点搜索完成后,才能确定其兄弟节点是否会被剪枝。这导致了内在的串行性。
Burkhard Monien等人提出的“Young Siblings Wait”(YSW)算法提供了解决方案。他们观察到,在最佳顺序的博弈树中,任何节点的子节点要么全部被探索(在主要变路上),要么只有第一个子节点被探索后就发生截断(在非主要变路上)。
YSW算法思路:
- 顺序搜索第一个子节点。
- 如果第一个子节点没有导致Beta截断,则推测当前节点是一个“全宽度”节点(即主要变路的一部分)。此时,可以安全地并行搜索剩余的兄弟节点,因为根据推测,它们都需要被评估。
- 如果推测错误(即后续发现第一个子节点其实很好,本应导致截断),则必须中止那些已经启动的、不必要的兄弟节点的搜索。
中止机制
为了实现中止,需要在每个搜索节点中维护一个标志(例如 aborted)。当某个节点发生Beta截断时,它沿着树向上传播“中止”信号。所有正在进行的推测性任务定期(例如,每评估若干次后)检查其祖先节点是否被标记为中止。如果是,则立即停止计算并返回。
这避免了无用工作的继续蔓延,是控制推测开销的关键。
共享数据结构的并行化
在并行化搜索循环时,需要处理几个全局共享的数据结构,它们可能引入数据竞争:
-
置换表:缓存已搜索过的局面及其评估结果。这是最重要的优化之一。
- 选项:加锁保证原子更新、为每个工作线程维护本地副本、容忍良性竞态。
- 建议:在MIT参赛程序的经验中,由于竞态实际影响最终走法选择的概率极低,而加锁开销显著,他们选择了容忍竞态。但必须提供关闭并行置换表访问的开关,以便进行确定性调试和功能测试。
-
杀手启发表:记录在特定深度下导致截断的“杀手”走法,用于优化走法顺序。
- 选项:全局共享并同步更新,或线程本地副本。
- 建议:需要评估同步开销与信息共享带来的收益。可能折衷的方案更有效。
-
历史启发表:基于历史统计对走法进行排序。
- 面临与杀手表类似的选择。可以考虑使用线程本地表,定期合并到全局表,或者使用读写锁保护。
调试提示:务必为这些并行组件(推测、置换表访问等)实现开关。在测试评估函数、走法生成等基础模块时,关闭所有非确定性并行功能,确保结果可重复,便于定位问题。
Leiserchess性能优化机会
上一节我们深入探讨了并行搜索的核心机制。本节中我们来看看在Leiserchess项目中,除了并行化之外,还有哪些重要的性能优化方向。
以下是项目中值得关注的优化点列表:
- 激光覆盖评估:这是当前代码中最耗时的部分之一。
LaserCover函数通过模拟所有单步移动后的激光路径来评估局面的攻击性。优化思路包括:- 缓存:如果一步移动不改变激光路径上的棋子,则无需重新计算完整的激光覆盖评估,可以直接使用缓存值。
- 增量更新:利用位棋盘或更智能的数据结构,只更新受移动影响的激光路径部分。
- 更便宜的启发式:考虑能否找到一个计算量更小、虽不精确但足够指导搜索的替代评估函数,从而允许进行更深度的搜索。

-
走法排序:
search函数中,生成走法列表后的排序操作开销不小。在最佳顺序的树中,很多走法可能因为截断而根本不会被搜索到。- 惰性排序:可以尝试先只找出看起来最好的几个走法进行搜索。如果它们触发了截断,则无需排序和搜索剩余走法。
- 优化排序键计算:走法排序依赖置换表、杀手表、历史表等多种启发信息。确保这些值的计算高效。
-
棋盘表示:当前简单的棋盘数组表示可能不是最高效的。
- 位棋盘:使用64位整数(bitboard)表示每种棋子的位置。可以利用位操作(如移位、AND、POPCOUNT)高效地生成走法、计算激光路径等。这是国际象棋程序的标准优化。
- 棋子列表:维护一个所有棋子的列表及其坐标。随着棋子被吃,列表变短,操作开销可能降低。
- 重构建议:在修改表示前,将所有棋盘访问操作重构到独立的函数或宏中。这样可以在保留旧表示进行验证的同时,逐步替换为新表示,并确保编译器能内联这些调用以避免开销。
-
迭代加深:代码已经实现了迭代加深。理解其好处很重要:
- 走法排序:浅层搜索的结果为深层搜索提供了优质的走法顺序信息(通过置换表),极大地提高了剪枝效率。
- 时间控制:自然提供了在任意深度中断搜索的能力。
-
静止搜索:确保在评估局面时,不在“动荡”状态(例如正在交换棋子)中进行。代码应实现静止搜索,在常规搜索结束后,继续模拟所有吃子走法,直到达到“静止”局面再评估。
-
空着剪枝:基于“即使放弃行棋权,我方局面依然足够好”的假设。如果放弃行棋权(空着)后浅搜仍能引发Beta截断,则无需搜索其他走法。需注意避免陷入“无等着”局面。
-
残局库:对于棋子极少的局面,可以预先计算所有结果并存入数据库。但需要注意:
- 不仅要存储胜/负/和,还要存储距离将死的步数,以避免循环。
- 评估残局库的实际收益,因为很多对局可能在中局就结束了。
-
开局库:预先计算开局阶段的最佳走法。可以显著节省开局时间,并可能引导对手进入我方熟悉的局面。技巧包括为执白和执黑分别维护开局库,以及在库中引入随机性以避免被对手预测。
-
置换表调优:置换表是一个组相联缓存。可以调整其大小和相联度,在命中率和查找开销之间取得平衡。
-
后期走法削减:对于排序靠后的走法,减少其搜索深度,因为它们是“好走法”的概率较低。代码中已有相关参数,可以谨慎调整。
总结

本节课中我们一起学习了推测并行性的原理与实践。我们从简单的阈值求和例子出发,理解了如何通过中止标志来并行化可提前终止的循环。接着,我们深入探讨了如何将推测并行性应用于Alpha-Beta搜索,介绍了Young Siblings Wait算法及其关键的中止机制。我们还详细分析了在并行化过程中,置换表、杀手表等共享数据结构面临的竞争问题及处理策略。最后,我们梳理了Leiserchess项目中一系列重要的性能优化机会,包括激光评估优化、走法排序、棋盘表示改革等,为后续的项目开发提供了清晰的改进方向。记住,在追求性能的同时,保持代码的可测试性和可调试性至关重要。
021:调优TSP算法 🚀


在本节课中,我们将跟随John Bentley的讲解,学习如何通过一系列性能工程技术,对一个经典的旅行商问题求解算法进行逐步优化。我们将从最基础的暴力枚举开始,最终实现一个能够高效求解较大规模问题的程序。
概述
旅行商问题是一个著名的NP完全问题,其目标是找到访问一系列城市并返回起点的最短可能路径。我们将从一个简单的递归枚举算法出发,通过应用缓存、剪枝、预计算和更智能的搜索策略等性能工程技巧,逐步提升程序的运行效率。
递归生成:枚举所有子集
在深入TSP之前,我们先理解一个基础概念:如何递归地枚举一个集合的所有子集。这对于后续的排列生成和搜索至关重要。
假设我们有一个大小为 n 的集合。一个直观的迭代解法是从0数到 2^n - 1,每个数字的二进制位表示一个子集。但递归方法提供了更清晰的思路和更好的可扩展性。
递归的核心思想是:要生成所有大小为 n 的子集,可以先将最左边的位固定为0,递归生成剩余 n-1 位的所有子集;然后再将该位固定为1,再次递归生成。这样就能覆盖所有 2^n 种可能性。
以下是递归生成子集的伪代码框架:
void generate(int n) {
if (n == 0) {
visit(current_subset);
return;
}
// 固定当前位为0
set_bit(n-1, 0);
generate(n-1);
// 固定当前位为1
set_bit(n-1, 1);
generate(n-1);
}
理解这个递归模式是后续所有优化算法的基础。
旅行商问题简介
TSP不仅是一个理论问题,它在现实世界中有广泛的应用,例如:
- 历史:亚伯拉罕·林肯时代,法官需要巡回审理案件,希望找到最短的巡回路线。
- 制造业:电路板钻孔机需要以最短路径移动。
- 汽车装配:安排不同配置的汽车生产顺序,以最小化产线切换成本。
TSP是算法研究的“大肠杆菌”——一个被深入研究的原型问题。本节课的目标不是介绍最前沿的TSP算法,而是展示如何通过系统的性能工程,让一个基础算法变得实用。
算法演进:从暴力到智能
我们将从最简单的算法开始,逐步应用优化技巧。
算法1:朴素递归枚举
第一个算法直接生成所有 n! 种城市排列(即旅行路径),计算每条路径的总长度,并记录最短的那条。
核心代码结构:
void search(int m) {
if (m == 1) {
// 计算当前排列的路径总长度
compute_total_distance();
// 如果更短,则更新最优解
if (total_distance < min_distance) {
update_best_tour();
}
return;
}
for (int i = 0; i < m; i++) {
// 交换元素,生成新的排列
swap(i, m-1);
search(m-1); // 递归
swap(i, m-1); // 回溯,恢复状态
}
}
这个算法非常直观,但速度极慢。对于 n=10 的城市,10! = 3,628,800 种排列,在现代计算机上约需1秒。但当 n 增加到16时,预计需要数月时间。显然,我们需要优化。
初步优化:编译器与硬件
在修改算法之前,我们可以先利用外部因素获得加速:
- 编译器优化:使用
-O3优化标志进行编译。这可以带来惊人的速度提升(在我们的例子中高达25倍),因为编译器会自动进行循环展开、内联等优化。 - 更快的硬件:与20年前的机器相比,现代计算机由于更快的时钟频率、更宽的数据通路和更深的流水线,可以提供约150倍的性能提升。
这些是“免费”的加速,但面对阶乘级增长,它们只能让我们多解决一两个城市的问题。
算法2 & 3:预计算与固定起点
以下是两个简单的代码级优化:
- 算法2:查表代替计算:原本每次都需要计算城市间的几何距离(涉及平方、开方等运算)。我们可以预先计算所有城市对之间的距离并存入一个二维数组,将距离计算变为一次数组查找,这带来了约3倍的加速。
- 算法3:固定起始城市:由于旅行路线是一个环,起点选择不影响总长度。我们可以固定一个城市作为起点,将搜索空间从
n!减少到(n-1)!,获得了约n倍的加速。
算法4:累积部分和
在算法1中,每次计算完整路径的总长度都需要 O(n) 的时间。我们可以改进为在递归过程中累积当前已走路径的部分和。当递归深入时,我们只需加上新边的距离,而不是从头计算。这避免了大量重复计算。
这个优化将每次路径评估的成本从 O(n) 降到了 O(1),带来了显著的加速(约 e ≈ 2.718 倍)。
关键转折:剪枝搜索空间
之前的优化都是常数倍或线性倍的改进。要应对阶乘爆炸,必须减少需要搜索的排列数量,即“剪枝”。
算法5:简单剪枝(Bound)
核心思想:在递归构建部分路径时,如果当前累积的距离已经超过了目前找到的最优完整路径的长度,那么无论后面怎么走,这条部分路径都不可能成为最优解。因此,可以立即停止(剪枝)对该分支的进一步搜索。
这个简单的“如果已经搞砸了,就不要再继续”的策略效果惊人,将运行时间减少了数十到上百倍。
算法6:使用更紧的下界剪枝
简单剪枝的下界是“当前部分路径长度”,这个界比较松。我们可以计算一个更紧的下界来提前剪掉更多分支。
一个有效且计算成本可接受的下界是:当前部分路径长度 + 剩余未访问城市的最小生成树长度。
- 原理:访问剩余所有城市并返回起点的最短路径,其长度一定不小于连接这些城市的最小生成树的长度。
- 实现:在递归过程中,我们维护一个位掩码表示已访问的城市。对于剩余城市的集合,我们计算其最小生成树(MST)的长度(使用Prim等算法,约需
O(k^2)时间,k是剩余城市数)。 - 代价与收益:计算MST有开销,但更紧的下界能剪掉大量分支。实验证明,收益远大于代价,带来了数量级的加速。
算法7:缓存计算结果(Memoization)
在算法6中,我们反复为相同的剩余城市集合计算MST。这是一个典型的重复计算场景。
解决方案:使用哈希表缓存已计算过的 (剩余城市集合) -> MST长度 的映射。
- 实现:在计算MST前,先查询哈希表。如果找到,直接使用缓存值;如果没找到,则计算MST并将结果存入哈希表。
- 效果:这避免了大量重复的MST计算,再次带来了显著的加速(约15-25倍)。
算法8:更智能的搜索顺序(启发式)
搜索顺序影响剪枝效果。如果我们能更快地找到一个“相对较好”的路径,那么它的长度就可以作为一个更紧的界,从而更早地剪掉其他劣质分支。
策略:在递归选择下一个要访问的城市时,不按原始顺序尝试,而是按照与当前城市距离由近及远的顺序尝试。
- 原理:贪心地访问最近的城市,更可能快速构建出一条较短的完整路径。
- 实现:为每个城市预计算一个邻居城市列表(按距离排序)。在搜索时,按此列表顺序尝试。
- 效果:这引导搜索更快地找到优质解,从而更有效地剪枝,进一步提升了性能。
成果总结
通过应用上述一系列性能工程技术:
- 编译器优化与更快硬件(外部因素)。
- 预计算距离矩阵。
- 累积部分和避免重复计算。
- 剪枝:利用当前最优解和MST下界。
- 缓存(Memoization)MST结果。
- 启发式搜索顺序。

我们最终用大约160行C代码,将一个只能解决 n≈12 问题的朴素算法,提升到能够在数分钟到数小时内解决 n≈50 规模TSP问题的实用程序。这展示了将算法理论与性能工程实践相结合的巨大威力。
核心性能工程原则
本节课中我们一起学习了以下关键原则:
- 测量,不要猜测:性能优化前先分析瓶颈,优化后测量效果。直觉常常是错的。
- 避免不必要的工作:最大的加速来自于不做某事(剪枝、缓存)。
- 用空间换时间:查表、缓存计算结果都是经典策略。
- 利用问题的特殊性:固定起点、使用MST下界、启发式排序都依赖于TSP本身的性质。
- 分阶段优化:从高级算法和数据结构优化开始(剪枝、缓存),再进行代码级调优。
- 递归生成与搜索是解决组合问题的强大通用工具。

性能工程不仅是一门技术,更是一种思维模式。它让你能够深入系统内部,理解成本所在,并创造性地解决问题,从而让软件在现实世界中真正高效运行。
022:图优化 🚀

在本节课中,我们将学习图(Graph)的基本概念、在内存中的不同表示方法,以及如何高效地实现广度优先搜索(BFS)算法。我们还将探讨如何通过图压缩和重排序等技术来提升图算法的局部性。
什么是图? 📊
图由顶点(Vertices)和边(Edges)组成。顶点代表我们感兴趣的对象,而边则代表对象之间的关系。
例如,在社交网络中,每个人可以表示为一个顶点,如果两个人是朋友,则在他们之间建立一条边。边不一定需要是双向的。例如,在推特网络中,Alice可以关注Bob,但Bob不一定需要回关Alice。图也不一定是连通的,可能存在独立的子图。
图还可以用于建模蛋白质网络,其中顶点是蛋白质,边表示蛋白质之间的相互作用。边可以是有向的,也可以是无向的。此外,边还可以被赋予权重,以表示关系的强度或距离。
顶点和边都可以带有元数据和类型。例如,谷歌知识图谱中的节点包含出生日期、逝世日期等信息,并用颜色表示其知识类型。
图的应用场景 🌐
图在许多领域都有广泛应用。
以下是图的一些常见应用:
- 社交网络查询:例如,在Facebook上查找与你上过同一所高中的朋友,或查找你与他人的共同好友。社交网络服务也会使用图算法来推荐你可能认识的人或感兴趣的产品。
- 聚类:目标是找到图中内部连接紧密、外部连接稀疏的顶点组。应用包括社交网络中的社区发现、互联网上的欺诈网站检测、文档聚类以及机器学习中的无监督学习。
- 连接组学:研究大脑的网络结构,其中顶点是神经元,边表示神经元之间的相互作用。
- 计算机视觉:例如图像分割,将像素表示为顶点,在相邻像素之间建立边,权重代表它们的相似度,然后运行最小割算法来分割图像中的不同物体。
还有许多其他应用,这里仅列举一部分。
图的表示方法 💾
在接下来的讨论中,我们假设顶点被标记为从0到n-1的整数。
以下是几种常见的图表示方法:
- 邻接矩阵:一个n×n的矩阵。如果存在从顶点i到顶点j的边,则矩阵中第i行第j列的值为1,否则为0。
- 空间复杂度:
O(n²)
- 空间复杂度:
- 边列表:简单地存储图中所有边的列表,每条边用一个包含两个顶点坐标的元组表示。
- 空间复杂度:
O(m),其中m是边的数量。
- 空间复杂度:
- 邻接列表:为每个顶点维护一个指针,指向一个存储其邻居的链表(或数组)。
- 空间复杂度:
O(n + m) - 使用链表可能导致缓存性能不佳,因为访问邻居需要多次随机内存访问。使用数组可以改善缓存局部性,但更新图(如添加边)的成本更高。
- 空间复杂度:
- 压缩稀疏行格式:这是存储稀疏矩阵的常用格式,也适用于存储稀疏图。它包含两个数组:
offsets数组:长度为n,offsets[i]表示顶点i的边在edges数组中的起始位置。edges数组:按顺序存储所有顶点的邻居。
- 空间复杂度:
O(n + m) - 可以通过
offsets[i+1] - offsets[i]高效获取顶点i的度。 - 要存储边权重,可以创建一个额外的长度为m的数组,或者为了更好的缓存局部性,将权重与目标顶点交错存储在一个长度为2m的数组中。
CSR格式非常适合需要顺序扫描顶点邻居的静态图算法,因为它提供了良好的内存局部性。但对于需要频繁更新边的动态图,CSR效率较低。
真实世界图的特性 📈
了解真实世界图的特性对优化算法很重要。
以下是真实世界图的一些关键特性:
- 规模庞大但可管理:例如,几年前的一个推特快照拥有4100万个顶点和15亿条边,占用约6.3GB内存,可以存储在个人电脑的主内存中。目前最大的公开图(Common Crawl网页图)拥有35亿个顶点和1280亿条边,需要超过0.5TB内存,这在如今拥有TB级内存的服务器上也是可以处理的。
- 稀疏性:边的数量m通常远小于n²。
- 幂律度分布:大多数顶点的度数较低,少数顶点(如社交网络中的名人)拥有极高的度数。数学上,度数为d的顶点数量与
d^(-p)成正比,其中p通常在2到3之间。这种分布可能导致并行算法中出现负载不均衡问题。
广度优先搜索算法 🔍
BFS算法从给定的源顶点s出发,按照距离源顶点的顺序访问所有顶点。
BFS可能的输出包括:顶点被访问的顺序、每个顶点到源顶点的距离,以及一棵BFS树(其中每个顶点的父节点是其在前一层中的邻居)。
BFS有许多应用,包括作为中介中心性、偏心率估计、最大流算法、网页爬虫、环检测和垃圾收集等算法的子例程。
串行BFS实现
以下是使用CSR格式的串行BFS算法伪代码:
parent = array of size n, initialized to -1
Q = array of size n
Q[0] = source
parent[source] = source
front = 0, back = 1
while front != back:
current = Q[front]
front++
start = offsets[current]
end = offsets[current + 1]
for i from start to end-1:
neighbor = edges[i]
if parent[neighbor] == -1:
parent[neighbor] = current
Q[back] = neighbor
back++
该算法的工作量为O(n + m)。
在性能分析中,访问parent[neighbor]是开销最大的操作,因为它是对parent数组的随机访问,可能导致大量缓存未命中。一种优化方法是使用一个位向量(bit vector)来记录顶点是否已被访问,只有在顶点未被访问时才去访问parent数组。这可以将随机访问的次数从m次减少到n次,从而提升大图上的性能。
并行BFS实现
并行BFS以前沿(frontier)为单位进行操作。初始前沿只包含源顶点。在每一轮迭代中,并行地探索当前前沿中所有顶点的邻居,将未访问的邻居加入下一个前沿。
算法需要处理潜在的竞争条件,因为多个前沿顶点可能试图访问同一个邻居。这可以通过比较交换(compare-and-swap)操作来解决。
以下是并行BFS的关键步骤概述:
- 初始化
parent数组为-1,将源顶点放入初始前沿。 - While 前沿非空:
- 并行计算前沿中每个顶点的度数。
- 对度数数组进行前缀和(prefix sum),为每个顶点在下一个前沿数组中分配连续的写入位置。
- 并行遍历前沿中的每个顶点v及其所有邻居u:
- 使用
compare-and-swap(&parent[u], -1, v)尝试将v设为u的父节点。 - 如果成功,则将u写入分配好的下一个前沿位置。
- 使用
- 使用前缀和过滤掉下一个前沿数组中的无效项(如-1),形成新的前沿。
- 重复步骤2。
该算法的跨度(span)为O(d * log m),其中d是图的直径,工作是O(n + m),与串行算法相同。
并行BFS在实践中可以获得不错的加速比,例如在40核机器上使用80个超线程可获得约32倍的加速。
确定性并行BFS
上述并行BFS由于使用比较交换,生成的BFS树是非确定性的。为了获得确定性,可以采用两阶段方法:
- 第一阶段:使用原子性的“写最小值”(write-min)操作。每个前沿顶点尝试将其ID写入邻居的
parent槽位,但只有最小的ID会成功写入。 - 第二阶段:每个顶点检查其邻居的
parent值是否等于自己的ID。如果是,则该顶点负责将该邻居加入下一个前沿。
这种方法生成的BFS树总是相同的,虽然比非确定性版本慢5-20%,但带来了易于调试和推理的好处。
方向优化 BFS 🔄
方向优化基于BFS前沿大小随时间变化的观察。在迭代初期和末期,前沿较小;在中期,前沿可能变得非常大。
传统BFS是自顶向下的:从当前前沿顶点出发,探索其出边邻居。另一种方法是自底向上的:检查所有未访问顶点,查看其入边邻居是否在当前前沿中。如果找到,则将该前沿顶点作为父节点。
- 自顶向下:当前沿较小时效率高。更新
parent数组需要原子操作。 - 自底向上:当前沿较大、且许多顶点已被访问时效率高。可以跳过许多边遍历,且更新
parent数组无需原子操作。
方向优化BFS根据前沿大小动态选择模式。例如,当前沿大小超过n/20时切换到自底向上模式,否则使用自顶向下模式。对于有向图,自底向上模式需要预处理得到反向图(入边信息)。
方向优化可以显著提升在幂律图等真实世界图上的性能,有时能达到传统自顶向下方法的3倍速度。此思想可推广到其他图算法,如中介中心性、连通分量等。
图压缩技术 🗜️
图压缩的目标是减少图表示的内存占用,从而缓解内存带宽瓶颈。
在CSR格式中,我们可以对edges数组进行压缩:
- 首先对每个顶点的邻居列表进行排序。
- 存储差值:对于第一个邻居,存储
target - source;对于后续邻居,存储与前一邻居的差值。 - 使用变长编码(如字节码)来存储这些通常较小的差值,而不是使用完整的32位或64位整数。
为了提升解码性能并避免分支预测错误,可以采用分组编码:将需要相同字节数编码的整数分组,使用一个头字节记录组大小和每个整数所需的字节数,然后连续存储整数的数据部分。
对于高度数顶点,为了避免解码成为并行瓶颈,可以将其邻居列表分块(例如每块包含T个邻居),每块独立编码和解码,从而实现块间的并行解码。
实验表明,压缩方案可以节省大量内存。在串行执行时,速度可能与未压缩版本相近;在并行执行时,由于减轻了内存子系统压力,压缩版本甚至可能更快,并且解码操作能获得良好的并行加速比。
总结 📝
本节课我们一起学习了图优化相关的核心知识。
我们回顾了图的基本概念和多种内存表示方法,重点分析了压缩稀疏行格式的优缺点。我们深入探讨了广度优先搜索算法,分别实现了其串行和并行版本,并分析了性能瓶颈与优化策略,如使用位向量减少缓存未命中。我们还介绍了通过方向优化(自顶向下/自底向上切换)来提升BFS性能,以及通过图压缩技术来减少内存占用并提升并行可扩展性。


关键要点在于,许多图算法是不规则的,受限于随机内存访问。通过巧妙的算法优化(如方向优化)和利用局部性,可以显著提升性能。同时,需要意识到针对特定图结构(如幂律图)的优化可能对其他类型图(如道路网络图)无效,因此在优化时应使用多种图进行测试。
023:动态语言中的高性能

在本节课中,我们将探讨如何在Python、Julia等高层次动态语言中实现高性能计算。我们将分析这些语言性能差异的根本原因,并重点介绍Julia语言如何通过独特的设计,在保持动态性和高生产力的同时,达到接近C语言的执行速度。
概述
传统上,在Python等动态语言中进行高性能计算时,开发者通常采用“双语言方案”:用Python进行高层探索和原型设计,当需要性能关键代码时,则切换到C或Fortran等低级语言。这种方法虽然有效,但带来了编码复杂度的巨大跳跃和代码通用性的损失。
Julia语言的出现旨在解决这一矛盾。它试图成为一种既具有Python般的高层交互性和动态类型特性,又能让用户编写的循环等低级代码达到C语言速度的通用语言。本节课将通过具体的代码示例和性能对比,深入剖析Julia实现这一目标的机制。
Python的性能瓶颈
为了理解Julia为何能快,我们首先需要理解Python为何慢。决定此类语言性能的关键因素之一是数据类型的语义,这极大地限制了任何编译器可能进行的优化。
Python列表的数据结构
Python列表可以包含任意类型的元素,这意味着它在内存中必须被实现为一个指针数组。每个指针指向一个“装箱”对象,该对象不仅包含值本身,还包含一个类型标签。
内存结构示意:
[ 指针1 -> (类型标签, 值1), 指针2 -> (类型标签, 值2), 指针3 -> (类型标签, 值3) ]
这种设计的后果是,即使是最优化的C实现,在遍历列表求和时,每个循环迭代也必须:
- 追踪指针。
- 读取类型标签。
- 根据类型动态决定使用哪个加法函数。
- 为中间结果分配新的“装箱”对象。
这导致每个迭代都需要执行大量指令,而C语言中一个简单的浮点数数组求和循环,每个迭代可能只需几条机器指令。
NumPy的解决方案
NumPy库通过引入同质类型数组来解决这个问题。在这种数组中,所有元素类型相同,因此只需为整个数组存储一个类型标签,值在内存中连续存储。
内存结构示意:
(类型标签: float64, 长度: N, 值1, 值2, 值3, ...)
这样,一旦知道了类型和长度,就可以分派到类似C代码的优化内核上执行,性能得以大幅提升。然而,关键问题在于Python语言本身不提供表达这种语义的能力。你无法在Python中创建一个强制所有元素类型相同的列表,并告知编译器可以省略类型标签和指针。因此,像NumPy这样的高性能库其核心必须用C实现。
Julia的高性能之道
Julia的设计目标是在保持完全通用性的同时,编译出高效的代码。以下是一个完全通用的Vandermonde矩阵生成函数示例:
Julia代码示例:
function vander(x)
m = length(x)
V = Matrix{eltype(x)}(undef, m, m)
for j in 1:m
V[1, j] = one(eltype(x))
for i in 2:m
V[i, j] = V[i-1, j] * x[j]
end
end
return V
end
这段代码看起来像C,但没有类型声明。x可以是任何容器,只要支持索引操作;其元素可以是任何数值类型,只要支持乘法运算。尽管如此,它的性能却能与高度优化的NumPy C代码相媲美,甚至在某些情况下更快。
核心机制一:即时编译与类型特化
Julia性能的关键在于其即时(JIT)编译和类型特化机制。当一个函数被以特定的参数类型调用时,Julia会为该类型组合编译一个专用的、高度优化的版本。
示例:
f(x) = x + 1
当首次调用 f(3)(Int64类型)时,Julia会编译一个f(::Int64)的专用版本。当首次调用 f(3.1)(Float64类型)时,会编译另一个f(::Float64)的专用版本。后续使用相同类型的调用会直接使用已编译的版本。
这个过程是递归的。对于 g(x) = f(x) * 2,当用整数调用g时,编译器会内联f的整数版本,知道其返回类型也是整数,进而选择整数的乘法函数,并在编译时完成常量折叠等优化。
核心机制二:类型推断
类型推断是编译器在给定输入类型的情况下,推断出函数内部所有中间值及返回值类型的过程。在Julia中,成功的类型推断是生成高效代码的前提。
类型稳定的重要性:
函数的返回类型应仅依赖于输入参数的类型,而非其值。例如,平方根函数sqrt(x),即使输入x是整数4(结果是整数2),也应返回浮点数2.0。如果sqrt(4)返回2(整数),而sqrt(5)返回2.236...(浮点数),那么这个函数就不是类型稳定的,会严重阻碍编译优化。
Julia的设计(以及许多库的开发规范)鼓励编写类型稳定的代码,以利于类型推断成功。
核心机制三:多重分派
Julia的核心抽象是多重分派。与面向对象语言的单分派(根据第一个参数的类型决定方法)不同,多重分派根据所有参数的类型来选择最具体的方法。
示例:
+(a::Complex, b::Real) = Complex(a.re + b, a.im) # 复数 + 实数
+(a::Real, b::Complex) = Complex(a + b.re, b.im) # 实数 + 复数
这对于数学运算非常自然,也使得定义和处理混合类型操作变得简单而高效。
核心机制四:参数化类型
为了实现高性能且通用的用户自定义类型,Julia提供了参数化类型(类似于C++模板)。
定义一个高性能的Point类型:
struct Point{T <: Real}
x::T
y::T
end
# 为 Point{T} 定义加法
Base.:+(p::Point{T}, q::Point{S}) where {T<:Real, S<:Real} = Point(p.x + q.x, p.y + q.y)
# 定义零元
Base.zero(::Type{Point{T}}) where T = Point(zero(T), zero(T))
这里,Point本身是一个抽象类型族。Point{Float64}和Point{Int32}是不同的具体类型。数组Vector{Point{Float64}}在内存中是连续的x1, y1, x2, y2, ...,编译器知晓所有类型信息,可以生成与C结构体数组同样高效的代码。
这种方式既保证了性能(类型具体、内存连续),又保持了通用性(可适用于任何实数类型T)。
编译流程简介
Julia的编译流程大致如下:
- 解析:将源代码解析为抽象语法树(AST)。
- 宏展开:在编译早期,宏可以对AST进行任意重写,这是元编程和代码生成的重要工具。
- 类型推断与特化:当函数以具体类型被调用时,进行类型推断,决定分派的方法,并可能内联函数。
- 生成LLVM IR:将优化后的Julia代码转换为LLVM中间表示。
- 机器码生成与缓存:LLVM将IR编译为本地机器码并缓存。下次以相同类型调用该函数时,直接使用缓存的机器码。
总结
本节课我们一起学习了动态语言中实现高性能的关键。我们了解到Python等语言的性能瓶颈主要源于其动态类型系统必须支持的“装箱”和异构容器语义。而Julia通过一系列协同工作的设计克服了这些障碍:
- 即时编译与类型特化:为不同的参数类型编译专用代码。
- 类型推断:鼓励类型稳定的编程风格,使编译器能在编译期知晓类型。
- 多重分派:根据所有参数类型选择最优方法,自然处理多态。
- 参数化类型:允许用户定义高性能、通用的数据类型。


这些特性使得Julia能够将高级、通用的代码编译成与C语言相媲美的机器码,从而让开发者无需在“生产力”和“性能”之间做出艰难抉择,真正实现了“双语言方案”的统一。

浙公网安备 33010602011771号