dely Tech Blog

クラシル・TRILLを運営するdely株式会社の開発ブログです

C# 9.0時代のnull判定解剖

f:id:meilcli:20201211111840j:plain

どうもC#erの@MeilCliです。仕事ではAndroidエンジニアしてますがC#erなのでアドベントカレンダーではC#について書きます

今回参加してるアドベントカレンダーはこちらです。16日目の記事になります

adventar.org

あと同様なカレンダーがもう1つあります

adventar.org

また、この記事の一部をクイズにしたものも投稿していますのでよろしければそちらもご覧ください

祝: C# 9.0リリース

さて、つい先日.NET 5と共にC# 9.0がリリースされました。C# 9.0の新機能は多々あるのですがその中でパターンマッチングの強化の一貫でvalue is not nullのようにnot条件が追加されました。この新機能によってC# 8.0のようにnot null判定をするためにvalue != nullvalue is T nonNullvalue is {}を書かずとも自然言語的な文章で書けるようになりました

C#では前述のようにバージョンアップに連れ様々なnot null判定ができる構文が追加され、どの構文を使えばいいのか迷うところでもありました。null判定も同様に様々な構文があり、場合によっては特定の構文は使わないほうがパフォーマンス的に良いということさえありました

というわけで今回はC#における歴代のnot null・null判定の構文紹介とC# 9.0時代の最適な判定方法を探していこうと思います

様々なnull・not null判定方法

null判定

null判定はC#バージョンによっての判定方法の追加が少なく、よく使われるものだと2種類あるかと思います*1

// 素直に==比較
bool isNull = value == null;

// C# 7.0のパターンマッチング(定数パターン)
bool isNull = value is null;

それ以外のものだと以下のような方法が考えられると思います*2

bool isNull = object.Equals(value, null);

// 参照型の場合
bool isNull = object.ReferenceEquals(value, null);

bool isNull = EqualityComparer<T>.Default(value, null);

null判定に関しては方法が少ないためパフォーマンスが同じならば書き手の好きな方を選べばいいとなるかと思いきや、==演算子がオーバーロード可能なため型によってはnullと==比較しているのにfalseを返すような邪悪なことをされる恐れがあります*3。より意図した通りのコードにしたいならばvalue is nullの判定方法が一択になるでしょう

not null判定

not null判定はnull判定よりも方法が多く、自分が知っているだけでもよく使われるもので5種類あります

// 素直に!=比較
bool isNotNull = value != null;

// is演算子
bool isNotNull = value is string; // string?の場合、int?ならばvalue is intになる

// C# 7.0のパターンマッチング(型パターン)
bool isNotNull = value is string notNullValue;

// C# 8.0のパターンマッチング(プロパティーパターン)
bool isNotNull = value is { };

// C# 9.0のパターンマッチング(not expression + 定数パターン)
bool isNotNull = value is not null;

それ以外にもnull許容値型の場合はHasValueプロパティによる判定もできます

// null許容値型の場合
bool isNotNull = value.HasValue;

さて、not null判定でもより意図した通りのコードにしたいならば前述のように!=演算子を避けた判定方法を取るといいのですが、それ以外の選択肢がたくさん存在しています。これは実際にパフォーマンスを計測してみるしかありませんね

ベンチマーク

パフォーマンスを測るためのベンチマークツールはいつも通りBenchmarkDotNetを使います

計測対象のプロジェクトでは.NET5でnull許容参照型を使ったりするので以下のようなcsprojにします

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net5.0</TargetFramework>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="BenchmarkDotNet" Version="0.12.1" />
  </ItemGroup>

</Project>

また計測対象となるクラスの基本構成はこんな感じです

[SimpleJob]
[MeanColumn, MinColumn, MaxColumn]
[MemoryDiagnoser]
public class Bench
{
    [Params(null, "")] // or null, 1
    public string? Value { get; set; } // or int?

    [Benchmark]
    public bool Method()
    {
        bool result = false;
        for (int i = 0; i < 10; i++)
        {
            result = Value is null; // ここを計測したいケースごとに変える
        }
        return result;
    }
}

本来ならば計測対象のメソッドにforループで演算回数の水増しをしないほうがいいのですが、単純に1回限りの演算だと実行時間が早すぎて計測できないため10回ループさせています*4

ベンチマークケース

今回は値型と参照型の基本的な想定ケースとしてstring?int?をnullと空文字または1の値のときを計測しました

まとめると4回の計測になります

  • 参照型(string?)のnull判定するとき
  • 参照型(string?)のnot null判定するとき
  • 値型(int?)のnull判定するとき
  • 値型(int?)のnot null判定するとき

また、null・not null判定は!演算子やfalse比較で反転した結果にすることで同様な判定方法を記述できるため計測パターンは!演算子を使いつつnull判定とnot null判定でほぼほぼ同じ判定式になるようにケースを作成しました

参照型・null判定

メソッド名 判定式
EqualOperator Value == null
IsOperator !(Value is string)
PatternMatchNull Value is null
PatternMatchNotNull7 !(Value is string notNullValue)
PatternMatchNotNull8 !(Value is { })
PatternMatchNotNull9 !(Value is not null)
ObjectEquals object.Equals(Value, null)
ObjectReferenceEquals object.ReferenceEquals(Value, null)
EqualityComparer EqualityComparer<string?>.Default.Equals(Value, null)

参照型・not null判定

