chiepomme

静的コード生成を用いたシリアライゼーション高速化


こんにちは、とある新規開発プロジェクトで主に C# でクライアントとリアルタイムサーバーを書いている 水上智絵(@chiepomme)です。今回は私のプロジェクトで行った、C#におけるシリアライゼーションの高速化についてお話ししたいと思います。

はじめに

いま私が関わっているプロジェクトで以前、通信が多くなると動作がカクカクしてしまう問題が出ていました。Unity のプロファイラで調査したところ、ほとんどがシリアライズ・デシリアライズのコストだったことから、シリアライズの処理を高速化する必要が生まれました。今回はその方法についてご紹介します。

そもそもシリアライズってなんだ!

シリアライズというのは、プログラムの中のオブジェクトを、ファイルに書き込んだり通信に乗せたり出来るようなバイト列(文字列)に変換することです。逆にバイト列からプログラム中のオブジェクトに変換することをデシリアライズといいます(由来は知らないのですが、シリーズとかシリアルと同じ言葉なので、一続きのもの、つまりバイト列一本にするからシリアライズというイメージで私は考えてます)。

たとえば、以下のようなクラスがあったとします。

class SerializableObject
{
    public int A = 10;
    public int B = 20;
}

これをファイルに保存したい、通信に乗せたいとなった場合には、「A:10, B:20」のようなテキストでの表現や、値である「10 20」をそれぞれバイト列に変換してつなげるというのが素直な方法だと思います。こういった処理をシリアライゼーション、動詞だとシリアライズする、と言います。

リフレクションを用いた汎用的なシリアライザーとパフォーマンス

さて、こうしたシリアライゼーションは小さな物であれば個別のクラス毎に定義することが可能ですが、規模が大きくなってくるとそうするのは現実的では無くなってきてしまいます。そこで色んなクラスに汎用的に使えるシリアライザーが必要となります。

汎用的なシリアライザーとしては代表的なのは C# を使い始めた頃きっとだれでもお世話になるであろう XmlSerializer です。コードは GitHub で見られます。
https://github.com/dotnet/corefx/tree/master/src/System.Xml.XmlSerializer/src/System/Xml

このコードを見てもらうと分かるのですが、通常こういったシリアライザーを作る場合にはリフレクションが必要となります。しかしリフレクションは動作が遅く、保存データの読み書きであれば問題になることは少ないと思いますが、リアルタイム通信に用いるような箇所に使用すると速度的にボトルネックになりやすいという問題点があります。

最新の C# を使えてかつ動的コード生成に対応している環境であれば、様々な方法でリフレクションを用いずにも、動的にオブジェクトにアクセスすることが可能です。具体的な方法は以下の岩永さんのブログをご覧ください。

[雑記] 動的コード生成のパフォーマンス – C# によるプログラミング入門 | ++C++; // 未確認飛行 C

しかし、Unity の C# は若干古く、また iOS では動的コード生成ができないという制約があります。よって、私たちのプロジェクトでもキャッシュを用いて改善を試みましたが、それだけでは満足のいく改善が見られませんでした。その後も細かなチューニングをしてみたもののそもそものリフレクションのパフォーマンスの問題があるため、天井が見えてしまっていました。

リフレクションから静的コード生成へ

大きな改善のためには根本的な発想の転換が必要だと思い、動的な仕組みでは無くシリアライズの方法自体を静的に生成してしまうという方法を考えました。ただ C# には静的なコード生成をサポートするような仕組みはないので、本当にコードそのものを生成してあげる必要があります(幸い Aiming には JSON Schema を用いた Generator のような、通信定義 DSL を書くことで通信ファイルを生成する仕組みがあるため、それに載せて使用しています)。

では、実際に C# のコードで比較してみましょう。

シリアライズしたいクラス

話を単純にするために以下のような、入れ子を伴わず、フィールドの型も int もしくは string のみのクラスを対象とします。また、シリアライズ先のフォーマットはテキストで「フィールド名<タブ>値<改行>…」とします。

public class SimpleSerializableObject
{
    public int Int1;
    public int Int2;
    public int Int3;

    public string String1;
    public string String2;
    public string String3;
}

