向量处理中 GCC 编译器内建函数简介

速度在多媒体、图形和信号处理中至关重要。有时程序员会求助于汇编语言,以从他们的机器中榨取最后一丝速度。GCC 提供了一种介于汇编和标准 C 之间的中间方法,可以在不必完全使用汇编语言的情况下,获得更快的速度和处理器特性:编译器内建函数。本文讨论了 GCC 的编译器内建函数,重点介绍了在三个平台上的向量处理:X86(使用 MMX、SSE 和 SSE2);摩托罗拉(现为飞思卡尔)(使用 Altivec);以及 ARM Cortex-A(使用 Neon)。我们最后会给出一些调试技巧和参考资料。

在此处下载本文的示例代码: https://linuxjournal.cn/files/linuxjournal.com/code/11108.tar

那么,什么是编译器内建函数?

编译器内建函数(有时称为“内置函数”)类似于您常用的库函数,只不过它们是内置在编译器中的。它们可能比常规库函数更快(编译器对它们了解更多,因此可以更好地优化)或处理比库函数更小的输入范围。内建函数还公开了处理器特定的功能,因此您可以将它们用作标准 C 和汇编语言之间的中间层。这使您能够获得类似汇编的功能,但仍然让编译器处理类型检查、寄存器分配、指令调度和调用堆栈维护等细节。一些内置函数是可移植的,另一些则不是——它们是处理器特定的。您可以在 GCC 信息页和包含文件中找到可移植和目标特定内建函数的列表(稍后会详细介绍)。本文重点介绍对向量处理有用的内建函数。

向量和标量

在本文中,向量是数字的有序集合,例如数组。如果向量的所有元素都是同一事物的度量,则称其为均匀向量。非均匀向量的元素代表不同的事物,并且它们的元素必须以不同的方式处理。在软件中,向量有自己的类型和操作。标量是单个值,即大小为 1 的向量。使用向量类型和操作的代码称为向量代码。仅使用标量类型和操作的代码称为标量代码。

向量处理概念

向量处理属于单指令多数据 (SIMD) 的范畴。在 SIMD 中,相同的操作同时发生在所有数据(向量中的值)上。向量中的每个值都是独立计算的。向量运算包括逻辑和数学运算。单个向量内的数学运算称为水平数学运算。两个向量之间的数学运算称为垂直数学运算。

与其写成:10 x 2 = 20,不如将其垂直表示为

                          10
                        x  2
                       ------
                          20

在垂直数学运算中,向量是这些值的行;多个操作同时发生

        -------------------------------
        |  10   |   10  |  10  |  10  |   vector1
        -------------------------------
        -------------------------------
    x   |  2    |   2   |  2   |  2   |   vector2
        -------------------------------
   --------------------------------------
        -------------------------------
        |  20   |   20  |  20  |  20  |   vector3
        -------------------------------

所有 10 都同时乘以所有 2。

因此,要使用 F = (9/5) * C + 32 将摄氏度转换为华氏度,对于摄氏温度向量

       -------------------------------
        |  C0   |   C1  |  C2  |  C3  |   Celsius temperatures vector
        -------------------------------
        -------------------------------
    x   |  9    |   9   |  9   |  9   |   vector2
        -------------------------------
   --------------------------------------
        -------------------------------
        |  p1   |   p2  |  p3  |  p4  |   partial result
        -------------------------------
        -------------------------------
    /   |  5    |   5   |  5   |  5   |   vector3
        -------------------------------
   --------------------------------------
        -------------------------------
        |  p1   |   p2  |  p3  |  p4  |   partial result
        -------------------------------
        -------------------------------
   +    |  32   |   32  |  32  |  32  |   vector4
        -------------------------------
    --------------------------------------

        -------------------------------
        |  F0   |   F1  |  F2  |  F3  |   Fahrenheit temperatures vector
        -------------------------------

