在 Cell BE 处理器上编写高性能的应用程序,第 5 部分: 使用 C/C++ 对 SPU 进行编程

使用语言扩展加速应用程序

在 Cell BE 处理器上编写高性能的应用程序 系列文章的第 5 部分中,我们将应用您对于 SPU(synergistic processing unit)的知识使用 C/C++ 语言对 Cell Broadband Engine™(Cell BE)处理器进行编程。您将学习如何使用向量扩展,引导编译器进行分支预测,并使用 C/C++ 实现 DMA 传输。

Jonathan Bartlett (johnnyb@eskimo.com), 技术总监, New Medio

Jonathan Bartlett 是 Programming from the Ground Up 一书的作者,这本书对使用 Linux 汇编语言的编程进行了简介。他是 New Medio 的开发技术总监,为客户开发 Web 应用程序、视频应用程序、kiosk 应用程序和桌面应用程序。



2007 年 5 月 24 日

前面有关 SPU 的讨论主要集中在 SPU 的汇编语言上,从而帮助您更加深入地了解处理器。现在我们将切换到 C/C++ 上来,这样就可以看到如何让编译器为您执行大量工作。为了利用 C/C++ 语言扩展,必须在代码的开头包含头文件 spu_intrinsics.h

SPU 的向量基础

向量处理器和非向量处理器之间的主要区别是,向量处理器有大量寄存器,这让它们可以存储很多相同数据类型的值(称为元素),并使用同一操作对这些数据同时进行处理。在向量处理器上,寄存器可以作为一个单元进行处理,也可以作为多个单元进行处理。为了使用 C/C++ 来表示这种概念,vector 关键字被添加到 C/C++ 语言中,它是一个原始数据类型,可以在整个寄存器上使用。举例来说,vector unsigned int myvec; 创建一个包含 4 个整数的向量,其中的数据是一起进行加载、处理和存储的,变量 myvec 会同时引用这 4 个值。signed/unsigned 关键字是声明非浮点类型必需的。向量常量的创建方法如下:先是在圆括号中的向量类型,然后是使用花括号括起来的向量内容。举例来说,可以采用下面的方式给一个名为 myvec 的向量赋值:

vector unsigned int myvec = (vector unsigned int){1, 2, 3, 4};

除了直接赋值之外,还有 4 个主要原语可以用来在标量和向量数据之间进行转换:spu_insertspu_extractspu_promotespu_splatsspu_insert 用来将一个标量值放入向量的某个特定元素中。spu_insert(5, myvec, 0) 返回一个向量 myvec 的拷贝,这个新向量的第一个元素(元素 0)被设置为 5。spu_extract 会从向量中取出某个指定的元素,并将其作为一个标量返回。spu_extract(myvec, 0)myvec 的第一个元素作为标量返回。spu_promote 将一个值转换成向量,不过只定义了一个元素。向量的类型取决于所提升的值的类型。spu_promote((unsigned int)5, 1) 创建了一个 unsigned int 类型的向量,其中第二个元素(元素 1)为 5,其他元素尚未定义。spu_splats 的工作方式类似于 spu_promote,除了它要将值拷贝到向量的所有 元素中。spu_splats((unsigned int)5) 创建了一个 unsigned int 类型的向量,每个元素的值都是 5。

将向量看作短数组的想法非常有诱惑力,不过实际上它们的操作方式在几个方面都有所不同。向量本质上是作为标量值进行处理的,而数组是作为引用来进行操作的。举例来说,spu_insert不会修改向量的内容。相反,它返回的是向量的一个新拷贝,其中包含了所插入的元素。表达式产生了一个值,而不是对值本身进行了修改。举例来说,就像 myvar + 1 会返回一个新值而不会修改 myvar 一样,spu_insert(1, myvec, 0) 也不会修改 myvec,而是返回一个新向量值,它等于 myvec,不过第一个元素被设置为 1。

下面是使用这种思想的一个小程序(请将下面的代码输入到 vec_test.c 中):

清单 1. 引入 SPU C/C++ 语言扩展的程序
#include <spu_intrinsics.h>

void print_vector(char *var, vector unsigned int val) {
	printf("Vector %s is: {%d, %d, %d, %d}\n", var, spu_extract(val, 0),
	 spu_extract(val, 1), spu_extract(val, 2), spu_extract(val, 3));
}

