始めに:pandasの作者であるWes McKinneyさんがPythonのデータツール関連でとても興味深いblogを書かれているので、翻訳して日本のPyDataコミュニティに公開してもいいでしょうか、とお聞きしたところ、快諾をいただきましたので少しずつ訳して公開していこうと思っています。
毎秒10GBでArrowからpandasへ
(原文:http://wesmckinney.com/blog/high-perf-arrow-to-pandas/ )
2016/12/27
このポストでは、汎用的なArrowの列指向のメモリを、pandasのオブジェクトに高速に変換できるようにするための最近のApache Arrowでの作業について述べます。
pandasのDataFrameオブジェクトを高速に構築する際の課題
pandasのDataFrameオブジェクトを高速に構築する際に困難なことの1つは、「ネイティブの」内部メモリ構造が辞書や1次元のNumPy配列のリストよりも複雑だということです。ここではそういった複雑さが生じている理由については踏み込みませんが、これはpandas 2.0の作業の中で何とかできればと思っています。この複雑さには、2つのレイヤーがあります:
- pandasのデータ型の中には、null値の有無によってメモリ表現が変わりうるものがあります。Booleanのデータはdtype=objectになりますが、integerはdtype=float64になります。
- pandas.DataFrameを呼び出す際に、pandasは内部的に入力データを内部的な2次元のブロック構造にコピーすることによって「とりまとめ」ます。精密なブロック構造を構築することが、ゼロコピーでDataFrameを構築するための唯一の本当の方法なのです。
とりまとめを行うことによって生ずるオーバーヘッドをイメージしてもらうために、ベンチマークをご覧いただきましょう。1GBのデータを含む100個のfloat64の配列からなる辞書を作成するセットアップのコードを考えてみます。
import numpy as np
import pandas as pd
import pyarrow as pa
type_ = np.dtype('float64')
DATA_SIZE = (1 << 30)
NCOLS = 100
NROWS = DATA_SIZE / NCOLS / np.dtype(type_).itemsize
data = {
'c' + str(i): np.random.randn(NROWS)
for i in range(NCOLS)
}
そして、pd.DataFrame(data)でDataFrameを作成します:
>>> %timeit df = pd.DataFrame(data)
10 loops, best of 3: 132 ms per loop
(計算してみている人のために書いておくと、これは7.58 GB/秒で、やっているのは内部的なメモリのコピーだけです)
ここで重要なのは、この時点でpandaの「ネイティブ」のメモリ表現は構築できている(nullは配列中でNaNになっているでしょう)ものの、それは1次元の配列のコレクションになっているということです。
Arrowの列指向メモリからpandasへの変換
私は、Apache Arrowが誕生した2016年からこのプロジェクトに深く関わってきました。Apache Arrowは、特定の言語に依存しないインメモリの列指向表現と、プロセス間通信(IPC)のツール群です。Apache Arrowは、入れ子になったJSONのようなデータをサポートし、高速な分析エンジンを構築するためのビルディングブロックになるよう設計されています。
pandasと比較すれば、Arrowは値からは分離されたビットマップでnull値を明確に表現できます。そのため、pandasに適した配列からなる辞書へのゼロコピーの変換でさえも、さらなる処理が必要になります。
Arrowの作業をする上での私の主要なゴールの1つは、それをPythonエコシステムのための広帯域のI/Oパイプとして利用するこことです。JVM、データベースシステム、そして様々なファイルフォーマットとのやりとりが、Arrowを列指向の交換フォーマットとして利用することで実現できます。このユースケースでは、できる限り高速にpandasのDataFrameへ戻せることが重要になります。
先月、私はpandasの内部的なブロック構造からArrowのメモリを広帯域で構築するためのエンジニアリングを済ませました。Featherのファイルフォーマットを見ている方なら、この処理がすべて密接に関連していることが分かるでしょう。
先ほどと同じギガバイトのデータに戻って、いくつかのnullを加えておきましょう。
>>> df = pd.DataFrame(data)
>>> df.values[::5] = np.nan
さあ、このDataFrameをArrowテーブルに変換しましょう。これで、Arrowの列指向の表現が構築されます。
>>> table = pa.Table.from_pandas(df)
>>> table
<pyarrow.table.Table at 0x7f18ec65abd0>
pandasに戻すには、テーブルのto_pandasメソッドを呼びます。これはマルチスレッドでの変換をサポートしているので、シングルスレッドでの変換をして比較してみましょう。
>>> %timeit df2 = table.to_pandas(nthreads=1)
10 loops, best of 3: 158 ms per loop
これは6.33 GB/秒で、言い換えれば純粋なmemcpyベースでの構築に比べておおよそ20%遅くなっています。私のデスクトップでは、4コアすべてを使って高速化できます。
>>> %timeit df2 = table.to_pandas(nthreads=4)
10 loops, best of 3: 103 ms per loop
9.71 GB/秒ということは、私のコンシューマ用デスクトップ機のハードウェアのメインメモリの帯域を使い切っている状況ではまったくありません(ただし私はこのあたりのエキスパートではありません)。
マルチスレッド化によるパフォーマンスの改善は、ハードウェアが異なればもっと劇的になるかもしれません。私のデスクトップでのパフォーマンスの比率は1.53に過ぎませんが、私のラップトップ(これもクアッドコアです)では3.29です。
ただし、数値データを扱うのは最善のケースの話であることには注意してください。文字列やバイナリのデータの場合、pandasはメモリ表現中でPythonのオブジェクトを使い続けますが、それでもオーバーヘッドが加わることになります。
今後への影響とロードマップ
Arrowの配列やレコードのバッチ(同じ長さの複数の配列)、あるいはテーブル(レコードのバッチのコレクション)を様々なソースから容易にゼロコピーで構築できるようになったので、この方法を使えば柔軟かつ効率的に表形式のデータをシステム間で移動させられるようになりました。高速にpandasへの変換ができるようになったことで、完全なpandasのDataFrameをわずかな変換コストで(5-10GB/秒であれば、通常は他のメディアのI/Oパフォーマンスに比べれば無視できます)得られるようになりました。
オーバーヘッドが少ない(そしてできる限りゼロコピーで処理する)ArrowのC++ I/Oサブシステムの技術的な詳細については、別にポストを書こうと思います。
pandas 2.0のロードマップを進むにしたがって、Arrowのような列指向のメモリとのやりとりでのオーバーヘッドをさらに減らせれば(場合によってはゼロに)と思っています。メモリ表現がシンプルになることで、他のアプリケーションが低レベルでpandasとやりとりすることも容易になるでしょう。