ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

vLLM迁移昇腾NPU完全指南:从环境搭建到性能优化

vLLM迁移昇腾NPU完全指南:从环境搭建到性能优化

把 vLLM 推理服务从 CUDA 环境迁移到昇腾 NPU 机器上,最开始的体验通常不会太友好。我自己的经历是:GPU 上稳定跑了几周的模型,到昇腾上第一天就段错误,第二天跑出 NaN,第三天吞吐低到让人怀疑拿到了假卡。后来一步步把问题拆开才明白,vLLM-Ascend 要做的事情,远不是把 device 名从cuda改成npu那么简单——它要把 vLLM 依赖 CUDA 生态的整套算子、显存管理和调度逻辑全部落到昇腾计算架构上。如果你正准备在昇腾 NPU 上开发推理服务、或者已经在调试 vLLM-Ascend 但被各种奇怪问题卡住,这篇文章会把开发适配逻辑、调试方法和性能优化路径完整梳理一遍。内容以我实际踩过的坑和验证过的方案为主,不涉及太底层的芯片细节,但足够帮你把弯路省掉一大半。

1. vLLM-Ascend 到底解决了什么问题:一套推理代码,两套算子栈

1.1 原版 vLLM 在 CUDA 生态里依赖了什么

要理解 vLLM-Ascend,先得明白原版 vLLM 为什么无法直接跑在昇腾上。vLLM 的性能核心并不是简单的 PyTorch 模型推理,它自己管理 KV cache、自己做连续批处理调度、自己维护 block table,这里面的关键算子全是手写的 CUDA kernel。PagedAttention 就是最典型的例子:它允许 KV cache 分散在物理上不连续的显存块中,通过 block table 索引,这样显存碎片能被充分压榨,并发序列也能更多。但这些 kernel 编译出来的指令只面向 NVIDIA 平台,昇腾 NPU 根本不可能加载。

除了手写 kernel,vLLM 还深度依赖 CUDA 的加速库和 NCCL 通信库。比如量化推理时,它会把权重 packing 成特定格式,再调用 Tensor Core 相关指令;多卡推理时,张量并行要在 NVIDIA 的通信原语上做 allreduce。这些机制和昇腾 NPU 的算子执行模型完全不是一回事。

1.2 torch_npu 和 CANN 是中间的桥,但不是万能桥

昇腾这边的软件链路是:CANN 提供算子库和底层计算接口,torch_npu 把 PyTorch 的 Tensor 运算映射到 CANN。理论上,只要一个算子有 CANN 实现,torch_npu 就能把对应的 PyTorch 调用转过去。但 vLLM 的麻烦在于,它把控制流和自定义 kernel 耦合得非常深,很多地方不是标准的nn.Module算子,而是直接发 CUDA kernel 或者调用加速库。torch_npu 能承接其中一部分通用算子,但 PagedAttention、某些量化 kernel 这类深度定制逻辑,必须单独适配。

vLLM-Ascend 这个项目做的就是这件事:把 vLLM 的模型执行器、注意力实现、显存分配器、算子注册表全部接到昇腾算子体系上,让原本只认 CUDA 的代码路径在昇腾上也能跑。它不是简单翻译层,而是真正的后端实现移植。

1.3 最容易误导人的一个认知

见过不少朋友一开始觉得移植 vLLM 就是把.cuda()改成.npu(),把cuda:0改成npu:0。如果真的这么简单,官方仓库也不需要单独维护一个 vllm-ascend 分支了。实际去看 vLLM 源码会发现大量条件判断在区分后端:某个算子没有昇腾实现时走替代路径,某个特性在昇腾上不支持时直接告警关闭,某些量化的算子格式在 NPU 上需要另一套 pack 逻辑。尤其在推理精度上,CUDA 里基于 Tensor Core 的 FP8/INT8 算子在昇腾上走的是自己的量化通道,如果不适配,推理即使能跑,输出也会慢慢漂移。所以 vLLM-Ascend 在体系里的位置,应该理解成"vLLM 的昇腾硬件后端"。

2. 环境搭建实操顺序:从一张裸卡到第一个推理请求

2.1 先对版本,再谈安装

