寄存器内 SIMD (单指令流多数据流) (SWAR) 并不是一个新概念。对于具有 k 位寄存器、数据路径和功能单元的机器,人们早就知道,普通的寄存器操作可以作为 SIMD 并行操作,作用于 n 个 k/n 位整数域值。然而,直到最近对多媒体技术的推动,SWAR 技术提供的 2 倍至 8 倍的加速才成为主流计算关注的问题。大多数微处理器的 1997 版本都集成了对 SWAR 的硬件支持
新型微处理器提供的硬件支持存在一些漏洞,例如仅支持某些字段大小的某些操作。然而,重要的是要记住,对于许多 SWAR 操作的高效执行,您不需要任何硬件支持。例如,位运算不受寄存器的逻辑分区的影响。
虽然每个现代处理器都能够执行至少某种程度的 SWAR 并行性,但可悲的事实是,即使是最好的 SWAR 增强指令集也不支持非常通用的并行性。事实上,许多人已经注意到,奔腾和“带有 MMX 技术的奔腾”之间的性能差异通常是由于诸如与 MMX 的出现同时出现的更大的 L1 缓存之类的因素造成的。那么,实际上,SWAR (或 MMX) 有什么用呢?
x[y]
(其中 y
是索引数组) 的成本高得令人望而却步。这些是严重的限制,但这种类型的并行性出现在许多并行算法中——不仅仅是多媒体应用程序。对于合适的算法类型,SWAR 比 SMP 或集群并行性更有效……而且使用它不需要任何成本。
SWAR (寄存器内 SIMD) 的基本概念是,对字长寄存器的操作可以用于加速计算,通过对 n 个 k/n 位字段值执行 SIMD 并行操作。然而,使用 SWAR 技术可能很麻烦,并且一些 SWAR 操作实际上比相应的串行操作序列更昂贵,因为它们需要额外的指令来强制执行字段分区。
为了说明这一点,让我们考虑一个大大简化的 SWAR 机制,该机制在每个 32 位寄存器中管理四个 8 位字段。两个寄存器中的值可以表示为
PE3 PE2 PE1 PE0 +-------+-------+-------+-------+ Reg0 | D 7:0 | C 7:0 | B 7:0 | A 7:0 | +-------+-------+-------+-------+ Reg1 | H 7:0 | G 7:0 | F 7:0 | E 7:0 | +-------+-------+-------+-------+
这简单地表明每个寄存器都被视为本质上是四个独立的 8 位整数值的向量。或者,可以将 A
和 E
视为处理单元 0 (PE0) 的 Reg0 和 Reg1 中的值,将 B
和 F
视为 PE1 寄存器中的值,依此类推。
本文档的其余部分简要回顾了这些整数向量上的 SIMD 并行操作的基本类别,以及如何实现这些功能。
一些 SWAR 操作可以使用普通的 32 位整数操作轻松执行,而无需考虑操作实际上旨在并行地独立作用于这些 8 位字段的事实。我们将任何此类 SWAR 操作称为多态操作,因为该功能不受字段类型(大小)的影响。
测试任何字段是否为非零值是多态的,所有位逻辑运算也是如此。例如,普通的按位与运算(C 的 &
运算符)执行按位与,无论字段大小如何。上述寄存器的简单按位与运算产生
PE3 PE2 PE1 PE0 +---------+---------+---------+---------+ Reg2 | D&H 7:0 | C&G 7:0 | B&F 7:0 | A&E 7:0 | +---------+---------+---------+---------+
由于按位与运算的结果位 k 的值始终仅受操作数位 k 值的影响,因此使用相同的单条指令支持所有字段大小。
不幸的是,许多重要的 SWAR 操作不是多态的。算术运算(例如加法、减法、乘法和除法)都受到字段之间进位/借位交互的影响。我们将此类 SWAR 操作称为分区操作,因为每个此类操作都必须有效地对操作数和结果进行分区,以防止字段之间的交互。然而,实际上有三种不同的方法可以用来实现这种效果。
实现分区操作的最明显方法可能是为“分区并行指令”提供硬件支持,这些指令可以切断字段之间的进位/借位逻辑。这种方法可以产生最高的性能,但它需要更改处理器的指令集,并且通常对字段大小施加许多限制(例如,可能支持 8 位字段,但不支持 12 位字段)。
AMD/Cyrix/Intel MMX、Digital MAX、HP MAX 和 Sun VIS 都实现了受限版本的分区指令。不幸的是,这些不同的指令集扩展具有显着不同的限制,使得算法在它们之间有些不可移植。例如,考虑以下分区操作的示例
Instruction AMD/Cyrix/Intel MMX DEC MAX HP MAX Sun VIS +---------------------+---------------------+---------+--------+---------+ | Absolute Difference | | 8 | | 8 | +---------------------+---------------------+---------+--------+---------+ | Merge Maximum | | 8, 16 | | | +---------------------+---------------------+---------+--------+---------+ | Compare | 8, 16, 32 | | | 16, 32 | +---------------------+---------------------+---------+--------+---------+ | Multiply | 16 | | | 8x16 | +---------------------+---------------------+---------+--------+---------+ | Add | 8, 16, 32 | | 16 | 16, 32 | +---------------------+---------------------+---------+--------+---------+
在表中,数字表示每个操作支持的字段大小(以位为单位)。即使该表省略了许多指令,包括所有更奇特的指令,也很明显存在许多差异。直接结果是,高级语言 (HLL) 实际上作为编程模型不是很有效,并且可移植性通常很差。
使用分区指令实现分区操作当然可以很有效,但是如果您需要的分区操作不受硬件支持,您该怎么办?答案是您使用一系列普通指令来执行操作,其中字段之间存在进位/借位,然后校正不需要的字段交互。
这是一种纯软件方法,校正确实会引入开销,但它适用于完全通用的字段分区。这种方法也是完全通用的,因为它既可以用于填补硬件对分区指令支持的空白,也可以用于为根本没有硬件支持的目标机器提供完整的功能。事实上,通过以 C 语言之类的语言表达代码序列,这种方法允许 SWAR 程序完全可移植。
问题立即出现:使用带有校正代码的非分区操作来模拟 SWAR 分区操作究竟有多低效?嗯,这当然是价值 6.4 万美元的问题……但许多操作并没有人们想象的那么困难。
考虑使用普通 32 位操作实现两个源向量 x
+y
的四元素 8 位整数向量加法。
普通的 32 位加法实际上可能会产生正确的结果,但前提是没有 8 位字段进位到下一个字段。因此,我们的目标只是确保不会发生此类进位。由于添加两个 k 位字段会生成最多 k+1 位的结果,因此我们可以通过简单地“屏蔽”每个字段的最高有效位来确保不发生进位。这通过将每个操作数与 0x7f7f7f7f
进行按位与运算,然后执行普通的 32 位加法来完成。
t = ((x & 0x7f7f7f7f) + (y & 0x7f7f7f7f));
该结果是正确的……除了每个字段中的最高有效位。计算每个字段的正确值只是将来自 x
和 y
的最高有效位与为 t
计算的 7 位进位结果进行两次 1 位分区加法的问题。幸运的是,1 位分区加法由普通的异或运算实现。因此,结果很简单
(t ^ ((x ^ y) & 0x80808080))
好吧,也许这并没有那么简单。毕竟,做四个加法需要六个操作。但是,请注意,操作的数量与字段的数量无关……因此,字段越多,我们获得的加速就越多。事实上,我们仍然可能获得加速,仅仅是因为字段是以单个(整数向量)操作加载和存储的,寄存器可用性可能会提高,并且动态代码调度依赖性更少(因为避免了部分字引用)。
虽然实现分区操作的其他两种方法都集中在获得寄存器的最大空间利用率上,但控制字段值以使字段间进位/借位事件永远不会发生可能在计算上更有效。例如,如果我们知道所有要相加的字段值都使得不会发生字段溢出,则可以使用普通的加法指令来实现分区加法运算;事实上,给定此约束,普通的加法指令似乎是多态的,并且可用于任何字段大小而无需校正代码。因此,问题变为如何确保字段值不会导致进位/借位事件。
确保此属性的一种方法是实现可以限制字段值范围的分区指令。Digital MAX 向量最小值和最大值指令可以被视为硬件支持,用于裁剪字段值以避免字段间进位/借位。
但是,假设我们没有可以有效限制字段值范围的分区指令……是否存在可以廉价地施加的充分条件来确保进位/借位事件不会干扰相邻字段?答案在于算术属性的分析。添加两个 k 位数字会生成最多 k+1 位的结果;因此,k+1 位的字段可以安全地包含这样的操作,尽管使用了普通指令。
因此,假设我们先前示例中的 8 位字段现在是 7 位字段,带有 1 位“进位/借位分隔符”
PE3 PE2 PE1 PE0 +----+-------+----+-------+----+-------+----+-------+ Reg0 | D' | D 6:0 | C' | C 6:0 | B' | B 6:0 | A' | A 6:0 | +----+-------+----+-------+----+-------+----+-------+
7 位向量加法执行如下。让我们假设,在任何分区操作开始之前,所有进位分隔符位(A'
、B'
、C'
和 D'
)的值都为 0。通过简单地执行普通的加法运算,所有字段都获得正确的 7 位值;但是,一些分隔符位值现在可能为 1。我们可以通过再进行一次常规操作来纠正这一点,即屏蔽分隔符位。因此,我们的 7 位整数向量加法 x
+y
是
((x + y) & 0x7f7f7f7f)
这只是两条指令用于四个加法,显然产生了良好的加速。
敏锐的读者可能已经注意到,将分隔符位设置为 0 不适用于减法运算。然而,校正非常简单。要计算 x
-y
,我们只需确保初始条件,即 x
中的分隔符都为 1,而 y
中的分隔符都为 0。在最坏的情况下,我们因此会得到
(((x | 0x80808080) - y) & 0x7f7f7f7f)
然而,通过确保生成 x
值的操作使用 | 0x80808080
而不是 & 0x7f7f7f7f
作为最后一步,通常可以优化掉额外的按位或运算。
哪种方法应该用于 SWAR 分区操作?答案很简单,“哪种方法产生的加速效果最好”。有趣的是,对于在同一台机器上运行的同一程序中不同的字段大小,使用的理想方法可能不同。
虽然一些并行计算(包括许多对图像像素的操作)具有向量中的第 i 个值仅是操作数向量中第 i 个位置出现的值的函数的属性,但这通常不是这种情况。例如,即使是像素操作(例如平滑)也需要来自相邻像素的值作为操作数,而诸如 FFT 之类的变换则需要更复杂(本地化程度较低)的通信模式。
使用非分区移位操作为 SWAR 有效地实现一维最近邻通信并不困难。例如,要将值从 PE
i 移动到 PE
(i+1),简单的移位操作就足够了。如果字段长度为 8 位,我们将使用
(x << 8)
尽管如此,事情并不总是那么简单。例如,要将值从 PE
i 移动到 PE
(i-1),简单的移位操作可能就足够了……但是 C 语言没有指定右移是否保留符号位,并且有些机器只提供带符号右移。因此,在一般情况下,我们必须显式地将可能复制的符号位清零
((x >> 8) & 0x00ffffff)
使用非分区移位添加“环绕连接”也相当有效。例如,要将值从 PE
i 移动到 PE
(i+1) 并进行环绕
((x << 8) | ((x >> 24) & 0x000000ff))
真正的问题出现在必须实现更通用的通信模式时。只有 HP MAX 指令集支持使用单条指令任意重新排列字段,这称为 Permute
。这个 Permute
指令实际上被误命名了;它不仅可以执行字段的任意排列,而且还允许重复。简而言之,它实现了任意的 x[y]
操作。
不幸的是,在没有此类指令的情况下,x[y]
非常难以实现。代码序列通常既长又低效;实际上,它是顺序代码。这非常令人失望。MasPar MP1/MP2 和 Thinking Machines CM1/CM2/CM200 SIMD 超级计算机中 x[y]
操作的相对高速度是这些机器性能良好的关键原因之一。然而,即使在那些超级计算机上,x[y]
也总是比最近邻通信慢,因此许多算法的设计都旨在最大限度地减少对 x[y]
操作的需求。简而言之,在没有硬件支持的情况下,最好开发 SWAR 算法,就好像 x[y]
不是合法的……或者至少不便宜一样。
递归是一种计算,其中正在计算的值之间存在明显的顺序关系。然而,如果这些递归涉及结合律运算,则可以使用树形结构的并行算法重新编码计算。
最常见的可并行化递归类型可能是称为结合律归约的类别。例如,要计算向量值的总和,人们通常编写纯粹的顺序 C 代码,例如
t = 0; for (i=0; i<MAX; ++i) t += x[i];
然而,加法的顺序很少重要。如果加法顺序改变,浮点和饱和数学可能会产生不同的答案,但普通的环绕整数加法将产生相同的结果,而与加法顺序无关。因此,我们可以将此序列重写为树形结构的并行求和,其中我们首先将值对相加,然后将那些部分和对相加,依此类推,直到产生单个最终和。对于四个 8 位值的向量,只需要两个加法步骤;第一步执行两个 8 位加法,产生两个 16 位结果字段(每个字段包含一个 9 位结果)
t = ((x & 0x00ff00ff) + ((x >> 8) & 0x00ff00ff));
第二步将这两个 9 位值在 16 位字段中相加,以产生单个 10 位结果
((t + (t >> 16)) & 0x000003ff)
实际上,第二步执行两个 16 位字段加法……但是顶部 16 位加法没有意义,这就是为什么结果被屏蔽为单个 10 位结果值的原因。
扫描,也称为“并行前缀”操作,实现效率更高一些。这是因为,与归约不同,扫描产生分区结果。因此,可以使用相当明显的分区操作序列来实现扫描。
对于 Linux,IA32 处理器是我们主要关注的对象。好消息是 AMD、Cyrix 和 Intel 都实现了相同的 MMX 指令。然而,MMX 性能各不相同;例如,K6 只有一个 MMX 流水线——带有 MMX 的奔腾有两个。唯一真正糟糕的消息是,英特尔仍在播放那些愚蠢的 MMX 商业广告…… ;-)
使用 MMX for SWAR 实际上有三种方法
总而言之,MMX SWAR 仍然难以使用。然而,稍微付出额外的努力,现在就可以使用上面给出的第二种方法。以下是基本知识
inline extern int mmx_init(void) { int mmx_available; __asm__ __volatile__ ( /* Get CPU version information */ "movl $1, %%eax\n\t" "cpuid\n\t" "andl $0x800000, %%edx\n\t" "movl %%edx, %0" : "=q" (mmx_available) : /* no input */ ); return mmx_available; }
unsigned long long
类型的值之一。因此,此类型的基于内存的变量成为您的 MMX 模块和调用它们的 C 程序之间的通信机制。或者,您可以将 MMX 数据声明为任何 64 位对齐的数据结构(通过将数据类型声明为带有 unsigned long long
字段的 union
来确保 64 位对齐非常方便)。.byte
汇编器指令编写 MMX 代码来编码每条指令。手工完成这项工作很痛苦,但对于编译器来说并不难生成。例如,MMX 指令 PADDB MM0,MM1
可以编码为 GCC 内联汇编代码__asm__ __volatile__ (".byte 0x0f, 0xfc, 0xc1\n\t");
EMMS
指令退出您的 MMX 代码,该指令可以编码为__asm__ __volatile__ (".byte 0x0f, 0x77\n\t");
如果以上内容看起来非常笨拙和粗糙,那确实如此。然而,MMX 仍然非常年轻……本文档的未来版本将提供更好的 MMX SWAR 编程方法。