Carpe Diem

備忘録

ProtobufとStreamで扱うJSONはどちらが省メモリなのか

背景

JSONよりもProtobufの方が

ということは色んな検証記事から明らかになっています。

一方でGoのProtobufはデータをstreamで扱うのではなく、全部メモリに載せてシリアライズ・デシリアライズするAPI(メソッド)しかありません。

github.com

過去にこんな記事を書いたように、streamで扱うことで大きくメモリ使用量が改善されるケースは多々あるので、

christina04.hatenablog.com

もしかして大きなデータをstructにデシリアライズする場合、メモリ使用量としてはJSONでstreamで扱う方が少なくなるのかな?と思って検証してみました。

環境

  • Go 1.17.2

検証

検証方法

  1. ファイルの読み込み
  2. structへデシリアライズ

のフローを以下の4つのケースで検証してみました。
元のデータは同じですが、データ形式でかなり差がありますね。

ケース データ形式 ファイルサイズ
a JSON 86MB
b gzip圧縮したJSON 9.3MB
c Protobuf 52MB
d gzip圧縮したProtobuf 8.5MB

検証コード

a. JSON

JSONはstreamで扱います。

func ReadJSON(path string) (*LargeData, error) {
        f, err := os.Open(path)
        if err != nil {
                return nil, err
        }
        defer f.Close()
        entity := &LargeData{}
        err = json.NewDecoder(f).Decode(entity)
        if err != nil {
                return nil, err
        }
        return entity, nil
}

b. gzip圧縮したJSON

gzip JSONもstreamで扱います。

func ReadGZipJSON(path string) (*LargeData, error) {
        f, err := os.Open(path)
        if err != nil {
                return nil, err
        }
        defer f.Close()
        gr, err := gzip.NewReader(f)
        if err != nil {
                return nil, err
        }
        defer gr.Close()
        entity := &LargeData{}
        err = json.NewDecoder(gr).Decode(entity)
        if err != nil {
                return nil, err
        }
        return entity, nil
}

c. Protobuf

Protobufはstreamで扱えないので、os.ReadFileで一気にメモリに載せます。

func ReadProtobuf(path string) (*LargeData, error) {
        data, err := os.ReadFile(path)
        if err != nil {
                return nil, err
        }
        entity := &LargeData{}
        err = entity.Unmarshal(data)
        if err != nil {
                return nil, err
        }
        return entity, nil
}

d. gzip圧縮したProtobuf

gzip展開まではstreamで扱いますが、それ以降は先程と同じです。

func ReadGZipProtobuf(path string) (*LargeData, error) {
        f, err := os.Open(path)
        if err != nil {
                return nil, err
        }
        defer f.Close()
        gr, err := gzip.NewReader(f)
        if err != nil {
                return nil, err
        }
        data := new(bytes.Buffer)
        _, err = io.Copy(data, gr)
        if err != nil {
                return nil, err
        }
        entity := &LargeData{}
        err = entity.Unmarshal(data.Bytes())
        if err != nil {
                return nil, err
        }
        return entity, nil
}

結果

$ go test -bench . -benchmem
goos: darwin
goarch: amd64
cpu: Intel(R) Core(TM) i9-9980HK CPU @ 2.40GHz
BenchmarkReadJSON-16                           1        1401583800 ns/op        586628144 B/op   2022214 allocs/op
BenchmarkReadGZipJSON-16                       1        1529638129 ns/op        587703008 B/op   2025822 allocs/op
BenchmarkReadProtobuf-16                       7         167106390 ns/op        419643998 B/op   1199804 allocs/op
BenchmarkReadGZipProtobuf-16                   4         333084560 ns/op        502478978 B/op   1215377 allocs/op
ケース メモリ使用量
a. JSON 587MB
b. gzip圧縮したJSON 588MB
c. Protobuf 420MB
d. gzip圧縮したProtobuf 502MB

仮説と異なり、Protobufの方が省メモリであることが分かりました。

また処理速度は断然Protobufの方が速いですね。

まとめ

ゲームのマスターデータなど静的かつ大きなデータをS3バケットからロードする際、プロファイリングでprotobufのUnmarshal()のメモリ使用量が高かったため今回の検証をしてみました。
仮説としてはstreamのJSONの方が省メモリで、そうであればProtobufよりもJSONで管理する方が良いかもと思っていましたが結果は逆でした。

Goの場合streamで扱うJSONよりもProtobufでまるっとメモリに載せた方がメモリ使用量は少ないことが分かりました。