PAYATHON 2026

为什么 Estrin 多项式求值在循环展开后加速,内联却可能变慢?

数据阿明

循环展开在分支预测准确时仍可能带来加速。这组代码中,MSVC 生成了紧凑的标量 4 次展开循环;ICX 则生成了标量和向量两套路径,通过地址重叠检查选择路径。执行 e = d 后,当前测试的后续层会走 ICX 未展开的标量路径,因此不能只比较汇编中的 AVX 代码。

允许内联后为什么变慢,目前的数据只能说明对应构建的运行时间增加了。原因可能与内联本身、向量化选择、寄存器分配或代码布局有关,还需要查看实际调用位置的汇编才能判断。

分支预测准确,为什么展开仍有收益?

分支预测主要减少走错执行路径的代价。即使预测正确,循环仍需要更新计数器、计算地址、比较边界和执行跳转,这些操作会占用处理器资源。

原始循环每生成一个结果就执行一次循环控制:

d[i] = e[2 * i] + e[2 * i + 1] * x;

展开 4 次后,一次循环控制可以生成四个结果。你给出的 MSVC 汇编采用的就是这种形式:四组加载、标量 FMA 和存储共用末尾的索引更新与条件跳转。

同一层中的不同输出没有 Horner 那样的浮点累加递推,处理器有机会重叠执行多组运算。展开也扩大了编译器的调度范围。不过,乱序处理器本来就能跨预测正确的循环分支执行后续迭代,不能把收益全部归因于展开带来的指令级并行。展开还减少了循环控制开销,改变了生成代码的结构。

源代码表面上的“没有数据依赖”也有范围限制:层与层之间存在数据依赖,x *= x 本身形成递推,从第二层开始,输入和输出的内存还会重叠。

ICX 的主要问题在哪里?

后续层实际走的是标量路径

这两行决定了后续迭代的内存关系:

e = d;
x *= x;

第一层从系数数组读取并写入 d。之后每一层都从 d 读取,再把缩减后的结果写回 d 的前部。

ICX 汇编在进入计算循环前有两组地址范围比较,用来决定是否使用向量路径。当前测试输入下,各层的路径如下:

层输入系数个数输出个数输入、输出关系所贴 ICX 汇编的路径
第一层3216p64_64 与 d 分离向量路径
第二层168e == d标量路径
第三层84e == d标量路径

后两层选择的标量循环每次只处理一个输出:

vmovsd xmm1,qword ptr [rdx+r10*2+8]
vfmadd213sd xmm1,xmm0,mmword ptr [rdx+r10*2]
vmovsd qword ptr [r10+rax],xmm1
add r10,8
dec r9
jne ...

因此,不能把 ICX 的整体实现概括为“较差的展开 2 次”。它做了运行时循环版本选择,后两层执行的是未展开的标量循环。

向前进行的原地缩减可以正确工作,但编译器仍需保证旧值在被覆盖前读出。程序在这些输入下安全,并不代表任意加载、存储重排都合法。

向量路径还有数据重排成本

相邻输入是交错排列的:

e[0], e[1], e[2], e[3], e[4], e[5], e[6], e[7]

一次计算四个输出时,需要分别取出偶数和奇数位置的值:

偶数项:e[0], e[2], e[4], e[6]
奇数项:e[1], e[3], e[5], e[7]

这解释了 ICX 向量路径中的 vperm2f128、vunpcklpd 和 vunpckhpd。四路 FMA 减少了算术指令数量,但增加了广播、重排和路径选择操作。

第一层只输出 16 个结果,这些开销值得测量。现有材料不足以判断哪种指令是主要瓶颈,也不能据此推断 AVX 向量化通常比标量展开慢。

最后的四项归约也不同

MSVC 基本保留了以下结构:

(e[0] + x * e[1]) + (x * x) * (e[2] + x * e[3])

其中两个括号可以独立计算。

ICX 则生成了连续三次依赖的 FMA,对应:

((e[3] * x + e[2]) * x + e[1]) * x + e[0]

两者在实数运算中表示同一个三次多项式,但依赖链和浮点舍入过程不同。这类转换可能出现在允许浮点重结合的编译配置下。比较性能时也要考虑这部分差异,不能只看内层循环。

为什么允许内联后可能变慢?

/Ob2 允许编译器进行内联,但不保证所有目标函数都会被内联,也不保证内联前后只相差一次函数调用。

这个测试通过函数指针调用被测函数,还读取全局变量 loop_npoly。当编译器能识别具体调用目标或传播参数时,可能同时改变:

  • 调用方式与常量传播,包括是否把长度视为固定值。
  • 循环展开、向量化和地址重叠检查。
  • 寄存器分配、栈上临时存储及指令调度。
  • 调用者的代码大小、分支位置与布局。

