第三节:cudaMemcpyAsync与流——异步拷贝的核心机制

各位同学,今天我们来聊聊CUDA编程里一个非常关键的话题——异步拷贝。说白了,就是让数据传输和计算同时进行,别让GPU闲着等数据。

我记得刚接触CUDA那会儿,写了个简单的向量加法,数据拷贝花了大部分时间,计算反而一眨眼就完了。当时我就想:这效率也太低了吧?后来才明白,原来有cudaMemcpyAsync这个好东西。

3.1 为什么需要异步拷贝?

先看一个典型的CUDA程序流程:

cudaMemcpy(d_data, h_data, size, cudaMemcpyHostToDevice);  // 拷贝到GPU
kernel<<<grid, block>>>(d_data);                          // 执行计算
cudaMemcpy(h_result, d_data, size, cudaMemcpyDeviceToHost); // 拷回结果

这段代码的问题在哪?每一步都是同步的。CPU在调用cudaMemcpy时,会一直等到数据传输完成才返回。你想想看,这期间CPU啥也干不了,GPU也在等数据到位才能开始算。

说白了,这就是串行执行——浪费了宝贵的并行能力。

核心思想:异步拷贝允许CPU在数据传输期间继续执行其他任务,同时让GPU在数据到达后立即开始计算,实现数据传输与内核计算的重叠。

3.2 CUDA流——异步操作的容器

要实现异步,我们需要一个东西来管理这些并发的操作。这就是CUDA流(Stream)。

你可以把流想象成一条流水线。每个流里的操作是顺序执行的,但不同流之间的操作可以并发执行。

cudaStream_t stream1, stream2;
cudaStreamCreate(&stream1);
cudaStreamCreate(&stream2);

// 在stream1中异步拷贝
cudaMemcpyAsync(d_data1, h_data1, size, cudaMemcpyHostToDevice, stream1);
// 在stream2中异步拷贝
cudaMemcpyAsync(d_data2, h_data2, size, cudaMemcpyHostToDevice, stream2);

// 在stream1中执行内核
kernel1<<<grid, block, 0, stream1>>>(d_data1);
// 在stream2中执行内核
kernel2<<<grid, block, 0, stream2>>>(d_data2);

这里有个细节要注意:cudaMemcpyAsync必须配合固定内存(pinned memory)使用。我在项目中遇到过,有人直接用普通malloc分配的内存传给cudaMemcpyAsync,结果程序直接报错。嗯,这个坑我踩过。

重要提醒:cudaMemcpyAsync要求源地址或目标地址必须是固定内存(通过cudaHostAlloc或cudaMallocHost分配)。否则,数据传输会退化为同步操作,异步效果就没了。

3.3 数据传输与计算的重叠模型

现在我们来画一张图,看看异步拷贝到底怎么工作的。

异步拷贝与计算重叠示意图 时间→ 同步模式 H2D拷贝 内核计算 D2H拷贝 异步模式 Stream 0 H2D拷贝 内核计算 D2H拷贝 Stream 1 H2D拷贝 内核计算 D2H拷贝 ← 重叠区域 → 说明:不同流中的操作可以同时执行,实现数据传输与计算的重叠

从这张图可以看得很清楚:同步模式下,拷贝和计算是串行的。异步模式下,不同流里的操作可以重叠执行,大大缩短了总时间。

3.4 实战:双缓冲技术

我个人最喜欢的一个技巧是双缓冲(Double Buffering)。说白了,就是准备两块缓冲区,一块在计算,另一块在传输数据,交替进行。

// 双缓冲示例
cudaStream_t stream[2];
cudaStreamCreate(&stream[0]);
cudaStreamCreate(&stream[1]);

float *d_data[2], *h_data[2];
// 分配固定内存和显存...

for (int i = 0; i < num_batches; i++) {
    int current = i % 2;
    int next = (i + 1) % 2;
    
    // 异步拷贝下一批数据
    if (i + 1 < num_batches) {
        cudaMemcpyAsync(d_data[next], h_data[next], 
                       batch_size, cudaMemcpyHostToDevice, stream[next]);
    }
    
    // 在当前流中执行内核
    kernel<<<grid, block, 0, stream[current]>>>(d_data[current]);
    
    // 异步拷回结果
    cudaMemcpyAsync(h_data[current], d_data[current],
                   batch_size, cudaMemcpyDeviceToHost, stream[current]);
}

我曾经在一个图像处理项目里用这个技巧,把处理时间从原来的5秒降到了2.8秒。效果立竿见影。

3.5 流的同步与事件

异步操作多了,怎么知道什么时候完成?这就需要事件(Event)来帮忙了。

cudaEvent_t start, stop;
cudaEventCreate(&start);
cudaEventCreate(&stop);

cudaEventRecord(start, stream1);
// ... 执行一些操作 ...
cudaEventRecord(stop, stream1);

// 等待事件完成
cudaEventSynchronize(stop);

float elapsed;
cudaEventElapsedTime(&elapsed, start, stop);
printf("耗时: %.2f ms\n", elapsed);

小技巧:用cudaEventQuery可以非阻塞地检查事件是否完成,返回cudaSuccess表示已完成,cudaErrorNotReady表示还在进行中。这在做进度条或者状态监控时特别有用。

3.6 避坑指南

最后,分享几个我踩过的坑:

  • 固定内存分配失败:cudaHostAlloc分配固定内存时,如果系统内存不足会失败。我曾经在内存紧张的服务器上吃过这个亏,后来改用cudaHostAllocPortable标志,效果好了很多。
  • 流同步遗漏:在程序结束前,一定要用cudaStreamSynchronize等待所有流完成。否则程序退出时,异步操作可能还没执行完。
  • 过度使用流:不是流越多越好。GPU的并发能力有限,一般2-4个流就够用了。我曾经试过创建16个流,结果性能反而下降了。
  • 内存拷贝方向:cudaMemcpyAsync的最后一个参数是流,别漏了。我见过有人写cudaMemcpyAsync(d, h, size, cudaMemcpyHostToDevice)——少了流参数,编译能过,但实际是同步的。

好了,关于cudaMemcpyAsync和流的内容就讲到这里。记住,异步拷贝的核心就是让GPU别闲着,数据一边传,计算一边跑。这个思想在很多高性能计算场景里都适用。

核心要点回顾:

  • cudaMemcpyAsync实现异步数据传输,需要固定内存支持
  • CUDA流管理并发操作,不同流之间可以重叠执行
  • 双缓冲技术是异步拷贝的经典应用模式
  • 事件(Event)用于同步和计时
  • 合理控制流数量,避免过度并行

可可精品资料库