🌟

【C#】クラスとクロージャーのパフォーマンスの違いを比較してみた

2023/01/25に公開

前書き

クラスとクロージャーでどれほど処理速度やメモリ使用量などに差が出るか気になったのでそれの比較を行った。その結果をここにまとめることとした。
いかに今回使用したコードがまとまっている。
https://github.com/ystsbry/experiment/tree/main/ComparisonOfClosureAndClassSpeeds

実験用プログラムの実装

実験用プログラムは10回インクリメントしてその値を返すプログラムとし、それの速度やヒープをどれだけ確保したかを計測することとした。

まずはクラスの実装から。いたってシンプルです。特にいうことはないでしょう。

public class ComparisionTargetClass
{
    public int Count = 0;

    public void Increment()
    {
        Count++;
    }
}

次にクロージャーの実装です。ローカル変数を定義したら、それをキャプチャする関数を二つ返すようにした。ひとつはインクリメントする関数。もう一つはローカル変数の値を取り出す関数です。

public class ComparisionTargetClosure
{
    static public (Action, Func<int>) Get()
    {
        int count = 0;
        return (
        () => 
        {
            count++;
        },
        () =>
        {
            return count;
        });
    }
}

次に最初に書いた要件を満たすコードを先ほどのクラスとクロージャーそれぞれで実装した。今回の速度の計測ではBenchmarkDotnetを利用した。上の実装でクラスの計測を行い、下の実装でクロージャーの計測を行った。

[MemoryDiagnoser]
public class BenchmarkTest
{

    [Benchmark]
    public int ClassBenchmark()
    {
        var TargetClass = new ComparisionTargetClass();

        for (var i = 0; i < 10; i++)
        {
            TargetClass.Increment();
        }
        return TargetClass.Count;
    }

    [Benchmark]
    public int ClosureBenchmark()
    {
        var (IncrementClosure, GetCountClosure) = ComparisionTargetClosure.Get();

        for (var i = 0; i < 10; i++)
        {
            IncrementClosure();
        }
        return GetCountClosure();
    }
}

Mainはこのようになっている。

internal class Program
{
    private static void Main() => BenchmarkRunner.Run<BenchmarkTest>();
}

使用している名前空間の一覧は以下のようになっている。

using BenchmarkDotNet.Running;
using BenchmarkDotNet.Attributes;

計測結果

dotnet run --configuration Releaseとコマンドをたたくと計測結果が出てきた。以下にその計測結果を示す。

|           Method |      Mean |     Error |    StdDev |   Gen0 | Allocated |
|----------------- |----------:|----------:|----------:|-------:|----------:|
|   ClassBenchmark |  7.614 ns | 0.0497 ns | 0.0465 ns | 0.0029 |      24 B |
| ClosureBenchmark | 32.894 ns | 0.4465 ns | 0.3958 ns | 0.0181 |     152 B |

この表の見方に関しては以下のようになっている。

  • method : 計測したメソッドの名前
  • Mean : 平均
  • Error : 誤差
  • StdDev : 標準偏差
  • Allocated : 確保したヒープの量

処理にかけた平均時間においても、確保したヒープの量においてもClassの実装のほうが速く、かつ軽量であることを示した。すべてを知っているわけではないのでもしかするとクロージャのほうが速く、軽量であるケースもあるかもしれないが探すのは骨が折れそうなのでここで留めておくこととする。

SharpLabでコードがどうなってるか確認する

確認する前にSharpLabが一体何なのかという話に入る。
SharpLabとは、簡単にC#を試せる実行環境でありブラウザから扱うことができるものである。これを使うと生成されたILや、ILをデコンパイルされたものまで確認できる。この機能を使えばC#の言語機能のいくつかはシンタックスシュガーになっているが、その中身がどうなっているかまでおおよそ確認することができる。
今回はこれを使い、上2つの実装をILからデコンパイルされたコードを日比べていこうと思う。

