Start by importing magma
and mantle
. magma
is the core system which implements circuits and the methods to compose them, and mantle
is a library of useful circuits.
In [1]:
import magma as m
import mantle
A full adder has three single bit inputs, and returns the sum and the carry. The sum is the exclusive or of the 3 bits, the carry is 1 if any two of the inputs bits are 1. Here is a schematic of a full adder circuit (from logisim
).
We start by defining a magma
combinational function that implements a full adder.
The full adder function takes three single bit inputs (type m.Bit
) and returns two single bit outputs as a tuple.
The first element of tuple is the sum, the second element is the carry. Note that the arguments and return values of the functions have type annotations using Python 3's typing syntax.
We compute the sum and carry using standard Python bitwise operators &
, |
, and ^
.
In [2]:
@m.circuit.combinational
def full_adder(A: m.Bit, B: m.Bit, C: m.Bit) -> (m.Bit, m.Bit):
return A ^ B ^ C, A & B | B & C | C & A # sum, carry
We can test our combinational function to verify that our implementation behaves as expected fault
.
We'll use the fault.PythonTester
which will simulate the circuit using magma
's Python simulator.
In [3]:
import fault
tester = fault.PythonTester(full_adder)
assert tester(1, 0, 0) == (1, 0), "Failed"
assert tester(0, 1, 0) == (1, 0), "Failed"
assert tester(1, 1, 0) == (0, 1), "Failed"
assert tester(1, 0, 1) == (0, 1), "Failed"
assert tester(1, 1, 1) == (1, 1), "Failed"
print("Success!")
combinational
functions are polymorphic over Python and magma types. If the function is called with magma
values, it will produce a circuit instance, wire up the inputs, and return references to the outputs. Otherwise, it will invoke the function in Python. For example, we can use the Python function to verify the circuit simulation.
In [4]:
assert tester(1, 0, 0) == full_adder(1, 0, 0), "Failed"
assert tester(0, 1, 0) == full_adder(0, 1, 0), "Failed"
assert tester(1, 1, 0) == full_adder(1, 1, 0), "Failed"
assert tester(1, 0, 1) == full_adder(1, 0, 1), "Failed"
assert tester(1, 1, 1) == full_adder(1, 1, 1), "Failed"
print("Success!")
Now that we have an implementation of full_adder
as a combinational function,
we'll use it to construct a magma
Circuit
.
A Circuit
in magma
corresponds to a module
in verilog
.
This example shows using the combinational
function inside a circuit definition, as opposed to using the Python implementation shown before.
In [5]:
class FullAdder(m.Circuit):
io = m.IO(I0=m.In(m.Bit),
I1=m.In(m.Bit),
CIN=m.In(m.Bit),
O=m.Out(m.Bit),
COUT=m.Out(m.Bit))
O, COUT = full_adder(io.I0, io.I1, io.CIN)
io.O @= O
io.COUT @= COUT
First, notice that the FullAdder
is a subclass of Circuit
. All magma
circuits are classes in python.
Second, the function IO
creates the interface to the circuit.
The arguments toIO
are keyword arguments.
The key is the name of the argument in the circuit, and the value is its type.
In this circuit, all the inputs and outputs have Magma
type Bit
.
We also qualify each type as an input or an output using the functions In
and Out
.
Note that when we call the python function fulladder
it is passed magma
values not standard python values.
In the previous cell, we tested fulladder
with standard python ints,
while in this case, the values passed to the Python fulladder
function
are magma
values of type Bit
.
The Python bitwise operators for Magma
types are overloaded to automatically create subcircuits to compute logical functions.
fulladder
returns two values.
These values are assigned to the python variables O
and COUT
.
Remember that assigning to a Python variable
sets the variable to refer to the object.
magma
values are Python objects,
so assigning an object to a variable creates a reference to that magma
value.
In order to complete the definition of the circuit,
O
and COUT
need to be wired to the outputs in the interface.
The python @=
operator is overloaded to perform wiring.
Let's inspect the circuit definition by printing the __repr__
.
In [6]:
print(repr(FullAdder))
We see that it has created an instance of the full_adder
combinational function and wired up the interface.
We can also inspect the contents of the full_adder
circuit definition. Notice that it has lowered the Python operators into a structural representation of the primitive logicoperations.
In [7]:
print(repr(full_adder.circuit_definition))
We can also inspect the code generated by the m.circuit.combinational
decorator by looking in the .magma
directory for a file named .magma/full_adder.py
. When using m.circuit.combinational
, magma
will generate a file matching the name of the decorated function. You'll notice that the generated code introduces an extra temporary variable (this is an artifact of the SSA pass that magma
runs to handle if
/else
statements).
In [8]:
with open(".magma/full_adder.py") as f:
print(f.read())
In the code above, a mux
is imported and named phi
. If the combinational circuit contains any if-then-else constructs, they will be transformed into muxes.
Note also the m.wire
function. m.wire(O0, io.I0)
is equivalent to io.O0 @= O0
.
fault
is a python package for testing magma circuits. By default, fault
is quiet, so we begin by enabling logging using the built-in logging
module
In [9]:
import logging
logging.basicConfig(level=logging.INFO)
import fault
Earlier in the notebook, we showed an example using fault.PythonTester
to simulate a circuit. This uses an interactive programming model where test actions are immediately dispatched to the underlying simulator (which is why we can perform assertions on the simulation values in Python.
fault
also provides a staged metaprogramming environment built upon the Tester
class. Using the staged environment means values are not returned immediately to Python. Instead, the Python test code records a sequence of actions that are compiled and run in a later stage.
A Tester
is instantiated with a magma
circuit.
In [10]:
tester = fault.Tester(FullAdder)
An instance of a Tester
has an attribute .circuit
that enables the user to record test actions. For example, inputs to a circuit can be poked by setting the attribute corresponding to the input port name.
In [11]:
tester.circuit.I0 = 1
tester.circuit.I1 = 1
tester.circuit.CIN = 1
fault
's default Tester
provides the semantics of a cycle accurate simulator, so, unlike verilog, pokes do not create events that trigger computation. Instead, these poke values are staged, and the propogation of their effect occurs when the user calls the eval
action.
In [12]:
tester.eval()
To assert that the output of the circuit is equal to a value, we use the expect
method that are defined on the attributes corresponding to circuit output ports
In [13]:
tester.circuit.O.expect(1)
tester.circuit.COUT.expect(1)
Because fault
is a staged programming environment, the above actions are not executed until we have advanced to the next stage. In the first stage, the user records test actions (e.g. poke, eval, expect). In the second stage, the test is compiled and run using a target runtime. Here's examples of running the test using magma
's python simulator, the coreir
c++ simulator, and verilator
.
In [14]:
# compile_and_run throws an exception if the test fails
tester.compile_and_run("verilator")
The tester also provides the same convenient __call__
interface we saw before.
In [15]:
O, COUT = tester(1, 0, 0)
tester.expect(O, 1)
tester.expect(COUT, 0)
tester.compile_and_run("verilator")
Magma's default compiler will generate verilog using CoreIR
In [16]:
m.compile("build/FullAdder", FullAdder, inline=True)
%cat build/FullAdder.v
We can also inspect the intermediate CoreIR used in the generation process.
In [17]:
%cat build/FullAdder.json
Here's an example of running a CoreIR pass on the intermediate representation.
In [18]:
!coreir -i build/FullAdder.json -p instancecount
In [ ]: