Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support splitting up struct method parameters into multiple input ports #729

Open
wants to merge 44 commits into
base: main
Choose a base branch
from

Conversation

krame505
Copy link
Contributor

@krame505 krame505 commented Aug 20, 2024

After some back and forth with @nanavati, we ended up going with a design that is closer to my original proposal in #714. It is really hard to do port splitting in GenWrap.hs with the degree of flexibility that we want, and there are some limitations like not being able to resolve numeric type operations, e.g. for computing the size of a vector. Not to mention that code is incredibly tedious and hard to modify, and would only make it harder to implement features like methods with multiple output ports. Doing this with type classes makes the logic a bit more transparent and easier to modify in the future.

There are several significant changes bundled together in this pull request:

  1. A significant refactor to wrapper generation, using a type class to determine the types of fields in wrapper interfaces, replacing some of the hard-coded logic in GenWrap.hs;
  2. Move port type saving logic out of the evaluator and genwrap into the type class;
  3. Pass method argument names by tagging method fields with a new primitive instead of via BetterInfo (this allows determining input port names at elaboration time);
  4. Move the sanity checks for port name collisions to post-elaboration;
  5. Determine how method arguments are split into ports using a type class;
  6. Add some port splitting utilities using generics.

Implementation

I'm not changing the fundamental structure of wrapper interfaces, only changing how field types are determined - flattening of nested interfaces still happens in genwrap as usual. The types of fields in a wrapper interface are determined by the typeclass WrapField. The toWrapField method converts from a field in the original interface (e.g. Int 8 -> ActionValue Bool) to the type of field in the wrapper interface (e.g. Bit 8 -> ActionValue_1), while fromWrapField is the inverse of this. The special cases for Clock, Reset and Inout are also handled by WrapField.

WrapField uses a type class WrapMethod to compute the wrapped type of a method field. This type class uses the SplitPorts type class to convert the type of a method input into a tuple of Ports, and the WrapPorts type class converts this into a tuple of Bit values. (Thus one determines how a method argument type should be split into ports by defining instances of SplitPorts. More on that later.) The individual tuples of method argument Bit values then get turned into a single curried function type for the wrapper interface method.

These type classes are also used to compute the names of input ports. This is happening on the value level as lists of strings1, not as type-level strings as I was originally thinking. I hit some sort of snag with this, although I don't remember exactly what it was, and being able to just compute this in the evaluator seemed like less of a pain than dealing with type-level lists of strings, adding type-level number to type-level-string conversion, etc.2 The only reason a type-level string is there in WrapField is to give the original field name to generate a better error message when context resolution fails due to a type not being in the Bits type class.

The list of argument names are tagged on to the wrapper method function/value with primMethod. The evaluator now expects this primitive to exist on method fields in iExpandField.3 We could potentially stick additional metadata that is computed at elaboration-time here in the future. The field name/result pragma and the arg_names pragma (if present) are passed as arguments to toWrapField, which are used to compute the base names of input ports, which are and tagged on to the converted method value.

Because port names are now determined at elaboration time, I had to move the port name collision checks to after elaboration. This is maybe slightly less nice as some error messages show up latter, but this sort of error isn't super common. It does feel like a more natural place to implement these checks anyway, instead of needing to figure out the port names from the pragmas before type checking.

Saving port types, on both sides of the synthesis boundary, is also handled via these type classes. See the saveFieldPortTypes method in WrapField type class. Calls to this method get inserted in both genwrap and wrappergen. This method also requires the same field naming arguments as toWrapField. I considered making toWrapperField/fromWrapperField be in the Module monad and do the port type saving too, but that complicates the code generation in genwrap a fair bit as every field value needs to be bound in a giant do-block.

Specifying port splitting

How a method argument type gets split up, and how the resulting ports are named is determined by the SplitPorts type class. There is a default instance that doesn't do any flattening, which preserves the current behavior:

