Ports and wires

If you prefer documentation in video form there is a talk available on this topic.

Note that the syntax of &mut has changed to inv & since that talk

Units in Spade, unlike most HDLs are similar to functions in programming languages in the sense that the receive a set of values, and their output is another set of values. For example, a function that adds 2 numbers is written as

fn add(x: uint<8>, y: uint<8>) -> uint<9> {
  x + y
}

This makes sense for a lot of hardware where there is a clear flow of values from inputs to outputs, but this is not always the case. wires and ports are a language feature that helps deal with these cases.

To understand wires and ports, it helps to look at a motivating example. If you're building a project consisting of 2 modules that communicate with each other via some other module, such as a memory, you want your hardware to look something like this:

Image showing two pipelines interconnected via a memory. The connections are made from stages in the middle of the pipelines

Without using ports, you'd have to write the signature of this hierarchy as

pipeline(1) mem(clk: clock, addr1: uint<16>, addr2: uint<16>) -> (T, T)
pipeline(4) mod1(clk: clock, inputs: I, data: T) -> (uint<16>, O)
pipeline(3) mod2(clk: clock, inputs: I, data: T) -> (uint<16>, O)

entity top(clk: clock) {
  decl memout1, memout2;
  let (addr1, mod1_out) = inst(4) mod1(clk, I(), memout1);
  let (addr2, mod2_out) = inst(3) mod2(clk, I(), memout2);
  let (memout1, memout2) = inst(1) mem(clk, addr1, addr2);
}

Writing it like this is tedious, and more importantly, error-prone as there is no way to communicate which signals correspond to each other. One might assume that the left output of the memory result is the data corresponding to address 1, but there is nothing to enforce this.

In addition, the pipelines internally have to prevent the addresses and returned data from being pipelined:

pipeline(4) mod1(clk: clock, inputs: I, mem_out: T) -> (uint<16>, O) {
        'start
    reg;
        // ...
    reg;
        'mem_read
        let mem_addr = inst mem_ctrl();
    reg;
        let result = inst compute(stage(start).mem_out);
    reg;
        (stage(mem_read).mem_addr, result)
}

This is another pain point and more importantly a source of errors. Graphically, the structure is more like the following which is as hard to follow as the code that describes it:

Image showing two pipelines interconnected via a memory when delays have to be accounted for manually.

Wires

The solution to the pipelining problem is a new type called a wire denoted by &. Wires, unlike values are not delayed in pipelines and can intuitively be viewed as representing physical wires connecting modules rather than values to be computed on.

To "read" the value of a wire, the * operator is used and to turn a value into a wire, & is used.

With this change, the pipeline example can be rewritten as

pipeline(4) mod1(clk: clock, inputs: I, mem_out: &T) -> (&uint<16>, &O) {
    reg;
        // ...
    reg;
        let mem_addr = &inst mem_ctrl();
    reg;
        let result = inst compute(*mem_out);
    reg;
        (mem_addr, &result)
}

For now, it is not possible to return a compound type with both wires and tuples, which is why the output of the module was changed to &O.

Inverted wires

There is still at least one big problem with the current structure: returning addresses as outputs and taking values as inputs is problematic as there is no clear link between input and output, and the return value of a unit ends up being a mix of both control signals like addresses, and values computed by the unit.

The solution to this problem is inverted wires, denoted inv &. These wires flow the opposite way to the normal flow of values. A unit which accepts an inverted wire as an input is able to set the value of that wire. A unit which returns an inverted wire is able to read the value that was set by the "other end"

Inverted wires are created using the port expression which returns (T, inv T)

let (read_side, write_side) = port;

The set statement is used to give set the value of an inverted wire. For example

set adder_out = a + b;

Rewriting the pipeline once again using inverted wires results in

pipeline(4) mod1(clk: clock, inputs: I, mem_addr: inv &uint<16>, mem_out: &T) -> O {
    reg;
        // ...
    reg;
        set mem_addr = inst mem_ctrl();
    reg;
        let result = inst compute(*mem_out);
    reg;
        result
}

The code can be made even neater by grouping all the memory signals together into a tuple:

