Если вы знакомы с C#, то, скорее всего, знаете, что необходимо всегда переопределять Equals
, а также GetHashCode
, чтобы избежать снижения производительности. Но что будет, если этого не сделать? Сегодня сравним производительность при двух вариантах настройки и рассмотрим инструменты, помогающие избегать ошибок.
Enum.HasFlag
не очень эффективен (*), но если не использовать его на ресурсоемком участке кода, то серьезных проблем в проекте не возникнет. Это верно и случае с защищенными копиями, созданными типами non-readonly struct в контексте readonly. Проблема существует, но вряд ли будет заметна в обычных приложениях.Equals
и GetHashCode
, то используются их стандартные версии из System.ValueType
. А они могут значительно снизить производительность конечного приложения.System.ValueType
или System.Enum
, запускает упаковку-преобразование (**).Enum.HasFlag
и генерирует подходящий код, который не запускает упаковку-преобразование.GetHashCode
. При реализации хэш-функции мы сталкиваемся с дилеммой: сделать распределение хэш-функции хорошо или быстро. В ряде случаев можно выполнить и то и другое, но в типе ValueType.GetHashCode
это обычно сопряжено с трудностями.ValueType
— использовать рефлексию. Вот почему авторы CLR решили пожертвовать скоростью ради распределения, и стандартная версия GetHashCode
только возвращает хэш-код первого ненулевого поля и «портит» его идентификатором типа (***) (подробнее см. RegularGetValueTypeHashCode
в coreclr repo на github).public readonly struct Location
{
public string Path { get; }
public int Position { get; }
public Location(string path, int position) => (Path, Position) = (path, position);
}
var hash1 = new Location(path: "", position: 42).GetHashCode();
var hash2 = new Location(path: "", position: 1).GetHashCode();
var hash3 = new Location(path: "1", position: 42).GetHashCode();
// hash1 and hash2 are the same and hash1 is different from hash3
public readonly struct Location1
{
public string Path { get; }
public int Position { get; }
public Location1(string path, int position) => (Path, Position) = (path, position);
}
public readonly struct Location2
{
// The order matters!
// The default GetHashCode version will get a hashcode of the first field
public int Position { get; }
public string Path { get; }
public Location2(string path, int position) => (Path, Position) = (path, position);
}
public readonly struct Location3 : IEquatable<Location3>
{
public string Path { get; }
public int Position { get; }
public Location3(string path, int position) => (Path, Position) = (path, position);
public override int GetHashCode() => (Path, Position).GetHashCode();
public override bool Equals(object other) => other is Location3 l && Equals(l);
public bool Equals(Location3 other) => Path == other.Path && Position == other.Position;
}
private HashSet<Location1> _locations1;
private HashSet<Location2> _locations2;
private HashSet<Location3> _locations3;
[Params(1, 10, 1000)]
public int NumberOfElements { get; set; }
[GlobalSetup]
public void Init()
{
_locations1 = new HashSet<Location1>(Enumerable.Range(1, NumberOfElements).Select(n => new Location1("", n)));
_locations2 = new HashSet<Location2>(Enumerable.Range(1, NumberOfElements).Select(n => new Location2("", n)));
_locations3 = new HashSet<Location3>(Enumerable.Range(1, NumberOfElements).Select(n => new Location3("", n)));
_locations4 = new HashSet<Location4>(Enumerable.Range(1, NumberOfElements).Select(n => new Location4("", n)));
}
[Benchmark]
public bool Path_Position_DefaultEquality()
{
var first = new Location1("", 0);
return _locations1.Contains(first);
}
[Benchmark]
public bool Position_Path_DefaultEquality()
{
var first = new Location2("", 0);
return _locations2.Contains(first);
}
[Benchmark]
public bool Path_Position_OverridenEquality()
{
var first = new Location3("", 0);
return _locations3.Contains(first);
}
Method | NumOfElements | Mean | Gen 0 | Allocated |
-------------------------------- |------ |--------------:|--------:|----------:|
Path_Position_DefaultEquality | 1 | 885.63 ns | 0.0286 | 92 B |
Position_Path_DefaultEquality | 1 | 127.80 ns | 0.0050 | 16 B |
Path_Position_OverridenEquality | 1 | 47.99 ns | - | 0 B |
Path_Position_DefaultEquality | 10 | 6,214.02 ns | 0.2441 | 776 B |
Position_Path_DefaultEquality | 10 | 130.04 ns | 0.0050 | 16 B |
Path_Position_OverridenEquality | 10 | 47.67 ns | - | 0 B |
Path_Position_DefaultEquality | 1000 | 589,014.52 ns | 23.4375 | 76025 B |
Position_Path_DefaultEquality | 1000 | 133.74 ns | 0.0050 | 16 B |
Path_Position_OverridenEquality | 1000 | 48.51 ns | - | 0 B |
ValueType.Equals
. Вот последствия метода, использующего рефлексию!Position_Path_DefaultEquality
). Но если это не так, то производительность будет крайне низкой.ValueType.Equals
загружался 50 секунд.private readonly HashSet<(ErrorLocation, int)> _locationsWithHitCount;
readonly struct ErrorLocation
{
// Empty almost all the time
public string OptionalDescription { get; }
public string Path { get; }
public int Position { get; }
}
Equals
. И, к сожалению, он имел необязательное первое поле, которое почти всегда равнялось String.equals
. Производительность оставалась высокой, пока значительно не увеличилось количество элементов в наборе. За считанные минуты инициализировалась коллекция с десятками тысяч элементов.ValueType.Equals/GetHashCode
по умолчанию работает медленно?ValueType.Equals
, и для ValueType.GetHashCode
есть специальные методы оптимизации. Если у типа нет «указателей» и он правильно упакован (я покажу пример через минуту), тогда используются оптимизированные версии: итерации GetHashCode
выполняются над блоками экземпляра, используется XOR размером 4 байта, метод Equals
сравнивает два экземпляра, используя memcmp
.// Optimized ValueType.GetHashCode implementation
static INT32 FastGetValueTypeHashCodeHelper(MethodTable *mt, void *pObjRef)
{
INT32 hashCode = 0;
INT32 *pObj = (INT32*)pObjRef;
// this is a struct with no refs and no "strange" offsets, just go through the obj and xor the bits
INT32 size = mt->GetNumInstanceFieldBytes();
for (INT32 i = 0; i < (INT32)(size / sizeof(INT32)); i++)
hashCode ^= *pObj++;
return hashCode;
}
// Optimized ValueType.Equals implementation
FCIMPL2(FC_BOOL_RET, ValueTypeHelper::FastEqualsCheck, Object* obj1, Object* obj2)
{
TypeHandle pTh = obj1->GetTypeHandle();
FC_RETURN_BOOL(memcmp(obj1->GetData(), obj2->GetData(), pTh.GetSize()) == 0);
}
ValueTypeHelper::CanCompareBits
, она вызывается и из итерации ValueType.Equals
, и из итерации ValueType.GetHashCode
.public struct Case1
{
// Optimization is "on", because the struct is properly "packed"
public int X { get; }
public byte Y { get; }
}
public struct Case2
{
// Optimization is "off", because struct has a padding between byte and int
public byte Y { get; }
public int X { get; }
}
public struct MyDouble
{
public double Value { get; }
public MyDouble(double value) => Value = value;
}
double d1 = -0.0;
double d2 = +0.0;
// True
bool b1 = d1.Equals(d2);
// False!
bool b2 = new MyDouble(d1).Equals(new MyDouble(d2));
-0,0
и +0,0
равны, но имеют разные двоичные представления. Это значит, что Double.Equals
окажется верным, а MyDouble.Equals
— ложным. В большинстве случаев разница несущественна, но представьте, сколько часов вы потратите на исправление проблемы, вызванной этой разницей.Equals
и GetHashCode
в типах struct — использование правила FxCop CA1815. Но есть одна проблема: это слишком строгий подход.System.Collections.Generic.KeyValuePair <TKey, TValue>
, определенная в mscorlib, не перезаписывает Equals
и GetHashCode
. Маловероятно, что сегодня кто-то определит переменную типа HashSet <KeyValuePair<string, int>>
, но я считаю, что даже BCL может нарушить правило. Поэтому полезно обнаружить это, пока не поздно.GetHashCode
, будет очень плохим, если первое поле многих экземпляров имеет одинаковое значение.Equals
и GetHashCode
существуют оптимизированные версии, но не стоит на них полагаться, поскольку их может выключить даже небольшое изменение кода.К сожалению, не доступен сервер mySQL