Skip to content

Decoupled Pipelines

Often in hardware design, pipelines cannot be statically timed to a number of cycles. Buffers can fill and stages can stall, necessitating all previous stages to hold their data until unblocked. In these cases, stages of a pipeline must be decoupled. Instead of knowing that an input will produce an output in a certain number of cycles, IO between stages is described using a decoupled interface. Decoupled interfaces are traditionally composed of three wires: data, valid, and ready. Ready is set by the consumer of the interface, and indicates if the consumer is not itself stalled and is able to receive the output from the provider. Valid indicates if the provider has data to provide that cycle, and data contains the output of the stage. When both ready and valid are set in the same cycle, the handshake is completed, meaning the consumer has processed the data from the provider. The provider now sets new data if it has any or sets valid to false otherwise.

Traditional HDLs force the architect to correctly implement this interface throughout a design, whereas Neo can guarantee soundess of decoupled pipelines through the Decoupled interface. Decoupled provides a safe abstraction that ensures the control wires are handled correctly.

A Decoupled interface is instantiated as any other:

neo
module IncrementStage {
  consumes in: Decoupled(Int(64)),
  provides out: Decoupled(Int(64)),
} {
  ...
}

To add a stage between two Decoupled interfaces, you can use the in.stage_into(out) method. It will connect the control signals between the interfaces and returns an assignable logic, which will be buffered to support the stalling of that stage. That logic will only accept assignments from logic values derived from the interface in, ensuring that all inputs of a stage are stalled when the output is.

neo
module IncrementStage {
  consumes in: Decoupled(Int(64)),
  provides out: Decoupled(Int(64)),
} {
  let out_buffer = in.stage_into(out); 
  out_buffer = in.data + 1; 
}

Sometimes, you may want to connect Decoupled interfaces without creating a registered stage, or otherwise have more control over the dataflow. The .provide method enables you to feed unbuffered data to a decoupled interface. It takes two functions, one that represents the dataflow when the consumer is ready, and one for when the consumer needs to stall.

To consume data from a Decoupled interface, Neo provides the .accept() and .stall() functions. .accept() sets ready to true and returns a Valid union that contains the data set by the provider. .stall() sets ready to false and returns no data.

neo
module Arbiter(T: LogicType) {
  consumes a: Decoupled(T),
  consumes b: Decoupled(T),
  provides out: Decoupled(T),
} {
  comb {
    out.provide(() => {
      // The ready case function returns a logic which feeds the interface
      match a.accept() {
        Valid(a) => {
          b.stall();
          Valid(a)
        },
        None => b.accept(),
      }
      // The second function returns no data, as it represents the case where the consumer is not ready
    }, () => {a.stall(); b.stall();});
  }
}

While it may appear easy to make mistakes when using .provide and .accept/.stall, there are actually many checks being performed in this example that eliminate common pipelining errors:

  • .provide checks for consistent assignments across both functions. Since .accept sets a's and b's ready logic in output's ready case, a missed call to .stall in the stall case will throw an error. This ensures all pipelines that provide data are explicitly stalled when the output is. (If you need to access data from a Decoupled interface without setting ready, use .peek).
  • .provide requires that the returned data in the ready case has a timing of * (always). This ensures that no cycle-timed logic is fed into an interface where the data may be ignored in the case of a stall. It also prevents using data from another decoupled interface that is not stalled when out is.
  • .accept ensures that ready is set to true in order to access the data, mitigating duplication of data in the pipeline from a missed handshake.
  • .accept returns a union that prevents accessing invalid data, ensuring bubbles in the pipeline don't cause unintended effects