sbclで文字列を効率的に扱う場合の型

あまり話題に関連性がないが、一応前々回の続き。


sbcl(1.0.37)での文字列関連の型の階層は以下のようになっている*1


僕は最近までsimple-string型*2が文字列関連型の最下層だと思い込んでいたので、文字列を扱う部分のプログラムを高速化したい場合、(declare (simple-string ...))などを宣言していた。
しかし、上の図を見るとsimple-string型は、simple-base-string型とsimple-character-string型*3の基底型となっている。
実際、変数の型が事前に明らかな場合は、simple-string型の二つの副型を指定した方がsimple-string型を指定するより高速である。

;;;;;;;;;;
;;;; 準備
;;; 型準備
; simple-character-stringは、クラス名として存在しても型名ではないので、等価なものを定義しておく
(deftype simple-character-string () '(simple-array character *))  

;;; データ準備
;; 一万要素のランダムな文字列を作成する
;; ※base-string用のサンプルデータと合わせるために、個々の文字の値の上限が#xFFに収まるようにする
(defvar *sample-string* 
  (coerce (loop REPEAT 10000 COLLECT (code-char (random #x80))) 'string))
;; *sample-string*をbase-stringに変換する
(defvar *sample-base-string*
  (coerce *sample-string* 'simple-base-string))

(type-of *sample-string*)
--> (SIMPLE-ARRAY CHARACTER (10000))
(type-of *sample-base-string*)
--> (SIMPLE-BASE-STRING 10000)
(subtypep * **)
--> NIL  ; *sample-string*の型と*sample-base-string*の型は独立している
    T

;;; 関数準備
;; 文字列の各文字の値の合計値を求める関数を定義するマクロ
;; string-type引数で、処理対象の文字列の型を指定できる
(defmacro def-charcode-sum (fn-name string-type)
  `(defun ,fn-name (string &aux (sum 0))
     (declare (optimize (speed 3) (safety 0))
              (,string-type string)
              (fixnum sum))
     (loop FOR char ACROSS string 
           FOR code = (char-code char)
           DO (incf sum code))
     sum))

;; 関数定義
(def-charcode-sum charcode-sum1 string)
(def-charcode-sum charcode-sum2 simple-string)
(def-charcode-sum charcode-sum3 simple-character-string)
(def-charcode-sum charcode-sum4 simple-base-string)


;;;;;;;;;
;;;; 実行
(charcode-sum1 *sample-string*)
--> 637886

(charcode-sum1 *sample-base-string*)
--> 637886

(charcode-sum3 *sample-base-string*)
--> -5604664  ; simple-character-string型用の関数をsimple-base-string型の引数に適用すると結果が不正になる

(= (charcode-sum1 *sample-string*) (charcode-sum1 *sample-base-string*)
   (charcode-sum2 *sample-string*) (charcode-sum2 *sample-base-string*)
   (charcode-sum3 *sample-string*) (charcode-sum4 *sample-base-string*))
--> T

;;; 型宣言ごとの処理時間
(time  ; string型と宣言されている関数をsimple-character-stringに適用した場合
  (dotimes (i 10000 'done) 
    (charcode-sum1 *sample-string*)))
Evaluation took:
  0.594 seconds of real time  ; 0.594秒
  0.592037 seconds of total run time (0.592037 user, 0.000000 system)
  99.66% CPU
  1,883,232,728 processor cycles
  0 bytes consed
--> DONE

(time  ; simple-string型と宣言されている関数をsimple-character-stringに適用した場合
  (dotimes (i 10000 'done) 
    (charcode-sum2 *sample-string*)))
Evaluation took:
  0.284 seconds of real time  ; 0.284秒
  0.284018 seconds of total run time (0.284018 user, 0.000000 system)
  100.00% CPU
  901,336,060 processor cycles
  0 bytes consed
--> DONE

(time  ; simple-character-string型と宣言されている関数をsimple-character-stringに適用した場合
  (dotimes (i 10000 'done) 
    (charcode-sum3 *sample-string*)))
Evaluation took:
  0.096 seconds of real time  ; 0.096秒
  0.096006 seconds of total run time (0.096006 user, 0.000000 system)
  100.00% CPU
  305,298,289 processor cycles
  0 bytes consed
--> DONE

(time  ; string型と宣言されている関数をsimple-base-stringに適用した場合
  (dotimes (i 10000 'done) 
    (charcode-sum1 *sample-base-string*)))
Evaluation took:
  0.597 seconds of real time  ; 0.597秒
  0.596038 seconds of total run time (0.596038 user, 0.000000 system)
  99.83% CPU
  1,894,275,594 processor cycles
  0 bytes consed
--> DONE

(time  ; simple-string型と宣言されている関数をsimple-base-stringに適用した場合
  (dotimes (i 10000 'done) 
    (charcode-sum2 *sample-base-string*)))
Evaluation took:
  0.208 seconds of real time  ; 0.208秒
  0.204013 seconds of total run time (0.204013 user, 0.000000 system)
  98.08% CPU
  657,645,081 processor cycles
  0 bytes consed
--> DONE

(time  ; simple-base-string型と宣言されている関数をsimple-base-stringに適用した場合
  (dotimes (i 10000 'done) 
    (charcode-sum4 *sample-base-string*)))
Evaluation took:
  0.125 seconds of real time  ; 0.125秒
  0.124008 seconds of total run time (0.124008 user, 0.000000 system)
  99.20% CPU
  393,795,273 processor cycles
  0 bytes consed
--> DONE

;;; おまけ
;;; simpleではない文字列に対する処理時間
;; displaced
(let ((str (make-array (length *sample-string*) 
                       :element-type 'character 
                       :displaced-to *sample-string*
                       :displaced-index-offset 0)))
  (time  
   (dotimes (i 10000 'done) 
     (charcode-sum1 str))))  ; simpleではないので、適用可能なのはcharcode-sum1のみ
(AND (VECTOR CHARACTER 10000) (NOT SIMPLE-ARRAY))  ; 型
Evaluation took:
  1.644 seconds of real time  ; 1.644秒
  1.644102 seconds of total run time (1.640102 user, 0.004000 system)
  100.00% CPU
  5,215,281,747 processor cycles
  0 bytes consed
--> DONE

;; fill-pointer
(let ((str (make-array (length *sample-string*) 
                       :element-type 'character
                       :fill-pointer (length *sample-string*)
                       :initial-contents *sample-string*)))
  (print (type-of str))
  (time  
   (dotimes (i 10000 'done) 
     (charcode-sum1 str))))
(AND (VECTOR CHARACTER 10000) (NOT SIMPLE-ARRAY)) ; 型
Evaluation took:
  0.871 seconds of real time  ; 0.871秒
  0.872054 seconds of total run time (0.872054 user, 0.000000 system)
  100.11% CPU
  2,762,532,161 processor cycles
  0 bytes consed
--> DONE

このため、最近は処理速度を重視する場合には、(simple-array character *)と宣言するようにしている。
ただその場合は、型が異なると処理結果が不正となってしまう*4ので、対象となる値の型が確実に(simple-array character *)となるように気をつけなければならない*5
igoやcreoleではそのために「文字列引数の型をチェックして(simple-array character *)ならそのまま、それ以外なら(simple-array character *)型になるように変換処理を施す」というようなことを行っている。


処理速度を考慮して文字列を扱う場合は、こういった型周りのことも考える必要がある。
しかし前々回も書いたように、こういったことを文字列処理を行う関数・ライブラリを各たびに意識しなければならないのは結構面倒。
なので、前々回に言及したJavaのStringライクなcommon lispの型(実際には構造体とその操作関数をまとめたパッケージ)で、文字列型に関する処理もまとめて行ってしまおう、というところで次に続く。
前置きが随分長くなってしまったが、次回が実装編。

*1:クラス間の階層関係はsb-mop:class-direct-subclasses及びsb-mop:class-direct-superclassesを用いて取得

*2:上の図に当てはめるなら本当はクラスと呼ぶのが正しいと思うが、今回の場合あまり厳密に区別する意味もないので"型"で統一することにする

*3: (simple-array character *)

*4:最悪のケースでは、プログラムが異常終了する

*5:全て均一に(simple-array character *)と宣言するのではなく「typecaseなどで分岐して各型ごとに処理を分ける」という方法もある。ただし、それはそれでいろいろと面倒なので、今のところ採用したことはない。もしかしたらそっちの方が良かったりして...