在这里插入图片描述

前言

昇腾 CANN(Compute Architecture for Neural Networks)是华为面向 AI 场景推出的异构计算架构,为昇腾系列 NPU 提供从上层模型到底层硬件的完整算力支撑体系。在 CANN 的软件栈中,ascend-run 是链接上层图执行引擎与底层硬件驱动之间的核心运行时组件,它负责管理算子从图下发到执行完毕的完整生命周期。可以说,没有 ascend-run,昇腾 NPU 的算力就只能停留在纸面上。本文将深入剖析 ascend-run 的架构定位、初始化流程、算子执行机制、内存管理体系以及调度策略,帮助开发者全面掌握这一关键运行时的内部原理与实战技巧。

ascend-run 定位:算子执行运行时

在传统的异构计算编程模型中,开发者需要显式管理 NPU 设备、内存拷贝和算子启动。与之对应,昇腾 CANN 提供了更抽象的分层架构。ascend-run 位于 CANN 的 Runtime 层(ACL 层),对上承接 Graph 执行引擎下发的计算图,对下对接CCE(Cube Coordinate Engine)驱动和 AICORE/AIMC 硬件资源。

从功能边界来看,ascend-run 的核心职责包括以下几个方面:设备资源的发现与分配、计算 Stream 的创建与管理、算子任务的下发与入队、Host 侧与 Device 侧之间的内存管理、以及通过 Event 机制实现异步操作之间的同步控制。它并不是一个孤立的进程或线程,而是一套集成在 ACL(Ascend Computing Language)库中的运行时状态机,贯穿整个算子执行的全过程。

当开发者调用 ACL API 加载模型并执行推理时,Graph 引擎首先会将计算图解析为算子列表,ascend-run 接收到这些算子的执行请求后,按照 Stream 划分将任务分发到不同的硬件队列中,然后通过异步入队机制将任务提交给 AICORE 执行。整个过程中,ascend-run 始终扮演着调度中枢的角色,确保算子在正确的设备上、正确的时间点、以正确的执行顺序运行。

理解 ascend-run 的定位,关键在于把握它与上下层的关系。对上,它屏蔽了底层硬件的复杂性,提供统一的 Runtime 接口;对下,它直接管理硬件队列和设备内存,提供最细粒度的执行控制。这种承上启下的角色,使得 ascend-run 成为 CANN 性能优化的关键层级——深入掌握它的行为模式,是发挥昇腾 NPU 极致性能的必要前提。

运行时初始化:aclInit 到设备分配的全链路

整体初始化流程

ascend-run 的初始化并不是一个孤立的函数调用,而是一系列有序状态切换的过程。完整的初始化链路遵循以下顺序:

aclInit → aclrtSetDevice → aclrtCreateStream → (可选) aclrtCreateEvent

aclInit 是整个 ACL 库的初始化入口,它负责加载 CANN 驱动、初始化全局上下文、建立 Host 侧与 Device 侧之间的通信通道。aclInit 必须在所有其他 ACL API 调用之前完成,通常只需执行一次。内部实现中,aclInit 会探查系统中所有可用的昇腾 NPU 设备,建立设备描述表,并预分配一些全局元数据结构。

aclrtSetDevice 将指定的 NPU 设备设置为当前线程的活跃设备。在多卡场景下,每个线程可以通过调用 aclrtSetDevice 绑定到不同的物理设备。绑定后,该线程后续所有的内存分配和算子执行都会作用在所绑定的设备上。如果不显式调用 aclrtSetDevice,ACL 会默认使用设备 0。

aclrtCreateStream 创建计算 Stream。Stream 是 ascend-run 中最核心的执行单元,它本质上是设备上的一个任务队列。所有绑定到同一 Stream 的算子按入队顺序串行执行,而不同 Stream 之间的算子则可以并行。创建 Stream 是可选的——如果开发者不显式创建,ACL 会提供一个默认 Stream(Default Stream)供使用。

aclrtCreateEvent 用于创建 Event 对象,Event 主要用于跨 Stream 的同步以及精确计时。一个 Event 只有在对应算子完成后才会被标记为完成状态,其他 Stream 可以等待该 Event 以实现跨 Stream 的依赖控制。

设备分配机制

在多设备环境下,设备分配策略直接影响整体吞吐量。ascend-run 提供了两种设备分配模式:显式分配自动分配

显式分配由用户通过 aclrtSetDevice 精确控制当前线程使用的设备编号,这在需要细粒度资源管理的场景中非常有用。自动分配则由 ACL 运行时根据设备负载情况自动选择空闲设备,减少了用户的认知负担,但在某些对性能敏感的场景中可能不是最优选择。

以下代码展示了完整的初始化流程:

#include "acl/acl.h"

int main(int argc, char **argv) {
    // 步骤1: 初始化 ACL 运行时库
    aclError ret = aclInit(nullptr);
    if (ret != ACL_SUCCESS) {
        printf("aclInit failed: %d\n", ret);
        return -1;
    }

    // 步骤2: 设置使用的设备(设备0)
    ret = aclrtSetDevice(0);
    if (ret != ACL_SUCCESS) {
        printf("aclrtSetDevice failed: %d\n", ret);
        return -1;
    }

    // 步骤3: 获取当前设备的上下文信息
    aclrtContext context;
    ret = aclrtGetCurrentContext(&context);
    if (ret != ACL_SUCCESS) {
        printf("aclrtGetCurrentContext failed: %d\n", ret);
        return -1;
    }

    // 步骤4: 创建自定义 Stream
    aclrtStream stream;
    ret = aclrtCreateStream(&stream);
    if (ret != ACL_SUCCESS) {
        printf("aclrtCreateStream failed: %d\n", ret);
        return -1;
    }

    printf("Runtime initialized successfully on device 0\n");

    // 此处执行业务逻辑

    // 清理阶段(反向释放资源)
    ret = aclrtDestroyStream(stream);
    ret = aclrtResetDevice(0);
    aclFinalize();

    return 0;
}

