个人主页:ujainu
文章目录
- 前言
- 为什么需要运行时这一层
- runtime管什么,不管什么
- Stream:并行的基本调度单位
- Event:跨Stream的同步锚点
- 内存池化:少一次malloc就少一次卡顿
- 任务队列:从计算图到硬件指令的最后一跳
- 在五层架构里占什么位置
- 和上下游仓库怎么协作
- 关键警告:runtime最容易踩的两个坑
前言
一个模型从编译完成到真正在芯片上执行,中间隔着一条鸿沟——谁把计算图拆成硬件能认的指令?谁管理多路并发不冲突?谁保证算完一步再走下一步?在昇腾CANN的架构里,这个活落在runtime头上。runtime是昇腾NPU的运行时层,驻守在编译层和硬件驱动之间,负责任务调度与资源管理。没有它,ge编译出来的计算图只是一份"图纸",永远变不成芯片上的电信号。
仓库地址:https://atomgit.com/cann/runtime
为什么需要运行时这一层
先看一个反直觉的事实:同一个模型,ge编译后直接逐算子下发给驱动执行,吞吐可能只有优化后的五分之一。差的不是算子本身,而是调度——算子之间的等待、内存的反复申请释放、多流之间的冲突,全是运行时该管的事。
runtime存在的原因可以归结为三点:
- 硬件并发需要编排——昇腾NPU有多组计算单元,不编排就是单线程串行,编排好了多流并行吞吐翻倍
- 内存不能乱来——NPU显存有限,无规划的分配会导致碎片化,甚至OOM
- 同步不能靠猜——多流之间有依赖关系,不同步就出数据竞争,调试到怀疑人生
runtime管什么,不管什么
runtime的职责边界需要说清楚,否则很容易把它和ge、Driver混为一谈:
runtime管的:
- Stream的创建、销毁与任务编排
- Event的记录与跨Stream同步
- Device显存的池化分配与回收
- Task队列的异步下发与完成通知
- 多Stream间的资源隔离与共享
runtime不管的:
- 计算图的算子融合与重排——这是ge的编译期职责
- 硬件寄存器的直接读写——这是Driver的基础层职责
- 算子的具体计算逻辑——这是Ascend C算子开发的事
- 分布式通信的拓扑构建——这是hccl的事,runtime只提供Stream承载
// runtime管与不管的边界示例// ✅ runtime管:创建Stream、分配显存、下发TaskaclrtStream stream;aclrtCreateStream(&stream);aclrtMalloc(&dev_ptr,size,ACL_MEM_MALLOC_HUGE_FIRST);// ❌ runtime不管:算子融合策略、硬件寄存器配置// 这些分别由ge编译期和Driver负责理解这条边界,才能在遇到问题时定位到正确的仓库:调度异常找runtime,算子执行异常找Ascend C算子或Driver,编译结果不对找ge。
Stream:并行的基本调度单位
Stream是runtime最核心的抽象。你可以把它理解成一条独立的任务流水线——同一个Stream里的任务严格按顺序执行,不同Stream之间的任务可以并行。
// 创建两个Stream,让数据拷贝和计算重叠aclrtStream stream1,stream2;aclrtCreateStream(&stream1);aclrtCreateStream(&stream2);// stream1 做数据搬运aclrtMemcpyAsync(dev_a,size,host_a,size,ACL_MEMCPY_HOST_TO_DEVICE,stream1);// stream2 做计算,不等stream1aclrtLaunchKernel(kernel1,...,stream2);这段代码的关键不是"怎么写",而是理解runtime在背后做了什么——两条Stream的任务队列独立维护,硬件调度器从不同队列取任务并发执行。一个常见的坑:以为创建多个Stream就能自动并行,但如果没有Event同步,stream2可能在数据还没搬完时就开始计算。
Stream的数量不是越多越好。每个Stream在硬件侧对应一组任务队列和上下文资源,创建过多会挤占执行资源。实际使用中,2-4条Stream通常足以覆盖H2D搬运、D2H回传、计算这三条流水线。
# Python AscendCL调用中创建Stream的典型方式importacl# 初始化acl.init()context=acl.rt.create_context(0)# 创建默认Stream和专用Streamdefault_stream=acl.rt.create_stream()# 默认计算流h2d_stream=acl.rt.create_stream()# 专用搬运流# 异步拷贝使用专用Streamacl.rt.memcpy_async(dev_buf,size,host_buf,size,acl.ACL_MEMCPY_HOST_TO_DEVICE,h2d_stream)Event:跨Stream的同步锚点
Stream解决了"谁跟谁并行"的问题,Event解决的是"谁等谁"的问题。Event是一个轻量的同步原语,插在Stream的某个位置,其他Stream可以等待这个Event完成。
// stream1搬完数据后记录一个EventaclrtEvent event;aclrtCreateEvent(&event);aclrtRecordEvent(event,stream1);// stream2等待这个Event,确保数据就绪aclrtStreamWaitEvent(stream2,event);// 现在stream2的计算可以安全使用dev_a了aclrtLaunchKernel(kernel2,...,stream2);Event的延迟极低——它不是CPU侧的轮询等待,而是硬件层面的信号量机制。runtime把Event映射到昇腾NPU的硬件同步原语上,开销几乎可以忽略。但别滥用:每个Event占用硬件资源,同时活跃的Event数量有上限。
Event还有一个常见用法是计时。通过在Stream前后各Record一个Event,然后计算两个Event之间的耗时,可以精确测量某段Device侧执行的时长,精度远高于Host侧的时钟:
// 用Event测量Device侧执行耗时aclrtEvent start,stop;aclrtCreateEvent(&start);aclrtCreateEvent(&stop);aclrtRecordEvent(start,stream);aclrtLaunchKernel(kernel,...,stream);// 要测量的执行aclrtRecordEvent(stop,stream);aclrtSynchronizeStream(stream);// 等执行完floatelapsed_ms;aclrtEventElapsedTime(&elapsed_ms,start,stop);printf("kernel耗时: %.3f ms\n",elapsed_ms);内存池化:少一次malloc就少一次卡顿
NPU显存的分配和释放是昂贵操作。直接调用驱动的内存接口,每次都要走完整的分配路径,包括物理页映射和TLB刷新。runtime的做法是池化——预分配一大块显存,内部按需切分。
// runtime内部大致的内存池逻辑(简化示意)// 不是真实API,只是说明池化思路// 首次分配时向驱动申请大块if(pool_size<request_size){aclrtMalloc(&pool_base,EXPAND_SIZE,ACL_MEM_MALLOC_HUGE_FIRST);pool_size+=EXPAND_SIZE;}// 从池中切分,不走驱动void*ptr=pool_alloc(request_size);// 释放时归还池,不真正freepool_free(ptr);池化的收益不只是快。连续内存分配意味着更少的内存碎片,对于大模型推理这种显存吃紧的场景,碎片化往往是OOM的直接原因。runtime的内存池还支持多Stream间的共享,避免每个Stream各占一块、互相浪费。
runtime的内存池有两种分配策略,通过aclrtMalloc的参数选择:
ACL_MEM_MALLOC_HUGE_FIRST:优先分配大页内存,减少TLB miss,适合大tensorACL_MEM_MALLOC_NORMAL_FIRST:优先分配普通页内存,适合小tensor和临时缓冲区
# Python中的内存分配策略选择importacl# 大tensor用HUGE_FIRSTbig_tensor=acl.rt.malloc(1024*1024*512,# 512MBacl.ACL_MEM_MALLOC_HUGE_FIRST)# 小缓冲区用NORMAL_FIRSTsmall_buf=acl.rt.malloc(1024*4,# 4KBacl.ACL_MEM_MALLOC_NORMAL_FIRST)# 使用完归还到池(不是真正free)acl.rt.free(big_tensor)acl.rt.free(small_buf)任务队列:从计算图到硬件指令的最后一跳
ge编译完的计算图,最终被拆解成一系列Task,由runtime下发给驱动。每个Task对应一个硬件可执行的指令包,runtime负责把这些Task按Stream编排、按依赖排序、按优先级调度。
// runtime内部Task下发的简化流程// 1. 接收ge编译后的Task列表TaskList*tasks=ge_get_compiled_tasks(graph);// 2. 按Stream分配Taskfor(task in tasks){stream=get_stream(task);enqueue(stream,task);// 入队,不立即执行}// 3. 触发硬件执行aclrtSynchronizeStream(stream);一个关键特征:runtime的任务队列是异步的。调用aclrtLaunchKernel时,Task只是入队,不会等硬件执行完才返回。这意味着CPU可以继续准备下一个Task,实现Host和Device的流水线重叠。但如果你忘了同步就去读结果——恭喜,数据可能是半成品。
异步队列带来的另一个好处是批处理。多个Task连续入队后,runtime会尝试将它们合并成更大的指令包一次性下发给硬件,减少Host-Device交互次数。这种"攒一批再发"的策略,在大batch推理时尤为有效:
# 通过环境变量控制runtime的Task下发行为# 开启Task聚合,减少下发次数exportASCEND_LAUNCH_TASK_OPTIMIZE=1# 设置Stream优先级(数值越小优先级越高)exportASCEND_STREAM_PRIORITY=0在五层架构里占什么位置
runtime处在昇腾CANN五层架构的第4层——昇腾计算执行层,同层还有Graph Executor、HCCL、DVPP、AIPP。这一层是"干活的层":编译层负责把计算图翻译好,执行层负责真正把活干完。
第3层 ge(编译层) → 输出编译后的Task列表 ↓ 第4层 runtime(执行层) → Stream编排、内存管理、Task下发 ↓ 第5层 Driver(基础层) → 硬件寄存器操作、物理内存映射runtime往上看,接收ge编译后的模型和Task;往下看,通过Driver接口操作硬件;横着看,HCCL的分布式通信也是在runtime的Stream机制上运行的。
runtime与AscendCL的关系需要特别说明。AscendCL是昇腾CANN对外的统一编程接口层,runtime的核心能力——Stream、Event、内存管理、Task下发——都通过AscendCL的API暴露给开发者。你在代码里调用的aclrtCreateStream、aclrtMalloc、aclrtLaunchKernel,看似是AscendCL的函数,底层实现全在runtime里。AscendCL更像一层薄薄的胶水,把runtime、hccl、DVPP等子系统的能力统一封装起来,给开发者一个一致的调用体验。
// AscendCL调用与runtime的对应关系// 开发者视角:调AscendCLaclrtCreateStream(&stream);// → runtime创建Stream对象aclrtMalloc(&ptr,size,flag);// → runtime内存池分配aclrtLaunchKernel(...,stream);// → runtime Task入队// runtime视角:接收请求后操作Driver// Stream创建 → 向Driver申请硬件队列资源// 内存分配 → 池化分配或向Driver申请新页// Task入队 → 写入Stream对应队列,通知硬件调度器和上下游仓库怎么协作
runtime不是孤立存在的,它的上下游关系很清晰:
上游——ge:ge编译完计算图后,把优化后的Task列表下沉给runtime执行。ge管"怎么算更优",runtime管"怎么跑更快"。ge在编译期做的算子融合、流水线切分、内存复用规划,最终都体现为Task列表里的依赖关系和内存地址。runtime拿到这份Task列表后,按照依赖顺序和Stream分配策略,把Task逐个下发到硬件。
下游——Driver:runtime通过Driver的HAL接口操作硬件。内存分配、Task下发、硬件状态查询,最终都走Driver。runtime不直接碰硬件寄存器。这意味着同一套runtime代码,只要Driver适配了不同的芯片型号,就能跑在不同的昇腾NPU上——这正是硬件抽象的意义。
下游——AscendCL:AscendCL是CANN对外的统一编程接口,runtime的Stream、Event、内存管理能力都通过AscendCL暴露给开发者。你调用的aclrtCreateStream,底层就是runtime在干活。
关联——hccl:HCCL的集合通信操作(AllReduce、AllGather等)在runtime的Stream上运行,依赖runtime的同步机制保证通信和计算的执行顺序。HCCL不直接操作硬件,它把通信Task提交到runtime的Stream上,由runtime统一调度。这种设计保证通信和计算可以正确地交叉执行——比如先算两轮,再做一次AllReduce,再算两轮,全程在同一个Stream上下文中有序推进。
ge → runtime → Driver ↑ AscendCL(对外接口) + hccl(分布式通信)// hccl在runtime Stream上运行的简化示意// hccl的AllReduce本质上是向Stream提交通信TaskhcclResult_tret=hcclAllReduce(sendbuf,recvbuf,count,hcclFloat,hcclSum,comm,stream// ← 使用runtime的Stream);// 通信Task入队后,runtime负责调度执行// 后续计算Task可以依赖这个Stream的顺序保证aclrtLaunchKernel(post_reduce_kernel,...,stream);关键警告:runtime最容易踩的两个坑
坑一:忘了同步就读结果。runtime的接口大多是异步的,Launch之后数据还在硬件上跑,CPU侧直接读Device内存拿到的是未定义数据。aclrtSynchronizeStream不是可选的,是必须的。
// 错误示范:异步Launch后直接读aclrtLaunchKernel(kernel,...,stream);aclrtMemcpy(host_result,size,dev_result,size,ACL_MEMCPY_DEVICE_TO_HOST);// ❌ 数据可能还没算完!// 正确做法:先同步再读aclrtLaunchKernel(kernel,...,stream);aclrtSynchronizeStream(stream);// ✅ 等硬件执行完aclrtMemcpy(host_result,size,dev_result,size,ACL_MEMCPY_DEVICE_TO_HOST);坑二:Stream之间隐式依赖。两个Stream操作同一块Device内存时,runtime不做自动同步。必须手动插Event,否则就是数据竞争——而且这种bug是概率性的,跑一百次可能只有三次出错,排查到崩溃。
# 排查Stream同步问题的实用命令# 开启runtime的同步检查日志exportASCEND_LOG_EVENT_ENABLE=1# 检测未同步的Device内存访问exportASCEND_MEM_CHECK=1# 查看runtime内部Stream和Event状态cat/usr/local/Ascend/ascend-toolkit/latest/runtime/conf/runtime.conf想真正理解runtime,最直接的方式是写一个多Stream并行的推理程序,手动管理Event和内存池。代码量不大,但能把Stream编排、同步等待、内存复用这几个核心概念全部串起来。仓库地址:https://atomgit.com/cann/runtime ,里面有你需要的头文件和示例。