想搞清楚 runtime 和 driver 的边界,不妨从 CANN 软件栈的调用链路入手。用户代码调用 NPU 算子的那一刻,函数调用会沿着 CANN 的五层架构逐层下传:框架层(PyTorch Plugin / TensorFlow Plugin)→ 计算服务层(AOL 算子库,如 ops-nn、hccl 等)→ 计算编译层(GE 图引擎、TBE 算子编译器)→ 计算执行层(Runtime,aclrt 等 API)→ 硬件抽象层(Driver,内核态驱动管理)。Runtime 和 Driver 占据了链路的最底两层,决定了 NPU 的硬件资源如何被软件实际使用。本文聚焦 CANN Runtime 和 CANN Driver 两个组件之间的职责边界和协作机制,帮你在调试昇腾 NPU 底层问题时有一个清晰的排查方向。

Runtime 和 Driver 的分工可以用操作系统里用户态库和内核态驱动的类比来理解。Runtime 运行在用户空间,向上为 GE 图引擎和 AOL 算子提供标准化的设备管理接口,向下通过 ioctl 或者类似 syscall 的机制调用 Driver。Driver 运行在内核空间,负责直接操作 NPU 硬件寄存器、管理物理内存页、处理中断和异常。两者之间的调用关系是从用户空间到内核空间的一次上下文切换,上下文切换有固定开销,所以 Runtime 在设计时尽量减少 ioctl 的调用频率——能把多次操作合并在一次系统调用里的就合并。

第二章 Runtime 的职责边界

Runtime(运行时库,库名 libascend_hal.so 或 aclrt)向上提供统一的设备管理、内存管理、任务调度 API。它是 GE 图引擎和驱动之间的中间层,把复杂的内核态调用封装成简洁的 C 接口,并且负责做调用参数校验和错误传播。

设备管理是 Runtime 的最基础功能。aclrtSetDevice 和 aclrtResetDevice 分别负责加载和卸载设备上下文,设备上下文中包含了一个 NPU 设备的完整运行状态:HBM 内存分配表、任务队列、Stream 调度信息、事件同步机制等。在设备管理层面,Runtime 还提供设备查询接口(aclrtGetDevice、aclrtGetDeviceCount),让上层能感知到底层硬件的数量、型号和容量。

内存管理是 Runtime 的核心能力。aclrtMalloc 在 NPU 的 HBM 上分配内存,aclrtFree 释放。aclrtMemcpy 在 Host 内存和 Device 内存之间搬运数据。内存管理的设计有几个技术细节:HBM 内存分配不是简单的 malloc,Runtime 内部维护了 HBM 的物理内存页映射表,每次分配都需要查表找到连续的物理页并更新映射表。大块连续分配失败的常见原因就是物理页碎片化。

任务调度是 Runtime 体现异步设计的地方。aclrtLaunchKernel 把 Kernel 提交到任务队列之后立刻返回,Kernel 的实际执行在 NPU 设备上异步跑。任务队列按 Stream 组织,同一个 Stream 上的 Kernel 串行执行,不同 Stream 上的 Kernel 可以并行执行。Runtime 在提交任务时记录每个 Kernel 对内存区域的使用信息,用来做硬件调度优化。

事件机制是 Runtime 提供的同步手段。aclrtCreateEvent 创建一个事件,aclrtRecordEvent 在 Stream 中插入事件记录点,aclrtStreamWaitEvent 等待指定事件完成后才继续执行。事件机制的底层实现是在任务队列里插入一个标记指令,设备执行到该指令时向主机写一个完成信号。

下面看一段 Runtime 的典型使用代码,理解 aclrt 的 API 调用模式。

// Runtime层的设备管理+内存分配+Kernel执行(aclrt API)
#include "acl/acl.h"