饱和算术类似于普通算术,不同之处在于,当运算结果将导致向量中的元素溢出或下溢时,该值将被钳制在范围的末端,而不允许环绕。(例如,255 是最大的无符号字符。在无符号字符的饱和算术中,250 + 10 = 255。)常规算术将允许该值环绕零并变得更小。例如,如果您有一个像素略微亮于最大亮度,则饱和算术很有用。它应该是最大亮度,而不是环绕变暗。

我们将整数数学运算纳入讨论范围。虽然整数数学运算并非向量处理所独有,但在您的向量硬件仅支持整数运算,或者整数数学运算比浮点数学运算快得多时,它会很有用。整数数学运算将是浮点数学运算的近似值,但您可能会获得更快且足够接近的答案。

整数数学运算的第一个选择是重新排列运算。如果您的公式足够简单,您或许可以重新排列运算以保留精度。例如,您可以重新排列

F = (9/5)C + 32
变成
F = (9*C)/5 + 32

只要 9 * C 不会溢出您使用的类型,精度就会保留。当您除以 5 时,精度会丢失;因此在乘法之后执行除法。重新排列可能不适用于更复杂的公式。

第二个选择是缩放数学运算。在缩放数学运算选项中,您可以决定所需的精度,然后将等式两边乘以一个常数,将系数四舍五入或截断为整数,然后使用它进行运算。获得答案的最后一步是除以该常数。例如,如果您想将摄氏度转换为华氏度

F = (9/5)C + 32
  = 1.8C + 32            -- but we can't have 1.8, so multiply by 10

sum = 10F = 18C + 320    -- 1.8 is now 18: now all integer operations

F = sum/10

如果您乘以 2 的幂而不是 10,则可以将最终的除法更改为移位,这几乎肯定更快,但更难理解。(所以不要随意这样做。)

整数数学运算的第三个选择是移位和加法。移位和加法是另一种基于以下思想的方法:浮点乘法可以使用多个移位和加法来实现。因此,我们麻烦的 1.8C 可以近似为

1.0C + 0.5C + 0.25C + ...   OR  C + (C >> 1) + (C >> 2) + ...

同样,它几乎肯定更快,但更难理解。

samples/simple/temperatures*.c 中有整数数学运算的示例,samples/colorconv2/scalar.c 中有移位和加法的示例。

向量类型、编译器和调试器

要使用处理器的向量硬件,请告诉编译器使用内建函数生成 SIMD 代码,包含定义向量类型的文件,并使用向量类型将数据放入向量形式。

编译器 SIMD 命令行参数在表 1 中列出。(本文仅涵盖这些,但 GCC 提供了更多。)

表 1. 用于生成 SIMD 代码的 GCC 命令行选项

处理器/ 选项
X86/MMX/SSE1/SSE2 -mfpmath=sse -mmmx -msse -msse2
ARM Neon -mfpu=neon -mfloat-abi=softfp
飞思卡尔 Altivec -maltivec -mabi=altivec

以下是您需要的包含文件

  • arm_neon.h - ARM Neon 类型和内建函数
  • altivec.h - 飞思卡尔 Altivec 类型和内建函数
  • mmintrin.h - X86 MMX
  • xmmintrin.h - X86 SSE1
  • emmintrin.h - X86 SSE2
X86:MMX、SSE、SSE2 类型和调试

与 MMX、SSE1 和 SSE2 兼容的 X86 具有以下类型

  • MMX:__m64 64 位整数,分解为八个 8 位整数、四个 16 位短整数或两个 32 位整数。
  • SSE1:__m128 128 位:四个单精度浮点数。
  • SSE2:__m128i 128 位任意大小的打包整数,__m128d 128 位:两个双精度浮点数。

由于调试器不知道您如何使用这些类型,因此在 gdb/ddd 中打印 X86 向量变量会显示向量的打包形式,而不是元素集合。要访问各个元素,请告诉调试器如何将打包形式解码为“print (type[]) x”。例如,如果您有


