PAYATHON 2026

为何未执行的 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 的值,所以编译器仍须生成这条调用路径。

这条路径会影响整个函数的编译:

  1. 函数调用会破坏调用者保存寄存器,编译器需要考虑哪些值必须跨调用保留。
  2. 新增的基本块会改变寄存器活跃区间和寄存器冲突图。
  3. 寄存器分配变化后,指令编码长度也可能随之变化。
  4. 指令长度会影响循环入口、分支目标及 cmp/jcc 指令对的位置。
  5. 链接后的地址变化还可能改变 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)。

备注:内容仅供参考。