leonardt / fault

A Python package for testing hardware (part of the magma ecosystem)
BSD 3-Clause "New" or "Revised" License
41 stars 13 forks source link

Fault

Linux Test MacOS Test BuildKite Status Code Coverage License

A Python package for testing hardware (part of the magma ecosystem).

API Documentation

CHANGELOG

Installation

pip install fault

Documentation

Check out the fault tutorial

Supported Simulators

Example

Here is a simple ALU defined in magma.

import magma as m

class ConfigReg(m.Circuit):
    io = m.IO(D=m.In(m.Bits[2]), Q=m.Out(m.Bits[2])) + \
        m.ClockIO(has_ce=True)

    reg = m.Register(m.Bits[2], has_enable=True)(name="conf_reg")
    io.Q @= reg(io.D, CE=io.CE)

class SimpleALU(m.Circuit):
    io = m.IO(
        a=m.In(m.UInt[16]),
        b=m.In(m.UInt[16]),
        c=m.Out(m.UInt[16]),
        config_data=m.In(m.Bits[2]),
        config_en=m.In(m.Enable)
    ) + m.ClockIO()

    opcode = ConfigReg(name="config_reg")(io.config_data, CE=io.config_en)
    io.c @= m.mux(
        [io.a + io.b, io.a - io.b, io.a * io.b, io.a ^ io.b], opcode)

Here's an example test in fault that uses the configuration interface, expects a value on the internal register, and checks the result of performing the expected operation.

import operator
import fault

ops = [operator.add, operator.sub, operator.mul, operator.floordiv]
tester = fault.Tester(SimpleALU, SimpleALU.CLK)
tester.circuit.CLK = 0
tester.circuit.config_en = 1
for i in range(0, 4):
    tester.circuit.config_data = i
    tester.step(2)
    tester.circuit.a = 3
    tester.circuit.b = 2
    tester.eval()
    tester.circuit.c.expect(ops[i](3, 2))

We can run this with three different simulators

tester.compile_and_run("verilator", flags=["-Wno-fatal"], directory="build")
tester.compile_and_run("system-verilog", simulator="ncsim", directory="build")
tester.compile_and_run("system-verilog", simulator="vcs", directory="build")

Working with internal signals

Fault supports peeking, expecting, and printing internal signals. For the verilator target, you should use the keyword argument magma_opts with "verilator_debug" set to true. This will cause coreir to compile the verilog with the required debug comments. Example:

tester.compile_and_run("verilator", flags=["-Wno-fatal"], 
                       magma_opts={"verilator_debug": True}, directory="build")

If you're using mantle.Register from the coreir implementation, you can also poke the internal register value directly using the value field. Notice that conf_reg is defined in ConfigReg to be an instance of mantle.Register and the test bench pokes it by setting confg_reg.value equal to 1.

tester = fault.Tester(SimpleALU, SimpleALU.CLK)
tester.circuit.CLK = 0
# Set config_en to 0 so stepping the clock doesn't clobber the poked value
tester.circuit.config_en = 0
# Initialize
tester.step(2)
for i in reversed(range(4)):
    tester.circuit.config_reg.conf_reg.value = i
    tester.step(2)
    tester.circuit.config_reg.conf_reg.O.expect(i)
    # You can also print these internal signals using the getattr interface
    tester.print("O=%d\n", tester.circuit.config_reg.conf_reg.O)

FAQ

How can I write test bench logic that depends on the runtime state of the circuit?

A common pattern in testing is to only perform certain actions depending on the state of the circuit. For example, one may only want to expect an output value when a valid signal is high, ignoring it otherwise. Another pattern is to change the expected value over time by using a looping structure. Finally, one may want to expect a value that is a function of other runtime values. To support these pattern, fault provides support "peeking" values, performing expressions on "peeked" values, if statements, and while loops.

Peek Expressions

Suppose we had a circuit as follows:

class BinaryOpCircuit(m.Circuit):
    io = m.IO(I0=m.In(m.UInt[5]), I1=m.In(m.UInt[5]), O=m.Out(m.UInt[5]))

    io.O @= io.I0 + io.I1 & (io.I1 - io.I0)

We can write a generic test that expects the output O in terms of the inputs I0 and I1 (rather than computing the expected value in Python).

tester = fault.Tester(BinaryOpCircuit)
for _ in range(5):
    tester.poke(tester._circuit.I0, hwtypes.BitVector.random(5))
    tester.poke(tester._circuit.I1, hwtypes.BitVector.random(5))
    tester.eval()
    expected = tester.circuit.I0 + tester.circuit.I1
    expected &= tester.circuit.I1 - tester.circuit.I0
    tester.circuit.O.expect(expected)

