私は、多数の大容量のデータをあちこちに移動させなければならない(クライアント端末をHTTP APIに接続してデータを取得します)ような特殊な使用事例を扱っています。なぜだか1、転送形式にはJSONが使われていました。ある時、その大容量のデータが、さらに巨大になったのです。数百メガバイトどころではありません。JSONのデコード処理を実行すると大量のRAMが使用されることが分かりました。たった240MBのJSONペイロードで4.4GBですよ。信じられません。2
組み込みのJSONライブラリを使っていて、まず「もっと性能の良いJSONパーサがあるはずだ」と思いました。そんなわけで、計測を始めたのです。
さて、メモリ使用量の計測はやっかいです。ps
コマンドを使ったり、/proc/<pid>
を見たりすることはできますが、断片的なスナップショットが得られるだけで、実際の最大使用量を求めることは難しいでしょう。幸いなことに、Valgrindは、どんなプログラムでもメモリの割り当てを追跡することができますし(カスタムメモリアロケータを使うためにすべて再コンパイルするのとは対照的に)、massifという素晴らしいツールもあります。
そこでValgrindを使ってちょっとしたベンチマークの作成に取り掛かりました。入力はこんな感じです。
{ "foo": [{ "bar": [ 'A"\\ :,;\n1' * 20000000, ], "b": [ 1, 0.333, True, ], "c": None, }] }
私のアプリケーションで問題となっているデータと非常によく似た構造を持つ240MBのJSONを得ることができました。
valgrind --tool=massif --pages-as-heap=yes --heap=yes --threshold=0 --peak-inaccuracy=0 --max-snapshots=1000 ...
を実行します。Python 2.7上では各パーサについて次のような結果が得られます。(Python 3.5上の結果については下にスクロールしてください)。
Peak memory usage (Python 2.7): cjson: 485.4 Mb rapidjson: 670.5 Mb yajl: 1,199.2 Mb ujson: 1,862.0 Mb jsonlib2: 2,882.7 Mb jsonlib: 2,884.2 Mb simplejson: 2,953.6 Mb json: 4,397.9 Mb
結果をご覧ください。私のサンプルデータがおかしいのではとおっしゃるかもしれません。しかし残念ながら、このようなデータに遭遇する場合があるのです。時折、わずかな文字列が恐ろしいほどの大きさの容量に拡大するのです。
jsonは重大な脆弱さをはらんでおり、入力の十数倍のメモリを必要とします。こんな結果になるとは。
cjsonを使うようにという結果を突き付けられました。VeryBadBugs™ を含んでいるうわさが出ていますが3、バグトラッカーの不足が、このプロジェクトを全く味気のないものにしているのだと思います。
rapidjson は、新く参入してきたパーサです4。しかし、Python 2 バインディングは、肝心な 部分が 欠けて いるようです。それでも、これがどのように動くのか、少なくともその考え方を知るのは興味深いことです。Python 3-onlyバインディングの方が完成度が高そうに見えます。しかし、残念ながら、今のところこのアプリケーションはPython 2上でしか動作しません。
yajlとujsonは、十分に完成度が高いにもかかわらず多くのメモリを食います。もっと良い方法があるはずです…。
何を選んでも短所があるようです。ここにぴったりの格言があります5 。
問題解決の最善策はそもそも問題を持たないことである
顧客が「何かを必要としている」と頼むとき、本当に必要としているものは、要求しているものよりももっとシンプルで低コストのものなのです。要件についてよく話し合い、精査すれば、問題の多くはその時点で解決します。これはそのような状況なのです。私のケースでJSONが全く必要なかったともっと早く気付いていれば…。
HTTP APIのフォーマットを変えるにはまだまだ手直しが必要です。しかし、cjson
やrapidjson
のバインディングを自力でメンテナンスしたり修正したりするよりはマシです。
msgpackを試してみたところ(さらに、怖いもの見たさで他の古いものも6)、このような結果が出ます。
Peak memory usage (Python 2): pickle: 368.9 Mb marshal: 368.9 Mb msgpack: 373.2 Mb cjson: 485.4 Mb rapidjson: 670.4 Mb yajl: 1,199.2 Mb ujson: 1,862.0 Mb jsonlib2: 2,882.7 Mb jsonlib: 2,884.2 Mb simplejson: 2,953.6 Mb json: 4,397.9 Mb
テストプログラムを見ると、msgpackで非常に特殊なオプションが使われていることに気付くでしょう。その理由は、Msgpackの初期バージョンが文字列の扱いをあまり得意としていなかったからで(扱う文字列型が1つでした 7)、特殊なオプションが必要なのです。
msgpack.dumps(obj, use_bin_type=True)
– バイト列には異なる型を使います。デフォルトのMsgpackではあらゆる種類の文字列を同じ型として扱い、元の型が何だったかわかりません。
Python 2では、
str
は、バイナリになります。unicode
は、文字列になります。
Python 3では、
* bytes
は、バイナリになります。
* str
は、文字列になります。
msgpack.loads(payload, encoding='utf8')
– 文字列をデコードします(結果としてunicode
が返されます)。
処理時間について
pytest-benchmarkを使った結果8です。
Speed (Python 2.7): ----------------------------------------------- Name (time in ms) Min ----------------------------------------------- test_speed[marshal] 59.2630 (1.0) test_speed[pickle] 59.4530 (1.00) test_speed[msgpack] 59.7100 (1.01) test_speed[rapidjson] 443.0561 (7.48) test_speed[cjson] 676.6071 (11.42) test_speed[ujson] 681.8101 (11.50) test_speed[yajl] 1,590.4601 (26.84) test_speed[jsonlib] 1,873.3799 (31.61) test_speed[jsonlib2] 2,006.7949 (33.86) test_speed[simplejson] 3,592.2401 (60.62) test_speed[json] 5,193.2762 (87.63) -----------------------------------------------
最短の処理時間だけを表示しました。テストの目的に合わせて実行した結果ですが、他に気になることがあれば、テストプログラムをご自分のコンピュータで試してください。
Python 3
問題を抱えた私のアプリケーションをPython 2の上だけで走らせるのは、まともな(と同時に悲しい)理由があってのことです。しかし、最新で最高の環境下でどうなるのかを探って自ら墓穴を掘る理由はありません。そのうち誰かが移植するでしょう…。
Peak memory usage (Python 3.5): marshal: 372.1 Mb pickle: 372.9 Mb msgpack: 376.6 Mb rapidjson: 668.6 Mb yajl: 687.3 Mb ujson: 1,578.9 Mb json: 3,422.3 Mb simplejson: 6,681.4 Mb Speed (Python 3.5) ----------------------------------------------- Name (time in ms) Min ----------------------------------------------- test_speed[msgpack] 69.0613 (1.0) test_speed[pickle] 69.9465 (1.01) test_speed[marshal] 74.9914 (1.09) test_speed[rapidjson] 337.5243 (4.89) test_speed[ujson] 902.8647 (13.07) test_speed[yajl] 1,195.4298 (17.31) test_speed[json] 4,404.9523 (63.78) test_speed[simplejson] 6,524.9919 (94.48) -----------------------------------------------
Python 3にはcjsonやjsonlibがありません。jsonlib2が生まれた背景すらわかりません。Msgpackを使う方が無難なようです。
異なる種類のデータ
この実験は、非常に偏ったデータを使っています。完全に例外的なデータ形式だと言う人がいるかもしれません。ですので、テストプログラムを使って、ご自身のデータでベンチマークすることをお勧めします。
しかし、ベンチマークが面倒なら、異なる種類のデータを使った結果がいくつかあります。これは、単に、入力データでメモリ使用量と処理時間がどのくらい変わるかを知るために行なった結果です。
小さいオブジェクトを大量に含むJSONの場合
189MBのcitylots.jsonでは驚くほど違う結果が出ます。
小容量のオブジェクトでは、明らかにsimplejsonが他よりも優れており、Python3上ではjsonの結果が大幅によくなっています。
Peak memory usage (Python 2.7): simplejson: 1,171.7 Mb cjson: 1,304.2 Mb msgpack: 1,357.2 Mb marshal: 1,385.2 Mb yajl: 1,457.1 Mb json: 1,468.0 Mb rapidjson: 1,561.6 Mb pickle: 1,854.1 Mb jsonlib2: 2,134.9 Mb jsonlib: 2,137.0 Mb ujson: 2,149.9 Mb Peak memory usage (Python 3.5): marshal: 951.0 Mb json: 1,059.8 Mb simplejson: 1,063.6 Mb pickle: 1,098.4 Mb msgpack: 1,115.9 Mb yajl: 1,226.6 Mb rapidjson: 1,404.9 Mb ujson: 2,077.6 Mb
処理時間について。
Speed (Python 2.7): ----------------------------------------------- Name (time in ms) Min ----------------------------------------------- test_speed[marshal] 3.9999 (1.0) test_speed[ujson] 4.2569 (1.06) test_speed[simplejson] 5.1105 (1.28) test_speed[cjson] 5.2355 (1.31) test_speed[msgpack] 5.9742 (1.49) test_speed[yajl] 6.1059 (1.53) test_speed[json] 6.3822 (1.60) test_speed[jsonlib2] 6.7880 (1.70) test_speed[jsonlib] 6.9587 (1.74) test_speed[rapidjson] 7.4734 (1.87) test_speed[pickle] 18.8649 (4.72) ----------------------------------------------- Speed (Python 3.5): ----------------------------------------------- Name (time in ms) Min ----------------------------------------------- test_speed[marshal] 1.1784 (1.0) test_speed[ujson] 3.6378 (3.09) test_speed[msgpack] 3.7226 (3.16) test_speed[pickle] 3.7739 (3.20) test_speed[rapidjson] 4.1379 (3.51) test_speed[json] 5.1150 (4.34) test_speed[simplejson] 5.1530 (4.37) test_speed[yajl] 5.9426 (5.04) -----------------------------------------------
さらに小さい容量のデータ
2.2MBというとても小さなcanada.jsonでは、さらに異なる結果が出ます。メモリ使用量は重要な指標とは言えません。
Peak memory usage (Python 2.7): marshal: 35.2 Mb cjson: 38.9 Mb yajl: 39.0 Mb json: 39.3 Mb msgpack: 39.5 Mb simplejson: 40.5 Mb pickle: 42.1 Mb jsonlib2: 47.4 Mb rapidjson: 48.5 Mb jsonlib: 48.8 Mb ujson: 50.9 Mb Peak memory usage (Python 3.5): marshal: 38.3 Mb pickle: 40.4 Mb yajl: 42.1 Mb json: 42.2 Mb msgpack: 42.7 Mb simplejson: 45.3 Mb rapidjson: 52.3 Mb ujson: 55.5 Mb
処理時間は、またしても異なる結果が出ます。
Speed (Python 2.7): ----------------------------------------------- Name (time in ms) Min ----------------------------------------------- test_speed[msgpack] 12.3210 (1.0) test_speed[marshal] 15.1060 (1.23) test_speed[ujson] 19.8410 (1.61) test_speed[json] 48.0320 (3.90) test_speed[cjson] 48.6560 (3.95) test_speed[simplejson] 52.0709 (4.23) test_speed[yajl] 62.1090 (5.04) test_speed[jsonlib2] 81.6209 (6.62) test_speed[jsonlib] 83.2670 (6.76) test_speed[rapidjson] 102.3500 (8.31) test_speed[pickle] 258.6429 (20.99) ----------------------------------------------- Speed (Python 3.5): ----------------------------------------------- Name (time in ms) Min ----------------------------------------------- test_speed[marshal] 10.0271 (1.0) test_speed[msgpack] 10.2731 (1.02) test_speed[pickle] 17.2853 (1.72) test_speed[ujson] 17.7634 (1.77) test_speed[rapidjson] 25.6136 (2.55) test_speed[json] 54.8634 (5.47) test_speed[yajl] 58.3519 (5.82) test_speed[simplejson] 65.0913 (6.49) -----------------------------------------------
こういう結果だからfreelistsといううまい利用法が考えられたのかも?
結論
処理速度もメモリ使用量もデータの構造に左右されます。処理速度がメモリ使用量に必ずしも比例するというわけではありません。
繰り返しますが、上記の数値を鵜呑みにしないで、ご自分のデータを使って自らベンチマークを行って下さい。たとえ、あなたのデータの形式が私のものと全く同じものであっても、あなたのコンピュータは私のコンピュータとは違った動きをするでしょう。あなたのコンピュータ(例えば、アーキテクチャ、共有ライブラリが異なります)上ではメモリ使用量すら違います。その上で、ベンチマークで使用した私のデータのどれかとあなたのデータがきっちり同じ形式になる見込みはあるでしょうか?
テスト環境のセットアップ
- Ubntu 14.04(少なくとも理論上、占有されていないホスト上の仮想マシン)
- Sandy Bridge i7(TurboBoostオフ、ただしクロック周波数のスケーリングはオン)
- Python 2.7.6
- Python 3.5.0
- Valgrind 3.11.0
上記セットアップは完璧ではありません。もし本当に関心があるのでしたら、テストプログラムをお使いください。
メモ
要約:ペイロードが大きいデコード中心の利用において、JSONデコーダーは、たびたび過度のメモリを使います。私はJSONをあきらめてMsgpackに変えました。
ご自分でテストプログラムを走らせてみて、ご自身の中での結論は決めてください。
⸻
いただいたいろいろなフィードバックを基に9 Valgrindの代わりにru_maxrss
を使い、さらにいくつか実装してベンチマークを行いました。
最新の結果はこちらです。
- 私のデータ(240Mb)
- citylots.json(189MB)
- canada.json(2.2Mb)
-
カーゴ・カルトの犠牲になってしまったようです。 ↩
-
ただし、メモリを過度に使用して、何もかもをスワッピングするLinuxほどではありません。これを、カーネルのことを知らない発言だとして片づけてしまうこともできますが、根本的には同じことです。スワップ領域を壊すと大変だということです。 ↩
-
cjson の問題点を指摘できる人は実際のところ存在しないようです。jsonlibの作成者でさえ不安げな投稿をしており、そのうわさ話を信じている人もいるようです。 事実、StackOverflowはうわさ話の転載で埋め尽くされていました。cjsonのエンコードと適合性には本当に問題があり、彼は正しいかもしれないが、正しい説明の仕方ではないでしょう。また、なんというか、皮肉なことに、あの投稿の1年後から jsonlibの更新は止まったままです…。 ↩
-
このC++ JSONライブラリベンチマークにrapidjsonがあることに気付きました。有効なPythonバインディングのある唯一のライブラリだったようでした。 ↩
-
この出所はどこでしょう。出典がわかりましたらコメントしてください。 ↩
-
HTTP APIで使うようなものではありません。セキュリティの問題以外に、pythonのバージョンが混在するという問題があります。それでもどんな動きができるのかを知りたくなります。 ↩
-
script -c tox | ansi2html
みたいな感じで生成。 ↩