唯物是真 @Scaled_Wurm

プログラミング(主にPython2.7)とか機械学習とか

MeCabのC++ライブラリを使ってみた(C++11のマルチスレッドに触ってみた)

MeCabをマルチスレッドで動かしてみたい!という話です。
マルチスレッドの方法としてはC++11のstd::threadを使います。

MeCabをマルチスレッドで動かす方法については以下の記事が参考になりました。

またMeCab公式にもマルチスレッドのサンプルプログラムが含まれているようです。

MeCabC++ライブラリの使い方

まずは基本的なC++からMeCabを使う方法です。

マルチスレッドでもシングルスレッドでも動かせる方法での例を書いときます。
流れは以下のようになっています

  1. Mecab::createModel()関数でmodelを作る。この関数の引数は通常MeCabに与えるオプション(辞書の指定や出力フォーマットの指定)を文字列で与えることができます
  2. 次にmodel->createTagger()関数でtaggerを作る
  3. スレッドごとにmodel->createLattice()関数でlatticeを確保
    1. lattice->set_sentence()関数で解析対象の文字列を指定。
  4. taggerのparse(lattice)関数で通常の解析ができます。
  5. 結果はlattice->toString()関数で得られます。

ソースコード

#include <iostream>
#include <memory>
#include <mecab.h>

int main(void) {
    std::shared_ptr<MeCab::Model> model(MeCab::createModel("-Owakati"));
    if(!model) {
        std::cerr << "Error!" << std::endl;
        return -1;
    }

    std::shared_ptr<MeCab::Tagger> tagger(model->createTagger());
    if(!tagger) {
        std::cerr << "Error!" << std::endl;
        return -1;
    }

    std::shared_ptr<MeCab::Lattice> lattice(model->createLattice());
    lattice->set_sentence("竹やぶ焼けた");

    tagger->parse(lattice.get());
    std::cout << lattice->toString() << std::endl;

    return 0;
}

コンパイル

コンパイルのコマンドは以下のようになります。

g++ cppファイル `mecab-config --cflags` `mecab-config --libs` -std=c++0x 

-std=c++0x は当方のg++が古いためこれを指定していますが、適切なC++11用のコンパイルオプションを指定してください。

参考

詳しくは公式のドキュメント↓を参照

次の記事も参考になります。

C++11のスレッドの簡単な使い方

std::threadに関数オブジェクトとその引数を渡して、あとはその終了をjoin関数で待つだけです。

ソースコード

以下のソースコードではstd::vectorにstd::threadを入れているので、代わりにvectorのemplace_back()関数に関数オブジェクトMeCabRunnerとその引数を与えています。
std::threadに与える関数オブジェクトの引数として参照を与える場合にはstd::ref()関数を使う必要があります。

#include <iostream>
#include <memory>
#include <mecab.h>
#include <thread>
#include <vector>

class MeCabRunner {
public:
    void operator()(const std::shared_ptr<MeCab::Model>& model, const std::string text) {
        std::shared_ptr<MeCab::Tagger> tagger(model->createTagger());
        if(!tagger) {
            std::cerr << "Error!" << std::endl;
            return;
        }

        std::shared_ptr<MeCab::Lattice> lattice(model->createLattice());
        lattice->set_sentence(text.c_str());

        tagger->parse(lattice.get());

        std::cout << lattice->toString() << std::endl;
    }
};

int main(void) {
    std::shared_ptr<MeCab::Model> model(MeCab::createModel("-Owakati"));
    if(!model) {
        std::cerr << "Error!" << std::endl;
        return -1;
    }

    MeCabRunner mecab;

    std::vector<std::thread> vec_thread;

    for(int i = 0; i < 10; ++i) {
        vec_thread.emplace_back(mecab, std::ref(model), "今の番号は" + std::to_string(i));
    }

    for(auto& thread: vec_thread) {
        thread.join();
    }

    return 0;
}

コンパイル

Linux環境でg++でコンパイルするときには -lpthread と指定する必要があるらしいです。

g++ cppファイル `mecab-config --cflags` `mecab-config --libs` -std=c++0x -lpthread

mutexによるロック

異なるスレッドで同時に共通の変数に書き込む場合にはデータが破損する可能性があるのでロックをして同時に操作しないようにする必要があります。
C++11にはmutexというヘッダが追加されていてこれを使えば簡単にロックができます。

