前面介绍的计算平方和的程序,似乎没有什么实用价值。所以我们的第二个 CUDA 程序,要做一个确实有(某些)实用价值的程序,也就是进行矩阵乘法。而且,这次我们会使用浮点数。

虽然矩阵乘法有点老套,不过因为它相当简单,而且也可以用来介绍一些有关 CUDA 的有趣性质。

矩阵乘法

为了单纯起见,我们这里以方形的矩阵为例子。基本上,假设有两个矩阵 和 B,则计算 AB = C 的方法如下:

    for(i = 0; i < n; i++) {
        for(j = 0; j < n; j++) {
            C[i][j] = 0;
            for(k = 0; k < n; k++) {
                C[i][j] += A[i][k] * B[k][j];
            }
        }
    }

一开始,我们先准备好产生数据、设定 CUDA 等等的工作:

    int main()
    {
        float *a, *b, *c, *d;
        int n = 1000;

        if(!InitCUDA()) return 0;

        a = (float*) malloc(sizeof(float) * n * n);
        b = (float*) malloc(sizeof(float) * n * n);
        c = (float*) malloc(sizeof(float) * n * n);
        d = (float*) malloc(sizeof(float) * n * n);

        srand(0);

        matgen(a, n, n);
        matgen(b, n, n);

        clock_t time = matmultCUDA(a, n, b, n, c, n, n);

        matmult(a, n, b, n, d, n, n);
        compare_mat(c, n, d, n, n);

        double sec = (double) time / CLOCKS_PER_SEC;
        printf("Time used: %.2f (%.2lf GFLOPS)\n", sec,
            2.0 * n * n * n / (sec * 1E9));

        return 0;
    }

InitCUDA  函式和第一个 CUDA 程序一样,可以直接参考前面的文章。以下是上面用到的一些其它的函式:

产生矩阵:

    void matgen(float* a, int lda, int n)
    {
        int i, j;

        for(i = 0; i < n; i++) {
            for(j = 0; j < n; j++) {
                a[i * lda + j] = (float) rand() / RAND_MAX +
                     (float) rand() / (RAND_MAX * RAND_MAX);
            }
        }
    }

这个函式只是利用随机数生成器把矩阵填满 0 ~ 1 之间的数字。特别注意到因为 语言中无法声明变动大小的二维矩阵,所以我们使用  i * lda + j  的方式。

进行矩阵乘法:

    void matmult(const float* a, int lda, const float* b, int ldb,
        float* c, int ldc, int n)
    {
        int i, j, k;

        for(i = 0; i < n; i++) {
            for(j = 0; j < n; j++) {
                double t = 0;
                for(k = 0; k < n; k++) {
                    t += a[i * lda + k] * b[k * ldb + j];
                }
                c[i * ldc + j] = t;
            }
        }
    }

这是以 CPU 进行矩阵乘法、用来进行验证答案正确与否的程序。特别注意到它用  double  来储存暂时的计算结果,以提高精确度。

验证结果:

    void compare_mat(const float* a, int lda,
        const float* b, int ldb, int n)
    {
        float max_err = 0;
        float average_err = 0;
        int i, j;

        for(i = 0; i < n; i++) {
            for(j = 0; j < n; j++) {
                if(b[i * ldb + j] != 0) {
                    float err = fabs((a[i * lda + j] -
                         b[i * ldb + j]) / b[i * ldb + j]);
                    if(max_err < err) max_err = err;
                    average_err += err;
                }
            }
        }

        printf("Max error: %g Average error: %g\n",
            max_err, average_err / (n * n));
    }

这个函式计算两个矩阵的最大相对误差和平均相对误差,并把结果印出来。

最后是 CUDA 的矩阵乘法的部份:

    #define NUM_THREADS 256

    clock_t matmultCUDA(const float* a, int lda,
        const float* b, int ldb, float* c, int ldc, int n)
    {
        float *ac, *bc, *cc;
        clock_t start, end;

        start = clock();
        cudaMalloc((void**) &ac, sizeof(float) * n * n);
         cudaMalloc((void**) &bc, sizeof(float) * n * n);
        cudaMalloc((void**) &cc, sizeof(float) * n * n);

        cudaMemcpy2D(ac, sizeof(float) * n, a, sizeof(float) * lda,
            sizeof(float) * n, n, cudaMemcpyHostToDevice);
        cudaMemcpy2D(bc, sizeof(float) * n, b, sizeof(float) * ldb,
            sizeof(float) * n, n, cudaMemcpyHostToDevice);

        int blocks = (n + NUM_THREADS - 1) / NUM_THREADS;
        matMultCUDA<<<blocks * n, NUM_THREADS>>>
            (ac, n, bc, n, cc, n, n);

        cudaMemcpy2D(c, sizeof(float) * ldc, cc, sizeof(float) * n,
        sizeof(float) * n, n, cudaMemcpyDeviceToHost);

        cudaFree(ac);
        cudaFree(bc);
        cudaFree(cc);

        end = clock();

        return end - start;
    }

