Grani Engineering Blog

株式会社グラニはC#を中心として、ASP.NET、Unity、VR開発を行っています。

Unite 2017 Tokyo講演「「黒騎士と白の魔王」にみるC#で統一したサーバー/クライアント開発と現実的なUniRx使いこなし術」

CTOの河合です。Unite 2017 Tokyoにて、 「黒騎士と白の魔王」にみるC#で統一したサーバー/クライアント開発と現実的なUniRx使いこなし術 という講演を行いました。

講演に参加いただいたみなさま、ありがとうございます。資料のほか、講演の動画は、また後日配信されるようなので、そちらもご覧いただけたらと思います。

C#大統一理論

「黒騎士と白の魔王」は、世にも珍しくクライアントとサーバーが共にC#で構築されています。クライアントがC#なのは当たり前ですが(Unityのお陰ですね!)、サーバーがC#なのも、そう珍しいわけではないのですが(特にBtoBの場では、Windows Serverで、C#でウェブシステムを組むことはよくあることです)、両者が組み合わさったケースは中々珍しいと思います。しかしグラニでは、一作目の「神獄のヴァルハラゲート」をC#で構築していたこともあり - グラニがC#にこだわる理由 - 神獄のヴァルハラゲートの裏側をCTOが語り尽くす!、両者をC#で作り込んでいくことは自然な選択でした。

しかしそれは同時にチャレンジでもあります。構成に前例が少ないこともですが、単にリクエスト/レスポンスが共通であること以上のメリットを生み出していこうと様々な試みを行いました。成功した試みもあれば、失敗もあります。その経験を、今回のセッションでは是非共有したかったわけです。

クライアントとサーバーがC#で構築されることは、間違いなく大きなメリットがあると実感しています。まだまだ完全な理想点には至っていませんが、着実に向かっていると考えています。今後も、C#大統一理論をより昇華させていきたいですし、ノウハウは積極的に共有していきます。皆さんも、是非チャレンジして欲しいですし、そのための参考になれば何よりも嬉しいです。

ライブラリ群

グラニでは実際に黒騎士と白の魔王で使われている多くのライブラリを、オープンソースソフトウェアとして公開しています。その中にはUnity単独で使うことも可能ですが、サーバーサイドもC#で構築することで、更なるパワーを発揮するのものもあります。

講演の後半で紹介したMessagePack for C#(Unityで、C#で、世界最速の汎用バイナリシリアライザ)もその一つです。単独で使っていただいても最高のパフォーマンスを発揮しますが、サーバーサイドもC#で構築されていれば使い勝手が大幅に向上します。より自然にUnity用のコードジェネレーターを動かせたり、特有の属性でマークすることによる挙動の変更が手間なくできるなど、IDEも含めた言語機能をフルに活かした使い心地を感じていただけるように注力しました。

サーバーサイド実装とインフラ

黒騎士と白の魔王のもう一つのチャレンジとして、gRPCをUnityで使うという世界初の事例があります。Unityで使うというだけではなく、gRPCをStreamingで活用していくというのも、あまり前例のないケースです。

そうした、今回紹介していない、サーバーサイドでのgRPCの使いこなしや、インフラ構築の話は、 2017 年 6 月 1 日(木)にAWS Summit Tokyoのサブトラック Game Tech Session ~AWS Summit Tokyo 2017~にて 「『黒騎士と白の魔王』の gRPC による HTTP/2 API/ストリーミング通信の実践」 として講演する予定です。こちらはUnityだけではなく、他のモバイルアプリケーションやMicroservicesのバックエンドとしてgRPCを使っている方々にも参考になる話ができると思っていますので、是非よろしくお願いいたします。

C# 7.0 が使えるようになったので ValueTuple を活用してみた

アプリケーション部の田口(@t_tetsuzin)です。 社内では数少ないF#erとして潜伏中です。

待ちに待った VisualStudio2017 がリリースされましたね!
Graniではさっそく C# 7.0 を本番環境に投入しています。

