Clojure高速化技术

内容

这是加速Clojure程序的技巧集。

本稿中所述的方法并不是全部。由于没有详细描述每个方法,请参考其他文献以获取详细信息。此外,关于Clojure程序的外部(如JVM等)也没有进行描述。

过早的优化是所有邪恶的根源。
  -- 唐纳德·克努斯

过度的调优可能会牺牲可维护性和可读性,因此需要注意。

测量。在你测量之前不要为了速度进行调优,即使在那之后,除非代码的某一部分压倒了其余部分。
  -- 罗布·派克

在仔细调查瓶颈后,进行适当的性能调优。

測定方法について

Clojure的版本是v1.8,作为测量工具使用criterium v0.4.4。代码中出现的bench函数是criterium的。bench会输出各种信息,但为了简化,这里仅记录平均执行时间和标准偏差。

测定代码是totakke/clj-perf-tips公开的。如果想在自己的环境中进行测定,请使用。

1. 类型提示

(defn str-len [^String s] (.length s))

通过添加类型提示,可以避免反射,从而加快速度。

;; 型提示
(defn len-a [^String s] (.length s)) (bench (dotimes [_ 100000] (len-a "にゃんぱすー")))
;; 平均: 662.186948 µs, 标准差: 25.343870 µs ;; 型提示无
(defn len-b [s] (.length s)) (bench (dotimes [_ 100000] (len-b "にゃんぱすー")))
;; 平均: 335.416292 ms, 标准差: 10.796219 ms

在进行Java调用的地方可以期待相当大的速度提升。

反射发生的地方,如果将*warn-on-reflection*设置为true,会发出警告。不过,由于也会输出依赖库的警告,可能会导致日志变得难以阅读。在对性能有要求的项目或会影响其他项目的库中,启用警告是个好主意。

'Leiningen使用时,在project.clj中,'

:profiles {:dev {:global-vars {*warn-on-reflection* true}}}

设置一下可能会比较好。

2. 原始提示

(defn calc [^long x] (* (+ x 5) 2))

通过为函数的参数添加原始提示,可以避免装箱,从而加快速度。

不过,原始提示仅适用于函数的参数,最多可以有4个,类型仅为^long^double两种。

;; 有原始提示
(defn a [^long x] (* (+ x 5) 2)) (bench (dotimes [_ 10000000] (a 2)))
;; 平均: 18.855526 ms, 标准差: 600.759563 µs ;; 无原始提示
(defn b [x] (* (+ x 5) 2)) (bench (dotimes [_ 10000000] (b 2)))
;; 平均: 56.192936 ms, 标准差: 1.817249 ms

3. 短暂

(loop [i n, v (transient [])] (if (pos? i) (recur (dec i) (conj! v i)) (persistent! v))))

使用transient可以创建临时的可变数据结构,从而加快速度。

;; transient存在
(defn a [n] (loop [i n, v (transient [])] (if (pos? i) (recur (dec i) (conj! v i)) (persistent! v)))) (bench (dorun (a 1000000)))
;; 平均: 53.700572 ms, 标准差: 1.787463 ms ;; transient不存在
(defn b [n] (loop [i n, v []] (if (pos? i) (recur (dec i) (conj v i)) v))) (bench (dorun (b 1000000)))
;; 平均: 78.853684 ms, 标准差: 1.015361 ms

4. 使用 loop/recur

在序列处理时,使用 loop/recur 可能比使用 reduce 等高阶函数更快。

(bench (loop [i 1, s 0] (if (< i 10000000) (recur (+ i 2) (+ s i)) s)))
;; 平均: 7.564495 毫秒, 标准差: 127.632208 微秒 (bench (->> (range 10000000) (filter odd?) (reduce +)))
;; 平均: 416.546250 毫秒, 标准差: 3.754950 毫秒

5. var的内联化

:const标签可以使var内联化。

;; :const存在
(def ^:const a 10) (bench (dotimes [_ 10000000] (inc a)))
;; 平均: 5.287694 ms, 标准差: 488.191900 µs ;; :const不存在
(def b 10) (bench (dotimes [_ 10000000] (inc b)))
;; 平均: 105.936600 ms, 标准差: 10.700749 ms

6. 多重方法 vs 协议

进行多重分派时,使用协议似乎比多重方法更快。

;; 多重方法
(defmulti sum-a class) (defmethod sum-a clojure.lang.PersistentVector [coll] (reduce + coll)) (defmethod sum-a clojure.lang.PersistentList [coll] (reduce + coll)) (bench (dotimes [_ 100000] (sum-a (vec (range 10)))))
;; 平均: 84.119469 ms, 标准差: 1.229922 ms ;; 协议
(defprotocol Summable (sum-b [this])) (extend-protocol Summable clojure.lang.PersistentVector (sum-b [coll] (reduce + coll)) clojure.lang.PersistentList (sum-b [coll] (reduce + coll))) (bench (dotimes [_ 100000] (sum-b (vec (range 10)))))
;; 平均: 76.328384 ms, 标准差: 1.715700 ms

7. 缓存

通过记忆化等方式缓存过去的计算结果,可以在空间效率的牺牲下提高时间效率。