メソッド名 判定式
EqualOperator Value != null
IsOperator Value is string
PatternMatchNull !(Value is null)
PatternMatchNotNull7 Value is string notNullValue
PatternMatchNotNull8 Value is { }
PatternMatchNotNull9 Value is not null
ObjectEquals !(object.Equals(Value, null))
ObjectReferenceEquals !(object.ReferenceEquals(Value, null))
EqualityComparer !(EqualityComparer<string?>.Default.Equals(Value, null))

値型・null判定

メソッド名 判定式
EqualOperator Value == null
HasValue !(Value.HasValue)
IsOperator !(Value is int)
PatternMatchNull Value is null
PatternMatchNotNull7 !(Value is int notNullValue)
PatternMatchNotNull8 !(Value is { })
PatternMatchNotNull9 !(Value is not null)
ObjectEquals object.Equals(Value, null)
EqualityComparer EqualityComparer<int?>.Default.Equals(Value, null)

値型・not null判定

メソッド名 判定式
EqualOperator Value != null
HasValue Value.HasValue
IsOperator Value is int
PatternMatchNull !(Value is null)
PatternMatchNotNull7 Value is int notNullValue
PatternMatchNotNull8 Value is { }
PatternMatchNotNull9 Value is not null
ObjectEquals !(object.Equals(Value, null))
EqualityComparer !(EqualityComparer<int?>.Default.Equals(Value, null))

結果

BenchmarkDotNet=v0.12.1, OS=Windows 10.0.19042
Intel Core i9-10900K CPU 3.70GHz, 1 CPU, 16 logical and 8 physical cores
.NET Core SDK=5.0.100
  [Host]     : .NET Core 5.0.0 (CoreCLR 5.0.20.51904, CoreFX 5.0.20.51904), X64 RyuJIT
  DefaultJob : .NET Core 5.0.0 (CoreCLR 5.0.20.51904, CoreFX 5.0.20.51904), X64 RyuJIT

計測環境はこんな感じです*5

f:id:meilcli:20201128201025p:plain
参照型・null判定

f:id:meilcli:20201128201217p:plain
参照型・not null判定

f:id:meilcli:20201128201347p:plain
値型・null判定

f:id:meilcli:20201128201447p:plain
値型・not null判定

また、計測コードなどの詳細はGitHubに公開しているのでそちらを参照ください

github.com

要約

ちょっと結果が多すぎるためそれぞれの平均値を取ってみます*6

メソッド名 参照型 値型
EqualOperator 3.106 3.282
HasValue N/A 3.25
IsOperator 3.129 226.055
PatternMatchNull 2.65 3.746
PatternMatchNotNull7 2.621 6.292
PatternMatchNotNull8 3.097 3.274
PatternMatchNotNull9 2.631 3.275
ObjectEquals 14.37 248.335
ObjectReferenceEquals 3.075 N/A
EqualityComparer 16.75 52.655

総合的にはPatternMatchNotNull9がよく、それ以外の場合ではそれぞれ参照型・値型で長短があったりそもそも遅かったりという感じでしょうか

ちなみに

EqualityComparerのケースではEqualityComparer<T>.Defaultを取得する時間が影響を与えてる可能性があったため、string?とint?それぞれのインスタンスを取得する時間のベンチマークを取りました

[SimpleJob]
[MeanColumn, MinColumn, MaxColumn]
[MemoryDiagnoser]
public class EqualityComparerBench
{
    [Benchmark]
    public IEqualityComparer<string?> StringEqualityComparer()
    {
        return EqualityComparer<string?>.Default;
    }

    [Benchmark]
    public IEqualityComparer<int?> IntEqualityComparer()
    {
        return EqualityComparer<int?>.Default;
    }
}

f:id:meilcli:20201125091317p:plain

結果としては計測できないほど早い処理が行われてそうという感じでした。.NET Core 2.1におけるDevirtualization関連の最適化によってランタイム側でEqualityComparer<T>.Defaultをすり替えて仮想メソッド呼び出しのコストを回避したという話もあるようなのでそのあたりが影響してるのではないかなと思います

こういうこともあったり、どこからどこまで*7をベンチマーク対象として捉えればいいのかややこしくなってくるということもあるので今回の計測ではIEqualityComparer<T>.Default.Equals(Value, null)を計測することにしました

ベンチマーク結果の解剖

さて、ベンチマークを出して終わりではありません。.NET 5(on Windows)の結果はわかりましたのでそれぞれのベンチマークケースでなぜ差が生じたのかを紐解いていきます

C#でこのようなベンチマークケースの差を探るにはまずC#のコンパイル結果となるCIL*8を見るのが手っ取り早いです。今回はILSpyを使ってデコンパイルしました

また、CILは中間言語ということもあってコードが長くなる傾向になります。この場ではできる限り省いたものを載せるので全文を読みたい方はGitHubリポジトリーを参照してください

参照型・null判定

// Value == null
// !(Value is string)
// Value is null
// object.ReferenceEquals(Value, null)
ldarg.0
call instance string NullCheck.Benchmark.ReferenceNullBench::get_Value()
ldnull
ceq

object.ReferenceEquals(Value, null)がコンパイル時の最適化によってあとかたもなくなっていることには驚きですね。ldarg.0でスタックから引数0(つまりこのクラスのインスタンス)を読み込んでcall instance string NullCheck.Benchmark.ReferenceNullBench::get_Value()でプロパティの値を読み込み、ldnullで読み込んだnull参照とプロパティの値をceqで等値比較するという感じです

// !(Value is string notNullValue)
// !(Value is { })
// !(Value is not null)
ldarg.0
call instance string NullCheck.Benchmark.ReferenceNullBench::get_Value()
ldnull
cgt.un
ldc.i4.0
ceq

