想象你是一家繁忙中餐厅的后厨总管。周末晚市客人爆满,你不会等热菜师傅把一道菜做完才接下单——你会把订单分给热菜、凉菜、炖汤几位师傅,每位师傅按自己手头的小纸条顺序做事。更妙的是,传菜员往备料台送食材时,炒菜师傅根本不用干等:食材边到,锅边在炒。这套“多线并进 + 定时器标记”的管理方法,硬是把一堆“等待时间”变成了“做菜时间”。 CUDA 里的流(Stream)和事件(Event),就是 GPU 世界里的“师傅分工”和“厨房定时器”。流决定一串 GPU 任务的先后归属,事件用来记录“某个时刻是否已经到来”。一旦你掌握这两件武器,就能让 GPU 一边疯狂算数,一边在后台悄悄搬数据,把硬件每一分算力都榨干。 这篇文章从一个后厨比喻出发,带你逐步理解默认流、多流并行、事件同步、精确计时,以及最激动人心的“计算与传输重叠”。你会发现,CUDA 的异步世界并没有那么神秘,一顿晚市高峰过去,你也就全熟络了。

岗位关联

学会这一篇,你就能避开“默认流隐式阻塞”这些暗坑,学会用多流和事件把 GPU 利用率真正拉满,让训练、推理、实时处理项目的性能更上一层楼。

目录
  1. 可视化演示
  2. 流的概念与默认流
  3. 创建与使用流
  4. 多流并行执行
  5. 事件机制与同步
  6. 事件精确计时
  7. 流同步方式
  8. 计算与传输重叠
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

展示CUDA多流并行执行中的数据拷贝、内核计算和设备到主机传输,通过Event标记实现同步,直观理解异步执行带来的计算重叠。

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

CUDA多流并行执行 多流H2D→Kernel→D2H异步执行,计算与传输重叠优化 Event记录同步点,精确计时性能分析 CUDA性能优化的核心基础,实现异步多任务并行

流的概念与默认流

后厨里,炖汤的砂锅、凉菜台的拌碗、热菜灶的炒锅,各归各的师傅。每位师傅面前都有一张“订单小条”,他只需要按小条排好的顺序做菜,做完一道再下一道。CUDA 里的“订单小条”就是流(Stream):它是一个由 GPU 操作构成的队列,规定了操作在设备端的执行次序。同一流中,前一个操作没完成,后一个操作不会开始。

流是 CUDA 异步执行的基本调度单位。任何内核启动、异步内存拷贝、事件记录,都可以放进一个流。放入同一流中的操作被保证按提交顺序在设备端严格串行执行;而不同流之间没有先后约束,GPU 硬件可以并行调度它们。这相当于给一大堆“待办任务”划出了几条互不干扰的流水线,每条线上的人只按自己的节奏干活。

当你没有显式创建流时,所有操作都跑进一个隐式存在的“默认流”(也常被称为 NULL 流,句柄为 0)。默认流最特殊之处是它和一些非默认流之间存在隐式同步:当默认流中的操作被启动时,GPU 可能先让其他流中的操作完成,再执行默认流,仿佛主厨一插队,全场都要停下来让路。这个“隐式阻塞”是初学者最容易踩到的性能陷阱之一。

你可以想象每位师傅只盯着自己的小纸条:切配、下锅、装盘。这就是流的意义——对内严格排序,对外互不干扰,然后整个后厨才能同时开火。理解了这个“纪律”,再看后面多流并行,就顺理成章了。

