CUDA, 软件抽象的幻影背后 之三

本文原载于我的主页:planckscale.info,转载于此。

版权声明:原创作品,欢迎转载,但转载请以超链接形式注明文章来源(planckscale.info)、作者信息和本声明,否则将追究法律责任。

上一篇中谈到了编程模型中的Block等概念如何映射到硬件上执行,以及CUDA如何用并行来掩盖延迟。这一篇继续剖析SIMT,谈一谈控制流分叉,指令吞吐和线程间通讯机制。

虽然我们说warp中的线程类似于SIMD,但事实上它是真正的线程。warp中的每一个thread都有自己的指令地址寄存器,允许它们各自执行不同的任务(控制流分叉)。最简单的,比如一个

[php]

if(threadIdx < 10)
{...}
else
{...}

[/php]

语句,将threadIdx=0...31这一个warp划分成两个分支,各自做不同的事情。这个灵活性以性能为代价,当一个warp中控制流出现分叉时,不同分支的线程会被分组相继执行,直到各分支执行完毕后,控制流重新汇聚成一支(上例中即if语句的结束点)。这种情况下执行单元的利用率较低,因为每个分支执行时都需要关闭其他分支的线程,所以这时一些执行单元是用不到的。

为了尽可能高效的计算,需要约束控制流分叉的出现。除了减少流程控制语句外,还需要注意,并不是只要有流程控制语句就一定会带来控制流分叉。关键是,控制流分叉只是针对同一warp中的线程而言,不同warp的线程原本就是串行化执行的,分叉对其无影响。因此,只有流程控制语句的条件在

同一warp内不一致时,才会有控制流分叉。这样,诸如

[php]

if(threadIdx.x / WARPSIZE < n)
{...}
else
{...}

[/php]

这样的语句是不会有分叉的。当然,更宽松的条件如

[php]

if(blockIdx.x < n)
{...}
else
{...}

[/php]

也不会有分叉。依赖于输入数据的条件如

[php]

if(globalArray[threadIdx.x] < n)
{...}
else
{...}

[/php]

则会带来分叉。

CUDA的指令都是针对一个warp中32个线程的并行指令,因而一条指令需要在每个线程中都被执行完才算执行完毕。对于简单的指令如32位浮点数的加、乘,32位整数的加减等,通常都可以由CUDA Core在一个时钟周期内完成,而每个SM中通常都有不少于32个的CUDA Cores,因而对于一个warp中上述类型的简单指令,就是一个CUDA Core处理一个线程,一周期内就可以执行完毕。而对于一些较复杂的指令,执行单元并不能提供这么高的吞吐率,此时一个warp中32个操作需要在多个周期内串行化处理。

我们可以用单位周期内进行的操作数目N除以32来计算指令的吞吐率。以GM204为例,它的SM中有32*4 = 128个CUDA Cores,32个SFU(特殊函数单元),因而在计算32位浮点加法时具有最高吞吐,一个周期内完成128次操作,单位周期内指令吞吐为128/32 = 4;而计算如sin/cos等超越函数时线程不再一一分配到CUDA Cores上,而是要在32个SFU上计算,单位周期内只能完成32次操作,指令吞吐为1条指令每周期.

指令的吞吐率数据可参考CUDA C Programming Guide中 5.4.1. Arithmetic Instructions,该小节以单位时钟周期每SM上能够进行的操作数的形式给出了各指令的吞吐率。

指令吞吐率是我们进行性能优化的有一个重要指标。通常,影响指令吞吐率的因素除了数值计算操作的复杂度、精确度之外,控制流分叉也是一个贡献因子。这里的原因不难理解,控制流分叉时执行单元的利用率下降,使得单位周期内执行的操作数目下降,从而降低了指令吞吐。

到这里,硬件图景下线程的执行就基本说完了,只剩下一个留到最后的话题:线程间交互。通常,不存在任何相互作用的线程,它们之间才能够以任意的顺序执行,像block。但对于warp这样的线程组,是可能与同一block中其他warp通讯或同步的,这时执行顺序就不能任意。所幸即便在block之内,线程间的交互仍然是较弱的,因而底层可以将block划分成warp来分组串行化执行,遇到交互时再另作处理。我们现在来看看这些交互机制。

