私は、Protobuf、Flatbuffers、Cap'n protoのどれがアプリケーションにとって最良/最速のシリアライゼーションになるかを判断することにしました。私の場合、ネットワークを介してある種のバイト/文字配列を送信します(その形式にシリアル化した理由)。だから私は文字列、浮動小数点数、整数をシリアライズしてデシリアライズする3つすべての簡単な実装を作りました。これは予想外の結果をもたらしました:プロトブフが最速です。 cap'n protoとflatbuffesの両方が「クレーム」を高速化するため、私はそれらを予想外と呼びます。これを受け入れる前に、どういうわけか私が自分のコードを誤って騙したかどうかを確認したいと思います。私がカンニングをしなかったのなら、なぜprotobufの方が速いのかを知りたいのです(正確にはなぜ不可能でしょう)。メッセージは、cap'n protoとfaltbuffersを実際に輝かせるための簡単なものですか?
私のタイミング:
フラットバッファにかかる時間:14162マイクロ秒
所要時間capnp:60259マイクロ秒
所要時間protobuf:12131マイクロ秒
(明らかにこれらは私のマシンに依存していますが、重要なのは相対的な時間です)
フラットバッファコード:
int main (int argc, char *argv[]){
std::string s = "string";
float f = 3.14;
int i = 1337;
std::string s_r;
float f_r;
int i_r;
flatbuffers::FlatBufferBuilder message_sender;
int steps = 10000;
auto start = high_resolution_clock::now();
for (int j = 0; j < steps; j++){
auto autostring = message_sender.CreateString(s);
auto encoded_message = CreateTestmessage(message_sender, autostring, f, i);
message_sender.Finish(encoded_message);
uint8_t *buf = message_sender.GetBufferPointer();
int size = message_sender.GetSize();
message_sender.Clear();
//Send stuffs
//Receive stuffs
auto recieved_message = GetTestmessage(buf);
s_r = recieved_message->string_()->str();
f_r = recieved_message->float_();
i_r = recieved_message->int_();
}
auto stop = high_resolution_clock::now();
auto duration = duration_cast<microseconds>(stop - start);
cout << "Time taken flatbuffer: " << duration.count() << " microseconds" << endl;
return 0;
}
cap'n protoコード:
int main (int argc, char *argv[]){
char s[] = "string";
float f = 3.14;
int i = 1337;
const char * s_r;
float f_r;
int i_r;
::capnp::MallocMessageBuilder message_builder;
Testmessage::Builder message = message_builder.initRoot<Testmessage>();
int steps = 10000;
auto start = high_resolution_clock::now();
for (int j = 0; j < steps; j++){
//Encodeing
message.setString(s);
message.setFloat(f);
message.setInt(i);
kj::Array<capnp::Word> encoded_array = capnp::messageToFlatArray(message_builder);
kj::ArrayPtr<char> encoded_array_ptr = encoded_array.asChars();
char * encoded_char_array = encoded_array_ptr.begin();
size_t size = encoded_array_ptr.size();
//Send stuffs
//Receive stuffs
//Decodeing
kj::ArrayPtr<capnp::Word> received_array = kj::ArrayPtr<capnp::Word>(reinterpret_cast<capnp::Word*>(encoded_char_array), size/sizeof(capnp::Word));
::capnp::FlatArrayMessageReader message_receiver_builder(received_array);
Testmessage::Reader message_receiver = message_receiver_builder.getRoot<Testmessage>();
s_r = message_receiver.getString().cStr();
f_r = message_receiver.getFloat();
i_r = message_receiver.getInt();
}
auto stop = high_resolution_clock::now();
auto duration = duration_cast<microseconds>(stop - start);
cout << "Time taken capnp: " << duration.count() << " microseconds" << endl;
return 0;
}
protobufコード:
int main (int argc, char *argv[]){
std::string s = "string";
float f = 3.14;
int i = 1337;
std::string s_r;
float f_r;
int i_r;
Testmessage message_sender;
Testmessage message_receiver;
int steps = 10000;
auto start = high_resolution_clock::now();
for (int j = 0; j < steps; j++){
message_sender.set_string(s);
message_sender.set_float_m(f);
message_sender.set_int_m(i);
int len = message_sender.ByteSize();
char encoded_message[len];
message_sender.SerializeToArray(encoded_message, len);
message_sender.Clear();
//Send stuffs
//Receive stuffs
message_receiver.ParseFromArray(encoded_message, len);
s_r = message_receiver.string();
f_r = message_receiver.float_m();
i_r = message_receiver.int_m();
message_receiver.Clear();
}
auto stop = high_resolution_clock::now();
auto duration = duration_cast<microseconds>(stop - start);
cout << "Time taken protobuf: " << duration.count() << " microseconds" << endl;
return 0;
}
メッセージ定義ファイルが含まれていないため、メッセージ定義ファイルは単純であり、ほとんどの場合それとは関係ありません。
Cap'n Protoでは、複数のメッセージに対してMessageBuilder
をnot再利用する必要があります。コードの記述方法では、新しいメッセージを開始するのではなく、既存のメッセージに実際に追加しているため、ループを繰り返すたびにメッセージが大きくなります。各反復でのメモリ割り当てを回避するには、スクラッチバッファーをMallocMessageBuilder
のコンストラクターに渡す必要があります。スクラッチバッファはループの外側で1回割り当てることができますが、ループのたびに新しいMallocMessageBuilder
を作成する必要があります。 (もちろん、ほとんどの人はスクラッチバッファーを気にせず、MallocMessageBuilder
に独自の割り当てを行わせますが、このベンチマークでそのパスを選択する場合は、Protobufベンチマークを変更して新しいメッセージを作成する必要があります。単一のオブジェクトを再利用するのではなく、すべての反復のオブジェクト。)
さらに、Cap'n Protoコードはcapnp::messageToFlatArray()
を使用しています。これは、メッセージを入れてメッセージ全体をコピーするためにまったく新しいバッファーを割り当てます。これはCap'n Protoを使用する最も効率的な方法ではありません。通常、メッセージをファイルまたはソケットに書き込む場合は、このコピーを作成せずに、メッセージの元のバッキングバッファーから直接書き込みます。代わりにこれを試してください:
_kj::ArrayPtr<const kj::ArrayPtr<const capnp::Word>> segments =
message_builder.getSegmentsForOutput();
// Send segments
// Receive segments
capnp::SegmentArrayMessageReader message_receiver_builder(segments);
_
または、物事をより現実的にするために、capnp::writeMessageToFd()
および_capnp::StreamFdMessageReader
_を使用して、メッセージをパイプに書き込み、それを読み戻すことができます。 (公平を期すために、protobufベンチマークにパイプへの書き込みとパイプからの読み取りも行わせる必要があります。)
(私はCap'n ProtoとProtobuf v2の作者です。FlatBuffersに詳しくないので、そのコードに同様の問題があるかどうかコメントできません...)
ProtobufとCap'n Protoのベンチマークに多くの時間を費やしてきました。このプロセスで学んだことの1つは、作成できるほとんどの単純なベンチマークでは現実的な結果が得られないことです。
まず、適切なベンチマークケースを考えると、シリアル化形式(JSONも含む)は「勝つ」ことができます。さまざまなフォーマットは、コンテンツに応じて非常に異なって実行されます。文字列が重い、数値が重い、またはオブジェクトが重い(つまり、深いメッセージツリーがある)か?ここではフォーマットごとに異なる長所があります(たとえば、Cap'n Protoは数値を非常にうまく変換しません。JSONは非常に悪いです)。メッセージのサイズが信じられないほど短い、中程度の長さ、または非常に大きいですか?短いメッセージは、本体処理ではなくセットアップ/ティアダウンコードを実行します(ただし、セットアップ/ティアダウンは重要です。実際の使用例では、多数の小さなメッセージが含まれることがあります!)。非常に大きなメッセージは、L1/L2/L3キャッシュを破壊し、解析の複雑さよりもメモリ帯域幅について多くを伝えます(ただし、これは重要です-一部の実装は他の実装よりもキャッシュフレンドリーです)。
これらすべてを考慮した後でも、別の問題があります。ループでコードを実行しても、実際のコードが実際にどのように機能するかはわかりません。タイトなループで実行される場合、命令キャッシュはホットなままであり、すべての分岐は非常に予測可能になります。そのため、ブランチの多いシリアライゼーション(protobufなど)の分岐コストはラグの下で一掃され、コードフットプリントの多いシリアライゼーション(再びprotobufなど)も有利になります。このため、マイクロベンチマークは、コードを他のバージョンと比較する場合(マイナーな最適化をテストする場合など)にのみ役立ち、完全に異なるコードベースを相互に比較する場合には役立ちません。これが現実の世界でどのように機能するかを調べるには、現実のユースケースをエンドツーエンドで測定する必要があります。しかし...正直なところ、それはかなり難しいです。 2つの異なるシリアル化に基づいて、アプリ全体の2つのバージョンを作成して、どちらが勝つかを確認する時間のある人はほとんどいません...