昇腾的软件栈有一个让所有新手抓狂的特点:版本锁得非常死。固件、驱动、CANN 工具包、torch_npu、vllm-ascend、Python 版本,这六样东西必须落在一个官方验证过的组合里,否则后面一定会冒出莫名其妙的问题。我第一次就是装了当时最新的 CANN,结果 torch_npu 怎么import都报错,最后翻文档才发现版本组合里对 CANN 的上限有要求。我的建议是:现在立刻去 vllm-ascend 官方 README 里找它验证过的版本组合,照抄,不要自己混搭。当时我验证过可用的一套组合大概是:

软件版本区间说明
Python3.9 / 3.103.11 也能跑,但有编译依赖要额外处理
PyTorch2.1.0 / 2.3.1必须与 torch_npu 配套,不能随便升
torch_npu与 PyTorch 严格对应比如 2.1.0 对应一套,2.3.1 对应另一套
CANN toolkit按官方组合选装完整版,别图省事装 mini 版
vllm-ascend跟 vLLM 主版本对齐0.6.x 对应 vLLM 0.6.x,以此类推

这里的核心逻辑是:torch_npu 在编译阶段就绑定了某个 PyTorch 版本的 API 布局,你手动pip install torch装出来的版本如果和它期望的不一致,import阶段就会报版本校验错误。CANN 和 torch_npu 的匹配同理,CANN 的算子接口一旦变化,torch_npu 就调不到对应符号。如果你直接用官方给的容器镜像,反而最省事;裸机手动装的话,每一步都要确认文档,别想当然。

2.2 源码编译 vllm-ascend 的常见报错

在昇腾上做调试时,基本都会选择源码编译 vllm-ascend,因为 release 版本往往追不上你本地要改的东西。编译前先把 cmake、gcc、pybind11 装齐,我遇到过的典型报错有三个:

一是编译脚本找不到 CANN,提示Can not find CANN之类。原因就是环境变量没生效,需要先执行source /usr/local/Ascend/ascend-toolkit/set_env.sh。二是 CMake 版本过低,昇腾编译脚本一般要求 CMake 3.16 以上。三是装完以后import vllm报找不到模块,这通常是把仓库 clone 下来后忘了设置 PYTHONPATH——vllm-ascend 默认把仓库根目录当包目录,需要手动加进去,或者用官方 README 里推荐的软链方式。

git clone https://gitee.com/ascend/vllm-ascend.git cd vllm-ascend pip install -e . export PYTHONPATH=$(pwd):$PYTHONPATH

这些报错本身不难,但很磨耐心。我的习惯是把安装过程全部输出重定向到日志文件,报错后直接grep -i error,不要一直盯着终端刷新。

2.3 一个会让你怀疑人生的坑:/dev/shm 太小

vLLM 在多卡推理和异步调度时会用共享内存做进程间通信,容器默认的/dev/shm通常只有 64MB。一旦并发请求上来,进程直接崩,日志里可能只留下一个Bus error,甚至什么都看不到。我第一次遇到时排查了很久,最后才发现是docker run的时候没加--shm-size。用容器跑昇腾环境,一定要显式给足共享内存:

docker run --shm-size 8g --device=/dev/davinci0 --device=/dev/davinci_manager ...

如果是物理机直接跑,一般没有这个问题。但我在任何环境下调试前都会用df -h /dev/shm确认一下容量,这个习惯后来帮我排查其他项目也省了不少时间。

2.4 最小推理服务验证

环境就绪后,先别急着上大模型,用一个 7B 左右的对话模型验证整条链路。以常见的调用方式为例:

python -m vllm.entrypoints.openai.api_server \ --model /path/to/qwen-7b-chat \ --dtype float16 \ --max-model-len 8192 \ --gpu-memory-utilization 0.85

然后另开终端,发一个最小请求:

curl -X POST http://localhost:8000/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{"model": "qwen-7b-chat", "messages": [{"role": "user", "content": "你好"}], "max_tokens": 128}'

如果这一步能顺利返回内容,恭喜,整套软件栈基本是通的,后面就可以进入调试和优化阶段了。

3. 调试方法论:把问题精确到算子层再动手

3.1 进程起不来时,我按这个顺序排查

服务进程直接崩、没有任何 Python traceback 的情况,是最让人头疼的。我的排查顺序是固定的:先npu-smi info看卡是否可见、驱动和固件版本是否匹配;然后设置ASCEND_GLOBAL_LOG_LEVEL=3和ASCEND_SLOG_PRINT_TO_STDOUT=1,把 CANN 的错误日志打到标准输出,再跑一次,看 stderr 里有没有关键信息;如果日志里什么都没有就退出,优先怀疑/dev/shm或权限问题。

