UVM anti-patterns I see in almost every new project

 DESIGN VERIFICATION  ·  ARTICLE 02 OF 06

UVM anti-patterns I see in almost every new project

UVM is powerful. It’s also one of the easiest frameworks to misuse in ways that quietly destroy your testbench maintainability for years.

 

Let me describe a project I’ve seen more than once.

A new block arrives for verification. The schedule is tight. A DV engineer — competent, experienced, and under pressure — copies an agent from the last project, renames a few things, and gets to work. Three months later, the testbench is running regressions. Six months later, a second engineer joins and finds the code nearly impossible to follow. A year later, when the next chip revision requires reuse, it turns out the testbench can’t be integrated without a near-complete rewrite.

This is not a story about bad engineers. It’s a story about the UVM anti-patterns that appear when the framework’s flexibility — its strength — is used without a shared set of conventions.

Here are the ones I see most often, what they cost, and how to fix them.

Anti-pattern 1: Copy-paste agent development

What it looks like

The project has five interfaces. Each one gets an agent. Each agent is copied from the last one, renamed at the top, and then quietly diverges as the engineer adds interface-specific logic. By the end of the project, five agents share 70% of the same code — but that code is not shared. It’s duplicated five times, in five slightly different forms.

// Agent A (AMBA AXI)

class axi_driver extends uvm_driver #(axi_seq_item);

  task run_phase(uvm_phase phase);

    forever begin

      seq_item_port.get_next_item(req);

      drive_item(req);   // copy-pasted, slightly modified

      seq_item_port.item_done();

    end

  endtask

endclass

 

// Agent B (APB) — same structure, same bug potential, zero sharing

class apb_driver extends uvm_driver #(apb_seq_item);

  task run_phase(uvm_phase phase);

    forever begin

      seq_item_port.get_next_item(req);

      drive_item(req);   // same boilerplate, separate maintenance burden

      seq_item_port.item_done();

    end

  endtask

endclass

 

Why it hurts

When you find a bug in the driver run loop — or improve the protocol — you have to find and fix it in five places. And you will miss one. Copy-paste agents create the illusion of structure while making the codebase progressively harder to maintain.

The fix

Build a base agent class with all shared infrastructure. Derive interface-specific agents from it. Use parameterization and virtual interfaces to handle the variation. This takes longer on day one and saves enormous time from month three onward.

// Base driver with shared infrastructure

virtual class base_driver #(type T = uvm_sequence_item)

  extends uvm_driver #(T);

 

  task run_phase(uvm_phase phase);

    forever begin

      seq_item_port.get_next_item(req);

      drive_item(req);  // defined in subclass

      seq_item_port.item_done();

    end

  endtask

 

  pure virtual task drive_item(T item);

endclass

 

// Interface-specific driver — only implements the difference

class axi_driver extends base_driver #(axi_seq_item);

  virtual task drive_item(axi_seq_item item);

    // AXI-specific protocol logic here

  endtask

endclass

 

 

 

Anti-pattern 2: Overloading the scoreboard with business logic

What it looks like

The scoreboard starts as a comparator. Then someone adds a state machine to track outstanding transactions. Then some protocol-specific filtering. Then a coverage model. Then some debug logging that grew into a partial re-implementation of the reference model. Six months later, the scoreboard is 3,000 lines long, tests half the things it should, and nobody fully understands it.

A scoreboard that does too many checks, too little. When every failure leads to “maybe it’s a scoreboard bug,” your scoreboard has become noise.

Why it hurts

The scoreboard’s job is to compare DUT output against a reference and flag mismatches. When it also contains the reference model, the tracking logic, the coverage bins, and the debug infrastructure, it becomes impossible to determine whether a failure represents a DUT bug or a scoreboard bug. You are using your safety net as a circus tent.

The fix

Enforce strict separation of concerns. The scoreboard compares. The reference model predicts. Coverage goes in a separate subscriber. Debug logging hooks into the analysis port network, not the scoreboard internals.

// Clean separation: scoreboard only compares

class my_scoreboard extends uvm_scoreboard;

  uvm_analysis_imp #(my_seq_item, my_scoreboard) dut_export;

  uvm_analysis_imp #(my_seq_item, my_scoreboard) ref_export;

 

  function void write_dut(my_seq_item item);

    dut_q.push_back(item);

    compare_if_ready();

  endfunction

 

  function void write_ref(my_seq_item item);

    ref_q.push_back(item);

    compare_if_ready();

  endfunction

 

  // Reference model lives in a separate class, feeds ref_export

  // Coverage lives in a separate subscriber

endclass

 

Anti-pattern 3: Skipping factory overrides

What it looks like

Sequence items, drivers, and monitors are instantiated with new() directly. Or — slightly better but still wrong — they’re created with type_id::create(), but nobody has defined a meaningful class hierarchy, so overriding one component requires editing the original source.

// Anti-pattern: direct instantiation locks you in

function void build_phase(uvm_phase phase);

  m_driver = new("m_driver", this);  // cannot override

endfunction

 

// Better but still missing hierarchy:

function void build_phase(uvm_phase phase);

  // create() works, but if my_driver has no base class,

  // you can’t override it at integration without editing the source

  m_driver = my_driver::type_id::create("m_driver", this);

endfunction

 

Why it hurts

