处理双数组的未对齐部分,将其余部分矢量化
Process unaligned part of a double array, vectorize the rest
我正在生成 sse/avx 指令,目前我必须使用未对齐的加载和存储。我在 float/double 数组上操作,我永远不知道它是否会对齐。因此,在对其进行矢量化之前,我希望有一个 pre 循环,可能还有一个 post 循环,它负责处理未对齐的部分。然后主矢量化循环在对齐的部分上运行。
但是我如何确定数组何时对齐?我可以检查指针值吗?预循环何时停止,post-循环何时开始?
这是我的简单代码示例:
void func(double * in, double * out, unsigned int size){
for( as long as in unaligned part ){
out[i] = do_something_with_array(in[i])
}
for( as long as aligned ){
awesome avx code that loads operates and stores 4 doubles
}
for( remaining part of array ){
out[i] = do_something_with_array(in[i])
}
}
编辑:
我一直在想。从理论上讲,指向第 i 个元素的指针应该可以除以 2,4,16,32(类似于 &a[i]%16==0)(取决于它是否为双精度以及它是 sse 还是 avx)。所以第一个循环应该覆盖不可分割的元素。
实际上我会尝试编译器 pragmas 和 flags out,看看编译器会产生什么。如果没有人给出好的答案,我将在周末 post 我的解决方案(如果有的话)。
这里有一些示例 C 代码可以满足您的需求
#include <stdio.h>
#include <x86intrin.h>
#include <inttypes.h>
#define ALIGN 32
#define SIMD_WIDTH (ALIGN/sizeof(double))
int main(void) {
int n = 17;
int c = 1;
double* p = _mm_malloc((n+c) * sizeof *p, ALIGN);
double* p1 = p+c;
for(int i=0; i<n; i++) p1[i] = 1.0*i;
double* p2 = (double*)((uintptr_t)(p1+SIMD_WIDTH-1)&-ALIGN);
double* p3 = (double*)((uintptr_t)(p1+n)&-ALIGN);
if(p2>p3) p2 = p3;
printf("%p %p %p %p\n", p1, p2, p3, p1+n);
double *t;
for(t=p1; t<p2; t+=1) {
printf("a %p %f\n", t, *t);
}
puts("");
for(;t<p3; t+=SIMD_WIDTH) {
printf("b %p ", t);
for(int i=0; i<SIMD_WIDTH; i++) printf("%f ", *(t+i));
puts("");
}
puts("");
for(;t<p1+n; t+=1) {
printf("c %p %f\n", t, *t);
}
}
这会生成一个 32 字节对齐的缓冲区,但随后会将其偏移一倍大小,因此它不再是 32 字节对齐的。它遍历标量值直到达到 32 字节对齐,遍历 32 字节对齐的值,然后最后以另一个标量循环结束任何剩余值,这些值不是 SIMD 宽度的倍数。
我认为这种优化只对 Nehalem 之前的英特尔 x86 处理器真正有意义。由于 Nehalem,未对齐的加载和存储的延迟和吞吐量与对齐的加载和存储相同。此外,由于 Nehalem,缓存行拆分的成本很小。
自 Nehalem 以来,SSE 有一个微妙之处,即未对齐的加载和存储不能与其他操作折叠。因此,自 Nehalem 以来,对齐加载和存储在 SSE 中并没有过时。所以原则上,这种优化甚至可以对 Nehalem 产生影响,但在实践中,我认为很少有情况会发生这种情况。
但是,对于 AVX,未对齐的加载和存储可以折叠,因此对齐的加载和存储指令已过时。
I looked into this with GCC, MSVC, and Clang。 GCC 如果它不能假定指针与例如对齐。 16 bytes with SSE 然后它会生成类似于上面代码的代码以达到 16 bytes 对齐以避免向量化时缓存行分裂。
Clang 和 MSVC 不这样做,因此它们会受到缓存行拆分的影响。然而,执行此操作的额外代码的成本弥补了缓存行拆分的成本,这可能解释了为什么 Clang 和 MSVC 不担心它。
唯一的例外是在纳哈勒姆之前。在这种情况下,当指针未对齐时,GCC 比 Clang 和 MSVC 快得多。如果指针对齐并且 Clang 知道它,那么它将使用对齐的加载和存储并且像 GCC 一样快速。 MSVC 矢量化仍然使用未对齐的存储和加载,因此在 Nahalem 之前速度很慢,即使指针是 16 字节对齐的也是如此。
这是一个我认为使用指针差异更清晰的版本
#include <stdio.h>
#include <x86intrin.h>
#include <inttypes.h>
#define ALIGN 32
#define SIMD_WIDTH (ALIGN/sizeof(double))
int main(void) {
int n = 17, c =1;
double* p = _mm_malloc((n+c) * sizeof *p, ALIGN);
double* p1 = p+c;
for(int i=0; i<n; i++) p1[i] = 1.0*i;
double* p2 = (double*)((uintptr_t)(p1+SIMD_WIDTH-1)&-ALIGN);
double* p3 = (double*)((uintptr_t)(p1+n)&-ALIGN);
int n1 = p2-p1, n2 = p3-p2;
if(n1>n2) n1=n2;
printf("%d %d %d\n", n1, n2, n);
int i;
for(i=0; i<n1; i++) {
printf("a %p %f\n", &p1[i], p1[i]);
}
puts("");
for(;i<n2; i+=SIMD_WIDTH) {
printf("b %p ", &p1[i]);
for(int j=0; j<SIMD_WIDTH; j++) printf("%f ", p1[i+j]);
puts("");
}
puts("");
for(;i<n; i++) {
printf("c %p %f\n", &p1[i], p1[i]);
}
}
我正在生成 sse/avx 指令,目前我必须使用未对齐的加载和存储。我在 float/double 数组上操作,我永远不知道它是否会对齐。因此,在对其进行矢量化之前,我希望有一个 pre 循环,可能还有一个 post 循环,它负责处理未对齐的部分。然后主矢量化循环在对齐的部分上运行。
但是我如何确定数组何时对齐?我可以检查指针值吗?预循环何时停止,post-循环何时开始?
这是我的简单代码示例:
void func(double * in, double * out, unsigned int size){
for( as long as in unaligned part ){
out[i] = do_something_with_array(in[i])
}
for( as long as aligned ){
awesome avx code that loads operates and stores 4 doubles
}
for( remaining part of array ){
out[i] = do_something_with_array(in[i])
}
}
编辑: 我一直在想。从理论上讲,指向第 i 个元素的指针应该可以除以 2,4,16,32(类似于 &a[i]%16==0)(取决于它是否为双精度以及它是 sse 还是 avx)。所以第一个循环应该覆盖不可分割的元素。
实际上我会尝试编译器 pragmas 和 flags out,看看编译器会产生什么。如果没有人给出好的答案,我将在周末 post 我的解决方案(如果有的话)。
这里有一些示例 C 代码可以满足您的需求
#include <stdio.h>
#include <x86intrin.h>
#include <inttypes.h>
#define ALIGN 32
#define SIMD_WIDTH (ALIGN/sizeof(double))
int main(void) {
int n = 17;
int c = 1;
double* p = _mm_malloc((n+c) * sizeof *p, ALIGN);
double* p1 = p+c;
for(int i=0; i<n; i++) p1[i] = 1.0*i;
double* p2 = (double*)((uintptr_t)(p1+SIMD_WIDTH-1)&-ALIGN);
double* p3 = (double*)((uintptr_t)(p1+n)&-ALIGN);
if(p2>p3) p2 = p3;
printf("%p %p %p %p\n", p1, p2, p3, p1+n);
double *t;
for(t=p1; t<p2; t+=1) {
printf("a %p %f\n", t, *t);
}
puts("");
for(;t<p3; t+=SIMD_WIDTH) {
printf("b %p ", t);
for(int i=0; i<SIMD_WIDTH; i++) printf("%f ", *(t+i));
puts("");
}
puts("");
for(;t<p1+n; t+=1) {
printf("c %p %f\n", t, *t);
}
}
这会生成一个 32 字节对齐的缓冲区,但随后会将其偏移一倍大小,因此它不再是 32 字节对齐的。它遍历标量值直到达到 32 字节对齐,遍历 32 字节对齐的值,然后最后以另一个标量循环结束任何剩余值,这些值不是 SIMD 宽度的倍数。
我认为这种优化只对 Nehalem 之前的英特尔 x86 处理器真正有意义。由于 Nehalem,未对齐的加载和存储的延迟和吞吐量与对齐的加载和存储相同。此外,由于 Nehalem,缓存行拆分的成本很小。
自 Nehalem 以来,SSE 有一个微妙之处,即未对齐的加载和存储不能与其他操作折叠。因此,自 Nehalem 以来,对齐加载和存储在 SSE 中并没有过时。所以原则上,这种优化甚至可以对 Nehalem 产生影响,但在实践中,我认为很少有情况会发生这种情况。
但是,对于 AVX,未对齐的加载和存储可以折叠,因此对齐的加载和存储指令已过时。
I looked into this with GCC, MSVC, and Clang。 GCC 如果它不能假定指针与例如对齐。 16 bytes with SSE 然后它会生成类似于上面代码的代码以达到 16 bytes 对齐以避免向量化时缓存行分裂。
Clang 和 MSVC 不这样做,因此它们会受到缓存行拆分的影响。然而,执行此操作的额外代码的成本弥补了缓存行拆分的成本,这可能解释了为什么 Clang 和 MSVC 不担心它。
唯一的例外是在纳哈勒姆之前。在这种情况下,当指针未对齐时,GCC 比 Clang 和 MSVC 快得多。如果指针对齐并且 Clang 知道它,那么它将使用对齐的加载和存储并且像 GCC 一样快速。 MSVC 矢量化仍然使用未对齐的存储和加载,因此在 Nahalem 之前速度很慢,即使指针是 16 字节对齐的也是如此。
这是一个我认为使用指针差异更清晰的版本
#include <stdio.h>
#include <x86intrin.h>
#include <inttypes.h>
#define ALIGN 32
#define SIMD_WIDTH (ALIGN/sizeof(double))
int main(void) {
int n = 17, c =1;
double* p = _mm_malloc((n+c) * sizeof *p, ALIGN);
double* p1 = p+c;
for(int i=0; i<n; i++) p1[i] = 1.0*i;
double* p2 = (double*)((uintptr_t)(p1+SIMD_WIDTH-1)&-ALIGN);
double* p3 = (double*)((uintptr_t)(p1+n)&-ALIGN);
int n1 = p2-p1, n2 = p3-p2;
if(n1>n2) n1=n2;
printf("%d %d %d\n", n1, n2, n);
int i;
for(i=0; i<n1; i++) {
printf("a %p %f\n", &p1[i], p1[i]);
}
puts("");
for(;i<n2; i+=SIMD_WIDTH) {
printf("b %p ", &p1[i]);
for(int j=0; j<SIMD_WIDTH; j++) printf("%f ", p1[i+j]);
puts("");
}
puts("");
for(;i<n; i++) {
printf("c %p %f\n", &p1[i], p1[i]);
}
}