Python C++を活用して速度向上を図る「pybind11」×「CMake」

 本記事では、PythonC++を連携する雛形コードを載せました。具体的には、データを抽出するためのプログラムで、forループとその中にあるif文条件分岐の部分をC++に任せて高速化を図ります。仕組みは、C++Pythonモジュールを作成することでPythonスクリプト内でインポートし、それに引数を与えて処理結果を戻り値で受け取ることが出来ます。
LinuxUbuntu)で動作確認しています。Windowsに関してはCMakeLists.txtのコードのみ文末に載せます。(PythonC++の連携方法は、Visual Stadio 2019やVS codeを使用する手段もあります。本記事ではそれには触れていません)
 はじめに、高速化度合いを検証した結果が下図です。

f:id:HK29:20210103115914p:plain

 (検証内容の詳細は後述しますが)入力因子の「乱数で生成した数値の範囲」が少ない100の場合の処理に掛かった時間を比較すると、C++(pybind11)の場合は0.01分(0.34秒)、numpyの場合は0.38分(22.79秒)、listの場合は0.06分(3.62秒)です。つまり、C++(pybind11)が、順に38倍、6倍速い結果です。
 しかし、扱うデータ数が多い1万の場合の処理に掛かった時間は、C++(pybind11)の場合は0.42分(25.42秒)、numpyの場合は0.47分(28.09秒)、listの場合は3.94分(236.17秒)です。つまりC++(pybind11)が、順に1.1倍、561倍速い結果です。
 以上より、少なくとも扱うデータによってご利益の度合いに差があることがわかりました。下図は、それをグラフ化した折れ線図です。興味深いのが、横軸のデータ数1万を超えた辺りからC++(pybind11)はnumpyにより遅くなっています。forループ以外の何かが律速(オーバーヘッド)している可能性があります(例えばif文条件分岐によるデータ抽出部分)

結局、実務において重要なのは体感できる程の速度差があるのか?です。

f:id:HK29:20210103015158j:plain

上図の検証内容の詳細について説明します。次のような4列の35万行のデータがあります。各セルの数字は乱数で生成しています。この乱数生成の範囲が上図の横軸です。つまり扱うデータ数の多さをパラメータとしています。

f:id:HK29:20210103022607p:plain

そして、下図のように数字がまた無数にあります。これもまた乱数で作成したもので、生成範囲は上図と同じです。数字に重複はありません。ここで、上図の各行を一行ずつforループで回した時に、下図にある数字が4列全て存在すれば、その行番号(インデックス)を取得してゆくプログラムです。この特徴は、forループが複数あるのと、if文による条件分岐があります。この処理に掛かった時間をC++(pybind11)とnumpy、listでした3つの場合で比較検証したのが冒頭のグラフでした。

f:id:HK29:20210103022941p:plain

 今回の検証の結果、C++のコード作成とコンパイルのためのトライ&エラーをする時間も加味すると、ループ処理のためにPythonC++を連携使用するのは必ずしも適切とは言えない。つまり、総合的早さを考慮すると、numpyで十分でありえる場合もあるとわかった。

■インストール

作業1. pybind11を入れる。condaでもpipでも出来る

conda install -c conda-forge pybind11
pip install pybind11

作業2. cmakeを入れる。

cmake.org

作業3. C++コンパイラであるg++やmakeに関連するツールを入れる。

sudo apt-get install build-essential 

■本プログラム

 次の①~③の3つのコードが必要です。そして、buildディレクトリを作成して、cd(チェンジディレクトリ)でそこに移動して次の3つの順でコマンド実行でできます。
上から順に、cmake ..コマンドによりコンパイルするためのスクリプトファイルMakefileを作成。makeコマンドで.soファイルを作成。最後にpythonスクリプトで実行。

$ cmake ..
$ make
$ python3 main_script.py

▼①. C++のコード

繰り返しループ処理の部分をcppで書く。この時、Pythonライブラリのpybind11というヘッダーを利用する。これをコンパイルすると、.soファイルが生成される(Windowsの場合は.prd)。これは、Pythonモジュールであって、Pythonスクリプト内でインポートすることで関数のように呼び出せるようになる。ファイル名はここでは「loop.cpp」としています。

