在异构计算软件栈中,Runtime(运行时)扮演着承上启下的关键角色。向上,它为图引擎(GE)或应用层提供标准的 C/C++ API(如 ACL);向下,它通过系统调用与内核态的 Driver 交互,将逻辑上的计算任务转化为物理硬件可识别的指令流。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 计算函数。
该过程通常涉及以下步骤:

  1. 参数读取: 从上下文寄存器或内存中读取当前的 actual_shape
  2. 公式计算: 根据预编译的 Tiling 公式,计算出 Block Dim(核心数)和 L1 Buffer 的切分大小。
  3. 参数固化: 将计算出的 Tiling 参数写入任务描述符(Task Descriptor),随 Kernel 一并下发。

二、 异步任务流(Stream)的高效编排与依赖管理

为了掩盖 Host 与 Device 之间的通信延迟(PCIe Latency),Runtime 采用全异步的编程模型。

2.1 Stream 对象的逻辑抽象

Stream 是 Runtime 中最核心的调度单元,它代表了一个按顺序执行的操作序列。

  • 指令队列: 每个 Stream 内部维护一个 FIFO 的指令队列。Runtime 将 MemcpyKernelLaunch 等操作封装为 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 告知硬件调度器开始执行该任务。
Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