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. Where the FIRRTL specification allows for undefined behavior, FIRRTL dialect and its passes will choose the SFC interpretation of specific undefined behavior. 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 annotations are also supported.

There are some exceptions to the above:

  1. We don’t support the Fixed types for fixed point numbers, and some primitives associated with them.
  2. 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 

Names in Verilog form part of the public API of a design and are used for many purposes and flows. Many things in verilog may have names, and those names specify points of interaction with the design. For example, a wire has a name, and one can monitor the value on the wire from a testbench by knowing this name. Instances have names and form the core of hierarchical references through designs. Even always blocks and loops can have names, which are required and used.

It is therefore critical that Chisel, and by extension FIRRTL, have language-level semantics about how entities are named and how named entities are used and transformed.
This must specify which entities with names in Chisel generate predictable output. Since names serve multiple purposes in a design, for example, debugging, test-bench attachment, hooks for physical layout, etc, we must balance multiple needs. This section describes the base semantics, which are conservative and aimed at enabling debugging. It is expected that the compiler provide flags to relax these rules to produce more synthesis-friendly or production-ready output.

Modules shall use the name given in Chisel, unless they conflict with a Verilog reserved word, not withstanding de-duplication or relevant annotations on the module.

Instances shall use the name given in Chisel, unless they conflict with a Verilog reserved word. Instances have preferential use of the name in the output in case of a conflict, after ports.

Chisel provides a “Don’t Touch” annotation to protect entities from transformation. A “Don’t Touch” on a wire or node produces a wire in Verilog and preserves the data-flow through that wire. Even a wire driven by a constant shall not have the constant forwarded around the wire. This is because a “Don’t Touch” annotation signals the possible public use of a wire and one common use is to provide a place to drive a new value into the logic from an external test-bench. If the node or wire is named (and it always should be for Chisel “Don’t Touch”), this name is used, unless it conflicts with a Verilog reserved word. This wire has preferential use of the name in the output in case of a conflict, after ports and instances.

Named wires and nodes in FIRRTL shall appear as a wire in the output verilog.
There is no requirement that data-flow through a wire be maintained, only that the data-flow into a wire be maintained. This allows bypassing and forwarding around wires who exist solely because of their name. An implementation may choose to not bypass trivial wires to reduce unused wire lint warnings, but shouldn’t cause other lint warnings to avoid unused wire warnings. A named wire without a symbol is thus equivalent to a named read-probe in the circuit.

Any name of an entity inside a module which starts with _ may be discarded.
This name pattern indicates the name is for convenience in the Chisel code (often temporaries are required) and there is no expectation it exist in the output.

Mandatory Renaming 

We want the naming of Verilog objects to match the names used in the original Chisel, but in several passes, there is mandatory renaming. It is important that this be a predictable transformation. 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.

Temporaries 

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}.

Symbols and Inner Symbols 

Symbols and Inner Symbols are documented in the symbol rationale. This section documents how symbols are used, their interaction with “Don’t Touch”, and the semantics imposed by them.

Public Symbols indicate there are uses of an entity outside the analysis scope of the compiler. This requires the entity be preserved in such a way as the operations possible in the target language have the expected effect. For example, a wire or port with a public symbol may be used by name in a test bench to read or write new values into the circuit. Therefore, these wires cannot be detached form their original dataflow as this would break the remote write case, nor can their input dataflow be changed, as this would break the remote read case. They cannot be renamed, as this would break all remote access.

Private Symbols indicate there are symbolic references to the entity, but they are all within the scope of the compiler’s IR and analysis. An entity with a private symbol may be arbitrarily transformed, so long as the transformation is semantic preserving with respect to all uses of the private symbol. If it can be proved a wire with a private symbol is only read from via its symbol and not written to, for example, the input can forwarded to the output (bypassing the wire) safely. If a private symbol is unused, it may be removed. Private symbols impose no restriction on output; they only exist to enable non-local effects in the IR.

“Don’t Touch” is implemented as a public symbol on an entity. A conservative interpretation of “Don’t Touch”, and a common use case, is that the entity is referred to by a testbench in unknown ways. This implies no transformation which would change observed behavior if the entity was arbitrarily read or written to remotely. This further implies the existence of the entity in the output.

