处理双倍数组的不对齐部分,向量化其余部分

9
我正在生成sse/avx指令,目前我必须使用未对齐的加载和存储。我操作的是float/double数组,我永远不知道它是否对齐。因此,在向量化之前,我想要有一个预循环和可能的后循环,来处理未对齐的部分。然后主向量化循环在对齐的部分上操作。
但是如何确定数组何时对齐?我可以检查指针值吗?预循环应该在何时停止,后循环应该在何时开始?
这是我的简单代码示例:
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(取决于它是双精度还是SSE或AVX)整除。因此,第一个循环应该覆盖那些不能被整除的元素。

实际上,我将尝试使用编译器pragma和标志来查看编译器生成了什么。如果没有人给出好的答案,我将在周末发布我的解决方案(如果有的话)。


2
дҢ зњ‹иү‡еѓЏgccж€–clangиү™ж ·зљ„и‡ҒеЉЁеђ‘й‡ЏеЊ–зә–иҮ‘е™Ёдёғ处理еЏҮиѓҢзљ„жњҒеҮ№йҢђжѓ…况生成的д»Әз Ѓеђ—пәџж€‘жЂЂз–‘дҢ е№¶дёҚе®Ње…Ёдғ†и§Әи‡Ғе·±ж­ӘењЁеЃљд»Ђд№€гЂ‚ - EOF
1
是的,将数组衰减为指针并对其进行算术运算可以帮助您确定它是否在良好对齐的地址上。但是...检查这一点并没有什么用。如果第一个元素的地址不是很好对齐的话,那么所有元素的地址可能都是未对齐的,或者至少有许多或大多数元素是未对齐的。更好的想法可能是使用动态分配并添加初始填充以确保您拥有一个对齐的“数组”。 - Some programmer dude
2
@JoachimPileborg:我认为问题是关于矢量指令的数据错位,此时矢量的各个元素是对齐的。 - EOF
3
@JoachimPileborg - 您所描述的情况实际上不会自然发生,因为数组根据底层数据类型进行对齐 - 因此 double 数组无论如何都将是 8 字节对齐的。因此,根据 EOF 的评论,这实际上是关于每个单独的 SIMD 指令内元素的布局(无论是 128 位、256 位还是 512 位)。 - Smeeheey
1
clang使用未对齐的加载/存储。gcc生成展开的序言/尾声代码以对齐指针。然而,由于您的代码有两个指针,并且您没有使用__builtin_assume_aligned或任何其他东西,因此gcc必须假定其中一个或两个指针是不对齐的。我忘记了gcc更喜欢对齐输入还是输出指针。如果输出对齐而输入不对齐,则无法执行对齐加载和对齐存储(除了palignr之外,在Nehalem及更高版本中,当它们跨越缓存行时,未对齐的存储只会变慢)。 - Peter Cordes
显示剩余8条评论
1个回答

6
这是一些示例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 之前的 Intel x86 处理器有意义。自从 Nehalem 以来,未对齐的加载和存储的延迟和吞吐量与对齐的加载和存储相同。此外,自 Nehalem 以来,缓存行分割的成本很小。
自 Nehalem 以来,SSE 存在一个微妙的问题,即未对齐的加载和存储无法与其他操作折叠。因此,自 Nehalem 以来,对齐的加载和存储不过时。因此,原则上,这种优化即使在 Nehalem 上也可能有所作用,但实际上我认为几乎没有什么情况会发生变化。
然而,使用 AVX,未对齐的加载和存储可以折叠,因此对齐的加载和存储指令已经过时。

我使用GCC、MSVC和Clang进行了调查。如果GCC无法假设指针对于例如SSE而言是16字节对齐的,则它将生成类似于上面代码的代码来达到16字节对齐,以避免在向量化时发生缓存行分裂。

Clang和MSVC不会这样做,因此它们会遭受缓存行分裂的影响。然而,执行此操作所需的额外代码成本弥补了缓存行分裂的成本,这可能解释了为什么Clang和MSVC不担心这个问题。

唯一的例外是Nahalem之前。在这种情况下,如果指针未对齐,则GCC比Clang和MSVC快得多。如果指针对齐且Clang知道它,则它将使用对齐的加载和存储,并像GCC一样快速。MSVC向量化仍使用非对齐存储和加载,因此即使指针对齐为16字节,它在Nahalem之前仍然很慢。


这是我认为使用指针差异更清晰的版本:

#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]);
    }  
}

1
@PeterCordes:是的,我知道缓存阻塞技术。甚至有比它更好的技术。我有理由使用NT存储器。这只是一个玩具示例(即使是Jacobi也是如此)。我将处理一组不同的算法,并且无法在每个地方都应用缓存阻塞技术。 - hr0m
@Zboson:感谢提供链接,还有架构提示。 - hr0m
如果我对这个主题有更多“有趣”的问题,我会给你们发送一个链接,因为你们似乎知道很多,好吗?但是这个讨论真的没有任何进展,因为我的问题相对于我正在处理的事情来说非常具体。 - hr0m
1
@PeterCordes,我觉得我可能误解了你的意思。你是说enhanced rep mov只对大型的memcpy有用,并且OP并没有这样做,所以对于一个大数组来说,非临时存储仍然是正确的选择(假设OP的算法受内存带宽限制)。我从未使用过enhanced rep mov,只是读过相关资料。 - Z boson
@Zboson:确切地说,OP需要在数据上进行工作,而不仅仅是memcpy。ERMSB并不比MOVNT好到值得将常规存储器存储到4k反弹缓冲区中,并从那里执行'rep movsb'到最终目的地。(与直接对最终目的地进行NT存储相比)如果数据有时可能足够小,以便在下次读取时命中L3,则可能会这样做,但否则不会。(或者在Broadwell上使用L4 eDRAM?Skylake L4是一个内存侧缓存,因此甚至不需要通过movnt刷新)。 - Peter Cordes
显示剩余14条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接