こちらはcgt.unで大小比較し、cgt.unでint32の1か0がstackにpushされているのでldc.i4.0(つまりint32の0)とceqで等値比較しています。前述のValue == nullなどのケースより命令数が多くなってるのでパフォーマンス的に不利かと思いきや、ベンチマーク結果的にはあまり差がないようです

// object.Equals(Value, null)
ldarg.0
call instance string NullCheck.Benchmark.ReferenceNullBench::get_Value()
ldnull
call bool [System.Runtime]System.Object::Equals(object, object)

object.Equalsの場合はメソッドの呼び出し結果をそのまま使うという感じでした。あまり面白みがないのですがあとでobject.Equalsの実装を探ってみます

// EqualityComparer<string?>.Default.Equals(Value, null)
call class [System.Collections]System.Collections.Generic.EqualityComparer`1<!0> class [System.Collections]System.Collections.Generic.EqualityComparer`1<string>::get_Default()
ldarg.0
call instance string NullCheck.Benchmark.ReferenceNullBench::get_Value()
ldnull
callvirt instance bool class [System.Collections]System.Collections.Generic.EqualityComparer`1<string>::Equals(!0, !0)

EqualityComparer<string?>.Default.Equals(Value, null)に関してはCIL的にはcallvirtで仮想メソッド呼び出しを行っている箇所がコストになりそうなものの、ランタイム側で最適化されるという話もあるためCILレベルではあまり判断できそうにないですね。ここに関しては実装を深堀っていこうと思います

参照型・not null判定

// Value != null
// Value is string
// Value is string notNullValue
// Value is { }
// Value is not null
// !(object.ReferenceEquals(Value, null))
ldarg.0
call instance string NullCheck.Benchmark.ReferenceNotNullBench::get_Value()
ldnull
cgt.un

こちらはnull判定の時とは逆にcgt.unで大小比較するという形になっていますね。ベンチマーク結果的にはValue != nullValue is stringValue is { }が他のケースよりちょっと遅いかな(?)という印象もありましたがCIL的には同じ結果にコンパイルされているので誤差の範囲なのでしょうか…(MinとMaxでも明らかに差がついているのでランタイム側でなんらかの最適化が入ってそうな気がしないこともないですがこれ以上はわかりませんね)

// !(Value is null)
ldarg.0
call instance string NullCheck.Benchmark.ReferenceNotNullBench::get_Value()
ldnull
ceq
ldc.i4.0
ceq

このケースのみ他のパターンマッチングなどと違い、ceqでnull参照と等値比較し、さらにその結果をldc.i4.0と等値比較しています。否定演算子を正直に変換している感じがしますね。こちらはCILレベルでは命令数的に不利ですが前述のケースとの差はなさそうです

!(object.Equals(Value, null))!(EqualityComparer<string?>.Default.Equals(Value, null))に関してはnull判定のときからldc.i4.0ceqで結果を反転してるだけなので省きます

値型・null判定

// Value == null
// !(Value.HasValue)
// Value is null
// !(Value is { })
// !(Value is not null)
ldarg.0
call instance valuetype [System.Runtime]System.Nullable`1<int32> NullCheck.Benchmark.ValueNullBench::get_Value()
stloc.2
ldloca.s 2
call instance bool valuetype [System.Runtime]System.Nullable`1<int32>::get_HasValue()
ldc.i4.0
ceq

さて値型の場合ですが、null許容値型は内部的にはNullable<T>構造体で表現されています。プロパティから値を取ってきたあとはstloc.2でローカル変数に保存し、ldloca.s.2でそのローカル変数のアドレスを取得しています。そしてそのアドレスに対しNullable<T>構造体のHasValueプロパティのgetter実装であるget_HasValueメソッドを呼び出しています。そのあとは値を反転するためにldc.i3.0ceqを使っていますね

// !(Value is int)
ldarg.0
call instance valuetype [System.Runtime]System.Nullable`1<int32> NullCheck.Benchmark.ValueNullBench::get_Value()
box valuetype [System.Runtime]System.Nullable`1<int32>
isinst [System.Runtime]System.Int32
ldnull
cgt.un
ldc.i4.0
ceq

IsOperatorのケースが値型で極端に遅いというベンチマーク結果が出ていましたが、原因はboxでボックス化しているからですね。この遅さはC# 7.3の頃に調べてみた結果と同様なままのようです。コンパイラーの最適化次第な領域ではありますが、現時点でボックス化される形にコンパイルされることを鑑みると値型においてはvalue is intみたいな形式は避けておいたほうが無難でしょう

!(Value is int notNullValue)
IL_0006: ldarg.0
IL_0007: call instance valuetype [System.Runtime]System.Nullable`1<int32> NullCheck.Benchmark.ValueNullBench::get_Value()
IL_000c: stloc.2
IL_000d: ldloca.s 2
IL_000f: call instance bool valuetype [System.Runtime]System.Nullable`1<int32>::get_HasValue()
IL_0014: brfalse.s IL_0021

IL_0016: ldloca.s 2
IL_0018: call instance !0 valuetype [System.Runtime]System.Nullable`1<int32>::GetValueOrDefault()
IL_001d: pop
IL_001e: ldc.i4.1
IL_001f: br.s IL_0022

IL_0021: ldc.i4.0

IL_0022: ldc.i4.0
IL_0023: ceq

今まで行数は省略して紹介してきましたが、今度のはジャンプする命令があるため行数も書いています。brfalse.sではstackの値(ここではget_HasValueの結果)がfalse(つまり0の値)の場合に引数の行数であるIL_0021にジャンプさせています、つまり早期リターンのようなものですね。trueだった場合はその直後の命令が実行されていき、GetValueOrDefaultを呼び出しています。しかし、C#コード上では宣言したnotNullValue変数をしていない箇所が直訳されてるようで、popによってGetValueOrDefaultの結果を捨てています。このような無駄な命令があるため、他の早いケースと比べるとちょっと遅くなってしまっています

