実業務で大きなサイズのデータをブラウザと交換する機会があったのですが、その際のデコード処理の負荷が課題となっており、効率的にエンコード・デコードできるデータフォーマットを探していました。候補としてProtocol BuffersとMessagePackが挙がり、詳しくパフォーマンスを知るために今回検証を行いました。この記事ではその調査結果をご紹介します。

主にブラウザでデータを受け取るという観点から デコードが早いかどうか を中心に測定を行いました。

なお、計測に使った環境は次のとおりです。

  • MacBook Pro 14インチ、2021 (Apple M1 Pro) / 32GB
  • Node.js 20.5.0 (V8 11.3.244.8-node.10)
Passmark PerformanceTest 11.0.1000

CPU Mark                      22,049
Integer Math                  46,634 MOps/Sec
Floating Point Math           65,615 MOps/Sec
Prime Numbers                    281 Million Primes/Sec
Sorting                       33,605 Thousand Strings/Sec
Encryption                    12,497 MB/Sec
Compression                  280,803 KB/Sec
CPU Single Thread              3,902 MOps/Sec
Physics                        2,957 Frames/Sec
Extended Instructions (NEON)  11,078 Million Matrices/Sec

Memory Mark                     3,927
Database Operations	            9,486 KOps/Sec
Memory Read Cached             24,395 MB/s
Memory Read Uncached           22,894 MB/s
Memory Write                   24,052 MB/s
Latency                            26 ns
Memory Threaded               118,100 MB/s

MessagePack

MessagePackはJSONと同じスキーマレスなデータ構造です。

MessagePackのためのJavaScriptライブラリとしては、次が人気のようです。

インターネット上でも少し探すとベンチマークが見つかります。この結果を見ると、JSONの方が10倍ほど高速のようです。

バイナリフォーマットと比べてJSONのパフォーマンスがこれほど高いのは意外でした。その理由は@msgpack/msgpackのREADMEのベンチマークで説明されていました。

Run-time performance is not the only reason to use MessagePack, but it's important to choose MessagePack libraries, so a benchmark suite is provided to monitor the performance of this library.

V8's built-in JSON has been improved for years, esp. JSON.parse() is significantly improved in V8/7.6, it is the fastest deserializer as of 2019, as the benchmark result bellow suggests.

(和訳) MessagePackを使う理由は実行時のパフォーマンスだけではありません。それでもどのMessagePackのライブラリを選ぶのかは重要です。そのため、このライブラリのパフォーマンスを観察するためのベンチマークスイートを用意してあります。 V8に組み込まれたJSONライブラリは何年も掛けて改善されており、特にJSON.parse()はV8/7.6で目を見張る改善がなされました。下のベンチマーク結果が示唆するように、2019時点では最も早いデシリアライザです。

ChromeのJavaScript実行エンジンのV8はC++で書かれており、パフォーマンスが高いのは納得できます。

とはいえ、2019年からもう4年が経過しており、多少状況が変わっている可能性はあります。

ベンチマーク

そこで現時点の実際のパフォーマンスを知るために、@msgpack/msgpackのリポジトリ内のベンチマークを実行してみます。

比較しやすいように出力結果は整形してあります。また、比較のために一部のテストケースにJSONを追加しています。

npx node -r ts-node/register benchmark/benchmark-from-msgpack-lite.ts

Benchmark on NodeJS/v20.5.0 (V8/11.3)

operation                                         |   op    |   ms  |  op/s
------------------------------------------------- | ------: | ----: | ------:
(エンコード)
buf = Buffer.from(JSON.stringify(obj));           | 1084100 |  5000 |  216820
buf = require("msgpack-lite").encode(obj);        | 1001900 |  5000 |  200380
buf = require("@msgpack/msgpack").encode(obj);    | 1260600 |  5000 |  252120
buf = /* @msgpack/msgpack */ encoder.encode(obj); | 1311300 |  5000 |  262260
buf = require("msgpackr").pack(obj);              | 2915100 |  5000 |  583020
(デコード)
obj = JSON.parse(buf.toString("utf-8"));          | 1737500 |  5000 |  347500
obj = require("msgpack-lite").decode(buf);        |  575700 |  5000 |  115140
obj = require("@msgpack/msgpack").decode(buf);    | 1536000 |  5000 |  307200
obj = /* @msgpack/msgpack */ decoder.decode(buf); | 1546600 |  5000 |  309320
obj = require("msgpackr").unpack(buf);            | 1424700 |  5000 |  284940

