MeCabバインディングとFFI

common lispからmecabを使う方法のメモ(sbcl依存)。

まずは、多分一番直截的な方法:

;;;;;;;;
;;;; FFI(Foreign Function Interface)用のpackageを使用する
(use-package :sb-alien)

;;;;;;;;
;;;; so読み込みと関数定義
(load-shared-object "/usr/local/lib/libmecab.so")

(define-alien-routine "mecab_new2" (* t) (param c-string))
(define-alien-routine "mecab_sparse_tostr" c-string (mecab (* t)) (text c-string))
(define-alien-routine "mecab_destroy" void (mecab (* t)))

;;;;;;;;
;;;; wrapper
(defvar *mecab*)

(defun mecab-parse (text &optional (*mecab* *mecab*))
  (mecab-sparse-tostr *mecab* text))

(defmacro with-mecab ((&optional (option "")) &body body)
  `(let ((*mecab* (mecab-new2 ,option)))
      (unwind-protect
           (progn ,@body)
         (mecab-destroy *mecab*))))

;;;;;;;;
;;;; 実行例
>(defvar *text* "MeCabはオープンソース形態素解析エンジンです. ")

;; デフォルト
> (with-mecab () (mecab-parse *text*))
"MeCab	名詞,固有名詞,組織,*,*,*,*
は	助詞,係助詞,*,*,*,*,は,ハ,ワ
オープン	名詞,サ変接続,*,*,*,*,オープン,オープン,オープン
ソース	名詞,一般,*,*,*,*,ソース,ソース,ソース
形態素	名詞,一般,*,*,*,*,形態素,ケイタイソ,ケイタイソ
解析	名詞,サ変接続,*,*,*,*,解析,カイセキ,カイセキ
エンジン	名詞,一般,*,*,*,*,エンジン,エンジン,エンジン
です	助動詞,*,*,*,特殊・デス,基本形,です,デス,デス
.	名詞,サ変接続,*,*,*,*,*
EOS
"

;; 分かち書き
> (with-mecab ("-Owakati") (mecab-parse *text*))
"MeCab は オープン ソース 形態素 解析 エンジン です . 
"



次は、mecab-sparse-tostrに文字列(c-string)ではなく、生の(?)バイト列(sbcl依存)を渡す版。(これをわざわざ作成した理由は後述)

;;;; ↓を、上のソースに追加

;;;;;;;;
;;;; utility
;; signed -> unsigned (手抜き)
(defun to-unsigned (b)
  (if (minusp b) (+ #x100 b) b))

;; unsigned -> signed (手抜き)
(defun to-signed (b)
  (if (>= b #x80) (- b #x100) b))

(defun char*->array (char*)
  (do ((i 0 (1+ i))
       (acc '() (cons (to-unsigned (deref char* i)) acc)))
      ((zerop (deref char* i)) ; NULL terminate
       (coerce (nreverse acc) '(vector (unsigned-byte 8))))))

;; 使用後に、free-alienする必要がある
(defun string->char* (str)
  (let* ((bytes (string-to-octets str))
         (len (length bytes))
         (char* (make-alien char (+ 1 len))))
    (dotimes (i len)
      (setf (deref char* i) (to-signed (aref bytes i))))
    (setf (deref char* len) 0) ; NULL
    char*))

;;;;;;;;
;;;; (* char)用のFF定義
(define-alien-routine "mecab_sparse_tostr" (* char) (mecab (* t)) (text (* char)))

;;;;;;;;
;;;; 実行例
>(defvar *char** (string->char* *text*))

>(with-mecab () 
   (octets-to-string (char*->array (merab-parse *char**))))
"MeCab	名詞,固有名詞,組織,*,*,*,*
は	助詞,係助詞,*,*,*,*,は,ハ,ワ
オープン	名詞,サ変接続,*,*,*,*,オープン,オープン,オープン
ソース	名詞,一般,*,*,*,*,ソース,ソース,ソース
形態素	名詞,一般,*,*,*,*,形態素,ケイタイソ,ケイタイソ
解析	名詞,サ変接続,*,*,*,*,解析,カイセキ,カイセキ
エンジン	名詞,一般,*,*,*,*,エンジン,エンジン,エンジン
です	助動詞,*,*,*,特殊・デス,基本形,です,デス,デス
.	名詞,サ変接続,*,*,*,*,*
EOS
"

;; 解放
>(free-alien *char**)

とりあえず、ちゃんと動作するようだ。


で、わざわざ面倒な生のバイト列を使う実装も載せた理由だが、まあ単純にc-stringを渡す方法が遅い(オーバヘッドが大きい)からだ。
メモリ周りの調整や文字列のエンコード・デコードを行っているためか、初めの実装と二番目の実装では、mecab呼び出し速度に1.5倍程度の差が出る。

;;;;;;;;
;;;; [速度を比較]
;;;; 比較のためのテキストとしては、夏目漱石の『こころ』を使わせてもらう(青空文庫より入手し、utf8に変換)

;;;;;;
;;; mecabコマンドを直接使った場合(shell)
> time mecab kokoro > /dev/null
real	0m0.145s
user	0m0.136s
sys	0m0.008s

;;;;;;
;;; c-stringを使った場合
;; ファイル読み込み関数
(defun read-file (path)
  (with-output-to-string (out)
    (with-open-file (in path)
      (let ((io (make-echo-stream in out)))
	(unwind-protect
	    (loop while (read-line io nil nil))
	  (close io))))))

>(define-alien-routine "mecab_sparse_tostr" c-string (mecab (* t)) (text c-string))
>(let ((text (read-file "kokoro")))
   (with-mecab ()
     (time (mecab-parse text)))
   'done)
;Evaluation took:
;  0.255 seconds of real time
;  0.252016 seconds of total run time (0.236015 user, 0.016001 system)
;  98.82% CPU
;  808,257,454 processor cycles
;  14,906,592 bytes consed
DONE

;;;;;;;;
;;; (* char)を使った場合
;; ファイルオープン
(defmacro with-unix-read-open ((fd path) &body body)
  `(let ((,fd (sb-unix:unix-open ,path sb-unix:O_RDONLY 0)))
     (assert ,fd () "'~A' open failed!: ~A" ,path (sb-int:strerror (get-errno)))
     (unwind-protect
         (progn ,@body)
       (sb-unix:unix-close ,fd)))

(defun alien-read-file (path)
  (let ((len (with-open-file (in path) (file-length in))))
    (with-unix-read-open (fd-in path)
      (let* ((buf (make-alien char (1+ len)))
             (read-num (sb-unix:unix-read fd-in buf len)))
          (setf (deref buf read-num) 0) 
          buf))))
          
>(define-alien-routine "mecab_sparse_tostr" (* char) (mecab (* t)) (text (* char)))
>(let ((text (alien-read-file "kokoro")))
   (with-mecab ()
     (time (mecab-parse text)))
   (free-alien text)
   'done)
;Evaluation took:
;  0.174 seconds of real time
;  0.176011 seconds of total run time (0.156010 user, 0.020001 system)
;  101.15% CPU
;  552,530,821 processor cycles
;  4,080 bytes consed 
DONE

二番目の方法の場合でも、mecabコマンドを直接使用する場合(及び、おそらくCから直接mecabライブラリを使う場合)に比べると遅いが、それでもc-stringを素直に使った場合に比べると、大分速度は改善している。


sbclは、Cなどで書かれたライブラリを手軽に呼び出せるのは良い(今回の例では、最低4行で準備が終わる)が、大量のテキストをやりとりする場合のオーバーヘッドが難だ。
FFIに限らずlispは、文字列をバイト列としてではなく、文字オブジェクトの配列*1として表現しているので、外部とのIOの際にエンコード・デコードのオーバーヘッドや文字コードが分からないとファイル(やsocket)の内容をstring型として読み込めない、という問題があるのが面倒だ。(逆にlisp内で処理が完結する場合は楽なのだが)

*1:C言語Rubyの場合は'あ'は、-utf8表現の場合-、227と129と130のバイト列として表現されているが、common lispだとcharacter型の'#\あ'という一つのオブジェクトだ。Javaと似てる?