int runtime_example() {
    // WHY: aclInit必须在任何其他aclrt调用之前执行
    // 它在内部做三件事:加载libascend_hal.so、初始化HBM管理器、
    // 向driver注册当前进程的设备使用权
    aclError ret = aclInit(NULL);
    
    // WHY: aclrtSetDevice加载设备上下文
    // 设备上下文包含HBM页表、Stream队列等设备状态
    // 不加载上下文中所有的内存和流操作都会返回错误
    ret = aclrtSetDevice(0);
    
    // WHY: aclrtMalloc在HBM上分配物理连续的内存
    // 不同于Host上的malloc,这里每次分配都需要通过driver查HBM页表
    void* dev_ptr = NULL;
    size_t size = 64 * 1024 * 1024;  // 64MB
    ret = aclrtMalloc(&dev_ptr, size, ACL_MEM_MALLOC_HUGE_FIRST);
    
    // WHY: aclrtMemcpy走device端的DMA引擎搬运数据
    // 拷贝方向由ACL_MEMCPY_HOST_TO_DEVICE指定
    // runtime层不直接操作DMA寄存器,最终还是通过ioctl走driver
    ret = aclrtMemcpy(dev_ptr, size, host_data, size,
                      ACL_MEMCPY_HOST_TO_DEVICE);
    
    // 创建stream用于异步任务调度
    aclrtStream stream;
    ret = aclrtCreateStream(&stream);
    
    // 提交kernel到stream(异步,立即返回)
    ret = aclrtLaunchKernel(kernel_func, dims, stream,
                            dev_ptr, size);
    
    // 同步等待stream上的所有任务完成
    ret = aclrtSynchronizeStream(stream);
    
    // 清理
    aclrtFree(dev_ptr);
    aclrtDestroyStream(stream);
    ret = aclrtResetDevice(0);
    aclFinalize();
    return 0;
}

观察这段代码中的错误处理逻辑:每个 aclrt 调用之后都检查返回的 aclError,这是生产代码中的硬性要求。Runtime 的内部状态是精细的状态机,一个未处理的错误可能导致后续调用在不确定的状态下执行,最严重的情况是导致 NPU 设备进入不可恢复的故障模式,需要 reset 整个设备上下文才能恢复。ACL_MEM_MALLOC_HUGE_FIRST 标志提示 Runtime 优先分配大页内存,大页有更少的 TLB 缓存缺失和更低的页表遍历开销,对 Kernel 内的内存访问延迟有正面影响。

Runtime 还承担了一项看似简单但直接影响可靠性的职责:参数校验。一个 Kernel 在提交到设备队列之前,Runtime 会检查所有参数的有效性:地址是否在有效区间内、维度是否合法、数据类型是否匹配。这些校验在用户空间完成,不触发内核态切换。如果校验失败,Runtime 直接返回错误码而不调用 Driver,避免了无效的内核态调用。

第三章 Driver 的职责边界

Driver(内核态驱动,.ko 模块)运行在内核空间,是唯一能直接读写的硬件寄存器的软件组件。它负责管理 NPU 设备的物理资源:设备加电和初始化、PCIe BAR 空间的映射和访问、物理内存页的分配和回收、中断注册和处理、硬件异常恢复。

设备初始化的过程从系统启动阶段开始。驱动加载时,Driver 会扫描 PCIe 总线找到 NPU 设备,读取设备配置空间里的厂商 ID、设备 ID、BAR 地址、中断向量等信息。然后驱动通过写 PCIe 配置寄存器使能设备,把 BAR 基地址映射到内核虚拟地址空间。接下来驱动初始化 NPU 芯片内部的各模块:AI Core 集群、Vector Core 单元、Cube 计算单元、HBM 控制器、片间互联 SERDES 通道。整个初始化过程从驱动加载到 NPU 设备可用,可能持续数秒。

内存管理在 Driver 层面的粒度是物理页,通常为 4KB 或更大的大页。Driver 维护一张 HBM 物理页分配表,记录每页的状态(空闲、已分配、预留)。当 Runtime 通过 ioctl 请求内存分配时,Driver 在页表里寻找足够的连续空闲页,标记为已分配并锁页,返回物理地址给 Runtime。大页分配和 4KB 页分配走不同的页表,大页的页表条目数少但每页更大,适合大块连续内存的分配场景。