int main() {
	/* Create four vectors */
	vector unsigned int a = (vector unsigned int){1, 2, 3, 4};
	vector unsigned int b;
	vector unsigned int c;
	vector unsigned int d;

	/* b is identical to a, but the last element is changed to 9 */
	b = spu_insert(9, a, 3);

	/* c has all four values set to 20 */
	c = spu_splats((unsigned int) 20);

	/* d has the second value set to to 5, and the others are garbage */
	/* (in this case they will all be set to 5, but that should not be relied upon) */
	d = spu_promote((unsigned int)5, 1);

	/* Show Results */
	print_vector("a", a);
	print_vector("b", b);
	print_vector("c", c);
	print_vector("d", d);

	return 0;
}

要在 elfspe 下编译并运行这个程序,只需使用下面的命令:

spu-gcc vec_test.c -o vec_test
./vec_test

向量 intrinsic

C/C++ 语言扩展包括了一些可以让程序员几乎能够完全访问 SPU 汇编语言指令的数据类型和 intrinsic 。然而,很多提供的 intrinsic 都通过将多条类似指令合并在一个 intrinsic 中而对 SPU 的汇编语言进行了极大的简化。那些只有操作数的类型会有点儿区别的指令(例如用来计算加法的指令 aaiahahifadfa)都是使用一个 C/C++ intrinsic 表现的,它会根据操作数的类型来选择适当的指令。另外,当 spu_add 被传递两个 vector unsigned int 作为参数时,就会生成一条 a(32 位加法)指令。然而,如果传递两个 vector float 作为参数,就会生成 fa(浮点加法)指令。注意,这种 intrinsic 通常与对应的汇编语言指令具有相同的限制。然而,在直接值对于适当直接模式指令太大时,编译器会将这个直接值提升为一个向量,并执行对应的向量/向量操作。举例来说,spu_add(myvec, 2) 就会生成一条 ai(立即加法)指令,而 spu_add(myvec, 2000) 则首先会使用 il2000 加载到自己的向量中,然后执行 a (加法)指令。

操作数在 intrinsic 中的顺序实际上与汇编语言指令相同,惟一不同的就是:第一个操作数(其中保存了汇编语言中使用的目的寄存器)不需在 C/C++ 中指定,而是用作该函数的返回值。编译器在自己生成的汇编语言中提供了适当的操作数。

下面是比较常见的一些 SPU intrinsic(类型没有给出,这是因为它们大多为多态):

  • spu_add(val1, val2)
    val1 的每个元素与 val2 中的对应元素相加。如果 val2 是一个非向量值,就将该值与 val1 的每个元素相加。
  • spu_sub(val1, val2)
    val2 的每个元素从 val1 的对应元素中减去。如果 val1 是一个非向量值,就将该值在整个向量中复制,然后将 val2 从中减去。
  • spu_mul(val1, val2)
    由于乘法指令的操作差异很大,因此 SPU intrinsic 并不会像对其他操作一样将它们连接在一起。spu_mul 处理浮点乘法(单精度和多精度)。结果是一个向量,其中每个元素都是 val1val2 对应元素相乘的结果。
  • spu_and(val1, val2)spu_or(val1, val2)spu_not(val)spu_xor(val1, val2)spu_nor(val1, val2)spu_nand(val1, val2)spu_eqv(val1, val2)
    逐位进行布尔操作,这样布尔操作接收到的操作数类型除了确定返回值的类型之外,就没什么关系了。spu_eqv 是针对位的相等操作,而不是针对每个元素的相等操作。
  • spu_rl(val, count)spu_sl(val, count)
    spu_rl 会对 val 的每个元素进行向左循环移位,移动的位数是在 count 的对应元素中指定的。移出末端的位都会被移入右边。如果 count 是一个标量值,就用作 val 中所有元素的移动位数。spu_sl 的操作方式相同,不过执行的是移位(shift)操作,而不是循环移位(rotate)操作。
  • spu_rlmask(val, count)spu_rlmaskaspu_rlmaskqw(val, count)spu_rlmaskqwbyte(val, count)
    这些是一些名称非常容易搞混的操作。它们的命名来自 “rotate left and mask(左循环移位和掩码)”,不过实际上执行的是右移位(right shift)(它们是使用左移位和掩码组合实现 的,不过编程接口是为右移位设计的)。spu_rlmaskspu_rlmaska 会将 val 的每个元素向右移位,移动的位数是在 count 中的对应元素中指定的(如果 count 是一个标量,移动的位数就是 count 的值)。在移入位时,spu_rlmaska 会复制符号位。spu_rlmaskqw 会对整个 4 字同时进行操作,不过最多只能移动 7 位(它会对 count 取模,从而将其放入正确的范围内)。spu_rlmaskqwbyte 的工作模式与之类似,除了 count 是字节数而不是位数,count 是对 16 取模,而不是对 8 取模。
  • spu_cmpgt(val1, val2)spu_cmpeq(val1, val2)
    这两条指令对自己的两个操作数逐元素进行比较。结果以全 1(比较为真的情况)或全 0 (比较为假的情况)的形式保存在对应元素的结果向量中。spu_cmpgt 执行的是大于比较,而 spu_cmpeq 执行的是等于比较。
  • spu_sel(val1, val2, conditional)
    它对应于汇编语言的 selb 指令。这条指令本身是基于位的,因此所有类型都使用相同的底层指令。然而,intrinsic 操作会返回与操作数相同类型的值。正如汇编语言中一样,spu_sel 会查看 conditional 中每一位的值。如果该位为 0,结果中的对应位就从 val1 中的对应位进行选择;否则就从 val2 中的对应位进行选择。
  • spu_shuffle(val1, val2, pattern)
    这是一条非常有趣的指令,让您可以根据某种模式对 val1val2 中的字节进行重新安排,模式是在 pattern 中指定的。这条指令会遍历 pattern 中的每个字节,如果这个字节是以位 0b10 开始的,那么结果中的对应字节就被设置为 0x00;如果这个字节是以位 0b110 开始的,那么结果中的对应字节就被设置为 0xff;如果这个字节是以位 0b111 开始的,那么结果中的对应字节就被设置为 0x80;最后(也是最重要的一点),如果前面这三条全不正确,就使用模式字节的最后 5 位来决定应该从 val1val2 中选择哪个字节作为当前字节的值。这两个值被串接在一起,5 位值用作所串接的值的字节索引。这个指令用于向向量中插入元素和执行快速表查询。

