【Runtime推理引擎④】多流并发执行:用Stream+Event让计算与通信重叠
本系列是推理引擎 Runtime 模块的深度拆解,面向对 LLM 推理系统感兴趣的开发者。
上一篇我们聊了 CUDA Graph 如何消除 Kernel Launch 开销,今天来看看多流并发怎么让 GPU"不闲着"。
一、开篇:推理不只有"算 Kernel"
很多人做推理优化时,注意力全放在"怎么让 kernel 算得更快"上------算子融合、Tensor Core、量化......这些当然重要,但如果你只盯着计算,就会漏掉另一块大肥肉:数据传输和预处理的时间。
1.1 一次推理到底包含哪些步骤?
我们来拆解一次典型的 LLM 推理请求,看看 GPU 都干了啥:
时间线 →
┌─────────┬──────────┬──────────────┬──────────┬─────────┐
│ H2D │ 预处理 │ 模型计算 │ 后处理 │ D2H │
│ 拷贝 │ (可选) │ (N个kernel) │ (可选) │ 拷贝 │
└─────────┴──────────┴──────────────┴──────────┴─────────┘
- H2D(Host to Device):把输入数据从 CPU 内存搬到 GPU 显存
- 预处理:比如 token embedding 查找、position encoding 计算等
- 模型计算:就是我们常说的 forward pass,一堆 kernel
- 后处理:比如 logits 采样、概率计算等
- D2H(Device to Host):把结果从 GPU 显存搬回 CPU 内存
如果你只用一个流(Stream),这些步骤是串行执行的------H2D 没拷完,计算就不能开始;计算没做完,D2H 就不能开始。
但 GPU 其实有多个独立的硬件引擎:
- 计算引擎:负责跑 kernel
- 复制引擎:负责 H2D/D2H 数据传输(通常有多个 Copy Engine)
- 等等
这些引擎是可以并行工作的!单流模式下,它们大部分时间在互相等,利用率很低。
多流并发,就是用多个 Stream 让不同引擎同时干活,把 GPU 的"边角料"时间都利用起来。
1.2 什么时候多流收益最明显?
一个简单的判断标准:如果你的推理流程里,数据传输或预处理的时间占比超过 10%,多流就值得做。
常见场景:
- 大 batch 输入:H2D 拷贝时间长,与计算重叠收益大
- 有预处理/后处理:比如图像预处理、文本 tokenize 等
- 多模型流水线:上一个模型的输出是下一个模型的输入
- KV Cache 换入换出:大模型推理中显存不足时,把 KV Cache 搬到 CPU
二、CUDA Stream 到底是什么?
2.1 最朴素的理解:命令队列
很多教材把 Stream 讲得很玄乎,其实你可以把它理解成GPU 的命令队列。
- 你往 Stream 里塞命令(kernel launch、memcpy 等)
- GPU 按顺序从队列里取命令执行
- 同一个 Stream 里的命令,严格按入队顺序执行
不同的 Stream 就是不同的队列,它们之间默认是无序的------GPU 想先执行哪个队列的命令都行,没有先后保证。

