Если вы C# разработчик, то наверняка вам знаком класс Dictionary. В качестве значений вы, скорее всего, использовали классы. Но что если я скажу, что в Dictionary можно использовать структуры? Не стоит бояться того, что структуры копируются при передаче в метод или возврате из него. Есть способ этого избежать, и это работает быстро.

Дисклеймер

Информация в этой статье верна только при определённых условиях. Я допускаю, что бенчмарк может показать другие результаты на другом железе, с другим компилятором, во время другой фазы луны или при другом сценарии использования рассматриваемого функционала языка. Всегда проверяйте ваш код и не полагайтесь лишь на статьи из интернета.

Сценарий использования

Представим, что есть некоторый массив данных data. От нас требуется реализовать следующий функционал:

  1. Преобразовать data в словарь для последующего поиска.

  2. По некоторому ключу найти в словаре объект и изменить его.

  3. Повторить п. 2 столько раз, сколько требуется.

Напишем код, который имитирует такое поведение:

// Инициализация словаря
var dict = new Dictionary<int, SomeClass>(Length);

// Заполнение словаря
foreach (var (i, obj) in data) {
    dict.Add(i, obj);
}

// Поиск значения и изменение данных
for (int i = 0; i < Length; i++) {
    dict[i].DoWork(i);
}

Код выше работает как задумано. Давайте попробуем его ускорить. Заменим класс SomeClass на структуру SomeStruct и сравним производительность обоих вариантов.

// Инициализация словаря
var dict = new Dictionary<int, SomeStruct>(Length);

// Заполнение словаря
foreach (var (i, obj) in data) {
   dict.Add(i, obj);
}

// Поиск значения и изменение данных
for (int i = 0; i < Length; i++) {
    var obj = dict[i];
    obj.DoWork(i);
    dict[i] = obj;
}

Бенчмарк

Замер производительности осуществлялся на массиве данных в 100 000 элементов. Размер классов (без заголовка) и структур менялся от 4 до 128 байт. Для замеров производительности я использовал библиотеку BenchmarkDotNet. Код бенчмарка и результаты можно найти в GitHub.

Среднее время выполнения бенчмарка в зависимости от размера сущности
Среднее время выполнения бенчмарка в зависимости от размера сущности

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

Инициализация словаря

Бенчмарк показал ожидаемые результаты. Время инициализации и размер словаря со структурами линейно возрастают с увеличением размера структур.

Среднее время инициализации словаря в зависимости от размера сущности
Среднее время инициализации словаря в зависимости от размера сущности

Связано это с тем, что массив entries в таком случае хранит непосредственно значения, а не ссылки. Соответственно, для хранения такого словаря нужно банально больше памяти.

Справедливости ради нужно отметить, что для классов CLR выделила памяти даже больше, просто это произошло ранее -- во время инициализации массива data. Если замерять время, затраченное на инициализацию массива классов и структур, то результаты будут не в пользу классов. Но это выходит за рамки статьи.

Заполнение словаря

И снова ожидаемые результаты. Время копирования структур, происходящее при заполнении словаря, линейно зависит от размера структур. Хотя разница между структурами и классами практически не заметна вплоть до 20 байт.

Среднее время заполнения словаря в зависимости от размера сущности
Среднее время заполнения словаря в зависимости от размера сущности

Поиск значения и его изменение

И в третий раз результаты не в пользу структур.

Среднее время поиска значения и его изменения в зависимости от размера сущности

Связано это с тем, что поиск по ключу и копирование структур осуществляется дважды:

SomeStruct s = dict[i]; // 1-й поиск по ключу и копирование структуры
s.DoWork(i);
d[i] = s; // 2-й поиск по ключу и копирование структуры

Вот тут нам и поможет класс CollectionsMarshal.

Кто такой этот ваш CollectionsMarshal?

