概要を3行で
- Ruby + nattoで、長い文章の中から特定の品詞だけを取得しようと思った。
- しかし遅かったので、Goで書いたshared objectを経由させた。
- 約60万文字の処理において、10倍の速度が出た
はじめに
Rubyで形態素解析をする場合は、mecabをnatto経由で使うのが定番だと思います。
natto自体はmecabのCバインディングをFFI経由で叩いているはずなので必要十分な速度が出ますが、解析結果を形態素ごとに文字列処理したりすると、当然、Rubyでの処理になってしまうので、決して速いとは言えません。解析する文章量によっては、かなりの処理コストになってしまう可能性があります。
Rubyのみで処理する
ちょっと試してみましょう。青空文庫の家なき子(上)全文の中から、名詞のみを抽出してみます。
具体的には、名詞のみを配列に突っ込むという処理です。ファイルのテキストを一度に読むと超重いので、一行ずつ読み込みます。
読み込むテキストファイル
文字数: 159,397文字
行数: 1926行
Rubyでの処理
require "natto"
words = []
nm = Natto::MeCab.new
File.foreach("ienaki.txt") do |line|
nm.parse(line) do |word|
words << word.surface if word.feature.include?('名詞')
end
end
結果
timeコマンドで測定しました。
time | |
---|---|
real | 0m1.541s |
user | 0m1.448s |
sys | 0m0.083s |
約1.5秒ですね。約16万文字あるのを考えると、そこまで遅いわけでもないです。
Goを経由させる
次にGoで出力したShared Libraryを経由させてみます。以下を参考にしました。
参考2: RubyからGoの関数をつかう → はやい
参考3: Golang で Shared Library を出力する。
Go側の準備
Goのmecabバインディングは色々ありますが、今回はgo-mecabを使いました。
package main
// #include <stdlib.h>
import "C"
import (
"strings"
"github.com/shogo82148/go-mecab"
"unsafe"
)
//export parse
func parse(str *C.char) *C.char {
tagger, err := mecab.New(map[string]string{})
if err != nil { panic(err) }
defer tagger.Destroy()
tagger.Parse("")
gostr := C.GoString(str)
array := make([]string, 0, 0)
scanner := bufio.NewScanner(strings.NewReader(gostr))
// 1行ずつ回す
for scanner.Scan() {
node, err := tagger.ParseToNode(scanner.Text())
if err != nil { panic(err) }
// nodeごとに回す
for ; node != (mecab.Node{}); node = node.Next() {
if strings.Contains(node.Feature(), "名詞") {
array = append(array, node.Surface())
}
}
}
cstr := C.CString(strings.Join(array, ","))
return cstr
}
func main() {
}
先ほどRubyで行ったものとほぼ同じ処理を実行する、parse
関数を定義しました。
ビルドします。
go build -buildmode=c-shared -o mecab.so mecab.go
Rubyでの処理
RubyでShared Libraryを使用するためのGem、ffiを使って、さっき出力したmecab.so
に定義されている関数を呼び出します。
require "ffi"
module Mecab
extend FFI::Library
ffi_lib "mecab.so"
attach_function :parse, [:string], :string
end
open("ienaki.txt") do |f|
Mecab.parse(f.read)
end
なお、Mecab.parse関数の戻り値は、カンマ区切りの文字列になります。
print Mecab.parse("モビルスーツの性能の違いが、戦力の決定的差でないということを教えてやる")
# => モビルスーツ,性能,違い,戦力,決定的,差,こと
結果
Ruby + Go | Ruby only | |
---|---|---|
real | 0m0.246s | 0m1.541s |
user | 0m0.190s | 0m1.448s |
sys | 0m0.056s | 0m0.083s |
約5〜6倍の速度となりました。期待していたほどの速度差は出ませんでしたが、それなりには早くなりました。
考察
試しに、テキスト量を4倍にして同じ処理を行ってみました。
文字数: 637,588文字
行数: 7704行
Ruby + Go | Ruby only | |
---|---|---|
real | 0m0.551s | 0m5.686s |
user | 0m0.493s | 0m5.555s |
sys | 0m0.065s | 0m0.116s |
Rubyはちょうど4倍ほどの時間になりましたが、Goを経由した場合は元の2.5倍ほどしか掛かっていません。速度差は約10倍ほどになりました。
つまり、関数呼び出しや文字列の型変換などといった、形態素解析を行って名詞のみを抜き出すという処理以外の部分に、かなり時間がかかっているかもしれません。
あと、mecab.go
の
cstr := C.CString(strings.Join(array, ","))
の部分なんですけど、このcstrはGoのGCから外れるっぽいです。cstrはparse
関数の戻り値として使っているので、deferでメモリの解放ができません(Go初心者なので、return後に実行させる方法がわかりません・・・)。当然RubyのGCにも入らないと思うので、Ruby側で明示的に解放してあげるのが安全かもしれないです。
展望とか
今回は掲載しませんでしたが、parse
メソッドの並列処理版も作ってみました。並列処理自体は出来たのですが、そもそも自分がGoの文字列処理に慣れていないので、並列処理とかmecab使うとか以前の段階で詰まり気味です(複数行の文字列をに分割するとか)。並列処理でパフォーマンスアップに成功したら、また更新したいと思います。