AvxToSve intrinsic 移植项目经验分享
收藏回复举报
AvxToSve intrinsic 移植项目经验分享
新人帖
发表于2022-10-25 18:15:14
0 查看

概述

AvxToSve 移植了一批 Intel intrinsic,目标是 ARM SVE。Intrinsic 是一种在 C 程序中选择指令,而不用将抽象下降到汇编语言的方式。本项目移植的 avx intrinsic 大部分属于 SIMD 指令,定义在向量上,向量体现为 C 语言的变量。向量变量的类型是需要移植的。

理想情况下,一个 intrinsic 调用编译为一条机器指令。所以,不宜将 intrinsic 实现为可以取地址、需要跳转调用的函数,实现为 inline 函数放在头文件中更合适。

本项目需要实现两部分内容:avx 向量类型和 avx intrinsic。需要编写若干头文件,在其中定义所有 avx 程序中出现的不属于标准 C 的名称。

大部分需要移植的 avx intrinsic 的逻辑不复杂,甚至直接对应于某个 SVE intrinsic 或 C 标准库函数。将数据类型和操作提取为模板参数,就可以将 intrinsic 实现转化为模板(测试数据和测试框架同理)。模板参数来源于对 Intel intrinsic guide 离线版数据文件的解析。我们通过 intrinsic 模板,减少了工作量,也降低了出错的风险。

向量类型

在 C 语言中,要使用向量运算就必须定义向量类型。与数组相似,向量是有多个分量组成的复合数据类型。考察主流的 AVX intrinsic 实现中对向量的定义,存在两种不同的方式。gcc、clang 和 Intel icx 使用 vector 拓展(不属于 C 标准规定的用法)定义向量类型,而 Microsoft Visual C++ 使用数组联合体定义向量类型。这两种定义方式,虽然语法上差异很大,但在语义上有两个重要方面是一致的。第一,存储变量需要的空间大小在编译阶段就确定了;第二,在赋值和函数传参时会传递变量的全部内容,而不是传递变量的地址。

假设需要移植的程序原本是使用 gcc、clang、icx 或 Microsoft Visual C++ 中的某个编译的,那么为了能全面移植 AVX 程序,我们定义的向量类型需要在上述两个重要方面与 vector 拓展以及数组联合体的行为保持一致。

概念上,SVE 向量和 AVX 向量直接对应。但由于 SVE 向量的长度(也就是存储所需空间大小)在编译时不可知,与 vector 拓展和数组联合体不一致,所以不能用 SVE 向量定义 AVX 向量。这种不一致会导致部分 AVX 程序移植失败。设想一个定义了向量类型的全局变量的 AVX 程序,如果将 AVX 向量定义为 SVE 向量,由于长度不可知,编译器拒绝定义 SVE 向量类型的全局变量,这就导致了该 AVX 程序移植失败。

数组在概念上和向量最为相似,但数组在作函数参数时是以头指针的形式传入的,不会复制数组的元素,与 vector 拓展和数组联合体不一致,所以不能用数组定义 AVX 向量。最终,我们选择使用数组联合体定义向量。

Intel intrinsic guide

Intel intrinsic guide 是描述 Intel intrinsic 的重要文档,是我们移植工作的主要参考资料。其内容详实且结构性好,易于编写程序脚本利用其中的信息。站在自动代码生成的角度看,无论是函数实现生成、测试框架生成还是测试用例生成,都需要结合 intrinsic 的信息。手动录入每一个 intrinsic 的信息的工作量很大,所以考虑从 Intel intrinsic guide 中提取信息。

提取 intrinsic 信息的方法介绍如下。访问 Intel intrinsic guide 网站,点击 Download: Offline Intel® Intrinsics Guide 下载包含所有 intrinsic 信息的数据文件。

下载到的内容是一个可以脱机访问的 Intel intrinsic guide 网站,包含网页 html 文件、动态网页控制脚本 js 文件等等。其中,data.js 文件是我们需要的数据文件(可能的路径是 Intel-Intrinsics-Guide-Offline-3.6.3-2\Intel Intrinsics Guide\files\data.js)。解析该文件包含的 html 格式的字符串,提取感兴趣的字段,转存为 excel 表,如下图所示。

Intel intrinsic guide as excel

我们利用到的字段列举如下:

字段名字段含义
headerintrinsic 所在头文件
nameintrinsic 的名称
returnintrinsic 的返回值向量类型和分量类型
paramsintrinsic 参数的向量类型和分量类型
descriptionintrinsic 功能描述(英语)
operationintrinsic 功能描述(伪代码)

注:AVX 向量类型只规定向量的总长度,不规定分量类型,同一个向量类型的分量类型在不同的 intrinsic 中可能是不同的。

随后,各类代码生成工作都可基于此数据文件开展。

函数模板

