CUDA动态并行性,性能差

7
我们在使用CUDA动态并行性时遇到了性能问题。目前,CDP的性能至少比传统方法慢3倍。
我们编写了最简单的可重现代码来展示这个问题,即将数组中所有元素的值增加1。例如:
a[0,0,0,0,0,0,0,.....,0] --> kernel +1 --> a[1,1,1,1,1,1,1,1,1]

这个简单示例的目的只是为了看看CDP是否能像其他工具一样执行,或者是否存在严重的开销。

代码在这里:

#include <stdio.h>
#include <cuda.h>
#define BLOCKSIZE 512

__global__ void kernel_parent(int *a, int n, int N);
__global__ void kernel_simple(int *a, int n, int N, int offset);


// N is the total array size
// n is the worksize for a kernel (one third of N)
__global__ void kernel_parent(int *a, int n, int N){
    cudaStream_t s1, s2;
    cudaStreamCreateWithFlags(&s1, cudaStreamNonBlocking);
    cudaStreamCreateWithFlags(&s2, cudaStreamNonBlocking);

    int tid = blockIdx.x * blockDim.x + threadIdx.x;
    if(tid == 0){
        dim3 block(BLOCKSIZE, 1, 1);
        dim3 grid( (n + BLOCKSIZE - 1)/BLOCKSIZE, 1, 1);

        kernel_simple<<< grid, block, 0, s1 >>> (a, n, N, n);
        kernel_simple<<< grid, block, 0, s2 >>> (a, n, N, 2*n);
    }

    a[tid] += 1;
}


__global__ void kernel_simple(int *a, int n, int N, int offset){
    int tid = blockIdx.x * blockDim.x + threadIdx.x;
    int pos = tid + offset;
    if(pos < N){
        a[pos] += 1;
    }
}

int main(int argc, char **argv){
    if(argc != 3){
        fprintf(stderr, "run as ./prog n method\nn multiple of 32 eg: 1024, 1048576 (1024^2), 4194304 (2048^2), 16777216 (4096^2)\nmethod:\n0 (traditional)  \n1 (dynamic parallelism)\n2 (three kernels using unique streams)\n");
        exit(EXIT_FAILURE);
    }
    int N = atoi(argv[1])*3;
    int method = atoi(argv[2]);
    // init array as 0
    int *ah, *ad;
    printf("genarray of 3*N = %i.......", N); fflush(stdout);
    ah = (int*)malloc(sizeof(int)*N);
    for(int i=0; i<N; ++i){
        ah[i] = 0;
    }
    printf("done\n"); fflush(stdout);

    // malloc and copy array to gpu
    printf("cudaMemcpy:Host->Device..........", N); fflush(stdout);
    cudaMalloc(&ad, sizeof(int)*N);
    cudaMemcpy(ad, ah, sizeof(int)*N, cudaMemcpyHostToDevice);
    printf("done\n"); fflush(stdout);

    // kernel launch (timed)
    cudaStream_t s1, s2, s3;
    cudaStreamCreateWithFlags(&s1, cudaStreamNonBlocking);
    cudaStreamCreateWithFlags(&s2, cudaStreamNonBlocking);
    cudaStreamCreateWithFlags(&s3, cudaStreamNonBlocking);
    cudaEvent_t start, stop;
    float rtime = 0.0f;
    cudaEventCreate(&start); 
    cudaEventCreate(&stop);
    printf("Kernel...........................", N); fflush(stdout);
    if(method == 0){
        // CLASSIC KERNEL LAUNCH
        dim3 block(BLOCKSIZE, 1, 1);
        dim3 grid( (N + BLOCKSIZE - 1)/BLOCKSIZE, 1, 1);
        cudaEventRecord(start, 0);
        kernel_simple<<< grid, block >>> (ad, N, N, 0);
        cudaDeviceSynchronize();
        cudaEventRecord(stop, 0);
    }
    else if(method == 1){
        // DYNAMIC PARALLELISM
        dim3 block(BLOCKSIZE, 1, 1);
        dim3 grid( (N/3 + BLOCKSIZE - 1)/BLOCKSIZE, 1, 1);
        cudaEventRecord(start, 0);
        kernel_parent<<< grid, block, 0, s1 >>> (ad, N/3, N);
        cudaDeviceSynchronize();
        cudaEventRecord(stop, 0);
    }
    else{
        // THREE CONCURRENT KERNEL LAUNCHES USING STREAMS
        dim3 block(BLOCKSIZE, 1, 1);
        dim3 grid( (N/3 + BLOCKSIZE - 1)/BLOCKSIZE, 1, 1);
        cudaEventRecord(start, 0);
        kernel_simple<<< grid, block, 0, s1 >>> (ad, N/3, N, 0);
        kernel_simple<<< grid, block, 0, s2 >>> (ad, N/3, N, N/3);
        kernel_simple<<< grid, block, 0, s3 >>> (ad, N/3, N, 2*(N/3));
        cudaDeviceSynchronize();
        cudaEventRecord(stop, 0);
    }
    printf("done\n"); fflush(stdout);


    printf("cudaMemcpy:Device->Host..........", N); fflush(stdout);
    cudaMemcpy(ah, ad, sizeof(int)*N, cudaMemcpyDeviceToHost);
    printf("done\n"); fflush(stdout);

    printf("checking result.................."); fflush(stdout);
    for(int i=0; i<N; ++i){
        if(ah[i] != 1){
            fprintf(stderr, "bad element: a[%i] = %i\n", i, ah[i]);
            exit(EXIT_FAILURE);
        }
    }
    printf("done\n"); fflush(stdout);
    cudaEventSynchronize(stop);
    cudaEventElapsedTime(&rtime, start, stop);
    printf("rtime: %f ms\n", rtime); fflush(stdout);
    return EXIT_SUCCESS;
}

