深入解构 Runtime:异构计算任务的动态调度与执行引擎
在异构计算软件栈中,Runtime(运行时)扮演着承上启下的关键角色。向上,它为图引擎(GE)或应用层提供标准的 C/C++ API(如 ACL);向下,它通过系统调用与内核态的 Driver 交互,将逻辑上的计算任务转化为物理硬件可识别的指令流。Runtime 的核心职能在于计算图的动态实例化、异步指令流的高效编排以及异构资源的即时管理。
核心资源链接:
- CANN 组织链接: https://atomgit.com/cann
- Runtime 仓库链接: https://atomgit.com/cann/runtime
一、 动态形状(Dynamic Shape)的运行时推导与传播
静态编译无法覆盖所有深度学习场景,特别是 NLP 和检测网络中输入张量维度随 Batch 变化的场景。Runtime 必须建立一套高效的机制,在微秒级的时间窗口内完成形状信息的推导与传播。
1.1 全局形状缓冲区(Global Shape Buffer)管理
当编译期无法确定 Tensor 的具体维度时,Runtime 并不直接分配固定大小的内存,而是传递一个指向“形状描述符”的句柄。
在执行流中,Runtime 维护着一个位于 Device 侧全局内存中的 Shape Buffer。
- 数据流向: 上游算子计算出的 Output Shape 会被写入该 Buffer。
- 下游获取: 下游算子在执行前,其 Tiling 引擎会直接从该 Buffer 读取最新的维度信息。这避免了将 Shape 数据回传到 Host 侧(CPU)进行解析再下发的昂贵开销,实现了Device-Side Shape Inference。
1.2 动态 Tiling 参数的即时重算
对于动态形状算子,其计算核心(Kernel)的切分策略(Tiling)必须随输入变化。Runtime 在内核发射(Kernel Launch)前,会激活轻量级的 Tiling 计算函数。
该过程通常涉及以下步骤:
- 参数读取: 从上下文寄存器或内存中读取当前的
actual_shape。 - 公式计算: 根据预编译的 Tiling 公式,计算出 Block Dim(核心数)和 L1 Buffer 的切分大小。
- 参数固化: 将计算出的 Tiling 参数写入任务描述符(Task Descriptor),随 Kernel 一并下发。
二、 异步任务流(Stream)的高效编排与依赖管理
为了掩盖 Host 与 Device 之间的通信延迟(PCIe Latency),Runtime 采用全异步的编程模型。
2.1 Stream 对象的逻辑抽象
Stream 是 Runtime 中最核心的调度单元,它代表了一个按顺序执行的操作序列。
- 指令队列: 每个 Stream 内部维护一个 FIFO 的指令队列。Runtime 将
Memcpy、KernelLaunch等操作封装为 Task,推入队列。 - 非阻塞提交: 所有的 API 调用(如
aclrtLaunchKernel)在将 Task 推入队列后立即返回,不会等待硬件执行结束。这允许 CPU 继续准备下一个 Batch 的数据,实现 CPU 预处理与 NPU 计算的流水线并行。
2.2 跨流依赖与同步原语
在复杂的计算图中,不同 Stream 之间存在依赖关系(例如:计算流必须等待数据搬运流完成)。Runtime 引入了 Event(事件) 作为同步原语。
Runtime 内部维护一个依赖图(Dependency DAG),并通过以下机制保障顺序:
- Record Event: 在源 Stream 中插入一个记录点。当硬件执行到此处时,会更新 Event 的状态。
- Wait Event: 在目标 Stream 中插入一个等待点。硬件调度器在分发该 Stream 的后续指令前,会轮询 Event 状态,直到其变为“已触发”。
三、 内存管理的原子性与池化策略
频繁的 malloc/free 系统调用是高性能计算的大忌。Runtime 在用户态实现了一套精细的内存分配器(Allocator)。
3.1 异构内存池(Heterogeneous Memory Pool)
Runtime 启动时,会向 Driver 预申请大块的 Device 物理内存,并接管其管理权。
- 分级缓存策略: 针对不同大小的内存请求(如小块的 Const 权重 vs 大块的 Feature Map),Runtime 使用不同的空闲链表(Free List)进行管理,减少内存碎片。
- 异步释放(Deferred Free): 当用户调用
aclrtFree时,Runtime 并不会立即归还内存给操作系统,而是将其标记为“待回收”。只有当关联的 Stream 执行完毕(通过 Event 确认)后,该内存块才真正回到池中,确保了异步执行的安全性。
3.2 虚拟地址与物理地址的映射缓存
为了加速地址转换,Runtime 维护了一个 TLB(Translation Lookaside Buffer)的软件镜像。
当算子需要访问某块内存时,Runtime 优先在本地哈希表中查找其虚拟地址对应的物理基址,只有在缓存未命中时才向 Driver 发起 ioctl 查询。这种机制显著降低了内核态切换的频率。
以下是一个简化版的 C++ 类,展示了 Runtime 如何封装 Event 来处理异步依赖:
#include <atomic>
#include <vector>
#include <mutex>
// 模拟 Runtime 内部的任务基类
struct RuntimeTask {
virtual void Execute() = 0;
virtual ~RuntimeTask() = default;
};
// 模拟 Event 状态
enum class EventStatus {
RECORDED,
COMPLETE
};
class AsyncEvent {
public:
AsyncEvent() : status_(EventStatus::RECORDED) {}
// 硬件回调或轮询更新状态
void Signal() {
status_.store(EventStatus::COMPLETE, std::memory_order_release);
}
bool IsComplete() const {
return status_.load(std::memory_order_acquire) == EventStatus::COMPLETE;
}
private:
std::atomic<EventStatus> status_;
};
// 模拟 Stream 管理器
class ComputationStream {
public:
// 提交计算任务
void PushTask(RuntimeTask* task) {
std::lock_guard<std::mutex> lock(queue_mutex_);
task_queue_.push_back(task);
}
// 插入 Record Event 操作
void RecordEvent(AsyncEvent* event) {
// 在实际 Runtime 中,这里会向硬件队列写入一个 MARKER 指令
// 当硬件执行到该指令时,触发 Event 的 Signal()
PushTask(new MarkerTask(event));
}
// 插入 Wait Event 操作(阻塞流直到 Event 完成)
void WaitEvent(AsyncEvent* event) {
// 实际上是向硬件发送 WAIT 指令,硬件调度器会挂起当前队列
PushTask(new BarrierTask(event));
}
private:
std::vector<RuntimeTask*> task_queue_;
std::mutex queue_mutex_;
// 内部任务定义...
struct MarkerTask : public RuntimeTask {
AsyncEvent* evt;
MarkerTask(AsyncEvent* e) : evt(e) {}
void Execute() override { evt->Signal(); }
};
struct BarrierTask : public RuntimeTask {
AsyncEvent* evt;
BarrierTask(AsyncEvent* e) : evt(e) {}
void Execute() override {
// 忙等待模拟硬件栅栏
while (!evt->IsComplete());
}
};
};
四、 计算与通信的混合流水线调度
在分布式训练中,HCCL(集合通信库)的操作与计算操作并行是提升扩展性的关键。Runtime 负责协调这两类截然不同的任务。
4.1 通信任务的特殊的处理
通信算子(如 AllReduce)通常涉及复杂的网卡交互。Runtime 将这类任务分发到专用的通信流中。
Runtime 实现了通信计算重叠(Communication-Computation Overlap):
- 在反向传播计算梯度的同时,Runtime 立即触发上一层的梯度聚合通信。
- 通过精细的 Dependency 注入,确保只有在数据真正就绪(Ready)的时刻,通信任务才被提交给硬件,防止死锁或空转。
4.2 跨组件事务一致性
Runtime 充当了计算算子与通信组件的仲裁者。
它通过原子变量或硬件信号量,确保“计算产出 -> 内存刷新 -> 通信传输”这一链路的原子性。特别是在 RDMA 场景下,Runtime 必须保证用于通信的内存页被锁定(Pinned),防止操作系统将其换出(Swap Out)。
五、 算子执行上下文与资源隔离
为了支持多租户(Multi-tenancy)或多模型并发,Runtime 实现了严格的上下文(Context)管理。
5.1 Context 栈与线程局部存储
Runtime 使用线程局部存储(TLS)来维护当前的 Context 栈。
- 资源归属: 所有的 Stream、Event 和内存指针都与特定的 Context 绑定。
- 快速切换: 当线程切换 Context 时,Runtime 仅仅更新指向硬件资源表的指针,而不涉及繁重的硬件复位操作。这使得在同一个进程内运行多个模型(如一个用于图像预处理,一个用于推理)成为可能且高效。
5.2 异常隔离与抢占恢复
当某个自定义算子(Custom Kernel)出现除零错误或非法访存时,Runtime 必须防止整个 NPU 崩溃。
Runtime 配合 Driver 实现了逻辑隔离:
- 每个 Context 拥有独立的硬件状态寄存器副本。
- 当检测到异常中断时,Runtime 仅终止当前 Context 下的 Stream 执行,释放相关内存,并向用户返回错误码,而其他并发运行的 Context 不受影响。
六、 硬件抽象与内核发射(Kernel Launch)机制
Runtime 的最底层职能是将软件定义的 Kernel 翻译为硬件指令。
6.1 二进制加载与链接
在初始化阶段,Runtime 负责加载 .o 或 .bin 格式的算子二进制文件。
它解析 ELF 格式的头部,提取出代码段(Text Segment)和数据段(Data Segment),并将其加载到 Device 的指令内存(Instruction Memory)中。
6.2 寄存器配置与任务描述符构建
在调用 aclrtLaunchKernel 时,Runtime 会构建一个任务描述符(Task Descriptor):
- 配置 PC 指针: 指向算子在指令内存中的入口地址。
- 参数压栈: 将 Kernel 函数的参数(指针、标量)按照硬件约定的 ABI(应用二进制接口)打包到参数缓冲区。
- 启动配置: 设置 Block Dim、Grid Dim 以及所需的共享内存大小。
最终,Runtime 通过“门铃(Doorbell)”机制通知 Driver,由 Driver 告知硬件调度器开始执行该任务。
更多推荐
所有评论(0)