如何解决AVX加载/存储操作的32字节对齐问题?

22

我在使用ymm寄存器时遇到了对齐问题,代码片段看起来对我来说似乎很好。这里是一个最小的工作示例:

#include <iostream> 
#include <immintrin.h>

inline void ones(float *a)
{
     __m256 out_aligned = _mm256_set1_ps(1.0f);
     _mm256_store_ps(a,out_aligned);
}

int main()
{
     size_t ss = 8;
     float *a = new float[ss];
     ones(a);

     delete [] a;

     std::cout << "All Good!" << std::endl;
     return 0;
}

在我的架构(Intel(R) Xeon(R) CPU E5-2650 v2 @ 2.60GHz)上,sizeof(float)4,我使用gcc -O3 -march=native编译。当然,使用未对齐内存访问(即指定_mm256_storeu_ps)可以消除错误。我在xmm寄存器上也没有这个问题。

inline void ones_sse(float *a)
{
     __m128 out_aligned = _mm_set1_ps(1.0f);
     _mm_store_ps(a,out_aligned);
}

我是否做了一些愚蠢的事情?有什么解决方法吗?


6
有点离题,但是记得在删除使用new []分配的内容时要使用delete [] - anorm
2
你试过使用_mm_malloc而不是new吗? - Alexander
3
在x64架构下,new/malloc返回的指针具有16字节对齐,这足以满足SSE的要求,但AVX需要32字节对齐。简而言之,就是由于对齐方式不同,导致AVX需要更高的对齐要求。 - stgatilov
相关:https://dev59.com/v2fWa4cB1Zd3GeqPh4QU#12055862(解决16字节SSE对齐问题,但答案也可以轻松地适应32字节AVX对齐)。 - Paul R
1
也许这也很有趣:https://dev59.com/kmQo5IYBdhLWcg3wBLny - stgatilov
你能试着自己对齐吗?例如,分配128字节并使第二个指针指向大缓冲区内所需的任何内容?只是为了看看它是否有效。 - Nick
3个回答

27

是的,你可以使用_mm256_loadu_ps/storeu进行未对齐的加载/存储(AVX:数据对齐:存储崩溃,storeu,load,loadu不支持)。如果编译器没有做糟糕的工作(咳咳 GCC默认调优),在数据对齐的情况下使用AVX _mm256_loadu/storeu与需要对齐的加载/存储一样快,因此在方便的时候对齐数据仍然可以让硬件处理它们不常见的情况,从而为通常在对齐数据上运行但让硬件处理它们不常见情况的函数提供最佳解决方案。(而不是总是运行额外的指令来检查这些内容)。

Alignment对于512位AVX-512向量特别重要,即使在大型数组上,你期望L3 / DRAM带宽成为瓶颈时,SKX的速度也会提高15到20%,而AVX2 CPU仅有几个百分点。如果你的数据在L2或尤其是L1d缓存中很热,并且可以接近每个时钟满载2个载入和/或1个存储,则甚至在现代CPU上使用AVX2仍然非常重要。缓存行分裂的吞吐量资源成本约为两倍,并且需要临时的线路分裂缓冲区。

标准分配器通常只对齐到alignof(max_align_t),通常为16B,例如在x86-64 System V ABI中的long double。但在某些32位ABI中,它仅为8B,因此甚至不足以动态分配对齐的__m128向量,您需要超出简单调用newmalloc

静态和自动存储很容易:使用alignas(32) float arr[N];

C++17提供了对齐的new用于对齐的动态分配。如果类型的alignof大于标准对齐方式,则使用对齐的operator new/operator delete。因此,在C++17中,new __m256[N]可以正常工作(如果编译器支持此C++17特性;请检查__cpp_aligned_new特性宏)。实际上,GCC / clang / MSVC / ICX都支持它,ICC 2021不支持。

没有C++17的特性,即使是像std::vector<__m256>这样的东西也会出错,不仅仅是std::vector<int>,除非你幸运地将其对齐为32。

使用delete兼容的float/int数组分配:

不幸的是,auto* arr = new alignas(32) float[numSteps]并不适用于所有编译器,因为alignas只适用于变量、成员或类声明,而不是类型修饰符。(GCC接受using vfloat = alignas(32) float;,所以这确实给了你一个与普通delete兼容的对齐新数组在GCC上)。

解决方法要么是将其包装在结构中(struct alignas(32) s { float v; }; new s[numSteps];),要么将对齐作为放置参数传递(new (std::align_val_t(32)) float[numSteps];),在后一种情况下,请确保调用匹配的对齐operator delete

请参阅new/new[]std::align_val_t的文档。

new/delete不兼容的其他选项

