FullAdder - Combinational Circuits

This notebook walks through the implementation of a basic combinational circuit, a full adder. This example introduces many of the features of Magma including circuits, wiring, operators, and the type system.

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!")


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!")


Success!

Circuits

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))


FullAdder = DefineCircuit("FullAdder", "I0", In(Bit), "I1", In(Bit), "CIN", In(Bit), "O", Out(Bit), "COUT", Out(Bit))
full_adder_inst0 = full_adder()
wire(FullAdder.I0, full_adder_inst0.A)
wire(FullAdder.I1, full_adder_inst0.B)
wire(FullAdder.CIN, full_adder_inst0.C)
wire(full_adder_inst0.O0, FullAdder.O)
wire(full_adder_inst0.O1, FullAdder.COUT)
EndCircuit()

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))


full_adder = DefineCircuit("full_adder", "A", In(Bit), "B", In(Bit), "C", In(Bit), "O0", Out(Bit), "O1", Out(Bit))
magma_Bit_and_inst0 = magma_Bit_and()
magma_Bit_and_inst1 = magma_Bit_and()
magma_Bit_and_inst2 = magma_Bit_and()
magma_Bit_or_inst0 = magma_Bit_or()
magma_Bit_or_inst1 = magma_Bit_or()
magma_Bit_xor_inst0 = magma_Bit_xor()
magma_Bit_xor_inst1 = magma_Bit_xor()
wire(full_adder.A, magma_Bit_and_inst0.in0)
wire(full_adder.B, magma_Bit_and_inst0.in1)
wire(full_adder.B, magma_Bit_and_inst1.in0)
wire(full_adder.C, magma_Bit_and_inst1.in1)
wire(full_adder.C, magma_Bit_and_inst2.in0)
wire(full_adder.A, magma_Bit_and_inst2.in1)
wire(magma_Bit_and_inst0.out, magma_Bit_or_inst0.in0)
wire(magma_Bit_and_inst1.out, magma_Bit_or_inst0.in1)
wire(magma_Bit_or_inst0.out, magma_Bit_or_inst1.in0)
wire(magma_Bit_and_inst2.out, magma_Bit_or_inst1.in1)
wire(full_adder.A, magma_Bit_xor_inst0.in0)
wire(full_adder.B, magma_Bit_xor_inst0.in1)
wire(magma_Bit_xor_inst0.out, magma_Bit_xor_inst1.in0)
wire(full_adder.C, magma_Bit_xor_inst1.in1)
wire(magma_Bit_xor_inst1.out, full_adder.O0)
wire(magma_Bit_or_inst1.out, full_adder.O1)
EndCircuit()

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())


import magma as m
from mantle import mux as phi


class full_adder(m.Circuit):
    io = m.IO(A=m.In(m.Bit), B=m.In(m.Bit), C=m.In(m.Bit), O0=m.Out(m.Bit),
        O1=m.Out(m.Bit))
    __magma_ssa_return_value_0 = (io.A ^ io.B ^ io.C, io.A & io.B | io.B &
        io.C | io.C & io.A)
    O0, O1 = __magma_ssa_return_value_0
    m.wire(O0, io.O0)
    m.wire(O1, io.O1)

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.

Staged testing with Fault

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")


INFO:root:Running tester...
INFO:root:Success!

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")


INFO:root:Running tester...
INFO:root:Success!

Generate Verilog

Magma's default compiler will generate verilog using CoreIR


In [16]:
m.compile("build/FullAdder", FullAdder, inline=True)
%cat build/FullAdder.v


module full_adder (
    input A,
    input B,
    input C,
    output O0,
    output O1
);
assign O0 = (A ^ B) ^ C;
assign O1 = ((A & B) | (B & C)) | (C & A);
endmodule

module FullAdder (
    input I0,
    input I1,
    input CIN,
    output O,
    output COUT
);
full_adder full_adder_inst0 (
    .A(I0),
    .B(I1),
    .C(CIN),
    .O0(O),
    .O1(COUT)
);
endmodule

Generate CoreIR

We can also inspect the intermediate CoreIR used in the generation process.


In [17]:
%cat build/FullAdder.json


