生成矢量常数的最佳指令序列是什么?

32
"Best"的意思是指最少的指令(或者最少的微操作,如果某些指令解码为多个微操作)。如果指令数量相等,则以机器代码大小为决胜者。
常量生成本质上是一个全新的依赖链的开始,因此延迟通常并不重要。在循环内部生成常量也很少见,因此吞吐量和执行端口需求也大多不相关。
生成常量而不是加载它们需要更多的指令(除了全零或全一),因此它会消耗宝贵的微操作缓存空间。这可能比数据缓存还要受限制。
Agner Fog的优秀汇编优化指南第13.8节中涵盖了这个问题。表格13.9列出了生成向量的序列,其中每个元素都是01234-1-2,元素大小从8到64位不等。表格13.11列出了生成一些浮点数值(0.00.51.01.52.0-2.0)的序列,以及符号位的位掩码。

Agner Fog的序列只使用SSE2,可能是由于设计原因或者因为它已经有一段时间没有更新了。

有哪些短小而不明显的指令序列可以生成其他常数?(使用不同的移位计数进行进一步扩展是显然且不“有趣”的。)是否有更好的序列可用于生成Agner Fog所列出的常数?

如何将128位立即数移动到XMM寄存器演示了将任意128位常数放入指令流的一些方法,但通常这并不明智(它不会节省任何空间,并占用大量uop缓存空间)。


5
我喜欢这些类型的问题。继续提问吧! - Z boson
@PeterCordes,Agner的常数在13.8,而不是13.4。此外,它是表13.9,而不是13.10。 - Noah
1
@Noah:随着他修订文档,数字会发生变化;请随意编辑以匹配当前版本。 - Peter Cordes
1个回答

29

全零: pxor xmm0,xmm0 (或者 xorps xmm0,xmm0, 只是指令字节更短一点). 在现代CPU上没有太大区别, 但在 Nehalem 上(在 xor-zero 消除之前), xorps uop 只能在端口5上运行. 我认为这就是为什么编译器喜欢使用 pxor-zeroing 即使对于将与 FP 指令一起使用的寄存器也是如此.

全一: pcmpeqw xmm0,xmm0. 这是生成其他常量的常用起始点,因为它(像 pxor 一样)打破了对寄存器先前值的依赖(除了旧的 CPU 如 K10 和 pre-Core2 P6).

在 Agner Fog 的指令表中,W 版本与 pcmpeq 的字节或双字元素大小版本在任何 CPU 上都没有优势,但 pcmpeqQ 需要额外一个字节,在 Silvermont 上速度较慢并且需要 SSE4.1。

所以实际上并没有表格格式,所以我只会列出 Agner Fog 的表 13.10 的补充,而不是改进版本。 抱歉。也许如果这个答案变得流行起来,我会使用 ASCII 艺术表格生成器,但希望将来的指南版本会有所改进。


主要困难在于8位向量,因为没有 PSLLB

Agner Fog 的表生成了16位元素的向量,并使用 packuswb 来解决这个问题。例如,pcmpeqw xmm0,xmm0 / psrlw xmm0,15 / psllw xmm0,1 / packuswb xmm0,xmm0 生成一个每个字节都是 2 的向量。(这种移位模式,具有不同的计数,是生成更宽向量的大多数常量的主要方法)。有一种更好的方法:

paddb xmm0,xmm0 (SSE2) 可以作为带有字节粒度的左移一位,因此只需要两个指令(pcmpeqw / paddb)就可以生成一个向量为 -2 字节. 对于其他元素大小,paddw/d/q 作为左移一位比移位指令节省一个字节的机器代码,并且通常可以在更多的端口上运行。

pabsb xmm0,xmm0(SSSE3)将一个全为1的向量(-1)转换为一个1字节的向量,且不会破坏原有的set1(-1)向量。

有时候您并不需要set1(1)。您可以通过使用psubb减去-1来将每个元素加1。

