![「玩物志趣」使用 x86inc.asm 编写 x86 SIMD插图 「玩物志趣」使用 x86inc.asm 编写 x86 SIMD插图](https://blog.eswlnk.com/wp-content/uploads/wpcy/d86034eec99ee48a4393e8f440d475ae.jpg)
在多媒体中,我们经常编写计算量大的函数的矢量汇编 (SIMD) 实现,以使我们的软件更快。在较高的层面上,可以使用三种基本方法来编写程序集优化(对于任何体系结构):
- 内在函数;
- 内联组装;
- 手写程序集。
内联汇编通常不受欢迎,因为它的可读性和可移植性较差。内在函数向您隐藏了复杂的内容(例如寄存器计数或堆栈内存),这使得编写优化变得更容易,但同时通常会带来性能损失(因为编译器生成的代码很差)——与手写(或内联)相比集会。 x86inc.asm
是一个最初由各种 x264 开发人员开发的帮助程序,在ISC下获得许可,旨在使在 x86 上编写手写程序集变得更容易。除了x264之外,x86inc.asm
还用于libvpx、 libaom、x265和FFmpeg。
![「玩物志趣」使用 x86inc.asm 编写 x86 SIMD插图1 「玩物志趣」使用 x86inc.asm 编写 x86 SIMD插图1](https://static.eswlnk.com/2024/03/20240326181856180-1024x320.png-esw)
如何使用的基本示例 x86inc.asm
为了解释它是如何工作的,最好从一个例子开始。想象一下以下 C 函数来计算 16×16 块的 SAD(绝对差之和)(这通常在运动搜索中作为失真度量调用):
#include <stddef.h>
#include <stdint.h>
#include <stdlib.h>
typedef uint8_t pixel;
static unsigned sad_16x16_c(const pixel *src, ptrdiff_t src_stride,
const pixel *dst, ptrdiff_t dst_stride)
{
unsigned sum = 0;
int y, x;
for (y = 0; y < 16; y++) {
for (x = 0; x < 16; x++)
sum += abs(src[x] - dst[x]);
src += src_stride;
dst += dst_stride;
}
return sum;
}
在x86inc.asm
语法上,这看起来像这样:
%include "x86inc.asm"
SECTION .text
INIT_XMM sse2
cglobal sad_16x16, 4, 7, 5, src, src_stride, dst, dst_stride, \
src_stride3, dst_stride3, cnt
lea src_stride3q, [src_strideq*3]
lea dst_stride3q, [dst_strideq*3]
mov cntd, 4
pxor m0, m0
.loop:
mova m1, [srcq+src_strideq*0]
mova m2, [srcq+src_strideq*1]
mova m3, [srcq+src_strideq*2]
mova m4, [srcq+src_stride3q]
lea srcq, [srcq+src_strideq*4]
psadbw m1, [dstq+dst_strideq*0]
psadbw m2, [dstq+dst_strideq*1]
psadbw m3, [dstq+dst_strideq*2]
psadbw m4, [dstq+dst_stride3q]
lea dstq, [dstq+dst_strideq*4]
paddw m1, m2
paddw m3, m4
paddw m0, m1
paddw m0, m3
dec cntd
jg .loop
movhlps m1, m0
paddw m0, m1
movd eax, m0
RET
这是一大堆东西。在上面的例子中需要理解的关键点是:
- 函数(符号)由cglobal声明;
- 我们不通过官方输入的名称(例如,,或)来引用向量寄存器
mm0
, 而是通过在;中生成的 模板化名称( )来引用向量寄存器xmm0
。ymm0
zmm0
m0
INIT_*
- 我们使用
mova
,而不是movdqa
,在寄存器之间或从/向内存移动数据; - 我们不通过其正式名称(例如
rdi
或edi
)来引用通用寄存器,而是通过模板化名称(例如srcq
)来引用通用寄存器,该名称是在 – 中生成(并填充)的cglobal
– 除非它是为了存储返回值(eax
); - 使用
RET
(不是ret
!)返回。 - 在您的构建系统中,这将像任何其他手写汇编文件一样处理,因此您可以使用
nasm
或构建一个目标文件yasm
。
让我们更详细地探索和理解这一切。
理解 INIT_*
、 cglobal
、 DEFINE_ARGS
和 RET
INIT_*
指示我们要使用什么类型的向量寄存器:MMX ( mm0
)、SSE ( xmm0
)、AVX ( ymm0
) 或 AVX-512 ( zmm0
)。该调用还允许我们针对特定的 CPU 指令集(例如 ssse3
或 avx2
)。这有多种特点:
- 模板化向量寄存器名称 (
m0
) 反映特定类别的寄存器 ( 、mm0
或xmm0
) ;ymm0
zmm0
- 模板化指令名称(例如
psadbw
),如果我们使用所选集不支持的指令(例如pmulhrsw
在 SSE2 函数中),它可以发出警告; - 当针对 AVX 时,模板化指令名称也会隐藏 VEX 编码(
vpsadbw
vs.psadbw
); - 用于将数据移入或移出完全对齐(
mova
,可转换为movq
formm
、(v)movdqa
forxmm
或vmovdqa
forymm
寄存器)、完全未对齐 (movu
) 或半 (movh
) 向量寄存器的别名; - 方便的别名
mmsize
并gprsize
指示向量和通用寄存器的大小(以字节为单位)。
例如,要使用 ymm
寄存器编写 AVX2 函数,您可以使用 INIT_YMM avx2
.要使用xmm
寄存器 编写 SSSE3 函数 ,您可以使用INIT_XMM ssse3
.要使用寄存器编写“扩展 MMX”(作为 SSE 一部分引入的整数指令)函数 mm
,您可以使用 INIT_MMX mmxext
.最后,对于 AVX-512,您使用INIT_ZMM avx512
.
cglobal
表示单个函数声明。这有多种特点:
- 可移植地声明带有项目名称前缀和指令集后缀的全局(导出)符号;
- 可移植地使被调用者保存的通用寄存器可用(通过将其内容推送到堆栈);
- 可移植地将函数参数从堆栈加载到通用寄存器中;
- 可移植地使被调用者保存
xmm
向量寄存器可用(在 Win64 上); - 生成命名和编号的通用寄存器别名,其到本机寄存器的映射针对每个特定目标平台进行了优化;
- 分配对齐的堆栈内存(请参阅“使用堆栈内存”)。
sad_16x16
例如,上面声明的函数具有以下 行 cglobal
:
cglobal sad_16x16, 4, 7, 5, src, src_stride, dst, dst_stride, \
src_stride3, dst_stride3, cnt
使用第一个参数 ( ),这将创建一个 可从其他地方的 C 函数访问的sad_16x16
全局符号 。<prefix>_sad_16x16_sse2()
前缀是在nasm
/ yasm
build 标志 ( -Dprefix=name
) 中定义的项目范围设置。
使用第三个参数,它请求 7 个通用寄存器 (GPR):
- 在 x86-32 上,只有前 3 个 GPR 是调用者保存的,这会将其他 4 个 GPR 的内容推送到堆栈,以便我们在函数体中有 7 个可用的 GPR;
- 在unix64/win64上,我们有7个或更多可用的调用者保存GPR,因此没有GPR内容被推送到堆栈。
使用第二个参数,指示 4 个 GPR 在函数输入时加载函数参数:
- 在 x86-32 上,所有函数参数都在堆栈上传输,这意味着我们将
mov
每个参数从堆栈转移到适当的寄存器中; - 在 win64/unix64 上,前 4/6 个参数通过 GPR 传输( 在 unix64 上为
rdi
,rsi
,rdx
,rcx
,R8
, 在 win64 上为 , , , ),因此在这种特殊情况下不需要实际 指令 。R9
rcx
rdx
R8
R9
mov
这也应该解释为什么我们想要使用模板化寄存器名称而不是它们的本机名称(例如 rdi
),因为我们希望 src
变量保留 rcx
在 win64 和 rdi
unix64 上。在这一层内部 x86inc.asm
,这些寄存器具有编号的别名(r0
、 r1
、 r2
等)。每个编号寄存器与每个目标平台的本机寄存器关联的具体顺序取决于 ABI 规定的函数调用参数顺序;然后调用者保存寄存器;最后是被调用者保存寄存器。
最后,使用第四个参数,我们表明我们将使用 5 个 xmm
寄存器。在 win64 上,如果这个数字大于 6(对于 AVX-512 则为 22),我们将使用被调用者保存 xmm
寄存器,因此必须将其内容备份到堆栈中。在其他平台上,该值被忽略。
其余参数被命名为每个 GPR 的别名(例如 ,src
将引用 r0
等)。对于每个命名或编号的寄存器,您会注意到一个后缀(例如 的 q
后缀 srcq
)。后缀的完整列表:
q
:qword(在64位上)或dword(在32位上) – 请注意q
编号别名中如何缺失,例如在unix64上,rdi = r0 = srcq
,但在32位上eax = r0 = srcq
;d
:双字,例如在unix64上,,eax = r6d = cntd
但在32位上ebp = r6d = cntd
;w
: word,例如在 unix64 上ax = r6w = cntw
,但在 32 位上bp = r6w = cntw
;b
:一个字中的(低)字节,例如在unix64上al = r6b = cntb
;h
:一个字的高字节,例如在unix64上ah = r6h = cnth
;m
:此变量的堆栈位置(如果有),否则为双字别名(例如在 32 位上[esp + stack_offset + 4] = r0m = srcm
);mp
:与 类似m
,但使用 qword 寄存器别名和内存大小指示符(例如,在 unix64 上qword [rsp + stack_offset + 8] = r6mp = cntmp
,但在 32 位上dword [rsp + stack_offset + 28] = r6mp = cntmp
)。
DEFINE_ARGS
是一种重命名使用 的最后一个参数定义的命名寄存器的方法 cglobal
。它允许您以不同的名称重复使用相同的物理/编号的通用寄存器,通常意味着不同的目的,从而允许更可读的代码,而不需要比严格必要的更多的通用寄存器。
RET
从由 压入的堆栈中恢复任何被调用者保存寄存器(GPR 或向量) cglobal
,撤消分配给自定义使用的任何附加堆栈内存(请参阅“使用堆栈内存”)。它还将调用vzeroupper
清除ymm
寄存器的上半部分(如果使用INIT_YMM
或更高版本调用)。最后,它调用 ret
返回给调用者。
多种寄存器类型的模板函数
至此,我们为通用寄存器使用模板化名称(r0
或 src
代替 rdi
(unix64)、 rcx
(win64) 或 eax
(32 位))的原因应该相当明显了:可移植性。然而,我们还没有解释为什么我们使用向量寄存器的模板化名称(m0
而不是 mm0
、 xmm0
或 )。这样做的原因是函数模板。让我们回到 上面的函数并使用模板:ymm0
zmm0
sad_16x16
%macro SAD_FN 2 ; width, height
cglobal sad_%1x%2, 4, 7, 5, src, src_stride, dst, dst_stride, \
src_stride3, dst_stride3, cnt
lea src_stride3q, [src_strideq*3]
lea dst_stride3q, [dst_strideq*3]
mov cntd, %2 / 4
pxor m0, m0
.loop:
mova m1, [srcq+src_strideq*0]
mova m2, [srcq+src_strideq*1]
mova m3, [srcq+src_strideq*2]
mova m4, [srcq+src_stride3q]
lea srcq, [srcq+src_strideq*4]
psadbw m1, [dstq+dst_strideq*0]
psadbw m2, [dstq+dst_strideq*1]
psadbw m3, [dstq+dst_strideq*2]
psadbw m4, [dstq+dst_stride3q]
lea dstq, [dstq+dst_strideq*4]
paddw m1, m2
paddw m3, m4
paddw m0, m1
paddw m0, m3
dec cntd
jg .loop
%if mmsize >= 16
%if mmsize >= 32
vextracti128 xm1, m0, 1
paddw xm0, xm1
%endif
movhlps xm1, xm0
paddw xm0, xm1
%endif
movd eax, xm0
RET
%endmacro
INIT_MMX mmxext
SAD_FN 8, 4
SAD_FN 8, 8
SAD_FN 8, 16
INIT_XMM sse2
SAD_FN 16, 8
SAD_FN 16, 16
SAD_FN 16, 32
INIT_YMM avx2
SAD_FN 32, 16
SAD_FN 32, 32
SAD_FN 32, 64
这确实为每种寄存器类型生成了 9 个正方形和矩形尺寸的函数。可以采取更多措施来减少二进制大小,但就本教程而言,最重要的信息是我们可以使用相同的源代码为多个向量寄存器类型( 、mm0
或 xmm0
) ymm0
编写函数zmm0
。
一些读者此时可能会注意到,x86inc.asm
允许程序员将非 VEX 指令名称(例如psadbw
)用于 VEX 指令(例如vpsadbw
),因为您只能ymm
使用 VEX 编码指令对寄存器进行操作。情况确实如此,并且在下面的“AVX 三操作数指令仿真”中更详细地讨论。您还会注意到我们xm0
在函数末尾的使用方式,这使我们能够显式访问xmm
函数中的寄存器(否则为ymm
寄存器模板化),但仍会正确映射到mm
MMX 函数中的寄存器。
多指令集的模板函数
我们还可以使用相同的方法来模板化指令集不同的函数的多个变体。例如,考虑 pabsw
SSSE3 中添加的指令。您可以使用模板为每像素 10 位或 12 位组件图片编写两个版本的 SAD 版本(typedef uint16_t pixel
在上面的 C 代码中):
%macro ABSW 2 ; dst/src, tmp
%if cpuflag(ssse3)
pabsw %1, %1
%else
pxor %2, %2
psubw %2, %1
pmaxsw %1, %2
%endif
%endmacro
%macro SAD_8x8_FN 0
cglobal sad_8x8, 4, 7, 6, src, src_stride, dst, dst_stride, \
src_stride3, dst_stride3, cnt
lea src_stride3q, [src_strideq*3]
lea dst_stride3q, [dst_strideq*3]
mov cntd, 2
pxor m0, m0
.loop:
mova m1, [srcq+src_strideq*0]
mova m2, [srcq+src_strideq*1]
mova m3, [srcq+src_strideq*2]
mova m4, [srcq+src_stride3q]
lea srcq, [srcq+src_strideq*4]
psubw m1, [dstq+dst_strideq*0]
psubw m2, [dstq+dst_strideq*1]
psubw m3, [dstq+dst_strideq*2]
psubw m4, [dstq+dst_stride3q]
lea dstq, [dstq+dst_strideq*4]
ABSW m1, m5
ABSW m2, m5
ABSW m3, m5
ABSW m4, m5
paddw m1, m2
paddw m3, m4
paddw m0, m1
paddw m0, m3
dec cntd
jg .loop
movhlps m1, m0
paddw m0, m1
pshuflw m1, m0, q1010
paddw m0, m1
pshuflw m1, m0, q0000 ; qNNNN is a base4-notation for imm8 arguments
paddw m0, m0
movd eax, m0
movsxwd eax, ax
RET
%endmacro
INIT_XMM sse2
SAD_8x8_FN
INIT_XMM ssse3
SAD_8x8_FN
总而言之,只要开发人员了解模板的工作原理和目标是什么,函数模板就可以制作易于维护的代码。模板可以部分隐藏宏中函数的复杂性,这可能会非常令人困惑,从而使代码更难以理解。同时,它将显着减少源代码重复,从而使代码维护变得更加容易。
AVX 三操作数指令仿真
作为 AVX 的一部分引入的关键功能之一(独立于 ymm
寄存器)是 VEX 编码,它允许三操作数指令。由于 vpsadbw
出于模板目的x86inc.asm
从非 VEX 版本(例如 )定义了 VEX 指令(例如 psadbw
),因此三操作数指令支持实际上也存在于非 VEX 版本中。因此,像这样的代码(交错垂直相邻像素)实际上是有效的:
[..]
mova m0, [srcq+src_strideq*0]
mova m1, [srcq+src_strideq*1]
punpckhbw m2, m0, m1
punpcklbw m0, m1
[..]
该函数的 AVX 版本(使用 xmm
向量寄存器,通过 INIT_XMM avx
)会在 unix64 上将其字面翻译为以下内容:
[..]
vmovdqa xmm0, [rdi]
vmovdqa xmm1, [rdi+rsi]
vpunpckhbw xmm2, xmm0, xmm1
vpunpcklbw xmm0, xmm0, xmm1
[..]
另一方面,相同源代码的 SSE2 版本(通过 INIT_XMM sse2
)在 unix64 上将字面翻译为以下内容:
[..]
movdqa xmm0, [rdi]
movdqa xmm1, [rdi+rsi]
movdqa xmm2, xmm0
punpckhbw xmm2, xmm1
punpcklbw xmm0, xmm1
[..]
实际上,如前所述,AVX/VEX 仿真还允许对预 AVX 函数和 AVX 函数使用相同的(模板化)源代码。
使用 SWAP
另一个值得注意的功能 x86inc.asm
是 SWAP
汇编器时间和无指令寄存器交换器。典型的用例是在向量寄存器中排序数据时保持数字一致。例如: SWAP 0, 1
将切换模板化向量寄存器名称 m0
和 的汇编程序的内部含义m1
。之前, m0
可能指 xmm0
并 m1
可能指 xmm1
;之后, m0
将提及 xmm1
并 m1
会提及 xmm0
。
SWAP
可以接受 2 个以上的参数,在这种情况下, SWAP
对每个后续数字对顺序调用。例如, SWAP 1, 2, 3, 4
与 相同 SWAP 1, 2
,后跟 SWAP 2, 3
,最后跟 SWAP 3, 4
。
顺序在每个函数的开头重新设置(在 中 cglobal
)。
使用堆栈内存
如果您想要的只是未对齐的数据,或者平台在每个函数的入口点提供对齐的堆栈访问,那么在手写汇编中使用堆栈内存相对容易。例如,在 Linux/Mac(32 位和 64 位)或 Windows(64 位)上,ABI 保证函数入口时的堆栈是 16 字节对齐的。不幸的是,有时我们需要 32 字节对齐(用于对齐 vmovdqa
寄存器 ymm
内容),或者需要在 32 位 Windows 上对齐内存。因此, x86inc.asm
提供了便携式对准。第四个(可选)数字参数 cglobal
是堆栈内存所需的字节数。如果该值为 0 或缺失,则不会分配堆栈内存。如果对齐约束 ( mmsize
) 大于平台保证的对齐约束,则手动对齐堆栈。
如果堆栈是手动对齐的,则有两种方法可以恢复 中的原始(预对齐)堆栈指针 RET
,每种方法都会对函数体/实现产生影响:
- 如果我们将原始堆栈指针保存在通用寄存器(GPR)中,我们仍然可以访问 函数体中的原始
m
和 命名寄存器别名。mp
然而,这意味着一个 GPR(保存堆栈指针的那个)在我们的函数体中不可用于其他用途。具体来说,在 32 位上,这会将函数体中可用 GPR 的数量限制为 6。要使用此选项,请指定正堆栈大小;
cglobal sad_16x16, 4, 7, 5, 64, src, src_stride, dst, dst_stride, \
src_stride3, dst_stride3, cnt
- 如果我们将原始堆栈指针保存在(对齐后)堆栈上,我们将无法访问函数体中的寄存器别名
m
或mp
命名寄存器别名,但我们不会占用 GPR。要使用此选项,请指定负堆栈大小。请注意我们如何将负数写为0 - 64
,而不仅仅是-64
因为某些旧版本中的错误yasm
。
cglobal sad_16x16, 4, 7, 5, 0 - 64, src, src_stride, dst, dst_stride, \
src_stride3, dst_stride3, cnt
分配堆栈内存后,可以使用(这是on 32位的[rsp]
别名)来访问它。[esp]
📮评论