文 / 王越
随着CPU与GPU合并成技术发展的趋势,苹果开发出了OpenCL框架,能够进行高速并行处理的能力使OpenCL成为了业界标准,被广泛应用。
最近几年,GPU的发展吸引了很多来自科学计算界人士的目光。GPU有稳定的市场推动力—公众喜闻乐见的电子游戏产生了源源不断的升级GPU的需求—因此比CPU的更新步伐更快。从技术上讲,GPU本身就是多核架构,高端显卡往往有五百多个核心,即使低端的集成GPU也有二三十个核心,所以能够通过并行来高效处理成千上万的线程。同时,对于科学技算中的浮点计算,GPU往往通过硬件加速使其效率比传统CPU更高,因为图形渲染等工作基本都是浮点计算。
GPGPU浮出水面
早期的GPU只能执行固定的程序,而不开放给程序员编程。随着时代的发展,图像处理有时需要对着色器进行编程以实现一些特效,因此需要程序员可以使用GPU的汇编语言写简单的着色程序。这自然对程序员要求过高,所以一些高阶的着色语言又被GPU厂商开发出来。比如微软和NVIDIA共同开发的Cg语言,就能为顶点和像素编写专门的着色程序。这类技术虽然面向图形渲染工作者,却吸引了一小簇科学计算研究者的兴趣。以计算流体力学为例,它是用纳维斯托克斯方程【注:把牛顿第二定律和质量守恒应用到流体后,所得到的偏微分方程】来求解流体力学问题的一种算法,广泛用于天气预报、F1方程式赛车设计等工程领域。同时,对于电影制片特效,计算流体力学也是最基本的用来模拟流体流动特放的算法,皮克斯动画工作室的《寻找尼莫》中的海洋流动和水花等,都是使用纳维斯托克斯方程来模拟的。
首先,对于一个几何空间进行网格化,每个网格中的流体,都可以列出纳维斯托克斯方程,把这些方程联立起来进行求解,即可得到各点的温度、压力、湿度、速度等流体信息。整个求解过程可以高度并行,因为每个网格的控制方程是完全一样的;同时也牵涉大量的浮点运算。但Cg这类语言并非面向普通的计算,其变量都是颜色、顶点、像素等图形学专用变量。来自北卡罗莱那大学教堂山分校的Mark Harris突发奇想:可以把流体力学中每个网格的速度、压力等变量,存成RGBA颜色后让Cg去处理,所以他在《GPU Gems》中著名的一章,公布了使用Cg来高速实现计算流体力学运算的成果,吸引了大量计算界的目光。然而,这种编程模式对科技工作者来说很不友好,因为这要求一个学力学的、学生物的、学化学的学生,先要明白复杂的GPU渲染原理,了解图形学中材质、顶点、合成、像素、光栅化、光线跟踪等深奥的理论,才能编写他们专业相关的GPU程序。
GPU生产厂商洞察到了GPU高速并行浮点数运算的潜力,所以GPGPU(General Purposed Graphics Processing Unit)概念终于浮出水面。一方面GPU设计一代比一代可编程化,另一方面各公司也在加紧研制新一代GPU编程语言。新一代的语言对比Cg,去掉了对于渲染相关的知识要求,独立于图形学之外,是纯粹的普通语言,比如变量不再是像素、顶点、面等类型,而是C/C++语言开发者喜闻乐见的浮点数组、整形数组等。这一时期为代表的语言,主要是CUDA(Compute Unified Device Architecture)。CUDA是NVIDIA在2007年公布的一项面对科学计算工作者的编程框架。通过该技术,使用者可利用NVIDIA的GeForce 8以后的GPU和较新的Quadro GPU进行高性能编程。用户先编写一个特殊的C++代码文件,扩展名为cu,文件中需要申明创建的变量、GPU计算核心(kernel)以及使用给定的编程接口来实现变量在CPU和GPU中的传送。然后通过NVIDIA自家的编译器编译这个代码,链接到NVIDIA自家的库上,即可把该运算核心编译为GPU汇编语句扔到特定型号的GPU上高度执行。其他厂家也紧随其后,比如AMD为ATI生产的GPU卡提供了一个类似的框架叫Stream SDK(先前被命名为 CTM, Close to Metal, ATI Stream Computing – Technical Overview, 03/20/2009 http://en.wikipedia.org/wiki/Close_to_Metal)。而微软更是趁Vista和Win7推出了DirectCompute,作为旗下DirectX技术的一部分。
CUDA并不完美
对科学工作者来说,CUDA比Cg友好太多。使用CUDA加速流体力学运算相关的论文更是雨后春笋般涌现。然而不久后,我发现它存在许多问题。
首先,对初学者来说,CUDA编程模式很容易学混。因为一个GPU数组和一个CPU数组在CUDA中的表述都是同样的C指针,但对于GPU数组和CPU数组,CUDA的处理模式完全不同,CPU数组使用常规的malloc来初始化,而GPU数组得使用CUDA提供的malloc。所以程序写着写着,就忘了一个变量到底是给CPU用的还是给GPU用的,这无疑增加了学习难度。同时,CUDA对C/C++语言进行了一系列扩展,这不但意味着写的程序不再具有C/C++那样良好的可移植性,而且这种计算核心和传统C程序混写的编程语言很不美观。
其次,CUDA这类语言的实现各自为政。如果你写了一个CUDA程序,就意味着这个代码只能运行在NVIDIA的显卡上。如果想使用ATI的显卡呢?没门,请用ATI Stream SDK重写。
再次,CUDA是在编译时就静态产生GPU代码的,所以只能产生特定的GPU代码。如果你发布了一个CUDA程序,它仅对某几种NVIDIA显卡进行特定的代码优化。如果NVIDIA自家出了一种新显卡,很抱歉,哪怕新显卡可能兼容老显卡的汇编指令而你的程序恰巧可以在新显卡上跑起来,你也无法发挥新显卡的所有特性。必须用针对新显卡的编译器重新编译源代码,才能够保证程序在新显卡上高效执行。
最后,CUDA这类语言仅能产生高效的GPU代码,而无法产生CPU代码,即:写完的代码只能跑在GPU上,在CPU上只能“模拟执行”,仅供调试用。所以在一台不具备给定GPU的机器上,无法高效运行CUDA程序。同样,如果你有一个性能很强的工作站,那么你的CPU亳无用处—CUDA不可能分配一部分任务给CPU完成。
另外还有未来计算机架构的不确定性。当时,GPU越来越一般化,可以跑多种数值计算程序,而CPU随着多核成为主流也越来越像GPU。所以很多厂家在考虑CPU和GPU合并的可能性。
当时轰动一时的热门事件,是CPU厂商AMD买下了GPU厂商ATI,来开发下一代处理器AMD Fusion,把GPU和CPU合并到一起。Intel自然不甘示弱,做出了Nehalem平台,在该平台上,CPU和集成GPU处于同一个包装中,外界一度猜测这样可使合并后的CPU具有图形处理工能,从而用户购置计算机就不用再考虑配一块GPU了。
更强大的是,当时Intel还公布了Larrabee计划,让GPU支援x86指令,使得一个常规的x86平台的程序不需要修改和重新编译便可在GPU上运行。
虽然事实和这些预期有稍许出入,但当时的技术趋势是:在将来可能出现一种新的合并GPU/CPU的技术,能够并行高速地运行一般的计算机程序,而面对这样新的可能的平台,我们如何准备?
OpenCL诞生
OpenCL则是苹果为这个新局面画下的蓝图。这项技术初期全称为Open Computing Library(如果留意苹果早期宣传广告的话),后改名为Open Computing Language。这项技术从本质上来说,和CUDA并没有太多的两样,但由于苹果在借鉴他人技术并把他人技术改得更棒这一点上是出了名的,所以OpenCL很好地解决了以上所有问题。
下面简单介绍一下这个框架。OpenCL技术的结构十分清晰,对程序员来说,它是一个Mac OS X的Framework,定义了两套标准,一套是一个C语言的编程界面(API),使得开发者创建、拷贝、回收GPU使用的对象,同时也包含检测处理器、为该处理器编译并调用核心程序(kernel)相关的接口;另一套是OpenCL核心程序语言的定义,是一套基于C99发展而来的语言。
例如我们有两个大数组,1024维的a和1024维的b(当然,1024不算大,OpenCL往往用来处理十万、百万数量级的任务),我们把两个数组对应的元素加和,结果是一个1024维的数组c。C程序员很容易能写出下面的程序:
for (int i = 0; i < 1024; i++)
c[i]=a[i]+b[i];
OpenCL的核心程序,则是取每个独立的可并行的循环分支,即上面程序中的 c[i]=a[i]+b[i]。所以核心程序大概是下面这样:
__kernel add(float *a, float *b, float *c){
int i = get_global_id(0);
c[i]=a[i]+b[i];}
其中,get_global_id()函数可以返回当前函数是全局中的第几个元素。把该程序保存为add.cl,就是一个OpenCL的核心程序,为C99语言的一个子集。
使用OpenCL的API就能调用这个核心程序。每个OpenCL程序基本上是模式化地照搬下面流程:
1. 探测硬件(用clGetDeviceIDs函数护取计算设备(可以指定使用GPU或是CPU),用clCreateContext函数来新建一个上下文(context),用clCreateCommandQueue函数针对设备和上下文新建一个命令队列);
2. 编译核心(读入add.cl,用clCreateProgram-WithSource和clBuildProgram以及clCreateKernel来编译读进来的字符串,产生一个核心程序);
3. 写入数组(用clCreateBuffer创建a、b、c三个内存对象,用clEnqueueWriteBuffer把C数组写到内存对象中);
4. 运行核心(把内存对象作为核心程序函数的输入参数执行这个核心,程序会并发为1024个线程,每个线程执行一次相应的加法运算);
5. 读出结果(用clEnqueueReadBuffer读取c内存对向,写为C的数组);
6. 回收内存。
OpenCL之美
让我们逐条来看前面那些问题是如何被解决的。
首先,OpenCL Framework由C API和OpenCL语言组成,泾渭分明,所有的GPU变量在C API中,都是内存对象的形式出现,有别于C自建的数组。因此,你永远不会搞混两者。同理,OpenCL核心程序是独立在C源程序之外的,不仅美观,也能保证你的C程序能被所有C编译器编译,因为调用OpenCL库和调用其他C的函数库没有任何不同。
其次,苹果开发出OpenCL后,觉得该技术甚好,索性联合AMD、ARM、ATI、TI、Intel、IBM、Nokia等公司,把它做成一个由Khronos组织主持的开放标准。不管电脑上用的显卡是ATI的还是NVIDIA的,OpenCL都能像OpenGL那样在你的设备上无缝运行。事实上,OpenCL已同OpenAL和OpenGL一样,成为Khronos Group旗下的三大业界标准。
再次,CUDA是在编译时就静态产生GPU代码的,所以只能产生特定的GPU代码。而OpenCL的核心程序(kernel)是在运行时被编译成GPU指令的。由于kernel所用的OpenCL语言,仅是C99的一个子集,所以负责编译这个程序的是OpenCL运行库自带的LLVM-Clang。这样做的好处是明显的,举例来说,如果用户有一堆OpenCL的程序,比如苹果最新的Final Cut Pro X就在许多地方采用了OpenCL,如果某一天硬件厂商发布了一个全新的GPU架构,那么用户安装显卡后,只要下载或更新相关的驱动程序和运行库即可,而不需要再求软件厂商发布一个新版本的Final Cut Pro X。因为OpenCL在运行时,会根据显卡厂商提供的驱动和新运行库自动优化程序到特定架构上。所以,程序兼容性问题也被圆满解决。
最后,由于OpenCL是个开放标准,也支持CPU和其他任何计算设备,比如数字信号处理芯片(DSPs)和各种专门的处理器架构。所以只要有相关的驱动和运行库,OpenCL程序可以高效地并行运行在任何架构的运算设备上。由于OpenCL和GCD的编程模式是一样的,因此当OpenCL程序在CPU上执行时,是跑在GCD队列上的。
由于OpenCL能高速地进行并行处理(如http://macresearch.org/opencl_episode1 的演示,OpenCL编写的GPU程序比单核CPU能快上数十至数百倍,笔者的论文Yue Wang, Ali Malkawi, Yun Yi, Implementing CFD (Computational Fluid Dynamics) in OpenCL for Building Simulation, 12th Conference of International Building Performance Simulation Association, 2011也得出了类似的结论),OpenCL被广泛地使用在很多产品中,苹果也是OpenCL的主要用户之一。如上面提到的Final Cut Pro X就是个典范,使用GCD和OpenCL进行大量并行的流媒体处理。在老版本Final Cut中,每当用户执行一次流媒体操作,都会弹出一个进度条来告诉用户剩余的处理时间,而Final Cut Pro X优化后的速度是如此实时,以至于这个进度条被去除了。Mac OS X许多的底层库也使用OpenCL重写,如Core Image,本身也是一个GPU加速库,使用OpenCL后相比原来,依然获得了可观的性能提升。
Snow Leopard的发布标志着第一个OpenCL框架的完整实现,OpenCL成为业界标准后,AMD抛弃了原先的策略,投入开放标准的怀抱,一连放出了几个测试版本的集成OpenCL的ATI Stream SDK,并在2009年年底发布了稳定版,2011年8月8日宣布废除原先的Close to Metal相关技术。NVIDIA也是早早地在CUDA SDK中加入了OpenCL相关的库。CUDA越来越不被看好,所以NVIDIA索性把CUDA发布为一个开源项目,并把CUDA架构在LLVM之上。这和OpenCL近几年的走强有很大关系。
开发者的瓶颈
目前看来,OpenCL虽然解决了上面的所有问题且且速度飞快,但对普通程序员来说,依然是非常底层的技术。而且由于硬件的限制(显卡不支持指针运算),很多C的标准并未在OpenCL中出现,写链表还需要用整数去模拟地址。程序员需要手动管理内存,处理底层的核心调用以及数据读写。而显卡厂商也大多不愿公开GPU的技术细节,因此不像CPU程序很容易通过汇编指令分析计算机底层干了什么,显卡对于开发者纯粹是个黑盒,把整个问题分成多少个线程并发也没有一个规律可循,有可能不起眼的改动会使程序运行瞬间变快或变慢数十倍,开发者也不知道其中的原因,只能凭经验操作。而且由于不存在良好的调试工具,所以很难改正程序的错误。
显卡作为系统最为重要的共享资源之一,不像现代操作系统那样提供内存保护机制,因此一个用户OpenCL程序的错误很容易导致整个计算机崩溃,所以经常是程序跑一遍后发现操作系统挂了,重启后发现了一个可能的错误,改完后编译运行,操作系统又挂了。我用OpenCL编写科学计算程序时,大量时间是在重启电脑而不是写程序。这些问题仍然阻碍着OpenCL被广泛采纳,不过,在科学计算界,已经涌现出了越来越多相关的论文和技术,相信在不久的将来,情况会有所改观。
结语
当写完这篇技术长文时,天色已晚,走出教室,和ENIAC擦肩而过。ENIAC的出现激励了之后一次次的处理器革命。2009年发布的Snow Leopard可能在整个Mac OS X发行版历史中不算最出彩,却是对于半导体集成电路革命的一次重大收获。
作者王越,美国宾夕法尼亚大学计算机系研究生,中国著名TeX开发者,非著名OpenFOAM开发者。