线程间交互可以细分为通讯和同步两类。通讯主要由公共存储区域交换数据来实现,但也不排除像shuffle这样的特殊方式存在。

从通讯的粒度来看,可以分为warp内部线程间通讯,block内部线程间通讯,block间通讯,更粗的粒度这里不考虑。block之间的通讯则只能基于global memory,block内部的通讯主要基于shared memory/global memory,warp内部线程间除了可以利用上述所有方式,还有一种特殊的shuffle机制.下面我们以通讯的粒度分类陈述各种通讯的实现方式。

block间通讯通常基于两次kernel发射,一次将通讯数据写入global memory,另一次发射读global memory进行后续处理。这种通讯开销较大,主要来自于global memory访存和kernel发射,所以如果有可能,尽量把任务放在一次kernel发射中完成。

或许有人会问,同一个kernel发射中的两个block具有共同的global memory,是不是也可以利用这个特点来构造同一kernel下block间的通讯呢?通常的答案是no,因为block之间执行顺序不定,很难构造有意义的通讯;但如果要较真,答案是yes,我们真的可以构造一些特殊的block间通讯方式。一个例子如下所示,该实例来自于CUDA C Programming Guide B.5. Memory Fence Functions:

[php]

__device__ unsigned int count = 0;
__shared__ bool isLastBlockDone;
__global__ void sum(const float* array, unsigned int N,
	volatile float* result)
{
	// Each block sums a subset of the input array.
	float partialSum = calculatePartialSum(array, N);
	if (threadIdx.x == 0) {
		// Thread 0 of each block stores the partial sum
		// to global memory. The compiler will use
		// a store operation that bypasses the L1 cache
		// since the "result" variable is declared as
		// volatile. This ensures that the threads of
		// the last block will read the correct partial
		// sums computed by all other blocks.
		result[blockIdx.x] = partialSum;
		// Thread 0 makes sure that the incrementation
		// of the "count" variable is only performed after
		// the partial sum has been written to global memory.
		__threadfence();
		// Thread 0 signals that it is done.
		unsigned int value = atomicInc(&amp; count, gridDim.x);
		// Thread 0 determines if its block is the last
		// block to be done.
		isLastBlockDone = (value == (gridDim.x - 1));
	}
	// Synchronize to make sure that each thread reads
	// the correct value of isLastBlockDone.
	__syncthreads();
	if (isLastBlockDone) {
		// The last block sums the partial sums
		// stored in result[0 .. gridDim.x-1]
		float totalSum = calculateTotalSum(result);
		if (threadIdx.x == 0) {
			// Thread 0 of last block stores the total sum
			// to global memory and resets the count
			// varialble, so that the next kernel call
			// works properly.
			result[0] = totalSum;
			count = 0;
		}
	}
}

[/php]

代码 1. block间通讯实现数组求和

本代码摘录自 CUDA C Programming Guide B.5. Memory Fence Functions

该例实现一个数组的求和,首先各个block计算部分和,然后由最后一个完成部分和计算的block再把所有的部分和加和出最终结果。block间通过一个位于global memory的变量count通讯,它记录了目前已经完成计算的线程数。这样,最后一个完成部分和计算的block就会发现count的数值为最大线

程id,因此可以判定需要由它自己来完成最后从部分和向总和的计算。

不过,为了更好的软件结构,最好还是避免同一kernel的block间产生耦合。同一kernel中block的通讯还涉及到CUDA的weakly-ordered内存模型问题,一个线程中先后两次内存操作在另一个线程看来未必能够保持原有顺序,这产生了相当大的复杂性。我们在下文还会提到这一问题。

block内的线程通讯机制较为丰富,尤其是线程同属一个warp时的shuffle机制。shuffle在Kepler后出现,是一种相当快的线程间通讯方式,它允许同属一个warp的线程间可以互相引用彼此的寄存器,比如下例:

[php]

__global__ void bcast(int arg)
{
	int laneId = threadIdx.x &amp; 0x1f;
	int value;
	if (laneId == 0) // Note unused variable for
		value = arg; // all threads except lane 0
	value = __shfl(value, 0); // Get "value" from lane 0
	if (value != arg)
		printf("Thread %d failed.\n", threadIdx.x);
}

[/php]

代码 2. shuffle机制实现一个值向整个warp的广播

本代码摘录自 CUDA C Programming Guide B.14. Warp Shuffle Functions

