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

文章详情

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

高性能计算框架实现:从GPU利用率到训练效率的全面优化

高性能计算框架实现:从GPU利用率到训练效率的全面优化 1. 先别急着写代码算力账单逼出来的框架需求今年初我们团队遇到一个所有做深度学习的人都懂的尴尬GPU服务器的账单比上季度翻了一倍但模型迭代速度反而更慢了。查了一圈监控才发现集群的整体GPU利用率只有40%出头大量时间耗在数据加载、进程间通信和显存分配这些小事情上真正跑算力的时间少得可怜。也就是从那时候起我正式下决心自己做一个高性能计算框架而不是继续抱着通用框架和一堆临时脚本凑合。先说清楚我这边说的高性能计算框架实现不是从零写一个PyTorch或者TensorFlow那种级别的通用深度学习框架那既不现实也没必要。我更想做的是在现有基础之上把训练流程中的数据管线、算子调度、显存管理、多卡通信这几个环节全部重排一遍让每一毫秒GPU时间都花在刀刃上。这篇文章就把我完整实现这个框架的思路、代码骨架和踩坑记录分享出来适合那些已经在用PyTorch、TensorFlow等基础框架做训练但被数据加载太慢、显存老爆、多卡效率上不去这些问题反复折磨的团队参考。1.1 一个月的账单和40%的GPU闲置率事情要从一次平平无奇的月度资源复盘说起。运维同学拉了一张账单出来我们团队名下的A100和V100机器加起来一共跑了差不多两千个GPU小时费用相当感人。但仔细一看监控曲线很多机器的利用率曲线像锯齿一样训练脚本跑几秒钟然后卡几分钟等数据再跑几秒再等。尤其是V100那批机器因为显存只有16GB经常出现OOM后重启或者是batch size被压得很小算力根本喂不饱。那次复盘让我意识到一个问题我们一直在抱怨算力不够但真实情况是算力在大量闲置。瓶颈根本不在GPU本身而是在GPU周围那一圈配套逻辑——数据要不要等磁盘图像预处理是不是在CPU主线程里同步干的分布式训练里梯度同步是不是阻塞了下一轮前向这些问题单个看都不起眼叠在一起就是几倍的效率差距。所以当时我就定了一个原则这个框架的KPI不是能跑起来而是同样一批训练任务能不能在相同的时间里多跑一倍的数据量以及同样的batch size显存占用能不能降30%以上。后续所有实现决策都围绕这两个目标凡是跟它们无关的炫技功能一律不做。1.2 框架的边界我到底要解决哪几层问题定义边界是这种项目里最容易忽略但最重要的一步。很多人一开始就想做个大而全的东西结果做到一半发现要么和底层库重复造轮子要么上层业务根本接不住。我花了一个下午把整个训练链路拆成了五层每一层都明确回答一个问题层级核心问题我是否自研数据获取层数据从哪里来、多快能到内存自研数据管线预处理层CPU上的增强/解码能否异步化自研算子调度计算图层模型如何描述、如何执行基于PyTorch扩展资源管理层显存怎么分配、怎么复用自研显存池通信层多卡间怎么同步、怎么少通信自研通信策略这么分层之后工作边界一下子就清楚了我不去碰算子实现本身比如卷积、注意力这些直接用现成的也不去改CUDA内核重点放在数据、调度、资源、通信这四块。这几块在开源框架里往往属于能用但不极致的状态恰恰是自研价值最大的地方。还有一个边界必须划清楚动态图和静态图之争。PyTorch的动态图灵活但调度开销大TensorFlow的静态图性能好但调试痛苦。我这个框架既然要兼顾业务同学的灵活度和性能就不能走极端后面我会专门讲我选的折中方案。1.3 为什么没直接换现成框架可能有人会问市面上有现成的高性能训练框架比如Horovod、DeepSpeed、Megatron这些你为什么不直接拿来用这个问题我也认真纠结过。我的结论是现成框架解决的是通用场景下的高性能但对于我们这种有大量自定义数据增强、模型结构频繁调整、混合了多个训练任务的团队它们引入的配置复杂度反而会拖慢迭代速度。举个最直接的例子我们有一个业务场景需要对图片做随机透视变换这个算子用OpenCV实现很快但Horovod的管线里要把自定义算子塞进去就得写一堆胶水代码。还有我们的数据源有部分在普通文件系统上而非高性能存储现成框架默认的IO策略在这种情况下效率很差。与其花两星期研究别人的框架怎么适配我的场景不如花一个月做出一个天生适配自家场景的薄框架再在薄框架下面挂载PyTorch的算子库和训练引擎。事实证明这个判断是对的。2. 计算图与执行引擎框架的心脏怎么搭数据挪到位之后真正决定性能上限的是计算图怎么组织、算子怎么调度。这一层是整个框架的心脏也是我前期设计耗时最长的部分。我不会去实现一个完整的自动微分引擎那是自己给自己挖坑但我会在PyTorch的算子之上加一层执行计划让算子的执行顺序、设备分配和内存生命周期都由我这层来控制。2.1 静态图与动态图的取舍我选了半静态方案PyTorch的Eager模式之所以慢一部分原因是每次前向都要重新调度算子。TensorFlow的Graph模式之所以快是因为执行计划被固化下来少了重复调度开销。但对我这边的业务来说模型里经常有不定的条件分支和动态shape完全静态化不现实。所以我设计的折中方案叫半静态执行计划模型第一次跑的时候框架会记录一遍算子执行轨迹包括每个算子的输入输出shape、设备位置、依赖关系然后把这套轨迹缓存起来。第二次跑同样的结构时跳过算子解析和依赖分析直接按缓存好的计划执行。如果检测到输入shape发生变化或者走了不同的分支就重新记录一次。这个方案落地下来效果很明显。在一个BERT类的文本分类训练任务里算子解析耗时从每个iteration约12毫秒降到了不到2毫秒。用它训练GPT类模型的时候因为序列长度固定、结构稳定几乎全程走缓存路径整体吞吐比原生PyTorch高了大概18%这个数据不算夸张但胜在稳定——不管跑多少step都不会衰减。2.2 算子注册与设备分发的实现细节接下来我遇到了调度器里最关键的一个问题算子怎么注册、怎么知道该往哪张卡上放。这里我借鉴了插件化的思路写了一个全局算子注册表每个算子通过一个装饰器把自己登记上去。注册表里存的不只是函数指针还包括这个算子的计算偏好和显存需求比如卷积类算子优先分配到计算密集型设备而数据搬运算子则优先分配在离数据源近的设备上。我挑一段核心代码做个示意真实项目里做了些简化但结构是这么个结构# operator_registry.py class OperatorRegistry: def __init__(self): self._ops {} def register(self, name, compute_intensity1.0, memory_scale1.0): def wrapper(func): self._ops[name] { func: func, intensity: compute_intensity, mem_scale: memory_scale, } return func return wrapper registry OperatorRegistry() registry.register(fused_linear, compute_intensity8.0, memory_scale2.0) def fused_linear(x, w, bNone): # 实际调用的是PyTorch编译好的算子 return torch.addmm(b, x, w.t()) if b is not None else torch.mm(x, w.t())设备分发逻辑则是这样调度器拿到一个算子和它的输入张量后先查注册表里的intensity和mem_scale再结合当前各设备的负载情况打分最后把算子放到得分最高的设备上。在多机多卡场景下这个策略能明显减少GPU之间来回搬运小张量的次数因为搬运的代价往往比计算本身还高。2.3 调度器如何决定一个Iteration的完整流程调度器是我花时间最多、也最容易写崩的部分。核心数据结构是一个有向无环图DAG节点是算子边是数据依赖。为了不引入重量级框架我直接用Python的deque加一个记录入度的字典做拓扑排序每次执行前就把入度为0的节点全部拿出来交给设备分发器安排。真正写起来比想象的麻烦有几个容易翻车的细节第一不能在每次iteration都重新构建DAG那样省下的调度时间又还回去了。我采取的策略是计划缓存模型结构没变就直接复用上一次的DAG拓扑只有捕获到结构变化时才重新构建然后和新的数据shape绑定再走一遍缓存流程。第二要处理控制依赖。比如某个算子必须在另一个算子出错时跳过执行这在动态图里是天然支持的但在DAG调度里需要手动加一种边我把它叫做条件依赖边节点执行完会返回一个状态调度器根据状态决定下游节点要不要激活。第三也是最容易忽略的算子的异步执行。我实现了一个简单的Future机制每个算子执行后立即返回一个future对象数据依赖它的算子可以通过future去拿真正的结果。这样如果两个分支互不依赖它们的算子就能在多个线程上并行跑起来而不是一个等一个。这一层实现完毕后我在一个图像分类任务上做了A/B对比在同样ResNet-50结构、同样batch size下带执行计划和异步调度的版本单卡吞吐比原生PyTorch提升了约22%。这里头有一部分是因为省掉了每iteration的重复调度还有一部分是因为数据预处理算子能和GPU算子真正并行起来详细的数据我放到后面性能验收那一节统一讲。3. 数据管线改造把IO时间藏进计算里如果说执行引擎是框架的心脏那数据管线就是血管。我观察过很多训练任务GPU利用率低的头号原因不是算子慢而是GPU在等数据。这个问题在本地用SSD的时候还不明显一旦数据在机械硬盘、网络文件系统或者远程对象存储上差距就是天壤之别。其实解决思路大家也都知道预取、缓存、多进程并行但真正实现好的人不多很多代码只是开了几个进程就号称做了并行实际瓶颈根本没消除。3.1 Prefetch、多进程与共享内存一个都不能少我第一版数据管线用了最简单的方案主进程开几个线程每个线程负责读一批数据出来。跑起来之后发现GPU利用率只提了不到5个百分点用perf一看问题出在Python的GIL上——多线程根本没法同时跑CPU密集的预处理逻辑真正并行起来的只有IO等待的部分。后来我把线程全部换成了进程。每个worker进程独立做读取文件 解码 增强全流程产出的数据通过一个循环缓冲区丢给主进程。这个循环缓冲区一开始用的是multiprocessing.Queue但那个在大批量场景下序列化开销很大尤其是图片这种高维数组进队列一次就是一次深拷贝加序列化。最终我换成了共享内存方案进程启动时直接申请一块固定大小的共享内存区域每个worker往里写数据时只写一份主进程读取时零拷贝取走。实现上用的是multiprocessing.shared_memory好处是省掉了跨进程序列化和拷贝。别小看这一步光是这个改动数据带宽就提升了好几倍。3.2 数据增强算子的异步化让CPU和GPU重叠工作数据管线做好了并行拿数据还得解决拿数据的节奏和GPU计算的节奏对不上的问题。GPU算得快数据产得慢的时候GPU就只能空转等待。我的办法是把数据管线从同步拉取改成异步预取主进程这边每次算完一个batch之后不要当场等下一个batch而是提前预取两三个batch放到一个预备缓冲区里。这里有个很关键的设计细节数据增强算子可以在GPU上做的就放到GPU上做别挤在CPU里排队。比如随机裁剪、色彩抖动这些操作我用CUDA实现了一部分这样做虽然会增加GPU的显存占用但能有效缓解CPU这边的压力。实测下来做了增强算子异步化之后CPU利用率更均匀了不再是那种突然飙到100%又突然掉到20%的锯齿状。异步化还带来了一个额外好处如果数据源偶发抖动比如某个文件突然读取变慢因为有预取的缓冲垫在中间对整体训练节奏的影响会被抹平不会出现之前那种一个慢文件导致GPU空等好几秒的尴尬。3.3 一个基准测试暴露的瓶颈pinned memory被忽视了框架做出来之后我跑了一轮完整benchmark却发现有个场景怎么优化都上不去小batch size下吞吐竟然比大batch还低。一开始我以为是调度器的问题查了整整两天最后发现瓶颈不在算子而在数据传输——GPU要数据的时候数据还躺在普通的pageable memory里CUDA得先拷到pinned memory再传到设备端这中间多了一次内存搬运。解决办法也很直白数据从共享内存进来之后直接分配到cuda的pinned memory锁页内存并且用torch.cuda.Stream做异步预拷贝。也就是说在GPU还在算上一个batch的时候下一个batch的数据已经通过单独的数据流拷到显存里等着了。改完之后小batch的吞吐一下子提升了近30%这次优化记录我一直留档每次团队招新人讲框架的时候都会拿出来当案例讲。4. 显存优化与量化支持V100上的每一GB都得抠算力吃满了接下来要面对的就是显存这道坎。V100只有16GB显存而现在的模型动不动就几个GB起步batch提不上去多卡又费钱。说白了显存就是高性能计算框架的稀缺资源谁能把显存的每一块都压榨到位谁就能在同配置下跑更大的模型、更大的batch。这一节讲讲我在显存池和量化上做的两个关键工作。4.1 显存池与复用机制为什么频繁alloc会拖垮训练PyTorch默认的显存分配策略是按需分配、用完释放这在高频iteration的训练场景下其实很浪费。每次算子结束释放的显存如果马上又有新算子要分配CUDA的分配器就得频繁和驱动打交道这个开销虽然比普通内存分配大得多但因为发生在GPU上很多人感知不到直到profiling的时候才发现cudaMalloc占了大量时间。我的方案是做一个显存池memory pool。训练开始前框架通过一次试运行估算出整个执行计划里可能用到的各级显存块大小然后预先一次性申请好之后所有算子都从这个池子里取显存块用完归还而不是真释放。池子内部按大小分级 空闲链表管理分配速度上了好几个量级原来的cudaMalloc调用基本从profiling结果里消失了。这个显存池项目里最需要注意的问题是碎片化。分配和归还的顺序如果不是规规矩矩的栈式操作池子里会逐渐出现大量小到没法利用的显存碎片。为了避免碎片化我规定算子占用的显存块必须在DAG执行计划的特定阶段释放不允许在任意时机随意释放这个约束来源于对执行计划的严格拓扑排序算是静态分析 显存池组合的一个额外红利。场景原生分配方式显存池分配提升幅度ResNet-50训练每iteration调cudaMalloc约70次每iteration调约3次分配耗时降低约92%12GB模型推理峰值显存12.4GB峰值显存10.1GB峰值显存降低约18%动态shape频繁变化OOM频发小碎片被池内复用OOM率降低约85%4.2 混合精度与量化框架的接入V100上的实战记录V100上有一个很特殊的地方它的Tensor Core对FP16计算支持很好但对INT8没有专门的硬件加速单元。所以给V100做量化收益更多是省显存和省带宽计算速度的提升不如在A100之类的新卡上那么明显。我在做这一层的时候调整了策略的优先级在V100上优先做混合精度和显存裁剪为主的是能塞下更大batch在A100等其他卡上再放开完整的INT8量化推理。混合精度这块我用的方案是自动混合精度AMP加动态loss缩放但是我在框架里加了一个显存感知的自动开关——框架检测到当前显存池的压力超过阈值时会自动把更多算子切到FP16执行如果显存压力不大就保持FP32保证精度。这样做的目的是不要把混合精度当成一刀切的开关而是让显存这个指标参与决策。纯量化方向我实现了一个按层校准的PTQ训练后量化流程。V100上做INT8推理时我遇到一个值得注意的点某些敏感的归一化层和激活函数如果强行量化到INT8精度会掉得非常厉害。我的做法是设计了一个敏感层豁免机制用一个小的校准数据集跑一遍收集每层的激活分布凡是分布特别不均匀的层就自动豁免量化、保留FP16。实测下来在一个语义分割模型上这个量化策略把模型体积压到原来的四分之一推理显存占用降了约37%mIoU只损失不到1个百分点这个代价换来显存大幅下降完全能接受。4.3 大batch下的显存碎片问题一个隐蔽的OOM碎片化问题在显存池上线后我以为已经解决了直到有一天一个同事跑大batch训练时反复OOM而且每次报错的地方都不一样。我打开torch.cuda.memory_summary()一看池子里明明还有大片空闲块但都是零散分布的最大的连续块比需求还小。这其实就是前边说的那个碎片化隐患在极端场景下爆发了。排查过程比较曲折。先是怀疑某个算子临时申请了大显存导致池子空间分布被打乱但加日志排查后发现问题的根源在数据管线的异步预取上预取buffer在GPU显存里申请了一块超大的连续显存因为是异步的它一直占着不放等到主进程真正要用这块空间跑算子时池子里的空间已经被分割得七零八落。解决方案分两步。第一步把预取buffer从GPU显存挪到CPU的pinned memory里传输用单独CUDA流异步执行这样GPU显存池始终只给算子使用。第二步给显存池加了一层压缩机制检测到某次分配失败且总空闲量大于需求时触发一次全池数据迁移把分散的空闲块合并起来虽然这个操作本身开销不小但频率很低在关键时刻能救急。5. 多卡训练与通信优化allreduce之前先算账单卡优化做到位之后多卡训练就是另一个维度的问题了。多卡训练的核心矛盾是算得越快通信开销占的比例就越大。一张卡算一个step要1秒通信要0.1秒那通信占比是9%如果单卡被优化到0.5秒算完通信还是0.1秒占比就跳到了17%。这说明一个扎心的规律——单卡性能越好通信优化的必要性越大。所以说到高性能计算框架多卡通信这块绝对不能绕开。5.1 NCCL vs 自研通信什么场景值得自己做业界做多卡训练大家默认用NCCL做allreduce它稳定性好、性能高正常情况下我是不会去自研通信原语的那等于重复造轮子还容易造出bug。但我在实现中确实遇到了一些NCCL覆盖不好的场景这才逼着做了补丁版的自研通信。场景是跨节点训练我们有两台机器每台4张卡机房网络是25GbE以太网。NCCL在这种跨节点环境里的表现是同一节点内用NVLink通信快跨节点走以太网慢而且NCCL的allreduce会把跨节点通信量放到全部参与通信的卡上导致网络很快被打满。我的做法是分层混合通信策略节点内部的梯度同步用NCCL的NVLink allreduce节点之间按梯度切片 异步流水的方式只做必要的跨节点传输。大致思路是每张卡先把本节点内部reduce好的梯度切成K份然后不同节点之间按流水线顺序交叉传输避免某一时刻所有流量同时涌到网络上。这其实就是一个简化版的通信流水线并行。实测效果两台8卡机器跑一个18亿参数模型训练纯NCCL方案中跨节点通信占比大约31%用混合策略后降到11%左右端到端训练吞吐提升了接近15%。但我也要补一句如果你只有一台8卡机器NCCL已经能做到很好不必画蛇添足去自研通信策略。5.2 梯度压缩与通信计算重叠把等待变成干活深度学习里的梯度有相当一部分是稀疏或者数值很小的直接全量传输很浪费。我在框架里加了一个可选的梯度压缩开关设一个阈值低于阈值的梯度置为零并用bitmask标记只传输非零部分接收端再按bitmask还原出完整的梯度。这样做通信量能省不少但需要注意一点压缩和解压本身也在消耗CPU时间如果压缩时间比省下来的通信时间还长就得不偿失。所以我又加了一个动态判断逻辑每个step统计当前梯度的稀疏度如果稀疏度高于某个设定值比如80%就启用压缩如果梯度密集就自动回退到全量通信。这个看菜下饭的策略在实践中很管用尤其是在微调预训练模型这种梯度天然稀疏的场景里。通信计算重叠这块我的做法是经典的分桶通信把梯度按模型层切分成多个桶前向计算结束后先对前面几个桶做allreduce同时让后面的层继续做反向计算累积梯度。这样梯度同步时间和反向计算时间就重叠了一部分等待通信的时间被有效消化掉。在NCCL的api上配合torch.distributed.bucket_cap_mb调参把桶的大小控制在4~8MB之间在我们集群上的效果最好过大或过小都会让延迟上升。5.3 拓扑感知的分布式调度机器别乱分写到这里我意识到多卡训练的性能其实从分配给哪些卡的那一刻就决定了。如果机器拓扑都不感知就随便分卡等同于开局就输一半。我在框架里做了一个简单的拓扑探测启动训练前用一条测试消息量出每张卡到其他卡的实际延迟和带宽然后优先把需要频繁通信的并行组分配在同一个交换机域内跨域只跑数据并行。这套拓扑感知调度上线后节点间的通信延迟平均减少了约20%丢失率也明显下降。值得一提的是这个概念在底层HPC集群里早就有了但在很多深度学习团队里根本没被重视。做这个其实不需要什么复杂度只需要在启动脚本里多花几十毫秒做一次探测属于性价比非常高的一个功能。6. 性能验收与维护框架上线只是开始框架写完、单测过了、训练能跑起来这只能算完成了一半。真正难的是怎么证明它真的高性能、在什么样的情况下高性能、以及上线之后怎么继续演进而不会腐烂。我见过太多项目上线时风光无限半年后没人敢改因为一改就崩这种框架就是给自己挖坑。所以我在最后这个阶段专门花了大力气做验收体系和维护规范。6.1 基准测试怎么设计才可信框架的基准测试如果设计得不好非常容易得出自欺欺人的结论。比如只测一个理想化的benchmark数据全在内存里、模型固定、不做任何日志打印这个结果能说明框架的上限但说明不了你真实业务的收益。我做了一套三明治基准测试第一层是微观基准micro-benchmark用来测单算子、数据管线和通信原语的极限性能这部分主要用来验证框架自身的模块是否达标。第二层是任务基准task-level benchmark直接拿业务真实模型、真实数据来跑对比原生方案和框架方案的端到端吞吐、显存峰值、OOM次数这些指标。第三层是稳定性基准连续跑至少48小时观察吞吐的方差、是否出现内存泄漏、是否出现分布式训练中的卡死。我的建议是三个层次缺一不可但权重应该明显偏向第二层和第三层。因为用户真正关心的是真实业务的收益和稳定性微观基准再好看业务跑不动也是白搭。我这边第二层测试里最终的提升数据是这样的在ResNet-50图像分类任务上框架相比PyTorch原生方案吞吐提升约21%在BERT文本分类任务上吞吐提升约17%在一个语义分割任务上加上混合精度和显存池后峰值显存占用从原来的13.8GB压到了9.2GB。这些数据都有完整记录每次框架迭代后都会重跑一遍保证性能不倒退。6.2 线上排查工具链性能问题得有证据链框架上线后必然要面对一个问题性能相关的bug通常非常难复现如果工具链不给力排查就是大海捞针。我在这块没有自己造轮子而是把几样现成工具串成了一条链。Python侧的profiling用cProfile加py-spyCUDA侧用原生的Nsight Systems配合torch.profiler抓GPU算子耗时和显存分配事件。每次训练跑完框架会自动生成一份带时间戳的profile报告包含每个算子的耗时占比、显存池命中率、通信等待时间、数据管线吞吐这四类指标这样从训练变慢到定位到某段代码通常不需要超过半小时。还有一个值得单独说的经验一定要给框架加详细的Trace日志而且日志格式要统一。我做了一个给每个iteration打点的小模块记录下每个阶段的毫秒耗时比如data_prep12.4msschedule3.2mscompute58.1mscomm8.7ms。这些日志在平时没什么存在感但一旦线上性能异常它们就是最直接的证据链。我靠这套日志定位过不下十次性能回退全是回归测试没有覆盖到的边界情况。6.3 框架演进的两个原则最后聊一下框架上线后怎么维护、怎么演进才不会变成一坨没人敢动的代码。我的经验可以浓缩成两句话。第一句话是只改被数据证明的问题不做预防性重构。我见过太多的框架性能刚达标就有人提议要不要把引擎层全部换成新架构顺应潮流——这就是大坑。框架要演进必须基于某个明确的性能数据或者功能需求比如数据管线吞吐已经连续两个模型触底这说明管线需要动了这时候动手才有价值。没有任何数据支撑的重构大概率只是把之前能跑的东西弄得更复杂。第二句话是保持薄的中间层别把业务逻辑吸进来。框架和业务之间必须有清晰的分界线框架只提供调度、资源、通信这些通用能力具体模型结构、损失函数、数据增强这些全部留在业务侧。我见过不少团队框架越写越厚最后把某些特定模型的特殊逻辑都塞进了框架里结果换一个模型就得改框架整个团队被拖死。薄框架维护起来才能真正做到轻装快跑。写到最后这台框架从立项到现在跑了半年多我能说出来的最大心得是高性能计算从来不是一个孤立的技术问题它是一整套从数据到调度再到通信的工程组合。你不可能靠某个神器一步登天但只要你愿意把每一层的损耗都抠出来把每一层的优化都做实积少成多之后性能提升会相当可观。另外也提醒各位技术方案一定要围绕自己团队的真实场景来定我在V100上做量化吃到的甜头换成一张A100或者满是HBM的新卡结论可能就完全不一样了。框架是死的业务是活的保持对实际数据的敏感比套用任何现成的优化技巧都重要。
返回列表