
2025/12/14 3:56
Fast Sequence Iteration in Common Lisp
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
要約
この記事では、Common Lisp の「シーケンス」がリンクリストまたはベクターのいずれかであり、本当のイテレータプロトコルを欠いているため、開発者は
elt+length+loop や reduce といったアドホックなユーティリティに頼らざるを得ないと説明しています。このギャップを埋めるために著者はマクロ
do-sequence を作成し、任意のシーケンスタイプを標準的なキーワード引数(:start、:end、:key)とオプションの :with-index および :with-raw-elt を使って反復処理します。マクロは etypecase によりリスト、シンプル配列、またはベクタータイプを分岐し、それぞれに効率的な loop フォームを生成します。
使用例として 2 つの関数が示されています:
(max-elt-reduce
を使って実装)とreduce
(新しいマクロを使用)。max-elt-do-seq
次に、カスタムキュー構造で一致するすべての位置を収集するベンチマークとして
とposition-all-reduce
を比較し、類似した速度向上が得られることを示しています。position-all-do‑seq
ベンチマークは AMD 5900X プロセッサと 64 GB DDR4 メモリ上で 6 種類の人気 Lisp 実装(SBCL、CCL、ECL、CLISP、および他の2つ)に対して実行されました。各テストは一貫した計測を確保するために完全なガベージコレクション (
measure) で開始し、コードは (speed 3) (safety 0) (debug 0) の設定でコンパイルされました。
結果は
do-sequence が常に reduce より高速であることを示しており、速度向上率は約 10 % から >100 % に及びます。特に SBCL 以外のシステムで顕著です。著者はマクロが可読性と性能を改善する一方で、コードサイズとコンパイル時間を増加させる可能性があるため、インライン化はデッドコード除去(DCE)が未使用パスを削除しない限り避けるべきだと警告しています。
Lisp 開発者にとって
do-sequence を採用するとシーケンス処理の速度と明確さが向上しますが、プロダクション環境ではマクロのオーバーヘッドを利益と比較検討する必要があります。本文
はじめに & コード
Common Lisp のシーケンスに慣れていない方へ。
「シーケンス」とは、連結リストかベクターのどちらでもあるデータ構造で、
要素検索・比較・削除といった操作を 1 つのインタフェースで扱うことが目的です。
ANSI CL には実際にイテレータプロトコルがないため、シーケンスはその代替手段となります。
リストとベクターを同じコードで走査しつつ
:start・:end・:key といった従来のキーワードをサポートすることは容易ではありません。ANSI CL は次の 2 つの統一インタフェースを提供しています。
(またはelt + length + loop
)do
の裏で動く、シンプルかつ直感的な手法。iteratereduce
キーワード全て(
を含む)を自動的に処理し、実装が速いというメリットがあります。:from-end
ただし
はクロージャを何度も呼び出すオーバーヘッドがあるほか、reduce
を適用する前の「生要素」やインデックスへの直接アクセスができません。:key
アドベント・オブ・コードで最初に書いた単純な
max-elt を reduce 版に書き換える際、繰り返し特殊化を避けるために次のようなマクロを作成しました。
(defmacro do-sequence ((var seq &key (start 0) end key with-index with-raw-elt) &body body) "シーケンス上で通常のキーワード(:START, :END, :KEY)を利用して走査するマクロ。 :WITH-INDEX と :WITH-RAW-ELT は評価されないシンボルを受け取り、インデックスや生要素として使われます。 走査は `(LOOP ... :DO (LOCALLY ,@BODY))` を通じて行われるため、BODY 内では RETURN や宣言が使用できます。" (once-only (seq start end key) (macrolet ((impl (type has-key-p &optional has-end-p) `(let ((ivar (or with-index (gensym "IDX"))) (rvar (cond (with-raw-elt with-raw-elt) (,,(not has-key-p) var) (t (gensym "RAW"))))) `(loop ,@(ecase ,type (list `(:for ,ivar :of-type (integer 0 #.array-dimension-limit) :from ,start ,@(when ,has-end-p `(:below ,end)) :for ,rvar :in (nthcdr ,start ,seq))) (vector `(:for ,ivar :of-type (integer 0 #.array-dimension-limit) :from ,start :below (or ,end (length ,seq)) :for ,rvar := (aref ,seq ,ivar)))) ,@(cond (,has-key-p `(:for ,var := (funcall ,key ,rvar))) (with-raw-elt `(:for ,var := ,rvar))) :do (locally ,@body))))) `(etypecase ,seq (list (cond ((and ,key ,end) ,(impl 'list t t)) (,key ,(impl 'list t nil)) (,end ,(impl 'list nil t)) (t ,(impl 'list nil nil)))) ((simple-array * (*)) (if ,key ,(impl 'vector t) ,(impl 'vector nil))) (vector (if ,key ,(impl 'vector t) ,(impl 'vector nil)))))))``` ANSI CL には従っており、`once-only` は実質的に既定です。 Zlib ライセンスの下でコピー可能です。 --- ## ベンチマーク 以下は `max-elt` を `reduce` と `do-sequence` の両方で実装し、さまざまなシーケンス型(リスト・ベクター・配列)に対して大きいサイズで比較した結果です。 インデックスと生要素が必要だったため、`:with-index` と `:with-raw-elt` を使用しています。 ```lisp (deftype index () `(integer 0 ,array-dimension-limit)) (deftype end () '(or null index)) (deftype key () '(or symbol (function (t) t) null)) (deftype test () '(or symbol (function (t t) t))) (declaim (ftype (function (sequence test &key (:start index) (:end end) (:key key)) (values t (or null index))) max-elt-reduce max-elt-do-seq)) (defun max-elt-reduce (seq pred &key (start 0) end key) (let ((i start) maxi max) (declare (type index i)) (reduce (if key (lambda (kmax el) (incf i) (let ((kel (funcall key el))) (if (or (= i start) (funcall pred kel kmax)) (progn (setf maxi i max el) kel) kmax))) (lambda (kmax el) (incf i) (if (or (= i start) (funcall pred el kmax)) (progn (setf maxi i max el) el) kmax))) seq :start start :end end) (values max maxi))) (defun max-elt-do-seq (seq pred &key (start 0) end key) (let (maxi rmax max) (do-sequence (el seq :start start :end end :key key :with-index i :with-raw-elt r) (when (or (= i start) (funcall pred el max)) (setf maxi i rmax r max el))) (values rmax maxi))) (defconstant +len+ 5000000) (let ((l (make-sequence 'list +len+ :initial-element 0)) (sv (make-sequence 'vector +len+ :initial-element 0)) (fsv (make-array +len+ :element-type 'fixnum :initial-element 0)) (fv (make-array +len+ :element-type 'fixnum :initial-element 0 :adjustable t))) (format t "Test,~A~%" (lisp-implementation-type)) (loop :for (args name) :in `(((,l ,#'>) "LIST") ((,l ,#'> :start 100 :end ,(- +len+ 100) :key ,#'1+) "LIST (fiddly)") ((,sv ,#'>) "SIMPLE-VECTOR") ((,fsv ,#'>) "(SIMPLE-ARRAY FIXNUM)") ((,fv ,#'>) "(VECTOR FIXNUM)")) :do (let ((tref (nth-value 1 (measure (apply #'max-elt-reduce args)))) (tnew (nth-value 1 (measure (apply #'max-elt-do-seq args))))) (format t "~A,~D → ~D (~@D%)~%" name (round (* tref 1000)) (round (* tnew 1000)) (round (* 100 (- (/ tref tnew) 1)))))))``` ### 結果 | 実装 | LIST | LIST (fiddly) | SIMPLE‑VECTOR | SIMPLE‑ARRAY FIXNUM | VECTOR FIXNUM | |------|------|---------------|--------------|---------------------|--------------| | SBCL | 39 → 26 (+50%) | 124 → 64 (+93%) | 979 → 499 (+96%) | 372 → 251 (+48%) | | CCL | 46 → 39 (+18%) | 129 → 77 (+69%) | 1186 → 703 (+69%) | 450 → 408 (+10%) | | ECL | 44 → 32 (+38%) | 140 → 81 (+71%) | 966 → 600 (+61%) | 417 → 314 (+33%) | | CLISP | 51 → 37 (+38%) | 177 → 121 (+46%) | 960 → 635 (+51%) | 422 → 331 (+27%) | --- ## 別のシンプルケース マクロを別コンテキストで検証するため、`position-all` を実装します。 これはシーケンス内にあるオブジェクトの **すべての位置** を収集します。 ```lisp (deftype queue () '(cons cons list)) (defun make-queue () (let ((q (cons nil nil))) (setf (car q) q) q)) (declaim (ftype (function (t queue) queue) push-queue) (inline push-queue)) (defun push-queue (obj queue) (let ((new-tail (list obj))) (setf (cdar queue) new-tail (car queue) new-tail)) queue) (declaim (ftype (function (t sequence &key (:start index) (:end end) (:key key) (:test test)) list) position-all-reduce position-all-do-seq)) (defun position-all-reduce (obj seq &key (start 0) end key (test #'eql)) (let ((i start)) (declare (type index i)) (cdr (reduce (if key (lambda (acc el) (incf i) (when (funcall test (funcall key el) obj) (push-queue i acc)) acc) (lambda (acc el) (incf i) (when (funcall test el obj) (push-queue i acc)) acc)) seq :start start :end end :initial-value (make-queue))))) (defun position-all-do-seq (obj seq &key (start 0) end key (test #'eql)) (let ((acc (make-queue))) (do-sequence (el seq :start start :end end :key key :with-index i) (when (funcall test el obj) (push-queue i acc))) (cdr acc))) (defconstant +len+ 5000000) (let* ((sv (let ((tmp (make-sequence 'vector +len+ :initial-element 0))) (loop :for i :from 0 :below +len+ :by 1000 :do (setf (aref tmp i) 42)) tmp)) (l (coerce sv 'list)) (fsv (make-array +len+ :element-type 'fixnum :initial-contents l)) (fv (make-array +len+ :element-type 'fixnum :initial-contents l :adjustable t))) (format t "Test,~A~%" (lisp-implementation-type)) (loop :for (args name) :in `(((42 ,l) "LIST") ((42 ,l :start 100 :end ,(- +len+ 100) :key ,#'1+) "LIST (fiddly)") ((42 ,sv) "SIMPLE-VECTOR") ((42 ,fsv) "(SIMPLE-ARRAY FIXNUM)") ((42 ,fv) "(VECTOR FIXNUM)")) :do (let ((tref (nth-value 1 (measure (apply #'position-all-reduce args)))) (tnew (nth-value 1 (measure (apply #'position-all-do-seq args))))) (format t "~A,~D → ~D (~@D%)~%" name (round (* tref 1000)) (round (* tnew 1000)) (round (* 100 (- (/ tref tnew) 1)))))))``` ### 結果 | 実装 | LIST | LIST (fiddly) | SIMPLE‑VECTOR | SIMPLE‑ARRAY FIXNUM | VECTOR FIXNUM | |------|------|---------------|--------------|---------------------|--------------| | SBCL | 30 → 11 (+173%) | 58 → 18 (+224%) | 879 → 351 (+150%) | 291 → 147 (+98%) | | CCL | 33 → 21 (+57%) | 63 → 28 (+122%) | 907 → 538 (+69%) | 352 → 301 (+17%) | | ECL | 29 → 20 (+45%) | 70 → 33 (+110%) | 826 → 458 (+80%) | 310 → 220 (+41%) | | CLISP | 40 → 26 (+54%) | 108 → 70 (+54%) | 835 → 468 (+78%) | 321 → 228 (+41%) | --- ## ベンチマークの詳細 * **実行環境** * SBCL 2.5.11, CCL 1.13, ECL 24.5.10, CLISP 2.49.92(バイトコードコンパイラ) * ハードウェア:AMD 5900X、64 GB DDR4 * OS:Gentoo Linux (カーネル 6.12) * GCC 15.2 / glibc 2.41 * **最適化設定** `(optimize (speed 3) (safety 0) (debug 0))` `measure` はタイミングを取る前にフル GC を行います。 --- ## 結論 マクロベースのアプローチは、特に SBCL が `reduce` の最適化を強力に実装している場合でも、**パフォーマンスと可読性**の両面で顕著な向上をもたらしました。 ただし、コードサイズが増えたりコンパイル時間が長くなる可能性がありますので、**インライン化は慎重に行い**、コンパイラの死コード除去機能によって恩恵が得られる場合のみ利用してください。