想象你是一家繁忙餐厅的厨房主厨。灶台只有一个,你只能一道道菜依次炒,客人等得火冒三丈——这就是没有 CUDA 流的世界。现在,你把厨房分成几个独立的工作台:一个台子焯水、一个台子爆炒、一个台子摆盘。它们同时开工,互不干扰。而每个工作台上挂着一只小铃铛,铃铛一响,就代表“这步做好了,可以进行下一步”。这只铃铛,就是 CUDA 里的事件(Event)。 CUDA 流(Stream)就是一条条独立的“工作台流水线”,它让 GPU 这台“超级厨房”不再死板地逐道做菜,而是像真正的后厨一样多线并行:数据拷贝的同时计算已经在跑,多个核函数也能首尾相接地重叠执行。事件则是流水线上的“进度标记铃”,既用来记录每道工序花了多久(计时),也用来协调不同工作台之间的先后关系(同步)。本文会用这套厨房比喻,带你彻底理解 CUDA 流与事件:从默认流的“串行陷阱”,到多流流水线并行、流优先级、事件同步与计时,最后落到 AI 训练与推理中的真实优化实践。

岗位关联

掌握 CUDA 流与事件,是 AI 从业者从“能在 GPU 上跑程序”进阶到“能把 GPU 榨干”的分水岭。它直接决定你在训练吞吐、推理延迟、多卡通信优化上能走多远。

目录
  1. 可视化演示
  2. 流的概念
  3. 默认流行为
  4. 流优先级
  5. 事件同步
  6. 事件计时
  7. 多流流水线
  8. AI 应用场景
  9. Python 演示
  10. 常见误区
  11. 面试题

可视化演示

展示CUDA多流并行执行队列、事件同步机制与流水线重叠计算,揭示异步执行如何通过流间重叠大幅提升GPU利用率。

本节配有交互动效(进入视口后自动加载,约 3KB)。

多流并行执行时序 默认流:所有操作串行执行无重叠GPU空闲率高 非默认流:不同流的核函数可并行执行实现重叠 流间独立:同流内操作按序执行不同流间无序 Speedup ≈ min(N_streams, SM_util) 多流并行通过重叠执行提升GPU利用率

流的概念

走进厨房,你会看到主厨(CPU)面前贴着一张张订单。没有流水线时,他必须做完一道菜才看下一道,锅铲永远只有一把在响。引入 CUDA 流之后,厨房被隔成多个独立台面——每个台面都有自己的一套“待办纸条队列”。GPU 上的一个流(Stream),就是这样一个异步执行队列:你把一批操作(核函数、数据拷贝、事件)按顺序塞进队列,GPU 会顺着队列依次执行。不同队列之间的操作互不干涉,可以同时推进。

从技术上看,CUDA 流封装了 GPU 工作调度的一组序列化操作。同一个流里的操作严格保持提交顺序:先入队的先执行。但不同流之间不存在顺序约束,硬件调度器可以自由穿插,这正是并行潜力所在。流还能让“提交操作”和“执行操作”解耦:主机把任务丢进队列后立刻返回,不必傻等 GPU 做完——这就是与主机异步,相当于主厨把订单递给灶台师傅后,马上回头去接新客人。

流的代表性创建方式如下:

回到比喻:每开一条流,等于多雇一个能独立干活的灶台师傅。流的数量不是越多越好——厨房面积(SM 数量)有限,师傅多了也要排队用锅。理解流的本质是队列,你就抓住了 CUDA 并发的第一把钥匙。

cudaStreamCreate(&stream)\texttt{cudaStreamCreate(\&stream)}
  1. cudaStreamCreate创建一条新流的 API,相当于在厨房里隔离出一个全新的工作台。
    cudaStreamCreate(&stream)\texttt{cudaStreamCreate(\&stream)}
  2. \&stream传入流句柄的地址,函数执行成功后,stream 变量就代表这条新流。
    stream∈{合法流句柄}\texttt{stream} \in \{\text{合法流句柄}\}

默认流行为