node benchmark/msgpack-benchmark.js

./sample-large.json: (7598 bytes in JSON)

(encode) json             x 82,388 ops/sec ±0.54% (96 runs sampled)
(encode) @msgpack/msgpack x 31,050 ops/sec ±0.16% (100 runs sampled)
(encode) msgpack-lite     x 29,034 ops/sec ±0.76% (96 runs sampled)
(encode) notepack.io      x 28,766 ops/sec ±0.13% (98 runs sampled)
(encode) msgpackr         x 66,822 ops/sec ±0.65% (97 runs sampled)
(decode) json             x 80,895 ops/sec ±0.73% (98 runs sampled)
(decode) @msgpack/msgpack x 32,898 ops/sec ±0.12% (98 runs sampled)
(decode) msgpack-lite     x 15,715 ops/sec ±0.27% (98 runs sampled)
(decode) notepack.io      x 21,699 ops/sec ±0.90% (100 runs sampled)
(decode) msgpackr         x 67,720 ops/sec ±0.21% (99 runs sampled)

結果を見ると、デコーディングに関してはJSONが依然として早いことがわかります。デコーディングにおけるパフォーマンスが低い理由は、JSONと同じくスキーマレスで構造がわからないというのが大きな理由だと考えられます。

ライブラリの中でも msgpackrは早いことがわかります。MeasureThat.netにはmsgpackrの異なるテストケースが載っています。

数値や真理値等を中心としたデータの多次元配列をデコードする MsgPack Numbers Decode というテストケースではJSONと比べて2.6倍程度早いようです。データの読み取りが単純な数値であれば、決まったデータを読み取って変換(ビット演算等)するだけであり、早いのは納得できます。

一方、文字列のデコードを中心とした MsgPack String Decode はJSONに比べて遅いという結果が出ています。MessagePackでは、ヌル終端文字列ではなくPascal文字列を採用しています。つまり、文字列長を最初のバイトに含めるようになっています。これにより、読み取り速度を大幅に向上させられます。しかし、それでも遅いという結果が出ているということは、V8のC++で実装されたJSON.parse()の方がJavaScriptで書かれたmsgpackrよりも早いのだろうと推測しています。

文字列データが支配的でない単純な配列であれば、MessagePackの方が早いのではないかと考えられます。実際にそのようなデータを試してみたところ、JSONよりも1.5倍程度早いという結果が得られました。とはいえ、配列の何番目にデータが入っているという知識を事前に共有しておく必要があり、データ構造の変更に注意を払う必要が出てきます。実用性を考えると好ましいとはいえないでしょう。なお、ここでは Chrome/115.0.5790.114 (V8 11.5.150.16) を計測に利用しました。

エンコードしたデータ

[
  ["hello", 123, true],
  ["world", 456, false],
  ["java", 541, true],
  ["javascript", 231, false],
  ["c", 852, true],
  ["cpp", 142, false],
  ["basic", 431, true],
  ["python", 591, false],
  ["hello", 123, true],
  ["world", 456, false],
  ["java", 541, true],
  ["javascript", 231, false],
  ["c", 852, true],
  ["cpp", 142, false],
  ["basic", 431, true],
  ["python", 591, false],
]
operation              |   op
---------------------- | ------:
JSON Numbers Decode    |   968,421 ops/sec ±0.83% (68 runs sampled)
JSON String Decode     | 1,104,092 ops/sec ±0.54% (69 runs sampled)
JSON Simple Decode     |   923,841 ops/sec ±0.22% (69 runs sampled)
MsgPack Numbers Decode | 1,593,083 ops/sec ±0.17% (69 runs sampled)
MsgPack String Decode  |   994,357 ops/sec ±1.79% (65 runs sampled)
MsgPack Simple Decode  | 1,366,154 ops/sec ±0.13% (69 runs sampled)

MessagePack自体はスキーマレスなデータ形式ですが、亜種のmsgpackrではスキーマを定義するための仕組みもあるようです。msgpackrに掲載されているベンチマークを実行してみました。

operation                                                  |    op    |   ms  |   op/s  |    size   |
---------------------------------------------------------- | -------: | ----: | ------: | --------: |
(エンコード)
msgpackr w/ shared structures: packr.pack(obj);            | 15845200 |  5000 | 3169040 |           |
msgpackr w/ random access structures: packr.pack(obj);     | 16235200 |  5000 | 3247040 |           |
require("msgpackr").pack(obj);                             | 10741900 |  5000 | 2148380 |           |
bundled strings packr.pack(obj);                           |  9101900 |  5000 | 1820380 |           |
buf = Buffer(JSON.stringify(obj));                         |  6359300 |  5000 | 1271860 |           |
(デコード)
msgpackr w/ shared structures: packr.unpack(buf);          | 34135500 |  5000 | 6827100 |        63 |
msgpackr w/ random access structures: packr.unpack(buf);   | 21832500 |  5000 | 4366500 |        58 |
require("msgpackr").unpack(buf);                           |  6387800 |  5000 | 1277560 |       143 |
bundled strings packr.unpack(buf);                         | 15376400 |  5000 | 3075280 |        74 |
obj = JSON.parse(buf);                                     |  7323000 |  5000 | 1464600 |       180 |

require("msgpackr").unpack(buf) とJSONでは、JSONのほうが早いという結果が出ています。一方、スキーマを定義したmsgpackr w/ shared structuresでは、4.7倍という非常に早い結果が出ています。

上記のベンチマーク結果を見ると、JSONのデコードとMessagePackのデコードのパフォーマンスに大きな差がないため、JSONと比べてもおよそ4倍程度の差だと推測できます。これは、後述するスキーマベースのProtocol BuffersがJSONと比べて4倍程度早いこととも一致します。

Protocol Buffers

Protocol Buffersは、スキーマに基づくデータフォーマットです。スキーマはデータ構造の定義のことで、これを事前に定義して送信者と受信者で共有し、エンコードとデコード時に用います。JSONやMessagePackでは、実際にデータを読み取らなければ構造がわかりませんでしたが、Protocol Buffersでは事前に分かっているため、高いパフォーマンスが期待できます。

Protocol BuffersのためのJavaScriptライブラリとしては、protobuf.jsgoogle-protobufが人気のようです。

protobuf.jsのREADMEに載っているベンチマーク結果は、i7-2600KとNode.js 6.9 (2016年)のもので結果が古くなっています。

ベンチマーク

protobuf.jsのリポジトリ内のベンチマークを実行してみます。

ただし、以下の点で変更があります:

  • 静的コードの再生成
    • pbjs --target static data/bench.proto > data/static_pbjs.js
  • google-protobufの更新
    • 3.11.3 -> 3.21.2
> protobufjs@7.2.4 bench
> node bench

benchmarking encoding performance ...

protobuf.js (reflect) x 1,544,621 ops/sec ±0.21% (96 runs sampled)
protobuf.js (static)  x 1,580,495 ops/sec ±0.17% (97 runs sampled)
JSON (string)         x 1,165,063 ops/sec ±0.18% (95 runs sampled)
JSON (buffer)         x   872,308 ops/sec ±0.19% (97 runs sampled)
google-protobuf       x 1,015,041 ops/sec ±0.16% (99 runs sampled)

   protobuf.js (static) was fastest
  protobuf.js (reflect) was  2.3% ops/sec slower (factor 1.0)
          JSON (string) was 26.3% ops/sec slower (factor 1.4)
        google-protobuf was 35.8% ops/sec slower (factor 1.6)
          JSON (buffer) was 44.8% ops/sec slower (factor 1.8)

benchmarking decoding performance ...

protobuf.js (reflect) x 3,936,820 ops/sec ±0.14% (99 runs sampled)
protobuf.js (static)  x 4,269,989 ops/sec ±0.20% (97 runs sampled)
JSON (string)         x   949,356 ops/sec ±0.14% (99 runs sampled)
JSON (buffer)         x   868,264 ops/sec ±0.15% (100 runs sampled)
google-protobuf       x   909,006 ops/sec ±0.18% (95 runs sampled)

   protobuf.js (static) was fastest
  protobuf.js (reflect) was  7.7% ops/sec slower (factor 1.1)
          JSON (string) was 77.8% ops/sec slower (factor 4.5)
        google-protobuf was 78.7% ops/sec slower (factor 4.7)
          JSON (buffer) was 79.7% ops/sec slower (factor 4.9)

benchmarking combined performance ...