__m64 avariable; /* storing 4 shorts */

您可以告诉 ddd 将各个元素列为短整数,如下所示


print (short[]) avariable

如果您正在使用字符向量,并且希望 gdb 将向量的元素打印为数字而不是字符,则可以使用“/”选项告诉它。例如


print/d acharvector

会将 acharvector 的内容打印为一系列十进制值。

PowerPC Altivec 类型和调试

具有 Altivec(也称为 VMX 和 Velocity Engine)的 PowerPC 处理器在其类型中添加了关键字“vector”。它们都是 16 字节长。以下是一些 Altivec 向量类型

  • vector unsigned char:16 个无符号字符
  • vector signed char:16 个有符号字符
  • vector bool char:16 个无符号字符(0 为假,255 为真)
  • vector unsigned short:8 个无符号短整数
  • vector signed short:8 个有符号短整数
  • vector bool short:8 个无符号短整数(0 为假,65535 为真)
  • vector unsigned int:4 个无符号整数
  • vector signed int:4 个有符号整数
  • vector bool int:4 个无符号整数(0 为假,2^32 -1 为真)
  • vector float:4 个浮点数

调试器将这些向量打印为各个元素的集合。

ARM Neon 类型和调试

在具有 Neon 扩展的 ARM 处理器上,Neon 类型遵循 [type]x[elementcount]_t 模式。类型包括以下列表中的类型

  • uint64x1_t - 单个 64 位无符号整数
  • uint32x2_t - 一对 32 位无符号整数
  • uint16x4_t - 四个 16 位无符号整数
  • uint8x8_t - 八个 8 位无符号整数
  • int32x2_t - 一对 32 位有符号整数
  • int16x4_t - 四个 16 位有符号整数
  • int8x8_t - 八个 8 位有符号整数
  • int64x1_t - 单个 64 位有符号整数
  • float32x2_t - 一对 32 位浮点数
  • uint32x4_t - 四个 32 位无符号整数
  • uint16x8_t - 八个 16 位无符号整数
  • uint8x16_t - 16 个 8 位无符号整数
  • int32x4_t - 四个 32 位有符号整数
  • int16x8_t - 八个 16 位有符号整数
  • int8x16_t - 16 个 8 位有符号整数
  • uint64x2_t - 一对 64 位无符号整数
  • int64x2_t - 一对 64 位有符号整数
  • float32x4_t - 四个 32 位浮点数
  • uint32x4_t - 四个 32 位无符号整数
  • uint16x8_t - 八个 16 位无符号整数

调试器将这些向量打印为各个元素的集合。

samples/simple 目录中有这些类型的示例。

现在我们已经介绍了向量类型,接下来讨论向量程序。

正如 Ian Ollman 指出的那样,向量程序是位块传输器。它们从内存加载数据,处理数据,然后将其存储到其他内存位置。在内存和向量寄存器之间移动数据是必要的,但这是开销。从内存中获取大量数据,处理数据,然后将其写回内存将最大限度地减少这种开销。

对齐是数据移动的另一个需要注意的方面。使用 GCC 的“aligned”属性将数据源和目标对齐到 16 位边界,以获得最佳性能。例如


float anarray[4] __attribute__((aligned(16))) = { 1.2, 3.5, 1.7, 2.8 };

不对齐可能会导致获得正确的答案、静默地获得错误的答案或崩溃。有一些技术可用于处理未对齐的数据,但它们比使用对齐的数据慢。示例代码中包含这些示例。

示例代码使用内建函数在 X86、Altivec 和 Neon 上进行向量运算。这些内建函数遵循命名约定,使其更易于解码。以下是命名约定

Altivec 内建函数以“vec_”为前缀。C++ 风格的重载适应不同的类型参数。

Neon 内建函数遵循命名方案 [opname][flags]_[type]。“q”标志表示它对四字(128 位)向量进行操作。