#include <pybind11/pybind11.h>
#include <pybind11/numpy.h>
#include <iostream.h>

using namespace std;
namespace py = pybind11;

py::array_t<int> extract_rows_index(
    py::array_t<double> input1,
    py::array_t<double> input2,
    int elem)
{
    py::buffer_info buf1 = input1.request(), buf2 = input2.request();

    if (buf1.ndim != 2 || buf2.ndim != 1)
        throw std::runtime_error("Number of dimensions must be one");

    auto r = input1.unchecked<2>(); // input1 must have ndim = 2;
    double *ptr2 = static_cast<double *>(buf2.ptr);

    py::array_t<double> result_array({elem});
    auto r2 = result_array.mutable_unchecked<1>();

    int n = 0;
    int flag_cnt;
    for (int i = 0; i < r.shape(0); i++){
        flag_cnt = 0;
        for (int j = 0; j < r.shape(1); j++){
            for (int k = 0; k < buf2.shape[0]; k++){
                if (r(i, j) == ptr2[k] ){
                    flag_cnt += 1;
                }
            }
        }
        if (flag_cnt == 4){
            r2(n) = i;
            n += 1;
        }
    }
/*
    for ( int t = 0; t < n; t++){
        //cout << r2[t] << " ";
    }
*/
    return result_array;
}
//PYBIND11_MODULEの第一引数は、pybind11_add_moduleの第一引数と同じにする
PYBIND11_MODULE(loop, m) {
    m.def("extract_rows_index", &extract_rows_index, "comment aiueo");
}

▼②. CMakeのコード

ファイル名は「CMakeLists.txt」とする必要あります。ビルド自動化を目的としたツールです。OSや、コンパイラが異なっても設定が一目にわかり、調整がし易いです。(例えば、WindowsLinuxではコンパイラの種類やver、パスが異なるため、これがあると調整がし易いです)

# CMakeのバージョン
cmake_minimum_required(VERSION 3.16)

# プロジェクト名
project(aaa VERSION 0.1.0)

# コンパイラーの指定
set(CMAKE_CXX_STANDARD 17)

# コンパイラー Option
set(CMAKE_CXX_FLAGS "-O1 -Wall")
set(CMAKE_CXX_FLAGS_DEBUG "-O0 -g")
set(CMAKE_CXX_FLAGS_RELEASE "-O2")
set(CMAKE_CXX_FLAGS_MINSIZEREL "-Os")
set(CMAKE_CXX_FLAGS_RELWITHDEBINFO "-Og -g")

# PyBind11 を取得
find_package(pybind11 REQUIRED)

# Pythonのパスを探索
find_package(Python REQUIRED)
message(${Python_FOUND})
message(${Python_EXECUTABLE})

# -I : インクルードパス
include_directories(/home/[userid]/anaconda3/envs/py37/include)
include_directories(/home/[userid]/anaconda3/envs/py37/include/pybind11)

# -L : ライブラリ探索パス
link_directories(/home/[userid]/anaconda3/envs/py37/lib)

# 作成する実行ファイル名とソースファイル
pybind11_add_module(loop loop.cpp)

 ▼③. Pythonのコード

ファイル名は例えば「main_script.py」とする。データの生成やグラフ化などの入出処理はPythonを使います。C++で作成したPythonモジュールのインポートの例は、ここではfrom loop import extract_rows_index です。

import pandas as pd
import numpy as np
from loop import extract_rows_index
import time
from tqdm import tqdm
from numpy.random import *

random_max_value = 5000
original_data_rows = 350000
num_of_target_extract = int(random_max_value / 2)


np_array_list = []
for i in range(4):
    np_array_list.append(randint(0, random_max_value, original_data_rows))
#print(np_array_list)

array_list = [np_array.tolist() for np_array in np_array_list]

df = pd.DataFrame(array_list).T
df.columns = ['node1', 'node2', 'node3', 'node4']

node_data_list = df[['node1', 'node2', 'node3', 'node4']].values.tolist()
#print(len(node_data_list))
node_data_np = np.array(node_data_list)
#print(node_data_np)


