如何实施" _mm_storeu_epi64"没有混叠问题?

时间:2014-07-16 17:39:22

标签: c++ sse intrinsics strict-aliasing

(注意:虽然这个问题是关于“存储”的,但“加载”情况具有相同的问题并且是完全对称的。)

SSE内在函数提供具有以下签名的_mm_storeu_pd函数:

void _mm_storeu_pd (double *p, __m128d a);

所以,如果我有两个双打的向量,并且我想将它存储到两个双精度数组中,我可以使用这个内在函数。

但是,我的矢量不是两个双打;它是两个64位整数,我想将它存储到两个64位整数的数组中。也就是说,我想要一个具有以下签名的函数:

void _mm_storeu_epi64 (int64_t *p, __m128i a);

但是内在函数没有提供这样的功能。他们最接近的是_mm_storeu_si128

void _mm_storeu_si128 (__m128i *p, __m128i a);

问题是此函数采用指向__m128i的指针,而我的数组是int64_t的数组。通过错误类型的指针写入对象违反了strict aliasing,并且肯定是未定义的行为。我担心我的编译器现在或将来会重新排序或以其他方式优化商店,从而以奇怪的方式破坏我的程序。

要清楚,我想要的是一个我可以这样调用的函数:

__m128i v = _mm_set_epi64x(2,1);
int64_t ra[2];
_mm_storeu_epi64(&ra[0], v); // does not exist, so I want to implement it

以下是创建此类功能的六次尝试。

尝试#1

void _mm_storeu_epi64(int64_t *p, __m128i a) {
    _mm_storeu_si128(reinterpret_cast<__m128i *>(p), a);
}

这似乎有我担心的严格别名问题。

尝试#2

void _mm_storeu_epi64(int64_t *p, __m128i a) {
    _mm_storeu_si128(static_cast<__m128i *>(static_cast<void *>(p)), a);
}

Possibly better in general,但我认为在这种情况下没有任何区别。

尝试#3

void _mm_storeu_epi64(int64_t *p, __m128i a) {
    union TypePun {
        int64_t a[2];
        __m128i v;
     };
    TypePun *p_u = reinterpret_cast<TypePun *>(p);
    p_u->v = a;
}

这会在我的编译器(GCC 4.9.0)上生成错误代码,该代码会发出对齐的movaps指令而不是未对齐的movups。 (联盟是对齐的,因此reinterpret_cast欺骗GCC,假设p_u也是对齐的。)

尝试#4

void _mm_storeu_epi64(int64_t *p, __m128i a) {
    union TypePun {
        int64_t a[2];
        __m128i v;
     };
    TypePun *p_u = reinterpret_cast<TypePun *>(p);
    _mm_storeu_si128(&p_u->v, a);
}

这似乎发出了我想要的代码。尽管technically undefined in C++,“通过联合打字”技巧是widely-supported。但是这个例子 - 我传递指向联合元素的指针而不是通过联合本身访问 - 真的是使用联合进行类型惩罚的有效方法吗?

尝试#5

void _mm_storeu_epi64(int64_t *p, __m128i a) {
    p[0] = _mm_extract_epi64(a, 0);
    p[1] = _mm_extract_epi64(a, 1);
}

这是有效的,但完全有效,但它会发出两条指令而不是一条指令。

尝试#6

void _mm_storeu_epi64(int64_t *p, __m128i a) {
    std::memcpy(p, &a, sizeof(a));
}

这是有效的,完全有效......我想。但它在我的系统上发出了可怕的代码。 GCC通过对齐的存储将a溢出到对齐的堆栈槽,然后手动将组件字移动到目标。 (实际上它会溢出两次,每个组件一次。非常奇怪。)

...

有没有办法编写这个函数,它将(a)在典型的现代编译器上生成最佳代码,(b)与严格别名冲突的风险最小?

1 个答案:

答案 0 :(得分:16)

SSE内在函数是那些需要稍微推行规则的小众角落案例之一。

由于这些内在函数是编译器扩展(由英特尔稍微标准化),因此它们已经超出了C和C ++语言标准的规范。因此,在使用明显不符合标准的功能时尝试“符合标准”会有些自我挫败。

尽管SSE内部库试图像普通的第三方库一样运行,但它们都是由编译器专门处理的。


意图:

SSE内在函数可能从一开始就设计为允许向量和标量类型之间的别名 - 因为向量实际上只是标量类型的集合。

但是设计SSE内在函数的人可能不是一个语言学者。
(这并不太令人惊讶。硬核低级绩效程序员和语言律师爱好者往往是非常不同的人群谁不总是相处。)

我们可以在加载/存储内在函数中看到这个的证据:

  • __m128i _mm_stream_load_si128(__m128i* mem_addr) - 一个带有非常量指针的加载内在函数?
  • void _mm_storeu_pd(double* mem_addr, __m128d a) - 如果我想存储到__m128i*
  • 该怎么办?

严格的混叠问题是这些糟糕原型的直接结果。

从AVX512开始,内在函数已全部转换为void*以解决此问题:

  • __m512d _mm512_load_pd(void const* mem_addr)
  • void _mm512_store_epi64 (void* mem_addr, __m512i a)

编译器细节:

  • Visual Studio将每个SSE / AVX类型定义为标量类型的并集。这本身就允许严格别名。此外,Visual Studio不会进行严格别名,所以重点是没有意义:

  • 英特尔编译器从未让我遇到各种各样的别名。它可能也没有严格别名 - 尽管我从来没有找到任何可靠的来源。

  • GCC确实做了严格别名,但根据我的经验,不是跨越功能界限。它从来没有让我失去传入指针(在任何类型上)。 GCC还将SSE类型声明为__may_alias__,从而明确允许其为其他类型添加别名。


我的建议:

  • 对于错误指针类型的函数参数,只需将其强制转换。
  • 对于在堆栈上声明和别名的变量,请使用union。该联合已经对齐,因此您可以直接读取/写入它们而无需内在函数。 (但请注意交错矢量/标量访问带来的存储转发问题。)
  • 如果您需要作为整体及其标量组件访问向量,请考虑使用insert / extract内在函数而不是别名。
  • 使用GCC时,请启用-Wall-Wstrict-aliasing。它会告诉你违反严格别名的行为。