Ключ-структура из числа и строки. Поиск по словарю на 10 000 таких ключей занимает 74 975 микросекунд. Тот же ключ, объявленный record struct, — 3,354 микросекунды.
Разница в 22 354 раза, и берётся она не из сравнения ключей. Все 10 000 записей попали в один бакет: у них одинаковый хеш. Словарь превратился в список, по которому идёт перебор.
|
Машина |
Процессор |
Система |
|
Комп 1 |
Intel Core i9-10900KF 3.70GHz, 10 ядер |
Windows 10 22H2 |
|
Комп 2 |
AMD Ryzen 9 5950X 3.39GHz, 16 ядер |
Windows 10 1809 |
|
Комп 3 |
Intel Xeon W-2255 3.70GHz, 10 ядер |
Windows Server 2022 |
|
Комп 4 |
Intel Xeon Silver 4314 2.40GHz, 2 CPU, 32 ядра |
Windows Server 2022 |
Все машины x64
Рантаймы .NET 8, 9 и 10 — все три в одном запуске BenchmarkDotNet 0.15.8. Числа в тексте с AMD Ryzen 9 5950X, таблицы по всем четырём машинам.
1. Ключ, после которого словарь перестаёт быть словарём
Исходник
Составной ключ вида «тип записи и её имя». Число повторяется, строка у каждой записи своя:
internal struct NumberFirst{ internal int A; // одно и то же значение internal string B; // у каждого ключа своё}
Такой ключ добавляется в обычный словарь:
Dictionary<NumberFirst, int> map = new(count); for (int i = 0; i < count; i++){ map[new NumberFirst { A = 1, B = texts[i] }] = i;}
Что показывает замер
Поиск по словарю из 10 000 записей, 256 обращений за вызов. Все шесть словарей построены из одних и тех же данных, различаются только объявления ключей.
|
Ключ |
Комп 1 |
Комп 2 |
Комп 3 |
Комп 4 |
|
число, строка |
69 430,125 |
74 974,622 |
91 522,879 |
115 766,058 |
|
сравнение без хеша |
4 035,570 |
4 019,258 |
6 848,745 |
6 602,455 |
|
строка, число |
30,151 |
30,119 |
34,575 |
44,964 |
|
без ссылок внутри |
8,774 |
10,474 |
11,729 |
12,300 |
|
свой хеш |
6,143 |
4,592 |
5,908 |
6,483 |
|
record struct |
4,866 |
3,354 |
4,586 |
4,879 |
Микросекунды, .NET 10
Первая строка медленнее последней в 14 268–23 727 раз в зависимости от машины.
На .NET 8 и .NET 9 разрыв ещё больше:
|
Рантайм |
Комп 1 |
Комп 2 |
Комп 3 |
Комп 4 |
|
.NET 8 |
205 263,676 |
366 908,497 |
275 283,953 |
309 657,300 |
|
.NET 9 |
128 643,347 |
178 689,332 |
178 409,297 |
207 162,240 |
|
.NET 10 |
69 430,125 |
74 974,622 |
91 522,879 |
115 766,058 |
Микросекунды, ключ «число, строка»
Отставание от ключа-записи 105 737 раз на .NET 8 и 56 086 на .NET 9. Поиск ускорили втрое от версии к версии, но разрыв остался в тысячи раз.
Причина
Хеш структуры считает ValueType.GetHashCode. В комментарии к методу написано: берётся первое нестатическое поле и его хеш.
// Our algorithm for returning the hashcode is a little bit complex.// We look for the first non-static field and get its hashcode.// If the type has no non-static fields, we return the hashcode of the type.
Так бывает не всегда. Сначала выполняется проверка CanCompareBitsOrUseFastGetHashCode: если структура не содержит ссылок и в ней нет пропусков между полями, хеш сразу считается по всем её байтам.
Поле типа string — ссылка. Проверка не проходит, и хеш считается по первому полю. Первое поле у всех ключей набора одинаковое, значит одинаков и хеш.
Отчёт из проекта на наборе из 1000 ключей показывает результат:
ключ различных хешей занято бакетов самая длинная связка число, строка 1 1 из 1103 1000 сравнение без хеша 1 1 из 1103 1000 строка, число 1000 671 из 1103 5 свой хеш 1000 656 из 1103 5 запись 1000 669 из 1103 5 без ссылок 1000 661 из 1103 6
Один занятый бакет из 1103, и в нём все 1000 записей. Поиск перебирает их подряд, как список.
Где легко ошибиться
Решить, что виновата строка. У того же ключа с полями в обратном порядке получается 1000 различных хешей: хеш считается по строке, и он у каждого свой.
Решить, что дело в размере набора. На 1000 ключей разрыв меньше, но он там же: 12 744 микросекунды против 2,792.
2. Дело не в упаковке
Исходник
Первое, о чём думают в такой ситуации, — что структура сравнивается через Equals(object) и упаковывается. Про это уже была отдельная статья, там разница выходила в 5 раз.
Чтобы отделить одно от другого, в замере есть контрольный ключ: сравнение написано вручную, поэтому упаковки нет, а GetHashCode остался базовым.
internal struct EquatableOnly : IEquatable<EquatableOnly>{ internal int A; internal string B; public bool Equals(EquatableOnly other) => A == other.A && B == other.B; public override bool Equals(object? other) => other is EquatableOnly key && Equals(key); // GetHashCode намеренно не переопределён}
Что показывает замер
|
Ключ |
Комп 1 |
Комп 2 |
Комп 3 |
Комп 4 |
|
число, строка |
69 430,125 |
74 974,622 |
91 522,879 |
115 766,058 |
|
сравнение без хеша |
4 035,570 |
4 019,258 |
6 848,745 |
6 602,455 |
|
record struct |
4,866 |
3,354 |
4,586 |
4,879 |
Микросекунды, .NET 10
Своё сравнение убирает упаковку и ускоряет поиск в 17,2 раза. До ключа-записи остаётся ещё 1198 раз: записи так и остались в одном бакете, и приходится проходить по каждой.
Причина
Упаковка и хеш друг с другом не связаны. IEquatable отвечает за то, как сравниваются два ключа. GetHashCode отвечает за то, сколько ключей придётся сравнить.
При одном хеше на весь набор второе перевешивает первое на три порядка.
Где легко ошибиться
Реализовать IEquatable и посчитать задачу закрытой. Компилятор предупреждает про несогласованный Equals, но про GetHashCode у структуры не скажет ничего: базовая реализация есть, и формально всё в порядке.
3. Хеши разные, а поиск всё равно медленный
Исходник
Ключ с полями в обратном порядке. Хеш считается по строке, все 1000 значений разные, записи разошлись по 671 бакету:
internal struct TextFirst{ internal string B; internal int A;}
Что показывает замер
|
Ключ |
Комп 1 |
Комп 2 |
Комп 3 |
Комп 4 |
|
строка, число |
30,151 |
30,119 |
34,575 |
44,964 |
|
без ссылок внутри |
8,774 |
10,474 |
11,729 |
12,300 |
|
свой хеш |
6,143 |
4,592 |
5,908 |
6,483 |
|
record struct |
4,866 |
3,354 |
4,586 |
4,879 |
Микросекунды, .NET 10
Записи разложены по бакетам, а поиск всё равно медленнее в 6,2–9,2 раза.
Причина
Когда проверка не проходит, GetHashCode не просто берёт первое поле. Он уходит в нативный код рантайма, где поля типа перебираются заново, пока не найдётся первое подходящее. Результат этого поиска нигде не сохраняется, поэтому обход повторяется при каждом вызове. Это и есть разница между 30 и 3,4 микросекунды.
У ключа без ссылок внутри проверка проходит, и хеш считается по всем байтам структуры. Всё равно медленнее своего: 10,474 против 4,592.
Где легко ошибиться
Решить, что базовой реализации хватит, раз хеши получаются разными. Разные хеши убирают разницу в тысячи раз, но рефлексия при каждом вычислении хеша остаётся.
4. Как писать ключ-структуру
Исходник
Два рабочих варианта. Первый — объявить ключ записью, тогда сравнение и хеш напишет компилятор:
internal readonly record struct RecordKey(int A, string B);
Второй — переопределить GetHashCode:
public override int GetHashCode() => HashCode.Combine(A, B);
Что показывает замер
Построение словаря из 5000 записей. Тут набор меньше, чем в поиске, и вот почему: когда хеш у всех ключей одинаковый, каждая новая вставка сравнивается со всеми предыдущими. На 10 000 записей такой замер идёт минутами.
|
Ключ |
Комп 1 |
Комп 2 |
Комп 3 |
Комп 4 |
|
число, строка |
793 643,66 |
959 232,75 |
1 161 838,32 |
1 239 249,33 |
|
сравнение без хеша |
35 885,39 |
36 388,74 |
66 057,18 |
60 668,67 |
|
строка, число |
293,53 |
300,52 |
354,86 |
487,71 |
|
свой хеш |
152,21 |
140,60 |
205,36 |
242,26 |
|
record struct |
127,78 |
119,17 |
171,35 |
193,09 |
Микросекунды, .NET 10
От 0,79 до 1,24 секунды против ста двадцати микросекунд. Разница от 6211 до 8049 раз.
С памятью картина такая же. За 256 поисков по словарю на 10 000 записей:
|
Ключ |
Выделено |
|
число, строка |
195 638 375 байт |
|
строка, число |
47 104 байта |
|
без ссылок внутри |
18 432 байта |
|
сравнение без хеша |
8 192 байта |
|
свой хеш |
0 |
|
record struct |
0 |
Столбец Allocated, .NET 10, одинаково на четырёх машинах
Причина
Базовое сравнение работает через рефлексию и упаковывает поля. Умножьте на 10 000 записей в бакете — получите 195 миллионов байт на 256 поисков.
У ключа со своим хешем и своим сравнением в куче не появляется ничего.
Где легко ошибиться
Взять record struct и не заметить, что он readonly. Изменяемый ключ в словаре опасен сам по себе: после изменения запись больше не находится.
Границы замеров
Все словари построены из одного массива строк, строки готовятся заранее и в замер не входят. Словарям задана вместимость по числу записей, чтобы увеличения массива не попали в результат.
За один вызов замера поиска идёт 256 обращений, номера ключей взяты равномерно по всему набору. Все 256 обязаны найтись — это проверяет сверка.
Число в ключе одинаково у всех записей набора: так выглядит составной ключ, где первое поле — категория или тип. Если первое поле уникально, все хеши будут разными, но останется рефлексия из третьего раздела.
Все замеры сняты на x64 под Windows, на .NET 8, 9 и 10.
Код из статьи
-
KeyHashProof — замеры, отчёты и выгрузки с четырёх машин
Ссылки
Всем удачи и до новых встреч!
ссылка на оригинал статьи https://habr.com/ru/articles/1080782/