前言

大语言模型(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 类似,但有几个不同点:

  1. 支持多模态(Qwen-VL):如果你要部署 Qwen-VL(视觉-语言模型),需要额外转换视觉编码器的权重。
  2. 分词器不同:Qwen 用的是 tiktoken 分词器(和 GPT-4 一样),而 LLaMA 用的是 SentencePiece。转换权重时要注意分词器的对齐。
  3. 支持更长上下文: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

数据解读:

  1. INT8 量化能提升 ~30% 性能(对比 FP16),同时省 ~40% 显存。

    • 为什么 INT8 更快?因为 NPU 的 Cube Core 对 INT8 的矩阵乘法有专门优化(吞吐量比 FP16 高 2-4 倍)。
    • 为什么显存省这么多?因为 INT8 每个参数占 1 字节,FP16 占 2 字节。
  2. 大模型(13B+)的吞吐下降明显(从 42 tokens/s 掉到 24 tokens/s)。

    • 为什么?因为大模型的每层注意力计算和前馈网络计算量更大,而且 KV Cache 也更大,占用显存导致 batch size 上不去。
  3. 首 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

Logo

昇腾计算产业是基于昇腾系列(HUAWEI Ascend)处理器和基础软件构建的全栈 AI计算基础设施、行业应用及服务,https://devpress.csdn.net/organization/setting/general/146749包括昇腾系列处理器、系列硬件、CANN、AI计算框架、应用使能、开发工具链、管理运维工具、行业应用及服务等全产业链

更多推荐