Local Optimization

Some statements can be deleted

x := x + 0
x := x * 1
Eliminating these instructions are great optimizations as we are removing an entire instruction. These transformations may look silly, but they are actually quite general, and are usually created because of the use of compile-time constants, e.g., macros, template parameters, etc.

Some statements can be simplified

x := x * 0  =>  x := 0
Maybe the instruction on the right is faster than the instruction on the right. More importantly, assigning to a constant allows more cascading optimizations, as we will see later.

Another example:

y := y ** 2  => y := y * y
Typically, an architecture will not have the exponentiation instruction and so this operator may have to be implemented by an exponentiation loop in software. For the special cases (e.g., exponent = 2), we can simply replace by multiplication.

Another example:

x := x * 8  => x := x << 3
x := x * 15  => t := x << 4; x := t - x
Replace multiplication by a power-of-two to a shift-left operation. Can also do this for non powers-of-two. On some machines, left-shift is faster than multiply, but not on all! Modern machines have their own "rewriting logic" in hardware that can deal with these special cases really fast. In other words, some of these compiler local optimizations may become less relevant on modern hardware. Yet, some optimizations are always done because they are "harmless" (e.g., the first one) while others may be more controversial (e.g., the second one).

All these transformations are examples of algebraic simplifications.

Some algebraic simplifications transfer from high-school algebra while others do not, because of the finite representation of integers and reals in a computer through bitvectors. For example, a+(b-a)/2 cannot be replaced with (a+b)/2 on a computer, as a+b may overflow for large values of a and b. Similarly, i+1<j+1 cannot be replaced with i<j in general, e.g., if i and j are unsigned integers in C.

Further, some algebraic simplifications are only legal on a computer, and do not hold for integers or reals, e.g., instead of "Numerator / Constant", use "(Numerator * M) >> shift". Hardware division instructions can take 30 to 100 cycles, whereas a multiplication only takes one to three cycles.

An important detail that we have omitted for simplicity is that LLVM IR has types. For example, pseudo-register x is of type iN (e.g., i1, i8, i32, ...) or fN for floating point (e.g., f16, f32, f64, f80), or ptr (for pointers).

Another underlying assumption is on the "notion of correctness". A compiler is usually required to preserve bit-correctness, also called bit-precision. This is the default compiler behaviour, even for floating point computations. However, compilers allow you, through a command-line flag, to relax bit-correctness requirements, to enable more optimizations. For example, a compiler may assume associativity of FP addition while performing transformations.

Operations on constants can be computed at compile time

Examples

This class of optimizations is called constant folding. One of the most consequential and most common optimizations performed by compilers.

Another important optimization: eliminate unreachable basic blocks (dead code):

Called dead-code elimination.

Why would unreachable basic blocks occur?

Dead code is more general the unreachable code. A register is live if the value contained in it may be consequential to the future execution of the program. An assignment to a register that is not live can be removed.

Discuss how one would implement dead code elimination and constant folding for a single basic block, using the following example:

i1: d := 10
i2: a := w + y
i3: x := 3 + a
i4: b := x * 5
i5: w := 3 + d
i6: x := w + 1
i7: d := (x % 1)
i8: if d goto L
...
For liveness, we will walk the basic block backward, maintaining at each point, which registrs are live at each program point. Conservatively assume that all a,b,d,w,x,y are live at the end of the basic block. At the beginning of i8, all are live; i7, all except d are live; i6, all except x,d are live; i5, a,b,y are live; i4, all except a,b,x,y are live; i3, a,d,y are live; i2, d,w,y are live; i1, w,y are live.

Similarly, for constant folding, we will walk the basic block forward, maintaining at each point, which registers are constant (along with constant value) at each point. Let NAC represent the "NotAConstant" determination. At i1, we conservatively assume that all the registers are NAC. At i2,i3,i4,i5, d=10; i6, d,w are 10,13.; i7, d,w,x are 10,13,14; i8, exit, d,w,x are 0,13,14.

If we make transformations based on any of these analyses, that may change the results of the analysis; e.g., the analysis may need to be run again, usually to obtain more optimization opportunities, e.g., b is dead and after its assignment is removed, x at i3 becomes dead too, and its assignment may also be removed.

Some optimizations are simplified if each register occurs only once on the left-hand side of an assignment.

Example

a := x ** 2
b := 3
c := x
d := c * c
e := b * 2
f := a + d
g := e + f

Final form

a := x * x
f := a + a
g := 6 * f
Possible to simplify further, but the compiler may get stuck in "local minima". e.g., should it have changed a + a to 2 * a, it would have had a better optimization opportunity (replacing g = 12*f and eliminating computation of f).

There are two questions here:

  1. What order to apply the transformations?
  2. If we repeat optimizations until no improvement possible, do we expect to converge?

On order of application:

On convergence:

Peephole optimizations

All the transformations we have studied so far, involve the identification of a pattern, and then a transformation (or a replacement for that pattern). One way to implement these pattern-replacement rules is through explicit hand-optimized code, e.g., in C++ to identify a pattern and transform it. Another way is to define a small language for specifying these patterns and their associated desired replacements. Such a pair of a pattern and its replacement is called a peephole optimization.

