ferrite-lithic811 tests · 21 designs

ferrite-lithic-cosimphase A333 tests

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

  1. 01

    Derive the plan from the design

    Plan::of takes 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"],
    );
  2. 02

    Build the stimulus

    A Stimulus is a list of rows, one per cycle, each a vector of column values. The handshake matters: a design whose in_ready drops 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))?;
    }
  3. 03

    Build the harness

    Harness::build writes dut.v and 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 same dut.v and then each runs the other's stimulus — the dangerous failure being is_equivalent answering 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())?;
  4. 04

    Run both and compare

    run_with returns a report. is_equivalent is the verdict and first is 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 is unique, 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 0 on 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, which cargo test already provides.

  • Skipping is reported, not silent

    With no Verilator installed the test prints SKIPPED and passes. CI installs Verilator and fails if anything skipped, so a green CI run means the equivalence actually ran.