Stream={Op1, Op2, …, Opn},Opi≺Opi+1\mathrm{Stream} = \{ \mathrm{Op}_1,\ \mathrm{Op}_2,\ \ldots,\ \mathrm{Op}_n \},\quad \mathrm{Op}_{i} \prec \mathrm{Op}_{i+1}
公式通俗说明:同一流里的操作,像一张张按顺序排好的订单小条;扯下第 1 张做完,才能扯下第 2 张。
  1. 操作集合花括号里装的 Op_1 到 Op_n 表示流中的一系列 GPU 操作,比如内核执行、异步拷贝、事件记录,它们是构成流的基本元素。
    {Op1,Op2,…,Opn}\{\mathrm{Op}_1, \mathrm{Op}_2, \ldots, \mathrm{Op}_n\}
  2. 顺序记号符号 \prec 表示“先于”,Op_i \prec Op_{i+1} 表达同一流中第 i 个操作必须等第 i+1 个操作前面的所有工作完成。
    Opi≺Opi+1\mathrm{Op}_{i} \prec \mathrm{Op}_{i+1}
  3. 流内严格串行因此一条流是“一个接一个”的队列,不是一堆乱飞的散任务;这是流定义中最核心的承诺。
    同一流:Op1→Op2→⋯\text{同一流:} \mathrm{Op}_1 \to \mathrm{Op}_2 \to \cdots

创建与使用流

每次来一桌大订单,经理就会给这桌开一条新的出菜流水线。在 CUDA 中,创建一条流只需要一个函数调用:cudaStreamCreate(&stream),用完再 cudaStreamDestroy(stream)。新流一诞生,你就能把内核、拷贝往里面塞。释放流时,CUDA 会等待流中所有已提交操作完成,因此不必担心“任务还没做完,流就被注销”。

使用流的标准姿势是“创建流 → 把操作放进流 → 销毁流”。关键的 API 有三个:内核启动时通过 <<<grid, block, sharedMemSize, streamId>>> 四个参数中的最后一个指定流;cudaMemcpyAsync(dst, src, size, kind, stream) 将内存搬运作为异步操作加入到流中;cudaStreamCreate 与 cudaStreamDestroy 负责流的生命周期管理。若第四个参数省略或填 0,则默认在 NULL 流中执行。

这种“四参数启动”和“Async 拷贝”是把操作提交给 GPU 的入口。调用完这些函数后,CPU 侧并不会等 GPU 执行完,而是立刻返回——这就是异步执行。CPU 可以继续发布下一条命令,不会干等 GPU。也是因此,我们可以把很多互不依赖的小任务“一次性撒出去”,让 GPU 自己慢慢消化。

经理开好新流水线后,还会顺手在单子上写“食材先送,菜后炒”——这就是异步拷贝 cudaMemcpyAsync 的定位。它不会让 CPU 站在库房门口等卸货,而是把搬运任务交给对应流,排队执行。你只需要保证流还活着,剩下的运输细节全部由 CUDA 调度。

kernel⟨⟨⟨ G, B, Sh, S ⟩⟩⟩⟺将内核放入流 S\text{kernel}\langle\langle\langle\ G,\ B,\ Sh,\ S\ \rangle\rangle\rangle \quad \Longleftrightarrow \quad \text{将内核放入流 } S
公式通俗说明:启动一个内核时,前三个参数规定“多少人、怎么分组”,最后一个参数决定“放到哪条流水线”。
  1. G(grid 网格维度)告诉 GPU 总共需要启动多少个线程块,相当于决定这道菜要做多少份。
    G∈Z3G \in \mathbb{Z}^3
  2. B(block 线程块维度)每个线程块里有多少个线程,相当于每个餐盘里盛多少食材。
    B∈Z3B \in \mathbb{Z}^3
  3. Sh(动态共享内存)额外给块内共享内存预留的字节数,一般没有特殊需求时可以填 0。
    Sh≥0Sh \ge 0
  4. S(流句柄)目标流的标识;为 0 或 NULL 时表示放入默认流,也就是走“主厨那条线”。
    S∈{cudaStream_t}∪{0}S \in \{\text{cudaStream\_t}\} \cup \{0\}

多流并行执行

后厨为什么快?不是一位师傅有三头六臂,而是十几位师傅同时开火。多流并行就是把不同任务放进不同流,让 GPU 上多个执行引擎同时运转。但这有一个前提:流之间的操作必须互相独立。如果流 A 要读流 B 的输出,那你还是得通过事件或同步让它们排队,否则就会产生数据竞争。

