Units
The basic building blocks of a Spade project are units. A unit takes a set of input signals, "processes" them, and usually produces a resulting output signal. We already saw an example of a unit in the blinky chapter, but here we will go into them in a bit more detail.
The basic syntax for defining all three is the same for all three though. They
start with fn
, entity
or pipeline
depending on their "flavor" which we
will talk about soon, then the name of the unit is specified. The unit inputs
are specified inside ()
with each argument on the form name: type
. The
output of the unit is specified after the parameter list as -> type
, and
finally the body of the unit is specified.
As an example, the blinky from the previous chapter has the following definition
entity blinky(clk: clock, rst: bool) -> bool
which means it
- is an
entity
- called
blinky
- which takes 2 inputs:
clk
with typeclock
andrst
with typebool
- returns a
bool
At this point you are probably wondering why we keep calling them "units" when they are defined as entity
. The reason for this is that units come in three "flavors": function
, entity
and pipeline
. While they all take inputs and produce outputs, their semantics are somewhat different
-
Entities are the most general units, but as we will see, they also come with the fewest guarantees. If you need registers but don't want to use a pipeline, you should use an
entity
. -
Functions are a special case of entities which don't allow registers or instantiation of non-functions. This means that they cannot contain any state, which in hardware terms means they are combinational, and in software terms means they are pure. While any function can be written as an entity, it is good practice to use functions whenever possible as it tells readers of the code that the unit is non-stateful.
-
Pipelines are a special unit which, as the name implies, is used when building pipelines. You will learn more about these in a later chapter.
In general, you should prefer to use function
and pipeline
where possible,
and only resort to entity
in cases where you both need state, and when the
hardware you are building is not pipeline-like, for example our blinky module.
Instantiating Units
Units are not very useful if they cannot be instantiated. Functions are
instantiated using the same syntax as function calls in C-like software
languages: function_name(parameter1, parameter2)
.
Entities on the other hand need the inst
keyword before the instantiation,
for example inst entityoname(parameter1, parameter2)
.
This is done to alert you as a writer of the code, and future readers of the
code that the unit you are instantiating can have underlying state.
If you do not see inst
, you know that that is a function and therefore is
pure which allows you to make more assumptions about the behaviour of your
circuit without having to read through the source code of what you are
instantiating.
Finally, when instantiating pipelines, you specify the pipeline depth after
the inst
, so inst(10)
. This will be described in more detail later.
Passing arguments
Of course, most functions need their arguments specified, and there are two ways to pass arguments to units in Spade: by position or by name.
Positional arguments work like they do in most languages: the first value passed is matched with the first argument, the second with the second and so on. It is the syntax we have seen so far.
Named arguments have a $
sign before the argument list and allow you specify
the name of each argument along with the value it should receive as arg: value
.
As an example, if we want to instantiate the following entity
entity some_entity(x: uint<8>, y: uint<8>) -> uint<8> // ...
with x=10
and y=15
we can do so with positional arguments as
inst some_entity(10, 15)
or using named arguments
inst some_entity$(x: 10, y: 15)
// or
inst some_entity$(y: 15, x: 10)
In many cases when specifying arguments by name, you have a variable where you
want to do your instantiation that has the same name as the argument you want
to pass it to. You could of course specify arg: arg
, but Spade also allows
you to use a short-hand syntax and only specify arg
in this case.
Continuing with our example, function, yet another way to instantiate it is therefore:
let x = 10;
let y = 15;
inst some_entity$(x, y)
You can even mix and match shorthand names with long names, which is especially useful
if you have signals with common names such as clk
and rst
:
entity do_something(clk: clock, rst: bool) -> uint<8> {
let x = 10;
inst takes_clk_rst$(clk, rst, x, y: trunc(x + 5))
}
However, note that you cannot mix positional and non-positional arguments!
Which style to use depends on your application and code, you should strive for the variant that gives the most readable code. Sometimes that means you pass arguments by position because the order is obvious while other times, you opt to pass arguments by name because your unit takes too many signals to keep track of their positions.
For Software People: Instantiation vs calling
Instantiation is similar in behaviour to "calling" in software terms, but because we are building hardware, we cannot simply "transfer control flow" to another function. Instead, we copy the hardware inside the function to our "chip" and connect its inputs and outputs as appropriate.
As an example, if we define the following functions
fn add(a: uint<16>, b: uint<16>) -> uint<16> {
trunc(a + b)
}
fn mul(a: uint<16>, b: uint<16>) -> uint<16> {
trunc(a * b)
}
fn sel(a: uint<16>, b: uint<16>, cond: bool) -> uint<16> {
if cond {a} else {b}
}
which generate the following hardware
and then use them as part of a bigger function:
fn mul_or_add(a: uint<16>, b: uint<16>, multiply: bool) -> uint<16> {
sel(add(a, b), mul(a, b), multiply)
}
it generates this hardware:
This is important to keep in mind as a very important metric for resource usage in hardware is the area of the chip being used. In software, an expensive function only used very rarely is relatively cheap since the time taken for the program to run is the main cost. However, in hardware, as soon as a unit is instantiated, you pay the cost upfront, regardless of it is used millions of times per second or just once over the lifetime of the chip.
In addition, it is important to keep in mind how much area each function and operator uses. In the graphics drawn now, the multiplier looks as big as the adder, but in practice, the size of the adder grows as \(O(n)\) in the number of bits, while the multiplier grows as \(O(n^2)\). In FPGAs, things are even trickier as they have built in multipliers. While you have spare multipliers, they are free in terms of other resources, but they themselves are finite. The resource usage of different units is generally something you will learn over time.
Naming conventions
While not strictly required, unit names are usually written using
snake_case
, so are variable names. User defined types use on PascalCase
while constant values use SCREAMING_SNAKE_CASE
Exercises
Modify the blinky code from the previous chapter to do the following
- Break the check for
count > (duration / 2)
into a function- Call with named arguments
- And positional arguments
- Break the counter logic out into its own
unit
- Should it be an entity or function?
Brief intro to generic parameters
We will discuss the type system in more detail later, but you will most likely come across a few generic functions before then, so here is a quick introduction.
In the functions we have seen so far, the type of the arguments has been
specified explicitly, for example, sel
in the example above takes two
uint<16>
and a bool
. However, this is quite restrictive, we may want sel
to operate on other sized integers, or other types entirely. There is nothing in
that function that requires 16-bit unsigned integers.
We can redefine sel
to make the values it selects "generic" as follows:
fn sel<T>(a: T, b: T, cond: bool) -> T {
if cond {a} else {b}
}
which defines a new local type T
that can be substituted for any other type in the
implementation, as long as that same type is used everywhere T
is.
We can now instantiate sel
with different types
let x_16: uint<16> = 10;
let y_16 = 10u16; // You can specify the type of integers using `u<size>` or `i<size>`
let max_16: uint<16> = sel(x_16, y_16, x_16 > y_16);
let (x_32, y_32) = (0, 0);
let selected: int<32> = sel(x_32, y_32, select_x);
In some cases, the typeinference is unable to infer the generic parameters of
an instance which you can resolve by specifying them using the "turbofish"1
syntax (::<>
). Like function arguments, type parameters can be specified positionally or by name using ::<>
or ::$<>
:
// We don't have enough information about what type the integers have here, we'll
// get a compiler error
let selected = sel(10, 20, select_10);
// Turbofish solves that
let selected = sel::<uint<8>>(10, 20, select_10);
let selected = sel::$<T: uint<8>>(10, 20, select_10);