vLLM 的 torch.compile 集成#

在 vLLM 的 V1 架构中,默认启用 torch.compile,并且它是框架的关键组成部分。本文档通过一个简单的示例,展示如何理解 torch.compile 的用法。

在整个示例中,我们将使用 v1 运行一个常见的 Llama 模型,并启用调试级别日志记录以显示所有详细信息。要使用的命令是 VLLM_USE_V1=1 VLLM_LOGGING_LEVEL=DEBUG vllm serve meta-llama/Llama-3.2-1B

编译缓存#

在非常详细的日志中,我们可以看到

INFO 03-07 03:06:55 [backends.py:409] Using cache directory: ~/.cache/vllm/torch_compile_cache/1517964802/rank_0_0 for vLLM's torch.compile

vLLM 将综合考虑所有可用因素,并确定一个目录来存储所有编译产物。这意味着,您可以直接复制部署场景中的整个 ~/.cache/vllm/torch_compile_cache 目录,以节省大量编译时间,从而加快 vLLM 实例的启动时间。

考虑的因素包括

  • 所有相关配置(请参阅 config.py 中的 compute_hash 函数)

  • PyTorch 配置(请参阅 compiler_interface.py 中的 compute_hash 函数)

  • 模型的 forward 函数以及 forward 函数调用的相关函数(见下文)

考虑到所有这些因素,通常我们可以保证缓存可以安全使用,并且不会导致任何意外行为。因此,默认启用缓存。如果您想调试编译过程,或者怀疑缓存导致了一些问题,您可以通过设置环境变量 VLLM_DISABLE_COMPILE_CACHE=1 来禁用它。

vLLM 的 torch.compile 集成的一个独特之处在于,我们保证所有编译都在服务任何请求之前完成。任何请求都不会触发新的编译。否则,引擎将在该请求上被阻塞,并且响应时间将出现意外的峰值。

Python 代码编译#

在非常详细的日志中,我们可以看到

DEBUG 03-07 03:06:52 [decorators.py:203] Start compiling function <code object forward at 0x7f08acf40c90, file "xxx/vllm/model_executor/models/llama.py", line 339>

DEBUG 03-07 03:06:54 [backends.py:370] Traced files (to be considered for compilation cache):
DEBUG 03-07 03:06:54 [backends.py:370] xxx/torch/_dynamo/polyfills/builtins.py
DEBUG 03-07 03:06:54 [backends.py:370] xxx/torch/nn/modules/container.py
DEBUG 03-07 03:06:54 [backends.py:370] xxx/torch/nn/modules/module.py
DEBUG 03-07 03:06:54 [backends.py:370] xxx/vllm/attention/layer.py
DEBUG 03-07 03:06:54 [backends.py:370] xxx/vllm/distributed/communication_op.py
DEBUG 03-07 03:06:54 [backends.py:370] xxx/vllm/distributed/parallel_state.py
DEBUG 03-07 03:06:54 [backends.py:370] xxx/vllm/model_executor/custom_op.py
DEBUG 03-07 03:06:54 [backends.py:370] xxx/vllm/model_executor/layers/activation.py
DEBUG 03-07 03:06:54 [backends.py:370] xxx/vllm/model_executor/layers/layernorm.py
DEBUG 03-07 03:06:54 [backends.py:370] xxx/vllm/model_executor/layers/linear.py
DEBUG 03-07 03:06:54 [backends.py:370] xxx/vllm/model_executor/layers/rotary_embedding.py
DEBUG 03-07 03:06:54 [backends.py:370] xxx/vllm/model_executor/layers/vocab_parallel_embedding.py
DEBUG 03-07 03:06:54 [backends.py:370] xxx/vllm/model_executor/models/llama.py

DEBUG 03-07 03:07:07 [backends.py:462] Computation graph saved to ~/.cache/vllm/torch_compile_cache/1517964802/rank_0_0/computation_graph.py
DEBUG 03-07 03:07:07 [wrapper.py:105] Dynamo transformed code saved to ~/.cache/vllm/torch_compile_cache/1517964802/rank_0_0/transformed_code.py

