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 を用い、キーとしてフィールドの名前を使わず数字を使うなどの方法でさらに最適化していますが、基本的な考え方はこの記事の内容と同じです。シリアライズ周りのパフォーマンスで困っている方がいらっしゃったら何かの参考になればうれしいです。


hirooka

C# + MySQL + Dapper で軽量 O/R Mapper


エンジニアの廣岡です。

最近は仕事で C# を用いたサーバを書いていますが、C# で O/R Mapper (ORM) を使ったことがなくて「とりあえず動く軽量なものが欲しい」と思って見つけ出した Dapper という Micro-ORM を紹介していきます。

Dapper: https://github.com/StackExchange/dapper-dot-net

ところで Micro-ORM とは、ORM の機能のうちいくつかの機能が無いものを指すようですが、「SQL 文を作るクエリビルダの機能」がないものを指すことが多いようです。

C# + MySQL + Dapper

C# から MySQL に接続するときは、ADO.NET インターフェイスを持った MySQL 公式の Connector/Net を使うのが無難でしょう。
http://dev.mysql.com/downloads/connector/net/
これを使えば基本的な MySQL への接続・SQL 実行などはできるようになるのですが、IDataReader や DataSet といったややローレベルなインターフェイスなのでちょっと使いにくいです。
そこで ORM が登場するわけですが、Entity Framework のようなリッチな ORM の導入がちょっとめんどくさかった(というか勉強不足だった)ので、超軽量な ORM は無いのかな〜と思って探していたら Dapper という Micro-ORM を見つけました。
これがどう軽量なのかというと

  • ライブラリは SqlMapper.cs ファイルだけ(数千行ありますが)
  • 基本的には ADO.NET の IDBConnection に拡張メソッドを追加しているだけ

というもので、基本は ADO.NET として使えばよくて、SQL クエリの結果をクラスや構造体にマッピングしたい時に Dapper による拡張メソッドを呼び出すというものです。
クエリビルダは無くデータマッピングしかしないのですが、今すぐ欲しい物としては十分な機能だったので採用しました。

サンプルコード

Dapper の IDbConnection.Query を使って DB 上の Users テーブルから User クラスの一覧を取得するコード

class User
{
    public int Age { get; set; }
    public string Name { get; set; }
}

using (var connection = new MySQLConnection("設定"))
{
    connection.Open();
    var users = connection.Query<User>("SELECT * FROM Users"); // この行だけ Dapper
    foreach (var user in users)
    {
        Console.WriteLine(user.Name);
    }
}

Dapper の IDbConnection.Execute を使って DB 上の Users テーブルにレコードを挿入するコード
(あまり ORM の恩恵がないケース)

using (var connection = new MySQLConnection("設定"))
{
    connection.Open();
    var numOfAffected = connection.Execute("INSERT INTO Users (Age, Name) VALUES (23, Alice)");
}

Dapper で満足?

Dapper は急ぎで欲しかった機能を提供してくれました。ただその後、クエリビルダがないことによって恥ずかしながらテーブル名をタイポしてしまい、気づきにくいバグを作ってしまいました。
この種のバグを二度と作らないためにも Dapper 用にクエリビルダを導入するか、Linq to Entities のようなフル ORM に乗り換えるか、検討をはじめています。

Dapper は ADO.NET をもう少し便利に使いたいなーというシーンには使いどころもありますが、ゲームのサーバ側の MySQL 用に使うには機能不足感があると思います。

Unity 上で SQLite を扱うときなんかにはこの軽量さが発揮されそうですね。


sindharta

幻塔戦記グリフォンの AI で使っている Behaviour Tree


こんにちは、クライアントエンジニアの Sindharta Tanuwijaya(シンダルタ タヌイジャヤ)です。

今更ですが、1月の社内の勉強会で、 Behaviour Tree という AI の手法を発表させて頂きました。当時は幻塔戦記グリフォンを開発するのにあたって、1つのフィーチャーを完成させるためにこの機能を作っていましたが、今はいろいろなフィーチャーで使われています。

Behaviour Tree とは思考 AI のアルゴリズムの1つで、比較的に良く知られているステートマシンと目的が似ています。それはゲーム内のオブジェクトをどう考えさせて、行動させることです。ステートマシンも良い手法ですが、 AI が複雑になってくるのにつれて、管理の難しさが倍に増えるデメリットがあります。そこで、 Behaviour Tree を導入してみたわけです。

当日発表したスライドは以下です。

また、自分の発表をさらに分かりやすくするために、動画も作りました。

折角ですので、私が参考にしたリソースも伝えたいと思います。すべて英語ですが。

  1. Introduction to Behavior Trees
  2. Unity 4.x Game AI Programming

ちなみに、 Behave という Behaviour Tree をデザインするための Unity のプラグインはあります。とても良いプラグインですが、様々な面を検討した結果、今回私たちは独自のものを作ることになりました。Behave が持っているエディター機能をはやく見習わないといけないですね。

Behaviour Tree という手法はコンシューマーゲームでは約10年前に導入されていたと思いますが、いよいよ今になってスマートデバイスのゲームに導入してもおかしくはない時代になってきています。スマートデバイスがリッチになってきた証拠です。Aiming でこれからもどんどんリッチなゲームを開発していきたいと思います!

最後にちょっとしたネタがあります。Behaviour も、u なしの Behavior も両方見かけたことがありませんか?実はu なしの Behavior はよく米国で使われているらしく、イギリスなどといった他のところでは Behaviour は正しいそうです。個人的にはu ありの Behaviour が格好よく見えるので、それを選ばせていただきました。


ppc(細田幸治)

Unityで使える非同期処理のクラスライブラリ(iterator-tasks)を公開しました


こんにちは。Aiming 東京開発グループの細田です。
私たちのチームで開発したロードオブナイツで使っている非同期処理ライブラリを公開しました!!

github のアドレスはこちら
https://github.com/aiming/iterator-tasks

サンプルとテストコード付いてます。

設計者は ++C++; の中の人こと岩永信之さんです。ネットでC#について検索したことがある方なら一度はサイトを見たことあると思います。

公開したクラスをどんな用途で使っているか、どんな設計で出来ているかについては以下のスライドをご参照ください。

.NET 4.0 の Task のような機能を Unity でも使えるようになります。
一般的なゲームで実装されるタスクとはちょっと意味合いが異なり、非同期で動き、必ず終了する処理について使いやすくするためのライブラリです。
WWW での異常系実装やシーケンシャルな非同期処理の実装が簡単に出来るので是非使ってみてください。

 


ppc(細田幸治)

Lord of Knightsの開発裏側みせます。のスライド公開


こんにちは細田です。

4/10に行われた「パソナテック エンジニアカフェ×Aiming Lord of Knights の裏側見せます!
で弊社で開発運営している Lord of Knights のクライアントとサーバー開発について発表を行いました。

当日は120名以上の方にご参加いただき大盛況でした。皆さまありがとうございます!

1つのオンラインゲームについてクライアント側とサーバー側との両方の話ができた珍しい勉強会だったと思います。

続きを読む