上学期选修了Prof. Tolga Soyata的“GPU Parallel Programming using C/C++”课程。该课程主要分两部分:前半部分通过讲解CPU并行程序来介绍并行计算的原理和思路;后半部分讲解如何用CUDA在GPU上进行并行计算。本文纪录其中的基础要点和关键实现方式。通过本文大家可以了解到:1、如何用CPU进行多线程并行计算;2、CUDA是什么,GPU如何进行并行计算;3、并行计算的优越性;4、内存的应用对程序性能的影响。
本文分两部分:第一部分是CPU计算部分,第二部分是GPU计算部分。其中主要介绍实现的具体方法(理论也讲不清楚)。内容参考课上课件和代码,以及教材:CUDA BY EXAMPLE。
在接下来的几个小节,我们会用不同的方式完成同一个任务:纵向翻转一张3200x1600的图片,并比较它们之间的性能差异。首先我们来看的是采用串行方法的实现,即不使用并行计算,对逐个像素进行交换操作。为了简化读写等无关操作,我们用opencv进行图像的读写,具体代码见这里。
性能:使用串行计算,其速度为平均每张图片约 107ms。
分析一下这个任务,我们可以发现:每一行执行的操作是独立且相同的。因此我们其实可以让不同行同时执行同样的操作,这便是并行的程序。具体来说,假设我们有 n 个并行的线程,我们便可以让每个线程完成 1/n 的任务,比如线程1完成 [1...colsn] 列,线程2完成 [(colsn+1)...2∗colsn] 列,以此类推。理想状况下,通过 n 个线程同时工作,我们可以将程序的运行时间降低 n 倍。
利用CPU实现上述并行程序需要用到pthread。Pthread是线程的POSIX标准,定义了创建和操作线程的一套API。以下将一些重要的核心代码列出,完整代码可见这里。
#include
#define MAX_THREADS 64
long NumThreads; // number of threads work in parallel
int ThParam[MAX_THREADS]; // store thread parameters
pthread_t ThHandle[MAX_THREADS]; //store thread handles
pthread_attr_t ThAttr; // pthread attribute
pthread_attr_init(&ThAttr);
pthread_attr_setdetachstate(&ThAttr, PTHREAD_CREATE_JOINABLE);
for (i=0; ii++) {
ThParam[i] = i;
// important!! lauch threads
ThErr = pthread_create(&ThHandle[i], &ThAttr, MFlip, (void *)&ThParam[i]);
if (ThErr != 0) {
printf("\nThread Creation Error %d.\n", ThErr);
exit(EXIT_FAILURE);
}
}
for (i=0; ii++) {
// important!! join all threads
pthread_join(ThHandle[i], NULL);
}
long ts = *((int *) tid); // thread ID
ts *= rows / NumThreads; // start index
...
pthread_exit(NULL);
并行程序的性能如下:
线程数 | 1 | 2 | 4 | 8 | 16 | 32 | 64 |
---|---|---|---|---|---|---|---|
时间(ms) | 135.54 | 91.72 | 52.18 | 27.99 | 15.13 | 13.62 | 14.40 |
从上表可以看出:
在这个小节中,我们会看到合理的内存应用和程序执行方式会对程序性能造成多大的影响。
我们先来分析一下上一小节的并行程序。我们将数据按照线程数量分割成等量的小块,并让不同线程同时执行各个小部分,这种并行方式可以成为数据并行。这种并行方式的好处是每个线程执行的任务都是相同的,只需要标记不同的处理位置。但是这样就足够了吗?并非如此。在上述程序中,我们没有考虑过程序的内存访问模式(Memory Access Pattern)。
毫无疑问,MFlip() 函数是一个内存密集型(memory-intensive)的函数。因为它对每个像素并没有进行任何计算,但是需要频繁的对内存进行读取和写入(为了交换像素值)。对于这一类的函数,其内存访问模式会大大地影响程序的性能。
从直观上我们便能体会到上节中程序的内存访问模式有多么糟糕。每次交换两个像素值,程序都要从内存中读取两个不同位置的数据,并且这两个位置相隔非常的远。而且每次从内存中读取一个像素的值(1比特)也是非常浪费资源的做法。再加上多线程同时进行不同位置的内存读写操作,会使程序的内存访问变得非常低效。从理论上讲,为了达到好的DRAM读写性能,我们需要遵守以下几条规则(为了描述的准确性直接上原文):
对比一下我们可以发现,上节的程序违反了Granularity的规则,即每次都读写过于少量的数据,造成读写时资源的浪费。另外,上节的程序并没有利用好缓存(cache memory),因为它从来没有进行数据的复用。
因此,我们来改进上节的程序,使其能满足上述的几点规则。从上表可以看到,虽然图片数据存储在DRAM中,我们并不希望频繁地对它进行访问。因此我们可以一次把一行图片的数据读取到一个临时缓冲区中,再对其中的数据进行处理。这样的好处不仅能够减少对DRAM的访问,还能充分利用L1缓存的作用对读取的数据进行重复利用。核心实现代码如下,整体代码在这里。
unsigned char Buffer1[16384]; // This is the buffer to get the first row of image;
unsigned char Buffer2[16384]; // This is the buffer to get the second row of image;
// important!! copy data to cache memory
memcpy((void *)Buffer1, TheImage.ptr(r), cols*sizeof(unsigned char));
memcpy((void *)Buffer2, TheImage.ptr(rows-(r+1)), cols*sizeof(unsigned char));
memcpy((void *) TheImage.ptr(r), (void *)Buffer2, cols*sizeof(unsigned char));
memcpy((void *) TheImage.ptr(rows-(r+1)), (void *)Buffer1, cols*sizeof(unsigned char));
性能如下:(注意,这里的提速有点夸张,个人认为除了内存访问模式的影响以为,还和之前程序中直接对opencv的矩阵对象进行操作有关)
线程数 | 1 | 2 | 4 | 8 | 16 | 32 | 64 |
---|---|---|---|---|---|---|---|
时间(ms) | 1.65 | 0.95 | 0.96 | 0.85 | 0.86 | 1.42 | 2.52 |
前面介绍的并行方式都属于同步并行(synchronized),即程序等待所有并行的线程都执行完当前的任务,再执行下一步工作。这样可能会出现的一个问题是:有些线程由于某些原因执行速度变慢了,则所有线程都会受到它的影响而滞后,因为它们需要等待所有线程都完成任务。换句话说,具有木桶效应。
除了同步并行以外,我们还有另一种并行方式叫作异步并行(asynchronized)。这种并行方式可以使线程无需等待其它线程的工作情况,而直接进行其它任务。下图可以看到它们的关系:
这里我们不讨论同步和异步之间的优劣,只介绍如何实现一个异步的并行程序。与同步并行不同,异步并行会出现线程之间对资源访问的冲突问题。解决这个问题的一种方法是使用互斥量(mutex)。简单来说就是通过对一个共享变量的上锁(lock)和解锁(unlock)来保证在同一时期只有一个线程对共享资源进行修改,从而解决冲突的问题。下图说明了其工作原理:
利用互斥量实现异步并行的核心代码如下,完整代码见这里。注意我们不再将数据等分为 n 份,而是每次让一个空闲的线程执行一行,若完成则等待下一次分配任务。
pthread_mutex_t CounterMutex; // define mutex
...
// initialize mutex
pthread_mutex_init(&CounterMutex, NULL);
pthread_mutex_lock(&CounterMutex);
NextRowToProcess = 0;
for (i=0; i0;
}
pthread_mutex_unlock(&CounterMutex);
// get the next row number
pthread_mutex_lock(&CounterMutex); // lock it before accessing
r = NextRowToProcess;
NextRowToProcess ++;
pthread_mutex_unlock(&CounterMutex); // unlock it after accessing
性能如下:
线程数 | 1 | 2 | 4 | 8 | 16 | 32 | 64 |
---|---|---|---|---|---|---|---|
时间(ms) | 1.70 | 1.99 | 1.15 | 1.18 | 1.32 | 1.67 | 2.66 |
在这课程中,除了学习到并行计算的原理及实现方式外,我最大的感触便是程序的细微改动对性能巨大的影响。这些改动涉及到内存的访问、CPU计算量的减少等方面。下面这个例子中,我们用7种不同方式实现旋转图片的任务,其中每种实现逐步地对代码进行修改和优化。我们可以看见rotate7()比rotate()的性能要优越很多。由于篇幅关系就不再一一讲解了,具体代码请看这里。