由李叶金、吴卡罗-珍、克里斯蒂安·普尔施、约尔·施洛瑟、德里斯·古索斯、万杰弗里、乔·艾萨克森、卡内·巴利奥格鲁、胡安·皮诺所著

本文是关于如何使用纯原生 PyTorch 加速生成式 AI 模型的博客系列第四部分。如需查看代码,请访问我们的 GitHub(seamless_communication, fairseq2)。我们很高兴与大家分享一系列新发布的 PyTorch 性能特性以及实际示例,以展示我们可以将 PyTorch 原生性能提升到何种程度。在第一部分中,我们展示了如何仅使用纯原生 PyTorch 将 Segment Anything 加速 8 倍。在第二部分中,我们展示了如何仅使用原生 PyTorch 优化将 Llama-7B 加速近 10 倍。在第三部分中,我们展示了如何仅使用原生 PyTorch 优化将文本到图像扩散模型加速至 3 倍。

在本博客中,我们将关注如何通过使用 CUDA Graph 和原生 PyTorch 优化来加速 FAIR 的 Seamless M4T-v2 模型,从而实现文本解码模块 2 倍速度提升和声码器模块 30 倍速度提升,最终端到端推理速度提升 2.7 倍,且不损失精度:

End to End Inference Speedup

引言

无缝 M4T 是 FAIR 开发的开源基础语音/文本翻译和转录技术。无缝 M4T 是一个大规模多语言和多模态机器翻译模型,最新版本(Seamless M4T-v2)于 2023 年 11 月 30 日发布。Seamless M4T-v2 的高级模型架构如图 1 所示。

Model Architecture of Seamless M4T-v2

图 1. Seamless M4T-v2 模型架构。

加速推理延迟对于翻译模型来说至关重要,可以通过更快的跨语言通信来提升用户体验。特别是,在聊天机器人、语音翻译和实时字幕等应用中,latency 至关重要,因此我们针对 batch_size=1 的推理性能进行了分析,如图 2 所示,以了解 Amdahl 定律的瓶颈。我们的结果表明,文本解码器和声码器是最耗时的模块,分别占推理时间的 61%和 23%。

Text decoder and vocoder are the most time consuming module. Breakdown of inference time by modules for English-Spanish S2ST (Speech-to-Speech-Text) task for batch_size=1 on A100 GPU.

图 2. 文本解码器和声码器是最耗时的模块。在 A100 GPU 上,对于 batch_size=1 的英语-西班牙语 S2ST(语音到语音文本)任务,按模块分解推理时间。

为了更详细地了解文本解码器和声码器的性能瓶颈,我们分析了 FLEURS 数据集中英语-西班牙语翻译示例的第 8 个样本的文本解码器和声码器的 GPU 跟踪信息,如图 3 所示。这揭示了文本解码器和声码器是高度 CPU 密集型的模块。我们观察到由于 CPU 开销导致的显著差距,延迟了 GPU 内核的启动,导致这两个模块的执行时间大幅增加。

CPU and GPU trace for Text Decoder

(a) 文本解码器的 CPU 和 GPU 跟踪

CPU and GPU trace for Vocoder

(b) 声码器的 CPU 和 GPU 跟踪

图 3. 文本解码器和声码器是高度 CPU 密集型模块。以 FLEURS 数据集的英语-西班牙语翻译示例中的第 8 个样本为对象,对文本解码器(a)和声码器(b)进行了 CPU 和 GPU 跟踪。跟踪是通过在 A100 GPU 上运行 batch_size=1 的推理获得的。

基于 Seamless M4T-v2 中真实系统性能分析结果,文本解码器和声码器是高度 CPU 密集型模块,我们为这些模块启用了 torch.compile + CUDA Graph。在此文章中,我们分享了为 batch_size=1 推理场景启用 torch.compile + CUDA Graph 所需的修改、CUDA Graph 的讨论以及下一步计划。

Torch.compile with CUDA Graph

torch.compile 是一个 PyTorch API,允许用户将 PyTorch 模型编译成独立的可执行文件或脚本,通常用于通过删除不必要的开销来优化模型性能。

