【Runtime推理引擎④】多流并发执行:用Stream+Event让计算与通信重叠

【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 完成了再继续往后执行

注意两个关键点:

  1. Record 和 Wait 都是异步的------它们只是往队列里插命令,CPU 不会等
  2. Wait 是 stream 级别的阻塞------只是那个 stream 停了,其他 stream 该干嘛干嘛

3.2 用 Event 解决上面的问题

回到刚才的例子,我们想让计算 stream 等拷贝 stream 拷完了再开始:

cpp 复制代码
cudaEvent_t copy_done;
cudaEventCreate(&copy_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 明显的条件:

  1. 计算和传输时间相当:如果计算 10ms,传输才 0.1ms,重叠了也没感觉
  2. 有足够多的批次:流水线需要"填满",只有 1 批的话体现不出优势
  3. GPU 有独立的 Copy Engine:现在的 GPU 一般都有,但某些低端卡或虚拟化环境可能没有
  4. 用的是页锁定内存(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 推理优化里的"进阶技能",它不改变计算本身,而是通过更好地调度硬件资源来提升整体效率。我们来回顾一下要点:

  1. 本质:利用 GPU 多个硬件引擎(计算、拷贝等)的并行能力,让计算与通信重叠
  2. Stream:GPU 的命令队列,同 stream 顺序执行,不同 stream 默认无序
  3. Event:GPU 时间线上的标记,用于建立 stream 间的依赖关系
  4. 核心模式:双缓冲流水线,计算和传输重叠,提升吞吐量
  5. Buffer 设计:多缓冲是多流的前提,注意避免 buffer 早复用
  6. 应用场景:Prefill/Decode 分离、多模型并发、优先级调度等
  7. 常见坑:只 record 不 wait、buffer 复用、误用同步 API、stream 太多等

思考问题

  1. 如果你的推理模型计算量很小(比如一个只有几层的小模型),多流并发还能带来收益吗?为什么?
  2. 双缓冲需要 2 倍的输入 buffer 显存。如果显存非常紧张,有没有什么折中方案?
  3. 在 LLM 推理的 Decode 阶段,每个 token 都很小,H2D/D2H 的时间可以忽略不计。这时候多流还有用吗?可以用在什么地方?

到这里,Runtime 推理引擎系列的四篇核心优化技术(算子融合、内存管理、CUDA Graph、多流并发)就都讲完了。它们不是孤立的,而是层层递进、互相配合的关系。真正的工业级推理引擎,往往是这几项技术的组合拳。

相关推荐
qyr67891 小时前
全球卫星遥感服务市场前景研究与行业发展趋势
大数据·人工智能·卫星遥感服务
麻瓜code1 小时前
Spring AI 全模块 API 字典一览(1.1.0 版)
人工智能·windows·spring
Peng25051 小时前
从 0 到持续运行:用 Hermes Agent + Python 搭一个小红书 AI 资讯自动发布系统
人工智能
程序员果子1 小时前
Ontology
大数据·人工智能·智能体·ontology·本体
用户750634133801 小时前
做了个"状态机 + 台账"的 LLM 应用糖糖写作,聊聊架构踩坑
架构
用户24101505702081 小时前
6岁小朋友一句话,一个dsh智能体,一个deepseek4.1大模型,一款水浒游戏
人工智能
AI小百科1 小时前
AI 音乐生成:不会乐器也能出一首歌
人工智能
老马识码1 小时前
WorkBuddy 的专家和专家团是怎么组织的
人工智能·agent
wwx_031 小时前
嵌入式单片机裸机C++软件架构设计
架构