这个函式相当单纯,就是在显卡内存中配置存放矩阵的内存,然后把主内存中的矩阵数据复制到显卡内存上。不过,因为我们的矩阵乘法函式可以指定 pitch(即 ldaldb、和 ldc),所以如果用一般的 cudaMemcpy 函式来复制内存的话,会需要每个 row 都分开复制,那会需要呼叫很多次 cudaMemcpy 函式,会使效率变得很差。因此,在这里我们用了一个新的  cudaMemcpy2D  函式,它是用来复制二维数组,可以指定数组的 pitch。这样就可以透过一次函数调用就可以了。

进行计算的 kernel 如下:

    __global__ static void matMultCUDA(const float* a, size_t lda,
        const float* b, size_t ldb, float* c, size_t ldc, int n)
    {
        const int tid = threadIdx.x;
        const int bid = blockIdx.x;
        const int idx = bid * blockDim.x + tid;
        const int row = idx / n;
        const int column = idx % n;
        int i;

        if(row < n && column < n) {
            float t = 0;
            for(i = 0; i < n; i++) {
                t += a[row * lda + i] * b[i * ldb + column];
            }
            c[row * ldc + column] = t;
        }
    }

这个函式一开始先从 bid 和 tid 计算出这个 thread 应该计算的 row 和 column,在判断 row 和 column 在范围内之后,就直接进行计算,并把结果写到 矩阵中,是非常单纯的函式。

在 GeForce 8800GT 上实际执行的结果如下:

    Max error: 2.01484e-006 Average error: 3.36637e-007
    Time used: 1.1560 (1.73 GFLOPS)

可以看到两个问题:

很明显的,执行效率相当低落。

最大相对误差偏高。理想上应该要低于 1e-6

计算结果的误差偏高的原因是,在 CPU 上进行计算时,我们使用  double (即 64 bits 浮点数)来累进计算过程,而在 GPU 上则只能用  float 32 bits 浮点数)。在累加大量数字的时候,由于累加结果很快会变大,因此后面的数字很容易被舍去过多的位数。

由于 CUDA 的浮点数运算,在进行加、减、乘法时是符合 IEEE 754 规定的精确度的,因此,我们可以利用 Kahan's Summation Formula 来提高精确度。把程序改成:

    if(row < n && column < n) {
        float t = 0;
        float y = 0;
        for(i = 0; i < n; i++) {
            float r;
            y -= a[row * lda + i] * b[i * ldb + column];
            r = t - y;
            y = (r - t) + y;
            t = r;
        }
    }

修改后的程序的执行结果是:

    Max error: 1.19209e-007 Average error: 4.22751e-008
    Time used: 1.1560 (1.73 GFLOPS)

可以看到相对误差有很大的改善,效率则没什么变化。

由于 Kahan's Summation Formula 需要的运算量提高,但是效率却没有什么改变,可以看出这个 kernel 主要的瓶颈应该是在内存的存取动作上。这是因为有大量的内存读取是重复的。例如,矩阵 的一个 row 在每次进行计算时都被重复读入,但这是相当浪费的。这样的计算方式,总共需要读取 2*n 3  次内存。如果让一个 row 只需要读入一次的话,就可以减到为 n 3 +n 2  次。

第一个改良

 

和我们的第一个 CUDA 程序一样,我们可以利用 shared memory 来储存每个 row 的数据。不过,因为只有同一个 block 的 threads 可以共享 shared memory,因此现在一个 row 只能由同一个 block 的 threads 来进行计算。另外我们也需要能存放一整个 row 的 shared memory。因此,把先把呼叫 kernel 的部份改成:

        matMultCUDA<<<n, NUM_THREADS, sizeof(float) * n>>>
            (ac, n, bc, n, cc, n, n);