そんな待望の C# 7.0 で使えるようになった新機能は

  • タプル(ValueTuple)・タプルの分解
  • Task-like
  • ローカル関数
  • 拡張されたswitch文(パターンマッチ)
  • etc…

と大きいのから小さいのまで多岐にわたりますが、 今回はタプルの便利な使い方について紹介したいと思います。

タプル以外の今回の目玉機能である Task-like はちょうど公開された弊社CTOの記事でどうぞ!

どういう風に便利なのか

※ ValueTuple は .NET Framework 4.7 未満で使用する場合は
Nuget から System.ValueTuple をパッケージをプロジェクトに追加する必要があります。

ソーシャルゲームではユーザーの情報など大量のデータを様々なところから呼び出す必要があるので、 こういう複数の非同期処理を呼ぶ場所がよく出てきます。

public async Task<int> HogeAsync()
{
    await HeavyFunctionAsync(); // 重い処理
    return 10;
}

public async Task<string> FugaAsync()
{
    
    await HeavyFunctionAsync(); // 重い処理
    return "Fuga";
}

public async Task<int[]> MogeAsync()
{
    await HeavyFunctionAsync(); // 重い処理
    return new[] { 10, 20, 30 };
}

非同期処理を並列に実行するため、今まではこのように書いていました。

public async Task Before()
{
    // C#6までの書き方
    var hogeTask = HogeAsync();
    var fugaTask = FugaAsync();
    var mogeTask = MogeAsync();

    await Task.WhenAll(hogeTask, fugaTask, mogeTask);

    var hoge = hogeTask.Result;
    var fuga = fugaTask.Result;
    var moge = mogeTask.Result;

    // hoge, fuga, moge を使った処理へつづく
    await SomeFunctionAsync(hoge, fuga, moge);
}

長いですね。とてもイケてるコードには見えません。
しかし、タプルと分解、そして拡張メソッドを組み合わせることで……

public async Task After()
{
    // C#7以降でタプル記法を使った書き方
    var (hoge, fuga, moge) = await (HogeAsync(), FugaAsync(), MogeAsync()).WhenAll();

    // hoge, fuga, moge を使った処理へつづく
    await SomeFunctionAsync(hoge, fuga, moge);
}

こんな風に書くことができるようになりました。

追加した拡張メソッド

public static class ValueTupleExtensions
{
    public static async Task<(T1, T2)> WhenAll<T1, T2>(this (Task<T1>, Task<T2>) tasks)
    {
        await Task.WhenAll(tasks.Item1, tasks.Item2).ConfigureAwait(false);
        return (tasks.Item1.Result, tasks.Item2.Result);
    }

    public static async Task<(T1, T2, T3)> WhenAll<T1, T2, T3>(this (Task<T1>, Task<T2>, Task<T3>) tasks)
    {
        await Task.WhenAll(tasks.Item1, tasks.Item2, tasks.Item3).ConfigureAwait(false);
        return (tasks.Item1.Result, tasks.Item2.Result, tasks.Item3.Result);
    }

    public static async Task<(T1, T2, T3, T4)> WhenAll<T1, T2, T3, T4>(this (Task<T1>, Task<T2>, Task<T3>, Task<T4>) tasks)
    {
        await Task.WhenAll(tasks.Item1, tasks.Item2, tasks.Item3, tasks.Item4).ConfigureAwait(false);
        return (tasks.Item1.Result, tasks.Item2.Result, tasks.Item3.Result, tasks.Item4.Result);
    }
}

上の例では4組のタプルまでしか挙げていませんが、8組程度なら用意してしまって良いと思います。

今回はタプルしか紹介していませんが、C# 7.0 には他にも有力な新機能が多く含まれているので、積極的に使っていきましょう! もちろん弊社のもうすぐリリースされる新タイトル黒騎士と白の魔王でも C# 7.0 が使われています!

Google 翻訳 Premium (ニューラルネットワーク適用版) を .NET SDK で使えるようにする方法

こんにちは!VR 部の鈴木 (@xin9le) です。前回 (と言ってももう 1 か月も前ですが…) 私が所属する Grani VR StudioProject Sonata というサービスについて紹介させていただきました。Project Sonata にはリアルタイム音声翻訳機能が搭載されているのですが、そこには Google 翻訳 API を利用しています。非常に精度が高く、レスポンスも高速なのでオススメです。Google 翻訳と言えば、最近流行りの (?) ニューラルネットワークを導入したことで話題になりました。

f:id:xin9le:20170403230520p:plain

せっかくなので、ニューラルネットワークの導入によってどの程度の改善が見られたのかパッと調べてみました。

翻訳前の文章 導入前 導入後
今日は何をしてたんですか? Did you have to do today? What were you doing today?
Oculusの展示が見たいです。 Exhibition of Oculus is I want to see. I would like to see the exhibition of Oculus.
賢そうな翻訳ですね。 It is a Ken likely translation. It looks like a clever translation.

導入前の「Ken likely」はさすがにヒド過ぎる…。というのはさておき、導入前とは比べものにならないほど飛躍的な改善が見られます。もはやこれを使わない手はない…!ということで、今回は Google 翻訳 Premium *1 を .NET 環境下で利用する方法についてご紹介します *2

Step.1 : Google 翻訳 Premium への申請

Google 翻訳 Premium を利用するには下記のフォームからプロジェクトを申請し、承認してもらう必要があります。

無事承認が下りれば、利用できるようになります。イキナリ使えるわけではないので注意が必要です。

Step.2 : Google 翻訳 API を使った実装

まず、以下の NuGet Package をインストールしましょう。Google さんが提供する NuGet Package は非常に数が多いので、ちょっと見つけづらいかもしれません。

f:id:xin9le:20170403211559p:plain

PM> Install-Package Google.Apis.Translate.v2

準備が整ったら以下のようなコードを書きましょう。これで Google 翻訳が動きます。

var text = "賢そうな翻訳ですね。";
var initializer = new BaseClientService.Initializer(){ ApiKey = "ひ・み・つ" };
var service = new TranslateService(initializer);
var request = service.Translations.List(text, "en");
var response = await request.ExecuteAsync();
var result = response.Translations.Single();
Console.WriteLine(result.TranslatedText);

// 翻訳結果:
// It is a Ken likely translation.

ですが、サンプルコード中のコメントにもある通り、このままではニューラルネットワークは適用されません!有効化するためには、ほんの少しの対応が必要になります。

Step.3 : ニューラルネットワークモデルの適用

ニューラルネットワークを有効化するための手法は公式ドキュメントに記載されています。

Google Cloud Translation API で、Standard Edition ではなく Premium Edition を使用するように指示するには、リクエスト内で model パラメータを nmt に設定して渡します。model パラメータは現在次の値をサポートしている文字列です。

  • base(デフォルト)は Standard Edition を使用します。
  • nmt は Neural Machine Translation モデルを使用した Premium Edition を使用します。

将来、エディションが追加される場合があります。この model パラメータを GET クエリのパラメータとして、または JSON POST リクエスト内のフィールドとして渡すことができます。

