但凡需要做序列化的地方,我们都离不开protobuf 了。至少在方案调研阶段,也会是一个必定提名的方案。最近弄了一个基于protobuf 的性能优化,是为记之,没准也对你有用。
一、将消息分层处理,提前序列化
protobuf本身并不支持将其中的某些字段提前编码,所以当一个大对象调用 SerializeToArray时,此时 pb 会逐个字段的、嵌套的序列化各个字段。且依据消息中的每个字段需要一个唯一的 tag,可以推断出,消息的序列化耗时与其消息中被填充字段的数量是呈正相关的。那么我们的优化方案出来了,可以将消息分层表达,把复杂的子消息,不以消息(Message)的形式放在上层消息中,而是将之序列化后,以bytes类型放在消息中。
这样做的好处:
- 减少单个 Message 中的所有字段(包括子消息),以提高编码速度。
- 由
bytes表达的这部分消息,可以提前编码,打散编码耗时避免卡顿。
同时,采取分层的编码, 并不会对解码端造成额外的负担。毕竟最终所要解析的数据量是不变的,唯一只是之前仅需调用一次ParseFromArray,而分层后需要多次调用。
此外,我们在 protobuf 的 guideline,也为这个优化找到了理论根据。按照guideline 的说法,我们应该将大的消息分割成各自独立的小的消息结构,而非一个大的整体的数据集。
二、将序列化对象逐步拆分,避免析构耗时(C++ Only)
场景:将一个超大(约1MB)的 pb 序列化后数据,通过 IPC 传递给另一个进程,另一个进程需要解析该消息,将其中的一部分保存下来反复使用。
根据 pb 的C++ 接口声明风格(Message::ParseFromArray()),不难看出,解析出来后的对象,是由该消息自己管理内存的,而非由用户管理(深入了解 RAII 可看 youtube 视频:RAII and the rule of zero)。那么该消息在析构时,自然也需要将消息中嵌套的子消息释放。如上一条所述的,提前序列化好的 bytes 数据,则是需要保存的。
此时我们有2种方法获得该bytes。第一种比较 naive,const std::string serialized_bytes() const 获得解析对象中的一个拷贝,并使用 std::string 的拷贝构造函数以保存之。
而我们这里要介绍的第2种,则是使用 std::string* release_serialized_bytes() 令该对象的所有权转为用户所有(相对于protobuf library 而言)。而你需要做的,则是使用 std::unique_ptr 将得到的所有权管理起来。
用第二种方法,
- 减少了内存拷贝所需的时间
- 减少了内存占用
- 减少了因内存碎片而需的内存碎片整理时间
- 对于单个大对象的析构来说,减少了析构的时间,避免了卡顿。
三、考虑使用 fixed32 替换 uint32 以加快编解码速度。
若对一段 pb 的编解码程序做性能分析,比如使用 perf top 去观察,你会发现调用栈上经常出现的一个调用是 WriteVarint32ToArray 或者类似的 ComputeRawVarint32Size。在 pb 的 schema 描述中,区分了 uint32 和 fix32 两种 32 位整型(当然还有诸如有无符号等的区别,不在此类讨论中)。当编解码时,后者使用的是 WriteLittleEndian32ToArray,实际则是调用了 memcpy (当本机为小端序时)。而前者,最终则会调用以下函数
template <typename T>
PROTOBUF_ALWAYS_INLINE static uint8_t* UnsafeVarint(T value, uint8_t* ptr) {
static_assert(std::is_unsigned<T>::value,
"Varint serialization must be unsigned");
ptr[0] = static_cast<uint8_t>(value);
if (value < 0x80) {
return ptr + 1;
}
// Turn on continuation bit in the byte we just wrote.
ptr[0] |= static_cast<uint8_t>(0x80);
value >>= 7;
ptr[1] = static_cast<uint8_t>(value);
if (value < 0x80) {
return ptr + 2;
}
ptr += 2;
do {
// Turn on continuation bit in the byte we just wrote.
ptr[-1] |= static_cast<uint8_t>(0x80);
value >>= 7;
*ptr = static_cast<uint8_t>(value);
++ptr;
} while (value >= 0x80);
return ptr;
}
可以看到,该函数会判断 value 是否小于 0x80,只有在大于时才会继续,右移后继续判断。那么为什么是 0x80 呢?因为在单字节的无符号数中,最高 bit 置1 时,即二进制 1000 0000,此时说明该字节中所有的bit 位都是不能省略的。可以想象,当 value 为一个非常小的数,比如 4,其二进制为 0000 0100,高 5 个bit 的0,都是浪费的,自然没有必要存。另外我们还可以注意到一个小的优化。为什么每次右移的时候,是移 7 bit,而不是8?毕竟一个字节是8 bit。那是因为,既然前面已经判断了 value > 0x80,即已经确定了最高bit 的值为1,可推导的值,自然就没有必要存储了,任何细小的空间优化都要抠出来。
说了这么长 WriteLittleEndian32ToArray 的调用逻辑,其实就是为了说明,当使用普通的 uint32时,为了实现即使定义了一个int32也使用少于4个字节的空间压缩,编码器做了很多额外的工作,这些可比一个简单的 memcpy负担重多了,自然编码起来会慢不少。如果你的整形,在大多数情况下都要大于的话,那么使用 fixed32 无疑是一个更好的选择,无论是从编解码的时间效率上来说,还是从编码后的消息的空间效率上来说都是。
小结
最近重温了一遍穷佐罗大师在内部培训中的 Linux 性能调优视频课。里面说了一句话,可以很好的总结这次优化的原因。他说,其实当机型固定时,那么你的程序天花板就是固定的了,所谓的调优只是平衡性能天平的两端,根据业务的特性压低一侧,以抬高另一侧。如果真的发生了全面的优化,那多半是之前的用法就是错的。
这次的优化之路就是,其实很多时候,最佳实践或者 guideline 中已经指出武功秘籍,但是由于在之前的匆忙上线、快速迭代的过程中忽略掉了。而我们后续做的,无非只是重读 guideline,践行其中的最佳实践。当然,这过程中最重要的,是你在这过程中如何层层剖析,找到症结所在,并定位到缺少最佳实践的地方。