本文主要归纳AMD HD Graphics(R700以后的架构)中的一些官方提出的术语以及与OpenCL中的术语的一些联系。主要从硬件架构和执行模型的角度做些讲解。内容摘自《AMD_Accelerated_Parallel_Processing_OpenCL_Programming_Guide.pdf》。
GPU计算设备(Compute Device)由计算单元(Compute Unit)组成(见图1.1)。不同的GPU计算设备有不同的特性(比如设备单元的数量),但会遵循一个相似的设计模式。
计算单元(译者注:这里,AMD的Compute Unit作为一个硬件上的术语,正好能与OpenCL中的Compute Unit这个逻辑抽象概念对应上,而老的称呼为SIMD;而在nVidia的GPGPU上被称为一个SM,即Streaming Multiprocessor)含有若干个流核心(Stream Core),它们负责执行内核程序,每个流核心对一条独立的数据流操作。每个流核心(译者注:老的称呼为流处理器,即Stream Processor;而在nVidia的GPGPU上仍然叫SP)含有若干个处理元素(Processing Element),它们是基本的可编程计算单元,用于执行整型、单精度浮点、双精度浮点、以及复杂功能操作。一个计算单元内的所有流核心以锁步(lock-step)执行同一个指令序列;不同的计算单元可以执行不同的指令。
一个流核心被安排为一个五路或四路(依赖于GPU类型)(译者注:R700以及R800的GPU有五路,即五个PE,而HD6900系列的GPU含有四路,即四个PE)超长指令字(VLIW)处理器(见图1.2的底部)。在一条VLIW指令中可以有多达五个标量操作(或四个,依赖于GPU类型)被协同发布。处理元素可以执行单精度浮点或整型操作,五个处理元素中有一个也可以执行复杂操作(正弦、余弦、对数等等)。一个双精度浮点操作通过连接其中两个或四个处理元素(除了复杂操作核心的其它四个)来执行。流核心也含有一个分支执行单元来处理分支指令。
不同的GPU计算设备含有不同数量的流核心。比如,ATI Radeon HD 5870 GPU含有20个计算单元,每个计算单元含有16个流核心,每个流核心含有5个处理元素;这产生了1600个物理处理元素。
在一个计算单元上运行的一个内核程序的每个实例称为一个工作项(work-item)。工作项所映射到的输出缓存的一个指定的矩形范围叫作n维索引空间,称为一个NDRange。GPU在一组流核心上调度工作项的范围,直到所有的工作项被处理。只有当应用完成后,后续的内核程序可以被执行。
OpenCL将所有要被发射的工作项映射到一个n维网格(ND-Range)上。开发者可以指定如何将这些工作项划分为工作组(Work Group)。AMD GPU在Wavefront(一个计算单元中的一组工作项[译者注:即一个工作组]以锁步执行在一个wavefront上)上执行。
1.3.1 工作组处理
一个计算单元内的所有流核心对于每个周期执行同一条指令。一个工作项每个时钟周期可以发布一条VLIW指令。一起被执行的工作项的块称为一个wavefront。为了隐藏由于存储器访问以及处理元素操作所带来的延迟,可以有多达4个来自同一个wavefront上的工作项在同一个流核心上被流水处理。(译者注:简而言之,一个流核心可以流水地在一个wavefront中最多调度执行4个工作项的同一条指令。这样的话,如果一个计算单元有16个流核心,那么一个wavefront在4个时钟周期内可以执行64个工作项的同一条指令。我们可以把这4个周期看作为一个wavefront执行周期。)
计算单元相互独立地执行,这样,对于每个阵列(译者注:指计算单元)就有可能执行不同的指令。
1.3.2 流控制
在讨论控制流之前,有必要阐明一下一个wavefront与一个工作组之间的关系。如果用户定义了一个工作组,那么该工作组就由一个或多个wavefront构成。wavefront是执行单元,一个wavefront由64个或更少的工作项构成,如果一个设备上的wavefront大小为64,那么两个wavefront就可以有65到128个工作项。对于最优的硬件使用来说,推荐使用64的整数倍个数的工作项(译者注:如果当前GPU的wavefront大小为64的话,其实确切地来说是wavefront大小的整数倍)。
诸如分支这样的流控制,通过结合所有必要的执行路径作为一个wavefront来完成。如果在一个wavefront中的工作项出现分岔,那么所有的路径必须被串行执行。比如,如果一个工作项含有一个带有两条路径的分支,那么该wavefront先执行一条分支,再执行另一条。执行分支的总的时间是每条路径的执行时间的总和。重要的一点是,即使一个wavefront中只有一个工作项出现分岔,那么wavefront中的其余工作项也要执行那个分支。[译者注:比如,
__kernel void test(__global int *pOrg) { size_t id = get_global_id(0); if((id & 63) == 0) // 一个wavefront中的所有工作项都会执行这个分支 { // 工作项0将执行以下分支 // 而当工作项0执行此分支时,该wavefront的其余的工作项都将会等待其执行完成 if(pOrg[id] < 0) pOrg[id]++; } pOrg[id] *= 2; }
]在一个分支中,必须被执行的工作项的个数被称为分支粒度。在AMD硬件上,分支粒度与wavefront粒度相同。(译者注:一个wavefront上的所有工作项参与本wavefront所遭遇到的一次分支执行,而其它wavefront上的工作项不受到当前分支影响。)
wavefront的执行掩膜(译者注:即是否执行当前分支的标志)通过以下构造产生效果:
if(x) { // 在这大括号内的工作项=A } else { // 在这大括号内的工作项=B }
wavefront掩膜在对于x为真的泳道(元素/工作项)被置为真,并且执行A。[译者注:所谓泳道,在高性能密集计算领域中往往对应一次SIMD操作中的某个元素。比如,
; x86 SSE2
; xmm1与xmm2长度为16个字节,每个字节单独作为一个元素,
; 此操作将xmm1与xmm2中的每个单字节元素对应相加,将结果放入xmm1中。
; 那么此操作过程中,xmm1和xmm2中的16个单字节元素的每一个称为一个泳道
paddb xmm1, xmm2
; 将xmm1和xmm2中的每四个字节作为一个单独的元素,一共4个元素
; 此操作将xmm1与xmm2的每个四字节元素对应相加,将结果存放入xmm1中。
; 那么在此操作过程中,xmm1和xmm2中的每个四字节元素称为一个泳道。
paddd xmm1, xmm2
那么这里,一个wavefront如果有64个工作项,那么每个工作项称为wavefront的一个泳道]掩膜然后再反转过来,则B被执行。
例1:如果有两个分支,A和B,花费了相同的时间t在一个wavefront上执行,那么如果有任一工作项出现分岔的话,执行的总时间就是2t。
循环以相似的方式执行,只要在wavefront中至少有一个工作项仍然在被处理,那么此wavefront还会在占用一个计算单元。
因此,对于wavefront的总的执行时间由耗费最长执行时间的工作项决定。
例2:如果t是执行一个循环的一单次迭代所花费的时间;并且在一个wavefront内,所有工作项执行该循环一次,而其中有一个工作项执行了该循环100次,那么执行整个wavefront所花费的时间为100t。
1.6 GPU计算设备调度
GPU计算设备以一种对应用透明的方式有效地并行处理大量的工作项。每个GPU计算设备使用大量的wavefront,通过使资源调度器切换在一个所给定的计算单元中的活动wavefront来隐藏存储器访问延迟,每当当前的wavefront在等待一次存储器访问完成时。隐藏存储器访问延迟要求每个工作项在每次存储器加载/存储时含有大量的ALU操作。
图1.9展示了在一个流核心中的对于工作项的一个简化的执行时序。在时刻0,工作项被排好队,等待执行。在这个例子中,只有四个工作项(T0...T3)为此计算单元而被调度。活动工作项的数量的硬件限制依赖于正被执行的程序的资源使用(诸如所使用的活动寄存器的个数)。一个最优编程的GPU计算设备一般具有上千个活动工作项。
在运行时,工作项T0一直执行直到周期20;在这个时刻,由于一次存储器读取请求而发生一个延迟。调度器开始执行下一个工作项,T1。工作项T1一直执行,直到它延迟或完成。新的工作项执行,并且这过程一直持续,直到达到可用的活动的工作项的数量(译者注:比如达到一个工作组最大的工作项的数量)。调度器然后返回到第一个工作项,T0。
如果正在等待的数据工作项T0从存储器操作返回了,T0继续执行。在图1.9中的例子中,数据准备好,因此T0继续执行。由于有足够多的工作项和处理元素操作来覆盖长的存储器延迟,流核心并不会闲置。这种存储器延迟隐藏方法帮助了GPU计算设备实现最大的性能。
如果T0倒T3都不在执行,那么流核心一直等待(延迟)直到T0到T3的其中一个准备执行。在图1.10所展示的例子中,T0是第一个继续执行的。
1.7 术语
1.7.1 计算内核
为了要定义一个计算内核,首先有必要定义一个内核。一个内核是一个小的,用户开发的程序,在一条数据流上重复地运行。它是一个并行的函数对输入流的每个元素(称为一个NDRange)进行操作。除非有其它指定,否则一个AMD计算设备是由一个主函数与零个或多个函数组成的一个内核。这也被称为一个着色器程序。这个内核不要与操作硬件的一个OS内核搞混淆。一个NDRange的最基本的形式是简单地映射到输入数据,并为每个输入元组生成一个输出项。对于基本模型的后续扩展提供了随机访问功能,可变的输出数量,以及缩减/积累操作。内核使用kernel关键字来指定。
有多种内核类型执行在一个AMD加速并行处理设备上,包括顶点、像素、几何、域(domain)、外廓(Hull),以及现在的计算。在计算内核开发出来之前,像素着色器有时被用于非图形计算。现在不通过像素着色器执行计算,新的硬件支持计算内核,它更好地适应于通用计算,它也可以被用于支持图形应用,允许基于传统图形流水线的渲染技术。一个计算内核是一个特定类型的内核,并不是传统图形流水线的一部分。计算内核可以被用于图形处理,但更有利于执行非图形领域,比如物理、AI、建模、HPC以及其它密集计算应用。
1.7.1.1 工作项产生次序
在一个计算内核中,工作项的产生次序是顺序的。这意味着在一个带有每个wavefront含有N个工作项的芯片上,头N个工作项去往wavefront 1,第二组N个工作项去往wavefront 2,以此类推。因此,对于wavefront K的工作项ID是从(K*N)到((K + 1)*N) - 1。
1.7.2 Wavefront和工作组
wavefront和工作组是与计算内核相关的两个概念,计算内核提供了数据并行的粒度。wavefront并行执行N个工作项,而N是硬件芯片特定的(对于ATI Radeon HD 5870系列,是64)。一单条指令在一个wavefront上经过所有的工作项被并行执行。它是控制流所能影响的最低层。这意味着如果在一个wavefront内的两个工作项跑控制流的分岔路径,那么该wavefront中的所有工作项都要跑控制流的这两个路径。
分组是数据并行的更高层粒度,它以软件实施,而不是硬件。在一个内核中的同步点确保一个工作组中的所有工作项在下一条语句被执行之前到达代码中的(栅栏)点。
工作组由wavefront构成。当工作组大小是wavefront大小的整数倍是能获得最佳的性能。
1.7.3 本地数据存储(LDS)
LDS是一个高速、低延迟的存储器,对每个计算单元私有。它是一个全搜集(gather)/散播(scatter)模型:一个工作组可以写在其所分配的空间中的任意位置。该模型为ATI Radeon HD5xxx系列。当前LDS的限制有:
1、所有的读/写都是32位并且是双字(译者注:32位)对齐的。
2、每个工作组都分配了LDS大小。每个工作组指定它需要多少LDS。硬件调度器使用此信息来确定哪些工作组可以共享一个计算单元。
3、数据只能在一个工作组中的工作项内被共享。
4、工作组外的存储器访问导致未定义的行为。
4.7 本地存储器(LDS)优化
AMD Evergreen GPU包含一个本地数据存储(LDS)Cache,这加速了本地存储访问。在AMD R700家族的GPU上的OpenCL中LDS不被支持。LDS提供了高带宽访问(比全局存储器高10倍)、在一个工作组中的任意两个工作项之间有效的数据传输、以及高性能原子支持。当数据被重用时,本地存储器提供了重要的优势;比如,后续的访问可以从本地存储器读,从而减少全局存储器带宽。另一个优势是本地存储器不需要合并访问(Coalescing)。
确定本地存储器大小:
clGetDeviceInfo( ..., CL_DEVICE_LOCAL_MEM_SIZE, ... );
所有AMD Evergreen GPU中,每个计算单元包含一个32KB的LDS。在高端GPU上,LDS含有32个段,每个段为4字节宽,256字节深(译者注:即256个条目,即每个段有256个条目);段地址由地址中的6:2位确定。在低端GPU上,LDS含有16个段,而每个段仍然为4个字节宽,并且段由地址中的5:2位确定。
在一单个周期中, 本地存储器可以为每个段服务一个请求(在ATI Radeon HD 5870 GPU上每个周期最多达到32个访问)。对于一个ATI Radeon HD Graphics 5870 GPU而言,这为每个计算单元提供了超过100GB/s的存储器带宽,而对于整个芯片大于2TB/s。这比全局存储器带宽高出14倍之多。然而,映射到同一个段的访问是串行的,并且是在连续的周期上受到服务。产生段冲突的一个wavefront在计算单元上延迟,直到所有的LDS访问已经完成。GPU在后续的周期上重新处理那个wavefront,只允许接收数据的泳道(译者注:这里的泳道是指wavefront上的一个泳道,即对应的一个工作项),直到所有的冲突访问完成。带有最多冲突访问的段决定了wavefront完成本地存储器操作的延迟。当所有64个工作项映射到同一个段时,最糟糕的情况发生,由于这样每次访问都是以每周期一个的速率;这种情况要花费64个周期来为该wavefront完成本地存储器访问。具有大量段冲突的一个程序或许可以通过使用常量或图像存储器获益。
因此,有效使用本地Cache存储器的关键是控制访问模式,以至于在同一个周期上所产生的访问映射到本地存储器的不同的段。一个要注意的例外是对同一地址的访问(即便它们都有相同的位6:2)可以被广播到所有的请求者并且不产生一个段冲突。LDS硬件为段冲突检查所产生的请求,需要两个周期(32个工作项的执行)。尽可能地确保由一个四分之一的wavefront所生成的存储器请求通过使用唯一的地址位6:2来避免段冲突。在ATI Radeon HD 5870 GPU上的一个简单的顺序地址访问模式的例子,每个工作项从LDS读取一个float2的值,产生一个无冲突的访问模式。注意,对于这么一个访问模式,每个工作项从LDS读取一个float4的值,在每个周期上只使用了一半的段,因而只提供了float2访问模式的一半的性能。
每个流处理器每个周期可以生成多达两个4字节LDS请求。byte和short读也要消耗LDS带宽的四个字节。由于每个流处理器每个周期在VLIW中可以执行五个操作(或四个操作)(一般需要10-15个输入操作数),所以两次本地存储器请求可能无法提供足够的带宽来服务整条指令(译者注:即一条VLIW指令)。开发者可以使用巨大的寄存器文件:每个计算单元具有256KB的可用寄存器空间(8倍的LDS大小)并且可以提供多达12个4字节值/周期(6倍的LDS带宽)。寄存器并不提供与LDS一样的索引灵活性,但是对于某些算法,这可以通过循环展开和显式寻址来克服。
LDS读需要一个ALU操作来将它们初始化。每次操作可以初始化两个加载,每个加载最多达四个字节。
本地存储器是软件控制的“便条”存储器。相比较之下,一般用于CPU上的Cache监视访问流并在一个打上标签的Cache(译者注:这里指一条Cache行)中捕获最近的访问。而本地存储器允许内核显式地将数据加载到存储器中;这些数据一直存在于本地存储器中,直到内核将它们替换掉,或工作组结束运行。要声明一块本地存储器,使用__local关键字;比如,
__local float localBuffer[64];
这些声明既可以在内核声明的形参中,也可以在内核的代码块中。__local语法分配一单块存储器,这块存储器在工作组中的所有工作项之间共享。
要将数据写入本地存储器,将它写入到用__local分配的一个数组中。比如:
localBuffer[i] = 5.0;
一个典型的访问模式为每个工作项相互协作地写到本地存储器:每个工作项写一个子区域,并且当这些工作项写整个本地存储器数组时,这些工作项并行执行。通过与适当地对访问模式和段对齐相结合,这些协作写方式可以产生高效的存储器访问。本地存储器只有在一个工作组栅栏上保证工作项之间的一致性;因此,在读相互协作而写的值之前,内核必须包含一条barrier()指令。
下面的例子是一个简单的内核片段,展示了协作写本地存储器,然后再读:
__kernel void localMemoryExample (__global float *In, __global float *Out) { __local float localBuffer[64]; uint tx = get_local_id(0); uint gx = get_global_id(0); // Initialize local memory: // Copy from this work-group’s section of global memory to local: // Each work-item writes one element; together they write it all localBuffer[tx] = In[gx]; // Ensure writes have completed: barrier(CLK_LOCAL_MEM_FENCE); // Toy computation to compute a partial factorial, shows re-use from local float f = localBuffer[tx];
for (uint i=tx+1; i<64; i++) { f *= localBuffer[i]; } Out[gx] = f; }
注意,主机端代码不能读写本地存储器。