昇腾还有一个和 CUDA 环境很不一样的地方:驱动和固件升级后必须重启机器,否则算子执行会异常。我就是因为升级完没重启,导致后面所有算子跑出来的数据都像是被随机数污染过,排查了很久才反应过来。这套链路走下来,绝大多数"起不来"都能定位到具体层。

3.2 算子不支持时,先做减法再做加法

vLLM-Ascend 虽然覆盖了主流场景,但仍有算子和特性没有完全适配。你可能会在启动时看到NotImplementedError,或者某个 kernel 找不到的报错。这类问题的关键是分清"是我配置不对"还是"功能真的不支持"。我的做法是:先把模型参数里的高级特性全部关掉,比如禁用 chunked prefill、禁用某些自定义 op,确认能跑通之后,再一个一个打开,直到复现报错。这样能很快锁定是哪个特性触发了问题。之后去 vllm-ascend 的 release notes 和 issue 列表里确认适配进度,通常比自己在源码里猜测高效得多。

3.3 结果不对或 NaN:用 dump 和单算子脚本锁定

推理能跑但结果不对,是最难调试的场景。我的建议是:先在 CPU 上用 float32 跑同一个 prompt,把每层输出当作基准;再在 NPU 上逐步对比。如果差异巨大,基本可以断定是算子精度或量化配置的问题。昇腾上 dump 算子的方式是设置 CANN 的 dump 相关环境变量,开启后框架会把算子的输入输出导出成文件,再用脚本解析对比。这个过程比较繁琐,但能精确到具体是哪个算子出了问题。

更快的定位方法,是写一个最小脚本,只构造一个 npu tensor,手动调用怀疑的算子,和 numpy 的参考输出对比:

import torch import torch_npu x = torch.randn(4, 16, dtype=torch.float16).npu() y = torch.nn.functional.silu(x) print(y.cpu())

vLLM 的调度逻辑很复杂,会把问题搅成一团,单算子脚本能直接验证某个算子本身有没有问题。我在昇腾项目里遇到 80% 的"跑出 NaN"最后都指向某一个算子没有按预期工作,而不是模型代码逻辑错误,所以这套最小复现手段一定要建立起来。

3.4 日志开到什么级别,有讲究

很多人调试时喜欢把所有日志开到最高,我的建议是千万别这样。昇腾的全量 debug 日志会以惊人的速度刷屏,把你彻底淹没。我一般先用 ERROR 级别跑,捕获调用栈;如果栈看不出问题,再对某个模块单独开 DEBUG。vLLM 自己的日志用VLLM_LOGGING_LEVEL=DEBUG控制,CANN 的日志用ASCEND_GLOBAL_LOG_LEVEL控制,两者分开看,通常能快速判断是框架侧的还是算子侧的问题。记住,日志是定位工具,不是显示忙碌的道具,开太满反而什么都看不见。

4. 性能优化:不是上来就调参,先量化瓶颈再动手

4.1 三个指标先测一遍

拿到一个能跑的 vLLM-Ascend 服务后,先别急着调参数。我每次都会先测三个数:吞吐量(output tokens/s,用并发压测)、首个 token 时延 TTFT、端到端时延。这三个指标对应的问题不一样:

  • 吞吐低:大概率是连续批处理的 batch 太小,或者 NPU 算子利用率不足;
  • TTFT 高:通常是 prefill 阶段太长,或者调度排队太严重;
  • 端到端时延高:还要看 decode 阶段逐 token 生成是否成了瓶颈。

测的时候务必固定 prompt 长度和输出长度,不然不同条件下拿到的数据根本没有可比性。我一般用贴近真实业务的 prompt 长度来压测,而不是用极短的 hello world——短请求测出来的吞吐数据放在生产场景里没有任何参考价值。

4.2 关键参数的联动关系

昇腾上性能影响最大的参数,我的排序是:max_num_seqs、gpu_memory_utilization、block_size、enable_prefix_caching、是否开启 chunked prefill、量化方式。它们不是独立的,互相牵制。