GPU 的调度器会在多个流之间自动寻找可以并行的操作:例如两个内核正好都只占一半的 SM(流多处理器),它们就能同时运行。但并发度并不是“流数量越多越好”。真正的并发上限由硬件资源决定:SM 的数量、每个 SM 可以驻留的线程块数、以及每个内核占用的寄存器和共享内存,共同决定了 GPU 到底能同时容纳多少个内核。开再多的流,多出来的也只能排队。

多流还有一个常见坑叫“虚假依赖”。比如默认流和自定义流交替使用,或在自定义流之间混用同步版拷贝,都会让两条本可并行的流隐式地排起队来。表面上看代码确实开了 4 条流,实际运行却是一条条串过去的。想真正并行,核心是让流与流之间的操作互不等待,让每个流都拥有自己独立的资源通道。

多流并行的主厨思想可以这样记:你的“任务划分”决定了有几位师傅愿意同时开工;而“灶台数量”决定了真正能同时站几位师傅。好的并行设计,就是在硬件允许的范围内,让每一位师傅都有活干、且互不抢灶。

Cconc=min⁡(Nstreams,    NSM×BcapBkernel)C_{\text{conc}} = \min\left( N_{streams},\;\; N_{SM} \times \frac{B_{cap}}{B_{kernel}} \right)
公式通俗说明:真实并发度等于“流的数量”和“GPU 能同时装下的内核数量”之间的较小者,就像后厨能站 5 位师傅时,你安排 10 位也只能排队。
  1. N_streams你创建并启动的流的条数。数量再多也只是候选,不等于真实并发。
    NstreamsN_{streams}
  2. N_SM × B_cap / B_kernel一个 GPU 能同时承载的“内核实例数”。例如有 16 个 SM、每个 SM 驻留 8 个块,而一个内核占用 2 个块,那么可并发容纳 64 个这种内核。
    NSM×BcapBkernelN_{SM} \times \frac{B_{cap}}{B_{kernel}}
  3. min 取最小实际并发度是取两者较小的值。它代表资源“短板”,像餐厅只有 4 个灶眼时,5 道菜同时点也只能一次炒 4 道。
    min⁡(⋅)\min(\cdot)

事件机制与同步

厨师做菜时,传菜员按一下铃,大家就知道“这道菜可以端走了”——这个铃铛就是 CUDA 里的事件(Event)。事件不搬运数据,也不执行计算,它只是一个轻量标记,用于表示“流中某个位置到了”。当它前面的所有操作都完成的那一刻,事件状态就变为“已完成”。

CUDA 事件的基本用法是四步:先用 cudaEventCreate(&event) 创建事件对象;然后用 cudaEventRecord(event, stream) 把事件记录到某条流里;之后可以用 cudaEventSynchronize(event) 阻塞等待它完成;用完用 cudaEventDestroy(event) 销毁。如果你不想阻塞,还可以用 cudaEventQuery(event) 非阻塞询问:事件完成返回 cudaSuccess,没完成返回 cudaErrorNotReady,非常适合做轮询。

事件还有一个妙用:在流之间当“旗语”。如果流 B 要等流 A 的某个阶段结束,可以让流 B 调用 cudaStreamWaitEvent(stream_B, event_A),这样流 B 会一直等到事件 A 发生后才继续。这比把两个内核硬塞进同一流要灵活得多,也是构建复杂依赖关系(DAG)的基本积木。

事件就像后厨里那排“菜品出单号”:每道工序完成,就在出单号上盖一个章,别的主厨看见章才敢动下一道工序。看似只是小小的铃铛,却是整个异步配合体系里的润滑剂。

Es→record流,K,Ee→record流⇒T=te−tsE_s \xrightarrow{\text{record}} \text{流},\quad K,\quad E_e \xrightarrow{\text{record}} \text{流} \quad \Rightarrow \quad T = t_e - t_s
公式通俗说明:在流的两头各放一个“闹钟”,两个闹钟都按下之后的时间差,就是中间那段操作的真实耗时。
  1. 记录起点事件 E_s在流中提交一个记录操作;只有它前面的操作都完成,E_s 才会被标记为发生。
    Es→recordstreamE_s \xrightarrow{\text{record}} \text{stream}
  2. 中间操作 K你要计时的内容,比如一个内核,它在流里排在两个事件之间。
    KK
  3. 记录终点事件 E_e同样插在流队尾,当它前面的任务全部完成后,该事件也被标记完成。
    Ee→recordstreamE_e \xrightarrow{\text{record}} \text{stream}
  4. 时间差 T读取两个事件各自的时刻,相减得到这段操作的耗时。
    T=te−tsT = t_e - t_s