つまり、GET リクエストに対して &model=nmt のクエリ文字列を追加すれば OK ということです。ただ、残念なことに .NET SDK は標準でニューラルネットワークモデルをサポートしていません。なので有効化するには若干のハックが必要となります。対応が必要なポイントは以下の 2 点です。

  • アクセス先の URL を変更 (← .NET SDK がアクセスしている URL が古い
  • &model=nmt のクエリ文字列を追加する

これらを考慮したコードは以下のようになります。

// アクセス先 URL を変更
class NeuralNetworkTranslateService : TranslateService
{
    public NeuralNetworkTranslateService(Initializer initializer)
        : base(initializer)
    {}

    public override string BaseUri
        => "https://translation.googleapis.com/language/translate/";
}

// クエリ文字列に &model=nmt を追加
class NeuralNetworkTranslateRequest : TranslationsResource.ListRequest
{
    // SDK 内部でリフレクションが走って、このプロパティを解釈しにくる
    [RequestParameter("model", RequestParameterType.Query)]
    public string Model { get; }

    public NeuralNetworkTranslateRequest(IClientService service, string text, string languageTo)
        : base(service, text, languageTo)
    {
        // 翻訳アルゴリズムをニューラルネットワークに
        this.Model = "nmt";
        
        // クエリ文字列にするパラメーターを追加
        this.RequestParameters.Add("model", new Parameter()
        {
            Name = "model",
            IsRequired = true,
            ParameterType = "query",
            DefaultValue = null,
            Pattern = null
        });
    }
}

// 実行
var text = "賢そうな翻訳ですね。";
var initializer = new BaseClientService.Initializer() { ApiKey = "ひ・み・つ" };
var service = new NeuralNetworkTranslateService(initializer);
var request = new NeuralNetworkTranslateRequest(service, text, "en");
var response = await request.ExecuteAsync();
var result = response.Translations.Single();
Console.WriteLine(result.TranslatedText);

// 翻訳結果:
// It sounds like a clever translation.

素敵な翻訳結果が返ってきましたね!

まとめ

ほんの少しクエリ文字列を追加するだけにも係わらず、SDK 標準でサポートされていないために結構面倒なコードを書かなければなりません。ただし逃げ道はあるので安心してください。将来的にはきっと対応していただけると思うので、首を長くして待ちましょう :)

*1:ニューラルネットワークが適用された Google 翻訳 API

*2:導入までの手順は公式ドキュメントにも記載されていますが、本稿執筆時点は .NET SDK のサンプルはない

Grani VR Studio が「Project Sonata」を発表しました

こんにちは!VR 部の鈴木 (@xin9le) です。本日、私が所属する Grani VR StudioProject Sonata というサービスを発表しました!このアナウンスができて、開発チーム一同大変に嬉しく思っています。今日はそんな Project Sonata について簡単にご紹介させていただきます。

f:id:xin9le:20170308204129p:plain

Project Sonata の概要

私たち Grani VR Studio は「Expand your communication」をスローガンとして掲げており、今回発表した Project Sonata もその理念に沿ったプロダクトになっています。Project Sonata では VR / AR をリアルタイムに接続することで、拡張現実空間と仮想空間の隔てなく、世界中どこでも翻訳機能を用いてコミュニケーションをとることができます。下記の動画でその一端を感じていただければと思います。

SxSW でのデモ

Grani VR Studio では、オースティンで 3/9 (木) から開催される SxSW 2017 (= South by South West 2017) にて、招待制で Project Sonata のデモを行う予定です。また不定期でのライブ配信も行う予定ですので、公式 Twitter アカウント (@sonata_PR) をチェックしていただければと思います :)

名前の由来

日本語で「あなた」を意味する言葉の古典的呼び名、「そなた」に「日本のおもてなしの心を持ちながら、接していく場を作る」という意味を含め、「Sonata」と命名しています。

AR / VR の壁、言語の壁を越えて「with you」。そんな気持ちですね!

メディア掲載 (本記事投稿時点)

大変ありがたいことに、すでにいくつかのメディア様にも紹介していただいています。

開発期間はどのくらい?

私たちがこのプロダクトの開発を始めたのは 2 月に入ってからでした。私自身 HoloLens 開発を始めたのは Tokyo HoloLens Meetup vol.1 が初めてで、そこから約 1 か月での発表となります。この短期間でここまでの形に持って行けたことは大変喜ばしいことだと感じていますし、今後も Project Sonata を通じて、より便利で、より面白い世界をお見せできるように努力していこうと思っています。

HoloLens の実機デプロイに失敗する衝撃の理由とその回避方法

こんにちは!VR 部の鈴木 (@xin9le) です。最近 VR 部では Microsoft HoloLens を使った Unity アプリケーションの調査/研究も積極的に行っているのですが、その中でデプロイ周りでハマッたことがあったので、それについてご紹介しようと思います。

とある条件で実機デプロイに失敗する

何ということはなく、Windows Store App をターゲットプラットフォームとしてビルドします。以下のような感じで、HoloLens 開発者としては至って普通の手順のひとつです。これで問題なく Unity から UWP 向けのプロジェクト出力ができます。

f:id:xin9le:20170306011938p:plain

Unity から出力された UWP プロジェクトを開き、x86 向け Release ビルドをします。これも問題なく成功します。非常に順調です。

f:id:xin9le:20170306012339p:plain

f:id:xin9le:20170306012345p:plain

それでは実機デプロイを行います。ところが、とある条件を満たすと以下のように無慈悲な配置エラーが発生してしまいます。

f:id:xin9le:20170306012610p:plain

エラー番号で調べても思い当たるものは特段出てきません。これは大変困った…!

原因は証明書

いろいろと試行錯誤した結果、Unity から UWP プロジェクトの出力を行う際に利用する証明書に問題があることが分かりました。UWP プロジェクト出力時、明示的な設定がない場合に Unity は自動的にテスト用の証明書を作成します。これには会社名 (Company Name) が利用されます。配置エラーが発生したとき、私は以下のように設定していました。

f:id:xin9le:20170306013227p:plain

至って普通で全く問題ないように見受けられますが、Company Name に「, Inc.」が入っていると配置エラーが発生します。より具体的に原因を絞り込むため、以下に挙げるような条件でも試してみました。

会社名 条件 結果
Grani. 末尾に「.」を入れてみる
Grani, 末尾に「.」を入れてみる
Grani  末尾に半角スペースを入れてみる
Grani Inc 途中に半角スペースを入れてみる
Grani.Inc 途中に「.」を入れてみる
Grani,Inc 途中に「,」を入れてみる ×
UWP プロジェクト自体出力できない
Grani, Inc 途中に「,」と半角スペースを入れてみる ×
配置エラー

もはや罠としか言いようがないですが、「, Inc.」を英語名に持つ会社にお勤めの方はもしかしたら引っ掛かるかもしれないので、同様の問題が出た際には「, Inc.」を入れずに試してみてください

グラニ x カヤック合同勉強会レポート。ネイティブ開発をテーマに gRPC, Unity, アセット管理, GitLFS について

CTOの河合です。

2/24(金)に面白法人カヤックさんと合同で、弊社の休憩/セミナースペースにて勉強会を開催しました!

カヤックさんには以前にもお越しいただいて、その時はVRがテーマだったので、今回はネイティブアプリケーション開発をテーマに、特に弊社では最近gRPCが熱いのでgRPCを切に希望しつつ、お悩みどころでもあるアセット管理などについて、お互いの知見を共有し合いました。

Golang x gRPC の話

カヤック矢吹さんより、GolangとgRPCの話をしていただきました。詳細はカヤックさんのレポート 【旅する勉強会】Grani & カヤックで合同勉強会を開催しました! へ。

gRPCの採用を検討し最終的には……!理由は非常に納得の行くもので、しっかりした検証と、しかしより良い形を目指して現実的な採用を進めるところは、さすがだと思いました。

NextGen Server/Client Architecture - gRPC + Unity + C#

カヤックさんの話を受けて、今度はgRPCを採用した、という話です。最もネックになるUnityで使えないじゃん!というところを自分で移植し、見えてる地雷を踏みまくりながら突き進んでいるのは自覚しつつも、C#企業であるというアイデンティティーとしても、決死の覚悟で成功事例を作っていきたいと思っています。

紹介しているOSSライブラリ群は以下のものです。

まだ開発中で正式アナウンスまでは至っていませんが、安定度を高めて近日中に公開していきたいところです。

アセットパイプライン、ビルドパイプラインの話