中断处理是 Driver 的另一项核心能力。NPU 硬件会在多种事件上产生中断:Kernel 执行完成、DMA 传输完成、任务异常(如越界访问、数值溢出)、硬件错误(如 HBM ECC 错误)。Driver 的中断服务程序(ISR)先做最轻量的处理——读硬件状态寄存器记下中断来源,然后调度一个底半部处理交上来的事件。底半部里会把中断事件转换成 Runtime 可以理解的通知,通过事件队列或直接写内存的方式通知 Runtime 相关 Stream 上的任务完成了。

异常恢复是一个容易被忽视但至关重要的 Driver 能力。HBM 发生 ECC 错误导致数据损坏时,Driver 需要判断这个错误是不是可纠正的(single-bit ECC)还是不可纠正的(multi-bit ECC),可纠正的自动修复并记日志,不可纠正的需要标记对应物理页为 bad page 并通知上层处理。Kernel 运行异常(例如死循环触发看门狗超时)时,Driver 需要强行中断 Kernel 执行并释放硬件资源。

下面是一段概念性的 Driver 调用示例,展示 Runtime 是如何通过 ioctl 访问 Driver 的。

// Driver层概念代码:Runtime通过ioctl调用Driver分配HBM内存
// 注意:这段代码在Driver的kernel空间运行,应用层不能直接调用
long hbm_alloc_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) {
    struct npu_dev *dev = filp->private_data;
    struct hbm_alloc_req req;
    
    // WHY: copy_from_user把用户空间的请求参数拷到内核空间
    // 内核空间不能直接访问用户空间的指针
    if (copy_from_user(&req, (void __user *)arg, sizeof(req)))
        return -EFAULT;
    
    // WHY: mutex_lock保护页表的并发访问
    // 多个进程可能同时请求HBM分配,不加锁会破坏页表状态
    mutex_lock(&dev->hbm_lock);
    
    // WHY: 在HBM物理页表中查找连续空闲页
    // 大页分配从大页池找,4KB页分配从4KB页池找
    phys_addr_t pa = hbm_find_contig_pages(&dev->hbm, req.size, req.flags);
    if (pa == 0) {
        mutex_unlock(&dev->hbm_lock);
        return -ENOMEM;
    }
    
    // WHY: 标记页为已分配并建立进程虚拟地址到物理地址的映射
    hbm_mark_pages_used(&dev->hbm, pa, req.size);
    mmap_hbm_pages_to_user(filp, pa, req.size, &req.user_va);
    
    mutex_unlock(&dev->hbm_lock);
    
    // WHY: copy_to_user把分配结果(物理地址)返回给Runtime
    if (copy_to_user((void __user *)arg, &req, sizeof(req)))
        return -EFAULT;
    
    return 0;
}

这段内核态代码揭示了 Runtime 和 Driver 之间的一条关键约定:Runtime 只传请求大小和标志,Driver 负责在物理页表里找到真的内存。这个设计的深层价值在于隔离了上层(Runtime、GE、AOL 等)对硬件物理拓扑的依赖。上层代码不需要知道 HBM 的内存通道分布、页碎片状态、或者某个物理地址是否被其他进程占用——所有这些都封装在 Driver 的页表管理器里。如果硬件升级了 HBM 容量或者改变了内存通道拓扑,只有 Driver 需要更新,Runtime 及其上层的所有代码完全不受影响。

第四章 Runtime 与 Driver 的协作机制

两者的协作可以抽象成一种请求-响应的协议模式。Runtime 发起 ioctl 调用进入内核态,Driver 在内核态处理请求,处理完成后返回结果。这个协议在 Kernel 执行场景下表现得最充分。

