CIRCT

Circuit IR Compilers and Tools

FIRRTL Dialect Rationale

This document describes various design points of the FIRRTL dialect, why it is the way it is, and current status and progress. This follows in the spirit of other MLIR Rationale docs .

Introduction 

The FIRRTL project is an existing open source compiler infrastructure used by the Chisel framework to lower “.fir” files to Verilog. It provides a number of useful compiler passes and infrastructure that allows the development of domain specific passes. The FIRRTL project includes a well documented IR specification that explains the semantics of its IR, an ANTLR grammar includes some extensions beyond it, and a compiler implemented in Scala which we refer to as the Scala FIRRTL Compiler (SFC).

The FIRRTL dialect in CIRCT is designed to provide a drop-in replacement for the SFC for the subset of FIRRTL IR that is produced by Chisel and in common use. The FIRRTL dialect also provides robust support for SFC Annotations.

To achieve these goals, the FIRRTL dialect follows the FIRRTL IR specification and the SFC implementation almost exactly. The small deviations we do make are discussed below. Early versions of the FIRRTL dialect made heavy deviations from FIRRTL IR and the SFC (see the Type Canonicalization section below). These deviations, while elegant, led to difficult to resolve mismatches with the SFC and the inability to verify FIRRTL IR. The remaining small deviations introduced in the FIRRTL dialect are done to simplify the CIRCT implementation of a FIRRTL compiler and to take advantage of MLIR’s various features.

This document generally assumes that you’ve read and have a basic grasp of the FIRRTL IR spec, and it can be occasionally helpful to refer to the ANTLR grammar.

Status 

The FIRRTL dialect and FIR parser is a generally complete implementation of the FIRRTL specification and is actively maintained, tracking new enhancements. The FIRRTL dialect supports some undocumented features and the “CHIRRTL” flavor of FIRRTL IR that is produced from Chisel. The FIRRTL dialect has support for parsing an SFC Annotation file consisting of only local annotations and converting this to operation or argument attributes. Non-local annotation support is planned, but not implemented.

There are some exceptions to the above:

  1. We don’t support the 'raw string' syntax for strings.
  2. We don’t support the Fixed types for fixed point numbers, and some primitives associated with them.
  3. We don’t support Interval types

Some of these may be research efforts that didn’t gain broad adoption, in which case we don’t want to support them. However, if there is a good reason and a community that would benefit from adding support for these, we can do so.

Naming 

One of the goals of the FIRRTL compiler is to produce human-readable Verilog which can be easily mapped back to the input FIRRTL. Part of this effort means that we want the naming of Verilog objects to match the names used in the original FIRRTL, and they are predictably transformed during lowering. For example, after bundles are replaced with scalars in the lower-types pass, each field should be prefixed with the bundle name:

circuit Example
  module Example
    reg myreg: { a :UInt<1>, b: UInt<1> }, clock
; firrtl-lower-types =>
circuit Example
  module Example
    reg myreg_a: UInt<1>, clock
    reg myreg_b: UInt<1>, clock

The name transformations applied by the SFC have become part of the documented API, and people rely on the final names to take a certain form.