Если кратко, то это класс с четырьмя extension-методами:

  1. AsSpan<T> -- возвращает Span<T> для элементов List<T>.

  2. GetValueRefOrAddDefault<TKey, TValue> -- по ключу возвращает из словаря ссылку на элемент TValue, создавая default значение если элемента не существует.

  3. GetValueRefOrNullRef<TKey, TValue> -- по ключу возвращает из словаря ссылку на элемент TValue или ссылку на null, если элемента не существует.

  4. SetCount<T> -- устанавливает значение Count для List<T>.

Нас интересуют только GetValueRefOrAddDefault и GetValueRefOrNullRef. При помощи этих методов можно извлечь значения из словаря по ссылке, что позволит избежать двойного поиска по ключу и двойного копирования структуры. Например, код выше можно переписать следующим образом:

ref SomeStruct s = ref CollectionsMarshal.GetValueRefOrNullRef(dict, i);
s.DoWork(i);

Ещё немного бенчмарков

Сделаем замеры реализации с GetValueRefOrNullRef и сравним с предыдущими результатами:

Среднее время поиска значения и его изменения в зависимости от размера сущности

Время выполнения кода с CollectionsMarshal даже быстрее, чем с классами. Чтобы компенсировать потери производительности при инициализации и заполнении словаря, количество операций поиска должно быть многократно больше, чем размер массива.

Среднее время выполнения бенчмарка. Графики разбиты по количеству операций поиска
Среднее время выполнения бенчмарка. Графики разбиты по количеству операций поиска

Особенности CollectionsMarshal

Проверка на default и null

Как упоминалось ранее, методы GetValueRefOrAddDefault и GetValueRefOrNullRef возвращают ссылку на default структуру и ссылку на null.

Проверить, дефолтная ли структура, т.е. все поля имеют дефолтное значение, довольно просто -- нужно проверить флаг exists:

ref var element = ref CollectionsMarshal.GetValueRefOrAddDefault(dict, key, out bool exist);

if (exist) {
   // some code here
}

Со ссылкой на null ситуация другая. Булевого флага нет, а при сравнении с null будет выброшено исключение NullReferenceException. Лучше воспользоваться методом Unsafe.IsNullRef<T>(ref T source).

ref var element = ref CollectionsMarshal.GetValueRefOrNullRef(dict, key);

if (Unsafe.IsNullRef<T>(ref element)) {
   // some code here
}

Изменение словаря после получения ссылки на структуру

В документации к методам GetValueRefOrAddDefault и GetValueRefOrNullRef прямым текстом указано, что нельзя изменять словарь после того, как была получена ссылка на структуру. Почему так делать не надо продемонстрировано на примере ниже. После изменения словаря, любые изменения структуры, полученной по ссылке, не повлияют на значение в словаре.

ref var element = ref CollectionsMarshal.GetValueRefOrNullRef(dict, key);

Console.WriteLine($"ref element: {element.Item1}"); // 30
Console.WriteLine($"dict[key]: {dict[key].Item1}"); // 30

element.Item1 = 50; // change #1

Console.WriteLine($"ref element: {element.Item1}"); // 50
Console.WriteLine($"dict[key]: {dict[key].Item1}"); // 50

dict.Add(100, new (100)); // add a new element
element.Item1 = 60; // change #2

Console.WriteLine($"ref element: {element.Item1}"); // 60
Console.WriteLine($"dict[key]: {dict[key].Item1}"); // 50

Выводы

Структуры -- недооценённые элементы C#, которые, при определённых условиях, способны ускорить ваше приложение. При использовании структур в качестве значений для Dictionary лучше воспользоваться классом CollectionsMarshal. Методы этого класса GetValueRefOrAddDefault и GetValueRefOrNullRef позволяют получать элементы словаря по ссылке. Это, в свою очередь, может положительно сказаться на производительности кода при относительно большом количестве операций поиска в словаре.

