前言
CANN五层架构里,Runtime排在第四层——昇腾计算执行层。上面是编译层(GE图编译器),下面是基础层(驱动DRV)。如果把CANN比作一个工厂,GE是排产计划系统,算子库是生产工具,那Runtime就是车间调度——它负责把编译好的任务分配到具体的NPU上执行,管理计算资源、内存资源和流(Stream)资源,确保任务按序完成。很多人用AscendCL写推理代码的时候,其实在不知不觉中就在调用Runtime的接口——aclrtMalloc分配内存、aclrtLaunchKernel启动算子、aclrtSynchronizeStream等待完成,这些都是Runtime提供的核心能力。CANN开源社区里的runtime仓库,在atomgit.com/cann上可以访问,是理解昇腾NPU执行模型的关键入口。
Runtime在CANN架构中的位置
先把Runtime在整个执行链路中的角色定位清楚。
一个模型从PyTorch代码到NPU上跑起来,经历这样一条链路:PyTorch前端 → TorchAir适配层 → GE图编译器 → 生成离线模型(om文件) → Runtime加载执行。
前半段(PyTorch到om文件)是编译期,由GE和算子库负责。后半段(加载om文件到执行完成)是运行期,由Runtime负责。
Runtime具体管什么?拆开来看有五个核心职责:
设备管理。Runtime负责发现和管理系统中的昇腾NPU设备,提供aclrtSetDevice/aclrtResetDevice接口来选择和释放设备。多卡场景下,每个进程通过SetDevice绑定到一张特定的NPU,后续所有操作都在这张卡上执行。
内存管理。昇腾NPU有两种内存:Device Memory(HBM,高带宽内存)和Host Memory(CPU侧内存)。Runtime提供aclrtMalloc/aclrtFree管理Device Memory,aclrtMallocHost/aclrtFreeHost管理Host Memory,还有aclrtMemcpy做Host和Device之间的数据搬运。内存管理的细节直接决定了推理的性能——频繁的Host-Device搬运是常见的性能瓶颈。
流管理。Stream是昇腾NPU上的任务队列,所有算子调用都提交到Stream上按序执行。Runtime提供aclrtCreateStream/aclrtDestroyStream创建和销毁Stream,aclrtSynchronizeStream等待Stream上所有任务完成。多Stream并行是提升NPU利用率的关键手段——如果你有独立的计算任务和数据搬运任务,可以分别放在不同的Stream上并行执行。
事件管理。Event是Stream之间同步的机制。一个Stream上可以Record一个Event,另一个Stream可以Wait这个Event,从而实现跨Stream的同步。这在双流并行推理中很常见:Stream 0做计算,Stream 1做数据搬运,Stream 1通过Event等待Stream 0的计算结果就绪后才开始搬运。
模型和算子执行。Runtime提供aclmdlLoadFromFile/aclmdlExecute加载和执行离线模型,以及aclrtLaunchKernel启动单个Ascend C算子。这是Runtime最核心的能力——把编译产物变成NPU上真正运行的指令。
Runtime的核心接口拆解
上面列了五大职责,下面用代码示例逐个拆解关键接口。以一个典型的模型推理流程为例:
#include"acl/acl.h"#include<cstdio>intmain(){// 1. 初始化AscendCL运行时// aclInit读取CANN的配置文件,初始化驱动层和运行时层// 为什么放在最前面?因为后续所有ACL接口都依赖运行时初始化完成aclError ret=aclInit(nullptr);if(ret!=ACL_SUCCESS){printf("aclInit failed, ret=%d\n",ret);return-1;}// 2. 选择NPU设备// device_id=0表示使用第一张NPU卡// SetDevice会创建该设备的Context,后续操作默认在这个Context下执行intdevice_id=0;ret=aclrtSetDevice(device_id);if(ret!=ACL_SUCCESS){printf("aclrtSetDevice failed, ret=%d\n",ret);return-1;}// 3. 创建Stream// Stream是任务队列,所有异步操作提交到Stream上// 为什么不直接在默认Stream上跑?因为自定义Stream可以做双流并行,// 默认Stream是串行的,无法和其他Stream并行aclrtStream stream=nullptr;ret=aclrtCreateStream(&stream);// 4. 加载离线模型// om文件是GE编译后的产物,包含了计算图和算子实现// LoadFromFile把om文件加载到Device Memory,返回model_id用于后续执行aclmdlModel model_id;ret=aclmdlLoadFromFile("resnet50.om",&model_id);// 5. 分配输入输出内存// aclrtMalloc分配Device Memory,ACL_MEM_MALLOC_HUGE_FIRST优先使用大页内存// 为什么用大页?减少TLB Miss,对大张量的连续访问更友好size_t input_size=224*224*3*sizeof(float);// ResNet50输入size_t output_size=1000*sizeof(float);// 1000类概率void*input_buf=nullptr,*output_buf=nullptr;aclrtMalloc(&input_buf,input_size,ACL_MEM_MALLOC_HUGE_FIRST);aclrtMalloc(&output_buf,output_size,ACL_MEM_MALLOC_HUGE_FIRST);// 6. Host到Device数据搬运// 准备输入数据并拷贝到Device Memory// 为什么分两步?因为Host和Device的内存地址空间不同,// 必须通过PCIe DMA搬运,不能直接指针访问floatinput_data[224*224*3];prepare_input(input_data);// 假设这个函数准备输入数据aclrtMemcpy(input_buf,input_size,input_data,input_size,ACL_MEMCPY_HOST_TO_DEVICE);// 7. 执行推理// aclmdlExecute是异步的,任务提交到Stream后立即返回// 实际执行在NPU上进行,和Host端的CPU并行aclmdlDataset*input_dataset=create_input_dataset(input_buf,input_size);aclmdlDataset*output_dataset=create_output_dataset(output_buf,output_size);ret=aclmdlExecute(model_id,input_dataset,output_dataset,stream);// 8. 等待推理完成// SynchronizeStream会阻塞Host线程,直到Stream上所有任务完成// 为什么不在Execute之后直接读output_buf?因为Execute是异步的,// 此时NPU可能还在计算,直接读会拿到不完整的数据aclrtSynchronizeStream(stream);// 9. Device到Host数据搬运floatoutput_data[1000];aclrtMemcpy(output_data,output_size,output_buf,output_size,ACL_MEMCPY_DEVICE_TO_HOST);// 10. 清理资源aclrtFree(input_buf);aclrtFree(output_buf);aclmdlUnload(model_id);aclrtDestroyStream(stream);aclrtResetDevice(device_id);aclFinalize();return0;}这段代码覆盖了Runtime最核心的接口调用流程。有几个要点值得单独展开。
内存管理的深层细节
aclrtMalloc分配Device Memory时,有几种内存分配策略:
ACL_MEM_MALLOC_HUGE_FIRST——优先使用大页内存(2MB或1GB的大页),适合大张量的场景。大页内存的好处是减少TLB Miss,对于ResNet50这种输入张量尺寸固定的模型,使用大页可以让PCIe DMA搬运更高效。
ACL_MEM_MALLOC_NORMAL_FIRST——优先使用普通页内存(4KB),适合小张量或者内存碎片较多的场景。大页内存是有限的资源,如果分配过多会导致后续大页分配失败。
ACL_MEM_MALLOC_HUGE_ONLY——只使用大页内存,分配失败则返回错误。适用于对性能要求极致、且能确保大页内存充足的场景。
Device Memory的管理有一个容易踩的坑:内存池复用。Runtime内部维护了一个Device Memory的内存池,aclrtFree释放的内存不会立即归还给驱动,而是缓存在内存池里供后续aclrtMalloc复用。这意味着如果你在推理过程中反复分配和释放同样大小的缓冲区,实际开销只有第一次分配,后续都是池内复用,速度很快。但这也意味着aclrtFree之后,npu-smi工具显示的显存占用不会立即下降——这在调试显存泄漏的时候容易误导人。
Host Memory的管理同样需要注意。aclrtMallocHost分配的Host Memory是锁页内存(Pinned Memory),不会被操作系统换出到磁盘。PCIe DMA只能访问锁页内存,所以aclrtMemcpy的Host端地址必须是锁页内存或者通过aclrtMallocHost分配的。如果你传了一个普通的malloc地址给aclrtMemcpy,Runtime会先把它拷贝到内部的锁页缓冲区,再做DMA搬运,多了一次内存拷贝的开销。
流和事件的并行模型
Stream是理解Runtime并行模型的关键。默认情况下,每个设备有一个Default Stream,所有没有显式指定Stream的ACL操作都会提交到Default Stream上。Default Stream上的操作是串行的——前一个操作完成后才开始下一个。
要实现并行,必须创建多个Stream。一个典型的双流推理架构:
aclrtStream compute_stream,copy_stream;aclrtCreateStream(&compute_stream);// 计算流aclrtCreateStream(©_stream);// 搬运流// 推理循环中的双流并行for(inti=0;i<batch_count;i++){// copy_stream搬运第i+1个batch的输入到Device// 为什么不和计算串行?因为PCIe搬运和NPU计算可以并行,// 搬运第i+1个输入的同时,NPU在第i个输入上做推理if(i+1<batch_count){aclrtMemcpyAsync(input_buf,input_size,host_input[i+1],input_size,ACL_MEMCPY_HOST_TO_DEVICE,copy_stream);}// compute_stream执行第i个batch的推理aclmdlExecute(model_id,input_dataset,output_dataset,compute_stream);// compute_stream等待copy_stream的搬运完成(仅对下一个batch需要)// 为什么用Event而不是SynchronizeStream?// SynchronizeStream会阻塞Host线程直到copy_stream上所有操作完成,// Event只阻塞compute_stream上的后续操作,Host线程可以继续做其他事if(i>0){aclrtEvent copy_done;aclrtCreateEvent(©_done);aclrtRecordEvent(copy_done,copy_stream);aclrtStreamWaitEvent(compute_stream,copy_done);}}这个双流模型在连续推理场景下效果显著。当NPU在处理第i个batch的推理时,PCIe同时在搬运第i+1个batch的输入数据,计算和搬运的时间重叠在一起,总吞吐量可以提升30%到50%。
Event的使用有一个注意点:Event对象创建后可以复用,但RecordEvent必须在Stream上按序调用。如果你在同一个Stream上连续Record多个Event,WaitEvent会等待到最近一次Record的位置,而不是所有Record都完成。实际使用中,建议每次Wait之前都重新Record,避免复用导致的同步错误。
模型下沉和动态Shape
Runtime加载om文件后,模型的计算图已经固化了——包括算子序列、Tiling参数、内存排布等。这意味着模型的输入Shape必须是固定的,或者至少在编译时声明的Shape范围内。
但实际业务中,输入Shape往往是动态的——比如NLP模型的序列长度、目标检测模型的batch size都可能变化。CANN 8.0开始支持动态Shape,需要在模型编译时通过GE的–dynamic_batch_size或–dynamic_image_size参数声明支持的Shape范围,Runtime在执行时通过aclmdlSetDatasetBuffer动态指定当前batch的实际Shape。
动态Shape的代价是性能下降。因为GE在编译时无法针对特定Shape做极致的Tiling优化,只能生成通用的Tiling策略。实测数据:固定Shape的ResNet50推理吞吐约1200 images/s,支持动态batch(1-32)后吞吐下降到约950 images/s,降幅约20%。
如果业务允许,建议对高频Shape分别编译专用的om模型,Runtime根据当前batch size选择对应的模型加载执行,比通用动态Shape方案的性能更好。
使用前后效率对比
以一个实际的推理服务为例:ResNet50图像分类,batch size=32,连续推理1000个batch。
| 对比维度 | 单流串行推理 | 双流并行推理 | 双流+大页内存 |
|---|---|---|---|
| 单batch延迟 | 8.2ms | 8.2ms | 7.8ms |
| 吞吐量(images/s) | 3900 | 5100 | 5600 |
| Host-Device搬运耗时占比 | 23% | 7%(被计算掩盖) | 5%(被计算掩盖) |
| NPU利用率 | 68% | 82% | 87% |
| 显存占用 | 1.2GB | 1.8GB(双缓冲) | 1.8GB |
| 代码复杂度 | 低 | 中 | 中 |
双流并行的效果很直观:NPU利用率从68%提升到82%,吞吐量提升30%。加上大页内存后,TLB Miss减少,吞吐量再提升约10%。
显存占用的增加是因为双流需要两份输入缓冲区——当前batch和下一个batch的输入同时存在于Device Memory中。这是空间换时间的典型权衡。
再来看动态Shape对性能的影响:
| Shape策略 | 编译时配置 | 推理吞吐(batch=32) | 推理吞吐(batch=1) |
|---|---|---|---|
| 固定Shape batch=32 | –batch_size=32 | 5600 images/s | 不支持 |
| 固定Shape batch=1 | –batch_size=1 | 不支持 | 210 images/s |
| 动态Shape batch=1-32 | –dynamic_batch_size=1,4,8,16,32 | 4500 images/s | 180 images/s |
| 双om模型 | 分别编译两个om | 5600 images/s(切模型) | 210 images/s |
动态Shape在batch=32时比固定Shape慢约20%,在batch=1时慢约14%。如果业务上同时需要batch=1和batch=32,双om模型方案虽然需要额外的显存加载两个模型,但性能比动态Shape方案好25%以上。
Runtime和GE的边界
开发者在实际使用中经常困惑:哪些事情是Runtime做的,哪些是GE做的?边界在哪里?
编译期的事情归GE:图优化、算子融合、Tiling计算、内存规划。这些操作发生在模型编译阶段,产出的om文件包含了所有编译期决策的结果。
运行期的事情归Runtime:设备管理、内存分配、任务提交、同步等待。Runtime拿到om文件后,按照里面的指令序列在NPU上执行,不做额外的优化决策。
一个常见的误解是:Runtime会在运行时做算子融合。实际上不会。Runtime只是执行GE已经融合好的算子序列。如果你在运行时发现两个算子没有被融合,那问题在编译期——GE的图优化Pass没有识别到融合机会,或者融合条件不满足。需要回到GE侧排查,不是Runtime的问题。
结尾
Runtime是CANN架构中离硬件最近的软件层(驱动层更近但对开发者不可见),它的核心价值是提供一套统一的资源管理和任务执行接口,让开发者不需要直接操作硬件寄存器就能驱动昇腾NPU工作。理解Runtime的内存模型、流和事件的并行机制、动态Shape的性能代价,对写出高性能的推理服务至关重要。如果你在做昇腾NPU上的模型部署,Runtime是你每天都要打交道的接口层,值得花时间深入理解。
仓库地址:https://atomgit.com/cann/runtime