为什么这段代码在对循环携带的加法进行强度降低的乘法运算后执行得更慢?

我正在阅读agn雾优化手册,我看到了这个例子:

double data[LEN];


void compute()
{
const double A = 1.1, B = 2.2, C = 3.3;


int i;
for(i=0; i<LEN; i++) {
data[i] = A*i*i + B*i + C;
}
}

Agner指出,有一种方法可以优化这段代码——通过实现循环可以避免使用昂贵的乘法,而是使用&;delta &;应用于每次迭代。

我用一张纸来证实这个理论,首先……

Theory

...当然,他是对的——在每次循环迭代中,我们都可以在旧结果的基础上计算出新的结果,通过添加“delta”。这个增量从值“;A+B"开始,然后以“;2*A"在每一步上。

所以我们将代码更新为如下所示:

void compute()
{
const double A = 1.1, B = 2.2, C = 3.3;
const double A2 = A+A;
double Z = A+B;
double Y = C;


int i;
for(i=0; i<LEN; i++) {
data[i] = Y;
Y += Z;
Z += A2;
}
}

就操作复杂性而言,这两个版本的函数确实存在显著差异。在我们的cpu中,与加法相比,乘法的速度要慢得多。我们已经替换了3个乘法和2个加法…只有2个补充!

因此,我继续添加一个循环来多次执行compute -然后保持执行所需的最小时间:

unsigned long long ts2ns(const struct timespec *ts)
{
return ts->tv_sec * 1e9 + ts->tv_nsec;
}


int main(int argc, char *argv[])
{
unsigned long long mini = 1e9;
for (int i=0; i<1000; i++) {
struct timespec t1, t2;
clock_gettime(CLOCK_MONOTONIC_RAW, &t1);
compute();
clock_gettime(CLOCK_MONOTONIC_RAW, &t2);
unsigned long long diff = ts2ns(&t2) - ts2ns(&t1);
if (mini > diff) mini = diff;
}
printf("[-] Took: %lld ns.\n", mini);
}

我编译了两个版本,运行它们…看看这个:

gcc -O3 -o 1 ./code1.c


gcc -O3 -o 2 ./code2.c


./1


[-] Took: 405858 ns.


./2


[-] Took: 791652 ns.

这可真出乎意料。由于我们报告的是执行的最短时间,所以我们扔掉了“噪音”;由操作系统的各个部分引起。我们还小心地在一台完全不做任何事情的机器上运行。结果或多或少是可重复的-重新运行两个二进制文件显示这是一个一致的结果:

for i in {1..10} ; do ./1 ; done


[-] Took: 406886 ns.
[-] Took: 413798 ns.
[-] Took: 405856 ns.
[-] Took: 405848 ns.
[-] Took: 406839 ns.
[-] Took: 405841 ns.
[-] Took: 405853 ns.
[-] Took: 405844 ns.
[-] Took: 405837 ns.
[-] Took: 406854 ns.


for i in {1..10} ; do ./2 ; done


[-] Took: 791797 ns.
[-] Took: 791643 ns.
[-] Took: 791640 ns.
[-] Took: 791636 ns.
[-] Took: 791631 ns.
[-] Took: 791642 ns.
[-] Took: 791642 ns.
[-] Took: 791640 ns.
[-] Took: 791647 ns.
[-] Took: 791639 ns.

接下来要做的唯一一件事就是看看编译器为这两个版本分别创建了什么样的代码。

objdump -d -S显示了compute的第一个版本——“哑巴”,但以某种方式快速的代码——有一个像这样的循环:

objdump naive

第二个优化版本呢?它只增加了两个功能。

objdump optimized

我不知道你们怎么想,但就我自己而言,我……困惑。第二个版本的指令大约少了4倍,其中两个主要的指令只是基于__abc2的添加(addsd)。第一个版本,不仅有4倍多的指令…它也充满了(正如预期的那样)乘法(mulpd)。

我承认我没有预料到那个结果。不是因为我是阿格纳的粉丝(我是,但这无关紧要)。

你知道我错过了什么吗?我在这里犯了什么错误,可以解释速度上的差异吗?注意,我已经在至强W5580至强e5 - 1620上做了测试-在这两个版本中,第一个(哑)版本比第二个快得多。

为了便于重现结果,这两个版本的代码有两个gist: 笨,但不知何故更快优化了,但不知何故变慢了

附注:请不要评论浮点精度问题;这不是问题的重点。

85344 次浏览

理解你所看到的性能差异的关键在于向量化。是的,基于加法的解决方案在其内部循环中只有两条指令,但重要的区别不在于循环中有有多少指令,而在于工作量有多大中每条指令都在执行。

在第一个版本中,输出完全依赖于输入:每个data[i]只是i本身的函数,这意味着每个data[i]可以以任何顺序计算:编译器可以向前、向后、横向,无论如何,你仍然会得到相同的结果——除非你从另一个线程观察内存,否则你永远不会注意到数据是如何被处理的。

