《CUDA TensorRT多流并发实战》

CUDA流与事件:流的概念、创建与同步

核心原理:流是GPU任务的执行序列

CUDA流(Stream)是GPU上按顺序执行的一组操作序列。从硬件视角看,GPU包含多个独立的执行引擎(如计算引擎、拷贝引擎、张量核心等),流的作用就是将这些操作按依赖关系组织起来,提交给GPU调度器执行。理解流的关键在于:同一个流中的操作严格按提交顺序执行,不同流中的操作可以并发执行。

默认情况下,所有CUDA操作(内核启动、内存拷贝、事件记录)都在默认流(Default Stream,流0)中执行。默认流具有隐式同步特性——它与其他所有流之间存在同步屏障,这会阻止并发执行。因此,要实现真正的多流并发,必须创建显式流(Non-default Stream)。

流与硬件执行引擎的关系:现代GPU(如A100、H100)拥有独立的DMA拷贝引擎和计算引擎。当操作位于不同流时,GPU调度器可以将拷贝操作分配给拷贝引擎、将计算操作分配给计算引擎,从而实现拷贝与计算的重叠。但同一流内的操作必须等待前一个操作完成,这是流的基本约束。

CUDA流与硬件执行引擎 Stream 1 H2D 拷贝 (cudaMemcpyAsync) Kernel A (计算) D2H 拷贝 Kernel B (计算) Stream 2 Kernel C (计算) GPU 硬件引擎 DMA 拷贝引擎 处理 H2D / D2H 内存传输 计算引擎 (SM) 执行 Kernel 计算任务 张量核心 (Tensor Core) 加速矩阵乘 / 卷积运算 H2D Kernel A D2H Kernel C 不同流操作可并发执行,同一流内严格串行

上图展示了两个流与硬件引擎的映射关系。Stream 1中的H2D拷贝、Kernel A、D2H拷贝、Kernel B按顺序执行;Stream 2中的Kernel C独立运行。由于拷贝引擎和计算引擎相互独立,Stream 1的H2D拷贝可以与Stream 2的Kernel C同时执行,实现计算与传输的重叠。

流的创建与同步方法

CUDA提供完整的流管理API,包括创建、销毁、同步和查询。核心函数如下:

// 创建流
cudaStream_t stream;
cudaStreamCreate(&stream);                    // 普通流
// 或创建带优先级/标志的流
cudaStreamCreateWithFlags(&stream, cudaStreamNonBlocking);
cudaStreamCreateWithPriority(&stream, cudaStreamNonBlocking, priority);

// 异步操作提交到流
cudaMemcpyAsync(dst, src, size, cudaMemcpyHostToDevice, stream);
kernel<<<grid, block, 0, stream>>>(args...);

// 流同步
cudaStreamSynchronize(stream);                // 阻塞主机直到流中所有操作完成
cudaStreamQuery(stream);                      // 非阻塞查询,返回cudaSuccess或cudaErrorNotReady

// 销毁流
cudaStreamDestroy(stream);

关键标志说明:cudaStreamNonBlocking标志使该流不与默认流隐式同步,这是多流并发的前提。默认创建的流(cudaStreamCreate)实际上也带有非阻塞标志,但为了代码清晰,建议显式指定。

事件:精确计时与流间同步

CUDA事件(Event)是流中的标记点,用于两个核心目的:测量操作耗时和实现流间同步。事件本身不执行计算,只记录GPU执行到该点的时间戳。

API 功能 典型使用场景
cudaEventCreate 创建事件对象 初始化计时或同步点
cudaEventRecord 将事件记录到指定流 标记操作开始/结束
cudaEventSynchronize 阻塞主机直到事件完成 等待GPU执行到事件点
cudaEventElapsedTime 计算两个事件间毫秒数 测量内核/拷贝耗时
cudaStreamWaitEvent 让流等待事件完成 跨流依赖控制

事件计时是性能分析的基础工具。相比CPU计时器(如clock()),事件计时直接在GPU时间线上打点,不受CPU调度和PCIe传输延迟影响,精度更高。

// 事件计时示例
cudaEvent_t start, stop;
cudaEventCreate(&start);
cudaEventCreate(&stop);

cudaEventRecord(start, stream);
kernel<<<grid, block, 0, stream>>>(args...);
cudaEventRecord(stop, stream);

cudaEventSynchronize(stop);  // 等待GPU完成
float ms = 0.0f;
cudaEventElapsedTime(&ms, start, stop);
printf("Kernel 耗时: %.3f ms\n", ms);

案例:多流并发流水线

以典型的数据处理流水线为例:输入数据分块传输到GPU、逐块执行计算、结果回传。使用多流可以将三个阶段重叠,大幅提升吞吐。

流水线设计:将数据分为N个块,创建M个流(通常M=2~4)。每个流负责一个数据块的完整处理链:H2D拷贝 → Kernel计算 → D2H拷贝。由于不同流的操作可以重叠,当流0执行Kernel时,流1可以同时执行H2D拷贝,流2可以执行D2H拷贝。

// 多流流水线核心代码
const int NUM_STREAMS = 4;
cudaStream_t streams[NUM_STREAMS];
for (int i = 0; i < NUM_STREAMS; ++i) {
    cudaStreamCreateWithFlags(&streams[i], cudaStreamNonBlocking);
}

for (int i = 0; i < num_chunks; ++i) {
    int s = i % NUM_STREAMS;
    int offset = i * chunk_size;

    // 1. H2D 拷贝
    cudaMemcpyAsync(d_data + offset, h_data + offset,
                    chunk_bytes, cudaMemcpyHostToDevice, streams[s]);

    // 2. 计算内核
    process_kernel<<<grid, block, 0, streams[s]>>>(d_data + offset, chunk_size);

    // 3. D2H 拷贝
    cudaMemcpyAsync(h_result + offset, d_data + offset,
                    chunk_bytes, cudaMemcpyDeviceToHost, streams[s]);
}

// 等待所有流完成
for (int i = 0; i < NUM_STREAMS; ++i) {
    cudaStreamSynchronize(streams[i]);
    cudaStreamDestroy(streams[i]);
}

该模式的关键在于:每个流内部保持H2D→Kernel→D2H的顺序依赖,但不同流之间完全独立。GPU调度器会自动将可并行的操作分配到空闲引擎上。

误区与复盘

常见误区一:忽略默认流的隐式同步。如果使用默认流(流0)执行任何操作,它会阻塞所有其他流。例如,在循环中调用cudaMemcpy(同步版本)会强制GPU等待,破坏并发。必须使用cudaMemcpyAsync并指定非默认流。

常见误区二:过度创建流。创建大量流(如100个)并不会带来线性加速。GPU硬件并发能力有限(通常2~4个引擎),过多流反而增加调度开销。建议流数量与数据块大小匹配,一般2~8个流即可。

常见误区三:事件同步位置错误。cudaEventSynchronize会阻塞CPU,如果在内核启动后立即调用,就退化为同步执行。正确做法是:先记录事件,完成所有异步提交后再同步等待。

复盘建议:使用nsys profile或ncu工具查看GPU时间线,确认不同流的操作是否真正重叠。如果时间线显示串行执行,检查是否存在隐式同步(如默认流使用、cudaDeviceSynchronize调用、内存分配未使用cudaMallocAsync等)。

流和事件是CUDA并发编程的基石。掌握流的创建、同步和事件计时,是后续实现多流并发、多CUDA流推理加速的前提。实际开发中,建议先用事件计时验证单流性能,再逐步引入多流,通过性能分析工具确认重叠效果,避免盲目增加流数量。