【问题标题】:Is `reinterpret_cast`ing between hardware SIMD vector pointer and the corresponding type an undefined behavior?硬件 SIMD 向量指针和相应类型之间的“reinterpret_cast”是未定义的行为吗?
【发布时间】:2019-02-06 07:38:13
【问题描述】:

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 behaviors?

它们是否违反了严格的别名规则? [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

【问题讨论】:

  • 为什么你认为它没有违反严格的别名规则?在我看来,您的第一个代码违反了它。正如你所建议的,我会为此使用内在函数。
  • @geza 谢谢。我只是不确定,因为带下划线的表示永远不会以float 以外的任何其他类型访问
  • 你不也将它用作__m256 吗?如果不是,那有什么意义呢? :)
  • @geza 那么在您看来,访问位于__m256 对象和__m256 生命周期内的浮点数是否违反了严格的别名规则?
  • 是的,我不会这样做。有一个肯定不违反的解决方案,我会改用加载/存储内在函数。如果出于某种原因,选择 reinterpret_cast 的唯一原因是它更快。但是当前的编译器在优化这类东西方面做得很好。

标签: c++ x86 language-lawyer undefined-behavior intrinsics


【解决方案1】:

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

Intel 的内部函数将矢量指针(如 __m256*)定义为允许为其他任何东西设置别名,就像 ISO C++ 将 char* 定义为允许使用别名一样。

所以是的,取消引用 __m256* 而不是使用 _mm256_load_ps() 对齐加载内在函数是安全的。

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


在 gcc 中,这是通过使用 may_alias 属性定义 __m256 来实现的:来自 gcc7.3 的 avxintrin.h&lt;immintrin.h&gt; 包括的标头之一):

/* 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 兼容所必需的。


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

_mm_storeu_si128( (__m128i*)&amp;arr[i], vec); 使用英特尔的 API 需要您创建潜在未对齐的指针,如果您尊重它们就会出错。而_mm_storeu_ps 到非 4 字节对齐的位置需要创建一个未对齐的 float*

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

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


另辟蹊径:向量的元素访问。

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

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


要插入/提取向量元素,请使用 shuffle 内在函数,SSE2 _mm_insert_epi16 / _mm_extract_epi16 或 SSE4.1 insert / _mm_extract_epi8/32/64。对于浮点数,没有应与标量 float 一起使用的插入/提取内在函数。

或存储到数组并读取数组。 (print a __m128i variable)。这确实优化了向量提取指令。

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

有像 Agner Fog's (GPL licensed) Vector Class Library 这样的包装库,它们为其向量类型提供可移植的 operator[] 重载,以及运算符 + / - / * / &lt;&lt; 等等。这非常好,特别是对于具有不同元素宽度的不同类型的整数类型,v1 + v2 可以使用正确的大小。 (GNU C 原生向量语法对浮点/双精度向量执行此操作,并将 __m128i 定义为有符号 int64_t 的向量,但 MSVC 不提供基于 __m128 类型的运算符。)


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

但不能保证您会从任何这些元素访问方法中获得高效代码。不要在内部循环中使用,如果性能很重要,请查看生成的 asm。

【讨论】:

  • 奇怪的是,虽然 icc(与 gcc 和 clang 不同)通常足够复杂,可以识别当指针从 T* 转换为 U* 并用于在下次之前访问存储时它是通过其他方式访问的,这样的操作实际上可能会影响所讨论的T 的值(即它可以在实际上不涉及别名的情况下处理类型双关语)我的测试表明当涉及__m256*uint32_t* 类型时,即使uint32_t* 派生自用于访问__m256 的同一指针对象,它也不处理此类情况。
  • 你认为这是一个足够接近的欺骗:stackoverflow.com/questions/24787268/…?我的投票具有约束力,所以我对扣动扳机犹豫不决。
  • @Mysticial: 嗯,是的,我们的答案几乎可以回答这两个问题,即使问题略有不同(另一个似乎假设 _mm_storeu_pd 将具有与取消引用相同的别名语义,但它是一个内在函数,所以它可以做任何事情。)我更喜欢我的回答,因为不是说有(明显的)UB,但它恰好可以工作,我说的是支持内在函数的编译器 do 定义这种情况下的行为。这是我唯一的犹豫不决。也许我应该在那里重新发布我的?
  • 或者关闭它作为一个副本?不过你的回答也不错。
  • @Mysticial 我也喜欢你的回答,尤其是尾随的一般准则。
【解决方案2】:

[编辑:对于反对者,请参阅https://stackoverflow.com/questions/tagged/language-lawyer。此答案适用于从 C++98 到当前草案的任何 ISO C++ 标准。一般认为Undefined Behavior等基本概念不需要详解,见http://eel.is/c++draft/defns.undefined和SO上的各种问题]

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

实现当然可以添加特定的附加保证,但Undefined Behavior 表示与 ISO C++ 相关。

【讨论】:

  • __m256 由实现提供。这是一个扩展。
  • @MSalters 由实现定义,__m256 不仅是允许的,而且实际上要求使用保留名称。
  • @geza:标准不要求实现对任何特定目的或任何目的有用。一个高质量的实现必须做什么才能适用于任何目的的问题在很大程度上与一个实现必须做什么才能符合 C 标准的问题是正交的。
  • @geza:如果一个动作调用了未定义的行为,这意味着编译器的行为方式可能使其不适合某些目的,但仍然符合要求。一些编译器编写者似乎认为,除了编译器“符合”这一事实(例如,期望它适合他们的程序应该服务的目的)以及任何依赖于除此之外的事情是“破碎的”。这种观点在恕我直言是荒谬的,但似乎正在引导当前的编译器理念。
  • @MSalters:我认为您发布的答案是正确的,但没有用。我们想知道__m256* 首先定义它的实现 的语义,并且旨在与英特尔的实现/文档兼容。当然,ISO C++ 标准对此无话可说。我发布了一个从这个角度解决它的答案。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2019-05-28
  • 2012-04-10
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多