所有前缀为 spu_ 的指令都会尝试基于操作数字节查找最佳指令匹配。然而,并非所有指令都可以支持所有的向量类型 —— 这取决于汇编语言指令处理向量的能力。另外,如果希望使用某条特定指令而不希望编译器来选择指令,那么可以使用 specific intrinsic 来执行几乎所有的非分支指令。所有 specific intrinsic 都会采用 si_assemblyinstructionname 的格式,其中 assemblyinstructionname 是 SPU 汇编语言规范中所定义的汇编语言指令的名字。因此,si_a(a, b) 会强制使用 a 指令做加法运算。specific intrinsic 的所有操作数都会映射为一个称为 qword 的特殊类型,它实际上是一个 opaque 寄存器值类型。specific intrinsic 的返回值也是 qword 类型的,它可以映射到您希望使用的任何向量类型。


使用 intrinsic

接下来,让我们看一下如何使用 C/C++(而不是汇编语言)来实现大写转换函数。转换一个向量的基本步骤如下:

  1. 使用大写转换来转换所有值。
  2. 对所有字节进行向量比较,查看它们是否在 'a''z' 的范围之内。
  3. 利用比较操作的结果,使用选择指令在已经转换和尚未转换的值之间进行选择。

另外,为了帮助更好地对指令进行调度,汇编语言版本还同时执行了几个转换。在 C/C++ 中,可以多次调用一个内联函数,让编译器来适当地解决调度问题。这并不意味着指令调度的知识没什么用处,相反,由于您了解了指令调度是如何工作的,因此可以为编译器提供可以更好利用的原始材料。如果不清楚指令调度可以对代码进行改进,以及指令调度可以通过展开循环来提供一些帮助,那么就无法帮助编译器对代码进行优化了。

下面是一个 C/C++ 版本的 convert_buffer_to_upper 函数(请将下面的代码输入到 convert_buffer_c.c 中,并将其放到与之前文章中的文件相同的目录下,这样才能编译整个应用程序):

清单 2. 使用 C/C++ 编写的大写转换函数
#include <spu_intrinsics.h>

unsigned char conversion_value = 'a' - 'A';

inline vec_uchar16 convert_vec_to_upper(vec_uchar16 values) {
	/* Process all characters */
	vec_uchar16 processed_values = spu_absd(values, spu_splats(conversion_value));
	/* Check to see which ones need processing (those between 'a' and 'z')*/
	vec_uchar16 should_be_processed = spu_xor(spu_cmpgt(values, 'a'-1), 
	spu_cmpgt(values, 'z'));
	/* Use should_be_processed to select between the original and processed values */
	return spu_sel(values, processed_values, should_be_processed);
}

