On this page:
9.1 Superoptimization
9.2 Problem 1
9.3 Problem 2
9.4 Problem 3
9.5 Problem 4
8.11

9 Homework 6A

Due:

Tuesday, 2/13 at 9PM

This assignment may be done with an approved PARTNER.

This assignment is entirely AUTOGRADED.

What to Download:

hw6a.rkt

Please start with this file, filling in where appropriate, and submit your homework to the HW6A assignment on Gradescope. This page has additional information, and allows us to format things nicely.

9.1 Superoptimization

Superoptimization is the idea of replacing a given loop-free sequence of instructions with an equivalent sequence of instructions that is somehow better (usually, shorter and as a result, faster). It’s used in compilers at the very lowest level, to optimize numeric operations into often counter-intuitive sequences of instructions that can be baffling at first. It was invented by computer scientist Alexia Massalin in 1987: her idea was to find the shortest sequence of instructions, given a particular instruction set, to compute a given function. This is in contrast to most compiler optimization, which merely aim to improve performance, rather than finding truly optimal solutions.

The resulting programs often rely somewhat complex interactions between different instructions, and sometimes "startling" programs that rely on strange overlap between representations of different values: often times, code with branches is replaced with equivalent code that doesn’t use branches, for example.

9.2 Problem 1

While superoptimization is something that can be done using SMT solvers like the one that exists within LSL, in this homework, we will tackle a simpler problem: checking ordinary optimizations. In particular, showing that a program before and after an optimization are equivalent.

Our optimizations will use a simple, stack-based language: each instruction operates on the stack of numbers, either pushing a new number or adding/multiplying/subtracting the top two (and pushing the result). For example, the program:

(list (make-push 10)
      (make-push 20)
      (make-push 2)
      (make-mul)
      (make-add))

Would start with an empty stack, after the first instruction, would have 10 on top of the stack, and nothing beneath. After the second instruction, 20 would be on top. After the third, 2 would be on top, 20 beneath, 10 at the bottom. Now to run "mul", it would pop the top two numbers (20 & 2), multiply them (yielding 40), and push that back on top of the stack (so it would be 40 above 10). Then the last instruction would do a similar thing with addition, yielding 50 as the final value on the stack, with no instructions left to run.

We provide you with both the language definition and a function to run programs, simple-eval:

 (define-struct push [num]) 
 (define-struct add []) 
 (define-struct mul []) 
 (define-struct sub []) 
 (define-contract SimpleInstr (OneOf (Push Integer) (Add) (Mul) (Sub))) 
  
 (: simple-eval (-> (List Integer) (List SimpleInstr) (List Integer))) 
 (define (simple-eval stk instrs) 
   (local [; stack-binop : [Integer Integer -> Integer] [List-of Integer] 
           ;               [List-of SimpleInstr] -> [List-of Integer] 
           ; evaluates a binary operator on top two numbers of stack, if present 
           (define (stack-binop op stk instrs) 
             (if (>= (length stk) 2) 
                 (simple-eval (cons (op (first stk) (second stk)) 
                             (rest (rest stk))) 
                       instrs) 
                 (list))) 
  
           ; eval-instr : Instr [List-of Integer] [List-of SimpleInstr] -> [List-of Integer] 
           ; evaluates a single instruction, given a stack and rest of instructions 
           (define (eval-instr i stk instrs) 
             (cond [(add? i) (stack-binop + stk instrs)] 
                   [(mul? i) (stack-binop * stk instrs)] 
                   [(sub? i) (stack-binop - stk instrs)] 
                   [(push? i) (simple-eval (cons (push-num i) stk) instrs)]))] 
     (cond [(empty? instrs) stk] 
           [(cons? instrs) (eval-instr (first instrs) stk (rest instrs))])))

Your task is to first implement a function that verifies that two programs written in this language are equivalent, where equivalent means runs to the same thing (This will be relevant and useful once you reach the 3rd problem of this assignment):

 (: simple-stack-verify (-> (List SimpleInstr) (List SimpleInstr) Boolean)) 
 (define (simple-stack-verify p1 p2) 
   ...) 
 

9.3 Problem 2