Importantly, the existence of a symbol doesn’t specify whether something is read-only, write-only, or read-write. Without analysis, a pass must assume the most conservative case, and in the case of public symbols, must always assume the most conservative case. To do better, all uses must be analyzed and understood (e.g. a symbol used by a verbatim has unknown use).

NonLocalAnchor 

In the FIRRTL dialect, it might be necessary to identify specific instances of operations in the instance hierarchy. The FIRRTL NonLocalAnchor operation (firrtl.nla) can be used to describe the path through an instance hiearchy to a declaration, which can be used by other operations or non-local annotations. Non-local anchors can refer to most declarations, such as modules, instances, wires, registers, and memories.

The firrtl.nla operations defines a symbol and contains a namepath, which is a list of InnerRefAttr and FlatSymbolRefAttr attributes. A FlatSymbolRefAttr is used to identify modules, and is printed as @Module. InnerRefAttr identifies a declaration inside a module, and is printed as @Module::@wire. Each element along the NLA’s namepath carries an annotation with class circt. nonlocal, which has a matching circt. nonlocal field pointing to the global op. Thus instances participating in nonlocal paths are readily apparent.

In the following example, @nla specifies instance @bar in module @Foo, followed by instance @baz in module @Bar, followed by the wire named @w in module @Baz.

