見出し画像

Now in REALITY Tech #69 Protocol Buffersのデータ量を削減する方法

REALITYのUnityエンジニアのyaegakiです。

REALITYでは多くの部分でProtocol Buffers(以下protobuf)を使用してメッセージをエンコードしています。
REALITYはモバイルアプリなのでデータの通信量が少なければ少ないほどギガにも優しく嬉しいです。
そこで今回はprotobufを使用する上でどのようなことを意識すればデータのサイズを削減できるかについて解説していきます。
今回の記事ではコードにC#を使用していますが、他の言語でも大体同じような結果にはなると思います。

今回解説する内容は下記の公式のページに記載されています。
詳しい内容を知りたい人はこちらを参照してください。

Encoding | Protocol Buffers Documentation

データ量削減のTips

protobufのデータの基本的な構造

protobufのデータは基本的にはフィールド番号をキーにしたKey-Value Pairのリストになっています。
データ量削減において重要なことはデフォルト値はデータに含まれないということです。
例えば以下のケースを考えます。

message ProtobufVector3
{
    float x = 1;
    float y = 2;
    float z = 3;
}

この定義から作成したクラスを使って全てデフォルト値でエンコードした場合、結果は0byteになります。

var vec = new ProtobufVector3();
Debug.Log($"Length: {v.ToByteArray().Length}");  // => Length: 0

送信する必要のないデータは初期値のままにしておくことでデータ量を削減することができます。

整数(intN)のエンコード

整数のエンコードは基本的に可変バイト数になります。
例えば値が 1 の時は1バイト、値が150の時は2バイトとなります。

message MessageInt32
{
    int32 value = 1;
}
var v = new MessageInt32();
v.Value = 1;
Debug.Log($"Length: {v.ToByteArray().Length}");// => Length: 2 (Key部分で1byte、Value部分で1byte)

v.Value = 150;
Debug.Log($"Length: {v.ToByteArray().Length}"); // => Length: 3

エンコード方式の詳細は Base 128 Variants を参照してください。

注意が必要な点としてはintの数値が最大値付近のときに5byte必要な可能性があること、負の値については常に10バイト必要になるという点です。

var v = new MessageInt32();
v.Value = int.MaxValue;
Debug.Log($"Length: {v.ToByteArray().Length}"); // => Length: 6

v.Value = -1;
Debug.Log($"Length: {v.ToByteArray().Length}"); // => Length: 11

どちらもエンコード方式によるもので前者については特に困ることもないと思いますが後者は注意が必要です。
負の値はできるだけ使用しない方がデータ量を削減することができます。
負の値を使用したい場合、intNの代わりに "s"intNを使用した方が効率的になります

整数(sintN)のエンコード

基本的にはintNと同様ですがより効率的に負の値をエンコードすることができます。
sintNではZigZagエンコードと呼ばれる手法で値を正の値に変換してから整数のエンコードを行います。

message MessageSInt32
{
    sint32 value = 1;
}
var v = new MessageSInt32();
v.Value = 1;
Debug.Log($"Length: {v.ToByteArray().Length}"); // => Length: 2

v.Value = -1;
Debug.Log($"Length: {v.ToByteArray().Length}"); // => Length: 2

ただし、ZigZagエンコードを行うことによって正の値をエンコードした時のデータ量が増える可能性もあります。

var v = new MessageInt32();
v.Value = 100;
Debug.Log($"Length: {v.ToByteArray().Length}"); // => Length: 2

var v2 = new MessageSInt32();
v2.Value = 100;
Debug.Log($"Length: {v2.ToByteArray().Length}"); // => Length: 3

負の値をほとんど使用しないことがわかっている場合はintNの方が若干効率的にはなります。

ZigZagエンコードの詳細は Signed Integers を参照してください。

実数のエンコード

実数(float、double)のエンコードは整数とは違い常に固定長のバイト数になります。

message MessageFloat
{
    float value = 1;
}
var v = new MessageFloat();
v.Value = 0.1f;
Debug.Log($"Length: {v.ToByteArray().Length}"); // => Length: 5

v.Value = float.MaxValue;
Debug.Log($"Length: {v.ToByteArray().Length}"); // => Length: 5

v.Value = -0.1f;
Debug.Log($"Length: {v.ToByteArray().Length}"); // => Length: 5

小さい値でも常に4byte(doubleは8byte)必要であるため、整数を代わりに使用した方がデータ量を削減できる可能性はあります。

文字列、バイト配列のエンコード

文字列やバイト配列のエンコードはデータ部分の長さを示す整数+実際のデータ分のバイト数になります。

エンコード方式の詳細は Length-Delimited Records を参照してください。

サブメッセージのエンコード

protobufではメッセージの中にメッセージを持つことができます。
メッセージの中にメッセージを持つ場合、文字列やバイト配列と同様の方式でエンコードされます。
つまり、メッセージをエンコードしたデータの長さを示す整数+メッセージをエンコードしたデータ分のバイト数になります

