Roost Compiler Optimizer
- Assign: Friday, 19 April
- Due: All parts complete by Friday, 3 May
- Reference:
Contents
- Plan
- Basic Blocks and Control Flow Graphs
- Data-Flow Framework and Analyses
- Optimizations
- Extensions
- Scala Case Classes, Equality, and Hashing.
Plan
Opt(imizer|tional)
As mentioned in class, this stage is now optional if you plan to complete the larger feature project.
The last stage is here! In this assignment, you will extend your compiler to support a general data-flow analysis framework and several optimizations implemented in this framework. It is also time to overload some favorite acronyms from the beginning of the course. DFA now means Data-Flow Analysis and CFG means Control-Flow Graph.
Basic Blocks and Control Flow Graphs
Build a control-flow graph representation of the TAC for each method.
Design a package that builds a CFG for a method’s TACList
. I
suggest a simple representation where each CFG node is a single
instruction. I provide a BasicBlock
and ControlFlowGraph
in the
roost.ir
package that you may use, modify, or replace. You will
mainly need to write a transformation from TACList
to
ControlFlowGraph
(and the reverse). To get this code, run git pull
origin starter
.
In the provided code, BasicBlock
is restricted to hold one
instruction, which is sufficient for this assignment. (Larger basic
blocks avoid some computation and space overhead and facilitate more
sophisticated instruction selection schemes that operate on the block
level, but single-instruction blocks are a little easier to use for
the analyses we are performing.) You will need to make some minor
modifications, to connect these classes with your TAC representations,
but otherwise they should work as is. You are of course welcome to
change anything you want.
In addition to toString
, the ControlFlowGraph
supports generating
dot files to show the control flow graph with the dotToFile
method.
If you generate a.dot
, the following commands will convert it to a
PDF showing the graph: dot -Tpdf < a.dot > a.pdf
Data-Flow Framework and Analyses
You will use a data-flow analysis framework with a general data-flow engine for forward and backward analyses to implement individual data-flow analyses on the engine. Each analysis simply describes its lattice and transfer functions. The engine uses the iterative algorithm we explored to solve the given data-flow equations on a CFG and provide access to the resulting IN and OUT values on each basic block. A later section in this document describes suggested code structure.
Data-Flow Framework
The provided code includes much of the general data-flow support you need to get started. As with basic blocks and control flow graphs, you may need to make some minor changes to connect these implementations with your compiler. As always, feel free to replace anything.
The provided roost.dataflow.Dataflow
defines the general behavior of
all data-flow analyses in terms of the data-flow framework parts we
have studied in tutorials.
/**
* Base class for data-flow analyses.
*
* @param direction: Direction of the dataflow analysis - Forward or Backward
* @tparam T: Type of dataflow values.
* @param boundary: Initial value for out[enter] or in[exit]
* @param top: Top value of the lattice.
* @param transfer: Transfer function.
* @param meet: Meet operator as a 2-argument function.
* @param equivalent: Function to judge equivalence of two dataflow values.
*/
class Dataflow[T](direction: Direction,
boundary: T,
top: T,
transfer: (BasicBlock, T) => T,
meet: (T, T) => T,
equivalent: (T, T) => Boolean) {
/**
* Run this dataflow analysis on the given CFG and return the result.
*
* @param cfg: Control flow graph to analyze.
* @return: The analysis result as a DataflowResult.
*/
def solve(cfg: ControlFlowGraph): DataflowResult[T]
}
/**
* Captures the results of a dataflow analysis as named parts.
*
* @param cfg: Control Flow Graph on which the analysis was performed.
* @param in: Maps each basic block to its IN value.
* @param out: Maps each basic block to its OUT value.
* @tparam T: The type of dataflow values.
*/
case class DataflowResult[T](cfg: ControlFlowGraph, in: Map[BasicBlock, T], out: Map[BasicBlock, T])
To implement a specific analysis, you can extend Dataflow
with a
Scala object
, defining the various components of the data-flow
framework, as in this example for unreachable code (code that follows
a return
):
object ReachableAnalysis extends Dataflow[Boolean](
// This is a forward analysis.
Forward,
// Dataflow values are true (reachable), false (unreachable).
// The boundary value (OUT[enter]) is true, i.e., reachable.
true,
// The top value in the lattice is false (unreachable).
// Initially, all other OUTs will hold this value.
false,
// The transfer function is the identity function.
// THIS ASSUMES that TAC returns have a control-flow edge to the exit block.
(_, value) => value,
// The meet operator is OR. If any predecessor is reachable, this block is reachable.
(v1, v2) => v1 || v2,
// Equality of dataflow values trivial for this analysis.
(v1, v2) => v1 == v2
)
This analysis (and an example driver for it) are provided in
roost.dataflow.ReachableAnalysis
.
Your analyses will be much more interesting than this one. You likely will want to define some functions (e.g., the transfer function) out-of-line instead of simply as an anonymous function like above. Scala supports first-class functions.
Make reasonable design choices about how to represent data-flow
facts. Feel free to use scala.collection
classes wherever possible.
Clarity of design and ease of implementation should be the primary
motivation for any initial design choice.
Data-Flow Analyses
Implement the following analyses using the framework:
- Live Variables Analysis: Compute the variables that may be live at each program point.
- Reaching Copies: compute the definitions generated by
Copy
instructions that always reach each program point. - At least one of:
- Constant Folding Analysis: determine variables whose values are constant at each program point. (Hint: data-flow values are maps from variables that hold constants to their constant values.)
- Available Expressions: compute the expressions available at each program point. (If you implement this analysis, please see the note at the end of this document about case classes and equality.)
Optimizations
Use the results of the analyses you implemented to perform the following optimizations:
- Dead Code Elimination (DCE): Removes code that updates variables whose values are not used in any executions. This optimization will use the results from the Live Variables analysis.
- Copy Propagation (CP): Use the results from the reaching copies analysis to perform copy propagation.
- Depending on which analyses you implemented, at least one of:
- Constant Folding (CF): Uses the results from constant folding analysis and replaces each constant expression with the computed constant. (The Dragon book describes this optimization in detail.)
-
Common Subexpression Elimination (CSE): Reuses expressions already computed in the program. Here you will use the information computed with the Available Expressions analysis. If an expression is available before an instruction that re-computes that expression, replace the computation by the variable holding the result of that expression. If there is no such variable, create a temporary variable to store the result of the expression at the site where the expression is initially computed.
Common subexpression elimination should also remove redundant run-time checks such as array bounds checks or null pointer checks. (Earlier, we may cast redundant null-check elimination as a distinct analysis and optimization. This works, but CSE can consider null checks or array bounds checks as “expressions” that can be removed if always “available” and provide a general way to eliminate some such checks.)
Note that your CSE implementation will find only syntactically equivalent expressions.
To improve the effectiveness of your optimizations, try composing them
in different orders and repeating the composed analyses. Each of
these individual optimizations often creates opportunities for the
others. (In the Tiny
compiler, we even computed a fixed point over
CF, CP, and DCE. You could think or experiment to determine whether
this is reasonable here.)
Optimization Code Structure
A simple framework for optimizations is sketched in
roost.opt.Optimization
, with an example in
roost.opt.UnreachableCodeElimination
. This is the provided code you
are most likely to reorganize or replace.
The optimize
methods return Unit
, but you can change them to
return a Boolean
indicating whether the TAC changed. While not
required, this allows you to provide a --fix-opt
command-line option
that iteratively applies all optimizations over and over again until
no additional changes happen (or a fixed upper bound on the number of
iterations is reached).
Command Line Interface
In addition to the options from the previous assignments, your
compiler should support the -O/--opt
command-line option, which
accepts a comma-separated list of optimization keys drawn from the
following set:
dce
for dead code elimination;cf
for constant folding;cse
for common subexpression elimination;cp
for copy propagaion; and- any others you implement.
Note: using a scopt
option of
Seq[String]
may work well.
The compiler will perform the optimizations in the order they occur on the command line. The above arguments may appear multiple times in the same command line — in that case the compiler will execute them multiple times, in the specified order. The compiler should only perform the analyses necessary for the optimizations requested.
For example, the command line --opt cf,cp,dce,cse,dce
would run 5
optimization passes. Repeating optimizations is legal.
Additionally:
- The compiler should support the option
--show-dataflow
that causes it to show the data-flow facts computed computed for each program point by each analysis pass in the--opt
list, in order, and clearly labeled. - The
--show-ir
option should cause the compiler to show the TAC for each function before any optimizations, and (if an--opt
list is given) after each optimization pass, in order, and clearly labeled.
Extensions
If you are looking for more fun or starting points to explore a feature project on optimization, many more optimizations are possible in your framework. Talk to me.
Scala Case Classes, Equality, and Hashing.
By default, Scala uses structural equality for ==
tests on case
classes. That is, two distinct objects of a case class will be
considered equal if all of their fields are equal. Thus, the
following prints true, even though x
and y
are distinct objects.
case class TACNot(dst: TACOperand, src: TACOperand) extends TACInstr
// ...
val x = TACNot(t1, t2)
val y = TACNot(t1, t2)
println(x == y) // prints true.
If you are storing TAC instructions in a set, searching for them in a
list, or creating a map where the domain is TAC instructions, you may
not be able to distinguish between two occurences of the same
instruction. So, if the TAC instruction for t1 = !t2
appears in
multiple places in a TAC list, the standard Scala collections
implementations will consider them all equal to each other.
This will potentially be an issue for analyses like CSE, for which you
may choose to construct sets of TAC instructions that compute the same
expression. In such a case, you will likely want to use reference
equality so that you can keep track of different occurences of the
same instruction. To switch to reference equality exclusive (not
necessarily recommended), add the following to your TACInstr
base
class:
abstract class TACInstr {
override def equals(o: Any) = {
o.isinstanceOf[AnyRef] && this eq o.asInstanceOf[AnyRef]
}
}
The function eq
is a pointer equality test. With this extension,
the above test x == y
yields false.
Also, objects considered equal must always have the same hash code.
Whenever you add your own .equals
method, you must ensure that
hashCode
is properly defined. For example, the following class
ignores the y
field in the equals method:
case class Point(x: Int, y: Int) {
override def equals(o: Any) = {
o.isinstanceOf[Point] && (x == o.asInstanceOf[Point].x)
}
}
val x = Point(1,2)
val y = Point(1,3)
val set = new HashSet(x,y)
The set
will contain both points, however, even though they are
considered equal, because they hash to different values. So, you must
add a hashCode
method to reflect the notion of equality you are
using:
case class Point(x: Int, y: Int) {
override def equals(o: Any) = {
o.isinstanceOf[Point] && (x == o.asInstanceOf[Point].x)
}
override def hashCode() = { x }
}
val x = Point(1,2)
val y = Point(1,3)
val set = new HashSet(x,y)