2.2 默认 Stream(Null Stream)
如果你写 CUDA 代码时从来没指定过 Stream,那你用的就是默认 Stream(也叫 Null Stream)。
默认 Stream 有个特殊的性质:它会和其他所有 Stream 同步。也就是说,如果你往默认 Stream 里塞了一个命令,它会等所有其他 Stream 的当前命令都执行完才开始;反过来,其他 Stream 的命令也会等默认 Stream 的命令执行完。
这个性质很容易踩坑------很多人用了自定义 Stream,但中间不小心调用了一个不带 stream 参数的 API(比如 cudaMemcpy 而不是 cudaMemcpyAsync),结果就触发了隐式同步,整个 pipeline 全卡住了。
最佳实践:尽量不要用默认 Stream,所有操作都显式指定自己的 Stream。
2.3 代码示例:创建与使用 Stream
cpp
// 创建两个 Stream
cudaStream_t stream_compute, stream_copy;
cudaStreamCreate(&stream_compute);
cudaStreamCreate(&stream_copy);
// 在 copy stream 上异步拷贝数据
float* d_input;
cudaMalloc(&d_input, size);
cudaMemcpyAsync(d_input, h_input, size, cudaMemcpyHostToDevice, stream_copy);
// 在 compute stream 上 launch kernel
my_kernel<<<grid, block, 0, stream_compute>>>(d_input, d_output, N);
// 等等------这里有问题!
// kernel 可能在拷贝完成之前就开始执行了!
// 怎么办?用 Event!(下一节讲)
// 同步(等待 stream 上所有命令完成)
cudaStreamSynchronize(stream_compute);
cudaStreamSynchronize(stream_copy);
// 销毁
cudaStreamDestroy(stream_compute);
cudaStreamDestroy(stream_copy);
上面这段代码有一个典型错误:两个 stream 是独立的,kernel 可能在数据拷贝完之前就开始跑了,导致读到脏数据。
怎么解决?这就需要 Event 了。
三、异步执行与事件机制
3.1 Event 的正确模型:时间戳标记
很多人把 CUDA Event 理解成"信号量",其实不太准确。更贴切的理解是:Event 是 GPU 时间线上的一个标记点。
cudaEventRecord(event, stream):在 stream 的命令队列里插入一个"标记"。当 GPU 执行到这个标记时,event 就变成"完成"状态cudaStreamWaitEvent(stream, event):让 stream 停下来等,等 event 完成了再继续往后执行
注意两个关键点:
- Record 和 Wait 都是异步的------它们只是往队列里插命令,CPU 不会等
- Wait 是 stream 级别的阻塞------只是那个 stream 停了,其他 stream 该干嘛干嘛
3.2 用 Event 解决上面的问题
回到刚才的例子,我们想让计算 stream 等拷贝 stream 拷完了再开始:
cpp
cudaEvent_t copy_done;
cudaEventCreate(©_done);
// 1. 拷贝 stream 开始拷贝
cudaMemcpyAsync(d_input, h_input, size, cudaMemcpyHostToDevice, stream_copy);
// 2. 在拷贝 stream 的末尾记录一个 event
cudaEventRecord(copy_done, stream_copy);
// 3. 让计算 stream 等待这个 event
cudaStreamWaitEvent(stream_compute, copy_done, 0);
// 4. 计算 stream 上的 kernel 会等 event 完成(即拷贝完成)后才开始
my_kernel<<<grid, block, 0, stream_compute>>>(d_input, d_output, N);
画成时间线就是这样的:
stream_copy: [ 拷贝数据 ]·event·
stream_compute: ·wait·[ kernel ]
·event· 和 ·wait· 就是 Event 标记的位置。通过 Event,我们在两个独立的 stream 之间建立了依赖关系。
3.3 同步方式对比
CUDA 里有好几种同步方式,千万别搞混了:
| 同步方式 | 作用范围 | CPU 是否阻塞 | 适用场景 |
|---|---|---|---|
cudaDeviceSynchronize() |
整个设备,所有 stream | 是 | 调试用,生产代码尽量别用 |
cudaStreamSynchronize(s) |
单个 stream | 是 | 需要拿结果时,比如推理结束读输出 |
cudaEventSynchronize(e) |
单个 event | 是 | 精确等待某个操作完成 |
cudaStreamWaitEvent(s, e) |
让 stream s 等 event e | 否(纯 GPU 端等待) | 建立 stream 间依赖,实现流水线 |
cudaEventQuery(e) |
检查 event 是否完成 | 否(立即返回) | 轮询模式,非阻塞检查 |
核心区别 :前三个是 CPU 等 GPU(同步调用),cudaStreamWaitEvent 是 GPU 内部等(异步调用)。
做多流并发,最常用的就是 cudaStreamWaitEvent------它不会阻塞 CPU,CPU 可以继续往各个 stream 塞任务。
四、多流重叠计算与通信:流水线模式
4.1 经典的双缓冲流水线
理解了 Stream 和 Event,我们来看最经典的应用:双缓冲(Double Buffering)流水线。
假设我们有两批数据要处理,每批都要先 H2D 拷贝,再计算。如果用单流:
单流模式:
Stream 0: [H2D batch1] [compute batch1] [H2D batch2] [compute batch2]
总时间 = 2 × (T_h2d + T_compute)
如果用双流 + 双缓冲:
多流模式:
copy_stream: [H2D batch1] [H2D batch2]
compute_stream: [compute batch1] [compute batch2]
总时间 ≈ T_h2d + 2 × T_compute + T_h2d
省掉了一次 H2D 的时间! 因为 batch2 的 H2D 和 batch1 的 compute 是重叠的。

