sip(System Interface Protocol):CANN软件栈中最靠近硬件的NPU系统管理层全解析
前言
在昇腾NPU上运行一个算子时,runtime需要知道NPU是否存在、有没有空闲的内存、固件是否已经加载完毕。这些看似基础的查询和操作,在CANN软件栈中有专门的一层来负责——sip(System Interface Protocol)。它不像ops系列那样直接面向计算场景,也不像driver那样沉浸在硬件的内核态细节中。sip占据的是一个中间位置:向上为runtime和上层框架提供标准化的设备管理接口,向下通过driver与硬件对话。理解sip的职责边界和设计思路,才能真正说清楚一个算子从用户态请求到硬件执行的完整链条中,每一层各自承担了什么角色。
sip在CANN栈中的位置
CANN的分层架构可以简化为五层:应用层、推理框架层、算子库层、计算语言层、计算基础层。sip明确归属于最底层——计算基础层,与driver处于同一层级但分工完全不同。
driver运行在内核态,负责管理硬件抽象、中断处理、设备内存映射等操作系统层面的工作。sip运行在用户态,提供的是更高层次的设备管理抽象。如果把NPU比作一栋楼,driver是楼宇的水电基础设施——管道、电线、承重墙,sip则是物业管理系统——登记入住、分配房间、处理报修、检查水电是否正常。
sip的核心职责覆盖四个领域。设备发现——系统启动后sip扫描所有可用的NPU设备,建立设备列表,记录每个设备的型号、固件版本、内存总量等元信息。内存分配——当runtime需要分配设备内存时,sip负责管理整个设备内存的分配和回收,维护空闲内存记录,处理内存碎片。固件加载——在设备初始化阶段,sip驱动driver完成固件加载流程,确保AI Core的固件正确启动。错误上报——当硬件出现异常时,sip从driver层获取错误信息,整理后上报给runtime,runtime据此决定是否要终止任务执行。
理解sip在CANN进程模型中的位置也很重要。CANN采用多进程架构,不同的组件运行在不同的进程空间中。sip运行在一个独立的守护进程或者服务进程中,为其他进程提供远程过程调用(RPC)风格的设备管理服务。这种隔离设计确保了设备管理的稳定性和安全性——即使某个算子进程崩溃了,设备状态仍然由sip维护,不会受到影响。
设备生命周期管理的完整流程
NPU设备从接入系统到报废使用,经历若干个明确的状态转换。sip负责管理这个完整生命周期。这个过程比大多数人想象的要复杂得多。
设备插入或者系统启动时,driver率先探测到硬件,在Linux内核中注册设备节点。sip通过IOCTL与driver通信,获取设备列表。这是设备发现的初始阶段。sip向runtime报告设备可用,但此时设备还不能运行计算任务,固件还没有加载。
固件加载紧随其后。sip向driver发出固件加载请求,driver读取固件二进制文件,通过PCIe或设备专有通道把固件写入AI Core的指定地址,再发送启动命令让固件开始执行。固件启动成功后,AI Core进入可调度状态,设备才算真正可用。这个过程可能持续数秒到数十秒,取决于固件大小和传输带宽。
设备可用之后,runtime就可以分配内存、提交任务了。这些操作本质上都是通过sip这个中间层来协调的。当所有任务完成后,runtime通知sip关闭设备。sip清理已分配的内存,通知driver卸载固件、关闭设备。最终设备回到未初始化状态。
classSipDeviceManager:def__init__(self):self.devices={}self.firmware_loaded=Falsedefdiscover_devices(self):dev_list=self.__query_kernel_devices()fordindev_list:info=self.__read_device_info(d)self.devices[d.id]=inforeturnlen(self.devices)设备管理接口被封装成SipDeviceManager类,而不是直接对外暴露设备列表字典。这种封装让设备发现的内部细节——比如如何通过IOCTL查询driver、如何解析设备信息结构体——完全隐藏在内部方法__query_kernel_devices和__read_device_info中。外部调用者不需要知道这些底层细节,只需要知道有多少设备可用就够了。这是典型的接口隔离原则在系统软件层的应用。
内存分配的核心路径
内存管理是sip最核心的实用功能之一。NPU设备内存是一种有限且宝贵的资源,多个计算任务共享同一块设备内存。sip需要在请求到来时快速分配,在任务结束时及时回收,同时还要避免碎片化导致分配失败。
内存分配的标准路径是:runtime调用acltRtMalloc,这个调用经过CANN的抽象层转发给sip。sip收到请求后,查询内部的空闲内存记录,找到满足大小要求的内存块,标记为已分配,把设备物理地址返回给runtime。runtime拿到地址后,就可以在任务提交时使用这个地址作为输入输出张量的存储位置。
// sip内存分配核心逻辑DeviceAddrSipAllocMemory(size_t size){LockGuardguard(&mem_lock);for(auto&block:free_list){if(block.size>=size){DeviceAddr addr=block.addr;block.size-=size;if(block.size==0)free_list.remove(block);allocated_map[addr]=size;returnaddr;}}returnNULL_ADDR;}这个分配函数体现了最佳适配(best-fit)策略。遍历空闲链表找到第一个大小满足请求的块,而不是使用首次适配或者最差适配。最佳适配在NPU设备内存这种资源受限的场景中,能最大程度减少大块内存的浪费。lock_guard的使用说明这是一个需要线程安全保护的操作——多个算子线程可能同时申请内存,没有锁保护会导致分配记录错乱。
内存回收时,sip需要处理一种特殊情况:归还的内存块可能和相邻的空闲块合并。如果不合并,连续分配和释放会导致内存碎片越来越多,最终出现有足够总量但找不到连续空间的尴尬局面。
// sip内存回收与碎片合并voidSipFreeMemory(DeviceAddr addr){LockGuardguard(&mem_lock);size_t size=allocated_map[addr];allocated_map.erase(addr);for(auto&block:free_list){if(block.addr+block.size==addr){block.size+=size;return;}if(addr+size==block.addr){block.addr=addr;block.size+=size;return;}}free_list.push_back({addr,size});}释放操作并不只是把内存块放回空闲链表那么简单。函数会先检查新释放的块是否紧邻某个已有的空闲块的末尾——如果是,直接合并;否则检查是否紧邻某个空闲块的开头——如果是,向前合并。只有两种合并都失败时,才作为独立块加入链表。这种设计针对的就是设备内存的碎片问题。NPU上的内存通常不提供硬件MMU级别的虚拟地址映射,物理地址必须是连续的,因此碎片控制比通用操作系统更加重要。
固件加载的幕后工作
固件加载在NPU使用中体现为两个阶段。系统初始化时,sip加载主固件,让AI Core进入可调度状态。运行时,如果某些高级特性需要额外的微码支持,sip可能还需要加载辅助固件。
加载过程涉及几个步骤。sip先从文件系统读取固件二进制数据到内存缓冲区。再通过driver提供的IOCTL命令,把固件数据传输到NPU的固件加载区域。传输完成后,driver向AI Core发送启动信号,固件开始执行初始化代码。固件初始化完成后,向主机侧发送一个就绪信号。sip通过轮询或者中断方式等待这个信号,确认固件加载成功。
如果固件加载失败,sip会尝试重新加载。重试次数通常有上限,超过上限后sip将设备标记为不可用,并向runtime返回错误。这种容错机制确保偶尔的传输错误不会导致整个系统不可用。
硬件错误的上报机制
NPU硬件在运行中可能出现各种异常:内存位翻转、计算单元超时、温度过高、电源异常等。这些硬件错误如果被忽视,会导致计算结果出错,甚至造成硬件损坏。sip承担了硬件错误的主动发现和上报职责。
错误检测的路径是:NPU硬件检测到异常后,触发设备侧中断。driver的硬件中断处理函数捕获这个中断,读取错误状态寄存器,把原始错误信息传递到用户态。sip从driver层获取这些错误信息,进行格式化、分类、聚合。格式化是指把硬件层面的错误码转换成有意义的错误描述。分类是指区分严重程度——有些错误可以继续运行,有些错误必须终止任务。聚合是指短时间内相同的错误不需要重复上报,避免日志风暴。
// sip错误信息处理ErrorReportSipParseError(uint64_traw_status){ErrorReport report;report.source=(raw_status>>48)&0xFF;report.code=raw_status&0xFFFF;report.severity=(raw_status>>32)&0xF;if(report.severity>=FATAL){report.action=HALT_DEVICE;}else{report.action=REPORT_ONLY;}returnreport;}这个函数展示了sip在错误处理中的核心逻辑:把硬件原始状态寄存器值解析成结构化的错误报告。硬件错误寄存器是一段压缩的二进制位域,直接暴露给上层使用既不友好也不安全。sip进行的一次解析把原始位域转换成带有来源、代码、严重级别的结构化信息,让runtime能够做出正确的后续决策。严重级别大于等于FATAL的错误触发设备停止,小于FATAL的错误只上报不停止——这种分级处理避免了对非致命错误的过度反应。
使用前后的效率对比
在没有sip的系统设计中,每个上层组件需要自己处理设备发现、内存管理、固件加载等基础操作。这不仅导致大量重复代码,还容易因为实现差异引发兼容性问题。引入sip之后,设备管理被集中到统一的抽象层,各个组件通过标准化接口交互,整个系统的可维护性和稳定性得到显著提升。
| 维度 | 使用前 | 使用后 | 差异来源 |
|---|---|---|---|
| 设备发现 | 每个组件各自扫描NPU设备,代码重复,发现结果不一致的概率较高 | 所有设备信息由sip统一管理,各组件通过sip查询设备状态 | sip提供了统一的设备发现入口,避免了信息不一致 |
| 内存分配 | runtime直接通过driver分配内存,分配策略分散在各个模块中实现 | sip统一管理设备内存池,提供标准分配和释放接口 | sip集中了内存管理策略,减少了碎片和冲突 |
| 固件加载 | 各组件在初始化时各自触发固件加载,加载次数和顺序不可控 | sip在系统初始化时统一加载,确保固件仅加载一次 | 统一加载避免了重复加载和固件版本冲突 |
| 错误处理 | 硬件错误信息直接从driver传递到各个组件,解析逻辑重复且不一致 | sip统一解析硬件错误后上报,各组件获得标准化的错误报告 | sip的解析和分类减少了各组件处理错误的复杂度 |
| 跨进程协作 | 设备状态在各个进程之间不一致,容易因为状态不同步导致异常 | sip运行在独立服务进程中,各进程通过sip查询和协调设备状态 | 独立的设备管理进程保证了状态的一致性和稳定性 |
| 开发效率 | 每个设备管理功能都需要上层组件自行实现,开发周期较长 | sip提供了完整的设备管理API,上层组件可以直接调用 | 标准API让上层开发更关注业务逻辑,不需要重复实现底层管理功能 |
从表格可以看出,sip引入后最根本的变化是把设备管理从分散到集中,从重复到复用,从随意到规范。这种变化带来的不只是开发效率的提升,更重要的是整个系统的稳定性和可预测性得到了保障。
sip与driver和runtime的边界划分
理解sip的职责,还需要厘清它和driver及runtime的边界在哪里。这三个组件在CANN栈中属于同一层级或相邻层级,职责划分是否清晰直接影响系统的可维护性。driver处理的是与硬件直接交互的最底层操作:IOCTL命令的实现、中断处理、设备内存的物理映射、直接寄存器读写。driver运行在内核态,拥有操作系统级别的权限。sip不会直接操作硬件寄存器,也不会注册中断处理函数,这些是driver的专属领域。
runtime处理的是算子执行的上层逻辑:任务流的构建、stream的管理、同步机制的实现。runtime调用sip的接口完成设备管理和硬件抽象操作,但它本身不直接管理设备状态。sip不参与任务流的构建,也不关心算子的具体执行逻辑。
sip处于driver和runtime之间,起到承上启下的作用。它封装了driver提供的底层能力,向上提供更加易用的设备管理接口。runtime不需要关心设备如何在Linux内核中表示,也不需要在多个IOCTL中穿梭——它只需要调用sip的API,sip负责把请求转换成对应的driver操作。
仓库地址:https://atomgit.com/cann/sip