X86 内建函数遵循命名约定 _mm_[opname]_[suffix]

    suffix    s single-precision floating point
              d double-precision floating point
              i128 signed 128-bit integer
              i64 signed 64-bit integer
              u64 unsigned 64-bit integer
              i32 signed 32-bit integer
              u32 unsigned 32-bit integer
              i16 signed 16-bit integer
              u16 unsigned 16-bit integer
              i8 signed 8-bit integer
              u8 unsigned 8-bit integer
              pi# 64-bit vector of packed #-bit integers
              pu# 64-bit vector of packed #-bit unsigned integers
              epi# 128-bit vector of packed #-bit unsigned integers
              epu# 128-bit vector of packed #-bit unsigned integers
              ps 128-bit vector of packed single precision floats
              ss 128-bit vector of one single precision float
              pd 128-bit vector of double precision floats
              sd 128-bit vector of one double precision (128-bit) float
              si64 64-bit vector of single 64-bit integer
              si128 128 bit vector

表 2 列出了示例代码中使用的内建函数。

表 2. 示例中使用的向量运算符和内建函数的子集。

操作 Altivec Neon MMX/SSE/SSE2
加载 vec_ld vld1q_f32 _mm_set_epi16
向量 vec_splat vld1q_s16 _mm_set1_epi16
vec_splat_s16 vsetq_lane_f32 _mm_set1_pi16
vec_splat_s32 vld1_u8 _mm_set_pi16
vec_splat_s8 vdupq_lane_s16 _mm_load_ps
vec_splat_u16 vdupq_n_s16 _mm_set1_ps
vec_splat_u32 vmovq_n_f32 _mm_loadh_pi
vec_splat_u8 vset_lane_u8 _mm_loadl_pi
存储 vec_st vst1_u8
向量 vst1q_s16 _mm_store_ps
vst1q_f32
vst1_s16
加法 vec_madd vaddq_s16 _mm_add_epi16
vec_mladd vaddq_f32 _mm_add_pi16
vec_adds vmlaq_n_f32 _mm_add_ps
减法 vec_sub vsubq_s16
乘法 vec_madd vmulq_n_s16 _mm_mullo_epi16
vec_mladd vmulq_s16 _mm_mullo_pi16
vmulq_f32 _mm_mul_ps
vmlaq_n_f32
算术移位 vec_sra vshrq_n_s16 _mm_srai_epi16
移位 vec_srl _mm_srai_pi16
vec_sr
字节 vec_perm vtbl1_u8 _mm_shuffle_pi16
置换 vec_sel vtbx1_u8 _mm_shuffle_ps
vec_mergeh vget_high_s16
vec_mergel vget_low_s16
vdupq_lane_s16
vdupq_n_s16
vmovq_n_f32
vbsl_u8
类型 vec_cts vmovl_u8 _mm_packs_pu16
转换 vec_unpackh vreinterpretq_s16_u16
vec_unpackl vcvtq_u32_f32
vec_cts vqmovn_s32 _mm_cvtps_pi16
vec_ctu vqmovun_s16 _mm_packus_epi16
vqmovn_u16
vcvtq_f32_s32
vmovl_s16
vmovq_n_f32
向量 vec_pack vcombine_u16
组合 vec_packsu vcombine_u8
vcombine_s16
最大值 _mm_max_ps
最小值 _mm_min_ps
向量 _mm_andnot_ps
逻辑 _mm_and_ps
_mm_or_ps
舍入 vec_trunc
杂项 _mm_empty
编写向量代码的建议

权衡利弊

使用内建函数编写向量代码会迫使您进行权衡。您的程序将在标量运算和向量运算之间取得平衡。您是否有足够的工作量让向量硬件值得使用?您必须权衡 C 的可移植性与对速度的需求以及向量代码的复杂性,尤其是在您维护标量代码和向量代码的代码路径时。您必须判断对速度的需求与准确性之间的关系。可能是整数数学运算足够快且足够准确以满足需求。做出这些决策的一种方法是进行测试:编写带有标量代码路径和向量代码路径的程序,并比较两者。

