.NET 8 ToFrozenDictionary: Dictionary против FrozenDictionary
Преобразуйте Dictionary в FrozenDictionary с помощью `ToFrozenDictionary()` в .NET 8 для более быстрого чтения. Бенчмарк, когда применять и компромисс по времени сборки.
В .NET 8 появляется новый тип словаря, который повышает производительность операций чтения. Подвох: после создания коллекции вы не можете вносить изменения в её ключи и значения. Этот тип особенно полезен для коллекций, которые заполняются при первом использовании и затем сохраняются на всё время жизни долгоживущего сервиса.
Посмотрим, что это значит в цифрах. Меня интересуют две вещи:
- производительность создания словаря, поскольку работа по оптимизации чтения, скорее всего, повлияет на это
- производительность чтения произвольного ключа из списка
Влияние на производительность при создании
Для этого теста мы берём 10 000 заранее созданных экземпляров KeyValuePair<string, string> и создаём три различных типа словарей:
- обычный словарь:
new Dictionary(source) - замороженный словарь:
source.ToFrozenDictionary(optimizeForReading: false) - и замороженный словарь, оптимизированный для чтения:
source.ToFrozenDictionary(optimizeForReading: true)
И мы измеряем, сколько времени занимает каждая из этих операций, с помощью BenchmarkDotNet. Вот результаты:
| Method | Mean | Error | StdDev |
|------------------------------------ |-----------:|---------:|---------:|
| Dictionary | 284.2 us | 1.26 us | 1.05 us |
| FrozenDictionaryNotOptimized | 486.0 us | 4.71 us | 4.41 us |
| FrozenDictionaryOptimizedForReading | 4,583.7 us | 13.98 us | 12.39 us |
Уже без оптимизации мы видим, что создание FrozenDictionary занимает примерно вдвое больше времени, чем создание обычного словаря. Но настоящий эффект проявляется при оптимизации данных для чтения. В этом сценарии мы получаем рост в 16x. Стоит ли оно того? Насколько быстро происходит чтение?
Производительность чтения замороженного словаря
В этом первом сценарии, где мы тестируем извлечение одного ключа из ‘середины’ словаря, мы получаем следующие результаты:
| Method | Mean | Error | StdDev |
|------------------------------------ |----------:|----------:|----------:|
| Dictionary | 11.609 ns | 0.0170 ns | 0.0142 ns |
| FrozenDictionaryNotOptimized | 10.203 ns | 0.0218 ns | 0.0193 ns |
| FrozenDictionaryOptimizedForReading | 4.789 ns | 0.0121 ns | 0.0113 ns |
По сути, FrozenDictionary оказывается в 2.4x быстрее обычного Dictionary. Заметное улучшение!
Важно обратить внимание на различия в единицах измерения. При создании времена находятся в диапазоне микросекунд, и в общей сумме мы теряем около 4299 us (микросекунд). В пересчёте на ns (наносекунды) это 4 299 000 ns. То есть, чтобы получить выгоду в производительности от использования FrozenDictionary, нужно выполнить как минимум 630 351 операций чтения. Это очень много чтений.
Рассмотрим ещё несколько тестовых сценариев и их влияние на производительность.
Сценарий 2: маленький словарь (100 элементов)
Соотношения, похоже, остаются прежними и при работе с меньшим словарём. С точки зрения соотношения затрат и выгоды, выигрыш появляется немного раньше, после примерно 4800 операций чтения.
| Method | Mean | Error | StdDev |
|------------------------------------------- |----------:|----------:|----------:|
| Dictionary_Create | 1.477 us | 0.0033 us | 0.0028 us |
| FrozenDictionaryOptimizedForReading_Create | 31.922 us | 0.1346 us | 0.1259 us |
| Dictionary_Read | 10.788 ns | 0.0156 ns | 0.0122 ns |
| FrozenDictionaryOptimizedForReading_Read | 4.444 ns | 0.0155 ns | 0.0129 ns |
Сценарий 3: чтение ключей из разных позиций
В этом сценарии мы проверяем, влияет ли каким-либо образом на производительность тот ключ, который мы извлекаем (его положение во внутренней структуре данных). Судя по результатам, на производительность чтения это не влияет вовсе.
| Method | Mean | Error | StdDev |
|------------------------------------------- |----------:|----------:|----------:|
| FrozenDictionaryOptimizedForReading_First | 4.314 ns | 0.0102 ns | 0.0085 ns |
| FrozenDictionaryOptimizedForReading_Middle | 4.311 ns | 0.0079 ns | 0.0066 ns |
| FrozenDictionaryOptimizedForReading_Last | 4.314 ns | 0.0180 ns | 0.0159 ns |
Сценарий 4: большой словарь (10 миллионов элементов)
В случае больших словарей производительность чтения остаётся практически такой же. Мы видим увеличение времени чтения на 18 %, несмотря на увеличение размера словаря в 1000x. Однако целевое количество чтений, необходимых для получения чистого выигрыша в производительности, существенно вырастает до 2 135 735 439, то есть более 2 миллиардов чтений.
| Method | Mean | Error | StdDev |
|------------------------------------------- |------------:|----------:|----------:|
| Dictionary_Create | 905.1 ms | 2.56 ms | 2.27 ms |
| FrozenDictionaryOptimizedForReading_Create | 13,886.4 ms | 276.22 ms | 483.77 ms |
| Dictionary_Read | 11.203 ns | 0.2601 ns | 0.3472 ns |
| FrozenDictionaryOptimizedForReading_Read | 5.125 ns | 0.0295 ns | 0.0230 ns |
Сценарий 5: сложный ключ
Здесь результаты очень интересные. Наш ключ выглядит так:
public class MyKey
{
public string K1 { get; set; }
public string K2 { get; set; }
}
И, как видим, в этом случае почти нет улучшений производительности при чтении по сравнению с обычным Dictionary, при этом создание словаря примерно в 4 раза медленнее.
| Method | Mean | Error | StdDev |
|------------------------------------------- |---------:|----------:|----------:|
| Dictionary_Create | 247.7 us | 3.27 us | 3.05 us |
| FrozenDictionaryOptimizedForReading_Create | 991.2 us | 8.75 us | 8.18 us |
| Dictionary_Read | 6.344 ns | 0.0602 ns | 0.0533 ns |
| FrozenDictionaryOptimizedForReading_Read | 6.041 ns | 0.0954 ns | 0.0845 ns |
Сценарий 6: использование record
А что если использовать record вместо class? Это должно дать прирост производительности, верно? По всей видимости, нет. Это даже более странно, поскольку времена чтения подскакивают с 6 ns до 44 ns.
| Method | Mean | Error | StdDev |
|------------------------------------------- |-----------:|---------:|---------:|
| Dictionary_Create | 654.1 us | 2.29 us | 2.14 us |
| FrozenDictionaryOptimizedForReading_Create | 1,761.4 us | 8.67 us | 8.11 us |
| Dictionary_Read | 45.37 ns | 0.088 ns | 0.082 ns |
| FrozenDictionaryOptimizedForReading_Read | 44.44 ns | 0.120 ns | 0.107 ns |
Выводы
На основе протестированных сценариев единственное улучшение, которое мы увидели, было при использовании ключей типа string. Всё остальное, что мы пробовали до сих пор, давало ту же производительность чтения, что и обычный Dictionary, с дополнительными накладными расходами на создание.
Даже когда вы используете string в качестве ключа вашего FrozenDictionary, нужно учитывать, сколько чтений вы планируете выполнить за время жизни этого словаря, поскольку с его созданием связаны накладные расходы. В тесте на 10 000 элементов эти накладные расходы составили около 4 299 000 ns. Производительность чтения улучшилась в 2.4x, со снижением с 11.6 ns до 4.8 ns, но это всё равно означает, что вам нужно примерно 630 351 операций чтения по словарю, прежде чем появится чистый выигрыш в производительности.
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.