Virtual platforms and refine-and-reuse

Use an instruction-set model as the reference for a core: run one test program on both, compare at each retire, bridge the speed gap with a deep queue, and know what a virtual platform can never tell you.

Lesson
U9.5
Time
35 min
Runs on
Verilator with UVM
Check
5 questions

A core passes its instruction-set comparison on every program the team owns. Then the performance team measures the first benchmark and finds it a third slower than planned: a multiply takes six cycles, not three. The reference model was right about every value and was never asked about time.

InstructionRetire observed (MUL 3 edges)Retire observed (MUL 6 edges)ISS verdict, both cores
#0 LI r1, 735 ns35 nsmatch
#1 LI r2, 355 ns55 nsmatch
#2 MUL r3, r1, r295 ns125 nsmatch
#3 SUB r0, r3, r1115 ns145 nsmatch
#4 ADD r1, r0, r2135 ns165 nsmatch
#5 MUL r2, r1, r1175 ns235 nsmatch

The same six-instruction program on the specified core and on a slow one. Times are when the monitor sees each retire, one edge after the core writes the register. Every retire matches the ISS on both.

One test program, two models

A virtual platform is a fast software model of a chip or subsystem: processor models, memories and peripherals joined by TLM-2.0 sockets, usually in SystemC. For a processor, its core is an instruction-set simulator (ISS). An ISS executes the program one instruction at a time and keeps the architectural state: the registers and memory a programmer can see. Platforms differ in fidelity: some are untimed, others add loosely or approximately timed models (U9.4), interrupts and shared memory. This lesson's ISS is untimed.

One test program, two modelsBoth sides start from the same instr objects. The ISS answers at once; the RTL answers at its own pace.
One test program, two modelsBoth sides start from the same instr objects. The ISS answers at once; the RTL answers at its own pace. stimulus prog[$] The test program,built once ISS tiny_iss run_ahead at 0 ns queue retire records Peak 6 queued UVM replay_seq,driver One instruction at atime RTL tiny_cpu MUL 3 edges, others1 monitor retire monitor ret_valid, ret_rd,ret_val compare retire_checker Register and value same objectssame objectspush allpinsretirewritepop oldest
  1. Write the test once

    The test builds six instr objects. It is the only copy of the stimulus.

    More context

    LI, LI, MUL, SUB, ADD, MUL: a short program whose two multiplies take longer in the RTL.

  2. Platform side0 ns (illustrative)

    chk.run_ahead(prog) steps the ISS through all six at 0 ns and queues one record per instruction.

    More context

    Log line 0 ns ISS ran 6 instructions, 6 retire records queued. The ISS is untimed, so the whole program runs before the RTL has taken its first instruction.

    Follow the calls, values and waits
    1. data six instr objects: prog[$] → tiny_iss
    2. data six retire records: tiny_iss → retire records
    3. Waiting: retire records — 6 records waiting for retires
  3. RTL side

    replay_seq sends the same objects to the driver at the speed the core takes them.

    More context

    One instruction at a time: the next one goes to the pins only when the core can take it.

    Follow the calls, values and waits
    1. data instr #0: LI r1, 7: prog[$] → replay_seq, driver
    2. data instr #0: LI r1, 7: replay_seq, driver → tiny_cpu
  4. Compare at retire35 ns (illustrative)

    Each retire the monitor publishes pops the oldest ISS record: register and value must match.

    More context

    Log line 35 ns retire #0 LI r1, 7 -> r1=07 matches. Five records are still queued.

    Follow the calls, values and waits
    1. data retire #0: r1=07: tiny_cpu → retire monitor
    2. data retire #0: r1=07: retire monitor → retire_checker
    3. data ISS record #0: r1=07: retire records → retire_checker
  5. A multiply takes longer95 ns (illustrative)

    The MUL retires 40 ns after the instruction before it. The checker does not care when; it pops the next record whenever a retire arrives.

    More context

    Log line 95 ns retire #2 MUL r3, r1, r2 -> r3=15 matches; retire #1 was at 55 ns. Hex 15 is 7 times 3.

    Follow the calls, values and waits
    1. data retire #2: r3=15: tiny_cpu → retire monitor
    2. data retire #2: r3=15: retire monitor → retire_checker
    3. data ISS record #2: r3=15: retire records → retire_checker
  6. The last retire175 ns (illustrative)

    The sixth retire pops the last record, and the queue is empty.

    More context

    The summary reads retired 6, mismatches 0, peak queue 6, last retire at 175. A retire with no record left, or a record left at the end, would be an error.

    Follow the calls, values and waits
    1. data retire #5: r2=21: tiny_cpu → retire monitor
    2. data retire #5: r2=21: retire monitor → retire_checker
    3. data ISS record #5: r2=21: retire records → retire_checker
    4. Released: retire records