CUDA Graph 是 NVIDIA 提供的一项功能,它允许优化 CUDA 应用程序中的内核启动。它创建了一个 CUDA 内核的执行图,该图可以在执行在 GPU 上之前由驱动程序进行预处理和优化。使用 CUDA Graph 的主要优势是它减少了与启动单个内核相关的开销,因为图可以作为单个单元启动,从而减少了主机和设备之间的 API 调用和数据传输次数。这可以导致显著的性能提升,特别是对于具有大量小型内核或多次重复相同内核集的应用程序。如果您想了解更多关于这方面的信息,请查看这篇强调数据在加速计算中重要作用的论文:数据在哪里?为什么没有我们自己的 Kim Hazelwood 的回答,您就不能辩论 CPU 与 GPU 的性能!这是 NVIDIA 在大力投资通用 GPU(GPGPUs)的时候,以及深度学习革命化计算行业之前!

然而,由于 CUDA Graph 在编译时记录了 1)固定内存指针,2)固定形状的张量,因此我们为 CUDA Graph 引入了以下改进,以便在多个输入大小之间重用,防止每次迭代都生成 CUDA Graph,并让 CUDA Graph 内部的数据在不同运行之间重用,以共享 KV 缓存,从而在多个解码步骤中重用。

文本解码器

在 Seamless 中,文本解码器是一个从 NLLB [1]转换而来的解码器,执行 T2TT(文本到文本翻译)。此外,该模块是一个 CPU 密集型模型,由于自动回归生成的特性需要按顺序处理标记,因此 GPU 执行时间不足以隐藏 CPU 开销,这限制了在 GPU 上实现的并行度。基于这一观察,我们为文本解码器启用了 torch.compile + CUDA Graph,以减少如图 4 所示的占主导地位的 CPU 开销。

CPU and GPU trace for Text Decoder after torch.compile + CUDA Graph are enabled

图 4.启用 torch.compile + CUDA Graph 后文本解码器的 CPU 和 GPU 跟踪

1. 更新和检索 KV 缓存

在推理过程中,文本解码器有两个计算阶段:一个预填充阶段,用于处理提示信息,以及一个增量生成阶段,逐个生成输出标记。当批量大小或输入长度足够高时,预填充会并行处理足够多的标记——GPU 性能是瓶颈,而 CPU 开销对性能的影响并不显著。另一方面,增量标记生成始终以序列长度 1 执行,并且通常以较小的批量大小(甚至 1)执行,例如用于交互式用例。因此,增量生成可能受到 CPU 速度的限制,因此是 torch.compile + CUDA Graph 的良好候选者。

然而,在增量标记生成阶段,参与注意力计算的键和值的序列长度维度在每一步都会增加一个,而查询的序列长度始终为 1。具体来说,键/值是通过将新计算的序列长度为 1 的键/值追加到迄今为止存储在 KV 缓存中的键/值来生成的。但如上所述,CUDA Graph 在编译和回放过程中记录了张量的所有形状,因此在此处出色工作的基础上,对这个问题进行的修改很少。

a) 我们修改了 KV 缓存处理,以使用 CUDA Tensor 中写入新值的索引(即, valid_seq_pos )而不是 Python 整数。

Modification to KV cache append and get

图 5. 对 KV 缓存 appendget 的修改

b) 我们还修改了注意力机制,使其能够与固定形状的键和值在 max_seq_length 上工作。我们只对当前解码步骤(即, valid_seq_pos )之前的序列位置进行 softmax 计算。为了屏蔽序列位置 > 当前解码步骤(即, valid_seq_pos) ),我们创建了一个布尔掩码张量(即, mask ),其中序列位置 > valid_seq_pos 被设置为 False。

Helper function to generate valid_seq_pos and mask

图 6.生成 valid_seq_posmask 的辅助函数

重要的是要指出,这些修改导致计算量增加,因为我们计算了比必要的更多序列位置上的注意力(多达 max_seq_length )。然而,尽管存在这一缺点,我们的结果表明,torch.compile + CUDA Graph 与标准 PyTorch 代码相比,仍然提供了显著的性能优势。

c) 由于不同的推理样本具有不同的序列长度,因此它还生成了不同形状的输入,这些输入需要投影到键和值以用于交叉注意力层。因此,我们填充输入以具有静态形状,并生成一个填充掩码来屏蔽填充输出。

2. 内存指针管理

CUDA Graph 记录张量的形状和内存指针,因此对不同的推理样本进行正确引用记录的内存指针(例如 KV 缓存)非常重要,以避免为每个推理样本编译 CUDA Graph。然而,Seamless 代码库的一些部分使不同的推理样本引用不同的内存地址,因此我们对内存影响进行了修改。

e) Seamless 采用 beam search 作为文本解码策略。在 beam search 过程中,我们需要对每个增量解码步骤的所有注意力层执行 KV 缓存重排序,以确保每个选定的 beam 都使用相应的 KV 缓存,如下代码片段所示。

