LoginSignup
397
342

More than 5 years have passed since last update.

C# パフォーマンス改善に使える新しめの機能たち 7.0〜

Last updated at Posted at 2019-04-10

時代に合わせてバージョンアップを続け、モダンな言語もまだまだ彼の背中を追っている部分があると噂されたりしている言語、C#。

現状の利用シーンとして割と大きいめの Unity (ゲームエンジン) では、使えるC#のバージョンがぐいぐいっと上がりはじめて以降、そこそこ新しい書き方も認知されてきているようです。

しかし、個人的に注目している C#の新機能は、ショートハンドや関数型言語由来の機能よりもむしろ、C#自体のパフォーマンスを改善するような文法や標準ライブラリたちです。

ーーパフォーマンスを改善する言語機能って一体なんのことでしょう。

「C# なんて、ランタイムが勝手にJITで最適化した機械語にして走らせてくれるわけで、Unityの場合はc++にトランスパイルされるわけで、べつにプログラマがミクロなチューニングとか意識しなくても、夜、寝る前とかに祈ったり寄付とかをしていれば、ランタイムをつくってくれている方々が勝手に速くしてくれるんじゃないだろうか……?」

そんなふうに考えている人も多いかもしれません。

しかし、C# は、書き方次第でランタイムの労働する量がどうなるか、明確にコントロールできる部分が広く、パフォーマンスチューニングの伸びしろが開かれた言語だと思います。

わかりやすいところだと、変数がスタックに置かれるただの値になるか、ヒープアロケーションを伴いVMに管理される参照になるか、書き手に委ねられている点が挙げられます。

C#では、値型はスタックに置かれ、参照型はヒープに置かれる、と、型のレベルで区別されます。これは、プリミティブ型以外は原則ヒープに置かれるJavaや、値の寿命いかんで自動的に値をボクシングするGoと比較すると、VMに仕事をさせない速いコードを書ける可能性があるデザインだと思います。

Unity の IL2CPP 環境では、参照型に対しては色々なコード生成を伴うのに、C#のスタック変数は本当にただのc++のスタック変数として扱われている様を目の当たりにしたことのある方も多いんじゃないでしょうか。

そして、バージョンが上がる度に、その辺をコントロールできる幅が徐々に広がってきています。

僕がパフォチューをいくつかやってきた経験上では、実行ステップを減らすよりも、とにかくヒープアロケーションを減らす方が、マネージドコードの実行時間自体も改善しやすかったです。(もちろん GCの実行時間も減るが、マネージドコードの実行速度自体が改善する)

つまり、チューニング次第で、まるでGCが載ってない別言語なのかっていうくらい実行速度が変わってくるポテンシャルがC#にはある、と言えるんじゃないでしょうか。

そこで、パフォーマンスを出したい、ここぞの場合に、新しめのバージョンではどんな機能が使えるか、ざっくり見渡してみたいと思います。

文法

タプル記法 (ValueTuple)

関数型言語でおなじみのタプルが C# 7.0 で導入されています。

一時的な型をつくりたい場合、匿名型を使いたいケースがありますが、これはヒープアロケーションが発生します。

var value = new { A = 1, B = 2 }; // この値はヒープ確保されて置かれる

新しいタプル記法を使えば、各プロパティに名前をつけたいという要件を満たしつつ、ヒープは使われません。

var x = (A: 1, B: 2); // ヒープつかわない

このようにプロパティに名前をつけてあげれば、匿名型がもっていた可読性もそのままです。
new 文もなくなりオシャレになりました。

ちなみに、変数束縛もできます。 (パターンマッチにも使えます)

var (a, b) = (1, 2);
// a == 1
// b == 2

分解した結果から名前つきタプルをつくることもできます。

(int A, int B) x = (1, 2);
// x.A == 1
// x.B == 2

タプルのシンタックスは、アロケーションを発生させず多値や一時的な型を扱えるので、便利さとパフォーマンス面で匿名型より勝ります。
くわしくは上記リンクを参照のこと。

注: 元々、.NET 4.0 から 多値を表現する Tuple<T> がありましたが、こちらは実は参照型です。パフォーマンス改善のため、.NET4.7 で ValueTuple が導入されています。C#7.0のタプル記法は、こっちの値型である ValueTuple<T> として扱われます。