可以编译使用

nvcc -arch=sm_35 -rdc=true -lineinfo -lcudadevrt -use_fast_math main.cu -o prog

这个例子可以使用三种方法计算结果:

  1. 简单的核心:只有一个传统的核心+在数组上进行一次 +1 操作。
  2. 动态并行性:从 main() 中调用一个父核心,在范围 [0,N/3) 上进行 +1 操作,并调用两个子核心。第一个子核心在范围 [N/3, 2*N/3) 上进行 +1 操作,第二个子核心在范围 [2*N/3,N) 上进行 +1 操作。子核心使用不同的流启动,因此它们可以并发执行。
  3. 来自主机的三个流:这个方法只是从 main() 启动三个非阻塞流,每个流都处理数组的三分之一。

我得到了以下关于方法 0(简单核心)的概要: Simple Kernel 以下为方法 1(动态并行性)的概要: Dynamic Parallelism 以下为方法 2(来自主机的三个流)的概要: enter image description here 运行时间如下:

➜  simple-cdp git:(master) ✗ ./prog 16777216 0
genarray of 3*N = 50331648.......done
cudaMemcpy:Host->Device..........done
Kernel...........................done
cudaMemcpy:Device->Host..........done
checking result..................done
rtime: 1.140928 ms
➜  simple-cdp git:(master) ✗ ./prog 16777216 1
genarray of 3*N = 50331648.......done
cudaMemcpy:Host->Device..........done
Kernel...........................done
cudaMemcpy:Device->Host..........done
checking result..................done
rtime: 5.790048 ms
➜  simple-cdp git:(master) ✗ ./prog 16777216 2
genarray of 3*N = 50331648.......done
cudaMemcpy:Host->Device..........done
Kernel...........................done
cudaMemcpy:Device->Host..........done
checking result..................done
rtime: 1.011936 ms

从图片中可以看出,在动态并行方法中,父内核在两个子内核完成后花费了过多的时间才关闭,这是导致执行时间增加3倍或4倍的原因。即使考虑最坏情况,如果所有三个内核(父内核和两个子内核)按顺序运行,所需时间也应少得多。即每个内核有N/3的工作量,整个父内核应该只需要大约3个子内核的时间,这要少得多。是否有办法解决这个问题?
编辑:罗伯特·克罗维拉在评论中已经解释了子内核的串行现象,以及方法2的情况(非常感谢)。事实上,内核确实按顺序运行,并不会无效地描述粗体文本中的问题(至少暂时不会)。