kernel 的部份则改成:

    __global__ static void matMultCUDA(const float* a, size_t lda,
        const float* b, size_t ldb, float* c, size_t ldc, int n)
    {
        extern __shared__ float data[];
        const int tid = threadIdx.x;
        const int row = blockIdx.x;
        int i, j;

        for(i = tid; i < n; i += blockDim.x) {
            data[i] = a[row * lda + i];
        }

        __syncthreads();

        for(j = tid; j < n; j += blockDim.x) {
            float t = 0;
            float y = 0;
            for(i = 0; i < n; i++) {
                float r;
                y -= data[i] * b[i * ldb + j];
                r = t - y;
                y = (r - t) + y;
                t = r;
            }
            c[row * ldc + j] = t;
        }
    }


第一个部份先把整个 row 读到 shared memory 中,而第二个部份则进行计算,并没有太大的变化。主要的差别是现在一个 row 只由一个 block 进行计算。

在 GeForce 8800GT 上,执行的结果是:

    Max error: 1.19209e-007  Average error: 4.22751e-008
    Time used: 0.4220   (4.74 GFLOPS)

很明显的,计算的结果并没有改变,不过速度则提高了超过一倍。虽然如此,但是这样的效率仍不尽理想,因为理论上 GeForce 8800GT 有超过 300GFLOPS 的运算性能。即使是把 Kahan's Summation Formula 所需要的额外运算考虑进去,这样的效率仍然连理论最大值的十分之一都不到。

会有这样的结果,原因其实还是同样的:对内存的存取次数太多了。虽然现在 矩阵的 row 的数据已经不再需要重复读取,但是 矩阵的 column 的数据仍然一直被重复读取。

另一个问题比较不是那么明显:对 矩阵的读取,虽然看起来不连续,但实际上它是连续的。这是因为不同的 thread 会读取不同的 column,因此同时间每个 thread 读取的各个 column 加起来,就是一个连续的内存区块。那么,为什么效率还是不佳呢?这是因为,GPU 上的内存控制器,从某个固定的倍数地址开始读取,才会有最高的效率(例如 16 bytes 的倍数)。由于矩阵大小并不是 16 的倍数(这里使用的是 1000x1000 的矩阵),所以造成效率不佳的情形。

要解决这个问题,我们可以在  cudaMalloc 的时候稍微修改一下,让宽度变成 适当的倍数就可以了。但是,适当的倍数是多少呢?幸运的是,我们并不需要知道这些细节。CUDA 提供了一个  cudaMallocPitch 的函式,可以自动以最佳的倍数来配置内存。因此,我们可以把  cudaMalloc  的部份改成:

    size_t pitch_a, pitch_b, pitch_c;
    cudaMallocPitch((void**) &ac, &pitch_a, sizeof(float) * n, n);
    cudaMallocPitch((void**) &bc, &pitch_b, sizeof(float) * n, n);
    cudaMallocPitch((void**) &cc, &pitch_c, sizeof(float) * n, n);

cudaMallocPitch 函式会以适当的倍数配置内存,并把配置的宽度传回。因此,在把矩阵复制到显卡内存上时,要使用它传回的宽度:

    cudaMemcpy2D(ac, pitch_a, a, sizeof(float) * lda,
        sizeof(float) * n, n, cudaMemcpyHostToDevice);
    cudaMemcpy2D(bc, pitch_b, b, sizeof(float) * ldb,
        sizeof(float) * n, n, cudaMemcpyHostToDevice);

呼叫 kernel 的部份也需要修改:

    matMultCUDA<<<n, NUM_THREADS, sizeof(float) * n>>>
        (ac, pitch_a / sizeof(float), bc, pitch_b / sizeof(float),
        cc, pitch_c / sizeof(float), n);

同样的,把计算结果复制回到主内存时,也要使用传回的宽度值:

    cudaMemcpy2D(c, sizeof(float) * ldc, cc, pitch_c,
        sizeof(float) * n, n, cudaMemcpyDeviceToHost);

这样就完成了。Kernel 部份则不需要修改。

这样的修改有多大的效果呢?在 GeForce 8800GT 上执行,结果如下:

    Max error: 1.19209e-007  Average error: 4.22751e-008
    Time used: 0.1250   (16.00 GFLOPS)