Комментарии (21)


  1. Aquahawk
    12.04.2024 05:59
    +3

    какой отвратительный дизайн, вот как об этой вещи должен узнать разработчик? Тут https://learn.microsoft.com/en-us/dotnet/api/system.collections.generic.dictionary-2?view=net-8.0 нет ни слова. У Рихтера тоже.


    1. mayorovp
      12.04.2024 05:59
      +5

      А большинству разработчиков эта фича ни разу не понадобится.

      Спрятана же она потому, что возвращённые ссылки, фактически, инвалидируются при добавлении или удалении элементов из словаря, в то время как способа выразить это в API нету. Я бы тоже не стал держать в основном пронстранстве имён API, которое настолько легко использовать неправильно.

      И вообще, нефиг делать такие большие структуры. 3-4 ссылочных поля - предел, за которым от структуры становится больше проблем чем пользы.


      1. Aquahawk
        12.04.2024 05:59
        +1

        а какой тогда смысл делать "секретную" фичу, и не писать о ней в документации? Как разработчик, которому это потребовалось вообще должен вообще об этом узнать?


        1. mayorovp
          12.04.2024 05:59
          +1

          Ну автор же откуда-то про эту фичу знал? Теперь вот мы тоже знаем.


          1. Aquahawk
            12.04.2024 05:59
            +2

            Я вот теперь тоже откуда-то узнал. Но почему функционал предназначенный для работы с диктами не упомянут в референсе на дикты? Имхо это провал архитектуры и документации.


            1. mvv-rus
              12.04.2024 05:59
              +1

              Этот функционал предназначен для взаимодействия с кодом, выполняющимся не под управлением .NET. И находится там же, где всё подобное - в System.InteropServices.

              В обычном (управляемом) коде .NET его лучше не использовать - как например и указатели, ровно по тем же причинам: unsafe.


              1. Aquahawk
                12.04.2024 05:59
                +1

                лучше - не лучше должен решать разработчик с головой на плечах, но в доке по диктам этот способ должен быть указан. Хотя он оказывается очень свежий, поэтому думаю если появится новое издание Рихтера он там будет


          1. alexeyfv Автор
            12.04.2024 05:59
            +2

            Случайно обнаружил вот это issue https://github.com/dotnet/runtime/issues/27062, когда гуглил как структуры подружить со словарём. :)


  1. Kerman
    12.04.2024 05:59
    +6

    Если мы получаем от словаря ссылку на структуру и потом работаем по ссылке, то чем, собственно говоря, это отличается от класса?

    Да, есть небольшой выигрыш за счёт того, что ссылки не считаются и ансейф во все поля. Но этот выигрыш чувствителен только для сценария с большим количеством поисков и не пополняющимся словарём. Для такого сценария существуют контейнеры получше, вроде FrozenDictionary. А то и бинарное дерево.

    И сравнение считаю некорректным. Структуры у вас с ружьём, направленным в ногу, а классы используют стандартный сейф механизм.

    И ещё. Не нашёл в коде объявления GetHash и Equals. Может у вас хэш коллизит и поиск деградировал до линейного.


    1. mayorovp
      12.04.2024 05:59
      +3

      Так ключом в словаре является int, у него GetHashCode и Equals свои есть


      1. mayorovp
        12.04.2024 05:59
        +2

        Кто-то не верит, что там ключ int? Смотрим исходники: https://github.com/alexeyfv/speed-up-the-dictionary/blob/0f6ce393fe2fb7e32caef579640749e4dc2b18f2/CodeGen/SourceGenerator.cs#L235-L245

                private readonly string _createDictionaryBenchmarkTemplate =
        @"    Dictionary<int, {0}{1}> _dict{0}{1} = new(Length);
            {0}{1}[] _array{0}{1} = new {0}{1}[Length];
        
            [Benchmark]
            [BenchmarkCategory(""{1}"")]
            public Dictionary<int, {0}{1}> {0}{1}DictCreate()
            {{
                _dict{0}{1} = new(Length);
                return _dict{0}{1};
            }}";
        


    1. sdramare
      12.04.2024 05:59

      Если мы получаем от словаря ссылку на структуру и потом работаем по ссылке, то чем, собственно говоря, это отличается от класса?

      В таком сценарии скорее всего все данные сразу попаду на кэш-линию, по-этому код будет работать быстрее.


    1. alexeyfv Автор
      12.04.2024 05:59

      чем, собственно говоря, это отличается от класса

      Да, есть небольшой выигрыш за счёт того, что ссылки не считаются и ансейф во все поля

      Вы сами ответили на вопрос. :)

      Если рассматривать только то, что приведено в статье, то да, выигрыш небольшой. Если смотреть шире, как структуры влияют на всё приложение, то за счёт их использования можно ускорить работу и снизить потребление памяти. Данные в массиве data из примера же не берутся из воздуха. CLR в какой-то момент времени 100 000 аллоцировала память под 100 000 экземпляров классов + 1 раз для словаря. В случае со структурами, аллокация произошла только для словаря. Что быстрее: аллоцировать 100 001 раз память в куче или всего 1 раз? Но, опять же, всё зависит от сценария. Я не утверждаю, что рассмотренный мною способ единственно правильный.


    1. alexeyfv Автор
      12.04.2024 05:59

      По поводу FrozenDictionary вы правы, работает быстрее.

      | Method                       | Categories | Repeats | Mean     | Error    | StdDev   | Gen0     | Gen1     | Gen2     | Allocated |
      |----------------------------- |----------- |-------- |---------:|---------:|---------:|---------:|---------:|---------:|----------:|
      | FrozenDictionary             | Class      | 100000  | 24.23 ms | 0.327 ms | 0.376 ms | 437.5000 | 437.5000 | 437.5000 |   7.29 MB |
      | Dictionary                   | Struct     | 100000  | 69.79 ms | 1.072 ms | 1.235 ms | 375.0000 | 375.0000 | 375.0000 |   4.14 MB |
      | DictionaryCollectionsMarshal | Struct     | 100000  | 34.22 ms | 2.077 ms | 2.392 ms | 428.5714 | 428.5714 | 428.5714 |   4.14 MB |

      Опять же, выбор будет зависеть от сценария. Если создавать словарь нужно относительно редко (т.е. аллоцировать память для экземпляров классов), то это хорошее решение. Если же, например, на каждый запрос нужно достать из БД какие-то данные, преобразовать в словарь, потом осуществить поиск, то структуры + CollectionsMarshall, думаю, будут быстрее.


      1. AgentFire
        12.04.2024 05:59
        +1

        А точно ли со всей этой махиной "обрабатываем входящий запрос -> наполняем словарь -> лезем в БД через ORM" выигрыш в class->struct будет так сильно заметен по аллокациям?


        1. alexeyfv Автор
          12.04.2024 05:59

          Нет, это не точно. Пока не сделать конкретные замеры и не показать результаты, нельзя сказать, что один подход быстрее другого. И то, результаты будут валидны с определёнными оговорками. Так что пока мы можем только теоретически рассуждать, что мы и делаем. :)


  1. Onni
    12.04.2024 05:59

    Я давно видел эту штуку. Но никогда не проверял, что буде если:

    ref var myRef = ref CollectionsMarshal.GetValueRefOrNullRef(dict, ket);
    dict.Add(...); // тут происходит рост коллекции
    myRef = value; // куда сейчас указывает myRef?


    1. mayorovp
      12.04.2024 05:59

      Либо на актуальную ячейку, либо в мусорный массив с копией старых данных, а куда точно - не определено.

      Интереснее результат можно получить если удалить ключ из словаря.


      1. Onni
        12.04.2024 05:59

        Проглядел что вы об написали об этом. С одной стороны ожидаемо, но я в тайне надеялся что есть какая-то магия.


      1. mvv-rus
        12.04.2024 05:59

        Интереснее результат можно получить если удалить ключ из словаря.

        Причем, возможно - если удалить совсем другой ключ;-). Вы не посмотрели когда туда лазили, не открытая ли адресация там используется?


        1. mayorovp
          12.04.2024 05:59

          Нет, там используются односвязные списки на массивах. Но даже и при открытой адресации никаких специфичных для неё эффектов не было бы.