动态分配的其他选项大多数与malloc/free兼容,但与new/delete不兼容

  • std::aligned_alloc: ISO C++17。 主要缺点:大小必须是对齐的倍数。 这个脑残的要求使其不适合分配未知数量的64B高速缓存行对齐的float数组,例如。 或者特别是2M对齐的数组以利用透明巨大页面

    aligned_alloc的C版本添加在ISO C11中。 它在一些但不是所有C ++编译器中可用。 正如cppreference页面上所述,当大小不是对齐的倍数时,不需要强制执行C11版本(这是未定义的行为),因此许多实现提供了明显的所需行为作为“扩展”。 正在进行讨论以修复此问题,但目前我无法真正推荐aligned_alloc作为分配任意大小数组的便携式方式。 实际上,某些实现在UB /需要失败的情况下工作得很好,因此它可以是一个良好的非便携式选项。

    此外,评论员报告MSVC ++中不可用。 有关Windows的可行#ifdef,请参见最佳跨平台方法以获取对齐内存。 但是,AFAIK没有Windows对齐分配函数可以生成与标准free兼容的指针。

  • posix_memalign: POSIX 2001的一部分,不是任何ISO C或C ++标准。 与aligned_alloc相比,原型/接口笨重。 我看到gcc生成指针的重新加载,因为它不确定缓冲区中的存储是否修改了指针。 (posix_memalign传递指针的地址,破坏了逃逸分析。)因此,如果使用此选项,请将指针复制到另一个未将其地址传递到函数外部的C ++变量中。

#include <stdlib.h>
int posix_memalign(void **memptr, size_t alignment, size_t size);  // POSIX 2001
void *aligned_alloc(size_t alignment, size_t size);                // C11 (and ISO C++17)

  • _mm_malloc: 可用于任何支持_mm_whatever_ps的平台,但不能将其指针传递给free。在许多C和C++实现中,_mm_freefree是兼容的,但无法保证可移植性。(与其他两者不同的是,它会在运行时而不是编译时失败。)在Windows上的MSVC中,_mm_malloc使用_aligned_malloc,这与free不兼容;在实践中会崩溃。

  • 直接使用系统调用,例如mmapVirtualAlloc。适用于大型分配,并且您获得的内存定义为页面对齐(4k,甚至可能是2M大页)。free不兼容;当然,您必须使用munmapVirtualFree,它们需要大小以及地址。(对于大型分配,您通常希望在完成后将内存返回给操作系统,而不是管理自由列表;glibc malloc直接使用mmap/munmap进行malloc/free超过某个大小阈值的块。)

    主要优点:您不必处理C++和C的愚蠢拒绝为对齐分配器提供增长/收缩功能。如果您想在分配后再获得另外1MiB的空间,甚至可以使用Linux的mremap(MREMAP_MAYMOVE)让其选择虚拟地址空间中的不同位置(如果需要)以获取相同物理页面,而无需复制任何内容。或者,如果不必移动,则当前正在使用部分的TLB条目保持有效。

    由于您已经在使用操作系统系统调用(并且知道您正在使用整个页面),因此可以使用madvise(MADV_HUGEPAGE)来暗示首选透明大页面,或者不是针对此范围的匿名页面。您还可以使用mmap中的分配提示,例如使OS预先分配零页,或者如果在hugetlbfs上映射文件,则使用2M或1G页面。(如果该内核机制仍然起作用)。

    通过madvise(MADV_FREE),您可以将其保留映射,但让内核在发生内存压力时回收页面,使其像延迟分配的零后备页面一样。因此,如果您很快重新使用它,则可能不会遭受新的页面错误。但是如果您不这样做,您不会独占它,并且当您阅读它时,它就像一个全新的mmapped区域。


alignas()与数组/结构体

在C++11及以后版本中:可以将alignas(32) float avx_array[1234]用作结构/类成员的第一个成员(或直接用于普通数组),这样该类型的静态和自动存储对象将具有32B对齐。std::aligned_storage documentation提供了这种技术的示例来解释std::aligned_storage的作用。

对于动态分配的存储(例如std::vector<my_class_with_aligned_member_array>),直到C++17才实际起作用,请参见Making std::vector allocate aligned memory

从C++17开始,编译器对于整个类型或其成员强制执行对齐的类型将选择对齐的new,此外std::allocator也将为这种类型选择对齐的new,因此在创建这种类型的std::vector时不必担心。


