Flatbuffersの検証

FlatBuffers

サーバーとクライアントでデータをやりとりする場合によく使われるデータフォーマットとしてJSONがあります。
しかし昨今はMessage Pack や ProtocolBuffers や FlatBuffersなど、JSONに変わるデータ変換フォーマットがたくさん現れてきています。

今回はその中でも非常に高速と言われているFlatBuffersについて検証を実施しました。

FlatBuffersは超高速なデータシリアライズ/デシリアライズ用のライブラリで、Googleが開発しています。
あらかじめデータ構造を定義したスキーマファイルを作成してそのスキーマファイルを使用してデータのMarshal、Unmarshalを行います。
その他の特徴として、Unmarshalにかかる時間が非常に少ないという特徴があります。
公式HP
GitHub

導入方法

スキーマファイルの定義

まずシリアライズ、デシリアライズ用のスキーマ定義ファイルを作成します。

namespace bench;

table UserResponse {
uid:long;
muid:string;
status:string;
tutorial:long;
nickname:string;
total_login_num:long;
opened_at:string;
created_at:string;
updated_at:string;
}

root_type UserResponse;

詳細な仕様は本家ドキュメントを参照してください。

スキーマファイルのコンパイル

上で書いたスキーマファイルを使用する言語に合わせてコンパイルします。
macではbrewから持ってくるのが楽です。

$ brew install flatbuffers

windowsではバイナリを配布しているのでそちらからダウンロードしてください。
https://github.com/google/flatbuffers/releases

flatcが導入されたら以下のコマンドでコンパイルを実行します。

$ flatc --go user_response.fbs
$ ls user_response
UserResponse.go

C#向けにコンパイルする場合は–goを–csharpに変えてください。
ここまでできたら、シリアライズ、デシリアライズ操作をコードに組み込みます。

コードへの組み込み

上記で作られたコードを使ってシリアライズやデシリアライズ作業を行なっていきます。

package main

import (
"fbuf/bench"
"fmt"
"github.com/google/flatbuffers/go"
)

func main() {
builder := flatbuffers.NewBuilder(0)
muID := builder.CreateString("abcd1234")
status := builder.CreateString("ok")
nickname := builder.CreateString("tatsuro")
openedAt := builder.CreateString("2016-11-10 12:00:00")
createdAt := builder.CreateString("2016-11-10 11:00:00")
updatedAt := builder.CreateString("2016-11-10 12:07:00")

bench.UserResponseStart(builder)
bench.UserResponseAddUid(builder, 10)
bench.UserResponseAddMuid(builder, muID)
bench.UserResponseAddStatus(builder, status)
bench.UserResponseAddTutorial(builder, 1)
bench.UserResponseAddNickname(builder, nickname)
bench.UserResponseAddTotalLoginNum(builder, 123)
bench.UserResponseAddOpenedAt(builder, openedAt)
bench.UserResponseAddCreatedAt(builder, createdAt)
bench.UserResponseAddUpdatedAt(builder, updatedAt)
end := bench.UserResponseEnd(builder)
builder.Finish(end)
data := builder.Bytes[builder.Head():]

response := bench.GetRootAsUserResponse(data, 0)
fmt.Println("uid: ", response.Uid())
fmt.Println("nickname: ", string(response.Nickname()))
}

コードとしては、flatbuffers.NewBuilder(0)で作ったビルダーオブジェクトに対して、データを登録していくことになります。
シリアライズされたデータをGetRootAsUserResponseで取得すると構造体として取得され、簡単にデータにアクセスできます。

ベンチマーク

JSONのシリアライズと比較してみます。

$ go test -bench .
testing: warning: no tests to run
BenchmarkJsonMarshal-4 500000 2752 ns/op 696 B/op 4 allocs/op
BenchmarkJsonUnmarshal-4 200000 6801 ns/op 640 B/op 12 allocs/op
BenchmarkFlatbufMarshal-4 1000000 1243 ns/op 872 B/op 14 allocs/op
BenchmarkFlatbufUnmarshal-4 3000000 465 ns/op 165 B/op 6 allocs/op
PASS
ok fbuf/bench 6.022s

MarshalはFlatBuffersの方はJSONの半分以下の時間でできていることがわかります。またUnmarshalについてはFlatBuffersは非常に早く15分の1程度の時間でできています。
以下にベンチマーク時に実行したテストコードを貼っておきます。

package bench

import (
"fmt"
"math/rand"
"testing"
"time"
"encoding/json"
"github.com/google/flatbuffers/go"
)