This is a useful pattern for writing reuseable test components (e.g. composign the output checking logic with various input stimuli generators).

Control Structures

The tester._while(<test>) action accepts a Peek value or expression as the test condition for a loop and returns a child tester that allows the user to add actions to the body of the loop. Here's a simple example that loops until a done signal is asserted, printing some debug information in the loop body:

# Wait for loop to complete
loop = tester._while(dut.n_done)
debug_print(loop, dut)
loop.step()
loop.step()

# check final state
tester.circuit.count.expect(expected_num_cycles - 1)

Notice that you can also add actions after the loop to check expected behavior after the loop has completed.

The tester._if(<test>) action behaves similarly by accepting a test peek value or expression and conditionally executes actions depending on the result of the expression. Here is a simple example:

if_tester = tester._if(tester.circuit.O == 0)
if_tester.circuit.I = 1
else_tester = if_tester._else()
else_tester.circuit.I = 0
tester.eval()

The tester._for(<num_iter>) action provides a simple way to write a loop over a fixed number of iterations. Use the attribute index to get access to the current iteration, for example:

loop = tester._for(8)
loop.poke(circ.I, loop.index)
loop.eval()
tester.expect(circ.O, loop.index)

What Python values can I use to poke/expect ports?

Here are the supported Python values for poking the following port types:

How do I generate waveforms with fault?

Fault supports generating .vcd dumps when using the verilator and system-verilog/ncsim target.

For the verilator target, use the flags keyword argument to pass the --trace flag. For example,

tester.compile_and_run("verilator", flags=["-Wno-fatal", "--trace"])

The --trace flag must be passed through to verilator so it generates code that supports waveform dumping. The test harness generated by fault will include the required logic for invoking tracer->dump(main_time) for every call to eval and step. main_time is incremented for every call to step. The output .vcd file will be saved in the file logs/{circuit_name} where circuit_name is the name of the ciruit passed to Tester. The logs directory will be placed in the same directory as the generated harness, which is controlled by the directory keyword argument (by default this is "build/").

For the system-verilog target, enable this feature using the compile_and_run parameter dump_waveform=True. By default, the waveform file will be named waveforms.vcd for ncsim and waveforms.vpd for vcs. The name of the file can be changed using the parameter waveform_file="<file_name>".

The vcs simulator also supports dumping fsdb by using the argument waveform_type="fsdb". For this to work, you'll need to also use the flags argument using the path defined in your verdi manual. For example, $VERDI_HOME/doc/linking_dumping.pdf.

Here is an example using an older version of verdi (using the VERDIHOME environment variable):

verdi_home = os.environ["VERDIHOME"]
# You may need to change the 'vcs_latest' and 'LINUX64' parts of the path
# depending on your verdi version, please consult
# $VERDI_HOME/doc/linking_dumping.pdf
flags = ['-P', 
         f' {verdi_home}/share/PLI/vcs_latest/LINUX64/novas.tab',
         f' {verdi_home}/share/PLI/vcs_latest/LINUX64/pli.a']
tester.compile_and_run(target="system-verilog", simulator="vcs",
                       waveform_type="fsdb", dump_waveforms=True, flags=flags)

Here's an example for a newer version of verdi

verdi_home = os.environ["VERDI_HOME"]
flags = ['-P',
         f' {verdi_home}/share/PLI/VCS/linux64/novas.tab',
         f' {verdi_home}/share/PLI/VCS/linux64/pli.a']
tester.compile_and_run(target="system-verilog", simulator="vcs",
                       waveform_type="fsdb", dump_waveforms=True, flags=flags)

To configure fsdb dumping, use the fsdb_dumpvars_args parameter of the compile_and_run command to pass a string to the $fsdbDumpvars() function.

For example:

tester.compile_and_run(target="system-verilog", simulator="vcs",
                       waveform_type="fsdb", dump_waveforms=True,
                       fsdb_dumpvars_args='0, "dut"')

will produce:

  $fsdbDumpvars(0, "dut");

inside the generated test bench.

How do I pass through flags to the simulator?

The verilator and system-verilog target support the parameter flags which accepts a list of flags (strings) that will be passed through to the simulator command (verilator for verilator, irun for ncsim, vcs for vcs, and iverilog for iverilog).

Can I include a message to print when an expect fails?