这是关于 Python 代码编译,即 Dynamo 的图捕获。它尝试跟踪代码为 xxx/vllm/model_executor/models/llama.py:339 的函数,这是我们编译的模型的 forward 函数。在正向传播期间,Dynamo 还会调用和内联其他函数,如日志所示,包括来自 xxx/torch/nn/modules/module.py 的一些 PyTorch 函数(由 PyTorch nn.Module 使用,因为模块属性访问将触发函数调用),来自 vLLM 的一些通信/注意力/激活函数。当我们决定要使用的缓存目录时,将考虑所有跟踪的文件。这样,上述文件中的任何代码更改都将触发编译缓存未命中,从而导致重新编译。

Dynamo 编译的结果是一个新函数,存储在 ~/.cache/vllm/torch_compile_cache/1517964802/rank_0_0/transformed_code.py 中。通常,此函数从模块中解包张量,然后将其传递给跟踪的计算图。计算图存储在 ~/.cache/vllm/torch_compile_cache/1517964802/rank_0_0/computation_graph.py 中。

计算图处理#

计算图具有每个张量的形状注释。输入是输入 ID、位置 ID、模型中的权重和缓冲区,输出是最终的隐藏状态。请注意,lm head 投影和采样操作不在图中考虑。

计算图的大多数输入都具有静态形状,因为它们是模型权重和缓冲区,并且在模型的生命周期内不会更改。只有输入 ID 和位置 ID 具有符号形状,即形状可以因批次而异。但是,它们将共享相同的符号形状。也就是说,计算图中唯一变化的大小是批次大小(当前正向传播中处理的 token 数量)。

注意力操作很复杂,它需要与 KV 缓存交互,并且形状复杂。幸运的是,注意力操作的输出与注意力操作的输入查询共享相同的形状。因此,我们将整个注意力操作包装到 PyTorch 自定义操作 torch.ops.vllm.unified_attention_with_output 中,以便 Dynamo 不会尝试检查任何内部操作。这样,尽管注意力操作很复杂,但从 Dynamo 的角度来看,我们仍然可以将模型的计算图捕获为完整图。

计算图通过 splitting_ops(通常是注意力操作)进一步分成几部分。因此,在 ~/.cache/vllm/torch_compile_cache/1517964802/rank_0_0/computation_graph.py 文件中,我们可以看到许多子模块,每个子模块都是拆分后的图形的一部分

  • 注意力操作本身就是一个子模块。

  • 计算图的一部分,从一个注意力操作到下一个注意力操作,是一个子模块。

每个子模块都可以通过其索引来识别,并将被单独处理。

计算图编译#

在非常详细的日志中,我们还可以看到

DEBUG 03-07 03:52:37 [backends.py:134] store the 0-th graph for shape None from inductor via handle ('fpegyiq3v3wzjzphd45wkflpabggdbjpylgr7tta4hj6uplstsiw', '~/.cache/vllm/torch_compile_cache/1517964802/rank_0_0/inductor_cache/iw/ciwzrk3ittdqatuzwonnajywvno3llvjcs2vfdldzwzozn3zi3iy.py')
DEBUG 03-07 03:52:39 [backends.py:134] store the 1-th graph for shape None from inductor via handle ('f7fmlodmf3h3by5iiu2c4zarwoxbg4eytwr3ujdd2jphl4pospfd', '~/.cache/vllm/torch_compile_cache/1517964802/rank_0_0/inductor_cache/ly/clyfzxldfsj7ehaluis2mca2omqka4r7mgcedlf6xfjh645nw6k2.py')
...
DEBUG 03-07 03:52:45 [backends.py:134] store the 15-th graph for shape None from inductor via handle ('f7fmlodmf3h3by5iiu2c4zarwoxbg4eytwr3ujdd2jphl4pospfd', '~/.cache/vllm/torch_compile_cache/1517964802/rank_0_0/inductor_cache/ly/clyfzxldfsj7ehaluis2mca2omqka4r7mgcedlf6xfjh645nw6k2.py')
DEBUG 03-07 03:52:45 [backends.py:134] store the 16-th graph for shape None from inductor via handle ('fvj3ccoi7m34f3dnr4itmu55mmun44l5xymwhrjlwisylsk7q6jy', '~/.cache/vllm/torch_compile_cache/1517964802/rank_0_0/inductor_cache/tf/ctfftkglj7b4lcttq5cymx6cew372uoauupqn6ldsvpiucavqcjc.py')