Read the diagram as text
  • prog[$]: The test program, built once
  • tiny_iss: run_ahead at 0 ns
  • retire records: Peak 6 queued
  • replay_seq, driver: One instruction at a time
  • tiny_cpu: MUL 3 edges, others 1
  • retire monitor: ret_valid, ret_rd, ret_val
  • retire_checker: Register and value
  • prog[$] → tiny_iss: same objects
  • prog[$] → replay_seq, driver: same objects
  • tiny_iss → retire records: push all
  • replay_seq, driver → tiny_cpu: pins
  • tiny_cpu → retire monitor: retire
  • retire monitor → retire_checker: write
  • retire records → retire_checker: pop oldest

In a commercial flow the program comes from the platform team's tests and reaches UVM through a bridge.

Both models see exactly the same traffic because there is one copy of it. In a commercial flow the program comes from the platform team's tests, written and debugged on the platform long before the RTL exists. A bridge replays them as UVM sequences, so that work is reused at RTL instead of written again.

Compare when an instruction retires

The ISS has no clock, so it cannot be compared with the RTL cycle by cycle. What both share is the moment an instruction retires: its result is written and becomes architectural state. Between retires, the RTL holds things the ISS has no name for, such as the busy count and the result waiting in p_val. The times below are when the monitor observes a retire: the core writes the register at one edge and the monitor sees ret_valid at the next.

The ISS runs ahead; RTL retires are observed one by one
0123456time (ns)0355595115135175ISS records queued6543210retire observed-#0 r1=07#1 r2=03#2 r3=15#3 r0=0e#4 r1=11#5 r2=21whole program run at 0 ns

Each RTL retire pops the oldest ISS record. The queue starts full and drains at the RTL's pace.

The queue is U9.2's decoupling at platform scale. The ISS finishes six instructions before the RTL takes the first, so the example's queue peaks at six. A real program has millions of instructions, and a real flow bounds the queue: the platform runs ahead until the FIFO is full, then waits for a free entry. Depth does not remove the speed difference. A platform that is faster on average fills any finite FIFO, and from then on it runs at the RTL's pace. Depth buys room for bursts and fewer, larger hand-overs between the two sides.

Set SUB_SWAP to 1 on tiny_cpu and rerun. The core now computes rs2 - rs1. The first mismatch is retire #3, SUB r0, r3, r1: RTL r0=f2, ISS r0=0e. Retires #4 and #5 mismatch too, because they read the wrong r0 and r1. The first mismatch names the faulty instruction; the rest are consequences, which is why platform flows stop, or copy the RTL's state into the ISS, after the first one.

From algorithm to RTL: refine and reuse

Platform teams rarely write a model straight at the transaction level. They refine one core of code through several stages, and each stage keeps the tests of the stage before it.

