当您启动一个程序时,它会创建一个拥有其内存的“进程”。内存以称为“页”的块的形式分配给软件进程。这些页面可能跨越 4kB、16kB 或更多。对于给定的进程,在这些页面内读写是安全的。
在您的代码中,您可能会分配一个 32 字节的数组。数组需要多少内存?答案是数组的分配可能不需要额外的内存,因为进程已经在其页面中拥有所需的空间,否则,数组可能会诱使操作系统授予进程更多的页面。同样,“释放”数组不会(通常)回收内存。
通常,操作系统和处理器并不关心您的程序何时读取和写入分配给它的页面中的任何位置。这些页面是进程拥有的“段”。当您访问一个未分配给您的进程的禁止页面时,通常会出现分段错误。大多数时候,这意味着你的程序崩溃了。
有趣的是,如果我分配一个小数组,然后我越界读取,程序通常不会立即崩溃,甚至可能保持正确。因此,您可以分配一个包含两个整数空间的数组,从中读取三个整数,您的程序可能会“工作”。以下函数是错误的,但它可能会很好地工作多年……(然后它会神秘地崩溃)
整数f ( ) { 整数*数据=新整数[ 2 ] ; 数据[ 0 ] = 1 ; 数据[ 1 ] = 2 ; 整数x =数据[ 0 ] ; 整数y =数据[ 1 ] ; int z =数据[ 2 ] ; 删除[ ]数据; 返回x + y ; }
但是为什么你会想要读取超出分配内存的范围呢?为了性能和/或方便。
现代处理器具有设计用于“大型寄存器”的矢量指令。例如,最近的 Intel 处理器具有 512 位寄存器。这样的寄存器可以存储 16 个标准浮点值。以下代码将非常快速地计算两个向量之间的点积……(阅读代码中的注释以跟进)
float dot512fma(float *x1, float *x2, size_t 长度) { // 创建一个包含 16 个 32 位浮点数的向量(归零) __m512 总和= _mm512_setzero_ps ( ) ; 对于(尺寸_t 我= 0 ;我<长度;我+ = 16 ) { // 加载 16 个 32 位浮点数 __m512 v1 = _mm512_loadu_ps ( x1 + i ) ; // 加载 16 个 32 位浮点数 __m512 v2 = _mm512_loadu_ps ( x2 + i ) ; // 求和[0] += v1[i]*v2[i](融合乘加) 总和= _mm512_fmadd_ps ( v1 , v2 ,总和) ; } // reduce:对所有元素求和 返回_mm512_reduce_add_ps (总和) ; }
但是,此代码存在问题。如果长度不是 16 的倍数,那么我们可能会读取太多数据。这可能会或可能不会使程序崩溃,并且可能会给出错误的结果。
你能做什么?使用早期的处理器,您必须以特殊的方式处理它。
今天的解决方案是使用屏蔽加载,仅加载在宽寄存器中可以安全读取的数据。对于 AVX-512,它几乎没有任何开销,除了掩码本身的计算。您可以将其编码如下:
float dot512fma(float *x1, float *x2, size_t 长度) { // 创建一个包含 16 个 32 位浮点数的向量(归零) __m512 总和= _mm512_setzero_ps ( ) ; 尺寸_t i = 0 ; 对于( ; i + 16 < =长度; i + = 16 ) { // 加载 16 个 32 位浮点数 __m512 v1 = _mm512_loadu_ps ( x1 + i ) ; // 加载 16 个 32 位浮点数 __m512 v2 = _mm512_loadu_ps ( x2 + i ) ; // 求和[0] += v1[i]*v2[i](融合乘加) 总和= _mm512_fmadd_ps ( v1 , v2 ,总和) ; } 如果(我<长度) { // 加载 16 个 32 位浮点数,仅加载第一个长度为 i 的浮点数 // 其他浮点数自动设置为零 __m512 v1 = _mm512_maskz_loadu_ps ( ( 1 < < (长度- i ) ) - 1 , x1 + i ) ; // 加载 16 个 32 位浮点数,仅加载第一个长度为 i 的浮点数 __m512 v2 = _mm512_maskz_loadu_ps ( ( 1 < < (长度- i ) ) - 1 , x2 + i ) ; // 求和[0] += v1[i]*v2[i](融合乘加) 总和= _mm512_fmadd_ps ( v1 , v2 ,总和) ; } // reduce:对所有元素求和 返回_mm512_reduce_add_ps (总和) ; }
如您所见,我添加了一个使用计算掩码的最终分支。使用算术计算掩码。在这种情况下,我使用了一个丑陋的公式:((1<<(length-i))-1)。
也可以只使用一个循环,并为最终迭代更新掩码,但在我看来,这会使代码更难理解。
无论如何,使用掩码,我实现了高性能:我始终使用快速的 AVX-512 指令。这也很方便:我可以用相同的编码风格编写整个函数。
一个明智的问题是,这些被屏蔽的加载和存储对于分段错误是否真的安全。您可以通过重复写入和加载超出分配的内存来检查它,最终会导致分段错误。我写了一个小的 C++ 测试。以下代码总是在循环的最后一行崩溃,其中状态的值为“RIGHT_AFTER”。
uint8_t *数据=新uint8_t [ 1024 ] ; size_t ps = page_size ( ) ; // 向上取整到页面末尾: uintptr_t page_limit = ps - ( reinterpret_cast < uintptr_t > (数据) % ps ) - 1 ; __m128i 个= _mm_set1_epi8 ( 1 ) ; // 寄存器填充一个 for ( int z = 0 ; ; z + + ) { 状态= RIGHT_BEFORE ; 数据[ z * ps + page_limit ] = 1 ; 状态= AVX512_STORE ; _mm_mask_storeu_epi8 (数据+ z * ps + page_limit , 1 ,个) ; 状态= AVX512_LOAD ; __m128i oneandzeroes = _mm_maskz_loadu_epi8 ( 1 ,数据+ z * ps + page_limit ) ; 状态= RIGHT_AFTER ; 数据[ z * ps + page_limit + 1 ] = 1 ; }
那么ARM处理器呢?值得庆幸的是,您可以使用亚马逊的引力子处理器做很多相同的事情。点积可能如下所示:
浮动点(浮动* x1 ,浮动* x2 , int64_t长度) { int64_t 我= 0 ; svfloat32_t sum = svdup_n_f32 ( 0 ) ; 而( i + svcntw ( ) < =长度) { svfloat32_t in1 = svld1_f32 ( svptrue_b32 ( ) , x1 + i ) ; svfloat32_t in2 = svld1_f32 ( svptrue_b32 ( ) , x2 + i ) ; sum = svmad_f32_m ( svptrue_b32 ( ) , in1 , in2 , sum ) ; i + = svcntw ( ) ; } svbool_t while_mask = svwhilelt_b32 (我,长度) ; 做{ svfloat32_t in1 = svld1_f32 ( while_mask , x1 + i ) ; svfloat32_t in2 = svld1_f32 ( while_mask , x2 + i ) ; sum = svmad_f32_m ( svptrue_b32 ( ) , in1 , in2 , sum ) ; i + = svcntw ( ) ; while_mask = svwhilelt_b32 (我,长度) ; }而( svptest_any ( svptrue_b32 ( ) , while_mask ) ) ; 返回svaddv_f32 ( svptrue_b32 ( ) , sum ) ; }
这是相同的算法。一个区别是 SVE 对通用掩码有自己的内在功能。此外,虽然 AVX-512 允许您选择不同的寄存器大小,但 SVE 隐藏了寄存器大小,因此无论寄存器大小如何,您的二进制代码都应该运行。
我的代码可用:AVX-512 代码和 ARM/SVE2 代码(在单独的目录中)。您可能需要访问 AWS (Amazon) 才能运行 ARM/SVE2 代码。
原文: https://lemire.me/blog/2022/11/08/modern-vector-programming-with-masked-loads-and-stores/