Local Function

Local Function は、メソッド内部にスコープの小さい関数を定義できる機能です。 (Nested Functionともいう)

これを ラムダ式のかわりにつかうことで、アロケーションを抑制できる場面があります。

C# のラムダ式はとても便利ですが、生成する度にアロケーションを伴う性質をもっています。

ラムダ式の中から外側のスコープの変数 (this やthisのメソッドを含む)を参照している場合、そのラムダ式が死ぬまで、参照を掴み続けなければいけません。この使命を果たすために、コンパイラは暗黙裏にクラスを生成して、キャプチャした外側の値をヒープに置いて保存します。

var a = 10;
Func<int, int> f = x => x + a;  // `x => x + a` という式が生成される
f(100);

//    // コンパイラが生成するC#:
//    // ローカル変数 a をキャプチャするための暗黙の型がつくられる
//    [CompilerGenerated]
//    private sealed class <>c__DisplayClass0_0
//    {
//        public int a;
//
//        internal int <M>b__0(int x)
//        {
//            return x + a;
//        }
//    }
//
//    // aを閉じこめる
//    <>c__DisplayClass0_0 <>c__DisplayClass0_ = new <>c__DisplayClass0_0();
//    <>c__DisplayClass0_.a = 10;
//
//    // Funcが生成される
//    Func<int, int> func = new Func<int, int>(<>c__DisplayClass0_.<M>b__0);
//    func(100);

この例の場合、外側の変数 a をラムダ式内で参照していますが、a はスコープを抜けると破棄される宿命です。しかし、ラムダ式が生きている間に破棄されてしまうと困りますよね。そこで、<>c_DisplayClass0 とかいうクラスのオブジェクトをつくり、a を閉じ込める、という動作をしています。(キャプチャするものが増減するともちろん、暗黙のクラスもでかくなります)

ちなみに、ラムダ式の生成のたびに毎回必ず暗黙のヒープ確保が行われるわけではありません。
以下のように、ラムダ式内で使っている変数が、いつ実行されても全く同じになる場合 = いつも同じ式になる 場合は、コンパイラがキャッシュしてくれる模様です。

Func<int, int> f = x => x + 10; // 外側のスコープや `this` を一切参照していない
f(100);

// さっきと同じものが生成されるけど、キャッシュしてくれてる!
// Func<int, int> func = <>c.<>9__0_0 ?? (<>c.<>9__0_0 = new Func<int, int>(<>c.<>9.<M>b__0_0));
// func(100);

ラムダ式は、いわゆるクロージャといわれている性質をもっていて、以下のような強力な機能をサポートしてくれる存在です。

  • ラムダ式をつくった時点の外側のスコープをキャプチャする
  • ラムダ式は、値として使うことができ、キャプチャした変数のスコープの外でもいつまでも使える

こうした機能の実現のために、基本的にはヒープアロケーションが必要になってくるのです。
一方、 C# 7.0 から導入された Local Function を使うことで、寿命が決まっている式は、ヒープを使わずに変数をキャプチャすることができます。

void Hoge
{
    var a = 10;
    int AddA(int x) => x + a;  // aをキャプチャしているLocal Function

    AddA(a);
}

// 暗黙の型は値型になる
// [Serializable]
// [CompilerGenerated]
// private sealed class <>c
// {
//     public static readonly <>c <>9 = new <>c();
//
//     public static Func<int, int> <>9__0_0;
//
//     internal int <M>b__0_0(int x)
//     {
//         return x + 10;
//     }
// }

// 暗黙の値のスコープがメソッド内であることが確定しているので `ref` による参照が使われてる
// <M>g__AddA|0_0(<>c__DisplayClass0_.a, ref <>c__DisplayClass0_);

アロケーションがなくなりました。

ただし、ローカル変数を ActionFunc をうけとる関数に渡してしまうと、結局、永遠の寿命を保証するために暗黙型はヒープに乗っけられてしまいます。アロケーション抑制の目的で効果があるのは、あくまで、ローカルスコープをキャプチャしたい場合だけというのは覚えておきましょう。

readonly struct

C# 7.2 から、struct 自体に readonly 修飾子をつけることができるようになりました。
これを使うことで struct に対するメソッド呼び出しの余計なコストを減らすことができます。

