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.
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.
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 stressWhy 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.
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
endclassInterview 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.