GeorgettaC 发表于 2023-3-22 19:49

Protobuf性能问题记录

写在前面

最近组里有个项目要在原有的HTTP+RESTful协议基础上,新增支持grpc协议,目的之一是为了进一步提升服务性能,毕竟业界一直有说法:grpc性能优于HTTP+RESTful。然而,同事压测发现当数据量上升,grpc的性能反而不及HTTP+RESTful。这一下就炸开了锅:“这怎么可能呢?你的代码写错了吧?”,“哦?这是为什么?”
笔者也是吃瓜群众之一,于是决定查一查原因。很快,就找到了这个问题:Why is gRPC so much slower than an HTTP API sending an array,高赞答案回答如下:
The reason gRPC -- well, really protobufs -- doesn’t scale well in your example is that every entry of your repeated field results in protobuf needing to decode a separate field, and there is overhead related to that. You can see more details about the encoding of repeated fields in the docs here. You're using proto3, so at least you don't need to specify the option, although that helps somewhat if you're on proto2.
The reason switching to a string or bytes field speeds it up so much is that there is only a constant decoding cost for this field which doesn't scale with the amount of data that's encoded in the field (not sure about JS though, which might need to create a copy of the data, but clearly that is still much faster than actually parsing the data). Just make sure your protocol defines what format / endianness the data in the field is :-)
Answering your question at a higher level, sending multiple megabytes in a single API call is usually not an amazing idea anyway -- it ties up a thread on both the server and client for a long time which forces you to use multithreading or async code to get reasonable performance. (Admittedly might be less of an issue since you are used to writing async stuff on Node, but there's still only so many CPUs to burn on the server.)
Depending on what you're actually trying to do, a common pattern can be to write the data to a file in a shared storage system (S3, etc.) and pass the filename to the other service, which can then download it when it's actually needed.笔者简单翻译如下:
grpc底层用protobufs做数据协议,而它的repeated字段每个都需要单独编解码,大量使用repeated字段会导性能退化。可以使用优化,但是这个只在proto2起作用。
可以使用string 或者 bytes字段,它的编解码只有常数时间。不过在JS中好像会拷贝数据,但是它仍然比较快。
另外,抽出来看这个问题,不建议在一个API的单次调用中发送大量数据,这会balabala...可以知道,对于Protobuf来说,repeated是一个性能坑!
那么问题来了,它有多坑?
实验一:Protobuf

首先,给出笔者的proto文件:
syntax = "proto3";

message MyMessage {
    repeated float float_vals = 1;
    bytes bytes_vals = 2;
};
笔者写了2个实验,分别测试相同的数据,喂给只有repeated float字段和只有bytes字段的差别。笔者的代码如下:
(备注:经过提醒,这里需要把numpy和pb的float对齐,其对应关系:pb float ~ numpy float32)
import json
import sys
import time

import numpy

from proto import message_pb2

TRY_CNT = 100


def _test_pb_serialization(pb_obj):
    start = time.time()
    for i in range(TRY_CNT):
      data = pb_obj.SerializeToString()
    end = time.time()
    data_size = sys.getsizeof(data)
    cost_time_ms = (round(end * 1000) - round(start * 1000)) / TRY_CNT
    return data_size, cost_time_ms

def test_normal_pb_repeated_float(min_size, max_size):
    for sz in range(min_size, max_size):
      size = 2 ** sz
      nd = numpy.random.rand(size).astype(numpy.float32)
      message1 = message_pb2.MyMessage()
      message1.float_vals.extend(nd)
      data_size, cost_time_ms = _test_pb_serialization(message1)
      print("{},{},{},{}".format(sz, size, data_size, cost_time_ms))
      

def test_normal_pb_bytes(min_size, max_size):
    for sz in range(min_size, max_size):
      size = 2 ** sz
      nd = numpy.random.rand(size).astype(numpy.float32)
      message2 = message_pb2.MyMessage()
      message2.bytes_vals = nd.tobytes()
      data_size, cost_time_ms = _test_pb_serialization(message2)
      print("{},{},{},{}".format(sz, size, data_size, cost_time_ms))


if __name__ == '__main__':
    test_normal_pb_bytes(1, 30)
    test_normal_pb_repeated_float(1, 30)笔者把结论整理如下:
1. 时间性能对比
图表说明:横坐标为float个数(个),纵坐标为耗时(ms)。黄色为repeated耗时曲线,蓝色为bytes耗时曲线。


可以说非常明显了,repeated 的斜率远高于bytes,这也就是说,随着float个数上升,两者耗时差距会越拉越大。也就是说, repeated性能退化非常厉害。
2. 压缩率对比
图表说明:横坐标为float个数(个),纵坐标为序列化后的包大小(MB)。黄色为repeated耗时曲线,蓝色为bytes耗时曲线。


~~哎,我们可以看到在压缩率上是相反的结论:repeated的压缩率比bytes的压缩率要更优秀。相同个数的float,存成前者的包要小于后者。~~【这是删除线】
笔者经过提醒,更正实验发现:protobuf的压缩在repeated float和bytes上是一样的,原因是protobuf有varint编码,能够在int类型数据上发挥巨大作用,而对float类型数据无用。
因此,综合序列化时间和压缩率来看,随着数据量上升,在压缩率不变的情况下,repeated时间性能退化到简直不可用,此时,建议转成bytes。
实验二:Protobuf 和 Json

笔者又开始好奇,grpc究竟为什么比HTTP+RESTful更好呢?
简单搜了答案,总结两点:

[*]grpc底层用了HTTP2.0,优于HTTP1.0
[*]grpc采用Protobuf,其性能优于Json(考虑到目前Json还是RESTful主流的数据协议)
第一点,笔者是认可的;不过第二点,真的么?
于是,又加了Json序列化的代码:
def test_normal_json_repeated_float(min_size, max_size):
    for sz in range(min_size, max_size):
      size = 2 ** sz
      nd = numpy.random.rand(size)
      message1 = {"float_vals": nd.tolist()}
      data_size, cost_time_ms = _test_json_serialization(message1)
      print("{},{},{},{}".format(sz, size, data_size, cost_time_ms))
      

def test_normal_json_bytes(min_size, max_size):
    for sz in range(min_size, max_size):
      size = 2 ** sz
      nd = numpy.random.rand(size)
      message2 = {"bytes_vals": str(nd.tobytes())}
      data_size, cost_time_ms = _test_json_serialization(message2)
      print("{},{},{},{}".format(sz, size, data_size, cost_time_ms))


if __name__ == '__main__':
    test_normal_json_bytes(1, 30)
    test_normal_json_repeated_float(1, 30)1. 时间性能对比
图表说明:横坐标为float个数(个),纵坐标为耗时(ms)。浅黄色为json repeated耗时曲线,深橘色为pb repeated耗时曲线,灰色为json bytes耗时曲线,蓝色为pb bytes耗时曲线。


很明显,pb bytes < json bytes < pb repeated < json repeated。
2. 压缩率
图表说明:横坐标为float个数(个),纵坐标为序列化后的包大小(MB)。浅黄色为json repeated耗时曲线,深橘色为pb repeated耗时曲线,灰色为json bytes耗时曲线,蓝色为pb bytes耗时曲线。


很明显,pb repeated = pb bytes < json repeated < json bytes。
因此,就压缩率来说,无论如何存储,Protobuf的压缩率要远优秀于Json;而在时间性能上,Protobuf的repeated性能和Json差不多,但是bytes的性能则远优秀于Json。
我们确确实实可以相信:无论是时间性能还是压缩性能,Protobuf比JSON更优秀。但是我们还是要规避大量频繁使用repeated。

写在后面

这里其实还遗留一个问题:使用大量repeated的grpc不及HTTP+RESTful,后者的Json是如何使用的?因为我们看到如果直接是repeated(Json里面是list),那么应该是差和更差。可见内部对该数据做了一定优化。
哈哈,笔者这里也得到了回复:在HTTP+RESTful使用上,是对list数据,或者说是numpy数组数据,先做了base64 encode,再塞进Json。这种做法其实就类似笔者实验中的Json bytes,因此也就解释通了。
以及,笔者也准备接这个机会,好好看一看grpc内部的实现,mark下。
最后,谢谢提醒笔者实验有纰漏的同学,笔芯~

Ilingis 发表于 2023-3-22 19:58

游戏公司大部分用pb,因为流量也是个重要指标

DungDaj 发表于 2023-3-22 20:07

[赞同] 流量这点,我没接触到这样的场景,现在知道了,挺好的。听说json基础上,可以再加一个gzip,压缩率更高,就是不知道性能怎么样。

ChuanXin 发表于 2023-3-22 20:16

可以得,json pb都可以gzip再压。我们一般会用。

kirin77 发表于 2023-3-22 20:24

实际测试下来,gzip压缩的时间性能如何?

franciscochonge 发表于 2023-3-22 20:34

go里压缩等级默认的情况下,压缩比例大概是 20%-90%不等,和对象本身结构关系比较大。对一个100k对象做压gzip压缩大概就是1000纳秒这个级别。可以忽略不计,因为通常游戏瓶颈不在cpu。

mastertravels77 发表于 2023-3-22 20:41

那比我想象的快多了[赞]
页: [1]
查看完整版本: Protobuf性能问题记录