为何未执行的 conditional printf 会加速两平方和代码?
结论
printf 本身不会让计算变快。性能变化来自编译器生成了不同的机器码,包括寄存器分配、指令长度和基本块布局的变化。结果是,内层循环的机器码恰好更适合这款处理器。
从给出的 ICX 汇编来看,较快的版本有两个明显特征:
- 热循环使用
ebx等低编号寄存器,部分指令因此少了一个 REX 前缀,循环体也更短。 - 包含
printf的版本中,循环范围约为0x1180到0x119f,完整落在一个 32-byte 对齐区域内。不含printf的版本约为0x10f0到0x1110,跨过了0x1100处的 32-byte 边界。
这些变化可能影响 Intel CPU 的 decoded-uop cache、取指、宏融合和分支前端吞吐。仅凭这两段汇编,还不能把 30% 的性能差异归因于某一个机制。不过可以确认,性能提升来自生成代码的变化,未执行的 I/O 不会产生运行时收益。
为什么未调用的 printf 仍会改变性能
当 n > 100 时,程序确实不会调用 printf。但函数也可能收到小于 100 的值,所以编译器仍须生成这条调用路径。
这条路径会影响整个函数的编译:
- 函数调用会破坏调用者保存寄存器,编译器需要考虑哪些值必须跨调用保留。
- 新增的基本块会改变寄存器活跃区间和寄存器冲突图。
- 寄存器分配变化后,指令编码长度也可能随之变化。
- 指令长度会影响循环入口、分支目标及
cmp/jcc指令对的位置。 - 链接后的地址变化还可能改变 CPU 前端每周期可提供的 µops 数量。
因此,冷路径即使从未调用 printf,也会在编译阶段影响热路径。
严格来说,也不是所有新增指令都不会执行。程序找到两平方和解后,仍会执行:
cmp edi, 63h
ja ...
当 n > 100 时,这个分支会跳过 printf。不过它只在找到解后执行,不属于常规的内层循环迭代,通常不足以解释主要的性能差异。
汇编里的主要差异
无调试输出版本的核心比较如下:
00007FF73C7610FA 45 8D 14 09 lea r10d,[r9+rcx]
00007FF73C7610FE 41 39 FA cmp r10d,edi
00007FF73C761101 74 9D je ...
cmp 从偏移 0x...10fe 开始,跨过了 0x...1100 处的 32-byte 边界,后面的条件跳转位于下一个区块。
包含调试输出的版本如下:
00007FF7DB91118B 43 8D 1C 08 lea ebx,[r8+r9]
00007FF7DB91118F 39 FB cmp ebx,edi
00007FF7DB911191 74 1D je ...
其中 cmp ebx,edi 只有两个字节,循环里存在依赖链的部分可以完整放进 0x...1180 到 0x...119f 这个 32-byte 区域。
这两项变化彼此相关。printf 路径改变了寄存器分配,低编号寄存器让指令更短,缩短后的循环又得到了更合适的布局。因此,很难把这一现象单独归为寄存器分配问题或分支对齐问题,因为寄存器分配本身就是布局变化的原因之一。
实际影响还取决于 CPU 微架构。不同代的 Intel、AMD 和 ARM 处理器采用不同的取指、µop cache 和分支实现,同一份源代码在其他机器上未必会获得相同比例的性能提升。
如何验证具体原因
可以按下面的顺序逐步缩小范围。
1. 测试最终二进制,而不是只看编译器输出
内联、链接顺序、ASLR 和函数布局都可能改变地址。应当反汇编实际运行的可执行文件,并记录热循环相对于 16-byte、32-byte 和 64-byte 边界的位置。
2. 只改变布局,不改变逻辑
可以在实验副本中加入不同数量的 nop,或者使用编译器提供的循环和函数对齐选项。如果性能随循环地址发生周期性变化,主要因素很可能就是前端布局。
这种填充方式只适合诊断。重新编译或更换链接器都可能改变结果,不宜把它作为长期优化手段。
3. 禁止内联并隔离测试函数
函数内联后,main 中的计数器、输出代码和函数内部变量会共同参与寄存器分配。可以暂时禁止内联,判断异常主要来自函数内部,还是来自内联后的整体分配。
例如:
#if defined(_MSC_VER)
#define NOINLINE __declspec(noinline)
#else
#define NOINLINE __attribute__((noinline))
#endif
NOINLINE bool sum_2squares(uint32_t n)
{
/* ... */
}
禁止内联可能降低绝对性能,但有助于定位问题。
4. 查看硬件性能计数器
如果条件允许,可以比较以下事件:
- frontend stalls;
- decoded-uop cache 与 legacy decoder 提供的 µops;
- branch mispredictions;
- retired instructions 和 retired µops;
- cycles per iteration。
Intel VTune、Linux perf 和处理器厂商提供的性能分析工具都能完成这类测量。结合实际 CPU 型号和计数器结果,才能进一步区分 µop cache、宏融合、分支取指和其他前端限制。
5. 使用 PGO
这类函数分支密集,冷热路径差异也很明显,适合使用 Profile-Guided Optimization。PGO 能向编译器提供实际的分支频率,帮助它重新排列基本块并分离冷路径,比保留一条偶然改善布局的 printf 更可靠。
不要把变量宽度当成通用优化规则
把 delta_a 改为 64 bit 后程序变快,不代表 64-bit 加法天然快于 32-bit 加法。在 x86-64 上,两种运算通常都很便宜。性能变化更可能来自以下因素:
- 变量能否与其他值共用寄存器;
- 是否需要额外的零扩展或截断;
- 编译器如何合并复制操作;
- 是否使用需要 REX 前缀的扩展寄存器;
- 循环体最终包含多少字节和多少 µops。
同样,把 main 中的 i 改为 64 bit 后性能下降,也可能是内联引起的寄存器压力和指令布局变化,不能由此得出“循环计数器应该始终使用 32 bit”的结论。
更稳妥的代码结构
可以把调试输出移到计算内核之外,并简化更新逻辑。下面的版本保留 uint32_t 输入,使用 uint64_t 保存平方及其差分,避免中间结果溢出:
#include <stdbool.h>
#include <stdint.h>
#include <math.h>
#if defined(_MSC_VER)
#define NOINLINE __declspec(noinline)
#else
#define NOINLINE __attribute__((noinline))
#endif
NOINLINE bool sum_2squares(uint32_t n)
{
uint64_t limit = (uint64_t)sqrt((double)n);
/* 防止浮点 sqrt 的舍入误差影响取整结果。 */
while ((limit + 1) * (limit + 1) <= n)
++limit;
while (limit * limit > n)
--limit;
uint64_t a2 = 0;
uint64_t b2 = limit * limit;
uint64_t delta_a = 1;
uint64_t delta_b = 2 * limit - 1;
uint64_t half = n / 2;
while (a2 <= half) {
uint64_t sum = a2 + b2;
if (sum == n)
return true;
if (sum > n) {
b2 -= delta_b;
delta_b -= 2;
} else {
a2 += delta_a;
delta_a += 2;
}
}
return false;
}
如果只需输出较小数值的解,可以在计时区外调用单独的诊断函数,甚至针对 n < 100 再计算一次。这样,生产环境中的计算内核就不再依赖 printf 偶然带来的布局变化。
还要注意一种类型宽度不一致的情况:如果更新后的版本把 n、a2 和 b2 扩展为 uint64_t,却仍将 sum 定义为 uint32_t,结果超过 UINT32_MAX 时就会被截断。若函数要支持完整的 uint64_t 输入,sum 也必须使用足够宽的类型,同时还要处理平方、2 * limit 和整数平方根的溢出边界。
计时也需要修正
clock() 返回的是 clock_t,不应使用 time_t 保存:
clock_t start = clock();
/* benchmark */
clock_t end = clock();
double seconds = (double)(end - start) / CLOCKS_PER_SEC;
正式测试时还应固定编译选项和输入,先预热代码,再交替运行两个版本多次,并避免在计时区间内输出。测试结果最好用于后续计算或输出,防止编译器删除待测代码。
从总运行时间来看,范围上限扩大 10 倍后,耗时约增加 32 倍。这符合累计执行 O(sqrt(n)) 检查时的复杂度:
[ \sum_{n=1}^{N} O(\sqrt n)=O(N^{3/2}) ]
如果目标是统计很大范围内的数量,汇编级优化只能改善常数项。要获得更明显的提升,通常需要更换算法。例如,可以使用“两平方和定理”:一个正整数能表示为两个整数平方和,当且仅当其素因数分解中所有满足 (p \equiv 3 \pmod 4) 的素数都具有偶数指数。统计整个区间时,还可以结合筛法,避免对每个数分别扫描到 (\sqrt n)。
备注:内容仅供参考。
