Производительность HashSet Добавить vs Содержит для существующих элементов
По какой-то причине кажется, что операция Add
на HashSet
медленнее, чем операция Contains
, когда элемент уже существует в HashSet
.
Вот доказательство:
Stopwatch watch = new Stopwatch();
int size = 10000;
int iterations = 10000;
var s = new HashSet<int>();
for (int i = 0; i < size; i++) {
s.Add(i);
}
Console.WriteLine(watch.Time(() =>
{
for (int i = 0; i < size; i++) {
s.Add(i);
}
}, iterations));
s = new HashSet<int>();
for (int i = 0; i < size; i++) {
s.Add(i);
}
// outputs: 47,074,764
Console.WriteLine(watch.Time(() =>
{
for (int i = 0; i < size; i++) {
if (!s.Contains(i))
s.Add(i);
}
}, iterations));
// outputs: 41,125,219
Почему Contains
быстрее, чем Add
для уже существующих элементов?
Примечание. Я использую это расширение Stopwatch
из другого вопроса SO.
public static long Time(this Stopwatch sw, Action action, int iterations) {
sw.Reset();
sw.Start();
for (int i = 0; i < iterations; i++) {
action();
}
sw.Stop();
return sw.ElapsedTicks;
}
UPDATE. Внутреннее тестирование показало, что большая производительность diff происходит только в x64-версии .NET framework. С 32-разрядной версией Framework Содержит, кажется, запускается с одинаковой скоростью для добавления (на самом деле кажется, что версия с содержимым в некоторых тестовых прогонах работает на более медленном уровне). В версиях X64 версии Framework версия с содержимым кажется работают на 15% быстрее.
Ответы
Ответ 1
AddIfNotPresent делает дополнительный разделитель, который Contains не выполняет. Взгляните на IL для Содержит:
IL_000a: call instance int32 class System.Collections.Generic.HashSet`1<!T>::InternalGetHashCode(!0)
IL_000f: stloc.0
IL_0010: ldarg.0
IL_0011: ldfld int32[] class System.Collections.Generic.HashSet`1<!T>::m_buckets
IL_0016: ldloc.0
IL_0017: ldarg.0
IL_0018: ldfld int32[] class System.Collections.Generic.HashSet`1<!T>::m_buckets
IL_001d: ldlen
IL_001e: conv.i4
IL_001f: rem
IL_0020: ldelem.i4
IL_0021: ldc.i4.1
IL_0022: sub
IL_0023: stloc.1
Это вычисляет местоположение ведра для хэш-кода. Результат сохраняется в локальном расположении памяти 1.
AddIfNotPresent делает что-то подобное, но также сохраняет вычисленное значение в местоположении 2, чтобы он мог вставить элемент в хеш-таблицу в этой позиции, если элемент не существует. Это делает это, потому что одно из мест изменяется позднее в цикле, который ищет элемент. В любом случае, здесь соответствующий код для AddIfNotPresent:
IL_0011: call instance int32 class System.Collections.Generic.HashSet`1<!T>::InternalGetHashCode(!0)
IL_0016: stloc.0
IL_0017: ldloc.0
IL_0018: ldarg.0
IL_0019: ldfld int32[] class System.Collections.Generic.HashSet`1<!T>::m_buckets
IL_001e: ldlen
IL_001f: conv.i4
IL_0020: rem
IL_0021: stloc.1
IL_0022: ldarg.0
IL_0023: ldfld int32[] class System.Collections.Generic.HashSet`1<!T>::m_buckets
IL_0028: ldloc.0
IL_0029: ldarg.0
IL_002a: ldfld int32[] class System.Collections.Generic.HashSet`1<!T>::m_buckets
IL_002f: ldlen
IL_0030: conv.i4
IL_0031: rem
IL_0032: ldelem.i4
IL_0033: ldc.i4.1
IL_0034: sub
IL_0035: stloc.2
В любом случае, я думаю, что дополнительный разрыв - это то, что вызывает добавление, чтобы уделить больше времени, чем Содержит. На первый взгляд, похоже, что дополнительный делитель может быть учтен, но я не могу сказать точно, не потратив немного времени на расшифровку ИЛ.
Ответ 2
Интересно, что на моей машине (Dell Latitude D630, двухъядерный 2.2 Ghz) я получаю почти одинаковые результаты для обоих тестов, если я не запустил секундомер против действия null
перед тестами. Например:
Я запускаю тесты с точным кодом, который вы указали в вопросе:
Without Contains(): 8205794
With Contains(): 8207596
Если я модифицирую код следующим образом:
После:
Stopwatch watch = new Stopwatch();
int size = 10000;
int iterations = 10000;
Добавить
watch.Time(null, 0);
Мои результаты станут:
Without Contains(): 8019129
With Contains(): 8275771
Мне кажется, что внутри Stopwatch
происходит что-то странное, что вызывает эти колебания.
Ответ 3
Моя догадка заключается в том, что вы выполнили свой тест из Visual Studio, что вызвало подавление вложения AddIfNotPresent
в Add
, поэтому вы видите результат дополнительного уровня косвенности в вызове метода.
Если я скомпилирую и запустим из командной строки, чтобы удалить любую обход VS...
> csc /o+ /t:exe Program.cs
> Program.exe
... тогда нет разницы в производительности.
Выборочные выходы (отображающие большее количество тестов):
35036174
35153818
35225763
34862330
35047377
35033323