需要一个优雅的 SSE2 方法来预乘 Alpha 然后将 Alpha 设置为 1.0f
Need an Elegant SSE2 Method for Premultiplying Alpha then Setting Alpha to 1.0f
我正在使用 Visual Studio 2015,构建 x64 代码,并使用四个 ABGR 像素值的浮点向量,即 Alpha(不透明度)在最重要的位置,蓝色、绿色和下三位红色数字。
我正在尝试编写一个 PreMultiplyAlpha 例程,它将 inline/__vectorcall 高效地将 alpha 预乘为蓝色、绿色和红色,并在完成后将 Alpha 值设置为 1.0f。
实际乘法是没有问题的。这会将 Alpha 传播到所有四个元素,然后将它们全部相乘。
__m128 Alpha = _mm_shuffle_ps(Pixel, Pixel, _MM_SHUFFLE(3, 3, 3, 3));
__m128 ReturnPixel = _mm_mul_ps(Pixel, Alpha);
通过上面的代码,alpha 乘以最少的指令即可得到所有颜色:
shufps xmm1, xmm0, 255 ; 000000ffH
mulps xmm1, xmm0
这是一个很好的开始,对吧?
然后我碰壁了...我还没有发现直接的方法 - 甚至是棘手的方法 - 做看起来应该是有效设置最重要元素(Alpha)的相当简单的行为到 1.0f。可能是我的盲点吧。
最明显的方法导致 VC++ 2015 创建执行两次 128 位内存访问的机器码:
ReturnPixel.m128_f32[ALPHA] = 1.0f;
上面生成的代码是这样的,它将整个像素保存在堆栈中,覆盖 Alpha,然后从堆栈中加载它:
movaps XMMWORD PTR ReturnPixel[rsp], xmm1
mov DWORD PTR ReturnPixel[rsp+12], 1065353216 ; 3f800000H
movaps xmm1, XMMWORD PTR ReturnPixel[rsp]
我非常喜欢让代码尽可能简单明了,以便维护人员能够理解,但是这个特定的例程被大量使用,需要以最佳方式快速完成。
我尝试过的其他事情似乎导致编译器发出比应有的更多指令(尤其是内存访问)...
这会尝试将 A 位置移动到最低有效字,将其替换为 1.0f,然后将其移回。这很好,但它确实会从内存位置获取单个 32 位 1.0f。
ReturnPixel = _mm_shuffle_ps(ReturnPixel, ReturnPixel, _MM_SHUFFLE(0, 2, 1, 3));
ReturnPixel = _mm_move_ss(ReturnPixel, _mm_set_ss(1.0f));
ReturnPixel = _mm_shuffle_ps(ReturnPixel, ReturnPixel, _MM_SHUFFLE(0, 2, 1, 3));
我得到了这些说明:
movss xmm0, DWORD PTR __real@3f800000
movaps xmm1, xmm0
shufps xmm2, xmm2, 39 ; 00000027H
movss xmm2, xmm1
shufps xmm2, xmm2, 39
关于如何使用最少的指令将 1.0f 保留在 A 字段(最重要的元素)中并且理想情况下除了从指令流中获取的内容之外没有额外的内存访问,有什么想法吗?我什至考虑过将矢量除以自身以在所有位置实现 1.0f,但我对除法过敏,因为至少可以说它们效率低下...
提前感谢您的想法。 :-)
-诺尔
1.0 float
常量必须来自某个地方,因此必须加载它或 . There's no SSE equivalent of fld1
, and compilers usually go for fewer instructions even at the risk of a D-cache miss instead of mov eax, 0x3f800000
/ movd xmm0, eax
or something. (See Agner Fog's Optimizing Assembly,第 13.4 节 table 序列。生成 1.0 需要 3 个 insns)。
没有 SSE/SSE2 单个指令可以替换向量的 32b 元素(其他 movss
用于低元素)。 SSE4.1 引入了 insertps
和 pinsrd
。使用两条 pinsrw
指令一次设置 16b 不太可能是最佳选择,尤其是。如果你想将该向量输入到 FP 计算中。
如果你想存储它,那么可能两个重叠存储是最好的:存储带有错误数据的 16B 向量,然后存储一个 1.0.理论上,聪明的编译器会将其编译为 shufps-broadcast / mulps / movaps [mem], xmm1
/ mov [mem+12], 0x3f800000
。但是,如果您立即从 [mem]
执行矢量加载,则会导致存储转发停顿。 (对于典型的 uarches 的 store/reload 往返,还有大约 10 个延迟周期高于正常值 ~5c)
处理常量
由于您正在处理像素,我认为这意味着这是在多次迭代的循环中发生的。这意味着我们正在优化循环中的效率,即使这意味着在循环外进行一些额外的设置。
一个好的编译器会在内联后将常量提升到循环之外,因此将操作分解到使用 _mm_set_ps
或 _mm_set1_ps
作为其常量的函数中应该没问题。不过,您应该检查 asm; MSVC doesn't always manage to do this,因此您可能需要手动内联和提升。
在寄存器中,为进一步的 FP 操作做准备
如果我们想在 regs 中保留矢量时继续使用该矢量,则重叠存储选项不可行。 (我们应该这样做:我们仍然可以足够便宜地做到这一点,以至于它没有理由对数据进行单独循环以应用 alphas)。
替换高元素的最便宜的选项是 blendps
(_mm_blend_ps
)。与立即控制操作数的混合在支持它们的 SSE4.1 和更高版本的 CPU 上非常有效:1c 延迟,并且可以 运行 在 SnB 和更高版本的多个执行端口上,因此它们不会在特定的上产生瓶颈执行端口。 (可变混合物更昂贵)。 insertps
(_mm_insert_ps`) 更强大(例如可以将目标中的选定元素置零,并从 src 中的任何元素中选取),但需要随机端口。
如果没有 SSE4.1,我们最好的选择可能是两条指令:用 AND 屏蔽高位元素,然后从 [ 1.0 0 0 0 ]
的向量中在 1.0f 中进行或运算。 0.0f
的 IEEE 表示是全零的,因此我们可以安全地进行 OR 而不会影响低位元素。这只有 2 条指令。
andps
和 orps
都仅 运行 在英特尔 Nehalem 到 Broadwell 的端口 5(与 shufps 竞争)上。 Skylake 运行 将它们放在 p015 上,与 pand
和 por
相同。如果吞吐量成为瓶颈,而不是延迟,请考虑改用整数指令(转换为 __m128i
)。当使用 por
的输出作为 addps
或其他东西的输入时,它只是一个额外的 1 个旁路延迟周期(英特尔 SnB 系列)。
__m128 apply_alpha(__m128 Pixel) {
__m128 Alpha = _mm_shuffle_ps(Pixel, Pixel, _MM_SHUFFLE(3, 3, 3, 3));
__m128 Multiplied = _mm_mul_ps(Pixel, Alpha);
#ifdef __SSE4_1__
// blendps imm8 is cheaper (runs on more ports) than insertps on Intel SnB-family
__m128 Alpha_Reset = _mm_blend_ps(Multiplied, _mm_set1_ps(1.0), 1<<3);
#else
// emulate the blend with AND/OR
const __m128 zeroalpha_mask = _mm_castsi128_ps( _mm_set_epi32(0,~0,~0,~0) ); // could be generated with pcmpeqw / psrldq 4
__m128 Alpha_Reset = _mm_and_ps(Multiplied, zeroalpha_mask);
const __m128 alpha_one = _mm_set_ps(1.0, 0, 0, 0);
Alpha_Reset = _mm_or_ps(Alpha_Reset, alpha_one);
#endif
return Alpha_Reset;
}
在循环中调用它与 gcc 配合使用效果很好:它在循环外的寄存器中设置所有常量,因此循环内只是一个加载、一些寄存器操作和一个存储。
在 Godbolt Compiler Explorer 上查看我的测试循环的源代码。您还可以添加 -march=haswell
以启用它支持的所有指令集,包括 -msse4.1
,并看到 blendps
版本也可以编译。
loop(float __vector(4)*):
movaps xmm4, XMMWORD PTR .LC0[rip] # setup of constants hoisted out of the loop
lea rax, [rdi+160000]
movaps xmm3, XMMWORD PTR .LC1[rip]
movaps xmm2, XMMWORD PTR .LC3[rip]
.L3:
movaps xmm1, XMMWORD PTR [rdi]
add rdi, 16
# apply_alpha inlined beginning here
movaps xmm0, xmm1 # This is the insn you forgot to include in the question, for your shufps broadcast without AVX. It's unavoidable, but still counts
shufps xmm0, xmm1, 255
mulps xmm0, xmm1
andps xmm0, xmm4
orps xmm0, xmm3
# and ends here
addps xmm0, xmm2 # extra add outside of apply_alpha, otherwise a scalar store to set alpha may be better
movaps XMMWORD PTR [rdi-16], xmm0
cmp rax, rdi
jne .L3
ret
将其扩展到 256b 向量也很容易:仍然使用具有两倍宽度的常量的 blendps 一次处理 2 个像素。
感谢所有响应者,我们确定了一种解决方案,它只执行一个 128 位内存访问,而不是我最初列出的直接代码执行的三个:
// Ensures the result of the multiply leaves a 0 in Alpha.
__m128 ABGZ = _mm_move_ss(Pixel, _mm_setzero_ps());
__m128 ZAAA = _mm_shuffle_ps(ABGZ, ABGZ, _MM_SHUFFLE(0, 3, 3, 3));
__m128 ReturnPixel = _mm_mul_ps(Pixel, ZAAA);
ReturnPixel = _mm_or_ps(ReturnPixel, _mm_set_ps(1.0f, 0, 0, 0));
这会生成以下代码:
xorps xmm1, xmm1
movss xmm2, xmm1
shufps xmm2, xmm2, 63 ; 0000003fH
mulps xmm2, xmm0
orps xmm2, XMMWORD PTR __xmm@3f800000000000000000000000000000
我曾希望有一个解决方案可以以编程方式生成 1.0f 并保持此代码的所有寄存器工作。那好吧。那 128 位值无疑会被缓存。
将来有一天,当我们将产品提高到 SSE4.1 的最低支持级别时,我们将重新讨论这个问题。
-诺埃尔
我正在使用 Visual Studio 2015,构建 x64 代码,并使用四个 ABGR 像素值的浮点向量,即 Alpha(不透明度)在最重要的位置,蓝色、绿色和下三位红色数字。
我正在尝试编写一个 PreMultiplyAlpha 例程,它将 inline/__vectorcall 高效地将 alpha 预乘为蓝色、绿色和红色,并在完成后将 Alpha 值设置为 1.0f。
实际乘法是没有问题的。这会将 Alpha 传播到所有四个元素,然后将它们全部相乘。
__m128 Alpha = _mm_shuffle_ps(Pixel, Pixel, _MM_SHUFFLE(3, 3, 3, 3));
__m128 ReturnPixel = _mm_mul_ps(Pixel, Alpha);
通过上面的代码,alpha 乘以最少的指令即可得到所有颜色:
shufps xmm1, xmm0, 255 ; 000000ffH
mulps xmm1, xmm0
这是一个很好的开始,对吧?
然后我碰壁了...我还没有发现直接的方法 - 甚至是棘手的方法 - 做看起来应该是有效设置最重要元素(Alpha)的相当简单的行为到 1.0f。可能是我的盲点吧。
最明显的方法导致 VC++ 2015 创建执行两次 128 位内存访问的机器码:
ReturnPixel.m128_f32[ALPHA] = 1.0f;
上面生成的代码是这样的,它将整个像素保存在堆栈中,覆盖 Alpha,然后从堆栈中加载它:
movaps XMMWORD PTR ReturnPixel[rsp], xmm1
mov DWORD PTR ReturnPixel[rsp+12], 1065353216 ; 3f800000H
movaps xmm1, XMMWORD PTR ReturnPixel[rsp]
我非常喜欢让代码尽可能简单明了,以便维护人员能够理解,但是这个特定的例程被大量使用,需要以最佳方式快速完成。
我尝试过的其他事情似乎导致编译器发出比应有的更多指令(尤其是内存访问)...
这会尝试将 A 位置移动到最低有效字,将其替换为 1.0f,然后将其移回。这很好,但它确实会从内存位置获取单个 32 位 1.0f。
ReturnPixel = _mm_shuffle_ps(ReturnPixel, ReturnPixel, _MM_SHUFFLE(0, 2, 1, 3));
ReturnPixel = _mm_move_ss(ReturnPixel, _mm_set_ss(1.0f));
ReturnPixel = _mm_shuffle_ps(ReturnPixel, ReturnPixel, _MM_SHUFFLE(0, 2, 1, 3));
我得到了这些说明:
movss xmm0, DWORD PTR __real@3f800000
movaps xmm1, xmm0
shufps xmm2, xmm2, 39 ; 00000027H
movss xmm2, xmm1
shufps xmm2, xmm2, 39
关于如何使用最少的指令将 1.0f 保留在 A 字段(最重要的元素)中并且理想情况下除了从指令流中获取的内容之外没有额外的内存访问,有什么想法吗?我什至考虑过将矢量除以自身以在所有位置实现 1.0f,但我对除法过敏,因为至少可以说它们效率低下...
提前感谢您的想法。 :-)
-诺尔
1.0 float
常量必须来自某个地方,因此必须加载它或 fld1
, and compilers usually go for fewer instructions even at the risk of a D-cache miss instead of mov eax, 0x3f800000
/ movd xmm0, eax
or something. (See Agner Fog's Optimizing Assembly,第 13.4 节 table 序列。生成 1.0 需要 3 个 insns)。
没有 SSE/SSE2 单个指令可以替换向量的 32b 元素(其他 movss
用于低元素)。 SSE4.1 引入了 insertps
和 pinsrd
。使用两条 pinsrw
指令一次设置 16b 不太可能是最佳选择,尤其是。如果你想将该向量输入到 FP 计算中。
如果你想存储它,那么可能两个重叠存储是最好的:存储带有错误数据的 16B 向量,然后存储一个 1.0.理论上,聪明的编译器会将其编译为 shufps-broadcast / mulps / movaps [mem], xmm1
/ mov [mem+12], 0x3f800000
。但是,如果您立即从 [mem]
执行矢量加载,则会导致存储转发停顿。 (对于典型的 uarches 的 store/reload 往返,还有大约 10 个延迟周期高于正常值 ~5c)
处理常量
由于您正在处理像素,我认为这意味着这是在多次迭代的循环中发生的。这意味着我们正在优化循环中的效率,即使这意味着在循环外进行一些额外的设置。
一个好的编译器会在内联后将常量提升到循环之外,因此将操作分解到使用 _mm_set_ps
或 _mm_set1_ps
作为其常量的函数中应该没问题。不过,您应该检查 asm; MSVC doesn't always manage to do this,因此您可能需要手动内联和提升。
在寄存器中,为进一步的 FP 操作做准备
如果我们想在 regs 中保留矢量时继续使用该矢量,则重叠存储选项不可行。 (我们应该这样做:我们仍然可以足够便宜地做到这一点,以至于它没有理由对数据进行单独循环以应用 alphas)。
替换高元素的最便宜的选项是 blendps
(_mm_blend_ps
)。与立即控制操作数的混合在支持它们的 SSE4.1 和更高版本的 CPU 上非常有效:1c 延迟,并且可以 运行 在 SnB 和更高版本的多个执行端口上,因此它们不会在特定的上产生瓶颈执行端口。 (可变混合物更昂贵)。 insertps
(_mm_insert_ps`) 更强大(例如可以将目标中的选定元素置零,并从 src 中的任何元素中选取),但需要随机端口。
如果没有 SSE4.1,我们最好的选择可能是两条指令:用 AND 屏蔽高位元素,然后从 [ 1.0 0 0 0 ]
的向量中在 1.0f 中进行或运算。 0.0f
的 IEEE 表示是全零的,因此我们可以安全地进行 OR 而不会影响低位元素。这只有 2 条指令。
andps
和 orps
都仅 运行 在英特尔 Nehalem 到 Broadwell 的端口 5(与 shufps 竞争)上。 Skylake 运行 将它们放在 p015 上,与 pand
和 por
相同。如果吞吐量成为瓶颈,而不是延迟,请考虑改用整数指令(转换为 __m128i
)。当使用 por
的输出作为 addps
或其他东西的输入时,它只是一个额外的 1 个旁路延迟周期(英特尔 SnB 系列)。
__m128 apply_alpha(__m128 Pixel) {
__m128 Alpha = _mm_shuffle_ps(Pixel, Pixel, _MM_SHUFFLE(3, 3, 3, 3));
__m128 Multiplied = _mm_mul_ps(Pixel, Alpha);
#ifdef __SSE4_1__
// blendps imm8 is cheaper (runs on more ports) than insertps on Intel SnB-family
__m128 Alpha_Reset = _mm_blend_ps(Multiplied, _mm_set1_ps(1.0), 1<<3);
#else
// emulate the blend with AND/OR
const __m128 zeroalpha_mask = _mm_castsi128_ps( _mm_set_epi32(0,~0,~0,~0) ); // could be generated with pcmpeqw / psrldq 4
__m128 Alpha_Reset = _mm_and_ps(Multiplied, zeroalpha_mask);
const __m128 alpha_one = _mm_set_ps(1.0, 0, 0, 0);
Alpha_Reset = _mm_or_ps(Alpha_Reset, alpha_one);
#endif
return Alpha_Reset;
}
在循环中调用它与 gcc 配合使用效果很好:它在循环外的寄存器中设置所有常量,因此循环内只是一个加载、一些寄存器操作和一个存储。
在 Godbolt Compiler Explorer 上查看我的测试循环的源代码。您还可以添加 -march=haswell
以启用它支持的所有指令集,包括 -msse4.1
,并看到 blendps
版本也可以编译。
loop(float __vector(4)*):
movaps xmm4, XMMWORD PTR .LC0[rip] # setup of constants hoisted out of the loop
lea rax, [rdi+160000]
movaps xmm3, XMMWORD PTR .LC1[rip]
movaps xmm2, XMMWORD PTR .LC3[rip]
.L3:
movaps xmm1, XMMWORD PTR [rdi]
add rdi, 16
# apply_alpha inlined beginning here
movaps xmm0, xmm1 # This is the insn you forgot to include in the question, for your shufps broadcast without AVX. It's unavoidable, but still counts
shufps xmm0, xmm1, 255
mulps xmm0, xmm1
andps xmm0, xmm4
orps xmm0, xmm3
# and ends here
addps xmm0, xmm2 # extra add outside of apply_alpha, otherwise a scalar store to set alpha may be better
movaps XMMWORD PTR [rdi-16], xmm0
cmp rax, rdi
jne .L3
ret
将其扩展到 256b 向量也很容易:仍然使用具有两倍宽度的常量的 blendps 一次处理 2 个像素。
感谢所有响应者,我们确定了一种解决方案,它只执行一个 128 位内存访问,而不是我最初列出的直接代码执行的三个:
// Ensures the result of the multiply leaves a 0 in Alpha.
__m128 ABGZ = _mm_move_ss(Pixel, _mm_setzero_ps());
__m128 ZAAA = _mm_shuffle_ps(ABGZ, ABGZ, _MM_SHUFFLE(0, 3, 3, 3));
__m128 ReturnPixel = _mm_mul_ps(Pixel, ZAAA);
ReturnPixel = _mm_or_ps(ReturnPixel, _mm_set_ps(1.0f, 0, 0, 0));
这会生成以下代码:
xorps xmm1, xmm1
movss xmm2, xmm1
shufps xmm2, xmm2, 63 ; 0000003fH
mulps xmm2, xmm0
orps xmm2, XMMWORD PTR __xmm@3f800000000000000000000000000000
我曾希望有一个解决方案可以以编程方式生成 1.0f 并保持此代码的所有寄存器工作。那好吧。那 128 位值无疑会被缓存。
将来有一天,当我们将产品提高到 SSE4.1 的最低支持级别时,我们将重新讨论这个问题。
-诺埃尔