Gotcha: Function in SystemVerilog Constraint - The Hidden Solve-Before Trap
The regression was green for three weeks. Then a nightly seed hit randomize() failed in the address generator — once. Re-run: green. Two nights later, twice. The constraint block hadn't changed in a month; what had changed was innocuous: someone reused the scoreboard's is_legal_addr() helper inside a constraint, because writing the legal-address rules twice felt like duplication. One function call turned a solid constraint block into a lottery ticket — and the failure came and went with the seed, which is the cruelest kind of bug to chase.
This post is about why that happens. The one-line version: the constraint solver cannot see through a function. Everything else — the implicit ordering, the skewed distributions, the intermittent failures — falls out of that single fact.
The Mental Model: Constraints Are Transparent, Functions Are Opaque
When you write a pure constraint, the solver sees algebra it can reason about in both directions:
constraint c_legal { addr >= 'h100; addr < 'h200; }
constraint c_range { addr inside {[0:'hFFF]}; }
// Solver: one system of inequalities → picks addr uniformly from ['h100:'h1FF]
A user function is different. Its body might contain loops, lookups, arbitrary procedural code — the solver cannot invert it, so it cannot ask "which inputs make this return 1?" All it can do is call it, and calling requires the inputs to already have values. The LRM resolves this by mandating a staged solve: random variables used as function arguments are solved first, against only the constraints that don't involve the function; then the function is evaluated with those fixed values, and its return value participates in what remains as a constant.
flowchart LR
subgraph PURE["Pure constraints"]
N1["All constraints"] --> N2["One joint solve"]
N2 --> N3["Uniform over full
solution space"]
end
subgraph FN["Function in constraint"]
F1["Stage 1: solve function's
rand inputs — function
constraint NOT visible"] --> F2["Stage 2: call function
on fixed values"]
F2 --> F3["Stage 3: check remaining
constraints — pass or FAIL"]
end
style N2 fill:#d1fae5,stroke:#10b981
style F1 fill:#fee2e2,stroke:#ef4444
style F3 fill:#fef3c7,stroke:#f59e0b
randomize() call fails. Satisfiable constraint sets become probabilistically unsatisfiable — the failure rate is the fraction of the blind stage's space that the function would reject.The Gotcha, Honestly
class packet;
rand int unsigned addr;
// Decode helper — also used by the scoreboard. Single source of truth!
function bit is_legal(int unsigned val);
return (val >= 'h100 && val < 'h200); // 256 legal addresses
endfunction
constraint c_legal { is_legal(addr) == 1; } // GOTCHA
constraint c_range { addr inside {[0:'hFFF]}; } // 4096 addresses
endclass
module tb;
initial begin
packet pkt = new();
repeat (20)
if (!pkt.randomize()) $display("randomize FAILED");
else $display("addr = 'h%0h", pkt.addr);
end
endmodule
Both constraints are satisfiable together — 256 perfectly good addresses exist. But watch the staged solve:
sequenceDiagram
participant S as Solver
participant A as addr
participant F as is_legal()
S->>A: Stage 1: solve addr against visible constraints only (c_range)
A-->>S: addr = 'h9C3 — uniform over [0:'hFFF]
S->>F: Stage 2: call is_legal('h9C3)
F-->>S: returns 0
Note over S: Stage 3: c_legal requires 1 — randomize() FAILS
Note over S: 256 legal / 4096 candidates ≈ 1-in-16 pass rate
In stage 1 the solver picks addr uniformly from all 4096 values allowed by c_range — it cannot use c_legal, because c_legal is hiding behind a function call it can't invert. Only about 1 pick in 16 lands in the legal window, so randomize() fails most of the time — and whether a given test survives depends on the seed and how many retries your methodology layer happens to wrap around it. Meanwhile the pure-constraint version of the same rules succeeds on every call, uniformly. Same math, opposite reliability.
Notice also what makes the war story sting: the function was reused from the checker. Checker code answers "is this value legal?" — a one-directional question. Constraints must answer "give me a legal value" — the inverse question. A function is the right tool for the first and the wrong tool for the second, even when the logic inside is identical.
The Fixes
Fix 1 — Inline the logic as real constraints (default choice)
constraint c_legal { addr >= 'h100; addr < 'h200; }
Transparent to the solver, one joint solve, uniform distribution, never fails. If the rules are simple enough to write as expressions, write them as expressions.
Fix 2 — Keep one source of truth in data, not code
The legitimate motive behind the original sin was avoiding duplicated legality rules. The fix is to move the shared truth into a data structure both sides can use — constraints can see through data, just not through procedures:
class packet;
typedef struct { int unsigned lo, hi; } range_t;
static range_t legal[$] = '{ '{ 'h100, 'h1FF }, '{ 'h400, 'h47F } };
rand int unsigned addr;
rand int unsigned region;
constraint c_legal {
region < legal.size();
addr inside {[ legal[region].lo : legal[region].hi ]};
}
// Scoreboard uses the same table:
static function bit is_legal(int unsigned val);
foreach (legal[i]) if (val inside {[legal[i].lo:legal[i].hi]}) return 1;
return 0;
endfunction
endclass
The constraint and the checker now share the legal table — change the memory map once, both follow. This pattern scales to register maps and address decoders far better than either duplication or the function trap.
Fix 3 — If a function must stay, make the staging deliberate
Sometimes the legality genuinely is procedural (a CRC, a compressed lookup). Then accept the two-stage reality and write it explicitly as generate-and-check with a bounded retry — visible, debuggable, and honest about its cost:
int tries = 0;
do begin
ok = pkt.randomize(); // pure constraints only
end while (ok && !pkt.is_legal(pkt.addr) && ++tries < 100);
if (tries == 100) `uvm_fatal("RAND", "rejection sampling exhausted")
What you should not do is reach for solve...before — the original 2016 version of this post suggested it, and it's a category error. solve a before b changes the probability distribution over solutions; the LRM is explicit that it never changes the solution space, and it does nothing about function opacity. It cannot rescue a constraint the solver can't see into.
When Functions in Constraints Are Actually Fine
The gotcha has a precise boundary — don't cargo-cult "never use functions":
| Safe | Why |
|---|---|
| Function of non-rand state only (config, parameters) | Evaluated before the solve; acts as a constant. data < cfg.get_max(); is idiomatic. |
Built-in system functions: $countones, $onehot, $clog2 | The solver knows their semantics and solves them bidirectionally like operators — they are not opaque user code. |
| Function of rand variables whose staged solve matches intent | E.g., a payload checksum computed from already-constrained fields — you want fields first, derived value second. |
| Trap | Why |
|---|---|
| Function reads rand variables that other constraints also restrict | The staged solve blinds stage 1 to the function's requirement — this post's entire subject. |
| Function with side effects (writes state, counts calls) | LRM-illegal in constraints; solvers may call it any number of times, or cache it. Tools rarely warn. |
| Reused checker/predictor helpers as constraints | Right logic, wrong direction — see Fix 2. |
Debugging Constraint Failures
// 1. The checker you already own: randomize(null) checks CURRENT values
// against all constraints WITHOUT randomizing anything.
pkt.addr = 'h9C3;
if (!pkt.randomize(null)) $display("current values violate constraints");
// 2. Bisect by disabling constraint blocks:
pkt.c_legal.constraint_mode(0);
if (pkt.randomize()) $display("passes without c_legal — it's the pinch point");
// 3. Pin suspect variables with an inline constraint:
if (!pkt.randomize() with { addr == 'h150; }) $display("fails even pinned");
For the seed-dependent flavor of this bug specifically: if a randomize fails on some seeds only, suspect staged solving before anything else — pure constraint systems are satisfiable or not, deterministically. Intermittence is the signature of a blind first stage. Most simulators also have a constraint-failure debug mode (Questa's -solvefaildebug, and equivalents elsewhere) that prints the reduced constraint set at the point of failure — turn it on before guessing.
Common Mistakes
- Reusing checker functions in constraints to avoid duplication. Share data, not procedures (Fix 2).
- Reaching for
solve...beforeas the fix. It reshapes distributions; it does not make functions transparent, and it cannot restore lost satisfiability. - Expecting the failure to reproduce deterministically. The failure probability is the rejected fraction of stage 1's space — it comes and goes with seeds. Flaky randomize ≈ staged solve until proven otherwise.
- Side effects in constraint functions. Illegal per LRM, unenforced by most tools, and a source of once-a-month heisenbugs when the solver changes how many times it calls you.
- Lumping
$countonesin with user functions. Built-ins are solver-native; banning them costs you the cleanest one-hot and popcount constraints in the language.
Interview Corner
Q: What does a function call inside a constraint do to the solve?A: It splits the solve into stages. The function's random inputs are solved first against only the constraints not involving the function — the solver can't invert arbitrary procedural code, so the function's own requirement is invisible at that stage. The function is then called on the fixed values and its return participates as a constant. Every constraint is still enforced; what's lost is joint solving — which costs you uniformity, and can cost you satisfiability probabilistically.
Q: Two individually satisfiable constraints, jointly satisfiable, yetrandomize() fails intermittently. Diagnosis?
A: Intermittent failure of a satisfiable system is the signature of staged solving — usually a function (or an explicit ordering) blinding one stage to another's requirement. The failure rate equals the fraction of the first stage's choices that the second stage rejects, which is why it tracks the seed.
Q: Doessolve a before b change what solutions are possible?
A: No — the LRM guarantees ordering constraints change only the probability distribution over the (unchanged) solution space. That's exactly why it can't fix the function trap, where the problem is a changed effective solution procedure. Distinguishing those two is the discriminator this interview question is fishing for.
Q: What doesobj.randomize(null) do, and when is it useful?
A: It runs the constraint check against the object's current values without randomizing anything — turning the constraint block into a checker. It's the cleanest way to validate stimulus you built by hand, and the mirror image of this post's bug: constraints can double as checkers for free, but checkers can't double as constraints.
Beyond the Gotcha: Advanced → Expert
Level 1 — What staging does to distributions (even when nothing fails)
Suppose the function's range and the other constraints always overlap, so randomize never fails. You're still not safe: stage 1 distributes uniformly over its own space, not over the joint space. Any correlation the function constraint would have induced is gone — corner cases that live in the intersection get the marginal probability of stage 1, not the boosted share a joint solve would give them. In coverage terms: the bins still exist, but the generator visits them at the wrong rates, and coverage closure slows down for no visible reason. Distribution bugs don't fail; they just quietly waste regression cycles.
Level 2 — solve...before semantics, precisely
Worth having exactly right, because it's the most misquoted rule in SV randomization: solve a before b partitions the solve so a is chosen first — but chosen only among values for which some valid b exists (the LRM preserves the solution space). Compare the function trap: stage 1 there is not guaranteed a viable stage 2, because the solver never saw the function's requirement at all. Same word — "ordering" — two different contracts. One reshapes probability; the other gambles satisfiability.
Level 3 — soft constraints as pressure, not law
Since SV-2012, soft addr inside {['h100:'h1FF]}; expresses a default the solver honors when consistent and silently drops when contradicted. Two expert uses: encode preferences that a test can override with an inline constraint (no constraint_mode choreography), and defuse function-adjacent patterns — a soft version of a legality preference degrades to skew instead of failure when it conflicts. Know the priority rule: later-declared and derived-class soft constraints win over earlier ones.
Level 4 — Rejection sampling as a first-class architecture
Fix 3's do-while loop is rejection sampling, and at scale it deserves engineering, not apology: bound the retries, count them (uvm_report_catcher or a plain counter dumped in report_phase), and treat the rejection rate as a health metric — a rising rate means your pure-constraint stage and your procedural filter are drifting apart. When the filter rejects most candidates, invert the architecture: enumerate or pre-generate the legal set once (a queue of legal addresses built at time zero from the same table as Fix 2) and constrain inside it. Memory for solver time is almost always a good trade in a regression.
Level 5 — Knowing what your solver actually does
The LRM defines the contract, not the algorithm — real solvers layer BDD/SAT hybrids, learned clause caches, and bounded internal retries on top. Three practical consequences. First, an "identical" testbench can have different failure rates on different simulators; flaky-on-one-tool-only is consistent with the staged-solve bug, not evidence against it. Second, constraint profilers (most tools ship one) attribute solve time per constraint block — a function-bearing block showing outsized time or retry counts is your smoking gun. Third, solver caches key on the constraint shape: a function call whose body changes with state can silently invalidate caching and turn an O(1)-per-call solve into a per-call resolve. If randomization time grows over a long run, look for state leaking into constraint functions — then reread the side-effect rule above.
Key Takeaways
- The solver can't see through user functions. Their random inputs get solved blind, first — that staging is the entire gotcha.
- Constraints are still enforced; what you lose is joint solving. The symptom is seed-dependent failure or silently skewed distributions, not "ignored constraints."
- Share legality as data (tables the constraint can
inside) rather than as procedures — one source of truth that both solver and scoreboard can use. solve...beforereshapes distributions and never changes the solution space — it is not a fix for function opacity.randomize(null)turns constraints into checkers for free; the reverse direction is exactly what doesn't work.
Comments (0)
Leave a Comment