前两天调一套多路视频推理服务,遇到一个非常折磨人的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 一条完整的安全依赖链
把事件依赖画出来,整个逻辑其实很清晰:
- 在
stream1上执行写buffer的kernel。 - 在
stream1上evt.record(stream1),给“写buffer完成”打个标记。 - 在
stream2上stream2.wait_event(evt),让stream2的后续任务等待这个标记。 - 只有evt完成后,stream2上读/写
buffer的kernel才会真正执行。 - 同时,在
buffer可能被释放前调用buffer.record_stream(stream1),确保显存池不会提前把这个块分给别人。
注意第3步和第5步是两件事。第5步保护的是“不能让其他分配拿到这块地址”,第3步保护的是“即使是同一个buffer,stream2也不能抢跑”。两者配合,才算完整。
我顺手总结了一个通俗版依赖时刻表:
| 顺序 | stream1 | stream2 |
|---|---|---|
| T0 | 写buffer的kernel入队 | 执行wait_event(evt),阻塞后续任务 |
| T1 | evt.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,先怀疑“谁和谁共享了同一块显存”。这里有几个有效的排查方法,按成本从低到高排:
- 打开
CUDA_LAUNCH_BLOCKING=1,让所有kernel变为同步启动。如果问题变成必定复现或消失,方向基本锁定在异步并发调度上。 - 在分配前后打印关键tensor的
data_ptr()。如果两个stream拿到的tensor地址相同,证明它们共享了同一块物理显存。 - 临时把显存缓存分配关掉或调整,比如设置
PYTORCH_NO_CUDA_MEMORY_CACHING=1,或不断调用torch.cuda.empty_cache()观察问题是否变化。 - 用
compute-sanitizer(老工具叫cuda-memcheck)跑一遍,它通常能直接指出第一次非法访问发生在哪个kernel、哪个地址。 - 在代码里临时插入
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的并行度很诱人,可一旦共享资源没有明确的依赖关系,它回报给你的就是这种随机崩溃和脏数据。希望这篇记录能帮你省掉那两个熬夜排查的晚上。