maphash-to-list
common lispではハッシュが使い難い(と思う)。
その理由の一つは、リストのようにマッピング関数がない*1せいだと思うので、そのための関数を定義。
ユーティリティ関数っぽくいろいろ装飾。
※ 宣言はsbcl(1.0.37)用に特化
(declaim (inline maphash-to-list)) ; sbclのmaphash関数にはinline宣言されているようなので、それに合わせる (defun maphash-to-list (function-designator hash-table &aux acc) "For each entry in HASH-TABLE, call the designated two-argument function on the key and value of the entry. Return list of the function return values." ;; 引数の型チェック等を行って欲しいため、safetyやdebugをここでは0にしない ;; ;; (/= safety 0)なら、関数呼び出し時に型チェックが行われる ;; - http://www.sbcl.org/manual/#Declarations-as-Assertions ;; ;; (>= debug 1)なら、関数の引数情報が保持される => (describe ...)の結果が親切 ;; - http://www.sbcl.org/manual/#Debugger-Policy-Control (declare (optimize (safety 1) (debug 1) (speed 3)) (hash-table hash-table) ((or symbol function) function-designator)) (locally ;; この時点では引数の型が正しいことが確定しているので、debug及びsafetyを0に設定 (declare (optimize (debug 0) (safety 0))) (let ((fn (if (typep function-designator 'function) function-designator (symbol-function function-designator)))) (maphash (lambda (k v) (push (funcall fn k v) acc)) hash-table)) acc)) ;;;;;;; ;;; 例 > (let ((hash (make-hash-table))) (setf (gethash :key hash) :value (gethash 1 hash) 2 (gethash "a" hash) "b") ;; キーだけを集める (maphash-to-list (lambda (k v) k) hash)) --> ("a" 1 :KEY)
比較のために、型チェックを全く行わない版も定義。
(declaim (inline maphash-to-list-fast)) (defun maphash-to-list-fast (function-designator hash-table &aux acc) (declare (optimize (safety 0) (debug 0) (speed 3)) (hash-table hash-table) (function function-designator)) (maphash (lambda (k v) (push (funcall function-designator k v) acc)) hash-table) acc)
比較。
;;;;;;;;;;;;;;;;;; ;;; 型チェック確認 > (maphash-to-list 1 1) debugger invoked on a TYPE-ERROR in thread #<THREAD "initial thread" RUNNING {AA087C1}>: The value 1 is not of type (OR SYMBOL FUNCTION). ; 型が違う > (maphash-to-list-fast 1 1) CORRUPTION WARNING in SBCL pid 9089(tid 3085166256): Memory fault at 3b (pc=0xb61914a, sp=0xb7907b88) The integrity of this image is possibly compromised. Continuing with fingers crossed. debugger invoked on a SB-SYS:MEMORY-FAULT-ERROR in thread #<THREAD "initial thread" RUNNING {AA087C1}>: Unhandled memory fault at #x3B. ; Unhandled memory? ;;;;;;;;;;;;; ;;;; 処理速度 ;; 要素数1000のハッシュを作成 > (defparameter *hash* (let ((hash (make-hash-table))) (loop FOR i FROM 0 BELOW 1000 DO (setf (gethash i hash) (random 10000000))) hash)) --> *HASH* ;; maphash関数の場合 > (time (dotimes (i 100000 'done) (declare (optimize (speed 3) (debug 0) (safety 0))) (let (acc) (maphash (lambda (k v) (declare (fixnum k v)) (push (+ k v) acc)) *hash*)))) Evaluation took: 0.845 seconds of real time 0.844052 seconds of total run time (0.844052 user, 0.000000 system) [ Run times consist of 0.060 seconds GC time, and 0.785 seconds non-GC time. ] 99.88% CPU 2,680,836,701 processor cycles 800,000,936 bytes consed --> DONE ;; maphash-to-list関数の場合 > (time (dotimes (i 100000 'done) (declare (optimize (speed 3) (debug 0) (safety 0))) (maphash-to-list (lambda (k v) (declare (fixnum k v)) (+ k v)) *hash*))) Evaluation took: 0.849 seconds of real time 0.848054 seconds of total run time (0.844053 user, 0.004001 system) [ Run times consist of 0.072 seconds GC time, and 0.777 seconds non-GC time. ] 99.88% CPU 2,693,672,940 processor cycles 800,002,736 bytes consed --> DONE ;; maphash-to-list-fast関数の場合 > (time (dotimes (i 100000 'done) (declare (optimize (speed 3) (debug 0) (safety 0))) (maphash-to-list-fast (lambda (k v) (declare (fixnum k v)) (+ k v)) *hash*))) Evaluation took: 0.848 seconds of real time 0.844052 seconds of total run time (0.836052 user, 0.008000 system) [ Run times consist of 0.064 seconds GC time, and 0.781 seconds non-GC time. ] 99.53% CPU 2,687,459,342 processor cycles 800,005,176 bytes consed --> DONE
どれもほとんど速度は変わらない。
今回のような関数の場合は、入り口部分で型チェックをしたからといってそこがボトルネックとなることもないので(inline化されているならなおさら)、安全性を高め文書化するために、適切な宣言を行った方が良い。
結構サボリがちだけど、今後ユーティリティ関数やライブラリを書く時には気をつけないと...。
*1:loopマクロを使えば出来ないことはないけど、これはこれで使い難いように思う。ハッシュのキーと値の両方を使いたい時は特に。
ftype型宣言(sbcl) : 戻り値の型指定
前回と重複するところがあるが、ftype型宣言の有用な使い方について書いておく。
関数の戻り値の型指定
ftype型宣言を使うと、関数の戻り値の型を指定することができる。
これが何故有用かと云うと、sbclは関数の戻り値の型が指定されている場合、それに基づいて型推論を行ってくれるので、(前回の例でも挙げたように)呼び出し側での型宣言が不要となる。
以下、例。
まずは、ftype型宣言がない場合。
;; バイナリファイル読み込み関数 ;; 戻り値の型指定無し (defun read-binary-file (path) (with-open-file (in path :element-type '(unsigned-byte 8)) (let ((as (make-array (file-length in) :element-type '(unsigned-byte 8)))) (read-sequence as in) as))) ;; read-binary-fileの呼び出し側 (defun fn (path) (declare (optimize (speed 3)) (sb-ext:unmuffle-conditions sb-ext:compiler-note)) (loop FOR c ACROSS (read-binary-file path) COLLECT c)) ;;;; ;; ループ対象の配列の型(ランク)が不明なため、最適化が行えないと云われる ; ; in: LAMBDA NIL ; (LOOP FOR C ACROSS (READ-BINARY-FIL PATH) ; COLLECT C) ; --> BLOCK LET SB-LOOP::WITH-LOOP-LIST-COLLECTION-HEAD LET* SB-LOOP::LOOP-BODY ; --> TAGBODY SB-LOOP::LOOP-REALLY-DESETQ SETQ THE AREF ; ==> ; (SB-KERNEL:HAIRY-DATA-VECTOR-REF/CHECK-BOUNDS ARRAY SB-INT:INDEX) ; ; note: unable to ; optimize ; because: ; Upgraded element type of array is not known at compile time. ; ; compilation unit finished ; printed 1 note --> FN ;; 型を明示する (defun fn (path) (declare (optimize (speed 3)) (sb-ext:unmuffle-conditions sb-ext:compiler-note)) (loop FOR c ACROSS (the (simple-array (unsigned-byte 8)) (read-binary-file path)) COLLECT c)) --> FN ; 警告はでない
次が、ftype型宣言をつけた場合。
;; 分かりやすさのために、型の別名を定義する (deftype simple-octets () '(simple-array (unsigned-byte 8))) ;; バイナリファイル読み込み関数 ;; simple-octets型の値を返すことを宣言する (declaim (ftype (function (t) simple-octets) read-binary-file)) ; XXX: 関数の引数の型は、本当は(or stream string pathname)とかが適切 (defun read-binary-file (path) (with-open-file (in path :element-type '(unsigned-byte 8)) (let ((as (make-array (file-length in) :element-type '(unsigned-byte 8)))) (read-sequence as in) as))) ;; read-binary-fileの呼び出し側 (defun fn (path) (declare (optimize (speed 3)) (sb-ext:unmuffle-conditions sb-ext:compiler-note)) (loop FOR c ACROSS (read-binary-fil path) COLLECT c)) --> FN ; 呼び出し側で、型を明示しなくても警告はでない
また、ftypeを使って関数の型を宣言しておいた場合、呼び出し側の関数で特にoptimize宣言などを行なっていない場合でも、コンパイラが勝手に型を推論して最適化を行ってくれる。
※ ただし、ftypeを使わなくても、コンパイラが(関数の定義から)戻り値の型を推論してくれる場合が結構ある*1。
;; まだread-binary-fileの型宣言は行っていない > (defun fn (path) (loop FOR c ACROSS (read-binary-fil path) COLLECT c)) --> FN ; (optimize ...)及び(unmuffle ...)宣言をつけていないので、警告はでない。最適化も行わない。 ;; bashコマンドの読み込み > (time (progn (fn "/bin/bash") 'done)) Evaluation took: 0.023 seconds of real time ; 0.023s 0.024002 seconds of total run time (0.024002 user, 0.000000 system) 104.35% CPU 63,412,396 processor cycles 6,321,280 bytes consed --> DONE ;; ;; 型宣言を付与 > (declaim (ftype (function (t) simple-octets) read-binary-file)) ;; 中身は変えずに再定義 > (defun fn (path) (loop FOR c ACROSS (read-binary-fil path) COLLECT c)) --> FN ;; bashコマンドの読み込み > (time (progn (fn "/bin/bash") 'done)) Evaluation took: 0.015 seconds of real time ; 0.015s -> 1.5倍くらい速くなる 0.016001 seconds of total run time (0.016001 user, 0.000000 system) 106.67% CPU 41,930,544 processor cycles 6,321,528 bytes consed --> DONE
このため、ユーティリティ関数などで戻り値の型があらかじめ決まっている場合は、ftypeを使って型を宣言しておくのが良いのではないかと思う*2 *3。
そうすれば、使用側は特に意識せずとも、型情報がついた関数を呼び出すだけで可能な最適化をコンパイラが行ってくれるし、明示的な最適化を行いたい場合も、自分で書く宣言の量を減らすことができる。
ftype型宣言(sbcl)
ftype型宣言。
あまり使われているのを見ない気がするが、これを使うと「型宣言なしでは遅いけど、宣言をつけるとコードが汚くなる」というような問題を解決できる時があるので、少し書いておく。
以下はsbcl(1.0.34)での挙動。
準備
;; 処理速度を最優先 (declaim (optimize (speed 3) (debug 0) (safety 0) (compilation-speed 0))) ;; unsigned int型 (deftype uint (max) `(integer 0 ,max)) ;; 計時関数 (defun test () (time (dotimes (i 100000) (dotimes (j 10) (dotimes (k 10) (fn2 j k)))))) ; fn2関数は後で定義する
型宣言なし
型宣言なしの関数定義。遅い。
> (defun fn (a b) (* a b)) --> FN > (defun fn2 (a b) (+ (fn a b) (fn b a))) --> FN2 > (test) Evaluation took: 0.165 seconds of real time 0.164010 seconds of total run time (0.164010 user, 0.000000 system) 99.39% CPU 523,636,114 processor cycles 0 bytes consed --> NIL
型宣言あり
型宣言あり。速いけど、関数定義が(宣言なしのものに比べ)汚い。
> (defun fn (a b) (declare ((uint 10) a b)) (* a b)) --> FN > (defun fn2 (a b) (+ (the (uint 100) (fn a b)) (the (uint 100) (fn b a)))) --> FN2 > (test) Evaluation took: 0.093 seconds of real time 0.096006 seconds of total run time (0.096006 user, 0.000000 system) 103.23% CPU 295,587,465 processor cycles 0 bytes consed --> NIL
型宣言あり(ftype版)
ftype版。関数定義は型宣言なしのものと変わらない。
;; fn関数の型を宣言 (declaim (ftype (function ((uint 10) (uint 10)) (uint 100)) fn)) > (defun fn (a b) (* a b)) --> FN > (defun fn2 (a b) (+ (fn a b) (fn b a))) ; 上の宣言でfn関数が(uint 100)を返すことが分かっているので、(the ...)は不要 --> FN2 > (test) Evaluation took: 0.094 seconds of real time 0.096006 seconds of total run time (0.096006 user, 0.000000 system) 102.13% CPU 297,934,421 processor cycles 0 bytes consed --> NIL
上のような簡潔な関数だと、どちらでもあまり変わらないような気もするが、少し複雑な関数定義の場合は、特に(the ...)の有る無しで、結構見やすさが変わってくるように思う。
たまに便利。
ただ、上の例でもそうだが、(declaim (ftype ...))を使って関数定義の外で宣言をした場合、定義内で宣言するのに比べて、生成されるコードが若干だが非効率になる傾向があるようなので、(最適化が必要なケースでは)結局あまり使えないかもしれない...。
C++とcommon lispの実行速度比較(素数判定)
今日たまたま見つけた『(Sather を試そう) 1. Sather vs C++: 実行速度の比較』*1というページに触発され、lisp(sbcl-1.0.28)でも同様の比較を行ってみた。
C++のソースコードは、上記ページのものと同様だが、g++のオプションには'-O3'を渡している。
実行結果は以下の通り。
> time ./prime_cpp 10000000 > /dev/null real 0m1.032s user 0m0.992s sys 0m0.004s
対するlispのコード。
※ 変数名などは、自分の好みに合わせて変えているが、アルゴリズム的にはC++のものと同様(のはず)。
;;;; 宣言などのため、C++のソースより長くなってしまっている (declaim (optimize (debug 0) (safety 0) (speed 3) (compilation-speed 0)) (inline primep)) (defun primep (n primes len) ;; inline化されるので、ここの宣言は(今回のケースでは)無くても大丈夫 (loop FOR i FROM 0 BELOW len FOR p fixnum = (svref primes i) DO (when (> (the fixnum (* p p)) n) (return t)) (when (zerop (mod n p)) (return nil)))) (defun nprime (limit) (format t "#Limit, N of primes~%0 0~%") (let ((primes (make-array (truncate (/ limit 2)))) (len 0) ; primesの長さは、プログラムで管理する (interval (- 500 2))) (declare (fixnum limit len interval) (simple-vector primes)) (macrolet ((post-incf (n) `(prog1 ,n (incf ,n))) (push-back (elem) `(setf (aref primes (post-incf len)) ,elem))) (push-back 2) (push-back 3) (loop FOR n FROM 5 TO limit BY 2 DO (when (primep n primes len) (push-back n)) (when (zerop (decf interval)) (format t "~D ~D~%" n len) (setf interval 500))))))
実行結果。
>(let ((*standard-output* (make-broadcast-stream))) (time (nprime 10000000))) Evaluation took: 1.152 seconds of real time 1.156072 seconds of total run time (1.156072 user, 0.000000 system) 100.35% CPU 3,655,271,262 processor cycles 20,000,008 bytes consed
なかなか、悪くない結果だ。
lisp(sbcl)版は、C++版の1.15倍程度に収まっている*2。
おまけ
宣言を外したり、primesの実装を変更した場合の処理速度の違いを以下にまとめる。
番号 | primes実装/宣言の有無 | 実行時間(秒) |
0 | simple-vector/宣言有(上の実装) | 1.152 |
1 | simple-vector/宣言無 | 6.632 |
2 | fill-pointer-vector/宣言有 | 9.576 |
3 | fill-pointer-vector/宣言無 | 8.936 |
4 | fill-pointer-vector/primepのみ宣言無*3 | 12.943 |
5 | fill-pointer-adjustable-vector/宣言有*4 | 9.605 |
6 | list/宣言有 | 1.167 |
7 | list/宣言無 | 5.499 |
多分、上記プログラムのボトルネックの一つは、primep関数内でのsequenceの各要素のイテレートなのだろうが、そこでのlistに対するcarとcdrが速く、fill-pointer-vectorに対するarefが(相対的に)遅いために、この様な結果になったのだと思う。
また、fill-pointer-vectorでは、何故か最適化宣言が有る場合の方が、無い場合よりも遅くなっている。
例えば、上の表の2番(宣言有)と3番(宣言無)の実行時間はあまり変わらないが、これは2番の場合、宣言によってfill-pointer-vector周りの処理で遅くなってprimep関数内での数値計算が速くなっているからだろう(両者が相殺)。
そのため、4番のようにprimep関数内の宣言だけを外した場合は、明らかに実行時間が延びている。
あと、adjustableは付けても付けなくても、それほど大きな影響がない感じだ。そもそも、今回は挿入回数が少なかったので、そのせいもあるだろう。
おまけ2
listでの実装が予想外に速かったので、このソースコードも載せておく。
(declaim (optimize (debug 0) (safety 0) (speed 3) (compilation-speed 0)) (inline primep)) ;; listによるqueue実装(必要な関数のみ) (declaim (inline make-que push-que que-list)) (defun make-que () (let ((q (list nil))) (setf (car q) q))) (defun push-que (x q) (setf (car q) (setf (cdar q) (list x)))) (defun que-list (q) (cdr q)) (defun primep (n primes) (dolist (p (que-list primes)) (declare (fixnum p)) (when (> (the fixnum (* p p)) n) (return t)) (when (zerop (mod n p)) (return nil)))) (defun nprime (limit) (format t "#Limit, N of primes~%0 0~%") (let ((primes (make-que)) (len 2) ;; listのlengthはコストが高いので、長さは別に管理する (interval (- 500 2))) (declare (fixnum limit interval len)) (push-que 2 primes) (push-que 3 primes) (loop FOR n FROM 5 TO limit BY 2 DO (when (primep n primes) (incf len) (push-que n primes)) (when (zerop (decf interval)) (format t "~D ~D~%" n len) (setf interval 500)))))
コンパイラノート表示
最近のバージョンのSBCL*1では、処理速度を最優先にする宣言を行って関数をコンパイルした場合でも、デフォルトではコンパイラノート*2が表示されないことがある*3。
そのため、最適化を確実に行いたい場合は、コンパイラノートを出力するように明示的に宣言する必要がある。
(declare (optimize (debug 0) (safety 0) (speed 3) (compilation-speed 0)) ;; ↓これが必要 (sb-ext:unmuffle-conditions sb-ext:compiler-note))
また、これはどうやらdeclaimで宣言した場合は無効で、declareを使う必要があるようだ。
型分岐最適化
sbcl(1.0.28)は、適切に型宣言を行っておけば、冗長な型判定を実行コードから除外してくれるようだ。
例えば次のようなマクロを定義する。
(defmacro get-type (obj) `(typecase ,obj (list :list) (fixnum :fixnum) (vector :vector) (t :others))) ; ↓のような定義でも可 ;(defmacro get-type (obj) ; `(cond ((typep ,obj 'list) :list) ; ((typep ,obj 'fixnum) :fixnum) ; ((typep ,obj 'vector) :vector) ; (t :others)))
これは単純にobjに対応する型名を返すだけものだが、これをdisassembleすると次のような結果となる。
(disassemble (lambda (obj) (declare (optimize (safety 0))) ; disassembleの出力を見やすくするために(safety 0)を宣言 (get-type obj))) ;disassembly for (LAMBDA (OBJ)) ; 0B197ECC: 8BC1 MOV EAX, ECX ; no-arg-parsing entry point ; ECE: 2407 AND AL, 7 ; ED0: 3C03 CMP AL, 3 ; ED2: 750B JNE L1 ; ED4: 8B15987E190B MOV EDX, [#xB197E98] ; :LIST ; EDA: L0: 8BE5 MOV ESP, EBP ; ... 省略 ... ; EFB: 3CF6 CMP AL, 246 ; EFD: 7608 JBE L3 ; EFF: L2: 8B159C7E190B MOV EDX, [#xB197E9C] ; :OTHERS ; F05: EBD3 JMP L0 ; F07: L3: 8B15A07E190B MOV EDX, [#xB197EA0] ; :VECTOR ; F0D: EBCB JMP L0 ; F0F: L4: 8B15A47E190B MOV EDX, [#xB197EA4] ; :FIXNUM ; F15: EBC3 JMP L0
出力コード(のコメント)の中に:LISTや:VECTORなどが含まれていることが見てとれる。
「objの型を検査して、分岐し、結果(keyword)を返す」というようなことをやっているのだろう。
ほぼマクロの定義通りのコードといった感じだ。
今度は、同じものに型宣言を加えて、再度disassembleを行う。
(disassemble (lambda (obj) (declare (optimize (safety 0)) (list obj)) ; objはlist型 (get-type obj))) ; disassembly for (LAMBDA (OBJ)) ; 0B1C9562: 8B1538951C0B MOV EDX, [#xB1C9538] ; :LIST ; no-arg-parsing entry point ; 8: 8BE5 MOV ESP, EBP ; A: F8 CLC ; B: 5D POP EBP ; C: C3 RET
型宣言(この場合はlist型)を加えると、出力させるコードは明らかに短くなっており、宣言された型以外に対応する節が完全に除外されていることが分かる。
ちゃんと型宣言をみて、最適化が行われているようだ。
この型宣言による最適化があると、ある種の汎用的な関数が(気分的に)書きやすくなるので嬉しい。
例えば、次のコード。
;; 標準のeltと似た動作をする関数をinline宣言付きで定義 (declaim (inline %elt)) (defun %elt (object index) (typecase object (list (nth index object)) (string (char object index)) (array (aref object index)))) ;;;;;;;; ;;;; 型宣言付きで%eltを使う場合と、charを直接使う場合を比較 (disassemble (lambda (x i) (declare (optimize (safety 0)) (string x)) (%elt x i))) (disassemble (lambda (x i) (declare (optimize (safety 0))) (char x i))) ;; どちらも同じ結果(↓)を出力する ; disassembly for (LAMBDA (X I)) ; 0B1E7077: 8B7DFC MOV EDI, [EBP-4] ; no-arg-parsing entry point ; 7A: 8BD6 MOV EDX, ESI ; 7C: 8B0548701E0B MOV EAX, [#xB1E7048] ; #<FDEFINITION object for SB-KERNEL:HAIRY-DATA-VECTOR-REF> ; 82: B908000000 MOV ECX, 8 ; 87: FF7504 PUSH DWORD PTR [EBP+4] ; 8A: FF6005 JMP DWORD PTR [EAX+5] NIL
%eltは複数の型に対応する汎用的なアクセサだが、型宣言を付与することで、より特殊な関数を直接呼び出すのと同じ効果が得られている。※ applyを適用した場合は異なる?
そのため、型分岐のオーバヘッドを嫌って、わざわざ個別に(elt-char、elt-vector、elt-xxx)関数を定義し使用する必要性が(完全にではないにしても、ある程度は)なくなるように思う。
残念なのは、この最適化は処理系(この場合は、sbcl)依存だということだ。
試しにclispで同様のコードを実行してみたところ、上記最適化は行われていなかった。※ ただし、僕はclispをほとんど使わないので分からないが、何かを上手くやれば最適化されるかもしれない