laneId是warp中线程的一个index,有threadIdx对32取余得到。__shfl(value, 0)语句使得各线程能够访问laneId==0这一线程中value的值。

更常用的通讯机制自然是shared memory和global memory了。其中shared memory更快速,在大多数时候是构建高性能CUDA程序的必由之路。这些常识不再赘述。基于shared/global memory的线程间数据交换,一定要注意线程的同步。block中线程的同步由__syncthreads()实现。线程会等待同block中其他线程都执行到这一点,并且__syncthreads()语句之前的所有shared/global memory操作都尘埃落定,保证block内所有线程在__syncthreads()之后都能看到这些操作的结果。

最后谈一下CUDA采用的weakly-ordered内存模型。它导致一个线程中相继执行的两个存储器操作在另一个线程看来未必是一样的顺序。例如:

[php]

__device__ int X = 1, Y = 2;
//thread 0
__device__ void writeXY()
{
	X = 10;
	Y = 20;
}

//thread 1
__device__ void readXY()
{
	int B = Y;
	int A = X;
}

[/php]

代码 3. weakly-ordered内存模型示例

本代码摘录自 CUDA C Programming Guide B.5. Memory Fence Functions

这段代码可能产生A=1,B=20这样的结果。原因是有多种可能的,要么thread 1看到的X、Y的写入顺序被颠倒,要么thread 1中读取顺序被颠倒。这种看似相当毁三观的事情确确实实发生在我们的代码背后。在一个线程里两个相继但无依赖的内存操作,其实际完成的顺序可能是不确定的。在这个线程

看来这并没有导致什么不同,因为两个操作无依赖,并不会破坏因果链;但在另一个线程的眼里,它就暴露出来了。

忍不住插句嘴,这简直就是狭义相对论的世界观在计算机世界的翻版:一个参考系的观察者所看到两个类空间隔事件(可以是相继发生但因距离遥远而无因果关联)在另一个参考系中看来是颠倒的,但有因果关联的两事件在所有观察者看来时序都不会改变。好玩吧?

所以,表面的秩序井然背后有着巨大的复杂性怪兽,为了关牢它的笼子,我们需要约束我们的代码,用合适的机制来实现线程间通讯。要保证另一个线程看起来,两组存储器操作具有我们所希望的顺序,需要用 Memory Fence Function. 这里不再涉及,对更多细节感兴趣的同学,请参考CUDA C Programming Guide B.5. Memory Fence Functions等章节。

(未完待续)

时间: 2024-10-08 10:29:14

CUDA, 软件抽象的幻影背后 之三的相关文章

CUDA, 软件抽象的幻影背后

本文原载于我们的博客planckscale.info,转载于此. 版权声明:原创作品,欢迎转载,但转载请以超链接形式注明文章来源(planckscale.info).作者信息和本声明,否则将追究法律责任. 今天最酷炫的事情应该就是来自老黄的这条消息:1TFLOPS,P < 15W, ARM Cortex A57 * 4 + ARM Cortex A53 * 4 +  Maxwell 256 CUDA Cores,  Tegra X1. 图1.  Tegra X1 本想挖掘一下写篇博,但目前报道满

CUDA, 软件抽象的幻影背后 之二

本文原载于我的主页:planckscale.info,转载于此. 版权声明:原创作品,欢迎转载,但转载请以超链接形式注明文章来源(planckscale.info).作者信息和本声明,否则将追究法律责任. 上一篇里说到,有两点对CUDA的计算能力影响甚大:数据并行,以及用多线程掩盖延迟.接下来我们要深入到其硬件实现,看一看这些机制是如何运作的. 通常人们经常说某GPU有几百甚至数千的CUDA核心,这很容易让人联想到多核CPU.不过事实上两种"核心"是不一样的概念,GPU的CUDA核心只

Socket是什么呢?中间软件抽象层

代表着网络连接 Socket是应用层与TCP/IP协议族通信的中间软件抽象层,它是一组接口.在设计模式中,Socket其实就是一个门面模式,它把复杂的TCP/IP协议族隐藏在Socket接口后面,对用户来说,一组简单的接口就是全部,让Socket去组织数据,以符合指定的协议.你会使用它们吗?       前人已经给我们做了好多的事了,网络间的通信也就简单了许多,但毕竟还是有挺多工作要做的.以前听到Socket编程,觉得它是比较高深的编程知识,但是只要弄清Socket编程的工作原理,神秘的面纱也就