Use the msg argument to the expect action. You can either pass a standalone string, e.g.

tester.circuit.O.expect(0, msg="my error message")

or you can pass a printf/$display style message using a tuple. The first argument should be the format string, the subsequent arguments are the format values, e.g.

tester.circuit.O.expect(0, msg=("MY_MESSAGE: got %x, expected 0!",
                                tester.circuit.O))

Can I display or print values from my testbench?

Yes, you can use the tester.print API which accepts a format string and a variable number of arguments. Here's an example:

tester = fault.Tester(circ, circ.CLK)
tester.poke(circ.I, 0)
tester.eval()
tester.expect(circ.O, 0)
tester.poke(circ.CLK, 0)
tester.step()
tester.print("%08x\n", circ.O)

Can I just generate a test bench without running it?

Yes, here's an example:

# compile the tester
tester.compile("verilator")
# generate the test bench file (returns the name of the file)
tb_file = tester.generate_test_bench("verilator")

or for system verilog

tester.compile("system-verilog", simulator="ncsim")
tb_file = tester.generate_test_bench("system-verilog")

Using the ReadyValid Tester

Fault provides a ReadyValidTester that provides a few convenient features for unit testing ReadyValid interfaces with sequences.

Consider the following circuit:

class Main2(m.Circuit):
    io = m.IO(I=m.Consumer(m.ReadyValid[m.UInt[8]]),
              O=m.Producer(m.ReadyValid[m.UInt[8]]),
              inc=m.In(m.UInt[8]),
              ) + m.ClockIO()
    count = m.Register(m.UInt[2])()
    count.I @= count.O + 1
    enable = io.I.valid & (count.O == 3) & io.O.ready
    io.I.ready @= enable
    io.O.data @= m.Register(m.UInt[8], has_enable=True)()(io.I.data + io.inc,
                                                          CE=enable)
    io.O.valid @= enable

The output stream O is the input stream I incremented by the value of inc and delayed by 4 cycles.

Here's a simple test that provides the input sequence I and expected output sequence O

def test_lifted_ready_valid_sequence_simple():
    I = [BitVector.random(8) for _ in range(8)] + [0]
    O = [0] + [i + 2 for i in I[:-1]]
    tester = f.ReadyValidTester(Main2, {"I": I, "O": O})
    tester.circuit.inc = 2
    tester.finish_sequences()
    tester.compile_and_run("verilator", disp_type="realtime")

Notice that we provide the sequences as a dictionary mapping port name to sequence in the constructor. Afterwards, we are free to poke values like in a normal tester, in this case provide 2 for inc which will satisfy the provided stream.

NOTE: The user must explicitly use the tester.circuit peek/poke interface, or call tester.poke(tester._circuit, value) since the user circuit is wrapped internally (cannot call tester.poke(Main2, value)).

The test finishes by calling tester.finish_sequences() which is a convenience API that waits for the provided sequences to finish.

Here's a different version of the above test that should fail (changing the inc value mid test).


def test_lifted_ready_valid_sequence_simple_fail():
    I = [BitVector.random(8) for _ in range(8)] + [0]
    O = [0] + [i + 2 for i in I[:-1]]
    tester = f.ReadyValidTester(Main2, {"I": I, "O": O})
    tester.circuit.inc = 2
    # Should work for a few cycles
    for i in range(9):
        tester.advance_cycle()
    # Bad inc should fail
    tester.circuit.inc = 3
    tester.finish_sequences()
    with pytest.raises(AssertionError):
        tester.compile_and_run("verilator", disp_type="realtime")

Finally, here's a variant that changes the inc value over time to match the expected sequence.

def test_lifted_ready_valid_sequence_changing_inc():
    I = [BitVector.random(8) for _ in range(8)] + [0]
    O = [0] + [I[i] + ((i + 1) % 2) for i in range(8)]
    tester = f.ReadyValidTester(Main2, {"I": I, "O": O})
    # Sequence expects inc to change over time
    for i in range(8):
        tester.circuit.inc = i % 2
        tester.advance_cycle()
        tester.wait_until_high(tester.circuit.O.ready & tester.circuit.O.valid)
    # Advance one cycle to finish last handshake
    tester.advance_cycle()
    tester.expect_sequences_finished()
    tester.compile_and_run("verilator", disp_type="realtime")

NOTE: At the end of the test, we call expect_sequences_finished() to assert that the sequences have all been processed, otherwise it's possible tha the test could pass without completing the sequences.