protobuf.js (reflect) x 1,057,638 ops/sec ±0.14% (96 runs sampled)
protobuf.js (static)  x 1,106,991 ops/sec ±0.19% (98 runs sampled)
JSON (string)         x   491,279 ops/sec ±0.16% (99 runs sampled)
JSON (buffer)         x   420,535 ops/sec ±0.21% (99 runs sampled)
google-protobuf       x   462,041 ops/sec ±0.31% (95 runs sampled)

   protobuf.js (static) was fastest
  protobuf.js (reflect) was  4.4% ops/sec slower (factor 1.0)
          JSON (string) was 55.6% ops/sec slower (factor 2.3)
        google-protobuf was 58.3% ops/sec slower (factor 2.4)
          JSON (buffer) was 62.0% ops/sec slower (factor 2.6)

公式サイトに掲載されているデータは、Node.js 6.9.1(V8 5.1系)であり、JSON.parse()の改善前のものでした。 今回のNode.js v20によるデコードの結果を見ると、差はやや縮まっているように見えますが、それでも4倍程度はprotobuf.jsのほうが早いことがわかります。

Protocol Buffersがなぜこんなにも早いのかを考察してみます。MessagePackでは文字列のデコードが遅いことが分かっていましたが、Protocol Buffersでも文字列の表現(Pascal文字列)には違いがありません。そのため、(検証は行っていないのですが)Protocol Buffersでも文字列が多いデータのデコードは早くないだろうと予想できます。MessagePackでオブジェクト(Map型=JSONのオブジェクトのような連想配列)を表現したい場合、フィールド名を文字列として表現しなければなりません。一方、Protocol Buffersではスキーマという形でフィールド名が事前に送信側と受信側で共有されており、フィールド名の代わりにフィールドのID(整数値)を使ってエンコードが行われます。Protocol Buffersは文字列のエンコードをしなくてよい分、早いのだろうと思います。他の要因としてデコード処理の効率化の程度の違いやデコード時の型チェックの省略による影響も考えられます。

まとめ

Protocol Buffers(特にprotobuf.js)は非常に早く、 JSONと比べて4倍以上高速 にデコードできることがわかりました。

MessagePackはJSONより遅くなる場合がありました。特に文字列を中心としたデータではパフォーマンスが出ないようです。一方、数値を中心とした単純な配列のようなデータであれば高いパフォーマンスを示しました。また、事前にスキーマを用意しておける亜種のmsgpackrを使えばProtocol Buffersに近い性能を発揮できるようです。

スキーマベースのデータフォーマットが高いパフォーマンスを発揮できる理由は、構造の情報を事前に共有してあることで、プロパティ名・フィールド名(JSONのキーに相当)のような構造の情報を実行時に読み取る必要がないためだろうと思われます。

ただ、桁が変わるほど、つまり10倍、100倍、1000倍といった大きな差があるわけではありませんでした。数KBのデータのデコードにかかる時間は数マイクロ秒程度であり、1000倍のデータ(数MB)がやってきたとしても単純計算で数ミリ秒しか掛かりません。ブラウザ上で更に大きなデータを大量に扱うという場合であれば役立つ可能性はありますが、パフォーマンスだけを考慮するならば、多くのケースでJSONでも十分だと考えられます。

一方、大量のリクエストやデータに応えなければならないサーバ側では、大量のデコード処理を行う必要が出てくるでしょう。サーバ側で使われるCやJavaのようなコンパイル型の言語であればバイナリフォーマットされたデータのデコードにおいてJavaScriptの実装よりも高いパフォーマンスを発揮できると予想されます。デコード処理を効率化できれば、その他のことにCPU時間を利用できるため、パフォーマンスは僅かながら向上するのではないかと期待できます。大規模システムではその小さなパフォーマンスの差が費用に大きな違いを生むかもしれません。

パフォーマンス以外にもスキーマベースのプロトコルには様々なメリットがあることを忘れてはなりません。スキーマによって送受両端でフォーマットの食い違いを予防すること、データフォーマットを安全に更新できること、型安全なプログラミングを実現できること、gRPC等のフレームワークに則った開発ができることなどといった利点があります。そういった他の効果も踏まえて検討をする必要があります。

今回は調べられなかったProtocol Buffersにおける文字列のデコード速度は、いつか測定できればなと考えています。また、今回はライブラリのテストケースをそのまま実行しましたが、条件の異なる様々なテストケースを作成して比較してみたいと考えています。

今回の結果はNode.jsで測定したもので、ブラウザによってはベンチマークの結果が大きく変わります。今回は記載していませんが、Safari(JavaScriptCore)や Firefox(SpiderMonkey)では JSON.parse() のパフォーマンスが異なります(その場合でもフォーマット間のパフォーマンスの差という意味ではあまり変わりません)。