Syntaxes
Magma's core syntax is at the structural RTL of abstraction. Magma provides layers on top of this core syntax to abstract some details such as wiring of inputs/outputs, referencing registers, and control logic state machines.
Core
Circuits are defined by subclassing m.Circuit
and assigning an interface
definition to the variable named io
. Inside, the user creates instances of
other circuits and wires their ports to the io
object.
class Accum(m.Circuit):
# Interface contains an input port and output port, both of type
UInt[8]
io = m.IO(
I=m.In(m.UInt[8]),
O=m.Out(m.UInt[8]))
)
# Add magma's standard clock interface (equivalent to
# including CLK=m.In(m.Clock) in an m.IO call
# Note that io objects can be incrementally constructed using the `+`
# operator
io += m.ClockIO()
# Generate a register circuit that stores a value of type UInt[8]
# Second set of parenthesis instances the generated circuit
sum = m.Register(m.UInt[8])()
# Calling an instance `sum(...)` corresponds to wiring up the inputs
# @= operator wires the output of sum (returned from calling the
# instance) to the `O` port of the output
io.O @= sum(sum.O + io.I)
# Register clock signal is automatically wired up
Combinational
Magma supports generating combinational logic from pure functions using the
@m.combinational2
decorator. This introduces a set of syntax level features
for defining combinational magma circuits, including the use of if
statements
to generate Mux
es.
The condition must be an expression that evaluates to a magma
value.
Here's a simple example:
@m.combinational2
def basic_if(I: m.Bits[2], S: m.Bit) -> m.Bit:
if S:
return I[0]
else:
return I[1]
For reference, here is the code produced for the corresponding magma circuit definition where the if statement has been lowered into a multiplexer:
class basic_if(m.Circuit):
io = m.IO(I=m.In(m.Bits[2]), S=m.In(m.Bit), O=m.Out(Bit))
Mux2xOutBit_inst0 = Mux2xOutBit()
wire(io.I[1], Mux2xOutBit_inst0.I0)
wire(io.I[0], Mux2xOutBit_inst0.I1)
wire(io.S, Mux2xOutBit_inst0.S)
wire(Mux2xOutBit_inst0.O, io.O)
combinational2
allows nesting if
statements:
@m.combinational2
def if_statement_nested(I: m.Bits[4], S: m.Bits[2]) -> m.Bit:
if S[0]:
if S[1]:
return I[0]
else:
return I[1]
else:
if S[1]:
return I[2]
else:
return I[3]
Also terneray expressions:
@m.combinational2
def ternary(I: m.Bits[2], S: m.Bit) -> m.Bit:
return I[0] if S else I[1]
Function composition
@m.combinational2
def basic_if_function_call(I: m.Bits[2], S: m.Bit) -> m.Bit:
return basic_if(I, S)
Function calls must refer to another m.combinational2
element, or a
function that accepts magma values, define instances and wires values, and
returns a magma. Calling any other type of function has undefined behavior.
Returning multiple values (tuples)
There are two ways to return multiple values, first is to use a Python tuple.
This is specified in the type signature as (m.Type, m.Type, ...)
. In the
body of the definition, the values can be returned using the standard Python
tuple syntax. The circuit defined with a Python tuple as an output type will
default to the naming convetion O0, O1, ...
for the output ports.
@m.combinational2
def return_py_tuple(I: m.Bits[2]) -> (m.Bit, m.Bit):
return I[0], I[1]
The other method is to use an m.Tuple
(magma's tuple type). Again, this is
specified in the type signature, using m.Tuple(m.Type, m.Type, ...)
. You can
also use the namedtuple pattern to give your multiple outputs explicit names
with m.Tuple(O0=m.Bit, O1=m.Bit)
.
@m.combinational2
def return_magma_tuple(I: m.Bits[2]) -> m.Tuple[m.Bit, m.Bit]:
return m.tuple_([I[0], I[1]])
@m.combinational2
def return_magma_named_tuple(I: m.Bits[2]) -> m.Product.from_fields("anon", {"x": m.Bit, "y": m.Bit}):
return m.namedtuple(x=I[0], y=I[1])
Using non-combinational magma circuits
The combinational syntax allows the use of other combinational circuits using the function call syntax to wire inputs and retrieve outputs.
Here is an example:
class EQ(m.Circuit):
IO = ["I0", m.In(m.Bit), "I1", m.In(m.Bit), "O", m.Out(m.Bit)]
@m.combinational2
def logic(a: m.Bit) -> (m.Bit,):
if EQ()(a, m.bit(0)):
c = m.bit(1)
else:
c = m.bit(0)
return (c,)
Using magma's higher order circuits
class Not(m.Circuit):
IO = ["I", m.In(m.Bit), "O", m.Out(m.Bit)]
@m.combinational2
def logic(a: m.Bits[10]) -> m.Bits[10]:
return m.join(m.map_(Not, 10))(a)
Using combinational circuits as a standard circuit definition
Combinational circuits can also be used as standard circuit definitions by
using the .circuit_definition
attribute to retrieve the corresponding magma
circuit.
@m.combinational2
def invert(a: m.Bit) -> m.Bit:
return Not()(a)
class Foo(m.Circuit):
IO = ["I", m.In(m.Bit), "O", m.Out(m.Bit)]
io = m.IO(I=m.In(m.Bit), O=m.Out(m.Bit))
# reference circuit_definition and instance
inv = invert.circuit_definition()
inv.a @= io.I
io.O @= inv.O
Statically Elaborated For Loops
Statically elaborated for loops are supported using the ast_tools loop unrolling macro. Here's an example:
from ast_tools.passes import loop_unroll, apply_ast_passes
n = 4
@m.combinational2
@apply_ast_passes([loop_unroll()])
def logic(a: m.Bits[n]) -> m.Bits[n]:
O = []
for i in ast_tools.macros.unroll(range(n)):
O.append(a[n - 1 - i])
return m.bits(O, n)
which compiles to this magma circuit
class logic(m.Circuit):
io = m.IO(a=m.In(m.Bits[n]), O=m.Out(m.Bits[n]))
O_0 = []
O_0.append(io.a[n - 1 - 0])
O_0.append(io.a[n - 1 - 1])
O_0.append(io.a[n - 1 - 2])
O_0.append(io.a[n - 1 - 3])
__magma_ssa_return_value_0 = m.bits(O_0, n)
O = __magma_ssa_return_value_0
m.wire(O, io.O)
Inline Combinational
inline_combinational
avoids having to define function parameters, pass
arguments, and assign return values when defining a combinational block. Instead,
the user can define a combinational
function inside their m.Circuit
class
defintiion and refer directly to magma values in the scope.
Here's a simple example:
class Main(m.Circuit):
io = m.IO(invert=m.In(m.Bit), O0=m.Out(m.Bit), O1=m.Out(m.Bit))
io += m.ClockIO()
reg = m.Register(m.Bit)()
@m.inline_combinational()
def logic():
if io.invert:
reg.I @= ~reg.O
O1 = ~reg.O
else:
reg.I @= reg.O
O1 = reg.O
io.O0 @= reg.O
io.O1 @= O1
Notice that the first 3 lines of Main
's definition are standard magma.
Inside the function logic
that has been decorated with
@m.inline_combinational
, the user can refer to reg
(a normal magma
instance) and it's ports to perform logic and wiring. The definition of
logic
shows two ways to use the combinational
rewrite to generate a muxes.
The first way wires to reg.I
using the @=
operator inside the if statement.
The combinational
rewrite logic will change these statements to assign to a
temporary value, which will then get process by the SSA pass to produce the
final value (output of a mux or chain of muxes) which is then wired to the
original target (reg.I
in this case).
The second way assigns to a temporary value O1
. This value is handled using
the standard combinational
treatment and the final value produced by SSA is
returned from the function and assigned in the enclosing environment.
Sequential
The @m.sequential2
decorator extends the @m.combinational2
syntax with the ability to use Python's class system to describe stateful
circuits.
The execution of sequential
clases begins with the __init__
method where
arbitrary magma code can be run to construct the internal state of a circuit.
Then, the __call__
function constructs the state transition logic as well as
the input/output logic using @m.combinational2
syntax.
State is referenced using the first argument self
and is implicitly updated
by writing to attributes of self (e.g. self.x = 3
).
Here's an example of a basic 2 element shift register:
@m.sequential2()
class Counter:
def __init__(self, reset_type=m.AsyncReset, has_enable=True):
# reset_type and has_enable will be set implicitly
self.count = m.Register(T=m.UInt[16], init=m.uint(0, 16))()
def __call__(self, en: m.Bit) -> m.SInt[16]:
if en:
self.count = self.count + 1
return self.count.prev()
In the __init__
method, the circuit declares a state element self.count
as
an instance of the m.Register
primitive. The type is determined by the T
parameter to the Register circuit generator. The __call__
method accepts an
input en
of type m.Bit
that controls whether the state should be changed by
incrementing the value stored in the count register. The output of the circuit
is returned, in this case self.count.prev()
. The prev
method is a magic
method that will return the previous value of the count register (not the
updated value which could be self.count + 1
is en
is high).
Notice that the inputs and output of the __call__
method have type
annotations just like m.combinational2
functions. The __call__
method should be treated as a standard @m.combinational2
function,
with the special parameter self
that provides access to the state declared
inside the __init__
method.
Hierarchy
Besides declaring magma values as state, the sequential
syntax also supports
using instances of other sequential circuits. For example, suppose we have a
register defined as follows:
@m.sequential2(reset_type=m.AsyncReset)
class Register:
def __init__(self):
self.value = m.Register(T=m.Bits[2], init=m.uint(0, 16))()
def __call__(self, I: m.Bits[2]) -> m.Bits[2]:
return self.value(I)
We can use the Register
class in the definition of a ShiftRegister
class:
@m.sequential2(reset_type=m.AsyncReset)
class TestShiftRegister:
def __init__(self):
self.x = Register()
self.y = Register()
def __call__(self, I: m.Bits[2]) -> m.Bits[2]:
return self.y(self.x(I))
NOTE Currently it is required that every sub
sequential circuit element receive an explicit invocation in the __call__
method. For example, if you have a sub sequential circuit self.x
that you
would like to keep constant, you must still call it with self.x(...)
to
ensure that some input value is provided every cycle (the sub sequential
circuit must similarly be designed in such a way that the logic expects inputs
every cycle, so enable logic must be explicitly defined).
Coroutine
The m.coroutine
syntax extends m.sequential2
with the ability to use
yield
statements inside the __call__
method to pause execution and wait
until the next clock cycle. This provides a convenient way to describe control
logic, particularly finite state machines.
Here's a simple example of defining a UART
transmitter:
@m.coroutine(reset_type=m.AsyncReset)
class UART:
def __init__(self):
self.message = m.Register(T=m.Bits[8], init=0)()
self.i = m.Register(T=m.UInt[3], init=7)()
self.tx = m.Register(T=m.Bit, init=1)()
def __call__(self, run: m.Bit, message: m.Bits[8]) -> m.Bit:
while True:
self.tx = m.bit(1) # end bit or idle
yield self.tx.prev()
if run:
self.message = message
self.tx = m.bit(0) # start bit
yield self.tx.prev()
while True:
self.i = self.i - 1
self.tx = self.message[self.i.prev()]
yield self.tx.prev()
if self.i == 7:
break
In some cases, the user may desire to explicitly choose the encoding of each
state (by default, the compielr will generate a unique value for each yield
statement). The coroutine syntax also supports yield from
as a way to sequentially transfer
control to another coroutine. This allows sequential composition of coroutines
(state machines). Here's an example of both in a JTAG controller:
TEST_LOGIC_RESET = m.bits(15, 4)
RUN_TEST_IDLE = m.bits(12, 4)
SELECT_DR_SCAN = m.bits(7, 4)
CAPTURE_DR = m.bits(6, 4)
SHIFT_DR = m.bits(2, 4)
EXIT1_DR = m.bits(1, 4)
PAUSE_DR = m.bits(3, 4)
EXIT2_DR = m.bits(0, 4)
UPDATE_DR = m.bits(5, 4)
SELECT_IR_SCAN = m.bits(4, 4)
CAPTURE_IR = m.bits(14, 4)
SHIFT_IR = m.bits(10, 4)
EXIT1_IR = m.bits(9, 4)
PAUSE_IR = m.bits(11, 4)
EXIT2_IR = m.bits(8, 4)
UPDATE_IR = m.bits(13, 4)
@m.coroutine(manual_encoding=True, reset_type=m.AsyncReset)
class JTAG:
def __init__(self):
self.yield_state = m.Register(T=m.Bits[4], init=TEST_LOGIC_RESET)()
def __call__(self, tms: m.Bit) -> m.Bits[4]:
# TODO: Prune infeasible paths (or check if synthesis optimizes
# them out)
while True:
while True:
self.yield_state = TEST_LOGIC_RESET
yield self.yield_state.prev()
if tms == 0:
break
while tms == 0:
self.yield_state = RUN_TEST_IDLE
yield self.yield_state.prev()
while tms == 1:
self.yield_state = SELECT_DR_SCAN
yield self.yield_state.prev()
if tms == 0:
# dr
yield from self.make_scan(CAPTURE_DR, SHIFT_DR,
EXIT1_DR, PAUSE_DR, EXIT2_DR,
UPDATE_DR)
else:
self.yield_state = SELECT_IR_SCAN
yield self.yield_state.prev()
if tms == 0:
# ir
yield from self.make_scan(CAPTURE_IR, SHIFT_IR,
EXIT1_IR, PAUSE_IR,
EXIT2_IR, UPDATE_IR)
else:
break
def make_scan(self, capture, shift, exit_1, pause, exit_2, update):
def scan(self, tms: m.Bit) -> m.Bits[4]:
self.yield_state = capture
yield self.yield_state.prev()
while True:
if tms == 0:
while True:
self.yield_state = shift
yield self.yield_state.prev()
if tms != 0:
break
self.yield_state = exit_1
yield self.yield_state.prev()
if tms == 0:
while True:
self.yield_state = pause
yield self.yield_state.prev()
if tms != 0:
break
self.yield_state = exit_2
yield self.yield_state.prev()
if tms != 0:
break
else:
break
self.yield_state = update
yield self.yield_state.prev()
return tms
return scan()
Here's another example of manual_encoding
and yield from
in an SDRAM
controller:
INIT_NOP1 = m.bits(0b01000, 5)
INIT_PRE1 = m.bits(0b01001, 5)
INIT_NOP1_1 = m.bits(0b00101, 5)
INIT_REF1 = m.bits(0b01010, 5)
INIT_NOP2 = m.bits(0b01011, 5)
INIT_REF2 = m.bits(0b01100, 5)
INIT_NOP3 = m.bits(0b01101, 5)
INIT_LOAD = m.bits(0b01110, 5)
INIT_NOP4 = m.bits(0b01111, 5)
REF_PRE = m.bits(0b00001, 5)
REF_NOP1 = m.bits(0b00010, 5)
REF_REF = m.bits(0b00011, 5)
REF_NOP2 = m.bits(0b00100, 5)
READ_ACT = m.bits(0b10000, 5)
READ_NOP1 = m.bits(0b10001, 5)
READ_CAS = m.bits(0b10010, 5)
READ_NOP2 = m.bits(0b10011, 5)
READ_READ = m.bits(0b10100, 5)
WRIT_ACT = m.bits(0b11000, 5)
WRIT_NOP1 = m.bits(0b11001, 5)
WRIT_CAS = m.bits(0b11010, 5)
WRIT_NOP2 = m.bits(0b11011, 5)
CMD_PALL = m.bits(0b10010001, 8)
CMD_REF = m.bits(0b10001000, 8)
CMD_NOP = m.bits(0b10111000, 8)
CMD_MRS = m.bits(0b10000000, 8)
CMD_BACT = m.bits(0b10011000, 8)
CMD_READ = m.bits(0b10101001, 8)
CMD_WRIT = m.bits(0b10100001, 8)
@m.coroutine(manual_encoding=True, reset_type=m.AsyncResetN)
class SDRAMController:
def __init__(self):
self.yield_state = m.Register(T=m.Bits[5], init=INIT_NOP1)()
self.command = m.Register(T=m.Bits[8], init=CMD_NOP)()
self.i = m.Register(T=m.UInt[4], init=m.uint(15, 4))()
def __call__(self, refresh_cnt: m.UInt[10], rd_enable: m.Bit, wr_enable: m.Bit) -> (m.Bits[5], m.Bits[8]):
yield from self.init()
while True:
self.command = CMD_NOP
self.yield_state = IDLE
yield self.yield_state.prev(), self.command.prev()
if refresh_cnt >= CYCLES_BETWEEN_REFRESH:
yield from self.refresh()
elif wr_enable:
yield from self.write()
elif rd_enable:
yield from self.read()
def init(self, refresh_cnt: m.UInt[10], rd_enable: m.Bit, wr_enable: m.Bit) -> (m.Bits[5], m.Bits[8]):
while True:
self.command = CMD_NOP
self.yield_state = INIT_NOP1
yield self.yield_state.prev(), self.command.prev()
if self.i == 0:
break
self.i = self.i - 1
self.command = CMD_PALL
self.yield_state = INIT_PRE1
yield self.yield_state.prev(), self.command.prev()
self.command = CMD_NOP
self.yield_state = INIT_NOP1_1
yield self.yield_state.prev(), self.command.prev()
self.command = CMD_REF
self.yield_state = INIT_REF1
yield self.yield_state.prev(), self.command.prev()
self.i = 7
while True:
self.command = CMD_NOP
self.yield_state = INIT_NOP2
yield self.yield_state.prev(), self.command.prev()
if self.i == 0:
break
self.i = self.i - 1
self.command = CMD_REF
self.yield_state = INIT_REF2
yield self.yield_state.prev(), self.command.prev()
self.i = 7
while True:
self.command = CMD_NOP
self.yield_state = INIT_NOP3
yield self.yield_state.prev(), self.command.prev()
if self.i == 0:
break
self.i = self.i - 1
self.command = CMD_MRS
self.yield_state = INIT_LOAD
yield self.yield_state.prev(), self.command.prev()
self.i = 1
while True:
self.command = CMD_NOP
self.yield_state = INIT_NOP4
yield self.yield_state.prev(), self.command.prev()
if self.i == 0:
break
self.i = self.i - 1
return refresh_cnt, rd_enable, wr_enable
def refresh(self, refresh_cnt: m.UInt[10], rd_enable: m.Bit, wr_enable: m.Bit) -> (m.Bits[5], m.Bits[8]):
self.command = CMD_PALL
self.yield_state = REF_PRE
yield self.yield_state.prev(), self.command.prev()
self.command = CMD_NOP
self.yield_state = REF_NOP1
yield self.yield_state.prev(), self.command.prev()
self.command = CMD_REF
self.yield_state = REF_REF
yield self.yield_state.prev(), self.command.prev()
self.i = 7
while True:
self.command = CMD_NOP
self.yield_state = REF_NOP2
yield self.yield_state.prev(), self.command.prev()
if self.i == 0:
break
self.i = self.i - 1
return refresh_cnt, rd_enable, wr_enable
def write(self, refresh_cnt: m.UInt[10], rd_enable: m.Bit, wr_enable: m.Bit) -> (m.Bits[5], m.Bits[8]):
self.command = CMD_BACT
self.yield_state = WRIT_ACT
yield self.yield_state.prev(), self.command.prev()
self.i = 1
while True:
self.command = CMD_NOP
self.yield_state = WRIT_NOP1
yield self.yield_state.prev(), self.command.prev()
if self.i == 0:
break
self.i = self.i - 1
self.command = CMD_WRIT
self.yield_state = WRIT_CAS
yield self.yield_state.prev(), self.command.prev()
self.i = 1
while True:
self.command = CMD_NOP
self.yield_state = WRIT_NOP2
yield self.yield_state.prev(), self.command.prev()
if self.i == 0:
break
self.i = self.i - 1
return refresh_cnt, rd_enable, wr_enable
def read(self, refresh_cnt: m.UInt[10], rd_enable: m.Bit, wr_enable: m.Bit) -> (m.Bits[5], m.Bits[8]):
self.command = CMD_BACT
self.yield_state = READ_ACT
yield self.yield_state.prev(), self.command.prev()
self.i = 1
while True:
self.command = CMD_NOP
self.yield_state = READ_NOP1
yield self.yield_state.prev(), self.command.prev()
if self.i == 0:
break
self.i = self.i - 1
self.command = CMD_READ
self.yield_state = READ_CAS
yield self.yield_state.prev(), self.command.prev()
self.i = 1
while True:
self.command = CMD_NOP
self.yield_state = READ_NOP2
yield self.yield_state.prev(), self.command.prev()
if self.i == 0:
break
self.i = self.i - 1
self.command = CMD_NOP
self.yield_state = READ_READ
yield self.yield_state.prev(), self.command.prev()
return refresh_cnt, rd_enable, wr_enable