参数核心作用调大影响调小影响
max_num_seqs单次调度最大并行序列数吞吐提升,TTFT 变高时延降低,吞吐下降
block_sizeKV cache 分配块大小管理开销小,碎片变多碎片少,管理开销大
gpu_memory_utilization可用显存比例能容纳更多 KV cache并发上限降低
enable_prefix_caching复用相同前缀的 KV cache多轮对话场景收益明显关闭可减少显存占用

max_num_seqs不是越大越好。调大它,连续批处理能装下更多序列,吞吐自然涨,但每个请求拿到的算力被摊薄,首 token 时延和单请求延迟都会变高。block_size我也在昇腾上做过对比,16、32、64、128 都试过。小模型上 32 和 64 差距不大;大模型上用 128 有时会因显存碎片导致能容纳的并发序列反而变少,所以不能盲目照搬别人的配置。gpu_memory_utilization在昇腾上要更保守一点,NPU 显存不只是模型和 KV cache 在占用,CANN 运行时也会占一部分,开到 0.95 以上很容易在图编译阶段申请不到缓存而报错。

4.3 用 msprof 看算子开销,别靠猜

参数调到一定程度后,必须靠 profiling 而不是感觉。昇腾上的 msprof 工具可以导出算子执行、通信、调度的时间线。我最常看两点:一是 NPU 算子流水是否打满,二是多卡场景下 HCCL 通信时间占比。单卡推理时如果 NPU 利用率不到 50%,瓶颈大概率在 host 侧——tokenizer 处理太慢、调度循环有同步等待、prefill 和 decode 切换产生气泡,这些都是隐藏的坑。多卡并行时如果通信时间占比超过 20%,就要考虑张量并行度是不是开得太大,通信成了天花板,不要盲目加卡。

4.4 图模式和量化在昇腾上的实际收益

昇腾 NPU 支持图执行模式,把整个模型编译成一张计算图,减少 host 侧反复下发算子的开销。vLLM-Ascend 会根据模型和算子情况自动启用一部分图优化。我的实测感受是:静态 shape 场景收益很大,吞吐可以提升 10%-30%;但动态 shape 场景,比如 max_model_len 很大、实际请求长短差异悬殊,图编译会频繁重建,反而得不偿失。

量化方面,昇腾对 INT8 矩阵计算的加速非常明显。如果你的模型能用 W8A8 方案,吞吐通常能再上一个台阶。精度上需要在量化数据集上验证,对话模型一般掉点幅度可以接受,但如果是代码生成或数学推理场景,掉点可能会放大,必须压测完再决定。

5. 容易被忽视但影响很大的几个细节

5.1 版本锁定,不然以后有你受的

昇腾的算子行为在不同 CANN 版本之间可能发生细微变化。同一个模型,CANN 升一个大版本,精度差异可能不大,但性能曲线会变;vllm-ascend、torch_npu、CANN 三者只要有任何一个变动,都应该重新跑一遍关键 benchmark。我自己吃过大亏:升级了某个小版本后,压测吞吐掉了 15%,翻遍配置才发现是 CANN 版本变了。后来我强制团队把部署环境的版本组合写进技术文档,并且锁住 pip 依赖和容器镜像,任何升级都走评审流程。

5.2 启动前十分钟自检清单

我整理了一份每次换机器、换环境时必过的自查清单,照着走一遍能挡住绝大多数低级问题:

  • npu-smi info确认所有卡状态正常;
  • source CANN 环境变量,检查/usr/local/Ascend目录版本;
  • python -c "import torch, torch_npu; torch_npu.npu.set_device(0)"验证初始化;
  • df -h /dev/shm确认共享内存容量;
  • 用最小模型跑一次 curl 请求,确认真实链路完整。

这套清单全部跑完不超过十分钟,但能挡住大量"我以为环境没问题"的翻车现场。

5.3 关于调试思路的一个总结性建议

回到本文开头说的那句话:昇腾 NPU 上的调试和 CUDA 最不一样的地方,是一旦出现异常,别急着怀疑模型逻辑和框架调度,先假设是算子边界问题。我在昇腾项目里搭建的算子 dump 和单算子验证能力,是花时间最少、回报最高的一套工具。准备入坑的朋友,建议你也先把这套排查环境打完,再谈性能优化——因为后面绝大部分性能问题,最后都会落到"某个算子没按预期工作"这个根因上。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进