初始化过程中有几个值得关注的细节。首先,aclInit 的参数为配置文件路径,传入 nullptr 表示使用默认配置路径。其次,aclrtSetDevice 是线程级操作,不同线程可以绑定不同设备,但同一时刻一个线程只能绑定一个设备。最后,设备分配与 Stream 创建的顺序非常重要——必须先完成设备绑定,才能创建属于该设备的 Stream。

算子执行流程:从 Graph 下发到执行完毕

Graph 下发与 Stream 绑定

当计算图被下发到 ascend-run 时,首先经历一个图编译和图切分的过程。图编译器会将高层的计算图转换为适配 AICORE 硬件的底层算子序列,并根据算子的类型和依赖关系将其分配到不同的 Stream 中。

对于没有显式依赖关系的算子,编译器会将它们尽可能分配到不同的 Stream 中以实现并行。对于存在数据依赖关系的算子,编译器会在相应的 Stream 中插入依赖边,确保执行顺序的正确性。开发者也可以通过手动创建 Stream 并使用 Event 机制来显式控制算子之间的执行顺序。

Graph 下发的核心 API 是 aclmdlLoadFromFile(加载模型)和 aclmdlExecute(执行模型)。在内部实现中,aclmdlExecute 会完成图解析、算子编译、Stream 绑定和任务下发等一系列操作,对外呈现为一个同步调用接口,但实际上底层包含了大量异步执行的工作。

#include "acl/acl.h"
#include "acl/acl_mdl.h"

int execute_model(const char *model_path, 
                  aclDataBuffer *input, 
                  aclDataBuffer *output) {
    // 加载模型
    uint32_t model_id;
    aclmdlDataset *input_dataset = nullptr;
    aclmdlDataset *output_dataset = nullptr;
    aclmdlDesc *model_desc = nullptr;

    aclError ret = aclmdlLoadFromFile(model_path, &model_id);
    if (ret != ACL_SUCCESS) {
        printf("Model load failed: %d\n", ret);
        return -1;
    }

    // 获取模型描述信息
    model_desc = aclmdlGetDesc(model_id);

    // 准备输入数据集
    input_dataset = aclmdlGetDataset(model_id, ACL_MODEL_INPUT);
    size_t input_size = aclmdlGetInputSizeByIndex(model_desc, 0);
    ret = aclmdlSetDatasetBuffer(input_dataset, 0, input);
    if (ret != ACL_SUCCESS) {
        printf("Set input buffer failed: %d\n", ret);
        return -1;
    }

    // 准备输出数据集
    output_dataset = aclmdlGetDataset(model_id, ACL_MODEL_OUTPUT);
    ret = aclmdlSetDatasetBuffer(output_dataset, 0, output);
    if (ret != ACL_SUCCESS) {
        printf("Set output buffer failed: %d\n", ret);
        return -1;
    }

    // 执行模型(同步接口,内部异步执行)
    ret = aclmdlExecute(model_id, input_dataset, output_dataset);
    if (ret != ACL_SUCCESS) {
        printf("Model execute failed: %d\n", ret);
        return -1;
    }

    // 卸载模型
    aclmdlUnload(model_id);
    aclmdlDestroyDataset(input_dataset);
    aclmdlDestroyDataset(output_dataset);
    aclmdlDestroyDesc(model_desc);

    return 0;
}

算子入队与异步执行

ascend-run 的算子执行采用异步入队、延迟同步的模式。当一个算子被提交到 Stream 时,aclrt 立即返回,而不是等待算子实际执行完成。这种异步执行模型是昇腾 NPU 编程中提升吞吐量的关键——Host 侧可以在算子在设备上执行的同时,准备下一个算子的输入数据或执行其他计算任务。

算子入队的具体过程是这样的:开发者首先准备好算子的输入输出内存地址,然后调用 aclopExecute 或相关的高级 API 将算子提交到 Stream。ascend-run 在接收到入队请求后,会做一次快速的参数校验,然后将算子任务打包为硬件指令写入对应的 Stream 队列。AICORE 硬件会从队列中取出指令并执行,整个过程完全独立于 Host CPU 的控制。

这种模型下,如果不做额外的同步控制,Host 侧在算子入队后会立即继续执行。当 Host 侧尝试读取算子输出数据时,设备上的算子可能尚未完成,此时读取到的数据就是无效的。因此,开发者必须使用同步机制来确保数据可用性。

#include "acl/acl.h"

// 异步执行算子的典型模式
int async_operator_execution(aclrtStream stream) {
    // 1. 在 Device 上分配输入和输出内存
    void *d_input = nullptr;
    void *d_output = nullptr;
    size_t input_size = 1024 * sizeof(float);
    size_t output_size = 512 * sizeof(float);

    aclError ret = aclrtMalloc(&d_input, input_size, ACL_MEM_MALLOC_NORMAL_ONLY);
    if (ret != ACL_SUCCESS) {
        printf("Allocate input memory failed\n");
        return -1;
    }

    ret = aclrtMalloc(&d_output, output_size, ACL_MEM_MALLOC_NORMAL_ONLY);
    if (ret != ACL_SUCCESS) {
        printf("Allocate output memory failed\n");
        aclrtFree(d_input);
        return -1;
    }

    // 2. 从 Host 拷贝数据到 Device(异步拷贝,提交到 stream)
    void *h_input = malloc(input_size);
    ret = aclrtMemcpy(d_input, input_size, h_input, input_size, 
                      ACL_MEMCPY_HOST_TO_DEVICE);
    // 异步拷贝会自动关联到当前 Stream

    // 3. 提交算子执行到 Stream(异步操作,立即返回)
    // 此处省略具体的算子执行 API 调用
    // aclopExecute(...); 

    printf("Operator enqueued asynchronously\n");

    // 4. 立即返回后,Host 可以做其他工作
    // do_other_work();

    // 5. 显式同步,等待 Stream 中所有操作完成
    ret = aclrtSynchronizeStream(stream);
    if (ret != ACL_SUCCESS) {
        printf("Stream synchronize failed: %d\n", ret);
        return -1;
    }

    printf("Operator execution completed\n");

    // 6. 现在可以安全地读取输出数据
    void *h_output = malloc(output_size);
    ret = aclrtMemcpy(h_output, output_size, d_output, output_size,
                      ACL_MEMCPY_DEVICE_TO_HOST);

    // 清理资源
    free(h_input);
    free(h_output);
    aclrtFree(d_input);
    aclrtFree(d_output);

    return 0;
}

