ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

vLLM在昇腾NPU上的性能剖析与优化实战:用Ascend Profiler定位TTFT瓶颈

vLLM在昇腾NPU上的性能剖析与优化实战:用Ascend Profiler定位TTFT瓶颈 先说一个真实场景。如果你也在用 vLLM 部署大模型并且跑在昇腾 NPU 上大概率经历过这个画面压测脚本里的并发数一调高tokens/s 先涨了一截紧接着 TTFT首 token 延迟直线飙升看起来像到了性能极限。但当别人问“瓶颈到底在计算、显存、通信还是调度”的时候你翻遍日志也答不上来。我们最近一次线上服务优化就是用 Ascend Profiler 把整条 vLLM 推理链路一层层剖开最后发现真正的瓶颈根本不在某个算子上而是调度和 prefill 互相拖拽外加 CPU 下发任务不及时NPU 一直在等饭。这篇文章把完整的复盘过程写出来包括 Ascend Profiler 怎么接入、时间线怎么读、数据怎么交叉验证以及最后落地的几项优化给同样在用 vLLM 昇腾做推理业务的同学一个可以直接参考的路径。1. 端到端推理链路全景与性能分层1.1 一次请求在 vLLM 内部到底走过了哪几站先别急着开 profiler得先知道一个请求进了 vLLM 之后要过哪些环节否则采集回来的数据只会是一堆时间戳对着看也白搭。用户请求先到 OpenAI API Server 入口经过鉴权、tokenizer 编码之后变成 token id 序列交给 vLLM 的 scheduler。scheduler 是 vLLM 的中枢它负责决定这一步要批量执行哪些 sequence给它们分配多少 KV cache 块。注意这里的“块”是逻辑上的 block实际的显存广播到 NPU 上之后才真正分配。接着进入执行阶段把一批 sequence 组织成 tensor送给模型跑一遍 forward这一步在内部又拆成 prefill 和 decode 两种模式。prefill 处理整个 prompt产出第一个 token 和对应的 KV cachedecode 则上一个 token 一个 token 地往后推生成完整答案。最后采样、detokenizer把内容返回给用户。这个链路里scheduler 跑在 CPU 上模型推理跑在 NPU 上中间靠任务下发机制衔接。所以“NPU 利用率低”这件事可能不是算子写得差而是 CPU 侧的调度、tokenizer、采样太慢导致 NPU 闲着等饭吃。单纯看 NPU 侧数据或者单纯看 CPU 侧日志都只能看到一半的真相这也是为什么要强调“端到端”三个字。1.2 压测时该盯哪几个指标TTFT、TPOT、端到端时延与吞吐做性能定位先要有统一的度量口径。我们压测时用了四个指标缺一不可。第一是端到端时延指的是请求发出到完整响应返回的时间用户直接感知到的就是这个。第二是 TTFT首 token 延迟它决定用户等多久才能看到第一个字对交互式应用来说是关键体验指标。第三是 TPOT平均每个输出 token 的耗时一次请求生成了 N 个 token总耗时减 TTFT 再除以 N 就是 TPOT它决定后续文本流畅度。第四是吞吐单位时间能生成的 token 总数通常用 tokens/s 表示。这四个指标很容易顾此失彼。比如调大 batch size 能显著提高吞吐但每个请求的 TTFT 会被拉长又比如为了降低 TTFT 把 prefill 优先级调得极高decode 阶段的 TPOT 又可能劣化。所以每次压测必须四个指标一起记录不能只报一个好看的 tokens/s。我们当时第一轮压测的数据就很典型吞吐看着还行TTFT 的 p95 却到了几秒级别这显然是不能接受的。1.3 为什么“NPU 利用率不低”不代表系统没瓶颈有一种很容易踩的坑一上来只看 AI Core 利用率发现 80% 多就认为 NPU 已经跑满了下一步应该去优化算子。但实际上AI Core 利用率高只说明 NPU 没有闲着不代表它干的是你期望的活儿。我给你一个我们真实遇到过的情况压测的时候 AI Core 利用率接近 85%但端到端时延依然很差。后来把时间线拉出来才发现NPU 大量时间在跑一个小算子的等待循环真正做 GEMM 和 Attention 的计算时间只占很小比例。更常见的还有另一种AI Core 利用率只有 40%但每个 step 的 wall time 却不短这种多半是 CPU 侧任务下发的 gap 太大算子之间有一大段空白。换句话说利用率低可能是瓶颈利用率高也可能是瓶颈关键在于把指标拆开看AI Core 利用率、HBM 带宽、MAC 单元利用率、算子间隔时间、通信占比这些维度彼此独立任何一个都可能成为天花板。所以我建议把性能定位拆成分层模型网络层看端到端时延分布框架层看 scheduler 等待和 batch 组成运行时层看 task 下发间隔算子层看 kernel 耗时和带宽利用率。哪一层异常就从哪一层往下钻。Ascend Profiler 正好覆盖了运行时层和算子层再配合 vLLM 自己的日志就能把端到端的链路补全。2. Ascend Profiler给 NPU 做全链路体检的正确姿势2.1 为什么不直接整个通用的 host profiler昇腾平台有个很尴尬的现实以前在 NVIDIA 上用的那套 Nsight Systems 不适用perf 这类 host 侧工具只能看到 CPU 行为看不到 NPU 上的算子时间线。如果没有设备侧数据优化基本靠猜要么对着代码把算子一个个拆开手动计时要么反复改配置跑压测碰运气。这两种方式的效率都低得让人绝望。Ascend Profiler 的价值在于它是从 CANN runtime 层打点采集的能拿到设备和主机两侧的数据。对我们这种跑 vLLM 的场景来说它的输入是正常的推理脚本不需要改模型代码只要在启动命令外面包一层采样或者在脚本里套一个上下文管理器就能把 NPU 侧算子、任务流、通信信息和 host 侧下发节奏抓到同一个时间线里。这样既能定位某个算子本身慢不慢又能判断是不是上层调度导致设备饥饿。另外一个很实际的点是vLLM 在昇腾上的算子执行路径和 NVIDIA 不完全一样很多融合算子只有在 vllm-ascend 这个插件里才会走自定义实现。通用 profiler 根本不知道这些算子的内部结构而 Ascend Profiler 能直接看到这些自定义算子的启用情况和细分耗时这对性能定位是决定性的。2.2 Ascend Profiler 能采集到的核心数据产物我用的是 CANN 自带的 msprof 采集模式另外通过 torch_npu profiler 接口在程序内精准掐段采样。不同 CANN 版本输出目录名和字段会有差异但核心产物大概就是这么几类op_summary、kernel_details、task_time、timeline、memory 和对应的通信数据。用一张表说明比较直观。数据产物里面有什么解决什么问题op_summary算子级汇总包括算子名、耗时、耗时占比、AI Core 时间快速锁定 top20 耗时算子kernel_detailskernel 级明细含开始时间、结束时间、所在核号看算子之间的间隔、并发情况task_time任务流在 host 侧的发送与 device 侧执行时间判断 task 下发是否成为瓶颈timelineChrome trace 格式的时间线可拖拽查看直观看到整段推理中 NPU 是否空转memory 数据显存分配与释放记录排查显存碎片、KV cache 容量不足通信数据通信算子耗时、通信量分析多卡并行时的拓展瓶颈真正决定分析效率的指标有三个。一是 AI Core 利用率它表示统计窗口内 NPU 计算核心有实际任务的时间占比。二是 HBM 带宽利用率很多算子慢不是 MAC 不够而是数据搬运太频繁特别是长序列 Attention 场景。三是 MAC 利用率反映访存型算子和计算型算子是否均衡如果 HBM 带宽已经满了但 MAC 利用率很低说明算子偏访存密集要往算子融合方向想办法。2.3 采集前的配置清单少一步都会白跑先说环境。vLLM 跑在昇腾上需要安装 vllm-ascend 插件启动时设置VLLM_TARGET_DEVICEascend否则框架会默认去找 CUDA。CANN 工具链要装好版本最好和 vllm-ascend 的 release 说明对齐我们吃过版本不匹配导致 profiler 导不出 timeline 的亏。然后是采集方式。我倾向于用 torch_npu.profiler 在代码里精确控制采样窗口因为实际生产中不可能让 msprof 对着整个服务一直采数据量太大也没必要。典型做法是在推理循环外面套一个 profile 上下文只采集三五个 step。下面这段伪代码是我们常用的模板import torch_npu from torch_npu.profiler import profile, ProfilerActivity def trace_handler(prof): prof.export_chrome_trace(trace.json) prof.export_op_summary(op_summary.csv) with profile( activities[ProfilerActivity.CPU, ProfilerActivity.NPU], scheduletorch_npu.profiler.schedule(wait1, warmup1, active3), on_trace_readytrace_handler ) as prof: for step in range(total_steps): run_inference_step() prof.step()这样只在中间三个 step 采集前一个 step 用来 warmup避免模块懒加载影响数据真实性。如果不想改代码也可以用 msprof 直接包住整个启动命令msprof --applicationpython -m vllm.entrypoints.openai.api_server \ --model /data/models/Qwen2.5-14B-Instruct \ --tensor-parallel-size 8 \ --max-model-len 8192 \ --output/path/to/profiling \ --ai-core-metricson \ --op-summaryon无论哪种方式建议采样前先压一两分钟“热身”把 KV cache 池、算子图都准备好再开采样这样拿到的时间线更像稳态表现。3. 实操记录一次完整的瓶颈定位流程3.1 压测场景与采集方案的确定这次的测试环境是单个昇腾 910 系列节点8 卡模型选的是 Qwen2.5-14B-Instruct权重放在本地磁盘。模型不大不小正好能看出 tensor parallel 和调度算法的问题。vLLM 启动参数大致是这样VLLM_TARGET_DEVICEascend python -m vllm.entrypoints.openai.api_server \ --model /data/models/Qwen2.5-14B-Instruct \ --tensor-parallel-size 8 \ --max-model-len 8192 \ --max-num-seqs 64 \ --gpu-memory-utilization 0.9 \ --disable-log-requests压测脚本用 Python 开了 30 个并发按照固定 prompt 长度构造请求。这里我明确说一下压测时的 prompt 长度要尽量贴近线上真实分布我们线上大概 1500 token 的输入、输出 800 token所以压测时也按这个比例混合否则 profile 出来的 prefill 占比没有任何参考意义。数据记录方面除了 vLLM 标准日志我们还在服务端打印了每个请求的 queue_wait_time、prefill_latency、decode_avg_latency这样后面和 NPU timeline 对齐时才有坐标。3.2 第一轮采集timeline 上到处都是“空档”第一轮 profile 跑完打开 timeline 的 Chrome trace 文件第一感觉是“断断续续”。NPU 的算子在跑但每个 step 之间有大段空白短则几十毫秒长的一百多毫秒。按 8 卡 14B 模型的体量decode 一个 token 的理想耗时应该在几十毫秒量级而 timeline 里一个 decode step 的墙钟时间几乎翻了一倍其中一半是空白。接着看 op_summary耗时 top 的算子确实有预想中的 Attention但它的绝对耗时没有特别离谱反而大量小算子的调度间隔把总时间拉高了。用 task_time 一查host 侧 task 下发到 device 侧开始执行之间存在明显延迟这基本说明瓶颈不在 NPU 算力而在 feeding 环节——scheduler 和 executor 之间的配合出了问题。我再对比了 AI Core 利用率只有 40% 左右HBM 带宽也没打满更证实了设备侧在“等任务”。3.3 交叉验证把瓶颈锁到 prefill 与调度排队光靠 NPU timeline 不够还得把 vLLM 侧的数据合进来。我们分析了服务端记录的 queue_wait_time 和 prefill_latency发现一个关键现象TTFT 高但真正消耗时间的不是 prefill 算子本身而是请求在 scheduler 队列里排队等待 prefill 执行的时间。因为第一轮压测的 batch 由大量长 prompt 构成prefill 阶段会一次处理完整个 prompt当多个长 prompt 挤在一起时scheduler 为了保证 batch 收益会让后来的请求等很久。再看 decode 侧由于 prefill 每次都会占用大量算力和带宽正在 decode 的请求速率被瞬间拉低表现为 TPOT 抖动剧烈。用时间线对应上去看prefill 算子执行期间后面跟了一串 decode 小算子两者的任务严重争抢 NPU。总结下来第一轮定位结论有三条CPU 侧任务下发 gap 大、prefill 与 decode 互相争抢、AI Core 利用率偏低。真正的算子层面优化空间反而不是最紧急的。4. 定位出的瓶颈与对应优化方案4.1 先解决 CPU 下发 gap把 NPU 喂饱第一件事是提高单 step 的 token 处理量。vLLM 里和这个直接相关的参数是--max-num-seqs和--max-num-batched-tokens。前者决定一个 batch 里最多放多少 sequence后者决定一个 step 最多处理多少 token。如果我们把--max-num-seqs从 64 提到 128--max-num-batched-tokens从默认值往上调每个 step 里能塞的任务更多NPU 的空档期就容易被填上。实测下来 AI Core 利用率从 40% 附近提到了接近 65%效果非常直接。第二个动作是减少 host 侧 Python 调度的额外开销。vLLM 的调度循环、tokenizer、采样逻辑都在 Python 里请求一旦变多GIL 和对象分配的开销会被放大。我们的办法是尽量复用采样和 tokenizer 的结果同时关掉压测时不需要的日志输出。这里有个细节--disable-log-requests只是不打印每个请求的明细不影响模型执行开压测时强烈建议打开否则日志落盘本身就会抢 CPU。还有个选项是开启图模式或者把模型切换到更高效的执行后端但昇腾上具体做法的可用性取决于 vllm-ascend 版本建议以官方 release 为准。4.2 prefill 和 decode 互相拖拽chunked prefill 与 PD 分离prefill 是长 prompt 场景下的头号敌人。一个 1500 token 的 prompt 在 prefill 阶段产生的计算量是 decode 单 token 的几十倍当 prefill 和 decode 混在同一批里NPU 会被 prefill 拖进一个“长耗时 step”其他 decode 请求只能跟着一起等。逻辑上很自然的方案是 chunked prefill也就是把长 prefill 拆成若干小 chunk每个 chunk 塞进一个相对短的 step 里和 decode 任务交错执行。vLLM 里开启方式是--enable-chunked-prefill。开启后prefill 不再是一个巨无霸而是细粒度地穿插在 decode 之间TTFT 和 TPOT 之间的“跷跷板”得到缓解。需要注意一点chunk 的大小受--max-num-batched-tokens约束如果这个值设得太小prefill 会被拆得过于零碎反而增加调度和 kernel 启动的开销。我们的做法是先设置一个中间值再根据 timeline 里每个 step 的耗时分布微调。如果业务场景对 TTFT 极度敏感且机器资源够用可以考虑做 PD 分离把 prefill 和 decode 放到不同的服务节点上prefill 节点只负责长 prompt 的首阶段计算算完把 KV cache 转给 decode 节点持续生成。vLLM 社区已经有一些项目在做这件事昇腾上也有对应的实践但部署复杂度高建议只有在 chunked prefill 已经调不动的时候再上。4.3 算子级优化不是看到 top 算子就冲上去改把 op_summary 里的 top20 算子拉出来后我们做了一个谁才是“真凶”的判断。一般来说有三类要区分一类是 Attention 相关算子耗时高但可能是访存受限一类是 GEMM 类算子耗时高通常意味着计算量确实大还有一类是 RMSNorm、残差连接、激活函数这类小算子单独看不吓人但叠加起来会拖慢整个 step。我们当时的 Attention 算子 HBM 带宽利用率已经很高说明它已经接近访存上限单纯改算法不如降精度减数据搬运来得直接。KV cache 换到更低精度的 dtype 之后Attention 的带宽压力下降TPOT 也稳定了一些。另外昇腾 CANN 对相邻小算子有融合能力前提是模型实现里没有阻断融合的操作。我们升级了 vllm-ascend 之后一批 RMSNorm 和 cast 操作被自动融合kernel 数量少了一截gap 也随之变小。这类优化属于“白捡的便宜”但前提是你得先有 profiler 数据证明 gap 存在否则升级完了也说不清收益来源。4.4 多卡通信与显存复用最后才动这两个旋钮在 8 卡 tensor parallel 模式下每个 transformer layer 的 allreduce 通信开销不可忽略尤其是 batch 不太大的时候通信时间甚至能和计算时间持平。我们用 profiler 里的通信数据算了一下发现当前模型规模下 TP8 是够用的通信占比没有超过 20%所以没有为了降通信去缩小并行度。如果你的模型只有 7B 左右TP8 就有些浪费可以试着用 TP4在 profiler 里对比通信占比与单算子耗时综合判断。显存方面的优化主要是为了给 KV cache 留更多空间避免因为 block 不够而频繁触发 swap 或者拒绝新请求。我们适当提高了--gpu-memory-utilization又根据线上 load 情况把--max-model-len从 8192 调整到 6144多出来的显存放 KV cache整体吞吐立刻变了。这类调整不会出现在 profiler 的算子时间里但会反映在请求成功率和高并发下的端到端时延上。优化做完之后我们又跑了一次同条件的 profile。对比下来AI Core 利用率稳定在 70% 以上每个 decode step 的空白间隔明显缩短TTFT 的 p95 降了一半以上TPOT 波动也收敛了。整个过程最值钱的工作其实是把指标一层层对上而不是闷头改参数。5. Profiling 过程中的常见问题与避坑指南5.1 采集数据太大把服务拖垮了第一次用 msprof 的时候我不小心全量采集了两分钟出来的 PROF 目录十几个 GB连导入 Chrome trace 都卡到怀疑人生。后来学乖了只要不是排查内存类问题timeline 只采 3 到 5 个 step 就够op_summary 可以多采一会但也不用超过 1 分钟。如果服务本身是生产环境建议先用独立压测流量不要把 profiling 直接怼到在线流量上否则采集器加 profiling 的额外开销会让 end-to-end 延迟虚高误导判断。5.2 NPU 时间线和服务日志的时间对不上Ascend Profiler 采集的 NPU 时间戳和 Python 日志里的 wall clock 不一定在一个 epoch 上直接做绝对对齐很痛苦。我们的办法是在推理循环里打印 step number同时把同一个 step 的 CPU 侧耗时记下来然后在 timeline 上按 step 边界做相对对齐。不需要精确到微秒只要能把“prefill 那个大块时间”和“请求排队等待时间”对应上就够用了。跨卡对比时还要注意各卡起始时间可能不同先找第一个算子的开始时间做归一化。5.3 profiler 本身对性能有干扰数据要分主次开启采集后任务下发路径上多了一层打点逻辑所以 profile 过程中测出来的绝对延迟不一定是真实值。尤其是 task_time 里的 host 侧时间会被打点开销轻微放大。处理办法是只看相对关系比如 gap 是长还是短、某个算子是否排第一、通信占比是否超过预期。优化落地之后关掉 profiler 再用同样的压测脚本重测绝对指标用这种“有采集定位相对瓶颈无采集验证绝对收益”的模式能避开采集器干扰带来的误判。5.4 一张速查表快速定位常见瓶颈类型现象可能原因Profiler 验证方法参考处理端到端时延高AI Core 利用率低调度排队 / CPU 下发 gap看 timeline 空白段task_time 对比调大 max-num-seqs 与 max-num-batched-tokensTTFT 高且波动大prefill 和 decode 争抢资源op_summary 中 Attention 耗时集中开启 chunked prefillTPOT 持续偏高单算子效率低或带宽受限看 HBM 带宽与 MAC 利用率算子融合 / 降精度 / 升级算子库多卡加速比不足通信开销大通信数据看 allreduce 占比降低 TP 并行度或换通信拓扑高并发被拒绝 / swap 频繁KV cache 空间不足memory 数据看剩余 block 数调 max-model-len提 gpu-memory-utilization这个表不是标准答案但能给日常排查提供一个清晰的切入点。实际遇到问题的时候先在表格里找到最接近的现象再用 profiler 数据验证至少能把排查范围缩小到原来的三分之一。我个人在做了几轮 profiling 之后养成了一个习惯每次优化前都把 kernel_details 里的 top20 算子导出来存档同时记下当时 vLLM 的启动参数、压测并发和 prompt 分布。下次升级模型或者调整 batch 策略之后再跑一次同条件 profile两张表一对比哪部分变慢了立刻能看出来。Ascend Profiler 这套东西看着复杂拆开其实就是组合拳端到端链路分层四个核心指标打底两层时间线筛查一张 top 算子表收尾。希望你下次遇到性能问题能一次定位成功尽早下班。
返回列表