Match Compilation

Introduction

The elimination form for constructed data is pattern matching (the case expression). Constructed data is a tree: an internal node is a block that represents an application of a value constructor, and a leaf may be a bare value constructor or any other value. This tree is called the subject tree. Pattern matching selects a choice in a case statement based on which value constructors are used in a subject tree, with what arities, at what points.

An efficient implementation of pattern matching often uses code that is structured as a decision tree. Each node of a decision tree examines one node of the subject tree. Based on the value constructor at the subject node and the number of arguments the value constructor is applied to, the decision node may have enough information to transfer control directly to code that matches a pattern, or it may need to examine some other part of the subject tree, which means transferring control to a subtree of the decision tree. In a decision tree, no node in the subject tree is examined more than once.

A typical target machine (VMs or native code) doesn’t implement decision trees directly. Instead, it implements a computed goto that transfers control based on a single value. In our SVM, that value is a labeled constructor. (In native code, the value is typically a small integer.)

The process of turning a single case expression into a decision tree is match compilation. Match compilation is the centerpiece of this module. We’ll implement an algorithm described by Kevin Scott1 and Norman Ramsey in their unpublished paper “When do Match-Compilation Heuristics Matter?”.

Your match compiler will be called from the K-normalizer and will work with expressions in K-normal form. It will therefore have to handle some details about registers that you won’t find in the paper. In particular, your decision trees will have a form of node that serves only to put a subject-tree value into a VM register.

From paper to code

Scott and Ramsey’s paper is really about the heuristics that might be used to decide in which order to scrutinize the nodes of a subject tree that is being matched. But the paper also presents a match-compilation algorithm, which our match compiler will use. We’ll ignore heuristics.

Before you begin working on your match compiler, you’ll read the first part of the paper, up to and including section 3.1. Skim section 1; focus on sections 2 and 3.

Reading Scott and Ramsey

When reading section 2, be aware of two correspondences:

When reading section 3, be aware of this difference:

The algorithm we will implement is described in the paper in Figure 8 on page 8. The algorithm compiles a list of frontiers, and I summarize it as follows:

  1. Does the first frontier match? If so, return a match node.

  2. If not, start planning a test node. What path do we want to test? Call it \(\pi\).

  3. What constructors appear at path \(\pi\) (in any frontier)? Call them \(\mathit{CS}\).

  4. For each constructor \(C\) that appears at path \(\pi\), compute an edge leaving the test node we plan to generate. Refine the frontiers using the \(\mathit{project}\) functions, and point the edge to a tree obtained by compiling the refined frontiers recursively.

  5. At path \(\pi\), the subject tree might contain a constructor that is not mentioned in any frontier. Any frontier that could match such a constructor should go into \(\mathit{defaults}\). Compile them too.

  6. Return the test node.

To explain the figure in more detail I will replace the paper’s constructor \(C\) with our fully labeled constructor \(C/n\). This change of notation won’t make any difference to the code, but it will make the specification easier to follow.

Study Figure 8 with the following analysis in mind:

Paths, registers, and let bindings

Given a nested pattern like

SOME x :: xs

The paper will produce the set of bindings \(\{\mathtt{x} \mapsto \Lambda.1.1, \mathtt{xs} \mapsto \Lambda.2 \}\).2 \(\Lambda\) stands for the register that holds the root of the subject tree, and each dot stands for a getblockslot primitive. But our match compiler is part of our K-normalizer, and an expression like getblockslot(getblockslot(\(\Lambda\), 1), 1) isn’t in K-normal form. To ensure K-normality, we’ll restrict the forms of paths and decision trees:

The restrictions on nodes and paths are embodied in these datatype definitions:

datatype 'a tree = TEST      of register * 'a edge list * 'a tree option
                 | MATCH     of 'a * register Env.env
                 | LET_CHILD of (register * int) * (register -> 'a tree)
and      'a edge = E of labeled_constructor * 'a tree

datatype path = REGISTER of register | CHILD of register * int

The TEST and MATCH forms are as in the paper. The LET_CHILD form carries two arguments:

LET_CHILD tree is translated into K-normal form something like this:

𝓡\(r\)LET_CHILD ((\(r\), \(i\)), \(k\))⟧ = let \(t\) = getblockslot(\(r\), \(i\)) in \(k\) \(t\),

where \(t\) is a fresh (temporary) register.

Path aliasing

Implementing Figure 8

Before implementing Figure 8, look over the code I provide in match-compiler.sml.

Once you start coding, you may benefit from this guidance:


  1. Kevin is now the chief technology officer at Microsoft.

  2. In the paper the paths are actually backwards. I have no idea what we might have been thinking.