事件精确计时

经理想量化一位师傅炒一盘菜到底要多久,最简单的方法是在开始时按一下秒表,结束时再按一下秒表。CUDA 事件就是 GPU 自带的高精度秒表。使用事件计时的好处是:时间戳由 GPU 时钟记录,不经过 CPU 调度,测量结果非常稳定,也不会把 CPU 启动内核的开销混进来。

标准的计时模板是这样的:创建 start 与 end 两个事件;在目标流中先 cudaEventRecord(start, stream);接着启动要计时的内核;然后 cudaEventRecord(end, stream);调用 cudaEventSynchronize(end) 等 end 事件完成;最后 cudaEventElapsedTime(&ms, start, end) 拿到毫秒单位的耗时。整个过程看起来就像在菜品的开始端和结束端各夹一个计时夹。

需要特别注意三点:第一,start 和 end 必须记录在同一条流里,否则时间差没有意义;第二,cudaEventSynchronize 之后才能读取耗时,因为事件尚未完成时,时间差还不完整;第三,为了排除首次启动的冷启动效应,建议让同一个内核跑多轮,取平均时间。这样得到的数字才能真正反映 GPU 上的计算开销。

精确计时的意义在于“分清每道工序究竟占了多少灶台时间”。没有这个秒表,你做任何优化都像闭着眼睛炒菜——不知道自己改完是快了还是慢了。事件计时是性能优化最基础也最可靠的度量工具之一。

ms=(Tend−Tstart)×10−3ms = \left(T_{end} - T_{start}\right) \times 10^{-3}
公式通俗说明:GPU 把两个事件的时间戳相减,再把微秒换算成毫秒,就是一份可靠的“后厨秒表读数”。
  1. T_end 与 T_startCUDA 事件内部分别记录了记录瞬间的 GPU 时间戳,精度约为微秒级。
    Tend,TstartT_{end}, T_{start}
  2. 时间差结束事件的时间戳减去开始事件的时间戳,得到中间阶段的原始耗时。
    Tend−TstartT_{end} - T_{start}
  3. 转换为毫秒CUDA 的 cudaEventElapsedTime 返回毫秒单位,因此要乘以 10^{-3},也就是把微秒缩小 1000 倍。
    ms=ΔT×10−3ms = \Delta T \times 10^{-3}

流同步方式

后厨里,经理有时候只要等某一位师傅把菜做完,就可以继续下一道工序;但晚上打烊时,他必须等全厨房所有菜都上齐,才能宣布结束营业。CUDA 里的“等”也是分粒度的,选错了粒度,要么等不够,要么等太多。

CUDA 提供了几种主要的同步 API:cudaStreamSynchronize(stream) 只等待指定流中所有操作完成,其他流继续执行;cudaDeviceSynchronize() 等待当前设备上所有流的所有操作完成;cudaStreamWaitEvent(stream, event) 则让一条流等待另一条流中的某个事件信号,是一种更细粒度的“握手”。选择哪一种,取决于你真正需要等的是什么。

比显式同步更加隐蔽的是“隐式同步”。默认流中的部分操作、同步版 cudaMemcpy、锁页内存的分配与释放、cudaDeviceSynchronize 调用、甚至某些版本的设备端 printf,都可能触发与其它流的隐式同步。隐式同步会让 GPU 暂时放弃并行调度,把所有流“拉平”到同一个时间点上,这对性能的破坏往往是致命的。

同步的选择就像后厨的“等菜上齐”指令:等的人少,耽误时间少;一等全店,所有窗口都得停一停。性能调优的关键思路,就是尽量把同步粒度缩小到“只等必要的对象”,把不必要的等待从关键路径上拿掉。

