|
|
- ---
- title: Optimisation through Constraint Propagation
- date: August 06, 2017
- ---
-
- Constraint propagation is a new optimisation proposed for implementation
- in the Urn compiler[^mr]. It is a variation on the idea of
- flow-sensitive typing in that it is not applied to increasing program
- safety, rather being used to improve _speed_.
-
- ### Motivation
-
- The Urn compiler is decently fast for being implemented in Lua.
- Currently, it manages to compile itself (and a decent chunk of the
- standard library) in about 4.5 seconds (when using LuaJIT; When using
- the lua.org interpreter, this time roughly doubles). Looking at
- a call-stack profile of the compiler, we notice a very interesting data
- point: about 11% of compiler runtime is spent in the `(type)` function.
-
- There are two ways to fix this: Either we introduce a type system (which
- is insanely hard to do for a language as dynamic as Urn - or Lisp in
- general) or we reduce the number of calls to `(type)` by means of
- optimisation. Our current plan is to do the latter.
-
- ### How
-
- The proposed solution is to collect all the branches that the program
- has taken to end up in the state it currently is. Thus, every branch
- grows the set of "constraints" - the predicates which have been invoked
- to get the program here.
-
- Most useful predicates involve a variable: Checking if it is or isn't
- nil, if is positive or negative, even or odd, a list or a string, and
- etc. However, when talking about a single variable, this test only has
- to be performed _once_ (in general - mutating the variable invalidates
- the set of collected constraints), and their truthiness can be kept, by
- the compiler, for later use.
-
- As an example, consider the following code. It has three branches, all
- of which imply something different about the type of the variable `x`.
-
- ```lisp
- (cond
- [(list? x)] ; first case
- [(string? x)] ; second case
- [(number? x)]) ; third case
- ```
-
- If, in the first case, the program then evaluated `(car x)`, it'd end up
- doing a redundant type check. `(car)`, is, in the standard library,
- implemented like so:
-
- ```lisp
- (defun car (x)
- (assert-type! x list)
- (.> x 0))
- ```
-
- `assert-type!` is merely a macro to make checking the types of arguments
- more convenient. Let's make the example of branching code a bit more
- complicated by making it take and print the `car` of the list.
-
- ```lisp
- (cond
- [(list? x)
- (print! (car x))])
- ; other branches elided for clarity
- ```
-
- To see how constraint propagation would aid the runtime performance of
- this code, let's play optimiser for a bit, and see what this code would
- end up looking like at each step.
-
- First, `(car x)` is inlined.
- ```lisp
- (cond
- [(list? x)
- (print! (progn (assert-type! x list)
- (.> x 0)))])
- ```
-
- `assert-type!` is expanded, and the problem becomes apparent: the type
- of `x` is being computed _twice_!
-
- ```lisp
- (cond
- [(list? x)
- (print! (progn (if (! (list? x))
- (error! "the argument x is not a list"))
- (.> x 0)))])
- ```
-
- If the compiler had constraint propagation (and the associated code
- motions), this code could be simplified further.
-
- ```lisp
- (cond
- [(list? x)
- (print! (.> x 0))])
- ```
-
- Seeing as we already know that `(list? x)` is true, we don't need to
- test anymore, and the conditional can be entirely eliminated. Figuring
- out `(! (list? x))` from `(list? x)` is entirely trivial constant
- folding (the compiler already does it)
-
- This code is optimal. The `(list? x)` test can't be eliminated because
- nothing else is known about `x`. If its value were statically known, the
- compiler could eliminate the branch and invocation of `(car x)`
- completely by constant propagation and folding (`(car)` is, type
- assertion notwithstanding, a pure function - it returns the same results
- for the same inputs. Thus, it is safe to execute at compile time)
-
- ### How, exactly
-
- In this section I'm going to outline a very simple implementation of the
- constraint propagation algorithm to be employed in the Urn compiler.
- It'll work on a simple Lisp with no quoting or macros (thus, basically
- the lambda calculus).
-
- ```lisp
- (lambda (var1 var2) exp) ; λ-abstraction
- (foo bar baz) ; procedure application
- var ; variable reference
- (list x y z) ; list
- t, nil ; boolean
- (cond [t1 b1] [t2 b2]) ; conditional
- ```
-
- The language has very simple semantics. It has three kinds of values
- (closures, lists and booleans), and only a couple reduction rules. The
- evaluation rules are presented as an interpretation function (in Urn,
- not the language itself).
-
- ```lisp
- (defun interpret (x env)
- (case x
- [(lambda ?params . ?body)
- `(:closure ,params ,body ,(copy env))] ; 1
- [(list . ?xs)
- (map (cut interpret <> env) xs)] ; 2
- [t true] [nil false] ; 3
- [(cond . ?alts) ; 4
- (interpret
- (block (map (lambda (alt)
- (when (interpret (car alt) env)
- (break (cdr alt))))))
- env)]
- [(?fn . ?args)
- (case (eval fn env)
- [(:closure ?params ?body ?cl-env) ; 5
- (map (lambda (a k)
- (.<! cl-env (symbol->string a) (interpret k env)))
- params args)
- (last (map (cut interpret <> env) body))]
- [_ (error! $"not a procedure: ${fn}")])]
- [else (.> env (symbol->string x))]))
- ```
-
- 1. In the case the expression currently being evaluated is a lambda, we
- make a copy of the current environment and store it in a _closure_.
- 2. If a list is being evaluated, we recursively evaluate each
- sub-expression and store all of them in a list.
- 3. If a boolean is being interpreted, they're mapped to the respective
- values in the host language.
- 4. If a conditional is being evaluated, each test is performed in order,
- and we abort to interpret with the corresponding body.
- 5. When evaluating a procedure application, the procedure to apply is
- inspected: If it is a closure, we evaluate all the arguments, bind
- them along with the closure environment, and interpret the body. If
- not, an error is thrown.
-
- Collecting constraints in a language as simple as this is fairly easy,
- so here's an implementation.
-
- ```lisp
- (defun collect-constraints (expr (constrs '()))
- (case expr
- [(lambda ?params . ?body)
- `(:constraints (lambda ,params
- ,@(map (cut collect-constraints <> constrs) body))
- ,constrs)]
- ```
-
- Lambda expressions incur no additional constraints, so the inner
- expressions (namely, the body) receive the old set.
- The same is true for lists:
-
- ```lisp
- [(list . ?xs)
- `(:constraints (list ,@(map (cut collect-constraints <> constrs) xs))
- ,constrs)]
- ```
-
- Booleans are simpler:
-
- ```lisp
- [t `(:constraints ,'t ,constrs)]
- [nil `(:constraints ,'nil ,constrs)]
- ```
-
- Since there are no sub-expressions to go through, we only associate the
- constraints with the boolean values.
-
- Conditionals are where the real work happens. For each case, we add that
- case's test as a constraint in its body.
-
- ```lisp
- [(cond . ?alts)
- `(:constraints
- (cond
- ,@(map (lambda (x)
- `(,(collect-constraints (car x) constrs)
- ,(collect-constraints (cadr x) (cons (car x) constrs))))
- alts))
- ,constrs)]
- ```
-
- Applications are as simple as lists. Note that we make no distinction
- between valid applications and invalid ones, and just tag both.
-
- ```lisp
- [(?fn . ?args)
- `(:constraints
- (,(collect-constraints fn constrs)
- ,@(map (cut collect-constraints <> constrs)
- args))
- ,constrs)]
- ```
-
- References are also straightforward:
-
- ```lisp
- [else `(:constraints ,expr ,constrs)]))
- ```
-
- That's it! Now, this information can be exploited to select a case
- branch at compile time, and eliminate the overhead of performing the
- test again.
-
- This is _really_ easy to do in a compiler that already has constant
- folding of alternatives. All we have to do is associate constraints to
- truthy values. For instance:
-
- ```lisp
- (defun fold-on-constraints (x)
- (case x
- [((:constraints ?e ?x)
- :when (known? e x))
- 't]
- [else x]))
- ```
-
- That's it! We check if the expression is in the set of known
- constraints, and if so, reduce it to true. Then, the constant folding
- code will take care of eliminating the redundant branches.
-
- ### When
-
- This is a really complicated question. The Urn core language,
- unfortunately, is a tad more complicated, as is the existing optimiser.
- Collecting constraints and eliminating tests would be in completely
- different parts of the compiler.
-
- There is also a series of code motions that need to be in place for
- constraints to be propagated optimally, especially when panic edges are
- involved. Fortunately, these are all simple to implement, but it's still
- a whole lot of work.
-
- I don't feel confident setting a specific timeframe for this, but
- I _will_ post more blags on this topic. It's fascinating (for me, at
- least) and will hopefully make the compiler faster!
-
- [^mr]: The relevant merge request can be found
- [here](https://gitlab.com/urn/urn/issues/27).
-
|