KV cache reordering operation for beam search decoding strategy

图 8. beam search 解码策略的 KV 缓存重排序操作。

上述代码为新内存空间分配并覆盖了 cache_kcache_v 的原内存指针。因此,我们修改了 KV 缓存重排序,使用 copy_操作符保持每个缓存的内存指针,以记录编译时的状态。

In-place update for KV cache using copy_ operator

图 9. 使用 copy_ 运算符对 KV 缓存进行原地更新

f) 在按照上述方法修改代码后启用 torch.compile 和 CUDA Graph 对文本解码器进行编译,文本解码器的开销转移到 KV 缓存重排,如图 10 所示。KV 缓存重排会重复调用 index_select 96 次(假设有 24 个解码层,每个层包含两种类型的注意力层,并具有键和值的缓存)。

CPU and GPU trace for Text Decoder after enabling torch.compile + CUDA Graph

图 10. 启用 torch.compile + CUDA Graph 后文本解码器的 CPU 和 GPU 跟踪

作为加速文本解码器的一部分,我们还对 KV 缓存重排应用了 torch.compile,以利用如图 11 所示的内核融合。请注意,在这里不能使用 CUDA Graph( mode='max-autotune' ),因为 copy_ 操作修改了输入,违反了 torch.compile 中 CUDA graph 版本静态输入的要求。

Applying torch.compile to KV Cache reordering

图 11. 应用 torch.compile 到 KV 缓存重排

启用 torch.compile 到 KV 缓存重排后,原本分别启动的 gpu 内核(图 12(a))现在被融合,因此需要启动的 gpu 内核数量大大减少(图 12(b))。

CPU and GPU trace for KV cache reordering before enabling torch.compile

(a) 启用 torch.compile 前 KV 缓存重排的 CPU 和 GPU 追踪

CPU and GPU trace for KV cache reordering after enabling torch.compile

(b) 启用 torch.compile 后 KV 缓存重排的 CPU 和 GPU 追踪

图 12.启用 torch.compile 前后 KV 缓存重排的 CPU 和 GPU 追踪(a)前和(b)后

语音合成器

在 Seamless 中,语音合成器是一个 HiFi-GAN 单元语音合成器,它将生成的单元转换为波形输出,其中单元是语音的一种表示,它结合了不同的方面,如音素和音节,可以用来生成人类可听到的声音。语音合成器是一个相对简单的模块,由 Conv1d 和 ConvTranspose1d 层组成,如图 3 所示,是一个 CPU 密集型模块。基于这一观察,我们决定为语音合成器启用 torch.compile 和 CUDA Graph,以减少如图 10 所示的过大 CPU 开销。但需要进行一些修复。

CPU and GPU trace for Vocoder after torch.compile + CUDA Graph are enabled

图 13.启用 torch.compile + CUDA Graph 后语音合成器的 CPU 和 GPU 追踪

a) 语音合成器的输入张量形状在不同推理样本中不同。但是,由于 CUDA Graph 记录张量的形状并回放它们,我们不得不使用零填充将输入填充到固定大小。由于语音合成器只由 Conv1d 层组成,我们不需要额外的填充掩码,使用零填充就足够了。

b) 语音合成器由 conv1d 层包裹着 torch.nn.utils.weight_norm (见此处)。然而,直接将 torch.compile 应用于 Vocoder 会导致图断裂,如下所示,这会导致性能提升不佳。这种图断裂发生在处理 weight_norm 的 PyTorch 代码的钩子部分中。

