形態素解析をmulti-processingで高速化する

2021/12/31

Python

t f B! P L

形態素解析をmulti-processingで高速化する

お客様「このテキストのデータ分析やっといて!データはドライブにあげといたから!」
わたくし「(データ分析の内容的には1時間でいけるな)明日の朝までに報告します〜」
お客様「早くて助かるよ!」


わたくし「さーてそろそろやりますか…、まずは形態素解析っと」
tqdm「4時間」
わたくし「!?」

解決策

# multi-processing
from concurrent.futures import ProcessPoolExecutor, as_completed
from tqdm import tqdm

# sudachipy
from sudachipy import tokenizer
from sudachipy import dictionary
mode = tokenizer.Tokenizer.SplitMode.A
tokenizer_obj = dictionary.Dictionary().create()

def tokenize(i, text):
    tokens = [m.surface() for m in tokenizer_obj.tokenize(text, mode)]
    return i, tokens

if __name__ == '__main__':
    tokens = []
    
    # 最大4プロセスのプール
    PoolExecutor = ProcessPoolExecutor(max_workers=4)
    
    with PoolExecutor as executor:
        
        # タスク投げ
        tasks = [executor.submit(tokenize, i, text) for i, text in enumerate(texts)]
        
        # 結果受け取り
        for future in tqdm(as_completed(tasks), total=len(tasks)):
            tokens += [future.result()]

textsは文字列の入ったリスト。
今回はライブドアニュースコーパスから適当に300件使用。

私の環境だとシーケンシャルに実行すると53秒だった処理が、4プロセスの並列処理で16秒で終わった(約3.3倍)。
早い!!!

ポイント

multi-threading

マルチプロセスとマルチスレッドがある。

結論から言うとPythonにはGIL(global interpreter lock)というのがあり、ある時刻tにCPU同時に1スレッドしか使えないそう。

そのため、形態素解析のようなCPU-boundな処理では、Multi-threadingにしてもほぼ意味ない。
1データあたりCPUを10秒使うデータ100件の形態素解析は結局10秒×100件で1000秒かかる。

ただし、I/O-boundな処理は待ち時間に別のスレッドで処理を行えるため、Multi-threadingで高速化が可能。
例えば、ディスクIOやネットワークIO、sleepによる待ち時間などが該当する。
sleep(1)、sleep(5)、sleep(10)を逐次実行すると16秒だが、Multi-threadingではほぼ10秒で終わる。並列して寝ることができる。

Multi-threadingではあくまで同じプロセス内で動くため、変数をそのまま共有できる。同時に意図しないタイミングで同じ変数に書き込んでしまう危険性もあるため、排他制御(ロック)が必要になる。

ちなみにサンプルコードのProcessPoolExecutorをThreadPoolExecutorに変えると同じAPIでマルチスレッド処理ができるが、tokenizer_objを同時に使用するとSegmentation faultが発生する。

ここもよく理解できておらず、Sudachipy内部の話なのか、他のライブラリでも起こりうるのか謎。

tokenizer_obj使用時にLockを使用すればSegmentation faultは発生しなくなるが、肝心の形態素解析の部分でLockを使うので並列化も何もない。

multi-processing

別プロセスを立ち上げる。こちらはGILの影響を受けないので、CPU-boundな処理の真の並列化が可能。

デメリットとして別プロセス間でのデータの共有が難しいと言われているが、実際はパイプやキューなど様々な共有方法があり、できる。というか出来る方法が色々ありすぎて逆に難しい。

子プロセスへ渡すデータ、子プロセスから戻すデータpickle化可能でなくてはいけないとされており、例えば、tokenizeメソッドで形態素解析されたSudachiのオブジェクトをそのまま戻すことはできなかった。
サンプルコードでは文字列のリストにしてるのでできる。

また、子プロセスの中でグローバル変数のtokenizer_objを参照しているが、これは明示的に引数で与えていなくてもなぜか参照できている。

逆に明示的に渡そうとすると、tokenizer_objはpickle化できないらしく動作しない。

と、速くなって便利な分、まだちゃんと理解できないところがあるので、とてもプロダクトコードでは使えない。あくまで自分の手元でデータ分析の前処理を高速化したいときだけやな。

concurrent.futuresを使う

threadingモジュール、multiprocessingモジュール、太古のThreadモジュール、最新のconcurrent.feturesモジュールなど、並列処理の進化に対応する形でモジュールも育っているらしく、API周りが少しわかりにくい。

PoolExecuterという便利な機能が使える後発のconcurrent.feturesがやはりいけてるので、特にこだわりがなければこれ一択かと。

as_completedを使う

PoolExecuterを利用する書き方も何パターンかあるのだが、サンプルコードの書き方だとtqdmと一緒に使えるのでおすすめ。
マルチプロセスで高速に処理が完了していくProgress barは見ていて楽しい。

ただし、処理が完了した順にバラバラに結果が返るので、戻り値にindexを入れるなどしてトレーサビリティを確保しておく必要がある。

まとめ

並列処理の難しさの1つが排他制御であるが、機械学習の前処理というのはデータポイントごとに独立して適用可能なものが多いので、排他制御にそれほど神経を使わずに、高速化の恩恵に預かれると思う。

またよく理解できておらず危なかっしい部分が多いので、もう少し習熟が必要。

ラベル

QooQ