2
关于序列化,序列化是由于内核的大小而产生的。完全占用GPU的内核启动将完全占用GPU并防止后续内核占用GPU。实际上,在实践中很难观察到并发内核执行。研究相关的CUDA示例代码,你会发现该内核被精心设计以尽可能地利用GPU资源来实现并发。如果你想看到内核并发,请运行CUDA示例代码,并学习如何设计类似的代码。 - Robert Crovella
1
我明白了。对于这个例子,设计几乎没有任何意义,但我理解了你所解释的内容,因此我不认为这是一个问题,因为GPU正在全速运行或接近全速运行。在我的真实例子中,递归会像二叉树一样继续进行,因此最终我会产生可以从并发中受益的小内核。 然后我将不得不专注于第二个问题,这是导致3倍或更多减速的原因。它可能与同样的原因有关,即父进程占用了GPU吗?但是所有三个工作的N/3都是相同的,因此在工作方面没有理由花费更多的时间。 - Cristobal Navarro
2
我并不是在暗示我理解动态并行性报告如何延长父内核的持续时间那么长。目前我无法解释这一点。串行化对我来说并不令人惊讶,但从我的角度来看,它似乎是两个问题中较小的一个。换句话说,我同意。但我还没有调查过另一个问题。第一步是尝试复现它,并且也要稍微研究一下你的代码。 - Robert Crovella
1
谢谢,如果您有机会复现代码并回复您的发现,那将非常有用。 - Cristobal Navarro
1
似乎是 "cudaStreamCreateWithFlags(...)" 函数产生了额外的时间。根本不使用流(也不创建)启动时,CDP 的运行速度在非分析时间上实际上就像其他运行速度一样快。这样做的坏处是我们消除了任何并发内核的机会,这对于最终递归算法来说是一个不好的打击,因为它在某些时候会产生小内核。现在,如果我们从不同的线程块中启动内核,如果一个内核的利用率很低,那么是否允许并发的机会呢? - Cristobal Navarro
2个回答

6
调用设备运行时的操作是“昂贵”的,就像调用主机运行时的操作一样昂贵。在这种情况下,似乎您正在调用设备运行时为每个线程创建流,尽管此代码仅要求为线程0创建流。
通过修改您的代码,仅为线程0请求流的创建,我们可以在使用单独的流进行子内核启动和不使用单独的流进行子内核启动之间产生时间上的平衡。
$ cat t370.cu
#include <stdio.h>
#define BLOCKSIZE 512

__global__ void kernel_parent(int *a, int n, int N);
__global__ void kernel_simple(int *a, int n, int N, int offset);


// N is the total array size
// n is the worksize for a kernel (one third of N)
__global__ void kernel_parent(int *a, int n, int N){
    int tid = blockIdx.x * blockDim.x + threadIdx.x;
    if(tid == 0){
        dim3 block(BLOCKSIZE, 1, 1);
        dim3 grid( (n + BLOCKSIZE - 1)/BLOCKSIZE, 1, 1);
#ifdef USE_STREAMS
        cudaStream_t s1, s2;
        cudaStreamCreateWithFlags(&s1, cudaStreamNonBlocking);
        cudaStreamCreateWithFlags(&s2, cudaStreamNonBlocking);
        kernel_simple<<< grid, block, 0, s1 >>> (a, n, N, n);
        kernel_simple<<< grid, block, 0, s2 >>> (a, n, N, 2*n);
#else
        kernel_simple<<< grid, block >>> (a, n, N, n);
        kernel_simple<<< grid, block >>> (a, n, N, 2*n);
#endif
// these next 2 lines add noticeably to the overall timing
        cudaError_t err = cudaGetLastError();
        if (err != cudaSuccess) printf("oops1: %d\n", (int)err);
    }

    a[tid] += 1;
}


__global__ void kernel_simple(int *a, int n, int N, int offset){
    int tid = blockIdx.x * blockDim.x + threadIdx.x;
    int pos = tid + offset;
    if(pos < N){
        a[pos] += 1;
    }
}

