做了这么多年系统方向的实验,我始终觉得 Performance Lab 是 CSAPP 系列里最容易被低估的一个。很多人看到题目要求——优化两个图像处理函数,让程序跑得更快——就觉得这只是个"拼手速"的活儿,随便展开几层循环、加几个inline就能交差。实际上,这个实验的分数差距可以非常悬殊:有的人折腾两天只提高 1.5 倍,有的人一个下午就做到 8 倍以上的加速比。差别不在谁写代码更花哨,而在谁更早理解了评测机制和程序的真实瓶颈。
这篇文章把我做 Performance Lab 的完整思路、踩坑记录和最终定稿的优化方案整理出来。内容会比较长,因为我不光想告诉你"怎么写能拿高分",更想解释清楚每一个优化步骤背后的原因:为什么朴素版本慢、为什么分块能提速、为什么 smooth 比 rotate 难搞、为什么你以为优化好了、分数却不涨。适合正在做这个实验、或者想系统提升程序性能优化能力的人对照着看。
1. Performance Lab 到底在考什么:先看懂评测机制再动手写代码
很多人拿到实验第一步就直接打开kernels.c开始改代码,这是最要命的。Performance Lab 不是一个"只要让程序跑得快就行"的实验,它有一套固定的评测流程和打分规则。不先搞清楚这套规则,你的优化可能是白做的,甚至可能是违规的。
1.1 实验任务与评分逻辑
从功能上讲,Performance Lab 一般要求你优化两个函数:
rotate:把一张正方形图像顺时针旋转 90 度。smooth:对图像做 3x3 均值模糊,也就是每个像素变成它自己和周围 8 个邻居的平均值。
评测过程大概是这样:评测脚本先运行一个标准朴素版本作为基准,再运行你提交的版本,分别计时。你的分数由加速比决定,加速比的计算方式本质上就是:
加速比 = 朴素版本的运行时间 / 你的版本的运行时间然后按加速比线性映射到分数区间。所以你的唯一目标非常明确:在功能完全正确的前提下,尽可能缩短运行时间。有人可能会说,那我直接把图像整个复制到内存再倒过来读不就行了?不行,rotate的结果必须写回目标区域,而且评测脚本会校验输出,输出错了直接零分。
我见过有人花大量时间优化评测脚本本身,还有人试图修改测试用例的规模,这些都是没意义的。评测脚本是固定的,你能改的只有kernels.c里那两三个函数的实现,其他位置的改动都属于作废条款。
1.2 评测计时方式的隐藏含义
还有个容易忽略的点:评测脚本在计时的时候,通常是先预热再多次运行,取一个代表性数值。这意味着你的函数会被反复调用很多次。如果你在函数里做"一次性初始化"——比如第一次调用时预计算一张大表——那么第一次调用的开销会被摊到很多次运行里,看起来平均时间很低。但这属于投机取巧,而且评测脚本往往会对这种情况有防范措施。
更关键的是,评测脚本编译你的代码时使用的优化选项和你本地可能不一样。我当时的做法是:先把评测脚本跑一遍,看它以什么选项编译、它究竟在测哪个函数、它用多大的矩阵。看清楚这些之后再动手写代码,你才知道自己该朝哪个方向使劲。比如,如果编译选项已经带了-O2,那你手写的一些"看起来聪明"的变换可能早就被编译器做了,反而要着重解决编译器不擅长的问题——比如缓存访问模式和数据依赖。
1.3 先建立一个可以复现的基准
我强烈建议你在动手优化之前,先跑一次原始的朴素版本,把基准时间记录下来。这一步很多人嫌麻烦,但它是你后面判断优化是否有效的唯一依据。我自己的习惯是写一个小的本地测量脚本,用和评测脚本相同的编译选项,对同一个矩阵跑 10 次,取最小值或者中位数。
为什么要取最小值而不是平均值?因为操作系统调度、后台进程、CPU 频率波动都会带来噪声,平均时间容易被偶然的慢速拖垮,最小值更接近这个程序在理想硬件条件下的真实成本。后面你每次改完代码,都用同一套方法测,保持对比条件一致,你的每个优化措施到底有没有效果才能一目了然。
2. rotate 函数:从朴素版本到缓存友好分块的完整优化路径
rotate 这个函数是多数人拿分的主要来源。它的逻辑很简单:把src矩阵旋转 90 度写入dst。朴素版本直接翻译数学定义,两层循环,每个元素单独移动。写起来确实快,但跑起来慢得惊人。
2.1 朴素旋转为什么慢:逐列写入的本质问题
以dst[i][j] = src[R-1-j][i]这种映射为例,对src的读取是逐行连续的,对dst的写入却是逐列进行的。举个例子,矩阵大小是 32x32,每个像素是 4 字节的int。现代 CPU 的缓存行一般是 64 字节,也就是一个缓存行能装 16 个int。矩阵的一行有 32 个int,正好占两个缓存行。
朴素写法的问题集中体现在写入端:当你固定列j、循环变量i从 0 变到 31 时,你写入dst[i][j]的地址每次跳跃 128 字节。这意味着每一次写入都落在不同的缓存行里,等于是每次写都伴随一次缓存未命中。32 次循环可能产生 32 次写缺失,而缓存的容量和行替换策略都帮不上忙。
打个比方:你有一摞扑克牌要按花色分成四堆,朴素做法是每看到一张牌就往对应的堆里放一张,来回走动 52 次。而分块优化的思路是,先把牌桌上的牌分批拿起来,在手里分好四堆,再一次性放过去。走动次数大大减少。
2.2 分块、展开与循环开销:三个优化手段的组合拳
针对上面的问题,最有效的优化是分块。核心思路是:不要一个元素一个元素地旋转,而是把矩阵分成小块,比如 32x32 或 16x16 的块,对每个块整体旋转搬运。这样,在搬运一个块时,块的源数据能装进缓存,写入端也有一定的连续性,减少反复跨越缓存行的次数。
分块大小不是随便定的。太小了,块内连续写入的优势不明显,循环开销占比也上升;太大了,一个块超过缓存容量,分块的效果就消失了。实践中最常见的几个选择是 32、16、8。以 32x32 为例,你的内外层循环变成遍历块的行列,每个块内部再做一次搬运。这里可以先给个模板:
void rotate_blocked(int dim, pixel *src, pixel *dst) { int i, j, bi, bj; for (bi = 0; bi < dim; bi += 32) { for (bj = 0; bj < dim; bj += 32) { for (i = bi; i < bi + 32; i++) { for (j = bj; j < bj + 32; j++) { dst[i][j] = src[dim - 1 - j][i]; } } } } }在此基础上,另一个收益明显的手段是循环展开。内层循环每轮只处理一个元素,循环控制和指针计算的开销占比较高。展开成 4x4 甚至 8x4 后,每次外层迭代做一批搬运,减少了循环跳转和索引计算的次数。同时展开后可以在多条独立指令之间暴露指令级并行的机会,因为多个搬运之间没有数据依赖。
还有一个小技巧是尽量用局部变量缓存数组下标。比如先计算好dim - 1 - j,或者把dst的行指针和列索引在循环外算好,避免每次寻址都重新计算。这些在-O2下编译器可能会帮你做一部分,但你手动展开能确保它做了。
2.3 我实测过的版本对比
为了让你对优化幅度有感知,我把我在某台开发机上测过的几个版本整理如下。不同机器绝对数值会不同,但相对趋势基本一致:
| 版本 | 关键改动 | 相对运行时间(越小越快) | 相对朴素版本的加速比 |
|---|---|---|---|
| 朴素版 | 直接两层循环 | 1.00 | 1.00 |
| 分块 32 | 32x32 块 | 0.38 | 约 2.6 倍 |
| 分块 16 | 16x16 块 | 0.36 | 约 2.8 倍 |
| 分块 32 + 4x4 展开 | 展开内层搬运 | 0.22 | 约 4.5 倍 |
| 分块 32 + 8x4 展开 + 局部下标缓存 | 进一步减少循环开销 | 0.18 | 约 5.6 倍 |
这组数据在同类机器上有一定代表性:分块本身就能带来 2 到 3 倍的提升,循环展开再翻一倍左右。如果矩阵更大(比如 256x256 或 512x512),分块收益会更明显,因为缓存缺失的绝对次数更多。
有个容易翻车的点我必须提醒:旋转方向不要搞反。dst[i][j]到底等于src[dim-1-j][i]还是src[j][dim-1-i],取决于评测系统定义的是顺时针还是逆时针。写代码前先看朴素版本的映射关系,别想当然。
3. smooth 函数:真正的瓶颈不是矩阵访问而是重复计算
如果说 rotate 是"缓存友好性"的练习题,那 smooth 就是对"算法冗余"的体检。很多人在 rotate 上能轻松拿到高分,到了 smooth 却一筹莫展。原因在于 smooth 的朴素版本其实访问模式相当良好——顺序读、顺序写——所以你再怎么折腾分块也压不出太多性能。真正的瓶颈是在大量无意义的重复计算上。
3.1 先厘清边界条件:三种情况的处理
3x3 均值模糊的逻辑是:输出图像的每个像素取输入图像对应位置及其上下左右八个邻居的平均值。但图像四边的像素没有完整的九个邻居,角落更少。所以朴素实现里通常有大量if判断,分别处理内部点、边界点、角落点。
这些if的分支预测开销比你想象的大。每次循环都要判断当前位置在不在边界,而矩阵的绝大多数像素是内部点,分支出其不意地稳定,但计算过程本身就复杂。一个更聪明的做法是把边界特判从主循环里挪出去:先单独处理四条边和四个角,剩下的大块区域全部用无分支的内部点公式计算。
内部点的均值公式可以写成:
dst[i][j] = ( src[i-1][j-1] + src[i-1][j] + src[i-1][j+1] + src[i][j-1] + src[i][j] + src[i][j+1] + src[i+1][j-1] + src[i+1][j] + src[i+1][j+1] ) / 9;3.2 核心优化:减少重复加法,甚至干掉除法
如果你直接按上面的公式写内部点循环,每个输出像素要做八次加法、一次除法。对一张 512x512 的图像,这意味着约 26 万次除法。整数除法在现代 CPU 上是出了名的慢,一条div指令延迟通常是 20 到 30 个周期,直接成了性能黑洞。
有两种常见且有效的优化路线。
路线一:行方向滑动窗口。对每一行,先计算前两个像素各自所在 3x3 窗口的第一列元素之和(三个元素相加),存到临时数组。接下来移动窗口时,新窗口的和等于旧窗口的和减去离开窗口的列,再加上新进入的列。这样每个内部像素只需要两次加法和两次减法,而不是八次加法。
路线二:预计算除法结果查表。如果像素的取值范围是 0 到 255,九个像素之和的范围就是 0 到 2295,总共只有 2296 种可能。你可以开一个长度 2296 的unsigned char数组,预先算好每个和值除以 9 的结果,然后每到一个新像素,只查一次表就得到均值。查表比整数除法快一个数量级。
我当时实际采用的是"滑窗 + 查表"的组合:先对每一行做水平方向的三像素和滑窗,再对每一列做垂直方向的窗口累加,最后的除法全部变成查表。这里我给出一段简化的内部点处理逻辑,展示滑窗思路:
// 假设 wh 已经保存了每个位置水平方向三个像素之和 // 对内部点区域进行垂直滑窗累加,最后查表得均值 for (i = 1; i < dim - 1; i++) { for (j = 1; j < dim - 1; j++) { int sum = wh[i - 1][j] + wh[i][j] + wh[i + 1][j]; dst[i][j] = avg_table[sum]; } }wh[i][j]表示第i行、以j为中心的三个像素之和,计算它本身可以用水平滑窗:对每个i,先加前两个,再循环滑动。这样整个 smooth 的计算量从"每个像素九次加法一次除法"降到了"每个像素两次加法一次查表"。
3.3 为什么这道题拿低分的人特别多
我总结了一下,smooth 拿低分通常不是代码写得差,而是没有看出重复计算。很多人盯着"内存访问模式"优化了好几天,分块、转置、对齐全试了一遍,加速比依然在 1.5 倍附近徘徊。方向错了。
另一个常见问题是边界处理写得臃肿。有人为了省事把所有情况统一用一个带大量if的公式处理,编译器难以做分支优化,每次循环都要做一堆判断。我的建议始终是:主循环内部不要出现任何分支。四条边加四个角的处理代码量不大,但收益非常可观。
还有一点容易被忽略:评测的矩阵尺寸未必是 32 或 512 这种规整数值。如果尺寸是奇数,比如 31x31 或 257x257,你的边界判断若是if (i == 0 || i == dim - 1 || j == 0 || j == dim - 1),逻辑上没问题,但如果你为了省事假设了所有边界都是dim-1这种对称形式,奇数尺寸下容易错位。我的实测经历里就出现过这类 bug,后面专门写了一段测试代码把所有尺寸跑了一遍才发现。
4. 评测驱动与成绩复查:如何确认你的优化真的被"收进"了分数
等你把两个函数都优化完,先别急着提交。Performance Lab 的评测机制是一个黑盒,如果你不理解它是怎么选中你写的函数的,很可能出现"本地测 8 倍加速、交上去只有 2 倍"的诡异情况。
4.1 函数注册机制:优化写好了,但你没注册等于白干
在kernels.c里,一般会有类似register_rotate_kernel和register_smooth_kernel的函数。评测脚本会遍历所有注册过的内核,逐个测试评分。如果你新写了两个优化版函数,却忘了把它们添加到注册列表里,评测脚本跑的还是朴素版本,你的分数自然毫无变化。
这个细节我栽过一次。当时我为了对比方便,把几个版本的代码都写在文件里,只有一个是注册的。最后交上去发现分数没变,回去一看,注册的还是旧的朴素版函数。建议你在写优化函数的同时,就把注册代码一并改好,不要拖到最后。
4.2 黑盒复查的两个实用技巧
提交前,我会做两件额外验证:
第一,用不同尺寸的矩阵各测一遍。分块大小对矩阵尺寸有依赖性。比如你只测试了 32x32,发现分块 32 效果最好,但评测可能用了 512x512 甚至 1024x1024 的矩阵,这时分块 32 可能就不是最优的了。我见过有人分块大小设成 32,评测时矩阵是 256,效果尚可;但换成 1024,块内局部性变差,加速比反而下降。
第二,检查输出正确性。优化过程中很容易在边界处理上引入偏差,尤其是 smooth 的四角处理。我写了个小函数,把朴素版本的结果和优化版本的结果逐个像素比较,任何不一致立刻定位。这个检查必须在每次改动后都跑,别偷懒。
4.3 曾经让我翻车的三个边界案例
老实说,这个实验我前前后后掉进过不少坑。挑三个印象最深的:
第一个是分块大小和缓存容量的错配。我一开始在图省事,rotate 直接用了 32x32 分块。在 32x32 的测试图上表现很好,但换成大矩阵后反而更慢了。原因是大矩阵下 32x32 的块太小,循环遍历块的开销占比变大,而且没有充分利用缓存行。后来我改用 64x64 甚至根据维度动态选择块大小,效果才稳定下来。
第二个是除法优化引入了精度问题。smooth 如果用浮点预计算1.0/9.0再乘回去,浮点误差会导致部分像素的值差 1。评测脚本校验输出时通常会容忍一个极小误差,但如果你把差的 1 堆到多个通道上,可能会触发失配。最终我选择整数查表法,彻底绕开这个问题,整数运算的结果和朴素版本分毫不差。
第三个是顺手改了评测脚本。那是我一个同学犯的错,他为了本地测试方便,改了评测脚本里的矩阵尺寸,提交时忘了恢复。结果交上去跑出了不可思议的成绩单,随后被判定为违规操作。无论本地怎么改,提交前请务必保证评测相关文件保持原始状态。
5. 写在最后:这个实验教给我的不只是优化技巧
做完 Performance Lab 之后很长一段时间里,我写代码都会下意识地多问自己一句:这个循环的访问模式对缓存友好吗?这段计算真的非做不可吗?这种思维习惯比拿多少分都值钱。
如果让我给正在做这个实验的人一条最核心的建议,那就是:先去读评测代码,再去读你的代码,最后才去改代码。这个实验真正考察的不是你会不会用某个编译器优化技巧,而是你有没有能力判断一个程序到底慢在哪儿。rotate 教会你看内存访问,smooth 教会你看重复计算。把这两个问题看透,你在大部分性能优化场景下都不会太差。祝顺利。