カヤック清水さんより、Unityのビルド周りの話をしていただきました。こちらも詳細はカヤックさんのレポート 【旅する勉強会】Grani & カヤックで合同勉強会を開催しました! へ。

GitHubを中心に、非常に作り込まれていて、羨ましいー、とかなり思ったり。弊社でも同様のビルド/配信システムは存在しているものの、ここまで柔軟に連携取れた動きはできていないので、参考にさせて頂きます!

Git LFS移行のお話

GitLFS移行のお話 / migrate to git lfs // Speaker Deck from Ryuichi Saito

最後は弊社インフラエンジニアの@sri_1128からGit LFSに関しての話です。

やむにやまれぬ事情からスタートし、すったもんだで今も大変といえば大変ですが、Git LFSの本格利用を開始しています。 特に移行時や運用面時の辛いポイントに関して、GitLFSを検討している方にぜひ読んでいただければと思います。

曰く、Git LFSはこれから良くなっていく!とのことなので期待して今後のバージョンアップを待ちましょう!

まとめ

懇親会では、グラニ社内バーコーナーにてお酒を片手に、色々と語り合いました。

グラニでも、今後も他社さんとの合同勉強会を積極的に開催していきたいと考えていますので(こちらからもお邪魔したいです!)、C#、Windowsインフラ、Unity、UniRx などなどご興味ありましたら @neuecc までお声掛けください。是非、一緒に高め合いましょう。

C#のswitch文のコンパイラ最適化について

CTOの河合(@neuecc)です。グラニもエンジニアブログはじめました!グラニの中心的テクノロジーであるC#関連は元より、Unity関連やUniRx、最近力を入れているVR関連についての情報を色々と発信していけたらと思っています。私自身は、思いたったときが書き時ということで、私が社内にふっと流したくなったものを、外向けに発信していこうかな、と思っています。

さて、今回のテーマはswitch文のコンパイル結果について。C#は割と素直なコンパイル結果(ILへの変換)を得られるのですが、一部のものはアグレッシブな変換を行います。有名所ではラムダ式のクロージャなどは、隠れたクラスを作ってくれる、作ってしまうなど、コードの見た目からイメージした通りではない結果を出力しますが、実はswitch文もかなりアグレッシブな変換を行います。そこで、今回はそうしたswitchの最適化を見ていきましょう。

なお、最適化はコンパイラの実装によって変わります。Unity(monoの古いバージョン)でもRoslyn(VS2015)以前と以降でも、あるいはその前でも主にVSのバージョンによって細かく向上していっています。今回の検証はViaual Studio 2017 RCによるものです。また、C#コンパイラによる最適化は中間言語(IL)への変換までで、実行時最適化についてはJITコンパイラの仕事になってきます。

case数による最適化

switchは中身が少ない場合に、if文に置換される場合があります。例えばintでたった2ケースの場合

static int SmallCase1(int x)
{
    switch (x)
    {
        case 0: return 0;
        case 1: return 1;
        default:
            return -1;
    }
}

これは以下のように変換されます。

if (x != 0)
{
    if (x != 1)
    {
        // default:
    }
    else
    {
        // case 1:
    }
}
else
{
    // case 0:
}

量が少ない場合はifのほうが速い、という決定でしょう。3ケース以上では、書いたままの挙動です。しかしそもそも書いたままとはどういうことか、というと、具体的にはILにおいてはOpCodes.Switch、つまりジャンプテーブルを利用した分岐に変わります。

// Emitのイメージ
OpCode.Switch(label1, label2, label3)

MarkLabel(label1);

MarkLabel(label2);

MarkLabel(label3);

悪くなさそうです。

文字列のswitch

C#のswitchは数値以外にも文字列が使えます。文字列も同様に少ない場合はifに書き換わります。書き換わる数はintの場合とは異なり、もう少し多めです。

static int StringSwitch(string x)
{
    switch (x)
    {
        case "aaa": return 0;
        case "bbb": return 1;
        case "ccc": return 2;
        case "ddd": return 3;
        case "eee": return 4;
        case "fff": return 5;
        default:
            return -1;
    }
}

