第三章:验证环境搭建

好,咱们今天聊聊验证环境搭建。说实话,这是整个NPU验证里最基础也最关键的一环。环境搭得好,后面调试就像顺水推舟;搭得不好,那真是天天在填坑。

3.1 UVM验证平台结构

UVM验证平台,说白了就是一套标准化的积木。你想想看,每个项目都要做验证,如果每次都从头造轮子,那效率得多低?UVM帮我们把框架定好了,我们只需要往里面填自己的东西。

一个典型的UVM平台长什么样呢?我画了个图,大家看看:

UVM验证平台结构图 test env agent scoreboard reference model sequencer driver monitor DUT (NPU Core) 驱动信号 采样信号 比对数据 期望值 图例 测试层 Agent组件 比对组件 参考模型 待测设计

这个结构图我画得比较简洁,但核心组件都在了。从上到下,test负责配置和启动,env是容器,里面装着agent、scoreboard和reference model。agent里面又有sequencer、driver和monitor三件套。

核心要点:UVM平台的分层设计让验证工程师可以各司其职。写test的人不用关心driver怎么实现,写driver的人也不用管scoreboard怎么比对。这种解耦,在大型NPU项目中特别重要。

3.2 NPU专用agent设计

NPU的agent跟普通IP的agent有什么不同?嗯,这里要注意了。NPU的接口通常不是标准的AXI或APB,而是自定义的流式接口。我做过一个项目,NPU的输入是tiled数据,输出是activation map,中间还有各种控制信号。

设计NPU agent时,我建议重点关注三个方面:

  • driver设计:NPU的驱动时序往往很复杂。比如权重加载、指令下发、数据流控制,这些都需要driver模拟。我个人习惯把driver拆成多个子任务,每个子任务对应一个phase。
  • monitor设计:NPU的输出数据量大,而且往往是流式的。monitor要能正确捕获数据包边界,还要处理握手信号。我曾经踩过一个坑,monitor采样时没处理好ready/valid的时序,导致丢了一拍数据,debug了两天才发现。
  • sequencer设计:NPU的测试序列通常有依赖关系。比如必须先加载权重,再下发指令,最后才能启动计算。sequencer要能管理这些依赖。

来看一个简单的driver代码片段:

class npu_driver extends uvm_driver #(npu_transaction);
  
  virtual npu_if vif;
  
  function void build_phase(uvm_phase phase);
    super.build_phase(phase);
    if(!uvm_config_db #(virtual npu_if)::get(this, "", "vif", vif))
      `uvm_fatal("NODRVIF", "No virtual interface")
  endfunction
  
  task run_phase(uvm_phase phase);
    forever begin
      seq_item_port.get_next_item(req);
      drive_transaction(req);
      seq_item_port.item_done();
    end
  endtask
  
  task drive_transaction(npu_transaction tr);
    // 驱动权重加载
    drive_weight_load(tr.weight_data);
    // 驱动指令下发
    drive_instruction(tr.instruction);
    // 驱动数据输入
    drive_data_input(tr.input_data);
    // 等待计算完成
    wait_for_completion();
  endtask
  
endclass

个人经验:写NPU agent时,别忘了处理reset和idle状态。很多bug都是在reset释放后的第一个transaction暴露的。我习惯在driver里加一个reset检测,确保复位期间不驱动任何信号。

3.3 Scoreboard与Reference Model构建

scoreboard和reference model,这俩是验证平台的"裁判"和"标准答案"。没有它们,你根本不知道DUT算得对不对。

Reference Model,说白了就是用软件实现一遍NPU的算法。NPU做卷积,reference model也用C或SystemC做一遍卷积。然后比对结果。

构建reference model时,有几点要注意:

  • 精度匹配:NPU内部可能用定点数,reference model如果用浮点数,比对时就会有误差。我建议reference model也用定点数,或者至少做精度对齐。
  • 时序抽象:reference model不需要关心时序,只关心功能正确。它可以在一个时钟周期内完成计算,而NPU需要多个周期。这没关系,scoreboard会处理时序对齐。
  • 可配置性:NPU的配置参数很多,比如数据位宽、卷积核大小、stride等。reference model要能支持这些配置。

Scoreboard的作用就是比对。它从monitor拿到DUT的实际输出,从reference model拿到期望输出,然后逐拍比对。

我见过很多新手把scoreboard写得特别复杂,其实没必要。核心逻辑就三步:

  1. 接收数据并缓存
  2. 等待比对时机
  3. 执行比对并报告结果

来看一个scoreboard的简化实现:

class npu_scoreboard extends uvm_scoreboard;
  
  uvm_tlm_analysis_fifo #(npu_transaction) dut_fifo;
  uvm_tlm_analysis_fifo #(npu_transaction) ref_fifo;
  
  function void build_phase(uvm_phase phase);
    dut_fifo = new("dut_fifo", this);
    ref_fifo = new("ref_fifo", this);
  endfunction
  
  task run_phase(uvm_phase phase);
    npu_transaction dut_tr, ref_tr;
    forever begin
      dut_fifo.get(dut_tr);
      ref_fifo.get(ref_tr);
      
      if(dut_tr.data !== ref_tr.data) begin
        `uvm_error("MISMATCH", $sformatf(
          "DUT: %h, REF: %h at time %t",
          dut_tr.data, ref_tr.data, $time))
      end
    end
  endtask
  
endclass

避坑指南:我曾经犯过一个错误,scoreboard里只比对最终结果,没比对中间结果。结果NPU的最终结果对了,但中间某个stage算错了。这种bug最隐蔽,因为最终结果可能碰巧正确。后来我改成每拍都比对,虽然慢了点,但心里踏实。

3.4 环境集成与调试

组件都写好了,怎么把它们集成到一起?嗯,这里有个小技巧。我习惯先搭一个最小可运行环境,只包含driver和DUT,确保驱动时序正确。然后再逐步加入monitor、reference model和scoreboard。

集成时要注意几个关键点:

组件 连接方式 常见问题
driver ↔ DUT virtual interface 时序不匹配、信号命名不一致
monitor ↔ DUT virtual interface 采样窗口不对、数据丢失
monitor → scoreboard TLM analysis port 数据顺序错乱、fifo溢出
reference model → scoreboard TLM analysis port 时序不同步、精度不一致

调试的时候,我建议先跑一个最简单的testcase,比如全0输入或者全1输入。这种case的期望结果很容易算,能快速验证环境是否通顺。等环境跑通了,再上复杂的随机测试。

总结一下:验证环境搭建就像盖房子,地基要稳。UVM给了我们框架,NPU专用agent是核心交互层,scoreboard和reference model是质量保证。这三块做好了,后面的验证工作才能事半功倍。

好了,这一章就聊到这儿。下一章咱们会深入NPU的测试用例设计,到时候再细聊。


公众号:蓝海资料掘金营,微信deep3321