登 录
註 冊
论坛
微波仿真网
注册
登录论坛可查看更多信息
微波仿真论坛
>
程序
>
深入浅出谈CUDA(基于显卡的高性能并行计算)
发帖
回复
1
2
3
4
5
6
...17
下一页
到第
页
确认
19196
阅读
166
回复
[
转载
]
深入浅出谈CUDA(基于显卡的高性能并行计算)
离线
cem-uestc
UID :9061
注册:
2008-03-07
登录:
2019-01-05
发帖:
2575
等级:
荣誉管理员
0楼
发表于: 2008-11-30 20:07:04
[post]现在基于CUDA的显卡进行科学计算比较热门,现转转一篇很好的关于这方面的资料。
8om6wALXB
----------------------------------------
?1*Ka
0_q8t!<xJw
深入浅出谈CUDA
.T 6NMIp*
发表时间:2008-11-21
Ckd@|
“CUDA 是 NVIDIA 的 GPGPU 模型,它使用 C 语言为基础,可以直接以大多数人熟悉的 C 语言,写出在显示芯片上执行的程序,而不需要去学习特定的显示芯片的指令或是特殊的结构。”
H?*EQK`7?0
“CUDA 是 NVIDIA 的 GPGPU 模型,它使用 C 语言为基础,可以直接以大多数人熟悉的 C 语言,写出在显示芯片上执行的程序,而不需要去学习特定的显示芯片的指令或是特殊的结构。”
#<s6L"Z-
CUDA是什么?能吃吗?
U(P^-J<n1
编者注:NVIDIA的GeFoce 8800GTX发布后,它的通用计算架构CUDA经过一年多的推广后,现在已经在有相当多的论文发表,在商业应用软件等方面也初步出现了视频编解码、金融、地质勘探、科学计算等领域的产品,是时候让我们对其作更深一步的了解。为了让大家更容易了解CUDA,我们征得Hotball的本人同意,发表他最近亲自撰写的本文。这篇文章的特点是深入浅出,也包含了hotball本人编写一些简单CUDA程序的亲身体验,对于希望了解CUDA的读者来说是非常不错的入门文章,PCINLIFE对本文的发表没有作任何的删减,主要是把一些台湾的词汇转换成大陆的词汇以及作了若干"编者注"的注释。
O;z:?
现代的显示芯片已经具有高度的可程序化能力,由于显示芯片通常具有相当高的内存带宽,以及大量的执行单元,因此开始有利用显示芯片来帮助进行一些计算工作的想法,即 GPGPU。CUDA 即是 NVIDIA 的 GPGPU 模型。
n/6#rj^$
NVIDIA 的新一代显示芯片,包括 GeForce 8 系列及更新的显示芯片都支持 CUDA。NVIDIA 免费提供 CUDA 的开发工具(包括 Windows 版本和 Linux 版本)、程序范例、文件等等,可以在 CUDA Zone 下载。
"3.v(GVr
GPGPU 的优缺点
JFH3)Q
使用显示芯片来进行运算工作,和使用 CPU 相比,主要有几个好处:
aUa.!,_dh
1. 显示芯片通常具有更大的内存带宽。例如,NVIDIA 的 GeForce 8800GTX 具有超过 50GB/s 的内存带宽,而目前高阶 CPU 的内存带宽则在 10GB/s 左右。
1)pwR3(^Fz
2. 显示芯片具有更大量的执行单元。例如 GeForce 8800GTX 具有 128 个 "stream processors",频率为 1.35GHz。CPU 频率通常较高,但是执行单元的数目则要少得多。
C]414Ibi
3. 和高阶 CPU 相比,显卡的价格较为低廉。例如目前一张 GeForce 8800GT 包括 512MB 内存的价格,和一颗 2.4GHz 四核心 CPU 的价格相若。
5.5<.")
当然,使用显示芯片也有它的一些缺点:
[Gop-Vi/~
1. 显示芯片的运算单元数量很多,因此对于不能高度并行化的工作,所能带来的帮助就不大。
qq.M]?Z
2. 显示芯片目前通常只支持 32 bits 浮点数,且多半不能完全支持 IEEE 754 规格, 有些运算的精确度可能较低。目前许多显示芯片并没有分开的整数运算单元,因此整数运算的效率较差。
k\BJs@-
3. 显示芯片通常不具有分支预测等复杂的流程控制单元,因此对于具有高度分支的程序,效率会比较差。
_8A
4. 目前 GPGPU 的程序模型仍不成熟,也还没有公认的标准。例如 NVIDIA 和 AMD/ATI 就有各自不同的程序模型。
\@;$xdA$
整体来说,显示芯片的性质类似 stream processor,适合一次进行大量相同的工作。CPU 则比较有弹性,能同时进行变化较多的工作。
h/ 5|3
CUDA 架构
CuC1s>
CUDA 是 NVIDIA 的 GPGPU 模型,它使用 C 语言为基础,可以直接以大多数人熟悉的 C 语言,写出在显示芯片上执行的程序,而不需要去学习特定的显示芯片的指令或是特殊的结构。
#%N v\g;
在 CUDA 的架构下,一个程序分为两个部份:host 端和 device 端。Host 端是指在 CPU 上执行的部份,而 device 端则是在显示芯片上执行的部份。Device 端的程序又称为 "kernel"。通常 host 端程序会将数据准备好后,复制到显卡的内存中,再由显示芯片执行 device 端程序,完成后再由 host 端程序将结果从显卡的内存中取回。
]6L;
Y[)b".K
由于 CPU 存取显卡内存时只能透过 PCI Express 接口,因此速度较慢(PCI Express x16 的理论带宽是双向各 4GB/s),因此不能太常进行这类动作,以免降低效率。
r+n0M';0
在 CUDA 架构下,显示芯片执行时的最小单位是 thread。数个 thread 可以组成一个 block。一个 block 中的 thread 能存取同一块共享的内存,而且可以快速进行同步的动作。
nF>41 K
每一个 block 所能包含的 thread 数目是有限的。不过,执行相同程序的 block,可以组成 grid。不同 block 中的 thread 无法存取同一个共享的内存,因此无法直接互通或进行同步。因此,不同 block 中的 thread 能合作的程度是比较低的。不过,利用这个模式,可以让程序不用担心显示芯片实际上能同时执行的 thread 数目限制。例如,一个具有很少量执行单元的显示芯片,可能会把各个 block 中的 thread 顺序执行,而非同时执行。不同的 grid 则可以执行不同的程序(即 kernel)。
?g^42IYG
Grid、block 和 thread 的关系,如下图所示:
Swz{5 J2C
@W6:JO
每个 thread 都有自己的一份 register 和 local memory 的空间。同一个 block 中的每个 thread 则有共享的一份 share memory。此外,所有的 thread(包括不同 block 的 thread)都共享一份 global memory、constant memory、和 texture memory。不同的 grid 则有各自的 global memory、constant memory 和 texture memory。这些不同的内存的差别,会在之后讨论。
u^|c_5J(
执行模式
TwlX'iI_;
由于显示芯片大量并行计算的特性,它处理一些问题的方式,和一般 CPU 是不同的。主要的特点包括:
J9eOBom8e<
1. 内存存取 latency 的问题:CPU 通常使用 cache 来减少存取主内存的次数,以避免内存 latency 影响到执行效率。显示芯片则多半没有 cache(或很小),而利用并行化执行的方式来隐藏内存的 latency(即,当第一个 thread 需要等待内存读取结果时,则开始执行第二个 thread,依此类推)。
39to5s,
2. 分支指令的问题:CPU 通常利用分支预测等方式来减少分支指令造成的 pipeline bubble。显示芯片则多半使用类似处理内存 latency 的方式。不过,通常显示芯片处理分支的效率会比较差。
WD# 96V
因此,最适合利用 CUDA 处理的问题,是可以大量并行化的问题,才能有效隐藏内存的 latency,并有效利用显示芯片上的大量执行单元。使用 CUDA 时,同时有上千个 thread 在执行是很正常的。因此,如果不能大量并行化的问题,使用 CUDA 就没办法达到最好的效率了。
G%^jgr)
CUDA Toolkit的安装
3}dTbr4y
目前 NVIDIA 提供的 CUDA Toolkit(可从这里下载)支持 Windows (32 bits 及 64 bits 版本)及许多不同的 Linux 版本。
,`PC^`0c}o
CUDA Toolkit 需要配合 C/C++ compiler。在 Windows 下,目前只支持 Visual Studio 7.x 及 Visual Studio 8(包括免费的 Visual Studio C++ 2005 Express)。Visual Studio 6 和 gcc 在 Windows 下是不支援的。在 Linux 下则只支援 gcc。
hb#Nm6
这里简单介绍一下在 Windows 下设定并使用 CUDA 的方式。
KBE3q)
下载及安装
d-c<dS+R
在 Windows 下,CUDA Toolkit 和 CUDA SDK 都是由安装程序的形式安装的。CUDA Toolkit 包括 CUDA 的基本工具,而 CUDA SDK 则包括许多范例程序以及链接库。基本上要写 CUDA 的程序,只需要安装 CUDA Toolkit 即可。不过 CUDA SDK 仍值得安装,因为里面的许多范例程序和链接库都相当有用。
.a2b&}/.d
CUDA Toolkit 安装完后,预设会安装在 C:\CUDA 目录里。其中包括几个目录:
N(uH y@
• bin -- 工具程序及动态链接库
(VDY]Q)
• doc -- 文件
fn.KZ
• include -- header 檔
7]W6\Z
• lib -- 链接库档案
3}aKok"k
• open64 -- 基于 Open64 的 CUDA compiler
2t 6m#
• src -- 一些原始码
T<yAfnTb`
安装程序也会设定一些环境变量,包括:
'L?e)u.
• CUDA_BIN_PATH -- 工具程序的目录,默认为 C:\CUDA\bin
*N>n5B2
• CUDA_INC_PATH -- header 文件的目录,默认为 C:\CUDA\inc
r5 tn'
• CUDA_LIB_PATH -- 链接库文件的目录,默认为 C:\CUDA\lib
|y9(qcKn$
在 Visual Studio 中使用 CUDA
N8x[8Rp
CUDA 的主要工具是 nvcc,它会执行所需要的程序,将 CUDA 程序代码编译成执行档 (或 object 檔) 。在 Visual Studio 下,我们透过设定 custom build tool 的方式,让 Visual Studio 会自动执行 nvcc。
&H8wYs
这里以 Visual Studio 2005 为例:
0Dtew N{Z
1. 首先,建立一个 Win32 Console 模式的 project(在 Application Settings 中记得勾选 Empty project),并新增一个档案,例如 main.cu。
E|{(O
2. 在 main.cu 上右键单击,并选择 Properties。点选 General,确定 Tool 的部份是选择 Custom Build Tool。
Zc 9@G-
3. 选择 Custom Build Step,在 Command Line 使用以下设定:
~MWI-oK
o Release 模式:"$(CUDA_BIN_PATH)\nvcc.exe" -ccbin "$(VCInstallDir)bin" -c -DWIN32 -D_CONSOLE -D_MBCS -Xcompiler /EHsc,/W3,/nologo,/Wp64,/O2,/Zi,/MT -I"$(CUDA_INC_PATH)" -o $(ConfigurationName)\$(InputName).obj $(InputFileName)
S&/,+x'c|
o Debug 模式:"$(CUDA_BIN_PATH)\nvcc.exe" -ccbin "$(VCInstallDir)bin" -c -D_DEBUG -DWIN32 -D_CONSOLE -D_MBCS -Xcompiler /EHsc,/W3,/nologo,/Wp64,/Od,/Zi,/RTC1,/MTd -I"$(CUDA_INC_PATH)" -o $(ConfigurationName)\$(InputName).obj $(InputFileName)
%O6r
4. 如果想要使用软件仿真的模式,可以新增两个额外的设定:
$inlI_
o EmuRelease 模式:"$(CUDA_BIN_PATH)\nvcc.exe" -ccbin "$(VCInstallDir)bin" -deviceemu -c -DWIN32 -D_CONSOLE -D_MBCS -Xcompiler /EHsc,/W3,/nologo,/Wp64,/O2,/Zi,/MT -I"$(CUDA_INC_PATH)" -o $(ConfigurationName)\$(InputName).obj $(InputFileName)
9d&@;&al
o EmuDebug 模式:"$(CUDA_BIN_PATH)\nvcc.exe" -ccbin "$(VCInstallDir)bin" -deviceemu -c -D_DEBUG -DWIN32 -D_CONSOLE -D_MBCS -Xcompiler /EHsc,/W3,/nologo,/Wp64,/Od,/Zi,/RTC1,/MTd -I"$(CUDA_INC_PATH)" -o $(ConfigurationName)\$(InputName).obj $(InputFileName)
GT.1,E,Vw
5. 对所有的配置文件,在 Custom Build Step 的 Outputs 中加入 $(ConfigurationName)\$(InputName).obj。
\]C_ul'
6. 选择 project,右键单击选择 Properties,再点选 Linker。对所有的配置文件修改以下设定:
GsIVx!
o General/Enable Incremental Linking:No
XSk*w'xO
o General/Additional Library Directories:$(CUDA_LIB_PATH)
' ` _TFTO
o Input/Additional Dependencies:cudart.lib
[wU e"{
这样应该就可以直接在 Visual Studio 的 IDE 中,编辑 CUDA 程序后,直接 build 以及执行程序了。
bGw56s'R5~
第一个CUDA程序
+t*V7nW
CUDA 目前有两种不同的 API:Runtime API 和 Driver API,两种 API 各有其适用的范围。由于 runtime API 较容易使用,一开始我们会以 runetime API 为主。
5~D(jHY;
CUDA 的初始化
F!7dGa$
首先,先建立一个档案 first_cuda.cu。如果是使用 Visual Studio 的话,则请先按照这里的设定方式设定 project。
/]j^a:#"6t
要使用 runtime API 的时候,需要 include cuda_runtime.h。所以,在程序的最前面,加上
SU,G0.
#include <stdio.h>
OT0%p)
#include <cuda_runtime.h>
=LXjq~p
接下来是一个 InitCUDA 函式,会呼叫 runtime API 中,有关初始化 CUDA 的功能:
Vu.VH([b]Q
bool InitCUDA()
{uUV(FzF6
{
}uZ/^_U.
int count;
8;6j
m)AF9#aT2
cudaGetDeviceCount(&count);
`%QXaKO-
if(count == 0) {
Zt_~Zxn3
fprintf(stderr, "There is no device.\n");
37apOK4+
return false;
_%g L
}
riR(CJ}Ff
'8J!(+
int i;
&Va="HNKt
for(i = 0; i < count; i++) {
F m?j-'
cudaDeviceProp prop;
mtU{d^B
if(cudaGetDeviceProperties(&prop, i) == cudaSuccess) {
{zX]41T
if(prop.major >= 1) {
Hg&.U;n
break;
}NjZfBQW`
}
/H4Z.|@
}
w*?SGW
}
Zd5Jz+f
PRJ
if(i == count) {
"C0?s7Y
fprintf(stderr, "There is no device supporting CUDA 1.x.\n");
Tp46K\}Uf
return false;
nuKcq!L
}
i<0_sxfUD
>|X )
cudaSetDevice(i);
Ml_Hq>\U
Q XSS
return true;
r>: ~!o*
}
:@uIEvD?
这个函式会先呼叫 cudaGetDeviceCount 函式,取得支持 CUDA 的装置的数目。如果系统上没有支持 CUDA 的装置,则它会传回 1,而 device 0 会是一个仿真的装置,但不支持 CUDA 1.0 以上的功能。所以,要确定系统上是否有支持 CUDA 的装置,需要对每个 device 呼叫 cudaGetDeviceProperties 函式,取得装置的各项数据,并判断装置支持的 CUDA 版本(prop.major 和 prop.minor 分别代表装置支持的版本号码,例如 1.0 则 prop.major 为 1 而 prop.minor 为 0)。
d|#&j."
透过 cudaGetDeviceProperties 函式可以取得许多数据,除了装置支持的 CUDA 版本之外,还有装置的名称、内存的大小、最大的 thread 数目、执行单元的频率等等。详情可参考 NVIDIA 的 CUDA Programming Guide。
6am g*=]
在找到支持 CUDA 1.0 以上的装置之后,就可以呼叫 cudaSetDevice 函式,把它设为目前要使用的装置。
g(_xo\
最后是 main 函式。在 main 函式中我们直接呼叫刚才的 InitCUDA 函式,并显示适当的讯息:
RW{y.WhB
int main()
IG+g7kDCY
{
r[?GO"ej5
if(!InitCUDA()) {
8Y4mTW
return 0;
x~Y{ {
}
GY>G}bfh
M"Dv-#f
printf("CUDA initialized.\n");
SzP`(}AU
|`ya+/ff+
return 0;
Vv54;Js9
}
0$A^ .M;
这样就可以利用 nvcc 来 compile 这个程序了。使用 Visual Studio 的话,若按照先前的设定方式,可以直接 Build Project 并执行。
Ii9[[I
nvcc 是 CUDA 的 compile 工具,它会将 .cu 檔拆解出在 GPU 上执行的部份,及在 host 上执行的部份,并呼叫适当的程序进行 compile 动作。在 GPU 执行的部份会透过 NVIDIA 提供的 compiler 编译成中介码,而 host 执行的部份则会透过系统上的 C++ compiler 编译(在 Windows 上使用 Visual C++ 而在 Linux 上使用 gcc)。
}y%c.
编译后的程序,执行时如果系统上有支持 CUDA 的装置,应该会显示 CUDA initialized. 的讯息,否则会显示相关的错误讯息。
:)Pj()Os|
l1bkhA b
利用 CUDA 进行运算
+m9ouF
K| dI'TnW
到目前为止,我们的程序并没有做什么有用的工作。所以,现在我们加入一个简单的动作,就是把一大堆数字,计算出它的平方和。
*b'4>U
首先,把程序最前面的 include 部份改成:
l~]D|92
#include <stdio.h>
ho-#Xbq#g
#include <stdlib.h>
e Fh7#~m
#include <cuda_runtime.h>
2&"qNpPtE
p}oGhO&=
#define DATA_SIZE 1048576
SZ29B
3CzF@t;5
int data[DATA_SIZE];
Brtsig,4
并加入一个新函式 GenerateNumbers:
q9:g
void GenerateNumbers(int *number, int size)
@)\4 $#+-
{
;6eBfMhL
for(int i = 0; i < size; i++) {
m"@o
number
= rand() % 10;
>?Duz+W)
}
V7b;qC'
}
\q($8<
这个函式会产生一大堆 0 ~ 9 之间的随机数。
5U-SIG*
要利用 CUDA 进行计算之前,要先把数据复制到显卡内存中,才能让显示芯片使用。因此,需要取得一块适当大小的显卡内存,再把产生好的数据复制进去。在 main 函式中加入:
2HUw^ *3
GenerateNumbers(data, DATA_SIZE);
=t^jlb
O8OAXRt/Y
int* gpudata, *result;
gw!d[{#
cudaMalloc((void**) &gpudata, sizeof(int) * DATA_SIZE);
-MRX@ a^1
cudaMalloc((void**) &result, sizeof(int));
,,SV@y;
cudaMemcpy(gpudata, data, sizeof(int) * DATA_SIZE,
?7>"ZGDe>
cudaMemcpyHostToDevice);
KomMzG:
上面这段程序会先呼叫 GenerateNumbers 产生随机数,并呼叫 cudaMalloc 取得一块显卡内存(result 则是用来存取计算结果,在稍后会用到),并透过 cudaMemcpy 将产生的随机数复制到显卡内存中。cudaMalloc 和 cudaMemcpy 的用法和一般的 malloc 及 memcpy 类似,不过 cudaMemcpy 则多出一个参数,指示复制内存的方向。在这里因为是从主内存复制到显卡内存,所以使用 cudaMemcpyHostToDevice。如果是从显卡内存到主内存,则使用 cudaMemcpyDeviceToHost。这在之后会用到。
]]0Yh
接下来是要写在显示芯片上执行的程序。在 CUDA 中,在函式前面加上 __global__ 表示这个函式是要在显示芯片上执行的。因此,加入以下的函式:
qnCjNN
__global__ static void sumOfSquares(int *num, int* result)
- "NK"nb
{
b)@D@K"5
int sum = 0;
f8 BZk h
int i;
{dXTj 7
for(i = 0; i < DATA_SIZE; i++) {
ZNVrja*
sum += num
* num
;
^@3,/dH1 t
}
G6QD`ED
6P3h955c
*result = sum;
z&cM8w:
}
tr5j<O
在显示芯片上执行的程序有一些限制,例如它不能有传回值。其它的限制会在之后提到。
,Nt^$2DZW
接下来是要让 CUDA 执行这个函式。在 CUDA 中,要执行一个函式,使用以下的语法:
h@E7wp1'~
函式名称<<<block 数目, thread 数目, shared memory 大小>>>(参数...);
`OfD^Q=
呼叫完后,还要把结果从显示芯片复制回主内存上。在 main 函式中加入以下的程序:
0kSM$D_
sumOfSquares<<<1, 1, 0>>>(gpudata, result);
c]h@<wnv
'W,*mfB
int sum;
JK`$/l|7
cudaMemcpy(&sum, result, sizeof(int), cudaMemcpyDeviceToHost);
[^Bjmw[7
cudaFree(gpudata);
Bn9#F#F<
cudaFree(result);
(\D E1q
e|?eY)_
printf("sum: %d\n", sum);
Y[l<fbh(}
因为这个程序只使用一个 thread,所以 block 数目、thread 数目都是 1。我们也没有使用到任何 shared memory,所以设为 0。编译后执行,应该可以看到执行的结果。
]#Cc7wa
为了确定执行的结果正确,我们可以加上一段以 CPU 执行的程序代码,来验证结果:
@DIEENiM
sum = 0;
(dZ]j){
for(int i = 0; i < DATA_SIZE; i++) {
h%U}Y5Ps~
sum += data
* data
;
&|j0GP&
}
55'
printf("sum (CPU): %d\n", sum);
y XKddD
编译后执行,确认两个结果相同。
/oW]? 9
s7afj t
计算运行时间
C1'y6{,@
4Q/{lqG
CUDA 提供了一个 clock 函式,可以取得目前的 timestamp,很适合用来判断一段程序执行所花费的时间(单位为 GPU 执行单元的频率)。这对程序的优化也相当有用。要在我们的程序中记录时间,把 sumOfSquares 函式改成:
>23$_'2
,PmUl=
__global__ static void sumOfSquares(int *num, int* result,
O wuc9
clock_t* time)
4(*PM&'R
{
,]:Gn5~
int sum = 0;
J%-4ZB"
int i;
ufCpX>lNF
clock_t start = clock();
XUzOt_L5<
for(i = 0; i < DATA_SIZE; i++) {
YA@MLZm
sum += num
* num
;
DF_wMv:>^
}
~4pP( JP
/o%VjP"<
*result = sum;
IITUM)
*time = clock() - start;
{nXygg J
}
Pz34a@%"
把 main 函式中间部份改成:
WW.@S5
int* gpudata, *result;
|_+#&x
clock_t* time;
U(0FL6sPC
cudaMalloc((void**) &gpudata, sizeof(int) * DATA_SIZE);
tAE(`ow/Ur
cudaMalloc((void**) &result, sizeof(int));
jz`3xFy *]
cudaMalloc((void**) &time, sizeof(clock_t));
n\)1Bz
cudaMemcpy(gpudata, data, sizeof(int) * DATA_SIZE,
JO*/UC>"
cudaMemcpyHostToDevice);
5D.Sg;\
sumOfSquares<<<1, 1, 0>>>(gpudata, result, time);
"w$,`M?2
67hfv e
int sum;
J$sBfOD
clock_t time_used;
]Zt ]wnL+
cudaMemcpy(&sum, result, sizeof(int), cudaMemcpyDeviceToHost);
e'>q( B
cudaMemcpy(&time_used, time, sizeof(clock_t),
#CPLvg#
cudaMemcpyDeviceToHost);
MT"&|Og
cudaFree(gpudata);
qEuO@oE
cudaFree(result);
~;?<OOt|wG
(8aj`> y
printf("sum: %d time: %d\n", sum, time_used);
S.4+tf7+
编译后执行,就可以看到执行所花费的时间了。
4d%0a%Z
如果计算实际运行时间的话,可能会注意到它的执行效率并不好。这是因为我们的程序并没有利用到 CUDA 的主要的优势,即并行化执行。在下一段文章中,会讨论如何进行优化的动作。
3<Zp+rD
改良第一个 CUDA程序
e@qH!.g)
在上一篇文章中,我们做了一个计算一大堆数字的平方和的程序。不过,我们也提到这个程序的执行效率并不理想。当然,实际上来说,如果只是要做计算平方和的动作,用 CPU 做会比用 GPU 快得多。这是因为平方和的计算并不需要太多运算能力,所以几乎都是被内存带宽所限制。因此,光是把数据复制到显卡内存上的这个动作,所需要的时间,可能已经和直接在 CPU 上进行计算差不多了。
i(pHJP:a:
不过,如果进行平方和的计算,只是一个更复杂的计算过程的一部份的话,那么当然在 GPU 上计算还是有它的好处的。而且,如果数据已经在显卡内存上(例如在 GPU 上透过某种算法产生),那么,使用 GPU 进行这样的运算,还是会比较快的。
H0#=oJr$)W
刚才也提到了,由于这个计算的主要瓶颈是内存带宽,所以,理论上显卡的内存带宽是相当大的。这里我们就来看看,倒底我们的第一个程序,能利用到多少内存带宽。
h}!9?:E
(:qc[,m
程序的并行化
E_En"r)y
8q%y(e
我们的第一个程序,并没有利用到任何并行化的功能。整个程序只有一个 thread。在 GeForce 8800GT 上面,在 GPU 上执行的部份(称为 "kernel")大约花费 640M 个频率。GeForce 8800GT 的执行单元的频率是 1.5GHz,因此这表示它花费了约 0.43 秒的时间。1M 个 32 bits 数字的数据量是 4MB,因此,这个程序实际上使用的内存带宽,只有 9.3MB/s 左右!这是非常糟糕的表现。
g0bYO!gCr
为什么会有这样差的表现呢?这是因为 GPU 的架构特性所造成的。在 CUDA 中,一般的数据复制到的显卡内存的部份,称为 global memory。这些内存是没有 cache 的,而且,存取 global memory 所需要的时间(即 latency)是非常长的,通常是数百个 cycles。由于我们的程序只有一个 thread,所以每次它读取 global memory 的内容,就要等到实际读取到数据、累加到 sum 之后,才能进行下一步。这就是为什么它的表现会这么的差。
/q+;!EM
由于 global memory 并没有 cache,所以要避开巨大的 latency 的方法,就是要利用大量的 threads。假设现在有大量的 threads 在同时执行,那么当一个 thread 读取内存,开始等待结果的时候,GPU 就可以立刻切换到下一个 thread,并读取下一个内存位置。因此,理想上当 thread 的数目够多的时候,就可以完全把 global memory 的巨大 latency 隐藏起来了。
L-}J=n\
要怎么把计算平方和的程序并行化呢?最简单的方法,似乎就是把数字分成若干组,把各组数字分别计算平方和后,最后再把每组的和加总起来就可以了。一开始,我们可以把最后加总的动作,由 CPU 来进行。
l 9 wO x
首先,在 first_cuda.cu 中,在 #define DATA_SIZE 的后面增加一个 #define,设定 thread 的数目:
t/Y0e#9,
#define DATA_SIZE 1048576
O>pv/Ns
#define THREAD_NUM 256
^ZO! (
接着,把 kernel 程序改成:
t"Ci1"U
__global__ static void sumOfSquares(int *num, int* result,
oz>2P.7
clock_t* time)
E(j#R"
{
W^H3 =hZ
const int tid = threadIdx.x;
*2u~5Kc<
const int size = DATA_SIZE / THREAD_NUM;
(De{r|
int sum = 0;
S\h5 D2G;
int i;
=qc+sMo
clock_t start;
PWyf3
if(tid == 0) start = clock();
z4:<?K
for(i = tid * size; i < (tid + 1) * size; i++) {
yw$4Hlj5
sum += num
* num
;
GLyPgZ`|
}
aUzCKX%>C
);':aXj
result[tid] = sum;
/ZD 6pF
if(tid == 0) *time = clock() - start;
s:7/\h
}
(bQ3:%nD
程序里的 threadIdx 是 CUDA 的一个内建的变量,表示目前的 thread 是第几个 thread(由 0 开始计算)。以我们的例子来说,会有 256 个 threads,所以同时会有 256 个 sumOfSquares 函式在执行,但每一个的 threadIdx.x 则分别会是 0 ~ 255。利用这个变量,我们就可以让每一份函式执行时,对整个数据不同的部份计算平方和。另外,我们也让计算时间的动作,只在 thread 0(即 threadIdx.x = 0 的时候)进行。
"5Y6.$Cuf!
同样的,由于会有 256 个计算结果,所以原来存放 result 的内存位置也要扩大。把 main 函式中的中间部份改成:
cLl~4jL
int* gpudata, *result;
'St6a*
clock_t* time;
%2T i Rb
cudaMalloc((void**) &gpudata, sizeof(int) * DATA_SIZE);
?*yyne
cudaMalloc((void**) &result, sizeof(int) * THREAD_NUM);
GE(~d '
cudaMalloc((void**) &time, sizeof(clock_t));
BaW4 s4u
cudaMemcpy(gpudata, data, sizeof(int) * DATA_SIZE,
r":anR( ;
cudaMemcpyHostToDevice);
^l|b>z"0ao
@U18Dj[
sumOfSquares<<<1, THREAD_NUM, 0>>>(gpudata, result, time);
'Zp{
g&c ~grD
int sum[THREAD_NUM];
y0sce
clock_t time_used;
VK]U* V1
cudaMemcpy(&sum, result, sizeof(int) * THREAD_NUM,
!}z'"l4i
cudaMemcpyDeviceToHost);
?/"|tuQMW
cudaMemcpy(&time_used, time, sizeof(clock_t),
<|4$TH^t
cudaMemcpyDeviceToHost);
t|eH'"N%o
cudaFree(gpudata);
s6@mXO:H^
cudaFree(result);
S@qR~_>a
cudaFree(time);
^\AeX-q2v'
可以注意到我们在呼叫 sumOfSquares 函式时,指定 THREAD_NUM 为 thread 的数目。最后,在 CPU 端把计算好的各组数据的平方和进行加总:
Nw'03Jzx_
int final_sum = 0;
7n6g;8xE
for(int i = 0; i < THREAD_NUM; i++) {
7Vsp<s9bj
final_sum += sum
;
V-}}?c1 F
}
=K18| Q0m
;o3gR4u_L
printf("sum: %d time: %d\n", final_sum, time_used);
m9oOH5@K~
X0u,QSt'O
final_sum = 0;
-1,0hmn=+
for(int i = 0; i < DATA_SIZE; i++) {
29,ET}~
sum += data
* data
;
RC/ 3\'
}
NDP" @
printf("sum (CPU): %d\n", final_sum);
q:/df]Ntt
编译后执行,确认结果和原来相同。
#M16qOEw
这个版本的程序,在 GeForce 8800GT 上执行,只需要约 8.3M cycles,比前一版程序快了 77 倍!这就是透过大量 thread 来隐藏 latency 所带来的效果。
#G77q$
不过,如果计算一下它使用的内存带宽,就会发现其实仍不是很理想,大约只有 723MB/s 而已。这和 GeForce 8800GT 所具有的内存带宽是很大的差距。为什么会这样呢?
bO2?DszT5
内存的存取模式
VwE4:/7YN
显卡上的内存是 DRAM,因此最有效率的存取方式,是以连续的方式存取。前面的程序,虽然看起来是连续存取内存位置(每个 thread 对一块连续的数字计算平方和),但是我们要考虑到实际上 thread 的执行方式。前面提过,当一个 thread 在等待内存的数据时,GPU 会切换到下一个 thread。也就是说,实际上执行的顺序是类似
3{$7tck,
thread 0 -> thread 1 -> thread 2 -> ...
!Fp %2gt|
因此,在同一个 thread 中连续存取内存,在实际执行时反而不是连续了。要让实际执行结果是连续的存取,我们应该要让 thread 0 读取第一个数字,thread 1 读取第二个数字…依此类推。所以,我们可以把 kernel 程序改成如下:
WA8<:#{e
__global__ static void sumOfSquares(int *num, int* result,
,< x/
clock_t* time)
o]4\Geg$
{
0o=HOCL\
const int tid = threadIdx.x;
X n$ZA-
int sum = 0;
)rK2%\Z
int i;
"vSKj/]
clock_t start;
lb.Q^TghU
if(tid == 0) start = clock();
Fs( PVN
for(i = tid; i < DATA_SIZE; i += THREAD_NUM) {
'ZW(Hjrd
sum += num
* num
;
yl~_~<s6
}
C)z4Cn9#
"0PrdZMx
result[tid] = sum;
\4q|Qno8
if(tid == 0) *time = clock() - start;
C~B ]@xxK)
}
^;RK-)
编译后执行,确认结果相同。
Q!;syJBb.
仅仅是这样简单的修改,实际执行的效率就有很大的差别。在 GeForce 8800GT 上,上面的程序执行需要的频率是 2.6M cycles,又比前一版程序快了三倍。不过,这样仍只有 2.3GB/s 的带宽而已。
tO8\} u4c
这是因为我们使用的 thread 数目还是不够多的原因。理论上 256 个 threads 最多只能隐藏 256 cycles 的 latency。但是 GPU 存取 global memory 时的 latency 可能高达 500 cycles 以上。如果增加 thread 数目,就可以看到更好的效率。例如,可以把 THREAD_NUM 改成 512。在 GeForce 8800GT 上,这可以让执行花费的时间减少到 1.95M cycles。有些改进,但是仍不够大。不幸的是,目前 GeForce 8800GT 一个 block 最多只能有 512 个 threads,所以不能再增加了,而且,如果 thread 数目增加太多,那么在 CPU 端要做的最后加总工作也会变多。
Dz: +. @k
更多的并行化
CpO_p%P
前面提到了 block。在之前介绍呼叫 CUDA 函式时,也有提到 "block 数目" 这个参数。到目前为止,我们都只使用一个 block。究竟 block 是什么呢?
z~pp7
在 CUDA 中,thread 是可以分组的,也就是 block。一个 block 中的 thread,具有一个共享的 shared memory,也可以进行同步工作。不同 block 之间的 thread 则不行。在我们的程序中,其实不太需要进行 thread 的同步动作,因此我们可以使用多个 block 来进一步增加 thread 的数目。
mkn1LzE|F
首先,在 #define DATA_SIZE 的地方,改成如下:
hc3hU
#define DATA_SIZE 1048576
r k W7;!
#define BLOCK_NUM 32
D#b*M)X"
#define THREAD_NUM 256
:J`@@H
这表示我们会建立 32 个 blocks,每个 blocks 有 256 个 threads,总共有 32*256 = 8192 个 threads。
&.,K@OFE}
接着,我们把 kernel 部份改成:
k0e}`#t
__global__ static void sumOfSquares(int *num, int* result,
93HVx#
clock_t* time)
J+`aj8_ B
{
e4tIO
const int tid = threadIdx.x;
g0tnt)]
const int bid = blockIdx.x;
,2nu*+6Y/
int sum = 0;
N $) G8
int i;
yov~'S9
if(tid == 0) time[bid] = clock();
,r~+ 9i0N
for(i = bid * THREAD_NUM + tid; i < DATA_SIZE;
aDKb78 1d
i += BLOCK_NUM * THREAD_NUM) {
e{5?+6KH
sum += num
* num
;
HE,L8S
}
C7FQc{
X ha9x,
result[bid * THREAD_NUM + tid] = sum;
q++r\d^{
if(tid == 0) time[bid + BLOCK_NUM] = clock();
[34N/;5
}
iPTQqx-m$7
blockIdx.x 和 threadIdx.x 一样是 CUDA 内建的变量,它表示的是目前的 block 编号。另外,注意到我们把计算时间的方式改成每个 block 都会记录开始时间及结束时间。
@gD)pH
main 函式部份,修改成:
Q?k*3A
int* gpudata, *result;
/h0bBP
clock_t* time;
HU $"o6ap
cudaMalloc((void**) &gpudata, sizeof(int) * DATA_SIZE);
|6*Bu1
cudaMalloc((void**) &result,
B-Jd|UE`u
sizeof(int) * THREAD_NUM * BLOCK_NUM);
1.cP3kl
cudaMalloc((void**) &time, sizeof(clock_t) * BLOCK_NUM * 2);
iYStl
cudaMemcpy(gpudata, data, sizeof(int) * DATA_SIZE,
?8-!hU@QC
cudaMemcpyHostToDevice);
NS[eQ_rT
Cg): Q8
sumOfSquares<<<BLOCK_NUM, THREAD_NUM, 0>>>(gpudata, result,
-I|xW
time);
2h
hy*{{f;
int sum[THREAD_NUM * BLOCK_NUM];
(`.OS)&
clock_t time_used[BLOCK_NUM * 2];
eWcqf/4?"
cudaMemcpy(&sum, result, sizeof(int) * THREAD_NUM * BLOCK_NUM,
[s>3xWZ+a
cudaMemcpyDeviceToHost);
H2s:M
cudaMemcpy(&time_used, time, sizeof(clock_t) * BLOCK_NUM * 2,
il5C9ql$
cudaMemcpyDeviceToHost);
g#J aw|N
cudaFree(gpudata);
0SIC=p=J
cudaFree(result);
<:v+<)K
cudaFree(time);
&u.{]Yjx
i)o;,~ee
int final_sum = 0;
qNQ54#
for(int i = 0; i < THREAD_NUM * BLOCK_NUM; i++) {
_6NUtU
final_sum += sum
;
"tz6O0D
}
:5NMgR.d
VGV-t
clock_t min_start, max_end;
S/'0czDMW
min_start = time_used[0];
qH}8TC
max_end = time_used[BLOCK_NUM];
U>E: Ub0r
for(int i = 1; i < BLOCK_NUM; i++) {
c* {6T}VZr
if(min_start > time_used
)
7AV{ h[J
min_start = time_used
;
j,M$l mR')
if(max_end < time_used[i + BLOCK_NUM])
JN0h3nZ_
max_end = time_used[i + BLOCK_NUM];
I\.|\^
}
9(N
;5 j|B|v
printf("sum: %d time: %d\n", final_sum, max_end - min_start);
ZRagM'K
基本上我们只是把 result 的大小变大,并修改计算时间的方式,把每个 block 最早的开始时间,和最晚的结束时间相减,取得总运行时间。
86r"hy~
这个版本的程序,执行的时间减少很多,在 GeForce 8800GT 上只需要约 150K cycles,相当于 40GB/s 左右的带宽。不过,它在 CPU 上执行的部份,需要的时间加长了(因为 CPU 现在需要加总 8192 个数字)。为了避免这个问题,我们可以让每个 block 把自己的每个 thread 的计算结果进行加总。
@V%\Gspv
Thread 的同步
o&?c,FwN
前面提过,一个 block 内的 thread 可以有共享的内存,也可以进行同步。我们可以利用这一点,让每个 block 内的所有 thread 把自己计算的结果加总起来。把 kernel 改成如下:
VL9wRu;
__global__ static void sumOfSquares(int *num, int* result,
d05xn7%!{
clock_t* time)
+`HMl;0m
{
$+*nb4
extern __shared__ int shared[];
:jiuu@<
const int tid = threadIdx.x;
y&NqVR=
const int bid = blockIdx.x;
~rb0G*R>
int i;
P}@AH02
if(tid == 0) time[bid] = clock();
|p6d]#z3
shared[tid] = 0;
fu"cX;
for(i = bid * THREAD_NUM + tid; i < DATA_SIZE;
@t9HRL?T~
i += BLOCK_NUM * THREAD_NUM) {
,9P-<P
shared[tid] += num
* num
;
GJ,&$@8)
}
}iUK`e
PM\Ju]
__syncthreads();
i HD!v7d7
if(tid == 0) {
{@InOo!4w]
for(i = 1; i < THREAD_NUM; i++) {
hQn?qJy%W
shared[0] += shared
;
E]@&<TFq
}
(C!p2f
result[bid] = shared[0];
cE]z Tu?!
}
"\b>JV5
QtW9!p7(
if(tid == 0) time[bid + BLOCK_NUM] = clock();
+0pI}a\
}
$Ll9ak}
利用 __shared__ 声明的变量表示这是 shared memory,是一个 block 中每个 thread 都共享的内存。它会使用在 GPU 上的内存,所以存取的速度相当快,不需要担心 latency 的问题。
2Vx4"fHP#N
__syncthreads() 是一个 CUDA 的内部函数,表示 block 中所有的 thread 都要同步到这个点,才能继续执行。在我们的例子中,由于之后要把所有 thread 计算的结果进行加总,所以我们需要确定每个 thread 都已经把结果写到 shared[tid] 里面了。
\;{ ]YX
接下来,把 main 函式的一部份改成:
ipv5JD[
int* gpudata, *result;
${ {4L?7
clock_t* time;
v"G1vSx)BT
cudaMalloc((void**) &gpudata, sizeof(int) * DATA_SIZE);
{[H_Vl@
cudaMalloc((void**) &result, sizeof(int) * BLOCK_NUM);
24jf`1XFW
cudaMalloc((void**) &time, sizeof(clock_t) * BLOCK_NUM * 2);
O?e9wI=H
cudaMemcpy(gpudata, data, sizeof(int) * DATA_SIZE,
;kgP:n
cudaMemcpyHostToDevice);
tIuM9D{P
U#3N90,N=
sumOfSquares<<<BLOCK_NUM, THREAD_NUM,
Fz7t84g(
THREAD_NUM * sizeof(int)>>>(gpudata, result, time);
L/8oqO|
,;g%/6X
int sum[BLOCK_NUM];
&6x(%o|
clock_t time_used[BLOCK_NUM * 2];
],]Rv#`
cudaMemcpy(&sum, result, sizeof(int) * BLOCK_NUM,
C%o|}i v"
cudaMemcpyDeviceToHost);
cJ4My#w
cudaMemcpy(&time_used, time, sizeof(clock_t) * BLOCK_NUM * 2,
#A/OGi
cudaMemcpyDeviceToHost);
o:d7IL
cudaFree(gpudata);
OIblBQ!
cudaFree(result);
h*S"]ye5
cudaFree(time);
vzIo2,/7
V\L;EHtc$
int final_sum = 0;
Fw<"]*iu
for(int i = 0; i < BLOCK_NUM; i++) {
F!vrvlD`s
final_sum += sum
;
Q0xO;20
}
J>Ar(p
可以注意到,现在 CPU 只需要加总 BLOCK_NUM 也就是 32 个数字就可以了。
kKyU?/aj
不过,这个程序由于在 GPU 上多做了一些动作,所以它的效率会比较差一些。在 GeForce 8800GT 上,它需要约 164K cycles。
+A3/^C0
当然,效率会变差的一个原因是,在这一版的程序中,最后加总的工作,只由每个 block 的 thread 0 来进行,但这并不是最有效率的方法。理论上,把 256 个数字加总的动作,是可以并行化的。最常见的方法,是透过树状的加法:
@>8(f#S%
fs!dI
把 kernel 改成如下:
,!:c6F+
__global__ static void sumOfSquares(int *num, int* result,
h.\I tK{)
clock_t* time)
YdhTjvx
{
\"O5li3n
extern __shared__ int shared[];
ea3w
const int tid = threadIdx.x;
;+ hh|NiQ
const int bid = blockIdx.x;
._}}@V_/
int i;
cE\w6uBR1
int offset = 1, mask = 1;
b'z $S+
if(tid == 0) time[bid] = clock();
hG1$YE
shared[tid] = 0;
fd)8lK[KJ"
for(i = bid * THREAD_NUM + tid; i < DATA_SIZE;
-"[4E0g0
i += BLOCK_NUM * THREAD_NUM) {
(!}N&!t
shared[tid] += num
* num
;
z$1RD)TQB
}
pv]@}+<Dt
(?qCtLZ
__syncthreads();
`d_T3^ayu
while(offset < THREAD_NUM) {
h"`\'(,X
if((tid & mask) == 0) {
Q0J1"*P0
shared[tid] += shared[tid + offset];
;8]HCC@:
}
'm`O34h
offset += offset;
Px_8lB/;
mask = offset + mask;
!1$x4 qxS
__syncthreads();
^z *0
}
Wmbc `XC
y3oq{Z>
if(tid == 0) {
Ic/hVKYG5
result[bid] = shared[0];
:\;9y3
time[bid + BLOCK_NUM] = clock();
R$:-~<O
}
F-;J N
}
# &v4c
后面的 while 循环就是进行树状加法。main 函式则不需要修改。
bQaRl=:[:
这一版的程序,在 GeForce 8800GT 上执行需要的时间,大约是 140K cycles(相当于约 43GB/s),比完全不在 GPU 上进行加总的版本还快!这是因为,在完全不在 GPU 上进行加总的版本,写入到 global memory 的数据数量很大(8192 个数字),也对效率会有影响。所以,这一版程序不但在 CPU 上的运算需求降低,在 GPU 上也能跑的更快。
=Vg~ VD
进一步改善
Id=20og
上一个版本的树状加法是一般的写法,但是它在 GPU 上执行的时候,会有 share memory 的 bank conflict 的问题(详情在后面介绍 GPU 架构时会提到)。采用下面的方法,可以避免这个问题:
V;SfW2`)
offset = THREAD_NUM / 2;
w~]2c{\Qz
while(offset > 0) {
K?l|1jez(#
if(tid < offset) {
n_QuuUB
shared[tid] += shared[tid + offset];
Thggas,
}
k\OZ'dS
offset >>= 1;
}i+C)VUX
__syncthreads();
yhH2b:nY(9
}
Za@\=}Tt
这样同时也省去了 mask 变数。因此,这个版本的执行的效率就可以再提高一些。在 GeForce 8800GT 上,这个版本执行的时间是约 137K cycles。当然,这时差别已经很小了。如果还要再提高效率,可以把树状加法整个展开:
5Q10Ohh
if(tid < 128) { shared[tid] += shared[tid + 128]; }
Oc)n,D)0
__syncthreads();
rc=E%Qv%?
if(tid < 64) { shared[tid] += shared[tid + 64]; }
Q7+WV`&
__syncthreads();
} 9@rhW
if(tid < 32) { shared[tid] += shared[tid + 32]; }
ioi/`iQR
__syncthreads();
7F"ljkN1S
if(tid < 16) { shared[tid] += shared[tid + 16]; }
aCU[9Xr?
__syncthreads();
| R,dsBd
if(tid < 8) { shared[tid] += shared[tid + 8]; }
x^y$ pr
__syncthreads();
?'V78N sA
if(tid < 4) { shared[tid] += shared[tid + 4]; }
!@z9n\Yj
__syncthreads();
4phCn5
if(tid < 2) { shared[tid] += shared[tid + 2]; }
>{^_]phlb
__syncthreads();
D^r g-E[L
if(tid < 1) { shared[tid] += shared[tid + 1]; }
k=)U
__syncthreads();
sUF$eVAT
当然这只适用于 THREAD_NUM 是 256 的情形。这样可以再省下约 1000 cycles 左右(约 44GB/s)。最后完整的程序文件可以从这里下载。
k5RzW4zq;
第二个 CUDA程序
X_PzK'#m
前面介绍的计算平方和的程序,似乎没有什么实用价值。所以我们的第二个 CUDA 程序,要做一个确实有(某些)实用价值的程序,也就是进行矩阵乘法。而且,这次我们会使用浮点数。
]5'*^rz ^
虽然矩阵乘法有点老套,不过因为它相当简单,而且也可以用来介绍一些有关 CUDA 的有趣性质。
!{&r|6
矩阵乘法
AH,?B*zGj
为了单纯起见,我们这里以方形的矩阵为例子。基本上,假设有两个矩阵 A 和 B,则计算 AB = C 的方法如下:
Q 8]X
for(i = 0; i < n; i++) {
f}c;s
for(j = 0; j < n; j++) {
+k=*AQt^8
C
[j] = 0;
N<JI^%HBgP
for(k = 0; k < n; k++) {
UC&$8^
C
[j] += A
[k] * B[k][j];
\9+,ynJH8z
}
JQ+Mg&&Q
}
(u?s@/e:`/
}
\%r#>8c8
一开始,我们先准备好产生数据、设定 CUDA 等等的工作:
#A>*pF
int main()
NZGO8u
{
I gJu/{:y^
float *a, *b, *c, *d;
kHK<~srB
int n = 1000;
rVabkwYD
,88B@a
if(!InitCUDA()) return 0;
cC8$ oCR?
,.Gp_BI
a = (float*) malloc(sizeof(float) * n * n);
'&CZ%&(Gw
b = (float*) malloc(sizeof(float) * n * n);
L`t786 (M
c = (float*) malloc(sizeof(float) * n * n);
RY5e%/bg~U
d = (float*) malloc(sizeof(float) * n * n);
h),;j`PrC
5zS%F: 3
srand(0);
oypLE=H
tN{0C/B9
matgen(a, n, n);
B} qRz
matgen(b, n, n);
v8-szW).
2Wcu.
clock_t time = matmultCUDA(a, n, b, n, c, n, n);
<(E)M@2
]Jz=.F sO
matmult(a, n, b, n, d, n, n);
}%KQrlbHJl
compare_mat(c, n, d, n, n);
u/tJ])~@
=o@}~G&HA
double sec = (double) time / CLOCKS_PER_SEC;
n^'d8Y(
printf("Time used: %.2f (%.2lf GFLOPS)\n", sec,
RlfI]uCDM
2.0 * n * n * n / (sec * 1E9));
frbd{o
9No6\{[M
return 0;
" lD -*e4
}
cJSNV*<
InitCUDA 函式和第一个 CUDA 程序一样,可以直接参考前面的文章。以下是上面用到的一些其它的函式:
CX#d
产生矩阵:
%\it4 r3
void matgen(float* a, int lda, int n)
&Hw:65O
{
$Y/z+ea
int i, j;
WGV]O|
1=]#=)+
for(i = 0; i < n; i++) {
CQ!D{o=
for(j = 0; j < n; j++) {
P#pb48^-
a[i * lda + j] = (float) rand() / RAND_MAX +
Z{3=.z{&^=
(float) rand() / (RAND_MAX * RAND_MAX);
z{R Mb
}
?3DL .U{
}
]FR#ZvM>x
}
|(~IfSE2
这个函式只是利用随机数生成器把矩阵填满 0 ~ 1 之间的数字。特别注意到因为 C 语言中无法声明变动大小的二维矩阵,所以我们使用 i * lda + j 的方式。
B)k/]vz)*D
进行矩阵乘法:
@G&oUhS
void matmult(const float* a, int lda, const float* b, int ldb,
v0'z''KM!
float* c, int ldc, int n)
5+GTK)D
{
_~Lu%
int i, j, k;
0yjYjIk"T
o=VZ7]
for(i = 0; i < n; i++) {
p&}m')
for(j = 0; j < n; j++) {
u9_?c G-
double t = 0;
6X ]I`e
for(k = 0; k < n; k++) {
xgOt%7sb
t += a[i * lda + k] * b[k * ldb + j];
H~[q<ybxr
}
>u%Bn\G
c[i * ldc + j] = t;
NiG&Lw*8
}
Fmn_fW6
}
-/8V2dv3
}
" "`z3-
这是以 CPU 进行矩阵乘法、用来进行验证答案正确与否的程序。特别注意到它用 double 来储存暂时的计算结果,以提高精确度。
ZV Ko$q:F
验证结果:
8\il~IFyi
void compare_mat(const float* a, int lda,
T c-fO /0
const float* b, int ldb, int n)
hQ3@Cf W
{
tT`S" 9T
float max_err = 0;
1sUgjyGQ
float average_err = 0;
i@{b+5$
int i, j;
i(rYc
P w6l'
for(i = 0; i < n; i++) {
"P5,p"k:)
for(j = 0; j < n; j++) {
319 4]
if(b[i * ldb + j] != 0) {
QP%AJ[3ea%
float err = fabs((a[i * lda + j] -
+fvVora
b[i * ldb + j]) / b[i * ldb + j]);
E:}s6l
if(max_err < err) max_err = err;
kb-XEJ}L
average_err += err;
6heK8*.T
}
6T"5,Q</h
}
oKRI2ni$j9
}
aV fsF|,
JA >&$h
printf("Max error: %g Average error: %g\n",
*o=Z~U9z
max_err, average_err / (n * n));
9'X "a
}
w_@6!zm
这个函式计算两个矩阵的最大相对误差和平均相对误差,并把结果印出来。
B4OFhtYE
最后是 CUDA 的矩阵乘法的部份:
NrcV%-+u%
#define NUM_THREADS 256
KjA7x
#E4oq9{0*W
clock_t matmultCUDA(const float* a, int lda,
0>Kgz!I
const float* b, int ldb, float* c, int ldc, int n)
m[ S1
{
}2=~7&)
float *ac, *bc, *cc;
KYhL}C+
clock_t start, end;
P EzT|uY
TPJuS)TU9
start = clock();
]_>38f7h
cudaMalloc((void**) &ac, sizeof(float) * n * n);
z'Bvjul
cudaMalloc((void**) &bc, sizeof(float) * n * n);
*<9M|H~
cudaMalloc((void**) &cc, sizeof(float) * n * n);
y=GDuU%
MDpx@.A,
cudaMemcpy2D(ac, sizeof(float) * n, a, sizeof(float) * lda,
jxK `ShW=
sizeof(float) * n, n, cudaMemcpyHostToDevice);
?zo7.R-Vac
cudaMemcpy2D(bc, sizeof(float) * n, b, sizeof(float) * ldb,
9aID&b+
sizeof(float) * n, n, cudaMemcpyHostToDevice);
s$^ 2Cuhv
BT:b&"AR[
int blocks = (n + NUM_THREADS - 1) / NUM_THREADS;
vSQB~Vw8t
matMultCUDA<<<blocks * n, NUM_THREADS>>>
1\jj3Y'i'
(ac, n, bc, n, cc, n, n);
2Z(?pJyDM
45+kwo0
cudaMemcpy2D(c, sizeof(float) * ldc, cc, sizeof(float) * n,
K/&
sizeof(float) * n, n, cudaMemcpyDeviceToHost);
#,jm3Mqj
Mt4`~`6
cudaFree(ac);
j1JdG<n
cudaFree(bc);
&Ai+t2
cudaFree(cc);
Ornm3%p+e
q:_-#u
end = clock();
IFSIQ q
H2_6m5[&,
return end - start;
*eAt '
}
@C'qbO{
这个函式相当单纯,就是在显卡内存中配置存放矩阵的内存,然后把主内存中的矩阵数据复制到显卡内存上。不过,因为我们的矩阵乘法函式可以指定 pitch(即 lda、ldb、和 ldc),所以如果用一般的 cudaMemcpy 函式来复制内存的话,会需要每个 row 都分开复制,那会需要呼叫很多次 cudaMemcpy 函式,会使效率变得很差。因此,在这里我们用了一个新的 cudaMemcpy2D 函式,它是用来复制二维数组,可以指定数组的 pitch。这样就可以透过一次函数调用就可以了。
&S^a_L:
进行计算的 kernel 如下:
=+e;BYD#!
__global__ static void matMultCUDA(const float* a, size_t lda,
+C$wkx]
const float* b, size_t ldb, float* c, size_t ldc, int n)
(!ud"A|ab4
{
GyE5jh2
const int tid = threadIdx.x;
CIR2sr0a
const int bid = blockIdx.x;
FN0<iL
const int idx = bid * blockDim.x + tid;
OoOwEV2p_
const int row = idx / n;
]VVx2ERs
const int column = idx % n;
Ob'[W;p)[w
int i;
wh]v{Fi'
7n#Mh-vq
if(row < n && column < n) {
FOa2VP%
float t = 0;
yWYsN
for(i = 0; i < n; i++) {
gZ(\/m8Z
t += a[row * lda + i] * b[i * ldb + column];
5IG#-Q(6sp
}
uN&49o
c[row * ldc + column] = t;
t-FrF </0
}
?f6Fj
}
<l)I%1T_c
这个函式一开始先从 bid 和 tid 计算出这个 thread 应该计算的 row 和 column,在判断 row 和 column 在范围内之后,就直接进行计算,并把结果写到 c 矩阵中,是非常单纯的函式。
_hB7;N3
在 GeForce 8800GT 上实际执行的结果如下:
N[=R$1\Z
Max error: 2.01484e-006 Average error: 3.36637e-007
!&8B8jHqA
Time used: 1.1560 (1.73 GFLOPS)
ovtZHq/
可以看到两个问题:
Xp^71A?>
1. 很明显的,执行效率相当低落。
!O,`Z`T?
2. 最大相对误差偏高。理想上应该要低于 1e-6。
f<DqA/$
计算结果的误差偏高的原因是,在 CPU 上进行计算时,我们使用 double(即 64 bits 浮点数)来累进计算过程,而在 GPU 上则只能用 float(32 bits 浮点数)。在累加大量数字的时候,由于累加结果很快会变大,因此后面的数字很容易被舍去过多的位数。
LZPLz@=&]
由于 CUDA 的浮点数运算,在进行加、减、乘法时是符合 IEEE 754 规定的精确度的,因此,我们可以利用 Kahan's Summation Formula 来提高精确度。把程序改成:
)=h+5Z>E1
if(row < n && column < n) {
Oi!uJofW
float t = 0;
$+ \JT/eG9
float y = 0;
_t7aOH
for(i = 0; i < n; i++) {
uoY]@.
float r;
]T<RC\o
y -= a[row * lda + i] * b[i * ldb + column];
n\scOM)3
r = t - y;
]gb?3a}A
y = (r - t) + y;
jW|M)[KJN
t = r;
F-?s8RD
}
CIt@xi#I
}
$8_b[~%2
修改后的程序的执行结果是:
JOq&(AZe
Max error: 1.19209e-007 Average error: 4.22751e-008
|>p?Cm
Time used: 1.1560 (1.73 GFLOPS)
Daf;; w
可以看到相对误差有很大的改善,效率则没什么变化。
v3/G.B@=
由于 Kahan's Summation Formula 需要的运算量提高,但是效率却没有什么改变,可以看出这个 kernel 主要的瓶颈应该是在内存的存取动作上。这是因为有大量的内存读取是重复的。例如,矩阵 a 的一个 row 在每次进行计算时都被重复读入,但这是相当浪费的。这样的计算方式,总共需要读取 2*n3 次内存。如果让一个 row 只需要读入一次的话,就可以减到为 n3+n2 次。
)J|~'{z:
o 3 G*
第一个改良
k8sjW!2
"CQ:<$|$
和我们的第一个 CUDA 程序一样,我们可以利用 shared memory 来储存每个 row 的数据。不过,因为只有同一个 block 的 threads 可以共享 shared memory,因此现在一个 row 只能由同一个 block 的 threads 来进行计算。另外我们也需要能存放一整个 row 的 shared memory。因此,把先把呼叫 kernel 的部份改成:
_`]YWvh
matMultCUDA<<<n, NUM_THREADS, sizeof(float) * n>>>
tjy@sO/Q
(ac, n, bc, n, cc, n, n);
LwCf}4u"
kernel 的部份则改成:
5 .bU2C
__global__ static void matMultCUDA(const float* a, size_t lda,
bq}o#d5p-_
const float* b, size_t ldb, float* c, size_t ldc, int n)
ADZU?7)
{
;x=0+0JD
extern __shared__ float data[];
^X?3e1om
const int tid = threadIdx.x;
A3e83g~L
const int row = blockIdx.x;
_AVP1
int i, j;
gM#]o QOGE
{Ve_u
for(i = tid; i < n; i += blockDim.x) {
n ^C"v6X
data
= a[row * lda + i];
XCZNvLG
}
o7@81QA!e
#F >R5 D
__syncthreads();
LT '2446
>]8H@. \
for(j = tid; j < n; j += blockDim.x) {
2HREO@._)
float t = 0;
]OA8H[U-eA
float y = 0;
~LN {5zg
for(i = 0; i < n; i++) {
G C3G=DTt
float r;
K<w$
y -= data
* b[i * ldb + j];
=qTmFszT
r = t - y;
TqXB2`7Ri
y = (r - t) + y;
$]]|#}J
t = r;
#ruL+-8!<
}
lZ }H?n%
c[row * ldc + j] = t;
zC\L-i>G
}
QK/~lN
}
[Gb8o'
1O;q|p'9
第一个部份先把整个 row 读到 shared memory 中,而第二个部份则进行计算,并没有太大的变化。主要的差别是现在一个 row 只由一个 block 进行计算。
&Ukh
在 GeForce 8800GT 上,执行的结果是:
eN]9=Y~-K
Max error: 1.19209e-007 Average error: 4.22751e-008
||kUi=5
Time used: 0.4220 (4.74 GFLOPS)
f>_' ]eM%
很明显的,计算的结果并没有改变,不过速度则提高了超过一倍。虽然如此,但是这样的效率仍不尽理想,因为理论上 GeForce 8800GT 有超过 300GFLOPS 的运算性能。即使是把 Kahan's Summation Formula 所需要的额外运算考虑进去,这样的效率仍然连理论最大值的十分之一都不到。
#ANbhHG
会有这样的结果,原因其实还是同样的:对内存的存取次数太多了。虽然现在 A 矩阵的 row 的数据已经不再需要重复读取,但是 B 矩阵的 column 的数据仍然一直被重复读取。
U> 1v oc
另一个问题比较不是那么明显:对 B 矩阵的读取,虽然看起来不连续,但实际上它是连续的。这是因为不同的 thread 会读取不同的 column,因此同时间每个 thread 读取的各个 column 加起来,就是一个连续的内存区块。那么,为什么效率还是不佳呢?这是因为,GPU 上的内存控制器,从某个固定的倍数地址开始读取,才会有最高的效率(例如 16 bytes 的倍数)。由于矩阵大小并不是 16 的倍数(这里使用的是 1000x1000 的矩阵),所以造成效率不佳的情形。
h>'Mh;+
要解决这个问题,我们可以在 cudaMalloc 的时候稍微修改一下,让宽度变成 适当的倍数就可以了。但是,适当的倍数是多少呢?幸运的是,我们并不需要知道这些细节。CUDA 提供了一个 cudaMallocPitch 的函式,可以自动以最佳的倍数来配置内存。因此,我们可以把 cudaMalloc 的部份改成:
H(j983
size_t pitch_a, pitch_b, pitch_c;
??`zW
cudaMallocPitch((void**) &ac, &pitch_a, sizeof(float) * n, n);
9rhl2E
cudaMallocPitch((void**) &bc, &pitch_b, sizeof(float) * n, n);
HO =\
cudaMallocPitch((void**) &cc, &pitch_c, sizeof(float) * n, n);
nx'D&,VX
cudaMallocPitch 函式会以适当的倍数配置内存,并把配置的宽度传回。因此,在把矩阵复制到显卡内存上时,要使用它传回的宽度:
T|Fl$is
cudaMemcpy2D(ac, pitch_a, a, sizeof(float) * lda,
w+3-j
sizeof(float) * n, n, cudaMemcpyHostToDevice);
D~JrO]mi
cudaMemcpy2D(bc, pitch_b, b, sizeof(float) * ldb,
z0-`D.D@\
sizeof(float) * n, n, cudaMemcpyHostToDevice);
PzDekyl
呼叫 kernel 的部份也需要修改:
jG&gd<^
matMultCUDA<<<n, NUM_THREADS, sizeof(float) * n>>>
%FO#j 6
(ac, pitch_a / sizeof(float), bc, pitch_b / sizeof(float),
rjW\tuZI
cc, pitch_c / sizeof(float), n);
9TbRrS09
同样的,把计算结果复制回到主内存时,也要使用传回的宽度值:
UgZuEfEGve
cudaMemcpy2D(c, sizeof(float) * ldc, cc, pitch_c,
qUtlh,4)
sizeof(float) * n, n, cudaMemcpyDeviceToHost);
;JQ;LbEn
这样就完成了。Kernel 部份则不需要修改。
-|A`+1-R+
这样的修改有多大的效果呢?在 GeForce 8800GT 上执行,结果如下:
V17SJSC-
Max error: 1.19209e-007 Average error: 4.22751e-008
3rw<#t;v
Time used: 0.1250 (16.00 GFLOPS)
dJD8c2G
可以看到,执行速度又再大幅提高了三倍多!而这只是把内存的配置方式稍微修改一下而已。
=?gB@vS
虽然执行速度提高了很多,但是,和前面提到的理论值相比,其实还是有相当的差距。这是因为,前面也提到过,这样的做法需要 n3+n2 次的内存读取,和 n2 次的内存写入动作。由于 n = 1000,每个数字的大小是 32 bits,所以总共的内存存取数据量约为 4GB。除以实际执行的时间 0.125 秒,得到的带宽数值是约 32GB/s,这已经接近 GeForce 8800GT 显卡内存的带宽了。由于我们计算时间的时候,把配置内存、以及数据的复制动作也计算进去,因此实际上花费在 kernel 的时间是更短的(约 0.09 秒)。因此,可以很明显的看出,这个程序的效率,是受限于内存带宽的。
?T <2Cl'C
进一步的改良
5QUL-*t
上一节的结论显示出,矩阵乘法的程序,效率是受限于内存带宽的。那有没有办法降低内存的存取次数呢?答案当然是有的,不然就不会有这一节了 :)
1|Z!8:&pj
要进一步降低内存带宽的使用,可以注意到,在上一节的方法中,虽然 A 矩阵的存取次数被减至最低,但是 B 矩阵的存取次数并没有减少。这是因为我们只将 A 矩阵的 row 加载到 shared memory 中,但是 B 矩阵的 column 也是有被重复使用的。理想上应该也可以避免重复加载才对。不过,由于 B 矩阵的 column 使用的时机,和 A 矩阵的 row 是不同的,所以并不能直接这样做。
a@V`EEZ
解决方法是 "blocking"。也就是把整个矩阵乘法的动作,切割成很多小矩阵的乘法。例如,要计算 C 矩阵的 (0, 0) ~ (15, 15) 的值,可以把它想成:
S+&Bf ~~D
A(0~15, 0~15) * B(0~15, 0~15) + A(0~15,16~31) * B(16~31, 0~15)
$Q< >MB7
+ A(0~15, 32~47) * B(32~47, 0~15) + ...
+>S\.h s4
这样一来,我们就可以把两个小矩阵加载到 shared memory,则小矩阵本身的乘法就不需要再存取任何外部的内存了!这样一来,假设小矩阵的大小是 k,则实际上需要的内存存取次数就会变成约 2k2(n/k)3 = 2n3/k。
|.Y}2>{
由于目前 CUDA 每个 block 的 thread 数目最多是 512,因此 k = 16 似乎是一个相当理想的数字(共 256 个 threads)。因此,对于一个 n = 1000 的矩阵来说,我们可以把内存存取的量减少到约 500MB,也就是上一节的存取量的 1/8。理论上,这样应该可以让效率提高八倍(假设没有遇到别的瓶颈)。
zU,Qph ,<
为了方便进行区块的计算,我们让每个 block 有 16x16 个 threads,再建立 (n/16)x(n/16) 个 blocks。把呼叫 kernel 的地方改成:
P+nd?:cz
int bx = (n + BLOCK_SIZE - 1) / BLOCK_SIZE;
^8eu+E.{
dim3 blocks(bx, bx);
aqzIMOAf
dim3 threads(BLOCK_SIZE, BLOCK_SIZE);
i_6 Y6
matMultCUDA<<<blocks, threads>>>(ac, pitch_a / sizeof(float),
;/g Bjp]H
bc, pitch_b / sizeof(float), cc, pitch_c / sizeof(float), n);
o79EDPX
BLOCK_SIZE 则是定义成 16。dim3 是 CUDA 的一种数据型态,表示一个 3D 的向量。在这里,我们透过 dim3 来建立 16x16 个 threads 的 block,和 (n/16)x(n/16) 个 blocks。
a$ FO5%o
Kernel 程序的部份,则改成:
W #kOcw
__global__ static void matMultCUDA(const float* a, size_t lda,
{]6Pd`-
const float* b, size_t ldb, float* c, size_t ldc, int n)
&;Jg2f%.
{
/KH,11)yc
__shared__ float matA[BLOCK_SIZE][BLOCK_SIZE];
m<uBRI*I
__shared__ float matB[BLOCK_SIZE][BLOCK_SIZE];
jou741
const int tidc = threadIdx.x;
3=~0m
const int tidr = threadIdx.y;
9s'[p'[Z
const int bidc = blockIdx.x * BLOCK_SIZE;
[GqQ6\
const int bidr = blockIdx.y * BLOCK_SIZE;
%ryYa
int i, j;
7[?{wbq
\_J;i[
float results = 0;
V1Opp8
float comp = 0;
m3i+b
VwrHD$
for(j = 0; j < n; j += BLOCK_SIZE) {
P4S]bPIp
if(tidr + bidr < n && tidc + j < n) {
TY6Q;BTU
matA[tidr][tidc] = a[(tidr + bidr) * lda + tidc + j];
`suEN@^
}
I[v6Y^{q
else {
48Z0aA~+
matA[tidr][tidc] = 0;
R-V4Ju[:
}
UiLiy?EJ
K/Qo~
if(tidr + j < n && tidc + bidc < n) {
qz<>9n@o
matB[tidr][tidc] = b[(tidr + j) * ldb + tidc + bidc];
,4tuWO)"
}
Y(3X5v?[
else {
X1PXX!]lo[
matB[tidr][tidc] = 0;
TcM;6h`
}
/f0*NNSat-
I c 2R\}q
__syncthreads();
gkw/Rd1oG
. +
for(i = 0; i < BLOCK_SIZE; i++) {
sV`!4 u7%}
float t;
r0fxEYze&
comp -= matA[tidr]
* matB
[tidc];
_gl7Ma
t = results - comp;
&nyJ :?
comp = (t - results) + comp;
&3_.k
results = t;
NP^j5|A*"
}
[uD G;We=
Yy3g7!K5E
__syncthreads();
51&K
}
4V9S~^v|
+<o}@hefY2
if(tidr + bidr < n && tidc + bidc < n) {
VHihC]ks,
c[(tidr + bidr) * ldc + tidc + bidc] = results;
jZ\a:K?
}
qzt.k^'-^
}
ydm2'aV
注意到因为我们现在使用 16x16 的 threads,因此 threadIdx 变量可以取得 threadIdx.x 和 threadIdx.y,范围分别是 0 ~ 15。blockIdx.x 和 blockIdx.y 变量也是同样的情形,范围分别是 0 ~ n/16。
$vK(Qm
在程序中,因为矩阵的大小不一定会是 16 的倍数,因此需要使用 if 判断式检查是否超出矩阵范围。
LaCVI
这个版本在 GeForce 8800GT 上的执行结果如下:
]d]rV `RF
Max error: 1.19209e-007 Average error: 4.22751e-008
M4`.[P4
Time used: 0.0780 (25.64 GFLOPS)
$"[5]{'J
速度虽然提高了,但是似乎并没有达到预期中的八倍。当然,前面提到过,我们在计算时间时,把一些复制内存、配置内存的动作也计算在内,这些动作的时间并不会缩短。实际上 kernel 的运行时间,大约是 0.053 秒左右(约略相当于 38GFLOPS),比上一节的版本快了将近一倍。
LW '3m5
如果这一版程序已经不再限于内存带宽,那为什么没有达到预期的效率呢?这是因为这一版程序已经是限于指令周期了。除了使用 Kahan's Summation Formula 会需要更多的运算之外,程序中也有大量计算矩阵地址的乘法等等,这都会需要花费运算资源。另外,那些用来判断超出矩阵范围的 if 判断式,也会有一定的影响。
Ods/1 KW
要把那些 if 判断式去掉,有一个方法是,在配置内存时,就配置成 16 的倍数,并在复制矩阵到显卡内存之前,先将它清为 0。如下所示:
nWz7$O
int newn = ((n + BLOCK_SIZE - 1) / BLOCK_SIZE) * BLOCK_SIZE;
r(VznKSx
@nOj6b
cudaMallocPitch((void**) &ac, &pitch_a,
yrVk$k#6}
sizeof(float) * newn, newn);
-.=:@H}r
cudaMallocPitch((void**) &bc, &pitch_b,
z IT)Hs5
sizeof(float) * newn, newn);
iu+r=sp
cudaMallocPitch((void**) &cc, &pitch_c,
7`_`V&3s
sizeof(float) * newn, newn);
p.|NZXk%%a
dFVx*{6
cudaMemset(ac, 0, pitch_a * newn);
zo>@"uH4
cudaMemset(bc, 0, pitch_b * newn);
?|NMJQsa7
这样一来,我们就可以把 kernel 中的 if 判断式都移除了:
Cl>{vSN
__global__ static void matMultCUDA(const float* a, size_t lda,
O~ ]3 .b
const float* b, size_t ldb, float* c, size_t ldc, int n)
$WS?/H0C
{
9 m`VIB
__shared__ float matA[BLOCK_SIZE][BLOCK_SIZE];
g/f^|:
__shared__ float matB[BLOCK_SIZE][BLOCK_SIZE];
+l) [A{
const int tidc = threadIdx.x;
v%$c_'d
const int tidr = threadIdx.y;
]oIP;J:&
const int bidc = blockIdx.x * BLOCK_SIZE;
bc}BQ|Q
const int bidr = blockIdx.y * BLOCK_SIZE;
UHT2a9rG
int i, j;
8%EauwAx
[ qx[ 0
float results = 0;
*'ffMnSZ
float comp = 0;
=dM'n}@U
mx3p/p
for(j = 0; j < n; j += BLOCK_SIZE) {
:'0.
matA[tidr][tidc] = a[(tidr + bidr) * lda + tidc + j];
vnS;T+NZSC
matB[tidr][tidc] = b[(tidr + j) * ldb + tidc + bidc];
ly~tB LH}
z<u*I@;
__syncthreads();
Yw_^]:~
DO{Lj#@
for(i = 0; i < BLOCK_SIZE; i++) {
%;MM+xVVX
float t;
VkJBqRzBOa
comp -= matA[tidr]
* matB
[tidc];
_my!YS5n
t = results - comp;
')#!M\1,HQ
comp = (t - results) + comp;
RR9G$}WS(
results = t;
<A`zK
}
.:/[%q{k
0wnC"2GUX
__syncthreads();
I92orr1
}
#m;|QWW
3s B9t X
c[(tidr + bidr) * ldc + tidc + bidc] = results;
O@-|_N*;K
}
fIwG9cR
这个版本的执行结果是:
PyQ P K,
Max error: 1.19209e-007 Average error: 4.22751e-008
(R|Ftjs .
Time used: 0.0780 (25.64 GFLOPS)
IJ E{JH
似乎没有改善。不过,实际上 kernel 的运行时间已经减少到 0.042 秒(约略相当于 48GFLOPS)。
p%ZOLoc)Y
结论
{&,MkWgG
有些读者可能会想,如果把 block 再变得更大(例如 32x32)是否会有帮助呢?当然,由于最后的程序已经不再是受限于内存带宽(在 0.042 秒内存取 500MB 的数据约相当于 12GB/s 的带宽),所以把 block 再加大并不会有帮助了。而且,由于一个 block 内的 thread 数目最多只能到 512 个,将 block 变大也会造成很多额外负担。而且 shared memory 的大小也有限制(GeForce 8800GT 的 shared memory 大小限制是 16384 bytes),所以也不能任意增加 block 的大小。
&\>. j|
最后一版程序的完整档案可以从这里下载。
'}BYMEd/m%
GPU 的硬件架构
J-d>#'Wb|
这里我们会简单介绍,NVIDIA 目前支持 CUDA 的 GPU,其在执行 CUDA 程序的部份(基本上就是其 shader 单元)的架构。这里的数据是综合 NVIDIA 所公布的信息,以及 NVIDIA 在各个研讨会、学校课程等所提供的数据,因此有可能会有不正确的地方。主要的数据源包括 NVIDIA 的 CUDA Programming Guide 1.1、NVIDIA 在 Supercomputing '07 介绍 CUDA 的 session,以及 UIUC 的 CUDA 课程。
}LTy Xo
GPU 的基本介绍
wx[Y2lUh6
目前 NVIDIA 推出的显示芯片,支持 CUDA 的是 G80 系列的显示芯片。其中 G80 显示芯片支持 CUDA 1.0 版,而 G84、G86、G92、G94、G96 则支援 CUDA 1.1 版。基本上,除了最早的 GeForce 8800 Ultra/GTX 及 320MB/640MB 版本的 GeForce 8800GTS、Tesla 等显卡是 CUDA 1.0 版之外,其它 GeForce 8 系列及 9 系列显卡都支持 CUDA 1.1。详细情形可以参考 CUDA Programming Guide 1.1 的 Appendix A。
3K{G =WE$
所有目前支持 CUDA 的 NVIDIA 显示芯片,其 shader 部份都是由多个 multiprocessors 组成。每个 multiprocessor 里包含了八个 stream processors,其组成是四个四个一组,也就是说实际上可以看成是有两组 4D 的 SIMD 处理器。此外,每个 multiprocessor 还具有 8192 个寄存器,16KB 的 share memory,以及 texture cache 和 constant cache。大致上如下图所示:
/|#" ;QsPN
:F`-<x/
详细的 multiprocessor 信息,都可以透过 CUDA 的 cudaGetDeviceProperties() 函式或 cuDeviceGetProperties() 函式取得。不过,目前还没有办法直接取得一个显示芯片中有多少 multiprocessor 的信息。
*aaK_=w
在 CUDA 中,大部份基本的运算动作,都可以由 stream processor 进行。每个 stream processor 都包含一个 FMA(fused-multiply-add)单元,可以进行一个乘法和一个加法。比较复杂的运算则会需要比较长的时间。
_A]=45cn~
执行过程
h= Mmd
在执行 CUDA 程序的时候,每个 stream processor 就是对应一个 thread。每个 multiprocessor 则对应一个 block。从之前的文章中,可以注意到一个 block 经常有很多个 thread(例如 256 个),远超过一个 multiprocessor 所有的 stream processor 数目。这又是怎么回事呢?
cl1>S 3
实际上,虽然一个 multiprocessor 只有八个 stream processor,但是由于 stream processor 进行各种运算都有 latency,更不用提内存存取的 latency,因此 CUDA 在执行程序的时候,是以 warp 为单位。目前的 CUDA 装置,一个 warp 里面有 32 个 threads,分成两组 16 threads 的 half-warp。由于 stream processor 的运算至少有 4 cycles 的 latency,因此对一个 4D 的 stream processors 来说,一次至少执行 16 个 threads(即 half-warp)才能有效隐藏各种运算的 latency。
#L`'<ge'g*
由于 multiprocessor 中并没有太多别的内存,因此每个 thread 的状态都是直接保存在 multiprocessor 的寄存器中。所以,如果一个 multiprocessor 同时有愈多的 thread 要执行,就会需要愈多的寄存器空间。例如,假设一个 block 里面有 256 个 threads,每个 thread 用到 20 个寄存器,那么总共就需要 256x20 = 5,120 个寄存器才能保存每个 thread 的状态。
bTep TWv
目前 CUDA 装置中每个 multiprocessor 有 8,192 个寄存器,因此,如果每个 thread 使用到 16 个寄存器,那就表示一个 multiprocessor 同时最多只能维持 512 个 thread 的执行。如果同时进行的 thread 数目超过这个数字,那么就会需要把一部份的数据储存在显卡内存中,就会降低执行的效率了。
|B~^7RHXo
编者注:在NVIDIA GT200中的Register File大小增加了一倍,在FP32下可用的register file为16K,FP64下是8K。
q'1 86L87
Shared memory
*pj^d><
目前 CUDA 装置中,每个 multiprocessor 有 16KB 的 shared memory。Shared memory 分成 16 个 bank。如果同时每个 thread 是存取不同的 bank,就不会产生任何问题,存取 shared memory 的速度和存取寄存器相同。不过,如果同时有两个(或更多个) threads 存取同一个 bank 的数据,就会发生 bank conflict,这些 threads 就必须照顺序去存取,而无法同时存取 shared memory 了。
dk>qTY+j5
Shared memory 是以 4 bytes 为单位分成 banks。因此,假设以下的数据:
q:ah%x[
__shared__ int data[128];
g?OC-zw
那么,data[0] 是 bank 0、data[1] 是 bank 1、data[2] 是 bank 2、…、data[15] 是 bank 15,而 data[16] 又回到 bank 0。由于 warp 在执行时是以 half-warp 的方式执行,因此分属于不同的 half warp 的 threads,不会造成 bank conflict。
gPqdl6#c
因此,如果程序在存取 shared memory 的时候,使用以下的方式:
YE;Tpji
int number = data[base + tid];
E@[ZwTnJ
那就不会有任何 bank conflict,可以达到最高的效率。但是,如果是以下的方式:
sX[k}=HCK
int number = data[base + 4 * tid];
o/4U`U)Q0v
那么,thread 0 和 thread 4 就会存取到同一个 bank,thread 1 和 thread 5 也是同样,这样就会造成 bank conflict。在这个例子中,一个 half warp 的 16 个 threads 会有四个 threads 存取同一个 bank,因此存取 share memory 的速度会变成原来的 1/4。
'gv~M_
一个重要的例外是,当多个 ..
%k i^XB86
h}&1 7M
未注册仅能浏览
部分内容
,查看
全部内容及附件
请先
登录
或
注册
共
条评分
欢迎光临
http://www.mwtee.com/home.php?mod=space&uid=13535
离线
zzhssss
UID :7228
注册:
2008-01-06
登录:
2012-02-16
发帖:
17
等级:
仿真新人
1楼
发表于: 2008-12-02 14:12:16
也不知能不能做到 并行计算这
共
条评分
离线
dsa0000
UID :21025
注册:
2008-11-08
登录:
2012-09-19
发帖:
85
等级:
仿真一级
2楼
发表于: 2008-12-03 17:14:52
共
条评分
离线
cem-uestc
UID :9061
注册:
2008-03-07
登录:
2019-01-05
发帖:
2575
等级:
荣誉管理员
3楼
发表于: 2008-12-03 17:16:59
基于CUDA的显卡进行科学计算开始热门了,我想明后两年将出现大量这样的工程计算代码
共
条评分
欢迎光临
http://www.mwtee.com/home.php?mod=space&uid=13535
离线
carature
UID :24277
注册:
2009-01-07
登录:
2009-01-08
发帖:
1
等级:
旁观者
4楼
发表于: 2009-01-08 00:09:04
对此比较好奇,看看再说。
共
条评分
离线
huadodo
UID :3119
注册:
2007-05-30
登录:
2020-06-24
发帖:
574
等级:
积极交流六级
5楼
发表于: 2009-01-08 08:39:40
看看哈,多谢LZ
共
条评分
离线
jyou0844
UID :24342
注册:
2009-01-09
登录:
2009-01-09
发帖:
1
等级:
旁观者
6楼
发表于: 2009-01-09 14:02:07
谢谢楼主。
|!"qz$8fB
看看再说!
共
条评分
离线
xdzxw
UID :1364
注册:
2007-03-22
登录:
2020-02-23
发帖:
80
等级:
仿真二级
7楼
发表于: 2009-01-20 11:02:08
看看 谢谢搂住
共
条评分
一切就像那浮云~
离线
funnyhaha
UID :16429
注册:
2008-08-05
登录:
2015-03-17
发帖:
173
等级:
积极交流四级
8楼
发表于: 2009-01-20 11:29:40
看看这篇文章。现谢谢了。
共
条评分
离线
kerbcurb
UID :5263
注册:
2007-10-02
登录:
2024-02-10
发帖:
417
等级:
积极交流五级
9楼
发表于: 2009-01-20 15:24:22
很好,成精听说过,但是没做过
共
条评分
发帖
回复