☰
多CUDA stream并发踩坑:record_stream与wait_event如何保证显存复用安全
2026/10/27 17:29:27 网站建设 项目流程

前两天调一套多路视频推理服务,遇到一个非常折磨人的Bug:单条CUDA stream运行稳如老狗,两条stream一起跑就开始间歇性报错,有时候是illegal memory access,有时候干脆没有报错,只是输出结果里混入上一批数据。一开始我怀疑是kernel内部越界,花了大半天时间打日志、查内存,最后定位到是一个显存复用相关的老朋友——record_stream,准确点说,是多CUDA stream并发时没有正确使用wait_event导致的跨流依赖缺失。如果你也在用PyTorch自定义stream做多路推理、多batch异步流水线,或者在纯CUDA里手动管理多stream并发,这篇记录值得看完。我会把整个坑的成因、record_stream的真实语义,以及如何在多stream场景里把事件依赖补齐,一步步说清楚。

1. record_stream到底在记录什么?

1.1 显存池与“假空闲”问题

先从PyTorch的显存管理说起。PyTorch的CUDACachingAllocator在底层维护一个大缓存池,第一次分配会真的调用cudaMalloc,之后所有分配都尽量从池里取,只有池里没有合适块时才去问驱动要新内存。这样做的好处是省去反复cudaMalloc/cudaFree的系统开销,坏处是“池里的空闲块”并不等于“GPU上的活已经干完”。

举个例子:你在默认流上分配了一个buf,然后把它丢给自定义流s1去写。buf这个Python对象可能很快被del,显存块回到池子里变成free状态。可是GPU上s1队列里的kernel可能还压根没执行完。如果这时候另一个streams2需要分配显存,分配器看到池子里有这么一块“空闲”内存,顺手就给出去了。结果就是s1的kernel还在往这个地址写数据,s2的kernel也开始往同一个地址写数据,两边互踩,轻则结果错乱,重则直接非法内存访问。

这个问题的本质是:显存块的使用生命周期不等于Python对象的生命周期,也不等于分配时所在stream的生命周期。

我一开始想不通为什么单stream没问题。后来想明白了,单stream是串行队列,块回收后再次分配时,同一队列里的后续kernel天然排在前面kernel后面,GPU硬件会保证顺序。但多stream之间默认是并发、无顺序约束的,一旦内存被错分,谁先写谁后写完全看调度心情。

用生活里的例子类比:显存缓存池像一个仓库管理员,他知道货物什么时候被借走、什么时候归还。现在有两条流水线都在用同一件设备,管理员只盯着第一条流水线的登记记录,以为第一条线完工就能把设备租出去,可实际上第二条流水线还在用。两拨人同时操作一台设备,必然出问题。

1.2 record_stream的官方语义与实现思路

torch.Tensor.record_stream(stream)这个接口,就是用来解决“跨stream使用但分配器不知道”的问题。它的官方语义是:让分配器知道,这个tensor还会在指定的stream上继续被使用,因此它的底层显存块在该stream上的所有工作完成之前,不能安全回收复用。

调用了record_stream之后,分配器会在底层为这个显存块记录一个和指定stream关联的事件,通常对应cudaEventRecord。当这个块进入空闲列表,分配器检查到它身上有关联的未完成事件,就不会把它分配给新的分配请求;直到事件真正完成,块才会重新进入可复用状态。这个机制是GPU侧的异步等待,不会立刻阻塞CPU线程,但会有效推迟显存的归还。

这里有个容易被忽略的关键点:record_stream必须由用户在实际使用这个tensor的stream上下文里调用,或者明确指定正确的stream。如果调用位置不对,它记录的stream和你实际跑kernel的stream不是同一个,那等于白记。

我踩过的坑就发生在这一步。我用多stream做推理时,习惯在一个stream上下文里创建中间张量,又在另一个stream上下文里用。代码里虽然写了buf.record_stream(s1),但因为调用时的current stream已经不是s1了,导致分配器理解的是“这块内存在当前流上有依赖”,而不是“在s1上有依赖”。这个错误非常隐蔽,因为单stream时不会暴露,一旦并发跑起来就随机翻车。

为了把行为说清楚,我把三种情况列在下面:

场景分配器感知结果
不调用record_stream认为显存块只和分配时所在stream相关跨stream复用时大概率数据竞争
调用record_stream但调用位置/stream错误记录的是错误流上的依赖并发场景仍可能提前复用
正确调用record_stream + 正确跨流等待显存块在所有相关流上都标记了依赖缓存复用安全,跨流顺序有保障