可以看到,执行速度又再大幅提高了三倍多!而这只是把内存的配置方式稍微修改一下而已。

虽然执行速度提高了很多,但是,和前面提到的理论值相比,其实还是有相当的差距。这是因为,前面也提到过,这样的做法需要 n 3 +n 2 次的内存读取,和 n 2 次 的内存写入动作。由于 n = 1000,每个数字的大小是 32 bits,所以总共的内存存取数据量约为 4GB。除以实际执行的时间 0.125 秒,得到的带宽数值是约 32GB/s,这已经接近 GeForce 8800GT 显卡内存的带宽了。由于我们计算时间的时候,把配置内存、以及数据的复制动作也计算进去,因此实际上花费在 kernel 的时间是更短的(约 0.09 秒)。因此,可以很明显的看出,这个程序的效率,是受限于内存带宽的。

进一步的改良

上一节的结论显示出,矩阵乘法的程序,效率是受限于内存带宽的。那有没有办法降低内存的存取次数呢?答案当然是有的,不然就不会有这一节了 :)

要进一步降低内存带宽的使用,可以注意到,在上一节的方法中,虽然 矩阵的存取次数被减至最低,但是 矩阵的存取次数并没有减少。这是因为我们只将 矩阵的 row 加载到 shared memory 中,但是 矩阵的 column 也是有被重复使用的。理想上应该也可以避免重复加载才对。不过,由于 矩阵的 column 使用的时机,和 矩阵的 row 是不同的,所以并不能直接这样做。

解决方法是 "blocking"。也就是把整个矩阵乘法的动作,切割成很多小矩阵的乘法。例如,要计算 矩阵的 (0, 0) ~ (15, 15) 的值,可以把它想成:

    A(0~15, 0~15) * B(0~15, 0~15) + A(0~15,16~31) * B(16~31, 0~15)
    + A(0~15, 32~47) * B(32~47, 0~15) + ...

这样一来,我们就可以把两个小矩阵加载到 shared memory,则小矩阵本身的乘法就不需要再存取任何外部的内存了!这样一来,假设小矩阵的大小是 k,则实际上需要的内存存取次数就会变成约 2k 2 (n/k) 3  = 2n 3 /k

由于目前 CUDA 每个 block 的 thread 数目最多是 512,因此 k = 16 似乎是一个相当理想的数字(共 256 个 threads)。因此,对于一个 n = 1000 的矩阵来说,我们可以把内存存取的量减少到约 500MB,也就是上一节的存取量的 1/8。理论上,这样应该可以让效率提高八倍(假设没有遇到别的瓶颈)。

为了方便进行区块的计算,我们让每个 block 有 16x16 个 threads,再建立 (n/16)x(n/16) 个 blocks。把呼叫 kernel 的地方改成:

    int bx = (n + BLOCK_SIZE - 1) / BLOCK_SIZE;
    dim3 blocks(bx, bx);
    dim3 threads(BLOCK_SIZE, BLOCK_SIZE);
    matMultCUDA<<<blocks, threads>>>(ac, pitch_a / sizeof(float),
        bc, pitch_b / sizeof(float), cc, pitch_c / sizeof(float), n);

BLOCK_SIZE  则是定义成 16 dim3  是 CUDA 的一种数据型态,表示一个 3D 的向量。在这里,我们透过  dim3  来建立 16x16 个 threads 的 block,和 (n/16)x(n/16) 个 blocks

Kernel 程序的部份,则改成:

    __global__ static void matMultCUDA(const float* a, size_t lda,
        const float* b, size_t ldb, float* c, size_t ldc, int n)
    {
        __shared__ float matA[BLOCK_SIZE][BLOCK_SIZE];
        __shared__ float matB[BLOCK_SIZE][BLOCK_SIZE];
        const int tidc = threadIdx.x;
        const int tidr = threadIdx.y;
        const int bidc = blockIdx.x * BLOCK_SIZE;
        const int bidr = blockIdx.y * BLOCK_SIZE;
        int i, j;

        float results = 0;
        float comp = 0;

        for(j = 0; j < n; j += BLOCK_SIZE) {
            if(tidr + bidr < n && tidc + j < n) {
                matA[tidr][tidc] = a[(tidr + bidr) * lda + tidc + j];
            }
            else {
                matA[tidr][tidc] = 0;
            }

            if(tidr + j < n && tidc + bidc < n) {
                matB[tidr][tidc] = b[(tidr + j) * ldb + tidc + bidc];
            }
            else {
                matB[tidr][tidc] = 0;
            }

            __syncthreads();

            for(i = 0; i < BLOCK_SIZE; i++) {
                float t;
                comp -= matA[tidr][i] * matB[i][tidc];
                t = results - comp;
                comp = (t - results) + comp;
                results = t;
            }

            __syncthreads();
        }

        if(tidr + bidr < n && tidc + bidc < n) {
            c[(tidr + bidr) * ldc + tidc + bidc] = results;
        }
    }