void convert_buffer_to_upper(vec_uchar16 *buffer, int buffer_size) {
	/* Find end of buffer (must be casted first because size is bytes) */
	vec_uchar16 *buffer_end = (vec_uchar16 *)((char *)buffer + buffer_size);

	while(__builtin_expect(buffer < buffer_end, 1)) {
		*buffer = convert_vec_to_upper(*buffer);
		buffer++;
		*buffer = convert_vec_to_upper(*buffer);
		buffer++;
		*buffer = convert_vec_to_upper(*buffer);
		buffer++;
		*buffer = convert_vec_to_upper(*buffer);
		buffer++;
	}
}

要编译并运行这段程序,只需执行以下步骤:

spu-gcc convert_buffer_c.c convert_driver.s dma_utils.s -o spe_convert
embedspu -m64 convert_to_upper_handle spe_convert spe_convert_csf.o
gcc -m64 spe_convert_csf.o ppu_dma_main.c -lspe -o dma_convert
./dma_convert

正如您可能已经注意到的,这个程序对向量类型名使用的符号与前面使用的符号稍有不同。SPU intrinsic 文档(请参看 参考资料)定义了一些以 vec_ 开头的简化向量类型名。对于整数类型来说,下一个字符是 us,分别针对有符号和无符号类型。之后是所使用的基本类型名(charintfloat 等)。最后是向量中该类型的元素的个数。举例来说,vec_uchar16 是一个 unsigned char 类型的 16 元素的向量,而 vec_float4 是一个 float 类型的 4 元素的向量。这种符号可以极大简化所需要的输入工作。

在计算 buffer_end 时,程序进行了一些转换工作。由于 size 是按照字节计算的,因此我们需要将指针转换成一个 char * 类型的指针,这样在增大大小时,就会按照字节来移动,而不是按照 4 字来移动了。由于向量指针所指向的值是 16 字节长的,因此会按照 16 字节的单位来移动;而 char 指针则是按照 1 个字节的单位来移动的。这就是 buffer++ 能够工作的原因 —— 它是按照单个向量长度增长的,也就是 16 个字节。

这个 C/C++ 版本另外一个有趣的特性是 __builtin_expect,它可以帮助编译器进行分支提示。在 C/C++ 中无法直接进行分支提示,这是因为既没有分支地址,也没有分支目标。因此,要做的是为编译器提供一些提示,它会生成适当的分支提示。__builtin_expect(buffer < buffer_end, 1) 基于第一个参数 buffer < buffer_end 生成分支代码,基于第二个参数 1 来生成分支提示。它告诉编译器生成这样一个提示:期望 buffer < buffer_end 的值为 1。

目前,有两个编译器可以用来进行 SPU 编程,正如您期望的,它们适合不同的领域。举例来说,GCC 在交错调用 convert_vec_to_upper 的指令方面做了一件不可思议的工作,这使指令延时最小化了。然而,在特定的程序中,__builtin_expect 基本上不能为我们提供任何帮助。另一方面,IBM XLC 编译器则正好相反。它根本就没有对调用 convert_vec_to_upper 的指令进行交错,而是对循环进行结构化,这使分支提示可以发挥最大作用,实际上分支提示即使没有提供也可以猜测到。毫不奇怪,没有哪种编译器可以接近上一篇文章中手工编写的汇编语言版本,不过对于这个程序来说,XLC 的效果比 GCC 更好。没有使用任何优化标记所编译出来的代码的速度大约会慢 5 倍,因此要确保使用 -O2-O3 标记来编译程序。


复合 intrinsic 和 MFC 编程

复合 intrinsic 是编译成多条指令的 intrinsic。复合 intrinsic 封装了 SPE 的常用模式,从而对它的编程进行简化。两个重要的复合 intrinsic 是 spu_mfcdma64spu_mfcstatspu_mfcdma64 与我在上一篇文章中编写并使用的 dma_transfer 函数几乎完全一样,除了有效地址的高位和低位被划分成了两个 32 位的参数(dma_transfer 使用一个 64 位的参数作为有效地址)。

spu_mfcdma64 可以接受 6 个参数:

  1. 传输的本地存储地址
  2. 有效地址的高 32 位
  3. 有效地址的低 32 位
  4. 传输数据的大小
  5. 给传输提供的一个标签
  6. 给出的 DMA 命令