最后一种选择实在太糟糕了,甚至不列在列表中:分配一个更大的缓冲区,并使用适当的转换进行“p+=31; p&=~31ULL”。由于有太多缺点(难以释放、浪费内存),不值得讨论,因为所有支持Intel“_mm256_…”指令集的平台都提供对齐分配函数。但是如果您坚持,甚至有库函数可以帮助您完成这个过程(如我所知)。需要使用_mm_free而不是free的要求可能部分原因是可以使用这种技术在普通的malloc上实现_mm_malloc。或者对于使用备用空闲链表的对齐分配器。

3
如果你正在使用SSE / AVX /其他指令的_mm_whatever内部函数,那么也可以使用_mm_malloc。如果将对齐分配与非对齐分配分开不是问题,或者你可以在程序中到处使用 _mm_malloc / _mm_free,并且不与任何分配或释放任何东西的库交互,则这也是一种有效的选择。 - Peter Cordes
2
既然你提到了C++17:alignas+动态分配在那里终于得到了修复。 - Marc Glisse
1
在C++17中,alignas非常好用。只需使用具有由alignas强制执行的对齐方式的类型的new T,以使其对齐方式大于__STDCPP_DEFAULT_NEW_ALIGNMENT__,并调用对齐形式的operator newstd::allocator也知道这一点,并在需要时调用对齐的operator new - Alex Guteniev
1
@AlexGuteniev:嗯,谢谢。所以是new alignas(32) float [numSteps]?这在GCC上可以工作,但在clang上无法编译,在ICC上也没有将对齐请求传递给new调用。https://godbolt.org/z/WW7jd4Wra。我发现`new __m256`在GCC和clang上都可以正常工作,但在ICC 2021上不行。 - Peter Cordes
1
完成了。关于ICC:它并没有声称支持这个功能:https://godbolt.org/z/cce4oGdj4 - Alex Guteniev
显示剩余15条评论

7

内存管理有两个内部函数。

_mm_malloc类似于标准的malloc,但它需要一个额外的参数来指定所需的对齐方式。在这种情况下,是32字节的对齐。使用此分配方法时,必须通过相应的_mm_free调用来释放内存。

float *a = static_cast<float*>(_mm_malloc(sizeof(float) * ss , 32));
...
_mm_free(a);

3
你需要使用对齐分配器。
但这并不意味着你不能将它们捆绑在一起:
template<class T, size_t align>
struct aligned_free {
  void operator()(T* t)const{
    ASSERT(!(uint_ptr(t) % align));
    _mm_free(t);
  }
  aligned_free() = default;
  aligned_free(aligned_free const&) = default;
  aligned_free(aligned_free&&) = default;
  // allow assignment from things that are
  // more aligned than we are:
  template<size_t o,
    std::enable_if_t< !(o % align) >* = nullptr
  >
  aligned_free( aligned_free<T, o> ) {}
};
template<class T>
struct aligned_free<T[]>:aligned_free<T>{};

template<class T, size_t align=1>
using mm_ptr = std::unique_ptr< T, aligned_free<T, align> >;
template<class T, size_t align>
struct aligned_make;
template<class T, size_t align>
struct aligned_make<T[],align> {
  mm_ptr<T, align> operator()(size_t N)const {
    return mm_ptr<T, align>(static_cast<T*>(_mm_malloc(sizeof(T)*N, align)));
  }
};
template<class T, size_t align>
struct aligned_make {
  mm_ptr<T, align> operator()()const {
    return aligned_make<T[],align>{}(1);
  }
};
template<class T, size_t N, size_t align>
struct aligned_make<T[N], align> {
  mm_ptr<T, align> operator()()const {
    return aligned_make<T[],align>{}(N);
  }
}:
// T[N] and T versions:
template<class T, size_t align>
auto make_aligned()
-> std::result_of_t<aligned_make<T,align>()>
{
  return aligned_make<T,align>{}();
}
// T[] version:
template<class T, size_t align>
auto make_aligned(size_t N)
-> std::result_of_t<aligned_make<T,align>(size_t)>
{
  return aligned_make<T,align>{}(N);
}

现在,mm_ptr<float[], 4>是指向长度为4的float数组的唯一指针,它是4字节对齐的。你可以通过make_aligned<float[], 4>(20)创建它,这将创建20个4字节对齐的float,或者通过make_aligned<float[20], 4>()(只有在编译时常量的语法中)创建。 make_aligned<float[20],4>返回mm_ptr<float[],4>而不是mm_ptr<float[20],4>mm_ptr<float[],8>可以移动构造mm_ptr<float[],4>,反之则不行,我认为这很好。 mm_ptr<float[]>可以采用任何对齐方式,但不能保证对齐。
std::unique_ptr一样,每个指针的开销基本上为零。通过积极地进行inline,可以将代码开销最小化。

@romeric 从大到小排序 - Yakk - Adam Nevraumont

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