Sketch/braindump:
Currently, a module hierarchy in kaze might look like this (taken/adapted from cases in sim tests):
fn test<'a>(c: &'a Context<'a>) -> &Module<'a> {
test_inner(c);
let m = c.module("Test");
let i1 = m.instance("inner1", "TestInner");
i1.drive_input("i1", m.input("i1", 32));
i1.drive_input("i2", m.input("i2", 32));
let i2 = m.instance("inner2", "TestInner");
i2.drive_input("i1", m.input("i3", 32));
i2.drive_input("i2", m.input("i4", 32));
let i3 = m.instance("inner3", "TestInner");
i3.drive_input("i1", i1.output("o"));
i3.drive_input("i2", i2.output("o"));
m.output("o", i3.output("o"));
m
}
fn test_inner<'a>(c: &'a Context<'a>) -> &Module<'a> {
let m = c.module("TestInner");
let i1 = m.input("i1", 32);
let i2 = m.input("i2", 32);
m.output("o", i1 & i2);
m
}
This fn creates a simple inner module ("TestInner") instantiated 3 times inside the top-level module ("Test").
While this is easy enough to understand, the code itself has a few problems:
- It's quite verbose, especially horizontally. Conceptually, we're not doing something very complicated, yet it still takes quite a few characters to express this. Not only does that make it annoying to type/put together, it also makes the code quite dense and a bit hard to understand what's going on as things get more complicated.
- Input/output names are specified as strings in several different places. While kaze validates module hierarchies internally before generating code (which ensures that string names are checked/validated at some point and keeps things safe), it would be nice for as many of these errors as possible to be caught at rust compile time and displayed in the editor (eg. referring to a port that doesn't exist).
- It can be difficult to get an overview of the top-level ports of a module. This is especially important when browsing unfamiliar RTL code (which includes code you wrote but haven't looked at in a while!)
Additionally, there are some more general loose ends with the current pattern for creating/instantiating modules:
- It's kindof nice to constrain module generation to a fn, but what does that really get us?
- Why do we return the
Module
at the end? This makes it easy to generate code for the top-level module, but a more common case actually is just to call a module generation function, ignore the return value, and then instantiate the module after (by string name, not the reference we just got back!), as is done for TestInner
above. Feels a bit janky to say the least.
- Currently, verilog gen requires the user to explicitly generate code for each
Module
that's been instantiated in their design, but other than providing a way to iterate over all Module
s in a Context
(which is a hack) there's no useful way to query which modules are instantiated (so we don't necessarily know which modules are/aren't important and the only conservative thing is to always generate all modules).
- Specialization for modules with different parameters has to be done by hand. This leads to many
Module
s with hand-generated mangled names. If we're already mangling names, why not try to make this automatic somehow and save some typing/decision making?
Generally, module generation/instantiation feels a bit half-baked, and I'd really like to improve things. I feel it's quite important to have a good pattern for this - we want to encourage the use of several decoupled modules in designs, and if it's too hairy to do this meaningfully in a lot of cases, then users will tend to create larger modules with much more logic, which are harder to build and maintain (I find myself doing this more often than I would like).
One thought I've had after looking a bit at nMigen is for a Module
to not represent a verilog module 1:1, but instead have it represent a specific instance (which is currently represented by Instance
in kaze). Then the convention might be to wrap each Module
(instance!) in a wrapper struct that exposes the inputs/outputs as Signal
s (or relevant wrappers) as fields. This way, adding a new module into the Context
and instantiating it (which are very commonly done one right after another) are merged into one action. For codegen, we can make verilog and rust sim gen more symmetric by always specifying a top-level module and having kaze always output the whole hierarchy (with mangled names for inner modules in the verilog case now, and still flattening the graph for rust sim). This then greatly simplifies codegen for the verilog case, since we only write code to output top-level modules.
Mockup:
struct Test<'a> {
pub i1: &'a Input<'a>,
pub i2: &'a Input<'a>,
pub i3: &'a Input<'a>,
pub i4: &'a Input<'a>,
pub o: &'a Output<'a>,
}
impl<'a> Test<'a> {
// TODO: Optionally make this a submodule
// TODO: How do we get the inner module again in order to generate code?
pub fn new<S: Into<String>>(c: &'a Context<'a>, instance_name: S) -> Test<'a> {
let m = c.module(instance_name, "Test");
let i1 = m.input("i1", 32);
let i2 = m.input("i2", 32);
let i3 = m.input("i3", 32);
let i4 = m.input("i4", 32);
let inner1 = TestInner::new(c, "inner1");
inner1.i1.drive(i1);
inner1.i2.drive(i2);
let inner2 = TestInner::new(c, "inner2");
inner2.i1.drive(i3);
inner2.i2.drive(i4);
let inner3 = TestInner::new(c, "inner3");
inner3.i1.drive(inner1.o);
inner3.i2.drive(inner2.o);
let o = m.output("o", inner3.o);
Test {
i1,
i2,
i3,
i4,
o,
}
}
}
struct TestInner<'a> {
pub i1: &'a Input<'a>,
pub i2: &'a Input<'a>,
pub o: &'a Output<'a>,
}
impl<'a> TestInner<'a> {
// TODO: Optionally make this a submodule
// TODO: How do we get the inner module again in order to generate code?
pub fn new<S: Into<String>>(c: &'a Context<'a>, instance_name: S) -> TestInner<'a> {
let m = c.module(instance_name, "TestInner");
let i1 = m.input("i1", 32);
let i2 = m.input("i2", 32);
let o = m.output("o", i1 & i2);
TestInner {
i1,
i2,
o,
}
}
}
Overall, I think this will make modules a bit more verbose (mostly vertically due to struct wrapping etc), but I think the verbosity adds clarity, and the code that actually constructs Module
s by combining Signal
s should get a bit lighter. Further, I think it's very useful to have instance signals exposed on a struct, as this could be extended by having other patterns - for example, we can group some signals with "bus port" structs, and add convenience functions to bind compatible bus ports to one another. Abstractions like this would be made ad-hoc and should reduce a lot of boilerplate in larger designs, especially where certain buses are used a lot (eg. xenowing). These abstractions can also potentially provide better semantic errors (eg. non-matching bus widths) before actually connecting signals (and deferring errors to lower-level kaze signal errors).
A small caveat: kaze currently treats all cases where Signal
s belonging to different Module
s cannot be combined, but if inputs/outputs were to represent inputs/outputs on the submodule instance, we need to make an exception for them. It probably also makes sense to continue to disallow inputs/outputs for any Module
that isn't a submodule of the current Module
.
It might also be possible/desirable to hide some of the boilerplate with proc macros, but I'm always hesitant about magic syntax like this. If this is a common pattern that works well it might be worth it tho.
Whatever pattern(s) we end up with, even if we support them with proc macros for syntactic convenience, I think it's crucial that they be easy to understand/expressible in plain code. Further, a user can choose to forego this pattern entirely!
Some unresolved issues include:
- How do we get the inner module from each wrapper struct in order to generate code for it? In nMigen, a new module is a new class that derives from a common base (
Elaboratable
I believe it's called), so the new module is the same object we'd generate code for, rather than a wrapper. Do we want to use traits/inheritance of some kind to mimic this? If we eventually move to an API where we separate unvalidated/validated graphs and we produce a validated graph by consuming an unvalidated graph (and transitively, all of the references to nodes in the graph, which includes modules!), how do we get ahold of the module(s) again in order to specify the top-level one(s) for codegen? Do we need to do that, or does it makes sense to "generate all top-level modules in this validated graph" always?
- How do we get modules to either represent top-level modules or submodules in a nice way?
- How do we specify instance names in a nice way? Do we always need instance names then (eg for top level modules, where this doesn't actually make sense in most cases)?
- How can we use
Input
s transparently as Signal
s and as sinks for Signal
s depending on context? Does it make sense to do Into<&'a Signal<'a>>
everywhere instead of &'a Signal<'a>
directly? Can this extend to Register
as well so we don't have to use Register::value
(which I tend to forget a lot at least)?