数据结构

首先,假设您将使用内建函数来布局数据结构。这意味着对齐数据项。如果可以为均匀向量排列数据,请这样做。

编写可移植的标量代码并进行性能分析

接下来,编写您的可移植标量代码并进行性能分析。这将是您用于正确性的参考代码和向量代码的基准时间。分析代码的性能将显示瓶颈在哪里。制作瓶颈的向量版本。

编写向量代码

当您编写向量代码时,请按架构将不可移植的代码分组到单独的文件中。为每个架构编写单独的 Makefile。这样可以轻松选择要编译的文件,并为每个架构向编译器提供参数。最大限度地减少标量代码和向量代码的混合。

如果使用 #ifdef,请使用编译器提供的符号

对于多个架构通用的文件,但具有架构特定的部分,您可以使用 #ifdef 和编译器在 SIMD 指令可用时提供的符号。 这些是

  • __MMX__ -- X86 MMX
  • __SSE__ -- X86 SSE
  • __SSE2__ -- X86 SSE2
  • __VEC__ -- altivec 函数
  • __ARM_NEON__ -- neon 函数

要查看为其他处理器定义的基线宏


touch emptyfile.c
gcc -E -dD emptyfile.c | more

要查看为 SIMD 添加的内容,请使用编译器的 SIMD 命令行参数执行此操作(请参阅表 1)。例如


touch emptyfile.c
gcc -E -dD emptyfile.c -mmmx -msse  -msse2 -mfpmath=sse | more

然后比较两个结果。

在运行时检查处理器