在第二个版本中,输出不依赖于i——它依赖于上次循环中的AZ

如果我们将这些循环的主体表示为小的数学函数,它们将有非常不同的整体形式:

  • f(我)→迪
  • f(Y, Z) ->(di, Y', Z')

在后一种形式中,没有对i的实际依赖-你可以计算函数值的唯一方法是通过知道上次函数调用的前YZ,这意味着函数形成了一个链-在完成前一个函数之前,你不能进行下一个函数。

为什么这很重要?因为CPU有向量平行指令,所以每一个可以同时执行两个、四个甚至八个算术运算!(AVX cpu可以并行执行更多操作。)4个乘法,4个加法,4个减法,4个比较,随便4个!因此,如果你试图计算的输出是只有,依赖于输入,那么你可以安全地一次执行两个,四个,甚至八个——不管它们是正向还是反向,因为结果是相同的。但是如果输出依赖于之前的计算,那么你就会被困在串行形式中——一次一个。

这就是为什么“longer”;代码赢得性能。尽管它有更多的设置,而且实际上有更多的工作,但大部分工作都是并行完成的:它不仅仅在每次循环迭代中计算data[i]——它同时计算data[i]data[i+1]data[i+2]data[i+3],然后跳转到下一个4个的集合。

为了扩展一下我在这里的意思,编译器首先将原始代码转换成这样:

int i;
for (i = 0; i < LEN; i += 4) {
data[i+0] = A*(i+0)*(i+0) + B*(i+0) + C;
data[i+1] = A*(i+1)*(i+1) + B*(i+1) + C;
data[i+2] = A*(i+2)*(i+2) + B*(i+2) + C;
data[i+3] = A*(i+3)*(i+3) + B*(i+3) + C;
}

你可以说服自己,如果你眯着眼睛看,它会和原来的一样。它这样做是因为所有这些相同的垂直操作符行:所有这些*+操作都是相同的操作,只是在不同的数据上执行——CPU有特殊的内置指令,可以在同一时间对不同的数据执行多个*或多个+操作,每个操作仅在一个时钟周期内。

注意快速解决方案——addpdmulpd中的指令中的字母p,以及较慢解决方案——addsd中的指令中的字母s。那是“双人打包”。和“Multiply Packed Doubles”;与“Add Single Double."

不仅如此,它看起来像编译器也部分展开了循环-循环不只是每次迭代执行两个值,而是实际上执行四个,并交叉操作以避免依赖和停顿,所有这些都减少了汇编代码必须测试i < 1000的次数。

不过,所有这一切只有在循环迭代之间存在没有依赖关系时才有效:如果唯一决定每个data[i]发生什么的是i本身。如果存在依赖关系,如果上次迭代的数据影响下一次迭代,那么编译器可能会受到它们的限制,以至于根本无法改变代码——而不是编译器能够使用花哨的并行指令或巧妙的优化(CSE强度降低循环展开,重新排序等),你得到的代码与你输入的完全相同——添加Y,然后添加Z,然后重复。

但是这里,在代码的第一个版本中,编译器正确地识别出数据中没有依赖关系,并发现它可以并行地完成工作,所以它做到了,这就是所有不同的地方。

这里的主要区别在于循环依赖关系。第二种情况下的循环是__abc0 -循环中的操作取决于前一次迭代。这意味着每次迭代甚至在前一次迭代结束之前都不能开始。在第一种情况下,循环体完全是__abc1 -循环体中的所有内容都是自包含的,仅依赖于迭代计数器和常量值。这意味着循环可以并行计算——多个迭代都可以同时工作。这允许循环简单地展开和向量化,重叠许多指令。

如果你要查看性能计数器(例如,使用perf stat ./1),你会发现第一个循环除了运行得更快之外,每个周期也运行了更多的指令(IPC)。相比之下,第二个循环有更多的依赖周期——当CPU无所事事,等待指令完成,然后才能发出更多指令时。

第一个可能会导致内存带宽瓶颈,特别是如果你让编译器在你的桑迪大桥 (gcc -O3 -march=native)上使用AVX自动向量化,如果它设法使用256位向量。此时,IPC将停止,特别是对于一个对于L3缓存来说太大的输出数组。


注意:展开和向量化不是需要独立的循环——你可以在存在(一些)循环依赖关系时执行它们。然而,这是比较困难的。因此,如果你想看到从向量化得到的最大加速,它有助于在可能的情况下删除循环依赖。

如果你需要这段代码来快速运行,或者如果你很好奇,你可以尝试以下方法:

您将a[i] = f(i)的计算更改为两个加法。将其修改为使用两个加法计算a[4i] = f(4i),使用两个加法计算a[4i+1] = f(4i+1),依此类推。现在你有四个计算可以并行完成。

