【问题标题】:How to write an efficient CUDA program with two stages如何编写具有两个阶段的高效 CUDA 程序
【发布时间】:2017-04-11 20:29:23
【问题描述】:

我想编写一个轻量级的 PIC(Particle-in-cell)程序。我所说的“轻量级”是指它不需要扩大规模:假设所有数据都可以放入单个 GPU 设备的内存和主机系统的内存中。但是我希望它尽可能快。

问题是,PIC的典型结构是两个阶段的交互:场求解器和粒子推进器。工作流程是这样的: 初始化系统 -> 推动粒子 -> 求解场 -> 推动粒子 -> 求解场... -> 输出

下一个推动粒子或求解场必须等到前一个求解场或推动粒子完成。可能需要数百万次迭代才能获得最终输出。

作为测试,省略场求解器,粒子推进器可以写成:

 __device__
 void push(Particle &par) {
   // some routines to move a particle. same excecutiong time for every particle.
 }

并像这样使用 kernel_1 来执行它:

__global__
void kernel_1(int n, Particle* parlist)
{
  int i = blockIdx.x*blockDim.x + threadIdx.x;
  if (i < n) {
      push(parlist[i]);
  }
}

在主循环中

for (int i=0;i<M;i++) {
    kernel_1<<<(n+255)/256, 256>>>(n, parlist);
}

M 是所需的迭代次数。但是,性能非常缓慢:在我的八核 Intel E5-2640 v3 和 Nvidia Quadro m4000 系统上,CUDA 提供与使用 openmp 的纯 CPU 版本类似的性能。对于 10,000,000 且 M=1000 的粒子数,大约需要 10 秒。

但是,如果我将循环移入内核:

void kernel_2(int n, Particle* parlist)
{
  int i = blockIdx.x*blockDim.x + threadIdx.x;
  if (i < n) {
      for (int i=0;i<M;i++) {
          push(parlist[i]);
      }
  }
}

kernel_2<<<(n+255)/256, 256>>>(n, parlist);

同样的M=1000,只需要100ms,就是10000%的加速。我已经验证了两种情况的结果相同且正确。可能内核运行M次调用成本太高了。

将循环移入内核所带来的性能提升是如此令人难以置信,但却是真实的。对于第一种情况,添加字段求解器很容易:只需编写一个新内核并在主循环中顺序执行两个内核。但是性能应该是中等的。

我发现很难将场解算器例程添加到第二种情况:在没有多次调用内核的情况下,块之间似乎没有同步机制,但是场解算器必须等到所有粒子都被推送,这必须分配到不同的块(因为粒子的数量非常多)。

那么是否可以在一个内核中实现两阶段迭代?性能提升太多不容忽视。

编辑: 我发现性能差异非常令人困惑:100ms 和 10s 的差异只是一行代码甚至是循环序列。我已将 push() 修改为更复杂一点(2d Boris pusher):

class Particle
{
public:
    float x, y;        //m
    float vx, vy;      //m/s
    float m;           //kg
    float q;           //ee
};

__device__
void run(Particle& par, float B)
{
    float t, s, vpx, vpy;
    t = (par.q*ee*B/par.m)*dt/2;
    s = 2*t/(1+t*t);
    vpx = par.vx+t*par.vy;
    vpy = par.vy-t*par.vx;
    par.vx += s*vpy;
    par.vy -= s*vpx;
    par.x += par.vx*dt;
    par.y += par.vy*dt;
}

我为 Particle 创建了 1 个 n 元素数组,为 B 创建了 1 个 n 元素浮点数组。它们是在主机和 cudaMemcpy 到设备上创建的。然后我检查了以下三个内核的性能:

__global__
void kernel_A(int n, int m, Particle* parlist, float* Blist)
{
    int i = blockIdx.x*blockDim.x + threadIdx.x;
    int j;
    if (i<n) {
        for (j=0;j<m;j++) {
            run(parlist[i], Blist[i]);
        }
    }
}

__global__
void kernel_B(int n, int m, Particle* parlist, float* Blist)
{
    int i = blockIdx.x*blockDim.x + threadIdx.x;
    int j;
    float B;
    if (i<n) {
        B = Blist[i];
        for (j=0;j<m;j++) {
            run(parlist[i], B);
        }
    }
}