每个厨房都有一个“主灶台”。即使你从来不主动创建流,CUDA 也会给你安排一条默认流(default stream,也叫 0 号流 / legacy stream)。它的特殊之处不在名字,而在“脾气”:默认流会与所有非默认流发生隐式同步。

这就像主灶台的规矩:主灶台一旦开火,其他任何台面都必须先放下手里的活,等主灶台做完这一道菜才能继续。GPU 术语里这叫隐式同步(implicit synchronization):你明明没写任何等待代码,GPU 却自己偷偷插入了屏障。结果就是:默认流和自定义流之间的并行被悄悄抹平,变成“伪串行”。

以下代码隐藏了性能陷阱:

实际调优时,要么克制地用默认流,要么用 cudaStreamCreateWithFlags(..., cudaStreamNonBlocking) 创建“不参与隐式同步”的流。把主灶台和分台之间那堵无形的墙拆掉,多条流水线才能真正并行起来。

foo<<<grid, block, 0, stream>>>()\texttt{foo<<<grid, block, 0, stream>>>()}
  1. 第 4 个参数 0核函数配置中的流参数填 0,表示把核函数交给默认流执行。
    stream = 0⇒采用默认流\texttt{stream = 0} \Rightarrow \text{采用默认流}
  2. 隐式同步点默认流操作开始前,必须等待所有先前非默认流操作完成。
    Wait(default,prior streams)\text{Wait}(\text{default}, \text{prior streams})

流优先级

客人分三六九等,订单也分轻重缓急。餐厅为了不让 VIP 客人久等,会给某些订单贴上红色标签,让厨师优先处理。CUDA 流同样支持优先级(priority):高优先级的流在调度时抢占先机,低优先级的流靠后站。

优先级不是“并行度”的开关,而是“插队权”的大小。两个流依然可以并发执行,但当计算资源(SM 上的执行单元)不够时,调度器会优先满足高优先级流里的任务。创建方式是在流生成时传入优先级参数:

实际项目中,常常把“必须低延迟返回”的小任务(如推理请求中的关键算子)放进高优先级流,把“不着急但量大”的批处理任务放进低优先级流。这就像把“马上要上桌的前菜”和“明天才用的高汤”分开排队——延迟和吞吐的权衡,就在这一个个优先级参数的旋钮之间。

cudaStreamCreateWithPriority(&stream, flags, priority)\texttt{cudaStreamCreateWithPriority(\&stream, flags, priority)}
  1. flags一般传 cudaStreamNonBlocking,表示该流不参与默认流的隐式同步。
    flags=cudaStreamNonBlocking\texttt{flags} = \texttt{cudaStreamNonBlocking}
  2. priority优先级数值,数值越小优先级越高(如 -2 高于 0)。
    priority∈[low,high], 高优先级=低数值\texttt{priority} \in [\text{low}, \text{high}],\ \text{高优先级} = \text{低数值}

事件同步

灶台师傅们各做各的,但总有需要“齐步走”的时刻:红烧肉必须等焯好水才能下锅,摆盘台必须等菜出锅才能动手。厨房里的做法是挂铃——出锅的师傅按一下铃,等铃的师傅听到后再开工。CUDA 里的铃铛就是事件(Event),而“听铃开工”的动作叫 cudaStreamWaitEvent。

事件本质上是流里的一个进度标记:它在流中占一个位置,当执行到它时,GPU 会记录一个“到这了”的信号。别的流可以通过等待这个信号,建立依赖关系(dependency),保证“我等你做完,我才开始”。

典型用法如下:

事件同步还有一个反直觉的优势:它不会让 CPU 阻塞。cudaStreamWaitEvent 只是让 GPU 上的某个流“知道要等”,主机代码照常往下跑。这场面就像是领班对着厨房喊一声“焯好水的喊一嗓子”,然后继续去接待下一桌——等铃真正响时,灶台师傅自己会动手,根本不需要领班盯着。

