Some statements can be deleted
x := x + 0 x := x * 1Eliminating 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 := 0Maybe 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 * yTypically, 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 - xReplace 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
x := y op zy and z are constants.y op z can be computed at compile time.
Examples
x:= 2 + 2 can be changed to x := 4if 2 < 0 jump L can be deleted.if 2 > 0 jump L can be replaced by jump LAnother important optimization: eliminate unreachable basic blocks (dead code):
Why would unreachable basic blocks occur?
if (DEBUG) then { .... }
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.
i1: d1 := 10 i2: a1 := w + y i3: x1 := 3 + a1 i4: b1 := x1 * 5 i5: w1 := 3 + d1 i6: x2 := w1 + 1 i7: d2 := (x2 % 1) i8: if d2 goto L ...
Why Static Single Assignment (SSA)?
Converting to Single-assignment form
The need for phi nodes: show a diamond structure where two different versions of the same variable reach the same point. Define y3=phi(BB1,y1,BB2,y2) at the beginning of the meet point.
Show an example with a do-while loop. Introduce Static Single Assignment (SSA)
Show an example with a while-do loop.
Inserting PHI nodes:
Path convergence criterion: need Phi node for a variable a at node z iff:
a.y!=x containing a definition of a.
z.
We will later learn how to convert a program to SSA form. LLVM IR is an SSA IR, to take advantage of all the benefits discussed earlier.
Common subexpressions: If
x := is the first use of x in a block
x := y + z .... .... w := y + zWe can be sure that the values of
x, y, and z do not change in the code. Hence, we can replace the assignment to w as follows:
x := y + z .... .... w := xThis optimization is called common-subexpression elimination. This is also another very common and consequential compiler optimization.
Copy propagation: If we see w := x appears in a block, replace subsequent uses
of w with uses of x
b := z + y a := b x := 2 * aThis can be replaced with
b := z + y a := b x := 2 * bThis is called copy propagation. This is useful for enabling other optimizations
If w := rhs appears in a basic block and w does not appear anywhere else in the program, THEN the statement w := rhs is dead and can be eliminated.
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 * fPossible 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:
On order of application:
x := x * 4; x := x * 3. This program can transformed through a combination of common-subexpression elimination and constant folding to x := x * 12. However, if we first implement the algebraic simplification x := x * 4 ==> x := x << 2, then maybe the constant folding transformation can no longer be triggered, as the compiler may not recognize that it can fold a left shift and a multiplication to a single multiplication.x := y * 5; w := x * 3t := y << 2; x := t + y; w := t * 3, and now the constant folding becomes harder. In general, the compiler will have a limited capability or scope of transformations, and a previous transformation can get the program to a state where the best solution falls outside the scope of transformations performed by a compiler. An infinitely capable compiler should still be able to perform the transformation, but that is not the compiler we have at hand.
x is formed by multiplication of y by a constant, even after the code transformation, e.g., through an extra common-subexpression entry.On convergence:
x := y * 4; z := x * 3 to x := y * 4; z := y * 12 may seem non-profitable in this model, even though it improves performance by reducing dependencies, and unlocking parallelism.store (r1), r2; r3 := load(r4); does the cost of the load depend on the latency of the preceding store. Answer: it depends on the values of address regsiters r1 and r4. If r1 and r4 refer to the same memory address, then the value that is returned by load is r2; else it is the original contents of the memory location at r4. This is called aliasing, where the same object in the machine can have more than two names --- here the same memory locations may have names r1 and r4.for i = 0 to n { a[i] += ... }, then it can predict with a high probability that a[i] will be L1-hit, as it will likely be prefetched due to spatial locality. Similarly, random looking accesses are given a high probability of being a cache miss.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; ..; jmFor 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 $bWorks 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
addiu $a $b 0 --> move $a $bmove $a $a --> addiu $a $a 0.
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.
"Program optimiztion" is grossly misnamed