2. 多stream并发为什么必须见event?

2.1 CUDA事件在多流协作里的地位

CUDA stream是GPU上的任务队列,每个队列里的kernel和memcpy按顺序执行,但不同stream之间默认是互相独立的。想让两个stream之间产生先后关系,唯一的正规方式就是用事件(event)建立依赖。

流程很简单:先在stream A上cudaEventRecord(evt, streamA),把这个事件标记插入A的队列;然后在stream B上调用cudaStreamWaitEvent(streamB, evt),让B的后续任务等在evt之后。这样B虽然还是异步提交,但GPU调度器会保证B里排在wait_event之后的kernel,不会早于A里record事件之前的任务完成。

如果你接触过纯CUDA编程,对这个应该不陌生。但在PyTorch里,很多人会忽视这一点,因为PyTorch的自动内存管理把这些底层操作封装得太圆滑了。with torch.cuda.stream(s1):这种写法只是把当前执行环境切到s1,并不会自动把s1的依赖关系同步给其他stream。s2该不等还是不等。

我后来总结了一句话:stream是流水线,event是流水线之间的交接信号。没有交接信号,两条流水线各跑各的,共享的物料就一定会抢。

2.2 为什么只调record_stream还不够

有朋友可能会问:既然record_stream内部会给显存块挂事件,那分配器在复用前会检查事件完成状态,是不是我只要调了它就行,不用再调wait_event?

这个想法对了一半,但恰恰是另一半导致了问题。

record_stream管的是内存池的复用安全。它的意思是:这块显存虽然没人引用了,但某个stream上还有活没干完,先别把它发给别人。它不会主动去阻塞另一个stream的kernel提交,也不会改变两个业务流的调度顺序。

看一个典型场景:两个stream从同一个缓存池里拿到同一块物理显存。分配器在s1分配时调用了record_stream(s1),所以s2要分配时,理论上会等s1的事件完成才把这块给出。但如果你不是通过分配器拿到这块内存,而是自己维护了一个buffer pool,提前把同一块显存分别交给了s1和s2,那么record_stream根本管不到你的业务调度。此时,s2上的kernel该写入还是会写入,s1上的kernel可能还没写完。

就算用的是分配器,record_stream也不是银弹。分配器能保证“这块内存在事件完成前不再分给新的分配请求”,但它不能保证“你新分配的这块内存不会和另一个还活着的tensor重叠”。后者靠的是分配器对活动块的追踪,前者靠的是record_stream的延迟归还。两者职责不同,容易混为一谈。

所以,在多stream并发里,你真正需要在代码层面补上的,是显式的跨流等待:把s1上的完成事件挂到s2的队列头部,让s2后续所有共享显存的操作都排在s1的事件后面。这正是标题里说的“要用wait_event”。

2.3 一条完整的安全依赖链

把事件依赖画出来,整个逻辑其实很清晰:

  1. 在stream1上执行写buffer的kernel。
  2. 在stream1上evt.record(stream1),给“写buffer完成”打个标记。
  3. 在stream2上stream2.wait_event(evt),让stream2的后续任务等待这个标记。
  4. 只有evt完成后,stream2上读/写buffer的kernel才会真正执行。
  5. 同时,在buffer可能被释放前调用buffer.record_stream(stream1),确保显存池不会提前把这个块分给别人。

注意第3步和第5步是两件事。第5步保护的是“不能让其他分配拿到这块地址”,第3步保护的是“即使是同一个buffer,stream2也不能抢跑”。两者配合,才算完整。

我顺手总结了一个通俗版依赖时刻表:

顺序stream1stream2
T0写buffer的kernel入队执行wait_event(evt),阻塞后续任务
T1evt.record(),标记写完成点继续等待
T2做其他与buffer无关的任务前序事件完成,buffer读取kernel开始执行

3. 从报错到修复:一个真实的多流复现

3.1 只看record_stream的坏代码

为了把问题复现清楚,我写了一个简化版本。这个例子不去追求模型复杂度,只保留多stream共享显存的核心矛盾:

import torch import torch.cuda as tc def bad_case(): s1 = torch.cuda.Stream() s2 = torch.cuda.Stream() # 在默认流上分配一块显存,后续两个流都要使用 buf = torch.empty(4096, device="cuda") with torch.cuda.stream(s1): # 模拟stream1向buffer写入数据 buf.fill_(1.0) # 告诉分配器,这块内存在s1上还有依赖 buf.record_stream(s1) with torch.cuda.stream(s2): # 模拟stream2立刻向同一块buffer写入数据 # 这里没有任何事件等待,s2可能抢在s1的fill_完成前执行 buf.fill_(2.0) torch.cuda.synchronize() # 最终buf是1还是2?完全不确定。 print(buf[:8])

这段代码的bug很明显:s2上的fill_(2.0)是异步提交的,它入队时s1的fill_(1.0)不一定执行完。两个kernel操作同一块显存,谁能先跑完全看GPU调度。就算调用了record_stream,也没能让s2排在s1后面。

3.2 用wait_event补上跨流依赖

修复方式不复杂,核心是在with torch.cuda.stream(s2):之前,让s2等待一个由s1记录的事件:

def fixed_case(): s1 = torch.cuda.Stream() s2 = torch.cuda.Stream() buf = torch.empty(4096, device="cuda") evt = torch.cuda.Event() with torch.cuda.stream(s1): buf.fill_(1.0) # 在s1上插入完成标记 evt.record(s1) # 保护显存池别提前复用 buf.record_stream(s1) # 关键:让s2等待s1上的事件,然后s2的所有后续任务排在evt之后 s2.wait_event(evt) with torch.cuda.stream(s2): buf.fill_(2.0) torch.cuda.synchronize() print(buf[:8])

这里evt.record(s1)的作用是给s1队列里“fill_完成之后”的位置做一个标记。s2.wait_event(evt)则把这个标记写成s2队列的一个前置条件。所以s2的fill_不会真正执行,直到s1的fill_执行完毕。结果必然先写入1.0再写入2.0,程序行为确定下来。

需要注意的是,这个例子里buf本身是同一个tensor,所以我能用事件等待解决。如果两个流分配的是两块地址相同、但来自缓存池不同分配路径的显存,那就要同时检查分配器日志,确认物理地址没有重叠。

3.3 为什么不用全局synchronize

有人会说,直接在两个流之间加一次torch.cuda.synchronize()不也能解决问题吗?同步确实能解决正确性问题,但代价是破坏并发。

synchronize()会阻塞CPU,并且让GPU执行队列里的所有任务都跑完才返回。在多stream流水线里,这意味着stream1的全部工作做完之前,stream2完全闲着,CPU也在空等。如果stream1有十段任务,只有第三段和buffer相关,你就被迫等全部十段完成,浪费掉后面七段的并行能力。

wait_event的好处是只建立“第三段完成”这一点上的依赖,stream1的后续任务可以继续排队执行,stream2也可以提前做和buffer无关的准备工作。我把这个思路称为细粒度同步,这是多stream优化里最有价值的地方。

实际项目里,流之间的依赖往往不是一条直线。比如stream1处理视频帧A,stream2处理视频帧B,中间只共享一个后处理buffer,那只需要在“共享buffer被写完后”和“共享buffer被读取前”这两个点上做事件依赖,其他时间两个流完全可以各自并行跑。

3.4 多消费者场景怎么处理

再往复杂一点说。如果同一块显存被三个stream依次使用,比如stream1预处理写数据,stream2推理读数据,stream3后处理再读或再写,那么最好的做法是每一步都维护事件依赖:

s1 = torch.cuda.Stream() s2 = torch.cuda.Stream() s3 = torch.cuda.Stream() evt1 = torch.cuda.Event() evt2 = torch.cuda.Event() with torch.cuda.stream(s1): data.fill_(1.0) evt1.record(s1) data.record_stream(s1) s2.wait_event(evt1) with torch.cuda.stream(s2): result = model(data) evt2.record(s2) data.record_stream(s2) s3.wait_event(evt2) with torch.cuda.stream(s3): postprocess(data, result)

这里s2等待evt1,保证看到的是s1写完整的数据;s3等待evt2,保证看到的是s2模型推理完成后的数据。每条依赖都是按需建立的,没有把一个流的全部任务都同步掉。

这个模式在纯CUDA C++里也一样,只是API换成了cudaEventRecord和cudaStreamWaitEvent。理解了事件依赖的原理,换语言只是换个写法。

4. 常见故障排查与避坑速查

4.1 三个典型报错长什么样

多stream显存冲突的症状并不是每次都一样,我这次项目里就遇到了三种:

第一种是偶发CUDA error: an illegal memory access was encountered。这种最吓人,因为报错地址随机,换一次运行可能发生在不同kernel上。排查半天如果发现kernel逻辑没问题,大概率就是显存块被两个stream同时用了。

