calyxir/calyx

rethink about the static interface

Closed this issue · 12 comments

Our current decision on the static interface is that a go signal is triggered for exactly 1 cycle and an output is produced n cycles later, where n is the static<n> annotation for the static component.

From our synchronous meeting, we decided the way we could implement this was the following: once we have compiled the inlined the control into a single static group, we make the assignments continuous and add the following guards:

  • fsm transition from 0 to 1 is guarded by the comp.go. All other fsm transitions are not guarded.
  • All other assignments that were in the single static group should by guarded by comp.go | fsm > 0. fsm > 0 is needed because we no longer necessarily need the go signal asserted for comp to be active: we just need to know the fsm is “running”. comp.go is needed because fsm will equal 0 when the component is inactive: we should make sure assignments are not triggered when this is the case.

We think this implementation would work but it incurs a cost of an ‘or’ per assignment, which could potentially increase LUT usage. There are also other concerns/ to think about:

  • The inconsistency between the triggering of static groups and the triggering of the static component. Calyx’s group’s go-done interface is consistent with its invocation interface in the sense that both the group’s interface and the invocation interface have go signals that trigger for the entire execution duration. However, our compilation of static<n> invoke comp would have compile to a static<n> group with comp.go asserted for cycle 0 , and then de-asserted for cycles [1,n) . This is expected, but just something we should take note of.
  • For static<n> invoke comp (// comp's other inputs)(...) , our current decision would need comp.go asserted for exactly one cycle, while the // comp's other inputs must be asserted for n cycles (unless we latch // comp's other inputs), which makes using the static interface potentially confusing.

The way we currently compile static invoke statements is turning the invoke into a static group with an annotated latency n that sets the component’s go signal high for n cycles (which is just an unguarded assignment inside the invoke group). The group also contains the assignments for the wiring of i-o ports active for n cycles. Regarding how pipelines would work, here is how static<n> pipelined_component might work. You should assert pipelined_component.go for n cycles to complete one stage of the pipeline. We think this idea may be workable?

(edit by Caleb for formatting)

All other assignments that were in the single static group should by guarded by comp.go | fsm > 0

This part doesn't make sense to me. Can we write down an example static group and work through what the compilation should look like?

Also, re: static invoke. It seems that you might just want something like:

static group inv_group<n> {
  comp.go = %0 ? 1'd1;
  comp.in0 = in0; comp.in1 = in1; ...
}

Said differently, the interface should make it trivial to reason about invoke compilation: it should just be a simple group.

re: re: static invoke. This is exactly how the invoke is compiled in the current implementation of the static interface. I guess my point is, if users want to structurally invoke a component for some reason, then our interface creates a weird discrepancy(?) between the time that the go signal is held and the inputs are held, which is kind of unnecessary.

Ah, I see! I think this is okay, however, because a static interface supports pipelining. Each time we set, static_comp.go = 1, we are sending a "token" that says, "please start a new computation." If we asserted the go signal for n cycles, we would be starting n new computations.

Asserting for go for 1 cycle and inputs for n cycles is a conservative way interface that says, "we don't know when exactly the inputs are used so we're going to assert them for the full latency". I think you should read the Filament paper to get a better sense of these kinds of interfaces. Filament's types really make it clear what is going on with interfaces like these.

All other assignments that were in the single static group should by guarded by comp.go | fsm > 0

This part doesn't make sense to me. Can we write down an example static group and work through what the compilation should look like?

After thinking about this, I think we should adjust our idea slightly. @paili0628 feel free to chime in on this.

Dynamic Component Compilation

Suppose this is our component, just before wire-inliner runs

component comp() {
  wires {
    group seq0 {
      ... 
    } 
  } 
  control {
    seq0;
  } 
} 

When wire-inliner runs, it compiles to:

component comp(go: 1) {
  wires {
    seq0_go.in = go; 
    // bunch of other assignments guarded by seq0_go.out 
  } 
  // control is empty 
} 

For static components, we don't want this to be the case: there are some cases where the comp.go signal is not asserted (but was asserted a few cycles ago), but we still want to the component to be running.

Proposed Static Component Compilation

component comp(go: 1) {
  wires {
    seq0_go.in = go | fsm > 0; // if seq0's fsm > 0, then that means the fsm is "already running" 
    // bunch of other assignments guarded by seq0_go.out 
  } 
  // control is empty 
} 

seq0_go.out is still guarding all of seq0's assignments. So the comp.go | fsm > 0 is indirectly (through a wire) guarding seq0's assignments.
Of course, this shouldn't include the assignments to the fsm, which should be guarded by comp.go for 0->1 and unconditional for everything else.

There will be some coding/engineering challenges with this, but does this make sense overall @rachitnigam @paili0628?

This makes sense but the guard has to be constructed at pass wire-inliner which makes it hard to pull out the fsm for the static group, (which is why my original implementation had the extra counter).

seq0_go.in = go | fsm > 0

I still don't understand what's going on here. If seq0 used to be a static group, then this will attempt to continuously execute seq0 every cycle right?

It will not. seq0 will execute for n cycles every time the go signal is up, where n is the annotated latency.

From the synchronous discussion:

static component comp() {
  wires {
    static<4> group seq0 {
        mult.go = %1 ? 1'd1;
        mult.left = 1;
        mult.right = 2;
    }
  }
  control {
    seq0
  }
}

// =>

static component comp() {
  cells {
    s_fsm = std_reg(4);
    @control start = std_wire(1);
  }
  wires {
    start.in = go;
    mult.go = %1 ? 1'd1;
    mult.left = %[0:4] ? 1;
    // =>
    mult.go = s_fsm.out == 1 ? 1'd1;
    mult.left = %0 | %[1:4] ? 1;
    // =>
    mult.left = start.out | (s_fsm.out >= 1 & s_fsm.out < 4) ? 1;
  }
  control {
    seq0;
  }
}

The compilation strategy needs to treat %0 as a special step because it needs to forward the value from the go signal.

@calebmkim could you share your notes from the synchronous meeting today? Also, if we have made a decision on how to proceed, let's change the labels.

Static islands (i.e., static control within a dynamic component) compilation doesn’t need to change for now, since they will be compiled with a wrapper and will be treated like any other dynamic group. Note that the assignments in a static islands will (at least indirectly) be guarded by the (dynamic) component’s go signal. Eventually we want to change this: we only want to have to trigger a static island’s go signal for one cycle, and the FSM will control execution from there.

Static components get a slightly fancier FSM.
The 0->1 is guarded by the static_comp.go signal, all other FSM transitions are unguarded.
Also, if a guard includes %[0:n], then we have to separate it out into %0 | %[1:n], and then replace %0 with static_comp.go.

@paili0628 and I just met to discuss some implementation details. We have a more concrete plan now so I removed discussion needed tag.

Fixed by #1759