为 SIMD 分配内存对齐的缓冲区; |16 如何给出 16 的奇数倍数,为啥要这样做?

Posted

技术标签:

【中文标题】为 SIMD 分配内存对齐的缓冲区; |16 如何给出 16 的奇数倍数,为啥要这样做?【英文标题】:Allocating memory aligned buffers for SIMD; how does |16 give an odd multiple of 16, and why do it?为 SIMD 分配内存对齐的缓冲区; |16 如何给出 16 的奇数倍数,为什么要这样做? 【发布时间】:2020-05-25 18:35:59 【问题描述】:

我正在开发一个 C++ 函数来在内存中分配多个缓冲区。 缓冲区必须是 N 字节对齐的,因为它们保存的数据将使用各种类型的 SIMD 指令集(SSE、AVX、AVX512 等...)进行处理

在 Apple Core Audio Utility Classes online 中,我找到了这段代码:

void CABufferList::AllocateBuffers(UInt32 nBytes)

    if (nBytes <= GetNumBytes()) return;

    if (mABL.mNumberBuffers > 1) 
        // align successive buffers for Altivec and to take alternating
        // cache line hits by spacing them by odd multiples of 16
        nBytes = ((nBytes + 15) & ~15) | 16;
    
    UInt32 memorySize = nBytes * mABL.mNumberBuffers;
    Byte *newMemory = new Byte[memorySize], *p = newMemory;
    memset(newMemory, 0, memorySize);   // get page faults now, not later

    AudioBuffer *buf = mABL.mBuffers;
    for (UInt32 i = mABL.mNumberBuffers; i--; ++buf) 
        if (buf->mData != NULL && buf->mDataByteSize > 0) 
            // preserve existing buffer contents
            memcpy(p, buf->mData, buf->mDataByteSize);
        
        buf->mDataByteSize = nBytes;
        buf->mData = p;
        p += nBytes;
    
    Byte *oldMemory = mBufferMemory;
    mBufferMemory = newMemory;
    mBufferCapacity = nBytes;
    delete[] oldMemory;

代码非常简单,但是有一行我没有完全理解:

nBytes = ((nBytes + 15) & ~15) | 16;

我知道它将字节数对齐/量化为 16,但我不明白为什么它在末尾使用按位 OR 16。评论说:“通过将它们间隔为 16 的奇数倍来进行交替缓存行命中”。请原谅我的厚度,但我还是不明白。

所以我有三个问题:

1) | 16; 究竟做了什么,为什么这样做?

2) 考虑到内存分配和数据访问的上下文,| 16; 如何以及在哪些方面改进了代码?从代码中的 cmets 我可以猜到它与缓存访问有关,但我不理解整个“交替缓存行命中”位。将内存分配地址间隔为 16 的奇数倍如何提高缓存访问?

3) 我是否认为上述函数只能在新运算符将返回至少 16 字节对齐内存的假设下正常工作?在 C++ 中,new 运算符被定义为返回一个指向存储的指针,该指针具有适合任何具有基本对齐要求的对象的对齐方式,可能不一定是 16 字节。

【问题讨论】:

en.cppreference.com/w/cpp/language/alignas @JesperJuhl:如果/当alignas动态 分配做任何事情,它只适用于C++17。较早的 C++ 版本很难在 new/delete 之上对齐内存。 @PeterCordes 由于没有指定具体标准,我假设是当前标准(C++17 ATM)。我认为这是合理的。 对于问题 (1),| 16 只是使 nBytes 成为 16 的奇数倍,根据代码中此行上方的注释。 @JesperJuhl:当然,但它总是在 C++17 中工作吗?或者如果您真正想要的是float 的对齐缓冲区,您是否需要new 的过度对齐类型? 【参考方案1】:

免责声明

根据提到 Altivec 的评论,这是特定于我不熟悉的 Power 架构的。另外,代码不完整,但看起来分配的内存是组织在一个或多个相邻的缓冲区中的,并且大小调整仅在有多个缓冲区时才有效。我们不知道如何在这些缓冲区中访问数据。这个答案会有很多假设,以至于它可能完全不正确。我发布它主要是因为它太大而无法发表评论。

回答(有点)

我可以看到尺寸修改的一个可能优势。首先,让我们记住一些关于 Power 架构的细节:

Altivec 向量大小为 16 字节(128 位) 缓存行大小为 128 字节

现在,我们举个例子,AllocateBuffers 为 4 个缓冲区分配内存(即mABL.mNumberBuffers 为 4),nBytes 为 256。让我们看看这些缓冲区在内存中是如何布局的:

| Buffer 1: 256+16=272 bytes | Buffer 2: 272 bytes | Buffer 3: 272 bytes | Buffer 4: 272 bytes |
^                            ^                     ^                     ^
|                            |                     |                     |
offset: 0                    272                   544                   816

注意偏移值并将它们与缓存行边界进行比较。为简单起见,我们假设内存是在高速缓存行边界分配的。没关系,如下所示。

缓冲区 1 从偏移量 0 开始,这是缓存行的开头。 缓冲区 2 从缓存线边界后 16 个字节开始(偏移量 2*128=256)。 缓冲区 3 从缓存线边界后 32 个字节开始(偏移量 4*128=512)。 缓冲区 4 从缓存线边界后 48 个字节开始(偏移量 6*128=768)。

注意距离最近的高速缓存行边界的偏移量如何增加 16 个字节。现在,如果我们假设每个缓冲区中的数据将以 16 字节块的形式在循环中正向访问,那么缓存行将以相当特定的顺序从内存中获取。让我们考虑循环的中间部分(因为在开始时 CPU 必须为每个缓冲区的开头获取缓存行):

迭代 5 在偏移量 5*16=80 处从缓冲区 1 加载,我们仍在使用之前迭代中提取的缓存行。 从缓冲区 2 的偏移量 352 处加载,我们仍在使用在先前迭代中获取的高速缓存行。缓存线边界在偏移量 256,我们在它的偏移量 96。 从缓冲区 3 的偏移量 624 处加载,我们仍在使用在先前迭代中获取的缓存行。缓存线边界在偏移量 512,我们在它的偏移量 112。 从缓冲区 4 的偏移量 896 处加载,我们到达新的缓存行边界并从内存中获取新的缓存行。 迭代 6 在偏移量 6*16=96 处从缓冲区 1 加载,我们仍在使用之前迭代中提取的缓存行。 从缓冲区 2 的偏移量 368 处加载,我们仍在使用在先前迭代中获取的高速缓存行。缓存线边界在偏移量 256,我们在它的偏移量 112。 从缓冲区 3 的偏移量 640 处加载,我们到达新的缓存行边界并从内存中获取新的缓存行。 从缓冲区 4 的偏移量 896 处加载,我们仍在使用上次迭代时获取的缓存行。缓存线边界在偏移量 896,我们在它的偏移量 16。 迭代 7 从缓冲区 1 的偏移量 7*16=112 处加载,我们仍在使用之前迭代中提取的缓存行。 从缓冲区 2 的偏移量 384 处加载,我们到达新的缓存行边界并从内存中获取新的缓存行。 从缓冲区 3 的偏移量 656 处加载,我们仍在使用上次迭代时获取的缓存行。缓存线边界在偏移量 640,我们在它的偏移量 16。 从缓冲区 4 的偏移量 912 处加载,我们仍在使用在先前迭代中获取的高速缓存行。缓存线边界在偏移量 896,我们在它的偏移量 32。 迭代 8 在偏移量 8*16=128 从缓冲区 1 加载,我们到达新的缓存行边界并从内存中获取新的缓存行。 从缓冲区 2 的偏移量 400 处加载,我们仍在使用在先前迭代中获取的缓存行。缓存线边界在偏移量 384,我们在它的偏移量 16。 从缓冲区 3 的偏移量 672 处加载,我们仍在使用在先前迭代中获取的缓存行。缓存线边界在偏移量 640,我们在它的偏移量 32。 从缓冲区 4 的偏移量 944 处加载,我们仍在使用在先前迭代中获取的高速缓存行。缓存线边界在偏移量 896,我们在它的偏移量 48。

