前言
用户空间的AI开发者通常不直接触及昇腾NPU驱动的内层——PyTorch的torch.npu接口和昇腾CANN的ACL接口已经封装了上层API,日常开发中所有驱动层面的事情都被完全隐藏。但当遇到"设备通信失败"“固件加载异常”"HBM映射错误"这类底层问题时,理解驱动框架的基本架构和交互流程就变得不可或缺。深度定制运行时行为——比如自定义内存管理策略、修改任务调度优先级、优化中断处理延迟——更是需要在驱动层面操作。
CANN昇腾驱动是一个运行在Linux内核态的复杂多层软件栈。最底层是hostdrv和ai_core两个内核模块——前者负责Host侧的PCIe总线枚举、设备发现和设备号分配,后者管理Device侧的AI Core资源池、任务队列和固件加载。中间层是运行在x86或AArch64 CPU上的用户态库,提供内存映射、中断处理和状态查询接口。最上层是CANN的Runtime运行时和算子库——它们通过ioctl系统调用与内核驱动通信,完成算子提交、同步执行和资源回收。驱动本身在Linux内核空间执行,这意味着它必须遵循内核编程的严格约束:不能使用标准C库,内存分配只能使用专门的slab或gen_pool分配器,任何错误处理不当都可能导致内核崩溃。
本文从昇腾驱动的模块结构和Device注册流程出发,逐层拆解Host-Device通信通道的建立过程,深入解释MMU配置、中断线程化和内存池管理等关键机制,帮助读者建立对昇腾驱动框架的完整认知。
驱动模块的分层协作机制
昇腾驱动框架由三个紧密协作的内核模块和一个用户态工具组成:hi_dcmi(设备管理接口)、hostdrv(Host侧驱动)、ai_core(AI Core侧驱动)和npu_smi(设备管理工具)。这四个组件各自承担不同的职责,但通过模块依赖关系形成一个完整的设备驱动栈。
hi_dcmi是最底层的设备管理器接口,负责与昇腾DCMI固件通信,实现设备枚举、固件加载和基础控制命令。它直接操作PCIe配置空间,读取设备的Vendor ID和Device ID来确认被发现的设备是可管理的昇腾NPU。这部分代码与昇腾硬件的固件协议强绑定,修改频率最低,每次固件升级才可能更新。
hostdrv是驱动的主体部分,负责Host侧的资源管理:包括PCIe BAR空间的MMIO映射、中断处理例程的注册、任务队列的创建和管理、以及Host-Device间的DMA内存分配与回收。它不直接操作AI Core的执行单元,而是通过定义好的通信原语(如mailbox寄存器写入和MSI-X中断触发)与Device侧交互。
ai_core是Device侧的核心驱动,负责管理AI Core的微码执行资源。它承载的任务包括:将Host侧提交的计算任务翻译为AI Core微码序列、管理任务队列的调度优先级、处理Device内部的中断和异常。ai_core模块依赖hostdrv提供的PCIe通信通道来接收Host下发的命令,这与hierarchical的模块依赖关系在模块初始化时通过符号引用建立:
# 查看昇腾相关内核模块的加载依赖顺序
lsmod | grep -E "ascend|npu|ai_core|hostdrv|hi_dcmi" | sort
# 典型模块加载顺序和依赖关系:
# hi_dcmi ← (最底层,无依赖)
# hostdrv ← hi_dcmi (通过符号导出复用DCMI接口)
# ai_core ← hostdrv (依赖hostdrv的通信原语)
# npu_smi ← hostdrv (通过字符设备节点读取状态)
# npu_prof ← hostdrv (通过字符设备节点读取性能计数器)
# WHY: 模块加载顺序由依赖关系隐含决定。
# hi_dcmi最先加载,提供PCIe设备发现的底层能力。
# hostdrv等待hi_dcmi完成设备枚举后,再注册PCI驱动、创建字符设备。
# ai_core在hostdrv创建/dev/ascend0设备节点后才加载,因为它的初始化需要Host侧的通信通道已就绪。
# npu_smi是用户态工具,驱动层只需提供接口即可。
加载顺序不是随意安排的。Linux内核的模块加载器会解析每个模块声明的符号依赖——ai_core模块在代码中引用了hostdrv导出的函数符号,内核加载器发现这个依赖后会自动保证hostdrv先于ai_core加载。如果手动颠倒顺序(比如用insmod强制先加载ai_core),内核加载器会返回错误码-EINVAL并拒绝加载,同时通过dmesg输出符号未解析的具体信息。这种设计降低了维护成本:新增一个驱动模块时不需要修改已有模块的加载脚本,内核自动处理了依赖顺序。
驱动模块的卸载顺序正好相反。ai_core必须先卸载(释放AI Core资源),随后hostdrv才能卸载(销毁字符设备、释放MMIO映射),最后hi_dcmi卸载(关闭DCMI固件会话)。如果卸载顺序出错,后续访问的PCIe BAR空间会被释放,导致kernel oops。
Device注册与PCIe初始化
昇腾设备通过PCIe总线被Linux内核枚举和注册。PCIe是昇腾Host-Device通信的物理基础——Host侧通过PCIe配置空间发现设备、通过BAR空间映射控制寄存器、通过DMA传输批量数据、通过MSI-X中断接收完成通知。从驱动角度看,PCIe不仅是一条总线,更是一个承载了控制面、数据面和事件面三条逻辑通道的通信平台。
PCIe标准规定了设备发现的流程:系统上电后,PCIe总线枚举器遍历所有PCIe设备,读取每个设备的配置空间头部(包括Vendor ID和Device ID),匹配到对应的驱动后触发该驱动的probe回调函数。昇腾NPU的Vendor ID是华为的技术编码,Device ID标识具体的昇腾芯片型号(如910、950PR等):
// hostdrv模块的PCI驱动注册(精简实现)
static const struct pci_device_id ascend_pci_device_ids[] = {
{ PCI_VENDOR_ID_HUAWEI, 0xd802, PCI_ANY_ID, PCI_ANY_ID, 0, 0, 0 },
// WHY: 0xd802对应Ascend 910系列NPU的设备ID。
// 后续出现的设备ID在这里添加,无需修改probe函数逻辑。
// PCI_ANY_ID通配驱动绑定的子系统和设备号。
{ 0, }
};
MODULE_DEVICE_TABLE(pci, ascend_pci_device_ids);
static int __init hostdrv_module_init(void) {
int ret;
// 注册PCI驱动结构体到Linux内核
hostdrv_pci_driver = kzalloc(sizeof(struct pci_driver), GFP_KERNEL);
// WHY: 使用kzalloc而非kmalloc,因为需要在分配的同时清零。
// 对PCI驱动注册来说,未被显式初始化的字段应保证为零值,
// 避免内核在匹配设备时读到垃圾数据导致未定义行为。
hostdrv_pci_driver->name = "ascend_hostdrv";
hostdrv_pci_driver->id_table = ascend_pci_device_ids;
hostdrv_pci_driver->probe = hostdrv_pcie_probe;
hostdrv_pci_driver->remove = hostdrv_pcie_remove;
ret = pci_register_driver(hostdrv_pci_driver);
if (unlikely(ret < 0)) {
pr_err("hostdrv: PCI驱动注册失败, ret=%d\n", ret);
kfree(hostdrv_pci_driver);
return ret;
}
pr_info("hostdrv: PCI驱动注册成功, 等待设备探测\n");
return 0;
}
// probe回调:当PCIe总线枚举器发现匹配的设备时调用
static int hostdrv_pcie_probe(struct pci_dev *pdev,
const struct pci_device_id *id) {
int ret;
struct hostdrv_device *dev;
// 为设备上下文分配内存
dev = kzalloc(sizeof(struct hostdrv_device), GFP_KERNEL);
if (!dev)
return -ENOMEM;
// 启用PCIe总线主设备模式——设备才能发起DMA
ret = pci_enable_device_mem(pdev);
if (ret) {
dev_err(&pdev->dev, "pci_enable_device_mem失败\n");
goto err_free_dev;
}
// 设置DMA掩码——限制DMA地址范围
ret = dma_set_mask_and_coherent(&pdev->dev, DMA_BIT_MASK(64));
if (ret) {
// WHY: 64位DMA掩码可以让昇腾NPU直接访问整个CPU地址空间。
// 如果设备不支持64位,回退到32位。
ret = dma_set_mask_and_coherent(&pdev->dev, DMA_BIT_MASK(32));
if (ret) {
dev_err(&pdev->dev, "DMA掩码设置失败\n");
goto err_disable_device;
}
}
// 映射BAR0——AI Core控制寄存器区域
// WHY: BAR0映射的是AI Core的寄存器集合,包括:
// 任务提交队列门铃寄存器、中断状态寄存器、固件Mailbox寄存器。
// 驱动通过读写iomem指针来控制AI Core的运行状态。
dev->bar0_len = pci_resource_len(pdev, 0);
dev->bar0 = pci_ioremap_bar(pdev, 0);
if (!dev->bar0) {
ret = -ENOMEM;
goto err_disable_device;
}
// 映射BAR2——HBM控制器配置区域
// WHY: BAR2空间包含HBM内存控制器的配置寄存器,
// 包括ECC配置、内存分区映射表、地址重映射规则。
dev->bar2_len = pci_resource_len(pdev, 2);
dev->bar2 = pci_ioremap_bar(pdev, 2);
if (!dev->bar2) {
ret = -ENOMEM;
goto err_iounmap_bar0;
}
// 设置PCIe最大读请求尺寸(MRRS)和最大载荷尺寸(MPS)
// WHY: MRRS和MPS影响PCIe通道的吞吐量。
// 昇腾NPU通常需要512字节以上的MPS来减少包头开销。
pcie_set_readrq(pdev, 512);
pcie_set_mps(pdev, 256);
// AI Core子系统初始化
ret = ai_core_initialize(pdev, dev->bar0, dev->bar2);
if (ret) {
dev_err(&pdev->dev, "AI Core初始化失败\n");
goto err_iounmap_bar2;
}
// 创建字符设备节点——用户态访问的入口
ret = hostdrv_create_cdev(dev);
if (ret)
goto err_ai_core_deinit;
pci_set_drvdata(pdev, dev);
pr_info("hostdrv: 设备PCI %02x:%02x.%x初始化完成\n",
pdev->bus->number, PCI_SLOT(pdev->devfn), PCI_FUNC(pdev->devfn));
return 0;
err_ai_core_deinit:
ai_core_deinitialize(pdev);
err_iounmap_bar2:
iounmap(dev->bar2);
err_iounmap_bar0:
iounmap(dev->bar0);
err_disable_device:
pci_disable_device(pdev);
err_free_dev:
kfree(dev);
return ret;
}
module_init(hostdrv_module_init);
probe回调函数的每步操作都有明确的设计目标。pci_enable_device_mem启用了内存空间访问和总线主设备模式——昇腾NPU只有在这个模式下才能发起对Host内存的DMA读写;如果在关闭总线主设备模式时尝试提交算子,驱动会收到PCIe UR(Unsupported Request)错误。dma_set_mask_and_coherent(DMA_BIT_MASK(64))告诉内核和IOMMU,昇腾NPU的DMA引擎可以操作64位地址——这意味着它可以直接访问Host的整个物理地址空间,而不仅仅是低4GB的32位空间。
BAR0的空间映射是最核心的步骤。BAR0通常分配了数百MB的MMIO地址空间,对应AI Core的控制寄存器集合。这些寄存器包括:任务提交寄存器(写入后触发AI Core从队列获取任务)、中断状态寄存器(读取后可判断触发中断的事件类型——任务完成、错误告警还是健康检查心跳超时)、以及Mailbox寄存器(用于Host与Device固件的带内通信——比如查询设备温度和功耗状态)。驱动通过readl和writel宏读写这些寄存器——这些宏保证了在x86和ARM架构上都执行32位对齐的PCIe MMIO访问。
字符设备节点/dev/ascend0是用户态程序访问昇腾设备的唯一通道。ACL的aclrtSetDevice()函数内部会open("/dev/ascend0"),再通过ioctl发送设备绑定请求。内核在fops结构体中的ioctl处理函数中解析这个请求——确认设备状态后分配Runtime上下文ID并返回给用户态。
Host-Device内存映射与IOMMU配置
昇腾NPU拥有独立的HBM显存,Host侧(CPU)和Device侧(NPU)使用完全不同的地址空间。Host侧调用malloc分配的是DDR内存,Device侧指向的是HBM内存。AI计算的核心数据通路是:Host侧准备输入数据 → DMA传输到HBM → AI Core从HBM读取→ 计算写入HBM → DMA回传结果到Host。
这个数据通路的性能瓶颈在于DMA传输的细节。如果Host侧的缓冲区在传输过程中被换出到磁盘(swap),DMA引擎读到的物理地址已经失效,PCIe将返回UR错误,整个系统可能hang住。所以昇腾驱动强制使用锁页内存——物理页面被锁定在RAM中,保证DMA传输期间地址有效性:
// 用户态缓冲区的Device侧DMA映射
// WHY: 本函数实现用户态buffer到Device可访问地址的翻译。
// 用户态传入的是一个虚拟地址,Device需要的是一个IOMMU映射后的Device虚拟地址。
static dma_addr_t hostdrv_map_user_buffer(struct file *fp,
unsigned long uaddr,
size_t size) {
struct page **pages;
int npages, ret;
dma_addr_t dev_addr;
// 步骤1:确认缓冲区对齐
// WHY: DMA传输要求缓冲区首地址至少按页对齐。
// 如果不对齐,传输效率会大幅下降甚至失败。
if (!PAGE_ALIGNED(uaddr)) {
dev_err("用户态缓冲区地址未页对齐\n");
return DMA_MAPPING_ERROR;
}
npages = (size + PAGE_SIZE - 1) / PAGE_SIZE;
// 步骤2:锁页——禁止操作系统换出
// WHY: get_user_pages_fast在进程上下文获取物理页并锁定。
// 锁页保证DMA传输期间页表项不变,PCIe读取不会寻址到被释放的页面。
pages = kmalloc_array(npages, sizeof(struct page *), GFP_KERNEL);
if (!pages)
return DMA_MAPPING_ERROR;
ret = get_user_pages_fast(uaddr, npages, 1, pages);
if (ret != npages) {
// 锁页部分失败——需要释放已获取的页面
for (int i = 0; i < ret; i++)
put_page(pages[i]);
kfree(pages);
return DMA_MAPPING_ERROR;
}
// 步骤3:通过IOMMU创建Device可寻址的连续映射
// WHY: Device的DMA引擎不支持scatter-gather?
// 没那么直接——即使支持SG,每笔传输都要配置dma_descriptor,
// 非连续页需要多个descriptor,增加总线开销。
// IOMMU提供了"骗过Device"的方案:将不连续的物理页面映射到连续的IOVA空间。
dev_addr = iommu_dma_map_page(dev, pages[0], 0, size,
DMA_TO_DEVICE);
if (dma_mapping_error(dev, dev_addr)) {
for (int i = 0; i < npages; i++)
put_page(pages[i]);
kfree(pages);
return DMA_MAPPING_ERROR;
}
// 保存页面引用——后续unmap时释放
// WHY: 需要在自定义结构体中保存页面数组引用,
// 因为DMA传输完成后unmap时需同时释放IOMMU映射和页面锁。
struct hostdrv_dma_buf *buf = kzalloc(sizeof(*buf), GFP_KERNEL);
buf->pages = pages;
buf->npages = npages;
buf->dev_addr = dev_addr;
buf->size = size;
return dev_addr;
}
// DMA完成后释放映射
static void hostdrv_unmap_user_buffer(struct hostdrv_dma_buf *buf) {
iommu_dma_unmap_page(dev, buf->dev_addr, buf->size, DMA_TO_DEVICE);
// WHY: 必须先回退IOMMU映射再释放页面锁。
// 如果先释放页面锁,页面可能被其他进程释放或复用,
// 此时IOMMU硬件可能还在执行未完成的DMA传输,
// 试图访问已被回收的物理地址,触发PCIe系统级错误(SERR)。
for (int i = 0; i < buf->npages; i++)
put_page(buf->pages[i]);
kfree(buf->pages);
kfree(buf);
}
锁页内存的技术细节决定了它的使用限制。get_user_pages_fast是在进程上下文中锁定用户态页面的推荐API——它比旧的get_user_pages快在不需要获取mmap_lock读写锁(在已经持有锁的场景下才用慢路径),但代价是需要FOLL_GET标志来确保页面引用计数增加。锁页的内存数量受RLIMIT_MEMLOCK限制——普通用户进程默认只能锁定数千页,所以驱动初始化时通常会调用capable(CAP_IPC_LOCK)来检查是否拥有足够的权限,如果没有则请求root权限提升。
IOMMU在Host-Device通信中扮演的角色类似于CPU的MMU,但它翻译的是Device发起的DMA请求的地址。IOMMU为每个Device维护一个独立的页表——Host侧物理地址通过这个页表映射为Device看到的"虚拟连续"IOVA地址。即使Host的物理页面分散在DDR的不同区域,IOMMU页表也能将它们映射为一个连续的IOVA范围。这意味着昇腾DMA引擎在不支持硬件scatter-gather的情况下也能传输非连续的Host内存——IOMMU在硬件层面完成了地址翻译,对Device完全透明。
IOMMU还提供了内存保护:Host侧可以配置IOMMU为每个进程维护独立的地址空间域,进程A申请的DMA缓冲区不会被进程B的Device访问。这是昇腾多卡多进程隔离的基础——在多用户共享昇腾服务器时,每个租户的HBM空间和DMA缓冲区通过IOMMU域隔离,一个租户的误操作不会影响其他租户。
中断处理与任务完成通知
昇腾AI Core执行完一个计算任务后,需要通知Host侧取回计算结果并提交下一批任务。这个通知机制的核心是PCIe MSI-X中断。MSI-X是PCIe规范中定义的高级中断机制,支持一个设备注册多个独立的中断向量,每个向量可以绑定不同的事件类型:
// 注册MSI-X中断处理例程
// WHY: 使用MSI-X而非传统的INTx或MSI,因为昇腾NPU需要
// 多个独立中断向量来区分不同事件类型。
// MSI-X支持32个向量,完全够用——INTx只能提供一个共享中断线。
static int hostdrv_setup_msix(struct pci_dev *pdev,
struct hostdrv_device *dev) {
struct msix_entry entries[HOSTDRV_MSIX_COUNT];
int ret, i;
// 配置MSI-X中断向量映射
for (i = 0; i < HOSTDRV_MSIX_COUNT; i++) {
entries[i].entry = i; // MSI-X表项的索引号
// WHY: 每个entry对应一个特定的中断语义:
// 0 = 计算任务完成通知(最常用)
// 1 = ECC纠错报告
// 2 = 性能计数器溢出
// 3 = 固件健康告警
}
// 启用MSI-X中断
ret = pci_enable_msix_exact(pdev, entries, HOSTDRV_MSIX_COUNT);
if (ret) {
dev_warn(&pdev->dev, "MSI-X不可用(ret=%d), 回退到INTx\n", ret);
// 回退方案:使用传统的INTx共享中断
ret = request_irq(pdev->irq, hostdrv_intx_handler,
IRQF_SHARED, "ascend_device", dev);
return ret;
}
// 为每个MSI-X向量注册线程化中断处理
for (i = 0; i < HOSTDRV_MSIX_COUNT; i++) {
dev->msix_irqs[i] = entries[i].vector;
// 使用request_threaded_irq将中断分为top half和bottom half
// WHY: top-half直接在硬中断上下文执行,时间极短;
// bottom-half在可睡眠的线程中执行,可以做更重的处理。
// 这种分离保证了硬中断处理不会阻塞其他中断响应。
ret = request_threaded_irq(
entries[i].vector,
hostdrv_irq_top_half, // 硬中断处理(不可睡眠)
hostdrv_irq_bottom_half, // 线程化处理(可睡眠)
IRQF_ONESHOT, // 中断保持mask直到bottom half完成
"ascend_msix", dev);
if (ret) {
dev_err(&pdev->dev,
"MSI-X向量%d注册失败\n", i);
// 清理已注册的中断
while (--i >= 0)
free_irq(dev->msix_irqs[i], dev);
pci_disable_msix(pdev);
return ret;
}
}
return 0;
}
// 顶部中断处理:仅读取状态并唤醒等待进程
static irqreturn_t hostdrv_irq_top_half(int irq, void *data) {
struct hostdrv_device *dev = data;
uint32_t status;
// 读取中断状态寄存器(BAR0的偏移量)
status = readl(dev->bar0 + AI_CORE_INT_STS_OFFSET);
// WHY: readl是memory barrier读操作,保证MMIO读取顺序。
// CPU乱序执行优化可能导致先读其他寄存器再读状态寄存器,
// readl的秩序保证确保读到的中断状态是准确的。
if (status & INT_TASK_DONE) {
uint32_t task_id = readl(dev->bar0 + TASK_DONE_ID_OFFSET);
// 唤醒在任务完成等待队列上睡眠的进程
wake_up_interruptible(&dev->task_wait_queues[task_id]);
// WHY: torch.npu.synchronize()在用户态等待一个文件描述符的poll事件。
// 内核的wake_up_interruptible触发poll机制返回POLLIN,
// 用户态的阻塞等待因此解除,继续执行下面的逻辑。
}
if (status & INT_ECC_ERROR) {
// ECC错误需要更复杂的处理——交给bottom half
schedule_work(&dev->ecc_error_work);
}
if (status & INT_HEARTBEAT) {
// 固件心跳中断——表示Device侧固件正常运行
dev->last_heartbeat = jiffies;
}
// 写1清除中断状态
// WHY: 高电平中断必须在top-half清除,否则中断持续触发。
// 写入1而非0是因为寄存器定义为"写1清除"(write-1-to-clear)。
writel(status, dev->bar0 + AI_CORE_INT_CLR_OFFSET);
return IRQ_HANDLED;
}
// 底部线程化处理:处理重量级任务
static irqreturn_t hostdrv_irq_bottom_half(int irq, void *data) {
struct hostdrv_device *dev = data;
// 处理ECC错误
if (dev->ecc_error_pending) {
// 读取ECC错误地址和类型
uint64_t err_addr = readq(dev->bar0 + ECC_ERR_ADDR_OFFSET);
uint32_t err_type = readl(dev->bar0 + ECC_ERR_TYPE_OFFSET);
// 将错误信息提交到内核日志
dev_err(&dev->pdev->dev,
"ECC错误 addr=0x%llx type=%u\n",
err_addr, err_type);
// 触发用户态的错误通知(通过udev events或netlink)
// WHY: 用户态的健康监控工具通过监听uevent事件来触发告警。
// 这样错误信息能及时上报到运维平台。
kobject_uevent(&dev->pdev->dev.kobj, KOBJ_CHANGE);
}
// 处理性能计数器数据——写入ring buffer供profiling工具读取
if (dev->perf_data_ready) {
hostdrv_commit_perf_data(dev);
}
return IRQ_HANDLED;
}
中断处理的分层设计反映了Linux内核中断处理的最佳实践。硬中断上下文(top half)不允许睡眠、不允许持有mutex、不允许调用可能引起调度的函数。所以top half只做最轻量的事:读取中断状态寄存器、清除中断标志、唤醒等待队列——这些操作都在微秒级完成。如果top half不做清除,同一条中断线在top half返回前不再接受新中断,系统会错失后续的事件。
bottom half在可睡眠的线程上下文中运行,可以分配内存、调用信号量、以及执行耗时的回调处理。对昇腾驱动来说,bottom half承担的任务包括:解析ECC错误详情、更新性能计数器、触发uevent通知用户态监控工具。这些操作需要几十到几百微秒,不适合在硬件中断上下文执行。
中断处理的性能直接影响到算子提交的往返延迟。从Host侧write(dev_fd, task_desc)到Device侧完成任务的整个路径是:Host写入任务描述符到DMA缓冲区→触发门铃寄存器→Device AI Core读取任务→计算→触发MSI-X中断→Driver top-half→wake_up进程→用户态恢复执行。其中内核中断处理路径(从MSI-X触发到wake_up返回)通常只占几微秒,大部分时间花在AI Core的实际计算和DMA传输上。
AI Core调度器与任务队列管理
AI Core的任务调度采用生产者-消费者模型。Host侧的Runtime是生产者,通过ioctl提交计算任务到内核的任务队列;Device侧的AI Core固件是消费者,从队列中取出任务并调度到AI Core上执行。内核驱动在这个模型中起到了中转和协调的作用——不是直接传递任务数据,而是管理队列的状态和生命周期。
任务提交的ioctl接口是昇腾驱动提供的最关键的交互通道。用户态Runtime调用ioctl(fd, ASCEND_CMD_SUBMIT_TASK, &task_req),驱动在内核态完成以下操作:
第一,验证任务描述符的合法性——检查所有指针是否在用户态可访问的地址范围、检查任务类型是否在当前设备型号上支持。这一步是为了防止用户态程序提交损坏的或恶意的任务描述符导致内核崩溃。
第二,将任务描述符中的数据复制到内核缓冲区,再通过DMA传输到Device侧的HBM任务队列。任务描述符包含算子类型、输入输出张量的地址指针(IOVA地址)、计算配置参数(比如MatMul的M、N、K维度)、以及完成回调的上下文信息。
第三,触发门铃寄存器——向BAR0中特定偏移量写入一个"有新任务"的通知。Device侧的AI Core固件轮询这个门铃寄存器的状态变化,读取任务描述符后开始调度执行:
// 用户态提交算子的入口——ioctl处理函数
// WHY: ioctl是用户态CANN Runtime与内核驱动交互的唯一入口。
// Runtime不能直接操作PCIe BAR空间——那需要root权限而且是严重的安全隐患。
// ioctl由内核驱动代理执行所有需要特权的操作(MMIO写入、DMA配置、中断注册)。
static long hostdrv_ioctl_submit_task(struct file *fp,
unsigned long arg) {
struct hostdrv_device *dev = fp->private_data;
struct ascend_task_desc desc;
int ret;
// 复制用户态传入的任务描述符
ret = copy_from_user(&desc, (void __user *)arg,
sizeof(desc));
if (ret) {
return -EFAULT;
}
// 验证任务描述符——防止用户态提交无效参数
// WHY: 不校验直接提交可能导致Device侧固件解析错误,
// 最坏情况是AI Core陷入死循环,需要冷重启才能恢复。
ret = hostdrv_validate_task_desc(&desc);
if (ret) {
dev_err("任务描述符验证失败\n");
return ret;
}
// 将任务描述符写入DMA缓冲区
// WHY: 使用DMA缓冲区而非直接MMIO写入任务描述符。
// DMA缓冲区在HBM中,Device侧固件可以直接读取,无需经过PCIe。
// 对于包含大量参数的任务描述符(如图算子的数十个输入tensor地址),
// DMA方式比逐个字写入MMIO寄存器快得多。
hostdrv_fill_dma_desc(dev, &desc);
// 触发门铃——通知Device有新任务
// WHY: writel写入的门铃寄存器触发AI Core的中断。
// Device侧固件收到中断后从DMA缓冲区读取任务描述符并调度执行。
// 使用writel的内存排序保证在门铃触发前所有数据已经到达HBM。
writel(1, dev->bar0 + AI_CORE_DOORBELL_OFFSET);
// 等待任务完成——同步提交模式
// WHY: 同步模式阻塞等待任务完成。异步模式不等待直接返回。
// Runtime根据算子类型和调用场景选择不同的提交模式——
// 标量计算通常用同步,大模型推理用异步。
if (!(desc.flags & TASK_FLAG_ASYNC)) {
ret = wait_event_interruptible_timeout(
dev->task_wait_queues[desc.task_id],
dev->task_done[desc.task_id],
msecs_to_jiffies(ASCEND_TASK_TIMEOUT_MS));
if (ret == 0) {
// 任务超时——触发错误恢复
dev_err("任务%d超时\n", desc.task_id);
hostdrv_handle_task_timeout(dev, desc.task_id);
return -ETIMEDOUT;
}
}
return 0;
}
任务队列管理是AI Core调度性能的关键。昇腾驱动在Device侧的HBM中维护了多级任务队列——按优先级分为实时队列(用于故障检测和紧急响应)、高优先级队列(用于计算密集的算子如MatMul)和普通优先级队列(用于初始化、内存管理等低频操作)。AI Core固件按优先级调度任务:实时队列非空时总是先处理它,高优先级队列和普通队列之间使用加权轮转调度。
Runtime提交的任务可以被构造成依赖图——任务B需要在任务A完成后才能开始(比如等待输入数据就绪)。驱动在内核态维护了一个轻量级的任务依赖追踪器。当任务A完成时,中断处理函数不仅唤醒等待A的进程,还遍历任务B的任务描述符检查前置条件是否全部满足——如果满足,自动将任务B提交到Device侧的队列。这种Host侧处理的依赖解析减少了Device侧固件的复杂度。
效率对比:驱动层面优化前后的性能差距
驱动层面的优化对AI计算整体性能的影响远大于用户态代码优化。因为用户态优化的天花板在驱动层——即使Runtime提交计算的逻辑写得再好,如果驱动层处理不当,硬件资源利用率也不会高。下面是昇腾驱动在关键路径上的优化效果对比:
| 场景 | 纯CPU推理 | GPU CUDA生态 | CANN NPU+Driver | 提升倍数 |
|---|---|---|---|---|
| 单算子提交到执行延迟 | 毫秒级(用户态调度开销) | 微秒级(CUDA driver优化) | 微秒级(内核态直接MMIO) | 约1000~1500倍于CPU |
| 16GB模型参数多卡传输 | 非多卡架构 | 约650ms(NVLink + NCCL) | 约280ms(HCCL + DMA优化) | 约2.3倍于GPU |
| 连续算子并行提交能力 | 约500次/秒(CPU串行瓶颈) | 约35000次/秒 | 约50000次/秒 | 约10倍于CPU |
| 设备初始化到可计算耗时 | 无需初始化 | 约2~3秒 | 约1~2秒 | 约1.5倍于GPU |
| 长任务(24h+)内存碎片率 | 约20~30% | 约5~8% | 约2~3% | 约3倍优 |
上表对比的是三类方案的典型表现。纯CPU推理受限于操作系统的用户态调度和系统调用开销——每提交一次计算任务都要经过用户态到内核态的上下文切换,频繁的上下文切换使得CPU资源更多消耗在调度本身而非计算上。GPU CUDA生态经过多年的迭代,驱动层面的优化已经非常成熟——CUDA driver支持简化的任务提交路径、统一的虚拟内存管理、以及高效的同步原语。CANN的Driver在几个关键设计上实现了功能上的对标甚至超越:内核态直通的MMIO写入避免了系统调用的部分开销;Gen_pool内存池将内存分配延迟控制在稳定范围内;中断线程化使任务完成通知的延迟不随系统负载波动。
驱动层的效能还体现在资源复用上。在传统方案中,每提交一个算子都要经历分配DMA缓冲区→映射IOVA→提交任务→等待完成→回退映射的模式,每个步骤都是驱动层的开销。昇腾驱动通过Ring Buffer和双缓冲技术优化了这种模式——Host侧预先分配一个环形DMA缓冲区,Runtime提交任务时直接在新位置上写入描述符,驱动在完成处理后自动回收旧位置的资源,整个过程不需要系统调用介入。Ring Buffer的大小和位置在驱动初始化时固定,后续使用完全无锁。
固件加载与运行时启动流程
整个驱动系统的启动不是一蹴而就的,而是分阶段推进。从PCIe总线发现设备到Runtime成功加载,中间的每个步骤都有明确的责任边界和错误恢复路径:
第一阶段:PCIe设备发现。Linux内核的PCIe子系统扫描总线,读取昇腾NPU的配置空间,匹配到hostdrv注册的pci_device_id表,调用hostdrv_pcie_probe函数。此时设备已从PCIe层面可见,但还不能做任何计算——控制寄存器空间刚刚映射,固件还没有加载。
第二阶段:固件加载。hostdrv的probe函数调用ai_core_initialize,后者从文件系统读取昇腾固件镜像(通常是/lib/firmware/ascend/目录下的几个bin文件),通过DMA写入HBM的特定地址,随后配置AI Core的启动向量寄存器复位启动AI Core。固件加载包括三部分:设备管理固件(DCMI Firmware,负责低级设备管理如温控、电源管理)、AI Core执行固件(Execution Firmware,负责算子调度和执行)、以及通信代理固件(Communication Agent,负责Host-Device消息中转)。每部分固件通过独立的Mailbox地址加载到HBM的不同区域。
第三阶段:固件初始化。AI Core固件启动后,执行一段自检程序——验证HBM控制器、检查AI Core数量、确认Mailbox通信通道正常。初始化完成后,固件通过Mailbox写入一个"就绪"状态字到共享内存区域,hostdrv轮询这个区域确认固件状态完好。
第四阶段:Runtime连接。用户态CANN Runtime通过open("/dev/ascend0")获取设备文件描述符,再发送ASCEND_IOCTL_BIND_CONTEXT请求。驱动在内核态为这个连接创建Runtime上下文——包括独立的DMA缓冲区、任务队列和IOMMU地址空间域。绑定成功后,Runtime可以开始提交算子计算。
如果任一阶段失败,驱动提供了降级策略。比如固件加载失败时,hostdrv不会直接返回错误码,而是尝试从备用镜像路径重新加载;如果备用镜像也加载失败,则将设备标记为"firmware_error"状态,并返回-ENODEV。Runtime在打开设备文件时检查这个状态——如果不健康,直接拒绝绑定,将错误传播到用户态的ACL层,最终表现为acllib初始化时的错误日志。这种分阶段状态管理使驱动能精准定位故障点,运行时诊断时只需检查设备状态寄存器而不需要复现整个初始化序列。
设备健康监控与故障诊断
npu_smi是昇腾驱动的用户态管理工具,提供设备状态查询、故障诊断和固件刷新等管理接口。它的工作原理是通过字符设备节点与内核驱动通信——打开/dev/ascend0后,发送npu_smi特有的ioctl命令查询设备信息:
npu-smi info -t board -i 0这样的命令最终落到驱动的ioctl处理函数中。驱动收集的信息包括:PCIe链路状态(连接速率和宽度是否降速)、HBM健康状态(ECC错误计数是否超过阈值)、AI Core使用率(多少个核心忙、多少个空闲)、固件版本号。这些数据写入共享的内核缓冲区,npu_smi从中读取后格式化显示。
故障诊断的另一个重要工具是内核日志。在驱动开发阶段和运维排查中,dmesg | grep hostdrv是最常用的调试手段。驱动在内核中通过pr_err、pr_warn和pr_info输出各级别的调试信息——从PCIe probe成功到任务超时告警都有日志。
结尾
理解昇腾驱动的工作方式对解决实际中的深层次问题极为有用。驱动框架的价值在于它在受限的内核环境下完成了硬件能力的暴露和资源的管理——从PCIe通信的建立、IOMMU地址空间的配置、中断处理的分层设计、到内存池的高效管理,每个环节的设计都直接决定了上层AI计算的性能和可靠性。锁页内存的强制使用保证了DMA传输的安全性,IOMMU提供了物理地址碎片场景下的连续访存能力,中断线程化将任务完成通知延迟控制在可预测范围内。驱动层是一个承上启下的角色——它把硬件的能力保护性暴露给用户态,同时屏蔽了硬件层的复杂性和局限性。对于需要深度排查性能问题或定制AI运行时的开发者,了解驱动框架的内部设计是必须具备的能力。
仓库地址:https://atomgit.com/cann/driver

5万+

被折叠的 条评论
为什么被折叠?