03软件构架实践阅读笔记之三

在上一次的阅读笔记当中,提到了很多关于软件构架的东西,例如:软件构架的周期性等,但是大部分的都是系统的说明,现在,下面看的都是详细的介绍. 在刚开始是构架的产生:在刚开始的第一句话就说:"构架也是若干商业和技术决策的结果",从这句话就可以看出构架对于软件技术的重要性,而正如我们所知道的不管什么事情都会受很多因素的干扰,同样的,架构会受系统涉众的影响,在上一学期,老师就提到了什么是涉众.但是每一种的涉众对于软件的要求就会不相同例如 客户涉众:要求成本低.及时交互.不要改动的太平凡等等:

基于用例点来度量软件规模并管理进度 之三

复用后的规模估算 需求复用 在需求可复用的情况下,识别可复用的用例所占的完毕度.求和可得初始折算已完毕用例点数.规模数据为所实用例点数减去初始折算已完毕用例点数,以折算已完毕用例点数来跟踪进度时,注意起点不为0:假设是绘制燃尽图.起点也不是所实用例点数. 比如:某小版本号的任务是开发实现100个用例点.用例分析已经由还有一个异地团队完毕了,依据两个团队的历史数据和 协定.用例分析所占完毕度为30%.那么初始折算已完毕用例点数为30,这个小版本号的规模是70个用例点. 对于设计复用,也可採用同需求

软件项目开发环境构建之三:JIRA7.2.3安装

JIRA是Atlassian公司出品的项目与事务跟踪工具,被广泛应用于缺陷跟踪.客户服务.需求收集.流程审批.任务跟踪.项目跟踪和敏捷管理等工作领域.可以使用JIRA Software将收集到的需求,采用Scrum.看板等敏捷开发方法,进行项目管理,实时跟踪产品的设计.发布和迭代.通过向backlog中添加卡片来合理安排每个冲刺环节的优先级. 一.在CentOS7.2的环境下安装支持组件 1.JDK1.8.0_102 64位(安装见:http://newthink.blog.51cto.com/

《软件需求》阅读笔记之三

这几天读的书,主要是讲解的如何降低风险 可以利用软件原型这种技术减少客户对产品不满意的风险.一个软件原型是所提出的新产品的部分实现.使用原型有三个主要目的: ? 明确并完善需求   原型作为一种需求工具,它初步实现所理解的系统的一部分.用户对原型的评价可以指出需求中的许多问题,在你开发真正产品之前,可以最低的费用来解决这些问题. ? 探索设计选择方案   原型作为一种设计工具,用它可以探索不同的用户界面技术,使系统达到最佳的可用性,并且可以评价可能的技术方案. ? 发展为最终的产品原型   作为

软件和硬件都是对生活的高度抽象---论中断控制(ARM体系编程)

不同的芯片体系设计在集成电路系统设计阶段其实都遵循大体一致的设计思想,芯片设计发展那么多年,真正为人所熟知的就是X86架构和ARM架构,当然还有日渐没落的MIPS,其他都是一些简单的控制器芯片体系.而硬件模块设计又是高度抽象于现实需求,很多时候,X86.ARM和MIPS只有底层寄存器和指令级别的差异,对于软件驱动基本是一致的.本文论及ARM体系的中断控制,以基于Cortex A8的S5PV210为例.中断是一种异步工作机制,也是嵌入式处理器的一个核心工作机制,对于实时操作系统来说必不可少. 1.

《软件需求》读书笔记3

<软件需求>读书笔记之三 需求来源.需求收集方法 软件需求可以来自方方面面,这取决于所开发产品的性质和开发环境.需从不同用户代表和来源收集需求,这说明了需求工程是以相互交流为核心的性质.下面是几个软件需求的典型来源. 1). 访问并与有潜力的用户探讨为找出新软件产品的用户需求,最直截了当的方法是询问他们. 2). 把对目前的或竞争产品的描述写成文档 文档可以描述一种所必须遵循的标准或产品所必须遵循的政府或工业规则. 3). 系统需求规格说明 一个包含软.硬件的产品需要一个高档次的系统需求规格说