请注意,从内存中获取新缓存行的顺序不取决于每次循环迭代中访问缓冲区的顺序。此外,它不依赖于整个内存分配是否与高速缓存行边界对齐。另请注意,如果以相反的顺序访问缓冲区内容,则将按正序获取缓存行,但仍按顺序。

这种有序的高速缓存行获取可能有助于 CPU 中的硬件偏好,因此,当执行下一次循环迭代时,所需的高速缓存行已经被预取。没有它,循环的每 8 次迭代将需要 4 个新的缓存行,无论程序访问缓冲区的顺序如何,这可能被解释为对内存的随机访问并妨碍预取器。根据循环复杂性,这 4 个缓存行获取可能不会被乱序执行模型隐藏并引入停顿。当您每次迭代最多只能获取 1 个缓存行时,这种情况不太可能发生。

另一个可能的好处是避免address aliasing。我不知道 Power 的缓存组织,但如果 nBytes 是页面大小的倍数,一次使用多个缓冲区,当每个缓冲区页面对齐时,可能会导致大量错误的依赖关系并阻碍 store-to-load forwarding。虽然代码会进行调整,但不仅仅是在 nBytes 是页面大小的倍数时进行调整,所以别名可能不是主要问题。

    我是否认为上述函数只能在新运算符将返回至少 16 字节对齐内存的假设下正常工作?在 C++ 中,new 运算符被定义为返回一个指向存储的指针,该指针具有适合任何具有基本对齐要求的对象的对齐方式,可能不一定是 16 字节。

是的,C++ 不保证任何特定的对齐方式,除了它适用于存储任何基本类型的对象。 C++17 增加了对过度对齐类型的动态分配的支持。

但是,即使使用较旧的 C++ 版本,每个编译器也都遵循目标系统 ABI 规范,该规范可能会指定内存分配的对齐方式。实际上,在许多系统上,malloc 返回至少 16 字节对齐的指针,operator new 使用由malloc 或类似的较低级别 API 返回的内存。

虽然它不是可移植的,因此不推荐使用。如果您需要特定的对齐方式,请确保您正在为 C++17 进行编译或使用专门的 API,例如 posix_memalign

【讨论】:

该评论的部分内容可能是在不同时间撰写的。例如它最初可能只是“对齐 Altivec 的连续缓冲区”(因为那是 Apple 的 第一个 ISA 与 SIMD,在 x86 和 ARM 与 NEON 之前。无论如何,我认为我们不能也不应该统治对其他 ISA 有好处,尤其是具有潜在有限内存级并行性的有序 ARM。(您关于错开缓存未命中的想法可能对此类 CPU 最有利。)但是带有 AltiVec 的 G4 PPC 可能是有序的,或者有限的 OoO 执行窗口:en.wikipedia.org/wiki/PowerPC_G4#e600 (也是en.wikipedia.org/wiki/AltiVec#Implementations)。我认为 TLB 可能无关紧要。这个小偏差不会改变正在访问的页面。但它可能会影响 L1d 和/或 L2 缓存中冲突未命中的别名。例如PPC7450 具有片上 256k 8 路 L2。可能还避免页面大小的精确倍数有助于消除内存歧义(通常通过仅查看地址的低位来确定加载是否正在重新加载最近的存储。例如,x86 CPU 具有 4k 别名错误依赖性;倾斜缓冲区对此有帮助。) > 我不认为 TLB 可能是相关的;这个小偏差不会改变正在访问的页面。 ——是的,你可能是对的。我更正了答案。 @AndreySemashev 很好的答案。谢谢你。因此,如果我们要实例化 N 个缓冲区,而 N 是一个很大的数字(在我的代码中我可能会实例化多达 N=1024x1024=1048576 个缓冲区),那么如果不使用这个“技巧”,我们可能需要 CPU 获取 N 个缓存行在一次迭代中。通过将缓冲区间隔为 16 的奇数倍,我们将最小化每次迭代的高速缓存行命中数。这将导致在迭代期间更均匀地“散布”缓存命中/获取。在我看来,访问缓存是一种更智能/有益的方法。我在这些方面的想法是否正确? 好吧,它最适合最多 128/16=8 个缓冲区(以及在 x86 - 64/16=4 个缓冲区上)。使用 8 个缓冲区,每次迭代将获取 1 个缓存行(而不是每 8 次迭代获取 8 个缓存行)。随着更多的缓冲区,这个数字会增加。使用 1048576 个缓冲区,您每次迭代获取 131072 个缓存行。在这一点上,我不确定这种技术的效率如何,因为您可能会用完缓存。可能仍然比没有它好,尽管您可能会受到系统内存的瓶颈。【参考方案2】:

Re:“如何”部分:在一组位中进行 ORing(0x10 aka 16)使其成为 16 的 奇数 倍数。即使是 16 的倍数也会清除该位,即它们也是 32 的倍数。这确保不是这种情况。

例如:32 | 16 = 48。48 | 16 = 48。在对齐 16 后,无论在值中设置其他高位如何,都适用。

注意这里调整的是分配大小。因此,如果多个缓冲区是从一个大分配中连续切出的,它们将不会都以相对于缓存行边界的相同对齐方式开始。正如安德烈的回答所指出的那样,如果它们最终的大小为n * line_size + 16,它们可能会错开。 如果它们都被分配器分配给与页面开头对齐的缓冲区的开头,则完全没有帮助,分配器回退到直接使用 mmap 进行 large 分配(例如 glibc 的马尔洛克)。据推测(至少在撰写本文时),Apple 没有这样做。

要求缓冲区大小为 2 的大幂的请求可能并不罕见。


请注意,这条评论可能已经过时了:Altivec 是 Apple 的第一个带有 SIMD 的 ISA,在他们采用 x86 之前,在他们使用 ARM + NEON 制造 iPhone 之前。

倾斜缓冲区(因此它们相对于页面或缓存行不会全部对齐)在 x86 上仍然有用,在 ARM 上也可能有用。

这些缓冲区的用例必须包括在相同索引处访问两个或多个缓冲区的循环。例如A[i] = f(B[i]).

性能原因可能包括:

避免 x86 Sandybridge 系列(https://www.agner.org/optimize/blog/read.php?i=142 和 Agner Fog's microarch pdf)上的缓存库冲突 在一个循环中访问多于 L1 或 L2 缓存关联性的数组时,请避免使用 conflict misses。如果必须逐出一个数组以腾出空间来缓存另一个数组,则可能会在整行中发生一次,而不是在一行中的每个 SIMD 向量发生一次。 避免内存消歧错误依赖存储(4k 别名)。例如L1 memory bandwidth: 50% drop in efficiency using addresses which differ by 4096+64 bytes。 x86 Intel CPU 仅查看存储/加载地址的低 12 位,作为快速检查加载是否与运行中存储重叠的第一个快速检查。在 4k 页面内具有相同偏移量的商店作为负载有效地对其进行别名处理,直到硬件发现它实际上没有,但这会延迟负载。如果 PPC 上的记忆消歧有类似的快速路径,我不会感到惊讶。 Andrey 关于惊人缓存未命中率的猜测:我喜欢这个想法,与现代高端 x86 相比,它在具有有限乱序执行窗口(并且可能是内存级并行度有限)的早期 PowerPC CPU 上更为重要和苹果的高端ARM。 https://en.wikipedia.org/wiki/AltiVec#Implementations。它也可能有助于现代有序 ARM CPU(它也可能具有有限的内存级并行性)。我敢肯定,一些 Apple 设备已经使用有序 ARM,至少作为 big.LITTLE 设置的低功耗内核。

(当我说“避免”时,有时这只是“降低可能性”。)

【讨论】:

感谢您的回答,彼得。碰巧在我的代码中我分配了大量相同大小的缓冲区。大小是 2 的幂...我在 x86 上。因此,以这种方式错开缓冲区可能是有益的。

以上是关于为 SIMD 分配内存对齐的缓冲区; |16 如何给出 16 的奇数倍数,为啥要这样做?的主要内容,如果未能解决你的问题,请参考以下文章

为啥动态分配的内存总是 16 字节对齐?

4096个字节是4K对齐了吗?

如何在 C++ 中急切提交分配的内存?

Perl 中的 OS 页面对齐分配

SIMD和动态内存分配[重复]

PPL Combinable 的 SIMD 对齐问题