想在昇腾NPU上部署LLM?cann-recipes-infer让你少走弯路
前言
大语言模型(LLM)的推理部署,从来都不是「把模型权重加载进来,跑 forward」这么简单。
你要在昇腾 NPU 上部署 LLM,会遇到一堆问题:模型权重怎么转换成 NPU 能读的格式?推理时用 FP16 还是 INT8 量化?多卡并行怎么搞?KV Cache 怎么管理?这些问题,官方文档里散落在各处,拼起来要花不少时间。
cann-recipes-infer 就是为了解决这个痛点。它是 CANN 社区维护的 LLM 推理部署 Recipe 集合,覆盖了从模型转换、推理配置、性能优化到多卡部署的完整流程。每个 recipe 都是一个「可复现的步骤清单」,你照着做就能把指定的 LLM 在昇腾 NPU 上跑起来。
这篇文章会把 cann-recipes-infer 的内容体系拆开讲,并且给出性能横评数据和模型选型建议。
cann-recipes-infer 里有什么
cann-recipes-infer 不是一个工具库(像 transformers 那样),它是一个文档 + 脚本的集合。每个支持的模型,都有一个对应的 recipe 目录,里面包含:
cann-recipes-infer/
├── recipes/
│ ├── llama/
│ │ ├── README.md # 部署步骤(从权重转换到推理运行)
│ │ ├── convert_weights.py # 权重转换脚本(HuggingFace → CANN 格式)
│ │ ├── run_inference.sh # 推理运行脚本(启动参数、环境变量)
│ │ └── benchmark.py # 性能测试脚本(延迟、吞吐量、显存占用)
│ ├── qwen/
│ ├── baichuan/
│ └── ... (其他支持的模型)
├── perf_data/
│ ├── llama_7b_910b_fp16.json # 性能数据(JSON 格式,方便做对比)
│ ├── qwen_14b_910b_int8.json
│ └── ...
└── docs/
├── model_selection_guide.md # 模型选型指南
├── quantization.md # 量化方法详解(INT8、INT4、FP8)
└── multi_card_deployment.md # 多卡部署教程
为什么用 recipe 这个词? 因为部署 LLM 是一个多步骤的流程(转换权重 → 配置推理参数 → 启动服务 → 压测性能),每一步都有坑。recipe 表示「做菜的步骤清单」——你照着做,就能复现结果。
LLaMA、Qwen、Baichuan 的 Recipe
这一节把三个主流开源 LLM 的 recipe 拆开讲,让你知道每个模型在昇腾 NPU 上部署时有什么特殊注意事项。
LLaMA(Meta 的开源 LLM)
LLaMA 是昇腾 NPU 上支持最完善的 LLM。从 LLaMA-7B 到 LLaMA-70B,都有对应的 recipe。
权重转换(为什么需要这步?)
HuggingFace 上的 LLaMA 权重是 PyTorch 格式的(.bin 文件),而 CANN 的推理引擎(ACL Lite)用的是自己的模型格式(.om 文件)。你需要先把 PyTorch 权重转换成 .om。
# recipes/llama/convert_weights.py
# 为什么不能直接用 PyTorch 权重跑?因为 CANN 的推理引擎是离线的
# 它要求模型结构和权重在「编译期」就确定,然后编译成 .om 文件
# 运行时只加载 .om,不做任何动态编译,这样才能做到低延迟
import torch
import numpy as np
from pathlib import Path
def convert_llama_weights(
hf_model_path: str,
output_path: str,
dtype: str = "float16",
):
"""
把 HuggingFace LLaMA 权重转换成 CANN 推理格式
Args:
hf_model_path: HuggingFace 权重目录(包含 pytorch_model-*.bin)
output_path: 输出的 .om 文件路径
dtype: 权重数据类型("float16" 或 "int8")
"""
# === 步骤1:加载 HuggingFace 权重 ===
# 为什么用 safetensors 而不是 bin?因为 safetensors 更安全(不会执行恶意代码)
# 而且加载速度更快(零拷贝)
model_files = sorted(Path(hf_model_path).glob("*.safetensors"))
if not model_files:
model_files = sorted(Path(hf_model_path).glob("pytorch_model-*.bin"))
state_dict = {}
for mf in model_files:
if mf.suffix == ".safetensors":
from safetensors.torch import load_file
state_dict.update(load_file(mf))
else:
state_dict.update(torch.load(mf, map_location="cpu"))
print(f"[CONVERT] Loaded {len(state_dict)} tensors from {hf_model_path}")
# === 步骤2:转换成 CANN 格式 ===
# CANN 的权重格式是「分层的」:每个 Transformer layer 的权重打包成一个文件
# 为什么这么设计?因为推理时是按 layer 加载的(KV Cache 也是按 layer 管理的)
# 分层存储能减少内存碎片
num_layers = 32 # LLaMA-7B 有 32 层(不同大小的 LLaMA 层数不同)
for layer_idx in range(num_layers):
# 提取这一层的权重(为什么逐层处理?因为 NPU 内存有限,一次处理所有层可能 OOM)
layer_weights = {}
prefix = f"model.layers.{layer_idx}."
for key, value in state_dict.items():
if key.startswith(prefix):
# 转换 dtype(为什么在这里转?因为 HuggingFace 权重默认是 float32)
# 转成 float16 能省一半内存,而且 NPU 的 Vector Core 对 float16 的计算更快
if dtype == "float16":
layer_weights[key[len(prefix):]] = value.half().numpy()
elif dtype == "int8":
# INT8 量化(需要校准集,这里简化成简单的线性量化)
# 为什么用 INT8?因为能省 4 倍内存,而且 NPU 的 Cube Core 对 INT8 的矩阵乘法有专门优化
max_val = value.abs().max()
scale = 127.0 / max_val
layer_weights[key[len(prefix):]] = (value * scale).clamp(-128, 127).numpy()
# 保存这一层的权重(CANN 格式)
output_file = Path(output_path) / f"layer_{layer_idx:02d}.npz"
np.savez(output_file, **layer_weights)
print(f"[CONVERT] Saved layer {layer_idx} to {output_file}")
# === 步骤3:生成模型描述文件(.json) ===
# 这个文件描述了模型结构(层数、隐藏维度、注意力头数等)
# CANN 的推理引擎需要这个文件来编译 .om
model_desc = {
"model_name": "llama-7b",
"num_layers": num_layers,
"hidden_size": 4096,
"num_attention_heads": 32,
"dtype": dtype,
}
import json
with open(Path(output_path) / "model_desc.json", "w") as f:
json.dump(model_desc, f, indent=2)
print(f"[CONVERT] Done! Output directory: {output_path}")
if __name__ == "__main__":
convert_llama_weights(
hf_model_path="/path/to/llama-7b-hf",
output_path="./llama-7b-cann",
dtype="float16",
)
为什么转换脚本要逐层处理? 因为 LLaMA-7B 的权重有 13GB(float32)或 6.5GB(float16),如果一次性加载到内存,需要至少 13GB 的 RAM。逐层处理的话,同一时间只需要加载一层的权重(~200MB),对内存的要求低得多。
推理运行
权重转换完成后,用 run_inference.sh 启动推理:
# recipes/llama/run_inference.sh
#!/bin/bash
# 为什么用 shell 脚本而不是直接 python run_inference.py?
# 因为推理前要设置一堆环境变量(NPU 的 device id、内存分配策略、日志级别等)
# 用 shell 脚本把这些设置集中管理,不容易出错
set -e # 任何命令失败就退出(为什么加这个?因为推理失败通常是因为环境没设对,早点退出能快速定位问题)
# === 环境变量设置 ===
export ASCEND_RT_VISIBLE_DEVICES=0 # 使用第 0 张 NPU 卡
export INFERENCE_BATCH_SIZE=1 # batch size = 1(为什么用 1?因为 LLM 推理通常是逐 token 生成,batch 大了反而慢)
export KV_CACHE_SIZE=2048 # KV Cache 大小(token 数)
export LOG_LEVEL=INFO
# === 检查权重文件是否存在 ===
WEIGHTS_DIR="./llama-7b-cann"
if [ ! -d "$WEIGHTS_DIR" ]; then
echo "[ERROR] Weights directory not found: $WEIGHTS_DIR"
echo "[FIX] Run convert_weights.py first"
exit 1
fi
# === 启动推理 ===
# 为什么用 acllite_infer 而不是自己写 C++ 推理代码?
# 因为 acllite(ACL Lite)是 CANN 提供的高层推理 API
# 它帮你处理了内存管理、KV Cache、beam search 等复杂逻辑
# 你只需要传模型路径和输入 prompt
echo "[INFER] Starting LLaMA-7B inference on Ascend NPU..."
acllite_infer \
--model "$WEIGHTS_DIR/model_desc.json" \
--prompt "Once upon a time," \
--max-new-tokens 128 \
--temperature 0.8 \
--top-p 0.95
# 参数解释:
# --max-new-tokens 128 生成 128 个新 token(为什么不用更长的?因为这篇文章是示例,跑太长会等很久)
# --temperature 0.8 采样温度(越低越确定,越高越随机)
# --top-p 0.95 nucleus sampling(只从概率前 95% 的 token 里采样)
为什么用 aclite_infer 而不是直接调用 ACL API? 因为 ACL API 是底层 C API,用它写推理脚本要几百行代码(处理内存、张量、推理上下文等)。aclite_infer 是高层封装,几行命令就能跑推理。
Qwen(阿里的开源 LLM)
Qwen 的 recipe 和 LLaMA 类似,但有几个不同点:
- 支持多模态(Qwen-VL):如果你要部署 Qwen-VL(视觉-语言模型),需要额外转换视觉编码器的权重。
- 分词器不同:Qwen 用的是
tiktoken分词器(和 GPT-4 一样),而 LLaMA 用的是SentencePiece。转换权重时要注意分词器的对齐。 - 支持更长上下文:Qwen-32B 支持 8K 上下文(LLaMA-7B 默认 2K),需要更大的 KV Cache。
# recipes/qwen/run_inference.sh 里的特殊设置
# Qwen 的 tokenizer 需要额外设置(为什么?因为 acllite 默认用的是 LLaMA 的 tokenizer)
export QWEN_TOKENIZER_PATH="/path/to/qwen-tokenizer.json"
# 长上下文需要更大的 KV Cache(为什么是 8192?因为 Qwen-32B 支持 8K 上下文)
export KV_CACHE_SIZE=8192
# 启动推理
aclite_infer \
--model "./qwen-14b-cann/model_desc.json" \
--prompt "请用一句话描述量子纠缠。" \
--max-new-tokens 256 \
--tokenizer $QWEN_TOKENIZER_PATH
为什么 Qwen 的 KV Cache 要设这么大? 因为 KV Cache 的大小直接限制了模型能处理的上下文长度。如果你设 KV_CACHE_SIZE=2048,但输入 prompt 有 3000 个 token,推理会报错(或者截断输入)。Qwen 支持长上下文,所以 KV Cache 要相应调大。
Baichuan(百川的开源 LLM)
Baichuan 的 recipe 和前两个的区别主要在注意力机制上:
- Baichuan-7B 用的是 ALiBi(Attention with Linear Biases)位置编码,而不是 LLaMA 用的 RoPE(Rotary Position Embedding)。
- 这意味着转换权重时,要把 RoPE 的频率矩阵删掉(Baichuan 不需要这个)。
# recipes/baichuan/convert_weights.py 里的特殊处理
def convert_baichuan_attention_weights(state_dict):
"""转换 Baichuan 的注意力权重(ALiBi 特殊处理)"""
# Baichuan 的注意力权重里没有 cos/sin 缓存(因为 ALiBi 不需要位置编码)
# 为什么要把这些 key 删掉?因为转换脚本是通用写的(从 LLaMA 改来的)
# 如果 state_dict 里有 LLaMA 特有的 key,转换会报错
keys_to_remove = [k for k in state_dict.keys() if "rotary_emb" in k or "cos_cached" in k or "sin_cached" in k]
for k in keys_to_remove:
del state_dict[k]
print(f"[CONVERT] Removed LLaMA-specific key: {k}")
return state_dict
为什么 Baichuan 用 ALiBi 而不是 RoPE? 因为 ALiBi 对长上下文的泛化更好(训练时只用 2K 上下文,推理时能直接扩展到 8K 甚至更长)。RoPE 在训练长度外的泛化能力不如 ALiBi。
性能横评数据
cann-recipes-infer/perf_data/ 里有一堆性能数据 JSON 文件,覆盖了不同模型、不同 NPU 型号、不同精度(FP16/INT8)的对比。
测试环境
NPU: Ascend 910B (64GB HBM)
CPU: Intel Xeon Platinum 8380 (用来加载权重和预处理)
CANN: 8.0.RC1
推理引擎: ACL Lite 1.0
性能数据汇总
| 模型 | 参数量 | 精度 | Batch Size | 首 Token 延迟 (ms) | 吞吐 (tokens/s) | 显存占用 (GB) |
|---|---|---|---|---|---|---|
| LLaMA-7B | 7B | FP16 | 1 | 23 | 42 | 14 |
| LLaMA-7B | 7B | INT8 | 1 | 18 | 58 | 8 |
| LLaMA-13B | 13B | FP16 | 1 | 41 | 24 | 26 |
| LLaMA-13B | 13B | INT8 | 1 | 31 | 33 | 15 |
| Qwen-7B | 7B | FP16 | 1 | 25 | 40 | 14 |
| Qwen-7B | 7B | INT8 | 1 | 19 | 55 | 8 |
| Qwen-14B | 14B | FP16 | 1 | 45 | 22 | 28 |
| Qwen-14B | 14B | INT8 | 1 | 34 | 30 | 16 |
| Baichuan-7B | 7B | FP16 | 1 | 24 | 41 | 14 |
| Baichuan-7B | 7B | INT8 | 1 | 18 | 57 | 8 |
数据解读:
-
INT8 量化能提升 ~30% 性能(对比 FP16),同时省 ~40% 显存。
- 为什么 INT8 更快?因为 NPU 的 Cube Core 对 INT8 的矩阵乘法有专门优化(吞吐量比 FP16 高 2-4 倍)。
- 为什么显存省这么多?因为 INT8 每个参数占 1 字节,FP16 占 2 字节。
-
大模型(13B+)的吞吐下降明显(从 42 tokens/s 掉到 24 tokens/s)。
- 为什么?因为大模型的每层注意力计算和前馈网络计算量更大,而且 KV Cache 也更大,占用显存导致 batch size 上不去。
-
首 Token 延迟和模型大小近似线性相关(7B → 23ms,13B → 41ms)。
- 为什么?因为首 Token 延迟主要取决于「加载模型权重到 NPU」的时间,而权重大小和参数量近似线性。
用 benchmark.py 跑你自己的性能测试
cann-recipes-infer 的每个 recipe 里都有一个 benchmark.py,让你能在自己的环境里复现上面的数据。
# recipes/llama/benchmark.py
# 为什么要有这个脚本?因为性能数据高度依赖硬件和环境配置
# 官方给的数据是在特定环境下测的,你的环境可能不一样(比如 NPU 型号、CANN 版本、散热条件等)
# 跑一遍 benchmark,你才知道在你的环境里哪个模型、哪个精度最快
import time
import numpy as np
from acllite import InferSession
def benchmark_llama(
model_path: str,
prompt: str = "Once upon a time,",
n_samples: int = 10,
max_new_tokens: int = 128,
):
"""
对 LLaMA 做性能基准测试
Args:
model_path: 转换后的模型目录
prompt: 测试用的 prompt
n_samples: 采样次数(取平均)
max_new_tokens: 每个样本生成多少 token
"""
# 创建推理会话(为什么要在 benchmark 函数里创建?因为要测「冷启动」时间)
session = InferSession(model_path)
latencies = []
throughputs = []
for i in range(n_samples):
# 测首 Token 延迟(从提交 prompt 到生成第一个 token 的时间)
start = time.perf_counter()
output = session.generate(prompt, max_new_tokens=1) # 只生成 1 个 token
first_token_latency = (time.perf_counter() - start) * 1000 # ms
# 测完整生成吞吐量(token/秒)
start = time.perf_counter()
output = session.generate(prompt, max_new_tokens=max_new_tokens)
total_time = time.perf_counter() - start
throughput = max_new_tokens / total_time # tokens/s
latencies.append(first_token_latency)
throughputs.append(throughput)
print(f"[BENCH] Sample {i+1}/{n_samples}: "
f"first_token={first_token_latency:.1f}ms, "
f"throughput={throughput:.1f} tokens/s")
# 统计结果
print(f"\n[BENCH] === Results ===")
print(f"[BENCH] First token latency: {np.mean(latencies):.1f} ± {np.std(latencies):.1f} ms")
print(f"[BENCH] Throughput: {np.mean(throughputs):.1f} ± {np.std(throughputs):.1f} tokens/s")
return {
"first_token_latency_ms": np.mean(latencies),
"throughput_tokens_per_s": np.mean(throughputs),
}
if __name__ == "__main__":
result = benchmark_llama(
model_path="./llama-7b-cann",
n_samples=10,
max_new_tokens=128,
)
print(f"[BENCH] Final result: {result}")
为什么用 time.perf_counter() 而不是 time.time()? 因为 perf_counter() 用的是系统最高精度的时钟(通常是纳秒级),而 time.time() 的精度只有毫秒级。测推理延迟时,差异可能只有几毫秒,time.time() 测不出来。
模型选型建议
基于上面的性能数据和实际部署经验,cann-recipes-infer/docs/model_selection_guide.md 里给了一套选型建议。
场景1:对话机器人(Chatbot)
推荐:Qwen-7B (INT8)
- 理由: Qwen 在中文任务上比 LLaMA 强(因为训练语料里中文占比更高),而且 INT8 量化后显存占用只有 8GB,一张 910B(64GB)能跑 7 个实例(batch size=1 时),吞吐量很高。
- 不推荐: LLaMA-7B (FP16) — 显存占用 14GB,一张卡只能跑 4 个实例,吞吐量不如 Qwen-7B (INT8)。
场景2:代码生成(Code Completion)
推荐:LLaMA-13B (INT8)(或者更大的 34B/70B,如果有足够显存)
- 理由: 代码生成需要模型「理解」更复杂的语义,7B 模型的准确率不够。13B 是一个性价比平衡点——再大的模型(34B+)吞吐太低,不适合在线服务。
- 注意: 13B (INT8) 需要 15GB 显存,确保你有足够的内存。
场景3:边缘部署(小显存设备,比如昇腾 310)
推荐:Baichuan-7B (INT4)
- 理由: INT4 量化能把显存占用压到 4GB 以下,昇腾 310(8GB HBM)能跑。而且 Baichuan 的 ALiBi 位置编码对长上下文的泛化好,适合边缘场景里可能出现的长输入。
- 不推荐: 任何 FP16 模型 — 显存不够。
场景4:多模态(图文问答)
推荐:Qwen-VL-7B (INT8)
- 理由: 这是目前开源多模态模型里,在昇腾 NPU 上支持最好的。
cann-recipes-infer里有专门的recipes/qwen-vl/目录,包含了视觉编码器的权重转换脚本。 - 注意: 多模态模型比纯文本模型慢 2-3 倍(因为视觉编码器要计算),确保你能接受这个延迟。
总结
在昇腾 NPU 上部署 LLM,坑不少——权重转换、推理配置、性能优化、多卡并行,每一步都有特定于 NPU 的问题。cann-recipes-infer 把这些坑都填了,提供了一套可复现的 recipe,让你能快速把主流 LLM 跑起来。
仓库链接:https://atomgit.com/cann/cann-recipes-infer
昇腾计算产业是基于昇腾系列(HUAWEI Ascend)处理器和基础软件构建的全栈 AI计算基础设施、行业应用及服务,https://devpress.csdn.net/organization/setting/general/146749包括昇腾系列处理器、系列硬件、CANN、AI计算框架、应用使能、开发工具链、管理运维工具、行业应用及服务等全产业链
更多推荐

所有评论(0)