上图展示了更一般的情况:H2D、计算、D2H 三者形成流水线,每一步都和下一步重叠。
4.2 代码示例:双缓冲流水线
cpp
// 两个 buffer,两组 stream
float *d_buf0, *d_buf1;
cudaMalloc(&d_buf0, size);
cudaMalloc(&d_buf1, size);
cudaStream_t st_copy, st_compute;
cudaStreamCreate(&st_copy);
cudaStreamCreate(&st_compute);
cudaEvent_t events[2];
cudaEventCreate(&events[0]);
cudaEventCreate(&events[1]);
// 第一批:先拷,不用等
cudaMemcpyAsync(d_buf0, h_data[0], size, cudaMemcpyHostToDevice, st_copy);
cudaEventRecord(events[0], st_copy);
for (int i = 0; i < num_batches; ++i) {
int cur = i % 2;
int next = (i + 1) % 2;
// 计算 stream 等待当前 batch 拷贝完成
cudaStreamWaitEvent(st_compute, events[cur], 0);
// 启动计算
my_kernel<<<grid, block, 0, st_compute>>>(d_buf[cur], d_output[i], N);
// 如果还有下一批,开始拷下一批
if (i + 1 < num_batches) {
cudaMemcpyAsync(d_buf[next], h_data[i+1], size, cudaMemcpyHostToDevice, st_copy);
cudaEventRecord(events[next], st_copy);
}
}
cudaStreamSynchronize(st_compute);
这段代码的精髓在于:当前 batch 在计算时,下一个 batch 已经在拷了。计算和拷贝完全重叠。
4.3 吞吐提升 vs 延迟不变
这里有一个很重要的点:多流并发提升的是吞吐量,不一定降低单请求延迟。
为什么?因为单个请求还是要经历 H2D + 计算 + D2H 三步,一步都少不了。多流只是让多个请求的步骤重叠起来,单位时间内能处理更多请求。
举个例子:
- 单流:每个请求 10ms(H2D 2ms + 计算 5ms + D2H 3ms),1 秒处理 100 个
- 多流:每个请求还是 10ms,但因为重叠,1 秒能处理 150 个
那延迟什么时候能降? 当你的单请求内部就能做切分时。比如把一个大矩阵乘法分成几块,边拷边算,这时候单请求的总延迟也能降低。
4.4 什么时候 Overlap 明显?
不是所有场景多流都能带来明显收益。Overlap 明显的条件:
- 计算和传输时间相当:如果计算 10ms,传输才 0.1ms,重叠了也没感觉
- 有足够多的批次:流水线需要"填满",只有 1 批的话体现不出优势
- GPU 有独立的 Copy Engine:现在的 GPU 一般都有,但某些低端卡或虚拟化环境可能没有
- 用的是页锁定内存(Pinned Memory):可分页内存的 H2D 走的是同步路径,没法和计算重叠
五、Buffer 设计:双缓冲/多缓冲是多流的前提
5.1 为什么需要多缓冲?
做多流并发,第一个要解决的问题就是数据放在哪。
如果你只有一个 buffer,那 copy stream 往里写的时候,compute stream 就不能读------会有竞态(Race Condition)。所以至少需要两个 buffer:
- Buffer A 正在被计算时,Buffer B 可以被写入新数据
- 一轮结束后,交换身份,A 变写入、B 变计算
这就是双缓冲(Double Buffering)。
5.2 三缓冲甚至更多?
双缓冲是最基础的。如果你的流水线有更多阶段(比如 H2D → 预处理 → 计算 → 后处理 → D2H),可能需要三缓冲甚至更多。
但不是缓冲越多越好:
- 缓冲多了,显存占用就大
- 缓冲多了,首帧延迟也高(得先填满几个 buffer 才开始出结果)
- 一般来说,N 级流水线用 N+1 个 buffer 就够了
5.3 风险点:Buffer 早复用
多缓冲最容易犯的错误就是buffer 还没被消费完就被复用了。
举个反例:
cpp
// 错误示例!
for (int i = 0; i < N; i++) {
cudaMemcpyAsync(d_buf, h_data[i], size, H2D, st_copy);
cudaEventRecord(ev_copy_done, st_copy);
cudaStreamWaitEvent(st_compute, ev_copy_done, 0);
kernel<<<..., st_compute>>>(d_buf, ...);
// 下一轮循环又往 d_buf 里拷新数据了
// 但上一轮的 kernel 可能还没执行完!
}
只有一个 buffer 的时候,下一轮的 H2D 会覆盖上一轮的输入,而上一轮的 kernel 可能还没读完。
正确做法:要么用多个 buffer,要么在复用 buffer 前确认它已经被所有消费者读完了(用 Event 同步)。
5.4 Pinned Memory 是必须的吗?
严格来说不是必须的,但不用 Pinned Memory 的话,H2D/D2H 没法和计算重叠。
因为普通的可分页内存(Pageable Memory),CUDA 驱动需要先在 CPU 端把数据拷到一个临时的 pinned buffer 里,再 DMA 到 GPU。这个"拷到临时 buffer"的步骤是同步的,会阻塞 CPU,也就没法和计算重叠了。
cpp
// 分配 Pinned Memory
float* h_pinned;
cudaMallocHost(&h_pinned, size); // 或者 cudaHostAlloc
// 使用完释放
cudaFreeHost(h_pinned);
Pinned Memory 是把双刃剑:它能提升传输性能和重叠能力,但分配太多会导致系统可用内存减少、交换分区使用增加。按需分配,用完就释放。
六、多 Session/多模型下的多流
前面讲的都是单模型内的多流,在实际的推理服务里,多流还有另一个维度:多模型、多 Session 之间的并发。
6.1 Prefill 与 Decode 分离
在 LLM 推理服务里,一个经典的架构是把 Prefill 和 Decode 放在不同的 Stream 甚至不同的线程里。
为什么?因为两者的计算特征完全不同:
| 特征 | Prefill | Decode |
|---|---|---|
| 计算量 | 大(O(n²) Attention) | 小(O(n) Attention) |
| Arithmetic Intensity | 高 | 低(Memory Bound) |
| 持续时间 | 长 | 短 |
| Kernel 数量 | 少 | 多 |
如果把 Prefill 和 Decode 混在一个 stream 里,Decode 请求(通常延迟敏感)会被 Prefill 堵住------一个 Prefill 可能占着 GPU 几十毫秒,后面的 Decode 就全卡住了。
放到不同的 stream 里呢?虽然计算引擎还是共享的,但至少:
- Decode 的 H2D/D2H 可以和 Prefill 的计算重叠
- 可以给 Decode stream 设置更高的优先级(见下)
- 配合 Time Slicing,小的 Decode kernel 有机会"插队"执行
6.2 高优先级 Stream
CUDA 支持给 Stream 设置优先级:
cpp
// 获取优先级范围(数值越小优先级越高)
int least_priority, highest_priority;
cudaDeviceGetStreamPriorityRange(&least_priority, &highest_priority);
// 创建高优先级 stream
cudaStream_t high_prio_stream;
cudaStreamCreateWithPriority(&high_prio_stream, cudaStreamNonBlocking, highest_priority);
高优先级 stream 里的 kernel 会被优先调度。在推理服务里,通常:
- Decode stream 设为高优先级(延迟敏感)
- Prefill stream 设为普通优先级(吞吐优先)
- 后台任务(比如 KV Cache 换出)设为低优先级
注意:优先级只是调度倾向,不是硬实时保证。低优先级的任务也不会被完全饿死,只是执行得慢一些。
6.3 多模型并发
如果一个 GPU 上跑多个模型(比如多租户场景),每个模型用独立的 Stream 也是常见做法。
好处:
- 模型之间互不阻塞,一个模型的长 kernel 不会完全卡死另一个模型
- 可以按模型重要性设置不同优先级
- 显存隔离做得好的话,故障也不容易扩散
但也要注意:Stream 不是越多越好。Stream 太多会导致:
- GPU 调度开销增加
- 每个 stream 的指令缓存命中率下降
- 显存碎片增多
一般经验:单个 GPU 上活跃的 Stream 控制在 4~8 个比较合适。
七、常见错误与踩坑
多流并发是一个"看起来简单,细节全是坑"的技术。这里列几个最常见的错误,大家可以对照自查。
7.1 只 Record 不 Wait
这是新手最常犯的错误:往不同 stream 里塞了任务,但忘了加 Event 同步。结果就是------数据依赖没保证,结果全错,还不一定每次都错(偶发性最难调试)。
检查方法:凡是有数据依赖的地方,上游 stream 必须 record event,下游 stream 必须 wait event。
7.2 Buffer 早复用
前面已经提到过了。buffer 被一个 stream 读的时候,另一个 stream 不能写。
检查方法:每个 buffer 配一个"就绪 event",写入完成时 record,读取前 wait;读取完成时也 record,下次写入前 wait。
7.3 Stream 太多
有人觉得 stream 越多并发越高,一上来创建几十个 stream。结果 GPU 调度不过来,性能反而下降。
经验法则:
- 计算密集型场景:2~4 个计算 stream 足够
- 有大量 I/O 的场景:可以多几个 copy stream,但别超过硬件 copy engine 的数量
- 先从 2~3 个开始测,逐步增加,找到性能拐点
7.4 不小心用了默认 Stream 或同步 API
cpp
// 你以为是异步的,其实是同步的!
cudaMemcpy(dst, src, size, cudaMemcpyHostToDevice); // 不带 stream 参数,走默认 stream,还会同步!
// 正确写法
cudaMemcpyAsync(dst, src, size, cudaMemcpyHostToDevice, my_stream);
类似的坑还有 cudaMalloc(可能触发隐式同步)、cudaGetLastError(有时也会同步)等。
检查方法:代码走查,确保热路径上所有 CUDA 调用都是 async 的,都显式指定了 stream。
7.5 用了可分页内存做异步拷贝
前面讲过,Pageable Memory 的 cudaMemcpyAsync 其实内部是同步的------驱动要先拷到 pinned 临时 buffer。
检查方法 :所有用于异步拷贝的 host 内存,都要用 cudaMallocHost 或 cudaHostAlloc 分配。
7.6 Event 创建销毁太频繁
Event 也是资源,创建和销毁有开销。如果每轮循环都 create + destroy 一次 event,性能会受影响。
正确做法:提前创建好 event 池,循环里重复使用。用完了统一销毁。
八、小结
多流并发是 GPU 推理优化里的"进阶技能",它不改变计算本身,而是通过更好地调度硬件资源来提升整体效率。我们来回顾一下要点:
- 本质:利用 GPU 多个硬件引擎(计算、拷贝等)的并行能力,让计算与通信重叠
- Stream:GPU 的命令队列,同 stream 顺序执行,不同 stream 默认无序
- Event:GPU 时间线上的标记,用于建立 stream 间的依赖关系
- 核心模式:双缓冲流水线,计算和传输重叠,提升吞吐量
- Buffer 设计:多缓冲是多流的前提,注意避免 buffer 早复用
- 应用场景:Prefill/Decode 分离、多模型并发、优先级调度等
- 常见坑:只 record 不 wait、buffer 复用、误用同步 API、stream 太多等
思考问题
- 如果你的推理模型计算量很小(比如一个只有几层的小模型),多流并发还能带来收益吗?为什么?
- 双缓冲需要 2 倍的输入 buffer 显存。如果显存非常紧张,有没有什么折中方案?
- 在 LLM 推理的 Decode 阶段,每个 token 都很小,H2D/D2H 的时间可以忽略不计。这时候多流还有用吗?可以用在什么地方?
到这里,Runtime 推理引擎系列的四篇核心优化技术(算子融合、内存管理、CUDA Graph、多流并发)就都讲完了。它们不是孤立的,而是层层递进、互相配合的关系。真正的工业级推理引擎,往往是这几项技术的组合拳。