Refine the model, reuse the testsLeft to right, one model core gains wrappers; the same tests run at every stage.
Refine the model, reuse the testsLeft to right, one model core gains wrappers; the same tests run at every stage. model Algorithm C, C++ or SystemCfunctions model Transaction level TLM-2.0 wrapper, LTor AT model Cycle-accurate Bus protocol wrapper design RTL The design itself tests One test set Stimulus and checks wrapwrapreplaced byrunreusereuse
  1. Algorithm

    The functional domain is verified here, in seconds per run. In this module: tiny_iss and the U9.3 C model.

    More context

    The tests check values only: no pins, no clock. That is why they can run so early and so fast.

    Follow the calls, values and waits
    1. data the same tests: One test set → Algorithm
  2. Transaction level

    The same core behind sockets (U9.4's mem_model); the algorithm tests drive it through the sockets.

    More context

    The core does not change; the wrapper adds the socket interface and, for an approximately timed model, phases.

    Follow the calls, values and waits
    1. data model core: Algorithm → Transaction level
    2. data the same tests: One test set → Transaction level
  3. Cycle-accurate

    A cycle-accurate wrapper turns transactions into pins; here, the UVM driver does that job.

    More context

    A second wrapper around the same core. Its timing can now be checked as well as its values.

    Follow the calls, values and waits
    1. data model core: Transaction level → Cycle-accurate
  4. RTL

    At RTL the design replaces the model, and the platform becomes the reference for the same tests.

    More context

    Nothing moves along replaced by: the RTL is new code, not a wrapper. The tests that ran on the algorithm now run against it.

    Follow the calls, values and waits
    1. data the same tests: One test set → RTL
Read the diagram as text
  • Algorithm: C, C++ or SystemC functions
  • Transaction level: TLM-2.0 wrapper, LT or AT
  • Cycle-accurate: Bus protocol wrapper
  • RTL: The design itself
  • One test set: Stimulus and checks
  • Algorithm → Transaction level: wrap
  • Transaction level → Cycle-accurate: wrap
  • Cycle-accurate → RTL: replaced by
  • One test set → Algorithm: run
  • One test set → Transaction level: reuse
  • One test set → RTL: reuse

The core stays, the wrappers change, the tests carry over. Conceptual on this runner.

The four stages side by side
StageModelTests and checksIn this module
AlgorithmC, C++ or SystemC functions with no transactions or timeWritten here: the functional domain is verified at this stagetiny_iss, the U9.3 C model
Transaction levelThe same core wrapped in TLM-2.0 sockets, loosely or approximately timedAlgorithm tests reused through the socketsU9.4's mem_model
Cycle-accurate wrapperTransactions turned into a bus protocol, cycle by cycleThe same tests drive pinsThe UVM driver
RTLThe design itselfPlatform tests replayed; the platform is the referenceThis lesson's bench

One model core and one test set carried through four stages.

The reuse is the return on the platform's cost. Without it the team writes stimulus twice and debugs it twice. With it, most functional bugs are found on the algorithm and transaction models, where a run takes seconds. RTL verification then concentrates on what only RTL has: interfaces, timing and concurrency.

When a platform is the wrong reference

Run the quiz's predict item: the same program on a core whose multiply takes six cycles. Every retire still matches and the run passes. Retires #2 to #4 are observed 30 ns later and #5 60 ns later, because each MUL takes three more edges. This untimed ISS answers "is the architectural state right?" and nothing else.

QuestionCan this untimed ISS answer it?What answers it instead
Is each retired value right?Yes-
Does it retire on time?No: it has no cycles. A timed platform answers only to the accuracy it was validated forA latency or throughput meter (U8.4), assertions on the pipeline
What happens when two masters collide, or an interrupt lands mid-instruction?No: it sees only instruction boundaries and models one masterDirected and random RTL tests, assertions, a cycle-accurate model
Is the cache or pipeline state right?No: it models architectural state onlyWhite-box checkers on the micro-architecture
Was the specification read correctly?Only if its authors read it differently from the designersReviews and a second, simpler checker (U9.1)

An untimed reference checks function at its boundaries. Everything between them, and every question of time, needs another checker.

So a platform is the wrong reference whenever the thing to check lies outside the fidelity it was built for: between its boundaries, or in time it does not model. It is also the wrong reference when it is not ready. A platform that lags the RTL by months checks nothing in the meantime; a scoreboard with its own model (U9.2, U9.3) fills that gap.

Try it

The ISS runs the whole program at 0 ns and queues six records. The core takes an instruction, retires it one edge later (three for MUL), and the monitor reports each retire on the following edge. Each report pops one record.

Change tiny_cpu dut to tiny_cpu #(.SUB_SWAP(1)) dut and rerun: the first error names retire #3, and #4 and #5 follow from it. Then try #(.MUL_LAT(6)): the run passes, later.

RUNVerilator + UVM 2020.3.1 · simulator service
Edit the code, then run it
Expected among the UVM messages: 0 ns ISS ran 6 instructions, 6 retire records queued 35 ns retire #0 LI r1, 7 -> r1=07 matches 55 ns retire #1 LI r2, 3 -> r2=03 matches 95 ns retire #2 MUL r3, r1, r2 -> r3=15 matches 115 ns retire #3 SUB r0, r3, r1 -> r0=0e matches 135 ns retire #4 ADD r1, r0, r2 -> r1=11 matches 175 ns retire #5 MUL r2, r1, r1 -> r2=21 matches retired 6, mismatches 0, peak queue 6, last retire at 175 UVM_ERROR : 0

Check yourself

Predict the outputThe same bench and program run on a core whose multiply takes six edges (MUL_LAT = 6). What line does report_phase print?
`timescale 1ns/1ns
`include "uvm_macros.svh"
import uvm_pkg::*;

interface cpu_if (input logic clk);
  logic       valid = 0;
  logic [1:0] op  = 0;            // 0 LI, 1 ADD, 2 SUB, 3 MUL
  logic [1:0] rd  = 0, rs1 = 0, rs2 = 0;
  logic [7:0] imm = 0;
  logic       ready;
  logic       ret_valid = 0;      // one cycle per retired instruction
  logic [1:0] ret_rd  = 0;
  logic [7:0] ret_val = 0;
endinterface

// A four-register 8-bit core. It takes an instruction at a rising edge where valid and ready are
// both 1, and retires it 1 edge later (LI, ADD, SUB) or MUL_LAT edges later (MUL): it writes the
// register and shows {ret_rd, ret_val} for one cycle. It takes nothing while an instruction is
// executing. SUB_SWAP is a fault switch for the bench: 1 computes rs2 - rs1.
module tiny_cpu #(parameter int MUL_LAT = 3, parameter bit SUB_SWAP = 0) (cpu_if bus);
  logic [7:0] r [4] = '{default: 0};
  int         busy = 0;
  logic [1:0] p_rd = 0;
  logic [7:0] p_val = 0;
  assign bus.ready = (busy == 0);
  function automatic logic [7:0] exec(logic [1:0] op, logic [7:0] a, logic [7:0] b, logic [7:0] imm);
    case (op)
      0: return imm;
      1: return a + b;
      2: if (SUB_SWAP) return b - a; else return a - b;
      default: return a * b;           // the low byte of the product
    endcase
  endfunction
  always @(posedge bus.clk) begin
    bus.ret_valid <= 0;
    if (busy > 1) busy <= busy - 1;
    else if (busy == 1) begin
      busy <= 0;
      r[p_rd] <= p_val;
      bus.ret_valid <= 1; bus.ret_rd <= p_rd; bus.ret_val <= p_val;
    end else if (bus.valid) begin
      p_rd  <= bus.rd;
      p_val <= exec(bus.op, r[bus.rs1], r[bus.rs2], bus.imm);
      if (bus.op == 3) busy <= MUL_LAT; else busy <= 1;
    end
  end
endmodule

class instr extends uvm_sequence_item;
  `uvm_object_utils(instr)
  typedef enum bit [1:0] { LI, ADD, SUB, MUL } op_e;
  op_e      op;
  bit [1:0] rd, rs1, rs2;
  bit [7:0] imm;
  int       idx;                       // position in the program
  function new(string name = "instr"); super.new(name); endfunction
  function string convert2string();
    if (op == LI) return $sformatf("#%0d LI  r%0d, %0d", idx, rd, imm);
    return $sformatf("#%0d %s r%0d, r%0d, r%0d", idx, op.name(), rd, rs1, rs2);
  endfunction
endclass

// What retiring one instruction changes in the architectural state.
class retire_rec extends uvm_sequence_item;
  `uvm_object_utils(retire_rec)
  bit [1:0] rd;
  bit [7:0] val;
  time      at;
  function new(string name = "retire_rec"); super.new(name); endfunction
endclass

// The instruction-set model: architectural state only, one call per instruction, no time.
class tiny_iss;
  bit [7:0] r [4];
  function retire_rec step(instr i);
    retire_rec e = new("e");
    case (i.op)
      instr::LI:  e.val = i.imm;
      instr::ADD: e.val = r[i.rs1] + r[i.rs2];
      instr::SUB: e.val = r[i.rs1] - r[i.rs2];
      instr::MUL: e.val = r[i.rs1] * r[i.rs2];
    endcase
    e.rd = i.rd;
    r[i.rd] = e.val;
    return e;
  endfunction
endclass

// Replays a finished program, instruction by instruction.
class replay_seq extends uvm_sequence #(instr);
  `uvm_object_utils(replay_seq)
  instr prog[$];
  function new(string name = "replay_seq"); super.new(name); endfunction
  task body();
    foreach (prog[k]) begin
      start_item(prog[k]);
      finish_item(prog[k]);
    end
  endtask
endclass

// Offers an instruction at a falling edge and holds it until an edge with ready.
class cpu_driver extends uvm_driver #(instr);
  `uvm_component_utils(cpu_driver)
  virtual cpu_if vif;
  function new(string name, uvm_component parent); super.new(name, parent); endfunction
  task run_phase(uvm_phase phase);
    forever begin
      @(negedge vif.clk);
      seq_item_port.try_next_item(req);
      if (req == null) begin
        vif.valid <= 0;
        continue;
      end
      vif.valid <= 1;
      vif.op  <= req.op;  vif.rd  <= req.rd;
      vif.rs1 <= req.rs1; vif.rs2 <= req.rs2; vif.imm <= req.imm;
      do @(posedge vif.clk); while (!vif.ready);
      seq_item_port.item_done();
    end
  endtask
endclass

// Publishes one record per retired instruction.
class retire_monitor extends uvm_monitor;
  `uvm_component_utils(retire_monitor)
  virtual cpu_if vif;
  uvm_analysis_port #(retire_rec) ap;
  function new(string name, uvm_component parent); super.new(name, parent); ap = new("ap", this); endfunction
  task run_phase(uvm_phase phase);
    forever begin
      @(posedge vif.clk);
      if (vif.ret_valid) begin
        retire_rec t = retire_rec::type_id::create("t");
        t.rd = vif.ret_rd; t.val = vif.ret_val; t.at = $time;
        ap.write(t);
      end
    end
  endtask
endclass

class retire_checker extends uvm_subscriber #(retire_rec);
  `uvm_component_utils(retire_checker)
  tiny_iss   iss = new();
  retire_rec expected[$];              // the ISS's answers, queued far ahead of the RTL
  instr      prog[$];
  int        retired, mismatches, peak;
  time       last_at;
  function new(string name, uvm_component parent); super.new(name, parent); endfunction
  // The platform side: run the whole program through the ISS before the RTL starts.
  function void run_ahead(instr p[$]);
    prog = p;
    foreach (p[k]) expected.push_back(iss.step(p[k]));
    peak = expected.size();
  endfunction
  // The RTL side: one retire at a time, compared with the oldest ISS record.
  function void write(retire_rec t);
    retire_rec e;
    bit        ok = 0;
    if (expected.size() == 0) begin
      `uvm_error("RET", $sformatf("r%0d=%02h retired with nothing left in the program", t.rd, t.val))
      return;
    end
    e = expected.pop_front();
    if (t.rd == e.rd && t.val == e.val) ok = 1;
    if (ok) begin
    end else begin
      mismatches++;
    end
    retired++;
    last_at = t.at;
  endfunction
  function void check_phase(uvm_phase phase);
    if (expected.size() != 0)
      `uvm_error("RET", $sformatf("%0d instruction(s) never retired", expected.size()))
  endfunction
endclass

class predict_test extends uvm_test;
  `uvm_component_utils(predict_test)
  uvm_sequencer #(instr) sqr;
  cpu_driver     drv;
  retire_monitor mon;
  retire_checker chk;
  instr          prog[$];              // the test program: one copy, used by both sides
  function new(string name, uvm_component parent); super.new(name, parent); endfunction
  function void build_phase(uvm_phase phase);
    virtual cpu_if vif;
    sqr = uvm_sequencer #(instr)::type_id::create("sqr", this);
    drv = cpu_driver::type_id::create("drv", this);
    mon = retire_monitor::type_id::create("mon", this);
    chk = retire_checker::type_id::create("chk", this);
    if (!uvm_config_db #(virtual cpu_if)::get(this, "", "vif", vif))
      `uvm_fatal("NOVIF", "no virtual interface")
    drv.vif = vif;
    mon.vif = vif;
  endfunction
  function void connect_phase(uvm_phase phase);
    drv.seq_item_port.connect(sqr.seq_item_export);
    mon.ap.connect(chk.analysis_export);
  endfunction
  function void add(instr::op_e op, bit [1:0] rd, bit [1:0] rs1 = 0, bit [1:0] rs2 = 0, bit [7:0] imm = 0);
    instr i = instr::type_id::create("i");
    i.op = op; i.rd = rd; i.rs1 = rs1; i.rs2 = rs2; i.imm = imm; i.idx = prog.size();
    prog.push_back(i);
  endfunction
  task run_phase(uvm_phase phase);
    replay_seq seq = replay_seq::type_id::create("seq");
    phase.raise_objection(this);
    add(instr::LI,  1, 0, 0, 7);        // r1 = 7
    add(instr::LI,  2, 0, 0, 3);        // r2 = 3
    add(instr::MUL, 3, 1, 2);           // r3 = 21
    add(instr::SUB, 0, 3, 1);           // r0 = 14
    add(instr::ADD, 1, 0, 2);           // r1 = 17
    add(instr::MUL, 2, 1, 1);           // r2 = 289, low byte 33
    chk.run_ahead(prog);                // the ISS runs the whole program at time 0
    seq.prog = prog;
    seq.start(sqr);                     // the RTL gets the same instructions, one by one
    fork                                // stop when every instruction has retired, or report it
      while (chk.retired != prog.size()) #10ns;
      begin
        #1000ns;
        `uvm_error("DRAIN", $sformatf("%0d of %0d instructions retired after 1000 ns", chk.retired, prog.size()))
      end
    join_any
    disable fork;
    #10;
    phase.drop_objection(this);
  endtask
  function void report_phase(uvm_phase phase);
    $display("retired %0d, mismatches %0d, peak queue %0d, last retire at %0t",
             chk.retired, chk.mismatches, chk.peak, chk.last_at);
  endfunction
endclass

module tb;
  bit clk;
  always #5 clk = ~clk;
  cpu_if bus (clk);
  tiny_cpu #(.MUL_LAT(6)) dut (bus);
  initial begin
    uvm_root::get().set_timeout(3000ns, 1);
    uvm_config_db #(virtual cpu_if)::set(null, "uvm_test_top", "vif", bus);
    run_test("predict_test");
  end
endmodule
Spot the bugThe core has the SUB_SWAP fault: SUB computes rs2 - rs1. Each option is the line in the checker's write that decides whether a retire t matches the ISS record e. Which checker lets the faulty core pass?
ConceptThe example's ISS finishes the whole program at 0 ns while the RTL needs 175 ns. In a flow with a million instructions, what keeps this arrangement workable?
ConceptWith SUB_SWAP on, the checker reports mismatches at retires #3, #4 and #5. Which instruction does the core execute wrongly?
ConceptA team plans to sign off tiny_cpu with this lesson's untimed ISS comparison alone. Which bug could that comparison never find?

Remember

  • A virtual platform is only as timed as it was built; an untimed ISS like this one models architectural state and is compared with RTL at each retire.
  • Write the test once and run it on both: the platform runs ahead and queues its answers, the RTL gets the same stimulus as a UVM sequence.
  • A bounded FIFO lets the fast model run ahead in batches, but a faster side still fills it and then runs at the RTL's pace; the first mismatch locates the fault, later ones usually follow from it.
  • Refine and reuse carries one model core and one test set from algorithm to transaction level to cycle-accurate wrapper to RTL.
  • An untimed reference cannot check timing, concurrency between its boundaries or micro-architectural state; those need meters, assertions or a model validated for them.