protobufのNon-varintの互換性について

以前、Varintを64bitから32bitに変更したときの挙動を検証しました。

cipepser.hatenablog.com

上記記事の通りVarintは、下位32-bitを残して切り詰める結果でした。 今回は、Non-varintの互換性を検証します。

例によって、Language Guideを見てみると

fixed32 is compatible with sfixed32, and fixed64 with sfixed64.

と述べられています。

fixed32sfixed32fixed64sfixed64のように同じ長さであれば、互換性があるようです。

実際、Protocol Buffers - Encodingwire typesで以下のように定義されており、32bitと64bitでそもそもTypeが異なります。

Type Meaning Used For
0 Varint int32, int64, uint32, uint64, sint32, sint64, bool, enum
1 64-bit fixed64, sfixed64, double
2 Length-delimited string, bytes, embedded messages, packed repeated fields
3 Start group groups (deprecated)
4 End group groups (deprecated)
5 32-bit fixed32, sfixed32, float

.proto

今回は以下の.protoを使います。

syntax = "proto3";
package fixed;

message User {
  fixed32 id = 1;
}

idfixed32fixed64sfixed32sfixed64の4パターンで変化させて、検証します。

書き出し

書き出し用のコードは以下の通りです。 Idvalueはbitがすべて1になる値で検証します(user.Idフィールドのコメントアウト参照)。

package main

import (
    "io/ioutil"
    "log"

    pb "github.com/cipepser/protobuf-sample/fixed"
    "github.com/golang/protobuf/proto"
    "fmt"
    // "math" // fixed32とfixed64のときのみ利用
)

func main() {
    user := &pb.User{
        //Id: math.MaxUint32, // fixed32のとき
        //Id: math.MaxUint64, // fixed64のとき
        Id: -1,  // sfixed32とsfixed64のとき
    }

    fmt.Printf("%x\n", user.Id)

    if err := write("./fixed/fixed32.bin", user); err != nil {
        log.Fatal(err)
    }
}

func write(file string, user *pb.User) error {
    out, err := proto.Marshal(user)
    if err != nil {
        return err
    }
    fmt.Printf("% x\n", out)
    if err := ioutil.WriteFile(file, out, 0644); err != nil {
        return err
    }
    return nil
}

出力ファイルfixed32.binの部分は、fixed32fixed64sfixed32sfixed64ごとに別ファイルとして出力します。

読み込み

読み込み用のコードです。

package main

import (
    "fmt"
    "io/ioutil"
    "log"

    pb "github.com/cipepser/protobuf-sample/fixed"
    "github.com/golang/protobuf/proto"
)

func main() {
    if err := read("./fixed/fixed32.bin"); err != nil {
        log.Fatal(err)
    }
    if err := read("./fixed/fixed64.bin"); err != nil {
        log.Fatal(err)
    }
    if err := read("./fixed/sfixed32.bin"); err != nil {
        log.Fatal(err)
    }
    if err := read("./fixed/sfixed64.bin"); err != nil {
        log.Fatal(err)
    }
}

func read(file string) error {
    in, err := ioutil.ReadFile(file)
    if err != nil {
        return err
    }
    user := &pb.User{}

    if err := proto.Unmarshal(in, user); err != nil {
        return err
    }
    fmt.Printf("%d\n", user.Id)
    return nil

}

読み込みの前に、.protoidfixed32fixed64sfixed32sfixed64で変化させて、protoc -I=fixed/ --go_out=fixed/ fixed.protoを実行しています。

結果

書き出し、読み込みを全パターンで行った結果は以下の通りです。
※表のレイアウトが崩れるため、4294967295(math.MaxUint32)18446744073709551615(math.MaxUint64)MaxUint32MaxUint64で略記します。

正しく読み込めている()のは、書き出し/読み込みで同じ.protoを使ったパターンのみでした。

.proto(write時) 値(write) .proto(read時) 値(read) 結果
fixed32 MaxUint32 fixed32 MaxUint32
fixed32 MaxUint32 fixed64 0 ×
fixed32 MaxUint32 sfixed32 -1
fixed32 MaxUint32 sfixed64 0 ×
fixed64 MaxUint64 fixed32 0 ×
fixed64 MaxUint64 fixed64 MaxUint64
fixed64 MaxUint64 sfixed32 0 ×
fixed64 MaxUint64 sfixed64 -1
sfixed32 -1 fixed32 MaxUint32
sfixed32 -1 fixed64 0 ×
sfixed32 -1 sfixed32 -1
sfixed32 -1 sfixed64 0 ×
sfixed64 -1 fixed32 0 ×
sfixed64 -1 fixed64 MaxUint64
sfixed64 -1 sfixed32 0 ×
sfixed64 -1 sfixed64 -1

