Start Debugging
2024-04-27 Обновлено 2025-03-27 dotnetdotnet-8 Edit on GitHub

.NET 8 ToFrozenDictionary: Dictionary против FrozenDictionary

Преобразуйте Dictionary в FrozenDictionary с помощью `ToFrozenDictionary()` в .NET 8 для более быстрого чтения. Бенчмарк, когда применять и компромисс по времени сборки.

В .NET 8 появляется новый тип словаря, который повышает производительность операций чтения. Подвох: после создания коллекции вы не можете вносить изменения в её ключи и значения. Этот тип особенно полезен для коллекций, которые заполняются при первом использовании и затем сохраняются на всё время жизни долгоживущего сервиса.

Посмотрим, что это значит в цифрах. Меня интересуют две вещи:

Влияние на производительность при создании

Для этого теста мы берём 10 000 заранее созданных экземпляров KeyValuePair<string, string> и создаём три различных типа словарей:

И мы измеряем, сколько времени занимает каждая из этих операций, с помощью 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.

< Назад