编译器很有可能会执行相同的循环展开和向量化,并且您有相同的延迟,但对于四个操作,而不是一个。

通过只使用加法作为优化,你会错过(新cpu的)乘法管道的所有GFLOPS,而循环携带的依赖关系会因为停止自动向量化而使情况变得更糟。如果它是自向量化的,它将比乘法+加法快得多。并且每个数据的能源效率更高(只添加比mul + add更好)。

另一个问题是数组的末尾会收到更多的舍入错误,因为累积的加法数量。但在非常大的数组(除非数据类型变成float)之前,它不应该是可见的。

当你应用带有GCC构建选项的霍纳的计划(在较新的cpu上)-std=c++20 -O3 -march=native -mavx2 -mprefer-vector-width=256 -ftree-vectorize -fno-math-errno时,

void f(double * const __restrict__ data){
double A=1.1,B=2.2,C=3.3;
for(int i=0; i<1024; i++) {
double id = double(i);
double result =  A;
result *=id;
result +=B;
result *=id;
result += C;
data[i] = result;
}
}

编译器生成:

.L2:
vmovdqa ymm0, ymm2
vcvtdq2pd       ymm1, xmm0
vextracti128    xmm0, ymm0, 0x1
vmovapd ymm7, ymm1
vcvtdq2pd       ymm0, xmm0
vmovapd ymm6, ymm0
vfmadd132pd     ymm7, ymm4, ymm5
vfmadd132pd     ymm6, ymm4, ymm5
add     rdi, 64
vpaddd  ymm2, ymm2, ymm8
vfmadd132pd     ymm1, ymm3, ymm7
vfmadd132pd     ymm0, ymm3, ymm6
vmovupd YMMWORD PTR [rdi-64], ymm1
vmovupd YMMWORD PTR [rdi-32], ymm0
cmp     rax, rdi
jne     .L2
vzeroupper
ret

使用-mavx512f -mprefer-vector-width=512:

.L2:
vmovdqa32       zmm0, zmm3
vcvtdq2pd       zmm4, ymm0
vextracti32x8   ymm0, zmm0, 0x1
vcvtdq2pd       zmm0, ymm0
vmovapd zmm2, zmm4
vmovapd zmm1, zmm0
vfmadd132pd     zmm2, zmm6, zmm7
vfmadd132pd     zmm1, zmm6, zmm7
sub     rdi, -128
vpaddd  zmm3, zmm3, zmm8
vfmadd132pd     zmm2, zmm5, zmm4
vfmadd132pd     zmm0, zmm5, zmm1
vmovupd ZMMWORD PTR [rdi-128], zmm2
vmovupd ZMMWORD PTR [rdi-64], zmm0
cmp     rax, rdi
jne     .L2
vzeroupper
ret

所有浮点运算都在“打包”中;向量形式和更少的指令(它是一个两次展开的版本),因为mul+add连接到单个菲利普-马萨。每64字节数据有16条指令(如果avx - 512为128字节)。

Horner方案的另一个优点是,它在FMA指令中计算精度更高,每次循环迭代只有O(1)个操作,因此对于较长的数组,它不会累积那么多错误。

我认为来自Agner Fog的优化手册的优化必须来自Quake III 快速的平方根倒数逼近的时代,只有x87有意义,但是被SSE淘汰。在那个时候,SIMD不够宽,不能产生太大的差异,而且对于sqrt()和除法来说很慢,但是有rsqrtss。手册上写着版权2004年,所以有上交所而不是FMA的赛扬 cpu。第一个AVX桌面CPU的推出要晚得多,FMA甚至比它还要晚。


下面是另一个强度降低的版本(用于id值):

void f(double * const __restrict__ data){


double B[]={2.2,2.2,2.2,2.2,2.2,2.2,2.2,2.2,
2.2,2.2,2.2,2.2,2.2,2.2,2.2,2.2};
double C[]={3.3,3.3,3.3,3.3,3.3,3.3,3.3,3.3,
3.3,3.3,3.3,3.3,3.3,3.3,3.3,3.3};
double id[] = {0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15};
for(long long i=0; i<1024; i+=16) {
double result[]={1.1,1.1,1.1,1.1,1.1,1.1,1.1,1.1,
1.1,1.1,1.1,1.1,1.1,1.1,1.1,1.1};
// The same thing, just with explicit auto-vectorization help
for(int j=0;j<16;j++)
{
result[j] *=id[j];
result[j] +=B[j];
result[j] *=id[j];
result[j] += C[j];
data[i+j] = result[j];
}


// strength reduction
for(int j=0;j<16;j++)
{
id[j] += 16.0;
}
}
}

组装:

.L2:
vmovapd zmm3, zmm0
vmovapd zmm2, zmm1
sub     rax, -128
vfmadd132pd     zmm3, zmm6, zmm7
vfmadd132pd     zmm2, zmm6, zmm7
vfmadd132pd     zmm3, zmm5, zmm0
vfmadd132pd     zmm2, zmm5, zmm1
vaddpd  zmm0, zmm0, zmm4
vaddpd  zmm1, zmm1, zmm4
vmovupd ZMMWORD PTR [rax-128], zmm3
vmovupd ZMMWORD PTR [rax-64], zmm2
cmp     rdx, rax
jne     .L2
vzeroupper
ret

当数据、A、B和C数组以alignas(64)和足够小的数据数组大小对齐时,它以0.26 周期 每个元素速度运行。

在我们的cpu中,与加法相比,乘法的速度要慢得多。

这在历史上可能是正确的,对于更简单的低功耗CPU可能仍然是正确的,但如果CPU设计者准备“解决问题”,乘法几乎可以和加法一样快。

现代cpu被设计成同时处理多条指令,通过流水线和多个执行单元的组合。

但问题是数据依赖关系。如果一条指令依赖于另一条指令的结果,那么在它所依赖的那条指令完成之前,它的执行不能开始。

现代cpu试图通过“乱序执行”来解决这个问题。等待数据的指令可以保持队列,而允许执行其他指令。

但是,即使采用这些措施,有时CPU也会耗尽要调度的新工作。

这个有限差分法强度减法优化可以提供了一个加速,超过了你可以分别为每个i重新计算多项式的最佳速度。但只有当您将其推广到更大的步数时,才能在循环中保持足够的并行性。我的版本存储一个向量(四个双)每个时钟周期在我的Skylake,用于适合L1d缓存的小数组;否则就是带宽测试。在早期的英特尔上,它也应该最大限度地提高SIMD FP-add吞吐量,包括你的桑迪大桥与AVX (1x 256位添加/时钟,和1x 256位存储每两个时钟,如果你对齐输出。)


依赖于前一个迭代的值是致命的

这个减强度优化(只是添加,而不是从一个新的i开始并乘以)在循环迭代中引入串行依赖项,涉及FP数学而不是整数增量。

原来的有跨每个输出元素的数据并行性:每个都只依赖于常量和自己的i值。编译器可以使用SIMD (SSE2,如果你使用-O3 -march=native,则可以使用AVX)自动向量化,并且cpu可以在乱序执行的循环迭代中重叠工作。尽管有大量的额外工作,但在编译器的帮助下,CPU能够应用足够的蛮力。

