动态生成向量常量的最佳指令序列是什么?
What are the best instruction sequences to generate vector constants on the fly?
"Best" 表示最少的指令(或最少的 uops,如果任何指令解码为一个以上的 uop)。以字节为单位的机器代码大小是相等 insn 计数的决胜局。
从本质上讲,持续生成是新依赖链的开始,因此延迟很重要是不常见的。在循环内生成常量也是不常见的,因此吞吐量和执行端口需求也大多无关紧要。
生成常量而不是加载常量需要更多指令(全零或全一除外),因此它确实会消耗宝贵的 uop 缓存 space。这可能是比数据缓存更有限的资源。
Agner Fog 的出色 Optimizing Assembly guide 在 Section 13.4
中介绍了这一点。 Table 13.10 具有用于生成向量的序列,其中每个元素都是 0
、1
、2
、3
、4
、-1
,或 -2
,元素大小从 8 到 64 位。 Table 13.11 具有用于生成一些浮点值的序列(0.0
、0.5
、1.0
、1.5
、2.0
、-2.0
, 和符号位的位掩码。)
Agner Fog 的序列仅使用 SSE2,这可能是设计使然,也可能是因为它有一段时间没有更新了。
可以用短的非显而易见的指令序列生成哪些其他常量?(具有不同移位计数的进一步扩展是显而易见的,而不是 "interesting"。)是否有生成 Agner Fog 列出的常数的更好序列?
How to move 128-bit immediates to XMM registers 说明了一些将任意 128b 常量放入指令流的方法,但这通常是不明智的(它不保存任何 space,并且占用大量 uop-cache space.)
全零:pxor xmm0,xmm0
(或 xorps xmm0,xmm0
,短一个指令字节。)在现代 CPUs 上没有太大区别,但在 Nehalem 上(在 xor 之前-零消除),xorps uop 只能在端口 5 上 运行。我认为这就是为什么编译器喜欢 pxor
- 归零,即使对于将与 FP 指令一起使用的寄存器也是如此。
全一:pcmpeqw xmm0,xmm0
。这是生成其他常量的通常起点,因为(如 pxor
)它打破了对寄存器先前值的依赖(旧的 CPUs 除外,如 K10 和 pre-Core2 P6)。
在 Agner Fog 的指令 table 中的任何 CPU 上,W
版本与 pcmpeq
的字节或双字元素大小版本没有任何优势,但是 pcmpeqQ
需要一个额外的字节,在 Silvermont 上速度较慢,并且需要 SSE4.1。
SO doesn't really have table formatting,所以我只列出 Agner Fog 的 table 13.10 的新增内容,而不是改进版本。对不起。也许如果这个答案变得流行,我将使用 ascii-art table-generator,但希望改进将融入指南的未来版本。
主要难点是8位向量,因为
Agner Fog 的 table 生成 16 位元素的向量并使用 packuswb
来解决这个问题。例如,pcmpeqw xmm0,xmm0
/ psrlw xmm0,15
/ psllw xmm0,1
/ packuswb xmm0,xmm0
生成一个向量,其中每个字节都是 2
。 (这种具有不同计数的移位模式是为更宽的向量生成大多数常数的主要方式)。还有更好的方法:
paddb xmm0,xmm0
(SSE2) 以字节粒度左移一位,因此 向量的 -2
字节 只能生成两条指令 (pcmpeqw
/ paddb
)。 paddw/d/q
作为其他元素大小的左移一比移位节省了一个字节的机器代码,并且通常可以 运行 在比 shift-imm 更多的端口上。
pabsb xmm0,xmm0
(SSSE3) 将全一向量 (-1
) 转换为 1
字节的向量,并且是非破坏性,所以你仍然有 set1(-1)
向量。
(有时您不需要 set1(1)
。您可以通过用 psubb
减去 -1
来为每个元素加 1。)
我们可以用 pcmpeqw
/ paddb
/ pabsb
生成 2
字节 。 (add 与 abs 的顺序无关紧要)。 pabs
不需要 imm8,但只为其他元素宽度保存代码字节,而当两者都需要 3 字节 VEX 前缀时右移。这仅在源寄存器为 xmm8-15 时发生。 (vpabsb/w/d
总是需要一个 3 字节的 VEX 前缀用于 VEX.128.66.0F38.WIG
,但是 vpsrlw dest,src,imm
可以为它的 VEX.NDD.128.66.0F.WIG
使用一个 2 字节的 VEX 前缀。
我们实际上也可以在生成 4
字节 时节省指令:pcmpeqw
/ pabsb
/ psllw xmm0, 2
。由于pabsb
,通过字移位跨越字节边界的所有位都是零。显然其他移位计数可以将单个设置位放在其他位置,包括符号位以生成 -128 (0x80) 字节 的向量。请注意 pabsb
是非破坏性的(目标操作数是只写的,不需要与源操作数相同即可获得所需的行为)。您可以将全一保留为常量,或作为生成另一个常量的开始,或作为 psubb
的源操作数(递增 1)。
0x80
字节的 向量 也可以(见上一段)从饱和到 -128 的任何东西生成,使用 packsswb
。例如如果你已经有一个 0xFF00
的矢量用于其他东西,只需复制它并使用 packsswb
。从内存加载的恰好正确饱和的常量是此的潜在目标。
可以使用 pcmpeqw
/ psrlw xmm0, 9
/ packuswb xmm0,xmm0
生成 0x7f
字节 的 向量。我把它算作 "non obvious" 因为大多数设置的性质并没有让我想到只是将它生成为每个单词中的值并执行通常的 packuswb
.
pavgb
(SSE2) 针对清零的寄存器可以右移一位,但前提是值是偶数。 (它对 unsigned dst = (dst+src+1)>>1
进行舍入,对临时值使用 9 位内部精度。)不过,这似乎对常量生成没有用,因为 0xff 是奇数:pxor xmm1,xmm1
/ pcmpeqw xmm0,xmm0
/ paddb xmm0,xmm0
/ pavgb xmm0, xmm1
产生 0x7f
字节 比 shift/pack 多一个 insn。但是,如果其他事情已经需要清零寄存器,paddb
/ pavgb
确实会节省一个指令字节。
我已经测试了这些序列。最简单的方法就是把它们丢在一个.asm
、assemble/link和运行 gdb中就可以了。 layout asm
、display /x $xmm0.v16_int8
在每个单步和单步指令(ni
或 si
)之后转储。在 layout reg
模式下,您可以 tui reg vec
切换到向量 regs 的显示,但这几乎没用,因为您无法 select 显示哪种解释(您总是得到所有这些,并且不能滚动,并且列在寄存器之间不对齐)。不过,它非常适合整数 regs/flags。
请注意,将这些与内在函数一起使用可能会很棘手。编译器不喜欢对未初始化的变量进行操作,因此您应该使用 _mm_undefined_si128()
来告诉编译器您的意思。或者也许使用 _mm_set1_epi32(-1)
会让你的编译器发出一个 pcmpeqd same,same
。如果没有这个,一些编译器会在使用前对未初始化的向量变量进行异或零操作,甚至 (MSVC) 从堆栈加载未初始化的内存。
通过利用 SSE4.1 的 pmovzx
或 pmovsx
动态零或符号扩展,许多常量可以更紧凑地存储在内存中。例如,{1, 2, 3, 4}
作为 32 位元素的 128b 向量可以通过从 32 位内存位置加载 pmovzx
来生成。内存操作数可以与 pmovzx
微融合,因此它不需要任何额外的融合域微指令。不过,它确实阻止了直接将常量用作内存操作数。
C/C++ :有 _mm_cvtepu8_epi32 (__m128i a)
,但没有采用 uint32_t *
指针操作数的版本。你可以破解它,但它很丑陋,编译器优化失败是一个问题。有关详细信息和 linkgcc 错误报告的 linked 问题。
使用 256b 和(不久之后)512b 常量,内存节省更大。不过,只有当多个有用的常量可以共享一个缓存行时,这才非常重要。
它的 FP 等价物是 VCVTPH2PS xmm1, xmm2/m64
,需要 F16C(半精度)特征标志。 (还有一条存储指令将单个打包成一半,但没有半精度计算。这只是内存带宽/缓存占用空间优化。)
显然,当所有元素都相同时(但不是用于动态生成的 suitable),pshufd
或 AVX vbroadcastps
/ AVX2 vpbroadcastb/w/d/q/i128
很有用。 pshufd
可以取内存源操作数,但必须是128b。 movddup
(SSE3) 执行 64 位加载,广播以填充 128b 寄存器。在 Intel 上,它不需要 ALU 执行单元,只需要加载端口。 (类似地,AVX v[p]broadcast
双字大小和更大的负载在加载单元中处理,没有 ALU)。
广播或pmovz/sx
非常适合在您要将掩码加载到寄存器中以在环形。从一个起点生成多个相似掩码,如果只需要一条指令,也可以节省space。
另见 ,它询问了更多关于使用 set1
内在函数的信息,目前尚不清楚它询问的是常量还是变量广播。
我也用 compiler output for broadcasts 做了一些实验。
如果缓存未命中是一个问题,请查看您的代码,看看当同一函数内联到不同的调用方时编译器是否重复了 _mm_set
常量.还要注意一起使用的常量(例如在一个接一个调用的函数中)分散到不同的缓存行中。许多常量的分散加载比从彼此靠近的地方加载大量常量要糟糕得多。
pmovzx
and/or 广播加载让您可以将更多常量打包到缓存行中,将它们加载到寄存器中的开销非常低。负载不会在关键路径上,因此即使它需要一个额外的 uop,它也可以在很长的 window.
的任何周期内占用一个空闲执行单元
clang actually does a good job of this: 分开的 set1
不同函数中的常量被认为是相同的,相同的字符串文字可以合并的方式。请注意,clang 的 asm 源输出似乎显示每个函数都有自己的常量副本,但二进制反汇编显示所有这些 RIP 相对有效地址都引用相同的位置。对于重复函数的 256b 版本,clang 还使用 vbroadcastsd
来只需要 8B 加载,但每个函数都需要额外的指令。 (这是在 -O3
,很明显 clang 开发人员已经意识到大小对性能很重要,而不仅仅是 -Os
)。 IDK 为什么它不使用 vbroadcastss
下降到 4B 常量,因为那应该一样快。不幸的是,vbroadcast 不仅仅来自其他函数使用的 16B 常量的一部分。这可能是有道理的:某物的 AVX 版本可能只能将其某些常量与 SSE 版本合并。最好让带有 SSE 常量的内存页完全保持冷,让 AVX 版本将所有常量放在一起。此外,在 assemble 或 link 时间处理它是一个更难的模式匹配问题(但是它已经完成了。我没有阅读每条指令来弄清楚哪个指令允许合并。)
gcc 5.3 也合并常量,但不使用 broadcast-loads 来压缩 32B 常量。同样,16B 常量不与 32B 常量重叠。
"Best" 表示最少的指令(或最少的 uops,如果任何指令解码为一个以上的 uop)。以字节为单位的机器代码大小是相等 insn 计数的决胜局。
从本质上讲,持续生成是新依赖链的开始,因此延迟很重要是不常见的。在循环内生成常量也是不常见的,因此吞吐量和执行端口需求也大多无关紧要。
生成常量而不是加载常量需要更多指令(全零或全一除外),因此它确实会消耗宝贵的 uop 缓存 space。这可能是比数据缓存更有限的资源。
Agner Fog 的出色 Optimizing Assembly guide 在 Section 13.4
中介绍了这一点。 Table 13.10 具有用于生成向量的序列,其中每个元素都是 0
、1
、2
、3
、4
、-1
,或 -2
,元素大小从 8 到 64 位。 Table 13.11 具有用于生成一些浮点值的序列(0.0
、0.5
、1.0
、1.5
、2.0
、-2.0
, 和符号位的位掩码。)
Agner Fog 的序列仅使用 SSE2,这可能是设计使然,也可能是因为它有一段时间没有更新了。
可以用短的非显而易见的指令序列生成哪些其他常量?(具有不同移位计数的进一步扩展是显而易见的,而不是 "interesting"。)是否有生成 Agner Fog 列出的常数的更好序列?
How to move 128-bit immediates to XMM registers 说明了一些将任意 128b 常量放入指令流的方法,但这通常是不明智的(它不保存任何 space,并且占用大量 uop-cache space.)
全零:pxor xmm0,xmm0
(或 xorps xmm0,xmm0
,短一个指令字节。)在现代 CPUs 上没有太大区别,但在 Nehalem 上(在 xor 之前-零消除),xorps uop 只能在端口 5 上 运行。我认为这就是为什么编译器喜欢 pxor
- 归零,即使对于将与 FP 指令一起使用的寄存器也是如此。
全一:pcmpeqw xmm0,xmm0
。这是生成其他常量的通常起点,因为(如 pxor
)它打破了对寄存器先前值的依赖(旧的 CPUs 除外,如 K10 和 pre-Core2 P6)。
在 Agner Fog 的指令 table 中的任何 CPU 上,W
版本与 pcmpeq
的字节或双字元素大小版本没有任何优势,但是 pcmpeqQ
需要一个额外的字节,在 Silvermont 上速度较慢,并且需要 SSE4.1。
SO doesn't really have table formatting,所以我只列出 Agner Fog 的 table 13.10 的新增内容,而不是改进版本。对不起。也许如果这个答案变得流行,我将使用 ascii-art table-generator,但希望改进将融入指南的未来版本。
主要难点是8位向量,因为
Agner Fog 的 table 生成 16 位元素的向量并使用 packuswb
来解决这个问题。例如,pcmpeqw xmm0,xmm0
/ psrlw xmm0,15
/ psllw xmm0,1
/ packuswb xmm0,xmm0
生成一个向量,其中每个字节都是 2
。 (这种具有不同计数的移位模式是为更宽的向量生成大多数常数的主要方式)。还有更好的方法:
paddb xmm0,xmm0
(SSE2) 以字节粒度左移一位,因此 向量的 -2
字节 只能生成两条指令 (pcmpeqw
/ paddb
)。 paddw/d/q
作为其他元素大小的左移一比移位节省了一个字节的机器代码,并且通常可以 运行 在比 shift-imm 更多的端口上。
pabsb xmm0,xmm0
(SSSE3) 将全一向量 (-1
) 转换为 1
字节的向量,并且是非破坏性,所以你仍然有 set1(-1)
向量。
(有时您不需要 set1(1)
。您可以通过用 psubb
减去 -1
来为每个元素加 1。)
我们可以用 pcmpeqw
/ paddb
/ pabsb
生成 2
字节 。 (add 与 abs 的顺序无关紧要)。 pabs
不需要 imm8,但只为其他元素宽度保存代码字节,而当两者都需要 3 字节 VEX 前缀时右移。这仅在源寄存器为 xmm8-15 时发生。 (vpabsb/w/d
总是需要一个 3 字节的 VEX 前缀用于 VEX.128.66.0F38.WIG
,但是 vpsrlw dest,src,imm
可以为它的 VEX.NDD.128.66.0F.WIG
使用一个 2 字节的 VEX 前缀。
我们实际上也可以在生成 4
字节 时节省指令:pcmpeqw
/ pabsb
/ psllw xmm0, 2
。由于pabsb
,通过字移位跨越字节边界的所有位都是零。显然其他移位计数可以将单个设置位放在其他位置,包括符号位以生成 -128 (0x80) 字节 的向量。请注意 pabsb
是非破坏性的(目标操作数是只写的,不需要与源操作数相同即可获得所需的行为)。您可以将全一保留为常量,或作为生成另一个常量的开始,或作为 psubb
的源操作数(递增 1)。
0x80
字节的 向量 也可以(见上一段)从饱和到 -128 的任何东西生成,使用 packsswb
。例如如果你已经有一个 0xFF00
的矢量用于其他东西,只需复制它并使用 packsswb
。从内存加载的恰好正确饱和的常量是此的潜在目标。
可以使用 pcmpeqw
/ psrlw xmm0, 9
/ packuswb xmm0,xmm0
生成 0x7f
字节 的 向量。我把它算作 "non obvious" 因为大多数设置的性质并没有让我想到只是将它生成为每个单词中的值并执行通常的 packuswb
.
pavgb
(SSE2) 针对清零的寄存器可以右移一位,但前提是值是偶数。 (它对 unsigned dst = (dst+src+1)>>1
进行舍入,对临时值使用 9 位内部精度。)不过,这似乎对常量生成没有用,因为 0xff 是奇数:pxor xmm1,xmm1
/ pcmpeqw xmm0,xmm0
/ paddb xmm0,xmm0
/ pavgb xmm0, xmm1
产生 0x7f
字节 比 shift/pack 多一个 insn。但是,如果其他事情已经需要清零寄存器,paddb
/ pavgb
确实会节省一个指令字节。
我已经测试了这些序列。最简单的方法就是把它们丢在一个.asm
、assemble/link和运行 gdb中就可以了。 layout asm
、display /x $xmm0.v16_int8
在每个单步和单步指令(ni
或 si
)之后转储。在 layout reg
模式下,您可以 tui reg vec
切换到向量 regs 的显示,但这几乎没用,因为您无法 select 显示哪种解释(您总是得到所有这些,并且不能滚动,并且列在寄存器之间不对齐)。不过,它非常适合整数 regs/flags。
请注意,将这些与内在函数一起使用可能会很棘手。编译器不喜欢对未初始化的变量进行操作,因此您应该使用 _mm_undefined_si128()
来告诉编译器您的意思。或者也许使用 _mm_set1_epi32(-1)
会让你的编译器发出一个 pcmpeqd same,same
。如果没有这个,一些编译器会在使用前对未初始化的向量变量进行异或零操作,甚至 (MSVC) 从堆栈加载未初始化的内存。
通过利用 SSE4.1 的 pmovzx
或 pmovsx
动态零或符号扩展,许多常量可以更紧凑地存储在内存中。例如,{1, 2, 3, 4}
作为 32 位元素的 128b 向量可以通过从 32 位内存位置加载 pmovzx
来生成。内存操作数可以与 pmovzx
微融合,因此它不需要任何额外的融合域微指令。不过,它确实阻止了直接将常量用作内存操作数。
C/C++ _mm_cvtepu8_epi32 (__m128i a)
,但没有采用 uint32_t *
指针操作数的版本。你可以破解它,但它很丑陋,编译器优化失败是一个问题。有关详细信息和 linkgcc 错误报告的 linked 问题。
使用 256b 和(不久之后)512b 常量,内存节省更大。不过,只有当多个有用的常量可以共享一个缓存行时,这才非常重要。
它的 FP 等价物是 VCVTPH2PS xmm1, xmm2/m64
,需要 F16C(半精度)特征标志。 (还有一条存储指令将单个打包成一半,但没有半精度计算。这只是内存带宽/缓存占用空间优化。)
显然,当所有元素都相同时(但不是用于动态生成的 suitable),pshufd
或 AVX vbroadcastps
/ AVX2 vpbroadcastb/w/d/q/i128
很有用。 pshufd
可以取内存源操作数,但必须是128b。 movddup
(SSE3) 执行 64 位加载,广播以填充 128b 寄存器。在 Intel 上,它不需要 ALU 执行单元,只需要加载端口。 (类似地,AVX v[p]broadcast
双字大小和更大的负载在加载单元中处理,没有 ALU)。
广播或pmovz/sx
非常适合在您要将掩码加载到寄存器中以在环形。从一个起点生成多个相似掩码,如果只需要一条指令,也可以节省space。
另见 set1
内在函数的信息,目前尚不清楚它询问的是常量还是变量广播。
我也用 compiler output for broadcasts 做了一些实验。
如果缓存未命中是一个问题,请查看您的代码,看看当同一函数内联到不同的调用方时编译器是否重复了 _mm_set
常量.还要注意一起使用的常量(例如在一个接一个调用的函数中)分散到不同的缓存行中。许多常量的分散加载比从彼此靠近的地方加载大量常量要糟糕得多。
pmovzx
and/or 广播加载让您可以将更多常量打包到缓存行中,将它们加载到寄存器中的开销非常低。负载不会在关键路径上,因此即使它需要一个额外的 uop,它也可以在很长的 window.
clang actually does a good job of this: 分开的 set1
不同函数中的常量被认为是相同的,相同的字符串文字可以合并的方式。请注意,clang 的 asm 源输出似乎显示每个函数都有自己的常量副本,但二进制反汇编显示所有这些 RIP 相对有效地址都引用相同的位置。对于重复函数的 256b 版本,clang 还使用 vbroadcastsd
来只需要 8B 加载,但每个函数都需要额外的指令。 (这是在 -O3
,很明显 clang 开发人员已经意识到大小对性能很重要,而不仅仅是 -Os
)。 IDK 为什么它不使用 vbroadcastss
下降到 4B 常量,因为那应该一样快。不幸的是,vbroadcast 不仅仅来自其他函数使用的 16B 常量的一部分。这可能是有道理的:某物的 AVX 版本可能只能将其某些常量与 SSE 版本合并。最好让带有 SSE 常量的内存页完全保持冷,让 AVX 版本将所有常量放在一起。此外,在 assemble 或 link 时间处理它是一个更难的模式匹配问题(但是它已经完成了。我没有阅读每条指令来弄清楚哪个指令允许合并。)
gcc 5.3 也合并常量,但不使用 broadcast-loads 来压缩 32B 常量。同样,16B 常量不与 32B 常量重叠。