int main(int argc, char **argv){
    if(argc != 3){
        fprintf(stderr, "run as ./prog n method\nn multiple of 32 eg: 1024, 1048576 (1024^2), 4194304 (2048^2), 16777216 (4096^2)\nmethod:\n0 (traditional)  \n1 (dynamic parallelism)\n2 (three kernels using unique streams)\n");
        exit(EXIT_FAILURE);
    }
    int N = atoi(argv[1])*3;
    int method = atoi(argv[2]);
    // init array as 0
    int *ah, *ad;
    printf("genarray of 3*N = %i.......", N); fflush(stdout);
    ah = (int*)malloc(sizeof(int)*N);
    for(int i=0; i<N; ++i){
        ah[i] = 0;
    }
    printf("done\n"); fflush(stdout);

    // malloc and copy array to gpu
    printf("cudaMemcpy:Host->Device..........", N); fflush(stdout);
    cudaMalloc(&ad, sizeof(int)*N);
    cudaMemcpy(ad, ah, sizeof(int)*N, cudaMemcpyHostToDevice);
    printf("done\n"); fflush(stdout);

    // kernel launch (timed)
    cudaStream_t s1, s2, s3;
    cudaStreamCreateWithFlags(&s1, cudaStreamNonBlocking);
    cudaStreamCreateWithFlags(&s2, cudaStreamNonBlocking);
    cudaStreamCreateWithFlags(&s3, cudaStreamNonBlocking);
    cudaEvent_t start, stop;
    float rtime = 0.0f;
    cudaEventCreate(&start);
    cudaEventCreate(&stop);
    printf("Kernel...........................", N); fflush(stdout);
    if(method == 0){
        // CLASSIC KERNEL LAUNCH
        dim3 block(BLOCKSIZE, 1, 1);
        dim3 grid( (N + BLOCKSIZE - 1)/BLOCKSIZE, 1, 1);
        cudaEventRecord(start, 0);
        kernel_simple<<< grid, block >>> (ad, N, N, 0);
        cudaDeviceSynchronize();
        cudaEventRecord(stop, 0);
    }
    else if(method == 1){
        // DYNAMIC PARALLELISM
        dim3 block(BLOCKSIZE, 1, 1);
        dim3 grid( (N/3 + BLOCKSIZE - 1)/BLOCKSIZE, 1, 1);
        cudaEventRecord(start, 0);
        kernel_parent<<< grid, block, 0, s1 >>> (ad, N/3, N);
        cudaDeviceSynchronize();
        cudaEventRecord(stop, 0);
    }
    else{
        // THREE CONCURRENT KERNEL LAUNCHES USING STREAMS
        dim3 block(BLOCKSIZE, 1, 1);
        dim3 grid( (N/3 + BLOCKSIZE - 1)/BLOCKSIZE, 1, 1);
        cudaEventRecord(start, 0);
        kernel_simple<<< grid, block, 0, s1 >>> (ad, N/3, N, 0);
        kernel_simple<<< grid, block, 0, s2 >>> (ad, N/3, N, N/3);
        kernel_simple<<< grid, block, 0, s3 >>> (ad, N/3, N, 2*(N/3));
        cudaDeviceSynchronize();
        cudaEventRecord(stop, 0);
    }
    printf("done\n"); fflush(stdout);


    printf("cudaMemcpy:Device->Host..........", N); fflush(stdout);
    cudaMemcpy(ah, ad, sizeof(int)*N, cudaMemcpyDeviceToHost);
    printf("done\n"); fflush(stdout);

    printf("checking result.................."); fflush(stdout);
    for(int i=0; i<N; ++i){
        if(ah[i] != 1){
            fprintf(stderr, "bad element: a[%i] = %i\n", i, ah[i]);
            exit(EXIT_FAILURE);
        }
    }
    printf("done\n"); fflush(stdout);
    cudaEventSynchronize(stop);
    cudaEventElapsedTime(&rtime, start, stop);
    printf("rtime: %f ms\n", rtime); fflush(stdout);
    return EXIT_SUCCESS;
}
$ nvcc -arch=sm_52 -rdc=true -lcudadevrt -o t370 t370.cu
$ ./t370 16777216 1
genarray of 3*N = 50331648.......done
cudaMemcpy:Host->Device..........done
Kernel...........................done
cudaMemcpy:Device->Host..........done
checking result..................done
rtime: 6.925632 ms
$ nvcc -arch=sm_52 -rdc=true -lcudadevrt -o t370 t370.cu -DUSE_STREAMS
$ ./t370 16777216 1
genarray of 3*N = 50331648.......done
cudaMemcpy:Host->Device..........done
Kernel...........................done
cudaMemcpy:Device->Host..........done
checking result..................done
rtime: 6.673568 ms
$

尽管上面的测试输出中没有包含此项,但根据我的测试,这也使得CUDA动态并行性(CDP)情况(1)与非CDP情况(02)之间达到了“近似平衡”。请注意,我们可以通过放弃在父内核中调用 cudaGetLastError()(我已将其添加到您的代码中)来缩短上述时间约1毫秒(!)。