注意到因为我们现在使用 16x16 的 threads,因此  threadIdx  变量可以取得  threadIdx.x  和  threadIdx.y ,范围分别是 0 ~ 15 blockIdx.x  和  blockIdx.y  变量也是同样的情形,范围分别是 0 ~ n/16

在程序中,因为矩阵的大小不一定会是 16 的倍数,因此需要使用 if 判断式检查是否超出矩阵范围。

这个版本在 GeForce 8800GT 上的执行结果如下:

    Max error: 1.19209e-007  Average error: 4.22751e-008
    Time used: 0.0780   (25.64 GFLOPS)

速度虽然提高了,但是似乎并没有达到预期中的八倍。当然,前面提到过,我们在计算时间时,把一些复制内存、配置内存的动作也计算在内,这些动作的时 间并不会缩短。实际上 kernel 的运行时间,大约是 0.053 秒左右(约略相当于 38GFLOPS),比上一节的版本快了将近一倍。

如果这一版程序已经不再限于内存带宽,那为什么没有达到预期的效率呢?这是因为这一版程序已经是限于指令周期了。除了使用 Kahan's Summation Formula 会需要更多的运算之外,程序中也有大量计算矩阵地址的乘法等等,这都会需要花费运算资源。另外,那些用来判断超出矩阵范围的 if 判断式,也会有一定的影响。

要把那些 if 判断式去掉,有一个方法是,在配置内存时,就配置成 16 的倍数,并在复制矩阵到显卡内存之前,先将它清为 0。如下所示:

    int newn = ((n + BLOCK_SIZE - 1) / BLOCK_SIZE) * BLOCK_SIZE;

    cudaMallocPitch((void**) &ac, &pitch_a,
         sizeof(float) * newn, newn);
    cudaMallocPitch((void**) &bc, &pitch_b,
        sizeof(float) * newn, newn);
    cudaMallocPitch((void**) &cc, &pitch_c,
        sizeof(float) * newn, newn);

    cudaMemset(ac, 0, pitch_a * newn);
    cudaMemset(bc, 0, pitch_b * newn);

 这样一来,我们就可以把 kernel 中的 if 判断式都移除了:

    __global__ static void matMultCUDA(const float* a, size_t lda,
        const float* b, size_t ldb, float* c, size_t ldc, int n)
    {
        __shared__ float matA[BLOCK_SIZE][BLOCK_SIZE];
        __shared__ float matB[BLOCK_SIZE][BLOCK_SIZE];
        const int tidc = threadIdx.x;
        const int tidr = threadIdx.y;
        const int bidc = blockIdx.x * BLOCK_SIZE;
        const int bidr = blockIdx.y * BLOCK_SIZE;
        int i, j;

        float results = 0;
        float comp = 0;

        for(j = 0; j < n; j += BLOCK_SIZE) {
            matA[tidr][tidc] = a[(tidr + bidr) * lda + tidc + j];
            matB[tidr][tidc] = b[(tidr + j) * ldb + tidc + bidc];

            __syncthreads();

            for(i = 0; i < BLOCK_SIZE; i++) {
                float t;
                comp -= matA[tidr][i] * matB[i][tidc];
                t = results - comp;
                comp = (t - results) + comp;
                results = t;
            }

            __syncthreads();
        }

        c[(tidr + bidr) * ldc + tidc + bidc] = results;
    }

这个版本的执行结果是:

    Max error: 1.19209e-007  Average error: 4.22751e-008
    Time used: 0.0780   (25.64 GFLOPS)

似乎没有改善。不过,实际上 kernel 的运行时间已经减少到 0.042 秒(约略相当于 48GFLOPS)。

结论

