从内存加载8个字符到一个__m256变量中,作为打包的单精度浮点数。

8

我正在优化一个用于对图像进行高斯模糊处理的算法,想要将下面代码中使用的float buffer[8]替换为__m256内置变量。哪一系列指令最适合完成这项任务?

// unsigned char *new_image is loaded with data
...
  float buffer[8];

  buffer[x ]      = new_image[x];       
  buffer[x + 1] = new_image[x + 1]; 
  buffer[x + 2] = new_image[x + 2]; 
  buffer[x + 3] = new_image[x + 3]; 
  buffer[x + 4] = new_image[x + 4]; 
  buffer[x + 5] = new_image[x + 5]; 
  buffer[x + 6] = new_image[x + 6]; 
  buffer[x + 7] = new_image[x + 7]; 
 // buffer is then used for further operations
...

//What I want instead in pseudocode:
 __m256 b = [float(new_image[x+7]), float(new_image[x+6]), ... , float(new_image[x])];

你有检查过优化汇编代码吗?你的编译器可能已经为你完成了这个任务。 - Ivan Aksamentov - Drop
类似于https://dev59.com/5Y7ea4cB1Zd3GeqPDJUz的问题。那个问题更广泛,询问特定的处理任务,但是我的答案涵盖了将打包到浮点数和返回(对于SSE,而不是AVX)。如果可以使用16位定点建议,则此处也适用。 - Peter Cordes
1个回答

13
如果您正在使用AVX2,您可以使用PMOVZX将字符零扩展为256b寄存器中的32位整数。从那里,可以就地转换为浮点数。
; rsi = new_image
VPMOVZXBD   ymm0,  [rsi]   ; or SX to sign-extend  (Byte to DWord)
VCVTDQ2PS   ymm0, ymm0     ; convert to packed foat