{"top":"global.FullAdder",
"namespaces":{
  "global":{
    "modules":{
      "FullAdder":{
        "type":["Record",[
          ["I0","BitIn"],
          ["I1","BitIn"],
          ["CIN","BitIn"],
          ["O","Bit"],
          ["COUT","Bit"]
        ]],
        "instances":{
          "full_adder_inst0":{
            "modref":"global.full_adder"
          }
        },
        "connections":[
          ["self.I0","full_adder_inst0.A"],
          ["self.I1","full_adder_inst0.B"],
          ["self.CIN","full_adder_inst0.C"],
          ["self.O","full_adder_inst0.O0"],
          ["self.COUT","full_adder_inst0.O1"]
        ]
      },
      "full_adder":{
        "type":["Record",[
          ["A","BitIn"],
          ["B","BitIn"],
          ["C","BitIn"],
          ["O0","Bit"],
          ["O1","Bit"]
        ]],
        "instances":{
          "magma_Bit_and_inst0":{
            "modref":"corebit.and"
          },
          "magma_Bit_and_inst1":{
            "modref":"corebit.and"
          },
          "magma_Bit_and_inst2":{
            "modref":"corebit.and"
          },
          "magma_Bit_or_inst0":{
            "modref":"corebit.or"
          },
          "magma_Bit_or_inst1":{
            "modref":"corebit.or"
          },
          "magma_Bit_xor_inst0":{
            "modref":"corebit.xor"
          },
          "magma_Bit_xor_inst1":{
            "modref":"corebit.xor"
          }
        },
        "connections":[
          ["self.A","magma_Bit_and_inst0.in0"],
          ["self.B","magma_Bit_and_inst0.in1"],
          ["magma_Bit_or_inst0.in0","magma_Bit_and_inst0.out"],
          ["self.B","magma_Bit_and_inst1.in0"],
          ["self.C","magma_Bit_and_inst1.in1"],
          ["magma_Bit_or_inst0.in1","magma_Bit_and_inst1.out"],
          ["self.C","magma_Bit_and_inst2.in0"],
          ["self.A","magma_Bit_and_inst2.in1"],
          ["magma_Bit_or_inst1.in1","magma_Bit_and_inst2.out"],
          ["magma_Bit_or_inst1.in0","magma_Bit_or_inst0.out"],
          ["self.O1","magma_Bit_or_inst1.out"],
          ["self.A","magma_Bit_xor_inst0.in0"],
          ["self.B","magma_Bit_xor_inst0.in1"],
          ["magma_Bit_xor_inst1.in0","magma_Bit_xor_inst0.out"],
          ["self.C","magma_Bit_xor_inst1.in1"],
          ["self.O0","magma_Bit_xor_inst1.out"]
        ]
      }
    }
  }
}
}

Here's an example of running a CoreIR pass on the intermediate representation.


In [18]:
!coreir -i build/FullAdder.json -p instancecount


An instance count of all the primitives
=======================================
full_adder | instances in current | instances in children | 
  corebit_and | 3 | 0
  corebit_or | 2 | 0
  corebit_xor | 2 | 0

FullAdder | instances in current | instances in children | 
  corebit_and | 0 | 3
  corebit_or | 0 | 2
  corebit_xor | 0 | 2

=======================================
{"top":"global.FullAdder",
"namespaces":{
  "global":{
    "modules":{
      "FullAdder":{
        "type":["Record",[
          ["I0","BitIn"],
          ["I1","BitIn"],
          ["CIN","BitIn"],
          ["O","Bit"],
          ["COUT","Bit"]
        ]],
        "instances":{
          "full_adder_inst0":{
            "modref":"global.full_adder"
          }
        },
        "connections":[
          ["self.I0","full_adder_inst0.A"],
          ["self.I1","full_adder_inst0.B"],
          ["self.CIN","full_adder_inst0.C"],
          ["self.O","full_adder_inst0.O0"],
          ["self.COUT","full_adder_inst0.O1"]
        ]
      },
      "full_adder":{
        "type":["Record",[
          ["A","BitIn"],
          ["B","BitIn"],
          ["C","BitIn"],
          ["O0","Bit"],
          ["O1","Bit"]
        ]],
        "instances":{
          "magma_Bit_and_inst0":{
            "modref":"corebit.and"
          },
          "magma_Bit_and_inst1":{
            "modref":"corebit.and"
          },
          "magma_Bit_and_inst2":{
            "modref":"corebit.and"
          },
          "magma_Bit_or_inst0":{
            "modref":"corebit.or"
          },
          "magma_Bit_or_inst1":{
            "modref":"corebit.or"
          },
          "magma_Bit_xor_inst0":{
            "modref":"corebit.xor"
          },
          "magma_Bit_xor_inst1":{
            "modref":"corebit.xor"
          }
        },
        "connections":[
          ["self.A","magma_Bit_and_inst0.in0"],
          ["self.B","magma_Bit_and_inst0.in1"],
          ["magma_Bit_or_inst0.in0","magma_Bit_and_inst0.out"],
          ["self.B","magma_Bit_and_inst1.in0"],
          ["self.C","magma_Bit_and_inst1.in1"],
          ["magma_Bit_or_inst0.in1","magma_Bit_and_inst1.out"],
          ["self.C","magma_Bit_and_inst2.in0"],
          ["self.A","magma_Bit_and_inst2.in1"],
          ["magma_Bit_or_inst1.in1","magma_Bit_and_inst2.out"],
          ["magma_Bit_or_inst1.in0","magma_Bit_or_inst0.out"],
          ["self.O1","magma_Bit_or_inst1.out"],
          ["self.A","magma_Bit_xor_inst0.in0"],
          ["self.B","magma_Bit_xor_inst0.in1"],
          ["magma_Bit_xor_inst1.in0","magma_Bit_xor_inst0.out"],
          ["self.C","magma_Bit_xor_inst1.in1"],
          ["self.O0","magma_Bit_xor_inst1.out"]
        ]
      }
    }
  }
}
}
/Users/travis/build/leonardt/pycoreir/coreir-cpp/src/binary/coreir.cpp:238 Modified?: No

In [ ]: