Passes and Pipelines

Passes are an important part of compiler infrastructures, enabling efficient modification of a whole program. All modifications to an SDFG, including pattern-matching transformations, are performed as passes on the graph.

Passes can be grouped together in Pipelines and depend on each other. The Pipeline object, upon applying, will ensure dependencies are met and that passes do not run redundantly.

See more information and examples in Available Passes.

Passes

A Pass is an SDFG analysis or manipulation that registers as part of the SDFG history. Classes that extend Pass can be used for optimization purposes, to collect data on an entire SDFG, for cleanup, or other uses.

A Pass is defined by one main method: apply_pass(). This method receives the SDFG to manipulate/analyze, as well as the previous Pipeline results (if run in the context of a pipeline).

Note

The return value of a pass serves as a report of the work performed by the pass. A pass returns None only if it did not perform any change on the graph. Always return some object if you made changes to the graph, even if it is an empty dictionary or zero.

An example of a simple pass that only traverses the graph and finds the number of total states is:

from dace import SDFG
from dace.transformation import pass_pipeline as ppl
from dataclasses import dataclass
from typing import Any, Dict

@dataclass
class CountStates(ppl.Pass):
    """
    Counts states in this SDFG and potentially nested SDFGs.
    (this description will appear in the Visual Studio Code plugin)
    """
    recursive: bool = False  # If True, traverses graph into nested SDFGs

    def modifies(self) -> ppl.Modifies:
        # This is an analysis pass, so it does not modify anything
        return ppl.Modifies.Nothing

    def should_reapply(self, modified: ppl.Modifies) -> bool:
        # We should rerun this pass if the state structure has changed
        return modified & ppl.Modifies.States

    def apply_pass(self, sdfg: SDFG, pipeline_results: Dict[str, Any]) -> int:
        """
        Counts the states and returns the result.
        """
        result = 0

        if self.recursive:
            # If recursive, also counts nested SDFG results
            for sd in sdfg.all_sdfgs_recursive():
                result += sd.number_of_nodes()
        else:
            # Otherwise, simply count the states in this graph
            result = sdfg.number_of_nodes()

        return result


# To use this pass, we create an object and give it the SDFG, as well as an empty
# dictionary for previous pipeline results
result = CountStates(recursive=True).apply_pass(sdfg, {})
print('SDFG has', result, 'states')

To improve productivity, we provide specific types of Passes that can be extended as necessary, for example VisitorPass:

class HasWriteConflicts(VisitorPass):
    def __init__(self):
        self.found_wcr = False

    def visit_Memlet(self, memlet: dace.Memlet, parent: dace.SDFGState, pipeline_results: Dict[str, Any]):
        if memlet.wcr:
            self.found_wcr = True

            # If a value is returned, a dictionary key will be filled with the visited object and the value
            return memlet.wcr

wcr_checker = HasWriteConflicts()
memlets_with_wcr = wcr_checker.apply_pass(sdfg, {})
print('SDFG has write-conflicted memlets:', wcr_checker.found_wcr)
print('Memlets:', memlets_with_wcr)

Other extensible sub-classes are StatePass and ScopePass, which apply on each state or scope, respectively.

Pipelines

Passes may depend on each other through a Pipeline object. A pass pipeline contains multiple, potentially dependent Pass objects, and applies them in the correct order. Each contained pass may depend on other (e.g., analysis) passes, which the pipeline avoids rerunning depending on which elements were modified by applied passes. An example of a built-in pipeline is the SimplifyPass, which runs multiple complexity reduction passes and may reuse data across them. Prior results of applied passes are contained in the pipeline_results argument to apply_pass, which can be used to access previous return values of Passes.

The return value of applying a pipeline is a dictionary whose keys are the Pass subclass names and values are the return values of each pass.

A Pipeline in itself is a type of Pass, so it can be arbitrarily nested in other Pipelines. Its dependencies and modified elements are unions of the contained Pass objects.

In every Pass, there are three optional pipeline-related methods that can be implemented:

  • depends_on: Which other passes this pass requires
  • modifies: Which elements of the SDFG does this Pass modify (used to avoid re-applying when unnecessary)
  • should_reapply: Given the modified elements of the SDFG, should this pass be rerun?

So what kind of elements can be modified? We provide a flag object called Modifies that specifies what type of elements in the graph to include. For example, Modifies.Memlets | Modifies.AccessNodes tells the system that both were modified.

To use an existing pipeline, all that is necessary is to instantiate it and call it. For example: MyPipeline().apply_pass(sdfg, {}). To create a new pipeline from existing passes, instantiate the object with a list of Pass objects, or extend the Pipeline class (e.g., if pipeline order should be modified). For example:

my_simplify = Pipeline([ScalarToSymbolPromotion(integers_only=False), ConstantPropagation()])
results = my_simplify.apply_pass(sdfg, {})
print('Promoted scalars:', results['ScalarToSymbolPromotion'])