OpenCL Kernel设计优化
使用Intel® FPGA SDK for OpenCL™ 离线编译器,不需要调整kernel代码便可以将其最佳的适应于固定的硬件设备,而是离线编译器会根据kernel的要求自适应调整硬件的结构。
通常来说,应该先优化针对单个计算单元的kernel,之后累哦通过增加计算单元数量来拓展硬件以填充FPGA其余的部分,从而提升性能。Kernel的使用面积与硬件编译所需要的时间有关,因此为了避免硬件编译时间过长,首先要专注于优化kernel在单个计算单元上的性能。
要优化kernel的性能,主要包括数据处理以及内存访问优化。
a. 通过SDK的channel 或pipe来传输数据。为了提高kernel之间的数据传输效率,在kernel程序中使用channel通道拓展。 如果想利用通道功能,又想使用其他SDK运行kernel,则使用OpenCL pipes。
b. 展开循环。
c. 优化浮点运算。对于浮点操作,可以手动引导SDK的离线编译器进行优化,从而在硬件中创建更有效的pipeline结构并减少总体硬件使用率。
d. 分配对齐的内存。再分配与FPGA进行数据传输的主机端存储器时,存储器至少是64字节对齐的。
e. 使用或不用Padding来对齐结构。
f. 保持向量元素的相似结构。如果更新了向量的一个元素,那么更新这个向量的所有元素。
g. 避免指针混淆。尽量在指针参数中插入strict关键字。
h. 避免开销大的函数/功能。有些函数在FPGA中实现开销很大,可能会减低kernel的性能,或是需要大量硬件来实现。
i. 避免依赖于work-item id的后向分支。避免在kernel中包括任何与工作项ID相关的向后分支(即,循环中发生的分支),因为这会降低性能。
1、通过SDK的channel 或pipe来传输数据
为了提高kernel之间的数据传输效率,在kernel程序中使用channel通道拓展。 如果想利用通道功能,又想使用其他SDK运行kernel,则使用OpenCL pipes。
有时,FPGA到global memory全局存储器带宽会限制内核之间的数据传输效率。 理论上FPGA到global memory全局存储器的最大带宽根据目标定制平台和板上可用的全局存储器bank的数量而变化。 要确定主板的理论最大带宽,要参考主板的文档。
实际上,kernel无法实现最大可用全局内存带宽的100%利用率。 利用率级别取决于算法的访问模式。
如果全局内存带宽是我们使用OpenCL内核的性能限制,首先尝试将算法分解为多个较小的kernel。 其次,通过在内核之间实现SDK的channel或OpenCL的pipe进行数据传输来消除一些全局内存访问。
(1) Channel与pipe的特性
a. Default Behavior
Channel默认行为是阻塞的,而pipe的默认行为是非阻塞的(nonblocking)。
b. 多个OpenCL内核的并发执行
可以同时执行多个OpenCL内核。 要启用并发执行,要修改主机代码以实例化多个命令队列。 每个同时执行的kernel内核都与一个单独的命令队列关联。
pipe的特别注意事项:Intel SDK 中OpenCL的pipe是允许在其他的OpenCL SDK上兼容的,但不能最大化kernel内核吞吐量。OpenCL 2.0中要求在进行pipe读取前先进性pipe写入,以免kernel在空pipe中读取数据,因此kernel无法同时运行。由于Intel SDK支持并发执行,可以修改主机应用程序以及kernel程序来实现并发执行,从而提高吞吐量。但不能将kernel移植到其他SDK上。
要启用并发执行包含pipe的内核,需要将内核代码中的depth属性替换为blocking属性(即__attribute __((blocking)))。 blocking属性在read_pipe和write_pipe函数调用时引入blocking行为。 调用点将阻止内核执行,直到管道的另一端准备好为止。
如果同时将blocking属性和depth属性添加到内核,则当管道为空时,read_pipe仅调用一个块,而当管道为满时,write_pipe仅调用一个块。 blocking行为会导致内核之间的隐式同步,从而使得内核之间互锁。
c. 隐式内核Kernel同步
通过blocking channel调用以及blocking pipe调用来隐式同步kernel。
channel int c0; __kernel void producer (__global int * in_buf) { for (int i = 0; i < 10; i++) { write_channel_intel (c0, in_buf[i]); } } __kernel void consumer (__global int * ret_buf) { for (int i = 0; i < 10; i++) { ret_buf[i] = read_channel_intel(c0); } }
__kernel void producer (__global int * in_buf, write_only pipe int __attribute__ ((blocking)) c0) { for (int i = 0; i < 10; i++) { write_pipe (c0, &in_buf[i]); } } __kernel void consumer (__global int * ret_buf, read_only pipe int __attribute__ ((blocking)) c0) { for (int i = 0; i < 10; i++) { int x; read_pipe (c0, &x); ret_buf[i] = x; } }
可以同步内核,以便在每次循环迭代期间,producer kernel写入数据,consumer kernel读取数据。如果producer中的write_channel_intel 或 write_pipe并没有写入数据,consumer将阻塞并在read_channel_intel或read_pipe调用处等待直到producer发送有效数据为止,反之亦然。d. 跨调用的数据持久性
在调用write_channel_intel将数据写入channel或调用write_pipe将数据写入pipe之后,数据在work-groups和NDRange调用之间保持不变。work-item写入channel或pipe的数据将保留在该channel或pipe中,直到从另一个work-item读取它为止。此外,channel或pipe中的数据顺序等效于对该channel或pipe的写操作顺序,且该顺序与执行写操作的work-item无关。
例如,如果多个work-item尝试同时访问一个channel或pipe,则只有一个工作项可以访问它。调用write_channel_intel或调用write_pipe分别将被称为DATAX的特定的work-item数据写入channel或pipe。同样,访问channel或pipe的第一个work-item将从中读取DATAX。读写操作的这种顺序顺序使通道和管道成为内核之间共享数据的有效方法。
e. 强制的work-item顺序
SDK强制work-item的顺序与channel或pipe的读写操作保持一致性。
(2) Channel或pipe的执行顺序
kernel程序中的每个channel或pipe的调用都会转换为FPGA pipeline中执行的指令。如果通过pipeline执行有效的work-item,则会调用channel或pipe。但即使channel或pipe调用之间没有控制或数据依赖性,它们的执行也可能无法在kernel的pipeline中实现最优的并行性。
(3) 优化channel或pipe的缓冲区推断
除了手动添加缓冲的channel或pipe以外,Intel SDK的离线编译器尽可能通过调整缓冲区大小来提高kernel的吞吐量。在编译期间,离线编译器计算交互的channel或pipe之间的调度不匹配,这些不匹配可能会导致读写操作之间的不平衡。离线编译器自动执行缓冲区推断优化来纠正不平衡性。
(4) Best Practices for Channel&Pipe
使用单线程kernel而不是多线程kernel。
考虑怎样用前馈数据来表示设计模型,例如,back-to-back循环或离散处理步骤。确定是否应该将设计拆分为通过channel连接的多个kernel。
只有当kernel上的同一点使用整个数据时,才在channel上将数据聚合。
尝试使每个kernel保持合理的channel数。
如果使用等待数据的循环结构,不要使用非阻塞(non-blocking)的channel或pipe。non-blocking的channel要比blocking的channel消耗更多的资源。
2、展开循环
使用 #pragma unroll 展开循环,注意尽量避免嵌套循环。
可以控制离线编译器将kernel装换位硬件资源的方式。如果kernel中包含循环迭代,可以通过展开循环来提高性能。循环的展开减少了离线编译器执行的迭代次数,但代价是硬件资源消耗的增加。
如果有比较充分的硬件资源,直接在主循环中添加#progma unroll来展开循环。循环的展开会显著地改变离线编译器创建的计算单元的结构。
__kernel void example ( __global const int * restrict x,
__global int * restrict sum ) {
int accum = 0;
#pragma unroll
for (size_t i = 0; i < 4; i++) {
accum += x[i + get_global_id(0) * 4];
}
sum[get_global_id(0)] = accum;
}
#pragma unroll指令使离线编译器完全展开循环的四个迭代。 为了完成展开,离线编译器通过将加法运算数量增加三倍并加载四倍的数据来扩展pipeline。 移除循环后,计算单元将采用前馈结构。 结果,计算单元可以在初始加载操作和加法完成之后的每个时钟周期存储和的计算结果。 离线编译器通过合并四个加载操作来进一步优化此kernel,以便计算单元可以加载所有必需的输入数据从而在一个加载操作中计算出结果。
注意:
不要使用嵌套循环,尽可能地添加#progma unroll指令,实现大的单循环或展开内部循环。离线编译器不一定能轻易地展开循环,如果循环嵌套层数较多较复杂,会导致编译时间很长。
展开循环并合并全局内存中的加载操作,可使内核的硬件实现在每个时钟周期执行更多操作。 通常,用于提高OpenCL的kernel性能的方法应达到以下结果:
增加并行操作数
增加实现的内存带宽
增加内核可以在硬件中执行的每个时钟周期的操作数
而在以下情况下,离线编译器可能无法完全展开循环:
想要完全展开具有非常大数量迭代的存在数据依赖性的循环,kernel的硬件实现可能不适用于FPGA。
完全展开循环边界不是常数的循环。
循环由负责的控制flow组成,例如包含复杂数组索引或退出条件(在编译时未知)的循环。
要在这些情况下启用循环展开,指定#pragma unroll <N>指令,其中<N>是展开因子。 展开因子限制离线编译器展开的迭代次数。 例如,要阻止kernel中的循环展开,将指令#pragma unroll 1添加到该循环中。
3、优化浮点操作
对于浮点操作,可以手动指导离线编译器进行优化,以在硬件中创建更有效的pipeline结构并减少总体硬件的使用率。这些优化可能会导致浮点操作的结果产生细小差别。
Tree Balancing
离线编译器不会自动将程序优化成树平衡结构运算,因为这会导致结果与真实结果有些许差别。如果不在意浮点结果的细微差别,并希望离线编译器使用平衡树结构优化浮点运算,则需要在aoc命令中包括 -fp-relaxed 选项:
aoc -fp-relaxed <kernel_filename>.cl
Rounding Operations
浮点运算的平衡树结构包含多个舍入运算,这些舍入操作在某些应用程序中可能需要大量的硬件资源,离线编译器不会自动减少舍入运算的数量。如果可以接受浮点运算结果的细小差异,可以使用aoc命令中的 -fpc 减少实现浮点运算所需的硬件数量:
aoc -fpc <kernel_filename>.cl
-fpc 选项指示离线编译器执行以下任务:
尽可能的删除浮点舍入运算及转换。如果可能,离线编译器仅在浮点运算树的末尾舍入一次浮点运算。
携带其他尾数位以保持精度。离线编译器在浮点计算过程中携带其他精度位,并在浮点运算树的末尾删除这些精度位。
这种类型的优化会导致硬件执行融合的浮点运算,这也是许多新硬件处理系统的功能。融合多个浮点运算可以最大程度地减少舍入步骤,从而获得更准确的结果。
(1)浮点vs定点表示
FPGA包含大量用于实现浮点运算的逻辑,但只要有可能,就可以使用数据的定点表示来增加可用的硬件资源量。实现定点运算所需的硬件资源通常要小于等效地浮点运算。所以与等效的浮点运算相比,可以在FPGA中容纳更多的定点运算。
OpenCL的标准不支持定点表示,必须使用整数数据类型实现。硬件开发者通常使用定点数据以节省硬件,并且仅保留执行计算所需的数据分辨率。必须使用8\16\32\64位标量数据类型,因为OpenCL标准支持这些数据分辨率。然而,可以在源代码中包含适量的屏蔽操作,以便硬件编译工具可以执行优化来节省硬件资源。
例如,算法使用17位数据的定点表示,那必须使用32位数据类型存储该值。如果使用Intel SDK的离线比那一起将两个17位的定点值加在一起,离线编译器必须创建额外的硬件来处理多余的高15位。为了避免使用额外的硬件,可以使用静态位掩码来指示硬件编译工具在硬件编译期间忽略不必要的位。
4、分配对齐内存
再分配用于与FPGA进行数据传输的主机端存储器时,该存储器必须至少与64字节对齐。对齐主机端存储器可以实现直接进出FPG的直接存储器访问(DMA)传输,并提高缓冲区传输效率。
注意:根据主机端内存的使用方式,Intel建议分配更严格的对齐方式。例如,如果使用 CL_MEM_USE_HOST_PTR 标志将分配的内存用于创建缓冲区,那么该内存也应该正确对齐以用于访问内核中的缓冲区的数据类型。要设定对齐的内存分配,需要将以下代码添加到HOST主机程序中:
对于Windows:
#define AOCL_ALIGNMENT 64 #include<malloc.h> void *ptr = _aligned_malloc(size, AOCL_ALIGNMENT);
对于Linux:
#define AOCL_ALIGNMENT 64 #include<stdlib.h> void *ptr = NULL; posix_memalign(&ptr, AOCL_ALIGNMENT, size);
如果要释放内存,则使用 free(ptr) 。
5、对齐带有或不带有填充的结构
确保数据的正确对齐。要确保数据结构的四字节对齐,小于四字节的结构对其会导致硬件变大与变慢。
例如:
typedef struct{ char r, g, b, alpha; } __attribute__((aligned(4))) Pixel;
代码将Pixel结构强制进行4字节对齐。
为了防止离线编译器自动为struct加入padding填充,可以使用packed属性,例如:
struct __attribute__((packed)) Mystruct { char a; int b; int c; }
这样Mystruct结构大小是16位字节而不是12字节,要比没有packed属性的12字节更高效。
而如果同时使用aligned 与 packed, 如下例所示。
struct __attribute__((packed)) __attribute__((aligned(16))) Mystruct { char a; int b; int c; }
Mystruct结构大小是9位字节。而由于aligned(16),所以struct是以16字节的方式对齐存储的,而且没有padding,所以kernel数据访问是高效的。
6、保持向量元素的相似结构
如果更新了向量类型的一个元素,那么更新这个向量的所有元素。
__kernel void update (__global const float4 * restrict in, __global const float4 * restrict out) { size_t gid = get_global_id(0); out[gid].x = process(in[gid].x); out[gid].y = process(in[gid].y); out[gid].z = process(in[gid].z); out[gid].w = 0; //Update w even if that variable is not required. }
7、避免指针混淆
尽量在指针参数中加入restrict。
8、避免代价高昂的函数/功能
在FPGA中代价高昂的函数包括:
整数除法以及取模(取余数)运算;
除了加、乘、绝对值与比较之外,绝大多数的浮点运算;
Atomic Function原子函数。
相反,代价较低的功能包括:
二进制的逻辑运算,AND/NAND/OR/XOR/NOR/XNOR等;
具有常数参数的逻辑运算;
常数位的位移;
整数乘或除以一个2的幂次常数。
如果一个代价高昂的函数为work-group中的每个work-item都创建了一个新数据,那么付出高昂代价是值得的,可以通过一次代价高昂的计算将计算结果传递到多个work-item中。
9、避免依赖于work-item id的后向分支
避免在kernel中包含任何与work-item id相关的后向分支(即循环中发生的分支),否则会降低性能。