硬件 SIMD 向量指针和相应类型之间的“reinterpret_cast”是未定义的行为吗?

Is `reinterpret_cast`ing between hardware SIMD vector pointer and the corresponding type an undefined behavior?

reinterpret_castfloat*__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 是否依赖于 undefined behavior

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

或者intrinsic只有一种定义方式:

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

godbolt

[编辑:对于反对者,请参阅 https://whosebug.com/questions/tagged/language-lawyer. This answer is valid for any ISO C++ standard from C++98 to the current draft. It's generally assumed that basic concepts such as Undefined Behavior do not need detailed explanation, but see http://eel.is/c++draft/defns.undefined 和关于 SO 的各种问题]

由于 __m256 不是标准类型,也不是用户定义类型的有效名称,它已经开始成为未定义行为。

实现当然可以添加特定的额外保证,但Undefined Behavior意味着与 ISO C++ 相关。

ISO C++ 没有定义 __m256,所以我们需要看看 做了什么 在支持它们的实现上定义它们的行为。

Intel 的内在定义 vector-pointers 就像 __m256* 允许别名一样,就像 ISO C++ 定义 char* 允许别名一样。

所以是的,取消引用 __m256* 而不是使用 _mm256_load_ps() aligned-load 内在函数是安全的。

但特别是对于 float/double,使用内在函数通常更容易,因为它们也负责从 float* 进行转换。对于整数,AVX512 load/store 内在函数被定义为采用 void*,但在此之前你需要一个额外的 (__m256i*),这只是很多混乱。


在 gcc 中,这是通过使用 may_alias 属性定义 __m256 来实现的:来自 gcc7.3 的 avxintrin.h([=22= 的 headers 之一] 包括):

/* 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 如何实现 Intel 的内在函数只是因为那是我所熟悉的。我从 gcc 开发人员那里听说他们选择该实现是因为它需要与 Intel 兼容。


需要定义英特尔内在函数的其他行为

将 Intel 的 API 用于 _mm_storeu_si128( (__m128i*)&arr[i], vec); 需要您创建 potentially-unaligned 指针,如果您对它们进行引用就会出错。 _mm_storeu_ps 到一个不是 4 字节对齐的位置需要创建一个 under-aligned float*.

只是 创建 未对齐的指针,或 object 之外的指针,在 ISO C++ 中是 UB,即使您不取消引用它们。 我想这允许在奇异的硬件上实现,这些硬件在创建指针时对指针进行某种检查(可能而不是在取消引用时),或者可能无法存储指针的低位。 (我不知道是否存在任何特定的硬件,因为这个 UB 可以实现更高效的代码。)

但是支持英特尔内在函数的实现必须定义行为,至少对于 __m* 类型和 float*/double*。这对于针对任何普通现代 CPU 的编译器来说是微不足道的,包括具有平面内存模型(无分段)的 x86; asm 中的指针只是保存在与数据相同的寄存器中的整数。 (m68k 有地址和数据寄存器,但它永远不会因为在 A 寄存器中保留 bit-patterns 而不是有效地址而出错,只要你不取消引用它们。)


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

请注意 may_alias,就像 char* 别名规则一样,只有一种方式:它是 而不是 保证可以安全使用int32_t*读一个__m256。使用 float* 读取 __m256 甚至可能不安全。就像做 char buf[1024]; int *p = (int*)buf;.

不安全一样

参见GCC AVX _m256i cast to int array leads to wrong values for a real-world example of GCC breaking code that points an int* into a __m256i vec; object. Not a dereferenced __m256i*;如果只有 __m256i 访问是通过 __m256i*,那将是安全的。因为它是 may_alias 类型,所以编译器无法推断出底层 object 是 __m256i;这就是重点,以及为什么将它指向 int arr[] 或其他什么是安全的。

Reading/writing 通过 char* 可以别名任何东西,但是当你有 char object, strict-aliasing 确实使它成为通过其他类型读取它的 UB。 (我不确定 x86 上的主要实现是否确实定义了该行为,但您不需要依赖它,因为它们将 4 个字节的 memcpy 优化为 int32_t。您可以应该使用 memcpy 来表示来自 char[] 缓冲区的未对齐加载,因为允许具有更宽类型的 auto-vectorization 假定 int16_t* 的 2 字节对齐,并编写代码如果不是则失败:Why does unaligned access to mmap'ed memory sometimes segfault on AMD64?)

A char arr[] 可能不是一个很好的类比,因为 arr[i] 是根据 *(arr+i) 定义的,所以实际上有一个 char* deref 涉及访问数组作为 char objects。也许结构的某些 char 成员会是一个更好的例子。


到insert/extract 向量元素,使用shuffle intrinsics、SSE2 _mm_insert_epi16 / _mm_extract_epi16 或SSE4.1 insert / _mm_extract_epi8/32/64。对于浮点数,没有应该与标量 float.

一起使用的 insert/extract 内在函数

或者存储到数组中,读取数组。 (print a __m128i variable)。这实际上优化了向量提取指令。

GNU C 向量语法为向量提供 [] 运算符,如 __m256 v = ...; v[3] = 1.25;。 MSVC 将矢量类型定义为具有 .m128_f32[] 成员的联合,用于 per-element 访问。

有像 Agner Fog's (GPL licensed) Vector Class Library 这样的包装库,它们为它们的向量类型提供可移植的 operator[] 重载,还有运算符 + / - / * / << 等等。这非常好,特别是对于整数类型,对于不同的元素宽度具有不同的类型使得 v1 + v2 使用正确的 s泽。 (GNU C 本机向量语法为 float/double 向量执行此操作,并将 __m128i 定义为带符号 int64_t 的向量,但 MSVC 不提供基于 __m128 类型的运算符.)


您还可以在向量和某种类型的数组之间使用并集 type-punning,这在 ISO C99 和 GNU C++ 中是安全的,但在 ISO C++ 中不是。我认为它在 MSVC 中也是正式安全的,因为我认为他们将 __m128 定义为普通联合的方式。

不过,不能保证您会从这些 element-access 方法中的任何一个中获得 高效的 代码。不要使用 inside 内部循环,如果性能很重要,请查看生成的 asm。