我们可以通过pcmpeqw/paddb/pabsb生成2个字节的向量。(加法和绝对值操作的顺序并不重要)。pabs不需要imm8,但是只有在源寄存器为xmm8-15时才能为其他元素宽度节省代码字节而不是右移。 (vpabsb/w/d始终需要一个3字节的VEX前缀以进行VEX.128.66.0F38.WIG,但是vpsrlw dest,src,imm可以使用其VEX.NDD.128.66.0F.WIG的2字节VEX前缀)。

实际上,我们还可以通过pcmpeqw/pabsb/psllw xmm0, 2生成42的幂次方字节,所有通过字移位跨越字节边界的位都是零,这要归功于pabsb。显然,其他移位计数可以将单个设置位放在其他位置,包括符号位以生成一个-128(0x80)字节向量。请注意,pabsb是非破坏性的(目标操作数仅写入,不需要与源相同即可获得所需行为)。您可以将全1保留为常量,或者作为生成另一个常量的起点,或者作为psubb的源操作数(加1)。

也可以使用packsswb从任何饱和到-128的值生成一个0x80字节向量(参见上一段)。例如,如果您已经有一个用于其他用途的0xFF00向量,请复制它并使用packsswb。从内存加载的常量如果恰好饱和,则可能成为此类操作的潜在目标。

使用pcmpeqw / paddb xmm0,xmm0 / psrlw xmm0, 1可以生成一个包含0x7f字节的向量。这比通常使用pcmpeqw / psrlw xmm0, 9 / packuswb xmm0,xmm0生成每个字中值的技巧稍微更好。但是,在大多数CPU上,PADDB可以在比PACK更多的端口上运行。
使用pavgb(SSE2)与零寄存器相反,可以右移一位,但只有该值为偶数时才可以。(它对临时变量进行无符号的dst = (dst + src + 1) >> 1以进行四舍五入,并具有9位内部精度)。但似乎这对于常量生成不是很有用,因为0xff是奇数:pxor xmm1,xmm1 / pcmpeqw xmm0,xmm0 / paddb xmm0,xmm0 / pavgb xmm0, xmm1 生成的指令比移位和打包使用了一个更多的指令字节,但会产生相同的结果。但是,如果零寄存器已经被用于其他目的,则paddb / pavgb可以节省一个指令字节。
我已经测试了这些序列。最简单的方法是将它们放入.asm文件中,进行汇编链接,然后在gdb上运行它。layout asmdisplay /x $xmm0.v16_int8命令可在每个单步后转储其结果,并使用nisi命令进行单步执行。在layout reg模式下,您可以使用tui reg vec命令切换到向量寄存器显示模式,但这几乎是无用的,因为无法选择要显示的解释(总是会得到所有的解释,不能水平滚动,而且列之间不对齐)。 但是,它对于整数寄存器/标志非常有用。
请注意,使用这些操作指令可能会很棘手。编译器不喜欢操作未初始化的变量,因此应使用_mm_undefined_si128()命令告诉编译器您的意思是什么。也许使用_mm_set1_epi32(-1)命令可以使编译器发出pcmpeqd same,same指令。如果没有这个命令,一些编译器将在使用之前对未初始化的向量变量进行异或零操作,甚至(MSVC)从堆栈中加载未初始化的内存。
许多常量可以通过利用SSE4.1的pmovzxpmovsx在内存中更紧凑地存储,以进行零扩展或符号扩展。例如,一个由32位元素组成的128b向量{1, 2, 3, 4}可以通过从32位内存位置进行pmovzx加载生成。内存操作数可以与pmovzx微融合,因此不需要任何额外的融合域uops。但是,它会阻止直接将常量用作内存操作数。
C/C++中使用pmovz/sx作为加载的内部支持非常糟糕:有_mm_cvtepu8_epi32 (__m128i a),但没有接受uint32_t *指针操作数的版本。您可以绕过它,但这很丑陋,并且编译器优化失败是一个问题。请参见链接的问题以获取详细信息和到gcc错误报告的链接。
对于256b和(不久的)512b常量,内存中的节省更大。但是,只有当多个有用的常量可以共享高速缓存行时,这才非常重要。
FP的等效物是VCVTPH2PS xmm1, xmm2/m64,需要F16C(半精度)特征标志。 (还有一个存储指令,将单个打包到一半,但没有在半精度上进行计算。这只是内存带宽/缓存占用优化。)
当所有元素都相同时(但不适合即时生成),pshufd或AVX vbroadcastps / AVX2 vpbroadcastb/w/d/q/i128非常有用。 pshufd可以采用内存源操作数,但必须为128b。 movddup(SSE3)执行64位加载,广播以填充128b寄存器。在Intel上,它不需要ALU执行单元,只需要加载端口。 (类似地,AVX v[p]broadcast加载大小为dword及更大的数据也在加载单元中处理,而无需ALU)。
当您要将掩码加载到寄存器中以在循环中重复使用时,广播或pmovz/sx非常适用于节省可执行文件大小。从一个起点生成多个类似的掩码也可以节省空间,如果只需要一个指令。
参见 For for an SSE vector that has all the same components, generate on the fly or precompute?,该问题更多地询问使用set1内部函数的情况,不清楚它是否涉及常量或变量的广播。

