loopマクロのcollect節でintoを使うと非常に遅い
loopマクロでcollect節と一緒にintoを使うと演算コストが非常に大きくなるという話。
原因を探るためにintoなしとintoありのloopマクロをmacroexpandで展開してpretty-printしてみます。どちらの演算式も返す値は同じになります。
;; intoなし (pp (macroexpand-all '(loop for i from 1 to 10000 collect i))) ;; intoあり (pp (macroexpand-all '(loop for i from 1 to 10000 collect i into x finally return x)))
;; 結果 ;; intoなし (cl-block-wrapper (catch '--cl-block-nil-- (let* ((i 1) (--cl-var-- nil)) (while (<= i 10000) (setq --cl-var-- (cons i --cl-var--)) (setq i (+ i 1))) (nreverse --cl-var--)))) ;; intoあり (cl-block-wrapper (catch '--cl-block-nil-- (let* ((i 1) (x nil)) (while (<= i 10000) (setq x (nconc x (list i))) (setq i (+ i 1))) x)))
intoなしの方は、高速なリスト生成手法としてよく知られたpush/nreverseイディオムが使用されています。それに対して、intoありの方は、nconcでリストの末尾に逐一値を追加しているのが分かります(参照されるタイミングが不明なため、常に追加順を保たないといけない)。Lispのリスト構造に詳しい方ならすぐに気付くと思いますが、nconcは結合されるリストの末尾コンスセルを走査するため、結合されるリストが長くなればなるほど演算コストが増加します。そのため、intoありのcollectは指数関数的に演算コストが増加するのです。
実際、以下のようなベンチマークを取ってみるとその違いが歴然としていることが分かります。
(require 'benchmark) (let ((gc-cons-threshold (* 1024 1024 32))) (garbage-collect) (message "push: %s" (benchmark-run-compiled 1 (funcall (lambda () (loop for i from 1 to 10000 collect i))))) (garbage-collect) (message "nconc: %s" (benchmark-run-compiled 1 (funcall (lambda () (loop for i from 1 to 10000 collect i into l))))))
結果 push: (0.001274 0 0.0) nconc: (0.39666999999999997 0 0.0)
push/nreverse版では1msで完了するのに対し、nconc版では396msもかかっています。
この結果をふまえて、intoありcollectは利用しないよう促すつもりでしたが、ふと自作パッケージをgrepしてみると見事にauto-complete.elで利用していました。これがJoel Spolskyの言う「抽象化の漏れ」なのかと、自分を戒めながらヒシヒシと感じたのでした。
参考
- info cl :: Loop Facility
- Joel on Software