Event 同步机制

Event 是 ascend-run 中实现精细化同步的核心工具。与 Stream 级别的 aclrtSynchronizeStream 不同,Event 允许开发者精确控制依赖关系——只有当特定算子完成后,才触发后续算子的执行。

Event 的使用场景主要包括三种:跨 Stream 同步(Stream A 中的算子完成后,Stream B 才开始执行)、精确计时(通过记录 Event 的完成时间来测量算子耗时)、以及条件触发(基于某个算子的完成状态来决定是否执行下一个计算步骤)。

#include "acl/acl.h"

int event_sync_demo() {
    // 初始化设备和 Stream
    aclrtSetDevice(0);

    aclrtStream stream_a, stream_b;
    aclrtStreamCreate(&stream_a);
    aclrtStreamCreate(&stream_b);

    aclrtEvent event_start, event_end;
    aclrtEventCreate(&event_start);
    aclrtEventCreate(&event_end);

    // 记录事件标记:Stream A 开始执行
    aclrtRecordEvent(event_start, stream_a);

    // Stream A 执行第一组算子
    // execute_ops_in_stream_a(stream_a);

    // Stream B 等待 Stream A 的 event 完成后才能继续
    aclrtStreamWaitEvent(stream_b, event_start);

    // 在 Stream B 中执行依赖于 Stream A 结果的算子
    // execute_ops_in_stream_b(stream_b);

    // Stream B 执行完后记录结束事件
    aclrtRecordEvent(event_end, stream_b);

    // 等待整个流程结束
    aclrtSynchronizeStream(stream_b);

    // 查询 Event 耗时
    float elapsed_ms = 0.0f;
    aclrtEventElapsedTime(&elapsed_ms, event_start, event_end);

    printf("Total execution time: %.3f ms\n", elapsed_ms);

    // 清理
    aclrtDestroyEvent(event_start);
    aclrtDestroyEvent(event_end);
    aclrtDestroyStream(stream_a);
    aclrtDestroyStream(stream_b);
    aclrtResetDevice(0);

    return 0;
}

在上述代码中,Stream B 通过 aclrtStreamWaitEvent 等待 Stream A 的 event_start 事件,这个机制实现了跨 Stream 的精确依赖控制。这种依赖控制是实现流水线并行的基础——通过将计算图划分为多个 Stage 并用 Event 串联,可以在不同的 Stream 上同时运行不同的计算 Stage,从而实现计算与数据拷贝的充分重叠。

内存管理:Device 内存、Host-Device 拷贝与内存池

Device 内存分配

ascend-run 提供了基于 aclrtMalloc 的 Device 内存分配接口。内存分配可以指定分配类型(ACL_MEM_MALLOC_NORMAL_ONLY 表示分配在 Device 的普通内存中,适合大规模数据;ACL_MEM_MALLOC_HUGE页面则尝试使用大页内存,减少 TLB miss)和分配策略(优先设备端还是可升降级)。

Device 内存分配有几个关键特点需要注意。首先,aclrtMalloc 是同步调用——分配操作本身会阻塞直到物理内存分配完成。其次,Device 内存的地址空间与 Host 完全不同,Device 指针不能直接在 Host 侧使用。第三,Device 内存分配失败(返回 ACL_ERROR_NO_MEM)通常意味着设备显存不足,此时需要考虑释放不再使用的内存或优化内存使用策略。

#include "acl/acl.h"

int memory_allocation_demo() {
    aclrtSetDevice(0);

    // 示例1: 标准内存分配
    void *buffer1 = nullptr;
    size_t size1 = 64 * 1024 * 1024;  // 64MB
    aclError ret = aclrtMalloc(&buffer1, size1, ACL_MEM_MALLOC_NORMAL_ONLY);
    if (ret != ACL_SUCCESS) {
        printf("Memory allocation failed with error: %d\n", ret);
        return -1;
    }
    printf("Allocated 64MB at device address: %p\n", buffer1);

    // 示例2: 使用大页内存分配(适合大 tensor 场景)
    void *buffer2 = nullptr;
    size_t size2 = 256 * 1024 * 1024;  // 256MB
    ret = aclrtMalloc(&buffer2, size2, ACL_MEM_MALLOC_HUGE页面);
    if (ret == ACL_SUCCESS) {
        printf("Allocated 256MB huge page buffer at: %p\n", buffer2);
    } else {
        printf("Huge page allocation failed, falling back: %d\n", ret);
        // 降级为普通分配
        ret = aclrtMalloc(&buffer2, size2, ACL_MEM_MALLOC_NORMAL_ONLY);
    }

    // 示例3: 查询当前设备的内存使用情况
    size_t total_mem = 0;
    size_t used_mem = 0;
    ret = aclrtGetMemInfo(ACL_DMEM, &total_mem, &used_mem);
    if (ret == ACL_SUCCESS) {
        printf("Device memory: total=%.2f GB, used=%.2f GB, free=%.2f GB\n",
               total_mem / 1024.0 / 1024.0 / 1024.0,
               used_mem / 1024.0 / 1024.0 / 1024.0,
               (total_mem - used_mem) / 1024.0 / 1024.0 / 1024.0);
    }

    // 释放内存
    aclrtFree(buffer1);
    aclrtFree(buffer2);
    aclrtResetDevice(0);

    return 0;
}

