Should SSA values in Handshake always have implicit handshake semantics?

Following up from [Handshake] Deprecating the FIRRTL lowering · Issue #5102 · llvm/circt · GitHub -

To me, the main issue with the Handshake dialect, as of this moment, is composability. Currently, the only way to compose the handshake dialect is to have handshake module instantiations + external handshake functions that eventually lower to a hw.module with a handshake-compatible interface. What if i want to have some parts of a module interface with a handshaking signal interface, and others without? or what if I want to embed an FSM inside a module that uses handshake operators, but I don’t want that FSM to be a second-class citizen wrt. control within the module (i.e., the FSM is forced to use handshake to communicate with other things).

Here’s two directions which the dialect could go in:

Make SSA values with Handshake semantics implicit through typing

That is, introduce an e.g. !handshake<T> type which would be required for all handshake operations. By introducing this, we will thus remove the notion that all SSA values have handshake semantics and thus allow for the embedding of other operations alongside handshake ops. To interact with non-handshake operations, we could introduce a handshake.unit operation, which wraps I/O signals with unit-rate actor semantics, e.g.:

handshake.func @foo(...) {
   ...
   %0 = arith.add %1, %2 : i32
}

becomes

handshake.func @foo(...) {
   ...
    %out = handshake.unit (%0, %1) : (!handshake<i32>, !handshake<i32>) -> (!handshake<i32>) {
    ^bb0(%in0 : i32, in1 : i32):
      %res = arith.addi %in0, %in1 : i32
      return %res : i32
    }
}

One counterpoint to this may be that in case you previously had a chain of non-handshake operations, those would now have to be represented as a chain of handshake.unit operations, which clearly would be harder to analyze and reason about.
As @stephenneuendorffer points out in the issue linked at the top of this post, there seems to be two competing uses/wants from the Handshake dialect:

  1. Modeling designs with KPN-type semantics.
  2. Modeling RTL designs with handshake signals as a shorthand to avoid explicitly writing valid/ready signal everywhere.

This proposed change would apply to the 2nd use, easing the use in which handshake can be composed with other designs. However, it makes modelling KPN designs more difficult, since this (arguably) the whole point of having implicit handshake semantics on all SSA values.

Which leads to a second, hybrid proposal

SSA semantics based on parent container

A second design point would be to allow handshake operations to exist outside of a handshake.func operation. We could thus say (i.e. verify) that

  1. whenever handshake operations live within a handshake.funcs, SSA value semantics are of implicit handshake signals, and handshake operations may take any type. “implicit handshake semantics” thus becomes a property of the handshake.func container.
  2. Whenever handshake operations live outside of a handshake.func (e.g. inside a hw.module or some other container), SSA value semantics are of explicit handshake signals (since we want to allow other signalling standards/semantics to live side by side with the ops). Handshake operations would therefore only accept !handshake<T>-typed values.

e.g. the following would be legal:

handshake.func @x2(%0 : i32) -> (i32) {
    %1:2 = fork [2] %0 : i32
    %2 = arith.addi %1#0 ,%1#1 : i32
    return %2 : i32
}

hw.module @x4(%0 : !handshake<i32>, %1 : !handshake<i32>) -> (!handshake<i32>) {
    %0 = handshake.instance @x2(%0) : (!handshake<i32>) -> (!handshake<i32>)
    %1 = handshake.instance @x2(%1) : (!handshake<i32>) -> (!handshake<i32>)
    %2 = handshake.unit (%0, %1) : (!handshake<i32>, !handshake<i32>) -> (!handshake<i32>) {
    ^bb0(%in0 : i32, in1 : i32):
      %res = arith.addi %in0, %in1 : i32
      return %res : i32
    }
    return %2 : !handshake<i32>
}

At first glance, this seems like the best of both worlds. If people want the KPN semantics, they can create their designs (with ease) inside a handshake.func. If instead it makes more sense to compose handshake operations alongside other kinds of operations, the second approach can be employed.
The semantics of the handshake operations remain the same, and I’d expect lowering paths to be shared close to completely.

CC people who may be interested in this:
@Dinistro @stephenneuendorffer @Lucas @mikeurbach @jdd

I’ve actually – at various times – considered the second option for ESI since it sure would be convenient to be able to perform arithmetic on esi channels in my tests!

Would operations like loops be allowed inside handshake.unit?

What’s the semantic difference between !handshake<type> and !esi.channel<type, signaling_type>? I think the difference is that handshake doesn’t imply a wire signaling standard. Longer-term, I plan on making ESI channels select their own signaling standard (ValidReady, FIFO0, FIFO1, etc.)… I’m not necessarily suggesting that you use the latter, though it could be useful to unify on one latency insensitive container type if the semantics are similar enough.