为什么 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 汇编的路径 |
|---|---|---|---|---|
| 第一层 | 32 | 16 | p64_64 与 d 分离 | 向量路径 |
| 第二层 | 16 | 8 | e == d | 标量路径 |
| 第三层 | 8 | 4 | e == 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,不能直接给原地读写路径加上不成立的无别名承诺。
建议按以下顺序验证:
- 修正编译命令和展开 8 次的尾部处理,先确认各版本计算同一个多项式。
- 分别测量输出长度为 16、8、4 的单层缩减,区分循环本身与层间存取的成本。
- 在相同配置下逐项比较是否内联、是否向量化、展开 1/2/4/8 次,记录实际生成的热路径。
- 使用固定调用次数和预先准备的输入,让结果参与计时后的可观察操作;重复运行并控制 CPU 核心、频率及系统负载。
- 再用性能分析工具检查分支、指令执行和内存相关瓶颈,避免仅凭代码长度判断性能。
Horner 与 Estrin 应该如何选择?
这组结果可以说明某些具体构建中的优劣,不能证明“32 个系数以内,内联的 Horner 总会获胜”。Horner 的单次求值存在较长的递推链;Estrin 缩短了算术依赖深度,但这个通用实现增加了临时数组读写和层级控制。固定长度实现是否能把中间结果留在寄存器中,也会显著影响比较。
另外,当前 err = ref - res 记录的是有符号绝对误差,而且拿多项式结果与 exp(x) 比较,其中包含逼近误差和浮点求值误差。要单独比较求值算法的数值误差,应以相同系数的高精度多项式结果作为参考。
备注:内容仅供参考。
