ちりもつもればミルキーウェイ

好奇心に可処分時間が奪われる

gob のエンコード結果が Go のバージョンによって違うらしい

経緯

TLで以下の投稿を見かけた

ほーむとおもって手元でいくつかのバージョンで試すと、実際バイナリが異なっていることがわかった。

なんか大きい変更入ってた気もしないのでなんでかな〜とおもい、気になって調べてみた。

tl;dr

go1.21 のときに入った gob パフォーマンスチューニングの commit により、ユーザー定義型の typeid が 64 から使われるようになり出力されるバイナリが変わっていた。(それまでは 65 から利用されていた)

ユーザー定義型の typeid は 64 以上であればなんぼでもちゃんと食えるようになっており gob 仕様としてはどっちも valid である!

go version によって gob の encode 出力が違う

go コード

package main

import (
    "bytes"
    "encoding/gob"
    "fmt"

    "github.com/convto/bit"
)

func main() {
    type payload struct {
        A int
        B string
    }
    p := payload{A: 1, B: "hello"}

    buf := &bytes.Buffer{}
    if err := gob.NewEncoder(buf).Encode(&p); err != nil {
        panic(err)
    }
    fmt.Print(bit.Dump(buf.Bytes()))
}

go1.23

go1.23.0 run main.go
00000000: 00100000 01111111 00000011 00000001 00000001 00000111   .....
00000006: 01110000 01100001 01111001 01101100 01101111 01100001  payloa
0000000c: 01100100 00000001 11111111 10000000 00000000 00000001  d.....
00000012: 00000010 00000001 00000001 01000001 00000001 00000100  ...A..
00000018: 00000000 00000001 00000001 01000010 00000001 00001100  ...B..
0000001e: 00000000 00000000 00000000 00001100 11111111 10000000  ......
00000024: 00000001 00000010 00000001 00000101 01101000 01100101  ....he
0000002a: 01101100 01101100 01101111 00000000                    llo.

go1.20

go1.20.1 run main.go
00000000: 00100001 11111111 10000001 00000011 00000001 00000001  !.....
00000006: 00000111 01110000 01100001 01111001 01101100 01101111  .paylo
0000000c: 01100001 01100100 00000001 11111111 10000010 00000000  ad....
00000012: 00000001 00000010 00000001 00000001 01000001 00000001  ....A.
00000018: 00000100 00000000 00000001 00000001 01000010 00000001  ....B.
0000001e: 00001100 00000000 00000000 00000000 00001100 11111111  ......
00000024: 10000010 00000001 00000010 00000001 00000101 01101000  .....h
0000002a: 01100101 01101100 01101100 01101111 00000000           ello.

1byte めの type definition の length が異なる。なんか細いところで型の表現が変わってたりする気がする?

目で見る感じ message value の length は変わってなさげ。大きい構造とかも変わってなさそう。type definition の細いところかわったか?

構造をざっくり解釈してみる

go1.20 系の出力をざっくり解釈してみる...

頑張って目で読むと以下の構造なことがわかる

  • len 33: 00100001
    • id
      • len 1: 11111111
      • val -65 (負数なので type def): 10000001
    • wireType structType: 00000011
      • field 0 (commonType): 00000001
        • field 0 (name string): 00000001
          • len 7: 00000111
          • val "payload": 01110000 01100001 01111001 01101100 01101111 01100001 01100100
        • field 1 (id typeId): 00000001
          • len 1: 11111111
          • id 65: 10000010
        • 終端: 00000000
      • field 1 (field): 00000001
        • len 2: 00000010
        • item1
          • field 0 (name string): 00000001
            • len 1: 00000001
            • val "A": 01000001
          • field 1 (id typeId): 00000001
            • val 2 (int): 00000100
          • 終端: 00000000
        • item2
          • field 0 (name string): 00000001
            • len 1: 00000001
            • val "B": 01000010
          • field 1 (id typeId): 00000001
            • val 6 (string): 00001100
          • 終端: 00000000
      • 終端: 00000000
    • 終端: 00000000
  • len 13: 00001100
    • id
      • len 1: 11111111
      • val 65 (正の値だから val): 10000010
    • field 0 (A int): 00000001
      • val 1: 00000010
    • field 0 (B string): 00000001
      • len 5: 00000101
      • val hello: 01101000 01100101 01101100 01101100 01101111
    • 終端: 00000000

うむ。ふつうに valid ですね。しいていえば typeid = 65 になっててちょっと勿体無いのが違いかな。

typeid = 65 だと、type definition のときは負数にする必要があるので -65 を表現しないといけない。先頭1bitは終端判定、末尾1bitは正負判定で使えないことを考えると、65 は 6bit 値をはみ出ているから 1byte(8bit) で表現できなくなってしまう。

typeid = 64 だったら 6bit で表現できるので終端判定/正負判定の bit ぬいて 1byte ですむ!ため typedef のセクションが 1byte 減りますね。見比べると実際最近の go ではそうしてるっぽいことが読み取れる。

元々 gob の typeid は予約領域以外は値が確定してない(=どういう値がきても受け取れる作りになってる)はずなので、どこかのバージョンから優先して id = 64 から使うようになったということだろう。そうすると一つ目の型情報は 1byte お得になる、ということかな

どこで挙動が変わったか探す

おもむろに go1.21.0 を入れたが go1.23 と同じ挙動ぽい

$ diff -s <(go1.21.0 run main.go) <(go1.23.0 run main.go)
Files /dev/fd/11 and /dev/fd/12 are identical

go1.20.1 ~ go1.21.0 のどこかで事件は起きている!

go1.20 の最後のリリースは go1.20.14 ぽい https://go.dev/doc/devel/release#go1.20.minor

見比べると 1.20.1 - 1.20.14 で差分はなし

diff -s <(go1.20.1 run main.go) <(go1.20.14 run main.go)
Files /dev/fd/11 and /dev/fd/12 are identical

てことは 1.21.0 リリース時に入っているはず。みにいく

release note を gob とかで調べても当然 hit せず https://go.dev/doc/go1.21

適当に diff を見てみる。 go1.21 のリリースタイミング的に昔すぎるのは見なくてよかろうということで 2023 以降に絞る

git log --after 2023-01-01 --pretty=format:'%h (%ai) %s' go1.20.1 go1.21.0 -- src/encoding/gob | grep encoding/gob
f62c9701b4 (2023-04-01 18:55:06 +0100) encoding/gob: use reflect.Value.Grow
56e900d9f0 (2023-04-01 18:44:20 +0100) encoding/gob: report allocs in benchmarks
f80e270bab (2023-03-25 12:04:59 +0000) encoding/gob: avoid pointers to fieldType
72013c4700 (2023-03-25 11:59:17 +0000) encoding/gob: reuse calls to TypeOf for wireType
4d296bcbc2 (2023-03-25 11:48:41 +0000) encoding/gob: avoid a pointer to wireType in typeInfo
6a51c000de (2023-03-25 11:23:12 +0000) encoding/gob: use reflect.Value.IsZero
09408a5b45 (2023-03-25 10:58:05 +0000) encoding/gob: avoid filling userTypeCache at init time
c994067e5b (2023-03-23 17:38:05 -0700) encoding/gob: update decgen to generate current dec_helpers
3d5391ed87 (2023-03-22 06:31:25 +0000) encoding/gob: extend partially allocated string slices
fcfbbf2ff6 (2023-02-25 17:04:30 +0000) encoding/gob: use reflect.Value.SetZero
43f9b826c3 (2023-01-20 21:53:34 +0000) encoding/gob: slightly simplify init code
8259ac4986 (2023-01-08 20:03:33 +0000) encoding/gob: shave off some init time cost

このくらいだったら見切れる。怪しい commit をさがすぞい!

みつけた commit

み〜つけた。こいつや

github.com

firstUserId = 64 が定義されてて user 定義の型は id = 64 から始まるんですが、上記 commit の このへん で、いままでとりあえず nextId +1 から確保してたのが止まって 64 から使えるようになったという話っぽい!

-nextId++
+nextId := typeId(len(idToType))

でもこれ副産物ぽくて PR の中身見ると全然違う他のチューニングが主眼ぽい感じがある!なんかおまけで挙動変わっとる

感想

TL で見かけて調べ始めましたが面白かったです。