Adding a New Pass

All passes in the compiler are stored in the calyx/src/passes directory. To add a new pass, we need to do a couple of things:

  1. Define a pass struct and implement the required traits.
  2. Expose the pass using in the passes module.
  3. Register the pass in the compiler.

It is possible to add passes outside the compiler tree, but we haven't needed to do this yet, so we will not cover it here.

Defining a Pass Struct

We first define a Rust structure that will manage the state of the pass:

#![allow(unused)]
fn main() {
pub struct NewPass;
}

A pass needs to implement the Named and Visitor traits. The former defines the name, description, and pass-specific options of the pass.

#![allow(unused)]
fn main() {
impl Named for NewPass {
    fn name(&self) -> &'static str { "new-pass" }
    ...
}
impl Visitor for NewPass { ... }
}

The pass name provided in used in the compiler's driver and needs to be unique for each registered pass.

The Visitor Trait

The visitor trait allows us to define the behavior of the pass. The visitor visits each control operator in each component and performs some action. Furthermore, it also allows us to control the order in which components are visited.

Component Iteration Order

The Order struct allows us to control the order in which components are visited:

  • Post: Iterate the subcomponents of a component before the component itself.
  • Pre: Iterate the subcomponents of a component after the component itself.
  • No: Iterate the components in any order.

Visiting Components

Most passes will attempt to transform the structural part of the program (wires or cells), the control schedule, or both. The Visitor trait is flexible enough to allow all of these patterns and efficiently traverse the program.

For a control program like this:

seq {
    one;
    if cond { two } else { three }
    invoke foo(..)
}

The following sequence of Visitor methods are called:

- start
- start_seq
  - enable       // group one
  - start_if
    - enable     // group two
    - enable     // group three
  - end_if
  - invoke       // invocation
- finish_seq
- finish

Each non-leaf control operator defines both a start_* and finish_* method which allows us to encode top-down and bottom-up traversal patterns.

Each method returns an Action value which allows us to control the traversal of the program. For example, Action::Stop will immediately stop the traversal of the program while Action::SkipChildren will skip the traversal of the children of the current control operator.

Registering the Pass

The final step is to register the pass in the compiler. We use the PassManager to register the pass defined in the default_passes.rs file.

Registering a pass is as simple as calling the register pass:

#![allow(unused)]
fn main() {
pm.register_pass::<NewPass>();
}

Once done, the pass is accessible from the command line:

cargo run -- -p new-pass <file>

This will run -p new-pass on the input file. In order to run this pass in the default pipeline, we need to add it to the all alias (which is called when no -p option is provided). The all alias is itself defined using other aliases which separate the pipeline into different phases. For example, if NewPass needs to run before the compilation passes, we can add it to the pre-opt alias.

The compiler has a ton of shared infrastructure that can be useful:

  • ir::Context: The top-level data structure that holds a complete Calyx program.
  • Rewriter: Helps with consistent renaming of ports, cells, groups, and comb groups in a component.
  • analysis: Provides a number of useful analysis that can be used within a pass.
  • IR macros: Macros useful for adding cells (structure!), guards (guard!) and assignments (build_assignments!) to component.

Also, check out how to visualize passes for development and debugging.