cudaStreamWaitEvent(stream, event, 0)\texttt{cudaStreamWaitEvent(stream, event, 0)}
  1. event希望等待的事件对象,相当于“我要等的那个铃铛”。
    event=已记录进度的标记\texttt{event} = \text{已记录进度的标记}
  2. stream当前流在见到这个事件之前,不会继续执行后续操作。
    Stream waits until event occurs\text{Stream waits until } \texttt{event} \text{ occurs}
  3. 0等待标志位,常规场景传 0 即可。
    flags=0\texttt{flags} = 0

事件计时

除了当铃铛,事件还兼任“秒表”。你可以在一段计算的前后各放一个事件,然后读取两个事件之间的 GPU 时间差,得到一段代码在设备上的真实耗时。这比在主机侧用 clock() 掐表准得多——主机计时会被内核启动开销、驱动排队、上下文切换污染,而事件计时直接发生在 GPU 执行流中。

第一次用事件计时,你通常会写下这样的流程:

厨房里掐秒表,最怕表走不准。如果 start 和 stop 写在不同的流上,或没有插到同一条流的时间线上,算出来的时间就串味了。把事件当作“同一根流水线上的两个计时戳”,而不是“两个随便的时间点”,这是事件计时不出错的关键。

cudaEventElapsedTime(&ms, start, stop)\texttt{cudaEventElapsedTime(\&ms, start, stop)}
  1. start / stop分别对应计算前后的两个事件对象,即起点铃和终点铃。
    start→GPU 执行时刻 t1,stop→GPU 执行时刻 t2\texttt{start} \to \text{GPU 执行时刻 } t_1,\quad \texttt{stop} \to \text{GPU 执行时刻 } t_2
  2. \&ms输出参数,接收两次记录之间的毫秒数。
    Δt=t2−t1\Delta t = t_2 - t_1
  3. 注意前提两个事件都必须已经被记录(cudaEventRecord)到同一条流中。
    cudaEventRecord(start, stream), cudaEventRecord(stop, stream)\texttt{cudaEventRecord(start, stream)},\ \texttt{cudaEventRecord(stop, stream)}

多流流水线

现在把整套工具串成一条真正的流水线。想象餐厅的订单流程是:备菜 → 烹饪 → 装盘。串行做,每桌客人要等 备菜+烹饪+装盘 三段时间;但如果你把三桌客人的订单交错推进——第 1 桌在烹饪时,第 2 桌已在备菜,第 3 桌正在装盘——厨房的利用率立刻拉满。CUDA 程序里对应的三阶段是:主机到设备拷贝 → 核函数计算 → 设备回拷主机。

多流流水线的核心收益可以用一个对比来表达。串行总时间:

换成三流流水线后(数据被切分为 N 个块):

在代码层面,实现这套流水线的骨架是:为每一块数据分配独立的流;分别在不同流上提交 cudaMemcpyAsync 和核函数;需要跨流依赖时用事件同步收尾。把整片数据切成小块、多个流交替提交,GPU 就会像三条同时开火的灶台一样,把 PCIe 拷贝和 SM 计算的重叠红利一点点吃干榨净。

Tserial=Tcopy+Tkernel+TbackT_{\text{serial}} = T_{\text{copy}} + T_{\text{kernel}} + T_{\text{back}}
  1. 切块把一个大数组切成 N 个小块,每块独立走“拷贝→计算→回拷”。
    chunki→copyi+kerneli+backi\text{chunk}_i \to \text{copy}_i + \text{kernel}_i + \text{back}_i
  2. 重叠项第 i 块计算时,第 i+1 块正在拷贝,第 i-1 块正在回拷。
    max⁡(Tkernel, Tcopy) 体现重叠后的瓶颈\max(T_{\text{kernel}},\ T_{\text{copy}}) \text{ 体现重叠后的瓶颈}
  3. 极限收益当拷贝与计算时间大致相等,流水线总时间接近单块时间+一份拷贝时间,而不是三者之和。
    Tpipe≪TserialT_{\text{pipe}} \ll T_{\text{serial}}

AI 应用场景

场景一:大模型训练中的数据加载流水线

阶段:数据预处理与训练循环