instance SplitPorts a (Port a) where
  splitPorts = Port
  unsplitPorts (Port a) = a
  portNames _  base = Cons base Nil

If we have a struct

struct Bar =
  v :: Vector 3 Bool
  w :: (Bool, UInt 16)
  z :: Foo

interface Top = 
  putBar :: Bar -> Action

then for putBar to have separate input ports for each field, we need an instance

instance SplitPorts Bar (Port (Vector 3 Bool), Port (Bool, UInt 16), Port Foo) where
  splitPorts (Bar { v = v; w = w; z = z; }) = (Port v, Port w, Port z)
  unsplitPorts (Port v, Port w, Port z) = Bar { v = v; w = w; z = z; }
  portNames _ base = Cons (base +++ "_v") $ Cons (base +++ "_w") $ Cons (base +++ "_z") Nil

One can write this sort of instance explicitly. However there are a few ways that this can be done with less boilerplate.

I added a library SplitPorts in Base1 with a couple of utility type classes. ShallowSplitPorts uses generics to flatten out a struct by one level, using the SplitPorts instances for each of its fields. One can use these to define a SplitPorts instance:

instance (ShallowSplitPorts Bar p) => SplitPorts Bar p where
  splitPorts = shallowSplitPorts
  unsplitPorts = shallowUnsplitPorts
  portNames = shallowSplitPortNames

This would be a bit nicer to use if we had deriving via. In fact, I'm wondering if we should make derive SplitPorts generate the above sort of instance automatically.

DeepSplitPorts fully flattens a struct, including nested struct, tuple and Vector4 fields, down to primitives and types with multiple constructors. When using this type class, if one wishes for some nested struct type not to be flattened, they can define a DeepSplitPorts instance that does nothing to prevent this.

Sometimes one might wish for a type to be flattened in only some places. Instead of defining a SplitPorts instance, you can insert the ShallowSplit or DeepSplit "newtype" wrapper on your interface method parameters:

interface Top = 
  putBar :: DeepSplit Bar -> Action

I added test cases illustrating all these different patterns/approaches. There are probably more possibilities and I'm not sure what will prove to be the most ergonomic in practice, but these utilities are easy to add/change later.

Future considerations

I designed this with support for methods with multiple output ports in mind, which I may or may not attempt next depending on how much time I have. The SplitPorts type class could be reused to also determine how results of value/ActionValue methods are split into output ports.

