前言

在昇腾CANN的软件栈中,runtime仓库扮演着连接上层框架与底层驱动的关键角色。当你在Ascend NPU上运行一个深度学习推理或训练任务时,从数据准备、算子调度到结果回传,几乎每一个步骤都离不开runtime提供的编程接口。如果把CANN的整体架构比作一栋建筑,runtime就是地基到地面层的那段结构——它不直接展示给外部,但任何想在这块硬件上工作的代码都必须踩在它上面。

这个仓库里的内容主要分为两大部分。第一部分是Runtime组件,它给出了Ascend NPU运行时的用户编程接口,涵盖设备管理、流管理、Event管理、内存管理和任务调度这些核心能力。第二部分是维测功能组件,包括性能数据采集工具msprof、模型和算子Dump工具adump、日志导出工具msnpureport等。理解了这两部分分别解决了什么问题,你对整个runtime仓库的定位就清晰了:它既要让你写的代码能够在NPU上跑起来,又要给你提供足够的手段去诊断它跑得好不好。

接下来的内容会按照一个实际开发流程展开,从初始化设备开始,到管理流和任务,再到内存操作的细节,以及介绍几个维测工具的具体用法。每个环节都有对应的代码示例,并且会解释为什么这样设计而不是那样设计。

设备管理与初始化

任何使用昇腾NPU进行计算的程序,都必须从一次初始化开始。在CANN的编程接口中,aclInit和aclFinalize这一对函数构成了整个运行时的生命周期边界。aclInit负责加载CANN的核心运行时库,建立与底层驱动的通信通道;aclFinalize则负责释放所有在初始化阶段申请的资源,确保程序退出时不会留下泄漏。

从源码结构来看,aclInit和aclFinalize的实现位于src/acl/目录下,而设备管理相关的核心逻辑则在src/runtime/中。设备管理接口的命名遵循acl前缀,代表这是AscendCL层的API。初始化之后,程序需要通过aclrtSetDevice和aclrtGetDevice来指定当前操作的目标Device。Device在昇腾NPU体系中是独立的工作单元,一个物理加速卡通常对应一个Device实例,但在多卡服务器环境下,会有多个Device同时存在。

多Device场景是实际部署中最常见的情况之一。比如在一台搭载多张昇腾910加速卡的服务器上同时运行多个独立任务,或者在一个任务中跨多卡并行处理不同的数据分区。在这类场景下,必须明确指定每一次内存分配和每一次流创建对应的Device ID,否则运行时无法知道你的意图是什么,系统可能默认使用Device 0,或者直接返回错误码。

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

int main(int argc, char **argv)
{
    // 初始化runtime,这是所有acl接口使用前的必经步骤
    aclError ret = aclInit(nullptr);
    if (ret != ACL_ERROR_NONE) {
        printf("init failed: %d\n", ret);
        return -1;
    }

    // 假设当前服务器有两张昇腾NPU,使用Device 1进行计算
    int dev_id = 1;
    ret = aclrtSetDevice(dev_id);
    if (ret != ACL_ERROR_NONE) {
        printf("set device %d failed: %d\n", dev_id, ret);
        aclFinalize();
        return -1;
    }

    // 在Device 1上分配内存
    float *d_x = nullptr;
    float *d_y = nullptr;
    size_t n = 1024 * 1024;
    ret = aclrtMalloc((void **)&d_x, n * sizeof(float), ACL_MEM_MALLOC_NORMAL_ONLY);
    ret |= aclrtMalloc((void **)&d_y, n * sizeof(float), ACL_MEM_MALLOC_NORMAL_ONLY);
    if (ret != ACL_ERROR_NONE) {
        printf("malloc on device %d failed\n", dev_id);
        aclrtFree(d_x);
        aclrtFree(d_y);
        aclrtResetDevice(dev_id);
        aclFinalize();
        return -1;
    }

    printf("device %d memory allocated: %zu bytes each\n", dev_id, n * sizeof(float));

    // 清理资源,顺序与申请顺序相反
    aclrtFree(d_x);
    aclrtFree(d_y);
    aclrtResetDevice(dev_id);
    aclFinalize();
    return 0;
}

