calyxir / calyx

Intermediate Language (IL) for Hardware Accelerator Generators
https://calyxir.org
MIT License
496 stars 50 forks source link

Allowing for nested `ref` cell instantiation #2079

Open nathanielnrn opened 5 months ago

nathanielnrn commented 5 months ago

It feels like it could be useful to allow for the passing in of cells that contain refs to other components that also take in ref cells. Currently, there isn't a way to express this in the language. I imagine this will be even more powerful once subtyping #2015 is introduced.

As a motivating example we can imagine a binary_op component that takes in a reference to some op, which itself takes in a reference to an implementation of an op. (To actually be useful this would require subtyping to allow to pass in all kinds of ops, for now we can assume this is a multiply.

I can imagine a case where during one invocation of op we want to use a fast multiplier, and during another invocation we want to use an efficient multiplier. This could be expressed as different implementations.

component bin_op () -> (){
  cells{
    ref op = multiplier();
  }
//...
}

component multiplier () -> (){
  cells{
    ref impl = implementation();
  }
  //...
}

component implementation () -> (){
  //...
}

comp main () -> () {
  cells{ 
    my_impl = implementation(); 
    my_multiplier = multiplier();
    my_op = my_op();
  }

  control{
    invoke my_multiplier[impl = my_impl]()(); //this attaches my_impl cell to ref cell impl
    invoke my_op[op = my_multiplier]()(); //this seems like it would have an "empty" impl?
  }
}

Note the last line passes in my_multiplier into an invocation of my_op, but my_multiplier doesn't have any implementation cell hooked up to it.

A work around could be to create high-level components with all the cells needed by lower-level components. In the above case this would mean that the high-level bin_op would need a reference to an implementation:

component bin_op () -> (){
  cells{
    ref bin_op_impl = implementation(); //added cell
    ref op = multiplier();
  }
//...
  control{
    invoke op[impl = bin_op_impl]()();
  }
}

We could then thread the concrete implementation found in main through a single invocation of my_op


comp main () -> () {
  cells{ 
    my_impl = implementation(); 
    my_multiplier = multiplier();
    my_op = my_op();
  }

  control{
    //Not needed anymore: invoke my_multiplier[impl = my_impl]()();
    invoke my_op[op = my_multiplier, bin_op_impl = my_impl]()(); //this seems like it would have an "empty" impl?
  }
}

But this seems equivalent to manually hoisting up all lower level ref cells and isn't very scalable.


An idea that came up for how to address this via @anshumanmohan

It may be useful to hook up ref cells in the cells block of a component. This would allow us to refer to "partially applied" cell instantiation within control blocks. So going back to our initial example, we could change the cells section of main as follows.

component bin_op () -> (){
  cells{
    ref op = multiplier();
  }
//...
}

component multiplier () -> (){
  cells{
    ref impl = implementation();
  }
 //...
}

component implementation () -> (){
  //...
}

comp main () -> () {
  cells{ 
    my_impl = implementation(); 
    my_multiplier = multiplier[impl = my_impl](); //now we can refer to my_multiplier in the control block and know that it has some some implementation component passed in
    my_op = my_op();
  }

  control{
    //also not needed anymore: invoke my_multiplier[impl = my_impl]()();
    invoke my_op[op = my_multiplier]()(); //this should be enough to perform an operation with a multiplier that uses `implementation`
  }
}

I think this would allow for the threading of concrete cells through component hierarchies by only worrying about them in the single component they are referenced in? So the only place we'd have to keep track of/instantiate an implementation cell would be in multiplier, which is exactly where a ref of implementation is expected, and we wouldn't have to "hoist up" an implementation cell into bin_op.

This might ruin some assumptions about invocations/passing in cells? Right now the only way to pass in cells to references is via invokes, and this would introduce such passing to cell declaration? It might be problematic w.r.t to the IR or the current passes, but hopefully this wouldn't be too big of a change. Maybe @calebmkim @sampsyo or @rachitnigam has thoughts about this aspect.

Perhaps this is worth talking about in the next Calyx meeting as well?

rachitnigam commented 5 months ago

What specific use case requires this capability?

nathanielnrn commented 5 months ago

I believe the idea came from wanting to pass in a seq_mem to an axi_seq_mem wrapper as a ref cell, then pass in the axi_seq_mem to an axi_wrapper as a ref cell. I moved away from that approach, partially because it is not currently possible.

I believe there was also an idea this could be useful with respect to some arbitration logic? @anshumanmohan would have to expand on that though.

nathanielnrn commented 4 months ago

Another example I just encountered which might be a bit more contained:

An axi_seq_mem consists of read_controller and write_controller which consists of a number of read channels and a number of write channels respectively. I'd like to pass in a std_reg that lives in an axi_seq_mem through the read_controller to a read_channel but this is not possible currently.

nathanielnrn commented 4 months ago

Following up based on a discussion we had at the weekly Calyx meeting.

Another, somewhat more generalized solution to this issue that @sampsyo proposed would be "scoped withs" that I understand have come up before (couldn't find an issue for this). The proposal as I understand it is to allow generalized with to exist within a control block, that describes the wiring of signals/ref cells`. So in the multiplier example we can imagine


comp main () -> () {
  cells{ 
    my_impl = implementation(); 
    my_multiplier = multiplier(); // back to the original version
    my_op = my_op();
  }

  control{
    with my_multiplier::impl = my_impl { // within here `impl` is wired up to `my_impl`
      invoke my_op[op = my_multiplier]()(); 
    }
  }
}

Another benefit is that this decouples the wiring of components from their invocations, which feels like it might be a bit constraining in the first place.

It might also make sense to allow for the assignment of single signals within with blocks, probably all on the same level, say something like

with [my_multiplier::impl = my_impl, my_multiplier.go = controller.out, ...] {}

I believe @EclecticGriffin mentioned that it seems feasible to compile these with blocks into the existing withs that enable combinational groups.

It was discussed that unfortunately this new with construct sort of couples a bunch of changes together: changes to invoke, new with, allowing for nested ref cells. So this proposal might deserve its own issue and further discussion about the details of these changes.