参照型の場合では跡形もなく消えていた未使用変数部分が値型の場合では直訳されるようなのでまだ少し最適化の余地があるという感じのようです

// object.Equals(Value, null)
ldarg.0
call instance valuetype [System.Runtime]System.Nullable`1<int32> NullCheck.Benchmark.ValueNullBench::get_Value()
box valuetype [System.Runtime]System.Nullable`1<int32>
ldnull
call bool [System.Runtime]System.Object::Equals(object, object)

object.Equalsの場合もボックス化が走っているようですね。これが遅い原因だとは思いますがあとでobject.Equalsの実装を覗けたらなと思います

// EqualityComparer<int?>.Default.Equals(Value, null)
call class [System.Collections]System.Collections.Generic.EqualityComparer`1<!0> class [System.Collections]System.Collections.Generic.EqualityComparer`1<valuetype [System.Runtime]System.Nullable`1<int32>>::get_Default()
ldarg.0
call instance valuetype [System.Runtime]System.Nullable`1<int32> NullCheck.Benchmark.ValueNullBench::get_Value()
ldloca.s 2
initobj valuetype [System.Runtime]System.Nullable`1<int32>
ldloc.2
callvirt instance bool class [System.Collections]System.Collections.Generic.EqualityComparer`1<valuetype [System.Runtime]System.Nullable`1<int32>>::Equals(!0, !0)

EqualityComparerのケースは少しややこしいですね*9 1スタック目*10ldarg.0callによってValueプロパティの値をpushし、2スタック目にldloca.s 2でローカル変数のNullable構造体のアドレスをpushし、initobjでNullable構造体の初期化を行っています(ここでスタックは消費している)。そして2スタック目にldloc.2で初期化したNullable構造体のローカル変数の値をpushし、callvirtでそれらの値を使ってEqualityComparerのメソッドを呼んでいます

EqualityComparerのケースがボックス化してるケースよりは早いけど時間がかかっているのはcallvirtしてるからという可能性もありますが、Devirtualizationされてると思われる箇所なのでEqualityComparerの実装体が少し遅い処理ということなんじゃないかなと想像できますね

値型・not null判定

// Value != null
// Value.HasValue
// Value is { }
// Value is not null
ldarg.0
call instance valuetype [System.Runtime]System.Nullable`1<int32> NullCheck.Benchmark.ValueNotNullBench::get_Value()
stloc.2
ldloca.s 2
call instance bool valuetype [System.Runtime]System.Nullable`1<int32>::get_HasValue()

こちらは値型・null判定でのValue is nullなどのケースからldc.i4.0ceqをしなくなったバージョンです。単純にbool値の反転がなくなったということですね

Value is intValue is int notNullValueでも同様にldc.i4.0ceqの命令がなくなっていました

// !(Value is null)
ldarg.0
call instance valuetype [System.Runtime]System.Nullable`1<int32> NullCheck.Benchmark.ValueNotNullBench::get_Value()
stloc.2
ldloca.s 2
call instance bool valuetype [System.Runtime]System.Nullable`1<int32>::get_HasValue()
ldc.i4.0
ceq
ldc.i4.0
ceq

!(Value is null)のケースではValue is nullのケースからさらにldc.i4.0ceqで値の反転をしています

これと同様に!(object.Equals(Value, null))!(EqualityComparer<int?>.Default.Equals(Value, null))もC#コードの通りにldc.i4.0ceqによる値の反転がされていました

Object.Equalsの実装

さて、Object.Equalsの実装が気になるので調べてみましょう。Objectは.NETの基礎となる型です。そのためソースコードを見るならば.NET5や.NET Coreのランタイム側を見るとよさそうです

.NET5や.NET Coreのランタイムはdotnet/runtimeに公開されています*11

GitHubの左上にある検索ボックスでfilename:Objectと検索してみます。すると大量のファイルがマッチするのでその中からObject.csを探し出します

src/libraries/System.Private.CoreLib/src/System/Object.csが検索結果の3ページ目ぐらいのところにあるのでそこからたどっていくことにします

public virtual bool Equals(object? obj)
{
    return RuntimeHelpers.Equals(this, obj);
}

public static bool Equals(object? objA, object? objB)
{
    if (objA == objB)
    {
        return true;
    }
    if (objA == null || objB == null)
    {
        return false;
    }
    return objA.Equals(objB);
}

コードを読むstatic bool Equalsのほうで早期リターンを行っている部分があるものの、早期リターンできなかった場合はRuntimeHelpers.Equalsを呼び出していることがわかります。そのままだと闇雲に探すことになってしまうのでヘッダー部分のusingされている名前空間を見ておきましょう

using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;

名前的にSystem.Runtime.CompilerServicesSystem.Runtime.InteropServicesにありそうですね

今度はfilename:RuntimeHelpersで検索してみます。すると8件ほどヒットするので目星をつけた名前空間に着目すると

の3つが該当しました。src/mono/netcoreというMonoなのか.NET Coreなのかよくわからないディレクトリーがありますが、まずはObject.csと同様な場所にあるsrc/librariesから読もうとなりましたがこのファイルでは定義されていません

partial classとなっているのでどうやらプラットフォームごとの別のソースコードを参照する形で実装されている様子です

[MethodImpl(MethodImplOptions.InternalCall)]
public static extern new bool Equals(object? o1, object? o2);

src/coreclrのほうを見てみるとこのようになっているため、どうやらCの世界まで潜らないといけないようです

検索ボックスでRuntimeHelpersをCやC++に絞って検索してみるとそれっぽいものが2つありました

src/coreclr/vm/corelib.hではDEFINE_CLASS(RUNTIME_HELPERS, CompilerServices, RuntimeHelpers)と記述されているだけなのでどうやらクラスを宣言してるだけのようです(CやC++詳しくないので間違ってるかもしれません)

src/coreclr/vm/ecalllist.hのほうを見てみます

FCFuncStart(gRuntimeHelpers)
    /* 略 */
    FCFuncElement("Equals", ObjectNative::Equals)
    /* 略 */
FCFuncEnd()

するとなにやら関数を登録してそうな処理が入っています。ObjectNativeに答えがありそうです

ObjectNativeで検索するとsrc/coreclr/classlibnative/bcltype/objectnative.cppsrc/coreclr/classlibnative/bcltype/objectnative.hが引っ掛かりますが、.hはヘッダーファイルなので.cppに実装がありそうです

.cppのほうでEqualsと検索するとこの処理がヒットしました

FCIMPL2(FC_BOOL_RET, ObjectNative::Equals, Object *pThisRef, Object *pCompareRef)
{
    CONTRACTL
    {
        FCALL_CHECK;
        INJECT_FAULT(FCThrow(kOutOfMemoryException););
    }
    CONTRACTL_END;

    if (pThisRef == pCompareRef)
        FC_RETURN_BOOL(TRUE);

    // Since we are in FCALL, we must handle NULL specially.
    if (pThisRef == NULL || pCompareRef == NULL)
        FC_RETURN_BOOL(FALSE);

    MethodTable *pThisMT = pThisRef->GetMethodTable();

    // If it's not a value class, don't compare by value
    if (!pThisMT->IsValueType())
        FC_RETURN_BOOL(FALSE);

    // Make sure they are the same type.
    if (pThisMT != pCompareRef->GetMethodTable())
        FC_RETURN_BOOL(FALSE);

    // Compare the contents (size - vtable - sync block index).
    DWORD dwBaseSize = pThisRef->GetMethodTable()->GetBaseSize();
    if(pThisRef->GetMethodTable() == g_pStringClass)
        dwBaseSize -= sizeof(WCHAR);
    BOOL ret = memcmp(
        (void *) (pThisRef+1),
        (void *) (pCompareRef+1),
        dwBaseSize - sizeof(Object) - sizeof(int)) == 0;

    FC_GC_POLL_RET();

    FC_RETURN_BOOL(ret);
}
FCIMPLEND

C#erには少し厳しいC++です…頑張って読み解いていきましょう

最初のCONTRACTLのところはおそらく防衛をしてるだけなのでスキップ。if (pThisRef == pCompareRef)trueを返してるので真っ先に参照を比較していますね

次にif (pThisRef == NULL || pCompareRef == NULL)でnullチェックをしています

そのあとのif (!pThisMT->IsValueType())ではコメントに値クラスではない場合は値で比較しないでください的なコメントが書かれています。参照型の場合は最初の参照比較で等値比較を終わらせていて、ここで値型以外はfalseとして返すようにしてるようです

そのあとのif (pThisMT != pCompareRef->GetMethodTable())では同じ型でなければfalseにするという処理が入っています

// Compare the contents (size - vtable - sync block index).
DWORD dwBaseSize = pThisRef->GetMethodTable()->GetBaseSize();
if(pThisRef->GetMethodTable() == g_pStringClass)
    dwBaseSize -= sizeof(WCHAR);
BOOL ret = memcmp(
    (void *) (pThisRef+1),
    (void *) (pCompareRef+1),
    dwBaseSize - sizeof(Object) - sizeof(int)) == 0;

さて、最後のこの比較が難関な匂いがします

まず真っ先に出てくるDWORDとはなんぞやというところからなので、グーグル大先生でclr dwordとググってみます。検索にヒットしたVB.NET/VB6.0/CLR/C/C++/Win32API 型一覧表 - 山崎はるかのメモによるとDWORDはC#でいうところのuintのようです、またそのあとに出てくるWCHARはC#でいうところのcharのようです

dwBaseSizeはMethodTableから取得しているようなのでおそらくその型が使用するメモリー量かなと思います。そのあとMethodTableがg_pStringClassだったらWCHARのサイズ分小さくしてるのはString末尾のヌル文字分減らしてるんじゃないかなと思いますが、値型でなかったらここまで到達しないはずでは…?というのもあるので謎ですね

そのあとはコメントの通りにmemcmpで参照先のメモリーブロックを比較しているという感じだと思います。(dwBaseSizeからsizeof(int)を減らしてる理由はわからないです)

Object.Equalsが遅かった理由

さて、本題に戻りObject.Equalsが参照型の場合はだいたい10ns、値型の場合はだいたい250nsかかっていたことについてですが、値型の場合はBenchmarkDotNetの結果をみるとAllocatedされているので明らかなのですが、Object.Equalsを呼び出すときにボックス化が行われていることがコストになっているようです。それを抜きにしても参照型・値型双方で通常の比較よりも多少の時間がかかっています

nullの場合はstatic bool Equalsのほうで早期リターンされるので早く終わってもいいはずですが、ベンチマーク結果的にはnullと空文字の場合であまり時間差がないため、メソッドの呼び出しコストに7nsぐらいかかってるんじゃないかなという匂いがします。一方で値型の場合は躊躇に差が表れているのでnullの場合は早期リターンされ、1の場合*12はObjectNativeの処理のあたりまで行ってるんじゃないかなと創造できます

真相は不明です、コード上からだとここが限界どころですね

ちなみにsrc/mono/netcoreのほうは

public static new bool Equals(object? o1, object? o2)
{
    if (o1 == o2)
        return true;

    if (o1 == null || o2 == null)
        return false;

    if (o1 is ValueType)
        return ValueType.DefaultEquals(o1, o2);

    return false;
}

値型以外の場合は単純な処理のようです。値型の場合だとValueType.DefautEqualsで比較するようです

internal static bool DefaultEquals(object o1, object o2)
{
    RuntimeType o1_type = (RuntimeType)o1.GetType();
    RuntimeType o2_type = (RuntimeType)o2.GetType();

    if (o1_type != o2_type)
        return false;

    object[] fields;
    bool res = InternalEquals(o1, o2, out fields);
    if (fields == null)
        return res;

    for (int i = 0; i < fields.Length; i += 2)
    {
        object meVal = fields[i];
        object youVal = fields[i + 1];
        if (meVal == null)
        {
            if (youVal == null)
                continue;

            return false;
        }

        if (!meVal.Equals(youVal))
            return false;
    }

    return true;
}

DefautEqualsに関してはこのメソッド名で検索するとこのファイルしか候補に上がらないため比較的楽に見つかりましたがInternalEqualsが嫌な予感しますね

[MethodImplAttribute(MethodImplOptions.InternalCall)]
private static extern bool InternalEquals(object o1, object o2, out object[] fields);

同じファイルに宣言されてましたが、どうやらまたCの世界に行くようです

InternalEqualsで調べるとsrc/coreclrのファイルも引っ掛かりますが、今回はsrc/monoコンテキストなのでその配下にあるそれっぽい結果のsrc/mono/mono/metadata/icall-def.hに探りをいれるとHANDLES(VALUET_1, "InternalEquals", ves_icall_System_ValueType_Equals, MonoBoolean, 3, (MonoObject, MonoObject, MonoArrayOut))とあるのでves_icall_System_ValueType_Equalsが本命のようです

同じように検索するとsrc/mono/mono/metadata/icall.cが出てきました。目的のメソッドは170行ぐらいあるのでここでは割愛しますが、リフレクションのような処理を行っているように見えます

さてC#erのみなさん、ここで思い出しましょう、値型のEqualsメソッドでは規定でリフレクションが使われるので遅いということを。このことは公式リファレンスの型の値の等価性を定義する方法 (C# プログラミング ガイド)でも書かれてることなので知ってる方も多いことでしょう*13。リファレンスに書かれてる通りのような実装をされているのでsrc/monoのほうは納得できるでしょう、しかしここまで読んでいただけた方はsrc/coreclrのほうではリフレクションではなくメモリブロックの比較を行っていたことにお気づきだと思います。自分のソースコード探索が間違っていなければいつの間にかにより高速だと思われる比較に変わっているということになりますね*14

Runtimeによる差を確認

前述のとおりsrc/monoはどうやら値型でEqualsメソッドを使うとリフレクションで遅いようだということがわかりました。本当にそうなのか比較したいところですがdotnet/runtimeのsrc/monoは謎の存在です(たぶんXamarin.Androidあたりのためにmono/monoからクローンしてるんじゃないかなと想像)

mono/monoでdotnet/runtimeのsrc/monoにあったDefaultEqualsを検索するとほぼ同様なコードがありましたので前述のとおりにmonoでは値型のEqualsメソッドでリフレクションが使われるという前提のもとその違いが出ないかの調査をします*15

調査と言ってもRuntimeによってEqualsの速度差が出るはずなのでBenchmarkDotNetによって差を計測していくことにします

今のcsprojファイルではそのまま計測することができないので少し手を加えます

<TargetFrameworks>net5.0;net48</TargetFrameworks>
<PlatformTarget>AnyCPU</PlatformTarget>
<LangVersion>9.0</LangVersion>

csprojのPropertyGroupにほとんどの場合ではTargetFrameworkが記述されてるかと思いますが、それはTargetFrameworksに変え.NET Framework 4.8であるnet48を記載します。それと同時にPlatformTargetLangVersionを指定します。ここでは.NET 5に合わせて9.0にしていますが.NET Framework 4.8やMonoだと対応するC#バージョンが異なりますので一部C# 9.0の機能が使えなくなります(検証では関係ありませんが)

次に.NET Framework 4.8とMonoを準備します。.NET FrameworkはDeveloper Packを入れ、Monoは公式サイトからインストーラーを入れて環境変数にPathを通せばいいです

[SimpleJob(RuntimeMoniker.Net48)]
[SimpleJob(RuntimeMoniker.NetCoreApp50)]
[SimpleJob(RuntimeMoniker.Mono)]
[MeanColumn, MinColumn, MaxColumn]
[MemoryDiagnoser]
public class ValueTypeEqualsBench
{
    private object expect = 1;

    [Params(null, 1)]
    public object? Value { get; set; }

    [Benchmark]
    public bool ValueTypeEquals()
    {
        return object.Equals(Value, expect);
    }
}

まず検証するのはこちらのベンチマークケースです。単純にメソッドの実行速度の差を計測したいのであらかじめボックス化をさせておきobject.Equalsを呼び出すだけのコードにしています

計測に関してですがMonoが.NET FrameworkのBCLを参照する必要があるらしくHost Processを.NET Framework 4.8にするためにdotnetコマンド(dotnet run -c Release -f net48)でHost Processを指定することで実施しています

そして結果がこう:

BenchmarkDotNet=v0.12.1, OS=Windows 10.0.19042, VM=Hyper-V
Intel Core i9-10900K CPU 3.70GHz, 1 CPU, 16 logical and 8 physical cores
  [Host]        : .NET Framework 4.8 (4.8.4250.0), X64 RyuJIT
  .NET 4.8      : .NET Framework 4.8 (4.8.4250.0), X64 RyuJIT
  .NET Core 5.0 : .NET Core 5.0.0 (CoreCLR 5.0.20.51904, CoreFX 5.0.20.51904), X64 RyuJIT
  Mono          : Mono 6.12.0 (Visual Studio), X64 

f:id:meilcli:20201213185456p:plain

.NET Framework 4.8が.NET 5より少し早いという結果になりましたがMonoが予想通り遅そうな結果が出ていますね。リフレクションで比較しているということは比較対象のフィールドが多くなればなるほど差が躊躇に現れるはずです

[SimpleJob(RuntimeMoniker.Net48)]
[SimpleJob(RuntimeMoniker.NetCoreApp50)]
[SimpleJob(RuntimeMoniker.Mono)]
[MeanColumn, MinColumn, MaxColumn]
[MemoryDiagnoser]
public class ValueTypeLongStructEqualsBench
{
    public struct BigStruct
    {
        public long Value1;
        public long Value2;
        public long Value3;
        public long Value4;
        public long Value5;
        public long Value6;
        public long Value7;
        public long Value8;
    }

    private object expect = new BigStruct();

    public IEnumerable<object> Source()
    {
        yield return new BigStruct();
    }

    [Benchmark]
    [ArgumentsSource(nameof(Source))]
    public bool ValueTypeEquals(object value)
    {
        return object.Equals(value, expect);
    }
}

今度は無理やり肥大化させた構造体でベンチマークをしてみます

f:id:meilcli:20201213190231p:plain

結果としては予想通りMonoが躊躇に遅くなりました。どうやらいつかわからないタイミングで値型のEqualsのパフォーマンスチューニングが施されていたようです

EqualityComparer<T>.Defaultの実装

さてEqualityComparer<T>.Defaultの実装を深掘っていこうと思いますが、すでにEqualityComparer.Defaultの実装を追ってみる。 - ねののお庭。で先駆者の方が実装を追っているようです。どうやら正攻法でコードを読んでいくと沼になるようなので趣向を変えてDevirtualizationが実装されたPullRequestを見ていこうと思います

EqualityComparer<T>.DefaultのDevirtualizationが実装されたのは.NET Core 2.1の頃なのでdotnet/runtimeリポジトリーではなくdotnet/coreclrリポジトリー*16を探すことになります

PullRequestを検索するとJIT: devirtualization support for EqualityComparer.Default #14125というそれっぽいPullRequestが見つかります

中身を見てみるとDevirtualizationはIntrinsicAttributeをC#コードに付けそれをJITが見つけると特殊対応をする構造になっているようです

EqualityComparer<T>.Defaultの場合は元々は

public static EqualityComparer<T> Default { get; } = (EqualityComparer<T>)ComparerHelpers.CreateDefaultEqualityComparer(typeof(T));

というコードだったものが

public static EqualityComparer<T> Default { [Intrinsic] get; } = (EqualityComparer<T>)ComparerHelpers.CreateDefaultEqualityComparer(typeof(T));

というコードに変更されています

それと同時にsrc/mscorlib/src/System/Collections/Generic/ComparerHelpers.csのCreateDefaultComparerメソッドにand in vm/jitinterface.cpp so the jit can model the behavior of this method.というドキュメントコメントが追記されているためJIT側でDefaultのEqualityComparerを作成してるようです

JIT側のコードはC++でよくわからないので割愛するとして、Devirtualizationの特殊対応をする対象メソッドはsrc/jit/namedintrinsiclist.hのNamedIntrinsic列挙型で管理されているようです

enum NamedIntrinsic
{
    NI_Illegal                                                 = 0,
    NI_System_Enum_HasFlag                                     = 1,
    NI_MathF_Round                                             = 2,
    NI_Math_Round                                              = 3,
    NI_System_Collections_Generic_EqualityComparer_get_Default = 4
};

このPullRequestの時点では対象となるメソッドは少ないようですが気になるので現在の.NET 5の実装を見ましょう

dotnet/coreclrのコードはdotnet/runtimeだとsrc/coreclrの中に移ってるはずなのでそのディレクトリーからそれっぽいところを探すとnamedintrinsiclist.hが見つかりました

enum NamedIntrinsic : unsigned short
{
    NI_Illegal = 0,

    NI_System_Enum_HasFlag,
    NI_System_Math_FusedMultiplyAdd,
    NI_System_Math_Sin,
    NI_System_Math_Cos,
    NI_System_Math_Cbrt,
    NI_System_Math_Sqrt,
    NI_System_Math_Abs,
    NI_System_Math_Round,
    NI_System_Math_Cosh,
    NI_System_Math_Sinh,
    NI_System_Math_Tan,
    NI_System_Math_Tanh,
    NI_System_Math_Asin,
    NI_System_Math_Asinh,
    NI_System_Math_Acos,
    NI_System_Math_Acosh,
    NI_System_Math_Atan,
    NI_System_Math_Atan2,
    NI_System_Math_Atanh,
    NI_System_Math_Log10,
    NI_System_Math_Pow,
    NI_System_Math_Exp,
    NI_System_Math_Ceiling,
    NI_System_Math_Floor,
    NI_System_Collections_Generic_EqualityComparer_get_Default,
    NI_System_Buffers_Binary_BinaryPrimitives_ReverseEndianness,
    NI_System_Numerics_BitOperations_PopCount,
    NI_System_GC_KeepAlive,
    NI_System_Threading_Thread_get_CurrentThread,
    NI_System_Threading_Thread_get_ManagedThreadId,
    NI_System_Type_get_IsValueType,
    NI_System_Type_IsAssignableFrom,
    NI_System_Type_IsAssignableTo,

    /* 略 */

現在だとかなりのメソッドが特殊対応の対象のようですがほとんどがMathクラスのものですね

EqualityComparer<T>.Defaultの実装の調査に関しては沼なのでここまでにしておきます

結局のところなにがいいのよ

総合評価(安全性・可読性・速度)をするとnull判定はvalue is null、not null判定はvalue is not nullが無難という感じでしょうか。もちろん他の表現方法でも気にする必要がないケースがほとんどだろうのでなんでもいいっちゃいいという感じではありますが

ちなみに

ldarg.0
call instance string NullCheck.Benchmark.ReferenceNullBench::get_Value()
ldnull
cgt.un
ldc.i4.0
ceq

最初のほうで!(Value is not null)の場合は上記のようなCILになってたよと紹介しましたが

public class ReferenceNull
{
    public bool PatternMatchNotNull9(string? value)
    {
        return !(value is not null);
    }
}

というコードのCILを見ると

.method public hidebysig 
    instance bool PatternMatchNotNull9 (
        string 'value'
    ) cil managed 
{
    // Method begins at RVA 0x2102
    // Code size 5 (0x5)
    .maxstack 8

    IL_0000: ldarg.1
    IL_0001: ldnull
    IL_0002: ceq
    IL_0004: ret
} // end of method ReferenceNull::PatternMatchNotNull9

というコードになっていました。式も文脈によっては異なるCILに変換されるようなので今回のベンチマークケースでこうなったからといってif文やreturn文で同様になるとは限らなさそうです。ボックス化している箇所についてはほぼ確実にどのような場所でもおきそうですがCILの命令数的な誤差は目をつぶるしかなさそうです

おまけ

public class Evil
{
    public static bool operator ==(Evil left, Evil right) => true;
    public static bool operator !=(Evil left, Evil right) => false;
}

public class Program
{
    public void Example()
    {
        var evil1 = new Evil();
        var evil2 = new Evil();
        bool isEquality = evil1 == evil2;
    }
}

C#的には合法(コンパイル可能)で邪悪なコードですがExampleメソッドのCILに変換されたコードを見るとオーバーロードした==演算子が呼ばれていることがわかります

.method public hidebysig 
    instance void Example () cil managed 
{
    // Method begins at RVA 0x2060
    // Code size 22 (0x16)
    .maxstack 2
    .locals init (
        [0] class Evil evil1,
        [1] class Evil evil2,
        [2] bool isEquality
    )

    IL_0000: nop
    IL_0001: newobj instance void Evil::.ctor()
    IL_0006: stloc.0
    IL_0007: newobj instance void Evil::.ctor()
    IL_000c: stloc.1
    IL_000d: ldloc.0
    IL_000e: ldloc.1
    IL_000f: call bool Evil::op_Equality(class Evil, class Evil)
    IL_0014: stloc.2
    IL_0015: ret
} // end of method Program::Example

Evilの変数をobject型として受け取ればobjectの規定の動作通りに==演算子で比較したようになったりしますが、たいていの場合はそういうわけにもいかないので==演算子のオーバーロードは注意が必要だったりします

おわりに

この記事はアドベントカレンダーだしC#の記事書いとくか〜と書き始めたら止まらなくなり肥大化してしまったものです(スコープの管理ができてない)。最後の方はダレてしまって手抜き感がありますがご了承ください、気になればいつの日か調査するかもしれません

昨日の「dely #2 Advent Calendar 2020」はnancyさんの「iOSのサブスクリプション機能 プロモーションオファーを触ってみた」でした

明日は永井さんの「Merged Manifest を使って uses-permission を調査した話」ですお楽しみに!


join-us.dely.jp

bethesun.connpass.com

*1:サンプルコードはすべてC# 9.0ベースです

*2:ポインターとか参照をUnsafeに比較する方法とかあるかもしれません

*3:普通はそんなオーバーロードをしないので普通のプラットフォーム向けのコードの場合は気にしなくていいですが、気にしないといけないプラットフォームがあるので闇です

*4:こういう場合のスマートな解決策があれば教えてください

*5:i9-10900Kは10core20threadなCPUですが、計測はHyper-V上のWindowsで行ったため8core16threadです。フルパフォーマンスとは言えませんが同環境での比較となるためベンチマーク結果としては有効かと思います

*6:ガチで判断するならnullの頻度分布によって調整をかけないといけませんがここでは手抜きということで平均値です

*7:たとえばIEqualityComparerのインスタンスが用意できてるという前提でcomparere.Equals(Value, null)を計測するのかとか

*8:Common Intermediate Language、共通中間言語、一部からはMSILとも呼ばれる

*9:ここのCILを理解するのに5分考えこみました

*10:EqualityComparerは0スタック目という数え方

*11:ちょっと前まではdotnet/coreclrで公開されていましたね

*12:nullと対比するための値として設定した1のことです

*13:自分はすっかり忘れてました

*14:それでもボックス化で遅いのは変わらず

*15:すべてのコードを確かめたわけではありませんが雰囲気的にはdotnet/runtimeのsrc/monoはmono/monoから手書きクローンしてそうな感じがしました

*16:今はdotnet/runtimeに移行されてコードがほとんどない状態ですがCommitやPullRequestは残っています