__global__
void kernel_C(int n, int m, Particle* parlist, float* Blist)
{
    int i = blockIdx.x*blockDim.x + threadIdx.x;
    int j;
    float B;
    if (i<n) {
        B = Blist[i];
        for (j=0;j<m;j++) {
            run(parlist[i], B);
            __syncthreads();
        }
    }
}

__global__    
void kernel_D(int n, int m, Particle* parlist, float* Blist)
{
    int i = blockIdx.x*blockDim.x + threadIdx.x;
    int j;
    float B;
    if (i<n) {
        B = Blist[i];
    }
    for (j=0;j<m;j++) {
        if (i<n) {
            run(parlist[i], B);
        }
    }
}

__global__
void kernel_E(int n, int m, Particle* parlist, float* Blist)
{
    int i = blockIdx.x*blockDim.x + threadIdx.x;
    int j;
    float B;
    if (i<n) {
        for (j=0;j<m;j++) {
            run(parlist[i], Blist[i]);
            __syncthreads();
        }
    }
}

而且运行时间完全不同。对于 n=10,000,000 和 m=1000:

  • 内核_A:7.6s
  • 内核_B:66ms
  • 内核_C:9.9s
  • 内核_D:10.0s
  • 内核_E:10.0s

三个内核的结果都是一样的,都是正确的(检查CPU版本)。

我从官方 CUDA 编程指南了解到,分支很昂贵,因此 kernel_C 应该比 kernel_B 慢,尽管我怀疑差异是两个数量级。我不明白为什么 kernel_B 的性能比 kernel_A 好得多。 Kernel_B 不必访问 Blist 1000 次,而 kernel_A 则需要,但是它们都需要访问 parlist 1000 次,对吗?为什么访问 Blist 这么慢?

Kernel_A、kernel_D 和 kernel_E 有相似的性能,这让我更加困惑:所以与 kernel_B 相比,额外的时间花在访问 Blist 或同步上?

我想在我的 PIC 程序中实现 kernel_B 的性能。

【问题讨论】:

    标签: c++ parallel-processing cuda


    【解决方案1】:

    不,不可能在块之间进行同步。通常内核调用带来的开销并不重要。我可以想象,您的内核不够大,无法充分利用您的设备。如果你想检查这个,你可以使用 nvprof 来分析你的程序并寻找瓶颈。

    实现快速的 PIC 代码并不容易。您是否考虑过使用像 PIConGPU 这样的库?您可以在此链接下找到它:https://github.com/ComputationalRadiationPhysics/picongpu

    【讨论】:

    • nvvp 无法做到这一点,但您可以运行 nvprof 生成时间线,然后运行 ​​nvprof --analysis-metrics 生成指标信息。然后 Nvvp 可以使用这些信息,来显示瓶颈。甚至还有一个引导模式,程序会告诉你在哪里看。
    • 感谢您的回复。你知道如何在内核级别进行分析吗?我使用 nvvp 并且只能获得在我的内核上花费了多少时间,但是我想在内核执行期间查看在设备功能上花费了多少时间以及在内核的其他部分上花费了多少时间(例如 __syncthreads()) .我已经编辑了这篇文章,了解为什么 kernel_B 表现如此出色可能是我问题的关键。希望大家多多指教。
    • 至于picongpu,我已经检查过了,它似乎对我的需求来说太复杂了。我不需要设备间的数据交换等,我希望一个简单的共享内存模型可以带来更好的小规模探针性能。
    • docs.nvidia.com/cuda/profiler-users-guide/#axzz4e2AUG0L5 这是分析工具链的 cuda 编程指南的链接。读起来很长,但值得花时间。
    • 如果您有 cc5.2 或更新的 GPU,您可能想尝试 instruction-level profiling。您的 Quadro m4000 是 cc5.2 GPU。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2010-12-12
    • 2014-07-07
    • 2022-01-16
    • 1970-01-01
    • 1970-01-01
    • 2010-10-15
    • 1970-01-01
    相关资源
    最近更新 更多