Kernel 执行的一次完整协作流程是这样的:Runtime 通过 aclrtLaunchKernel 接收上层的 Kernel 描述信息(函数指针、参数列表、Stream 标识),在用户空间做参数校验后封装成 Kernel 提交请求,通过 ioctl 发给 Driver。Driver 在内核空间收到请求后解析 Kernel 描述符,把 Kernel 的参数从用户空间内存拷贝到 NPU 可以访问的设备内存区域,配置硬件寄存器设置 Kernel 的启动地址和参数基地址,把启动指令写入硬件命令队列。NPU 硬件检测到命令队列非空就开始执行 Kernel。Kernel 执行完成后 NPU 硬件产生中断,Driver 的 ISR 处理中断,更新 Stream 的任务完成状态。Driver 通过事件机制通知 Runtime。Runtime 的上层通过 aclrtSynchronizeStream 或者 aclrtQueryEvent 感知到 Kernel 完成。

这个协作流程中的性能关键路径有两个:ioctl 上下文切换的开销和 Kernel 参数的拷贝开销。为了优化上下文切换频率,Runtime 实现了任务批量提交——把多个 Kernel 打包在单次 ioctl 里一起发给 Driver。为了优化参数拷贝,Runtime 使用参数的直接内存映射(而不是逐字节拷贝),把参数所在的用户空间物理页直接映射到 NPU 的地址空间内,让 NPU 的 DMA 引擎直接读取参数。

Stream 同步是另一个协作重点。同一个 Stream 上的任务保证串行执行,这个保证由 Driver 层的硬件命令队列实现。Driver 把同一个 Stream 的任务按提交顺序写入同一个硬件命令队列,NPU 硬件按 FIFO 顺序执行命令队列中的任务。不同 Stream 之间可以并行,但共享的物理资源(例如同一个 HBM 区域)可能会产生竞争。Driver 对这种竞争不做加锁处理——加锁的工作推到了编译器层面,由 GE 图引擎在编译时通过依赖分析确保不同 Stream 上的 Kernel 不访问同一块内存。

接下来看一段协作调度中内存搬运的典型流程。

# Runtime层发起异步内存拷贝,Driver在后台驱动DMA
import acl

def copy_data_async_runtime(host_buf, dev_buf, size, stream):
    # WHY: aclrtMemcpyAsync走DMA引擎在后台搬运
    # 不等拷贝完成,立即返回给调用者
    ret = acl.aclrtMemcpyAsync(dev_buf, size, host_buf, size,
                                acl.ACL_MEMCPY_HOST_TO_DEVICE, stream)
    # WHY: 拷贝在后台跑,CPU线程不阻塞
    # 可以进行其他Host端计算
    perform_host_preprocessing()
    
    # WHY: 等拷贝完成后才能用device端的dev_buf
    # 不等就用的结果是读到未完成的数据
    acl.aclrtSynchronizeStream(stream)
    return ret

这个例子里,aclrtMemcpyAsync 做的事情是向 Target 的 DMA 引擎提交一组源地址到目标地址的传输描述符,然后立刻返回。DMA 引擎自己完成数据搬运,Host 端的 CPU 在函数返回之后就可以继续做别的事。当 Host 端的准备工作做完之后,再调 aclrtSynchronizeStream 等 Device 端的拷贝完成。这个模式(异步提交 + 延迟同步)是 Runtime 和 Driver 协作中最能带来性能收益的设计模式之一:它利用 DMA 引擎的后台搬运能力把拷贝延迟藏在 Host 端的计算时间里面。

第五章 Runtime 和 Driver 的调试和信息获取

出了问题怎么定位是 Runtime 的问题还是 Driver 的问题?有几个实用的排查路径。

校验层错误信息分辨。aclrt 返回值的前缀能帮你快速判断错误源头。ACL_ERROR_RT_ 前缀的错误是 Runtime 层自己产生的,通常是参数校验失败、状态机错误、资源不足等用户空间的问题,不需要查 Driver 日志。ACL_ERROR_DRV_ 前缀的错误意味着错误从 Driver 传上来的,需要去查 Driver 的内核日志。这个前缀规则是 CANN 错误码设计的核心指引,所有 aclrt 调用返回的错误码都遵循这个规范。