The next problem is to implement a simple optimization. In this case, we want you to implement a simple form of constant folding. Constant folding is the idea of replacing a series of operations that are all on constants with the result of running those operations at optimization time. By doing it at optimization time, it won’t have to be done when the program is actually running. In this case, we want your optimization to replace the sequence (make-push n) (make-push m) (make-add) with (make-push o) where o is m plus n. You do not need to (and should not) replace sequences that only appear after the initial constant folding has been done. i.e., the sequence (make-push l) (make-push n) (make-push m) (make-add) (make-add) should turn into (make-push l) (make-push o) (make-add), where o is the result of adding n and m, but should not be further optimized into a single (make-push p), where p is the result of adding l and o.

 (: simple-const-fold (-> (List SimpleInstr) (List SimpleInstr))) 
 (define (simple-const-fold p) 
   ...) 
 

9.4 Problem 3

To check that your constant folding works, define a property that shows that given a list of instructions, running the constant folding operation results in a program that is equivalent to the original program.

 (: simple-const-fold-prop (-> (List SimpleInstr) True)) 
 (define (simple-const-fold-prop ...) ...) 
  
 (check-contract simple-const-fold-prop) 
 

9.5 Problem 4

In this problem, we’ll extend the stack calculator to include variables, which represent input from the user (as they will be substituted when running the program) so that there are real limits to what even the best constant folding could do (constant folding happens before running the program, so variables represent unknowns at that time). We represent variable names with strings, to make things easier, and now eval takes a set of variable bindings that give values for each variable.

 (define-struct var [name]) 
 (define-contract Instr (OneOf (Push Integer) (Add) (Mul) (Sub) (Var String))) 
  
 (define-struct bind [name value]) 
 (define-contract Binding (Bind String Integer)) 
  
  
 (: eval (-> (List Binding) (List Integer) (List Instr) (List Integer))) 
 ; will return an empty list if it reaches an unbound variable, or a malformed 
 ; program (trying to do an operation without enough values on stack). 
 (define (eval env stk instrs) 
   (local [; stack-binop : [Integer Integer -> Integer] [List-of Integer] 
           ;               [List-of Instr] -> [List-of Integer] 
           ; evaluates a binary operator on top two numbers of stack, if present 
           (define (stack-binop op stk instrs) 
             (if (>= (length stk) 2) 
                 (eval env 
                       (cons (op (first stk) (second stk)) 
                             (rest (rest stk))) 
                       instrs) 
                 (list))) 
  
           ; lookup-var : String [List-of Binding] [List-of Integer] 
           ;              [List-of Instr] -> [List-of Integer] 
           (define (lookup-var name env stk instrs) 
             (cond [(empty? env) (list)] 
                   [(cons? env) (if (equal? name (bind-name (first env))) 
                                    (eval env 
                                          (cons (bind-value (first env)) 
                                                stk) 
                                          instrs) 
                                    (lookup-var name (rest env) stk instrs))])) 
  
           ; eval-instr : Instr [List-of Integer] [List-of SimpleInstr] -> [List-of Integer] 
           ; evaluates a single instruction, given a stack and rest of instructions 
           (define (eval-instr i stk instrs) 
             (cond [(add? i) (stack-binop + stk instrs)] 
                   [(mul? i) (stack-binop * stk instrs)] 
                   [(sub? i) (stack-binop - stk instrs)] 
                   [(push? i) (eval env (cons (push-num i) stk) instrs)] 
                   [(var? i) (lookup-var (var-name i) env stk instrs)]))] 
     (cond [(empty? instrs) stk] 
           [(cons? instrs) (eval-instr (first instrs) stk (rest instrs))])))

Your first task is to make an updated version of your verify function, to check that two programs are equivalent. Now, you take in a set of variable bindings that both programs will use.

 (: stack-verify (-> (List Binding) (List Instr) (List Instr) Boolean)) 
 (define (stack-verify env p1 p2) ...) 
 

Your next task is to define a more general version of constant folding. This time, look for any sequence of (make-push n) (make-push m) (make-op) where op is one of add, sub, or mul. You still shouldn’t replace sequences that only appear after the initial constant folding has been done.

 (: const-fold (-> (List Instr) (List Instr))) 
 (define (const-fold p) ...)

So you can do similar validation of your constant folding:

 (: const-fold-prop (-> (List Instr) True)) 
 (define (const-fold-prop ...) ...) 
  
 (check-contract const-fold-prop)