Подтвердить что ты не робот

Почему назначение элемента массива ухудшает производительность программы на С#?

Резюме

При обработке большого текстового файла я столкнулся с следующей (неожиданной) деградацией производительности, которую я не могу объяснить. Мои цели для этого вопроса:

  • Понять, что вызывает замедление, описанное ниже
  • Узнайте, как быстро инициализировать большие непримитивные массивы.

Проблема

  • Массив содержит не примитивные ссылочные позиции
    • Не int[], но MyComplexType[]
    • MyComplexType - это класс, а не структура
    • MyComplexType содержит некоторые свойства string
  • Массив предварительно выделен
  • Массив большой
  • Если элемент создан и используется без назначения массиву, программа выполняется быстро
  • Если элемент создан, а затем назначен элементу массива, программа значительно замедляется
    • Я ожидал, что назначение элемента массива будет простым ссылочным назначением, но это не похоже на случай, основанный на моих результатах для моей тестовой программы ниже

Программа тестирования

Рассмотрим следующую программу C#:

namespace Test
{
    public static class Program
    {
        // Simple data structure
        private sealed class Item
        {
            public Item(int i)
            {
                this.Name = "Hello " + i;
                //this.Name = "Hello";
                //this.Name = null;
            }
            public readonly string Name;
        }

        // Test program
        public static void Main()
        {
            const int length = 1000000;
            var items = new Item[length];

            // Create one million items but don't assign to array
            var w = System.Diagnostics.Stopwatch.StartNew();
            for (var i = 0; i < length; i++)
            {
                var item = new Item(i);
                if (!string.IsNullOrEmpty(item.Name)) // reference the item and its Name property
                {
                    items[i] = null; // do not remember the item
                }
            }
            System.Console.Error.WriteLine("Without assignment: " + w.Elapsed);

            // Create one million items and assign to array
            w.Restart();
            for (var i = 0; i < length; i++)
            {
                var item = new Item(i);
                if (!string.IsNullOrEmpty(item.Name)) // reference the item and its Name property
                {
                    items[i] = item; // remember the item
                }
            }
            System.Console.Error.WriteLine("   With assignment: " + w.Elapsed);
        }
    }
}

Он содержит две почти одинаковые петли. Каждый цикл создает миллион экземпляров класса Item. Первый цикл использует созданный элемент, а затем отбрасывает ссылку (не сохраняя ее в массиве items). Второй цикл использует созданный элемент, а затем сохраняет ссылку в массиве items. Назначение элемента массива - это единственное отличие между циклами.

Мои результаты

  • Когда я запускаю Release build (оптимизация включена) на моем компьютере, я получаю следующие результаты:

    Without assignment: 00:00:00.2193348
       With assignment: 00:00:00.8819170
    

    Цикл с назначением массива значительно медленнее, чем тот, у которого нет назначения (~ 4 раза медленнее).

  • Если я изменяю конструктор Item, чтобы назначить константную строку свойству Name:

    public Item(int i)
    {
        //this.Name = "Hello " + i;
        this.Name = "Hello";
        //this.Name = null;
    }
    

    Получаю следующие результаты:

    Without assignment: 00:00:00.0228067
       With assignment: 00:00:00.0718317
    

    Петля с назначением все еще ~ 3 раза медленнее, чем без

  • Наконец, если я присвою null свойству Name:

    public Item(int i)
    {
        //this.Name = "Hello " + i;
        //this.Name = "Hello";
        this.Name = null;
    }
    

    Получаю следующий результат:

    Without assignment: 00:00:00.0146696
       With assignment: 00:00:00.0105369
    

    После того, как строка не выделена, версия без назначения, наконец, немного медленнее (я предполагаю, что все эти экземпляры выпущены для сбора мусора)

Вопросы

  • Почему назначение элемента массива так сильно замедляет тестовую программу?

  • Есть ли конструкция атрибута/языка/etc, которая ускорит назначение?

PS: Я попытался изучить замедление с помощью dotTrace, но это было неубедительно. Одна вещь, которую я видел, - это намного больше операций копирования строк и сбора мусора в цикле с назначением, чем в цикле без назначения (хотя я и ожидал обратного).