Driver 日志在系统日志里的位置。Driver 的内核日志写在内核 ring buffer 里,用 dmesg 或者 journalctl -k 查看。日志里如果出现 NPU 相关的错误,常见的有 “ASCEND: device timeout”(设备超时,可能是 Kernel 执行卡死)、“ASCEND: ECC error”(HBM 数据错误)、“ASCEND: PCIe link error”(PCIe 链路故障)。这些错误的上层可能是 Runtime 返回莫名其妙的内存分配失败或 Kernel 执行错误,但如果只看 Runtime 日志,可能以为是用户代码的问题而浪费大量时间。

Runtime 的日志系统。CANN Runtime 在 /var/log/npu/slog 目录下输出运行日志,按进程和日期组织。日志级别可以在 aclInit 时设置或者通过环境变量 ASCEND_GLOBAL_LOG_LEVEL 控制。调试内存问题可以把日志级别调到 DEBUG,Runtime 在每次 aclrtMalloc 和 aclrtFree 调用时都会输出日志,可以用来追踪内存分配的生命周期。如果训练过程中出现 HBM 内存泄漏(每次 iteration 之后的空闲 HBM 在逐渐减少),查 Runtime 日志里 alloc/free 的配对情况是最直接的手段。

还有一个更底层的调试工具是 NPU 设备的调试文件系统(debugfs)。CANN 安装之后在 /sys/kernel/debug/ascend 目录下提供了设备状态、HBM 使用情况、AI Core 利用率等信息的直接查询接口。这些接口走的是 Driver 的内核空间直接查询路径,不经过 Runtime,所以即使 Runtime 在某个操作上卡死了,也能通过 debugfs 获取设备状态。在 Runtime 出现 hang 住的问题时,debugfs 是仅剩的诊断手段。

第七章 Runtime 与 Driver 版本兼容性

Runtime 和 Driver 之间的版本兼容性是部署阶段必须认真对待的问题。CANN 在版本升级时,Runtime 的 API 可能有增删或行为变化,Driver 的内核接口也可能改变。如果 Runtime 版本和 Driver 版本不匹配,轻则某些接口返回不支持错误,重则整个设备上下文无法加载,所有 NPU 操作全部失败。

版本匹配的规则是:Runtime 的版本号必须和 Driver 的版本号的主版本号相同。CANN 的版本号格式是 X.Y.Z,其中 X 是主版本号。例如 Runtime 8.0.1 可以和 Driver 8.0.3 配合使用(同主版本),但不能和 Driver 7.0.5 配合使用(跨主版本)。这条规则确保了 API 和内核接口的向前兼容性在同一主版本号内得到保证。

升级 CANN 版本时,最安全的做法是 Runtime 和 Driver 一起升级。如果只升级了 Runtime 没升级 Driver,Runtime 可能调用了新 Driver 才有的内核接口,在旧 Driver 上返回 ENOSYS(系统调用未实现)错误。如果只升级了 Driver 没升级 Runtime,Runtime 用旧的内核接口参数格式去调新 Driver,可能因为参数大小不匹配导致内存越界。在升级工具包时,运行 ascend-dkms 命令可以同时更新 Runtime 和 Driver 到匹配的版本。

还有一个实操层面的细节:多版本的 Runtime 共存。如果同一台机器上运行了多个需要不同 CANN 版本的应用,可以通过不同的环境变量来加载不同版本的 Runtime 共享库。每个 CANN 版本安装在独立的目录下(如 /usr/local/Ascend/ascend-toolkit/8.0.0/ 和 /usr/local/Ascend/ascend-toolkit/8.1.0/),应用启动时 source 对应版本的 set_env.sh 来指定加载哪个版本的 Runtime。Driver 在同一台机器上只能装一个版本,所以所有应用必须使用同一个主版本的 CANN——这又回到了主版本号必须匹配的规则。

第九章 Runtime 错误处理与恢复机制

