Inverted Signals and Wires
Units in Spade, unlike most HDLs are similar to functions in programming languages in the sense that they receive a set of values, and their output is another set of values. For example, a function that adds two numbers is written as
fn add(x: uint<8>, y: uint<8>) -> uint<9> {
x + y
}
While this may feel unfamiliar to hardware designers, it is a very deliberate choice as most hardware modules have a relatively well-defined notion of “direction”. An adder takes inputs and produces outputs, a pipeline gradually transforms values from input to output, and even something complex with a lot of feedback like a processor has most signals flowing “forward” with the occasional wire going against the flow to update state. This can be accomplished with stage references in pipelines, or explicitly pre-declaring the few signals that oppose the flow using the decl keyword. By assuming a forward flow of data, it becomes easier to chain operations, and creating combinational loops becomes more difficult.
However, especially when interconnecting modules, there is not an as clear notion of directionality which makes the pure input-to-output flow of Spade limiting. Therefore, Spade contains “inverted” signals. To motivate their use, let’s consider a worker that is connected to a memory. The memory is relatively straight forward:
entity memory(addr: Addr) -> MemoryContent {
// ...
}
it receives an address to read from, and returns the value stored at that address.
The worker without the memory access details is also relatively straight forward:
entity worker(i1: Input1, i2: Input2) -> Output {
// ...
}
However, when adding the memory interface to the worker, things become more tricky. It produces an address to read from, in addition to its normal output, and receives the read result as an input:
entity worker(i1: Input1, i2: Input2, memory_read: MemoryContent) -> (Output, Addr) {
// ...
}
Not only does this add noise to the declaration, but there is also no clear link between the input containing the read result, and the output address. This is manageable for a single port like this, but becomes a real problem when units need to interact with multiple ports.
At the instantiation site, things are equally un-ergonomic:
decl addr;
let read_result = inst memory(addr);
let (result, addr) = inst worker(i1, i2, read_result);
inv signals
The solution to this is inverted signals, denoted inv. An inverted signal flows in the opposite direction of other signals, i.e. if a unit returns an inverted signal, it can read its value, and if it receives an inverted signal as an input, it has to give it a value. With inverted wires, we can rewrite the memory like this:
entity memory() -> (inv Addr, MemoryContent) {
// ...
}
the worker like this:
entity worker(i1: Input1, i2: Input2, memory: (inv Addr, MemoryContent)) -> Output {
// ...
}
and the instantiation like this:
let mem_port = inst memory(addr);
let result = inst worker(i1, i2, mem_port);
which is much more ergonomic and solves the problem of the address being disconnected from the resulting value.
Effectively, we have changed the memory from taking an address and returning the content at that address to producing a memory port as a cohesive unit. The worker now accepts such a memory port which it uses for processing. This means we are no longer values between units, we are passing bundles of wires. This has a few consequences which we will see later in the chapter.
You are not limited to inverted signals in tuples, they can appear in most compound types, for example arrays if you have multiple signals to set, or in structs if you want to define a more complex port.
Since Spade remains linear, you will often want to define a “forward” direction for ports. Here, we chose to define a memory as “producing” a memory port, while the worker “consumes” the port. We could have done the opposite here, having the worker return a (Addr, inv MemoryContent) port which gets passed to the memory. However, since the worker already produces another output value, in this case it is more ergonomic to have the memory be the “producer” of the port.
Creating inv Signals With port
Having read the previous example, you are probably wondering what the body of the memory and worker looks like. Starting off with the memory, its internals look like this:
let addr = port;
let result = read_from_memory(addr.0);
(addr.1, result)
On the first line, we use a new expression: port. It creates a new (T, inv T) pair where the value of the first element is the value set on the second. On the second line, we use the non-inverted value to do the actual reading from the memory, emulated by the read_from_memory function here. Finally, on the last line, we return the other end of the port, the inv T where another module will set the read address, and the read result.
Setting inv Signals With set
The worker receives a memory port, which means it needs to set an address to read from on the inverted signal, and can then read the resulting value on the non-inverted signal. This is done with the set statement:
entity worker(i1: Input1, i2: Input2, memory: (inv Addr, MemoryContent)) -> Output {
let addr = compute_addr(i1, i2);
set memory.0 = addr;
let read_result = memory.1;
// ...
}
set takes an inverted signal and assigns a value to it. The non-inverted memory output can be read without any special statements.
Correctness Guarantees
Inverted wires come with some rules that the compiler enforces in order to ensure correctness. First: an inverted wire needs to have a well-defined value, which means that it must be set exactly once. If it is not set at all, for example, if the memory port is not connected to any driver, then the read address is left floating and the memory does not know which address to read from. Similarly, if the address wire were connected to two separate drivers, there would be a conflict and the memory would be unable to serve both writers.
Luckily, this is not something you as a programmer have to actively ensure, the compiler will check your work and report errors in case a wire is not set, or if it is set multiple times. As an example, consider a unit consisting of a memory with two ports that are both
As an example, consider the following unit which has a memory with two parallel ports m1 and m2 which are passed to two workers (mod1 and mod2)
entity top(clk: clock) {
let (m1, m2) = inst mem(clk);
let out1 = inst mod1(clk, m1);
let out2 = inst mod2(clk, m2);
}
Here, it would be very easy to be sloppy and accidentally pass the first memory port to both modules, which would result in this error:
error: Use of consumed resource
┌─ src/wires.spade:234:39
│
3 │ let out1 = inst mod1(clk, m1);
│ -- Previously used here
4 │ let out2 = inst mod2(clk, m1);
│ ^^ Use of consumed resource
Similarly, if we only instantiate one worker and don’t drive the second one, we would get an error like this:
error: swim_test_project::wires::m9::m2.addr is unused
┌─ src/wires.spade:231:10
│
231 │ let (m1, m2) = port;
│ ^^ m2.addr is unused
│
= note: m2.addr is a inv value which must be set
Setting Wires Conditionally
An aspect that is easily forgotten when writing Spade is that what you are describing is not a sequence of instructions for a processor to execute, but a list of hardware to be instantiated. When working with inverted signals, this becomes more important as you may be tempted to conditionally set a value of a wire like this:
if cond {
set w = 1;
} else {
set w = 2;
}
However, doing this results in a compiler error: w is set twice. The intuition behind this is that if cond {...} else {...} does not mean “execute” the true branch when cond is true, it means create the hardware for the true branch and false branch, then use a multiplexer to connect the true or false branch to the output, depending on the condition. Since both branches get instantiated, you are now trying to connect w to both 1 and 2.
The solution is to instead set the value of w based on the condition:
set w = if cond {1} else {2};
The Data trait
Earlier in the chapter, we wrote “This means we are no longer values between units, we are passing bundles of wires”. This turns out to be an important distinction as some primitives such as registers can only contain values, not wires going both directions.
Just like the guarantees that wires are set exactly once, registers only containing values is enforced by the compiler, but it is useful to know that this is the case.
The mechanism used to enforce this is a special trait called Data which is implemented automatically by any signal that can be stored in registers. Generally, you do not have to concern yourself with this trait, but it will appear in type errors when wires are used as values.
wire and pipelines
Finally, when working with pipelines you will also run into the issue of bundles of wires not being possible to store in registers, which means that they cannot be delayed in pipelines. To solve this, variables in pipelines can be marked wire which means that they will not be placed in pipeline registers. For example, if the worker defined above were a pipeline, you would have to define the memory port as a wire:
entity worker(i1: Input1, i2: Input2, wire memory: (inv Addr, MemoryContent)) -> Output {
let addr = compute_addr(i1, i2);
reg;
set memory.0 = addr;
let read_result = memory.1;
reg;
// ...
}
This means that any non-inverted signals values in the memory signal are not delayed by the pipelining. You can also annotate signals which do not contain any inv signals with wire for signals which you do not want to be pipelined.