始めに:pandasの作者であるWes McKinneyさんがPythonのデータツール関連でとても興味深いblogを書かれているので、翻訳して日本のPyDataコミュニティに公開してもいいでしょうか、とお聞きしたところ、快諾をいただきましたので少しずつ訳して公開していこうと思っています。
2017/9/21(木)
Apache Arrow、pandas、pandas2、そして最近の私の作業の大まかな方向性と視界が開けてきている未来に関して、これから一連のポストを書いていきます。このポストはその第一弾です。少々量があり、全体に技術的な色合いが濃くなっていますが、興味を持たれたなら読み進めていってください。
このポストでは、pandasの内部構造に関する主要な問題のいくつかと、それらに対して私が着実に進めてきた現実的な解決策の計画と構築について、できる限り簡潔に説明しようと思います。外から見れば、私が力を注いできた、たとえばpandas、Badger、Ibis、Arrow、Feather、Parquetといったプロジェクト群の間にはほとんど関係がないように見えるかも知れません。しかし実際にはまったくその逆で、これらはすべて私がほとんど10年前に始めた連続的な弧を成す、相互に密接に関係している構成要素なのです。
注記:pandasの開発をサポートするために所得税控除の対象となる寄付を検討してください。
(訳注:日本ではどういう扱いになるのか、訳者は今のところ知りません)
背景を少々
私がpandasを作り始めたのは2008年の4月です。実験的に始めたこの取り組みは、ほとんど私の夜や週末に開発を進めました。振り返ってみれば、当時の私はソフトウェアエンジニアリングやPythonのサイエンスコンピューティングスタックについてあまり知りませんでした。私のコードは汚く、低速でした。そして先へ進んでできる限り周りの人々から学ぶことで、私の理解も広がっていきました。Cで真剣に開発を始めたのは2013年からでしたし、C++に取り組んだのは2015年からです。9年前の時点では、これほどC++に感謝することはなかったでしょう。
現在と比較すれば、今日データサイエンスの開発と呼ばれている事に関して、当時のPythonはそれほど快適なものではありませんでした。2017年現在人々がpandasを使って解決しているような問題について、概してそのころPythonは使われていなかったのです。広く使われていたのはR、SAS、SPSS、Stata、MATLABなどでした(この順序に特に意味はありません)。
そんなわけで、pandasの内部のアーキテクチャに多少の欠点があるのも不思議なことではないでしょう。2011年の夏に、私はBlockManagerと呼ばれているものを考案しました。これはpandas.DataFrameの内部的なデータの列を管理するためのメモリ管理のオブジェクトで、その中ではNumPyの配列を使っていました。これについて2011年の7月に私が書いたポストは今でも読むことができます。
BlockManagerとpandasが内部でNumPyと全体的に密結合になっていることは、歴史的に見ればプロジェクトにとってうまく働いたものの、これは今日、大きなデータセットを扱うpandasのユーザーを悩ませている問題の根本原因の一部になっています。
簡単に言えば、2011年の時点では、100GBや1TBといったデータセットの分析をすることなど考えてはいなかったのです。今日、私としてはpandasにデータセットのサイズの5倍から10倍のRAMを用意することを大まかなルールとしています。したがって、メモリ管理の問題を起こさずに10GBのデータセットを扱うなら、実際には64、できれば128GBのRAMを用意すべきです。これは、自分のコンピューターのRAMの2倍から3倍のデータセットを分析できると考えているユーザーにはショックなことでしょう。
pandasのルール:データセットの5倍から10倍のRAMを用意しよう
pandasのプロジェクトには、これ以外にも多くの内部的な細部で使われているPythonのオブジェクト(stringなど)の利用方法など、身を潜めているメモリキラーがあるので、ディスク上では5GBのデータセットが20GB以上のメモリを消費してしまうことも珍しくはないのです。これは、大規模なデータセットにとって全体的に良くない状況です。
DataPad、Badger、そしてClouderaでの日々
2013年に、私は最も長い友人であり、pandasの協力者であるChang SheとともにDataPadを立ち上げました。私たちは萌芽期のPyDataスタックを使ってビジュアルな分析アプリケーションを構築していましたが、パフォーマンスに関する重大な問題にぶつかりました。これは特にクラウドで顕著で、DataPadアプリケーションから分析クエリのレスポンスが、素のままのpandasでは良くなかったのです。
そこで私は、pandasの機能群をごく重要なものに絞り込んで新たに小さな実装を作成し、これをBadgerという名前にしました。そこで私が気づいたのは、連続的でイミュータブルな列指向のデータ構造をデータローカリティに最適化することで、幅広い操作に対してパフォーマンスを2倍から20倍に向上させられるということでした。最も改善が大きかったのは文字列処理でしたが、全体的にも大きな改善になりました。DataPadのデモはこちらで見ることができます。
Badgerは、間違いなく「スタートアップのコード」でした。2014年に私たちがClouderaに買収されたとき、私はBadgerをオープンソース化することを考えましたが、コードのクリーンアップに人手がかかりすぎる(BadgerはほとんどCで書かれており、あまりにマクロが多かったのです)ことになりそうであり、私としては10年間は役に立ち続けられるような将来にわたって有効な実装をつくりたいと思いました。Badgerをそのままリリースすればpandasのユーザーは混乱するかもしれず、私自身もBadgerのコードベースでの開発を続けたくはありませんでした。コードベースを放棄するためだけにリリースするのは良くない発想です。基本的に書き直しが必要だという事実を踏まえ、私はBadgerをしまったままにしました。
10 Things I Hate About Pandasというサブタイトルの2013年11月のプレゼンテーションのスライドは、4年間でおよそ100,000回閲覧されました。これは、2013年を通じて、そしてpandasの開発の最初の5年間での戦傷から学んだことをまとめたものです。
その10項目(実際には11項目)は以下のとおりです(私自身の表現を言い換えています)。
- 内部構造があまりに「物理層」からかけ離れていること
- メモリマップされたデータセットをサポートしていないこと
- データベースやファイルとの入出力のパフォーマンスが貧弱
- 欠損データのサポートがおかしい
- メモリの利用やRAMの管理に関する透明性の欠如
- カテゴリー型のデータのサポートが弱い
- 複雑なgroupbyの操作が扱いにくく低速
- DataFrameへのデータの追加がめんどうで非常にコストがかかる
- 限定的で拡張性に欠ける型メタデータ
- 即時評価モデル、クエリプランニングの欠如
- 大規模なデータセットに対する「低速で」限定的なマルチコアアルゴリズム
Badgerで、私はこれらの問題のいくつかを解決し始めましたが、その範囲はDataPadにおいて私たちが解決しようとしている課題の範囲に合わせて狭まっていました。幸運なことに、転職したClouderaには数多くのデータベースやビッグデータシステムの開発者がいたので、私は彼らから学ぶことができました。
Clouderaで、私はImpala、Kudu、Spark、Parquetやその他のビッグデータのストレージや分析のシステムを見始めました。これらのプロジェクトはいずれもPythonやpandasとの関わりがなかったので、インテグレーションの構築は難しいことでした。最も大きな問題を一つあげるなら、それはデータの交換でした。特に問題だったのは、あるプロセスのメモリ空間から他のプロセスのメモリ空間へ大規模な表形式のデータセットを移動させることでした。これは非常に負荷の大きい処理であり、標準的な解決方法がありませんでした。ThriftやProtocol BuffersといったRPC指向のシリアライゼーションプロトコルは、あまりに低速であり、あまりに汎用的すぎました。
様々なシステムにおける様々な接続ポイントを探るにつれて、Badgerのこれから取り組んできた問題との共通点が多々あることが見えてきました。最も大きかったのは、ゼロコピーデータアクセスです。ディスク上の1TBのデータに1MBのデータと同じような速度と容易さでアクセスできるようにするためには、複雑なテーブルをメモリマップできなければならないのです。
2015年の初めには、私はそのころ列指向データミドルウェアと呼んでいたものを切望していました。これはゼロコピーアクセスが可能で、文字列、ネスト型、そして世間に流れているその他の扱いにくいJSONスタイルのデータのすべてを十分サポートできるだけの豊富な機能を持つものです。プロトタイプのBedgerランタイムのように、このフォーマットは最大の速度でクエリを評価できるよう、データローカリティに対して最適化されている必要がありました。
幸運なことに、私は多くのビッグデータプロジェクト、特にApache Drill、Impala、Kudu、Sparkなどに関わる、私と似た考えを持つ人々に出会うことができました。2015年の終わりには、ソフトウェアベンダーとの関係を持たない中立的な「安全地帯」の作成を目的として、私たちはApahe Arrowを立ち上げるためにApache Software Foundationとの作業に取り組んでいました。
論文上では、Apache Arrowは私が何年にもわたって求めてきたものをすべて備えていました。しかし2015年の終わりに私の手元に存在していたのは(Pythonに関するかぎり)、Markdownで書かれた多少の仕様のドキュメントだけでした。これらの仕様は最終版でさえありませんでした。私たちは、Apache Arrowのプロジェクトをセットアップし、Arrowの仕様やArrowが解決しようとしている問題について幅広いコミュニティが対話を持てる場を作り出しました。私たちは、Arrowのビジョンを実現して有益なものにするために、仕事に取りかかって現実のソフトウェアを構築しなければなりませんでした。この時点で私はほぼ2年間にわたってこのプロジェクトに取り組んできており、達成しようとしていることへの私たちの理解は大きく進んでいました。
**私は、Arrowは次世代のデータサイエンスツールにとって鍵となる技術だと強く感じています。**私は、このことについての私のビジョンを最近のJupyterConのキーノートで述べました。
2015年末には、これまでよりも高速でクリーンなpandasのコア実装の構築に関する議論を始めるために、多くの設計ドキュメント群も書きました。これはpandas2と呼ぶことになるかも知れません。pandasはコミュニティプロジェクトであり、合意に基づいて管轄されます(私は行き詰まりを打破するためのBDFL = 慈悲深き終身独裁者です)。私が確かめたいのは、私以外のコア開発者たちがpandasの内部に関する問題についての私の評価に同意してくれるかどうかでした。それから2年が経過し、これらの問題については全体的な合意ができあがったものの、既存のpandasのユーザーコミュニティを混乱させることなくそれらを解決する方法については、答えがまだ出ていません。この間、私はpandasのユーザーがほとんど目にすることのない演算インフラストラクチャの構築に集中してきました。
Arrowは「10項目」を解決するのか?
Arrowは10項目をまだまだすべて解決するわけではありませんが、そこに向かって大きく前進しています。
ArrowのC++実装は、pandasのようなプロジェクトのための基本的なインメモリ分析インフラストラクチャを提供しています。
- 分析的な処理のパフォーマンスに最適化されたランタイムの列指向メモリフォーマット
- 最大限の速度で大規模なデータセットの移動やアクセスを処理するために設計されたゼロコピーのストリーミング / チャンク指向のデータレイヤー
- 実世界のシステムに現れる幅広いフラット及びネスト型のデータ型を記述するための拡張性を持ち、ユーザー定義型もサポートする型メタデータ
Arrow C++に現時点で欠けている(ただしそれほど遠からず解消されるでしょう)ものは以下のとおりです。
- 包括的な分析機能の「カーネル」ライブラリ
- グラフデータフロー実行のための論理オペレータのグラフ(TensorFlowやPyTorchをイメージしてみてください。ただしこれはdata frame用です)
- オペレータのグラフを並列評価するためのマルチコアスケジューラー
Arrowメモリのための分析エンジン(これはpandasのようなプロジェクトで利用できるものです)の構築に関するロードマップについては、今後のポストでさらに書いていきます。
このポストの残りでは「10項目」について掘り下げ、Arrowプロジェクトがそれらにどのように対処しようとしているのかを説明します。
1. 物理層への接近
列ベースのArrowのメモリは、それが文字列であれ数値であれ、あるいはネストした型であれ、すべてランダムアクセス(単一の値)やスキャン(隣り合っている複数の値)のパフォーマンスのために最適化された連続的なメモリバッファーに配置されています。ここで念頭に置いているのは、テーブルの列内のデータに対するループ処理を行う際に、例えそれが文字列やその他の非数値型のデータであっても、CPUやGPUのキャッシュミスを最小限に抑えるということです。
pandasでは、文字列の配列はPyObjectポインターの配列であり、実際の文字列データはPyBytesもしくはPyUnicodeといった構造が保持しています。そしてこれらはすべて、プロセスヒープ内に散らばっているのです。開発者として、私たちはこれらのオブジェクトを処理する際の効率の悪さやメモリの制約によって苦労させられています。Pythonでは、**'wes'という単純な文字列は52バイトのメモリを占めます。''**なら49バイトです。この事に関する問題についての素晴らしい議論については、Jake Vanderplasの素晴らしい記事Why Python is Slowを参照してください。
Arrowでは、それぞれの文字列はメモリ内で一つ前の文字列の隣に置かれるので、文字列データの列内にあるすべてのデータをスキャンする際にキャッシュミスは生じません。連続的なバイトの処理がそのまま物理層に反映されることが保証されているのです。
ArrowのC/C++ APIを使うということは、Pythonに着いて何も知らないアプリケーションが自然なままのArrowのテーブルを生成したり利用したりでき、それらをインプロセスで、あるいは共有メモリ/メモリマップを通じて共有できるようになるということです。pandasにdata frameのためのCやCython APIがないこともまた、長年の大きな問題だったのです。
2. 巨大なデータセットのメモリマッピング
pandasにおけるメモリ管理の問題で最も大きなものを一つえらぶなら、おそらくそれはデータをすべてRAMにロードしなければ処理できないということでしょう。pandas内部のBlockManagerはあまりに複雑であり、現実的にはメモリマッピングの環境下で使うことはできないのです。そのため、pandas.DataFrameを生成するたびに変換とコピーを実行しなければなりません。
Arrowのシリアライゼーションの設計には、テーブル内でのすべての列のすべてのメモリバッファの正確な位置とサイズを記述する「データヘッダー」があります。これはすなわち、巨大でRAMの容量を超えるデータセットをメモリマップでき、現在のpandasのようにデータをメモリにロードすることなくpandasスタイルのアルゴリズムをデータに対して適用できるということです。1TBのテーブルの中にある1MBのデータを読み取っても、そのためのランダムリードの合計もまた1MBだけで済ませられるのです。現代的なSSDを使うなら、これは一般にいい戦略です。
Arrowではメモリマッピングが可能なことによって、一つの巨大なデータセットを移動させたりコピーしたりすることなく複数のプロセッサで処理できるようにもなります。このことについては、UCバークレーのRayプロジェクトで使われているPlasma Object Store(これは現在ではArrowの一部になっています)で素晴らしい効果がありました。
3. 高速なデータ交換(データベースとファイルフォーマット)
効率的なメモリレイアウトと型メタデータのおかげで、Arrowはデータベースから取り込まれたデータや、Apache Parquetのような列指向のストレージファイルフォーマットのための理想的なコンテナーにもなっています。
Arrowのプリミティブな構造の一つは、「レコードバッチストリーム」という概念です。これは、アトミックなテーブル群の並びをまとめて一つの大きなデータセットとするものです。このストリーム処理のデータモデルは、データベースカーソルからレコードのストリームを提供するようなデータベースの発想です。
私たちは、Parquetフォーマットとの高速なコネクターを開発してきました。また、ODBCベースのデータベース接続に最適化されたturboodbcプロジェクトも見てきました。
これ以外にも、私としては以下のような多くのファイルフォーマットやデータベースのためのArrowネイティブのコネクターをぜひ構築したいと考えています。
- SQLite
- PostgreSQL
- Apache Avro
- Apache ORC
- CSV(pandas.read_csvの改善バージョン)
- JSON
4. 欠損値の適切な扱い
Arrowでは、すべての欠損データは他のデータとは別のパックされたビット配列として表現されます。こうすることで欠損データの扱いはシンプルになり、すべてのデータ型を通じて一貫することになります。また、nullビットに対する分析(ビットマップのAND処理やセットされたビットのカウント)に高速なビット単位のハードウェア演算命令やSIMDが使えるようになります。
配列中のnullカウントも明示的にメタデータ中に保存されるので、データにnullが含まれていなければnullチェックをスキップする高速なコードパスを選択できるようになります。pandasでは、配列はnullを表現する値を持たないことを前提にできなかったので、ほとんどの分析処理はパフォーマンスを損なうnullチェックを余分に行わなければなりませんでした。nullがないのであれば、nullのためのビット配列をアロケートする必要さえもありません。
NumPyが欠損データを直接サポートしていないことから、時間の経過と共に私たちはパフォーマンスが重要な主要なアルゴリズムについて、独自にnullを適切に扱えるバージョンを実装しなければなりませんでした。すべてのアルゴリズムやメモリ管理機能は、最初からnullを扱えるようになっているほうが良いのです。
5. メモリアロケーションの状況を常に調べておく
pandasでは、すべてのメモリはNumPyもしくはPythonインタープリタが持っており、あるpandas.DataFrameが使用しているメモリの量を正確に計ることが難しい場合があります。1行のコードによる一時的な割り当てによってプロセスのメモリフットプリントが2倍や3倍になり、場合によってはそのためにMemoryErrorが生じてしまうことも珍しくありません。
ArrowのC++実装では、すべてのメモリアロケーションは集中型の「メモリプール」で注意深く追跡されているので、ArrowのメモリがRAMの中でどれだけになっているのか、いつでも正確に知ることができます。親子関係を持つ「サブプール」を使用することで、アルゴリズム中の「最高水位」を正確に計測し、分析操作におけるメモリのピーク使用量を把握できます。この手法は、オペレーターの評価におけるメモリの使用量のモニタリングや制限のためのものとして、データベースでは一般的な手法です。利用可能なRAMを超えてしまうことが分かっているなら、ディスクへのスピルのような緩和策を採ることもできます(この場合、当然ながらディスク上のデータセットをメモリマップできることが鍵となります)。
Arrowでは、メモリはイミュータブルかコピーオンライトです。任意の時点で、見えているバッファを他の配列が参照しているかどうかが分かります。このことによって、防御的なコピーをせずに済みます。
6. カテゴリー型データの十分なサポート
2013年に私がプレゼンテーションをした時点では、pandasにはpandas.Categorical型はありませんでした。これは後から実装されたものなのです。しかし、NumPyにないデータ型に対するpandasの対処方法は、常に多少問題があるものでした。pandasの外に出てしまえば、pandasのカテゴリー型を扱うことはできません。拡張dtypeの実装はうまくいってはいたものの、pandasがNumPyと密結合になっていることから、多少後付けのものになっていたのです。
Arrawでは、カテゴリー型のデータは第一級市民であり、私たちはインメモリ、通信、共有メモリで一貫性のある効率的な表現を持たせることに重点を置きました。複数の配列間でのカテゴリー(Arrowでは ディクショナリと呼びます)の共有がサポートされています。
pandasには、これ以外にもユーザーが定義した型として、タイムゾーン付きのdatetimeと期間があります。私たちは論理データ型(これは物理的に特定のメモリ表現を持つものです)をArrowでうまくサポートして、あるシステムがArrowを使ってそのシステムのデータを忠実に転送するうえで、Arrowのフォーマットドキュメントに手を加える必要が生じないようにしたいと考えています。
7. groupby(...).applyの処理の改善
Arrowはgroupbyの処理の並列化を容易にすることによってこの改善を支援します。ここで述べている他の問題のために、**df.groupby(...).apply(f)**の処理を完全に並列化することは困難です。
8. data frameへのアペンド
pandasでは、DataFrame内の一つの列内の全データは同じNumPy配列中になければなりません。この要求は制約であり、SeriesやDataFrameオブジェクトを連結する際にメモリが2倍必要になったり追加の演算処理が生じたりする原因になることが頻繁にありました。
Arrow C++のテーブルの列はチャンク化することができるので、テーブルへのアペンドはゼロコピーの処理であり、複雑な演算やメモリのアロケーションを必要としません。設計で事前に備えておくことによって、ストリーミング、チャンク化されたテーブル、既存のインメモリテーブルへのアペンドは現在のpandasに比べて演算負荷が低くなります。チャンク化されたデータやストリーミングデータにそなえた設計をしておくことは、out-of-coreなアルゴリズムの実装においても欠かせないことなので、メモリに収まらない大きさのデータセットの処理のための基礎をも構築していることになります。
9. 新しいデータ型の追加
新しいデータ型の追加の複雑さには、複数のレイヤーがあります。
- 新しいメタデータの追加
- 分析のオペレーターの実装への動的なディスパッチルールの追加
- 処理の過程におけるメタデータの保持
たとえば「通貨」型は通貨の種類を文字列として持ち、データそのものの物理表現はfloat64もしくはdecimalになっているかもしれません。そうであれば、通貨の演算はその数値表現と同じように扱いながら、その数値処理の過程で通貨のメタデータは保持し続けなければなりません。
メタデータの保持に関するルールはオペレーターに依存するかもしれず、従って複雑になるかもしれないのです。
Arrowでは、メタデータの表現を演算処理の詳細やメタデータの保持から分離しました。C++の実装では、ユーザー定義型について事前に計画していたので、分析エンジンの構築にさらに注力するときになれば、ユーザー定義型のオペレーターのディスパッチと、メタデータの受け渡しのルールを生成できるようにすることが目標になるでしょう。
10/11. クエリプランニング、マルチコアでの実行
df[df.c < 0].d.sum()と書かれた場合、pandasは一時的なDataFrameとしてdf[df.c < 0]を生成し、それからその一時オブジェクトのd列の合計を取ります。仮にdfが多くの列を持っていたなら、これはばからしいほど無駄な処理になります。もちろん**df.d[df.c < 0].sum()**と書くことはできますが、それでも合計を取る前には一時的なSeriesが生成されてしまいます!
評価しようとしている式の全体像が分かっているなら、もっと処理をうまくやって、こういった一時的なアロケーションも回避できることは明らかでしょう。加えて、多くのアルゴリズム(この例も含め)は、コンピュータ上のすべてのプロセッサーコアにわたって並列化できます。
Arrowのための分析エンジンの構築の一部として、私たちは軽量な物理「クエリプランナー」を構築することも計画しています。これにはマルチコアのインプロセススケジューラーを持たせ、多くの種類のアルゴリズムを並列化し、効率的に評価できるようにします。グラフデータフローの実行という分野では、これまでに際だった技術が生まれている(特にTensorFlowやPyTorchなど、最近の機械学習の分野)ので、これはデータのプリミティブな単位をArrowのテーブルとするグラフデータフローエンジンを生み出すということになるでしょう。
このユースケースに備えた計画として、2015年に私はIbisプロジェクト(現在も開発が活発に進められています)を立ち上げ、pandasとの親和性が高い静的な分析とこの種の処理をコンパイルするための遅延表現(deferred expression)のシステムをつくることにしました。私がIbisに取り組み始めた時点では、pandasのためのマルチスレッド化された効率的なインメモリエンジンは存在しなかったので、その代わりに私はRのdplyrパッケージに似たSQLエンジン(Impala、PostgreSQL、SQLite)のためのコンパイラーの構築に注力しました。pandasのコアチームのPhillip Cloudは、とても長い期間にわたって私と共に活発にIbisの作業を続けてくれています。
この次は?
この先のblogポストでは、Arrowネイティブなマルチスレッド化されたインメモリ実行エンジンの構築のロードマップについてもっと詳しく述べていきます。また、このエンジンがpandas2のアーキテクチャとどのように関係するのかも述べていきます。
補遺:Daskについて
今日、多くの人々がDask(とSparkやその他の同様のプロジェクト)がpandasのパフォーマンスやスケーラビリティをどのように支援するのかということについて私に尋ねてきます。Daskが以下に示すような様々な形で役立つことは間違いありません。
- 大規模なデータセットを分割し、それらを個別のスレッドやプロセスで処理する
- 不要になったpandasのデータをRAMから退避させる
Daskは、pandas.read_csvを並列に実行することでCSVファイルを含むディレクトリからの読み取りを容易にし、データセット全体に対してgroupbyの処理を実行してくれます。Matt RocklinとDaskのチームは、ほんとうに素晴らしいものを作り上げました。
Daskのモデルが持つ一つの問題は、それがpandasをブラックボックスとして使っているということです。dask.dataframeはpandasが本来的に抱えているパフォーマンスやメモリの利用に関する問題を解決してはくれませんが、それらを複数のプロセスに分散させ、一度に扱うデータが大きくなりすぎて好ましからざるMemoryErrorが生ずることのないように注意深く問題を緩和させる役に立ちます。
Daskのパーティション並列分散タスク実行モデルには適さない問題もあります。また、pandasのメモリ管理とI/Oの問題のためにDaskのジョブの処理は非常に低速になってしまっており、もっと効率的なインメモリのランタイムがあればはるかに高速になるはずです。