というswitchは

static int StringSwitch(string x)
{
    int result;
    if (!(x == "aaa"))
    {
        if (!(x == "bbb"))
        {
            if (!(x == "ccc"))
            {
                if (!(x == "ddd"))
                {
                    if (!(x == "eee"))
                    {
                        if (!(x == "fff"))
                        {
                            result = -1;
                        }
                     
    // 以下elseが続くので省略
}

に書き換わります。なるほど、若干理想のイメージと異なる気はしますが、shoganai。それより大きな場合は更に異なるものになり、

static int StringSwitch(string x)
{
    switch (x)
    {
        case "aaa": return 0;
        case "bbb": return 1;
        case "ccc": return 2;
        case "ddd": return 3;
        case "eee": return 4;
        case "fff": return 5;
        case "ggg": return 6;
    default:
            return -1;
    }
}

これは、以下のような原型をとどめてる気があまりしないものに変わります。

private static int StringSwitch(string x)
{
    uint num = <PrivateImplementationDetails>.ComputeStringHash(x);
    int result;
    if (num <= 1488952485u)
    {
        if (num != 876991330u)
        {
            if (num != 1426598844u)
            {
                if (num == 1488952485u)
                {
                    if (x == "bbb")
                    {
        // elseは中略
    }
    else
    {
        if (num <= 3003732817u)
        {
            if (num != 2339852366u)
            {
                if (num == 3003732817u)
                {
                    if (x == "fff")
                    {
            // elseは中略
        }
        else
        {
            if (num != 3815745472u)
            {
                if (num == 3848007555u)
                {
                    if (x == "ddd")
                    {
                    // (略)
            }
            else
            {
                if (x == "ccc")
                {
                // (略)
            }
        }
    }
    result = -1;
    return result;
}

えー、全然違うじゃん!何に書き換わるかというとまずどこからか出てくる

<PrivateImplementationDetails>.ComputeStringHash

によって文字列のハッシュコードが計算され、そこから二部探索的に目的のコード部分を探り当てるコードに変換されます。switch使えば文字列も一発で引いてくれるんだ、などという甘い幻想はなかった。普通のstring.GetHashCodeではなく、わざわざ隠れたComputeStringHashメソッドを使うのは、ハッシュ値の算出をライブラリに依存せずコンパイル時に固定するためでしょう(文字列のハッシュコード値は、環境によって異なる(同じであるとは保証されていない)ため、永続化させてはいけない)。

バラバラの数値の場合

文字列が思ってたのと違うような形に変換される他に、数値であっても、その中身によって変換結果が変わってきます。例えば、以下のようにバラバラの数値をswitchにかける場合

static int SwitchCase(int x)
{
    switch (x)
    {
        case 1230: return 0;
        case 324: return 0;
        case 24432: return 0;
        case 99: return 0;
        case 93429: return 0;
        case 929: return 0;
        case 199: return 0;
        case 94249: return 0;
        case 53: return 0;
        case 1234: return 0;
        default:
            return 0;
    }
}

これは以下のようなifの羅列になります。

if (x <= 929)
{
    if (x <= 99)
    {
        if (x == 53)
        {
        }
        if (x == 99)
        {
        }
    }
    else
    {
        if (x == 199)
        {
        }
        if (x == 324)
        {
        }
        if (x == 929)
        {
        }
    }
}
else
{
    if (x <= 1234)
    {
        if (x == 1230)
        {
        }
        if (x == 1234)
        {
        }
    }
    else
    {
        if (x == 24432)
        {
        }
        if (x == 93429)
        {
        }
        if (x == 94249)
        {
        }
    }
}

二分探索のifのコードになっていて、つまりstringのComputeStringHashを除いた場合と同様、ってことですね。

さて、何故こうなってしまうかというと、OpCodes.Switch を改めて見てもらうと

switch (N, t1, t2... tN) | Jumps to one of N values.

飛び番のテーブルは作れません。しょーがないよね、そりゃそうですね、ということなのであった。なお、別に0始まりである必要はなくて、連番であれば構いません。連番も、1,2個の欠番ならば塞いでくれます。以下のようなコードは

static int SwitchCase(int x)
{
    switch (x)
    {
        case 30: return 1;
        case 31: return 2;
        // 32, 33...
        case 34: return 3;
        case 100: return 4;
        case 101: return 5;
        case 102: return 6;
        default:
            return -1;
    }
}

以下のような、ILの原理を理解した上でイメージする最大限にいい感じのコードに書き換わってくれます。

switch (x)
{
    case 30:
        return 1;
    case 31:
        return 1;
    case 32:
    case 33:
        break;
    case 34:
        return 3;
    default:
        switch (x)
        {
        case 100:
            return 4;
        case 101:
            return 5;
        case 102:
            return 6;
        }
        break;
}
return -1;

C#コンパイラもなかなか賢いですね!

なお、switchはintや文字列よりも、Enumと組み合わされるケースが多いと思いますが、その場合はその後ろのプリミティブの型(何も指定していない場合はint)で適用されます。

// Enum定義
public enum MyEnum1 // defaultは : int
{
    Foo, // defaultは0から連番
    Bar,
    Baz,
}

// 数字を弄るパターンも割りとよくある(10XXXは魔法、20XXXは物理、みたいなゾーン分けなど)
public enum MyEnum2
{
    Foo = 100,
    Bar = 200,
    Baz = 300,
}

つまり、上記のようなEnumをswitchした場合、前者のほうが速い、ということになります。

switch非対象の型でswitchしたい場合の最適化手段

intや文字列以外でもswitchのようなことをしたい、場合は全然あると思います。ifの羅列、もいいですが、数が多くなる場合は、今回見たstringの生成結果を模してみるのも効果的かもしれません。

// 数が十分に多い場合。10以下ぐらいならif列挙で良いでしょう(stringと同じように)
static readonly Dictionary<Type, int> jumpTable = new Dictionary<Type, int>(3)
{
    {typeof(FooClass), 0 },
    {typeof(BarClass), 1 },
    {typeof(FooBarClass), 2 },
};

static void TypeJump(Type t)
{
    int key;
    if (!jumpTable.TryGetValue(t, out key)) return;

    switch (key)
    {
        case 0:
            // case FooClass
            break;
        case 1:
            // case BarClass
            break;
        case 2:
            // case FooBarClass
            break;
        default:
            break;
    }
}

int側は0からの連番にすることにより、ジャンプテーブルが間違いなく使われることが期待できます。他に、より書きやすい代替としてDictionaryにActionを詰める(Dictionary<T, Action>)手法もありますが、intからのswitch-caseのほうが効率的に動作するでしょう。

まとめ

コード高速化の手段としてILGeneratorを用いて、ILを直接動的に書き込む手段は(AOT/IL2CPP環境など動的生成が許されていない環境でなければ)非常に有効です。動的生成ならではの、ifなどの分岐を生成時に決定して除去する、定数的なものを埋め込んでしまうなど、その生成時に最適化されたコードパスを通すなどのような手段が取れることも大変強いです。しかし、ILを直接書き込むことには弱点があり、今回見たようなC#コンパイラの行う最適化は事実上できません。やろうと思えば出来ますが、途方もない手間であり、実際は、素直なILを書く程度に留まらざるをえないでしょう。一概に動的コード生成すれば最速なんだ、というわけではないし、また、ではより速いコードを作るにはどうすればいいのか。何故やや遅くなってしまうのか、の原則は、こういうところに隠れているものかもしれません。

なお、強調しすぎてもしすぎることはないですが、「速い」といっても、これは本当に本当に些細な差ですので、よし、Enumは連番のほうが速いから連番にしよう!とかはやめるべきです。理屈上そうだというだけで、実際のアプリケーションで有意な差にまで繋がることはほぼないでしょう。