type UserResponseStruct struct {
Uid int64 `json:"uid,omitempty"`
Muid string `json:"muid,omitempty"`
Status string `json:"status,omitempty"`
Tutorial int64 `json:"tutorial,omitempty"`
Nickname string `json:"nickname,omitempty"`
TotalLoginNum int64 `json:"total_login_num,omitempty"`
OpenedAt string `json:"opened_at,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}

type Serializer interface {
Marshal(o interface{}) []byte
Unmarshal(d []byte, o interface{}) error
String() string
}

func randString(l int) string {
buf := make([]byte, l)
for i := 0; i < (l+1)/2; i++ {
buf[i] = byte(rand.Intn(256))
}
return fmt.Sprintf("%x", buf)[:l]
}

func generate() []*UserResponseStruct {
a := make([]*UserResponseStruct, 0, 1000)
t := fmt.Sprint(time.Now().Format("2006-01-02 15:04:05"))
for i := 0; i < 1000; i++ {
a = append(a, &UserResponseStruct{
Uid: time.Now().UnixNano(),
Muid: randString(32),
Status: randString(5),
Tutorial: time.Now().UnixNano(),
Nickname: randString(20),
TotalLoginNum: time.Now().UnixNano(),
OpenedAt: t,
CreatedAt: t,
UpdatedAt: t,
})
}
return a
}

func benchMarshal(b *testing.B, s Serializer) {
b.StopTimer()
data := generate()
b.ReportAllocs()
b.StartTimer()
for i := 0; i < b.N; i++ {
s.Marshal(data[rand.Intn(len(data))])
}
}

func benchUnmarshal(b *testing.B, s Serializer) {
b.StopTimer()
data := generate()
ser := make([][]byte, len(data))
for i, d := range data {
o := s.Marshal(d)
t := make([]byte, len(o))
copy(t, o)
ser[i] = t
}
b.ReportAllocs()
b.StartTimer()
for i := 0; i < b.N; i++ {
n := rand.Intn(len(ser))
o := &UserResponseStruct{}
err := s.Unmarshal(ser[n], o)
if err != nil {
b.Fatalf("%s failed to unmarshal: %s (%s)", s, err, ser[n])
}
}
}

// JSON

type JsonSerializer struct{}

func (j JsonSerializer) Marshal(o interface{}) []byte {
d, _ := json.Marshal(o)
return d
}

func (j JsonSerializer) Unmarshal(d []byte, o interface{}) error {
return json.Unmarshal(d, o)
}

func (j JsonSerializer) String() string {
return "json"
}

func BenchmarkJsonMarshal(b *testing.B) {
benchMarshal(b, JsonSerializer{})
}

func BenchmarkJsonUnmarshal(b *testing.B) {
benchUnmarshal(b, JsonSerializer{})
}

// FlatBuffers

func fbMarshal(obj *UserResponseStruct) []byte {
builder := flatbuffers.NewBuilder(0)
muID := builder.CreateString(obj.Muid)
status := builder.CreateString(obj.Status)
nickname := builder.CreateString(obj.Nickname)
openedAt := builder.CreateString(obj.OpenedAt)
createdAt := builder.CreateString(obj.CreatedAt)
updatedAt := builder.CreateString(obj.UpdatedAt)
UserResponseStart(builder)
UserResponseAddUid(builder, obj.Uid)
UserResponseAddMuid(builder, muID)
UserResponseAddStatus(builder, status)
UserResponseAddTutorial(builder, obj.Tutorial)
UserResponseAddNickname(builder, nickname)
UserResponseAddTotalLoginNum(builder, obj.TotalLoginNum)
UserResponseAddOpenedAt(builder, openedAt)
UserResponseAddCreatedAt(builder, createdAt)
UserResponseAddUpdatedAt(builder, updatedAt)
end := UserResponseEnd(builder)
builder.Finish(end)

return builder.Bytes[builder.Head():]
}

func fbUnmarshal(data []byte) UserResponseStruct {
obj := GetRootAsUserResponse(data, 0)
userResponse := UserResponseStruct{
Uid: obj.Uid(),
Muid: string(obj.Muid()),
Status: string(obj.Status()),
Tutorial: obj.Tutorial(),
Nickname: string(obj.Nickname()),
TotalLoginNum: obj.TotalLoginNum(),
OpenedAt: string(obj.OpenedAt()),
CreatedAt: string(obj.CreatedAt()),
UpdatedAt: string(obj.UpdatedAt()),
}
return userResponse
}

func BenchmarkFlatbufMarshal(b *testing.B) {
b.StopTimer()
data := generate()
b.ReportAllocs()
b.StartTimer()
for i := 0; i < b.N; i ++ {
fbMarshal(data[rand.Intn(len(data))])
}

}

func BenchmarkFlatbufUnmarshal(b *testing.B) {
b.StopTimer()
data := generate()
ser := make([][]byte, len(data))
for i, d := range data {
ser[i] = fbMarshal(d)
}
b.ReportAllocs()
b.StartTimer()
for i := 0; i < b.N; i++ {
_ = fbUnmarshal(ser[rand.Intn(len(ser))])
}
}

所感

FlatBuffersはかなり早いですが、UnMarshal時にFlatBuffersの構造体から自分の定義する構造体にデータをコピーする必要があるのでそこは注意が必要です。
構造体を用意せず、直接FlatBufferのデータを参照すればその対応が必要なくなるので、FlatBuffersを使う場合はそういう使い方になるかと思います。
使い方としてはProtocolBufferの方が直感的かな〜・・・