「玩物志趣」使用 x86inc.asm 编写 x86 SIMD插图

在多媒体中,我们经常编写计算量大的函数的矢量汇编 (SIMD) 实现,以使我们的软件更快。在较高的层面上,可以使用三种基本方法来编写程序集优化(对于任何体系结构):

  • 内在函数;
  • 内联组装;
  • 手写程序集。

内联汇编通常不受欢迎,因为它的可读性和可移植性较差。内在函数向您隐藏了复杂的内容(例如寄存器计数或堆栈内存),这使得编写优化变得更容易,但同时通常会带来性能损失(因为编译器生成的代码很差)——与手写(或内联)相比集会。 x86inc.asm 是一个最初由各种 x264 开发人员开发的帮助程序,在ISC下获得许可,旨在使在 x86 上编写手写程序集变得更容易。除了x264之外,x86inc.asm还用于libvpx、  libaomx265FFmpeg

「玩物志趣」使用 x86inc.asm 编写 x86 SIMD插图1

如何使用的基本示例 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。 ymm0zmm0m0INIT_*
  • 我们使用 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)  ;ymm0zmm0
  • 模板化指令名称(例如 psadbw),如果我们使用所选集不支持的指令(例如 pmulhrsw 在 SSE2 函数中),它可以发出警告;
  • 当针对 AVX 时,模板化指令名称也会隐藏 VEX 编码(vpsadbw vs.  psadbw);
  • 用于将数据移入或移出完全对齐(mova,可转换为 movq for  mm(v)movdqa for xmm 或 vmovdqa for ymm 寄存器)、完全未对齐 ( 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()前缀是在nasmyasmbuild 标志 ( -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 上为 , ,  ,   ),因此在这种特殊情况下不需要实际 指令  。R9rcxrdxR8R9mov

这也应该解释为什么我们想要使用模板化寄存器名称而不是它们的本机名称(例如 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或 )。这样做的原因是函数模板。让我们回到  上面的函数并使用模板:ymm0zmm0sad_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寄存器模板化),但仍会正确映射到mmMMX 函数中的寄存器。

多指令集的模板函数

我们还可以使用相同的方法来模板化指令集不同的函数的多个变体。例如,考虑 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]