这意味着计算图的第一部分(对于符号形状,形状为 None)由 Inductor 编译(密钥为 fpegyiq3v3wzjzphd45wkflpabggdbjpylgr7tta4hj6uplstsiw)。编译后的内核存储在 ~/.cache/vllm/torch_compile_cache/1517964802/rank_0_0/inductor_cache/iw/ciwzrk3ittdqatuzwonnajywvno3llvjcs2vfdldzwzozn3zi3iy.py 中。您可以打开该文件以查看 Inductor 最终运行的代码。

还有一个细节:您可以看到第 1 个图和第 15 个图具有相同的密钥,而第 0 个图和第 16 个图则不同。这是预期的,因为我们通过注意力操作拆分了图,我们得到了 3 个唯一的子图

  • 注意力之前的第一个层

  • 每个中间层,从一个注意力操作到下一个注意力操作

  • 注意力之后的最后一层

如果我们已经有了缓存目录(例如,第二次运行相同的代码),我们将看到以下日志

DEBUG 03-07 04:00:45 [backends.py:86] Directly load the 0-th graph for shape None from inductor via handle ('fpegyiq3v3wzjzphd45wkflpabggdbjpylgr7tta4hj6uplstsiw', '~/.cache/vllm/torch_compile_cache/1517964802/rank_0_0/inductor_cache/iw/ciwzrk3ittdqatuzwonnajywvno3llvjcs2vfdldzwzozn3zi3iy.py')

这一次,完全绕过了 Inductor 编译,我们将从磁盘加载以读取上次获得的编译产物。

以上示例仅使用 Inductor 为通用形状(即符号形状)进行编译。我们还可以使用 Inductor 为某些特定形状进行编译,例如

VLLM_USE_V1=1 vllm serve meta-llama/Llama-3.2-1B --compilation_config "{'compile_sizes': [1, 2, 4, 8]}"

然后它还将为批次大小 1, 2, 4, 8 编译一个特定的内核。此时,计算图中的所有形状都是静态且已知的,我们将启用自动调优以优化性能。当您第一次运行时,这可能会很慢,但是下次运行时,我们可以直接绕过调优并运行调优后的内核。

当所有形状都已知时,torch.compile 可以比较不同的配置,并且通常会找到一些更好的配置来运行内核。例如,我们可以看到以下日志

