Posted at

Ruby + mecabが遅いのでGoを経由する

More than 1 year has passed since last update.


概要を3行で


  • Ruby + nattoで、長い文章の中から特定の品詞だけを取得しようと思った。

  • しかし遅かったので、Goで書いたshared objectを経由させた。

  • 約60万文字の処理において、10倍の速度が出た


はじめに

Rubyで形態素解析をする場合は、mecabをnatto経由で使うのが定番だと思います。

参考: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を使いました。


macab.go

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使うとか以前の段階で詰まり気味です(複数行の文字列をに分割するとか)。並列処理でパフォーマンスアップに成功したら、また更新したいと思います。