有些读者可能会想,如果把 block 再变得更大(例如 32x32)是否会有帮助呢?当然,由于最后的程序已经不再是受限于内存带宽(在 0.042 秒内存取 500MB 的数据约相当于 12GB/s 的带宽),所以把 block 再加大并不会有帮助了。而且,由于一个 block 内的 thread 数目最多只能到 512 个,将 block 变大也会造成很多额外负担。而且 shared memory 的大小也有限制(GeForce 8800GT 的 shared memory 大小限制是 16384 bytes),所以也不能任意增加 block 的大小。

最后一版程序的完整档案可以从 这里 下载。

GPU 的硬件架构

 这里我们会简单介绍,NVIDIA 目前支持 CUDA 的 GPU,其在执行 CUDA 程序的部份(基本上就是其 shader 单元)的架构。这里的数据是综合 NVIDIA 所公布的信息,以及 NVIDIA 在各个研讨会、学校课程等所提供的数据,因此有可能会有不正确的地方。主要的数据源包括 NVIDIA 的 CUDA Programming Guide 1.1NVIDIA 在 Supercomputing '07 介绍 CUDA 的 session,以及 UIUC 的 CUDA 课程。

GPU 的基本介绍

目前 NVIDIA 推出的显示芯片,支持 CUDA 的是 G80 系列的显示芯片。其中 G80 显示芯片支持 CUDA 1.0 版,而 G84G86G92G94G96 则支援 CUDA 1.1 版。基本上,除了最早的 GeForce 8800 Ultra/GTX 及 320MB/640MB 版本的 GeForce 8800GTSTesla 等显卡是 CUDA 1.0 版之外,其它 GeForce 8 系列及 系列显卡都支持 CUDA 1.1。详细情形可以参考 CUDA Programming Guide 1.1 的 Appendix A

所有目前支持 CUDA 的 NVIDIA 显示芯片,其 shader 部份都是由多个  multiprocessors  组成。每个 multiprocessor 里包含了八个  stream processors , 其组成是四个四个一组,也就是说实际上可以看成是有两组 4D 的 SIMD 处理器。此外,每个 multiprocessor 还具有 8192 个寄存器,16KB 的 share memory,以及 texture cache 和 constant cache。大致上如下图所示:


详细的 multiprocessor 信息,都可以透过 CUDA 的 cudaGetDeviceProperties() 函式或  cuDeviceGetProperties()  函式取得。不过,目前还没有办法直接取得一个显示芯片中有多少 multiprocessor 的信息。

在 CUDA 中,大部份基本的运算动作,都可以由 stream processor 进行。每个 stream processor 都包含一个 FMAfused-multiply-add)单元,可以进行一个乘法和一个加法。比较复杂的运算则会需要比较长的时间。

执行过程

在执行 CUDA 程序的时候,每个 stream processor 就是对应一个 thread。每个 multiprocessor 则对应一个 block。从之前的文章中,可以注意到一个 block 经常有很多个 thread(例如 256 个),远超过一个 multiprocessor 所有的 stream processor 数目。这又是怎么回事呢?

实际上,虽然一个 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

由于 multiprocessor 中并没有太多别的内存,因此每个 thread 的状态都是直接保存在 multiprocessor 的寄存器中。所以,如果一个 multiprocessor 同时有愈多的 thread 要执行,就会需要愈多的寄存器空间。例如,假设一个 block 里面有 256 个 threads,每个 thread 用到 20 个寄存器,那么总共就需要 256x20 = 5,120 个寄存器才能保存每个 thread 的状态。

目前 CUDA 装置中每个 multiprocessor 有 8,192 个寄存器,因此,如果每个 thread 使用到 16 个寄存器,那就表示一个 multiprocessor 同时最多只能维持 512 个 thread 的执行。如果同时进行的 thread 数目超过这个数字,那么就会需要把一部份的数据储存在显卡内存中,就会降低执行的效率了。

编者注:在NVIDIA GT200中的Register File大小增加了一倍,在FP32下可用的register file16KFP64下是8K

Shared memory

目前 CUDA 装置中,每个 multiprocessor 有 16KB 的 shared memoryShared memory 分成 16 个 bank。如果同时每个 thread 是存取不同的 bank,就不会产生任何问题,存取 shared memory 的速度和存取寄存器相同。不过,如果同时有两个(或更多个) threads 存取同一个 bank 的数据,就会发生 bank conflict,这些 threads 就必须照顺序去存取,而无法同时存取 shared memory 了。