4b9b3361

Ответ 1

Я подозреваю, что большинство проблем с синхронизацией связаны с распределением памяти.

Когда вы назначаете элементы в массив, они никогда не становятся пригодными для сбора мусора. Когда у вас есть строка как свойство, которая не является константой (интернированной) или нулевой, это приведет к увеличению требований к распределению памяти.

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

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

Что касается вашей окончательной проверки - когда вы устанавливаете Name на null, проверка if (!string.IsNullOrEmpty(item.Name)) не позволит ее добавить. Таким образом, два пути кода и, следовательно, тайминги становятся (эффективно) идентичными, хотя первый немного медленнее (скорее всего, из-за запуска JIT в первый раз).

Ответ 2

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

Я предполагаю, что это согласуется с тем, что вы видели в отношении сбора мусора. Один элемент создается в первом цикле против многих.

Быстрая заметка - первый цикл, вероятно, использует объединение объектов в качестве оптимизации. Эта статья может дать представление. Как быстро говорит Рид, в статье рассказывается об оптимизации приложений, но я полагаю, что сам распределитель имеет множество оптимизаций, которые делают подобные вещи.

Ответ 3

Я не верю, что это имеет смысл (действительно) с назначением массива. Это связано с количеством времени, в течение которого элемент и его содержащиеся объекты должны храниться вокруг, на всякий случай, когда вы сможете позже ссылаться на них. Это связано с распределением кучи и сбором мусора.

Когда сначала выделяется item, а строки будут в "генерации 0". Это часто сбор мусора и очень жаркий, возможно, даже кешированный, память. Очень вероятно, что на следующих нескольких итерациях цикла все "поколение 0" будет GC'ed, а память будет повторно использована для новых items и их строк. Когда мы добавляем присваивание массиву, объект не может быть собран из мусора, потому что есть ссылка на него. Это приводит к увеличению потребления памяти.

Я считаю, что вы увидите увеличение памяти во время выполнения вашего кода: я считаю, что проблема связана с распределением памяти в куче в сочетании с пропуском кеша, поскольку она всегда должна использовать "свежую" память и не может извлечь выгоду из аппаратного обеспечения кэширование памяти.

Ответ 4

Попробовать и решить вашу реальную проблему (хотя это была интересная головоломка для решения). Я бы порекомендовал несколько вещей:

  • Не храните конкатенированные строки при построении. Используйте get accessor, чтобы вернуть строковые значения. Это приводит к конкатенации строк из изображения при диагностировании распределения массива. Если вы хотите "кэшировать" вычисленное значение при первом get, это должно быть ОК.
  • Запустите dotTrace против вашей реальной программы, чтобы лучше понять, где тратится время. Поскольку вы ничего не можете сделать, чтобы ускорить выделение массива, вы должны взглянуть на другие области, которые вы можете изменить.

Ответ 5

По-моему, вы являетесь жертвой предсказания ветки. Давайте подробно рассмотрим, что вы делаете:

В случае "Без указания" вы просто назначаете null для всех элементов массива элементов; тем самым, после нескольких итераций цикла for процессор узнает, что вы назначаете такое же значение (даже null) для элементов массива; поэтому инструкция if больше не нужна: ваша программа будет работать быстрее.

В случае "С назначением" процессор не имеет представления о прогрессировании новых сгенерированных элементов: оператор if вызывается каждая итерация цикла для; это приводит к замедлению работы программы...

Это поведение зависит от части процессорного оборудования, называемого Unit Prediction Unit (потребляющего значительную часть транзисторов чипа...). Аналогичная тема хорошо иллюстрируется здесь Почему быстрее ли обрабатывать отсортированный массив, чем несортированный массив?

Ответ 6

Хорошо, я все еще ищу, но MSDN предлагает использовать коллекцию (предположительно List<T> или HashTable<T> или что-то подобное), а не массив. Из документации MSDN:

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

Возможно, что-то есть в документах спецификации .NET.