通常都会将有效地址作为一个 64 位值使用。要将这个地划分成不同的部分,需要使用 mfc_ea2h 提取出高位,使用 mfc_ea2l 提取出低位。标签是程序员指定的一个数字,范围在 0 到 31 之间,用来标识一次传输或一组传输,这可以用来进行状态查询和序列化操作。DMA 命令可以使用很多值(更多信息请参看 参考资料 中的介绍)。如果传输是从 SPU 本地存储发往系统内存的,那么 DMA 传输就称为 PUT 操作;如果方向正好相反,那么 DMA 操作就称为 GET 操作。这些 DMA 命令名都有一个前缀,形如 MFC_PUTMFC_GET。然后,MFC 命令可以逐个进行操作,也可以对一个列表进行操作。如果 DMA 命令是一个列表命令,DMA 命令名后面就会再附加上一个 L(有关 DMA 列表命令的更多信息,请参看 参考资料)。DMA 命令也可以应用一些特定级别的同步。对于 barrier 同步会增加一个 B,对于 fence 同步会增加一个 F,对于无同步则不会增加任何内容。最后,所有的 DMA 命令名都有一个 _CMD 后缀。因此使用 fence 同步从本地存储向系统内存发起的一次数据传输使用的命令名应该是 MFC_PUTF_CMD

缺省情况下,SPE 的 MFC 上所有 DMA 命令全部都是无序的 —— MFC 可以按照自己希望的顺序对它们进行处理。然而,标签、fence 和 barrier 可以用来对 MFC DMA 传输强制实施排序约束。fence 建立了这样一个约束:某个给定的 DMA 传输只有在前面的所有使用相同标签的命令全部执行完成之后 才会执行。barrier 建立了这样一个约束:某个给定的 DMA 传输只有在前面的所有使用相同标签的命令全部执行完成之后 才会执行(这一点与 fence 一样),不过还必须要在后面的所有使用相同标签的命令执行之前 执行。

下面是 spu_mfcdma64 的几个例子:

清单 3. 使用 spu_mfcdma64
typedef unsigned long long uint64;
typedef unsigned long uint32;
uint64 ea1, ea2, ea3, ea4, ea5; /* assume each of these have sensible values */
void *ls1, *ls2, *ls3, *ls4; /* assume each of these have sensible values */
uint32 sz1, sz2, sz3, sz4; /* assume each of these have sensible values */
int tag = 3; /* Arbitrary value, but needs to be the same for all 
synchronized transfers */

/* Transfer 1: System Storage -> Local Store, no ordering specified */
spu_mfcdma64(ls1, mfc_ea2h(ea1), mfc_ea2l(ea1), sz1, tag, MFC_GET_CMD);

/* Transfer 2: Local Storage -> System Storage, must perform after previous transfers */
spu_mfcdma64(ls2, mfc_ea2h(ea2), mfc_ea2l(ea2), sz2, tag, MFC_PUTF_CMD);

/* Transfer 3: Local Storage -> System Storage, no ordering specified */
spu_mfcdma64(ls3, mfc_ea2h(ea3), mfc_ea2l(ea3), sz3, tag, MFC_PUT_CMD);

/* Transfer 4: Local Storage -> System Storage, must be synchronized */
spu_mfcdma64(ls4, mfc_ea2h(ea4), mfc_ea2l(ea4), sz4, tag, MFC_PUTB_CMD);

/* Transfer 5: System Storage -> Local Storage, no ordering specified */
spu_mfcdma64(ls4, mfc_ea2h(ea5), mfc_ea2l(ea5), sz4, tag, MFC_GET_CMD);

上面的例子有几个可能的排序。可能的选择如下:

  • 1, 2, 3, 4, 5
  • 3, 1, 2, 4, 5
  • 1, 3, 2, 4, 5

由于传输 2 只使用了一个 fence,而传输 3 根本就没有指定任何次序,因此传输 3 可以在 barrier(传输 4)之前的任何地方执行。对于前 3 次传输的惟一要求是,传输 2 必须在传输 1 之后执行。然而,传输 4 需要在传输前后进行完全同步。

下面深入地来看一下传输 4 和 5。这是一个需要注意的有用习惯 —— 保存和重新加载。如果正在一次处理系统内存数据中的一部分,将其放入本地存储中,并再将其存储回系统内存中,就可以同时对保存和加载进行排队,并使用 fence 或 barrier 对它们进行排序。这会将所有传输逻辑全部放入 MFC 中,并让程序可以在缓冲区等待新数据的同时自由地执行其他计算任务。在下一篇文章讨论双缓冲时我们将讨论这个问题。