接下来,您的代码应在运行时检查您的处理器,以查看您是否拥有向量支持。如果您没有该处理器的向量代码路径,请回退到您的标量代码。如果您有向量支持,并且向量支持更快,请使用向量代码路径。使用 <cpuid.h> 中的 cpuid 指令测试 X86 上的处理器功能。(您在 samples/simple/x86/*c 中看到了示例。)我们找不到针对 Altivec 和 Neon 的成熟方法,因此那里的示例解析 /proc/cpuinfo。(严肃的代码可能会插入测试 SIMD 指令。如果处理器在遇到该测试指令时抛出 SIGILL 信号,则您不具备该功能。)

测试、测试、再测试

测试所有内容。测试时间:查看您的标量代码或向量代码是否更快。测试结果是否正确:将向量代码的结果与标量代码的结果进行比较。在不同的优化级别进行测试:程序的行为可能会在不同的优化级别下发生变化。针对代码的整数数学运算版本进行测试。最后,注意编译器错误。GCC 的 SIMD 和内建函数仍在开发中。

这使我们进入了最后一个代码示例。在 samples/colorconv2 中是一个颜色空间转换库,它采用非平面 YUV422 图像并将其转换为 RGBA。它在 PowerPC 上使用 Altivec 运行;ARM Cortex-A 使用 Neon 运行;以及 X86 使用 MMX、SSE 和 SSE2 运行。(我们在运行 Fedora 12 的 PowerMac G5、运行 Angstrom 2009.X-test-20090508 的 Beagleboard 和运行 Fedora 10 的 Pentium 3 上进行了测试。)Colorconv 检测 CPU 功能并使用针对它们的代码。如果未检测到受支持的功能,它将回退到标量代码。

要构建,请解压源文件并运行 make。Make 使用“uname”命令来查找特定于架构的 Makefile。(不幸的是,Beagleboard 上的 Angstrom 的 uname 返回“unknown”,这就是目录的名称。)

测试程序与库一起构建。Testrange 将标量代码的结果与向量代码的结果在整个输入范围内进行比较。Testcolorconv 运行计时测试,比较它可用的代码路径(内建函数和标量代码),以便您可以查看哪个运行更快。

最后,这里有一些性能提示。

首先,获取最新的编译器并使用最佳的代码生成选项。(查看编译器附带的信息页,了解 -mcpu 选项等内容。)

其次,分析您的代码的性能。人类不擅长猜测瓶颈在哪里。修复瓶颈,而不是其他部分。

第三,通过使用数据可以容纳的最窄类型元素的向量,从每个向量运算中获得尽可能多的工作。通过拥有足够的工作来保持向量硬件忙碌,从而在每个时间片中获得尽可能多的工作。大量获取数据。如果您的向量硬件可以同时处理大量向量,请使用它们。但是,超出您可用的向量寄存器数量会降低速度。(查看处理器的文档。)

第四,不要重新发明轮子。Intel、Freescale 和 ARM 都提供库和代码示例来帮助您充分利用他们的处理器。这些包括 Intel 的 Integrated Performance Primitives、Freescale 的 libmotovec 和 ARM 的 OpenMAX。

总结

总而言之,GCC 提供了内建函数,使您无需完全使用汇编语言即可从处理器获得更多性能。我们已经介绍了基本类型和一些向量数学函数。当您使用内建函数时,请确保彻底测试。针对代码的标量版本测试速度和正确性。每个处理器的不同功能以及它们的运行良好程度意味着这是一个广阔的领域。您投入的精力越多,您获得的就越多。

参考资料

将内建函数映射到编译器内置函数(例如 arm_neon.h)的 GCC 包含文件以及解释这些内置函数的 GCC 信息页

http://gcc.gnu.org/onlinedocs/gcc/Target-Builtins.html


http://ds9a.nl/gcc-simd/
http://softpixel.com/~cwright/programming/simd/index.php

http://infocenter.arm.com/help/index.jsp?topic=/com.arm.doc.dht0002a/BABCJFDG.html
http://www.arm.com/products/processors/technologies/neon.php
http://infocenter.arm.com/help/index.jsp?topic=/com.arm.doc.dht0002a/ch01s04s02.html
http://infocenter.arm.com/help/index.jsp?topic=/com.arm.doc.dui0205j/BABGHIFH.html

http://www.tommesani.com/Docs.html
https://linuxjournal.cn/article/7269

http://developer.apple.com/hardwaredrivers/ve/sse.html
http://en.wikipedia.org/wiki/Multiplication_algorithm#Shift_and_add
http://www.ibm.com/developerworks/power/library/pa-unrollav1/
http://en.wikipedia.org/wiki/MMX_(instruction_set)

集成性能原语
http://software.intel.com/en-us/articles/intel-ipp/
http://software.intel.com/en-us/articles/non-commercial-software-download/

OpenMAX
http://www.khronos.org/developers/resources/openmax

适用于 Linux 的飞思卡尔 AltiVec 库
http://www.freescale.com/webapp/sps/site/overview.jsp?code=DRPPCNWALTVCLIB


AltiVec TM 技术编程接口手册
http://www.freescale.com/files/32bit/doc/ref_manual/ALTIVECPIM.pdf

http://developer.apple.com/hardwaredrivers/ve/instruction_crossref.html

Ian Ollmann 的 Altivec 教程
http://www-linux.gsi.de/~ikisel/reco/Systems/Altivec.pdf
http://arstechnica.com/civis/viewtopic.php?f=19&t=381165

RealView Compilation Tools Compiler Reference Guide(尤其是附录 E)
http://infocenter.arm.com/help/index.jsp?topic=/com.arm.doc.dui0348c/index.html

RealView Compilation Tools Assembler Guide(特别是第 5 章)
http://infocenter.arm.com/help/index.jsp?topic=/com.arm.doc.dui0204j/index.html

Intel C++ 内建函数参考

http://software.intel.com/sites/default/files/m/9/4/c/8/e/18072-347603.pdf
加载 Disqus 评论