刚接触 CANN 推理时我在想一件事:AI 模型编译完送进昇腾NPU,这中间谁在管 Device 初始化、模型加载、内存搬运这些事情?答案是 AscendCL——CANN 最上层的应用编程接口。

这篇文章记录我从零开始在昇腾 NPU 上跑通第一个推理 Demo 的全部步骤和踩坑点,少理论,多实操。


AscendCL 到底是什么

AscendCL 是 CANN 五层架构中的最顶层——异构计算语言层。它封装了底层的 Runtime 调用、GE 图执行和算子调度,对外暴露一套 C API。

开发者调用 AscendCL 时不需要关心 Runtime Stream 怎么管理、GE 怎么执行、算子怎么分发——这些全部被封装了。你需要做的就几步:LoadModel、CreateInput、Execute、GetOutput。

AscendCL 的身份很明确:它是 CANN 推理的"面向开发者"的接口。如果你只想做推理部署而不是写算子,AscendCL 就是你需要面对的唯一一套 API。


环境安装

硬件检查:

npu-smi info

能看到 NPU 型号和驱动版本。推理最低要求驱动版本 22.0.0+,CANN Toolkit 7.0+。

CANN 安装(Ubuntu 环境):

wget https://www.hiascend.com/software/CANN/community
chmod +x Ascend-cann-toolkit_8.0_x86_64.run
./Ascend-cann-toolkit_8.0_x86_64.run --install

# 设置环境变量
source /usr/local/Ascend/ascend-toolkit/set_env.sh

装完后 npu-smi info 能正常显示 NPU 状态,find /usr/local/Ascend -name "acl.h" 能找到头文件路径,环境就绪了。

模型准备: 用 ATC 工具把 ONNX 或 PyTorch 模型转换成 OM 格式。

atc --model=model.onnx --framework=5 --output=model --soc_version=Ascend910

--soc_version 根据实际 NPU 型号填写,比如 910B 也用 Ascend910(昇腾 NPU 型号命名不做 910A/910B 区分)。转换成功后会生成 model.om


第一个推理 Demo

AscendCL 推理的完整流程:

Device初始化 → Context创建 → 模型加载 → 创建输入输出Buffer → 推理执行 → 获取结果 → 释放资源

下面是完整代码,模型以 OM 格式输入,输入是预处理好的 float 数组。

#include <iostream>
#include "acl/acl.h"

int main(int argc, char *argv[]) {
    // 1. Device 初始化
    int32_t deviceId = 0;
    aclError ret = aclInit(nullptr);
    ret = aclrtSetDevice(deviceId);

    // 2. 创建 Context——当前线程的执行上下文
    aclrtContext context;
    ret = aclrtCreateContext(&context, deviceId);

    // 3. 加载 OM 模型到 Device
    uint32_t modelId;
    ret = aclmdlLoadFromFile("./model.om", &modelId);

    // 4. 创建输入输出 Buffer——必须在 Device 上分配
    aclmdlDesc *modelDesc = aclmdlCreateDesc();
    ret = aclmdlGetDesc(modelDesc, modelId);

    size_t inputSize = aclmdlGetInputSizeByIndex(modelDesc, 0);
    size_t outputSize = aclmdlGetOutputSizeByIndex(modelDesc, 0);

    void *inputBuffer = nullptr;
    void *outputBuffer = nullptr;
    // 用 HUGE_FIRST 策略让 Runtime 优先从大块池分配
    ret = aclrtMalloc(&inputBuffer, inputSize, ACL_MEM_MALLOC_HUGE_FIRST);
    ret = aclrtMalloc(&outputBuffer, outputSize, ACL_MEM_MALLOC_HUGE_FIRST);

    // 把输入数据从 Host 拷到 Device Buffer
    float inputData[INPUT_ELEM_COUNT] = { /* 填充预处理后的数据 */ };
    ret = aclrtMemcpy(inputBuffer, inputSize, inputData, inputSize,
                      ACL_MEMCPY_HOST_TO_DEVICE);

    // 5. 创建数据集——把 Buffer 包装成 Dataset
    aclmdlDataset *inputDataSet = aclmdlCreateDataset();
    aclDataBuffer *inputDataBuf = aclCreateDataBuffer(inputBuffer, inputSize);
    aclmdlAddDatasetBuffer(inputDataSet, inputDataBuf);

    aclmdlDataset *outputDataSet = aclmdlCreateDataset();
    aclDataBuffer *outputDataBuf = aclCreateDataBuffer(outputBuffer, outputSize);
    aclmdlAddDatasetBuffer(outputDataSet, outputDataBuf);

    // 6. 推理执行——同步模式
    ret = aclmdlExecute(modelId, inputDataSet, outputDataSet);

    // 7. 结果从 Device 拷回 Host
    float resultData[OUTPUT_ELEM_COUNT];
    ret = aclrtMemcpy(resultData, outputSize, outputBuffer, outputSize,
                      ACL_MEMCPY_DEVICE_TO_HOST);

    std::cout << "推理完成" << std::endl;
    // 处理 resultData...

    // 8. 释放资源——释放顺序不要乱
    aclDestroyDataBuffer(inputDataBuf);
    aclDestroyDataBuffer(outputDataBuf);
    aclmdlDestroyDataset(inputDataSet);
    aclmdlDestroyDataset(outputDataSet);
    aclrtFree(inputBuffer);
    aclrtFree(outputBuffer);
    aclmdlDestroyDesc(modelDesc);
    aclmdlUnload(modelId);
    aclrtDestroyContext(context);
    aclrtResetDevice(deviceId);
    aclFinalize();

    return 0;
}