There are names for temporaries generated by the Chisel and FIRRTL tooling which are not important to maintain. These names are discarded when parsing, which saves memory during compilation. New names are generated at Verilog export time, which has the effect of renumbering intermediate value names. Names generated by Chisel typically look like _T_12, and names generated by the SFC look like _GEN_12. The FIRRTL compiler will not discard these names if the object has an array attribute annotations containing the attribute {class = "firrtl.transforms.DontTouchAnnotation}.

It is common for EDA tools to hide or optimize away entities which have a name beginning with an _. FIRRTL considers these names precious (excluding FIRRTL temporary names) and will maintain them.

Type system 

Not using standard types 

At one point we tried to use the integer types in the standard dialect, like si42 instead of !firrtl.sint<42>, but we backed away from this. While it originally seemed appealing to use those types, FIRRTL operations generally need to work with “unknown width” integer types (i.e. !firrtl.sint).

Having the known width and unknown width types implemented with two different C++ classes was awkward, led to casting bugs, and prevented having a FIRRTLType class that unified all the FIRRTL dialect types.

Not Canonicalizing Flip Types 

An initial version of the FIRRTL dialect relied on canonicalization of flip types according to the following rules:

  1. flip(flip(x)) == x.
  2. flip(analog(x)) == analog(x) since analog types are implicitly bidirectional.
  3. flip(bundle(a,b,c,d)) == bundle(flip(a), flip(b), flip(c), flip(d)) when the bundle has non-passive type or contains an analog type. This forces the flip into the subelements, where it recursively merges with the non-passive subelements and analogs.
  4. flip(vector(a, n)) == vector(flip(a), n) when the vector has non-passive type or analogs. This forces the flip into the element type, generally canceling it out.
  5. bundle(flip(a), flip(b), flip(c), flip(d)) == flip(bundle(a, b, c, d). Due to the other rules, the operand to a flip must be a passive type, so the entire bundle will be passive, and rule #3 won’t be recursively reinvoked.

While elegant in a number of ways (e.g., FIRRTL types are guaranteed to have a canonical representation and can be compared using pointer equality, flips partially subsume port directionality and “flow”, and analog inputs and outputs are canonicalized to the same representation), this resulted in information loss during canonicalization because the number of flip types can change. Namely, three problems were identified:

  1. Type canonicalization may make illegal operations legal.
  2. The flow of connections could not be verified because flow is a function of the number of flip types.
  3. The directionality of leaves in an aggregate could not be determined.

As an example of the first problem, consider the following circuit:

module Foo:
  output a: { flip a: UInt<1> }
  output b: { a: UInt<1> }

  b <= a

The connection b <= a is illegal FIRRTL due to a type mismatch where { flip a: UInt<1> } is not equal to { a: UInt<1> }. However, type canonicalization would transform this circuit into the following circuit:

module Foo:
  input a: { a: UInt<1> }
  output b: { a: UInt<1> }

  b <= a

Here, the connection b <= a is legal FIRRTL. This then makes it impossible for a type canonical form to be type checked.

As an example of the second problem, consider the following circuit:

module Bar:
  output a: { flip a: UInt<1> }
  input b: { flip b: UInt<1> }

  b <= a

Here, the connection b <= a is illegal FIRRTL because b is a source and a is a sink. However, type canonicalization converts this to the following circuit:

module Bar:
  input a: { a: UInt<1> }
  output b: { b: UInt<1> }

  b <= a

Here, the connect b <= a is legal FIRRTL because b is now a sink and a is now a source. This then makes it impossible for a type canonical form to be flow checked.

As an example of the third problem, consider the following circuit:

module Baz:
  wire a: {flip a: {flip a: UInt<1>}}
  wire b: {flip a: {flip a: UInt<1>}}

  b.a <= a.a

The connection b.a <= a.a, when lowered, results in the reverse connect a.a.a <= b.a.a. However, type canonicalization will remove the flips from the circuit to produce:

module Baz:
  wire a: {a: {a: UInt<1>}}
  wire b: {a: {a: UInt<1>}}

  b.a <= a.a

Here, the connect b.a <= a.a, when lowered, results in the normal connect b.a.a <= a.a.a. Type canonicalization has thereby changed the semantics of connect.

Due to the elegance of type canonicalization, we initially decided that we would use type canonicalization and CIRCT would accept more circuits than the SFC. The third problem (identified much later than the first two) convinced us to remove type canonicalization.

For a historical discussion of type canonicalization see:

Flow 

The FIRRTL specification describes the concept of “flow”. Flow encodes additional information that determines the legality of operations. FIRRTL defines three different flows: sink, source, and duplex. Module inputs, instance outputs, and nodes are source, module outputs and instance inputs are sink, and wires and registers are duplex. A value with sink flow may only be written to, but not read from (with the exception of module outputs and instance inputs which may be also read from). A value with source flow may be read from, but not written to. A value with duplex flow may be read from or written to.

For FIRRTL connects or partial connect statements, it follows that the left-hand-side must be sink or duplex and the right-hand-side must be source, duplex, or a port/instance sink.

Flow is not represented as a first-class type in CIRCT. We instead provide utilities for computing flow when needed, e.g., for connect statement verification.

Operations 

Multiple result firrtl.instance operation 

The FIRRTL spec describes instances as returning a bundle type, where each element of the bundle corresponds to one of the ports of the module being instanced. This makes sense in the Scala FIRRTL implementation, given that it does not support multiple ports.

The MLIR FIRRTL dialect takes a different approach, having each element of the bundle result turn into its own distinct result on the firrtl.instance operation. This is made possible by MLIR’s robust support for multiple value operands, and makes the IR much easier to analyze and work with.

Module bodies require def-before-use dominance instead of allowing graphs 

MLIR allows regions with arbitrary graphs in their bodies, and this is used by the HW dialect to allow direct expression of cyclic graphs etc. While this makes sense for hardware in general, the FIRRTL dialect is intended to be a pragmatic infrastructure focused on lowering of Chisel code to the HW dialect, it isn’t intended to be a “generally useful IR for hardware”.

We recommend that non-Chisel frontends target the HW dialect, or a higher level dialect of their own creation that lowers to HW as appropriate.

input and output Module Ports 

The FIRRTL specification describes two kinds of ports: input and output. In the firrtl.module declaration we track this via an arbitrary precision integer attribute (IntegerAttr) where each bit encodes the directionality of the port at that index.

Originally, we encoded direction as the absence of an outer flip type (input) or presence of an outer flip type (output). This was done as part of the original type canonicalization effort which combined input/output with the type system. However, once type canonicalization was removed flip type only became used in three places: on the types of bundle fields, on the variadic return types of instances or memories, and on ports. The first is the same as the FIRRTL specification. The second is a deviation from the FIRRTL specification, but allowable as it takes advantage of the MLIR’s variadic capabilities to simplify the IR. The third was an inelegant abuse of an unrelated concept that added bloat to the type system. Many operations would have to check for an outer flip on ports and immediately discard it.

For this reason, the IntegerAttr encoding implementation was chosen.

For a historical discussion of this issue and its development see:

firrtl.mem 

Unlike the SFC, the FIRRTL dialect represents each memory port as a distinct result value of the firrtl.mem operation. Also, the firrtl.mem node does not allow zero port memories for simplicity. Zero port memories are dropped by the .fir file parser.

More things are represented as primitives 

We describe the mux expression as “primitive”, whereas the IR spec and grammar implement it as a special kind of expression.

We do this to simplify the implementation: These expressions have the same structure as primitives, and modeling them as such allows reuse of the parsing logic instead of duplication of grammar rules.

invalid Invalidate Operation is an expression 

The FIRRTL spec describes an x is invalid statement that logically computes an invalid value and connects it to x according to flow semantics. This behavior makes analysis and transformation a bit more complicated, because there are now two things that perform connections: firrtl.connect and the x is invalid operation.

To make things easier to reason about, we split the x is invalid operation into two different ops: an firrtl.invalidvalue op that takes no operands and returns an invalid value, and a standard firrtl.connect operation that connects the invalid value to the destination (or a firrtl.attach for analog values). This has the same expressive power as the standard FIRRTL representation but is easier to work with.

During parsing, we break up an x is invalid statement into leaf connections. As an example, consider the following FIRRTL module where a bi-directional aggregate, a is invalidated:

module Foo:
  output a: { a: UInt<1>, flip b: UInt<1> }

  a is invalid

This is parsed into the following MLIR. Here, only a.a is invalidated:

firrtl.module @Foo(out %a: !firrtl.bundle<a: uint<1>, b: flip<uint<1>>>) {
  %0 = firrtl.subfield %a("a") : (!firrtl.bundle<a: uint<1>, b: flip<uint<1>>>) -> !firrtl.uint<1>
  %invalid_ui1 = firrtl.invalidvalue : !firrtl.uint<1>
  firrtl.connect %0, %invalid_ui1 : !firrtl.uint<1>, !firrtl.uint<1>
}

validif represented as a multiplexer 

The FIRRTL spec describes a validIf(en, x) operation that is used during lowering from high to low FIRRTL. Consider the following example:

c <= invalid
when a:
  c <= b

Lowering will introduce the following intermediate representation in low FIRRTL:

c <= validIf(a, b)

Since there is no precedence of this validIf being used anywhere in the Chisel/FIRRTL ecosystem thus far and instead is always replaced by its right-hand operand b, the FIRRTL MLIR dialect does not provide such an operation at all. Rather it directly replaces any validIf in FIRRTL input with the following equivalent operations:

%0 = firrtl.invalidvalue : !firrtl.uint<42>
%c = firrtl.mux(%a, %b, %0) : (!firrtl.uint<1>, !firrtl.uint<42>, !firrtl.uint<42>) -> !firrtl.uint<42>

A canonicalization then folds this combination of firrtl.invalidvalue and firrtl.mux to the “high” operand of the multiplexer to facilitate downstream transformation passes.

Inline SystemVerilog through verbatim.expr operation 

The FIRRTL dialect offers a firrtl.verbatim.expr operation that allows for SystemVerilog expressions to be embedded verbatim in the IR. It is lowered to the corresponding sv.verbatim.expr operation of the underlying SystemVerilog dialect, which embeds it in the emitted output. The operation has a FIRRTL result type, and a variadic number of operands can be accessed from within the inline SystemVerilog source text through string interpolation of {{0}}-style placeholders.

The rationale behind this verbatim operation is to offer an escape hatch analogous to asm ("...") in C/C++ and other languages, giving the user or compiler passes full control of what exactly gets embedded in the output. Usually, though, you would rather add a new operation to the IR to properly represent additional constructs.

As an example, a verbatim expression could be used to interact with yet-unsupported SystemVerilog constructs such as parametrized class typedef members:

firrtl.module @Magic (out %n : !firrtl.uint<32>) {
  %0 = firrtl.verbatim.expr "$bits(SomeClass #(.Param(1))::SomeTypedef)" : !firrtl.uint<32>
  firrtl.connect %n, %0 : !firrtl.uint<32>, !firrtl.uint<32>
}

This would lower through the other dialects to SystemVerilog as you would expect:

module Magic (output [31:0] n);
  assign n = $bits(SomeClass #(.Param(1))::SomeTypedef);
endmodule

Annotations 

The SFC provides a mechanism to encode arbitrary metadata and associate it with zero or more “things” in a FIRRTL circuit. This mechanism is an Annotation and the association is described using one or more Targets. Annotations are serializable to JSON and either live in a separate file (e.g., during the handoff between Chisel and the SFC) or are stored in-memory (e.g., during SFC-based compilation). The SFC pass API requires that passes describe which targets in the circuit they update. SFC infrastructure then automatically updates annotations so they are always synchronized with their corresponding FIRRTL IR.

Historically, annotations grew out of three choices in the design of FIRRTL IR:

  1. FIRRTL IR is not extensible with user-defined IR nodes.
  2. FIRRTL IR is not parameterized.
  3. FIRRTL IR does not support in-IR attributes.

Annotations have then been used for all manner of extensions including:

  1. Encoding SystemVerilog nodes into the IR using special printfs, an example of working around (1) above.
  2. Setting the reset vector of different, identical CPU cores, an example of working around (2) above.
  3. Encoding sources and sinks that should be wired together by an SFC pass, an example of (3) above.

Targets 

A target is a special identifier of the form:

“~” (circuit) (“|” (module) (“/” (instance) “:” (module) )* (“>” (reference) )?)?

A reference is a name inside a module and one or more qualifying tokens that encode subfields (of a bundle) or subindices (of a vector):

(name) ("[" (index) "]" | "." (field))*

To show some examples of what these look like, consider the following example circuit. This consists of four instances of module Baz, two instances of module Bar, and one instance of module Foo:

circuit Foo:
  module Baz:
    skip
  module Bar:
    inst c of Baz
    inst d of Baz
  module Foo:
    inst a of Bar
    inst b of Bar

Using targets (or multiple targets), any specific module, instance, or combination of instances can be expressed. Some examples include:

  • ~Foo refers to the whole circuit
  • ~Foo|Foo refers to the top module
  • ~Foo|Bar refers to module Bar (or both instances of module Bar)
  • ~Foo|Foo/a:Bar refers just to one instance of module Bar
  • ~Foo|Foo/b:Bar/c:Baz refers to one instance of module Baz
  • ~Foo|Bar/d:Baz refers to two instances of module Baz

If a target does not contain an instance path, it is a local target. A local target points to all instances of a module. If a target contains an instance path, it is a non-local target. A non-local target may not point to all instances of a module. Additionally, a non-local target may have an equivalent local target representation.

CIRCT Support 

We plan to provide full support for annotations in CIRCT. The FIRRTL dialect current supports:

  1. All non-local annotations can be parsed and applied to the correct circuit component.
  2. Annotations, with and without references, are copied to the correct ground type in the LowerTypes pass.

Annotations can be parsed using the --annotation-file command line argument to the firtool utility. Alternatively, we provide a non-standard way of encoding annotations in the FIRRTL IR textual representation. We provide this non-standard support primarily to make test writing easier. As an example of this, consider the following JSON annotation file:

[
  {
    "target": "~Foo|Foo",
    "hello": "world"
  }
]

This can be equivalently, in CIRCT, expressed as:

circuit Foo: %[[{"target":"~Foo|Foo","hello":"world"}]]
  module Foo:
    skip

During parsing, annotations are “scattered” into the MLIR representation as operation or argument attributes. As an example of this, the above parses into the following MLIR representation:

firrtl.circuit "Foo"  {
  firrtl.module @Foo() attributes {annotations = [{hello = "world"}]} {
    firrtl.skip
  }
}

Targets without references have their targets stripped during scattering since target information is redundant once annotation metadata is attached to the IR. Targets with references have the reference portion of the target included in the attribute. The LowerTypes pass then uses this reference information to attach annotation metadata to only the lowered portion of a targeted circuit component.

Annotations are expected to be fully removed via custom transforms, conversion to other MLIR operations, or dropped. E.g., the ModuleInliner pass removes firrtl.passes.InlineAnnotation by inlining annotated modules or instances.