POSTD PRODUCED BY NIJIBOX

POSTD PRODUCED BY NIJIBOX

ニジボックスが運営する
エンジニアに向けた
キュレーションメディア

Ionel Cristian Mărieș

本記事は、原著者の許諾のもとに翻訳・掲載しております。

私は、多数の大容量のデータをあちこちに移動させなければならない(クライアント端末を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上でしか動作しません。

yajlujson は、十分に完成度が高いにもかかわらず多くのメモリを食います。もっと良い方法があるはずです…。

何を選んでも短所があるようです。ここにぴったりの格言があります ^(5) 。

問題解決の最善策はそもそも問題を持たないことである

顧客が「 何か を必要としている」と頼むとき、本当に必要としているものは、要求しているものよりももっとシンプルで低コストのものなのです。要件についてよく話し合い、精査すれば、問題の多くはその時点で解決します。これはそのような状況なのです。私のケースでJSONが全く必要なかったともっと早く気付いていれば…。

HTTP APIのフォーマットを変えるにはまだまだ手直しが必要です。しかし、 cjsonrapidjson のバインディングを自力でメンテナンスしたり修正したりするよりはマシです。

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には cjsonjsonlib がありません。 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 を使い、さらにいくつか実装してベンチマークを行いました。

最新の結果はこちらです。


  1. カーゴ・カルトの犠牲になってしまったようです。

  2. ただし、メモリを過度に使用して、何もかもをスワッピングするLinuxほどではありません。これを、カーネルのことを知らない発言だとして片づけてしまうこともできますが、根本的には同じことです。スワップ領域を壊すと大変だということです。

  3. cjson の問題点を指摘できる人は実際のところ存在しないようです。 jsonlib の作成者でさえ 不安げな投稿 をしており、そのうわさ話を信じている人もいるようです。 事実、StackOverflowはうわさ話の転載で埋め尽くされていました。 cjson のエンコードと適合性には本当に問題があり、彼は正しいかもしれないが、正しい説明の仕方ではないでしょう。また、なんというか、皮肉なことに、あの投稿の1年後から jsonlib の更新は止まったままです…。

  4. この C++ JSONライブラリベンチマーク にrapidjsonがあることに気付きました。有効なPythonバインディングのある唯一のライブラリだったようでした。

  5. この出所はどこでしょう。出典がわかりましたらコメントしてください。

  6. HTTP APIで使うようなものではありません。セキュリティの問題以外に、pythonのバージョンが混在するという問題があります。それでもどんな動きができるのかを知りたくなります。

  7. Msgpack での多言語対応とunicode非対応が、お互いに影響しあうことはありません。

  8. script -c tox | ansi2html みたいな感じで生成。

  9. 読者からフィードバックされたコメントが REadditHacknewsGoogle+ にあります。