A peephole optimization (also called a peephole optimization rule) is typically specified as one sequence of instructions (pattern) to be transformed to another sequence of instructions (replacement).

i1; i2; i3; ..; in --> j1; j2; ..; jm
For example, say x := x + 4; x := x + 3 should be replaced with x := x + 7. It seems wasteful to have specific constants in a peephole optimization rule, so we can generalize the format of a peephole optimization so it supports holes which indicate that any value (e.g., constant) can substitute for that hole (think of a hole as a "variable"). So we generalize the example rule: x := x + C1; x := x + C2 to x := x + (C1 + C2). Here, C1 and C2 are compile-time constants, and the computation C1+C2 is performed by the compiler at compile time. Notice that there are subtleties here, e.g., what should happen if C1+C2 overflows at compile time? Answer, the behaviour should be identical to that of the original program, so the final result should maintain the addition semantics for the types of the original registers. For example, it would be valid to have a rule that says: (C1+C2) does not overflow; x := x + C1; x := x + C2 to x := x + (C1 + C2). Here, the compiler is forced to check that C1+C2 does not overflow before potentially applying the transformation.

The example peephole optimization rule above can be generalized further, e.g., x := y + C1; x := x + C2 to x := y + (C1+C2) is more general, as it allows x and y to refer to the same or different registers. A more general peephole optimization rule is better as it can trigger in strictly more number of situations.

In fact, the rule can be generalized further to something like: x := y + C1; z := x + C2; x is not live to z := y + (C1+C2). In this generalization, we introduce a third register z, and allow it to be (same or) different to registers x, y. Notice that the x is not live is required because the replacement gets rid of the assignment to x. Further, notice that the original code still matches this pattern --- if both z and x match to the same register, say r, then the rule triggers even if r is live at the end of the instruction sequence, because z is allowed to be live (requires a slightly more sophisticated logic to interpret the rule).

Notice that through my examples, I have sneaked in constraints in addition to instruction sequences in a peephole optimization. Examples of constraints above are: C1+C2 does not overflow, x is not live, etc. In general, the language of these constraints (and consequently the language of a peephole optimization) can be made arbitrarily rich. For example, it may be possible to say that the two instructions present in the pattern may be separated by an arbitrary length sequence of instructions, as long as those instructions obey certain properties. The richer the language of a peephole optimization, the more general a peephole optimization rule can be made; however this richness comes at a significant cost for the compiler as it needs to interpret and apply the peephole optimization. For example, the application of x is not live requires a compiler to first execute a liveness analysis. Or the allowance of an arbitrary instruction sequence between the two instructions in the pattern, can require a compiler to perform a search over the basic block that can be linear or quadratic in running time, depending on the implementation. Arguably, peephole optimization formats can be made arbitrarily rich, and a compiler can be made to support them; however this is rarely done today, instead the common transformations are simply hand-coded in C++ so they execute fast (much faster than interpreting an arbitrarily-rich peephole optimization rule).

As for local optimizations, peephole optimizations are usually applied repeatedly for maximum effect. Just like other local optimizations, need to ensure that the replacement rules cannot cause oscillations. e.g., each replacement rule can only "improve" the code.

Originally, the idea of peephole optimizations was first applied to the assembly code (after the code generation and optimization phases, as the final step). The idea was that at the assembly-level, we have greater visibility into instruction costs and instruction opcodes. e.g., hardware opcodes may be higher-level than IR opcodes. And so peephole optimizations were effective at improving assembly code. Example:

move $a $b; move $b $a --> move $a $b
Works if the second instruction is not the target of a jump (i.e., both instructions belong to a basic block).

Another example:

addiu $a $a i; addiu $a $a j --> addiu $a $a (i+j)

Many (but not all) of the basic block optimizations can be cast as peephole optimizations

Today, peephole optimizations are used at the LLVM IR level, at the Machine-IR level, and at the assembly level, e.g., LLVM IR peephole optimizer (called Instcombine) implements hundreds or thousands of peephole optimizations. They support a restricted language of constraints, e.g., the kind of constraints shown in examples above.

Research efforts have tried to make peephole optimizers much more general, and also discover peephole optimization rules automatically using AI, but these ideas have found only limited adoption in production so far.

Order of transformations in LLVM IR

So far we have studied the following compiler analyses and transformations
  1. Algebraic simplifications (AS)
  2. Constant folding / propagation (CP)
  3. Liveness analysis (LA)
  4. Dead code elimination (DCE)
  5. Copy propagation (CP)
  6. Common Subexpression Elimination (CSE)
In LLVM, CP is done at construction time, as the IR does not support copy instructions at all; in other words, CP is done at the very beginning. CSE is also done while constructing an instruction in the IR, as if an RHS expression is already available (in SSA form) as a register, that register is used, instead of creating a new register. Similarly, the liveness information is easily maintained at all times, by maintaining the "users" of every SSA definition (a user is an instruction that uses that definition). Constant folding or propagation is done a few times in the transformation pipeline; DCE is usually done in the later stages, but it is also done multiple times, especially after passes such as CP.

"Program optimiztion" is grossly misnamed