LoginSignup
20
21

More than 5 years have passed since last update.

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

Posted at

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

20
21
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
20
21