我假设data 是一个大的有符号字节输入数组,f 是一个长度为 10 的小浮点数组,output 是一个大的浮点输出数组。您的代码在前 10 次迭代中超出了 i 的范围,因此我将从 10 开始 i。这是原始代码的干净版本:
int s = 10;
for (int i = s; i < N; i += 2) {
for (int j = 0; j < 10; j++) {
output[i] += f[j] * data[i-j-1];
output[i+1] += f[j] * data[i-j];
}
}
事实证明,i 处理两次迭代不会改变任何东西,因此我们将其进一步简化为:
for (int i = s; i < N; i++)
for (int j = 0; j < 10; j++)
output[i] += f[j] * data[i-j-1];
这个版本的代码(连同输入/输出数据的声明)应该已经存在于问题本身中,而无需其他人清理/简化混乱。
现在很明显,这段代码应用了one-dimensional convolution filter,这是信号处理中很常见的事情。例如,它可以在 Python 中使用numpy.convolve 函数进行计算。内核的长度非常小,为 10,因此与蛮力方法相比,Fast Fourier Transform 不会提供任何好处。鉴于这个问题众所周知,您可以阅读很多关于矢量化小核卷积的文章。我会关注the article by hgomersall。
首先,让我们摆脱反向索引。显然,我们可以在运行主算法之前反转内核。之后,我们必须计算所谓的cross-correlation 而不是卷积。简单来说,我们沿着输入数组移动内核数组,并为每个可能的偏移量计算它们之间的点积。
std::reverse(f.data(), f.data() + 10);
for (int i = s; i < N; i++) {
int b = i-10;
float res = 0.0;
for (int j = 0; j < 10; j++)
res += f[j] * data[b+j];
output[i] = res;
}
为了向量化它,让我们一次计算 8 个连续的点积。回想一下,我们可以将八个 32 位浮点数打包到一个 256 位 AVX 寄存器中。我们将通过 i 向量化外循环,这意味着:
- i 的循环每次迭代都会提前 8 次。
- 外循环内的每个值都会变成一个 8 元素包,因此包的第 k 个元素在标量版本的外循环的第 (i+k) 次迭代中保持该值。
这是生成的代码:
//reverse the kernel
__m256 revKernel[10];
for (size_t i = 0; i < 10; i++)
revKernel[i] = _mm256_set1_ps(f[9-i]); //every component will have same value
//note: you have to compute the last 16 values separately!
for (size_t i = s; i + 16 <= N; i += 8) {
int b = i-10;
__m256 res = _mm256_setzero_ps();
for (size_t j = 0; j < 10; j++) {
//load: data[b+j], data[b+j+1], data[b+j+2], ..., data[b+j+15]
__m128i bytes = _mm_loadu_si128((__m128i*)&data[b+j]);
//convert first 8 bytes of loaded 16-byte pack into 8 floats
__m256 floats = _mm256_cvtepi32_ps(_mm256_cvtepi8_epi32(bytes));
//compute res = res + floats * revKernel[j] elementwise
res = _mm256_fmadd_ps(revKernel[j], floats, res);
}
//store 8 values packed in res into: output[i], output[i+1], ..., output[i+7]
_mm256_storeu_ps(&output[i], res);
}
对于 1 亿个元素,这段代码在我的机器上大约需要 120 毫秒,而原始标量实现需要 850 毫秒。注意:我有 Ryzen 1600 CPU,所以在 Intel CPU 上的结果可能会有所不同。
现在,如果您真的想展开某些东西,由 10 个内核元素组成的内部循环是完美的地方。以下是它的完成方式:
__m256 revKernel[10];
for (size_t i = 0; i < 10; i++)
revKernel[i] = _mm256_set1_ps(f[9-i]);
for (size_t i = s; i + 16 <= N; i += 8) {
size_t b = i-10;
__m256 res = _mm256_setzero_ps();
#define DOIT(j) {\
__m128i bytes = _mm_loadu_si128((__m128i*)&data[b+j]); \
__m256 floats = _mm256_cvtepi32_ps(_mm256_cvtepi8_epi32(bytes)); \
res = _mm256_fmadd_ps(revKernel[j], floats, res); \
}
DOIT(0);
DOIT(1);
DOIT(2);
DOIT(3);
DOIT(4);
DOIT(5);
DOIT(6);
DOIT(7);
DOIT(8);
DOIT(9);
_mm256_storeu_ps(&output[i], res);
}
在我的机器上需要 110 毫秒(比第一个矢量化版本稍微好一点)。
所有元素的简单复制(从字节到浮点数的转换)对我来说需要 40 毫秒,这意味着这段代码还没有受内存限制,还有一些改进的空间。