my blog lives here now
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

276 lines
8.9 KiB

6 years ago
  1. ---
  2. title: Optimisation through Constraint Propagation
  3. date: August 06, 2017
  4. ---
  5. Constraint propagation is a new optimisation proposed for implementation
  6. in the Urn compiler[^mr]. It is a variation on the idea of
  7. flow-sensitive typing in that it is not applied to increasing program
  8. safety, rather being used to improve _speed_.
  9. ### Motivation
  10. The Urn compiler is decently fast for being implemented in Lua.
  11. Currently, it manages to compile itself (and a decent chunk of the
  12. standard library) in about 4.5 seconds (when using LuaJIT; When using
  13. the lua.org interpreter, this time roughly doubles). Looking at
  14. a call-stack profile of the compiler, we notice a very interesting data
  15. point: about 11% of compiler runtime is spent in the `(type)` function.
  16. There are two ways to fix this: Either we introduce a type system (which
  17. is insanely hard to do for a language as dynamic as Urn - or Lisp in
  18. general) or we reduce the number of calls to `(type)` by means of
  19. optimisation. Our current plan is to do the latter.
  20. ### How
  21. The proposed solution is to collect all the branches that the program
  22. has taken to end up in the state it currently is. Thus, every branch
  23. grows the set of "constraints" - the predicates which have been invoked
  24. to get the program here.
  25. Most useful predicates involve a variable: Checking if it is or isn't
  26. nil, if is positive or negative, even or odd, a list or a string, and
  27. etc. However, when talking about a single variable, this test only has
  28. to be performed _once_ (in general - mutating the variable invalidates
  29. the set of collected constraints), and their truthiness can be kept, by
  30. the compiler, for later use.
  31. As an example, consider the following code. It has three branches, all
  32. of which imply something different about the type of the variable `x`.
  33. ```lisp
  34. (cond
  35. [(list? x)] ; first case
  36. [(string? x)] ; second case
  37. [(number? x)]) ; third case
  38. ```
  39. If, in the first case, the program then evaluated `(car x)`, it'd end up
  40. doing a redundant type check. `(car)`, is, in the standard library,
  41. implemented like so:
  42. ```lisp
  43. (defun car (x)
  44. (assert-type! x list)
  45. (.> x 0))
  46. ```
  47. `assert-type!` is merely a macro to make checking the types of arguments
  48. more convenient. Let's make the example of branching code a bit more
  49. complicated by making it take and print the `car` of the list.
  50. ```lisp
  51. (cond
  52. [(list? x)
  53. (print! (car x))])
  54. ; other branches elided for clarity
  55. ```
  56. To see how constraint propagation would aid the runtime performance of
  57. this code, let's play optimiser for a bit, and see what this code would
  58. end up looking like at each step.
  59. First, `(car x)` is inlined.
  60. ```lisp
  61. (cond
  62. [(list? x)
  63. (print! (progn (assert-type! x list)
  64. (.> x 0)))])
  65. ```
  66. `assert-type!` is expanded, and the problem becomes apparent: the type
  67. of `x` is being computed _twice_!
  68. ```lisp
  69. (cond
  70. [(list? x)
  71. (print! (progn (if (! (list? x))
  72. (error! "the argument x is not a list"))
  73. (.> x 0)))])
  74. ```
  75. If the compiler had constraint propagation (and the associated code
  76. motions), this code could be simplified further.
  77. ```lisp
  78. (cond
  79. [(list? x)
  80. (print! (.> x 0))])
  81. ```
  82. Seeing as we already know that `(list? x)` is true, we don't need to
  83. test anymore, and the conditional can be entirely eliminated. Figuring
  84. out `(! (list? x))` from `(list? x)` is entirely trivial constant
  85. folding (the compiler already does it)
  86. This code is optimal. The `(list? x)` test can't be eliminated because
  87. nothing else is known about `x`. If its value were statically known, the
  88. compiler could eliminate the branch and invocation of `(car x)`
  89. completely by constant propagation and folding (`(car)` is, type
  90. assertion notwithstanding, a pure function - it returns the same results
  91. for the same inputs. Thus, it is safe to execute at compile time)
  92. ### How, exactly
  93. In this section I'm going to outline a very simple implementation of the
  94. constraint propagation algorithm to be employed in the Urn compiler.
  95. It'll work on a simple Lisp with no quoting or macros (thus, basically
  96. the lambda calculus).
  97. ```lisp
  98. (lambda (var1 var2) exp) ; λ-abstraction
  99. (foo bar baz) ; procedure application
  100. var ; variable reference
  101. (list x y z) ; list
  102. t, nil ; boolean
  103. (cond [t1 b1] [t2 b2]) ; conditional
  104. ```
  105. The language has very simple semantics. It has three kinds of values
  106. (closures, lists and booleans), and only a couple reduction rules. The
  107. evaluation rules are presented as an interpretation function (in Urn,
  108. not the language itself).
  109. ```lisp
  110. (defun interpret (x env)
  111. (case x
  112. [(lambda ?params . ?body)
  113. `(:closure ,params ,body ,(copy env))] ; 1
  114. [(list . ?xs)
  115. (map (cut interpret <> env) xs)] ; 2
  116. [t true] [nil false] ; 3
  117. [(cond . ?alts) ; 4
  118. (interpret
  119. (block (map (lambda (alt)
  120. (when (interpret (car alt) env)
  121. (break (cdr alt))))))
  122. env)]
  123. [(?fn . ?args)
  124. (case (eval fn env)
  125. [(:closure ?params ?body ?cl-env) ; 5
  126. (map (lambda (a k)
  127. (.<! cl-env (symbol->string a) (interpret k env)))
  128. params args)
  129. (last (map (cut interpret <> env) body))]
  130. [_ (error! $"not a procedure: ${fn}")])]
  131. [else (.> env (symbol->string x))]))
  132. ```
  133. 1. In the case the expression currently being evaluated is a lambda, we
  134. make a copy of the current environment and store it in a _closure_.
  135. 2. If a list is being evaluated, we recursively evaluate each
  136. sub-expression and store all of them in a list.
  137. 3. If a boolean is being interpreted, they're mapped to the respective
  138. values in the host language.
  139. 4. If a conditional is being evaluated, each test is performed in order,
  140. and we abort to interpret with the corresponding body.
  141. 5. When evaluating a procedure application, the procedure to apply is
  142. inspected: If it is a closure, we evaluate all the arguments, bind
  143. them along with the closure environment, and interpret the body. If
  144. not, an error is thrown.
  145. Collecting constraints in a language as simple as this is fairly easy,
  146. so here's an implementation.
  147. ```lisp
  148. (defun collect-constraints (expr (constrs '()))
  149. (case expr
  150. [(lambda ?params . ?body)
  151. `(:constraints (lambda ,params
  152. ,@(map (cut collect-constraints <> constrs) body))
  153. ,constrs)]
  154. ```
  155. Lambda expressions incur no additional constraints, so the inner
  156. expressions (namely, the body) receive the old set.
  157. The same is true for lists:
  158. ```lisp
  159. [(list . ?xs)
  160. `(:constraints (list ,@(map (cut collect-constraints <> constrs) xs))
  161. ,constrs)]
  162. ```
  163. Booleans are simpler:
  164. ```lisp
  165. [t `(:constraints ,'t ,constrs)]
  166. [nil `(:constraints ,'nil ,constrs)]
  167. ```
  168. Since there are no sub-expressions to go through, we only associate the
  169. constraints with the boolean values.
  170. Conditionals are where the real work happens. For each case, we add that
  171. case's test as a constraint in its body.
  172. ```lisp
  173. [(cond . ?alts)
  174. `(:constraints
  175. (cond
  176. ,@(map (lambda (x)
  177. `(,(collect-constraints (car x) constrs)
  178. ,(collect-constraints (cadr x) (cons (car x) constrs))))
  179. alts))
  180. ,constrs)]
  181. ```
  182. Applications are as simple as lists. Note that we make no distinction
  183. between valid applications and invalid ones, and just tag both.
  184. ```lisp
  185. [(?fn . ?args)
  186. `(:constraints
  187. (,(collect-constraints fn constrs)
  188. ,@(map (cut collect-constraints <> constrs)
  189. args))
  190. ,constrs)]
  191. ```
  192. References are also straightforward:
  193. ```lisp
  194. [else `(:constraints ,expr ,constrs)]))
  195. ```
  196. That's it! Now, this information can be exploited to select a case
  197. branch at compile time, and eliminate the overhead of performing the
  198. test again.
  199. This is _really_ easy to do in a compiler that already has constant
  200. folding of alternatives. All we have to do is associate constraints to
  201. truthy values. For instance:
  202. ```lisp
  203. (defun fold-on-constraints (x)
  204. (case x
  205. [((:constraints ?e ?x)
  206. :when (known? e x))
  207. 't]
  208. [else x]))
  209. ```
  210. That's it! We check if the expression is in the set of known
  211. constraints, and if so, reduce it to true. Then, the constant folding
  212. code will take care of eliminating the redundant branches.
  213. ### When
  214. This is a really complicated question. The Urn core language,
  215. unfortunately, is a tad more complicated, as is the existing optimiser.
  216. Collecting constraints and eliminating tests would be in completely
  217. different parts of the compiler.
  218. There is also a series of code motions that need to be in place for
  219. constraints to be propagated optimally, especially when panic edges are
  220. involved. Fortunately, these are all simple to implement, but it's still
  221. a whole lot of work.
  222. I don't feel confident setting a specific timeframe for this, but
  223. I _will_ post more blags on this topic. It's fascinating (for me, at
  224. least) and will hopefully make the compiler faster!
  225. [^mr]: The relevant merge request can be found
  226. [here](https://gitlab.com/urn/urn/issues/27).