大家做高性能计算的朋友,想必对CPU的执行模式已经非常熟悉了吧。当代高级些的CPU一般采用超标量流水线,使得毗邻几条相互独立的指令能够并行执行——这称为指令集并行(ILP,Instruction-Level Parallelism);而像x86引入的SSE(Streaming SIMD Extension)、AVX(Advanced Vector Extension),以及ARM的NEON技术都属于数据级并行(Data-Level Parallelism)。而GPGPU的执行与CPU比起来还是有不少差异的。这里,为了能够让大家更好地理解、并使用OpenCL,想谈谈当前主流用于超算的GPGPU的执行模式。
下面主要针对nVidia的Fermi和Kepler架构以及AMD的TeraScale3(Radeon HD 6900系列)和GCN架构进行分析。
我们先来简单介绍一下OpenCL中的一些术语对应到nVidia以及AMD的GPGPU硬件中的称谓。
在物理上,一个GPGPU作为一个计算设备,在OpenCL中就称为Device。在这么一个计算设备中由若干大的计算核心构成,这个大的计算核心在OpenCL中称为CU(Compute Unit),在nVidia中称为SM(Streaming Multiprocessor),在AMD中则称为SIMD(Single-Instruction-Multiple-Data)。一个大的计算核心中又由许多小的计算核心构成,这种小的计算核心在OpenCL中称为PE(Processing Element),在nVidia和AMD中均称为SP(Stream Processor)。此外,nVidia也好、AMD也罢,甚至还有一些如Intel HD Graphics这样的GPGPU,有一个OpenCL中没有对应到的术语,它其实属于一种GPGPU的执行模式,我这里暂且称为“线程流”。以应用开发者的角度来看,它是GPGPU中线程执行的最小并行粒度,稍后会详细讲解。这个概念在nVidia中称为Warp,在AMD中称为Wavefront。
我们下面先来看看nVidia的Fermi架构是如何在OpenCL中执行的。在Fermi架构中,一个SM一共有32个SP,16个存储器读写单元,四个特殊功能计算单元SFU(用于计算超越函数等复杂操作),64KB的共享存储器(Local Memory),32768个32位寄存器,两个Warp调度器与两个指令分派单元。
在此架构中,SM调度线程时会将32个线程(OpenCL中称为work-item)组成一组,然后并发执行。这个32线程组就是一个warp。由于每个SM含有两个warp调度器与两个指令分派单元,因此这就能够将两个warp同时进行发射和执行。Fermi的双warp调度器先选择两个warp,然后从每个warp发射一条指令到一个十六核心的组,或是十六个读写单元,或是四个SFU。正由于warp执行是独立的,因此Fermi的调度器无需检查指令流内的依赖性。
那么这个执行模式如何映射到一个OpenCL的kernel程序里呢?我们现在假设给kernel分配了512个work-item(Fermi架构的Max Work-group Size为1024),work-group size也是512,然后执行以下kernel代码:
1 __kernel ocl_test(__global int *p) 2 { 3 int index = get_global_id(0); 4 5 int x = p[index]; 6 7 x += 10; 8 9 p[index] = x; 10 }
我们首先要知道,一个work-group是被一个SM负责执行的。因为一个work-group中所含有的寄存器以及local memory资源都属于一个SM里的资源。所以,对于上述配置,这512个work-item都将在一个SM中完成执行。
那么上面提到的warp调度器从每个warp发射一条指令到一个十六核心的组,或是十六个读写单元,是怎么回事呢?
我们之前提到,一个SM一共有32个核心,每个调度器会将指令发送到其中一半(相应的16个核心),这样两个调度器同时发射一次,那么相应的指令正好能在这32个核心中执行一次。因此,对于warp调度器而言,完整地执行一个warp会将一条指令连续发射两次。而这两次发射对于程序员而言可以看作是原子的,即不可分割的。对于每个调度器,前一次发射,执行前16个work-item;后一次发射,执行后16个work-item;这前后两组16个work-item就组成了一个完整的32个work-item的Warp。
下面,我们看上述kernel程序,第5行、第9行,核心执行的是读写操作;第7行核心执行的算术计算。那么global ID则是从0到511。如果将一个warp的一次执行看作为一个周期的话,那么:
第一个周期:id从0-31的work-item,组成为warp0;id从32到63的work-item组成为warp1,送到SM同时执行一次。
第二个周期:id从64-95的work-item,组成为warp2;id从96到127的work-item组成为warp3,送到SM同时执行一次。
...
第八个周期:id从448-479的work-item,组成为warp14;id从480到511的work-item组成为warp15,送到SM同时执行一次。
这样,8个周期就将整个work-group执行了一遍。
Kepler架构的执行跟Fermi差不多,不过原本的SM,现在更名为SMX,它拥有四个warp调度器和八个指令分派单元,这就允许warp调度器选择4个warp被并发发射执行,而且又因为每个warp调度器又对应两个指令分派单元,从而使得每个warp的邻近两条相互独立的指令能够在一个周期内被同时执行。一个SMX至少含有128个核心,因此能够并行执行的work-item数量都是Fermi架构的两倍。因此,对于Fermi架构而言,我们在分配一个work-group size的时候,应该将它分配为64的倍数;而Kepler架构,则应该是128的倍数,这样能充分利用调度器而达到峰值计算。
下面我们再来看看Radeon HD Graphics TeraScale3的执行方式。Radeon HD Graphics 6900系列的一个SIMD作为一个CU。每个CU含有16个SP,256KB的寄存器文件(65536个32位寄存器),32KB的Local Memory。其中,每个SP含有四个独立的算术逻辑单元(ALU),允许四条相互独立的标量数据计算同时执行。不过每个SIMD的线程调度器仅一个。
Radeon HD Graphics的执行是以wavefront的模式执行的。一个SP中的每个ALU对应一条独立的wave,这样,一个SIMD中的每个SP就可以在一个周期以四条wave同时执行,当然前提是这四个操作相互独立,并且正好能被编排到一个SP中的各个ALU中。一个周期同时执行16个SP,这样最多就能完成64个标量算术逻辑操作。而GPU对同一组指令连续发射4次就正好把一条完整的wavefront执行完成,一共最多能完成16条wave,256个标量算术逻辑操作。下面举一个OpenCL的kernel例子:
假定,设置work-group大小为256,一共256个work-item。
1 __kernel ocl_test(__global int *a, __global int *b) 2 { 3 int index = get_global_id(0); 4 5 int4 vecA = vload4(index, a); 6 int4 vecB = vload4(index, b); 7 8 vecA.x += vecB.x 9 vecA.y += vecB.y; 10 vecA.z += vecB.z; 11 vecA.w += vecB.w; 12 }
上述代码第8到第11行,我们是把vecA += vecB;这条向量计算语句拆成了四条标量语句。OpenCL驱动确实也是如此做的,这样,这四条独立的标量算术操作正好能对应上一个SP的四个ALU上。就拿这四条语句而言:
第一个周期:id为0-15的work-item,每个work-item的第8行对应wave0,第9行对应wave1,第10行对应wave2,第11行对应wave3;
第二个周期:id为16-31的work-item,每个work-item的第8行对应wave4,第9行对应wave5,第10行对应wave6,第11行对应wave7;
第三个周期:id为32-47的work-item,每个work-item的第8行对应wave8,第9行对应wave9,第10行对应wave10,第11行对应wave11;
第四个周期:id为58-63的work-item,每个work-item的第8行对应wave12,第9行对应wave13,第10行对应wave14,第11行对应wave15;
这样,这四个周期完整地执行了整条wavefront,一共占用64个work-item,执行了256次算术操作。当然,对于应用开发者而言,这四个周期是原子的,不可被分割的。这也是为啥这四个周期执行了独立的16条wave的缘由。所以,对于VLIW4或VLIW5架构的Radeon HD Graphics,我们设置work-group size最好是64的倍数。
第五个周期:id为64-79的work-item,每个work-item的第8行对应wave0,第9行对应wave1,第10行对应wave2,第11行对应wave3;
...
第十六个周期:id为240-255的work-item,每个work-item的第8行对应wave12,第9行对应wave13,第10行对应wave14,第11行对应wave15;
这样,16个周期就完成了所有256个work-item对第8到第11行语句的执行,总共执行了1024次算术操作。当然,这是在最好的情况下。倘若第8到第11行有些语句存在相互依赖,那么将会导致某些操作无法被同时放入SP的四个ALU单元,从而使得SP在执行时某些ALU计算单元处于空闲状态。这也是为啥以VLIW类型进行执行的GPGPU,在写OpenCL代码时最好使用向量数据类型进行操作的原因。通常,一个向量的每条通道(lane)相互独立,使得它们能够被送到SP的各个ALU中。
下面接着谈一下AMD的GCN架构。GCN架构与TeraScale系列完全不同,反而跟Kepler架构更接近。GCN架构将CU这个概念正式运用到了GPU的硬件架构上。原本,TeraScale3的单个SIMD由四个独立的ALU组成,而被改成了GCN中,一个CU由四个SIMD单元组成,其中,每个SIMD仅由单个ALU构成。每个SIMD还含有独立的64KB的寄存器,整个CU含有64KB的Local Memory。
在GCN架构中,一个SIMD至少对应10条wavefront,那么对于一个CU而言就是40条wavefront(4个SIMD,每个SIMD有10条wavefront)可以在执行流水线上运行。而每条wavefront对应执行64个work-item,并且可以在各自不同的work-group上,甚至不同的kernel上执行。那么一个CU则一次可执行2560个work-item。而每个SIMD一次可同时执行16个work-item,而且每个SIMD可以对各自的wavefront进行操作。这样,GCN的执行模式与Kepler就很像了。
在GCN架构中,指令分发序列器以每个CU为单位进行分发,它管理4阶段的执行。也就是说,一个SIMD在执行完一整条wavefront与之前的VLIW4一样,需要连续发射4次完成。
其中,4个CU组成一个簇共享一个32KB的四路组相联的L1指令Cache,通过L2 Cache进行后备缓存。Cache行为64字节长,一般能保留8条指令。当Cache满的时候,系统会发出一条新的请求,以最近最少使用策略(LRU)将某条Cache行逐出,为新的指令留出空间。4个CU所共享的L1 Cache含有4个段,并且可以维持每周期对所有4个CU做取32字节指令操作(每个CU取一条8字节的指令)。去指令在一个CU内的4个SIMD之间进行仲裁,基于工作时长、调度优先级以及对wavefront指令缓存的利用。
一旦指令取到wavefront缓存中,下一步就进行译码并发射指令。CU在每个周期,通过轮询仲裁方式选择一个SIMD来译码并发射。所选中的SIMD可以从10条wavefront的缓存中译码并发射多达5条指令到执行单元。此外,在wavefront缓存中还可以执行一条特殊功能指令(比如,NOP操作、栅栏操作、暂停操作、跳过一条谓词向量指令等),而不占用任一功能单元。每个CU具有16个缓存来追踪栅栏指令。栅栏指令会迫使一条wavefront进行全局同步。
CU前端可以译码并发射七种不同类型的指令:分支,标量ALU或访存,向量ALU,向量访存,LDS(Local Data Share,相当于OpenCL中的Local Memory)访问,全局数据访存,特殊功能指令。每个SIMD每个周期只能发射每种类型的其中一条指令,以避免过多注册(oversubscribing)执行流水线。为了维护顺序执行,每条指令也必须来自不同的wavefront。每个SIMD具有10条wave,那么一般就有许多种选择了。除了上述这两种限制,任意混合都是被允许的,这给了编译器充分的自由来安排指令发射执行。
CU的前端可以每个周期发射5条指令到一个6个向量与标量相混合的执行流水线,使用两个寄存器文件。向量单元提供了对于图形着色器以及计算密集的通用目的应用强大的计算能力。两个标量单元与指令缓存中处理的特殊指令一起负责GCN架构所有的控制流。
每个CU含有8KB的标量寄存器文件,给每个SIMD划分为512个条目。在一个SIMD上,所有10条wavefront共享这些标量寄存器。一条wavefront可以分配112个用户寄存器以及若干保留作为架构状态的寄存器。每个寄存器是32位宽,并且邻近两个寄存器可以用于存放一个64位的值。
对于向量寄存器,由于每个SIMD独立地执行一条wavefront,因此一个CU中的寄存器文件可以被划分为四个独立的片段。
向量通用目的寄存器(vGPR)包含了64个通道(lane),每个通道宽度为32位。邻近的vGPR可以被联结为64位或128位数据。每个SIMD具有vGPR的64KB子部分,一个CU所占用的向量寄存器总数是固定的。每个SIMD的子部分被细粒度地分段,并且可以同时读X寄存器,写Y寄存器。
每个SIMD包含了一条16通道(lane)的向量流水线。每条通道可以执行一个单精度的融合或非融合乘加操作或是一个24位整数操作。一条wavefront在一单个周期被发送到一个SIMD,不过要花费4个周期来执行所有64个work-item的的执行操作。
同时,我们之前已经提到了,“在GCN架构中,指令分发序列器以每个CU为单位进行分发,它管理4阶段的执行。每个SIMD一次可同时执行16个work-item,而且每个SIMD可以对各自的wavefront进行操作。一个CU在每个周期,通过轮询仲裁方式选择一个SIMD来译码并发射”。因此,在一个CU内,四个SIMD是以4级流水线那样被调度执行的。当然,这个调度不是严格按照某一次序,作为一个例子,我们可以想象第一个周期把第一条指令发射给SIMD0执行;第二个周期把第1条指令发射给SIMD1执行,同时SIMD0执行第2条指令;第三个周期,把第1条指令发射给SIMD2执行,同时SIMD0执行第3条指令,SIMD1执行第2条指令;第四个周期,把第一条指令发射给SIMD3执行,同时,SIMD0执行第4条指令,SIMD1执行第3条指令,SIMD2执行第2条指令。这样,当整个流水线被填满时候,该CU即处于峰值计算状态,四个周期即能同时对一个CU的4个SIMD同时发射执行,完成4整条完整的wavefront。这里还要注意的是,同一种指令被分配到不同的wavefront执行时是属于不同的指令。其实,每个CU中含有4个独立的指令缓存,所以对于每个SIMD正好可以使用一个。就拿上述代码第8条指令“vecA.x += vecB.x”而言,这一条指令可以在发射到一个CU的不同SIMD执行时,其实是被复制了4份,分别放入到该CU的4个独立的指令缓存中。可见,GCN架构的GPU在执行模式上显得十分灵活。
因此,我们对于GCN架构的GPGPU,我们可以把它想象成一个通用的CPU。每个CU如果看成一个核心的话,那么其中的40条wavefront可以被看作为40个硬件线程(类似于HTT,超线程技术),通过4级流水线执行;而每条wavefront又是以SIMD的方式执行的,4个周期能处理完一条wavefront,共64个work-item。而我们给每条wavefront发射的指令当然都是相互独立且不同的,尽管可能都是同一种,比如上面的“vecA.x += vecB.x”。而在一条wavefront内部,每个work-item所执行的指令绝对是同一条,仅仅是数据通道不同(64条lane)。
参考资料: