Protobuf vs Flatbuffers vs Cap'n proto 哪个更快?
Protobuf vs Flatbuffers vs Cap'n proto which is faster?
我决定弄清楚 Protobuf、Flatbuffers 和 Cap'n proto 中的哪一个将是我的应用程序的 best/fastest 序列化。在我的例子中,通过网络发送某种 byte/char 数组(我序列化为该格式的原因)。因此,我对所有三个进行了简单的实现,其中我对字符串、浮点数和整数进行了序列化和反序列化。这给出了意想不到的结果:Protobuf 是最快的。我会称它们为意外,因为 cap'n proto 和 flatbuffes "claims" 都是更快的选择。在我接受这个之前,我想看看我是否无意中以某种方式在我的代码中作弊。如果我没有作弊,我想知道为什么 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 原型代码:
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
。按照您编写代码的方式,循环的每次迭代都会使消息变大,因为您实际上是在添加现有消息而不是开始新消息。为了避免每次迭代都分配内存,您应该将临时缓冲区传递给 MallocMessageBuilder
的构造函数。临时缓冲区可以在循环外分配一次,但每次循环都需要创建一个新的 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 进行基准测试。我在这个过程中学到的一件事是,您可以创建的最简单的基准测试不会给您实际的结果。
首先,任何序列化格式(甚至 JSON)都可以 "win" 给定正确的基准案例。根据内容的不同,不同格式的表现会非常非常不同。它是字符串重、数字重还是对象重(即具有深层消息树)?不同的格式在这里有不同的优势(例如,Cap'n Proto 非常擅长数字,因为它根本不转换它们;JSON 非常糟糕)。您的邮件大小是非常短、中等长度还是非常大?短消息将主要使用 setup/teardown 代码而不是正文处理(但 setup/teardown 很重要——有时现实世界的用例会涉及很多小消息!)。非常大的消息会破坏 L1/L2/L3 缓存,并告诉您更多关于内存带宽而不是解析复杂性的信息(但同样,这很重要——一些实现比其他实现对缓存更友好)。
即使考虑了所有这些,您还有另一个问题:运行 循环中的代码实际上并没有告诉您它在现实世界中的表现。当 运行 进入紧密循环时,指令缓存保持热状态,所有分支都变得高度可预测。因此,分支繁重的序列化(如 protobuf)将隐藏其分支成本,而代码足迹繁重的序列化(同样......如 protobuf)也将获得优势。这就是为什么微基准测试仅在将代码与自身的其他版本进行比较时真正有用(例如测试较小的优化),而不是将完全不同的代码库相互比较。要了解这些在现实世界中的表现如何,您需要端到端地测量真实世界的用例。不过……说实话,挺难的。很少有人有时间基于两个不同的序列化构建整个应用程序的两个版本,看看哪个版本胜出...
我决定弄清楚 Protobuf、Flatbuffers 和 Cap'n proto 中的哪一个将是我的应用程序的 best/fastest 序列化。在我的例子中,通过网络发送某种 byte/char 数组(我序列化为该格式的原因)。因此,我对所有三个进行了简单的实现,其中我对字符串、浮点数和整数进行了序列化和反序列化。这给出了意想不到的结果:Protobuf 是最快的。我会称它们为意外,因为 cap'n proto 和 flatbuffes "claims" 都是更快的选择。在我接受这个之前,我想看看我是否无意中以某种方式在我的代码中作弊。如果我没有作弊,我想知道为什么 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 原型代码:
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
。按照您编写代码的方式,循环的每次迭代都会使消息变大,因为您实际上是在添加现有消息而不是开始新消息。为了避免每次迭代都分配内存,您应该将临时缓冲区传递给 MallocMessageBuilder
的构造函数。临时缓冲区可以在循环外分配一次,但每次循环都需要创建一个新的 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 进行基准测试。我在这个过程中学到的一件事是,您可以创建的最简单的基准测试不会给您实际的结果。
首先,任何序列化格式(甚至 JSON)都可以 "win" 给定正确的基准案例。根据内容的不同,不同格式的表现会非常非常不同。它是字符串重、数字重还是对象重(即具有深层消息树)?不同的格式在这里有不同的优势(例如,Cap'n Proto 非常擅长数字,因为它根本不转换它们;JSON 非常糟糕)。您的邮件大小是非常短、中等长度还是非常大?短消息将主要使用 setup/teardown 代码而不是正文处理(但 setup/teardown 很重要——有时现实世界的用例会涉及很多小消息!)。非常大的消息会破坏 L1/L2/L3 缓存,并告诉您更多关于内存带宽而不是解析复杂性的信息(但同样,这很重要——一些实现比其他实现对缓存更友好)。
即使考虑了所有这些,您还有另一个问题:运行 循环中的代码实际上并没有告诉您它在现实世界中的表现。当 运行 进入紧密循环时,指令缓存保持热状态,所有分支都变得高度可预测。因此,分支繁重的序列化(如 protobuf)将隐藏其分支成本,而代码足迹繁重的序列化(同样......如 protobuf)也将获得优势。这就是为什么微基准测试仅在将代码与自身的其他版本进行比较时真正有用(例如测试较小的优化),而不是将完全不同的代码库相互比较。要了解这些在现实世界中的表现如何,您需要端到端地测量真实世界的用例。不过……说实话,挺难的。很少有人有时间基于两个不同的序列化构建整个应用程序的两个版本,看看哪个版本胜出...