tstream≤tdevice≤thost+devicet_{\text{stream}} \le t_{\text{device}} \le t_{\text{host+device}}
公式通俗说明:同步 API 的覆盖范围逐级扩大,代价也随之升高;同步粒度越细,对并行的伤害越小。
  1. t_stream只等待一条流的完成时间,影响范围最小,如同只叫住一位师傅问进度。
    tstreamt_{\text{stream}}
  2. t_device等待当前设备所有流完成,影响范围是整个 GPU,如同要求全厨房暂停。
    tdevicet_{\text{device}}
  3. t_host+device还包含主机侧上下文或跨设备的等待,代价最高,如同整家店关门歇业等客人全部离开。
    thost+devicet_{\text{host+device}}

计算与传输重叠

后厨最精彩的配合,是传菜员推着食材进备料台的同时,炒菜师傅已经开始炒下一道菜。CUDA 里的“计算与传输重叠”,就是让 GPU 的访存引擎(负责数据搬运)与计算引擎(负责内核算力)同时开工。很多加速应用最核心的技巧,都在这一节。

要实现重叠,需要三个条件。第一,把同步的 cudaMemcpy 换成异步的 cudaMemcpyAsync,并放入指定流;第二,使用锁页内存(cudaMallocHost 或 cudaHostAlloc),否则异步拷贝会退化成同步行为;第三,把不同阶段分配到不同流中,让流 A 负责搬运、流 B 负责计算。更经典的工程做法是“双缓冲流水线”:GPU 计算第 i 帧数据时,主机同时准备第 i+1 帧的传输,两块数据轮流交接,让 GPU 几乎不空闲。

当计算时间和传输时间相差不大时,重叠的效果非常显著。串行执行时,总耗时等于两者的和;重叠执行时,总耗时会压缩到两者的最大值。如果计算远大于传输,加速比接近 1 倍;如果两者相当,理论最高可以获得接近 2 倍的提速,这正是许多推理引擎“吞吐翻倍”的秘密来源。

主厨的高效不是因为他炒得有多快,而是因为他永远不在等食材——食材还在路上时,锅里的菜已经开始滋滋作响。这正是 CUDA 流与事件最值得掌握的实战技巧:让“等待”从运行时间里消失,让计算和传输真正并肩奔跑。

Toverlap=max⁡(Tc, Tt),Tserial=Tc+TtT_{\text{overlap}} = \max(T_c,\ T_t), \qquad T_{\text{serial}} = T_c + T_t
公式通俗说明:重叠执行的总时间按“最长的那条路径”走,而不是把各段时间简单相加;这就是后厨“边送货边炒菜”的逻辑。
  1. T_c 与 T_t分别表示计算阶段和传输阶段的耗时,一个代表炒菜时间,一个代表备料时间。
    Tc,TtT_c, T_t
  2. 串行总耗时同一时间只能做一件事时,先备料再炒菜,总耗时是两者相加。
    Tserial=Tc+TtT_{\text{serial}} = T_c + T_t
  3. 重叠总耗时备料与炒菜同时开工,总耗时为两者中的较大者。
    Toverlap=max⁡(Tc,Tt)T_{\text{overlap}} = \max(T_c, T_t)
  4. 提速收益串行时间与重叠时间的比值就是加速比;两者接近时,理论上接近 2 倍。
    Speedup=Tc+Ttmax⁡(Tc,Tt)\text{Speedup} = \frac{T_c + T_t}{\max(T_c, T_t)}

AI 应用场景

大模型推理服务中的连续批处理

阶段:模型推理与在线服务

解决的问题:大语言模型推理时,单个请求的生成是串行的,GPU 经常处于“吃几口就停一下”的状态。如果只用同步拷贝和单条流,输入请求的预处理、GPU 推理、输出回传会依次等待,GPU 利用率很低。用 CUDA 流把不同请求的预处理和推理拆分到多条流中,一个请求在排队时,另一个请求的输入已经在后台搬运,GPU 就能像后厨流水线一样停不下来。

