clojure使用macro优化多项式计算的性能
Lisp系语言中,宏是重要的特性。本文展示了一个宏的高级用法。clojure和clojurescript都支持宏。
但是也有区别有区别。
因为clojurescript里编译(java实现的cljs->js)和执行(js)是完全分开的。Macro需要写到clj、cljc文件(宏定义不能放到cljs文件中),然后以:require-macros方式引用。
(ns my.namespace
(:require-macros [my.macros :as my]))
这些区别仅仅是工具链上的,不涉及语言本身。
本blog的在线编译器是 lumo 直接在浏览器端执行, 所谓self-hosted。cljs的宏写法上有特异性,同一个namespace的宏可以用$macro的方式写到同一段cljs代码中。
以多项式计算为例:
$$p(x) = a_0 + a_1 x + a_2 x^2 + a_3 x^3 + ... + a_n x^n$$
需要 $(n^2 + n)/2$ 次乘法运算和n次加法运算。
从性能来说,不如 HornerForm :
$$p(x) = a_0 + x(a_1 + x(a_2 + x (a_3 + ... + x(a_{n-1} + a_n x))))$$
因为只需要n次乘法和n次加法。以一个四项式为例:
(ns poly.core$macros
(:require [cljs.js :as cljs]))
;; 系数和x
(def a0 134.96340251)
(def a1 477198.8675605)
(def a2 0.0088553)
(def a3 1.4343E-5)
(def x 0.0426236319)
;; 正规计算
(defn poly-canonical [x]
(+ a0
(* x a1)
(* x x a2)
(* x x x a3)))
;; HonerForm
(defn poly-horner [x]
(+ a0
(*
(+ a1
(*
(+ a2
(* a3 x))
x))
x)))
;; 两种计算方法结果一致
[(poly-canonical x) (poly-horner x)]
(.now js/performance)可以计算精确到5μs(μs=微秒=10^-6 秒),拿它来做性能比较。
(defn time-fn[n-samples f]
(let[t0 (.now js/performance)
_ (dotimes[_ n-samples] (f))]
(- (.now js/performance) t0)))
;; 耗时从短到长排序,以最短的为基准
(defn normalize-scores [res]
(let[[[_ base] :as v] (sort-by second res)]
(mapv (fn[[k val]] [k (/ val base)]) v)))
;; 排名
(defn rank[n & args]
(loop[[k f & r] args res []]
(if (and k f)
(recur r (conj res [k (time-fn n f)]))
(normalize-scores res))))
执行1000次比较性能, 不出意外, HornerForm性能更胜一筹
(rank
1000
:canonical #(poly-canonical x)
:horner #(poly-horner x))
上文中的四项式是写死的,更加通用的方式是需要有个过程接受
- 一个数值:x,前文已经定义0.0426236319为x
- 系数列表:一个数组代表系数 c,姑且定义为
$[a_0\ a_1\ a_2\ a_3]$
;; 定义系数列表
(def c [a0 a1 a2 a3])
多项式计算抽象为函数,可以灵活的接受x和多项式系数的值
;; 普通版
(defn eval-canonical [t cs]
(reduce + (map-indexed (fn[i c] (apply * (cons c (repeat i t)))) cs)))
;; horner form版
(defn eval-horner [t c]
(reduce #(+ (* t %1) %2) (rseq c)))
(defn poly-canonical-coefficients [t]
(eval-canonical t c))
(defn poly-horner-coefficients [t]
(eval-horner t c))
[(poly-canonical-coefficients x)
(poly-horner-coefficients x) ]
展开版和多项式函数版性能对比,我们发现函数版耗时要几十倍于先前的版本。不用profiler我们也知道函数调用是有代价的。如果使用type hint会好很多,在此我们不深入分析。 接下来我们研究下怎么用宏来避免这些性能开销。
(rank
1000
:canonical #(poly-canonical x)
:horner #(poly-horner x)
:canonical-coefficients #(poly-canonical-coefficients x)
:horner-coefficients #(poly-horner-coefficients x))
宏在编译期间执行,我们借助它把多项式计算展开。这样,我们既有了函数的遍历,又有手写展开的性能。
(defmacro canonical-expand [terms]
(let [t# (eval terms)]
`(fn [~'t]
(+ ~@(for [i (range (count t#)) :let [c (t# i)]]
`(* ~c ~@(repeat i 't)))))))
(defmacro horner-expand [terms]
(let [t# (rseq (eval terms))]
`(fn [~'t]
~(reduce (fn [b a] `(+ ~a (* ~b ~'t))) t#))))
(defmacro defpoly [n terms]
`(def n (horner-expand ~terms)))
让我们看一下宏展开:
(macroexpand '(poly.core/canonical-expand [1 2 3]))
(macroexpand '(poly.core/horner-expand [1 2 3]))
(def poly-canonical-macro
(poly.core/canonical-expand [a0 a1 a2 a3]))
(def poly-horner-macro
(poly.core/horner-expand [a0 a1 a2 a3]))
[(poly-canonical-macro x)
(poly-horner-macro x)]
来,跑个分:
(rank
1000
:canonical #(poly-canonical x)
:horner #(poly-horner x)
:canonical-coefficients #(poly-canonical-coefficients x)
:horner-coefficients #(poly-horner-coefficients x)
:canonical-macro #(poly-canonical-macro x)
:horner-macro #(poly-horner-macro x))
以上测试都是在四项式进行的,如果多项式的项多起来是什么样子的? 比如说,根据麦克劳林公式
$e^x = 1 + x + \frac{x^2}{2!} + \frac{x^3}{3!} + ... + \frac{x^n}{n!}$
当$x = 1$的时候, 我们就可以计算$e$的值。
我们先生成系数序列:
(def e-coefficients
(->> (iterate (fn [[i f]] [(inc i) (* i f)]) [1.0 1.0])
(map second)
(take-while #(js/isFinite %))
(mapv #(/ 1.0 %))))
;应该可以生成 171 个系数
(count e-coefficients)
;; 解除下面的注释可能会卡住浏览器,有耐心可以等会
;(defonce exp-canonical-macro (poly.core/canonical-expand e-coefficients))
(defonce exp-horner-macro (poly.core/horner-expand e-coefficients))
;;下面都会得到e
;;[(exp-canonical-macro 1.0) (exp-horner-macro 1.0)]
#_(rank
10
:canonical-macro #(exp-canonical-macro 1.0)
:horner-macro #(exp-horner-macro 1.0)
:canonical-eval #(eval-canonical 1.0 e-coefficients)
:horner-eval #(eval-horner 1.0 e-coefficients))