Verilator equivalence
Two backends, one stimulus, compared cycle by cycle, with the first difference reported.
This is the crate that makes the other ones trustworthy. It takes one design, emits Verilog, compiles it with Verilator, runs both it and the simulator against the same stimulus, and compares every output on every cycle.
The comparison is only meaningful because of what a Plan fixes: the same columns, the same initial values, the same clock exclusion. Without that, the two backends are being compared as two different experiments rather than as two implementations of one.
Step by step
- 01
Derive the plan from the design
Plan::oftakes the design and the module and works out the stimulus columns, excluding the clock — which is a port but not something the stimulus drives, because the harness drives it.use ferrite_lithic_cosim::Plan; let plan = Plan::of(&design, &module)?; // The clock is a port, but not a stimulus column. assert_eq!( plan.inputs.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["rst", "init", "count", "in_valid", "in_bit"], ); - 02
Build the stimulus
A
Stimulusis a list of rows, one per cycle, each a vector of column values. The handshake matters: a design whosein_readydrops needs a slower stimulus, or the bits that overrun it are lost and both backends agree on the wrong answer.use ferrite_lithic_cosim::Stimulus; let mut stimulus = Stimulus::new(); stimulus.push(row(1, 0, 0, 0, 0))?; // reset stimulus.push(row(0, 1, count, 0, 0))?; // init // One bit every two cycles, because in_ready drops when the buffer is full. for bit in bits { stimulus.push(row(0, 0, 0, 1, u64::from(bit)))?; stimulus.push(row(0, 0, 0, 0, 0))?; } - 03
Build the harness
Harness::buildwritesdut.vand a generated driver into a fresh subdirectory per build, then compiles and links them. Sharing a directory between two harnesses means two processes write the samedut.vand then each runs the other's stimulus — the dangerous failure beingis_equivalentanswering about a stimulus the caller never passed.use ferrite_lithic_cosim::Harness; let verilog = ferrite_lithic_cosim::emit(&design, &plan)?; let harness = Harness::build(&plan, &verilog, &plan.work_dir())?;
- 04
Run both and compare
run_withreturns a report.is_equivalentis the verdict andfirstis the first cycle and column that disagreed, which is the difference between a usable failure and a useless one.let report = ferrite_lithic_cosim::run_with(&design, &plan, &stimulus, &harness)?; assert!( report.is_equivalent(), "the two backends disagree: {report}\nfirst: {:?}", report.first(), );
What bites
Verilator is pinned to two-state, zero-initialised
The argument is
--x-initial 0. Verilator's default isunique, which randomises initial state per run to shake out designs that depend on uninitialised state — the right default for Verilator's own tests and the wrong one here, because the simulator zero-fills and a design relying on reset state would then diverge at random rather than every time.A fixed job count, not every core
Verilator is invoked with
-j 4. With-j 0on a many-core machine it intermittently aborts at teardown with an internal thread-pool error. The generated C++ is small, so the parallelism that matters is across designs, whichcargo testalready provides.Skipping is reported, not silent
With no Verilator installed the test prints
SKIPPEDand passes. CI installs Verilator and fails if anything skipped, so a green CI run means the equivalence actually ran.