多Device环境下,如果不先调用aclrtSetDevice就直接分配内存,运行时无法确定应该在哪个Device上完成这次分配。aclrtSetDevice在这里充当了一种隐式的上下文绑定机制,后续所有不带Device ID参数的aclrt接口都会作用在当前选中的Device上。这种设计在CUDA等主流GPU编程模型中也普遍存在,它的好处是减少API签名中的冗余参数,让连续操作同一个Device的代码更简洁。但它也有一个潜在风险:如果在多线程场景下不加锁地切换Device,不同线程之间可能互相覆盖Device上下文。昇腾NPU的runtime在设计上要求开发者自行管理这种并发安全性,对于需要在多个Device上同时工作的场景,每个线程应该各自维护自己的Device上下文。aclrtResetDevice在aclrtFree之后调用,是因为某些底层资源需要在Device被切换或重置之前完成释放,顺序颠倒可能引起驱动层的断言失败。

流与任务调度

流(Stream)是理解昇腾NPU异步执行模型的核心概念。在传统的CPU编程中,一行接一行的代码是顺序执行的,除非你显式地引入线程或异步机制。在NPU上,数据的传输和计算操作默认是同步的——aclrtMemcpy会等待数据搬运完成才返回,kernel调用会等待计算完成才返回。但这样做的问题在于,数据搬运和计算无法重叠,当一个数据传输在总线上占用带宽时,计算单元只能空闲等待,反之亦然。

引入Stream之后,运行时能够将不同的任务分配到不同的执行队列中。如果两个操作在不同的Stream上,它们在硬件层面可以被调度到并行执行的时隙。Stream本质上是一个任务队列,同一个Stream内的操作严格按照提交顺序串行执行,但不同Stream之间的操作互不阻塞,从而实现粗粒度的并行。

aclrtCreateStream用于创建一个新的Stream,aclrtDestroyStream用于销毁不再需要的Stream。在创建Stream时可以指定它隶属于哪个Device,默认为当前Device上下文中的Device ID。如果需要等待某个Stream上已提交的所有任务完成,可以调用aclrtSynchronize强制Host侧阻塞到Stream清空;如果只是想在某个事件点插入一个标记,则使用Event相关接口。

异步执行的精髓在于:你提交了任务,不等于任务执行完了。代码执行流会在任务入队后立即返回,而不是等待硬件真正把数据算出来。这一点是新手最容易踩坑的地方——如果在内存中刚刚把输入数据填充进去就调用aclrtSynchronize,NPU可能还没来得及开始计算,同步等待就已经触发了,此时读到的结果可能仍然是旧的。

#include "acl/acl.h"
#include <pthread.h>
#include <stdio.h>

#define BATCH 4
#define N 256

typedef struct {
    int dev_id;
    aclrtStream s1;
    aclrtStream s2;
} WorkerCtx;

void *worker(void *arg)
{
    WorkerCtx *ctx = (WorkerCtx *)arg;
    aclError ret;

    ret = aclrtSetDevice(ctx->dev_id);
    if (ret != ACL_ERROR_NONE) return nullptr;

    ret = aclrtCreateStream(&ctx->s1);
    ret |= aclrtCreateStream(&ctx->s2);
    if (ret != ACL_ERROR_NONE) return nullptr;

    float *hx = (float *)malloc(N * sizeof(float));
    float *hy = (float *)malloc(N * sizeof(float));
    float *dx = nullptr;
    float *dy = nullptr;

    aclrtMalloc((void **)&dx, N * sizeof(float), ACL_MEM_MALLOC_NORMAL_ONLY);
    aclrtMalloc((void **)&dy, N * sizeof(float), ACL_MEM_MALLOC_NORMAL_ONLY);

    // 模拟两个阶段的处理流水线
    for (int i = 0; i < BATCH; i++) {
        for (int j = 0; j < N; j++) {
            hx[j] = (float)(i * BATCH + j);
            hy[j] = (float)(i * BATCH + j) * 2.0f;
        }

        // 第一路走s1,异步搬运数据
        aclrtMemcpy(dx, N * sizeof(float), hx, N * sizeof(float),
                    ACL_MEMCPY_HOST_TO_DEVICE);

        // 第二路走s2,异步搬运另一路数据
        aclrtMemcpy(dy, N * sizeof(float), hy, N * sizeof(float),
                    ACL_MEMCPY_HOST_TO_DEVICE);

        // 分别在两个Stream上等待这一批次的数据搬运完成
        // 两个Stream之间互不阻塞,硬件可并行处理两路数据
        aclrtSynchronizeStream(ctx->s1);
        aclrtSynchronizeStream(ctx->s2);
    }

    aclrtSynchronize();
    printf("device %d pipeline done\n", ctx->dev_id);

    free(hx);
    free(hy);
    aclrtFree(dx);
    aclrtFree(dy);
    aclrtDestroyStream(ctx->s1);
    aclrtDestroyStream(ctx->s2);
    aclrtResetDevice(ctx->dev_id);
    return nullptr;
}