上記結果を見ると、 パースそのものに失敗して、値が0となった(×)ものと、 値は読み込めているものの元の値と異なる()もの があることがわかります。

よくよく見てみると×は32bitと64bitで長さが異なるもののみです。

Language Guide

fixed32 is compatible with sfixed32, and fixed64 with sfixed64.

の通りですね。
しかし、同じ長さであればcompatibleなはずなのにで元の値と変わってしまったものもあります。
もう少し深掘りするために、書き出したバイナリを表に加えてみます。(binary列)

proto(write時) 値(write) binary proto(read時) 値(read) 結果
fixed32 MaxUint32 0d ff ff ff ff fixed32 MaxUint32
fixed32 MaxUint32 0d ff ff ff ff fixed64 0 ×(32bit vs 64bit)
fixed32 MaxUint32 0d ff ff ff ff sfixed32 -1
fixed32 MaxUint32 0d ff ff ff ff sfixed64 0 ×(32bit vs 64bit)
fixed64 math.MaxUint64 09 ff ff ff ff ff ff ff ff fixed32 0 ×(32bit vs 64bit)
fixed64 math.MaxUint64 09 ff ff ff ff ff ff ff ff fixed64 math.MaxUint64
fixed64 math.MaxUint64 09 ff ff ff ff ff ff ff ff sfixed32 0 ×(32bit vs 64bit)
fixed64 math.MaxUint64 09 ff ff ff ff ff ff ff ff sfixed64 -1
sfixed32 -1 0d ff ff ff ff fixed32 MaxUint32
sfixed32 -1 0d ff ff ff ff fixed64 0 ×(32bit vs 64bit)
sfixed32 -1 0d ff ff ff ff sfixed32 -1
sfixed32 -1 0d ff ff ff ff sfixed64 0 ×(32bit vs 64bit)
sfixed64 -1 09 ff ff ff ff ff ff ff ff fixed32 0 ×(32bit vs 64bit)
sfixed64 -1 09 ff ff ff ff ff ff ff ff fixed64 math.MaxUint64
sfixed64 -1 09 ff ff ff ff ff ff ff ff sfixed32 0 ×(32bit vs 64bit)
sfixed64 -1 09 ff ff ff ff ff ff ff ff sfixed64 -1

まず、追加したbinary列の先頭の0d09でkeyのfield_numberwire_typeがわかります。
Protocol Buffers - Encodingに書いてあるとおり、keyは、(field_number << 3) | wire_typeエンコードされます。

つまり、
0d(1 << 3) | 5なので、type5(32-bit)のフィールド1(Id)であり、
09(1 << 3) | 1なので、type5(64-bit)のフィールド1(Id)です。

その後に続くff...value1です。

まとめ

以上から、×となった32bitと64bitは0d09でそもそものwire_typeが異なり、パースに失敗し、defalut valueである0となったようです。

となった箇所については、wire_typeが同じ(fixed32sfixed320dfixed64sfixed6409)ため、後続のバイナリをvalueとしてパースします。 パースする際に.protoが異なるため、書き込んだ値と違う値で解釈されてしまったわけです。 例えば、fixed32sfixed32では、 fixed32としてMaxUint32(4294967295)0xff ff ff ffとしてエンコードします。 しかし、sfixed320xff ff ff ffをパースすると-1として解釈されてしまいます。

パースに際してエラーが起きるわけではない2ので、正しく解釈できる範囲でcompatibleです。 上記の例(32bit)では、0x00 00 00 000x07 ff ff ffです。 これを超えると、正数と2の補数で表現された負数が区別できないため、signed/unsignedで結果が異なります。

References


  1. 今回はffとなっているため区別しませんが、valueはホストバイトオーダーで書き込まれるようなので、リトルエンディアンになることが多いと思います。バイナリを読む際にはご注意ください。

  2. 処理系によって異なるかもしれません。今回検証したGoの範囲での結果です。