1
非常感谢。时间方面,为了与其他方法竞争,我们去掉了错误检查以获得更快的时间。现在,我们应该将这3.18毫秒(CDP)与2.15毫秒(其他)的时间差异视为CDP开销的一部分吗? - Cristobal Navarro
当我在使用CUDA 9 EA或CUDA 7.5时,在GTX 960上进行测试时,当我删除了我提到的额外错误检查时,三种情况之间的时间差异比你所指示的要小。我看到0:4.8毫秒,1:5.3毫秒,2:4.7毫秒(CUDA 7.5和CUDA 9性能之间没有区别)。在带有Pascal Titan X的CUDA 8上,我看到0:1.08毫秒,1:1.35毫秒,2:1.08毫秒。是的,我希望子内核启动会有一些开销,并且对于像这样微不足道简单的问题,明显更有效地使用CDP。(我所有的测试都在Linux上进行。) - Robert Crovella
1
我明白了。关于这个例子,是的,它太简单了。我会制作一个递归的CDP版本来解决这个问题,看看是否有太多的开销,但这超出了这个问题的范围。在那种情况下,我会再做一个。再次感谢。 - Cristobal Navarro
我注意到,对于一个小的计数值,开销非常大(在我的P5000卡上为100微秒对26微秒)。 - Andrei Pokrovsky

2
#include <stdio.h>
#include <thrust/host_vector.h>
#include <thrust/device_vector.h>

using thrust::host_vector;
using thrust::device_vector;

#define BLOCKSIZE 512

__global__ void child(int* a)
{
    if (threadIdx.x == 0 && blockIdx.x == 0)
        a[0]++;
}

__global__ void parent(int* a)
{
    if (threadIdx.x == 0 && blockIdx.x == 0)
        child<<<gridDim, blockDim>>>(a);
}

#define NBLOCKS 1024
#define NTHREADS 1024
#define BENCHCOUNT 1000

template<typename Lambda>
void runBench(Lambda arg, int* rp, const char* name)
{
    // "preheat" the GPU
    for (int i = 0; i < 100; i++)
        child<<<dim3(NBLOCKS,1,1), dim3(NTHREADS,1,1)>>>(rp);

    cudaEvent_t start, stop;
    float rtime = 0.0f;
    cudaEventCreate(&start);
    cudaEventCreate(&stop);

    cudaEventRecord(start, 0);
    for (int i = 0; i < BENCHCOUNT; i++)
        arg();
    cudaEventRecord(stop, 0);
    cudaEventSynchronize(stop);
    cudaEventElapsedTime(&rtime, start, stop);

    printf("=== %s ===\n", name);
    printf("time: %f ms\n", rtime/BENCHCOUNT); fflush(stdout);
    cudaEventDestroy(start);
    cudaEventDestroy(stop);
    cudaDeviceSynchronize();
}

int main(int argc, char **argv)
{
    host_vector<int> hv(1);
    hv[0] = 0xAABBCCDD;
    device_vector<int> dv(1);
    dv = hv;
    int* rp = thrust::raw_pointer_cast(&dv[0]);

    auto benchFun = [&](void) {
        child<<<dim3(NBLOCKS,1,1), dim3(NTHREADS,1,1)>>>(rp); };
    runBench(benchFun, rp, "Single kernel launch");

    auto benchFun2 = [&](void) {
        for (int j = 0; j < 2; j++)
            child<<<dim3(NBLOCKS,1,1), dim3(NTHREADS,1,1)>>>(rp);
    };
    runBench(benchFun2, rp, "2x sequential kernel launch");

    auto benchFunDP = [&](void) {
        parent<<<dim3(NBLOCKS,1,1), dim3(NTHREADS,1,1)>>>(rp); };
    runBench(benchFunDP, rp, "Nested kernel launch");
}

构建/运行:

  • 将上面的代码复制/粘贴到dpar.cu中
  • nvcc -arch=sm_52 -rdc=true -std=c++11 -lcudadevrt -o dpar dpar.cu
  • ./dpar

在我的p5000笔记本电脑上,它会打印出:

=== 单内核启动 ===
时间: 0.014297 毫秒
=== 2x 顺序内核启动 ===
时间: 0.030468 毫秒
=== 嵌套内核启动 ===
时间: 0.083820 毫秒

因此,开销相当大...看起来在我的情况下为43微秒。


谢谢。看起来DP只有在与一批内核调用进行比较时才具有竞争力,而不是与一个内核调用进行比较。 - Cristobal Navarro
尝试使用Titan X(Pascal但不是Xp)进行测试,结果如下: === 单内核启动 === 时间:0.007592毫秒 === 2倍顺序内核启动 === 时间:0.016331毫秒 === 嵌套内核启动 === 时间:0.047563毫秒 - Cristobal Navarro

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接