尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

PyTorch多流训练中record_stream与wait_event的协同机制

PyTorch多流训练中record_stream与wait_event的协同机制 1. 项目概述为什么 record_stream 不是“记个账”那么简单在 PyTorch 的 CUDA 异步执行世界里“record_stream” 这个 API 名字起得实在太有迷惑性了——它听起来就像在日志本上随手写一笔“这张 tensor 是在 stream A 上诞生的”轻描淡写毫无压力。我第一次用它时也是这么想的直到模型在多卡训练中开始随机崩掉报错信息像幽灵一样飘在 terminal 里CUDA error: an illegal memory access was encountered或者更狡猾的device-side assert triggeredGPU 显存占用曲线像心电图一样忽高忽低但就是找不到具体哪一行代码在作祟。折腾三天后我才真正读懂record_stream四个字母背后压着的不是记录而是责任。核心问题就藏在标题那句“掉坑”里当你在 PyTorch 中创建一个 tensor比如从 DataLoader 读出的图像 batch它的内存默认绑定在当前默认 CUDA stream通常是torch.cuda.default_stream()上。但如果你后续用torch.cuda.Stream()创建了多个自定义 stream比如 stream_A 做数据预处理stream_B 做前向计算stream_C 做梯度更新并让不同操作在不同 stream 上并发跑那么 tensor 的生命周期管理就立刻变得极其脆弱。record_stream的作用不是给 tensor “贴个标签”而是向 PyTorch 的内存管理器发出一个明确指令“请把这张 tensor 的内存释放时机推迟到这个特定 stream 执行完毕之后”。换句话说它是在告诉 GC“别急着回收这张显存还在 stream B 上干活呢等它干完你再收。”而“多 CUDA streams 时要用 wait_event”这个结论正是从无数个record_stream被误用的血泪教训里淬炼出来的。wait_event不是可选项它是record_stream的强制搭档。没有wait_event的record_stream就像给一辆高速行驶的赛车只装了油门没装刹车——它能跑但随时可能撞墙。我见过太多团队在做混合精度训练、Pipeline 并行、或者自定义 CUDA kernel 时为了追求极致吞吐把 stream 拆得细之又细结果却因为漏掉了一个wait_event导致 tensor 在某个 stream 上还没用完另一个 stream 就把它背后的显存给重用了最终触发非法访问。这种 bug 极其隐蔽它不总在每次运行时复现可能只在 batch size 变大、GPU 负载升高、或者特定硬件驱动版本下才露头调试成本远超预期。所以这篇文章不是讲一个 API 的用法而是讲一套 GPU 内存安全的生存法则。它适合所有正在用 PyTorch 做高性能计算的开发者如果你的代码里出现了torch.cuda.Stream()或者你在看 Hugging Face 的 Trainer 源码、DeepSpeed 的 ZeRO-3 实现、或是自己手写 CUDA kernel那你已经站在了这个坑的边缘。这篇文章会带你亲手把坑填平不是靠玄学猜测而是靠对 CUDA 流模型和 PyTorch 内存管理机制的透彻理解。接下来我会一层层拆开这个“坑”的地质结构告诉你为什么record_stream必须和wait_event绑定使用它们在底层究竟发生了什么以及如何用最稳妥的方式在你的项目里一劳永逸地避开它。2. 核心原理拆解CUDA Stream、Event 与 PyTorch 内存管理的三角关系要真正理解record_stream和wait_event的共生关系必须先看清它们所处的底层舞台——CUDA 的流Stream与事件Event模型以及 PyTorch 如何在这个舞台上构建自己的内存管理逻辑。这三者构成一个精密的三角关系任何一角失衡整个系统就会崩溃。2.1 CUDA Stream不是“线程”而是“执行队列”很多初学者会把 CUDA stream 误解为 GPU 上的“线程”。这是根本性的错误。CUDA stream 本质上是一个有序的、异步的命令队列。你可以把它想象成一条单行道上面只能按顺序排队行驶车辆即 kernel launch、memory copy 等操作。GPU 的硬件调度器会保证同一条 stream 上的命令严格按照提交的先后顺序执行但不同 stream 之间的命令只要没有显式同步就可以完全并发、乱序执行。这就是 GPU 高性能的基石数据搬运H2D, D2H、kernel 计算、甚至不同 kernel 之间都可以在物理上重叠overlap极大提升硬件利用率。举个生活化的例子一家餐厅的厨房。默认 stream 就像主厨亲自盯的“招牌菜专用车道”所有步骤备料、炒制、装盘必须严格按顺序来。而你创建的stream_A和stream_B则像是两条独立的“外卖打包专线”。stream_A负责把刚出锅的菜快速分装进保温盒对应数据预处理stream_B则负责同时打印订单小票并贴上快递单对应模型前向计算。这两条线互不干扰可以并行工作。但如果stream_A还没把菜装好stream_B就把空盒子拿去发快递了那客户收到的就是一个空盒——这正是非法内存访问的根源。PyTorch 默认为你创建了一个全局的default_stream所有tensor.cuda()、model.forward()等操作都默认在这个 stream 上排队。当你显式创建新 stream 时你就是在开辟新的“专线”。但关键在于PyTorch 的 tensor 对象本身并不“属于”某一个 stream它只属于一块显存地址。而这块显存的“所有权”和“释放权”是由 PyTorch 的内存管理器Allocator动态决定的。2.2 CUDA Event流间的“交通灯”与“时间戳”如果 stream 是车道那么 CUDA Event 就是安装在车道上的智能交通灯和高精度计时器。一个 event 本质上是一个 GPU 上的轻量级同步点。你可以对它做两件事record()和wait()。record(event, stream)表示“请把这个 event 的时间戳打在stream当前执行到的位置”。wait(event, stream)表示“请让stream暂停执行直到event被前面某个 streamrecord过”。这个机制的精妙之处在于它实现了跨 stream 的、非阻塞的依赖关系。stream_B.wait(event)并不会让stream_B整个停下来干等它只是把stream_B后续的所有命令都挂起在 event 这个“关卡”后面一旦event被stream_Arecord过stream_B就立刻放行继续执行。这比stream_B.synchronize()让整个 stream 等待所有命令完成高效得多因为它只等待一个精确的点而不是整条流水线。在 PyTorch 的语境下torch.cuda.Event就是对 CUDA Event 的封装。当你调用event.record(stream)PyTorch 会在底层调用cudaEventRecord当你调用stream.wait_event(event)PyTorch 会调用cudaStreamWaitEvent。这个wait_event就是record_stream的“安全阀”。2.3 PyTorch 的内存管理器谁在决定“这块显存能不能被回收”这才是整个故事的核心。PyTorch 的显存分配器如CachingAllocator并不是一个简单的“谁申请谁释放”的傻瓜式管理器。它采用了一种基于stream dependency的延迟释放策略。当一个 tensor 被创建时它的显存块会被标记为“活跃”并关联到当前的 default stream。当这个 tensor 的引用计数降为 0比如你del tensor或函数返回后局部变量消失PyTorch 并不会立刻调用cudaFree。相反它会检查这个 tensor 是否被record_stream过。如果被记录过那么这块显存的释放就会被“挂起”并绑定到那个被记录的 stream 上。只有当那个 stream 完全执行完毕或者说当该 stream 上所有已提交的命令都已完成这块显存才会被真正回收。这里的关键陷阱在于record_stream只建立了“释放依赖”但没有建立“使用依赖”。它告诉内存管理器“这块显存请等到 stream_B 结束再释放”但它并没有告诉 stream_B“这块显存请等到 tensor 的所有相关操作都做完再开始用”。如果 stream_B 在 tensor 还没被record_stream绑定之前就提前开始了对它的读写或者在record_stream之后stream_B 上还有其他 kernel 会修改这块显存而这些 kernel 的执行顺序又无法被record_stream保证那么灾难就发生了。wait_event正是来填补这个空白的。它的作用是在 stream_B 上插入一个精确的同步点确保 stream_B 上所有后续操作都发生在event被record之后。而这个event我们通常是在 tensor 被正确初始化、且所有前置依赖都满足之后由另一个 stream比如负责数据加载的 stream_A来record的。这样wait_event就在逻辑上为 tensor 的“首次安全使用”划定了一个清晰的边界。提示record_stream的本质是“延长显存生命周期”而wait_event的本质是“约束 kernel 执行时机”。两者缺一不可共同构成了 GPU 内存安全的双保险。3. 实操场景还原一个典型的“掉坑”案例与安全修复纸上谈兵不如现场拆解。下面我将用一个高度还原真实生产环境的案例带你一步步走进那个经典的“掉坑”现场然后亲手把它填平。这个案例模拟了一个常见的数据增强 pipelineCPU 上进行基础解码GPU 上进行耗时的随机裁剪和色彩抖动最后送入模型。为了最大化吞吐我们希望数据加载、预处理、模型计算三者尽可能重叠。3.1 “掉坑”代码看似优雅实则危险import torch import torch.nn as nn from torch.utils.data import Dataset, DataLoader import numpy as np # 模拟一个耗时的数据增强 kernel实际中可能是自定义 CUDA kernel def gpu_random_crop_and_jitter(tensor: torch.Tensor, stream: torch.cuda.Stream) - torch.Tensor: # 这里省略具体实现假设它在指定 stream 上执行 with torch.cuda.stream(stream): # 模拟一个需要时间的 kernel result tensor * 1.0 torch.randn_like(tensor) * 0.1 return result class UnsafeDataset(Dataset): def __init__(self, data_size1000): self.data [np.random.randint(0, 256, (3, 224, 224), dtypenp.uint8) for _ in range(data_size)] def __getitem__(self, idx): # CPU 上解码得到 numpy array img_np self.data[idx] # 转为 tensor此时在 CPU 上 img_tensor torch.from_numpy(img_np).float() / 255.0 # 异步拷贝到 GPU默认 stream img_gpu img_tensor.cuda(non_blockingTrue) # 关键错误在这里就 record_stream但此时 img_gpu 还没被任何 kernel 使用 img_gpu.record_stream(torch.cuda.current_stream()) return img_gpu # 主训练循环 def unsafe_training_loop(): model nn.Sequential(nn.Linear(3*224*224, 1000)).cuda() dataset UnsafeDataset() dataloader DataLoader(dataset, batch_size32, num_workers4, pin_memoryTrue) # 创建两个自定义 stream preprocess_stream torch.cuda.Stream() compute_stream torch.cuda.Stream() for batch in dataloader: # batch 是从 dataloader 加载的已经在 GPU 上但它的 record_stream 是在 Dataset 里做的 # 现在我们想在 preprocess_stream 上做增强 enhanced_batch gpu_random_crop_and_jitter(batch, preprocess_stream) # 错误没有 wait_event直接把 enhanced_batch 交给 compute_stream with torch.cuda.stream(compute_stream): output model(enhanced_batch.view(enhanced_batch.size(0), -1)) # ... 后续 loss 计算等这段代码的问题就藏在UnsafeDataset.__getitem__的img_gpu.record_stream(torch.cuda.current_stream())这一行。它在img_gpu刚从 CPU 拷贝到 GPU 后就立刻record_stream了。但此时img_gpu的内容还只是原始图像它还没有经过任何预处理 kernel。而gpu_random_crop_and_jitter函数是在preprocess_stream上执行的它会读取batch也就是img_gpu并写入enhanced_batch。如果compute_stream上的model.forward()比preprocess_stream上的gpu_random_crop_and_jitter执行得更快这在 GPU 负载不均或 kernel 性能波动时非常常见那么model就会读取到一个尚未被正确写入的、内容混乱的enhanced_batch从而触发非法访问。3.2 安全修复方案引入 Event构建确定性依赖修复的核心思想是让compute_stream明确知道它必须等到preprocess_stream上的预处理 kernel 真正完成之后才能开始使用enhanced_batch。这就需要一个torch.cuda.Event来充当这个“完成信号”。class SafeDataset(Dataset): def __init__(self, data_size1000): self.data [np.random.randint(0, 256, (3, 224, 224), dtypenp.uint8) for _ in range(data_size)] # 预先创建一个 event复用以减少开销 self.preprocess_done_event torch.cuda.Event(enable_timingFalse) def __getitem__(self, idx): img_np self.data[idx] img_tensor torch.from_numpy(img_np).float() / 255.0 # 异步拷贝到 GPU img_gpu img_tensor.cuda(non_blockingTrue) # 关键修复1不再在这里 record_stream # 我们将在预处理 kernel 执行完毕后再 record_stream return img_gpu, self.preprocess_done_event def safe_training_loop(): model nn.Sequential(nn.Linear(3*224*224, 1000)).cuda() dataset SafeDataset() dataloader DataLoader(dataset, batch_size32, num_workers4, pin_memoryTrue) preprocess_stream torch.cuda.Stream() compute_stream torch.cuda.Stream() for batch, event in dataloader: # batch 是 CPU tensor需要先拷贝到 GPU batch_gpu batch.cuda(non_blockingTrue) # 在 preprocess_stream 上执行预处理 with torch.cuda.stream(preprocess_stream): enhanced_batch gpu_random_crop_and_jitter(batch_gpu, preprocess_stream) # 关键修复2在预处理 kernel 执行完毕后立即 record_stream # 这确保了 enhanced_batch 的显存会等到 preprocess_stream 结束才释放 enhanced_batch.record_stream(preprocess_stream) # 关键修复3在此刻 record event标记“预处理已完成” event.record(preprocess_stream) # 关键修复4在 compute_stream 上等待 event # 这确保了 compute_stream 上的所有后续操作都发生在预处理完成之后 compute_stream.wait_event(event) # 现在enhanced_batch 是安全的 with torch.cuda.stream(compute_stream): output model(enhanced_batch.view(enhanced_batch.size(0), -1)) # ... loss, backward 等这个修复方案的精妙之处在于四点延迟record_stream不再在 tensor 刚拷贝到 GPU 时就record_stream而是等到它被 kernel真正使用并产生结果即enhanced_batch之后再对结果 tensor 进行record_stream。这确保了record_stream绑定的是“最终有效数据”的生命周期而不是中间状态。record在record_stream之后event.record(preprocess_stream)必须放在enhanced_batch.record_stream(preprocess_stream)之后。因为record_stream的作用是延长显存生命周期而event.record是标记一个时间点。只有当enhanced_batch的显存被安全地绑定到preprocess_stream上之后我们才能放心地认为这个event所标记的时刻是enhanced_batch真正可用的时刻。wait_event是强制守门员compute_stream.wait_event(event)是不可省略的。它像一道闸门拦住了compute_stream上所有后续的 kernel直到event被preprocess_streamrecord。这为enhanced_batch的读取提供了绝对的时间保证。record_stream的对象是结果 tensor我们对enhanced_batch调用record_stream而不是对输入batch_gpu。因为batch_gpu在预处理 kernel 中是只读的它的生命周期由dataloader的pin_memory和non_blockingTrue保证通常不需要额外record_stream。而enhanced_batch是新生成的、需要被下游compute_stream使用的 tensor它的显存安全才是我们最关心的。注意event的创建应该尽量复用避免在循环内频繁创建销毁这会产生不必要的 GPU 开销。enable_timingFalse是为了关闭计时功能进一步提升性能。4. 深度实操指南从零构建一个健壮的多 stream pipeline理解了原理和案例现在我们来构建一个真正工业级的、可复用的多 stream pipeline。这个 pipeline 将涵盖数据加载、预处理、模型前向/反向、梯度同步等完整环节并严格遵循record_streamwait_event的黄金法则。我会提供完整的、可直接运行的代码框架并详细解释每一行的“为什么”。4.1 Pipeline 架构设计四个 stream 的协同作战一个高效的训练 pipeline通常需要至少四个 stream 来实现最大重叠data_stream专门负责从 pinned memory 异步拷贝数据到 GPU 显存。这是整个 pipeline 的“水源”。preprocess_stream负责所有 GPU 上的数据增强、归一化等预处理操作。它消费data_stream的输出。compute_stream负责模型的前向传播forward和反向传播backward。它消费preprocess_stream的输出。sync_stream负责在分布式训练中执行all_reduce等跨卡同步操作。它消费compute_stream的梯度。这四个 stream 形成一条清晰的“数据流”data_stream→preprocess_stream→compute_stream→sync_stream。每个环节的输出都通过wait_event传递给下一个环节确保数据的“新鲜度”和“一致性”。4.2 核心组件实现Event Manager 与 Stream Manager为了管理复杂的 stream 依赖我们需要一个轻量级的管理器。下面是一个经过生产环境验证的StreamManager类class StreamManager: def __init__(self): # 创建四个 stream self.data_stream torch.cuda.Stream() self.preprocess_stream torch.cuda.Stream() self.compute_stream torch.cuda.Stream() self.sync_stream torch.cuda.Stream() # 为每一对上下游 stream 创建一个 event # data - preprocess self.data_to_preprocess torch.cuda.Event(enable_timingFalse) # preprocess - compute self.preprocess_to_compute torch.cuda.Event(enable_timingFalse) # compute - sync self.compute_to_sync torch.cuda.Event(enable_timingFalse) def wait_for_data(self): 在 preprocess_stream 上等待 data_stream 完成 self.preprocess_stream.wait_event(self.data_to_preprocess) def wait_for_preprocess(self): 在 compute_stream 上等待 preprocess_stream 完成 self.compute_stream.wait_event(self.preprocess_to_compute) def wait_for_compute(self): 在 sync_stream 上等待 compute_stream 完成 self.sync_stream.wait_event(self.compute_to_sync) def record_data_done(self): 在 data_stream 上标记数据拷贝完成 self.data_to_preprocess.record(self.data_stream) def record_preprocess_done(self): 在 preprocess_stream 上标记预处理完成 self.preprocess_to_compute.record(self.preprocess_stream) def record_compute_done(self): 在 compute_stream 上标记计算完成 self.compute_to_sync.record(self.compute_stream) def get_streams(self): return { data: self.data_stream, preprocess: self.preprocess_stream, compute: self.compute_stream, sync: self.sync_stream } # 全局单例避免重复创建 STREAM_MANAGER StreamManager()这个StreamManager的设计哲学是将 stream 间的依赖关系封装成清晰、语义化的函数调用。wait_for_preprocess()比compute_stream.wait_event(preprocess_to_compute)更易读也更难出错。它隐藏了底层细节让你专注于业务逻辑。4.3 完整训练循环每一步都标注安全要点def robust_training_loop(model, dataloader, optimizer, criterion, device): 一个健壮的、多 stream 的训练循环 model.train() stream_mgr STREAM_MANAGER for batch_idx, (data, target) in enumerate(dataloader): # Step 1: 数据拷贝到 GPU (data_stream) # data 是 CPU tensor, target 也是 with torch.cuda.stream(stream_mgr.data_stream): data_gpu data.to(device, non_blockingTrue) target_gpu target.to(device, non_blockingTrue) # 关键record_stream 对于 data_gpu 和 target_gpu # 因为它们会被 preprocess_stream 和 compute_stream 使用 data_gpu.record_stream(stream_mgr.data_stream) target_gpu.record_stream(stream_mgr.data_stream) # 标记 data_stream 完成 stream_mgr.record_data_done() # Step 2: 预处理 (preprocess_stream) # 等待 data_stream 完成 stream_mgr.wait_for_data() with torch.cuda.stream(stream_mgr.preprocess_stream): # 这里可以放任何 GPU 预处理 kernel # 例如RandomResizedCrop, ColorJitter 的 CUDA 实现 processed_data your_custom_preprocess_kernel(data_gpu, stream_mgr.preprocess_stream) # 关键record_stream 对于 processed_data processed_data.record_stream(stream_mgr.preprocess_stream) # 标记 preprocess_stream 完成 stream_mgr.record_preprocess_done() # Step 3: 模型计算 (compute_stream) # 等待 preprocess_stream 完成 stream_mgr.wait_for_preprocess() with torch.cuda.stream(stream_mgr.compute_stream): # 前向传播 output model(processed_data) loss criterion(output, target_gpu) # 清零梯度 optimizer.zero_grad() # 反向传播 loss.backward() # 关键record_stream 对于所有需要在 sync_stream 上使用的梯度 # 通常我们 record_stream 所有 .grad 属性 for param in model.parameters(): if param.grad is not None: param.grad.record_stream(stream_mgr.compute_stream) # 标记 compute_stream 完成 stream_mgr.record_compute_done() # Step 4: 梯度同步 (sync_stream) # 等待 compute_stream 完成 stream_mgr.wait_for_compute() with torch.cuda.stream(stream_mgr.sync_stream): # 在分布式训练中这里会调用 all_reduce # 例如torch.distributed.all_reduce(...) # 关键sync_stream 上的操作会消费 compute_stream 的梯度 pass # Step 5: 参数更新 (default_stream) # 注意optimizer.step() 应该在 default_stream 上执行 # 因为它需要读取 compute_stream 上产生的梯度 # 但我们已经通过 wait_event 保证了梯度的可用性 optimizer.step() # 可选强制同步以测量时间仅用于 profiling # torch.cuda.synchronize()这个循环的每一个Step都是精心设计的安全堡垒Step 1data_gpu和target_gpu的record_stream是必须的因为它们是后续所有 stream 的源头。record_data_done()是preprocess_stream的“准入证”。Step 2processed_data是新生成的 tensor它的record_stream是核心。wait_for_data()确保了preprocess_stream不会去读一个尚未拷贝完成的data_gpu。Step 3这是最容易被忽视的一环。loss.backward()产生的梯度param.grad是新的 tensor它们的生命周期同样需要被管理。对每个param.grad调用record_stream(stream_mgr.compute_stream)是为了确保sync_stream在执行all_reduce时能读取到一个完整、未被覆盖的梯度值。record_compute_done()是sync_stream的“准入证”。Step 4sync_stream的存在是为了将耗时的all_reduce操作与其他计算重叠。wait_for_compute()是它的生命线。Step 5optimizer.step()放在default_stream上是因为它是一个 CPU-GPU 协同操作读取梯度、更新参数。由于前面的wait_event已经保证了梯度的可用性所以它是安全的。4.4 实操心得与避坑清单那些文档里不会写的细节在将这套方案落地到十几个不同规模的项目后我总结出以下几条血泪经验它们比任何理论都重要record_stream的对象永远是“被消费的 tensor”而不是“被生产的 tensor”。这是一个常见的思维误区。例如在output model(input)中input是被消费的output是被生产的。你应该对outputrecord_stream如果它会被下游 stream 使用而不是对input。input的生命周期通常由它的上游比如dataloader负责。wait_event的位置决定了“等待的起点”。stream_B.wait_event(event)这行代码本身是在stream_B上执行的。它意味着stream_B上这行代码之后的所有命令都会被挂起直到event被record。因此wait_event必须放在你打算使用被保护 tensor 的紧前方。把它放在with torch.cuda.stream(...):块的开头是最安全的做法。non_blockingTrue是record_stream的前提。如果你在tensor.cuda()时不加non_blockingTrue那么拷贝操作会阻塞default_streamrecord_stream就失去了意义。non_blockingTrue是开启异步世界的钥匙。torch.cuda.synchronize()是调试神器但不是解决方案。当你遇到难以复现的非法访问时可以在怀疑出问题的wait_event之后临时加上torch.cuda.synchronize()强制所有 stream 等待。如果加上后 bug 消失了那就 100% 证明是 stream 同步问题。但这只是诊断手段真正的修复永远是添加正确的wait_event。不要试图用torch.cuda.current_stream()替代显式 stream。current_stream()返回的是当前上下文的 stream但在多线程或多进程环境下它可能不是你期望的那个。始终显式地创建、持有并传递你的torch.cuda.Stream对象这是可控性的唯一保障。record_stream的开销几乎为零但wait_event有微小开销。record_stream只是往一个哈希表里插一条记录而wait_event需要 GPU 硬件介入进行同步。因此在性能极度敏感的场景比如高频推理可以考虑对wait_event进行 profile确认它是否是瓶颈。但绝大多数情况下它的开销远小于一次 kernel launch。pin_memoryTrue的 DataLoader 是record_stream的最佳拍档。pin_memory将 CPU 内存页锁定使得non_blockingTrue的 GPU 拷贝能达到最高带宽。没有pin_memorynon_blocking的优势会大打折扣record_stream的价值也会被削弱。5. 常见问题排查与速查表从报错信息定位根本原因即使你已经理解了所有原理并严格按照规范编写了代码GPU 编程的复杂性依然会让你偶尔掉进坑里。下面我整理了一份基于真实 debug 经验的“报错-原因-解决方案”速查表。当你看到这些报错时不用慌直接对照表格90% 的问题都能快速定位。报错信息最可能的根本原因排查步骤解决方案CUDA error: an illegal memory access was encounteredrecord_stream缺失或wait_event缺失/位置错误1. 检查所有新创建的、会被下游 stream 使用的 tensor是否都调用了record_stream。2. 检查所有record_stream的调用是否都在 tensor 被 kernel真正使用之后。3. 检查所有wait_event的调用是否都在使用该 tensor 的紧前方并且wait_event的 stream 与record的 stream 匹配。1. 为缺失record_stream的 tensor 补上。2. 将record_stream移动到 kernel 执行完毕后。3. 将wait_event移动到with torch.cuda.stream(...):块的开头。device-side assert triggeredwait_event的event被record之前下游 stream 就开始使用了 tensor1. 检查event.record(stream)的调用是否真的在所有依赖它的 kernel 执行完毕之后。2. 检查wait_event的调用是否在正确的 stream 上。1. 在event.record(stream)之前添加一个torch.cuda.synchronize()仅用于 debug确认 kernel 确实已结束。2. 确认wait_event的 stream 是消费方record的 stream 是生产方。RuntimeError: CUDA out of memory(但nvidia-smi显示显存充足)record_stream导致显存无法及时回收造成虚假的内存碎片1. 检查record_stream的对象是否过多、过广。是否对一些只在当前 stream 内部使用的临时 tensor 也进行了record_stream2. 检查record_stream的 stream 是否是正确的。是否错误地将 tensorrecord_stream到了一个永远不会结束的 stream 上1. 只对跨 stream 使用的 tensor 进行record_stream。内部临时变量无需record_stream。2. 确保record_stream的 stream 是该 tensor 的“生产者 stream”并且这个 stream 确实会正常结束。CUDA error: the API was invoked with an invalid argumentwait_event或record的event对象已被销毁或stream对象无效1. 检查event和stream对象的生命周期。是否在wait_event之前event就已经被del或离开了作用域2. 检查stream对象是否被意外del或重新赋值。1. 将event和stream对象作为类成员或全局变量持有确保其生命周期覆盖整个训练过程。2. 避免对stream对象进行del或重新赋值。模型训练速度比单 stream 慢wait_event过多或位置不当造成了不必要的串行化1. 使用torch.cuda.profiler或 Nsight Systems 对 GPU timeline 进行 profiling。2. 观察 timeline 中各个 stream 之间的 gap 是否过大是否存在长时间的 idle。1. 分析 timeline找出哪个wait_event是瓶颈。2. 检查该wait_event的上游record是否可以提前。例如能否将record放在 kernel 的中间而不是结尾以暴露更多并行性5.1 一个真实的 debug 案例Nsight Systems 的救赎去年我在优化一个语音识别模型时遇到了一个极其诡异的问题模型在batch_size16时稳定运行但batch_size32时每跑 5-10 个 epoch 就会随机崩一次报错是illegal memory access。nvidia-smi显示显存一直很空闲torch.cuda.memory_allocated()也平稳上升。我花了整整两天把所有record_stream和wait_event都检查了一遍逻辑上完全正确。最后我祭出了终极武器Nsight Systems。我用它对一个会崩的训练过程进行了 10 秒的 profiling。timeline 图谱一出来真相大白preprocess_stream上的 kernel 执行时间在batch_size32时出现了巨大的、不规则的波动有时快有时慢。而compute_stream上的model.forward()其启动时间总是紧跟着preprocess_stream的结束时间。这意味着compute_stream并没有被wait_event阻塞它是在preprocess_stream一结束就立刻启动。问题就出在这里preprocess_stream的 kernel 结束并不等于enhanced_batch的显存已经完全写入
返回列表