我们在开发过程中发现,本项目代码中存在大量重复模式,所以考虑利用代码生成技术来减少工作量、降低编程出错风险。Intel intrinsic guide 包含 AVX intrinsic 的信息,相应地,Arm C Language Extensions for SVE 包含 SVE intrinsic 的信息。结合两者的信息,理论上可以实现一个从 AVX intrinsic 到 C 语言 + SVE intrinsic 的编译器。然而,实现编译器的工作量大、难度高,可能使得项目工期失控。

所以我们使用了一种更加简单易行的策略:基于函数模板的代码生成。通过提取相似的函数实现的公共部分,我们有如下发现:

  1. 同一种算术运算对应的 intrinsic 的实现相似;
  2. 相似 intrinsic 的差异几乎都来源于输入参数类型的不同;
  3. 相似 intrinsic 的差异化信息包含在 Intel intrinsic guide 中。

基于上述三点,我们为每一种算数运算编写了一个函数模板。函数模板体现代码结构,忽略变量类型。结合 Intel intrinsic guide 中的类型信息,就可以通过函数模板生成一系列参数类型不同的具体实现。

同样的方法还可以用在测试框架代码的生成中。总之,代码生成过程和 C++ 的模板函数具体化相似,但模板参数不限于类型名。

下一步的工作

Intrinsic 是一种在 C 语言中显式使用机器指令的方式,利用特殊硬件加速计算是使用 intrinsic 编程的根本目的。使用 intrinsic 编程不如纯 C 语言简单直接,在这个意义上说,一个 intrinsic 程序只有在功能正确的基础上具有性能优势才有存在的价值。

从 AVX 到 SVE,在功能移植的基础上,是否可以做到 “性能移植” 呢?我们可以分析一个案例。下面的函数 f 通过组合 AVX intrinsic,实现了对两个参数 a, b 先取绝对值再相加的功能。

__m512i f(__m512i a, __m512i b) {
  return _mm512_add_epi8(
    _mm512_abs_epi8(a),
    _mm512_abs_epi8(b)
  );
}

分别用 “原生 AVX”,AvxToNeon, AvxToSve 编译函数 f,编译参数如下:

平台编译参数
原生 AVX-march=tigerlake -O3
AvxToNeon-march=armv8-a+fp+simd+crc -O3
AvxToSve-march=armv8-a+sve -O3
  • 原生 AVX
f:                   # @f
    vpabsb zmm0, zmm0
    vpabsb zmm1, zmm1
    vpaddb zmm0, zmm1, zmm0
    ret

变量 a, b 通过 zmm0 和 zmm1 传入,返回值通过 zmm0 传出。

  • AvxToNeon
f:                   // @f
    abs   v4.16b, v4.16b
    abs   v2.16b, v2.16b
    abs   v0.16b, v0.16b
    abs   v1.16b, v1.16b
    abs   v3.16b, v3.16b
    abs   v5.16b, v5.16b
    abs   v6.16b, v6.16b
    abs   v7.16b, v7.16b
    add   v0.16b, v4.16b, v0.16b
    add   v1.16b, v5.16b, v1.16b
    add   v2.16b, v6.16b, v2.16b
    add   v3.16b, v7.16b, v3.16b
    ret

由于 Neon 的向量寄存器只有 128 位,是 AVX zmm 寄存器的四分之一,故原本只需一次的操作要重复四次。参数 a, b 通过 v0 到 v7 寄存器传入,返回值通过 v0 到 v3 传出。没有额外的操作。

  • AvxToSve