spu_mfcdma64 是一个非常方便的工具,不过它有些单调乏味,尤其是当一直使用 mfc_ea2hmfc_ea2l 来转换地址时更是如此。因此,规范还提供了一些实用函数来减少必需的冗余输入的数量。mfc_ 类的函数全部使用与 spu_mfcdma64 函数相同的参数,除了有效地址是一个 64 位的参数;DMA 命令也被编码到函数名中。它还要使用另外两个参数,传输类标识符替换类标识符。在非实时应用程序中,它们都可以安全地设置为 0(有关这两个域的进一步信息请参看 参考资料)。因此,上面的传输 2 可以重写成下面的形式:

mfc_putf(ls2, ea2, sz2, tag, 0, 0);

标签不但对于同步数据传输非常有用,而且对于检查传输状态也非常有用。在 SPE 上,有一个掩码通道可以用来指定哪个标签目前正在用来进行状态检查,有一个通道用来产生状态请求,另外一个通道用来读取通道状态。尽管这些只是非常简单的操作,这个规范给出了一些特殊的方法来执行这些操作。mfc_write_tag_mask 接受一个 32 位整数,并使用它作为将来状态更新的通道掩码。在这个掩码中,将想要检查其状态的每个标签的位位置设置为 1。因此,要检查通道 2 和 4 的状态,就可以使用 mfc_write_tag_mask(20),或者为了可读性更好起见,可以这样使用:mfc_write_tag_mask(1<<2 | 1<<4);。要实际进行状态更新,必须选取一个状态命令,并使用 spu_mfcstat(unsigned int command) 来发送它。可以使用的命令有:

  • MFC_TAG_UPDATE_IMMEDIATE
    这个命令会导致 SPE 立即返回 DMA 通道的状态。如果队列中没有具有这个标签的其他命令(换而言之,之前可能已经活动的所有操作都已经完成了),那么通道掩码中指定的每个通道都会设置为 1;如果队列中还有命令,就将其设置为 0。
  • MFC_TAG_UPDATE_ANY
    这个命令会导致 SPE 开始等待,直到标签掩码中指定的标签至少有一个没有剩余命令才能返回,然后就返回在标签掩码中指定的 DMA 通道的状态。
  • MFC_TAG_UPDATE_ALL
    这个命令会导致 SPE 开始等待,直到标签掩码中指定的所有标签都没有剩余命令才能返回。返回值为 0。

要使用这些命令,需要包含 spu_mfcio.h

使用 spu_mfcstat 让您可以既检查 DMA 请求的状态,又进行等待。使用 MFC_TAG_UPDATE_ANY 可以产生多个 DMA 请求,让 MFC 按照自己认为最佳的次序对它们进行处理,然后代码就可以根据 MFC 处理的顺序进行响应。


示例 MFC 程序

接下来,让我们将有关 MFC 复合 intrinsic 的知识应用到大写转换程序中。在本文开头我使用 C 语言重新编写了主转换函数,现在将使用 C 语言重新编写主循环。新代码非常简单(请将下面的内容输入到 convert_driver_c.c 中):

清单 4. 大写转换 MFC 传输代码
#include <spu_intrinsics.h>
#include <spu_mfcio.h>
typedef unsigned long long uint64;

#define CONVERSION_BUFFER_SIZE 16384
#define DMA_TAG 0

void convert_buffer_to_upper(char *conversion_buffer, int current_transfer_size);

char conversion_buffer[CONVERSION_BUFFER_SIZE];

typedef struct {
	int length __attribute__((aligned(16)));
	uint64 data __attribute__((aligned(16)));
} conversion_structure;

int main(uint64 spe_id, uint64 conversion_info_ea) {
	conversion_structure conversion_info; 
	/* Information about the data from the PPE */

	/* We are only using one tag in this program */
	mfc_write_tag_mask(1<<DMA_TAG);

	/* Grab the conversion information */
	mfc_get(&conversion_info, conversion_info_ea, sizeof(conversion_info), 
	  DMA_TAG, 0, 0);
	spu_mfcstat(MFC_TAG_UPDATE_ALL); /* Wait for Completion */

	/* Get the actual data */
	mfc_get(conversion_buffer, conversion_info.data, conversion_info.length,
	   DMA_TAG, 0, 0);
	spu_mfcstat(MFC_TAG_UPDATE_ALL);

	/* Perform the conversion */
	convert_buffer_to_upper(conversion_buffer, conversion_info.length);

	/* Put the data back into system storage */
	mfc_put(conversion_buffer, conversion_info.data, conversion_info.length, 
	   DMA_TAG, 0, 0);
	spu_mfcstat(MFC_TAG_UPDATE_ALL); /* Wait for Completion */
}