readonly struct は、struct が一生を通じて絶対に不変な値であることをコンパイラに教えることができる機能です。 (readonly でないとコンパイルエラー)

値が不変であることとパフォーマンス改善がどう関係しているんでしょうか ?

実は、readonly struct を使うことで、 暗黙のうちに行われる値型のコピーが抑制されるのです。

いわゆる「Diffensive Copy」というやつです。

C# では、昔から、メンバ変数 に readonly 修飾子をつけることで、そのメンバが不変であることを保証できました。

class Owner
{
    readonly Foo foo; // 不変
}

しかし、これは実は、 Foo が参照型なのか、値型なのかで意味が変わります。(c言語のconst をはじめ、よくあるやつ)

  • Foo が参照型の場合
    • → 参照先が変わらないことを保証する。参照先のオブジェクトの値が変わっても気にしない。
  • Foo が値型の場合 :
    • → 値が変わらないこと。つまり foo のメンバも不変であることが保証される。

そう、 値型のメンバに readonly がついている場合、コンパイラは、foo のメンバが絶対に変更されないことを保証してくれるのです。

そんなことできるんでしょうか?
パフォーマンスを犠牲にするとできます。その答えが、「Deffensive Copy」です。

値型のメンバ変数が絶対に天地がひっくり返っても不変であることを保証するために、コンパイラはコピーを作成してからメソッド呼びだしをする、という保守的な方法を採ります。

/* readonly */ struct Foo // readonlyかどうかで挙動が変わる
{
    public readonly int A;

    public void Hello()
    {
        // ..
    }
}

class Owner
{
    readonly Foo foo = new Foo(100);  

    public Hello()
    {
        foo.Hello(); // ここでコピー発生。 
        // 元のfooではなく、fooのコピーをつくってから Hello() を呼びだす。
        // メソッド呼び出しで値が変わるか知る方法がないため。
    }
}

var owner = Owner();
owner.Hello(); 

readonly struct に変更してみましょう。

-struct Foo
+readonly struct Foo

コンパイルされた IL を覗くと、ローカル変数へのfooの展開が減っていることが確認できました。

@@ -2,9 +2,8 @@
 IL_0001: newobj instance void Owner::.ctor()
 IL_0006: stloc.0
 IL_0007: ldloc.0
-IL_0008: ldfld valuetype Foo Owner::Foo
-IL_000d: stloc.1
-IL_000e: ldloca.s 1
-IL_0010: call instance void Foo::Hello()
-IL_0015: nop
-IL_0016: ret
\ No newline at end of file
+IL_0008: ldflda valuetype Foo Owner::Foo
+IL_000d: call instance void Foo::Hello()
+IL_0012: nop
+IL_0013: ret
+
\ No newline at end of file

参考リンクに書かれていますが、 Enumerator<T> は大抵は値型として実装されているので、MoveNext を呼ぶたびにコピーが走るらしいです。
(この辺は今後のバージョンアップで標準ライブラリは改善されていくんでしょうか)

また、Diffensive Copy は、メンバ変数に対するメソッド呼び出しだけでなく、 参照渡しした実引数のsturctに対してのメソッド呼び出しでも発生してしまいます。

このコピーは、けっこうわかりずらいです。struct のサイズが大きければ大きいほど、安くないコストがかかるので、セマンティクス的にもパフォーマンス的にも、不変な型には readonly をつけるスタイルを心掛けましょう。

引数の in 修飾子

もともと、C# には、2種類の参照渡しができました。 読み込みも書き込みもできるrefと、必ず書き込ませることを保証する out です。7.2 では、 新たに 3つめの in 参照渡しが追加されています。

int Hoge(in Foo foo)
{
    foo.Hogehoge();
}

var foo = new Foo();
Hoge(foo);

in は、その名のとおり、参照とその指している変数への書き替えを禁止しつつ参照渡しができる機能です。

参考リンクによると、書き替えが禁止されているおかげで、 using文の中など、 in ,out 渡しが禁止されている場面でも in をつけると struct の参照渡しができるようになったとのこと。

でかいstruct を引数に渡したい場面で、コピーが発生せず高速にc++とかrustとかのノリで参照渡しできて便利です。