Stream的存在让运行时能够区分不同任务之间的依赖关系。两个没有任何数据依赖的算子,如果被提交到同一个Stream里,运行时只能按顺序执行,因为Stream本身是一个FIFO队列,没有乱序或并行调度的能力。但如果把它们分配到不同的Stream,运行时调度器就可以看到这两个Stream之间没有依赖约束,从而在硬件资源允许的范围内让它们并行执行。在上面的代码示例中,s1和s2各自管理一组数据,两路处理在逻辑上独立,在硬件上也能并行。aclrtSynchronizeStream在每个batch结束后等待当前Stream清空,确保了同一路数据内的顺序正确性,同时不影响另一路Stream的推进。如果去掉这行同步,代码会在数据还没搬运完之前就进入下一次循环迭代,导致数据竞争。这种按Stream粒度同步而不是全局同步的设计,给了开发者足够灵活的控制力:你可以只等待某个特定阶段完成,而不用把整个流水线全部暂停。在真实的融合算子场景中,这种多Stream流水线是掩盖数据搬运延迟的标准手段。

内存管理与性能调优对比

内存管理在昇腾NPU编程中是最容易出现性能瓶颈的环节。aclrtMalloc负责在Device侧分配内存,aclrtFree负责释放。Device侧内存的物理位置在NPU显存上,访问延迟远低于Host侧的系统内存,但数据需要通过PCIe总线从Host搬运到Device才能使用。aclrtMemcpy就是完成这种跨地址空间数据传输的接口,它有四种模式:HOST_TO_DEVICE、DEVICE_TO_HOST、DEVICE_TO_DEVICE和HOST_TO_HOST,分别对应不同的源和目标物理位置。

在实际的AI推理或训练场景中,内存管理的效率直接影响整个系统的吞吐量。一个常见的问题是Host和Device之间的数据搬运过于频繁。每一次aclrtMemcpy调用都涉及PCIe带宽的占用和同步等待的开销,如果每个算子调用前后都要同步搬运一次数据,大量时间会浪费在等待数据传输完成上,而不是真正的计算上。

解决这个问题的思路有几种。第一种是减少搬运次数,将多个小数据块合并成一次大搬运,或者将可复用的数据保持在Device侧而不是每轮都重新搬运。第二种是使用异步搬运配合Stream重叠,aclrtMemcpy支持异步版本,它会立即返回,任务被提交到指定Stream上执行,主线程可以去处理其他事情。第三种是合理利用Workspace机制——某些融合算子需要在Device侧申请一块额外的临时工作区,Workspace的大小取决于算子的内部实现逻辑,如果申请的工作区不足,算子会返回错误而不是自动降级。

理解了这几种内存操作的差异,就能明白为什么同样的算法在不同实现方式下性能差距可能达到数量级。CPU上跑的程序,数据从内存到寄存器只需要几十个时钟周期,PCIe搬运的延迟是它的数百倍,任何不必要的跨地址空间拷贝都会被放大成明显的性能损耗。

使用昇腾NPU运行时进行内存操作时,不同的实现方式在性能表现上有明显差异。以下对比展示了两种常见做法在典型AI推理场景下的效率区别:

维度 使用前(传统同步方案) 使用后(runtime优化方案) 差异来源
Host到Device数据搬运 每轮推理同步等待,aclrtMemcpy强制Host阻塞直到PCIe事务完成 异步版本aclrtMemcpyAsync立即返回,下一轮计算时自动重叠上一轮搬运 同步API让Host空转等待PCIe,异步版本解耦了数据搬运与CPU计算
计算与数据传输重叠 无法重叠,NPU计算单元在数据搬运期间完全空闲 可重叠,Stream机制让硬件在等待数据时调度其他算子执行 Stream的异步入队能力让调度器在数据未就绪时不阻塞其他工作
显存占用 最低,仅存当前batch,推理结束后立即释放 预热阶段一次性分配全部显存,推理轮次内不再申请释放 显存分配和释放涉及驱动层物理页映射操作,运行中避免这部分开销
适合场景 单batch离线推理,调试阶段 多batch连续推理,吞吐量敏感或实时流式推理 长期运行的生产推理服务
差异来源 每次调用aclrtMemcpy都强制同步,Host阻塞等待PCIe事务完成 数据预热消除了推理循环内的搬运开销,Stream流水线化让计算和数据传输形成生产者-消费者关系 传统方案中PCIe带宽被同步阻塞完全浪费,runtime优化方案通过异步和流水线最大化利用率

在实际项目中,传统方案常出现在初次接触NPU编程的阶段,代码能跑通,但大量时间消耗在等待数据搬运上。优化方案则需要对数据流和计算流做全局规划,但它带来的效率提升在持续运行的生产系统中是实质性的。如果将同步方案和优化方案放在一起对比,在同样的硬件条件下,处理同样的批量推理任务,两种方案的总耗时差距可能达到一个数量级,这个差距主要来源于两个方面:一是同步等待导致的NPU空转,二是显存反复申请释放带来的固定开销。

在代码层面,以下场景展示了从传统方案迁移到优化方案的典型路径:

#include "acl/acl.h"
#include <stdio.h>
#include <stdlib.h>

#define MAX_BATCH 32
#define FEATURE_SIZE 512

typedef struct {
    float *d_input;
    float *d_workspace;
    float *d_output;
    float *h_output;
    size_t workspace_size;
    aclrtStream infer_stream;
} Session;

int session_create(Session *sess, int dev_id)
{
    aclError ret = aclInit(nullptr);
    if (ret != ACL_ERROR_NONE) return -1;

    ret = aclrtSetDevice(dev_id);
    if (ret != ACL_ERROR_NONE) return -1;

    // 一次性分配推理所需的全部显存,包括输入、工作区、输出
    size_t input_bytes = MAX_BATCH * FEATURE_SIZE * sizeof(float);
    size_t output_bytes = MAX_BATCH * FEATURE_SIZE * sizeof(float);

    ret = aclrtMalloc((void **)&sess->d_input, input_bytes,
                      ACL_MEM_MALLOC_NORMAL_ONLY);
    ret |= aclrtMalloc((void **)&sess->d_output, output_bytes,
                      ACL_MEM_MALLOC_NORMAL_ONLY);
    // 工作区大小由算子实现决定,运行时不应当随意改动
    sess->workspace_size = 512 * 1024;
    ret |= aclrtMalloc((void **)&sess->d_workspace, sess->workspace_size,
                      ACL_MEM_MALLOC_NORMAL_ONLY);
    if (ret != ACL_ERROR_NONE) {
        session_destroy(sess);
        return -1;
    }

    sess->h_output = (float *)malloc(output_bytes);
    ret = aclrtCreateStream(&sess->infer_stream);
    if (ret != ACL_ERROR_NONE) {
        session_destroy(sess);
        return -1;
    }

    printf("session ready on device %d, workspace %zu bytes\n",
           dev_id, sess->workspace_size);
    return 0;
}

int session_run(Session *sess, float *h_input, int batch_size)
{
    size_t copy_bytes = batch_size * FEATURE_SIZE * sizeof(float);

    // 异步搬运输入数据,立即返回,不阻塞后续计算
    aclrtMemcpyAsync(sess->d_input, copy_bytes,
                     h_input, copy_bytes,
                     ACL_MEMCPY_HOST_TO_DEVICE,
                     sess->infer_stream);

    // 调用acl接口执行推理算子(这里以同步调用替代)
    // 真实场景中算子会使用预分配的工作区
    aclrtSynchronizeStream(sess->infer_stream);

    // 异步搬运输出数据回Host
    aclrtMemcpyAsync(sess->h_output, copy_bytes,
                     sess->d_output, copy_bytes,
                     ACL_MEMCPY_DEVICE_TO_HOST,
                     sess->infer_stream);

    // 等待这一批次全部完成再返回
    aclrtSynchronizeStream(sess->infer_stream);
    return 0;
}

