在硬件SIMD向量指针和相应类型之间使用reinterpret_cast是否属于未定义行为?

10

float*重新解释为__m256*并通过不同的指针类型访问float对象是否合法?

constexpr size_t _m256_float_step_sz = sizeof(__m256) / sizeof(float);
alignas(__m256) float stack_store[100 * _m256_float_step_sz ]{};
__m256& hwvec1 = *reinterpret_cast<__m256*>(&stack_store[0 * _m256_float_step_sz]);

using arr_t = float[_m256_float_step_sz];
arr_t& arr1 = *reinterpret_cast<float(*)[_m256_float_step_sz]>(&hwvec1);

hwvec1arr1是否依赖于未定义行为?

它们是否违反了严格别名规则?[basic.lval]/11

还是说只有一种定义好的内在方式:

__m256 hwvec2 = _mm256_load_ps(&stack_store[0 * _m256_float_step_sz]);
_mm256_store_ps(&stack_store[1 * _m256_float_step_sz], hwvec2);

godbolt


1
你为什么认为它不违反严格别名规则?在我看来,你的第一段代码违反了它。我会像你建议的那样使用内置函数来解决这个问题。 - geza
@geza 谢谢。我只是不确定,因为下划线强调的表示方式从未被访问为除了 float 以外的任何其他类型。 - sandthorn
你难道不打算也用 __m256 吗?如果不是,那还有什么意义呢? :) - geza
@geza,那么在您的看法中,访问存储在__m256对象内部且在__m256生命周期内的浮点数是否违反了严格别名规则? - sandthorn
是的,我不会这样做。肯定有一种不违规的解决方案,我会使用load/store内置函数。选择reinterpret_cast的唯一原因是如果某种情况下它更快。但是当前的编译器非常擅长优化这些东西。 - geza
2个回答

20

ISO C++没有定义__m256,因此我们需要查看在支持它们的实现中定义它们行为的内容。

英特尔的内部函数将向量指针(如__m256*)定义为允许别名任何其他东西,就像ISO C++将char*定义为允许别名一样。

因此,安全地取消引用__m256*而不是使用_mm256_load_ps()对齐加载内部函数是安全的。

但是,特别是对于浮点/双精度,使用内部函数通常更容易,因为它们也会处理从float*进行转换。对于整数,AVX512加载/存储内部函数被定义为采用void*,但在此之前,您需要额外的(__m256i*),这只是很多杂乱无章的东西。


在gcc中,这是通过使用may_alias属性来定义__m256实现的:来自gcc7.3的avxintrin.h<immintrin.h>包含的头文件之一):

/* The Intel API is flexible enough that we must allow aliasing with other
   vector types, and their scalar components.  */
typedef float __m256 __attribute__ ((__vector_size__ (32),
                                     __may_alias__));
typedef long long __m256i __attribute__ ((__vector_size__ (32),
                                          __may_alias__));
typedef double __m256d __attribute__ ((__vector_size__ (32),
                                       __may_alias__));

/* Unaligned version of the same types.  */
typedef float __m256_u __attribute__ ((__vector_size__ (32),
                                       __may_alias__,
                                       __aligned__ (1)));
typedef long long __m256i_u __attribute__ ((__vector_size__ (32),
                                            __may_alias__,
                                            __aligned__ (1)));
typedef double __m256d_u __attribute__ ((__vector_size__ (32),
                                         __may_alias__,
                                         __aligned__ (1)));
(如果你想知道,这就是为什么解引用 __m256* 就像 _mm256_store_ps,而不是 storeu。)
没有 may_alias 的 GNU C 本机向量可以与它们的标量类型别名,例如即使没有 may_alias,你也可以安全地在 float* 和假设的 v8sf 类型之间进行转换。但 may_alias 使得从 int[]char[] 或其他数组中加载变得安全。
我谈论 GCC 如何实现英特尔内置函数只是因为我熟悉这个。我听说 GCC 开发人员选择这种实现是因为它对于与英特尔的兼容性是必需的。

其他行为需要定义Intel的intrinsic函数

使用Intel的API _mm_storeu_si128((__m128i*)&arr[i], vec); 需要创建潜在未对齐的指针,如果您推迟引用它们,则会出错。并且将_mm_storeu_ps存储到不是4字节对齐的位置需要创建低于对齐的float*

仅仅创建未对齐的指针或指向对象外部的指针就是ISO C++中的未定义行为,即使您不引用它们。我猜这允许在异构硬件上实现某些类型的指针检查(可能是代替解除引用时进行的),或者可能无法存储指针的低位。(我不知道是否存在任何特定的硬件,其中由于此UB可以实现更有效的代码。)

但是支持Intel的intrinsic函数的实现必须定义行为,至少对于__m*类型和float*/double*。对于任何正常的现代CPU,包括具有平面内存模型(没有分段)的x86,汇编中的指针只是保留在与数据相同的寄存器中的整数。(m68k具有地址寄存器和数据寄存器,但只要不dereference它们,就从A寄存器中保留不是有效地址的位模式而不会出错。)


另一种方式:访问向量元素。

请注意,像char*别名规则一样,may_alias只能单向使用:使用int32_t*读取__m256可能不安全。甚至使用float*读取__m256也可能不安全。就像char buf[1024];int *p = (int*)buf;一样不安全。

请参见GCC AVX _m256i cast to int array leads to wrong values,这是一个真实的例子,展示了GCC如何破坏将int*指向__m256i vec;对象的代码。Not a dereferenced __m256i*;如果仅通过__m256i*访问__m256i,那么这是安全的。因为它是一个may_alias类型,编译器无法推断底层对象是否为__m256i。这就是整个问题所在,也是为什么可以将其指向int arr[]或其他内容的原因。

