Оптимизации с типами значений и вызовами методов с использованием ref

Я создаю простую систему частиц на C#/XNA, и, поскольку потенциально каждую секунду будет выполняться большое количество вызовов методов, я хотел убедиться, что точно понимаю, как все работает.

У меня есть класс Particle и Emitter, который:

public sealed class Emitter
{
    private struct Particle
    {
        public Vector2 Position;
        public Vector2 Velocity;
        public ushort Life;
        public bool Alive { get { return (Life > 0); } }
    }

    private readonly Particle[] _particles;

    public Emitter(ushort maxParticles)
    {
        _particles = new Particle[maxParticles];
    }
}

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

public static void UpdateParticle(Particle p)
{
    p.Position += p.Velocity;
}

Если бы я сделал Emitter со 100 000 частиц (как бы маловероятно это ни было), копирование частицы только для обновления, похоже, будет выполнять много ненужной работы. Если бы метод использовал ref вместо UpdateParticle(ref Particle p) { ... }, тогда он бы просто обращался к данным напрямую и обновлял их там, верно?

Однако, что касается ответа Джона Скита на этот вопрос, он пишет:

Вам почти никогда не нужно использовать ref/out. По сути, это способ получить другое возвращаемое значение, и его обычно следует избегать именно потому, что это означает, что метод, вероятно, пытается сделать слишком много. Это не всегда так (TryParse и т. д. являются каноническими примерами разумного использования out), но использование ref/out должно быть относительной редкостью.

Является ли это одним из тех «относительно редких» случаев, когда его использование является правильным выбором?

Что касается моего выбора дизайна для этого класса:

  • Некоторое время назад я исследовал систему частиц. Я не могу вспомнить точную причину создания Particle структуры — что-то о более быстром доступе к непрерывному фрагменту памяти или о меньшем количестве ссылок на объекты — но бенчмаркинг доказывает, что он работает лучше, чем класс.

  • Particle — это закрытый тип, потому что класс Emitter — это единственное, что когда-либо будет заботиться о нем. Всегда. Клиентский код никогда не должен заботиться об отдельных частицах, только о том, что Emitter создает красивые блестки и визуализирует их.

  • _particles — это фиксированный размер, действующий как пул объектов, потому что повторное использование выделенной памяти должно быть более чувствительным к производительности, чем вызов new().


person Kyle Baran    schedule 18.01.2014    source источник


Ответы (1)


Ваш тип Particle будет иметь стоимость памяти не менее 20 байт.

Если вы не используете ref, ваш метод будет выглядеть так:

public static Particle UpdateParticle(Particle p)
{
    p.Position += p.Velocity;
    return p;
}

Если вы сделаете это, вы добавите копию Particle для передачи Particle методу UpdateParticle() и копию Particle для возврата копии. Поскольку копирование представляет собой чтение и запись, копия частицы требует 40 байт доступа к памяти (и 2 копии представляют 80 байт доступа).

Обновление 100 000 частиц без использования ref требует копирования около 8 000 000 байт.

У обычных процессоров Core i5/i7 пропускная способность памяти составляет от 15 до 40 ГБ/с. Накладные расходы на доступ к памяти при неиспользовании ref можно оценить как 8/20 000 = 0,4 мс. С моим Core 2 с пропускной способностью памяти 6 Гбит/с: 8 / 6 000 = 1,3 мс)

Чтобы измерить дельту, вам нужно иметь дело как минимум с 10 000 000 частиц (в 100 раз больше = 130 мс на моем компьютере).

На моем компьютере измерения показывают около 600 мс для версии копии и около 200 мс для версии ref.

Я также добавил метод-член Update() в структуру Particle, и обновление 10 000 000 частиц с помощью этого метода также занимает около 200 мс.

Наконец, я попытался выполнить операцию добавления непосредственно в цикле (без вызовов методов). Это заняло всего около 150 мс (прирост 50 мс)

Суммарная дельта в 400 мс (чуть меньше половины секунды) почти в 3 раза больше, чем полоса пропускания одной памяти с накладными расходами.

Эти дополнительные накладные расходы связаны с JIT-компилятором.

Наконец, если использование ref не рекомендуется в .NET Framework, очень интересно использовать относительно большие структуры.

В C++ вы можете получить гораздо лучшую производительность. Мы можем ожидать, что в будущем интеллектуальный компилятор C# будет обеспечивать лучшую производительность (особенно с автоматическим встраиванием методов, которое может устранить накладные расходы на копирование памяти и вызовы методов).

В C# строгая изоляция ссылочных типов (классов) и типов значений (структур) проблематична, когда вы имеете дело с огромным количеством экземпляров: 1) используя классы, вы сталкиваетесь с длительным выделением памяти унитарной кучи. 2) используя структуру, вы сталкиваетесь с длительным временем автоматического копирования.

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

person Renaud Bancel    schedule 18.01.2014