个人主页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()contextacl.rt.create_context(0)# 创建默认Stream和专用Streamdefault_streamacl.rt.create_stream()# 默认计算流h2d_streamacl.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_sizerequest_size){aclrtMalloc(pool_base,EXPAND_SIZE,ACL_MEM_MALLOC_HUGE_FIRST);pool_sizeEXPAND_SIZE;}// 从池中切分不走驱动void*ptrpool_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_tensoracl.rt.malloc(1024*1024*512,# 512MBacl.ACL_MEM_MALLOC_HUGE_FIRST)# 小缓冲区用NORMAL_FIRSTsmall_bufacl.rt.malloc(1024*4,# 4KBacl.ACL_MEM_MALLOC_NORMAL_FIRST)# 使用完归还到池不是真正freeacl.rt.free(big_tensor)acl.rt.free(small_buf)任务队列从计算图到硬件指令的最后一跳ge编译完的计算图最终被拆解成一系列Task由runtime下发给驱动。每个Task对应一个硬件可执行的指令包runtime负责把这些Task按Stream编排、按依赖排序、按优先级调度。// runtime内部Task下发的简化流程// 1. 接收ge编译后的Task列表TaskList*tasksge_get_compiled_tasks(graph);// 2. 按Stream分配Taskfor(task in tasks){streamget_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_OPTIMIZE1# 设置Stream优先级数值越小优先级越高exportASCEND_STREAM_PRIORITY0在五层架构里占什么位置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不是孤立存在的它的上下游关系很清晰上游——gege编译完计算图后把优化后的Task列表下沉给runtime执行。ge管怎么算更优runtime管怎么跑更快。ge在编译期做的算子融合、流水线切分、内存复用规划最终都体现为Task列表里的依赖关系和内存地址。runtime拿到这份Task列表后按照依赖顺序和Stream分配策略把Task逐个下发到硬件。下游——Driverruntime通过Driver的HAL接口操作硬件。内存分配、Task下发、硬件状态查询最终都走Driver。runtime不直接碰硬件寄存器。这意味着同一套runtime代码只要Driver适配了不同的芯片型号就能跑在不同的昇腾NPU上——这正是硬件抽象的意义。下游——AscendCLAscendCL是CANN对外的统一编程接口runtime的Stream、Event、内存管理能力都通过AscendCL暴露给开发者。你调用的aclrtCreateStream底层就是runtime在干活。关联——hcclHCCL的集合通信操作AllReduce、AllGather等在runtime的Stream上运行依赖runtime的同步机制保证通信和计算的执行顺序。HCCL不直接操作硬件它把通信Task提交到runtime的Stream上由runtime统一调度。这种设计保证通信和计算可以正确地交叉执行——比如先算两轮再做一次AllReduce再算两轮全程在同一个Stream上下文中有序推进。ge → runtime → Driver ↑ AscendCL对外接口 hccl分布式通信// hccl在runtime Stream上运行的简化示意// hccl的AllReduce本质上是向Stream提交通信TaskhcclResult_trethcclAllReduce(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_ENABLE1# 检测未同步的Device内存访问exportASCEND_MEM_CHECK1# 查看runtime内部Stream和Event状态cat/usr/local/Ascend/ascend-toolkit/latest/runtime/conf/runtime.conf想真正理解runtime最直接的方式是写一个多Stream并行的推理程序手动管理Event和内存池。代码量不大但能把Stream编排、同步等待、内存复用这几个核心概念全部串起来。仓库地址https://atomgit.com/cann/runtime 里面有你需要的头文件和示例。