使用char*进行读/写可以别名任何内容,但是如果你有一个char对象,则严格别名设置使得通过其他类型读取它是未定义行为。(我不确定x86的主要实现是否定义了这种行为,但你不需要依赖它,因为它们会优化掉将4个字节memcpyint32_t中的操作。你应该使用memcpy来表示从char[]缓冲区中的非对齐加载,因为自动向量化使用更宽的类型允许假设int16_t*具有2字节对齐,从而生成失败的代码:为什么在AMD64上访问不对齐的mmap内存有时会导致段错误?)

char arr[]可能不是一个很好的类比,因为使用char对象访问数组实际上涉及到char*解引用。或许一些结构体中的char成员会是一个更好的例子。


要插入/提取向量元素,请使用Shuffle intrinsics,SSE2 _mm_insert_epi16 / _mm_extract_epi16或SSE4.1 insert / _mm_extract_epi8/32/64。对于float,没有应该与标量float一起使用的插入/提取intrinsics。
或将其存储到数组中并读取该数组。(打印一个__m128i变量)。这实际上会优化为矢量提取指令。
GNU C矢量语法为矢量提供了[]运算符,例如__m256 v = ...; v[3] = 1.25;。 MSVC将矢量类型定义为带有.m128_f32 []成员的联合,用于逐个元素访问。
有一些包装库,例如Agner Fog的(GPL许可)Vector Class Library,它们为其向量类型提供了可移植的operator[]重载,以及+/-/*/<<等运算符。这非常好,特别是对于整数类型而言,不同的元素宽度可以使v1 + v2使用正确的大小。(GNU C本地向量语法适用于浮点/双精度向量,并将__m128i定义为带符号int64_t向量,但MSVC不会在基本的__m128类型上提供运算符。)
你也可以在向量和某些类型的数组之间使用联合类型转换,这在ISO C99和GNU C++中是安全的,但在ISO C++中不是安全的。我认为它在MSVC中也是官方安全的,因为我认为他们将 __m128 定义为普通联合体的方式。
尽管如此,无法保证这些元素访问方法会获得高效的代码。请勿在内部循环中使用,并查看汇编结果以提高性能。

1
有趣的是,虽然icc(不像gcc和clang)通常足够复杂,以识别从T*转换为U*的指针,并在下一次通过其他方式访问存储之前使用它来访问存储,这样的操作实际上可能会影响所讨论的T的值(即它可以处理涉及类型游戏但实际上不涉及别名的情况),但我的测试表明,当它们涉及到类型__m256*uint32_t*时,它并不处理这样的情况,即使uint32_t*是从用于访问__m256的相同指针对象派生的。 - supercat
2
你认为这个问题是否足够接近以下链接的重复问题:https://dev59.com/omAf5IYBdhLWcg3wUBT4?我的投票是有约束力的,所以我不确定是否应该采取行动。 - Mysticial
1
@Mysticial:嗯,是的,我们的答案几乎可以回答两个不同的问题,尽管这些问题略有不同(另一个似乎假定“_mm_storeu_pd”将具有与解引用相同的别名语义,但它是一种内部函数,因此可能会产生任何结果)。我更喜欢我的答案,因为我没有说存在(明显)的未定义行为,但它碰巧有效,我说支持内部函数的编译器在这种情况下确实定义了行为。这是我重复的唯一犹豫。也许我应该在那里重新发布我的答案? - Peter Cordes
或者将其关闭为此问题的重复?但是您的答案也很好。 - Peter Cordes
@Mysticial,我也喜欢你的回答,尤其是提供了一些有用的指导方针。 - sandthorn
我想知道在标准中被定义为“未定义行为”的主题上声明“实现定义行为”(例如__m256允许别名)是否总是可以的?对我来说,即使是AVX512的void*,它看起来也像是某种“别名漏洞”,就像特权的memcpy一样。 - sandthorn

-3

注意:此答案适用于从C++98到当前草案的任何ISO C++标准。

由于__m256既不是标准类型,也不是有效的用户定义类型名称,因此它已经开始成为未定义行为。库选择它没有充分的理由;C++明确添加了namespace以避免冲突。namespace avx { using m256 = ...至少可以使名称部分定义良好。

实现当然可以添加特定的附加保证,但是未定义行为是指与ISO C++相关。


2
__m256由实现提供。它是一个扩展。 - n. m.
3
由于实现的定义,__m256被允许并且实际上需要使用保留名称。 - eerorika
1
@geza:标准不要求实现对任何特定目的或任何目的有用。质量实现必须做什么才适合任何用途的问题在很大程度上与符合C标准的实现必须做什么的问题不相关。 - supercat
1
@geza:如果一个操作引发未定义行为,那么编译器可能会表现出一种使其不适合某些目的但仍然符合标准的方式。一些编译器编写者似乎认为程序员除了期望它符合标准(例如期望它适用于他们的程序所需的目的)之外,没有权利对编译器期望任何东西,并且依赖超出此范围的任何代码都是“错误”的。这种观点在我看来是荒谬的,但似乎正在引导当前的编译器哲学。 - supercat
1
@geza:你似乎认为编译器的作者会尽力使其编译器最适合用户的需求。虽然我认为这对于有能力编写高质量编译器的人来说是正确的,但并不是所有的编译器作者/维护团队都是如此。 - supercat
显示剩余20条评论

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