要编译并运行这段程序,只需执行下面的步骤:

spu-gcc convert_buffer_c.c convert_driver_c.c -o spe_convert
embedspu -m64 convert_to_upper_handle spe_convert spe_convert_csf.o
gcc -m64 spe_convert_csf.o ppu_dma_main.c -lspe -o dma_convert
./dma_convert

这个 C 语言实现使用的基本结构与原来的代码相同,但是它的可读性更好;顺便说一下,这可以简化它的修正与扩展。举例来说,原来代码的一个问题是一次 DMA 传输的大小有限制。如果想消除这种限制该怎样做呢?可以简单地将所有的事情全部封装到一个循环中,并一次移动数据的一部分,直到整个字符串全部处理完成为止。下面是实现这种功能的重写之后的代码:

清单 5. MFC 传输代码中的循环
#include <spu_intrinsics.h>
#include <spu_mfcio.h> /* constant declarations for the MFC */
typedef unsigned long long uint64;
typedef unsigned int uint32;

/* Renamed CONVERSION_BUFFER_SIZE to MAX_TRANSFER_SIZE because it is now 
primarily used to limit the size of DMA transfers */
#define MAX_TRANSFER_SIZE 16384

void convert_buffer_to_upper(char *conversion_buffer, int current_transfer_size);

char conversion_buffer[MAX_TRANSFER_SIZE];

typedef struct {
	uint32 length __attribute__((aligned(16)));
	uint64 data __attribute__((aligned(16)));
} conversion_structure;

int main(uint64 spe_id, uint64 conversion_info_ea) {
	conversion_structure conversion_info; 
	/* Information about the data from the PPE */

	/* New variables to keep track of where we are in the data */
	uint32 remaining_data; /* How much data is left in the whole string */
	uint64 current_ea_pointer; /* Where we are in system memory */
	uint32 current_transfer_size; 
	/* How big the current transfer is (may be smaller 
	than MAX_TRANSFER_SIZE) */

	/* We are only using one tag in this program */
	mfc_write_tag_mask(1<<0);

	/* Grab the conversion information */
	mfc_get(&conversion_info, conversion_info_ea, sizeof(conversion_info), 
	           0, 0, 0);
	spu_mfcstat(MFC_TAG_UPDATE_ALL); /* Wait for Completion */

	/* Setup the loop */
	remaining_data = conversion_info.length;
	current_ea_pointer = conversion_info.data;

	while(remaining_data > 0) {
		/* Determine how much data is left to transfer */
		if(remaining_data < MAX_TRANSFER_SIZE)
			current_transfer_size = remaining_data;
		else
			current_transfer_size = MAX_TRANSFER_SIZE;

		/* Get the actual data */
		mfc_getb(conversion_buffer, current_ea_pointer, 
		  current_transfer_size, 0, 0, 0);
		spu_mfcstat(MFC_TAG_UPDATE_ALL);

		/* Perform the conversion */
		convert_buffer_to_upper(conversion_buffer, current_transfer_size);

		/* Put the data back into system storage */
		mfc_putb(conversion_buffer, current_ea_pointer, 
		   current_transfer_size, 0, 0, 0);

		/* Advance to the next segment of data */
		remaining_data -= current_transfer_size;
		current_ea_pointer += current_transfer_size;
	}
	spu_mfcstat(MFC_TAG_UPDATE_ALL); /* Wait for Completion */
}

使用与前面例子相同的命令来编译并运行这段程序:

spu-gcc convert_buffer_c.c convert_driver_c.c -o spe_convert
embedspu -m64 convert_to_upper_handle spe_convert spe_convert_csf.o
gcc -m64 spe_convert_csf.o ppu_dma_main.c -lspe -o dma_convert
./dma_convert

现在已经将可以处理的数据大小扩展到了 4GB,不过只需要通过将数据大小变量设置为 64 位的而不是 32 位,就可以轻松突破这个限制。注意,并不需要通过显式地编码让 MFC 等待 PUT 操作完成,然后再重新执行 GET 操作。这是因为正在对传输使用 barrier,并对它们使用相同的 DMA 标签。这会强制传输由 MFC 本身进行序列化,因此总会等待,直到当前的转换被 PUT 到系统存储中,然后才会 GET 更多数据到缓冲区中。只需要记住,必须等待最终完成(注意 spu_mfcstat 在循环之外),否则在程序中使用数据之前,最后的数据位可能还没有完成数据传输!