Host-Device 内存拷贝

ascend-run 提供了 aclrtMemcpy 函数来处理 Host 与 Device 之间的数据传输。aclrtMemcpy 是一个同步接口(除非指定了特殊的异步变体),在数据拷贝完成之前不会返回。根据拷贝方向的不同,有三种模式:ACL_MEMCPY_HOST_TO_DEVICE(Host 内存拷贝到 Device)、ACL_MEMCPY_DEVICE_TO_HOST(Device 内存拷贝到 Host)、以及 ACL_MEMCPY_DEVICE_TO_DEVICE(Device 内不同缓冲区之间的直接拷贝,后者通常通过 DMA 加速)。

在高性能场景中,Host-Device 拷贝往往是性能瓶颈之一。为了最大化带宽利用率,ascend-run 支持异步拷贝——通过将 aclrtMemcpy 提交到特定的 Stream 中,Host 可以在数据拷贝进行的同时执行其他计算任务。此外,使用 pinned memory(锁页内存,通过 aclrtMallocHost 分配)可以显著提升 Host-Device 之间的拷贝带宽,因为避免了操作系统层面的额外数据拷贝。

#include "acl/acl.h"

int memory_copy_demo() {
    aclrtSetDevice(0);

    const size_t data_size = 1024 * 1024 * sizeof(float);  // 4MB

    // 分配 pinned memory(锁页内存,Host 端)
    void *h_pinned_data = nullptr;
    aclrtMallocHost(&h_pinned_data, data_size);

    // 初始化数据
    float *data = (float *)h_pinned_data;
    for (size_t i = 0; i < 1024 * 1024; ++i) {
        data[i] = (float)i;
    }

    // 分配 Device 内存
    void *d_data = nullptr;
    aclrtMalloc(&d_data, data_size, ACL_MEM_MALLOC_NORMAL_ONLY);

    // 同步拷贝(直接等待完成)
    aclrtMemcpy(d_data, data_size, h_pinned_data, data_size,
                ACL_MEMCPY_HOST_TO_DEVICE);
    printf("Synchronous copy completed\n");

    // 异步拷贝(提交到 stream,不等待)
    aclrtStream stream;
    aclrtStreamCreate(&stream);

    void *h_pinned_result = nullptr;
    aclrtMallocHost(&h_pinned_result, data_size);

    // 将 Device 数据拷贝回 Host(关联到 stream)
    aclrtMemcpyAsync(h_pinned_result, data_size, d_data, data_size,
                     ACL_MEMCPY_DEVICE_TO_HOST, stream);

    // 同步 stream,等待异步拷贝完成
    aclrtSynchronizeStream(stream);
    printf("Asynchronous copy back completed\n");

    // 清理
    aclrtFreeHost(h_pinned_data);
    aclrtFreeHost(h_pinned_result);
    aclrtFree(d_data);
    aclrtDestroyStream(stream);
    aclrtResetDevice(0);

    return 0;
}

内存池机制

ascend-run 内置了一个内存池管理器,其核心思想是预分配一大块设备内存,然后按需切分给各个算子和数据缓冲区使用,以减少重复分配和释放带来的开销。内存池机制对于深度学习训练场景尤为重要,因为训练过程中需要反复分配和释放大量的临时 tensor 内存。

内存池的工作流程是:初始化阶段在 Device 上预分配一块较大的连续内存区域,后续的 aclrtMalloc 请求首先在内存池中查找空闲块,只有当内存池无法满足请求时,才会向底层驱动申请新的内存页。释放内存时,aclrtFree 将内存块归还到内存池而非直接释放给底层驱动,从而实现内存的复用。

对于追求极致性能的开发者,可以通过 aclrtSetKernelArgsPoolSize 等接口来调整内存池的参数,根据实际 workload 的内存需求特征来配置最优的预分配大小和分配策略。

Stream 与算子调度:队列机制与优先级

Default Stream 与自定义 Stream

ascend-run 在每个设备上默认提供一个 Default Stream。所有未显式指定 Stream 的算子执行和内存操作都会自动提交到 Default Stream。Default Stream 的优势在于使用便捷,无需额外的初始化代码;其劣势在于所有操作都挤在同一个队列中,难以实现真正的并行。

自定义 Stream 的价值在于隔离和并行化。通过创建多个独立的 Stream,可以将彼此没有数据依赖的计算任务分发到不同的队列中,让硬件真正并行执行。以下场景特别适合使用多 Stream:

  • 计算与数据预处理并行:将数据加载和预处理放在一个 Stream,计算放在另一个 Stream,通过 Event 实现同步。
  • 多模型并行推理:每个模型使用独立的 Stream,避免模型间的资源竞争。
  • 流水线并行:将模型的不同 Stage 分配到不同的 Stream,实现计算与通信的重叠。
#include "acl/acl.h"

int multi_stream_demo() {
    // 初始化
    aclrtSetDevice(0);

    // 创建多个 Stream 实现并行
    aclrtStream compute_stream;
    aclrtStream preprocess_stream;
    aclrtStream postprocess_stream;

    aclrtStreamCreate(&compute_stream);
    aclrtStreamCreate(&preprocess_stream);
    aclrtStreamCreate(&postprocess_stream);

    // Stream A: 数据预处理
    // execute_preprocess(preprocess_stream);

    // Stream B: 核心计算(等待预处理完成后开始)
    aclrtEvent preprocess_done;
    aclrtEventCreate(&preprocess_done);
    aclrtRecordEvent(preprocess_done, preprocess_stream);
    aclrtStreamWaitEvent(compute_stream, preprocess_done);

    // execute_compute(compute_stream);

    // Stream C: 后处理(等待计算完成后开始)
    aclrtEvent compute_done;
    aclrtEventCreate(&compute_done);
    aclrtRecordEvent(compute_done, compute_stream);
    aclrtStreamWaitEvent(postprocess_stream, compute_done);

    // execute_postprocess(postprocess_stream);

    // 等待所有 Stream 完成
    aclrtSynchronizeStream(compute_stream);
    aclrtSynchronizeStream(postprocess_stream);

    printf("Multi-stream pipeline completed\n");

    // 清理
    aclrtDestroyEvent(preprocess_done);
    aclrtDestroyEvent(compute_done);
    aclrtDestroyStream(compute_stream);
    aclrtDestroyStream(preprocess_stream);
    aclrtDestroyStream(postprocess_stream);
    aclrtResetDevice(0);

    return 0;
}

