提问人:ampersander 提问时间:11/3/2023 最后编辑:ampersander 更新时间:11/6/2023 访问量:136
矢量化带有“continue”分支的循环
Vectorizing a loop with a "continue" branch in it
问:
我目前正在接受一门课程,在该课程中,我们可以使用超级计算机。CPU 是 Intel(R) Xeon(R) Gold 6240 CPU。我们的任务是矢量化(但不允许使用并行化)曼德布洛特集的计算。目标是使代码尽可能快。我尝试了一些优化。仅计算曼德布洛特集的上半部分,然后向下复制值,利用所有可能的 OpenMP 编译指示,并尝试对每个循环进行矢量化。然而,我的代码比它应该的要慢得多(~900ms,而参考解决方案在~500ms)。我需要帮助使此代码运行得更快。我们使用这些标志在编译器上编译。我运行了一个Advixe Profiler,并在内部循环上收到了以下时间。但是,在分析器的多次运行中,每行花费的时间差异很大,尽管大多数时间总是花在 and or 变量上:icc
-O3 -mavx2 -xHost -g -qopenmp-simd -qopt-report=1 -qopt-report-phase=vec
pointsRemaining
i2
r2
这是我的代码(它将从自定义脚本运行 - 我无法更改编译,也无法更改集合的宽度或高度等任何内容 - 但这两者将是 4096 的倍数,所有对齐都可以)。对于图像中的每一行,它都会计算迭代次数(其数量由运行计算器的脚本给出,通常为 ~100-500)。只要至少有一个点没有偏离曼德布洛特集,程序就会计算一行中所有点的迭代次数(我尝试跳过每个点的计算,但添加注释掉的“if”子句只会减慢代码速度 - i我认为如果我设法矢量化注释的“if”, 它会运行得更快):
#include <iostream>
#include <string>
#include <vector>
#include <algorithm>
#include <cstring>
#include <stdlib.h>
#include "LineMandelCalculator.h"
LineMandelCalculator::LineMandelCalculator(unsigned matrixBaseSize, unsigned limit) :
BaseMandelCalculator(matrixBaseSize, limit, "LineMandelCalculator")
{
data = (int *)(_mm_malloc(height * width * sizeof(int), 64));
zReal = (float *)(_mm_malloc(width * sizeof(float), 64));
zImag = (float *)(_mm_malloc(width * sizeof(float), 64));
iteration = (int *)(_mm_malloc(width * sizeof(int), 64));
calculated_reals = (float *)(_mm_malloc(width * sizeof(float), 64));
for (int i = 0; i < width; ++i) {
calculated_reals[i] = x_start + i * dx;
zReal[i] = 0.0f;
zImag[i] = 0.0f;
iteration[i] = limit;
}
}
LineMandelCalculator::~LineMandelCalculator() {
_mm_free(data);
_mm_free(zReal);
_mm_free(zImag);
_mm_free(iteration);
_mm_free(calculated_reals);
data = NULL;
zReal = NULL;
zImag = NULL;
iteration = NULL;
calculated_reals = NULL;
}
int * LineMandelCalculator::calculateMandelbrot() {
int *pdata = data;
int half_height = height / 2;
float *zReal = this -> zReal;
float *zImag = this -> zImag;
float *calculated_reals = this -> calculated_reals;
int * iteration = this -> iteration;
int limit = this -> limit;
int width = this -> width;
float dx = (float)this -> dx;
float dy = (float)this -> dy;
for (int i = 0; i < half_height; i++) {
float y = y_start + i * dy;
#pragma omp simd simdlen(64) aligned(zReal, zImag, iteration:64)
for (int j = 0; j < width; j++) {
zReal[j] = calculated_reals[j];
zImag[j] = y;
iteration[j] = limit;
}
int pointsRemaining = width;
for (int k = 0; k < limit && pointsRemaining!=0; ++k) {
pointsRemaining = 0;
#pragma omp simd simdlen(64) reduction(+:pointsRemaining) aligned(zReal, zImag, calculated_reals, iteration:64)
for (int j = 0; j < width; j++) {
//if(iteration[j]!=limit){
// continue;
//}
float r2 = zReal[j] * zReal[j];
float i2 = zImag[j] * zImag[j];
bool inside = (r2 + i2 <= 4.0f);
if (inside)pointsRemaining ++;
zImag[j] = 2.0f * zReal[j] * zImag[j] + y;
zReal[j] = r2 - i2 + calculated_reals[j];
if (!inside && iteration[j] == limit){
iteration[j] = k;
}
}
}
memcpy(pdata + i * width, iteration, width * sizeof(int));
memcpy(pdata + (height - i - 1) * width, pdata + i * width, width * sizeof(int));
}
return data;
}
答:
这个问题肯定是由于需要在 C 和 C++ 中延迟评估。编译器无法矢量化这样的情况,因为 fetching 具有可见的行为:编译器无法计算,因为可能超出了 的范围。因此,ICC(和其他编译器)无法对整个循环进行矢量化,并生成条件跳转的缓慢级联。!inside && iteration[j] == limit
iteration[j]
iteration[j]
j
iteration
!inside
如果您知道可以急切地评估这样的条件,那么您可以将其替换为 .使用二进制运算符的表达式不会延迟计算。这使ICC能够生成完全矢量化的代码。(!inside) & (iteration[j] == limit)
&
话虽如此,生成的代码非常庞大(约 12 KiB),热循环为 1.5 KiB。代码太大可能会影响性能。事实上,现代 x86-64 处理器将指令转换为微指令 (uops)。翻译通常可能是此类热循环的瓶颈。因此,它们有一个 uops 缓存,用于不重新翻译指令,但它的大小有限。太大的循环会导致 uops 缓存丢失,指令一遍又一遍地重新解码,这很慢。关于使用的确切目标 CPU,这可能是一个问题,也可能不是问题。在作为 Cascade Lake CPU 的 Intel(R) Xeon(R) Gold 6240 上,我预计不会成为重大瓶颈。请注意,说明的对齐方式也很重要,尤其是在 Cascade Lake 上(请参阅此帖子)。乍一看,这就是为什么生成的代码如此之大。这可能不合理。至少,肯定不是 AVX-2,当然应该首选。simdlen(64)
simdlen(32)
指定冲突的编译器标志。事实上,-xHost
告诉编译器使用目标机器的可用指令集(目标平台上的 AFAIK AVX-512),同时告诉编译器它可以使用 AVX-2。 因此对 .指定是要对编译器使用 AVX-2 还是 AVX-512,而不是同时使用两者。您的目标 CPU 似乎支持 2 个 AVX-512 单元,因此 AVX-512 是这里的最佳解决方案。-mavx2
-mavx2
-xHost
一旦矢量化,代码还不错,但效率也不是很高。一个非常好的 Mandelbrot 实现应该把大部分时间花在做 FMA 指令上。这里似乎并非如此。但是,看起来 AVX-512 ALU 是瓶颈,这非常好。做更少的条件当然是你应该尝试的事情。
经验法则是在优化代码时查看编译器生成的汇编代码。这很关键。使用低级工具进行性能分析也非常重要。没有它,你就是盲目的,优化会变得非常困难。减少展开(这里)是很好的,以便获得更小的汇编代码,更容易评估(人类很难评估具有数千条指令的汇编代码的质量,尽管有一些工具可以有很大帮助)。simdlen(64)
笔记
有一些方法可以检测永远不会收敛的点。当迭代次数较多时,它们可用于尽早打破迭代循环。
如果整行不适合 L1 缓存,则对整行进行迭代可能会使计算次优。
如果 >50% 的项目已经收敛,则 SIMD 寄存器的使用效率可能较低。当迭代次数很大时,重新排序项目可以稍微提高性能。不过,在使用 OpenMP 的 C++ 中很难有效地做到这一点。
这不是一个完整的答案,因为对我来说并不完全清楚,你被“允许”做什么(例如,你是否允许用内部函数手动编写这个?)——而且我没有太多使用 OpenMP 的经验。但是有很多地方可以用你的代码进行优化。
首先,这个条件基本上是无用的(它总是正确的,除非你计算一条完全在曼德布洛特集之外的线,即,如果)。pointsRemaining!=0
abs(y) > 1.12...
但是,如果计算较小的切片,则检查条件是有意义的。
例如,如果您计算 1x16 或 4x4 图块,它们将适合寄存器。然后 16 个变量可以合并到 ,并且等价于 (可以用单个 检查)。bool inside
__mmask16 inside_mask
pointsRemaining!=0
inside_mask!=0
ktest
(这基本上回答了你的 -Question,只是你会退出 -loop)。continue
break
for(k)
此外
for(k=0; k<limit; ++k) {
// ...
if (!inside && iteration[j] == limit){
iteration[j] = k;
}
}
等同于以 开头,然后做iteration[j]=0
for(k=0; k<limit; ++k) {
// ...
if(inside) ++iteration[j]
}
假设寄存器中有 16 个值,这可以通过单个值来完成(因为无论如何您都有掩码)。iteration[]
_mm512_mask_add_epi32
总的来说,你根本不需要任何 、 、 临时数组,因为所有计算都可以完全在寄存器中完成(你可能需要同时使用 2 或 4 组寄存器进行计算,以避免延迟引起的瓶颈)。甚至预计算的好处也是值得怀疑的,因为下一组 16 个值可以使用简单的 .zReal
zImag
iteration
calculated_reals
vaddps
评论
j
continue