ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

PyTorch多流并发必看:record_stream与wait_event的区别及正确用法

PyTorch多流并发必看:record_stream与wait_event的区别及正确用法 先说结论多个 CUDA streams 交错使用同一个 tensor 的时候record_stream只是给内存分配器上了一道保险真正决定执行顺序的是wait_event。我在一次训练管线里只调了tensor.record_stream(...)以为显存不会再被提前回收结果 loss 突然爆炸、cuda illegal memory access 乱跳折腾了一整天才搞清楚多流并发不仅要考虑“显存块会不会被提前复用”还要考虑“消费流有没有等生产流把数据写完”。这两件事经常被当成一个问题处理实际上是两套完全不同的机制。这篇文章适合正在写 PyTorch 多流训练、异步数据预取、自定义推理管线的人。尤其是那种代码里已经出现了两三个Stream、Event、record_stream但问题仍然时隐时现的场景。我先把这个坑的完整来龙去脉写清楚再给出一套可以直接抄走的正确骨架。1. 这个坑是怎么踩出来的一次典型的多流崩溃1.1 症状不是必现而是“随机灵异”我最开始用多流的目的是常规的异步数据预取一个 stream 负责把下一批数据从 CPU 搬到 GPU另一个 stream 负责实际的模型 forward两者重叠理论上能把 H2D 拷贝时间隐藏掉。代码写完后第一次跑没问题第二次跑没问题可一旦加大 batch size、增加数据读取耗时、或者换一台显存更紧张的机器问题就来了。典型症状有三种。第一种是 loss 在某个 step 突然变成一个离谱的数值然后下一轮又恢复正常第二种是程序跑着跑着直接报CUDA error: illegal memory access was encountered第三种更隐蔽没有任何报错但结果精度不对而且每次跑错的位置都不一样。这种“随机灵异”的状态就是流同步问题最典型的特征因为它跟 GPU 异步执行的实际时序强相关换个环境、换一个 kernel 的耗时甚至换一个 CUDA driver 版本表现都可能不同。1.2 排查过程从显存占用到 CUDA Illegal Memory Access我最初的判断是显存泄漏或者模型里有某个临时 tensor 生命周期没管好。于是先看显存占用曲线发现并没有持续上涨接着用CUDA_LAUNCH_BLOCKING1跑把每个 CUDA kernel 变成同步执行结果问题消失了。这基本就锁定了是异步执行顺序的问题。后来我尝试把所有with torch.cuda.stream(...)全部换成默认流问题同样消失。到这里其实已经能确认问题来自不同 stream 之间缺少依赖关系。但我当时的代码里明明已经调用了tensor.record_stream(compute_stream)为什么还是不对这就是我在标题里写到的掉坑点record_stream被很多人包括当时的我当成了一种“跨流同步”的工具但它其实管不了执行顺序。1.3 根因隐式顺序 提前回收实际根因有两个而且它们会叠加。第一个问题是预取流上的 H2D 拷贝和计算流上的 forward 没有建立依赖。CUDA 只保证同一个 stream 内部的操作按顺序执行不同 stream 之间如果没有event、wait_event这类同步原语执行顺序是可以乱掉的。也就是说计算流这边的 kernel 完全可能跑到预取流把数据拷贝完之前就开始读数据。这个现象不是“理论上可能”而是在显存压力大、kernel 调度复杂时非常常见。第二个问题是tensor 的 Python 引用在迭代循环里一旦被覆盖底层的显存块就回到缓存分配器手里。分配器不知道这块内存在计算流上还在被使用于是可能把这块显存分配给下一轮的预取数据被覆盖计算流上的老 kernel 读到的就是坏数据。这里record_stream确实应该登场但它只解决“分配器提前复用内存”这个问题不解决“计算流没等预取流完成”的问题。2. record_stream 的真实身份内存生命周期标记2.1 分配器到底在等什么要理解record_stream得先知道 PyTorch 的 CUDACachingAllocator 是怎么管理显存的。它有一个大块缓存池tensor 的 Python 对象析构时底层的显存块不是立刻还给驱动而是放进分配器自己的空闲池里。下一次如果有形状匹配的分配请求这个块会被直接复用省掉 cudaMalloc / cudaFree 的开销。问题就出在这个复用上。分配器并不知道某块显存是否还被某个异步流上的 kernel 使用。如果一个块在 stream A 上刚被读取但回收动作发生在 stream B 的分配请求里分配器有可能在 stream A 还没执行完时就把这块内存给出去。真实场景里这个间隔非常短所以错误往往延迟显现。PyTorch 对此有自己的处理逻辑每个缓存块会记录“最后使用它的 stream”释放时如果发现当前分配的 stream 不是该块最后被使用的 stream会先插入一个事件并等待它完成。这种机制能挡住一部分问题但它依赖一个前提——必须有人告诉分配器“这块内存会在哪个 stream 上被继续使用”。跨流场景里分配器自己往往是不知道的。2.2 record_stream 会做什么、不会做什么tensor.record_stream(stream)的作用很简单向分配器注册一个信息——这块 tensor 的显存还会在指定的stream上被使用你暂时别把它复用掉。等到分配器真正需要复用这块内存时它会在对应 stream 上插入事件并确保相关操作完成然后才释放或复用。说得直白一点record_stream是“内存生命周期管理”工具不是流同步工具。它不会让某一个 stream 停下来等另一个 stream不会保证两个流上的 kernel 谁先谁后也不会在你的计算流里插入任何等待动作。它只是让分配器在“回收内存”这个动作发生之前先对指定流做一次等待。所以会出现一个很迷惑的现象代码里已经调用了record_stream显存也确实没有被提前复用但计算流上的 kernel 仍然在数据还没准备好的时候就开始执行了。因为 kernel 早就排队排在队列里了它根本不在乎内存块是否安全它在乎的只是数据有没有被生产流写出来。2.3 什么时候不要碰 record_stream不是所有多流场景都需要record_stream。比如数据只在默认流上从头用到尾默认流本身就是顺序执行的分配器也知道这块内存在默认流上这时候再调record_stream就是多余且有害的——它会让分配器额外记录流、额外插入事件增加一次事件开销。还有两种情况我也踩过。一种是在循环里对同一个 tensor 反复调用record_stream每次都注册一个流事件越积越多GPU 上的时间戳记录会被不断添加虽然不会立刻报错但会白白拖慢分配器的跟踪速度。另一种是 tensor 已经在新流上开始使用之后很久才调用record_stream这时候旧流水账已经发生调用只是亡羊补牢风险并没有被消除。我的经验是record_stream只在两种场景下用。第一种tensor 的生命周期跨度超过当前流比如跨流预取、跨流后处理第二种tensor 的 Python 引用可能在 GPU 操作完成前消失比如作为异步队列里临时存放的中间结果。其他情况下能不加就不加。3. wait_event 才是跨流握手的核心3.1 event 的一套完整用法CUDA 里跨流同步的标准姿势是 event。流程是生产流做完关键操作后把一个 event 记录到生产流上消费流在执行后续操作之前调用一次wait_event等待这个 event。event.record(stream)不会阻塞生产流本身它只是在生产流里插入一个标记stream.wait_event(event)则会让这个 stream 后续排队的 kernel 全部等到 event 被触发后再执行。PyTorch 里的写法也不复杂# 创建事件 producer_done torch.cuda.Event() with torch.cuda.stream(producer_stream): # 生产流上的关键操作拷贝、预处理、切分等 batch produce() producer_done.record(producer_stream) # 关键让消费流等待生产流完成 consumer_stream.wait_event(producer_done) with torch.cuda.stream(consumer_stream): output model(batch)这里要注意一个很容易写错的位置wait_event一定要作用在你想让它等待的 stream 上。很多人图省事写torch.cuda.current_stream().wait_event(ev)如果当前流根本不是 consumer_stream这句代码就等于没有执行。我自己的习惯是直接对具体的 stream 对象调用wait_event不用 current_stream 转一手。还要注意 event 的 record 时机。event 必须最后记录在生产流的关键操作之后而不是在进入流上下文之前否则它只能代表流里一个很靠前的执行点起不到等待完整生产动作的作用。3.2 为什么只用 wait_event 也不够那是不是只要用了wait_event就可以完全不用record_stream也不是。wait_event保证的是消费流上的 kernel 不会在生产流完成之前启动它解决的是“读取顺序”问题。但内存生命周期是另一条线如果 batch 这个 tensor 在计算流上被 kernel 使用了之后它的 Python 引用被覆盖分配器仍然可能在计算流完成前把这块显存复用到别的地方。更典型的场景是消费流上用到 batch 的 kernel 已经排队了但消费流本身还没执行到那个 kernel此时分配器如果判断这个块“空闲”就可能在另一个分配流上把它拿走。wait_event并不能自动通知分配器“这块内存在消费流上还有使用计划”它只约束 kernel 的执行顺序不约束缓存分配器的回收行为。所以正确答案不是二选一而是两件事都要做用wait_event建立跨流执行依赖用record_stream保护跨流内存生命周期。一个管“谁先谁后”一个管“内存块别被提前复用”。3.3 一张表理清 record_stream 与 wait_event 的分工我用一张表总结一下这两者的核心区别方便你贴在代码文件附近维度record_streamwait_event本质内存生命周期标记流间执行顺序依赖影响对象CUDACachingAllocator 的显存复用逻辑CUDA 流的 kernel 排队顺序解决问题内存块被提前复用、数据被覆盖消费流读到未完成的数据调用位置tensor 还活着的时候指定目标流消费流进入关键计算之前是否可以替代对方不能不能典型使用时机把 tensor 交给另一个流使用时消费者执行前等待生产者事件完成记住一句话凡是跨流传递 tensor而你又不确定它的执行和释放属于哪条流就默认套这两件事——消费流wait_event生产者事件tensorrecord_stream消费流。这两个动作组合起来才能把多流并发这个“表面并行”的局面用安全的方式撑起来。4. 完整修正实例多流预取 计算的正确骨架4.1 错误的写法下面这个例子非常接近我踩坑时的代码看起来没什么大问题跑起来却随机崩。import torch prefetch_stream torch.cuda.Stream() compute_stream torch.cuda.Stream() def prefetch_next(loader): with torch.cuda.stream(prefetch_stream): data next(loader).cuda(non_blockingTrue) return data for step in range(total_steps): batch prefetch_next(loader) with torch.cuda.stream(compute_stream): output model(batch) loss output.sum() loss.backward()这段代码犯了三个错误第一batch在prefetch_stream上通过 H2D 拷贝生成但compute_stream上没有等待这个拷贝完成的事件。计算流上的 forward 和预取流上的拷贝之间存在竞态。第二batch这个 Python 对象在下一轮循环里会被新的batch变量覆盖旧的显存块回到分配器。分配器不知道它还在compute_stream上被使用可能把它复用给下一轮预取。第三如果下一轮预取确实复用了同一块显存那么前一轮还在排队的 kernel 就会读到被覆盖的数据表现出来就是 loss 爆炸或者非法内存访问。4.2 修正后的写法正确的做法是在错误代码基础上补两个动作消费流等待生产事件、tensor 标记目标流。import torch prefetch_stream torch.cuda.Stream() compute_stream torch.cuda.Stream() # 复用同一个 event避免循环里反复创建 Event prefetch_done torch.cuda.Event() def prefetch_next(loader): with torch.cuda.stream(prefetch_stream): data next(loader).cuda(non_blockingTrue) prefetch_done.record(prefetch_stream) return data for step in range(total_steps): batch prefetch_next(loader) # 核心修复 1计算流等待预取流完成 compute_stream.wait_event(prefetch_done) with torch.cuda.stream(compute_stream): output model(batch) # 核心修复 2告诉分配器batch 这块内存在 compute_stream 上还会被使用 batch.record_stream(compute_stream) # 这里必须同步否则 loss 的求值可能在 GPU 上还没执行完 loss output.sum() loss.backward()逐行解释一下关键点prefetch_done.record(prefetch_stream)发生在预取流完成 H2D 拷贝之后它标记了“这批数据已经准备好了”。compute_stream.wait_event(prefetch_done)发生在计算流执行任何实质性操作之前它让计算流等候预取完成。batch.record_stream(compute_stream)发生在计算流上的模型已经开始使用 batch 后它把这个流信息注册给分配器即使之后 batch 的 Python 引用消失分配器也知道这块内存不能提前复用。还有一个必须提醒的细节loss.backward()后面如果还要在 CPU 上读 loss应该加torch.cuda.synchronize()或loss.item()本身会触发同步。多流并发的代码里CPU 侧认为循环结束GPU 侧可能还积压着一堆未执行的 kernel这种“CPU 跑到前头”的问题是另一类常见的隐性 bug。4.3 更进一步流水线双缓冲的注意点前面例子是最小的修正版。真实场景如果你想同时预取下一批、计算当前批就需要双缓冲甚至多缓冲而不是每轮等上一轮全部跑完。双缓冲的核心是为每个 buffer 单独维护事件状态生产流和消费流通过两个 event 交替等待。prefetch_stream torch.cuda.Stream() compute_stream torch.cuda.Stream() ev [torch.cuda.Event() for _ in range(2)] buf [None, None] for step in range(total_steps): cur, nxt step % 2, (step 1) % 2 # 等待上一轮这个 buffer 在计算流上的操作完成 compute_stream.wait_event(ev[cur]) with torch.cuda.stream(prefetch_stream): buf[cur] next(loader).cuda(non_blockingTrue) ev[cur].record(prefetch_stream) compute_stream.wait_event(ev[cur]) with torch.cuda.stream(compute_stream): if buf[nxt] is not None: output model(buf[nxt]) buf[nxt].record_stream(compute_stream) # 保活缓存里的 tensor 引用不能丢这个双缓冲代码里最容易被忽略的是“复用 ev”时机的控制。如果你在上一轮事件还没被消费时就重新record同一个事件会让等待方产生错误行为。稳妥的做法是每个 buffer 配一个独立事件循环里轮换使用确保当前流程里不会有重复记录同一个 event 的窗口。事件对象的创建开销不算大但循环量大了以后还是尽量复用固定事件不要每轮 new 一个。5. 排查技巧与我的避坑清单5.1 三招快速确认是不是流依赖问题遇到“随机崩、偶发错”这种问题我建议按下面三个动作排查能快速定位是不是多流依赖导致第一招把所有with torch.cuda.stream(...)暂时去掉改成默认流执行。如果问题消失基本可以确定是流相关问题如果问题还在先去查算法或数据处理逻辑。第二招设置环境变量CUDA_LAUNCH_BLOCKING1运行。这个变量会让所有 CUDA kernel 同步执行把异步掩盖掉。多流依赖问题因为有这个变量通常会消失。需要注意它只适合排查不能作为正式运行配置否则性能会崩盘。第三招如果出现了illegal memory access用 NVIDIA 的compute-sanitizer做内存检测。命令是compute-sanitizer --tool memcheck python train.py。它会把非法访问发生的位置精确报出来能直接看到是哪个 tensor 对应的内存块被非法访问往往一眼就能看出是“另一个流提前用了这块内存”。这三招层层递进前两招是粗筛第三招是精确定位。我在实际项目里用第三招抓到过不少次“use-after-free”式的显存问题比肉眼读日志效率高得多。5.2 常见故障速查表我把这几年做多流并发时遇到的典型问题整理成一张表你可以直接当速查手册用症状可能原因正确做法loss 偶发增大结果对不上消费流没有等生产流完成就读取数据消费流wait_event生产事件cuda illegal memory access显存块被分配器提前复用数据被覆盖跨流 tensor 调用record_stream显存占用持续上涨循环里创建了太多 Event或过度使用 record_stream复用固定 Event减少冗余标记去掉多流后问题消失不同 stream 间缺失同步依赖用 wait_event 建立依赖CUDA_LAUNCH_BLOCKING1 后正常异步执行顺序问题检查跨流同步与内存生命周期双缓冲后数据错乱event 被提前重复记录或等待了错的事件每个 buffer 独立事件严格按序轮换这里特别提醒一句record_stream并不是调用得越多越安全。有一次我在每个 batch 的多个输出 tensor 上都做record_stream结果显存占用和高频事件开销把性能拖垮了收益却几乎看不到。合理的做法是只对真正跨流传递、生命周期可能交错的 tensor 做标记不要写成“走过路过都要记一笔”。5.3 一点不算总结的忠告从我个人的经验看多流并发的难点不在于“把代码写对”而在于“把异步模型彻底装进脑子里”。你在 CPU 侧看到的执行顺序和 GPU 侧真正的执行顺序是两回事你不能指望编译器或框架替你兜底。我现在写代码时已经养成了一套肌肉记忆每建一个多流场景先画一条生产者、消费者的时间线生产流落 Event消费流 wait Event跨流 tensor 全部record_stream最后在 CPU 读结果前显式同步。这套模板一开始写起来会慢一点但它让我少熬了无数个排查灵异 bug 的夜。如果你正踩在同样的坑里不妨直接把上面的正确骨架拿去改到你的代码里先跑通再去抠细节。
返回列表