firrtl.circuit "Foo"   {
  firrtl.nla @nla [@Foo::@bar, @Bar::@baz, @Baz::@w]
  firrtl.module @Baz() {
    %w = firrtl.wire sym @w {annotations = [{circt.nonlocal = @nla, class = "ExampleAnno"}]} : !firrtl.uint
  }
  firrtl.module @Bar() {
    firrtl.instance baz sym @baz {annotations = [{circt.nonlocal = @nla, class = "circt.nonlocal"}]} @Baz()
  }
  firrtl.module @Foo() {
    firrtl.instance bar sym @bar {annotations = [{circt.nonlocal = @nla, class = "circt.nonlocal"}]} @Bar()
  }
}

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 a: 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: { a: 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.bitcast 

The bitcast operation represents a bitwise reinterpretation (cast) of a value. It can be used to cast a vector or bundle type to an int type or vice-versa. The bit width of input and result types must be known. For an aggregate type, the bit width of every field must be known. This always synthesizes away in hardware, and follows the same endianness policy as hw.bitcast.

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.

In the FIRRTL pipeline, the firrtl.mem op can be lowered into either a external module for macro replacement or a register of vector type. The conditions for macro replacement are as follows:

  1. –replSeqMem option is passed and
  2. readLatency == 1 and
  3. writeLatency == 1 and
  4. numWritePorts + numReadWritePorts == 1 and
  5. numReadPorts <= 1 and
  6. width(data) > 0

Any MemOp not satisfying the above conditions is lowered to Register vector.

MemToRegOfVec transformation outline: 

The MemToRegOfVec pass runs early in the pipeline, after the LowerCHIRRTL pass and right before the InferResets pass.

  1. Select all MemOps that are not candidates for macro replacement,
  2. Create a reg
  3. Read ports return the value at the address when the enable signal is high.
if (enable) {
  readOut = register[address]
}
  1. Write ports store the value at the address when the mask signal is high.
if (enable) {
  if (mask[0])
    register[0] = dataIn[0]
  if (mask[1])
    register[1] = dataIn[1]
}

Handling of MemTaps 

The sifive.enterprise.grandcentral.MemTapAnnotation annotation is attached to the MemOp and the corresponding Memtap module ports. After lowering the memory to registers, this annotation must be properly scattered such that GrandCentralTaps can generate the appropriate code.

The memtap module has memtap annotations, where the number of ports with the annotation is equal to the memory depth. In the MemToRegOfVec transformation, after lowering the memory to the register vector, a subannotation is created for each sub-field of the data and the sifive.enterprise.grandcentral.MemTapAnnotation annotation is copied from the original MemOp. The LowerTypes pass will handle the subannotations appropriately.

Interaction with AsyncReset Inference 

The AsyncReset pass runs right after the MemToRegOfVec. It will transform the memory registers to async registers if the corresponding annotations are present. Only if a MemOp had sifive.enterprise.firrtl.ExcludeMemFromMemToRegOfVec, annotation, then it is not converted to an async reset register.

firrtl.mem Attributes 

A firrtl.mem has the following properties

  1. Data type
  2. Mask bitwidth
  3. Depth
  4. Name
  5. Number of read ports, write ports, read-write ports
  6. Read under write behavior
  7. Read latency
  8. Write latency
Mask bitwidth 

Any aggregate memory data type is lowered to ground type by the LowerTypes pass. After lowering the data type, the data bitwidth must be divisible by mask bitwidth. And we define the property granularity as: mask granularity = (Data bitwidth)/(Mask bitwidth).

Each mask bit can guard the write to mask granularity number of data bits. For a single-bit mask, one-bit guards write to the data, hence mask granularity = data bitwidth.

Macro replacement 

Memories that satisfy the conditions above are candidates for macro replacement.

A memory generator defines the external module definition corresponding to the memory for macro replacement. Memory generators need metadata to generate the memory definition. SFC uses some metadata files to communicate with the memory generators.

<design-name>.conf is a file, that contains the metadata for the memories which are under the “design-under-test” module hierarchy. Following is a sample content of the file:

name dir_ext depth 512 width 248 ports mrw mask_gran 31
name banks_0_ext depth 2048 width 72 ports rw
name banks_1_ext depth 2048 width 72 ports rw
  1. name followed by the memory name.
  2. depth followed by the memory depth.
  3. width followed by the data bitwidth.
  4. ports followed by the mrw for read-write port, mwrite for a write port and read for a read port.
  5. mask_gran followed by the mask granularity.

CHIRRTL Memories 

FIRRTL has two different representations of memories: Chisel cmemory operations, smem and cmem, and the standard FIRRTL mem operation. Chisel memory operations exist to make it easy to produce FIRRTL code from Chisel, and closely match the Chisel API for memories. Chisel memories are intended to be replaced with standard FIRRTL memories early in the pipeline. The set of operations related to Chisel memories are often referred to as CHIRRTL.

The main difference between Chisel and FIRRTL memories is that Chisel memories have an operation to add a memory port to a memory, while FIRRTL memories require all ports to be defined up front. Another difference is that Chisel memories have “enable inferrence”, and are usually inferred to be enabled where they are declared. The following example shows a CHIRRTL memory declaration, and the standard FIRRTL memory equivalent.

smem mymemory : UInt<4>[8]
when p:
  read mport port0 = mymemory[address], clock
mem mymemory:
    data-type => UInt<4>
    depth => 8
    read-latency => 0
    write-latency => 1
    reader => port0
    read-under-write => undefined

mymemory.port0.en <= p
mymemory.port0.clk <= clock
mymemory.port0.addr <= address

FIRRTL memory operations were created because it was thought that a concrete memory primitive, that looks like an instance, is a better design for a compiler IR. It was originally intended that Chisel would be modified to emit FIRRTL memory operations directly, and the CHIRRTL operations would be retired. The lowering from Chisel memories to FIRRTL memories proved far more complicated than originally envisioned, specifically surrounding the type of ports, inference of enable signals, and inference of clocks.

CHIRRTL operations have since stuck around, but their strange behavior has lead to discussions to remove, improve, or totally redesign them. For some current discussion about this see 1, 2. Since CIRCT is attempting to be a drop in replacement FIRRTL compiler, we are not attempting to implement these new ideas for Chisel memories. Instead, we are trying to implement what exists today.

There is, however, a major compatibility issue with the existing implementation of Chisel memories which made them difficult to support in CIRCT. The FIRRTL specification disallows using any declaration outside of the scope where it is created. This means that a Chisel memory port declared inside of a when block can only be used inside the scope of the when block. Unfortunately, this invariant is not enforced for memory ports, and this leniency has been abused by the Chisel standard library. Due to the way clock and enable inference works, we couldn’t just hoist the declaration into the outer scope.

To support escaping memory port definitions, we decided to split the memory port operation into two operations. We created a chirrtl.memoryport operation to declare the memory port, and a chirrtl.memoryport.access operation to enable the memory port. The following is an example of how FIRRTL translates into the CIRCT dialect:

smem mymem : UInt<1>[8]
when cond:
  infer mport myport = mymem[addr], clock
out <= myport
%mymem = chirrtl.seqmem Undefined  : !chirrtl.cmemory<uint<1>, 8>
%myport_data, %myport_port = chirrtl.memoryport Infer %mymem {name = "myport"}  : (!chirrtl.cmemory<uint<1>, 8>) -> (!firrtl.uint<1>, !chirrtl.cmemoryport)
firrtl.when %cond  {
  chirrtl.memoryport.access %myport_port[%addr], %clock : !chirrtl.cmemoryport, !firrtl.uint<3>, !firrtl.clock
}
firrtl.connect %out, %myport_data : !firrtl.uint<1>, !firrtl.uint<1

The CHIRRTL operations and types are contained in the CHIRRTL dialect. The is primary reason to move them into their own dialect was to keep the CHIRRTL types out of the FIRRTL dialect type hierarchy. We tried to have the CHIRRTL dialect depend on the FIRRTL dialect, but the flow checking in FIRRTL had to know about CHIRRTL operations, which created a circular dependency. To simplify how this is handled, both dialects are contained in the same library.

For a historical discussion of this issue and its development see llvm/circt#1561.

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 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 is invalid operation.

To make things easier to reason about, we split the 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

Interpretation of Undefined Behavior 

The FIRRTL Specification has undefined behavior for certain features. For compatibility reasons, FIRRTL dialect always chooses to implement undefined behavior in the same manner as the SFC.

Invalid 

The SFC has multiple context-sensitive interpretations of invalid. Failure to implement all of these can result in formal equivalence failures when comparing CIRCT-generated Verilog with SFC-generated Verilog. A list of these interpretations is enumerated below and then described in more detail.

  1. An invalid value driving the initialization value of a register (looking through wires and connections within module scope) removes the reset from the register.
  2. An invalid value used in a when-encoded multiplexer tree results in a direct connection to the non-invalid leg of the multiplexer.
  3. A validif construct is a direct connection.
  4. Any other use of an invalid value is treated as constant zero.

Interpretation (1) is a mechanism to remove unnecessary reset connections in a circuit as fewer resets can enable a higher performance design. The SFC implementation of this works as a dedicated pass that does a module-local analysis looking for registers with resets whose initialization values come from invalidated signals. This analysis only looks through wires and connections. It is legal to use an invalidated output port or instance input port.

As an example, the following module should have register r converted to a reset-less register:

wire inv: UInt<8>
inv is invalid

wire tmp: UInt<8>
tmp <= inv

reg r: UInt<8>, clock with : (reset => (reset, tmp))

Notably, if tmp is a node, this optimization should not be performed.

Interpretation (2) and Interpretation (3) mean that either of the following circuits should be optimized to a direct connection from bar to foo:

foo is invalid
when cond:
  foo <= bar
foo <= validif(cond, bar)

Note that the SFC implementation of this optimization is handled via two passes. An ExpandWhens (later refactored as ExpandWhensAndCheck) pass converts all when blocks to multiplexer trees. Any invalid values that arise from this conversion produce validif expressions. A later pass, RemoveValidIfs optimizes/removes validif by replacing it with a direct connection.

It is important to note that the above two formulations using when or validif are not equivalent to a mux formulation like the following. The code below should be optimized using Interpretation (4) of invalid as constant zero:

wire inv: UInt<8>
inv is invalid

foo <= mux(cond, bar, inv)

A legal lowering of this is only to:

foo <= mux(cond, bar, UInt<8>(0))

Interpretation (4) is used in all other situations involving an invalid value.

Critically, the nature of an invalid value has context-sensitive information that relies on the exact structural nature of the circuit. It follows that any seemingly mundane optimization can result in an end-to-end miscompilations where the SFC is treated as ground truth.

As an example, consider a reformulation of the when example above, but using a temporary, single-use, invalidated wire:

wire inv: UInt<8>
inv is invalid

b <= inv
when cond:
  b <= a

This should not produce a direction connection to b and should instead lower to:

b <= mux(cond, a, inv)

It follows that interpretation (4) will then convert the false leg of the mux to a constant zero.


  1. https://github.com/chipsalliance/firrtl/issues/727 ↩︎

  2. https://github.com/chipsalliance/firrtl/pull/1821 ↩︎