掌握 PyTorch 编译:释放 AI/ML 模型的极致性能

Towardsdatascience

自 PyTorch 2.0 于 2023 年 3 月推出以来,torch.compile 已迅速成为优化 AI 和机器学习工作负载性能不可或缺的工具。PyTorch 最初因其“Pythonic”设计、易用性以及急切(逐行)执行模式而广受欢迎。因此,即时 (JIT) 图编译模式的成功采用并非理所当然。然而,仅仅两年多后,其在提升运行时性能方面的重要性不容小觑。尽管功能强大,torch.compile 仍然可能感觉像一门深奥的艺术;当它正常工作时,其好处显而易见,但由于其众多的 API 控制和分散的文档,诊断问题可能具有挑战性。本文旨在揭开 torch.compile 的神秘面纱,解释其工作原理,演示有效的应用策略,并评估其对模型性能的影响。

PyTorch 默认的急切执行模式虽然对调试用户友好,但本质上牺牲了优化机会。每行 Python 代码都是独立处理的,这阻碍了诸如操作符融合(将多个 GPU 操作组合成一个更高效的内核)和内存布局与执行顺序的预先 (AOT) 编译等效率提升。此外,Python 解释器和 CUDA 后端之间持续的切换引入了显著的开销。torch.compile 通过充当 JIT 编译器来解决这些限制。当首次调用编译函数时,它会使用 TorchDynamo 将 Python 代码追踪成中间图表示,通常称为 FX Graph。对于训练,AOTAutograd 捕获反向传播以生成组合的前向和反向图。然后,此图被传递给编译器后端,通常是 TorchInductor,它执行广泛的优化,如内核融合和乱序执行。对于 NVIDIA GPU,TorchInductor 利用 Triton 编译器创建高度优化的 GPU 内核,并在可能的情况下使用 CUDA Graphs 将多个内核组合成高效、可重复的序列。生成的机器特定计算图随后被缓存并重用于所有后续调用,显著减少了 Python 解释器的参与并最大化了图优化。

虽然 torch.compile 通常能提升模型吞吐量,但开发者有时会遇到性能停滞甚至下降的情况。除了像慢速数据输入管道这样的外部瓶颈外,两个主要的“编译杀手”通常是罪魁祸首:图中断 (graph-breaks) 和重新编译 (recompilations)。

当追踪库 TorchDynamo 或 AOTAutograd 遇到无法转换为图操作的 Python 操作时,就会发生图中断。这会强制编译器分割代码,分别编译各个部分,并在片段之间将控制权返回给 Python 解释器。这种碎片化阻碍了像内核融合这样的全局优化,并可能完全抵消 torch.compile 的好处。常见的罪魁祸首包括 print() 语句、复杂的条件逻辑和 asserts。令人沮丧的是,图中断通常会静默地回退到急切执行模式。为了解决这些问题,开发者可以配置编译器来报告它们,例如通过设置 TORCH_LOGS="graph_breaks" 或使用 fullgraph=True 来强制在遇到中断时编译失败。解决方案通常涉及用图友好的替代方案(如 torch.wheretorch.cond)替换条件块,或者仅在不进行编译时有条件地执行 print/assert 语句。

第二个主要陷阱是图重新编译。在初始编译期间,torch.compile 会对输入(例如张量数据类型和形状)做出假设,这些假设被称为“保护条件”(guards)。如果在后续步骤中违反了这些保护条件——例如,如果张量的形状发生变化——当前图就会失效,从而触发代价高昂的重新编译。过度的重新编译会抵消所有性能增益,甚至可能在达到默认的八次重新编译限制后导致回退到急切模式。通过设置 TORCH_LOGS="recompiles" 可以识别重新编译。处理动态形状时,存在多种策略。默认行为 (dynamic=None) 会自动检测动态性并进行精细重新编译,但这可能会触及重新编译限制。当预先知道动态形状时,使用 torch._dynamo.mark_dynamic 显式标记动态张量和轴通常是最佳方法,因为它会通知编译器构建一个支持动态性而无需重新编译的图。或者,设置 dynamic=True 会指示编译器创建最大动态图,尽管这可能会禁用某些静态优化(如 CUDA Graphs)。一种更受控的方法是通过将动态张量填充到几个预定长度来编译固定数量的静态图,确保所有图变体都在预热阶段创建。

调试编译问题通常会伴随冗长、晦涩的错误消息,这可能令人望而却步。方法包括“自上而下”——将编译应用于整个模型,并在问题出现时解决(需要仔细解读日志),以及“自下而上”——逐步编译低级组件直到识别出错误(使问题定位更容易,并允许部分优化收益)。这些策略的结合通常能产生最佳结果。

一旦模型成功编译,通过各种调优选项可以进一步榨取性能,尽管与初始编译相比,这些通常提供较小的改进。高级编译器模式,如“reduce-overhead”和“max-autotune”,可以分别积极地优化以减少开销和基准测试多个内核选项,尽管它们会增加编译时间。可以指定不同的编译器后端,其中 TorchInductor 是 NVIDIA GPU 的默认后端,而其他如 ipex 可能更适合 Intel CPU。对于具有明显静态和动态组件的模型,模块化编译——将 torch.compile 应用于单个子模块——允许为每个部分量身定制优化设置。除了模型本身,PyTorch 2.2 还引入了编译优化器的能力,进一步增强了训练工作负载的性能。例如,在一个示例图像字幕模型中编译优化器,将吞吐量从每秒 5.17 步提升到 5.54 步。

虽然初始编译和预热时间可能很长,但与模型的整体训练或推理生命周期相比,它们通常可以忽略不计。然而,对于编译可能需要数小时的超大型模型,或对于启动时间影响用户体验的推理服务器,缩短此持续时间变得至关重要。两种关键技术是编译时缓存和区域编译。编译时缓存涉及将编译后的图工件保存到持久存储(例如 Amazon S3),并在后续运行中重新加载它们,从而跳过从头开始的重新编译。在一个演示中,这将编译预热时间从 196 秒缩短到 56 秒,提速 3.5 倍。区域编译将 torch.compile 应用于大型模型中重复的计算块,而不是整个结构。这会创建一个单一的、更小的图,并在该块的所有实例中重用。对于示例模型,这将预热时间从 196 秒缩短到 80 秒(提速 2.45 倍),尽管吞吐量略有下降,从每秒 7.78 步降至 7.61 步。虽然在小型示例模型上的收益不大,但这些技术对于实际的大规模部署可能至关重要。

最终,随着 AI/ML 模型在复杂性和规模上持续增长,优化其运行时性能至关重要。torch.compile 作为 PyTorch 最强大的优化工具之一,能够显著提升速度——在某些静态图场景中最高可达 78% 的加速,在所示动态图示例中最高可达 72% 的加速。掌握其细微之处,从避免图中断和重新编译等常见陷阱,到微调设置和利用高级功能,对于释放其全部潜力至关重要。