具体案例:在推理框架中维护一个“请求流池”,每个请求绑定一条流。前端拿到用户文本后,先用 cudaMemcpyAsync 把 token 输入搬进显存,放入流 1;同时流 2 正在执行上一个请求的 decode 内核。通过事件记录输出阶段,再异步拷贝回主机。实测在并发请求数较多时,GPU 利用率可从 40% 提升到 80% 以上。

深度学习训练中的数据预取流水线

阶段:数据加载与训练循环

解决的问题:训练时 CPU 负责读图片、做增强、传输到 GPU,GPU 负责前向与反向。传统做法是每轮迭代同步等待数据到位,GPU 空转时间较长。AI 工程师用多流实现“双缓冲预取”:当前迭代在 GPU 上计算的同时,下一个小 batch 的图片已经在另一条流上做 H2D 拷贝,数据搬运不再占用训练的关键路径。

具体案例:在 PyTorch 自定义训练循环中,通过 CUDA Stream 把 to(device) 操作放入非默认流,并与当前计算流用事件连接。每个 epoch 的训练时间缩短约 20%-35%,尤其在数据增强较重、图片较大的视觉任务上效果明显。

实时音视频多路并行处理

阶段:音视频采集与 AI 实时处理

解决的问题:安防或直播场景中常常有几十路视频流需要同时做目标检测。每路视频都要经历上传、预处理、推理、回传。如果所有路共用一条流,任何一路的慢操作都会拖住全局;把不同路映射到不同流,各路处理就能在 GPU 上自然交错,互不阻塞。

具体案例:某视频分析平台接入 8 路 1080p 摄像头,每路分配一条 CUDA 流做缩放、推理、结果回传。通过事件同步保证同一路的帧处理顺序,跨路之间不等待。最终整体延迟从 85ms 压到 45ms,GPU 显存占用反而下降,因为不再需要一次性把所有帧都塞进显存。

Python 演示

python51 行
import threadingimport time  class CudaStream:    """模拟一条 CUDA Stream: 按提交顺序执行任务."""    def __init__(self, name):        self.name = name        self.tasks = []  # (描述, 耗时秒)     def add_task(self, desc, cost):        self.tasks.append((desc, cost))     def run(self):        for desc, cost in self.tasks:            time.sleep(cost)  def main():    s1 = CudaStream("stream1")    s1.add_task("copy H2D", 0.2)    s1.add_task("compute", 0.6)    s1.add_task("copy D2H", 0.2)     s2 = CudaStream("stream2")    s2.add_task("copy H2D", 0.2)    s2.add_task("compute", 0.6)    s2.add_task("copy D2H", 0.2)     # 串行执行: 像同一流里排队    start = time.time()    for s in (s1, s2):        s.run()    serial = time.time() - start     # 并行执行: 像两个独立流同时跑    start = time.time()    threads = [threading.Thread(target=s.run) for s in (s1, s2)]    for t in threads:        t.start()    for t in threads:        t.join()    parallel = time.time() - start     print("serial  : %.2f s" % serial)    print("parallel: %.2f s" % parallel)    print("speedup : %.2f" % (serial / parallel))  if __name__ == "__main__":    main()
目的:用一个纯 Python 模拟器演示 CUDA 流的核心行为——同一流内任务按序执行,不同流之间可以并行执行,从而获得接近 2 倍的加速比。
  1. self.tasks = []模拟流内部的“操作队列”,所有加入该流的任务都按顺序排队。
  2. def run(self): ... time.sleep(cost)模拟一个内核或一次拷贝所占据的 GPU 执行时间。
  3. for s in (s1, s2): s.run()串行模式:两条流挨个执行,相当于没有启用多流并行。
  4. threading.Thread(target=s.run)把每条流放进独立线程,相当于让 GPU 上的每条流各自推进。
  5. for t in threads: t.start()所有流同时开工,对应 CUDA 中的多流并行启动。
  6. for t in threads: t.join()等待所有流结束,对应 cudaDeviceSynchronize()。
  7. serial / parallel计算加速比,直观展示重叠带来的收益。

常见误区

误区我经常用 `cudaMemcpy` 在 CPU 和 GPU 之间搬数据,以为只要调用完它,CPU 就可以立刻干别的事,反正拷贝在后台自动进行。