void session_destroy(Session *sess)
{
    if (sess->infer_stream) {
        aclrtDestroyStream(sess->infer_stream);
        sess->infer_stream = nullptr;
    }
    if (sess->d_input) { aclrtFree(sess->d_input); sess->d_input = nullptr; }
    if (sess->d_workspace) { aclrtFree(sess->d_workspace); sess->d_workspace = nullptr; }
    if (sess->d_output) { aclrtFree(sess->d_output); sess->d_output = nullptr; }
    if (sess->h_output) { free(sess->h_output); sess->h_output = nullptr; }
    aclrtResetDevice(0);
    aclFinalize();
}

优化方案的核心思路是预热一次、持续复用。在session_create阶段一次性分配好所有显存,每个推理轮次只需要做数据搬运和计算,不需要再分配或释放任何显存。显存分配和释放在运行时是一个相对昂贵的操作,因为底层要调用驱动接口完成物理页的映射和取消映射,把这些操作从推理循环中移除能降低延迟波动。这个效果在推理轮次较多时会越发明显,因为申请释放的开销被分摊到了更多的计算任务上。aclrtMalloc用于分配Device侧显存,aclrtFree对应释放,二者必须配对使用,一旦配对关系被打破,比如分配了但忘记释放,会造成显存泄漏,长期运行时可能导致后续申请失败。Workspace的工作区概念在昇腾NPU算子实现中很常见,融合算子内部往往需要一块临时缓冲区来存放中间结果,这块区域的大小由算子编译时决定,运行时不应当随意改动它的大小,否则可能导致算子运行失败甚至内存越界。

维测工具实战

即便代码能够正确运行,性能调优和问题诊断仍然需要借助专业的维测工具。runtime仓库中提供了三套核心的维测组件,分别针对性能分析、数据Dump和日志记录三个不同维度。

msprof是CANN提供的性能分析工具,用于采集和分析运行在昇腾AI处理器上的AI任务各运行阶段的关键性能指标。在分布式训练场景下,msprof能够帮助开发者识别算子执行的热点、内存带宽的瓶颈以及数据搬运的等待时间。它的使用方式是在目标程序启动前设置特定的环境变量,指定数据采集的输出路径,完成配置后运行程序。程序退出后,msprof会在指定目录生成结构化的性能数据文件,这些文件可以通过后续的分析工具打开查看。

# 设置msprof环境变量,启用算子级性能数据采集
export ASCEND_PROFILER_FLAGS=enable
export ASCEND_PROFILER_OUTPUT=/tmp/msprof_output

# 如果需要分析特定阶段,可以指定采样范围
export ASCEND_PROFILER_START_STEP=10
export ASCEND_PROFILER_STOP_STEP=100

# 运行推理程序,结束后在输出目录中查看性能数据
./your_inference_binary

# 查看生成的文件
ls /tmp/msprof_output/

msprof通过在运行时注入采集探针来收集硬件性能计数器数据,这些数据在程序正常执行时不会被记录,因为全量的硬件事件采集会干扰程序的运行时行为。设置ASCEND_PROFILER_FLAGS环境变量告诉运行时在退出时汇总这段时间内的硬件事件统计并写入文件,这是轻量级的采集方式,对程序运行时的性能影响非常小。输出目录选择在/tmp下是出于IO性能的考虑,临时目录通常对应内存文件系统,写入速度远快于机械硬盘或普通SSD,能避免维测工具自身的IO操作成为性能瓶颈。如果在生产环境中持续开启msprof,输出目录应定期清理,否则磁盘占用会持续增长。START_STEP和STOP_STEP用于指定采集的时间窗口,这在长时运行的任务中很有用——可以在系统预热之后再开始采集,避免前几轮的初始化噪声影响对稳定状态的判断。