但是根据poly(i)计算poly(i+1)的版本并行性非常有限;没有SIMD向量化,你的CPU只能运行两个标量加法每四个周期,例如,四个周期是FP加法从英特尔Skylake到Tiger Lake的延迟。(https://uops.info/)。


Huseyin tugrul buyukisik的回答展示了如何在更现代的CPU上接近最大化原始版本的吞吐量,使用两个FMA操作来计算多项式(霍纳的计划),再加上一个整型到浮点数的转换或浮点增量。(后者会创建一个FP依赖链,您需要展开来隐藏它。)

因此,在最好的情况下,每个SIMD输出向量有三个浮点数学运算。(加上一家商店)。目前的英特尔cpu只有两个浮点执行单元,可以运行FP数学操作,包括int-to-double。(对于512位向量,目前的cpu在端口1上关闭了向量ALU,因此总共只有两个SIMD ALU端口,因此非fp -math操作,如SIMD-integer增量,也将竞争SIMD吞吐量。除了只有一个512位FMA单元的cpu外,端口5可以用于其他工作。)

AMD因为Zen 2在两个端口上有两个FMA/mul单元,在两个不同的端口上有两个FP添加/子单元,所以如果使用FMA进行添加,理论上每个时钟周期最多有四个SIMD添加。

Haswell/Broadwell有2个时钟FMA,但只有1个时钟FP add/sub(延迟较低)。这对于简单的代码很好,不是很好的用于已经优化为具有大量并行性的代码。这可能就是英特尔在Skylake上改名字的原因。(Alder Lake重新引入了低延迟的FP add/sub,但2/时钟吞吐量与乘法相同。有趣的是,不可交换的延迟:目的地只有2个循环,来自另一个操作数的3个循环,所以它非常适合较长的依赖链。)

您的Sandy Bridge (E5-1620)和Nehalem (W5580) cpu在单独的端口上有1/clock FP add/sub, 1/clock FP mul。这就是哈斯威尔的基础。为什么添加额外的乘法不是一个大问题:它们可以与现有的加法并行运行。(Sandy Bridge的是256位宽,但你编译时没有启用AVX:使用-march=native。)


寻找并行性:任意步幅的力量减少

你的compute2根据前一个值计算下一个Y和下一个Z。例如,步幅为1时,你需要data[i+1]的值。所以每次迭代都依赖于前一次迭代。

如果您将其推广到其他跨步,您可以将4、6、8或更多单独的Y和Z值向前推进,这样它们都可以相互独立地同步跨步。这为编译器和/或CPU恢复了足够的并行性。

poly(i)   = A i^2           + B i       + C


poly(i+s) = A (i+s)^2       + B (i+s)   + C
= A*i^2 + A*2*s*i + A*s^2 +  B*i + B*s + C
= poly(i) + A*2*s*i + A*s^2 + B*s + C

这有点乱,不太清楚如何把它分解成Y和Z部分。(这个答案的早期版本是错误的。)

从FP值序列(有限差分法)的一阶和二阶差异向后计算可能更容易。这将直接发现我们需要添加什么来继续前进;Z[]初始化式和步长。

这基本上就像求一阶导数和二阶导数,然后优化的循环有效地积分来恢复原始函数。下面的输出是由下面基准测试中的main的正确性检查部分生成的。

# method of differences for stride=1, A=1, B=0, C=0
poly(i) 1st    2nd  difference from this poly(i) to poly(i+1)
0       1
1       3       2        # 4-1 = 3   | 3-1 = 2
4       5       2        # 9-4 = 5   | 5-3 = 2
9       7       2        # ...
16      9       2
25      11      2

相同的多项式(x^2),但以3的步幅取差异。非2的幂有助于显示步幅的因子/幂,而不是自然发生的因子2。

# for stride of 3, printing in groups. A=1, B=0, C=0
poly(i)  1st   2nd  difference from this poly(i) to poly(i+3)
0        9
1       15
4       21


9       27      18     # 36- 9 = 27 | 27-9  = 18
16      33      18     # 49-16 = 33 | 33-15 = 18
25      39      18     # ...


36      45      18     # 81-36 = 45 | 45-27 = 18
49      51      18
64      57      18


81      63      18
100     69      18
121     75      18

Y[]和Z[]初始化

  • 初始Y[j] = poly(j),因为它必须存储到输出的相应位置(data[i+j] = Y[j])。

  • 初始Z[j]将被添加到Y[j]中,并需要使其成为poly(j+stride)。因此,初始的Z[j] = poly(j+stride) - Y[j],如果我们愿意,我们可以对其进行代数化简。(对于编译时常数A,B,C,编译器将以任何一种方式常量传播。)

    对于poly(0..stride-1)的起始点,Z[j]保存了通过poly(x)的一阶差值。这是上表的中间一列。

  • Z[j] += second_difference的必要更新是一个标量常数,正如我们可以从二阶差分相同中看到的那样。

    通过处理几个不同的strideA值(i^2的系数),我们可以看到它是A * 2 * (stride * stride)。(使用3和5等非互素值有助于理清事情。)用更多的代数,你可以用符号来表示。因子2在微积分PoV中是有意义的:d(A*x^2)/dx = 2Ax,二阶导数是2A

// Tested and correct for a few stride and coefficient values.


#include <stdalign.h>
#include <stdlib.h>
#define LEN 1024
alignas(64) double data[LEN];


//static const double A = 1, B = 0, C = 0; // for easy testing
static const double A = 5, B = 3, C = 7; // can be function args


void compute2(double * const __restrict__ data)
{
const int stride = 16; // unroll factor.  1 reduces to the original
const double diff2 = (stride * stride) * 2 * A; // 2nd-order differences
double Z[stride], Y[stride];
for (int j = 0 ; j<stride ; j++){ // this loop will fully unroll
Y[j] = j*j*A + j*B + C; // poly(j) starting values to increment
//Z[j] = (j+stride)*(j+stride)*A + (j+stride)*B + C - Y[j];
//Z[j] = 2*j*stride*A + stride*stride*A + stride*B;
Z[j] = ((2*j + stride)*A + B)*stride; // 1st-difference to next Y[j], from this to the next i
}


for(ptrdiff_t i=0; i < LEN - (stride-1); i+=stride) {
// loops that are easy(?) for a compiler to roll up into some SIMD vectors
for (int j=0 ; j<stride ; j++)  data[i+j] = Y[j];  // store
for (int j=0 ; j<stride ; j++)  Y[j] += Z[j];      // add
for (int j=0 ; j<stride ; j++)  Z[j] += diff2;     // add
}


// cleanup for the last few i values
for (int j = 0 ; j < LEN % stride ; j++) {
// let the compiler see LEN%stride to help it decide *not* to auto-vectorize this part
//size_t i = LEN - (stride-1) + j;
//data[i] = poly(i);
}
}

对于stride=1(不展开),这些简化为原始值。但是对于更大的stride,编译器可以将Y[]和Z[]的元素分别保存在几个SIMD向量中,因为每个Y[j]只与对应的Z[j]交互。

stride独立的并行依赖链供编译器(SIMD)和CPU(流水线执行单元)利用,运行stride比原来的快一倍,直到你在SIMD fp上遇到瓶颈-增加吞吐量而不是延迟,或者如果你的缓冲区不适合L1d,则存储带宽。(或者直到编译器面植并且没有很好地展开和向量化这些循环!)


这是如何在实践中编译的:很好地与clang

(Godbolt编译器浏览器) Clang自动向量化良好的stride=16 (4x YMM向量,每个4 __abc1)与clang14 -O3 -march=skylake -ffast-math

看起来clang进一步展开了2,将Z[j] += diff2简化为tmp = Z[j] + diff2; / Z[j] += 2*diff2;。这减轻了Z依赖链的压力,只剩下Y[j]在Skylake上面临延迟瓶颈。

因此,每次asm循环迭代执行2x 8__abc0指令和2x 4个存储。循环开销是add +宏融合cmp/jne,所以2个uops。(或者对于全局数组,只有一个add/jne uop,将负下标数到0;它相对于数组的末尾进行索引。)

Skylake运行这个非常接近1个商店和2x vaddpd每个时钟周期。这是两者的最大吞吐量。前端只需要保持超过3个uops /时钟周期,但从Core2开始就已经是4个了。 Sandy bridge家族中的uop缓存可以解决这个问题。(除非你在Skylake上遇到JCC勘误表,所以我使用-mbranches-within-32B-boundaries 为了避免这种情况的发生,需要有叮咚垫的指示.)

Skylake的vaddpd延迟为4个周期,来自stride=16的4个依赖链仅够维持4个独立的操作运行。任何时候,Y[j]+=没有运行它准备好的周期,就会产生一个气泡。由于Clang额外展开Z[]链,Z[j]+=可以提前运行,因此Z链可以领先。使用最老的准备优先调度,它倾向于稳定到Yj+= uops没有冲突的状态,显然,因为它确实在我的Skylake上全速运行。如果我们能让编译器仍然为stride=32创建良好的asm,那就会留下更多的空间,但不幸的是,它没有。(代价是奇数尺寸的清理工作更多。)

Clang奇怪地只用-ffast-math向量化它。下面完整基准测试中的模板版本不需要-ffast-math。源代码经过精心编写,对simd友好,并按照源代码顺序进行数学操作。(不过,快速数学是允许clang更多地展开Z增量的方法。)

另一种写循环的方法是用一个内循环代替所有的Y运算,然后是所有的Z运算。这在下面的基准测试中很好(有时实际上更好),但在这里即使使用-ffast-math也没有向量化。从编译器中获得像这样的重要问题的最优展开SIMD asm可能非常繁琐和不可靠,而且可能需要一些实践。

我将它包含在Godbolt的#if 0 / #else / #endif块中。

// can auto-vectorize better or worse than the other way
// depending on compiler and surrounding code.
for(int i=0; i < LEN - (stride-1); i+=stride) {
for (int j = 0 ; j<stride ; j++){
data[i+j] = Y[j];
Y[j] += Z[j];
Z[j] += deriv2;
}
}

我们必须手动选择一个合适的展开量。太大的展开因子甚至会阻止编译器看到正在发生什么,并停止将临时数组保存在寄存器中。例如,3224是clang的问题,但不是16。可能会有一些调优选项来强制编译器展开循环到一定数量;对于GCC来说,有时可以使用它在编译时看穿一些东西。

另一种方法是用#include <immintrin.h>__m256d Z[4]代替double Z[16]手动向量化。但是这个版本可以向量化其他isa,比如AArch64。

大展开系数的另一个缺点是,当问题规模不是展开系数的几倍时,需要进行更多的清理工作。(你可以使用compute1策略进行清理,让编译器在进行标量操作之前对其进行一两次迭代的向量化。)


理论上,编译器应该是允许,用-ffast-math为你做到这一点,要么从compute1对原始多项式进行强度约减,要么从compute2查看步数是如何累积的。

但实际上,这是非常复杂的,人类必须自己去做。除非/直到有人教编译器如何寻找这样的模式,并应用差异本身的方法,并选择一个步法!但是,即使使用-ffast-math也可能不希望大规模重写具有不同错误累积属性的算法。(Integer没有任何精度问题,但它仍然是一个复杂的模式匹配/替换。)


实验性能结果:

我在我的台式机(i7-6700k)上使用clang13.0.0进行了测试。这实际上在每个时钟周期运行一个SIMD存储,并使用编译器选项的几种组合(快速数学与否)和内部循环策略上的#if 0 vs. #if 1。我的基准/测试框架基于@huseyin tugrul buyukisik的版本,改进为在rdtsc指令之间重复更可测量的量,并使用测试循环来检查多项式的简单计算的正确性。

我还让它补偿了核心时钟频率和“reference"由rdtsc读取TSC的频率之间的差异,在我的例子中,3.9 GHz vs. 4008 MHz。(额定最大turbo是4.2 GHz,但在Linux上的EPP = balance_performance,它只希望时钟高达3.9 GHz。)

源代码on Godbolt:使用一个内循环,而不是3个单独的j<16循环,使用-ffast-math。使用__attribute__((noinline))来防止它内联到repeat循环中。其他一些选项和源的变化导致了循环内部的一些vpermpd洗牌

下面的基准数据来自一个有bug的Z[j]初始化器的旧版本,但同样的循环asm。 Godbolt链接现在有一个正确性测试后的计时循环,通过。在我的桌面上,实际性能仍然相同,每个double仅超过0.25个周期,即使没有#if 1 / -ffast-math来允许clang额外展开。

$ clang++ -std=gnu++17 -O3 -march=native -mbranches-within-32B-boundaries poly-eval.cpp -Wall
# warning about noipa, only GCC knows that attribute
$ perf stat --all-user -etask-clock,context-switches,cpu-migrations,page-faults,cycles,instructions,uops_issued.any,uops_executed.thread,fp_arith_inst_retired.256b_packed_double -r10 ./a.out
... (10 runs of the whole program, ending with)
...
0.252295 cycles per data element (corrected from ref cycles to core clocks for i7-6700k @ 3.9 GHz)
0.252109 cycles per data element (corrected from ref cycles to core clocks for i7-6700k @ 3.9 GHz)
xor=4303
min cycles per data = 0.251868


Performance counter stats for './a.out' (10 runs):


298.92 msec task-clock                #    0.989 CPUs utilized            ( +-  0.49% )
0      context-switches          #    0.000 /sec
0      cpu-migrations            #    0.000 /sec
129      page-faults               #  427.583 /sec                     ( +-  0.56% )
1,162,430,637      cycles                    #    3.853 GHz                      ( +-  0.49% )  # time spent in the kernel for system calls and interrupts isn't counted, that's why it's not 3.90 GHz
3,772,516,605      instructions              #    3.22  insn per cycle           ( +-  0.00% )
3,683,072,459      uops_issued.any           #   12.208 G/sec                    ( +-  0.00% )
4,824,064,881      uops_executed.thread      #   15.990 G/sec                    ( +-  0.00% )
2,304,000,000      fp_arith_inst_retired.256b_packed_double # 7.637 G/sec


0.30210 +- 0.00152 seconds time elapsed (+- 0.50%)

fp_arith_inst_retired.256b_packed_double为每个FP add或mul指令计算1 (FMA为2),因此我们得到1.98 vaddpd指令每个时钟周期为整个程序,包括打印等。这非常接近理论最大值2/时钟,显然没有受到次优uop调度的影响。(我增加了重复循环,所以程序在那里花费了大部分时间,使得整个程序的perf stat非常有用。)

这一优化的目标是用更少的FLOPS完成相同的工作,但这也意味着我们在不使用FMA的情况下将《Skylake》的FLOP/时钟限制最大化。(单内核3.9 GHz时为30.58 GFLOP/s)。

Asm的非内联函数(objdump -drwC -Mintel);clang使用了4对Y,Z对YMM向量,并将循环进一步展开3x,使其成为24 KiB大小的精确倍数,无需清理。注意add rax,0x30每次迭代执行3 * stride=0x10 double操作。

0000000000001440 <void compute2<3072>(double*)>:
# just loading constants; the setup loop did fully unroll and disappear
1440:  c5 fd 28 0d 18 0c 00 00         vmovapd ymm1,YMMWORD PTR [rip+0xc18]    # 2060 <_IO_stdin_used+0x60>
1448:  c5 fd 28 15 30 0c 00 00         vmovapd ymm2,YMMWORD PTR [rip+0xc30]    # 2080
1450:  c5 fd 28 1d 48 0c 00 00         vmovapd ymm3,YMMWORD PTR [rip+0xc48]    # 20a0
1458:  c4 e2 7d 19 25 bf 0b 00 00      vbroadcastsd ymm4,QWORD PTR [rip+0xbbf] # 2020
1461:  c5 fd 28 2d 57 0c 00 00         vmovapd ymm5,YMMWORD PTR [rip+0xc57]    # 20c0
1469:  48 c7 c0 d0 ff ff ff    mov    rax,0xffffffffffffffd0
1470:  c4 e2 7d 19 05 af 0b 00 00      vbroadcastsd ymm0,QWORD PTR [rip+0xbaf] # 2028
1479:  c5 fd 28 f4             vmovapd ymm6,ymm4 # buggy Z[j] initialization in this ver used the same value everywhere
147d:  c5 fd 28 fc             vmovapd ymm7,ymm4
1481:  c5 7d 28 c4             vmovapd ymm8,ymm4
1485:  66 66 2e 0f 1f 84 00 00 00 00 00        data16 cs nop WORD PTR [rax+rax*1+0x0]


# top of outer loop.  The NOP before this is to align it.
1490:  c5 fd 11 ac c7 80 01 00 00      vmovupd YMMWORD PTR [rdi+rax*8+0x180],ymm5
1499:  c5 d5 58 ec             vaddpd ymm5,ymm5,ymm4
149d:  c5 dd 58 e0             vaddpd ymm4,ymm4,ymm0
14a1:  c5 fd 11 9c c7 a0 01 00 00      vmovupd YMMWORD PTR [rdi+rax*8+0x1a0],ymm3
14aa:  c5 e5 58 de             vaddpd ymm3,ymm3,ymm6
14ae:  c5 cd 58 f0             vaddpd ymm6,ymm6,ymm0
14b2:  c5 fd 11 94 c7 c0 01 00 00      vmovupd YMMWORD PTR [rdi+rax*8+0x1c0],ymm2
14bb:  c5 ed 58 d7             vaddpd ymm2,ymm2,ymm7
14bf:  c5 c5 58 f8             vaddpd ymm7,ymm7,ymm0
14c3:  c5 fd 11 8c c7 e0 01 00 00      vmovupd YMMWORD PTR [rdi+rax*8+0x1e0],ymm1
14cc:  c5 bd 58 c9             vaddpd ymm1,ymm8,ymm1
14d0:  c5 3d 58 c0             vaddpd ymm8,ymm8,ymm0
14d4:  c5 fd 11 ac c7 00 02 00 00      vmovupd YMMWORD PTR [rdi+rax*8+0x200],ymm5
14dd:  c5 d5 58 ec             vaddpd ymm5,ymm5,ymm4
14e1:  c5 dd 58 e0             vaddpd ymm4,ymm4,ymm0
14e5:  c5 fd 11 9c c7 20 02 00 00      vmovupd YMMWORD PTR [rdi+rax*8+0x220],ymm3
14ee:  c5 e5 58 de             vaddpd ymm3,ymm3,ymm6
14f2:  c5 cd 58 f0             vaddpd ymm6,ymm6,ymm0
14f6:  c5 fd 11 94 c7 40 02 00 00      vmovupd YMMWORD PTR [rdi+rax*8+0x240],ymm2
14ff:  c5 ed 58 d7             vaddpd ymm2,ymm2,ymm7
1503:  c5 c5 58 f8             vaddpd ymm7,ymm7,ymm0
1507:  c5 fd 11 8c c7 60 02 00 00      vmovupd YMMWORD PTR [rdi+rax*8+0x260],ymm1
1510:  c5 bd 58 c9             vaddpd ymm1,ymm8,ymm1
1514:  c5 3d 58 c0             vaddpd ymm8,ymm8,ymm0
1518:  c5 fd 11 ac c7 80 02 00 00      vmovupd YMMWORD PTR [rdi+rax*8+0x280],ymm5
1521:  c5 d5 58 ec             vaddpd ymm5,ymm5,ymm4
1525:  c5 dd 58 e0             vaddpd ymm4,ymm4,ymm0
1529:  c5 fd 11 9c c7 a0 02 00 00      vmovupd YMMWORD PTR [rdi+rax*8+0x2a0],ymm3
1532:  c5 e5 58 de             vaddpd ymm3,ymm3,ymm6
1536:  c5 cd 58 f0             vaddpd ymm6,ymm6,ymm0
153a:  c5 fd 11 94 c7 c0 02 00 00      vmovupd YMMWORD PTR [rdi+rax*8+0x2c0],ymm2
1543:  c5 ed 58 d7             vaddpd ymm2,ymm2,ymm7
1547:  c5 c5 58 f8             vaddpd ymm7,ymm7,ymm0
154b:  c5 fd 11 8c c7 e0 02 00 00      vmovupd YMMWORD PTR [rdi+rax*8+0x2e0],ymm1
1554:  c5 bd 58 c9             vaddpd ymm1,ymm8,ymm1
1558:  c5 3d 58 c0             vaddpd ymm8,ymm8,ymm0
155c:  48 83 c0 30             add    rax,0x30
1560:  48 3d c1 0b 00 00       cmp    rax,0xbc1
1566:  0f 82 24 ff ff ff       jb     1490 <void compute2<3072>(double*)+0x50>
156c:  c5 f8 77                vzeroupper
156f:  c3                      ret

相关:

通过手动将代码并行化,你似乎可以鱼与熊掌兼得:

double A4 = A+A+A+A;
double Z = 3A+B;
double Y1 = C;
double Y2 = A+B+C;


int i;
// ... setup unroll when LEN is odd...


for(i=0; i<LEN; i++) {
data[i] = Y1;
data[++i] = Y2;
Y1 += Z;
Y2 += Z;
Z += A4;
}

它可能不完全像所写的那样具有功能性,但您可以理解它的思想:展开循环,以便每个依赖于数据的路径都可以并行执行。对于所考虑的机器,四步展开应该能够实现最大的性能,当然,在软件中对体系结构进行硬编码时,您可以获得所有有趣的东西。