(from https://github.com/chiepomme/SampleSerializer/blob/master/Serialization/SimpleSerializableObject.cs)

リフレクションを使用したコード

using System;
using System.Reflection;
using System.Text;

namespace Serialization
{
    public class StringSerializerWithReflection
    {
        public string Serialize<T>(T serializableObject) where T : new()
        {
            var stringBuilder = new StringBuilder();

            // シリアライズ対象クラスの全てのフィールドを取得
            var fields = typeof(T).GetFields(BindingFlags.GetField | BindingFlags.Public | BindingFlags.Instance);
            foreach (var field in fields)
            {
                // キー + タブ + 値 の形で文字列にする
                stringBuilder.AppendLine(field.Name + "\t" + field.GetValue(serializableObject));
            }
            return stringBuilder.ToString().Replace("\r\n", "\n");
        }

        public T Deserialize<T>(string serializedString) where T : new()
        {
            var resultObj = new T();
            // デシリアライズ対象クラスの全てのフィールドを取得
            var fields = typeof(T).GetFields(BindingFlags.SetField | BindingFlags.Public | BindingFlags.Instance);

            foreach (var line in serializedString.Split('\n'))
            {
                // キー + タブ + 値 の形で格納されているのを取り出す
                if (string.IsNullOrEmpty(line)) continue;
                var nameAndValue = line.Split('\t');
                var name = nameAndValue[0];
                var rawValue = nameAndValue[1];
                var field = Array.Find(fields, (e) => e.Name == name);

                SetValueToField(resultObj, field, rawValue);
            }

            return resultObj;
        }

        void SetValueToField<T>(T resultObj, FieldInfo field, string rawValue)
        {
            // テキストをフィールドの型でパースして実際の値にする
            if (field.FieldType == typeof(int))
            {
                field.SetValue(resultObj, int.Parse(rawValue));
            }
            else if (field.FieldType == typeof(string))
            {
                field.SetValue(resultObj, rawValue);
            }
        }
    }
}

(from https://github.com/chiepomme/SampleSerializer/blob/master/Serialization/StringSerializerWithReflection.cs)

シリアライズ時には、リフレクションで全てのフィールドを走査して、それぞれの名前をキーとして StringBuilder に追加していき、最終的な文字列を構築します。デシリアライズ時にはその逆のことを行うだけの単純な物です。

静的コード生成による方法

リフレクションを使わず静的コード生成を用いる時には、ビルドより前にシリアライズ対象のクラスに対応したシリアライズ・デシリアライズメソッドを生成する必要があります。今回の場合にリフレクションを使わずリフレクションと同じ事をするためのコードは以下のようになるでしょう。

// このファイルは StaticStringSerializerGenerator によって生成されました
using System.Text;

namespace Serialization
{
    public partial class SimpleSerializableObject
    {
        public string Serialize()
        {
            // キー + タブ + 値 の形で文字列にする
            var sb = new StringBuilder();
            sb.AppendLine("Int1\t" + Int1);
            sb.AppendLine("Int2\t" + Int2);
            sb.AppendLine("Int3\t" + Int3);
            sb.AppendLine("String1\t" + String1);
            sb.AppendLine("String2\t" + String2);
            sb.AppendLine("String3\t" + String3);
            return sb.ToString().Replace("\r\n", "\n");
        }

        public SimpleSerializableObject(string serializedString)
        {
            foreach (var line in serializedString.Split('\n'))
            {
                // キー + タブ + 値 の形で格納されているのを取り出す
                if (string.IsNullOrEmpty(line)) continue;
                var nameAndValue = line.Split('\t');
                var name = nameAndValue[0];
                var rawValue = nameAndValue[1];

                switch (name)
                {
                    case "Int1": Int1 = int.Parse(rawValue); break;
                    case "Int2": Int2 = int.Parse(rawValue); break;
                    case "Int3": Int3 = int.Parse(rawValue); break;
                    case "String1": String1 = rawValue; break;
                    case "String2": String2 = rawValue; break;
                    case "String3": String3 = rawValue; break;
                }
            }
        }
    }
}

(from https://github.com/chiepomme/SampleSerializer/blob/master/Serialization/SimpleSerializableObject.Serializer.cs)

これは、シリアライズ対象の SimpleSerializableObject を 以下のような Generator に通して自動生成したものになります。

using System;
using System.IO;
using System.Reflection;
using System.Text;

namespace SerializerGenerator
{
    class StaticStringSerializerGenerator
    {
        public void Generate(Type type)
        {
            File.WriteAllText(type.Name + ".Serializer.cs", Stringify(type));
        }

        public string Stringify(Type type)
        {
            var fields = type.GetFields(BindingFlags.GetField | BindingFlags.Public | BindingFlags.Instance);

            // 本当はハードコードじゃなくて何かしらのテンプレートを使ってね!
            var sb = new StringBuilder();
            sb.AppendLine(@"// このファイルは StaticStringSerializerGenerator によって生成されました");
            sb.AppendLine(@"using System.Text;");
            sb.AppendLine();
            sb.AppendLine(@"namespace " + type.Namespace);
            sb.AppendLine(@"{");
            sb.AppendLine(@"    public partial class " + type.Name);
            sb.AppendLine(@"    {");
            sb.AppendLine(@"        public string Serialize()");
            sb.AppendLine(@"        {");
            sb.AppendLine(@"            var sb = new StringBuilder();");
            // 全フィールドの値を出力する部分を生成
            foreach (var field in fields)
            {
                sb.AppendFormat(@"            sb.AppendLine(""{0}\t"" + {0});", field.Name);
                sb.AppendLine();
            }
            sb.AppendLine(@"            return sb.ToString().Replace(""\r\n"", ""\n"");");
            sb.AppendLine(@"        }");
            sb.AppendLine();
            sb.AppendLine(@"        public " + type.Name + "(string serializedString)");
            sb.AppendLine(@"        {");
            sb.AppendLine(@"            foreach (var line in serializedString.Split('\n'))");
            sb.AppendLine(@"            {");
            sb.AppendLine(@"                if (string.IsNullOrEmpty(line)) continue;");
            sb.AppendLine(@"                var nameAndValue = line.Split('\t');");
            sb.AppendLine(@"                var name = nameAndValue[0];");
            sb.AppendLine(@"                var rawValue = nameAndValue[1];");
            sb.AppendLine();
            sb.AppendLine(@"                switch (name)");
            sb.AppendLine(@"                {");

            // 全フィールドのパース部分を生成
            foreach (var field in fields)
            {
                if (field.FieldType == typeof(int))
                {
                    sb.AppendFormat(@"                    case ""{0}"": {0} = int.Parse(rawValue); break;", field.Name);
                }
                else if (field.FieldType == typeof(string))
                {
                    sb.AppendFormat(@"                    case ""{0}"": {0} = rawValue; break;", field.Name);
                }
                sb.AppendLine();
            }

            sb.AppendLine(@"                }");
            sb.AppendLine(@"            }");
            sb.AppendLine(@"        }");
            sb.AppendLine(@"    }");
            sb.AppendLine(@"}");

            return sb.ToString();
        }
    }
}

(from https://github.com/chiepomme/SampleSerializer/blob/master/SerializerGenerator/StaticStringSerializerGenerator.cs)

コードはだいぶ読みづらくなってしまっていますが、先ほどのリフレクションを使わずにシリアライズするコードをテンプレートとして、個別のフィールドの部分を動的に生成するようにしたコードです。

このようにクラス毎のシリアライズのコードをファイルとしてそのまま生成してしまうというのが、静的コード生成の基本的な戦略になります。

リフレクションによるものと静的コード生成のパフォーマンスの比較

実際に上記の2つの方法+キャッシュ有りを比較したのが以下のグラフになります。

シリアライズ(1000回)

シリアライズ(100000回)

試行回数を問わず、静的コード生成バージョンはリフレクションを用いる物に比べて、最低でも2倍以上の速度が出るようになりました。

なお、シリアライザーのコードも計測コードもまとめて chiepomme/SampleSerializer にアップしてありますので、興味があれば是非ご覧ください。

おわりに

動的なものに比べるとこういった静的なコード生成は泥臭い印象がありますが、速度面で有利なことが多いです。その一方でコードのサイズは膨れてしまうので、場合によっては使用するべきではないシチュエーションもあると思います。このあたりはプロジェクトによるところなので、より理想に近い物を選んでいきたいですね。

ちなみに実際のプロダクトでは、シリアライズのフォーマットは MessagePack を用い、キーとしてフィールドの名前を使わず数字を使うなどの方法でさらに最適化していますが、基本的な考え方はこの記事の内容と同じです。シリアライズ周りのパフォーマンスで困っている方がいらっしゃったら何かの参考になればうれしいです。