ただし、readonly のついていないstructを参照渡ししても、結局は前述のとおり 暗黙裏にDiffensive Copyが発生してしまうため、readonly struct を合わせて使うことが推奨されます

標準ライブラリ編

System.Memory (Span<T> / Memory<T>)

C# は、c, rust, swift , go あたりなどと違い、プリミティブな固定長配列も必ずヒープに置かれます。

これは、ある意味で言語としてのパフォーマンス上の制約のひとつになっていたと思います。

スタック変数で充分な一時的な配列でもヒープ割り合てをしないといけないし、 文字列のいち部分だけの参照を使いたい場合や、配列を受けとるAPIに対して配列の一部だけを渡したいケースで、ヒープの部分的なコピーをこれまたヒープにつくらないといけない、という状況がすぐ生まれてしまうのです。(unsafeを使えば一応がんばれるが…) ヒープにコピーをつくるのは遅いです。こういうのは、どんどん伝搬していくので、全体として GCゴミの抑制がむずかしくなっていってしまいがちです。

System.Memory パッケージは、機能的な拡張というよりは、このパフォーマンス上の制約を乗りこえるためのソリューションです。

このパッケージによって、Span<T> という、他の言語でいうところの「スライス」に近いものが導入されました。

これは、メモリ上にある配列や文字列などのオブジェクトの一部分だけの参照を表現し、コレクションとして扱うことができる型です。

Span<T> という型が追加された、と聞いてもどう使うかわかりずらいかもしれませんが、参考リンクにも紹介されいるとおり、標準ライブラリの至るところでオーバーロードが用意され、 int.Parse()string.SubString() などが Spanを渡したり返したりできるようになっています。

尚、Span<T> は、スタックに存在するポインタへの参照をもつことを保証するため、ボクシングができない ref struct という特殊な型として実装されています。

さきほど、「配列の確保にはヒープが必要」と書きましたが、 戻り値が Span<T>stackalloc 文をつかうことで、ヒープ割り合てを回避し、スタック上に一時的な固定長配列をつくれるようになりました。

また、Span<T> は、 ref struct であるため、そのローカルスコープの外へ渡すことができない、という制約があります。どうしても外へ渡したい場合のために、 Memory<T> というただのstruct も用意されています。こっちは、参照をボクシングして閉じこめたものなので、Span<T> が使えるケースでは Span<T> を使おう。

.NET Core では、リリースの度に標準ライブラリのパフォーマンスが改善されてますが、この新しい Span<T> / Memory<T> を使った実装にどんどん書き換わっていることも大きく貢献しているみたいです。

スライスが使えるようになったということは、配列を値として扱えるような言語と同等の パフォーマンスを叩き出す扉が開かれたと言って良いんじゃないでしょうか。後方互換性を全く損なわいところがすごいな、と思います。

System.IO.Pipelines

C# でSocket APIとかを使ってサーバを書こうとした場合、前述のような事情で、バイト配列のコピーを抑制しようと四苦八苦し、なかなか煩雑なコードになりがちです。

あたらしい 標準ライブラリの System.IO.Pipelines パッケージを使うことで、「データをバッファリングしておいて、逐次読み込んで処理する」といったよくある処理が簡単に書けるようになりました。

これは、内部的に Span<T>/Memorty<T> なども使われる実装になっていたりと、バッファのアロケーションを抑制するような仕組みになっているため、これまでの自前のバイト配列管理よりパフォーマンス面でも有利です。

僕はまだ使ったことがなく、理解できていない部分が多いので、くわしい説明は参考リンクのほうへ譲ります。

C# の有名なRedisクライアント、コネクションを多重化して使う実装で有名な StackExchange.Redis は、バージョン2以降でこのpipelines API で書き直されたそうです。
(僕は、StackExchange.Redis 作者の人の情報発信をきっかけに知りました)

そんなかんじです

なぜ速くなるのかわかりずらいけど速くなるC#の新機能について紹介してみました。
その他、 ArrayPool<T> など、わかりやすいやつも色々あると思うので、色々使ってみよう。

C# が他の ぶ厚いVMの言語よりもパフォーマンス上のポテンシャルがあることがもっと広まって、今後もどんどん速いライブラリが増えると良いなーと思いまっす。

397
342
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
397
342