« 蒙特霍尔问题与我那餐盒饭 | 返回首页 | 开发笔记 (6) : 结构化数据的共享存储 »

pbc 库的 lua binding

前几天写的 pbc 初衷就是想可以方便的 binding 到动态语言中去用的。所以今天花了整整一天自己写了个简单的 lua binding 库,就是很自然的工作了。

写完了之后,我很好奇性能怎样,就写了一个非常简单的测试程序测了一下。当然这个测试不说明很多问题,因为测试用的数据实在是太简单了,等明天有空再弄个复杂点的来跑一下吧。我很奇怪,为什么 google 官方的 C++ 版性能这么差。

我的 lua 测试代码大约是这样的:

local protobuf = require "protobuf"

addr = io.open("../../build/addressbook.pb","rb")
buffer = addr:read "*a"
addr:close()
protobuf.register(buffer)

for i=1,1000000 do
    local person = {
        name = "Alice",
        id = 123,
    }
    local buffer = protobuf.encode("tutorial.Person", person)
    local t = protobuf.decode("tutorial.Person", buffer)
end

100 万次的编码和解码在我目前的机器上,耗时 3.8s 。

为了适应性能要求极高的场合,我还提供了另一组高性能 api 。他们可以把数据平坦展开在 lua 栈上,而不构成 table 。只需要把循环里的代码换成

    local buffer = protobuf.pack(
        "tutorial.Person name id",
         "Alice", 123)
    protobuf.unpack("tutorial.Person name id", buffer)

就可以了。这个版本只需要耗时 0.9s 。

一个月前,我曾经自己用 luajit + ffi 实现过一个纯 lua 的版本(没有开源),我跑了一下这个 case ,那个版本也很给力,达到前面的接口的功能,只需要 2.1s 。

不过我相信我新写的 binding 慢主要还是慢在 lua 上, 我换上了 luajit 跑以后,果然快了很多。

table 版本的耗时 1.7s , 平坦展开版是 0.57s.

看来 luajit 的优化力度很大。

btw, 我去年早些时候还写过一个 lua binding ,今天也顺便测了一下,在 luajit 下跑的时间是 1.2s 。没有这次写的这个版本快。

最后,我随手写了一个 C++ 的版本。应该有不少优化途径。不过我想这也是某中常规用法。

#include <iostream>
#include <sstream>
#include <string>
#include "addressbook.pb.h"
using namespace std;

int main(int argc, char* argv[]) {
  GOOGLE_PROTOBUF_VERIFY_VERSION;


  for (int i=0;i<1000000;i++) {
      tutorial::Person person;

      person.set_name("Alice");
      person.set_id(123);

      stringstream output;

      person.SerializeToOstream(&output);
      output.str();
      tutorial::Person person2;

      person2.ParseFromIstream(&output);

      person.name();
      person.id();
  }

  google::protobuf::ShutdownProtobufLibrary();

  return 0;
}

这段代码在开了 -O2 编译后,在我的机器上依旧需要时间 1.9s。若是这么看,那简直是太慢了 (比 luajit + c binding 还慢)。很久没研究 C++ 的细节,也懒得看了,如果谁有兴趣研究一下为什么 C++ 这么慢,我很有兴趣知道原因。


12 月 16 日

留言中 lifc0 说这段 C++ 代码中开销最大的是 stringstream 的构造和销毁, 所以我改了一段代码:

stringstream output;
stringstream input;

for (int i=0;i<1000000;i++) {
    output.clear();
    output.str("");

    tutorial::Person person;
    person.set_name("Alice");
    person.set_id(123);

    person.SerializeToOstream(&output);

    input.clear();
    input.str(output.str());

    tutorial::Person person2;

    person2.ParseFromIstream(&input);

    person2.name();
    person2.id();
}

这样更符合现实应用, 每次初始化 stringstream 而不构造新的出来.

这样运行时间就从 1.90s 下降到 1.18s 了.

Comments

Why not use std::vector<char> instead of std::stringstream ?

看你 proto源码中的 float类型都强转成double类型了 这个真有点苦呀!我们java后台接收到关于float类型的数据直接奔掉 查了好久才知道是这个问题

补38楼:原来有protobuf.check...开始没看到

补38楼:原来有protobuf.check...开始没看到

在协议中存在嵌套时,decode并没有一次解析完,而且repeated和嵌套字段均为table,区别不开啊~~现在想到的办法就是1、repeated固定命名为xxx.list,2、导出_pbcP_get_message接口,判断table[1]是否需要继续解析~~感觉都不是太好~~求方案

不好意思 风大侠
今天不甘心 看了你里面的源码也支持枚举类型
我就产生怀疑是不是我proto文件枚举问题
果然正着 我枚举是从0开始的 我换成1开始 立马让我乐乐起来

大侠 这个 lua的pbc protobuf 不支持枚举类型呀 丢pb进行解析直接报解析错误 找了好久以为pbc问题呢 请注明哟

extend google.protobuf.MessageOptions{
optional CGMessageType cg_message_type = 50001;
optional GCMessageType gc_message_type = 50002;
}
请问pbc对这种扩展是否支持呢?

不支持扩展吗?

坏处就是要附带.proto 文件,协议容易被破解

坏处就是要附带.proto 文件,协议容易被破解

在游戏服务器的io序列化中使用protobuf是低效的,测试结果显示更简单直接的序列化代码可以比protobuf快5-10倍。而这些序列化代码,通过协议数据结构定义可以自动生成,很容易就写出这样的工具脚本。如果追求效率,游戏服务器处理消息并不适合使用probobuf。惟一潜在用途是数据库。因为数据结构频繁改动,使得读取老数据有问题,而probobuf则很适合这个场合。

推荐使用显式的销毁 buffer 的接口。 gc 那个只是 5.2 支持顺手加上的。

一个小项目需要lua+protobuf,研究了纯c的nanopb、protobuf-c、upb和lua的lua-pb、lua-protobuf、protoc-gen-lua都不太合适。upb只能decode没encode,其他一些要么需要代码生成,要么没有lua绑定,纯lua的方案则不方便和c代码交换消息。
本打算花点时间给nanopb加上lua绑定,后来在google groups上搜索lightweight、fast等关键字时无意看到http://comments.gmane.org/gmane.comp.lib.protocol-buffers.general/7972,去github研究之后觉得几乎就是我理想中的模式(和nanopb思路差不多,但实现了lua绑定),仔细看原来是云风大侠的作品。再搜相关资料来到这个页面,居然去年还过来踩过一脚。

给pbc提个小建议:lua绑定有几处用setmetatable给table绑定__gc元方法,但lua 5.1似乎只处理userdata的__gc,不知是否5.2做了相关调整,等有空再去研究。

for (int i = 0; i < 1000000; i++)
{
Person person;
person.set_name("Alice");
person.set_id(123);
std::string s = person.SerializeAsString();
person.ParseFromString(s);
}

要源码的同学就是不肯去 github 自己取?难道要人打包好 email 才行么?

请问能否将你的bind库开源呢?

@tearshark

把一小短测试代码, 针对性的优化是没有意义的.

实际不可能这样连着用,因为这样的代码段其实什么事情都没有做.

对于任何语言的任何代码片断, 都是以最舒适和直观的写出来为最常规的用法.

对于一个通用库来说尤其如此,因为它是给许多不同的人在不同的场合用的.

就这段代码而言. 一个常规的用法 :

比如在数据编码阶段是, 准备一个输出流, 准备一个待序列化的结构, 装填结构的数据, 序列化到流, 流输出.

这些步骤在实际用的时候是在不同流程,不同时机去做的, 甚至不是一个人来维护, 在同一模块里出现.

测试代码应体现这些流程和步骤, 而不是想办法放在一起,再看看哪里可以优化, 这样得到的优化结果没有太多意义.

for (int i=0;i<1000000;i++) {
output.clear();
output.str(""); //构造/拷贝/析购string,释放内存,分配内存

tutorial::Person person; //构造person
person.set_name("Alice");
person.set_id(123);

person.SerializeToOstream(&output);

input.clear();
input.str(output.str()); //构造/拷贝/析购string,释放内存,分配内存

tutorial::Person person2; //构造person2

person2.ParseFromIstream(&input);

person2.name();
person2.id();
}

这段代码测试什么的呢?内存分配?
input和output用相同的对象,然后通过seek操作重用数据不更好?
另外,stringstream笨拙的可以,还不如vector<>.clear()----至少我见过的vector<>的实现,clear()都不会真正的删除内存.
如果把stringstream替换成vector<>的实现,则我心目中理想的写法是

vectorstream<char> input;
tutorial::Person person;
tutorial::Person person2;

for (int i=0;i<1000000;i++) {
input.clear();
person.set_name("Alice");
person.set_id(123);

person.SerializeToOstream(&input);

input.seek(0);
person2.ParseFromIstream(&input);

person2.name();
person2.id();
}

这样可以尽量避免input的反复内存分配导致的效率低下.
C++真不是适合新手使用的库,到处都是陷阱,特别的STL的IO实现部分.还不如C.

路过学习。

万恶的stringstream。这个东西极其低效,用它做格式化不论在哪个平台上都比printf低一个数量级。当年有个项目被它打败过。

小弟愚见: 因为protobuffer编码的时候默认会加tag,而解码的时候根据tag来确定数据类型进行解码,它这样做为了灵活性而牺牲性能,并不是每个场景都需要。

现实情况中stringstream对象可复用。定义放循环外,用前或用后调output.str("")、output.clear()清空并复位,在我机器上运行时间从4.5s缩短到2.5s。

所有不信的都可以自己调整代码,然后试试, 代码都在的.

ps, 我也写过 10 来年 C++ 代码. 纸屑了解这些表层的开销.

我是故意这样写的.

c++效率不高的原因就是你用了太多的临时对象,而这个在lua中可能是有一个缓存来处理的。不信你可以把那些个临时对象放外面,重复使用,这样效率估计会比lua高很多。

lua 不是一个适合抠性能的语言, 适当注意就好了. 那些机械性的优化策略应该交给 jit 去处理.

关注每条指令背后的机器码,是在 C 层面做的.

@Cloud

又分析了一下,发现不是luajit有对table查找做了优化,而是jit有一定的优化能力.
而用luajit的解释器就能发现local化的明显更快,列出字节码也能看出确实指令少了.

@Cloud

反效果应该只限于ffi中的函数, 但貌似第一段程序中的protobuf不是ffi.load返回.
非ffi的函数我刚测试过,性能几乎没有差别,可能luajit有对table查找做了优化.

要是这样说,的确很慢。
我自己写的序列化引擎,你这2个字段的序列化和反序列化100万次加起来的事件也少于1s.

当然,这一定情况上取决于机器的性能,我的机器是家用PC双核的。

也不知道为啥官方的PB会那么慢,我还是使用的.NET语言呢~

@dwing

你说的这个在 luajit 上有反效果.

其实lua代码有很多极端的优化手段, 比如尽量local化.
第一段lua代码里, 应该把encode和decode local化, 每个循环省了2个table查找,
实际应用中即使不能在函数内local化, 也要在upvalue上local化, 也比查table快.

luajit的实现有内置的快速内存分配器, 不过我没详细看是否对常用的对象做对象池.
官方lua有接口使用自己的分配器. 对象池的实现涉及到很多细节问题, 官方不提供也很正常.

测了一下官方的Python版,35秒,,,泪流满面。

官方的实现不符合STL惯例。符合STL惯例的做法是提供一个模板函数序列化到一个iterator. 而官方的实现搞了个什么ZeroCopyOutputStream就是抄袭Java IO那一套,用虚函数桥接各种输出目标。

而且std::ostream其实是用来做文本格式化的。谷歌实现提供输出到ostream其实是错误的。如果要输出到二进制流,而又不想用模板函数输出到iterator,就应该使用streambuf,会减少一些间接层。

谷歌实现还提供输出到std::string,这也是错的。std::string是给文本用的。许多std::string实现都为字符串专门优化过,比如方便的添加'\0',某些实现甚至还实现了Copy-On-Write。这些优化都增加了额外的间接层,在不需要这些功能时有损性能。输出二进制应该输出到std::vector,更轻量级一些。

@wenbo

定义在循环体外是不符合实际应用环境的.

这里模拟 1M 个包, 显然不可能是用的同一个对象.

要测试的流程包括了构建和销毁包和流本身.在对等的 lua 版本中也做了这些操作.

试了一下, 把person, person2, output定义在循环体外可以省去差不多一秒

@kai

显然 lua 不是你想像的那样工作的, 即使你重新实现也做不到那样.

@Cloud

我对lua的GC不熟。如果是我来实现lua的vm,我肯定会针对表的大小做fixed memory pool。另外对表里面的成员的大小也会有fixed memory pool,我觉得这是lua可以做的很基本的优化,因为lua元素的组成大小比较固定,对内存上没有连续的要求。如果这个假设成立,lua表的连续创建,可以不涉及到堆操作。

@kai

刚才我调整了 lua 代码,把 table 放在循环内部, 这样需要临时构造百万次 table ,且会触发 gc.

即使这样, luajit 加速后,依然比 c++ 版本快.

猜测lua快是GC的功劳。C++如果使用fixed memory pool也许会有明显提升。不熟悉系统默认的堆的操作,不知道如果连续申请释放同样大小的内存,使用的是同一块内存还是不同的。

这是模拟现实应用:

现实应用中, 处理每个包, 就需要做这么多事情.

不过即使这么改了也没有提升太多.

为了和 lua 对应且公平, 我调整了 lua 代码重新测试了.

把 stringstream output 放到循环外试试。stringstream属于比较重量级的对象,频繁创建、销毁有会耗费不少cpu时间。

风哥,你c++代码中,在循环里多做了几件事:

1创建了 两个Person
2调用了Person各个属性的get/set方法各一次

要是把这些放循环体外,能快不少吧

我不懂lua,提点拙见

Post a comment

非这个主题相关的留言请到:留言本