単純な使い方としてはstd::mutex型の変数mtxを作って、それをstd::unique_lock型の変数のコンストラクタに与えれば、そのスコープ内についてはstd::mutex型の変数mtxについてロックされます。
このとき他のスレッドがstd::mutex型の変数mtxについてロックを取得しようとすると、前のロックしたスレッドが終わるまで待たされることになります。

ロックには色々な種類があって、詳しくは以下のURLを参照。

それぞれの文の解析が終わるごとにロックして結果を書き込むと大量のロックの確保と解放が重なるのが理由なのかあまり速く動きません。
というわけでスレッドごとにデータを持たせてある程度処理してからまとめて結果を統合したりするといいかもしれません。

形態素の出現回数カウントのプログラム

マルチスレッドでMeCabを動かして、それぞれの形態素の出現回数をカウントするプログラムを書きました。
最初にテキストを全部の行読み込んで、それを各々のスレッドに並列数で等分しています。
以下がそのソースコードです。
10回以上出現した単語とその回数をソートせずに出力。

ソースコード

#include <iostream>
#include <fstream>
#include <memory>
#include <mecab.h>
#include <thread>
#include <vector>
#include <map>
#include <mutex>
#include <sstream>

typedef std::map<std::string, int> Count;

namespace {
    std::mutex mtx;
}

class MeCabRunner {
public:
    void operator()(std::shared_ptr<MeCab::Model>& model, Count& count, const std::vector<std::string>& vec_text, size_t begin, size_t end) {
        std::shared_ptr<MeCab::Tagger> tagger(model->createTagger());
        if(!tagger) {
            std::cerr << "Error!" << std::endl;
            return;
        }

        std::shared_ptr<MeCab::Lattice> lattice(model->createLattice());

        Count inner;
        for(int i = begin; i < end;++i) {
            const std::string& text = vec_text[i];
            lattice->set_sentence(text.c_str());

            tagger->parse(lattice.get());
            std::string parsed = lattice->toString();
            std::istringstream is(parsed);
            std::string temp;
            while(is) {
                is >> temp;
                inner[temp] += 1;
            }
        }
        {
            std::unique_lock<std::mutex> lock(mtx);
            for(auto& pair: inner) {
                count[pair.first] += pair.second;
            }
        }
    }
};

int main(void) {
    std::vector<std::string> vec_text;
    std::ifstream ifs("input.txt");
    std::string buffer;
    while(std::getline(ifs, buffer)) {
        vec_text.emplace_back(buffer);
    }
    
    std::shared_ptr<MeCab::Model> model(MeCab::createModel("-Owakati"));
    if(!model) {
        std::cerr << "Error!" << std::endl;
        return -1;
    }
    
    std::vector<std::thread> vec_thread;
    Count count;
    MeCabRunner mecab;

    auto size = vec_text.size();
    size_t parallel = 4;
    
    if(size < parallel) {
        parallel = size;
    }

    for(int i = 0; i < parallel; ++i) {
        vec_thread.emplace_back(mecab, std::ref(model), std::ref(count), std::ref(vec_text), i * size / parallel, (i + 1) * size / parallel);
    }
    for(int i = 0; i < parallel; ++i) {
        vec_thread[i].join();
    }

    for(auto& pair: count) {
        if(pair.second > 9) {
            std::cout << pair.first << ": " << pair.second << std::endl;
        }
    }

    return 0;
}

速度比較

このプログラムをあるテキストに対して実行したところ、2並列で約24秒、4並列で約14秒、8並列で約9秒かかりました。
また同一のファイルに対してMeCabをシェルで実行(1プロセス)したところ、形態素解析だけでおよそ15、6秒かかりました。

注意点

一応強調しておきますが、たぶん単純に形態素解析の速度を速くしたいならファイルを分割して別々のプロセスとしてMeCabに投げたほうが速いと思われます。

今回のプログラムは簡単にするために、テキストデータを全部メモリに保持したままなので、テキストデータ全体+ちょっとぐらいのメモリを使うので注意。
大量のテキストを投げるとメモリを食い過ぎて死にます。

それぞれの文を必ずしも常にメモリ上においておく必要はないです。
本来はProcuder-Consumerパターンを使ってファイルの読み込みと解析のスレッドに分けたり、あるいはスレッドごとにファイルを読み込んだりすればいいかもしれません。

わざわざMeCab分かち書きさせた結果をsstringstreamを使って分解しているので、直接MeCabのラティス内部のノードの文字列を見ればもっと速くなると思います。

記事内容やプログラムの間違い、こう書いたほうがいいなどありましたらご指摘いただけると嬉しいです。