target_list = randint(0, random_max_value, num_of_target_extract).tolist()
target_node_list = list(set(target_list))
target_node_list.sort()
df2 = pd.DataFrame(target_node_list)
#df2.hist()
target_node_np = np.array(target_node_list)
#print(target_node_np)


print(r'########################################### c++')
start = time.time()
arr = extract_rows_index(node_data_np,
                         target_node_np,
                         int(len(node_data_np)/10))

#print(arr)
#print(type(arr))
for i, t in enumerate(arr):
    if(i > 0) and (t == 0):
        buf = i
        break
arr2 = np.resize(arr, buf)
extract_rows_No_list = arr2.tolist()
print(extract_rows_No_list[:10])
print(extract_rows_No_list[-10:])
print(type(extract_rows_No_list))
cpp_time = time.time() - start
print("c++ time -> ", cpp_time)
print(len(extract_rows_No_list), '/', len(node_data_list))

print(r'########################################### numpy')
extract_rows_No_list = []
extract_rows_data_list = []
start = time.time()
for i, row in tqdm(enumerate(node_data_np)):
    cnt = 0
    row_data_list = []
    for data in row:
        if data in target_node_np:
            row_data_list.append(data)
            cnt += 1
    if cnt==4:
        extract_rows_No_list.append(i)
        extract_rows_data_list.append(row_data_list)

print(extract_rows_No_list[:10])
print(extract_rows_No_list[-10:])
print(type(extract_rows_No_list))
numpy_time = time.time() - start
print("numpy time -> ", numpy_time)
print(len(extract_rows_No_list), '/', len(node_data_list))

print(r'########################################### list')
extract_rows_No_list = []
extract_rows_data_list = []
start = time.time()
for i, row in tqdm(enumerate(node_data_list)):
    cnt = 0
    row_data_list = []
    for data in row:
        if data in target_node_list:
            row_data_list.append(data)
            cnt += 1
    if cnt==4:
        extract_rows_No_list.append(i)
        extract_rows_data_list.append(row_data_list)

print(extract_rows_No_list[:10])
print(extract_rows_No_list[-10:])
print(type(extract_rows_No_list))
list_time = time.time() - start
print("list time -> ", list_time)
print(len(extract_rows_No_list), '/', len(node_data_list))

data_list = [random_max_value, original_data_rows, cpp_time, numpy_time, list_time]
data_list = list(map(str, data_list))
data_str = ','.join(data_list)
with open('00_time_data.csv', 'a') as f:
   f.write(data_str + '\n')

print("finished")

#################################

▼(Windows)CMakeのコード

C++コンパイラのg++にMinGWを使用する場合、64bitPCでは64bitをインストールして使用すること

# CMakeのバージョン
cmake_minimum_required(VERSION 3.19) 

# プロジェクト名
project(aaaa CXX)

# PyBind11 を取得
include(FetchContent)
FetchContent_Declare(
    pybind11
    GIT_REPOSITORY https://github.com/pybind/pybind11
    GIT_TAG        v2.6.1
)
FetchContent_MakeAvailable(pybind11)

# コンパイラーの指定
set(CMAKE_CXX_COMPILER C:\\mingw-w64\\mingw64\\bin)

# コンパイルオプション。下記はC++の場合
set(CMAKE_CXX_FLAGS "-Wall -std=c++1z")

# Pythonのパスを探索
find_package(Python REQUIRED)
message(${Python_FOUND})
message(${Python_EXECUTABLE})

# .h を検出できるように,
# -I : インクルードパス
#include_directories(${Python_EXECUTABLE})
include_directories(C:\\Users\\[userid]\\Anaconda3\\include)
include_directories(C:\\Users\\[userid]\\Anaconda3\\lib\\site-packages\\pybind11\\include)
include_directories(C:\\Users\\[userid]\\Anaconda3\\Lib\\site-packages\\pybind11\\include\\pybind11)

# -L : ライブラリ探索パス
link_directories(C:\\Users\\[userid]\\Anaconda3\\Lib)

# 作成する実行ファイル名とソースファイル
pybind11_add_module(main main.cpp)

実行例を下記に記す(Linuxの場合とは異なります)

$ make .. -G "MinGW Makefiles"
$ mingw32-make loop
$ python main_script.py

以上

<広告>