September 24, 2019
By: Kevin

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))

上文中的四项式是写死的,更加通用的方式是需要有个过程接受

  1. 一个数值:x,前文已经定义0.0426236319为x
  2. 系数列表:一个数组代表系数 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))
Tags: clojure clojurescript macro