这是加速Clojure程序的技巧集。
本稿中所述的方法并不是全部。由于没有详细描述每个方法,请参考其他文献以获取详细信息。此外,关于Clojure程序的外部(如JVM等)也没有进行描述。
过早的优化是所有邪恶的根源。
-- 唐纳德·克努斯
过度的调优可能会牺牲可维护性和可读性,因此需要注意。
测量。在你测量之前不要为了速度进行调优,即使在那之后,除非代码的某一部分压倒了其余部分。
-- 罗布·派克
在仔细调查瓶颈后,进行适当的性能调优。
Clojure的版本是v1.8,作为测量工具使用criterium v0.4.4。代码中出现的bench
函数是criterium的。bench
会输出各种信息,但为了简化,这里仅记录平均执行时间和标准偏差。
测定代码是totakke/clj-perf-tips公开的。如果想在自己的环境中进行测定,请使用。
(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}}}
设置一下可能会比较好。
(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
(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
在序列处理时,使用 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 毫秒
: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
进行多重分派时,使用协议似乎比多重方法更快。
;; 多重方法
(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
通过记忆化等方式缓存过去的计算结果,可以在空间效率的牺牲下提高时间效率。
;; 具有备忘录
(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
通过并行化来实现高速化。
(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
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
此外,即使是具有相同功能的函数,计算量也可能不同。例如,针对向量的last
和peek
都返回最后一个元素,但peek
的速度更快。
;; 对于向量的last
(bench (dotimes [_ 100000] (last avec)))
;; 平均: 2.197115 秒, 标准差: 84.094077 毫秒 ;; 对于向量的peek
(bench (dotimes [_ 100000] (peek avec)))
;; 平均: 1.761301 毫秒, 标准差: 23.357396 微秒
(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 毫秒
要扁平化嵌套序列,通常使用flatten
,但如果嵌套只有一级,可以使用apply concat
或mapcat
来加速。
(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
。
(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