正解只有 cudaMemcpyAsync 才是异步的,普通的 cudaMemcpy 是同步阻塞调用,CPU 会一直等到拷贝完成才返回。想让拷贝和计算重叠,必须使用异步版本,并且配合锁页内存和流。比喻来说,普通 cudaMemcpy 是传菜员站在库房门口等你签字验收,cudaMemcpyAsync 才是他放下货转身就走。

为何常见:文档里“拷贝很快”给人的错觉,以及很多老式教程代码只在单流场景里使用同步拷贝,让人误以为拷贝总是非阻塞的。

误区我一次把三个内核都提交到同一条流里,以为它们会像多个厨师一样同时炒菜,这样肯定比一个个来要快很多。

正解同一流严格保证顺序执行,前一个内核全部完成,后一个内核才会开始。想让内核并行,必须把它们放到不同的流中。三条内核挤在一条流里,就像三位师傅共用一张订单小条,结果只能排队做菜。

为何常见:人们把“CPU 上提交多个任务”的并发直觉带到了 GPU 流上,忽略了流本身就是“一个队列”的本质。

误区我开 32 条流跑一个小内核,总比开 4 条流快吧?反正多开不要钱。

正解并行上限由硬件决定:SM 数量、每个 SM 能驻留的线程块数、每个内核占用的资源(寄存器、共享内存)。当流超过硬件承载能力后,多出来的流只能排队,不仅没有加速,还可能因为调度开销变慢。就好比后厨只有 4 个灶眼,你安排 8 位师傅也站不下。

为何常见:现代 CPU 的“超线程越多越好”经验被错误迁移到 GPU 流上,实际上 GPU 并发要靠资源和任务粒度共同匹配。

误区我在代码里只用了流 A,调用 `cudaDeviceSynchronize()` 时,以为它只会等流 A 完成,流 B 可以在后台继续跑。

正解cudaDeviceSynchronize() 会等待当前设备上所有流的所有操作完成,它不是一个“流级”接口。如果你只想等一条流,应该用 cudaStreamSynchronize(stream)。用设备级同步来等单条流,就像打烊时把全厨师傅都叫停,只为等一道甜品。

为何常见:函数名里带 “Device” 容易被忽略,很多初学者把所有同步需求都用一个设备级同步解决,最后发现性能下降。

误区我觉得事件和缓存差不多,应该是 GPU 里的一块区域,用来存放中间结果或临时数据。

正解事件不存储任何用户数据,它只是一个“状态标记”加“时间戳”的轻量对象。它记录的是“流执行到某个位置了没有”,以及“到达那一刻的 GPU 时钟读数”。把它理解为后厨的按铃或盖章更合适,而不是一个仓库。

为何常见:名字 “Event” 容易让人联想到“事件队列”或“事件缓冲区”,但在 CUDA 里它更像一个信号灯,而不是容器。

面试题

Q1. 关于 CUDA 流,最准确的说法是?

单选题 · easy

  • A.GPU 上一条有序执行的操作队列← 正确答案
  • B.一种用于存储数据的专用显存区域
  • C.一个用于线程间通信的内建消息队列
  • D.一条连接 CPU 与 GPU 的物理总线

答案:A。CUDA 流本质上是 GPU 操作的有序队列,同一流内按顺序执行,不同流之间则可并行。它不是显存区域、消息队列或物理总线,就像后厨的订单小条不是灶台也不是仓库。

Q2. 默认流(NULL 流)的操作,说法正确的是?

单选题 · easy

  • A.默认流内操作没有固定的执行顺序
  • B.默认流可能与其他流产生隐式同步← 正确答案
  • C.默认流永远和其他流完全并行执行
  • D.默认流上无法使用事件记录或同步

答案:B。默认流的最大特点是会与部分非默认流产生隐式同步,可能让并发变串行。默认流内部仍然严格有序,也可以用事件。这就像主厨一旦插队,全场都得让路。

Q3. 同一流中连续提交两个内核,它们会如何执行?

单选题 · easy

  • A.一定并行执行以充分利用 GPU
  • B.一定按照先后顺序严格执行← 正确答案
  • C.是否能并行由 SM 空闲数决定
  • D.后一个内核会先于前一个启动

