Part 6 · Testbench Architecture · Intermediate

Coordinating Parallel Agents

Multi-interface benches, event barriers, a scenario coordinator class, per-agent end-of-test aggregation, and a two-agent skeleton.

From one agent to many

Real DUTs have multiple interfaces — two bus masters into one arbiter, a config port plus a streaming port, N lanes of the same protocol. The clean scaling move is to bundle each interface's components into an agent (generator + driver + monitor for one interface) and instantiate one agent per interface. What is genuinely new at the multi-agent level is coordination : making scenarios line up across agents and making end-of-test account for all of them.

diagram
TWO MASTERS, ONE SLAVE (ARBITER DUT)

  ┌─────────── agent_a ───────────┐
  │ gen_a ─► drv_a ─► if_a ───┐   │
  │              mon_a ◄── if_a   │      ┌──────────┐
  └───────────────│──────────────┘      │          │
                  │              ┌────► │   DUT     │ ──► slave_if
  ┌─────────── agent_b ─────────┐│      │ (arbiter) │       │
  │ gen_b ─► drv_b ─► if_b ──────┘      └──────────┘       ▼
  │              mon_b ◄── if_b  │                       mon_s
  └───────────────│─────────────┘                          │
                  │                                        │
                  ▼                                        ▼
        coordinator (barriers, scenario steps)      scoreboard
        objection counter (shared by ALL agents)    (a+b expected
                                                     vs slave actual)
  • One agent class, many instances — agents differ by virtual interface handle and config, never by copy-pasted code.

  • The scoreboard sits above the agents: expected stream merges both masters' input monitors; actual comes from the slave-side monitor.

  • Cross-agent scenario logic lives in a coordinator, not inside any agent — agents stay reusable.


Barriers and the scenario coordinator

The workhorse primitive is the event barrier : N parties arrive, none proceeds until all have arrived. With a barrier you can express "both masters fire a burst in the same window" or "nobody starts phase 2 until everyone finished phase 1" without agents knowing about each other.

systemverilog
class barrier;
  local int unsigned n_parties;
  local int unsigned arrived;
  local int unsigned generation;     // reusable across rounds

  function new(int unsigned n);
    n_parties = n;
  endfunction

  task arrive_and_wait();
    int unsigned my_gen = generation;
    arrived++;
    if (arrived == n_parties) begin
      arrived = 0;
      generation++;                  // releases this round's waiters
    end
    else wait (generation != my_gen);
  endtask
endclass

class scenario_coordinator;
  barrier   sync_b;
  objection obj;

  function new(int unsigned n_agents, objection obj);
    sync_b   = new(n_agents);
    this.obj = obj;
  endfunction

  // Called by each agent's generator between scenario steps
  task step_boundary(string who);
    sync_b.arrive_and_wait();        // all agents align here
  endtask
endclass

// In each generator:
//   send_config_traffic();
//   coord.step_boundary("gen_a");   // wait for gen_b to finish config
//   send_burst_traffic();           // both bursts now overlap → arb stress

Why the generation counter

A naive barrier that waits on arrived == n breaks the second time it is used: resetting arrived to zero releases waiters, but a fast thread can re-arrive before a slow one has woken, corrupting the count. The generation counter makes each round distinct — waiters watch for the generation to change, so re-arrivals in the next round cannot interfere. This is the same construction used in pthread barriers.


Per-agent end-of-test and the full skeleton

End-of-test must aggregate across agents: every agent's stimulus complete, every agent's checking quiescent. The shared objection counter from the previous lesson scales to this directly — each agent's components raise and drop on the same counter instance , and the env waits once for global zero. Per-agent statistics still get reported individually so a silent agent (zero transactions) is caught per interface, not hidden in a global total.

systemverilog
class bus_agent;
  generator   gen;
  driver      drv;
  bus_monitor mon;
  string      name;

  function new(string name, virtual bus_if vif,
               objection obj, scenario_coordinator coord,
               mailbox #(bus_txn) mbx_to_scb);
    this.name = name;
    gen = new(name, obj, coord);
    drv = new(vif, obj);
    mon = new(vif, mbx_to_scb);
    gen.mbx = new(1); drv.mbx = gen.mbx;     // bounded handshake
  endfunction

  task run();
    fork
      drv.run();
      mon.run();
    join_none
    gen.run();                                // returns at stimulus end
  endtask

  function void report();
    $display("[%s] generated=%0d driven=%0d observed=%0d",
             name, gen.n_items, drv.items_driven, mon.txn_count);
    if (mon.txn_count == 0)
      $error("[%s] agent observed ZERO transactions", name);
  endfunction
endclass

class multi_env;
  bus_agent            agt_a, agt_b;
  scoreboard           scb;
  objection            obj   = new();
  scenario_coordinator coord;

  function void build(virtual bus_if vif_a, virtual bus_if vif_b);
    coord = new(2, obj);
    agt_a = new("agent_a", vif_a, obj, coord, scb.mbx_exp);
    agt_b = new("agent_b", vif_b, obj, coord, scb.mbx_exp);
  endfunction

  task run();
    fork : agents
      agt_a.run();
      agt_b.run();
      scb.run();
    join_none
    obj.wait_for_done();          // GLOBAL: both agents + checking idle
    disable agents;
    agt_a.report();
    agt_b.report();
    scb.final_check();
    scb.report();
  endtask
endclass

Interview angle

Multi-agent questions probe architecture sense: "How do you make two masters collide on the arbiter in the same window?" (barrier at the scenario step boundary — not #delay tuning), and "How does end-of-test work with three agents?" (one shared objection counter for the global decision, per-agent counts in the report so a dead agent cannot hide). The barrier generation-counter detail is a strong differentiator if asked to implement one live.

Key takeaways

  • Scale by instantiating one agent per interface — agents differ by config and vif, never by code.

  • Cross-agent alignment belongs in a coordinator with barriers, keeping agents ignorant of each other.

  • A reusable barrier needs a generation counter — arrived-count alone breaks on the second round.

  • End-of-test: one shared objection counter globally, plus per-agent counts so silent agents fail loudly.

Common pitfalls

  • Tuning #delays so two agents' traffic happens to overlap — collapses on any timing change; use a barrier.

  • Scenario logic coded inside one agent referencing the other — neither agent is reusable again.

  • Reusing a count-only barrier across rounds — fast re-arrivals corrupt the count; add the generation.

  • Global-only end-of-test stats — one agent driving zero traffic still shows a green global PASS.