第二种是misaligned address。有时候不是地址本身没对齐,而是因为显存块被覆盖,你读到的是一个被其他数据篡改过的指针值或张量metadata。多stream复用时这类错也很常见。

第三种最坑,没有任何报错,但推理结果里夹杂上一批数据。原因是上一轮kernel还没写完,这一轮就已经开始复用同一块地址,导致一部分数据是旧的。这种问题在torch.cuda.synchronize()隐藏很深的pipeline里特别难发现。

4.2 定位多流显存冲突的几条路子

遇到上述症状,第一反应不要急着改kernel,先怀疑“谁和谁共享了同一块显存”。这里有几个有效的排查方法,按成本从低到高排:

  1. 打开CUDA_LAUNCH_BLOCKING=1,让所有kernel变为同步启动。如果问题变成必定复现或消失,方向基本锁定在异步并发调度上。
  2. 在分配前后打印关键tensor的data_ptr()。如果两个stream拿到的tensor地址相同,证明它们共享了同一块物理显存。
  3. 临时把显存缓存分配关掉或调整,比如设置PYTORCH_NO_CUDA_MEMORY_CACHING=1,或不断调用torch.cuda.empty_cache()观察问题是否变化。
  4. 用compute-sanitizer(老工具叫cuda-memcheck)跑一遍,它通常能直接指出第一次非法访问发生在哪个kernel、哪个地址。
  5. 在代码里临时插入torch.cuda.synchronize(),如果同步后问题消失,基本可以确认是跨流依赖缺失。

我自己最常用的手段是前两个,因为它们不需要额外工具,打几行日志就能定位。先确认data_ptr()是否重叠,如果重叠再看两个stream之间有没有事件依赖,大部分问题立刻浮出水面。

4.3 多流编程的避坑清单

吃了一次大亏之后,我给自己列了一张多流编程的避坑清单,现在写代码都会对着过一遍:

  • record_stream只能解决显存池的延迟归还问题,不能解决业务流之间的顺序问题。跨流共享数据时,必须显式用wait_event或wait_stream建立依赖。
  • record_stream要放在正确的stream上下文中调用,最好显式传入实际使用该tensor的stream,不要依赖当时的current stream。
  • 一个tensor被多个stream使用时,最好对每个使用它的stream都调用record_stream。漏掉任何一个,分配器都可能在那条漏掉的流上提前复用显存。
  • 不要对大量小tensor频繁调用record_stream。事件对象本身有开销,记录太多会导致显存迟迟不回收,反而把显存占用量顶上去。
  • 多线程场景下,CPU线程各自操作stream时要确保对应的stream对象和事件对象生命周期合法,PyTorch的Stream和Event默认不跨线程自动同步。

我用一个表把这些要点收拢起来,方便快速查阅:

症状最可能原因排查重点
偶发illegal memory access两块活动tensor物理地址重叠打印data_ptr,检查分配日志
输出混入上一轮数据上一轮kernel未完成就开始复用buffer补record_stream + wait_event
加了record_stream仍出问题调用位置/stream不正确,或缺少跨流等待检查调用上下文,补wait_event
显存占用异常上涨事件记录过多,块迟迟不复用删除不必要的record_stream,或显式同步
并发下性能反而变差同步粒度过大,比如频繁synchronize改用细粒度event,只同步必要依赖点

4.4 一个能保命的小习惯

我后来养成的习惯是:在写任何多stream代码之前,先在纸上画一张“资源-流”依赖图。横轴画stream,纵轴画共享资源,哪个stream在哪个时刻读、哪个时刻写,一目了然。画完图再写代码,基本能提前发现八成以上的依赖缺失问题。

举个例子,如果图上出现“同一块buffer,stream1在T2要写,stream2在T1要写”,那你就知道必须在T1和T2之间加一个事件依赖。如果图上只有stream1自己碰这块buffer,那record_stream就够用了,不需要额外的事件等待。这个习惯看着简单,但比记一堆API细节靠谱得多。

这次排错最后只改了几行代码,但查错过程花了两天。表面上是record_stream和wait_event两个API配合的事,背后却是对CUDA流调度模型的理解。多stream的并行度很诱人,可一旦共享资源没有明确的依赖关系,它回报给你的就是这种随机崩溃和脏数据。希望这篇记录能帮你省掉那两个熬夜排查的晚上。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询