本文深入介绍了指令周期计算、位操作和其他一些汇编语言不能很好处理的细微区别。在阅读完本文之后,您可能会确信再也不会用汇编语言编写程序了。不过,本文的观点并不是在任何时候都要使用汇编语言进行编程,而是理解编译器需要执行哪些操作来对您的代码进行优化,并且在需要时能够使用定制好的汇编语言作为补充。了解 SPU 的汇编语言是如何工作的,也可以对使用高级语言来使用处理器提供帮助。后续文章会使用 C 语言进行编程,并展示在实际的例子中如何使用这种优化知识。SPU 有很多 C 语言扩展;了解 SPU 汇编语言可以帮助您理解这些 C 语言扩展,了解 SPU 优化的知识也可以帮助您更好地使用它们。
上一篇文章最后给出了一个名为 convert_to_upper 的函数,它一次操作一个字节,并将字符串转换成大写格式。本文中这个程序中的函数都是每次对整个缓冲区进行操作的。SPU 的设计就是用来批量处理数据,因此转到 “一次处理一个缓冲区” 的模型可以简化这种改进。第一个版本会简单地围绕在上一篇文章中开发的代码来包装一个循环。由于它是基于上一篇文章中开发的代码和概念的,因此我们就不需要逐行对这些代码进行解释了。
下面是实现大写转换功能的一次处理一个缓冲区函数的未经优化的版本(请将其输入到 convert_buffer.s 中):
清单 1. 第一个示例程序
.text .global convert_buffer_to_upper .type convert_buffer_to_upper, @function convert_buffer_to_upper: ##REGISTER USAGE: # 3) buffer address / current address # 4) buffer size # 5) end_address # 6) current quadword # 7) current quadword with byte in first position # 8, 9, & 10) Determine if byte is in range # 11) byte insertion control # 12) current quadword with byte properly inserted # 13) true if we need to branch, false otherwise # 14) conversion factor #Calculate end address a $5, $4, $3 loop_start: #UNALIGNED LOAD lqd $6, 0($3) rotqby $7, $6, $3 rotqbyi $7, $7, -3 #IS IN RANGE 'a'-'z'? cgtbi $8, $7, 'a' - 1 cgtbi $9, $7, 'z' xor $10, $8, $9 andi $10, $10, 255 #If no, exit brz $10, finish_loop is_lowercase: #If yes, perform conversion il $14, 'a' - 'A' absdb $7, $7, $14 finish_loop: #Unaligned Store ($6 already has current word) cbd $11, 0($3) shufb $12, $7, $6, $11 stqd $12, 0($3) #Increment pointer ai $3, $3, 1 #Are we at the end? If not then loop. cgt $13, $3, $5 brz $13, loop_start end_function: #Return bi $lr |
就性能来看,这段代码非常糟糕。后续几节将一次一步地对它们进行改进。
调用转换函数的函数现在简单一点了,这是因为它需要加载数据、运行函数并将结果复制回来。这个函数的代码如下(请将其输入到 convert_driver.s 中):
清单 2. 大写转换的主函数
.data #This is the struct we will copy from the main PPE process .align 4 conversion_info: conversion_length: .octa 0 conversion_data: .octa 0 .equ CONVERSION_STRUCT_SIZE, 32 .section .bss #Uninitialized Data Section #This is the buffer we will store the string in .align 4 .lcomm conversion_buffer, 16384 .text #MFC Constants .equ MFC_GET_CMD, 0x40 .equ MFC_PUT_CMD, 0x20 .equ LR_OFFSET, 16 .global main .type main, @function .equ MAIN_FRAME_SIZE, 32 main: #Prologue stqd $lr, LR_OFFSET($sp) stqd $sp, -MAIN_FRAME_SIZE($sp) ai $sp, $sp, -MAIN_FRAME_SIZE ##COPY IN CONVERSION INFORMATION## ila $3, conversion_info #Local Store Address #register 4 already has address #64-bit Effective Address il $5, CONVERSION_STRUCT_SIZE #Transfer size il $6, 0 #DMA Tag il $7, MFC_GET_CMD #DMA Command brsl $lr, perform_dma #Wait for DMA to complete il $3, 0 brsl $lr, wait_for_dma_completion ##COPY STRING IN TO BUFFER## #Load buffer data pointer ila $3, conversion_buffer #Local Store lqr $4, conversion_data #64-bit Effective Address lqr $5, conversion_length #SIZE il $6, 0 #DMA Tag il $7, MFC_GET_CMD #DMA Command brsl $lr, perform_dma #Wait for DMA to complete il $3, 0 brsl $lr, wait_for_dma_completion ##PERFORM CONVERSION## ila $3, conversion_buffer lqr $4, conversion_length brsl $lr, convert_buffer_to_upper ##COPY DATA BACK## ila $3, conversion_buffer #Local Store Address lqr $4, conversion_data #64-bit effective address lqr $5, conversion_length #Size il $6, 0 #DMA Tag il $7, MFC_PUT_CMD #DMA Command brsl $lr, perform_dma #Wait for DMA to complete il $3, 0 brsl $lr, wait_for_dma_completion ##EXIT PROGRAM## #Return Value il $3, 0 #Epilogue ai $sp, $sp, MAIN_FRAME_SIZE lqd $lr, LR_OFFSET($sp) bi $lr |
您还需要上一篇文章中给出的 dma_utils.s 和
ppu_dma_main.c 文件。
要编译并运行这个程序,请执行以下步骤:
spu-gcc convert_buffer.s 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 |
相同的步骤可以用来编译本文中所有的示例。
对向量处理过程最明显的优化是对代码进行向量化。这种技术称为 SIMD(单指令,多数据)或数据并行。在 SPU 上,大部分指令都可以在寄存器上进行操作,就仿佛它们分别包含多个无关的值一样(因此可以对多个数据项执行一条指令)。每个 128 位寄存器都可以作为 16 个单独的字节、8 个半字、4 个字、两个双字或一个单位进行处理。虽然指令集主要是将它们拆解成 4 个 32 位的字,不过对于这种情况的处理已经有足够的支持了。
如果对代码进行向量化,由于您正在将这些值当作字节进行处理,这意味着每条指令都要一次操作 16 个值!然而,问题是向量处理会假设每条指令都可以适用于向量的所有元素。然而,在主循环中,您使用了一个条件分支。这意味着满足条件的与不满足条件的向量元素执行的指令集不同。因此,至少从目前的代码使用方式来说,尚不能进行向量化。
首先需要做的是消除分支(eliminate the branch),这样不管代码是否满足您的条件,都会使用完全相同的指令(正如在后面指出的一样,消除分支也可以帮助减少分支暂停)。那么应该如何实现呢?问题的关键是 SPU 有几个条件指令,例如 selb、
shufb 和位操作,它们可以不用分支就能实现条件操作。这个程序最终要做的是计算两种答案,然后使用一个条件指令来选择所需要的答案。
下面是转换代码目前使用的方式:
#IS IN RANGE 'a'-'z' cgtbi $8, $7, 'a' - 1 cgtbi $9, $7, 'z' xor $10, $8, $9 andi $10, $10, 255 brz $10, finish_loop is_lowercase: #lowercase condition il $14, 'a' - 'A' absdb $7, $7, $14 finish_loop: #non-lowercase condition #all code winds up here |
在这个例子中,我们计算了两个答案:
- 转换成大写的字母(如果是小写)
- 原始输入字母(如果不是小写)
这段代码从 $7 中的初始值开始操作。需要做的第一件事情是将计算转换值的代码移动到条件之前,然后将其存储在一个不同的寄存器中(本例中使用的是 $15)。这样代码就应该如下所示了:
#$7 has our original value il $14, 'a' - 'A' absdb $15, $7, $14 #$7 has the original, and $15 has the converted value #Choose between the value in $7 and $14 and put it in $7 ##...rest of loop... |
现在您需要说明希望使用哪个值。需要做的第一件事情是使用原始指令来检查条件:
cgtbi $8, $7, 'a' - 1 cgtbi $9, $7, 'z' xor $10, $8, $9 |
注意前面的 andi 已经不再需要了,这是因为它用于屏蔽条件分支的不需要的值(条件分支基于字 首选 slot 值是否为真,而您只关心字节 首选的 slot 值)。由于您并没有进入分支,因此就不用考虑这个问题!因此现在如果这个值在这个范围内,则 $10 在首选 slot 中的值就全部为 1;反之则全部是 0。现在您需要做的是基于 $10 中的值选择 $7 或 $15。 指令 selb(选择位)就非常适合完成这种功能。selb 有 4 个操作数:
- 目标寄存器
- 源值 1
- 源值 2
- 选择器
selb 通过逐位遍历选择器进行操作。对于每个位位置来说,如果该位为 0,那么目标寄存器中相同位的位置就会使用源值 1 该位的值。如果该位为 1,就使用源值 2 该位的值。如果您假设每个寄存器都是一个位数组,那么 selb 就包含以下意义:
//imaginary representation of selb for those more familiar with C than assembly language:
for(i = 0; i < 128; i++) {
destination[i] = selector[i] == 0 ? source_1[i] : source_2[i]
}
|
现在希望您已经了解为什么在条件为真时,条件语句将目标寄存器的所有相应位都设置为 1 —— 这是为了简化 selb 对值的使用。在本例中,您可以简单地添加下面的代码行:
selb $7, $7, $15, $10 |
现在不管输入是大写还是小写,所有值都会经过以下代码序列进行处理:
清单 3. 不用分支的转换代码
#Original value starts in $7
#Perform conversion and store in $15
il $14, 'a' - 'A'
absdb $15, $7, $14
#Is it lowercase ('a'-'z')?
cgtbi $8, $7, 'a'-1
cgtbi $9, $7, 'z'
xor $10, $8, $9
#$10 has all 1s for lowercase and all 0s for non-lowercase in the preferred slot
#Select appropriate value into $7 based on condition
selb $7, $7, $15, $10
#$7 now has the correct value
|
在本例中,需在原始值和处理后的值之间进行选择,但是如果在两个处理后的值之间进行选择,则代码也很类似。在这种情况下,您将使用两组处理指令,每组指令都会使用一个不同的寄存器来保存其结果,selb 指令直接在这两个寄存器之间进行选择就可以了。同理,如果代码可能会跳转到多个方向,就需要使用多个 selb 指令进行选择。然而,那样的话您可能需要衡量一下对于每种可能输入的计算代价与消除分支带来的好处孰轻孰重。
请记住:删除分支的目的在于对代码进行向量化。问题是为了对代码进行向量化,代码必须要对所有向量元素都按照相同的指令集执行。现在您已经消除了可能的分支了。
事实上,核心转换代码实际几乎已经向量化了。所有指令不管怎样都会对整个寄存器进行操作。前面的问题现在变得更加复杂了:
- 不管是否要转换整个寄存器,都会进行分支。
- 保存转换因子的寄存器是按单个字节(而不是整个寄存器)的使用进行处理的(
il将给定的值加载到每个字 中,不过您需要按每个字节 来使用这些值)。 - load/store 指令和循环计数器的目标是一次处理一个字节。
既然已消除了分支,接下来需要将转换因子加载到转换寄存器的每个字节中。最简单的方法是将转换因子手动放入 .data 段中,并将其直接加载到寄存器中。也可以将其移出循环,因为这个值是不变的。因此,在 .data 段中添加以下内容:
.equ CONVERSION_FACTOR, 'a' - 'A' .align 4 conversion_bytes: .fill 16, 1, CONVERSION_FACTOR |
在循环之前的代码中,添加以下内容:
lqr $14, conversion_bytes |
添加这些代码之后,寄存器 7 中所有的值都可以正确地进行处理了。下面再回到代码上来,我们使用一个可能的初值来展示会发生什么事情:
清单 4. 使用一组值遍历转换过程
#$7 starts with 'Hello There! ' #In hex, that's 0x48656c6c6f2054686572652120202020 #$14 is the conversion factor in each byte #In hex, that's 0x20202020202020202020202020202020 absdb $15, $7, $14 # -> $15 now has 0x28454c4c4f0034484552450100000000 cgtbi $8, $7, 'a'-1 # -> $8 now has 0xffffffffff00ffffffffff0000000000 cgtbi $9, $7, 'z' # -> $9 now has 0xff0000000000ff000000000000000000 xor $10, $8, $9 # -> $10 now has 0x00ffffffff0000ffffffff0000000000 selb $7, $7, $15, $10 # -> $7 now has 0x48454c4c4f2054484552452120202020 # which is hex for 'HELLO THERE! ' |
现在您所需要做的就是更改循环以利用这种方法。它需要一次加载一个完整的 4 字(16 字节),并将其一次存储回来,然后使用 16(而不是 1)作为指针增量。有趣的是,这样做需要的指令更少,因为您不必再去处理首选 slot 的问题了。因此,下面是使用新循环主体的完整函数:
清单 5. 向量化代码的循环主体
##Store Conversion Factor## .data .equ CONVERSION_FACTOR, 'a' - 'A' .align 4 conversion_bytes: .fill 16, 1, CONVERSION_FACTOR .text .global convert_buffer_to_upper .type convert_buffer_to_upper, @function convert_buffer_to_upper: #Calculate end address a $5, $4, $3 #Load in conversion factors lqr $14, conversion_bytes loop_start: #Aligned Load lqd $7, 0($3) ##CONVERSION## absdb $15, $7, $14 cgtbi $8, $7, 'a'-1 cgtbi $9, $7, 'z' xor $10, $8, $9 selb $7, $7, $15, $10 ##END CONVERSION## #Aligned Store stqd $7, 0($3) #Increment Pointer ai $3, $3, 16 #Exit if needed ($5 has the ending address) cgt $13, $3, $5 brz $13, loop_start end_function: bi $lr |
如您所见,这段代码更加简单 —— 分支更少,指令也更少。然而,这段新代码假定起始地址是按照 16 字节对齐的,并且末尾也有足够的填充位,因此内存中下一个数据元素也是 16 字节对齐的。否则,您可能会将输入字母以外的东西转换了!如您所见,对于向量处理来说,对齐和填充都非常重要。数据本身是否足够大以填满缓冲区,这一点无关紧要。由于它是作为一个向量进行转换的,因此再多转换几个无关字节也不会有什么耗费。如果您不得不在缓冲区中浪费几个字节,这种代价与处理非对齐数据的开头和结尾所需要的时间和代码量相比也是微不足道的。通过保持数据对齐并针对 16 字节边界进行填充,向量操作就可以毫不费力地执行了。
循环展开从计算机编程诞生开始就已经成为一种优化技术了。我在此处再重提这个话题并不只是因为它通过消除分支而提高了效率,而且因为如果使用得当,它稍后可帮助您更好地实现指令调度。
到现在为止,您很可能已经碰到如何获悉哪个寄存器保存了什么值的问题。毕竟,寄存器名实际上都是任意数字,这几乎没有任何含义。然而,由于寄存器都只是数字,因此您可以使用 .equ 给寄存器指定一些描述性的名称。例如,您可以重新编写自己的转换程序,如下所示(注意寄存器已经重新编号了):
清单 6. 使用命名寄存器进行大写转换
.data .equ CONVERSION_FACTOR, 'a' - 'A' .align 4 conversion_bytes: .fill 16, 1, CONVERSION_FACTOR .text .global convert_buffer_to_upper .type convert_buffer_to_upper, @function ##REGISTER DEFINITIONS## #Loop/function control registers .equ BUFFER_REG, 3 #Buffer address / current address .equ BUFFER_SZ_REG, 4 #Buffer size .equ BUFFER_END_REG, 5 #End address .equ CONVERSION_BYTES_REG, 6 #Conversion data .equ IS_FINISHED_REG, 7 #Finished conversion? #Conversion-oriented registers .equ CURRENT_VAL_REG, 8 #Current quadword .equ BOOL_TMP1_REG, 9 #used for computing IN_RANGE_REG .equ BOOL_TMP2_REG, 10 #used for computing IN_RANGE_REG .equ IN_RANGE_REG, 11 #Value in range? .equ PROCESSED_VAL_REG, 12 #Conversion bytes, properly masked #Information about registers .equ NUMREGS, 5 #Number of per-iteration registers .equ REGBYTES, 16 #Number of bytes in a register convert_buffer_to_upper: #Calculate end address a $BUFFER_END_REG, $BUFFER_SZ_REG, $BUFFER_REG lqr $CONVERSION_BYTES_REG, conversion_bytes loop_start: #Aligned Load lqd $CURRENT_VAL_REG, 0($BUFFER_REG) ##CONVERSION## absdb $PROCESSED_VALS_REG, $CURRENT_VAL_REG, $CONVERSION_BYTES_REG cgtbi $BOOL_TMP1_REG, $CURRENT_VAL_REG, 'a'-1 cgtbi $BOOL_TMP2_REG, $CURRENT_VAL_REG, 'z' xor $IN_RANGE_REG, $BOOL_TMP1_REG, $BOOL_TMP2_REG selb $CURRENT_VAL_REG, $CURRENT_VAL_REG, $PROCESSED_VAL_REG, $IN_RANGE_REG ##END CONVERSION## #Aligned Store stqd $CURRENT_VAL_REG, 0($BUFFER_REG) #Increment Pointer ai $BUFFER_REG, $BUFFER_REG, REGBYTES #Exit if needed cgt $IS_FINISHED_REG, $BUFFER_REG, $BUFFER_END_REG brz $IS_FINISHED_REG, loop_start end_function: bi $lr |
虽然这段代码冗长很多,不过它仍然可以简化代码浏览。它还简化了对于展开循环进行的指令调度。稍后我们就会介绍这个问题。对于现在来说,先来了解一下如何展开这个循环 4 次,每次迭代都使用不同的寄存器(使用不同的寄存器可以帮助优化指令调度)。稍后我们将讨论为什么要以这种方式重写这个程序,以及如何重写这个程序:
清单 7. 缓冲区转换 —— 循环展开
loop_start: #ITERATION 0 lqd $(CURRENT_VAL_REG+0*NUMREGS), 0*REGBYTES($BUFFER_REG) absdb $(PROCESSED_VAL_REG+0*NUMREGS), $(CURRENT_VAL_REG+0*NUMREGS), $CONVERSION_BYTES_REG cgtbi $(BOOL_TMP1_REG+0*NUMREGS), $(CURRENT_VAL_REG+0*NUMREGS), 'a'-1 cgtbi $(BOOL_TMP2_REG+0*NUMREGS), $(CURRENT_VAL_REG+0*NUMREGS), 'z' xor $(IN_RANGE_REG+0*NUMREGS), $(BOOL_TMP1_REG+0*NUMREGS), $(BOOL_TMP2_REG+0*NUMREGS) selb $(CURRENT_VAL_REG+0*NUMREGS), $(CURRENT_VAL_REG+0*NUMREGS), $(PROCESSED_VAL_REG+0*NUMREGS), $(IN_RANGE_REG+0*NUMREGS) stqd $(CURRENT_VAL_REG+0*NUMREGS), 0*REGBYTES($BUFFER_REG) #ITERATION 1 lqd $(CURRENT_VAL_REG+1*NUMREGS), 1*REGBYTES($BUFFER_REG) absdb $(PROCESSED_VAL_REG+1*NUMREGS), $(CURRENT_VAL_REG+1*NUMREGS), $CONVERSION_BYTES_REG cgtbi $(BOOL_TMP1_REG+1*NUMREGS), $(CURRENT_VAL_REG+1*NUMREGS), 'a'-1 cgtbi $(BOOL_TMP2_REG+1*NUMREGS), $(CURRENT_VAL_REG+1*NUMREGS), 'z' xor $(IN_RANGE_REG+1*NUMREGS), $(BOOL_TMP1_REG+1*NUMREGS), $(BOOL_TMP2_REG+1*NUMREGS) selb $(CURRENT_VAL_REG+1*NUMREGS), $(CURRENT_VAL_REG+1*NUMREGS), $(PROCESSED_VAL_REG+1*NUMREGS), $(IN_RANGE_REG+1*NUMREGS) stqd $(CURRENT_VAL_REG+1*NUMREGS), 1*REGBYTES($BUFFER_REG) #ITERATION 2 lqd $(CURRENT_VAL_REG+2*NUMREGS), 2*REGBYTES($BUFFER_REG) absdb $(PROCESSED_VAL_REG+2*NUMREGS), $(CURRENT_VAL_REG+2*NUMREGS), $CONVERSION_BYTES_REG cgtbi $(BOOL_TMP1_REG+2*NUMREGS), $(CURRENT_VAL_REG+2*NUMREGS), 'a'-1 cgtbi $(BOOL_TMP2_REG+2*NUMREGS), $(CURRENT_VAL_REG+2*NUMREGS), 'z' xor $(IN_RANGE_REG+2*NUMREGS), $(BOOL_TMP1_REG+2*NUMREGS), $(BOOL_TMP2_REG+2*NUMREGS) selb $(CURRENT_VAL_REG+2*NUMREGS), $(CURRENT_VAL_REG+2*NUMREGS), $(PROCESSED_VAL_REG+2*NUMREGS), $(IN_RANGE_REG+2*NUMREGS) stqd $(CURRENT_VAL_REG+2*NUMREGS), 2*REGBYTES($BUFFER_REG) #ITERATION 3 lqd $(CURRENT_VAL_REG+3*NUMREGS), 3*REGBYTES($BUFFER_REG) absdb $(PROCESSED_VAL_REG+3*NUMREGS), $(CURRENT_VAL_REG+3*NUMREGS), $CONVERSION_BYTES_REG cgtbi $(BOOL_TMP1_REG+3*NUMREGS), $(CURRENT_VAL_REG+3*NUMREGS), 'a'-1 cgtbi $(BOOL_TMP2_REG+3*NUMREGS), $(CURRENT_VAL_REG+3*NUMREGS), 'z' xor $(IN_RANGE_REG+3*NUMREGS), $(BOOL_TMP1_REG+3*NUMREGS), $(BOOL_TMP2_REG+3*NUMREGS) selb $(CURRENT_VAL_REG+3*NUMREGS), $(CURRENT_VAL_REG+3*NUMREGS), $(PROCESSED_VAL_REG+3*NUMREGS), $(IN_RANGE_REG+3*NUMREGS) stqd $(CURRENT_VAL_REG+3*NUMREGS), 3*REGBYTES($BUFFER_REG) #Increment Pointer ai $BUFFER_REG, $BUFFER_REG, 4*REGBYTES #Exit if needed cgt $IS_FINISHED_REG, $BUFFER_REG, $BUFFER_END_REG brz $IS_FINISHED_REG, loop_start |
这个程序执行的操作是计算正在使用的寄存器。您可能已经简单地对寄存器进行了编号,不过之后编写代码并记住哪个寄存器实现哪些功能会比以前更加乏味。然而,由于每次迭代都使用了相同编号的寄存器,因此您可以简单地在汇编时计算寄存器的编号。例如,查看一下 $(BOOL_TMP1_REG+2*NUMREGS)。这意味着它是迭代 2 的 BOOL_TMP1_REG。由于 BOOL_TMP1_REG 为 9,
NUMREGS 为 5,因此实际的寄存器编号是 9+2*5,即 19。这样,如果您以后需要向代码中添加一个寄存器,那么汇编程序就会自动重新计算新寄存器的编号,您也不必更改自己的寄存器编号约定。您只需要为寄存器分配它自己的符号名并增加 NUMREGS 的值即可。
另外,就像稍后就会明了的那样,如果需要对指令重新排序以获得更快的执行速度,则这种命名寄存器的方法可以简化寄存器处理的循环的迭代次数和寄存器的用途。当二者都可以在代码中看到时,这也可以简化对于程序的修改。
对于新入门的汇编语言程序员来说,不太明显的一个问题是指令的顺序会影响程序执行速度。这个问题的原因在于有些指令需要多个周期才能完成,而处理器的设置使根据指令的顺序可以在开始执行下条指令之前,并不需要完成上一条指令。这种技术称为流水线。对指令进行设置使它们可以充分利用处理器流水线的技术称为指令调度。下面是有关流水线和指令调度的几个重要术语:
- 延时 —— 一条指令用来产生最终值所使用的时钟周期数。这与流水线用来处理值的大小相同。
- 暂停(Stall) —— 处理器不开始执行新指令处的时钟周期。
- 依赖性暂停(Dependency stall) —— 这种暂停之所以会发生是因为下一条指令的一个操作数需要上一条尚未完成的指令产生的值。
对 SPU 进行性能调优的大多数工作就是避免寄存器暂停。因此,让我们来看一下 SPU 上不同类型的指令的流水线(以下内容引自 Cell BE Handbook,第 688 页):
SPU 指令延时
| 指令类型 | 延时 | 流水线 | 其他说明 |
|---|---|---|---|
| 双精度浮点操作 | 13 | even | 前 6 个周期实际上都是暂停,不能在其中执行其他指令。也不允许对这些指令进行双发射(稍后讨论)操作。 |
| 整数乘法,浮点/整数转换,插值 | 7 | even | |
| 单精度浮点 | 6 | even | |
| 字节操作 | 4 | even | |
| 基于元素的旋转和移位 | 4 | even | |
| 即时模式加载 | 2 | even | |
简单整数和逻辑操作(包括 selb) | 2 | even | |
| Load 和 Store 操作 | 6 | odd | 与其他架构不同,SPU 的加载和存储都是确定的,因为它没有缓存。通过减少内存使数据都保存在本地存储芯片中,SPU 可以实现比其他处理器更快、更可靠的加载和存储次数。 |
| 分支提示 | 6 | odd | 分支提示的专用规则在接下来的一节中讨论。 |
| 通道操作 | 6 | odd | |
| 专用寄存器操作 | 6 | odd | |
| 分支 | 4 | odd | 适当的提示分支(在接下来的一节中讨论)允许下一个指令恰好在下一周期执行。 |
| Shuffle 字节 | 4 | odd | |
| 4 字节循环和移位 | 4 | odd | |
| 评估 | 4 | odd | |
| 聚合、掩码和生成插入控制 | 4 | odd |
因此,我们有了以下指令:
a $5, $6, $7 #instruction 1 a $8, $5, $9 #instruction 2 a $10, $8, $7 #instruction 3 a $11, $8, $7 #instruction 4 |
在这个程序中,完成指令 1 需要花费 4 个时钟周期。指令 2 需要指令 1 的结果($5)进行计算,因此指令等待 4 个完整的时钟周期。指令 3 需要指令 2 的结果($8),因此它也必须等待 4 个时钟周期。指令 4 可以在指令 3 之后紧接 的那个时钟周期执行,因为它不需要指令 3 的结果来执行。您可以按如下显示:
a $5, $6, $7 #cycle 1 #Stall for $5 #cycle 2 #Stall for $5 #cycle 3 #Stall for $5 #cycle 4 a $8, $5, $9 #cycle 5 #Stall for $8 #cycle 6 #Stall for $8 #cycle 7 #Stall for $8 #cycle 8 a $10, $8, $7 #cycle 9 a $11, $8, $7 #cycle 10 |
正如可以看到的一样,如果您可以对指令进行安排,以便没有指令会等待任何其他指令,那么系统性能会急剧提高。
SPU 不仅可以通过自己的流水线一次处理多个值,而且可以通过不同的流水线来执行双发射(dual-issue) 指令。SPU 有两条流水线 even(有时称为 pipeline 0 或 execute 流水线)和 odd(有时称为 pipeline 1 或 load 流水线)。在上面的表中,不同类型的指令都与执行指令所在的流水线列出在一起。SPU 实际上会从一个双字对齐的边界开始一次加载两条指令。这称为一个取出组(fetch
group)。如果这个取出组中的第一条指令是一条 even 流水线指令,第二条指令是一条 odd 流水线指令,则它们都可以同时执行。如果这些条件不都满足,或者第二条指令在执行之前需要等待依赖项,那么它们可以在单独的周期中执行。为了帮助正确地对齐指令以启用双发射,有两个无操作指令可以用来正确地填充这些指令 —— nop(even 流水线上的无操作指令)和 lnop(odd 流水线上的无操作指令)。另外,您可以使用 .align 3 在新的取出组中强制启动某条给定的指令(它可以使用适当的无操作指令进行填充,从而正确地进行对齐)。
下面让我们查看这个循环中的一次迭代,并了解一下它在 SPU 流水线中是如何执行的。我已经添加了无操作指令,这样您可以看到流水线执行得更好了:
清单 8. 带有暂停信息的循环迭代
.align 4 #force new fetch group #ITERATION 0 nop lqd $(CURRENT_VAL_REG+0*NUMREGS), 0*REGBYTES($BUFFER_REG) #stall (waiting on CURRENT_VAL_REG) #stall #stall #stall #stall absdb $(PROCESSED_VAL_REG+0*NUMREGS), $(CURRENT_VAL_REG+0*NUMREGS), $CONVERSION_BYTES_REG lnop cgtbi $(BOOL_TMP1_REG+0*NUMREGS), $(CURRENT_VAL_REG+0*NUMREGS), 'a'-1 lnop cgtbi $(BOOL_TMP2_REG+0*NUMREGS), $(CURRENT_VAL_REG+0*NUMREGS), 'z' lnop #stall (waiting on BOOL_TMP2_REG) xor $(IN_RANGE_REG+0*NUMREGS), $(BOOL_TMP1_REG+0*NUMREGS), $(BOOL_TMP2_REG+0*NUMREGS) lnop #stall (waiting on IN_RANGE_REG) selb $(CURRENT_VAL_REG+0*NUMREGS), $(CURRENT_VAL_REG+0*NUMREGS), $(PROCESSED_VAL_REG+0*NUMREGS), $(IN_RANGE_REG+0*NUMREGS) lnop #stall (waiting on CURRENT_VAL_REG) nop stqd $(CURRENT_VAL_REG+0*NUMREGS), 0*REGBYTES($BUFFER_REG) |
正如您可以看到的一样,这一次迭代浪费了 8 个周期,只是用来等待寄存器完成加载过程。另外,它还浪费了 7 次双发射的机会。因此即使在向量化实现中,也有很多改进空间!
您可能会纳闷为什么程序不将 selb 和 stqd 放到一个取出组中。您可能已经这样做过,但是它并不会提高程序的速度。由于 stqd 需要为 CURRENT_VAL_REG 的值进行暂停,因此不管怎样它们都要单独执行,您并不能获得任何速度提升。
有时指令调度会带来争议。然而,在与循环展开一起使用时,效果还算可以。由于每次迭代都要使用不同的寄存器组进行计算,因此您需要做的是在每次迭代中进行交叉计算来填满时间空隙。因此新循环体应该如下所示:
清单 9. 交叉循环体可以最小化延时暂停
lqd $(CURRENT_VAL_REG+0*NUMREGS), 0*REGBYTES($BUFFER_REG) lqd $(CURRENT_VAL_REG+1*NUMREGS), 1*REGBYTES($BUFFER_REG) lqd $(CURRENT_VAL_REG+2*NUMREGS), 2*REGBYTES($BUFFER_REG) lqd $(CURRENT_VAL_REG+3*NUMREGS), 3*REGBYTES($BUFFER_REG) absdb $(PROCESSED_VAL_REG+0*NUMREGS), $(CURRENT_VAL_REG+0*NUMREGS), $CONVERSION_BYTES_REG absdb $(PROCESSED_VAL_REG+1*NUMREGS), $(CURRENT_VAL_REG+1*NUMREGS), $CONVERSION_BYTES_REG absdb $(PROCESSED_VAL_REG+2*NUMREGS), $(CURRENT_VAL_REG+2*NUMREGS), $CONVERSION_BYTES_REG absdb $(PROCESSED_VAL_REG+3*NUMREGS), $(CURRENT_VAL_REG+3*NUMREGS), $CONVERSION_BYTES_REG cgtbi $(BOOL_TMP1_REG+0*NUMREGS), $(CURRENT_VAL_REG+0*NUMREGS), 'a'-1 cgtbi $(BOOL_TMP1_REG+1*NUMREGS), $(CURRENT_VAL_REG+1*NUMREGS), 'a'-1 cgtbi $(BOOL_TMP1_REG+2*NUMREGS), $(CURRENT_VAL_REG+2*NUMREGS), 'a'-1 cgtbi $(BOOL_TMP1_REG+3*NUMREGS), $(CURRENT_VAL_REG+3*NUMREGS), 'a'-1 cgtbi $(BOOL_TMP2_REG+0*NUMREGS), $(CURRENT_VAL_REG+0*NUMREGS), 'z' cgtbi $(BOOL_TMP2_REG+1*NUMREGS), $(CURRENT_VAL_REG+1*NUMREGS), 'z' cgtbi $(BOOL_TMP2_REG+2*NUMREGS), $(CURRENT_VAL_REG+2*NUMREGS), 'z' cgtbi $(BOOL_TMP2_REG+3*NUMREGS), $(CURRENT_VAL_REG+3*NUMREGS), 'z' xor $(IN_RANGE_REG+0*NUMREGS), $(BOOL_TMP1_REG+0*NUMREGS), $(BOOL_TMP2_REG+0*NUMREGS) xor $(IN_RANGE_REG+1*NUMREGS), $(BOOL_TMP1_REG+1*NUMREGS), $(BOOL_TMP2_REG+1*NUMREGS) xor $(IN_RANGE_REG+2*NUMREGS), $(BOOL_TMP1_REG+2*NUMREGS), $(BOOL_TMP2_REG+2*NUMREGS) xor $(IN_RANGE_REG+3*NUMREGS), $(BOOL_TMP1_REG+3*NUMREGS), $(BOOL_TMP2_REG+3*NUMREGS) selb $(CURRENT_VAL_REG+0*NUMREGS), $(CURRENT_VAL_REG+0*NUMREGS), $(PROCESSED_VAL_REG+0*NUMREGS), $(IN_RANGE_REG+0*NUMREGS) selb $(CURRENT_VAL_REG+1*NUMREGS), $(CURRENT_VAL_REG+1*NUMREGS), $(PROCESSED_VAL_REG+1*NUMREGS), $(IN_RANGE_REG+1*NUMREGS) selb $(CURRENT_VAL_REG+2*NUMREGS), $(CURRENT_VAL_REG+2*NUMREGS), $(PROCESSED_VAL_REG+2*NUMREGS), $(IN_RANGE_REG+2*NUMREGS) selb $(CURRENT_VAL_REG+3*NUMREGS), $(CURRENT_VAL_REG+3*NUMREGS), $(PROCESSED_VAL_REG+3*NUMREGS), $(IN_RANGE_REG+3*NUMREGS) stqd $(CURRENT_VAL_REG+0*NUMREGS), 0*REGBYTES($BUFFER_REG) stqd $(CURRENT_VAL_REG+1*NUMREGS), 1*REGBYTES($BUFFER_REG) stqd $(CURRENT_VAL_REG+2*NUMREGS), 2*REGBYTES($BUFFER_REG) stqd $(CURRENT_VAL_REG+3*NUMREGS), 3*REGBYTES($BUFFER_REG) #Increment Pointer ai $BUFFER_REG, $BUFFER_REG, 4*REGBYTES #Exit if needed cgt $IS_FINISHED_REG, $BUFFER_REG, $BUFFER_END_REG brz $IS_FINISHED_REG, loop_start |
这种技术称为软件流水线(software pipelining),这段代码只损失了 2 个周期在暂停上。然而,这也没有太多利用双发射机制的优点。实际上,在这段代码中并没有太多机会这样做。
如果您要将这个循环多展开 4 次迭代,就可以将这 4 次执行的指令错开,使一组指令在加载数据,而另外一组指令则可进入执行,这样可以通过双发射节省一些时钟周期。不过对于现在来说,我们将简单地展示如何通过调整 selb 和 stqd 指令的顺序来节省两个时钟周期。新顺序如下:
清单 10. 重新调度指令
selb $(CURRENT_VAL_REG+0*NUMREGS), $(CURRENT_VAL_REG+0*NUMREGS), $(PROCESSED_VAL_REG+0*NUMREGS), $(IN_RANGE_REG+0*NUMREGS) selb $(CURRENT_VAL_REG+1*NUMREGS), $(CURRENT_VAL_REG+1*NUMREGS), $(PROCESSED_VAL_REG+1*NUMREGS), $(IN_RANGE_REG+1*NUMREGS) .align 3 ####Force to the start of a fetch-group #Next two issued concurrently selb $(CURRENT_VAL_REG+2*NUMREGS), $(CURRENT_VAL_REG+2*NUMREGS), $(PROCESSED_VAL_REG+2*NUMREGS), $(IN_RANGE_REG+2*NUMREGS) stqd $(CURRENT_VAL_REG+0*NUMREGS), 0*REGBYTES($BUFFER_REG) #Next two issued concurrently selb $(CURRENT_VAL_REG+3*NUMREGS), $(CURRENT_VAL_REG+3*NUMREGS), $(PROCESSED_VAL_REG+3*NUMREGS), $(IN_RANGE_REG+3*NUMREGS) stqd $(CURRENT_VAL_REG+1*NUMREGS), 1*REGBYTES($BUFFER_REG) stqd $(CURRENT_VAL_REG+2*NUMREGS), 2*REGBYTES($BUFFER_REG) stqd $(CURRENT_VAL_REG+3*NUMREGS), 3*REGBYTES($BUFFER_REG) |
只需通过将程序的一部分正确对齐预定的取出组边界并将一条指令(最后一条 selb)移动到更好的位置,您就可以节省两个时钟周期。注意如果没有 .align 3,selb 指令在 odd 位置,而 stqd 指令在 even 位置,那么您就无法实现双发射,因为双发射只有在两条指令都已经适当进行序列化和对齐之后才会发生。
SPU 对于分支提示并没有没有实际硬件 支持。然而,它通过为分支提示提供非常优秀的软件 支持来提供这种功能(在某些情况中,这有可能会超过硬件支持)。
分支提示之所以在 SPU 是必需的是因为预测错误的分支可能会带来高耗费。从预测错误的分支中恢复需要 18 到 19 个周期。另外,默认情况下,SPU 所碰到的每个分支都应当不会执行,包括无条件分支。分支提示所做的事情是对某个特定的分支指令(也称为提示触发地址)告诉处理器自己可能会跳转到什么地址(称为分支目标地址)。这让处理器可以提前准备分支操作(例如预取指令)。分支提示永远都不会影响程序的逻辑结果。他们只会影响运行程序所需要的周期数。
下面是 3 条分支提示指令:
-
hbr hint_trigger, $register—— 告诉处理器相对地址hint_trigger处的分支指令可能会跳转到寄存器$register所指定的地址。 -
hbrr hint_trigger, branch_target—— 告诉处理器相对地址hint_trigger处的分支指令可能会跳转到相对地址branch_target处(二者都是相对于当前指令的位置的)。 -
hbra hint_trigger, branch_target—— 与hbrr相同,只是branch_target被指定为一个绝对地址。
要想让分支提示变得非常有效(这样分支就根本不会暂停了),就必须设置至少 4 个指令取出组和分支指令之前的 11 个周期。分支提示至少是分支指令之前的 4 个指令取出组,否则就没有效果。从提示分支处开始,(物理上)也可能不会偏离 255 条指令(指令本身还有一些空间,可以保存 8 位加上一个符号位,用来表示提示触发器的相对偏移量,这个值最终会再连接上两个 0)。例如,如果它距离分支指令是 4 个指令取出组加上 3 个周期远,那么分支指令就会进入提示暂停(hint stall) 等待 8 个周期,尽管这可能不是最适宜的情况,不过仍然要比没有提示时暂停 18 个周期要好很多。一次只能有一个提示是活动的,与其他情况一样,sync 指令会清除任何活动提示。
代码中使用提示的最佳地方是循环之前。由于循环执行的可能性比不执行的可能性要大很多(至少对于较大的字符串来说是如此),您可以对自己的分支指令给出一个符号名,并在执行分支的循环之前进行提示。代码变化如下所示:
清单 11. 提示过的分支
hbrr loop_branch_instruction, loop_start loop_start: ##... conversions go here ... ## #Increment Pointer ai $BUFFER_REG, $BUFFER_REG, 4*REGBYTES #Exit if needed cgt $IS_FINISHED_REG, $BUFFER_REG, $BUFFER_END_REG loop_branch_instruction: brz $IS_FINISHED_REG, loop_start |
由于提示位于循环体之前,因此这段代码将使提示对于循环的每次迭代都保持活动状态,不过它只使用了一个周期。
不幸的是,由于循环分支太接近返回语句,因此您无法预测循环分支和返回分支。然而,如果您认为分支循环很可能不会执行(例如一个字符串小于 64 字符就是这种情况),那么您就可以通过将提示修改为下面的形式来提示返回地址:
#This assumes that $lr has the right address right now (true in our case) hbr end_function, $lr |
您实际上可以使用基于寄存器的提示指令来实现一些非常高级的提示行为。您只需要记住提示限制和一个事实:提示指令确实需要占用程序的一个周期。
最后,优化后的函数应该如下所示:
清单 12. 完全优化后的转换函数
.data .equ CONVERSION_FACTOR, 'a' - 'A' .align 4 conversion_bytes: .fill 16, 1, CONVERSION_FACTOR .text .global convert_buffer_to_upper .type convert_buffer_to_upper, @function .equ BUFFER_REG, 3 .equ BUFFER_SZ_REG, 4 .equ BUFFER_END_REG, 5 .equ CONVERSION_BYTES_REG, 6 .equ IS_FINISHED_REG, 7 .equ CURRENT_VAL_REG, 8 .equ BOOL_TMP1_REG, 9 .equ BOOL_TMP2_REG, 10 .equ IN_RANGE_REG, 11 .equ PROCESSED_VAL_REG, 12 .equ NUMREGS, 5 .equ REGBYTES, 16 convert_buffer_to_upper: a $BUFFER_END_REG, $BUFFER_SZ_REG, $BUFFER_REG lqr $CONVERSION_BYTES_REG, conversion_bytes hbrr loop_branch_instruction, loop_start loop_start: lqd $(CURRENT_VAL_REG+0*NUMREGS), 0*REGBYTES($BUFFER_REG) lqd $(CURRENT_VAL_REG+1*NUMREGS), 1*REGBYTES($BUFFER_REG) lqd $(CURRENT_VAL_REG+2*NUMREGS), 2*REGBYTES($BUFFER_REG) lqd $(CURRENT_VAL_REG+3*NUMREGS), 3*REGBYTES($BUFFER_REG) absdb $(PROCESSED_VAL_REG+0*NUMREGS), $(CURRENT_VAL_REG+0*NUMREGS), $CONVERSION_BYTES_REG absdb $(PROCESSED_VAL_REG+1*NUMREGS), $(CURRENT_VAL_REG+1*NUMREGS), $CONVERSION_BYTES_REG absdb $(PROCESSED_VAL_REG+2*NUMREGS), $(CURRENT_VAL_REG+2*NUMREGS), $CONVERSION_BYTES_REG absdb $(PROCESSED_VAL_REG+3*NUMREGS), $(CURRENT_VAL_REG+3*NUMREGS), $CONVERSION_BYTES_REG cgtbi $(BOOL_TMP1_REG+0*NUMREGS), $(CURRENT_VAL_REG+0*NUMREGS), 'a'-1 cgtbi $(BOOL_TMP1_REG+1*NUMREGS), $(CURRENT_VAL_REG+1*NUMREGS), 'a'-1 cgtbi $(BOOL_TMP1_REG+2*NUMREGS), $(CURRENT_VAL_REG+2*NUMREGS), 'a'-1 cgtbi $(BOOL_TMP1_REG+3*NUMREGS), $(CURRENT_VAL_REG+3*NUMREGS), 'a'-1 cgtbi $(BOOL_TMP2_REG+0*NUMREGS), $(CURRENT_VAL_REG+0*NUMREGS), 'z' cgtbi $(BOOL_TMP2_REG+1*NUMREGS), $(CURRENT_VAL_REG+1*NUMREGS), 'z' cgtbi $(BOOL_TMP2_REG+2*NUMREGS), $(CURRENT_VAL_REG+2*NUMREGS), 'z' cgtbi $(BOOL_TMP2_REG+3*NUMREGS), $(CURRENT_VAL_REG+3*NUMREGS), 'z' xor $(IN_RANGE_REG+0*NUMREGS), $(BOOL_TMP1_REG+0*NUMREGS), $(BOOL_TMP2_REG+0*NUMREGS) xor $(IN_RANGE_REG+1*NUMREGS), $(BOOL_TMP1_REG+1*NUMREGS), $(BOOL_TMP2_REG+1*NUMREGS) xor $(IN_RANGE_REG+2*NUMREGS), $(BOOL_TMP1_REG+2*NUMREGS), $(BOOL_TMP2_REG+2*NUMREGS) xor $(IN_RANGE_REG+3*NUMREGS), $(BOOL_TMP1_REG+3*NUMREGS), $(BOOL_TMP2_REG+3*NUMREGS) selb $(CURRENT_VAL_REG+0*NUMREGS), $(CURRENT_VAL_REG+0*NUMREGS), $(PROCESSED_VAL_REG+0*NUMREGS), $(IN_RANGE_REG+0*NUMREGS) selb $(CURRENT_VAL_REG+1*NUMREGS), $(CURRENT_VAL_REG+1*NUMREGS), $(PROCESSED_VAL_REG+1*NUMREGS), $(IN_RANGE_REG+1*NUMREGS) .align 3 selb $(CURRENT_VAL_REG+2*NUMREGS), $(CURRENT_VAL_REG+2*NUMREGS), $(PROCESSED_VAL_REG+2*NUMREGS), $(IN_RANGE_REG+2*NUMREGS) stqd $(CURRENT_VAL_REG+0*NUMREGS), 0*REGBYTES($BUFFER_REG) selb $(CURRENT_VAL_REG+3*NUMREGS), $(CURRENT_VAL_REG+3*NUMREGS), $(PROCESSED_VAL_REG+3*NUMREGS), $(IN_RANGE_REG+3*NUMREGS) stqd $(CURRENT_VAL_REG+1*NUMREGS), 1*REGBYTES($BUFFER_REG) stqd $(CURRENT_VAL_REG+2*NUMREGS), 2*REGBYTES($BUFFER_REG) stqd $(CURRENT_VAL_REG+3*NUMREGS), 3*REGBYTES($BUFFER_REG) ai $BUFFER_REG, $BUFFER_REG, REGBYTES cgt $IS_FINISHED_REG, $BUFFER_REG, $BUFFER_END_REG loop_branch_instruction: brz $IS_FINISHED_REG, loop_start end_function: bi $lr |
这段代码已经消除了分支,进行了向量化,并且对循环进行了展开,还应用了指令调度和分支提示。换言之,它现在已经运行得非常快了。下一篇文章将转到使用 C 语言进行编程的话题上,不过这些信息对于理解编译器试图(至少应该尝试)干些什么会非常有用,并对编译器的输出结果进行分析,以便了解手动编写的汇编语言在哪些方面可以带来更好的性能。
敬请关注本系列的其他文章:我们将探索使用 C 语言编写 SPU 程序,将 SPU 扩展转换成 C 语言格式,以及其他一些高级优化技术。
-
您可以参阅本文在 developerWorks 全球网站上的 英文原文。
-
查看 在 Cell BE 处理器上编写高性能的应用程序 系列文章的其他部分。
-
随时使用 assembly language overview 和 instruction set architecture reference,获取更详细的信息。
- ArsTechnica 有一篇 overview of SIMD architectures(在 Cell BE 处理器之前),以及一篇 introduction to the Cell BE's SIMD architecture。
- Cell Broadband Engine Programming Handbook 有很多有关 SPU 底层细节的有趣内容。其中非常有用的一些内容有:
- 75 到 76 页和 687 到 688 页讨论了流水线和延时问题。
- 768 到 772 页介绍了为什么需要使用无操作指令来利用双发射规则,指令流水线是什么样子,以及指令预取是如何工作的。772 页介绍了 hbrp 指令,它可以用来帮助预取调度。
- 689 到 697 页介绍了分支消除和提示的内容。
- 如果您希望详细了解分支优化的知识,应该 阅读 IBM 编译器小组对分支优化的其他考虑。
- 其他优化考虑和建议都可以在这个幻灯片中看到: Cell programming tips and techniques(PDF)。
-
随时关注 Cell BE 的消息:订阅 IBM
microNews。
Jonathan Bartlett 是 Programming from the Ground Up 一书的作者,这本书对使用 Linux 汇编语言的编程进行了简介。他是 New Medio 的开发技术总监,为客户开发 Web 应用程序、视频应用程序、kiosk 应用程序和桌面应用程序。