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 type clock and rst with type bool
  • 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

The hardware generated by the above code

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:

The hardware generated by the above code

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