多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

零侵入AI Profiling:从GPU利用率低到定位性能瓶颈的实操指南

零侵入AI Profiling:从GPU利用率低到定位性能瓶颈的实操指南 看着nvidia-smi里跳动的 GPU 利用率只有 35%训练 loss 还在正常下降任务也没有报错——这种状态往往最磨人。你大概会想到底要不要管怎么管是不是模型本身就不吃 GPU很多人在这一步卡住试了一圈调大 batch size、换数据加载方式之后发现毫无变化最后才想起系统性地做一次 Profiling。先说结论GPU 利用率这个数字本身的信息量极低低利用率的高价值信息从来不在利用率数字里而在时间线里。这篇文章讲的就是怎么用零侵入、不改训练代码的方式把GPU 利用率为啥低这个问题拆成一个能定位根因的分析流程而不是靠猜。我下面说的这套方法主要依托当前深度学习场景最常用的 PyTorch CUDA 技术栈但思路是通用的先搞清楚 GPU 在等什么再搞清楚每个算子花在哪最后对着时间线判断是 IO 瓶颈、调度瓶颈还是内核本身效率问题。不涉及任何要改模型代码的操作采集过程也只是在训练启动命令外面包一层工具所以叫零侵入。1. 为什么 GPU 利用率低这么难定位1.1 GPU 利用率不是你想的那个数很多人对 GPU 利用率有个朴素的误解觉得它跟 CPU 利用率一样是一个比较精确的时间占比。实际上nvidia-smi里的GPU-Util是一个采样值驱动按照一定周期去查询 GPU 是否有内核在执行然后显示为百分比。如果内核执行时间很短或者两个内核之间空隙比较多采集到的数值就会偏低甚至出现GPU 明明在跑但利用率显示为 0的错觉。这里可以拿拍照来类比CPU 利用率像是一直开着的摄像头录的是连续画面GPU 利用率像是一台每隔一秒按一次快门的相机你看到的只是某个瞬间是否有人在动。于是问题来了——一个快速训练循环里模型执行大量微秒级别的 CUDA 内核纯计算时间占比其实不低但采样点恰好落在空隙里显示出来的利用率就不好看。反过来有些内核执行时间很长但线程数很少SM 内部大部分计算单元是闲着的GPU-Util却可能显示 98%这又是一种假高。所以把期望寄托在nvidia-smi的利用率数字上从一开始就走错了方向。它适合用来做长时间粗粒度观察比如判断训练任务是否死了、显存是否吃紧但不适合用来定位性能根因。1.2 真低与假低先分类再动手在跑 Profiling 之前我会先把低利用率分两类一类是真低GPU 大部分时间确实没事干在等待数据、等待通信、等待 CPU 算完另一类是假低GPU 其实一直在断断续续执行内核只是因为内核粒度太碎或间隙太多导致利用率采样失真。区分这两类有一个很实用的观察点看训练的整体吞吐也就是每秒能跑多少个 step。如果 GPU 利用率低但吞吐正常那大概率是假低模型本来就是小内核高频调度的风格你不需要对性能做任何动作如果吞吐同步下降那基本可以判定是真低任务确实被某个环节卡住了。接下来要判断卡在哪一层。我习惯按照链路顺序逐个排查数据加载 - CPU 侧算子执行 - 数据从内存拷贝到显存 - 内核在 GPU 上执行 - 多卡情况下的通信和同步。每个环节都会在 Profiling 时间线上留下痕迹关键是要拿到一个能区分这些阶段的时间线而不是只看一个聚合数字。现象可能的瓶颈层初步判断线索GPU 空闲但 CPU 忙数据加载/预处理磁盘 IO 高DataLoader worker 占用高GPU 空闲且 CPU 也空同步等待/网络通信多卡训练出现周期性停顿单卡少见GPU 频繁有短内核内核粒度过小/启动开销时间线上大量 20us 的内核间隙密集内核耗时长但 SM 占用低内核本身效率问题访存密集或存在大量分支线程空转1.3 为什么框架层不直接告诉我们答案现在的训练框架把底层细节包裹得很严实。PyTorch 里你写一个loss.backward()背后可能是几百个 CUDA 内核按依赖关系排队执行DataLoader 在后台预取数据前向刚结束、反向还没开始的时候下一个 batch 的数据可能已经拷到 GPU 上了。框架抽象度高好处是易用坏处是你很难从用户代码层面感知到真正的瓶颈在哪里。这也是我要强调零侵入 Profiling 的原因。所谓零侵入不是指不额外花钱买软件而是指你不需要去改模型代码、不需要在训练脚本里到处插入计时逻辑只需要在启动命令外面包一层采集工具就能拿到完整的执行时间线。工具通过 CUDA 驱动的回调机制去监听内核启动和结束事件不需要改动业务代码。这在高成本的大模型训练场景里尤其重要——谁也不敢因为要排查性能就去改正在跑关键任务的那份代码。2. 零侵入 Profiling 工具的设计思路与选型2.1 从计时器加 print到时间线视角不少团队排查性能的第一反应是在代码里加计时器。比如测一下time.time()包住forward再包住backward看看哪段耗时多。这种做法不是没用但有两个很难绕开的缺陷一是它会把异步执行同步化因为 PyTorch 的 CUDA 操作默认是异步的CPU 代码执行到下一行时 GPU 内核可能还没跑完你用 CPU 计时器根本测不到真实 GPU 执行时间二是手工计时只能覆盖你主动埋点的地方无法看到算子之间的依赖、空隙和调度关系。时间线视角完全不一样。Profiling 工具采集的是每个线程在 CPU 侧调用了哪些 CUDA API、这些 API 触发了哪些内核、内核实际在 GPU 上是何时开始何时结束的。把这些事件画在同一条时间轴之后瓶颈就肉眼可见了如果 GPU 内核时间线中间有一段长时间的空白说明 GPU 在等某个东西如果 CPU 时间线显示 PyTorch 的算子执行逻辑出现明显断层说明 CPU 侧在等前置依赖。这种诊断粒度是任何手动埋点都做不到的。2.2 零侵入背后的核心机制三层数据采集要理解工具为什么不改代码也能抓数据可以拆成三层来看。第一层是API Trace也叫运行时跟踪。工具拦截的是应用对 CUDA Runtime API 和驱动 API 的调用比如cudaLaunchKernel、cudaMemcpy记录调用发生的时间、线程、参数。这一层解决的是CPU 侧什么时候发起了 GPU 操作的问题。第二层是Kernel Trace记录具体内核在 GPU 硬件上的执行时间。工具通过 CUDA 的回调机制在内核启动和结束的时候各打一个时间戳。这一层解决的是GPU 侧实际执行了多久的问题。第三层是硬件计数器采集比如 SM 占用率、显存读写吞吐、指令吞吐等。这些信息来自 GPU 内部的性能计数器可以帮你判断一个内核到底是计算密集、访存密集还是存在大量线程空转。三层数据对齐之后CPU 等待时间、内核执行时间、显存搬移时间和内核效率就能够完整对上了。有一些额外的标签机制可以帮助关联业务逻辑比如 PyTorch Profiler 自带对torch.nn.Module名字的自动标注Nsight Systems 支持使用 NVTX 在你的代码里加轻量标记。加了 NVTX 标记后时间线上就能把在跑第几步、在跑哪个模块这类信息标出来。这些标记本身也是一种非侵入方式只是加一段注解不改变执行逻辑不需要的时候随时可以不加。2.3 主流工具怎么选我在实际项目里接触过好几类 Profiling 工具各自覆盖的场景不太一样简单列一下。工具侵入性采集粒度主要优势主要局限NVIDIA Nsight Systems基本零侵入通过命令行包裹CPU/GPU 全链路事件、内核时间线系统级诊断能力最强能看到 CPU/GPU 重叠对算子内部极致优化分析不如专门工具PyTorch Profiler代码侵入极小建议用 context manager 包住少量 step算子级 CPU/GPU 耗时、显存分配直接对齐模型层结构表格化输出最友好无法覆盖到系统底层行为比如数据加载线程状态DCGM零侵入独立服务采集GPU 硬件计数器长时间趋势适合长时间监控、多卡集群轮询没有细粒度算子级信息自研上报脚本视实现而定自定义可对接内部监控平台覆盖范围有限容易漏事件我的选择习惯是先用 DCGM 这类长时间监控工具确定大致的时间段和机层趋势再用 Nsight Systems 做一次完整系统级采集拿到时间线定位瓶颈层最后用 PyTorch Profiler 聚焦到算子级做优化验证。三者各有分工不是互相替代的关系。标题里说的零侵入 AI Profiling 工具其实指的就是 Nsight Systems PyTorch Profiler 这套组合玩法不改模型代码采集完即可继续跑正式训练。3. Profiling 实操从采集到定位全流程3.1 最小可用采集命令先说 Nsight Systems 这一路。假设你原本启动训练的命令是python train.py --config configs/base.yaml想完成一次基本的 CPU CUDA 采集只需要在外面包一层nsysnsys profile --tracecuda,nvtx --outputzs_run_$(date %s) \ -f true -c cuda -t 20000 \ python train.py --config configs/base.yaml参数含义拆开解释一下--tracecuda,nvtx只采集 CUDA 内核事件和 NVTX 标记这是最常用的组合。如果还想看显存拷贝、网络传输、线程调度可以追加--tracecuda,nvtx,osrt但采集开销会相应增加。-c cuda -t 20000让工具在检测到 CUDA 相关活动后最多持续采集 20 秒就自动结束。这个参数很关键避免对整个训练过程全程记录导致文件巨大。-f true如果输出文件已存在则覆盖。跑完之后会生成一个.nsys-rep文件Windows 上可以用 Nsight Systems 的 GUI 打开Linux 上也可以通过nsys stats导出 CSV 报表做文本分析。再来看 PyTorch Profiler 的用法它适合做算子级分析。因为采集全部 step 会造成不必要的开销通常只采集少数几个 stepfrom torch.profiler import profile, ProfilerActivity, schedule with profile( activities[ProfilerActivity.CPU, ProfilerActivity.CUDA], scheduleschedule(wait5, warmup3, active2, repeat1), record_shapesTrue ) as prof: for step, batch in enumerate(dataloader): if step 10: break out model(batch) loss loss_fn(out, batch) loss.backward() optimizer.step() prof.step() print(prof.key_averages().table(sort_bycuda_time_total, row_limit20))关于schedule的几个参数值得展开说说wait5表示前 5 个 step 不采集让 CUDA 上下文初始化、显存分配都稳定下来warmup3表示接下来 3 个 step 预热 Profiler 内部缓冲active2表示真正采集 2 个 step。这比全程开着 Profiler 跑要合理得多既能代表稳态性能又不会把训练拖得太慢。采集完成后还可以用prof.export_chrome_trace(trace.json)导出时间线拖进 Perfetto 或 Chrome tracing 里看可视化结果。3.2 时间线怎么看把问题缩小到GPU 在等谁拿到报告或者时间线之后不要急着看最耗时算子排名先做三层判断。第一步看CPU 侧的事件间隔。如果时间线上 CPU 侧很长一段距离没有任何操作说明 CPU 在等 GPU 或等数据这往往是同步点导致的如果 CPU 侧非常忙碌但 GPU 内核迟迟不出现说明 CPU 在执行预处理GPU 在空闲等数据。第二步看GPU 内核之间的空白段。两个内核之间如果经常有几十微秒以上的空白优先怀疑是内核启动开销或同步等待。小内核高频调度的场景最容易出现这种情况很多微秒级别的 kernel 之间都被 launch 延迟占用掉GPU 的实际计算占比上不去。第三步看数据搬运和计算是否重叠。时间线上Memcpy操作如果出现频繁且长条要看它和内核执行是否在时间上重叠。如果拷贝和内核执行完全串行说明数据在 CPU 和 GPU 之间来回搬移每次搬移都会带来一次同步这是个明确的优化点。为了说清楚我用一个表格总结时间线上几种典型的形态特征对应的问题时间线形态读出来的结论常驻方向GPU 内核之间有规律的大段空白GPU 在周期性等待外部输入数据加载或网络通信内核大量且极短启动间隙密内核太碎launch 开销占比高算子融合、CUDA GraphCPU 侧长忙、GPU 侧长空CPU 先算GPU 后算串行明显异步化、预处理下沉内核执行时间长且 SM 占用低单内核效率差内存访问优化、改算子实现3.3 三个真实案例的定位与修复复盘案例一数据加载拖垮 GPU时间线上露出马脚。一个 NLP 分类任务batch size 设为 16GPU 利用率只有 30%吞吐每秒钟只跑 2 个 step。用 Nsight Systems 采集后时间线上非常明显GPU 每跑完一波内核就有一个 200 毫秒以上的空白然后突然出现一批拷贝和内核。接着用nsys stats看了 CPU 侧的线程活动发现 DataLoader worker 几乎全程在等待读取数据。当时磁盘是普通机械盘训练集图片解压后有几万个小文件每个 epoch 都在随机读盘。解决办法是先用脚本把图片打成了 TFRecord 格式当时用的是 TensorFlow 2后来换成内存文件系统缓存部分数据再配合num_workers8和prefetch_factor4GPU 利用率直接拉到了 85% 以上。这个案例的底层逻辑是数据加载的耗时没有和 GPU 计算重叠GPU 每轮都在等数据。案例二模型太碎小算子把启动开销拉爆。一个排序模型结构不复杂但里面用了大量细粒度的矩阵乘和激活函数。观察时间线GPU 利用率采样不高但内核数量极多平均内核时长远低于 20 微秒。CPU 侧的事件是高频发射内核GPU 侧是极短爆发后再等下一个指令。定位思路是先把相邻的矩阵乘和激活通过算子融合合并再把整个前向计算的若干个小算子组织成一个 capture 的 CUDA Graph 做整体重放。改造后 GPU 利用率没有大幅提升但训练吞吐提升了约 40%。利用率数字没变是因为采样方式没变但实际计算效率上去了。案例三多卡分布式场景中的同步抖点。四卡训练刚开始 GPU 利用率 70%跑了 20 分钟后掉到 45%。时间线上看到每隔一段时间就有一次集体停顿停顿期间所有 GPU 都在互相等待。用 NVTX 在训练循环的梯度同步阶段加上标记立刻定位到每次停顿都发生在allreduce附近。仔细排查发现是共享带宽被一个日志写入进程抢占了网络抖动导致同步时长从预期的 3 毫秒变成了 40 毫秒。后来把日志和数据备份改到了训练任务不用的时间段同步时长恢复正常。多卡场景往往不是单点问题而是通信与进度的耦合问题这时候没有时间线几乎没法靠直觉去猜。4. 常见问题与排查技巧实录4.1 开 Profiling 之后训练变慢是不是不准这是一个高频担忧。先说结论任何 Profiling 工具都会带来一定开销只是程度问题。Nsight Systems 在只跟踪 CUDA 和 NVTX 事件时开销通常控制在 5% 以内对定位问题来说完全可以接受PyTorch Profiler 如果一直开着采集每个 step开销会高一些所以我前面强调要设置wait和active。还有一个容易被忽略的点带 Profiler 跑出来的首次数据不一定准因为 CUDA 上下文、显存分配器、CPU 缓存都是冷的。所以正确的做法是先让训练跑几步进入稳态之后再采集采集完对比几次结果取一致的部分不要被第一次的数据带偏。另外Profiling 本身不会改变训练任务的逻辑。它只是监听事件不会修改算子的执行方式。如果在采集过程中你观察到训练吞吐明显下降优先检查是不是采集范围太宽了。比如--tracecuda,nvtx,osrt里osrt会记录所有系统线程的调度和文件 IO开销就明显上升。调窄跟踪范围就好。4.2 多卡和分布式场景怎么正确采集多卡训练时每个进程都持有独立的 CUDA contextnsys默认只跟踪当前进程。多卡场景通常有两种做法一是用nsys profile --trace-fork-before-exec让工具自动跟随 fork 出来的子进程二是每个 rank 单独启动一个nsys采集然后产出的报告文件里会包含对应 rank 的数据。我的经验是没必要全部 rank 同时采集通常只采集一个卡上的代表进程就够了。如果你要分析的是通信同步问题再考虑同时采集两个或多个卡但必须保证它们的时钟是同步的。分析时用 NVTX 把 rank、模型阶段、数据迭代编号都标记清楚不然多份报告叠加起来很容易看乱。分布式训练里有个常见的坑是你在一个节点上采集数据时其他节点可能正处于不同的迭代状态导致同步时间看起来特别长。所以多机训练排查通信问题最好在多个节点都采集同一段迭代范围然后在对齐时间线时把迭代编号作为主键。4.3 几个容易误判的经典场景我遇到过不少诊断方向完全反了的情况整理成一份速查表供参考。表面现象误判方式正确理解GPU 利用率显示 0认为 GPU 没干活内核短于采样间隔利用率采样点正好落在空隙GPU 利用率 80% 但很慢认为内核效率高利用率高不代表吞吐高可能是在跑低效内核CPU 占用高单纯认为 CPU 性能不足CPU 高可能因为它在做大量串行预处理要判断是否可异步Profiling 文件巨大认为不该再看采集窗口太宽是主因缩小采集时间再分析还有一个特别常见的操作问题nvidia-smi的--query-gpuutilization.gpu输出和 AI Profiling 工具给出的 GPU 内核时间占比经常对不上。我的建议是以 Profiling 工具的内核时间线为准。nvidia-smi的采样周期无法覆盖到亚毫秒级别的事件尤其当模型包含大量融合算子时偏差会很明显。4.4 实操中值得固化的几条习惯顺手再补充几个长期养成的操作习惯。第一养成只采少量 step的习惯避免整个训练过程全程采集。全程采集不仅有开销还会让报告文件变得巨大分析效率反而下降。第二做性能优化之前先设定一个可以量化的目标。比如把每秒 step 数从 2.1 提升到 3.0或者把 GPU 空闲占比从 50% 降到 20%。没有目标的话看到一堆时间线数据很容易陷入无意义折腾。第三固定一个配置文件来保持采集参数稳定。每次对比优化前后效果时如果 Profiling 参数不一致得出的结论就不可信。我们内部现在有一个profile_common.yaml定义采集窗口、跟踪范围、输出目录所有成员都用同一套参数这也是从几次无效对比里踩坑踩出来的。第四数据要分步验证。每次只改一个变量改完重新 Profiling不要一次调一堆参数。这个道理谁都懂但到了排查瓶颈的时候特别容易犯想一口气把数据加载、算子融合、同步问题全改掉结果最后都不知道是哪个改动起了作用。我个人在实际操作中的体会是AI Profiling 工具的价值不在于给我一个最优配置而在于让我能把一个模糊的性能问题表述清楚——GPU 在等数据和GPU 在做低效计算是两种完全不同的处理路线前者要改 IO 链路后者要改计算实现。没有时间线之前这两者在指标数字上表现得几乎一模一样。如果你现在项目里正好遇到 GPU 使用率不高但说不清原因的情况建议先不要急着改 batch size 或换网络结构花半天时间跑一次 Nsight Systems把时间线导出来看看大概率会看到一个以前完全忽略的等待环节。按照前面的采集步骤走一遍十分钟之内就能得到第一份可分析的时间线。这个投入比在代码里盲目试参数要划算得多。
返回列表