どうもC#erの@MeilCliです。仕事ではAndroidエンジニアしてますがC#erなのでアドベントカレンダーではC#について書きます
今回参加してるアドベントカレンダーはこちらです。16日目の記事になります
あと同様なカレンダーがもう1つあります
また、この記事の一部をクイズにしたものも投稿していますのでよろしければそちらもご覧ください
祝: C# 9.0リリース
さて、つい先日.NET 5と共にC# 9.0がリリースされました。C# 9.0の新機能は多々あるのですがその中でパターンマッチングの強化の一貫でvalue is not null
のようにnot条件が追加されました。この新機能によってC# 8.0のようにnot null判定をするためにvalue != null
やvalue is T nonNull
やvalue 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
また、計測コードなどの詳細はGitHubに公開しているのでそちらを参照ください
要約
ちょっと結果が多すぎるためそれぞれの平均値を取ってみます*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; } }
結果としては計測できないほど早い処理が行われてそうという感じでした。.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 != null
とValue is string
とValue 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.0
とceq
で結果を反転してるだけなので省きます
値型・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.0
とceq
を使っていますね
// !(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スタック目*10にldarg.0
とcall
によって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.0
とceq
をしなくなったバージョンです。単純にbool値の反転がなくなったということですね
Value is int
やValue is int notNullValue
でも同様にldc.i4.0
とceq
の命令がなくなっていました
// !(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.0
とceq
で値の反転をしています
これと同様に!(object.Equals(Value, null))
と!(EqualityComparer<int?>.Default.Equals(Value, null))
もC#コードの通りにldc.i4.0
とceq
による値の反転がされていました
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.CompilerServices
かSystem.Runtime.InteropServices
にありそうですね
今度はfilename:RuntimeHelpers
で検索してみます。すると8件ほどヒットするので目星をつけた名前空間に着目すると
- src/coreclr/System.Private.CoreLib/src/System/Runtime/CompilerServices/RuntimeHelpers.CoreCLR.cs
- src/mono/netcore/System.Private.CoreLib/src/System/Runtime/CompilerServices/RuntimeHelpers.Mono.cs
- src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/RuntimeHelpers.cs
の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.cppとsrc/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
を記載します。それと同時にPlatformTarget
とLangVersion
を指定します。ここでは.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
.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); } }
今度は無理やり肥大化させた構造体でベンチマークをしてみます
結果としては予想通りMonoが躊躇に遅くなりました。どうやらいつかわからないタイミングで値型のEqualsのパフォーマンスチューニングが施されていたようです
EqualityComparer<T>.Defaultの実装
さてEqualityComparer<T>.Defaultの実装を深掘っていこうと思いますが、すでにEqualityComparer
EqualityComparer<T>.DefaultのDevirtualizationが実装されたのは.NET Core 2.1の頃なのでdotnet/runtimeリポジトリーではなくdotnet/coreclrリポジトリー*16を探すことになります
PullRequestを検索するとJIT: devirtualization support for EqualityComparer
中身を見てみると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 を調査した話」ですお楽しみに!
*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は残っています