这是一个好的策略,即使您想为多个向量执行此操作,但更好的可能是使用128位广播加载来提供vpmovzxbd ymm,xmmvpshufb ymm (_mm256_shuffle_epi8) 用于高64位,因为Intel SnB系列CPU不会微聚合vpmovzx ymm,mem,只有vpmovzx xmm,mem。(https://agner.org/optimize/)。广播加载是单个uop,不需要ALU端口,在负载端口中纯运行。因此,这是3个总uops以进行广播加载+ vpmovzx + vpshufb。
(TODO:编写该函数的内部版本。它还避免了对_mm_loadl_epi64-> _mm256_cvtepu8_epi32的未优化问题。)
当然,这需要在另一个寄存器中具有洗牌控制向量,因此仅在可以多次使用时才值得这样做。

vpshufb是可用的,因为从广播中获取了每个lane所需的数据,并且洗牌控制的高位将使相应元素归零。

这种广播+洗牌策略在Ryzen上可能很好;Agner Fog没有列出vpmovsx/zx ymm在其上的uop计数。


请不要进行类似128位或256位的加载,然后对其进行调整以供进一步的vpmovzx指令使用。由于vpmovzx是一个调整操作,所以总调整吞吐量可能已经成为瓶颈。英特尔Haswell/Skylake(最常见的AVX2架构)具有每时钟周期1个调整操作,但具有每时钟周期2个加载操作。使用额外的调整指令而不是将单独的内存操作折叠到vpmovzxbd中是很糟糕的。只有当您能够像我建议的广播-加载+ vpmovzxbd + vpshufb一样减少总uop计数时才能获胜。

我在Scaling byte pixel values (y=ax+b) with SSE2 (as floats)?上的回答可能与将其转换回uint8_t有关。如果使用AVX2 packssdw/packuswb进行打包,则之后的打包回字节部分有些棘手,因为它们是在通道内工作的,不像vpmovzx


仅使用AVX1而非AVX2,您应该执行以下操作:

VPMOVZXBD   xmm0,  [rsi]
VPMOVZXBD   xmm1,  [rsi+4]
VINSERTF128 ymm0, ymm0, xmm1, 1   ; put the 2nd load of data into the high128 of ymm0
VCVTDQ2PS   ymm0, ymm0     ; convert to packed float.  Yes, works without AVX2

当然,你永远不需要一个浮点数数组,只需要使用__m256向量。

GCC / MSVC错过了使用内部函数VPMOVZXBD ymm,[mem]的优化

GCC和MSVC无法将_mm_loadl_epi64折叠成vpmovzx*的内存操作数。 (但至少有正确宽度的加载内部函数,与pmovzxbq xmm, word [mem]不同。)

我们得到一个vmovq加载,然后是一个带有XMM输入的单独的vpmovzx。(使用ICC和clang3.6+,我们从_mm_loadl_epi64获得安全且最佳的代码,例如来自gcc9+)

但是gcc8.3及更早版本可以将_mm_loadu_si128 16字节加载内部函数折叠成8字节内存操作数。这在GCC的-O3下给出了最佳的汇编代码,但在-O0下是不安全的,因为它会编译成实际的vmovdqu加载,触及比我们实际加载更多的数据,并可能超出页面的末尾。

因为这个答案,提交了两个gcc的bug:


没有使用SSE4.1 pmovsx / pmovzx 作为加载的内在需求,只有使用__m128i源操作数。但是汇编指令只读取它们实际使用的数据量,而不是一个16字节的__m128i内存源操作数。与punpck*不同,即使在非对齐地址下也可以在页面的最后8B上使用它,而不会出现故障。(即使是非AVX版本,在非对齐地址上也可以使用)。
所以这是我想到的恶劣解决方案。不要使用这个,#ifdef __OPTIMIZE__是不好的,可能会创建只在调试构建中或只在优化构建中发生的错误!
#if !defined(__OPTIMIZE__)
// Making your code compile differently with/without optimization is a TERRIBLE idea
// great way to create Heisenbugs that disappear when you try to debug them.
// Even if you *plan* to always use -Og for debugging, instead of -O0, this is still evil
#define USE_MOVQ
#endif

__m256 load_bytes_to_m256(uint8_t *p)
{
#ifdef  USE_MOVQ  // compiles to an actual movq then movzx ymm, xmm with gcc8.3 -O3
    __m128i small_load = _mm_loadl_epi64( (const __m128i*)p);
#else  // USE_LOADU // compiles to a 128b load with gcc -O0, potentially segfaulting
    __m128i small_load = _mm_loadu_si128( (const __m128i*)p );
#endif

    __m256i intvec = _mm256_cvtepu8_epi32( small_load );
    //__m256i intvec = _mm256_cvtepu8_epi32( *(__m128i*)p );  // compiles to an aligned load with -O0
    return _mm256_cvtepi32_ps(intvec);
}

启用USE_MOVQ后,gcc -O3 (v5.3.0) 发出这个指令。(MSVC也是如此)
load_bytes_to_m256(unsigned char*):
        vmovq   xmm0, QWORD PTR [rdi]
        vpmovzxbd       ymm0, xmm0
        vcvtdq2ps       ymm0, ymm0
        ret

我们希望避免使用愚蠢的 vmovq。如果您让它使用不安全的 loadu_si128 版本,则会生成良好优化的代码。

GCC9、clang和ICC都会发出:

load_bytes_to_m256(unsigned char*): 
        vpmovzxbd       ymm0, qword ptr [rdi] # ymm0 = mem[0],zero,zero,zero,mem[1],zero,zero,zero,mem[2],zero,zero,zero,mem[3],zero,zero,zero,mem[4],zero,zero,zero,mem[5],zero,zero,zero,mem[6],zero,zero,zero,mem[7],zero,zero,zero
        vcvtdq2ps       ymm0, ymm0
        ret

需要使用内部函数编写仅支持AVX1的版本,这是一个无趣的读者练习。您要求“指令”,而不是“内部函数”,这是内部函数存在差距的地方之一。必须使用_mm_cvtsi64_si128来避免潜在的越界加载是愚蠢的,在我看来。我希望能够根据它们映射到的指令来考虑内部函数,将加载/存储内部函数作为告知编译器对齐保证或缺乏对齐保证的方式。必须使用我不想要的内部函数是相当愚蠢的。


请注意,如果您在查看英特尔指令参考手册,则会发现movq有两个单独的条目。
  • movd/movq是指可以将整数寄存器作为源/目标操作数的版本(66 REX.W 0F 6E (或 VEX.128.66.0F.W1 6E)用于(V)MOVQ xmm,r/m64)。这里你会找到可以接受64位整数的内部函数,_mm_cvtsi64_si128。(一些编译器在32位模式下未定义它。)

  • movq是指可以有两个xmm寄存器作为操作数的版本。这是MMXreg-> MMXreg指令的扩展,也可以像MOVDQU一样进行加载/存储。其操作码为F3 0F 7EVEX.128.F3.0F.WIG 7E)用于MOVQ xmm, xmm/m64)

    汇编ISA参考手册仅列出了m128i _mm_mov_epi64(__m128i a)内部函数用于清零向量的高64位并复制它。但是内部函数指南确实列出了_mm_loadl_epi64(__m128i const* mem_addr),它有一个愚蠢的原型(指向16字节__m128i类型的指针,实际上只加载8字节)。它在所有4个主要的x86编译器上都可用,而且应该是安全的。请注意,__m128i*只是传递给这个不透明的内部函数,实际上并没有被引用。

    更合理的_mm_loadu_si64 (void const* mem_addr)也被列出,但gcc缺少这个内部函数。


根据您在VS2015中引用的评论,我尝试使用基于AVX2的代码,并使用__m256i m = _mm256_cvtepu8_epi32 ((__m128i)(new_image + x));。但是编译失败了。查看intel intrinsics页面,该指令需要特定的__m128i。有理由相信我尝试的这种类型的转换会起作用吗?我注意到VPMOVZXBD确实需要一个内存操作数,因此一个intrinsic不应该出现这种情况。 - pseudomarvin
1
@pseudomarvin:注意在转换之前的 * 解引用运算符。你错过了这个,所以你传递给 _mm256_cvtepu8_epi32 的是一个指针,而不是一个 __m128i。此外,我建议使用 _mm_loadu_si128,这样你的代码在使用 -O0 编译时更不容易崩溃。如果你想要的代码仍然绝对不会访问数组边界之外,即使使用 -O0,你必须使用 _mm_cvtsi64_si128,但像我的 gcc bug 报告一样,那么加载将无法折叠成 pmovzxbd 的内存操作数。你是正确的,这是一种奇怪和糟糕的内部设计。 - Peter Cordes

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