最近、ValueTuple(*)構造のEqualsメソッドが大量のメモリトラフィック(約1 GB)を生成することに気付きました。
この構造はパフォーマンスが重要なシナリオで使用されるため、これは私にとって驚きでした。 これは次のようなものです。
public struct ValueTuple<TItem1, TItem2> : IEquatable<ValueTuple<TItem1, TItem2>> { public TItem1 Item1 { get; } public TItem2 Item2 { get; } public ValueTuple(TItem1 item1, TItem2 item2) { Item1 = item1; Item2 = item2; } public override int GetHashCode() { // . XOR return EqualityComparer<TItem1>.Default.GetHashCode(Item1) ^ EqualityComparer<TItem2>.Default.GetHashCode(Item2); } public bool Equals(ValueTuple<TItem1, TItem2> other) { return (Item1 != null && Item1.Equals(other.Item1)) && (Item2 != null && Item2.Equals(other.Item2)); } public override bool Equals(object obj) { return obj is ValueTuple<TItem1, TItem2> && Equals((ValueTuple<TItem1, TItem2>)obj); } // , , }
(*)2017年にVisual Studioに登場する前にValueTupleが実装されましたが、実装は不変です。
EqualsおよびGetHashCodeメソッドは、ボクシングを回避するために再定義されます。 Item1またはItem2が参照型のオブジェクトである場合、NullReferenceExceptionを回避するにはnullチェックが必要です。 Nullチェックはパッケージ化につながる可能性がありますが、JITは意味のあるタイプのこのチェックを除外するのに十分スマートです。 すべてがシンプルで明確です。 そうだね ほぼ。
この場合、メモリトラフィックはすべてのValueTupleバリアントではなく、特定のHashSet <ValueTuple <int、MyEnum >>に対してのみ生成されました。 わかった より明確になりました。 そう?
Equalsメソッドを呼び出して、ValueTuple <int、MyEnm>型の2つのインスタンスを比較するとどうなるか見てみましょう。
// Compiler's view of the world public bool Equals(ValueTuple<int, MyEnum> rhs) { return Item1.Equals(rhs.Item1) && Item2.Equals(rhs.Item2); }
Item1についてはint.Equals(int)を呼び出し、Item2についてはMyEnum.Equals(MyEnum)メソッドを呼び出します。 前者の場合、特別なことは何も起こりませんが、後者の場合、メソッドを呼び出すとパッケージ化されます!
「いつ」「なぜ」パッケージングが行われますか?
通常、重要な型のインスタンスが明示的または暗黙的に参照型に変換されると、パッケージ化が発生すると考えられます。
int n = 42; object o = n; // Boxing IComparable c = n; // Boxing
しかし、現実はもう少し複雑です。 JITとCLRは、他の場合(たとえば、ValueTypeクラスで定義されたメソッドを呼び出す場合)に、重要な型のインスタンスをパックするように強制されます。
すべてのユーザー構造は暗黙的にシールされ、特別なクラスSystem.ValueTypeから継承されます。 すべての重要なタイプには「値のセマンティクス」があり、リンクの比較に基づいてSystem.Objectに実装された動作はそれらに適合しません。 値のセマンティクスを提供するために、System.ValueTypeは、GetHashCodeとEqualsの2つのメソッドの特別な実装を提供します。
ただし、デフォルトの実装には2つの問題があります。
- リフレクションを使用できるため、パフォーマンスは非常に劣ります(**)。
- これらのメソッドの1つを呼び出している間のパッキング。
(**)ValueType.EqualsおよびValueType.GetHashCodeのデフォルトの実装パフォーマンスは、特定の重要なタイプの形式に応じて大幅に異なる場合があります。 構造にポインターが含まれておらず、正しく「パック」されている場合、ビットごとの比較が可能です。 そうでなければ、反射が使用され、その使用は生産性の急激な低下につながります。 coreclrレジストリのCanCompareBitsの実装を参照してください。
上記の最初の問題は多くの人によく知られていますが、2番目の問題はより微妙です。構造がEqualsまたはGetHashCodeメソッドをオーバーライドしない場合、これらのメソッドの1つが呼び出されるとパッケージ化が行われます。
struct MyStruct { public int N { get; } // , // , System.ValueType. public override int GetHashCode() => N.GetHashCode(); public override bool Equals(object obj) { return obj is MyStruct && Equals((MyStruct)obj); } public bool Equals(MyStruct other) => N == other.N; } var myStruct = new MyStruct(); // : MyStruct GetHashCode var hc = myStruct.GetHashCode(); // : MyStruct Equals var equality = myStruct.Equals(myStruct); // : MyStruct ToString var s = myStruct.ToString(); // : GetType var t = myStruct.GetType();
上記の例では、パッケージ化は最初の2つのケースでは発生しませんが、最後の2つのケースで発生します。 System.ValueTypeで定義されたメソッド(ToStringやGetTypeなど)を呼び出すとパッケージ化され、オーバーライドされたメソッド(EqualsやGetHashCodeなど)を呼び出すとパッケージ化されません。
次に、ValueTuple <int、MyEnum>を使用した例に戻ります。 カスタム列挙は、GetHashCodeメソッドとEqualsメソッドをオーバーライドすることができない意味のあるタイプです。つまり、MyEnum.GetHashCodeまたはMyEnum.Equalsを呼び出すたびにパッケージ化とメモリ割り当てが行われます。
これを回避できますか? はい、EqualityComparer.Defaultを使用します。
EqualityComparerはメモリのパックと割り当てをどのように回避しますか?
例を単純化して、列挙値を比較する2つの方法を比較してみましょう。Equalsメソッドを使用して、EqualityComparerを使用します。デフォルト:
MyEnum e1 = MyEnum.Foo; MyEnum e2 = MyEnum.Bar; // bool b1 = e1.Equals(e2); // bool b2 = EqualityComparer<MyEnum>.Default.Equals(e1, e2);
BenchmarkDotNetを使用して、前者がメモリ割り当てを引き起こし、もう一方がメモリ割り当てを引き起こさないことを証明しましょう(イテレータ割り当てを避けるために、Enumerable.AnyやEnumerable.Containsのようなものではなく、単純なforeachループを使用します):
[MemoryDiagnoser] public class EnumComparisonBenchmark { public MyEnum[] values = Enumerable.Range(1, 1_000_000).Select(n => MyEnum.Foo).ToArray(); public EnumComparisonBenchmark() { values[values.Length - 1] = MyEnum.Bar; } [Benchmark] public bool UsingEquals() { foreach(var n in values) { if (n.Equals(MyEnum.Bar)) return true; } return false; } [Benchmark] public bool UsingEqualityComparer() { foreach (var n in values) { if (EqualityComparer<MyEnum>.Default.Equals(n, MyEnum.Bar)) return true; } return false; } }
方法
| 平均
| Gen 0
| 割り当て済み
|
UsingEquals
| 13.300ミリ秒
| 15195.9459
| 48000597 B
|
UsingEqualityComparer
| 4.659ミリ秒
| - | 58 B
|
ご覧のとおり、Equalsメソッドを呼び出すと、多くのメモリが割り当てられます。 EqualityComparerの方が高速ですが、私の場合、実装をEqualityComparerに置き換えても違いは見られませんでした。 主な質問は次のとおりです。EqualityComparerはどのようにそれを行いますか?
EqualityComparerは、EqualityComparer .Defaultプロパティを介して、指定された型引数に基づいて最適なコンパレータを提供する抽象クラスです。 メインロジックはComparerHelpers.CreateDefaultEqualityComparerメソッド内にあり、列挙の場合、別のヘルパーメソッドTryCreateEnumEqualityComparerに渡します。 最後のメソッドは、列挙の基本型をチェックし、厄介なトリックを行う特別な比較オブジェクトを作成します。
[Serializable] internal class EnumEqualityComparer<T> : EqualityComparer<T> where T : struct { [Pure] public override bool Equals(T x, T y) { int x_final = System.Runtime.CompilerServices.JitHelpers.UnsafeEnumCast(x); int y_final = System.Runtime.CompilerServices.JitHelpers.UnsafeEnumCast(y); return x_final == y_final; } [Pure] public override int GetHashCode(T obj) { int x_final = System.Runtime.CompilerServices.JitHelpers.UnsafeEnumCast(obj); return x_final.GetHashCode(); }
EnumEqualityComparerは、JitHelpers.UnsafeEnumCastと次の2つの数値の比較を使用して、enumインスタンスをそのベース数値に変換します。
それでは、最終的な解決策は何ですか?
修正は非常に簡単でした。Item1.Equalsを使用して値を比較する代わりに、EqualityComparer .Default.Equals(Item1、other.Item1)に切り替えました。