答案:B。同一流内的所有操作被保证按提交顺序串行执行,前一个内核完全结束后后一个才开始。流内没有并行,只有不同流之间才可能并行。

Q4. 要在流 s 中启动名为 kernel 的内核,正确写法是?

单选题 · mid

  • A.kernel<<<G, B, 0, s>>>(……)← 正确答案
  • B.cudaStreamLaunch(kernel, s, ……)
  • C.kernel<<<s>>> 然后在流中执行
  • D.cudaLaunchStream(kernel, s, 0)

答案:A。内核启动最多支持四个配置参数:网格维度、块维度、动态共享内存、流句柄。第 4 个参数就是流 s,填 0 或不填表示默认流。CUDA 并没有 cudaStreamLaunch 这类 API。

Q5. cudaMemcpyAsync 与 cudaMemcpy 的核心差异是?

单选题 · mid

  • A.前者速度一定比后者快
  • B.前者可放入指定流并立即返回← 正确答案
  • C.前者只能用于设备到设备拷贝
  • D.前者会等待拷贝在 CPU 侧完成

答案:B。cudaMemcpyAsync 把拷贝作为异步任务提交到指定流,CPU 不等待拷贝完成;cudaMemcpy 是阻塞同步版本。两者速度并不是绝对的快慢关系,Async 版本价值在于能与其他计算重叠。

Q6. 哪些因素会限制多个流真正并行执行的程度?

多选题 · mid

  • A.GPU 中流多处理器 SM 的数量← 正确答案
  • B.每个 SM 可驻留的线程块上限← 正确答案
  • C.创建流时使用的变量名长度
  • D.单个内核占用的寄存器与共享内存← 正确答案

答案:ABD。并发并行受 SM 数量、每个 SM 能容纳的线程块数量以及内核本身资源占用共同限制。变量名长度只是源码层的符号,不影响 GPU 执行。

Q7. 关于 cudaEventQuery(event) 的作用,正确的是?

单选题 · mid

  • A.阻塞等待事件所在流全部执行完
  • B.非阻塞地查询事件是否已完成← 正确答案
  • C.将事件重新记录到它所在的流中
  • D.销毁事件并释放占用的 GPU 资源

答案:B。cudaEventQuery 是非阻塞查询接口:如果事件已完成,返回 cudaSuccess;尚未完成则返回 cudaErrorNotReady。你可以把它当成看一眼“菜品出单号”盖没盖章,不用站在那干等。

Q8. 使用 CUDA 事件做内核计时,正确做法是?

多选题 · hard

  • A.在目标流中先后记录 start 与 end← 正确答案
  • B.在两个不同的流中分别记录两个事件
  • C.等待 end 完成后调用 ElapsedTime← 正确答案
  • D.记录事件后立即读取 elapsed 时间

答案:AC。计时的两个事件必须记录在同一条流中,才有先后可比性;且必须等 end 事件完成后再读耗时。跨流计时或不等完成就读时间,都会得到无意义或偏小的结果。

Q9. 关于隐式同步,以下描述正确的是?

单选题 · hard

  • A.默认流从来不会影响其他流执行
  • B.默认流操作可能与全设备同步等待← 正确答案
  • C.隐式同步只发生在显存分配时
  • D.隐式同步会提升内核并行效率

答案:B。默认流中的操作、同步拷贝、锁页内存分配等都可能触发隐式同步,让多个流被迫对齐,破坏并行。隐式同步通常会降低效率,而不是提升。

Q10. 以下关于 CUDA 流与事件的说法,正确的是?

多选题 · extreme

  • A.多流可用来重叠计算与数据传输← 正确答案
  • B.同一流内的操作按顺序严格执行← 正确答案
  • C.流的数量越多,加速效果一定越好
  • D.事件可以在不同流之间充当同步信号← 正确答案

答案:ABD。多流最典型的用途是让拷贝与计算重叠;同一流内有序是流的基本语义;事件配合 cudaStreamWaitEvent 可以做流间同步。但流过多时受硬件资源制约,不会“无限加速升”。