こんにちは。 DSOC R&D グループの高橋寛治です。
普段はPython言語によりアルゴリズムの実装を行っています。 スクリプト言語はコンパイルが不要であり、試行錯誤しやすいためです。 しかしながら、実行速度はコンパイル言語に比べて見劣りします。
そこで今回は、高速動作を目的にC++で実装したアルゴリズムをPythonライブラリとして利用する方法について紹介します。 社内で使うアルゴリズムについて計算量が多い処理を実装したところ、Python比で10倍ほど早くなりました。
pybind11とは
pybind11はC++11をPythonからお手軽に取り扱うためのヘッダライブラリです。 これまでにSWIGやBoost.Python*1など、C++を手軽に扱うためのライブラリはありましたが、手順はやや複雑です。 Python用ラッパーモジュールを素で実装するのに比べると、上記ライブラリはかなり手軽ですが、型の変換などで同じような記述*2をたくさんしていました。
pybind11の利用イメージを簡単に説明すると次の3ステップとなります。
- 高速化したい処理をC++で記述
- 関数やメソッドをPythonからどう呼ぶか、定義
- ビルドしてPythonから利用
pybind11のインストールは pip
コマンドで完結します。
pip install pybind11
さて、それでは簡単なアルゴリズムを実装しながら pybind11 に慣れていきます。
簡単なアルゴリズムを実装する
ユークリッドの互除法*3を用いて最大公約数を算出するという、アルゴリズムの基本ともいえる処理を実装します。
最大公約数(Greatest common divisor)が求まると最小公倍数(Least common multiple)も計算できます。 この2つの関数を実装し、C++とPythonで比較します。
ユークリッドの互除法は、二つの引数 x と y (ただし x>=y)が与えられた際に、x から yを割り、xを余りで置き換えます。そして、余りが 0 になるまで繰り返し、そのときの y を出力します。 最小公倍数は、 x と y の積を x と y の最小公倍数で割ることで求まります。
まずは、Pythonで実装します。 Pythonの機能を活かせば少ない行数で記述できるのですが、わかりやすさを重視するためにC++版と記述を揃えています。
pylcm.py
def greatest_common_divisor(x, y): if x < y: tmp = x x = y y = tmp r = x % y while r != 0: x = y y = r r = x % y return y def least_common_multiple(x, y): return int(x * y / greatest_common_divisor(x, y))
C++版もPythonと同様に実装します。
cpplcm.cpp
#include <pybind11/pybind11.h> int greatest_common_divisor(int x, int y) { int r, tmp; if(x < y){ tmp = x; x = y; y = tmp; } r= x % y; while(r != 0){ x = y; y = r; r = x % y; } return y; } int least_common_multiple(int x, int y) { return x * y / greatest_common_divisor(x, y); } PYBIND11_MODULE(cpplcm, m) { m.doc() = "pybind11 example"; m.def("greatest_common_divisor", &greatest_common_divisor, "A function which calculate greatest common divisor"); m.def("least_common_multiple", &least_common_multiple, ""); }
ここで、pybind11が登場します。
関数部は普通のC++と記述は同じです。
pybind11ですが、ヘッダライブラリをインクルードし、Pythonから利用したいモジュールを PYBIND11_MODULE
で定義します。
Python版はそのまま実行できますが、C++版は次のコマンドでビルドします。
g++ -O3 -Wall -shared -std=c++11 -fPIC `python3 -m pybind11 --includes` cpplcm.cpp -o cpplcm`python3-config --extension-suffix`
コンパイラオプションには、最適化(-O3)、共有ライブラリとしてビルド(-shared -fPIC
)、言語標準の指定(-std=c+11
)、インクルードディレクトリの追加( `python3 -m pybind11 --includes`
)、コンパイル対象(cpplcm.cpp
)、出力(cpplcm`python3-config --extension-suffix`
)となります。
Pythonの対話型インタプリタ上で動作確認します。
$ ipython In [1]: import cpplcm In [2]: cpplcm.greatest_common_divisor(42, 28) Out[2]: 14 In [3]: cpplcm.least_common_multiple(42, 28) Out[3]: 84 In [4]: import pylcm In [5]: pylcm.greatest_common_divisor(42, 28) Out[5]: 14 In [6]: pylcm.least_common_multiple(42, 28) Out[6]: 84
計算できることを確認しました。 それでは、速度を比較します。
Pythonから100万回無作為な自然数に対する最大公約数を求める処理について、最大公約数算出部分にPythonライブラリかC++ライブラリを用いて比較します。
compare.py
import random import statistics import time import cpplcm import pylcm def calctest(least_common_multiple_function, repeat_number=1000000): for i in range(repeat_number): a = random.randrange(start=100000, stop=1000000) b = random.randrange(start=100000, stop=1000000) least_common_multiple_function(a, b) def mean_elapsed_times(least_common_multiple_function): elapsed_times = [] for i in range(10): start = time.time() calctest(least_common_multiple_function) elapsed_time = time.time() - start elapsed_times.append(elapsed_time) return statistics.mean(elapsed_times) if __name__ == "__main__": cpp = mean_elapsed_times(cpplcm.least_common_multiple) py = mean_elapsed_times(pylcm.least_common_multiple) print(f"cpp elapsed_time:{ cpp } [sec]") print(f"py elapsed_time:{ py } [sec]")
乱数で入力を生成しているため、決め打ちですが10回の平均をとるとします。
それでは実行してみましょう。
$ python compare.py cpp elapsed_time:2.2361205577850343 [sec] py elapsed_time:2.901941442489624 [sec]
少し高速になったのがわかります。 よく効くのは、forループを大量に用いる処理をC++内部で処理する場合です。 Pythonはfor文のオーバーヘッドが非常に大きいため、かなり差が出ます。 ぜひ試してみてください。
パッケージ化
作成するモジュールをPythonパッケージとして利用する場合の方法について説明します。
公式のサンプルが充実しているため、これを元に setup.py
を作成します。
setup.py
import sys import setuptools from setuptools import setup, Extension from setuptools.command.build_ext import build_ext from pathlib import Path __version__ = '0.1.0' class get_pybind_include(object): """Helper class to determine the pybind11 include path The purpose of this class is to postpone importing pybind11 until it is actually installed, so that the ``get_include()`` method can be invoked. """ def __init__(self, user=False): self.user = user def __str__(self): import pybind11 return pybind11.get_include(self.user) ext_modules = [ Extension( 'cpplcm', ['cpplcm.cpp'], include_dirs=[ # Path to pybind11 headers get_pybind_include(), get_pybind_include(user=True), ], language='c++' ), ] # As of Python 3.6, CCompiler has a `has_flag` method. # cf http://bugs.python.org/issue26689 def has_flag(compiler, flagname): """Return a boolean indicating whether a flag name is supported on the specified compiler. """ import tempfile with tempfile.NamedTemporaryFile('w', suffix='.cpp') as f: f.write('int main (int argc, char **argv) { return 0; }') try: compiler.compile([f.name], extra_postargs=[flagname]) except setuptools.distutils.errors.CompileError: return False return True def cpp_flag(compiler): """Return the -std=c++[11/14/17] compiler flag. The newer version is prefered over c++11 (when it is available). """ flags = ['-std=c++17', '-std=c++14', '-std=c++11'] for flag in flags: if has_flag(compiler, flag): return flag raise RuntimeError('Unsupported compiler -- at least C++11 support ' 'is needed!') class BuildExt(build_ext): """A custom build extension for adding compiler-specific options.""" c_opts = { 'msvc': ['/EHsc'], 'unix': [], } l_opts = { 'msvc': [], 'unix': [], } if sys.platform == 'darwin': darwin_opts = ['-stdlib=libc++', '-mmacosx-version-min=10.7'] c_opts['unix'] += darwin_opts l_opts['unix'] += darwin_opts def build_extensions(self): ct = self.compiler.compiler_type opts = self.c_opts.get(ct, []) link_opts = self.l_opts.get(ct, []) if ct == 'unix': opts.append('-DVERSION_INFO="%s"' % self.distribution.get_version()) opts.append(cpp_flag(self.compiler)) if has_flag(self.compiler, '-fvisibility=hidden'): opts.append('-fvisibility=hidden') elif ct == 'msvc': opts.append('/DVERSION_INFO=\\"%s\\"' % self.distribution.get_version()) for ext in self.extensions: ext.extra_compile_args = opts ext.extra_link_args = link_opts build_ext.build_extensions(self) setup( name='cpplcm', version=__version__, author='Sansan, Inc.', author_email='', url='', description='sample', long_description='', ext_modules=ext_modules, install_requires=['pybind11>=2.4'], setup_requires=['pybind11>=2.4'], cmdclass={'build_ext': BuildExt}, zip_safe=False, )
基本的には、setupメソッドに渡す引数の内容を変更するのみです。
実際にもう少し複雑なライブラリを作成する場合には、ソースコードのディレクトリが分かれます。その際には、ext_modulesのinclude_dirsに対象ディレクトリを追加します。
実際にインストールして使ってみましょう。
$ pip install . $ pip list | grep cpplcm cpplcm 0.1.0 # どこか現在のソースコードのあるディレクトリ以外に移動 $ cd ~ $ ipython In [1]: import cpplcm In [2]: cpplcm.greatest_common_divisor(42, 28) Out[2]: 14 In [3]: cpplcm.least_common_multiple(42, 28) Out[3]: 84
簡単にライブラリ化することができました。
手軽に高速化
Pythonだけではどうしても速度を上げることのできない場面で、手軽にC++ライブラリとして実装できるというのは魅力です。
実際に業務で利用しており、ボイラープレートが少なかったり、他の手段のCythonだと別の記法を覚えないといけなかったりしますが、C++部分はそのままであるためコードレビューが難解になりにくいと感じています。
Dockerコンテナで稼働することが多い最近では、ビルドが成立する環境をDockerfileとして記述するため、C++コードを動作させるハードルはさらに下がっていると思います。
ぜひ、部分的に高速な実装を活用していきましょう。
▼本連載のほかの記事はこちら buildersbox.corp-sansan.com
執筆者プロフィール
高橋寛治 Sansan株式会社 DSOC (Data Strategy & Operation Center) R&Dグループ研究員
阿南工業高等専門学校卒業後に、長岡技術科学大学に編入学。同大学大学院電気電子情報工学専攻修了。在学中は、自然言語処理の研究に取り組み、解析ツールの開発や機械翻訳に関連する研究を行う。大学院を卒業後、2017年にSansan株式会社に入社。キーワード抽出など自然言語処理を生かした研究に取り組む。