I'm not quite sure what the wrapper type representation looks like for types with multiple output ports. Just using a tuple of Bit values for methods with multiple output ports might work for value methods, but ActionValue_ only accepts a single numeric size parameter. My current thinking is that we should ditch ActionValue_ and have
a struct PrimValue :: (# -> * -> *) n a that tags a Bit n value onto a chain of output values a, ending in a PrimAction or PrimUnit.

Remaining issues

The error message when a method yields a port that isn't in Bits is fine, but there is another error message about a Bits context that didn't reduce, with unknown position. See for instance testsuite/bsc.verilog/noinline/NoInline_ArgNotInBits.bsv.bsc-vcomp-out.expected. I'm not totally sure where this is coming from or how to suppress it, but it maybe isn't too bad.

Congrats on making it to the end of this wall of text. Hopefully @quark17 has time to look this over before the sync meeting on Friday?

Footnotes

  1. Really, this should be using ListN to ensure that the list of port names is always the correct length. But sadly that doesn't exist in the Prelude, and SplitPorts needs to be.

  2. Although in retrospect the various tuple shenanigans I needed were just as complicated, and I could maybe have just stuck the port name in the Port type constructor. So I'm not sure if it ended up being much simpler. Having the evaluator is more flexible, at least.

  3. This required a corresponding tweak in vMkRWire1, which is a handwritten interface LARPing as a generated wrapper interface, to be instantiated way later by the scheduler.

  4. Making this work reasonably for large vectors required a fun bit of awesomeness in the ConcatTuple type class I added, which converts a vector of tuples to and from a flattened tuple.

…sn't work b/c lambda bodies aren't partially evaluated before iExpandMethod.
src/comp/CVPrint.hs Outdated Show resolved Hide resolved
src/comp/ContextErrors.hs Outdated Show resolved Hide resolved
src/comp/GenWrap.hs Outdated Show resolved Hide resolved
src/comp/IExpand.hs Outdated Show resolved Hide resolved
src/comp/IExpand.hs Outdated Show resolved Hide resolved
@@ -6,7 +6,7 @@ Error: "ClockCheckCond.bsv", line 6, column 8: (G0007)
Method calls by clock domain:
Clock domain 1:
default_clock:
the_y.read at "ClockCheckCond.bsv", line 2, column 18,
the_y.read at "ClockCheckCond.bsv", line 2, column 10,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you know why this position changed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I understand why this error message had the position that it did in the first place. It is still pointing to the same interface field at least.

@nanavati
Copy link
Collaborator

I think it would be good to have tests for the error messages you get if SplitPorts instances are wrong (wrong number of names, name conflicts between generated ports and any others you can think of).

idEither = prelude_id_no fsEither
idLeft = prelude_id_no fsLeft
idRight = prelude_id_no fsRight
idPreludeCons = prelude_id_no fsCons -- idCons isn't qualified
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why did you need a qualified version of idCons? Are the existing uses of idCons wrong?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah the existing idCons is unqualified for some reason. I am comparing equality with the id coming out of ICon, which is qualified. I suppose I could just compare the base string but comparing with the whole id seems better?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with Ravi that instead of defining idPreludeCons, we should fix idCons (and idNil) to be qualified. (I'd also remove the neighboring idConcat, since it's unused, and remove idNothing, replacing its few uses in the BSV parser with idInvalid -- and remove their associated definitions in PreStrings.) That could be done in a separate PR.

endfunction
method wset = primMethod(Cons("v", Nil), rw_wset);
method wget = primMethod(Nil, _rw.wget);
method whas = primMethod(Nil, pack(_rw.whas));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the user writes a conflict_free attribute for two rules, then BSC will add logic to dynamically check that any conflicting methods between the two have not been called at the same time. BSC does this by instantiating an RWire for each method called by each rule, and inserting a wset action at the place in the rule where it calls the method; then a $display is inserted into the module, which prints an error message if the wires for two conflicting methods is ever true.

This occurs in BSC after scheduling, when the module is in ASyntax. To add a submodule instantiation at that point, BSC needs an AVInst value. The reason that vMkRWire exists is so that BSC can get its hands on the AVInst for RWire. It does this by simulating the entire compilation flow for vMkRWire -- first typecheck, then convert to ISyntax, then elaboration, then convert to ASyntax -- and then looking for the sole submodule in the resulting ASyntax.

The interface for vMkRWire is entirely unneeded -- BSC just wants to get the AVInst structure for the _rw submodule instantiation. We might consider changing this module to have an empty interface, and then you don't need to worry about adding primMethod to any methods.

The reason that you needed to add primMethod is because BSC's simulated compilation flow starts at typecheck, without any GenWrap stage. It used to be OK for BSC to skip GenWrap, because later stages only required that the interface be raw types (bit and PrimAction). But you've changed the evaluator to expect that all methods evaluate to IMethod, and that only happens when there's a call to primMethod, and that gets inserted by the toWrapField call that GenWrap now inserts on the interface. So AAddSchedAssumps would need to simulate the GenWrap step by inserting toWrapField; but if that's not done, then you would need to manually insert the toWrapField, or manually write the result of that (by inserting primMethod on each method).

I think it might be better to write toWrapField on _rw, rather than explicitly spell out the interface; and better than that would be to update AAddSchedAssump to insert it; but better still would be to eliminate the interface on vMkRWire1 altogether -- but actually, I would prefer that we eliminate the simulated compile flow altogether and just hardcode an AVInst value for RWire in AAddSchedAssump.

However, all of this does make me wonder: For imported modules in BH code, we have written them as an import of the raw interface and then a wrapper module (that converts from the raw interface and inserts calls to primitives to save types etc) -- for example, mkReg is a wrapper around vMkReg. Can we simplify those wrappers now with just a call to toWrapField?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, it looks like someone added a vMkUnsafeRWire1 version of this module, when they were creating Unsafe versions of all the RWire modules, but there's no need for that, so I'd delete that module.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be honest I haven't dug into AAddSchedAssump suffieciently to understand what is going on there. Although constructing an AVInst directly does seem a lot nicer than simulating the whole compilation flow, if doing so is reasonable.

I did try changing the primMethod to toWrapField, but that doesn't work because VRWireN.wset returns a PrimAction, but toWrapField uses ActionValue_ 0. The implementation of ActionValue_ is not exported from the Prelude, and even if it was, we can't manipulate the result of toWrapField since it is wrapped in primMethod. I suppose we could make toWrapMethod use PrimAction instead of ActionValue_ 0 for wrapping ActionValues? But this is likely to change again anyway in the future when we add support for methods with multiple output ports.

I suppose it is possible to use toWrapMethod in the manually-written wrapper modules for imported Verilog. Although in those cases the wrapped interface field types need to be written down anyway. Also this would require fixing the above-mentioned PrimAction/ActionValue_ 0 disparity.

MetaConsNamed(..), MetaConsAnon(..), MetaField(..)
MetaConsNamed(..), MetaConsAnon(..), MetaField(..),

primMethod, WrapField(..), WrapMethod(..), WrapPorts(..),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume that primMethod here is only exported so that it can be used in PreludeBSV for vMkRWire1 and can be removed when that's resolved.

@@ -171,6 +171,7 @@ package Prelude(
Tuple6, tuple6, Has_tpl_6(..),
Tuple7, tuple7, Has_tpl_7(..),
Tuple8, tuple8, Has_tpl_8(..),
AppendTuple(..), AppendTuple'(..), TupleSize(..),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does AppendTuple' need to be exported?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For instance resolution to work correctly, any type classes referenced by an instance need to be exported. E.g. we need to export PrimDeepSeqCond'(..) in addition to PrimDeepSeqCond(..).

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will have to create an example to see what the behavior is. I thought that BSC had access to all instances inside imported files, and that the exporting was only about making the name available to users to write code that mentions the typeclass -- and the (..) only needed for code that mentions the method names (thus there's probably no need to have (..) on TupleSize since it has no methods?).

@@ -2589,6 +2593,23 @@ curry f x y = f (x, y)
uncurry :: (a -> b -> c) -> ((a, b) -> c)
uncurry f (x, y) = f x y

-- Polymorphic, N-argument version of curry/uncurry
class Curry f g | f -> g where
curryN :: f -> g
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm trying to understand: Can any current use of curry/uncurry be replaced by curryN/unCurryN? That is -- error message concerns aside -- could the current curry and uncurry definitions be removed and the typeclass methods be named curry and uncurry? (I understand that Haskell's Data.Tuple.Curry names them curryN and uncurryN, so you might be following that.)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did use the names from Haskell.

It should be possible to replace them, I think. The downside is that in principle this could create type ambiguities when the curried function type isn't constrained. There would also be slight breaking change in the behavior if someone wrote uncurry f where f :: a -> b -> c -> d; the result would now have type (a, b, c) -> d instead of (a, b) -> c -> d.

class TupleSize a n | a -> n where {}
instance TupleSize () 0 where {}
instance TupleSize a 1 where {}
instance (TupleSize b n) => TupleSize (a, b) (TAdd n 1) where {}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, would the size of (x,()) be reported as 1? Do we think it should be 2?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I suppose it would be reported as 1.

I don't think it's too unreasonable to treat tuples that end in () as one element smaller. Note that AppendTuple also would drop the () if the first tuple being appended ends in (), and the same for CurryN.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants