第4章:NPU编程模型:主机端与设备端编程、Kernel Launch流程、同步与异步执行模型

好,咱们今天聊聊NPU的编程模型。说实话,很多刚接触NPU的同学,第一反应就是“这不就跟GPU差不多嘛”。嗯,大方向没错,但细节上差别挺大的。我当年第一次调NPU的时候,就被它的同步机制坑了一把——数据都算完了,结果没同步,读回来的全是垃圾值。从那以后,我对这套模型就格外上心。

4.1 主机端与设备端:两个世界

NPU的编程模型,说白了就是“双人舞”。一边是CPU(主机端),一边是NPU(设备端)。CPU负责发号施令,NPU负责埋头苦干。

主机端能干的事:

  • 内存分配与释放(malloc/free)
  • 数据搬运(从DDR搬到NPU的SRAM)
  • 启动Kernel(告诉NPU该干啥)
  • 同步等待(等NPU干完活)

设备端能干的事:

  • 执行算子(卷积、池化、全连接等)
  • 内部数据流动(从SRAM到计算单元)
  • 本地同步(多个计算单元之间)

核心要点:主机端是“指挥官”,设备端是“士兵”。指挥官不能替士兵开枪,士兵也不能自己决定打谁。

我在项目中遇到过一种情况:有人把数据搬运的逻辑写在了Kernel里,结果NPU跑起来后,CPU还在那傻等数据。嗯,这其实是个典型的“职责不清”问题。记住,搬运数据是主机端的活,计算才是设备端的活。

4.2 Kernel Launch流程:从调用到执行

Kernel Launch,说白了就是“按下启动按钮”。但这个过程比你想的要复杂。我习惯把它拆成5步:

  1. 准备Kernel参数——把输入输出地址、维度信息打包好
  2. 提交任务——通过驱动接口把Kernel扔给NPU
  3. 硬件调度——NPU的调度器决定哪个计算单元来干这活
  4. 加载指令——把Kernel的指令从DDR加载到NPU的指令缓存
  5. 开始执行——NPU的计算单元开始干活

你想想看,这5步里哪一步最耗时?我告诉你,是第4步——加载指令。尤其是当你的Kernel特别大时,指令加载时间可能比计算时间还长。所以,我一般建议把多个小Kernel合并成一个大的,减少Launch次数。

// 典型的Kernel Launch代码(伪代码)
// 主机端
void launch_conv_kernel(float* input, float* weight, float* output, int N, int C, int H, int W) {
    // 1. 准备参数
    kernel_params_t params;
    params.input_addr = (uint64_t)input;
    params.weight_addr = (uint64_t)weight;
    params.output_addr = (uint64_t)output;
    params.N = N; params.C = C; params.H = H; params.W = W;
    
    // 2. 提交任务
    npu_submit_kernel("conv2d", &params);
    
    // 3. 等待完成(同步模式)
    npu_wait_for_completion();
}

我的小技巧:Kernel参数尽量用结构体打包,别一个一个传。这样驱动层处理起来更快,而且不容易出错。

4.3 同步与异步执行模型

同步和异步,这是NPU编程里最容易踩坑的地方。我刚开始做NPU开发时,就犯过一个低级错误:用同步模式跑了一个大模型,结果CPU在那干等NPU算完,啥也没干。白白浪费了CPU的计算能力。

同步模型:

  • CPU提交Kernel后,阻塞等待NPU完成
  • 优点:代码简单,逻辑清晰
  • 缺点:CPU利用率低,浪费资源

异步模型:

  • CPU提交Kernel后,立即返回,继续干别的
  • 优点:CPU和NPU可以并行工作
  • 缺点:需要手动管理同步点,容易出bug

为什么会这样?说白了,同步模型就像你叫了个外卖,然后坐在门口等。异步模型就像你叫了外卖,然后继续打游戏,等外卖到了再吃。哪个效率高?当然是异步。

// 异步模式示例
// 主机端
npu_stream_t stream1, stream2;
npu_create_stream(&stream1);
npu_create_stream(&stream2);

// 在stream1上启动Kernel A
npu_launch_kernel_async(kernelA, argsA, stream1);

// 在stream2上启动Kernel B(与Kernel A并行)
npu_launch_kernel_async(kernelB, argsB, stream2);

// CPU可以在这做点别的,比如准备下一批数据
prepare_next_batch();

// 需要结果时再同步
npu_synchronize_stream(stream1);
npu_synchronize_stream(stream2);

我曾经踩过的坑:异步模式下,如果你在Kernel还没完成时就读取输出数据,读到的全是垃圾。一定要在同步点之后才能读取。我有个同事因为这个bug调了三天,最后发现是同步位置放错了。

4.4 知识体系总览

下面这张图,是我自己总结的NPU编程模型核心逻辑。你看一眼,基本就能把今天的内容串起来。

主机端 (CPU) 设备端 (NPU) 内存管理 malloc/free / 数据搬运 Kernel Launch 参数准备 → 任务提交 同步控制 同步 / 异步 模式选择 指令加载 从DDR加载到指令缓存 计算单元 卷积 / 池化 / 全连接 内部同步 多计算单元协同 Kernel Launch 同步信号 核心流程 CPU准备数据 → CPU Launch Kernel → NPU加载指令 → NPU计算 → CPU同步等待 异步模式下:CPU在NPU计算时可并行执行其他任务 主机端 设备端

这张图里,我特意把“同步信号”画成了从设备端指向主机端的箭头。为什么?因为同步的本质就是设备端告诉主机端“我干完了”。异步模式下,这个信号来得晚一些,但终究会来。

4.5 实战建议

最后,给几个实战中的建议:

  • 小模型用同步,大模型用异步。小模型Launch开销占比大,同步反而简单;大模型计算时间长,异步能充分利用CPU。
  • 多个Kernel尽量合并。减少Launch次数,就是减少指令加载的开销。
  • 同步点别放太多。每同步一次,CPU就要等一次。我见过有人每个Kernel后面都加同步,那还不如用同步模式呢。
  • 注意数据依赖。如果Kernel B依赖Kernel A的输出,那必须在A完成后才能启动B。这时候可以用Stream内的顺序执行,不用全局同步。

一句话总结:NPU编程模型的核心就是“CPU指挥,NPU干活”。搞清楚谁该干啥,同步点放对位置,你的性能就不会差。

好了,这一章就到这。记住,多动手写代码,光看是学不会的。下次见。