Runtime 的错误处理机制比表面上看起来要复杂。一个 Kernel 在设备端执行出错时,错误不会立刻被 Runtime 感知到——因为 Kernel 是异步提交的,Runtime 在调用 aclrtLaunchKernel 后马上返回了,后续代码继续运行。Kernel 执行过程中的错误(越界访问、数据对齐错误、除零等)最先被 Driver 的中断处理程序捕获,记录到硬件错误寄存器中。下一次 Runtime 调用同步接口(如 aclrtSynchronizeStream 或 aclrtQueryEvent)时,Driver 把累积的错误信息传回给 Runtime,Runtime 再以错误码的形式返回给调用者。

这种异步错误机制的一个副作用是错误发生点和错误暴露点之间可以隔很多行代码。在一个 Stream 上提交了五个 Kernel,第三个 Kernel 访问越界错了,但你在调用 aclrtSynchronizeStream 之前不知道出了错。等同步调用返回错误时,你看到的是当前执行点的调用栈,而不是第三个 Kernel 提交时的调用栈。这给调试增加了难度。一个有用的调试技巧是:在每个 Kernel 提交之后立刻紧跟着一个 aclrtQueryEvent 调用(用事件标记 Kernel 提交点),把错误检测点的粒度从 Stream 级别细化到 Kernel 级别。这样每次 Kernel 执行完就能立刻知道是否成功,而不是等所有 Kernel 执行完统一检查。

Runtime 还提供了一项设备复位功能,在设备进入不可恢复的错误状态时使用。aclrtResetDevice 会释放当前设备上下文上分配的所有资源(HBM 内存、Stream、事件),并把设备硬件恢复到初始化状态。这个操作是有代价的——所有未完成的 Kernel 会被强制中止,所有已分配的显存被释放——但它是在 NPU 卡进入异常状态后重新恢复可用性的唯一手段。在需要做设备复位的场景中,比较好的做法是在复位前把设备错误寄存器的内容读出来保存到日志里,方便后续分析根因。

第十章 效率对比

从几个维度对比忽略 Runtime 和 Driver 边界与正确利用两者分工的效率差异。

维度 不了解边界(黑箱使用) 理解边界与协作 差异来源
内存分配效率 频繁小块分配导致HBM碎片 批量大页分配减少碎片和ioctl调用 内存管理粒度与调用次数
Kernel提交延迟 逐个Kernel独立提交多次ioctl 批量打包提交减少上下文切换 ioctl调用频率
异常恢复速度 不区分Runtime错误和Driver错误 按错误码快速定位责任层 错误定位的层级归因
Stream调度效率 不利用多Stream并行 按依赖关系分派到不同Stream 硬件命令队列的并行度
调试效率 面对crash只能查上层日志 从Driver日志向上追踪调用链 调试从源头向下或从头向上
硬件升级兼容 依赖硬件特定的配置参数 通过Runtime抽象层隔离硬件依赖 抽象层次与硬件耦合度

搞清楚 Runtime 和 Driver 的边界,最大的收益不是在性能优化上——虽然那也很重要——而是在排查问题的效率上。一个 NPU 推理服务的延迟抖动,可能是网络的问题,可能是框架序列化的问题,可能是模型图编译的问题,也可能是 Runtime 或 Driver 层的问题。知道每一层的职责和日志位置,意味着你可以在五分钟内把怀疑范围从五个层缩减到两个层,而不是在五个层的日志里大海捞针。这个排查效率的差别,在线上紧急故障处理中,可能就是几小时和几分钟的差别。

暴露了 Runtime 错误传播的一个设计取舍:为了保持接口简洁,Runtime 把 Driver 返回的多种内存失败原因(ENOMEM 是因为容量不足还是碎片化)合并成了一个错误码。简洁性的代价是排查时需要跨层查看日志——如果只看 Runtime 日志,很容易被误导到应用层的内存泄漏方向。解决方式有两种:一是应用层使用大页分配(ACL_MEM_MALLOC_HUGE_FIRST),减少碎片化概率;二是定期调用 aclrtResetDevice 重建设备上下文,相当于对 HBM 做一次整理回收。这个教训的价值远超本篇文章的范围——它告诉你,理解 Runtime 和 Driver 的分工,最终是为了在问题发生的时候不被表面现象误导。


https://atomgit.com/cann/runtime
https://atomgit.com/cann/driver

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