pipeline(4) mod1(clk: clock, inputs: I, mem: (inv &uint<16>, &T)) -> O {
    reg;
        // ...
    reg;
        set mem#0 = inst mem_ctrl();
    reg;
        let result = inst compute(*mem#1);
    reg;
        result
}

Wires are passed around as if they were values, so our memory can now return all its signals, both inputs and outputs. As an example, to convert from a memory that does not use ports to one that does, we can write:

// A mockup memory which takes 2 addresses and returns two values.
pipeline(1) fake_memory(clk: clock, addrs: [uint<16>; 2]) -> [T;2]

pipeline(1) mem(clk: clock) -> ((inv &uint<16>, &T), (inv &uint<16>, &T)) {
        let (addr1_read, addr1) = port;
        let (addr2_read, addr2) = port;
        let [out1, out2] = inst(1) fake_memory(clk, [*addr1_read, *addr2_read]);
    reg;
        ((addr1, &out1), (addr2, &out2))
}

This finally allows us to write a neat top module for our running example:

entity top(clk: clock) {
    let (m1, m2) = inst(1) mem(clk);
    let out1 = inst(4) mod1(clk, I(), m1);
    let out2 = inst(4) mod2(clk, I(), m2);
    // ...
}

Ports

It is often desirable to define structs of related wires, for example the wires we've used in the memory interface. We can wrap them all in tuples like we did above it is often desirable to give things names with structs. To put wires in structs, we need to define them as struct port which tells the compiler that the struct is of port kind which is a broader concept than just struct port. In fact, wries, their inversions, compound types of wires like tuples and even clocks are all ports as opposed to values as discussed previously. Most of the time, what is and what is not a port is unimportant, but they have two important properties:

  • Ports are not pipelined.
  • Generic arguments cannot be ports.

We can define a struct port for our memory example as

struct port MemoryPort<T> {
    addr: inv &uint<16>,
    // A practical memory will usually also have a write value:
    write: inv &Option<T>,
    read: &T,
}

inv for real

The inv type is not only used to invert wires, it can be used to invert whole ports. Effectively this flips the direction of all wires in the port. This is very useful if there is no "owner" of a particular port as is the case with the memory example. We could tweak our memory example to use an inverted port by making the memory module also accept the port as an (inverted) input.

pipeline(1) mem<T>(clk: clock, p1: inv MemoryPort<T>, p2: inv MemoryPort<T>) {
        let [out1, out2] = inst(1) fake_memory(clk, [*p1.addr, *p2.addr]);
    reg;
        set p1.read = out1;
        set p2.read = out2;
}

entity top(clk: clock) {
    let (m1, m1_inv) = port;
    let (m2, m2_inv) = port;
    let _ = inst(1) mem::<uint<32>>(clk, m1_inv, m2_inv);
    let out1 = inst(4) mod1(clk, I(), m1);
    let out2 = inst(3) mod2(clk, I(), m2);
    // ...
}

Inverted wires must be set

It is important that a circuit which uses inveted wires has a well defined value for all wires. In practice this means that a wire can only be assigned to exactly once, which is enforced by the compiler.

In practice this means that if you create an inv & wire, or receive one as an argument you must either set the value, or hand it off to a sub-unit you instantiate.

For example, if we make an error while writing the top module in our running example and accidentally pass m1 to both mod1 and mod2

entity top(clk: clock) {
    let (m1, m2) = inst(1) mem(clk);
    let out1 = inst(4) mod1(clk, I(), m1);
    let out2 = inst(4) mod2(clk, I(), m1);
                                   // ^^ Should be  m2
}

We get a compilation error:

error: Use of consumed resource
    ┌─ src/wires.spade:234:39
    │
3   │     let out1 = inst(4) mod1(clk, I(), m1);
    │                                       -- Previously used here
4   │     let out2 = inst(3) mod2(clk, I(), m1);
    │                                       ^^ Use of consumed resource

Similarly, if we don't give m2 a value by removing the last line, we get another error

error: swim_test_project::wires::m9::m2.addr is unused
    ┌─ src/wires.spade:231:10
    │
231 │     let (m2, m2_inv) = port;
    │          ^^ swim_test_project::wires::m9::m2.addr is unused
    │
    = note: swim_test_project::wires::m9::m2.addr is a inv & value which must be set

Conditional assignment

Since Spade is expression based, setting the value of an inv & wire inside an if branch is not supported. For example, you may be tempted to write a multiplexer as

entity mux(sel: bool, on_false: bool, on_true: bool, out: inv &T) {
  if sel {
    set out = on_true
  } else {
    set out = on_false;
  }
}

However, this will result in a multiply used resource error.

The correct way to write this is instead

entity mux<T>(sel: bool, on_false: T, on_true: T, out: inv &T) {
  set out = if sel {on_true} else {on_false};
}

NOTE This mux is only written like this to showcase how mutable wires are used A better way to write a mux is

entity mux<T>(sel: bool, on_false: T, on_true: T) -> T {
    if sel {on_true} else {on_false}
}