跳到正文
vLLM Blog· Nils Matteson·· 27 天前精选AI 评分65

用 uv 的 UV_COMPILE_BYTECODE 参数缩短 vLLM 启动时间

Reducing vLLM startup time with one uv flag

AI 导读

作者在 H100 服务器上实测,构建镜像时设置 ENV UV_COMPILE_BYTECODE=1 让 uv 预编译 Python 字节码,Qwen3-8B 和 Qwen3-32B 的启动中位节省分别为 17.6 秒和 17.8 秒。

推荐理由

作者用配对实测拆解启动耗时来源,并给出可直接复用的构建参数与镜像版本信息。

正文 · AI 翻译

全新的 vLLM 容器在开始处理任何请求之前,可能会花上几秒钟编译 Python 源码。在镜像中预先打包这些字节码,在我的 H100 服务器基准测试中每次启动大约节省了 18 秒,只需一个 uv 构建设置:

ENV UV_COMPILE_BYTECODE=1

在安装运行时包之前设置该标志,这样 uv 会在构建期间编译它们的 Python 源码。这避免了在跨模型共享的导入过程中重复编译。Qwen3-8B 和 Qwen3-32B 是两个例子:配对启动节省的中位数分别为 17.6 秒和 17.8 秒。

对于官方镜像用户,PR #55422 已包含在这个 CUDA nightly 中:

vllm/vllm-openai:nightly-8cbd5d03006c33185f402249ff2b448efd594986

该变更将从 v0.32.0 起在官方发布镜像中提供支持。这些镜像的用户无需自行设置构建标志。

H100 上的启动节省

我测量了从进程启动到首次正确响应的时间,镜像和模型文件均已位于本地。每个模型进行四组相邻配对,比较 nightly 中随镜像发布的包字节码与移除这些缓存后的情况。全部八组配对均有改善。镜像拉取和模型下载不计入。

All four paired process-launch-to-first-correct-response observations for each model, comparing package bytecode absent with the original shipped caches.
每个模型全部四组从进程启动到首次正确响应的配对观测,比较缺少包字节码与原始随镜像发布缓存的情况。

每个模型四组配对,每种顺序各两组,在一台 Xeon Platinum 8480+ / H100 SXM 80GB 服务器上。软件文件常驻内存;应用/编译器缓存从全新状态开始。连线连接配对的启动。

解释器、已安装软件和路径保持不变;只有包 .pyc 的可用性发生变化。两种条件下均保持正常的缓存写入。 方法、JSON 和 全部 16 次观测 记录了配置和输出检查。这些是单一部署上的配对中位数,并非全集群范围的估计。

两种模型规模的绝对节省相近,而 8B 的百分比降幅更大:17.6% 对 13.9%。这与避免两个模型共享的 Python 导入工作相一致。节省的时间将取决于运行时和主机。

其他启动测量

在我的 i9-13900HX / RTX 4090 Laptop 上,一项单独的匹配镜像研究覆盖了 Qwen3-0.6B、Qwen3-1.7B 和 SmolLM2-1.7B,每个模型六组平衡配对。全部 18 组配对均有利于预编译,配对节省的中位数为 12.8–13.5 秒。图表 展示了每一组配对,方法 链接了数据。硬件、运行时和时钟与 H100 研究不同,因此我将结果分开呈现。

更早的一项 Qwen3-0.6B 测试还包括从本地回环注册表拉取并解包匹配镜像到空的 Docker 存储中。在三组配对中,配对节省的中位数为 15.4 秒。预编译镜像增加了 153 MB 压缩体积,即 1.8%,这在首次拉取较慢时可能有影响。镜像交付测量 保留了这一单独的计时边界和镜像大小权衡。

为什么全新容器会再次编译 Python

CPython 在执行源码之前会将其编译为代码对象。有效的 .pyc 文件存储了编译后的代码,以便 Python 下次直接加载。模块初始化仍会运行。这些是 Python 导入;torch.compile 和 GPU 内核有单独的编译和缓存路径。

pip 默认在安装时编译字节码;uv 会跳过这一步。Python 可以在容器运行期间写入缺失的缓存,但这些文件位于其可写层中。下一个全新容器从镜像启动,同样要面对相同的编译工作。uv 的 Docker 指南建议为生产镜像启用编译。

在此前匹配镜像的研究中,我使用 vLLM 针对测试 GPU 架构的 CUDA Dockerfile,从同一个 vLLM 开发提交构建了两个镜像,一个启用该设置,一个不启用。它们的 Python 解释器、已安装包版本、源文件和原生库均一致。

为了确认 Python 使用了缓存,我在一个全新进程中运行了 import vllm.entrypoints.cli.serve。这会加载 vllm serve 背后的 Python 代码和依赖,但不加载模型,也不处理请求。我将其称为serving-CLI 导入:

匹配的 Docker 镜像默认构建预编译构建
已安装包的源文件26,73426,734
具有有效字节码的源文件40726,734
serving-CLI 导入期间的源文件编译5,2570

使用 -X pycache_prefix 指向空目录运行预编译镜像时,源文件编译又回来了。包和源文件没有变化;加载器再也找不到随镜像提供的字节码。

编译器耗时解释了大部分导入节省

在 Docker 之外的一个独立 vLLM 安装中,有效字节码将插桩后的 serving-CLI 导入从12.20 秒缩短到 5.01 秒,节省了 7.19 秒。没有缓存时,Python 在 5,118 次源文件编译器调用中花费了 6.99 秒;有缓存时,此类调用为零。编译占了这一导入差距的大部分。这个每种条件只运行一次的诊断并未划分完整的服务器启动节省。

另一项重复研究在 Docker 之外的一个已发布 vLLM 0.28.0 安装中测量了不带编译器插桩的导入。每个导入根都有改善:

Fresh-process import measurements for torch, transformers, vLLM and the serving CLI. Each root is measured independently, with and without bytecode.
torch、transformers、vLLM 和 serving CLI 的全新进程导入测量。每个根独立测量,分别在有和没有字节码的情况下进行。

每种条件三个全新进程,被测 Python 和原生库文件常驻内存,且无编译器插桩。这些是与上述诊断分开的观察结果。圆点是观察值;刻度是中位数。每个导入都包含其依赖,因此各行不可相加。

在这个 vLLM 版本中,import vllm 已经加载了 Dynamo、Inductor 和 SymPy;一个普通的 import torch 则不会加载它们。大部分源文件属于依赖,但 vLLM 的导入路径决定了何时加载它们。

在另一个仅针对编译器的测试中,我使用了追踪得到的 serving-CLI 语料库:5,118 个文件,包含 78.6 MB 源代码。此安装与上面的 Docker 构建不同,后者编译了 5,257 个文件。我在计时 Python 编译器之前先将语料库读入内存:

# Read and verify the sources before starting the clock.
for filename, source_bytes in preloaded_sources:
    compile(source_bytes, filename, "exec", dont_inherit=True, optimize=0)

编译并丢弃这些代码对象在三次全新进程运行中的中位数为 6.30 秒,当前线程 CPU 时间为 6.30 秒。计时循环内没有发生文件读取、模块执行或字节码写入。这是独立编译器的中位数;上面的 6.99 秒是在实际导入期间通过逐调用计时钩子测得的。

源文件已在 RAM 中时节省依然存在

当两种条件下相同的 8.85 GB 已安装 Python 和共享库文件都已常驻内存时,预编译字节码仍能在每次服务器启动时节省 9–12 秒。将这些文件保留在操作系统页面缓存中并未消除这一节省。

更快的第二次启动无法确定原因:第一次启动会预热文件系统页面并写入 .pyc 文件。在同一解释器中重复导入也可能复用 sys.modules。

对于此测试,我直接从 Linux Python 安装中启动 vllm serve,而非在 Docker 中。每次全新启动前我都验证了驻留情况,然后计时从启动到首次正确 HTTP 响应的时间。不涉及镜像拉取。一种情况以预编译包 .pyc 文件开始;另一种情况在测试的包安装中没有任何此类文件。

从启动到首次正确响应,无预编译包缓存时耗时 54.7–60.6 秒,有缓存时耗时 44.6–48.4 秒。三组配对节省分别为 10.13、9.35 和 12.23 秒。全部六次启动均返回正确响应。

正常的字节码写入和父/子缓存复用保持启用,因此这测量的不只是编译器。它还使用了与匹配镜像实验不同的 vLLM 构建、Python 构建、CPU 限制和计时边界。结果不可叠加。这些是单主机观察结果;CPU 频率和热状态未固定。

在镜像中使用

对于你自己的镜像,在安装运行时包的 uv 命令之前设置 UV_COMPILE_BYTECODE=1,然后重新构建。

主要陷阱是已有的基础镜像。后续 uv 命令是否也会编译从 FROM 继承的包,取决于命令和 uv 版本。检查最终镜像,并使用运行时 Python 的 compileall 准备任何缺失的缓存。在阶段之间复制包时保留源代码和字节码。添加 -O 或更改运行时缓存路径可能会改变 Python 查找的文件。方法补充 包含缓存覆盖命令和兼容性注意事项。

在之前的匹配镜像测试中,额外的 153 MB 在缓慢的镜像拉取时可能很重要。已缓存层的节点在启动另一个容器时可避免该传输。构建时开销未单独测量。在之前的隔离安装中,缓存的服务 CLI 导入仍耗时约五秒;模块初始化仍是需要调查的工作。

方法与测量 给出了确切的环境、运行顺序、控制和限制,以及 JSON 和 CSV 数据。感谢 Simon Mo 推动我控制页面缓存并单独计时编译器。

来源:vLLM Blog · vllm.ai