解决的问题:训练时 GPU 计算很快,但数据从磁盘读入、预处理、拷贝到显存却拖后腿。传统做法是每轮迭代同步拷贝,GPU 频繁“空转等饭”。使用 CUDA 流后,数据加载与上一轮前向/反向计算可以完全重叠,GPU 利用率从 60% 提升到 95% 以上。

具体案例:在 PyTorch 的 DataLoader 底层或自定义 CUDA 流水线中,为数据拷贝单独开一条流,计算在主流上进行。第 k 轮迭代反向传播的同时,第 k+1 轮的 batch 正在 PCIe 上飞奔。实际训练吞吐量可提升 30%-50%,尤其在小 batch 高频迭代场景下收益惊人。

场景二:AI 推理服务的低延迟响应

阶段:模型推理与请求调度

解决的问题:推理服务器同时收到多个请求,如果串行执行,长任务会堵住短任务。传统方案用多进程或多线程复制模型,内存开销巨大。CUDA 流允许在同一个模型上下文中并发处理多个请求,并用优先级保证关键请求插队。

具体案例:某在线推荐系统使用两个流:高优先级流承载实时点击请求(要求 5ms 内返回),低优先级流承载离线批处理任务。通过 cudaStreamCreateWithPriority 分离两类负载,P99 延迟下降 40%,吞吐提升 2 倍。

Python 演示

python56 行
import timeimport threading class FakeStream:    """模拟一条 CUDA 流:串行执行队列,但多流之间可并行"""    def __init__(self, name):        self.name = name        self.queue = []     def add_task(self, fn, tag):        self.queue.append((tag, fn))     def run(self):        for tag, fn in self.queue:            print(f"[{self.name}] 执行: {tag}")            fn() def make_job(tag, seconds):    def _job():        time.sleep(seconds)        print(f"    -- {tag} 完成")    return _job def serial_flow():    """串行执行:拷贝 -> 计算 -> 回拷"""    T0 = time.time()    s = FakeStream("主灶台")    s.add_task(make_job("拷贝数据块", 0.3), "copy")    s.add_task(make_job("执行核函数", 0.5), "kernel")    s.add_task(make_job("回拷结果", 0.2), "back")    s.run()    print(f"串行总耗时: {time.time() - T0:.2f}s\n") def pipelined_flow():    """三流流水线:第 i 块的拷贝与第 i-1 块的计算重叠"""    T0 = time.time()    chunk_count = 3    streams = []    for i in range(chunk_count):        s = FakeStream(f"流{i+1}")        s.add_task(make_job(f"块{i+1} 拷贝", 0.3), f"copy{i+1}")        s.add_task(make_job(f"块{i+1} 计算", 0.5), f"kernel{i+1}")        s.add_task(make_job(f"块{i+1} 回拷", 0.2), f"back{i+1}")        streams.append(s)     threads = [threading.Thread(target=s.run) for s in streams]    for t in threads:        t.start()    for t in threads:        t.join()    print(f"三流流水线总耗时: {time.time() - T0:.2f}s\n") if __name__ == "__main__":    print("=== 模拟 CUDA 流与事件:串行 vs 多流重叠 ===")    serial_flow()    pipelined_flow()
目的:直观展示“串行三阶段”与“三条流重叠执行”的总耗时差异,体会 CUDA 流通过并行队列压缩整体时间的基本原理。
  1. class FakeStream模拟 CUDA 流的数据结构,本质是一个“按顺序执行的任务队列”。
  2. s.add_task(make_job("拷贝数据块", 0.3), "copy")往流里塞一个任务,对应 cudaMemcpyAsync。
  3. s.run()按入队顺序逐个执行,体现“同一流内串行”。
  4. threading.Thread(target=s.run)多个流各自独立跑,模拟 GPU 上不同流并发调度。
  5. serial_flow()` 与 `pipelined_flow()对照组:一个流跑完所有阶段 vs 三个流各管一块。
  6. time.time()模拟事件计时:记录开始与结束时刻,算出总耗时。

常见误区