其中一些变化会加速,另一些可能抵消省掉函数调用的收益。

你贴出的汇编是独立函数体,不能完整解释 /Ob2 构建中的执行行为。应检查 timefun 的实际执行位置:函数指针调用是否仍在、求值函数是否被展开到调用者内部、热循环最终采用哪条路径。对于展开 2 次后变慢的情况,没有这些汇编还不能确定原因。

先修正基准中的两个具体问题

GCC 命令没有写对优化选项

原命令中的:

-o3

使用的是小写字母 o,表示指定输出文件名,不能开启 -O3 优化。末尾的 -om.out 又指定了输出文件。

可以先用下面的命令重新比较:

g++ -O3 -march=native -ffast-math so_toy_AH4.cpp -o m.out

这里的 -O3 使用大写字母 O。-ffast-math 会放宽浮点语义,是否保留应与实验目的一致。原命令不足以支持“GCC 优化后的代码慢一个数量级”的结论。

源码中的 #include <stdbool.g> 也需要修正;它不是标准头文件,C++ 本身已支持 bool。

展开 8 次的版本没有处理最后不足 8 项的情况

32 个系数的 radix 2 缩减过程为:

32 → 16 → 8 → 4

最后一层只需要输出 4 项,但 newest_estrin8 仍执行 8 项计算,额外读取当前有效输入范围以外的数据,并写入不需要的输出。

在当前全局 d[1024] 布局和 32 个系数的调用下,这些访问仍可能落在数组对象内部,不能直接断言这里发生了数组越界。但它确实执行了额外工作。如果使用恰好只有 8 个元素的输入数组调用该版本,第一次缩减就可能越界读取。

这使展开 8 次与其他版本的比较并不公平,应先加上尾部处理。

怎样改写并验证?

可以先把单层缩减提取出来,独立比较标量展开与自动向量化。下面的示例处理四项一组,并保留尾部循环;它约定输入、输出区域不重叠,src 至少包含 2 * n 项,dst 至少包含 n 项:

#include <cstddef>

void reduce_pairs(double x, const double* src,
                  double* dst, std::size_t n)
{
    std::size_t i = 0;

    for (; n - i >= 4; i += 4) {
        const double t0 = src[2 * i]     + x * src[2 * i + 1];
        const double t1 = src[2 * i + 2] + x * src[2 * i + 3];
        const double t2 = src[2 * i + 4] + x * src[2 * i + 5];
        const double t3 = src[2 * i + 6] + x * src[2 * i + 7];

        dst[i]     = t0;
        dst[i + 1] = t1;
        dst[i + 2] = t2;
        dst[i + 3] = t3;
    }

    for (; i < n; ++i) {
        dst[i] = src[2 * i] + x * src[2 * i + 1];
    }
}

这段源码不能保证生成某一种汇编。它明确了边界处理,方便控制实验变量。

如果完整算法改成两个工作缓冲区交替读写,可以为每层建立真正不重叠的输入、输出关系,再按编译器支持情况使用 __restrict 等扩展表达这一约定。工作缓冲区应在计时之外准备,并重新测量收益。原来的实现会执行 e = d,不能直接给原地读写路径加上不成立的无别名承诺。

建议按以下顺序验证:

  1. 修正编译命令和展开 8 次的尾部处理,先确认各版本计算同一个多项式。
  2. 分别测量输出长度为 16、8、4 的单层缩减,区分循环本身与层间存取的成本。
  3. 在相同配置下逐项比较是否内联、是否向量化、展开 1/2/4/8 次,记录实际生成的热路径。
  4. 使用固定调用次数和预先准备的输入,让结果参与计时后的可观察操作;重复运行并控制 CPU 核心、频率及系统负载。
  5. 再用性能分析工具检查分支、指令执行和内存相关瓶颈,避免仅凭代码长度判断性能。

Horner 与 Estrin 应该如何选择?

这组结果可以说明某些具体构建中的优劣,不能证明“32 个系数以内,内联的 Horner 总会获胜”。Horner 的单次求值存在较长的递推链;Estrin 缩短了算术依赖深度,但这个通用实现增加了临时数组读写和层级控制。固定长度实现是否能把中间结果留在寄存器中,也会显著影响比较。

另外,当前 err = ref - res 记录的是有符号绝对误差,而且拿多项式结果与 exp(x) 比较,其中包含逼近误差和浮点求值误差。要单独比较求值算法的数值误差,应以相同系数的高精度多项式结果作为参考。

备注:内容仅供参考。