AUTOTUNE mm(8x2048, 2048x3072)
  triton_mm_4 0.0130 ms 100.0% ACC_TYPE='tl.float32', ALLOW_TF32=False, BLOCK_K=128, BLOCK_M=16, BLOCK_N=32, B_PROLOGUE_CAST_TYPE=None, EVEN_K=True, GROUP_M=8, num_stages=5, num_warps=2
  triton_mm_8 0.0134 ms 97.4% ACC_TYPE='tl.float32', ALLOW_TF32=False, BLOCK_K=128, BLOCK_M=16, BLOCK_N=64, B_PROLOGUE_CAST_TYPE=None, EVEN_K=True, GROUP_M=8, num_stages=5, num_warps=4
  triton_mm_12 0.0148 ms 87.7% ACC_TYPE='tl.float32', ALLOW_TF32=False, BLOCK_K=128, BLOCK_M=16, BLOCK_N=128, B_PROLOGUE_CAST_TYPE=None, EVEN_K=True, GROUP_M=8, num_stages=4, num_warps=4
  mm 0.0160 ms 81.6% 
  triton_mm_16 0.0165 ms 78.7% ACC_TYPE='tl.float32', ALLOW_TF32=False, BLOCK_K=64, BLOCK_M=16, BLOCK_N=128, B_PROLOGUE_CAST_TYPE=None, EVEN_K=True, GROUP_M=8, num_stages=5, num_warps=8
  triton_mm_3 0.0199 ms 65.4% ACC_TYPE='tl.float32', ALLOW_TF32=False, BLOCK_K=32, BLOCK_M=16, BLOCK_N=32, B_PROLOGUE_CAST_TYPE=None, EVEN_K=True, GROUP_M=8, num_stages=5, num_warps=2
  triton_mm_1 0.0203 ms 64.2% ACC_TYPE='tl.float32', ALLOW_TF32=False, BLOCK_K=128, BLOCK_M=16, BLOCK_N=32, B_PROLOGUE_CAST_TYPE=None, EVEN_K=True, GROUP_M=8, num_stages=2, num_warps=2
  triton_mm_7 0.0203 ms 64.1% ACC_TYPE='tl.float32', ALLOW_TF32=False, BLOCK_K=64, BLOCK_M=16, BLOCK_N=64, B_PROLOGUE_CAST_TYPE=None, EVEN_K=True, GROUP_M=8, num_stages=3, num_warps=4
  triton_mm_2 0.0208 ms 62.5% ACC_TYPE='tl.float32', ALLOW_TF32=False, BLOCK_K=32, BLOCK_M=16, BLOCK_N=64, B_PROLOGUE_CAST_TYPE=None, EVEN_K=True, GROUP_M=8, num_stages=5, num_warps=4
  triton_mm_11 0.0215 ms 60.5% ACC_TYPE='tl.float32', ALLOW_TF32=False, BLOCK_K=64, BLOCK_M=16, BLOCK_N=128, B_PROLOGUE_CAST_TYPE=None, EVEN_K=True, GROUP_M=8, num_stages=3, num_warps=4
SingleProcess AUTOTUNE benchmarking takes 2.0428 seconds and 7.5727 seconds precompiling

这意味着,对于形状为 8x2048x3072 的矩阵乘法,torch.compile 尝试使用各种配置的 triton 模板,它比默认代码(默认代码分派到 cublas 库)快得多。

不幸的是,由于自动调优需要相当长的时间(从几秒钟到几分钟,具体取决于模型大小和批次大小),即使它可以缓存以供以后使用,但为了用户友好性,我们默认将其关闭。如果您想要获得最大性能,建议尝试通过编译特定形状来实现。

Cudagraph 捕获#

vLLM 的 V1 架构使用分段 Cudagraph。完整的计算图如上所述被拆分,我们仅捕获注意力操作之间图形片段的 cudagraph(包括任何注意力操作之前的第一个图形和所有注意力操作之后的最后一个图形)。这是基于一个常见的观察:注意力之间的计算通常是 token 级别的,并且对于 cudagraph 来说很容易处理;而注意力操作与 cudagraph 兼容并非易事。因此,通过在 eager 模式下运行注意力操作,同时在 cudagraph 中运行其余操作,我们保持了注意力操作的灵活性。

分段 cudagraph 还具有细粒度的内存管理。目的是仅将注意力内核从 cudagraph 中排除,同时将所有其余模块和内存分配操作保留在 cudagraph 中。这就是为什么 V1 中的注意力操作将输出张量作为注意力的输入。

cudagraph 由编译器后端捕获和管理,并在批次大小具有相应的捕获 cudagraph 时重放。模型的调用者(模型运行器)只需要确保正确管理输入缓冲区。所有中间缓冲区都由编译器后端自动管理。

默认情况下,vLLM 将尝试确定一组大小来捕获 cudagraph。您也可以使用配置 cudagraph_capture_sizes 覆盖它

VLLM_USE_V1=1 vllm serve meta-llama/Llama-3.2-1B --compilation_config "{'cudagraph_capture_sizes': [1, 2, 4, 8]}"

然后它将仅捕获指定大小的 cudagraph。对于细粒度控制 cudagraph 捕获,这可能很有用。