第三章:验证环境搭建
好,咱们今天聊聊验证环境搭建。说实话,这是整个NPU验证里最基础也最关键的一环。环境搭得好,后面调试就像顺水推舟;搭得不好,那真是天天在填坑。
3.1 UVM验证平台结构
UVM验证平台,说白了就是一套标准化的积木。你想想看,每个项目都要做验证,如果每次都从头造轮子,那效率得多低?UVM帮我们把框架定好了,我们只需要往里面填自己的东西。
一个典型的UVM平台长什么样呢?我画了个图,大家看看:
这个结构图我画得比较简洁,但核心组件都在了。从上到下,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写得特别复杂,其实没必要。核心逻辑就三步:
- 接收数据并缓存
- 等待比对时机
- 执行比对并报告结果
来看一个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