误区我创建了 8 条流,把任务随便丢进去,GPU 利用率应该自动拉满吧?为什么反而更慢了?

正解多流只是提供“并行可能”,不等于“并行现实”。流的并行收益取决于资源是否有空闲:如果 SM 已经被一个核函数塞满,再多的流也只能排队;如果每个任务本身太小,流的调度开销反而会吃掉收益。正确的做法是像流水线一样“切块 + 交错提交”,让拷贝与计算有重叠窗口,而不是盲目堆流。

为何常见:初学者看到“多流=并行”的宣传,误以为流是性能加速器本身。实际流的价值在于暴露并行机会,真正提速靠的是硬件资源被有效利用。

误区0 号流不就是一条普通的流吗?我写的核函数都在默认流里,其他自定义流应该能和它同时跑。

正解默认流(legacy default stream)与所有非默认流存在隐式同步点:默认流中的操作会等待之前所有非默认流完成,反之亦然。想让自定义流完全独立,必须用 cudaStreamCreateWithFlags(..., cudaStreamNonBlocking) 创建“非阻塞”流。否则你会发现代码里明明写了多流,行为却还是串行。

为何常见:默认流的隐式同步是历史兼容性设计,CUDA 文档把它藏在“特殊行为”一栏。没有踩过性能坑的人,很难主动意识到这条看不见的屏障。

误区我在核函数前后用 `clock()` 掐表,测出来的时间就是 GPU 的执行时间吧?

正解CPU 计时测的是“主机侧感知的时间”,包含内核启动延迟、驱动排队、显存同步等待等大量系统开销。正确做法是用 CUDA 事件:在流中插入 cudaEventRecord(start) 和 cudaEventRecord(stop),再用 cudaEventElapsedTime 读取 GPU 流内时间差。这相当于在厨房装了两个计时戳,而不是在门口用手机秒表。

为何常见:CPU 计时在直觉上“够用”,且普通程序的 CPU 时间确实接近真实耗时。但 GPU 是异步设备,CPU 计时天然无法反映设备端的精确时间线,这个认知差需要踩坑才能补上。

误区同步嘛,把流同步一下和把设备同步一下,效果差不多,随便选一个就行。

正解cudaStreamSynchronize 只阻塞主机,直到指定流里的所有操作完成;其他流可以继续跑。而 cudaDeviceSynchronize 要等整台设备上所有流都完成,代价大得多。这就像“等一个灶台收工”和“等整个厨房打烊”的区别。滥用 cudaDeviceSynchronize 会把好不容易建立的多流重叠全部摧毁。

为何常见:两者的名字都以 Synchronize 结尾,新手容易把它们当成同义 API。实际它们的粒度差了整整一个层级。

面试题

Q1. CUDA 流(Stream)的本质是什么?

单选题 · easy

  • A.一种并行编程语言
  • B.GPU 上的一条异步执行队列← 正确答案
  • C.一个特殊的显存区域
  • D.一种线程同步锁

答案:B。流的本质是“按顺序执行的异步操作队列”,主机把操作提交进去后立即返回,GPU 顺着队列依次执行。它不是编程语言、显存区域或锁,而是调度与执行的基本容器。

Q2. 默认流(0 号流)与自定义流之间的特殊关系是?

单选题 · easy

  • A.完全相同,无任何区别
  • B.默认流优先级更高
  • C.存在隐式同步← 正确答案
  • D.默认流不能执行核函数

答案:C。默认流(legacy stream)会与所有非默认流发生隐式同步,导致并行被悄悄抹平。要让自定义流完全独立,需用 cudaStreamCreateWithFlags 加 cudaStreamNonBlocking 标志。

Q3. 以下哪个 API 用于创建带优先级的流?

单选题 · mid

  • A.cudaStreamCreate
  • B.cudaStreamCreateWithFlags
  • C.cudaStreamCreateWithPriority← 正确答案
  • D.cudaStreamSynchronize