;; 具有备忘录
(defn a [n] (reduce + (range (inc n)))) (def a (memoize a)) (bench (dotimes [_ 1000000] (a 10)))
;; 平均: 157.453862 ms, 标准差: 4.522523 ms ;; 不具有备忘录
(defn b [n] (reduce + (range (inc n)))) (bench (dotimes [_ 1000000] (b 10)))
;; 平均: 319.929337 ms, 标准差: 11.463727 ms

8. 并列化する

通过并行化来实现高速化。

(defn inc-after [n] (Thread/sleep 10) (inc n)) ;; 并行化有
(bench (dorun (pmap inc-after (range 100))))
;; mean: 50.175887 ms, sd: 936.942738 µs ;; 并行化无
(bench (dorun (map inc-after (range 100))))
;; mean: 1.217942 sec, sd: 16.667079 ms

9. 数据结构的特性を把握する

Clojure中提供了列表、向量、映射等各种数据结构。理解各数据结构的特性是很重要的。

同样的处理函数由于数据结构的不同,性能可能会显著不同。例如,对向量的nth很快,但对列表的nth则很慢。

(def alist (list* (range 1000)))
(def avec (vec (range 1000))) ;; 列表的 nth
(bench (dotimes [_ 100000] (nth alist 999)))
;; mean: 717.114923 ms, sd: 98.263191 ms ;; 向量的 nth
(bench (dotimes [_ 100000] (nth avec 999)))
;; mean: 817.346025 µs, sd: 14.691106 µs

此外,即使是具有相同功能的函数,计算量也可能不同。例如,针对向量的lastpeek都返回最后一个元素,但peek的速度更快。

;; 对于向量的last
(bench (dotimes [_ 100000] (last avec)))
;; 平均: 2.197115 秒, 标准差: 84.094077 毫秒 ;; 对于向量的peek
(bench (dotimes [_ 100000] (peek avec)))
;; 平均: 1.761301 毫秒, 标准差: 23.357396 微秒

10. 使用记录代替地图

(defrecord 点 [x y z])

如果使用的是几乎固定的键的映射,使用记录可能会加快速度。这是因为记录对元素的访问更快。

;; 地图
(def p1 {:x 10, :y 20, :z 30}) (bench (dotimes [_ 10000000] (:y p1)))
;; 平均: 137.594371 ms, 标准差: 10.014844 ms ;; 记录
(defrecord Point [x y z])
(def p2 (->Point 10 20 30)) (bench (dotimes [_ 10000000] (:y p2)))
;; 平均: 86.313934 ms, 标准差: 5.312125 ms

使用Java的字段访问会更快。

user> (bench (dotimes [_ 10000000] (.y ^Point p2)))
;; 平均: 15.905680 毫秒, 标准差: 1.293925 毫秒

11. 考虑平坦化的方法

要扁平化嵌套序列,通常使用flatten,但如果嵌套只有一级,可以使用apply concatmapcat来加速。

(def coll [[1 2 3] [4 5 6]]) ;; 扁平化
(bench (dotimes [_ 10000] (flatten coll))) ;=> (1 2 3 4 5 6)
;; 平均: 6.044534 ms, 标准差: 174.447276 µs ;; 应用 concat
(bench (dotimes [_ 10000] (apply concat coll))) ;=> (1 2 3 4 5 6)
;; 平均: 946.066505 µs, 标准差: 26.963664 µs ;; mapcat
(bench (dotimes [_ 10000] (mapcat identity coll))) ;=> (1 2 3 4 5 6)
;; 平均: 4.557187 ms, 标准差: 53.755322 µs

但是,如果嵌套超过两层,或者第一层包含非序列的元素,则需要使用flatten

12. 使用变换器

(let [xf (comp (filter 偶数?) (map 增加1))] (transduce xf + (range 100))

进行多个序列操作时,可以通过使用在Clojure 1.7中引入的Transducers来加速。这是因为Transducers不会生成中间数据。

;; 无转换器
(defn a [n] (->> (range n) (filter even?) (map inc) (remove #(zero? (mod % 3))) (map #(* % %)) (reduce +))) (bench (dotimes [_ 1000] (a 1000)))
;; 平均: 126.482482 ms, 标准差: 4.383541 ms ;; 有转换器
(defn b [n] (let [xf (comp (filter even?) (map inc) (remove #(zero? (mod % 3))) (map #(* % %)))] (transduce xf + (range n)))) (bench (dotimes [_ 1000] (b 1000)))
;; 平均: 104.169563 ms, 标准差: 7.549028 ms
总结
本文介绍了多种Clojure程序的性能优化技巧,强调在进行优化前应先进行测量,以避免过度优化导致的可维护性和可读性下降。文章提到了一些具体的优化方法,包括使用类型提示来避免反射、使用原始类型提示来避免装箱、利用transient数据结构提高性能、使用loop/recur替代高阶函数以加快序列处理、通过:const标签实现var的内联化,以及在多重分派时优先使用协议而非多重方法。每种方法都附有性能测试结果,展示了优化前后的执行时间差异。总之,合理的性能优化可以显著提升Clojure程序的执行效率,但需谨慎实施。