算子优先级与队列调度策略

ascend-run 的 Stream 内部维护了一个任务队列,队列中的任务按照 FIFO(先进先出)策略顺序执行。在基本的 FIFO 策略之上,ascend-run 还支持算子优先级机制。高优先级的算子在入队时会被插入到队列中比低优先级算子更靠前的位置,从而获得更快的调度机会。

需要注意的是,算子优先级只在同一个 Stream 内部有效——不同 Stream 之间的调度由硬件层面的多个独立的硬件队列(TE Queue)负责,优先级无法跨 Stream 生效。在实际使用中,优先级机制主要应用于实时推理场景,将时延敏感的关键路径算子设置为高优先级,而批量推理的后台任务设置为低优先级。

#include "acl/acl.h"

// 设置 Stream 中算子优先级的示例
int priority_demo() {
    aclrtSetDevice(0);

    aclrtStream high_priority_stream;
    aclrtStream low_priority_stream;

    // 创建高优先级 Stream
    aclrtStreamAttr stream_attr_high;
    stream_attr_high.priority = 0;  // 数值越小优先级越高
    aclrtStreamCreateWithAttribute(&high_priority_stream, &stream_attr_high);

    // 创建低优先级 Stream
    aclrtStreamAttr stream_attr_low;
    stream_attr_low.priority = 10;
    aclrtStreamCreateWithAttribute(&low_priority_stream, &stream_attr_low);

    // 高优先级 Stream 处理推理请求
    // execute_realtime_inference(high_priority_stream);

    // 低优先级 Stream 处理批量训练任务
    // execute_batch_training(low_priority_stream);

    aclrtDestroyStream(high_priority_stream);
    aclrtDestroyStream(low_priority_stream);
    aclrtResetDevice(0);

    return 0;
}

ascend-run 的调度策略还涉及到一个重要的概念:队列积压(Queue Backlog)。当 Stream 队列中积累了大量未执行的任务时,Host 侧继续往队列中提交任务的开销会增加,因为底层需要维护更复杂的队列状态。因此,在高吞吐量的批处理场景中,建议监控队列深度,避免过度积压导致的调度效率下降。

运行时 Profiling:msprof 集成与性能分析

msprof 集成

msprof 是 CANN 提供的官方性能分析工具,它可以与 ascend-run 无缝集成,对算子执行、内存访问、Stream 调度等关键路径进行全方位的性能分析。启用 msprof 非常简单,只需在环境变量中设置相关的 profiling 参数,然后在代码中插入 profiling 控制 API 即可。

msprof 支持两种采集模式:实时采集事后分析。实时采集在程序运行过程中持续收集性能数据,适用于短时任务或调试阶段。事后分析则是在程序运行完成后,通过导入之前保存的性能数据文件来进行深度分析,适用于长时训练任务或生产环境的性能调优。

#include "acl/acl.h"
#include "acl/acl_profiling.h"

int profiling_demo() {
    // 初始化
    aclrtSetDevice(0);

    // 方式1: 通过环境变量启用 profiling(运行时设置)
    // export ASCEND_PROFILING_ENABLE=1
    // export ASCEND_PROFILING_OPTIONS="output:/path/to/profiling_result"

    // 方式2: 通过 API 手动控制 profiling 采集范围
    aclError ret = aclprofInit(0);
    if (ret != ACL_SUCCESS) {
        printf("aclprofInit failed: %d\n", ret);
    }

    aclprofWorkspace *workspace = nullptr;
    aclprofCreateWorkspace(&workspace, "/path/to/output");

    uint32_t options = ACL_PROF_OP_STAT | 
                       ACL_PROF_MEMORY_BANDWIDTH |
                       ACL_PROF_STREAM_SCHEDULE;
    aclprofSetOptions(workspace, options);

    aclprofStart(workspace);

    aclrtStream stream;
    aclrtStreamCreate(&stream);
    // execute_operators(stream);
    aclrtSynchronizeStream(stream);

    aclprofStop(workspace);
    aclprofDumpReport(workspace);
    aclprofDestroyWorkspace(workspace);

    printf("Profiling completed.\n");
    aclrtDestroyStream(stream);
    aclrtResetDevice(0);
    return 0;
}

算子耗时分析与内存带宽分析

msprof 输出的报告中,最核心的两部分是算子耗时分析和内存带宽分析。算子耗时分析以时间线(timeline)的形式展示每个算子的开始时间、结束时间和持续时长,通过颜色编码区分不同的算子类型。开发者可以清晰地看到哪些算子是性能瓶颈——这些算子的时间条通常最长或者在关键路径上。

内存带宽分析则展示每个算子的 HBM(High Bandwidth Memory)读写量、带宽利用率以及与 AICORE 计算单元的比率。如果某个算子的内存带宽利用率接近理论上限,说明它是**内存带宽受限(Memory Bound)的,此时优化方向应该是减少数据搬运或提高数据复用率。如果计算单元利用率低但带宽充足,则说明是计算受限(Compute Bound)**的,应该从算法层面或算子实现层面进行优化。

两个关键陷阱与解决方案

陷阱一:算子执行顺序依赖未正确设置 Event 导致结果错误

这是 ascend-run 使用中最容易出错、也最难调试的一类问题。问题的本质在于:开发者错误地假设算子按照代码中的调用顺序同步执行,而忽视了 ascend-run 的异步入队模型。当两个算子之间存在数据依赖关系(第二个算子的输入是第一个算子的输出),但没有通过 Event 或 Stream 同步来保证执行顺序时,就会出现未定义行为——第二个算子可能在第一个算子完成之前就开始执行,读到的输入数据是无效的中间状态。

一个典型的错误场景如下:开发者首先调用 aclrtMemcpy 将数据拷贝到 Device,然后立即提交一个使用该数据的算子执行,但忘记了调用 aclrtSynchronizeStream 等待拷贝完成。由于拷贝是异步的,算子可能在数据尚未就绪时就开始执行,最终产生完全错误的计算结果。

#include "acl/acl.h"

// 错误示例:缺少同步导致的数据竞争
int wrong_sync_example() {
    aclrtSetDevice(0);
    aclrtStream stream;
    aclrtStreamCreate(&stream);

    void *d_input = nullptr;
    void *d_output = nullptr;
    const size_t size = 1024 * sizeof(float);

    aclrtMalloc(&d_input, size, ACL_MEM_MALLOC_NORMAL_ONLY);
    aclrtMalloc(&d_output, size, ACL_MEM_MALLOC_NORMAL_ONLY);

    float h_data[1024] = {1.0f};
    // 错误:直接使用同步拷贝的返回值判断是否完成是错误的
    // 实际上 aclrtMemcpy 是同步的,这里只是为了说明 Event 的必要性

    // 创建依赖链 Event
    aclrtEvent copy_done, compute_done;
    aclrtEventCreate(&copy_done);
    aclrtEventCreate(&compute_done);

    // 第一步:异步拷贝数据到 Device
    aclrtMemcpyAsync(d_input, size, h_data, size,
                     ACL_MEMCPY_HOST_TO_DEVICE, stream);
    aclrtRecordEvent(copy_done, stream);  // 标记拷贝完成点

    // 第二步:等待拷贝完成后执行算子
    aclrtStreamWaitEvent(stream, copy_done);  // 关键:等待拷贝完成
    // execute_compute_operator(stream, d_input, d_output);
    aclrtRecordEvent(compute_done, stream);

    // 第三步:等待算子完成后将结果拷贝回 Host
    aclrtStreamWaitEvent(stream, compute_done);  // 关键:等待算子完成
    float h_result[1024];
    aclrtMemcpy(h_result, size, d_output, size, 
                ACL_MEMCPY_DEVICE_TO_HOST);

    printf("Result verification: first element = %.2f\n", h_result[0]);

    // 清理
    aclrtDestroyEvent(copy_done);
    aclrtDestroyEvent(compute_done);
    aclrtFree(d_input);
    aclrtFree(d_output);
    aclrtDestroyStream(stream);
    aclrtResetDevice(0);

    return 0;
}

解决方案的核心原则是:任何存在数据依赖关系的操作之间,必须通过 Event 或 Stream 同步来确保执行顺序。具体而言,有三种推荐的同步策略。第一种是粗粒度同步:在每次数据拷贝或算子执行后立即调用 aclrtSynchronizeStream,适用于简单的线性流程。第二种是基于 Event 的细粒度同步:在关键依赖点创建和等待 Event,适用于多 Stream 并行的复杂场景。第三种是同步拷贝替代异步拷贝:将 aclrtMemcpyAsync 替换为同步的 aclrtMemcpy,简化同步逻辑但牺牲一定的性能。

陷阱二:内存泄漏未及时释放导致 Device 显存耗尽

在长时间运行的服务进程(如模型推理服务器)中,内存泄漏是最具破坏性的问题之一。由于 Device 显存总量有限(通常为 8GB 到 64GB),即使每次泄漏的内存量很小,经过大量请求的累积后,显存也会被耗尽,导致后续请求分配内存失败或程序崩溃。

ascend-run 的内存泄漏问题通常发生在以下几个环节:模型加载后未正确卸载(aclmdlUnload)、临时缓冲区分配后未释放(aclrtFree)、错误处理路径中遗漏了资源清理逻辑、以及在循环中反复创建 Stream 或 Event 但没有销毁。

#include "acl/acl.h"
#include "acl/acl_mdl.h"

// 健壮的内存管理模式:统一资源管理 + 错误处理路径完整清理
typedef struct {
    aclrtStream stream;
    aclrtEvent event;
    void *d_workspace;
    void *d_weights;
    void *d_input;
    void *d_output;
    uint32_t model_id;
} RuntimeContext;

int runtime_context_init(RuntimeContext *ctx, const char *model_path) {
    if (!ctx) return -1;
    memset(ctx, 0, sizeof(RuntimeContext));

    // 初始化 Runtime
    aclError ret = aclInit(nullptr);
    if (ret != ACL_SUCCESS) return -1;

    ret = aclrtSetDevice(0);
    if (ret != ACL_SUCCESS) {
        aclFinalize();
        return -1;
    }

    // 创建 Stream
    ret = aclrtCreateStream(&ctx->stream);
    if (ret != ACL_SUCCESS) {
        aclrtResetDevice(0);
        aclFinalize();
        return -1;
    }

    // 创建 Event
    ret = aclrtCreateEvent(&ctx->event);
    if (ret != ACL_SUCCESS) {
        aclrtDestroyStream(ctx->stream);
        aclrtResetDevice(0);
        aclFinalize();
        return -1;
    }

    // 分配工作内存
    const size_t workspace_size = 64 * 1024 * 1024;
    const size_t weight_size = 128 * 1024 * 1024;
    const size_t io_size = 16 * 1024 * 1024;

    ret = aclrtMalloc(&ctx->d_workspace, workspace_size, 
                      ACL_MEM_MALLOC_NORMAL_ONLY);
    if (ret != ACL_SUCCESS) goto cleanup;

    ret = aclrtMalloc(&ctx->d_weights, weight_size, 
                      ACL_MEM_MALLOC_NORMAL_ONLY);
    if (ret != ACL_SUCCESS) goto cleanup;

    ret = aclrtMalloc(&ctx->d_input, io_size, 
                      ACL_MEM_MALLOC_NORMAL_ONLY);
    if (ret != ACL_SUCCESS) goto cleanup;

    ret = aclrtMalloc(&ctx->d_output, io_size, 
                      ACL_MEM_MALLOC_NORMAL_ONLY);
    if (ret != ACL_SUCCESS) goto cleanup;

    // 加载模型
    ret = aclmdlLoadFromFile(model_path, &ctx->model_id);
    if (ret != ACL_SUCCESS) goto cleanup;

    printf("RuntimeContext initialized successfully\n");
    return 0;

cleanup:
    // 统一的错误清理路径:按创建的反序释放所有资源
    if (ctx->d_output) aclrtFree(ctx->d_output);
    if (ctx->d_input)  aclrtFree(ctx->d_input);
    if (ctx->d_weights) aclrtFree(ctx->d_weights);
    if (ctx->d_workspace) aclrtFree(ctx->d_workspace);
    if (ctx->event)   aclrtDestroyEvent(ctx->event);
    if (ctx->stream)   aclrtDestroyStream(ctx->stream);
    aclrtResetDevice(0);
    aclFinalize();
    return -1;
}

void runtime_context_destroy(RuntimeContext *ctx) {
    if (!ctx) return;

    // 等待所有操作完成后再释放
    if (ctx->stream) {
        aclrtSynchronizeStream(ctx->stream);
    }

    if (ctx->model_id != 0) {
        aclmdlUnload(ctx->model_id);
    }

    if (ctx->d_output)  aclrtFree(ctx->d_output);
    if (ctx->d_input)   aclrtFree(ctx->d_input);
    if (ctx->d_weights)  aclrtFree(ctx->d_weights);
    if (ctx->d_workspace) aclrtFree(ctx->d_workspace);
    if (ctx->event)      aclrtDestroyEvent(ctx->event);
    if (ctx->stream)     aclrtDestroyStream(ctx->stream);

    aclrtResetDevice(0);
    aclFinalize();

    printf("RuntimeContext destroyed, all resources freed\n");
}

解决内存泄漏的最佳实践包括以下几个方面。首先,使用 RAII(资源获取即初始化)风格的封装,将每个资源包装为一个结构体,在析构函数中自动释放资源。其次,在所有错误处理路径中统一清理资源,不要在成功路径和错误路径中分别写清理代码,而是使用 goto cleanup 或 try-catch 模式将所有清理逻辑集中在一起。第三,定期检查设备内存使用情况,在推理服务中添加内存监控逻辑,当显存使用率超过阈值时触发告警或自动清理。第四,避免在循环中反复创建和销毁 Stream/Event,它们应该在初始化阶段创建、在销毁阶段释放。

#include "acl/acl.h"

// 内存泄漏检测脚本(可集成到推理服务中)
int memory_leak_check() {
    aclrtSetDevice(0);

    // 查询初始内存状态
    size_t baseline_total = 0, baseline_used = 0;
    aclrtGetMemInfo(ACL_DMEM, &baseline_total, &baseline_used);
    printf("Baseline: total=%.2f GB, used=%.2f GB\n",
           baseline_total / 1024.0 / 1024.0 / 1024.0,
           baseline_used / 1024.0 / 1024.0 / 1024.0);

    // 模拟多次模型加载-卸载循环
    const int iterations = 10;
    for (int i = 0; i < iterations; ++i) {
        uint32_t model_id = 0;
        // 加载模型
        // aclmdlLoadFromFile("model.om", &model_id);
        // 执行推理
        // aclmdlExecute(model_id, input, output);
        // 卸载模型
        // aclmdlUnload(model_id);
    }

    // 同步设备,确保所有操作完成
    aclrtDeviceSynchronize();

    // 查询循环后内存状态
    size_t final_total = 0, final_used = 0;
    aclrtGetMemInfo(ACL_DMEM, &final_total, &final_used);

    size_t leaked = final_used - baseline_used;
    printf("After %d iterations: used=%.2f GB, leaked=%.2f MB\n",
           iterations,
           final_used / 1024.0 / 1024.0 / 1024.0,
           leaked / 1024.0 / 1024.0);

    if (leaked > 10 * 1024 * 1024) {  // 泄漏超过 10MB 则告警
        printf("WARNING: Possible memory leak detected!\n");
        return -1;
    }

    printf("Memory check passed\n");
    aclrtResetDevice(0);

    return 0;
}

实战代码:完整初始化与执行脚本

以下是几个实战中最常用的完整代码模板,涵盖从初始化到执行再到 Profiling 的完整链路。

// 实战代码1: 生产级 Runtime 初始化脚本(健壮版)
#include "acl/acl.h"
#include <stdio.h>
#include <stdlib.h>

int robust_runtime_init() {
    const char *config_path = getenv("ASCEND_CONFIG_PATH");
    aclError ret = aclInit(config_path);
    if (ret != ACL_SUCCESS) { printf("FATAL: aclInit failed %d\n", ret); return -1; }

    int device_count = 0;
    ret = aclrtGetDeviceCount(&device_count);
    if (ret != ACL_SUCCESS || device_count == 0) { aclFinalize(); return -1; }

    int device_id = 0;
    const char *env = getenv("ASCEND_DEVICE_ID");
    if (env) device_id = atoi(env);
    if (device_id >= device_count) device_id = 0;

    ret = aclrtSetDevice(device_id);
    if (ret != ACL_SUCCESS) { aclFinalize(); return -1; }

    aclrtDeviceProp prop;
    aclrtGetDeviceProperties(&prop, device_id);
    printf("Device %d: %s, %.2f GB memory\n",
           device_id, prop.name, prop.dram_size / 1024.0 / 1024.0 / 1024.0);

    aclrtStream stream;
    ret = aclrtCreateStream(&stream);
    if (ret != ACL_SUCCESS) { printf("Stream create failed\n"); return -1; }
    printf("Runtime initialized on device %d\n", device_id);
    return 0;
}
// 实战代码2: 模型推理完整调用流程 + 批量推理引擎
#include "acl/acl.h"
#include "acl/acl_mdl.h"

#define MAX_BATCH_SIZE 32

typedef struct {
    aclrtStream stream;
    uint32_t model_id;
    void *d_input;
    void *d_output;
} BatchContext;

int inference_pipeline(const char *model_path, float *input_data, float *output_data,
                       size_t input_size, size_t output_size) {
    aclrtSetDevice(0);
    uint32_t model_id = 0;
    aclError ret = aclmdlLoadFromFile(model_path, &model_id);
    if (ret != ACL_SUCCESS) return -1;

    aclmdlDesc *desc = aclmdlGetDesc(model_id);
    printf("Model: %zu inputs, %zu outputs\n",
           aclmdlGetNumInputs(desc), aclmdlGetNumOutputs(desc));

    void *d_in = nullptr, *d_out = nullptr;
    aclrtMalloc(&d_in, input_size, ACL_MEM_MALLOC_NORMAL_ONLY);
    aclrtMalloc(&d_out, output_size, ACL_MEM_MALLOC_NORMAL_ONLY);
    aclrtMemcpy(d_in, input_size, input_data, input_size, ACL_MEMCPY_HOST_TO_DEVICE);

    aclmdlDataset *in_ds = aclmdlCreateDataset();
    aclmdlDataset *out_ds = aclmdlCreateDataset();
    aclmdlAddDatasetBuffer(in_ds, aclCreateDataBuffer(d_in, input_size));
    aclmdlAddDatasetBuffer(out_ds, aclCreateDataBuffer(d_out, output_size));

    ret = aclmdlExecute(model_id, in_ds, out_ds);
    aclrtDeviceSynchronize();
    aclrtMemcpy(output_data, output_size, d_out, output_size, ACL_MEMCPY_DEVICE_TO_HOST);

    aclDestroyDataBuffer(aclmdlGetDatasetBuffer(in_ds, 0));
    aclDestroyDataBuffer(aclmdlGetDatasetBuffer(out_ds, 0));
    aclmdlDestroyDataset(in_ds); aclmdlDestroyDataset(out_ds);
    aclrtFree(d_in); aclrtFree(d_out);
    aclmdlUnload(model_id); aclmdlDestroyDesc(desc);
    return 0;
}

int batch_engine_init(BatchContext *ctx, int device_id, const char *model_path) {
    aclrtSetDevice(device_id);
    aclrtCreateStream(&ctx->stream);
    aclmdlLoadFromFile(model_path, &ctx->model_id);
    size_t buf = MAX_BATCH_SIZE * 1024 * sizeof(float);
    aclrtMalloc(&ctx->d_input, buf, ACL_MEM_MALLOC_NORMAL_ONLY);
    aclrtMalloc(&ctx->d_output, buf, ACL_MEM_MALLOC_NORMAL_ONLY);
    printf("BatchContext on device %d\n", device_id); return 0;
}

void batch_engine_submit(BatchContext *ctx, float *in, float *out, size_t sz) {
    aclrtMemcpyAsync(ctx->d_input, sz, in, sz, ACL_MEMCPY_HOST_TO_DEVICE, ctx->stream);
    // aclmdlExecute(ctx->model_id, ...);
    aclrtMemcpyAsync(out, sz, ctx->d_output, sz, ACL_MEMCPY_DEVICE_TO_HOST, ctx->stream);
}

void batch_engine_destroy(BatchContext *ctx, int device_id) {
    aclrtSynchronizeStream(ctx->stream);
    aclrtFree(ctx->d_input); aclrtFree(ctx->d_output);
    aclmdlUnload(ctx->model_id); aclrtDestroyStream(ctx->stream); aclrtResetDevice(device_id);
}

结尾:Runtime 调度优化与进阶学习

通过对 ascend-run 运行时体系的全面梳理,我们可以看到,算子执行效率的提升不仅仅依赖于单个算子的实现优化,更需要在 Runtime 层面进行系统性的调度优化。以下是几个关键的优化方向供读者在实践中参考。

Stream 数量的合理规划是第一步。过少的 Stream 无法充分利用硬件的并行能力,过多的 Stream 则会增加调度开销。对于大多数场景,建议将 Stream 数量设置为设备数量的 2-4 倍,并结合 Profiling 数据进行调优。

Event 同步的精细化控制是第二个关键。避免使用全量的 Stream Synchronize,改用细粒度的 Event 等待,可以让硬件在等待期间继续执行其他不依赖该 Event 的任务。特别是在流水线场景中,精确的 Event 同步可以实现计算与通信的完美重叠。

内存分配的优化策略包括预分配和复用两个方面。对于已知大小的反复使用的缓冲区,应该在初始化阶段一次性分配并持续复用,避免在关键路径上频繁调用 malloc/free。对于大小变化较大的场景,可以使用内存池来减少分配开销。

Profiling 驱动的迭代优化是性能调优的核心方法论。建议在开发的每个阶段都进行 Profiling 分析,而非等到最后才做全面优化。msprof 的 timeline 视图能够直观地展示算子间的空闲时间和依赖瓶颈,是定位调度低效的利器。

深入学习 ascend-run 的最佳途径是结合官方源码和实际项目。推荐读者访问 ascend-run 的官方代码仓库,获取最新的 API 文档和最佳实践案例。

https://atomgit.com/cann/ascend-run

通过阅读源码理解其内部实现,结合本文介绍的核心概念和实战技巧,相信读者能够快速掌握 ascend-run 的使用精髓,在昇腾 NPU 上开发出高性能、高可靠的 AI 应用。运行时调度的优化是一个持续迭代的过程,只有在深入理解底层机制的基础上,才能做出最有效的优化决策。祝各位开发者在昇腾平台上探索出令人惊叹的性能突破。

Logo

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

更多推荐