The UVM factory is the mechanism that makes testbench reuse possible. When you skip it, or use it without a meaningful type hierarchy, you lose the ability to swap components at the test level — which is exactly what you need when integrating a block testbench into a subsystem or chip-level environment. You either break encapsulation or rewrite the agent.

The fix

Use type_id::create() everywhere, without exception. Define base types for your sequence items and protocol components that can be extended. Write integration tests using factory overrides to substitute chip-level sequences for block-level ones.

 // Correct: factory-registered, overridable hierarchy

class base_seq_item extends uvm_sequence_item;

  `uvm_object_utils(base_seq_item)

  // common fields

endclass

 

class block_seq_item extends base_seq_item;

  `uvm_object_utils(block_seq_item)

  // block-specific fields and constraints

endclass

 

// At chip level — override without touching agent source:

// factory.set_type_override_by_type(

//   block_seq_item::get_type(),

//   chip_seq_item::get_type());

 

Anti-pattern 4: Phase synchronization done wrong

What it looks like

Objections are raised in start_of_simulation or connect_phase. Objections are dropped in a task that runs on a fixed delay after the last stimulus. The test ends before all scoreboards drain. Or it never ends, because something is holding an objection that was never dropped.

// Common race: dropping objection on a timer instead of a condition

task run_phase(uvm_phase phase);

  phase.raise_objection(this);

  // ... drive stimulus ...

#1000;  // hope the scoreboard is done by now

  phase.drop_objection(this);  // race condition waiting to happen

endtask

 

Why it hurts

Phase synchronization bugs are among the hardest to debug because they are non-deterministic. A race between objection drop and scoreboard drain can produce a test that passes 99 times and fails on the 100th, with a different failure each time. Or it produces a test that passes but silently misses the last N transactions.

A test that ends before the scoreboard drains is not a passing test. It’s an incomplete one. These are not the same thing.

The fix

Drop objections based on conditions, not timers. Use drain time sparingly and only when the condition is genuinely not observable. Make scoreboard drain explicit.

// Correct: condition-based objection management

task run_phase(uvm_phase phase);

  phase.raise_objection(this);

  run_all_sequences();

  wait_for_scoreboard_idle();  // explicit drain condition

  phase.drop_objection(this);

endtask

 

// In scoreboard: track pending transactions explicitly

function bit is_idle();

  return (dut_q.size() == 0 && ref_q.size() == 0);

endfunction

 

Anti-pattern 5: Coverage models that measure activity, not intent

What it looks like

The coverage model has bins for every field value, every state, every transaction type. It’s comprehensive in the sense that it’s large. But it doesn’t capture the scenarios that actually matter: the corner cases in the spec, the boundary conditions, the cross-coverage between fields that interact.

// Covers everything and nothing meaningful

covergroup cg_transaction;

  cp_addr: coverpoint item.addr;  // 32-bit field: 4B bins, all useless

  cp_len:  coverpoint item.len;   // same problem

  cp_type: coverpoint item.type;  // 8 values, probably fine

  // No crosses. No boundary bins. No illegal coverage.

  // 100% hit rate, near-zero verification value.

endgroup

 

Why it hurts

Coverage that hits 100% without effort is not telling you anything. Worse, it creates false confidence. The purpose of a coverage model is to measure the gap between what has been exercised and what needs to be exercised. If your model is trivially satisfiable, you’ve measured nothing.

The fix

Write coverage bins against the spec, not against the data types. Cover the boundaries explicitly. Cross the fields that interact. Make the model hard to close — if your first regression hits 60%, that’s a sign you’ve written something meaningful.

// Coverage that reflects verification intent

covergroup cg_transaction with function sample(my_seq_item item);

  // Boundaries and interesting values — not full range

  cp_len: coverpoint item.len {

    bins single     = {1};

    bins small[]    = {[2:7]};

    bins max_burst  = {256};

    bins wrap_boundary = {item.len inside {[253:256]}};

  }

 // Cross that captures the interaction your spec cares about

  cx_type_len: cross item.type, cp_len {

    // Illegal: WRITE with max burst to restricted range

    illegal_bins write_max = binsof(item.type) intersect {WRITE}

                         && binsof(cp_len.max_burst);

  }

endgroup

 

A pattern in the patterns

Looking at these five anti-patterns together, they share a common root: they are all optimizations for day one at the expense of month six. Copy-paste is faster than abstraction. A fat scoreboard is easier than defining clean interfaces. Skipping the factory hierarchy saves setup time. Timer-based objections avoid having to think through drain conditions. Simple coverage closes faster than meaningful coverage.

The cost is deferred, but it arrives. It arrives when the second engineer joins and can’t read the code. When the block needs to be integrated, and the agent can’t be reused. When a subtle race condition starts corrupting regression results intermittently. When sign-off reveals that your coverage model was measuring activity, not intent, and the corner cases you missed are still in silicon.

These are the patterns worth holding.

The teams that succeed with UVM aren’t the fastest on day one.
They’re the ones who invest early in structure, abstraction, and intent — and benefit from it every day after.

Good verification isn’t about writing more code.
It’s about writing code that survives change.

 

Next in this series:

Article 03 — Coverage closure isn’t done when the number hits 100%

Comments

Popular posts from this blog

Why verification is the hardest engineering job nobody talks about

Coverage closure isn’t done when the number hits 100%