デコンパイルされたコードの比較

まずクラスのほうだが素直に書いているのでデコンパイルされたものに違いはなかった。しかし、クロージャーのほうはデコンパイルされたものは以下のようになっている。

public class ComparisionTargetClosure
{
    [CompilerGenerated]
    private sealed class <>c__DisplayClass0_0
    {
        public int count;

        internal void <Get>b__0()
        {
            count++;
        }

        internal int <Get>b__1()
        {
            return count;
        }
    }

    [return: System.Runtime.CompilerServices.Nullable(new byte[] { 0, 1, 1 })]
    public static ValueTuple<Action, Func<int>> Get()
    {
        <> c__DisplayClass0_0 <> c__DisplayClass0_ = new <> c__DisplayClass0_0();
        <> c__DisplayClass0_.count = 0;
        return new ValueTuple<Action, Func<int>>(new Action(<> c__DisplayClass0_.< Get > b__0), new Func<int>(<> c__DisplayClass0_.< Get > b__1));
    }
}

非常にわかりずらいというか、人間の感覚的に受け入れずらいものがあるのでこれをもとに実装し、それもベンチマークを計測してみる。少し手を加えて見やすくした実装は以下

public class ComparisionTargetClosureSyntax
{
    private sealed class Counter
    {
        public int count;

        internal void Increment()
        {
            count++;
        }

        internal int GetCount()
        {
            return count;
        }

    }

    public static ValueTuple<Action, Func<int>> Get()
    {
        Counter counter = new Counter();
        counter.count = 0;
        return new ValueTuple<Action, Func<int>>(new Action(counter.Increment), new Func<int>(counter.GetCount));
    }
}

そして計測した結果は以下。

|                 Method |      Mean |     Error |    StdDev |   Gen0 | Allocated |
|----------------------- |----------:|----------:|----------:|-------:|----------:|
|         ClassBenchmark |  9.620 ns | 0.2226 ns | 0.2650 ns | 0.0076 |      24 B |
|       ClosureBenchmark | 40.358 ns | 0.7293 ns | 0.6822 ns | 0.0485 |     152 B |
| ClosureSyntaxBenchmark | 41.917 ns | 0.8495 ns | 1.1909 ns | 0.0485 |     152 B |

ClosueBenchmarkClosureSyntaxBenchmarkはMeanに関しては少し増えたものの、Allocatedに関してはまったく同じ値になりました。

ILからデコンパイルされたコードを読むと、どうやらActionFunc<>はクラスのようです。となるとクロージャーを取得するGet()メソッドを呼び出すと3回もクラスを生成しているので普通のクラスの1回に比べてAllocatedの値が多くなるのも納得できます。
Meanに関してなぜ多くなるのかは、ローカル変数をキャプチャするというのがパフォーマンス的に不利になるのかなと考えられる。また、ClosureBenchmarkClosureSyntaxBenchmarkでパフォーマンスにおよそ1.5usの差が出るのはなぜなのか。

最後に

クラスのほうが速かったからとは言え、クロージャーも適切に使えば可読性や使い手にとって使いやすい関数を提供できる便利な機能であると思う。(c#のsystem.threading.channelsはおそらくクロージャーを活用してProducer Consumerパターンを再現していると思われる。)
また、最初はActionだったりFunc<>だったり正直使いずらいなと思っていて、TypeScriptと作者が同じなら(Type1: type) => Type2こんな感じに関数の型を定義させてくれてもいいじゃんなどと思っていたが、この計測をした今ではむしろこのデザインでよかったなと思っている。型の書き方が直観的じゃないので敬遠していたがなんとなく狙い通りに書かされた気分だけどまあいっか。

参考文献

https://github.com/dotnet/BenchmarkDotNet/
https://qiita.com/Tokeiya/items/30d8a76163622a4b5be1
https://cactuaroid.hatenablog.com/entry/2018/10/23/212729

Discussion