我还对广播的编译器输出进行了一些实验。


如果缓存未命中是一个问题,请查看您的代码,并查看编译器在将相同的函数内联到不同的调用者时是否已复制了_mm_set常量。同时要注意,一起使用的常量(例如,在一个接一个调用的函数中)被分散到不同的缓存行中。许多分散的常量加载比从靠近彼此的位置加载很多常量要糟糕得多。

pmovzx和/或广播加载允许您将更多的常量打包到缓存行中,非常低的开销将它们加载到寄存器中。加载不会成为关键路径上的操作,因此即使需要额外的微操作,它也可以在长时间窗口内的任何周期中占用一个免费的执行单元。

clang实际上做得很好:不同函数中的set1常量被识别为相同,就像相同的字符串字面量可以合并一样。请注意,clang的汇编源代码输出似乎显示每个函数都有其自己的常量副本,但二进制反汇编显示所有那些RIP相对有效地址都引用同一个位置。对于重复函数的256b版本,clang还使用vbroadcastsd只需要一个8B加载,代价是每个函数多了一条指令。(这是在-O3下进行的,因此显然clang开发人员已经意识到大小对性能很重要,而不仅仅是对-Os.)我不知道为什么它不会用vbroadcastss将常量降至4B,因为速度应该一样快。不幸的是,vbroadcast并不简单地来自其他函数使用的16B常量的一部分。这也许是有道理的:某个东西的AVX版本可能只能将某些常量与SSE版本合并。最好让带着SSE常量的内存页面完全不活跃,并让AVX版本将所有常量保持在一起。此外,这是一个更难的模式匹配问题,需要在汇编或链接时间处理(无论是怎么做的,我都没有阅读每个指令来找出哪个指令启用了合并)。

GCC 5.3同样合并常量,但不使用广播加载来压缩32B常量。同时16B常量不会与32B常量重叠。


GCC12开始倾向于在AVX可用时动态构建某些矢量常量,但始终使用mov reg,imm64 / vmovq / shuffle。即使模式简单且重复,它也将使用笨重的64位立即数和vpunpcklqdq而不是5字节的mov eax,imm32和字广播。与此反过来的是,对于一个微不足道的set1_epi16(0x00ff),它没有进行动态构建(pcmpeqd / psrlw xmm,8)。https://godbolt.org/z/78cMaxjMz

有了AVX-512,mov r,imm / vpbroadcastd x/y/zmm, eax 只需2条指令。(在英特尔处理器中,vpbroadcastd ymm0,eax 为1个微操作码,在Zen 4上为2个微操作码)。这使得用这种方式构建向量更具吸引力,对于编译器来说更容易实现。


在StackExchange上的表格:https://meta.stackexchange.com/questions/5255/please-add-support-for-tables-in-answers-and-questions - Johannes Schaub - litb
psignb 能否替代 pabsb - phuclv
@phuclv:它们都是SSSE3,所以我看不出有什么优势。而且psignb xmm0,xmm0只能原地操作,但pabsb可以复制并进行绝对值运算而不破坏全1。但是,是的,它可以使用。-(-1)产生+1。 - Peter Cordes

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