Shared memory 是以 4 bytes 为单位分成 banks。因此,假设以下的数据:

    __shared__ int data[128];

那么,data[0] 是 bank 0data[1] 是 bank 1data[2] 是 bank 2data[15] 是 bank 15,而 data[16] 又回到 bank 0。由于 warp 在执行时是以 half-warp 的方式执行,因此分属于不同的 half warp 的 threads,不会造成 bank conflict

因此,如果程序在存取 shared memory 的时候,使用以下的方式:

    int number = data[base + tid];

那就不会有任何 bank conflict,可以达到最高的效率。但是,如果是以下的方式:

    int number = data[base + 4 * tid];

那么,thread 0 和 thread 4 就会存取到同一个 bankthread 1 和 thread 5 也是同样,这样就会造成 bank conflict。在这个例子中,一个 half warp 的 16 个 threads 会有四个 threads 存取同一个 bank,因此存取 share memory 的速度会变成原来的 1/4

一个重要的例外是,当多个 thread 存取到同一个 shared memory 的地址时,shared memory 可以将这个地址的 32 bits 数据「广播」到所有读取的 threads,因此不会造成 bank conflict。例如:

    int number = data[3];

这样不会造成 bank conflict,因为所有的 thread 都读取同一个地址的数据。

很多时候 shared memory 的 bank conflict 可以透过修改数据存放的方式来解决。例如,以下的程序:

    data[tid] = global_data[tid];
    ...
    int number = data[16 * tid];

会造成严重的 bank conflict,为了避免这个问题,可以把数据的排列方式稍加修改,把存取方式改成:

    int row = tid / 16;
    int column = tid % 16;
    data[row * 17 + column] = global_data[tid];
    ...
    int number = data[17 * tid];

这样就不会造成 bank conflict 了。

编者注:share memoryNVIDIA的文档中其实还有不同的叫法,例如PDCParallel Data Cache)、PBSMper-block share memory)。

Global memory

由于 multiprocessor 并没有对 global memory 做 cache(如果每个 multiprocessor 都有自己的 global memory cache,将会需要 cache coherence protocol,会大幅增加 cache 的复杂度),所以 global memory 存取的 latency 非常的长。除此之外,前面的文章中也提到过 global memory 的存取,要尽可能的连续。这是因为 DRAM 存取的特性所造成的结果。

更精确的说,global memory 的存取,需要是 "coalesced"。所谓的 coalesced,是表示除了连续之外,而且它开始的地址,必须是每个 thread 所存取的大小的 16 倍。例如,如果每个 thread 都读取 32 bits 的数据,那么第一个 thread 读取的地址,必须是 16*4 = 64 bytes 的倍数。

如果有一部份的 thread 没有读取内存,并不会影响到其它的 thread 速行 coalesced 的存取。例如:

    if(tid != 3) {
        int number = data[tid];
    }

虽然 thread 3 并没有读取数据,但是由于其它的 thread 仍符合 coalesced 的条件(假设 data 的地址是 64 bytes 的倍数),这样的内存读取仍会符合 coalesced 的条件。

在目前的 CUDA 1.1 装置中,每个 thread 一次读取的内存数据量,可以是 32 bits64 bits、或 128 bits。不过,32 bits 的效率是最好的。64 bits 的效率会稍差,而一次读取 128 bits 的效率则比一次读取 32 bits 要显著来得低(但仍比 non-coalesced 的存取要好)。

如果每个 thread 一次存取的数据并不是 32 bits64 bits、或 128 bits,那就无法符合 coalesced 的条件。例如,以下的程序:

    struct vec3d { float x, y, z; };
    ...
    __global__ void func(struct vec3d* data, float* output)
    {
        output[tid] = data[tid].x * data[tid].x +
            data[tid].y * data[tid].y +
            data[tid].z * data[tid].z;
    }

并不是 coalesced 的读取,因为 vec3d 的大小是 12 bytes,而非 4 bytes8 bytes、或 16 bytes。要解决这个问题,可以使用 __align(n)__ 的指示,例如:

    struct __align__(16) vec3d { float x, y, z; };