ほぼ気にしなくていいレベルではありますが、メッセージの中にメッセージを持つよりメッセージに直接値を保持した方がデータ量としては少なくなります。
(数バイトしか違いがないのでおすすめはしません。)

message ProtobufVector3
{
    float x = 1;
    float y = 2;
    float z = 3;
}

message EmbedVector
{
    float x = 1;
    float y = 2;
    float z = 3;
    int32 hoge = 4;
}

message SubMessageVector
{
    ProtobufVector3 vec = 1;
    int32 hoge = 2;
}
var v = new EmbedVector();
v.X = .1f;
v.Y = .1f;
v.Z = .1f;
v.Hoge = 1;
Debug.Log($"Length: {v.ToByteArray().Length}"); // => Length: 17

var v2 = new SubMessageVector();
v2.Vec = new ProtobufVector3
{
    X = .1f,
    Y = .1f,
    Z = .1f,
};
v2.Hoge = 1;
Debug.Log($"Length: {v2.ToByteArray().Length}"); // => Length: 19

Optionalのエンコード

Optionalについては単純に存在すればエンコードし、存在しなければエンコードせずデータにも含まれなくなります。

Repeatedのエンコード

repeated指定されたフィールドについては値がスカラー型かどうかで扱いがことなります。
主に整数や実数がスカラー型にあたります。

スカラー型の場合は値がパックされてエンコードされます
スカラー型以外では値が個別に含まれることになります
以下のようなイメージです

// スカラー型の場合は一つのキーに対して複数の値が入る
field-number1: { 1, 2, 3, 4, 5 }

// スカラー型以外の場合は一つのキーに対して一つの値が入る
// キーが重複して含まれることになる
field-number1: 1
field-number1: 2
field-number1: 3
field-number1: 4
field-number1: 5

パックされている場合、キーが一つでいいので若干データ量が少なくなります。

repeated指定されたフィールドはデフォルト値でも値が含まれてしまうという点には注意が必要です。
例えばfloatのデフォルト値である0を100個格納したrepeated指定されたフィールドをエンコードする時、4*100byteのデータ量になります。

message FloatList
{
    repeated float list = 1;
}
var v = new FloatList();
// デフォルト値を100個入れる
v.List.AddRange(Enumerable.Range(0, 100).Select(_ => 0f));
Debug.Log($"Length: {v.ToByteArray().Length}"); // => Length: 403

floatを直接使う代わりにサブメッセージを使用することでデータ量を削減できる可能性があります。
これはサブメッセージ自体がデフォルト値の場合はデータ量が0になり、キーの部分のデータ量だけで済むためです。

message MessageFloat
{
    float value = 1;
}

message MessageFloatList
{
    repeated MessageFloat list = 1;
}
var v = new MessageFloatList();
// デフォルト値を100個入れる
v.List.AddRange(Enumerable.Range(0, 100).Select(_ => new MessageFloat{Value = 0f}));
Debug.Log($"Length: {v.ToByteArray().Length}"); // => Length: 200

リストの中が大部分がデフォルト値でいい場合、値が存在する場所のIndexとその値のペアを使うようにした方がデータ量を削減できます

message IndexFloatPair
{
    int32 index = 1;
    float value = 2;
}

message SparseFloatList
{
    repeated IndexFloatPair list = 1;
}
var v = new FloatList();
v.List.AddRange(Enumerable.Range(0, 100).Select(_ => 0f));
v.List.Add(.1f); // // 101個目の要素に.1fを入れる
Debug.Log($"Length: {v.ToByteArray().Length}"); // => Length: 407

var v2 = new SparseFloatList();
v2.List.Add(new IndexFloatPair
{
    Index = 100, // 101個目の要素に
    Value = 0.1f, // .1fを入れる
});
Debug.Log($"Length: {v2.ToByteArray().Length}"); // => Length: 9

もしくは単純にmapを使用することもできます。

message FloatMap
{
    map<int32, float> map = 1;
}

Mapのエンコード

mapのエンコードはKey-Value Pairのrepeatedフィールドをエンコードする場合と同じ結果になります。

message IndexFloatPair
{
    int32 index = 1;
    float value = 2;
}

message SparseFloatList
{
    repeated IndexFloatPair list = 1;
}

// SparseFloatListとエンコード結果は大体同じ
message FloatMap
{
    map<int32, float> map = 1;
}

まとめ

細かいことを色々書きましたが以下を覚えておけばなんとかなると思います。

  • デフォルト値はエンコード結果に含まれない

  • 小さい整数はデータサイズも小さい

  • 負の整数はデータサイズが大きい

    • 負の整数を使用する場合はsintNを使用した方が効率的

  • 実数は固定長

  • repeatedを使う時はサイズに注意する

    • 必要なデータ以外は入れない方がいい