编译命令:

g++ -o infer_demo infer_demo.cpp -I/usr/local/Ascend/ascend-toolkit/latest/include \
    -L/usr/local/Ascend/ascend-toolkit/latest/lib64 -lascendcl -std=c++11

LD_LIBRARY_PATH 记得包含 Ascend 的 lib64 目录,否则运行时找不到 libascendcl.so。


Buffer 管理:最常见的四个踩坑点

坑 1:Input Buffer 大小不匹配。 aclmdlGetInputSizeByIndex 返回模型要求的精确尺寸。如果输入做完预处理后尺寸不一致,aclrtMemcpy 不会报错,但推理结果完全错乱。必须在 CreateDataBuffer 之前先比对大小。

坑 2:Device 内存泄漏。 推理框架中反复 aclrtMalloc / aclrtFree 的代码很容易漏掉某个异常分支的 Free。CANN Runtime 在进程退出时不会自动回收泄露的 Device 显存,长时间推理服务的显存会持续增长。建议开发阶段每个 aclrtMalloc 都配对写 aclrtFree,用对称缩进规范化写法。

坑 3:多线程下 Context 未绑定。 每个线程必须有自己的 Context。非主线程调用推理前做了 aclrtSetDevice 但没做 aclrtCreateContext,推理接口会直接返回 ACL_ERROR_INVALID_CONTEXT。排查时先看错误码是不是 107005。

坑 4:Host 内存对齐。 CANN Device 要求部分 Buffer 按 64 字节对齐。用 aclrtMallocHost 代替普通 malloc 可以自动满足对齐要求。自己 malloc 再传给推理接口可能出现 ACL_ERROR_INVALID_MALLOC_TYPE


异步执行:进阶选项

上面的 Demo 用 aclmdlExecute——同步模式,NPU 执行期间 Host 线程阻塞。对吞吐敏感的场景应该用异步版本:

aclrtStream stream;
aclrtCreateStream(&stream);

// 提交推理到 Stream,立即返回
aclmdlExecuteAsync(modelId, inputDataSet, outputDataSet, stream);

// Host 侧不空等,可以准备下一批
prepare_next_batch();

// 在需要结果时同步
aclrtSynchronizeStream(stream);

异步模式的核心收益是 Host 侧不空等。预处理、后处理、多组输入的 pipeline 编排都可以通过 Stream 来管理。一个 Stream 提交推理,另一个 Stream 搬运数据,两条流水线同时推进。


为什么 ACL 是 CANN 推理的核心

AscendCL 是大部分部署开发者接触 CANN 的第一个入口,也是最后一层抽象——它下面是 Runtime 的 Stream 调度和 GE 的图优化,上面是你自己的工程代码。懂 AscendCL 的 API 语义不代表懂它的机制,但不懂 AscendCL 连推理都跑不起来。

入门后值得深入 ACL Runtime 层——理解 aclrtSetDevice 底下为什么需要一个 Context,推理时 Stream 是怎么创建的,多流场景下如何手动编排流水线——这些知识决定了你的推理服务能跑到多少吞吐。

Model Load 的更多细节

aclmdlLoadFromFile 做的事不止是读文件。GE 在背后解析 OM 模型的计算图,对图中的算子做融合优化,然后分配执行所需的全部 Tensor 内存。这意味着模型加载阶段已经完成了图优化的大部分工作。

一个常见的疑问是:aclmdlLoadFromFile 后能不能换模型?答案是模型运行时模型描述是只读的。想换模型必须 Unload 再 Load。如果是多模型服务,建议在初始化阶段全部 Load 完,推理时通过 modelId 切换,省去 reload 开销。

uint32_t modelA, modelB;
aclmdlLoadFromFile("./modelA.om", &modelA);
aclmdlLoadFromFile("./modelB.om", &modelB);
// 推理时直接切换 modelId 就行
aclmdlExecute(modelA, input, output);
aclmdlExecute(modelB, input, output);

内存占用方面,每个 Load 的模型都会在 NPU 显存中占用一份常驻空间。模型太多时需要注意显存总量是否够用。

结语

跑通第一个推理 Demo 通常半天到一天,但把推理代码写得稳定、高效、没有资源泄漏,需要更深的 Buffer 管理理解和多线程 Context 认知。先跑起来,再优化。

CANN AscendCL 仓库

昇腾 ACL Runtime 层源码

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