这会让 compiler 在 vec3d 后面加上一个空的 4 bytes,以补齐 16 bytes。另一个方法,是把数据结构转换成三个连续的数组,例如:

    __global__ void func(float* x, float* y, float* z, float* output)
    {
        output[tid] = x[tid] * x[tid] + y[tid] * y[tid] +
            z[tid] * z[tid];
    }

如果因为其它原因使数据结构无法这样调整,也可以考虑利用 shared memory 在 GPU 上做结构的调整。例如:

    __global__ void func(struct vec3d* data, float* output)
    {
        __shared__ float temp[THREAD_NUM * 3];
        const float* fdata = (float*) data;
        temp[tid] = fdata[tid];
        temp[tid + THREAD_NUM] = fdata[tid + THREAD_NUM];
        temp[tid + THREAD_NUM*2] = fdata[tid + THREAD_NUM*2];
        __syncthreads();
        output[tid] = temp[tid*3] * temp[tid*3] +
            temp[tid*3+1] * temp[tid*3+1] +
            temp[tid*3+2] * temp[tid*3+2];
    }

在上面的例子中,我们先用连续的方式,把数据从 global memory 读到 shared memory。由于 shared memory 不需要担心存取顺序(但要注意 bank conflict 问题,参照前一节),所以可以避开 non-coalesced 读取的问题。


Texture

CUDA 支援 texture。在 CUDA 的 kernel 程序中,可以利用显示芯片的 texture 单元,读取 texture 的数据。使用 texture 和 global memory 最大的差别在于 texture 只能读取,不能写入,而且显示芯片上有一定大小的 texture cache。因此,读取 texture 的时候,不需要符合 coalesced 的规则,也可以达到不错的效率。此外,读取 texture 时,也可以利用显示芯片中的 texture filtering 功能(例如 bilinear filtering),也可以快速转换数据型态,例如可以直接将 32 bits RGBA 的数据转换成四个 32 bits 浮点数。

显示芯片上的 texture cache 是针对一般绘图应用所设计,因此它仍最适合有区块性质的存取动作,而非随机的存取。因此,同一个 warp 中的各个 thread 最好是读取地址相近的数据,才能达到最高的效率。

对于已经能符合 coalesced 规则的数据,使用 global memory 通常会比使用 texture 要来得快。

运算单元

Stream processor 里的运算单元,基本上是一个浮点数的 fused multiply-add 单元,也就是说它可以进行一次乘法和一次加法,如下所示:

    a = b * c + d;

compiler 会自动把适当的加法和乘法运算,结合成一个 fmad 指令。

除了浮点数的加法及乘法之外,整数的加法、位运算、比较、取最小值、取最大值、及以型态的转换(浮点数转整数或整数转浮点数)都是可以全速进行的。 整数的乘法则无法全速进行,但 24 bits 的乘法则可以。在 CUDA 中可以利用内建的 __mul24 和 __umul24 函式来进行 24 bits 的整数乘法。

浮点数的除法是利用先取倒数,再相乘的方式计算,因此精确度并不能达到 IEEE 754 的规范(最大误差为 2 ulp)。内建的 __fdividef(x,y) 提供更快速的除法,和一般的除法有相同的精确度,但是在 2 216  < y < 2 218  时会得到错误的结果。

此外 CUDA 还提供了一些精确度较低的内部函数,包括 __expf__logf__sinf__cosf__powf 等等。这些函式的速度较快,但精确度不如标准的函式。详细的数据可以参考 CUDA Programming Guide 1.1 的 Appendix B

和主内存间的数据传输

在 CUDA 中,GPU 不能直接存取主内存,只能存取显卡上的显示内存。因此,会需要将数据从主内存先复制到显卡内存中,进行运算后,再将结果从显卡内存中复制到主内存中。这些 复制的动作会限于 PCI Express 的速度。使用 PCI Express x16 时,PCI Express 1.0 可以提供双向各 4GB/s 的带宽,而 PCI Express 2.0 则可提供 8GB/s 的带宽。当然这都是理论值。

从一般的内存复制数据到显卡内存的时候,由于一般的内存可能随时会被操作系统搬动,因此 CUDA 会先将数据复制到一块内部的内存中,才能利用 DMA 将数据复制到显卡内存中。如果想要避免这个重复的复制动作,可以使用 cudaMallocHost 函式,在主内存中取得一块 page locked 的内存。不过,如果要求太大量的 page locked 的内存,将会影响到操作系统对内存的管理,可能会减低系统的效率。