各位好,欢迎来到今天的 C++ 高性能计算讲座。今天我们不聊那些花里胡哨的神经网络架构,也不聊怎么调参让 Loss 下降得更快。今天我们要聊的是“等待的艺术”。
在 AI 框架(比如 PyTorch 或者 TensorFlow 的底层)里,我们最讨厌什么?不是计算量大,也不是模型复杂,而是——等待。
具体来说,就是当你把数据从 CPU 的内存(RAM)搬运到 GPU 的显存(VRAM)时,GPU 就像个在那儿干瞪眼的大懒虫,啥也不干,等着数据送上门。这就像你点了一份外卖,骑手在送,你在等,外卖员在等,整个系统都在等。这时候,你的 GPU 就在烧显卡(哦不,是在空转),浪费着昂贵的电力和算力。
为了解决这个问题,我们要祭出今天的神器——CUDA Stream(流)以及异步调度。简单说,就是让 CPU 和 GPU 像两个配合默契的交响乐团,CPU 在拉小提琴(搬运数据),GPU 在敲大鼓(做矩阵乘法),互不干扰,甚至互相掩护。
废话少说,让我们直接进入代码和原理的泥潭里打个滚。
第一部分:同步地狱与“单线程”模式的悲哀
首先,我们来看看如果不使用异步流,代码是怎么写的。这通常是初学者最容易犯的错误,也是性能的噩梦。
假设我们要做一个简单的卷积操作。第一步,把数据从 Host 搬到 Device;第二步,启动 Kernel;第三步,把结果搬回来。
// 这就是“同步地狱”
void bad_example(float* d_input, float* d_output, int size) {
// 1. 同步拷贝:CPU 会被卡住,直到数据完全传输完毕
cudaMemcpy(d_input, h_input, size * sizeof(float), cudaMemcpyHostToDevice);
// 2. 同步启动:CPU 会被卡住,直到 Kernel 执行完毕
conv_kernel<<<grid, block>>>(d_input, d_output, size);
// 3. 同步拷贝:CPU 又被卡住
cudaMemcpy(h_output, d_output, size * sizeof(float), cudaMemcpyDeviceToHost);
}
在这段代码里,CPU 像个只会做一件事的机械臂。cudaMemcpy 花了 5ms,kernel 花了 10ms,cudaMemcpy 又花了 5ms。如果传输比计算慢,那这 5ms 的传输时间就是纯浪费。
这时候,聪明的你可能会想:“我能不能让 Kernel 在拷贝的时候就开始跑?” 很好,你有这个想法了。但在单 Stream 的情况下,这是不可能的。CUDA 默认只有一个“默认流”,它是同步的,你没法插队。
第二部分:Stream 是什么?一个排队的食堂
CUDA Stream,本质上就是一个指令队列。
你可以想象一下大学食堂打饭的场景:
- Stream A 是一个窗口,排在前面的人要打饭,后面的人必须等前面的人打完(或者拿到盘子)才能开始。
- Stream B 是另一个窗口,它有自己独立的队伍,Stream B 的操作不会影响 Stream A。
通过创建多个 Stream,我们可以把计算任务和传输任务拆分到不同的队列里。
cudaStream_t stream1, stream2;
// 创建两个流
cudaStreamCreate(&stream1);
cudaStreamCreate(&stream2);
// 在 Stream1 里做计算
conv_kernel<<<grid, block, 0, stream1>>>(d_input, d_output, size);
// 在 Stream2 里做另一个计算
matmul_kernel<<<grid, block, 0, stream2>>>(d_matrix, d_result, size);
注意代码中的 0, stream1。这是告诉 CUDA:“把这个 Kernel 放进 stream1 里,别用默认流,别挡别人的道。”
第三部分:掩盖——让 GPU 别闲着
现在我们有了流,但怎么让计算和传输重叠呢?这就需要用到异步拷贝。
cudaMemcpy 默认是同步的,它会阻塞 CPU。我们需要用 cudaMemcpyAsync。这个函数就像个快递员,他接了单(CPU 调用函数),转身就走了,去送快递了,不告诉你什么时候送到。CPU 继续去干别的事(比如调度别的流)。
核心思想: 当 CPU 正在把数据 A 搬到 GPU 时,GPU 可以同时用数据 B 在 Stream B 里计算。这就是掩盖。
让我们看一个简单的例子:
void overlap_example() {
// 准备两块数据:Batch 0 和 Batch 1
float* d_batch0, *d_batch1;
float* h_batch0, *h_batch1;
// ... 分配内存省略 ...
cudaStream_t stream_compute, stream_transfer;
// 创建流
cudaStreamCreate(&stream_compute);
cudaStreamCreate(&stream_transfer);
// --- 时刻 T0 ---
// 在 Stream 0 里启动计算(用上一批的数据)
conv_kernel<<<grid, block, 0, stream_compute>>>(d_batch0, d_output, size);
// --- 时刻 T1 ---
// 在 Stream 1 里启动传输(把新数据搬进来)
cudaMemcpyAsync(d_batch1, h_batch1, size * sizeof(float), cudaMemcpyHostToDevice, stream_transfer);
// --- 时刻 T2 ---
// 现在发生了什么?
// Stream 0 正在计算,Stream 1 正在搬运。
// 如果计算速度够快,或者传输速度够慢,它们就在并行工作!
// CPU 这会儿可以去干别的事,比如管理其他 GPU。
}
在这个例子里,如果你把 cudaMemcpyAsync 改成普通的 cudaMemcpy,那么 CPU 在 T1 时刻就会被卡住,直到数据搬完。而有了 Async,CPU 瞬间就释放了。
第四部分:事件——告诉 GPU “准备好再动”
但是,事情没那么简单。你可能会问:“如果 Stream 0 需要用到数据 B 怎么办?数据 B 还在 Stream 1 里传输呢。”
这时候,我们需要一个信号灯——Event(事件)。
cudaEvent_t 就像是一个里程碑。你可以让 GPU 在某个点(比如数据传输完)打个卡,然后 CPU 就可以检查这个打卡记录。
我们有两种操作:
cudaEventRecord(event, stream):在某个流里记录这个事件。cudaStreamWaitEvent(stream, event):在启动 Kernel 前,等待某个事件发生。
让我们重写上面的例子,加上依赖关系:
void dependency_example() {
float* d_input, *d_output;
float* h_input, *h_output;
cudaStream_t stream1, stream2;
cudaEvent_t data_ready;
// 初始化
cudaStreamCreate(&stream1);
cudaStreamCreate(&stream2);
cudaEventCreate(&data_ready);
// --- Phase 1: 准备数据 ---
// 在 Stream 2 里拷贝数据
cudaMemcpyAsync(d_input, h_input, size * sizeof(float), cudaMemcpyHostToDevice, stream2);
// 在 Stream 2 里记录事件:数据准备好了!
cudaEventRecord(data_ready, stream2);
// --- Phase 2: 计算逻辑 ---
// 在 Stream 1 里启动 Kernel
// 关键点:cudaStreamWaitEvent
// 告诉 Stream 1:“等一等!等 Stream 2 里的 data_ready 事件触发后再启动!”
conv_kernel<<<grid, block, 0, stream1>>>(
d_input,
d_output,
size
);
// 这里的 cudaStreamWaitEvent 会阻塞 Stream 1 的执行,
// 直到 Stream 2 里的 cudaMemcpyAsync 完成并打完卡。
}
这就像你在厨房做饭。你需要切菜(数据传输),切完菜后你挂个牌子“菜切好了”。然后你开火炒菜(Kernel 计算),在开火前,你会看一眼牌子:“哦,菜切好了,可以炒了。”
第五部分:实战演练——构建一个简易的 AI 层
光说不练假把式。我们来构建一个模拟的 C++ 类,叫 AsyncLayer。这个类模拟了一个神经网络层,它能够管理多个 CUDA Stream,自动处理计算与传输的重叠。
为了演示方便,我们假设这个层需要处理两个 Batch 的数据。我们用 RAII(资源获取即初始化)来管理 Stream 和 Event,避免内存泄漏。
#include <cuda_runtime.h>
#include <vector>
#include <iostream>
#include <memory>
class AsyncLayer {
public:
AsyncLayer(int batch_size, int element_count)
: batch_size_(batch_size), element_count_(element_count) {
// 创建两个流:一个专门干活,一个专门搬砖
cudaStreamCreate(&compute_stream_);
cudaStreamCreate(&transfer_stream_);
// 创建两个事件:一个标志数据A就绪,一个标志数据B就绪
cudaEventCreate(&event_a_ready_);
cudaEventCreate(&event_b_ready_);
}
~AsyncLayer() {
cudaStreamDestroy(compute_stream_);
cudaStreamDestroy(transfer_stream_);
cudaEventDestroy(event_a_ready_);
cudaEventDestroy(event_b_ready_);
}
// 核心功能:处理数据
void forward(float* h_input_batch_a, float* h_input_batch_b,
float* h_output_batch_a, float* h_output_batch_b) {
// 1. 启动传输 Batch A (在 transfer_stream_ 中)
cudaMemcpyAsync(d_input_a_.get(), h_input_batch_a,
element_count_ * sizeof(float),
cudaMemcpyHostToDevice, transfer_stream_);
// 2. 启动传输 Batch B (在 transfer_stream_ 中)
cudaMemcpyAsync(d_input_b_.get(), h_input_batch_b,
element_count_ * sizeof(float),
cudaMemcpyHostToDevice, transfer_stream_);
// 3. 在 transfer_stream_ 中记录事件
// 这意味着:当两个数据都成功搬进 GPU 后,才记录 event_a_ready_ 和 event_b_ready_
cudaEventRecord(event_a_ready_, transfer_stream_);
cudaEventRecord(event_b_ready_, transfer_stream_);
// 4. --- 关键重叠逻辑 ---
// 我们启动两个计算任务,分别等待各自的数据就绪。
// Kernel A:等待 Batch A 就绪,然后在 compute_stream_ 中计算
cudaStreamWaitEvent(compute_stream_, event_a_ready_, 0);
launch_kernel<<<grid, block, 0, compute_stream_>>>(d_input_a_.get(), d_output_a_.get(), element_count_);
// Kernel B:等待 Batch B 就绪,然后在 compute_stream_ 中计算
// 注意:这里虽然都在同一个 compute_stream_ 中,但因为有 WaitEvent,
// 它们会按顺序执行(先A后B),但与 transfer_stream_ 是并行的!
cudaStreamWaitEvent(compute_stream_, event_b_ready_, 0);
launch_kernel<<<grid, block, 0, compute_stream_>>>(d_input_b_.get(), d_output_b_.get(), element_count_);
// 5. 结果回传
// 在计算完成后,我们再异步把结果搬回来
cudaMemcpyAsync(h_output_batch_a, d_output_a_.get(),
element_count_ * sizeof(float),
cudaMemcpyDeviceToHost, compute_stream_);
cudaMemcpyAsync(h_output_batch_b, d_output_b_.get(),
element_count_ * sizeof(float),
cudaMemcpyDeviceToHost, compute_stream_);
}
private:
int batch_size_;
int element_count_;
// 模拟显存缓冲区
std::unique_ptr<float[]> d_input_a_;
std::unique_ptr<float[]> d_input_b_;
std::unique_ptr<float[]> d_output_a_;
std::unique_ptr<float[]> d_output_b_;
cudaStream_t compute_stream_;
cudaStream_t transfer_stream_;
cudaEvent_t event_a_ready_;
cudaEvent_t event_b_ready_;
};
这段代码展示了“乒乓缓冲”和“双流”的基本模型。在这个模型中:
- Transfer Stream 只管把数据搬进来,然后打完卡(Record Event)就走人。
- Compute Stream 只管计算,计算前先看一眼事件牌(Wait Event)。
- 两个流完全解耦,互不阻塞,实现了流水线作业。
第六部分:深度剖析——为什么这能提升性能?
你可能觉得,这不就是把任务拆开吗?有什么大不了的?
大极了。我们来看看时间轴。
场景一:单流同步
CPU: [传输 A (5ms)] [计算 A (10ms)] [传输 B (5ms)] [计算 B (10ms)]
GPU: [等待传输 A] [计算 A] [等待传输 B] [计算 B]
总耗时:30ms。GPU 在传输 B 的时候是闲着的。
场景二:双流异步
CPU: [传输 A (5ms)] [传输 B (5ms)] [计算 A (10ms)] [计算 B (10ms)]
GPU: [计算 A (10ms)] [计算 B (10ms)] <-- 这里发生了什么?
等等,这看起来 CPU 还是花了 20ms,GPU 花了 20ms。如果 PCIe 传输只有 5ms,那不就省了 5ms 吗?
是的,这就是所谓的 Latency Hiding(延迟掩盖)。
但是,如果计算是 10ms,传输是 5ms呢?
- 单流: CPU 花 15ms,GPU 花 15ms。没有重叠。
- 双流: CPU 花 10ms(传输完 B 后立即去管计算),GPU 花 15ms。
- CPU 在传输 B 的时候,其实可以去准备下一批数据,或者去调度其他 GPU。
- 更重要的是,在 GPU 端,当计算 A 开始后,如果数据 B 还在传输中,GPU 就不会停在那儿傻等,而是继续执行计算 A。一旦数据 B 到了,立刻开始计算 B。
这就是流水线的威力。只要计算足够“重”(耗时长于数据传输耗时),流就能让 GPU 一直满载运行。
第七部分:陷阱与坑——别被流坑了
虽然多流调度听起来很美好,但现实是残酷的。如果你不懂规则,流会让你写出的程序像瑞士奶酪一样全是洞。
1. 流阻塞
这是新手最容易遇到的坑。
如果你在 Stream 1 里调用了 cudaMemcpyAsync(在 CPU 端等待完成),那么 Stream 1 就会被阻塞。如果 Stream 2 依赖 Stream 1 的结果,那么 Stream 2 也会被阻塞。
代码反面教材:
// 错误示范
cudaMemcpyAsync(d_data, h_data, size, cudaMemcpyHostToDevice, stream1); // 这里的 cudaMemcpyAsync 在 CPU 端会阻塞吗?
// 答案是:如果参数是 cudaMemcpyHostToDevice,它默认是同步的!
// 所以 stream1 被卡住了。
// 正确做法
cudaMemcpyAsync(d_data, h_data, size, cudaMemcpyHostToDevice, stream1); // 这个是异步的
// 但如果你在后面紧接着加:
cudaStreamWaitEvent(stream2, event_ready); // stream2 等待 stream1 的结果
// 这没问题,因为 stream1 虽然在干活,但它没有在 CPU 端死等。
记住:cudaMemcpyAsync 默认是同步的,除非你显式地使用 cudaMemcpyAsync(..., cudaMemcpyAsyncDefault)(在较新版本中)或者确保 Host 端没有其他阻塞操作。 等等,更准确地说,cudaMemcpyAsync 本身是异步的,但它会阻塞 Host 线程直到传输完成。如果 Host 线程是唯一在跑的线程,那它看起来就像同步了。
2. 内存对齐
流调度最怕碎片化。
如果你在 Stream 1 里拷贝了数据 A 的前 1MB,在 Stream 2 里拷贝了数据 B 的后 1MB,然后试图把它们拼在一起计算,CUDA 可能无法进行合并内存访问。流越多,内存碎片越严重。
建议: 在 AI 框架中,我们通常会对数据 Padding(填充)到 2 的幂次方,或者使用连续的内存池来分配 Tensor,确保流在处理数据时,内存地址是连续的。
3. 流的顺序
CUDA 保证同一流内的操作是按顺序执行的。但是,不同流之间的操作顺序是不确定的。
如果你在 Stream 1 里把数据拷贝到 d_ptr,然后在 Stream 2 里用 d_ptr 做计算,你可能会得到垃圾数据。因为 Stream 2 可能会在 Stream 1 拷贝完成之前就开始读取 d_ptr。
解决方法: 必须使用 cudaStreamWaitEvent 或 cudaMemcpyAsync 的返回值(虽然返回值不能保证 GPU 内部状态完全就绪)来建立依赖关系。
第八部分:高级技巧——非阻塞拷贝与多 GPU
在 C++ AI 框架中,我们不仅要管流,还要管内存带宽。
非阻塞拷贝
默认的 cudaMemcpyAsync 传输数据时,可能会阻塞其他流(取决于驱动和硬件)。为了最大化性能,我们可以使用 cudaMemcpyAsync(..., cudaMemcpyAsyncDefault)。
这告诉 CUDA:“我不管数据现在在不在,反正我先把命令发出去,CPU 你去干别的。” 这样 CPU 就可以立刻去准备下一批数据,甚至去启动另一个 GPU 的 Kernel。
多 GPU 的流
在多 GPU 系统上,流是绑定在特定 GPU 上的。
- GPU 0 的 Stream 1 和 GPU 1 的 Stream 1 是完全独立的。
- 你可以在 GPU 0 上传输数据,同时在 GPU 1 上计算。
- 甚至,你可以在 GPU 0 上计算,同时在 GPU 0 上传输数据。
这就像在高速公路上,左边车道运货,右边车道跑车,互不干扰。但如果你想跨 GPU 通信(比如 AllReduce),你就得用 P2P 传输或者 NCCL,这时候流的概念就变成了通信任务的调度。
第九部分:代码示例——完整的乒乓缓冲实现
为了让你彻底理解,我们来写一个更完整的类。这个类模拟了一个 LSTM 层或者 Transformer Block 的前向传播,它会自动管理两个 Buffer 的乒乓切换。
class PingPongLayer {
public:
PingPongLayer(int size) : size_(size) {
// 分配两个输入 Buffer 和两个输出 Buffer
cudaMalloc(&d_in0_, size * sizeof(float));
cudaMalloc(&d_in1_, size * sizeof(float));
cudaMalloc(&d_out0_, size * sizeof(float));
cudaMalloc(&d_out1_, size * sizeof(float));
// 创建流和事件
cudaStreamCreate(&stream_);
cudaEventCreate(&event_transfer_done_);
// 初始状态:假设数据 0 已经在 GPU 上了(比如从上一帧传过来的)
current_input_ = d_in0_;
current_output_ = d_out0_;
next_input_ = d_in1_;
next_output_ = d_out1_;
ready_flag_ = true; // 初始状态认为数据 0 已就绪
}
void process(float* h_new_data) {
// 1. 异步拷贝新数据到 next_input_
// 注意:这里使用 cudaMemcpyAsyncDefault (假设支持) 或者直接用 Async
// 我们假设 h_new_data 是 CPU 端的新数据
cudaMemcpyAsync(next_input_, h_new_data, size_ * sizeof(float),
cudaMemcpyHostToDevice, stream_);
// 2. 记录事件:当拷贝完成时,标记 next_input_ 准备好
cudaEventRecord(event_transfer_done_, stream_);
// 3. 异步计算:用 current_input_ 计算,结果存入 current_output_
// 关键:等待事件,确保 current_input_ 的数据是旧的(已经处理完了)
cudaStreamWaitEvent(stream_, event_transfer_done_, 0);
launch_kernel<<<grid, block, 0, stream_>>>(current_input_, current_output_, size_);
// 4. 回传结果
// 同样异步回传,不阻塞 CPU
cudaMemcpyAsync(h_current_result_, current_output_, size_ * sizeof(float),
cudaMemcpyDeviceToHost, stream_);
// 5. 交换指针!
// 准备下一轮:现在 next_input_ 已经有了新数据,next_output_ 有了计算结果
// 所以下一轮计算时,我们要用 next_input_ 和 next_output_
std::swap(current_input_, next_input_);
std::swap(current_output_, next_output_);
}
~PingPongLayer() {
cudaFree(d_in0_);
cudaFree(d_in1_);
cudaFree(d_out0_);
cudaFree(d_out1_);
cudaStreamDestroy(stream_);
cudaEventDestroy(event_transfer_done_);
}
private:
int size_;
float *d_in0_, *d_in1_;
float *d_out0_, *d_out1_;
float *current_input_, *current_output_;
cudaStream_t stream_;
cudaEvent_t event_transfer_done_;
float* h_current_result_;
};
在这个例子中,交换指针是关键。我们不需要频繁地 cudaMemcpy,只需要把指针指向不同的内存块。这极大地减少了 PCIe 带宽的占用,因为大部分时候数据已经在 GPU 里了,我们只是在“借”内存块用。
第十部分:总结——如何成为调度大师
好了,讲了这么多,我们来总结一下在 C++ AI 框架中利用多流调度的“秘籍”。
- 打破同步: 所有的
cudaMemcpy和 Kernel 启动,尽量都加上流参数,或者使用 Async 版本。永远不要让 CPU 等待 GPU。 - 建立依赖: 当一个流需要另一个流的结果时,用
cudaEventRecord和cudaStreamWaitEvent。这是连接不同流的生命线。 - 乒乓缓冲: 对于连续的数据流,使用双 Buffer 或多 Buffer 技术。计算 Buffer A,同时传输 Buffer B;计算 Buffer B,同时传输 Buffer A。
- 内存连续: 保持数据在显存中是连续的。流调度最怕的就是因为内存碎片导致的访问冲突。
- 非阻塞拷贝: 在支持的环境下,使用非阻塞拷贝,让数据传输和计算在 CPU 端也尽量重叠。
最后,我想说的是,写代码就像写诗,而写高性能 C++ 代码就像是在钢丝上跳舞。Stream 调度就是你的平衡杆。如果你能熟练掌握它,你就能让你的 GPU 在任何时刻都保持满负荷运转,不再有任何一个周期是浪费的。
现在,拿起你的编译器,去优化你的框架吧!别忘了检查 Stream 的依赖关系,别让你的程序因为死锁而崩溃。如果你看到 cudaErrorLaunchFailure,别慌,检查一下是不是某个流在等一个永远不会来的 Event。
祝大家代码飞快,模型收敛神速!