f:                   // @f
    stp   x29, x30, [sp, #-16]!      // 16-byte Folded Spill
    mov   x29, sp
    sub   x9, sp, #240
    and   sp, x9, #0xffffffffffffffc0
    ldp   q0, q1, [x0]
    mov   w9, #64
    add   x12, sp, #64
    rdvl  x10, #1
    add   x11, sp, #128
    whilelo p0.b, xzr, x9
    lsr   x9, x10, #4
    cmp   x9, #3
    ldp   q2, q3, [x0, #32]
    stp   q0, q1, [sp, #64]
    stp   q2, q3, [sp, #96]
    ld1b  { z0.b }, p0/z, [x12]
    abs   z0.b, p0/m, z0.b
    st1b  { z0.b }, p0, [x11]
    b.hi  .LBB0_4
    mov   w13, #64
    whilelo p0.b, x10, x13
    ld1b  { z0.b }, p0/z, [x12, x10]
    cmp   x9, #1
    abs   z0.b, p0/m, z0.b
    st1b  { z0.b }, p0, [x11, x10]
    b.hi  .LBB0_4
    lsl   x16, x9, #5
    mov   w14, #64
    add   x13, sp, #64
    add   x15, x9, x9, lsl #1
    lsl   x15, x15, #4
    whilelo p0.b, x16, x14
    add   x14, sp, #128
    ld1b  { z0.b }, p0/z, [x13, x16]
    cmp   x15, #63
    abs   z0.b, p0/m, z0.b
    st1b  { z0.b }, p0, [x14, x16]
    b.hi  .LBB0_4
    mov   w16, #64
    whilelo p0.b, x15, x16
    ld1b  { z0.b }, p0/z, [x13, x15]
    abs   z0.b, p0/m, z0.b
    st1b  { z0.b }, p0, [x14, x15]
.LBB0_4:
    ldp   q0, q1, [x1]
    mov   w13, #64
    whilelo p0.b, xzr, x13
    cmp   x9, #3
    ldp   q2, q3, [x1, #32]
    stp   q0, q1, [sp, #64]
    stp   q2, q3, [sp, #96]
    ld1b  { z0.b }, p0/z, [x12]
    mov   x12, sp
    abs   z0.b, p0/m, z0.b
    st1b  { z0.b }, p0, [x12]
    b.hi  .LBB0_8
    mov   w14, #64
    add   x13, sp, #64
    whilelo p1.b, x10, x14
    ld1b  { z0.b }, p1/z, [x13, x10]
    cmp   x9, #1
    abs   z0.b, p1/m, z0.b
    st1b  { z0.b }, p1, [x12, x10]
    b.hi  .LBB0_8
    lsl   x15, x9, #5
    mov   w14, #64
    whilelo p1.b, x15, x14
    add   x14, x9, x9, lsl #1
    ld1b  { z0.b }, p1/z, [x13, x15]
    mov   x13, sp
    lsl   x14, x14, #4
    cmp   x14, #63
    abs   z0.b, p1/m, z0.b
    st1b  { z0.b }, p1, [x13, x15]
    b.hi  .LBB0_8
    mov   w15, #64
    add   x16, sp, #64
    whilelo p1.b, x14, x15
    ld1b  { z0.b }, p1/z, [x16, x14]
    abs   z0.b, p1/m, z0.b
    st1b  { z0.b }, p1, [x13, x14]
.LBB0_8:
    ld1b  { z0.b }, p0/z, [x11]
    ld1b  { z1.b }, p0/z, [x12]
    cmp   x9, #3
    add   z0.b, p0/m, z0.b, z1.b
    st1b  { z0.b }, p0, [x8]
    b.hi  .LBB0_12
    mov   w13, #64
    add   x11, sp, #128
    mov   x12, sp
    whilelo p0.b, x10, x13
    ld1b  { z0.b }, p0/z, [x11, x10]
    ld1b  { z1.b }, p0/z, [x12, x10]
    cmp   x9, #1
    add   z0.b, p0/m, z0.b, z1.b
    st1b  { z0.b }, p0, [x8, x10]
    b.hi  .LBB0_12
    lsl   x10, x9, #5
    mov   w13, #64
    add   x9, x9, x9, lsl #1
    lsl   x9, x9, #4
    whilelo p0.b, x10, x13
    ld1b  { z0.b }, p0/z, [x11, x10]
    ld1b  { z1.b }, p0/z, [x12, x10]
    cmp   x9, #63
    add   z0.b, p0/m, z0.b, z1.b
    st1b  { z0.b }, p0, [x8, x10]
    b.hi  .LBB0_12
    mov   w10, #64
    add   x11, sp, #128
    mov   x12, sp
    whilelo p0.b, x9, x10
    ld1b  { z0.b }, p0/z, [x11, x9]
    ld1b  { z1.b }, p0/z, [x12, x9]
    add   z0.b, p0/m, z0.b, z1.b
    st1b  { z0.b }, p0, [x8, x9]
.LBB0_12:
    mov   sp, x29
    ldp   x29, x30, [sp], #16       // 16-byte Folded Reload
    ret

AvxToSve 产生的汇编代码明显更长。SVE 中涉及内存地址的 intrinsic (如svld1 和 svst1)迫使变量通过内存传入函数,即使 SVE 向量寄存器的位宽大于函数参数的位宽。额外的操作很多。

汇编代码中指令分类统计如下:

平台访存指令数总指令数(除去 ret)总指令数与原生 AVX 的比值
原生 AVX031
AvxToNeon0124
AvxToSve3811739

本例中,AvxToSve 代码总行数比 AvxToNeon 多十倍,而且其中包括很多访存指令(属于调整参数栈或读写 SVE 向量)。虽然我们没有 SVE 机器做性能实验,但几乎可以断定 AvxToSve 无法做到 “性能移植”。

从向量类型定义到函数实现,每一步似乎都是必然之举,无奈结果令人哑然。我想,AvxToSve 的下一步工作可以是解决生成汇编冗长的问题。

本帖最后由 匿名用户 于 2023/11/17 15:15:09 编辑

我要发帖子