[1/0_2] torch._dynamo.symbolic_convert.__graph_breaks: [DEBUG] Graph break: setattr(UserDefinedObjectVariable) <function Module.__setattr__ at 0x7fac8f483c10> from user code at:
[1/0_2] torch._dynamo.symbolic_convert.__graph_breaks: [DEBUG]   File "/mnt/fsx-home/yejinlee/yejinlee/seamless_communication/src/seamless_communication/models/vocoder/vocoder.py", line 49, in forward
[1/0_2] torch._dynamo.symbolic_convert.__graph_breaks: [DEBUG]     return self.code_generator(x, dur_prediction)  # type: ignore[no-any-return]1/0_2] torch._dynamo.symbolic_convert.__graph_breaks: [DEBUG]   File "/data/home/yejinlee/mambaforge/envs/fairseq2_12.1/lib/python3.8/site-packages/torch/nn/modules/module.py", line 1520, in _call_impl
[1/0_2] torch._dynamo.symbolic_convert.__graph_breaks: [DEBUG]     return forward_call(*args, **kwargs)
[2023-12-13 04:26:16,822] [1/0_2] torch._dynamo.symbolic_convert.__graph_breaks: [DEBUG]   File "/mnt/fsx-home/yejinlee/yejinlee/seamless_communication/src/seamless_communication/models/vocoder/codehifigan.py", line 101, in forward
[1/0_2] torch._dynamo.symbolic_convert.__graph_breaks: [DEBUG]     return super().forward(x)
[1/0_2] torch._dynamo.symbolic_convert.__graph_breaks: [DEBUG]   File "/mnt/fsx-home/yejinlee/yejinlee/seamless_communication/src/seamless_communication/models/vocoder/hifigan.py", line 185, in forward
[1/0_2] torch._dynamo.symbolic_convert.__graph_breaks: [DEBUG]     x = self.ups[i](x)
[1/0_2] torch._dynamo.symbolic_convert.__graph_breaks: [DEBUG]   File "/data/home/yejinlee/mambaforge/envs/fairseq2_12.1/lib/python3.8/site-packages/torch/nn/modules/module.py", line 1550, in _call_impl
[1/0_2] torch._dynamo.symbolic_convert.__graph_breaks: [DEBUG]     args_result = hook(self, args)
[1/0_2] torch._dynamo.symbolic_convert.__graph_breaks: [DEBUG]   File "/data/home/yejinlee/mambaforge/envs/fairseq2_12.1/lib/python3.8/site-packages/torch/nn/utils/weight_norm.py", line 65, in __call__
[1/0_2] torch._dynamo.symbolic_convert.__graph_breaks: [DEBUG]     setattr(module, self.name, self.compute_weight(module))
[1/0_2] torch._dynamo.symbolic_convert.__graph_breaks: [DEBUG] 

由于层权重在推理过程中不会改变,我们不需要权重归一化。因此,我们简单地移除了 Vocoder 的权重归一化,如图 14 所示,通过利用 Seamless 代码库中已提供的 remove_weight_norm 函数(此处)。

Removing weight_norm for Vocoder

图 14. 移除 Vocoder 的 weight_norm

性能评估 + CUDA 图的影响

图 15 展示了启用 torch.compile(mode="max-autotune") + CUDA 图在文本解码器和语音合成器上的加速结果。我们实现了文本解码器的 2 倍加速和语音合成器的 30 倍加速,从而将端到端推理时间提高了 2.7 倍。

Inference time speedup of text decoder and vocoder of applying torch.compile and torch.compile + CUDA Graph Inference time speedup of text decoder and vocoder of applying torch.compile and torch.compile + CUDA Graph

图 15. 应用 torch.compile 和 torch.compile + CUDA 图对文本解码器和语音合成器推理时间的加速

我们还报告了使用 torch.compile 但不使用 CUDA 图时文本解码器和语音合成器的加速情况,这由 torch.compile 的 API 支持(即, torch.compile(mode="max-autotune-no-cudagraphs") ),以确定 CUDA 图对性能的影响。没有 CUDA 图,文本解码器和语音合成器的加速降低到 1.17 倍和 18.4 倍。虽然仍然相当显著,但它表明 CUDA 图的重要作用。我们得出结论,Seamless M4T-v2 在许多情况下都暴露于大量的 CUDA 内核启动时间,尤其是在我们使用小批量(例如,1)时,GPU 内核执行时间不足以分摊 GPU 内核启动时间。

End-to-end inference speedup of applying torch.compile and CUDA graph incrementally

图 16.应用 torch.compile 和 CUDA 图增量方式带来的端到端推理速度提升。a) “增量解码”:仅对文本解码器应用 torch.compile b) “增量解码+CUDA 图”:对文本解码器应用 torch.compile + CUDA 图 c) “+KV 缓存重排”:在 b)的基础上,对 KV 缓存重排操作也应用 torch.compile d) “+Vocoder”:在 c)的基础上,对 Vocoder 也应用 torch.compile e) “+Vocoder+CUDA 图”:在 d)的基础上,对 Vocoder 应用 torch.compile + CUDA 图。

图 16 展示了应用 torch.compile(带 CUDA 图和不带 CUDA 图)对模块的累积影响。结果表明,端到端推理速度提升显著,证明了这些技术在优化整体延迟方面的有效性。因此,对于 batch_size=1 的 Seamless M4T-v2,我们获得了 2.7 倍的端到端推理速度提升。

致谢

我们感谢 PyTorch 团队和 Seamless 团队对我们工作的巨大支持。