答案:C。cudaStreamCreateWithPriority 接受 priority 参数,用来指定新流的优先级。cudaStreamCreate 只能创建默认优先级流,cudaStreamCreateWithFlags 只控制标志位,cudaStreamSynchronize 是同步操作。

Q4. cudaStreamWaitEvent(stream, event, 0) 的作用是?

单选题 · mid

  • A.让主机 CPU 阻塞等待 GPU
  • B.让指定流等待一个事件发生← 正确答案
  • C.立即终止执行中的事件
  • D.将两个流合并成一个

答案:B。cudaStreamWaitEvent 建立跨流依赖:指定流会暂停后续操作,直到 event 被记录。它不会阻塞主机 CPU,也不会终止或合并流。这是实现“我等你做完再开工”的标准手段。

Q5. 用 CUDA 事件计时,以下哪步是必需的?

单选题 · mid

  • A.在主机侧调用 clock()
  • B.事件必须记录到同一条流← 正确答案
  • C.调用 cudaDeviceSynchronize 后立即读取
  • D.事件之间必须间隔 1 秒

答案:B。事件计时要求 start 和 stop 都通过 cudaEventRecord 记录到同一条流中,才能反映真实的 GPU 流内时间差。主机 clock()、全设备同步都不是事件计时的前提,间隔 1 秒更是无稽之谈。

Q6. 两条流中的核函数一定能在 GPU 上同时执行吗?

单选题 · hard

  • A.一定可以,流之间完全独立
  • B.一定不行,GPU 只能串行
  • C.取决于资源是否足够← 正确答案
  • D.只有相同优先级才能并行

答案:C。流提供并行机会,但实际是否重叠取决于 SM、执行单元、显存带宽等资源是否富余。若硬件资源已被占用,不同流的核函数只能排队。这也是为什么“多流 + 切块 + 交错”的流水线设计比“盲目多流”重要得多。

Q7. 关于默认流的隐式同步,以下说法正确的是?

多选题 · mid

  • A.默认流操作会等待之前所有非默认流完成← 正确答案
  • B.非默认流操作也会等待默认流完成← 正确答案
  • C.隐式同步只在调试模式下生效
  • D.用 cudaStreamNonBlocking 可避开隐式同步← 正确答案

答案:ABD。默认流与所有非默认流之间存在双向隐式同步:默认流操作开始前要等非默认流完事,非默认流遇到默认流操作也要让路。这是始终生效的规则,与调试模式无关。创建非阻塞流即可绕开这条限制。

Q8. 下列关于事件(Event)的说法,正确的有?

多选题 · mid

  • A.事件可以标记流中的执行进度← 正确答案
  • B.事件只能用于计时,不能用于同步
  • C.事件可以被其他流等待← 正确答案
  • D.事件必须和流绑定才能记录← 正确答案

答案:ACD。事件是流中的进度标记,需要 cudaEventRecord 绑定到具体流记录。它既可用于计时,也能通过 cudaStreamWaitEvent 参与同步。所以“只能计时”是错的。

Q9. 多流流水线能提升性能,依赖哪些前提?

多选题 · hard

  • A.数据被切分成多个块← 正确答案
  • B.拷贝与计算的时间可以重叠← 正确答案
  • C.GPU 硬件资源存在空闲← 正确答案
  • D.使用高优先级流才能生效

答案:ABC。流水线并行要求:数据切块后逐块提交,拷贝与计算能重叠,且硬件资源未饱和。优先级只是调度策略,并不是流水线生效的前提,故 D 不正确。

Q10. 以下哪个操作最可能让“多流重叠”彻底失效?

单选题 · hard

  • A.在每个流中调用 cudaEventRecord
  • B.在主循环里频繁调用 cudaDeviceSynchronize← 正确答案
  • C.使用 cudaMemcpyAsync 提交异步拷贝
  • D.为不同流设置不同优先级

答案:B。cudaDeviceSynchronize 会等待设备上所有流全部完成,相当于让整个厨房停业清场。频繁调用它会反复打断流间重叠,把并行退化成串行。事件记录、异步拷贝、优先级都是支持并行的机制,不会破坏重叠。