adump用于Dump单算子或模型的输入输出数据,主要用于定位精度问题。当推理结果与预期不符时,需要检查算子的输入数据是否正确、权重是否被正确加载、输出是否在传播过程中出现了异常值。adump的工作方式是通过配置Dump策略文件指定要Dump哪些算子或哪些Layer,程序运行结束后再查看生成的数据文件。

# 创建Dump配置,指定要Dump的关键层
cat > /tmp/adump_config.json << 'EOF'
{
    "dump": {
        "dump_path": "/tmp/adump_output",
        "dump_mode": "all",
        "layers": [
            "conv1",
            "matmul_v0",
            "softmax_v0"
        ]
    }
}
EOF

# 设置环境变量指向配置文件
export DUMP_CONFIG_PATH=/tmp/adump_config.json

# 运行程序,结束后在dump_path中查看生成的数据文件
./your_inference_binary

# 检查Dump输出
ls /tmp/adump_output/

精度问题的定位往往需要逐层核对中间结果,手工在代码中插入打印逻辑既麻烦又容易改动原始行为。adump提供了非侵入式的Dump能力,它通过在图执行引擎中插入Hook来拦截指定算子的输入输出数据,整个过程对算子本身的执行逻辑没有任何干扰。dump_mode设置为all意味着Dump所有符合条件的算子实例,如果只关心特定层或特定迭代的输出,可以把模式改为iteration并指定范围。输出路径同样推荐使用/tmp,因为Dump数据量可能很大,在高频推理场景下几秒钟就能产生GB级别的文件,放在内存文件系统可以避免影响程序本身的IO性能。

msnpureport是msnpureport命令行工具,用于导出device侧的日志。在某些场景下,程序崩溃或异常退出时Host侧可能无法捕获到完整的调试信息,而device侧的日志可能还保留着有价值的状态数据。msnpureport通过驱动接口读取device侧的日志缓冲区并将内容导出到文件中,供开发人员离线分析。

# 使用msnpureport导出device侧日志
msnpureport --mode=export --output=/tmp/device_logs.tar.gz

# 解压查看日志内容
tar -xzf /tmp/device_logs.tar.gz -C /tmp/device_logs/
cat /tmp/device_logs/*.log

昇腾NPU的device侧固件和驱动在运行时会产生详细的内部日志,记录了硬件调度、内存分配、错误检测等关键事件。这些日志默认保存在device侧的内部存储中,容量有限,如果长时间运行或错误频繁触发,新日志会覆盖旧日志。msnpureport提供的导出功能确保在问题发生后能够及时把device侧的状态快照保存出来,避免日志被覆盖。导出格式选择tar.gz是因为日志文件通常有多个分散的小文件,压缩后更便于传输和归档。在实际调试过程中,如果程序Hang住了无法正常退出,仍然可以单独运行msnpureport命令来尝试读取已写入的日志数据,这是其他需要程序主动配合的维测手段所不具备的优势。

结尾

runtime仓库虽然不像算子仓库那样有丰富的算法实现,但它提供的每一层接口都直接决定了上层应用能否高效、稳定地利用昇腾NPU的硬件能力。从aclInit的初始化约定,到Device上下文的绑定规则,再到Stream带来的异步并行能力,以及msprof、adump、msnpureport构成的完整维测闭环,理解这些核心概念和工具的过程,就是在建立对整个CANN软件栈的底层认知。

在实际项目中,很多性能问题归根结底是内存搬运策略不当或者同步阻塞点过多造成的。通过aclrtMemcpyAsync配合Stream实现搬运与计算的重叠,通过预分配显存减少分配释放开销,通过msprof定位热点算子再针对性优化,这三条路径构成了一个完整的NPU性能调优闭环。这个闭环从runtime出发,最终又回到runtime的理解上。


仓库地址:https://atomgit.com/cann/runtime

Logo

鲲鹏昇腾开发者社区是面向全社会开放的“联接全球计算开发者,聚合华为+生态”的社区,内容涵盖鲲鹏、昇腾资源,帮助开发者快速获取所需的知识、经验、软件、工具、算力,支撑开发者易学、好用、成功,成为核心开发者。

更多推荐