在使用 C 语言进行编程时,另外一件需要注意的事情是,一定要确保提供了函数原型。实际上很容易会偶然将 32 位和 64 位值混淆。在 PPE 上,这样做的结果并不太坏,因为值不过是被截断或扩充了而已。但是在 SPE 上,如果原型错了,那么所希望的 32 位和 64 位值的 slot 就偏移了,这样二者之间的转换必须显式地进行处理。


对 C 语言 SPE 编程的一些有用提示

下面是使用 C 语言编写 SPE 应用程序时需要随时记住的一些提示:

  • 向量可以 在其他类型的向量之间进行转换,并在向量类型和特殊的 quad 类型之间反复进行转换,不过这些类型转换都不能执行任何数据转换。如果需要在类型之间进行数据转换,可以使用适当的 SPU intrinsic。
  • 向量和非向量指针都可以 相互进行转换,不过在从标量指针转换成向量指针时,程序员要负责确保指针是按照 4 字对齐的
  • 声明的变量在分配时通常都是按照 4 字对齐的。
  • 记住,传输 16 字节或更多字节的 DMA 在 SPE 和 PPE 上必须是 16 字节的整数倍,并按照 16 字节边界进行对齐。传输较小的数据必须是 2 的幂,并是自然对齐的。优化的传输是 128 的整数倍,它们都在 128 字节边界上对齐。
  • 如果不确定 PPE 上数据的对齐方法,可以使用 memalignposix_memalign 来从堆中分配一个对齐指针,并使用 memcpy 或等价命令将数据移动到对齐的区域中。
  • 记得总要使用 -Wall 标记来编译程序,并要特别注意缺少原型消息。不正确地声明原型(尤其是出现 32 位和 64 位类型之间的差异)可以导致出现 bizarre 错误条件。
  • 记得在 SPE 和 PPE 上,总要将有效地址作为 unsigned long long 类型保存。这种方法可以作为 SPE 和 PPE 的一种统一形式,而不管 PPE 代码是为 32 位还是 64 位执行进行编译的。
  • 避免在 SPE 上进行整数乘法(尤其是 32 位的乘法)。计算乘法要用 5 条指令。如果一定要计算乘法,可以在计算乘法之前转换为 unsigned short
  • 在 SPE 上的标量代码中,将标量值声明为向量和向量指针(即使没有将它们作为向量使用)可以加速代码的执行,因为这样就不会执行非对齐的加载和存储操作。
  • 注意,在 SPE 上 floatdouble 的实现很不相同,因此范围也不同。float 来自于 C99 标准。下一篇文章将进一步介绍这些问题。

结束语

C 语言可以使用的 intrinsic 让程序员可以最适当地混合使用 C 和汇编语言的知识。SPU intrinsic 让程序可以在高级和低级代码之间自如地切换,不过所有操作都要在 C 语言的语义框架之内进行。

本系列的下一篇将会把这些知识应用到一个真实的数学应用程序中。

参考资料

学习

获得产品和技术

讨论

条评论

developerWorks: 登录

标有星(*)号的字段是必填字段。


需要一个 IBM ID?
忘记 IBM ID?


忘记密码?
更改您的密码

单击提交则表示您同意developerWorks 的条款和条件。 查看条款和条件

 


在您首次登录 developerWorks 时,会为您创建一份个人概要。您的个人概要中的信息(您的姓名、国家/地区,以及公司名称)是公开显示的,而且会随着您发布的任何内容一起显示,除非您选择隐藏您的公司名称。您可以随时更新您的 IBM 帐户。

所有提交的信息确保安全。

选择您的昵称



当您初次登录到 developerWorks 时,将会为您创建一份概要信息,您需要指定一个昵称。您的昵称将和您在 developerWorks 发布的内容显示在一起。

昵称长度在 3 至 31 个字符之间。 您的昵称在 developerWorks 社区中必须是唯一的,并且出于隐私保护的原因,不能是您的电子邮件地址。

标有星(*)号的字段是必填字段。

(昵称长度在 3 至 31 个字符之间)

单击提交则表示您同意developerWorks 的条款和条件。 查看条款和条件.

 


所有提交的信息确保安全。


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=Linux
ArticleID=226479
ArticleTitle=在 Cell BE 处理器上编写高性能的应用程序,第 5 部分: 使用 C/C++ 对 SPU 进行编程
publish-date=05242007