Но может ли этот ООП быть недостатком для программного обеспечения, основанного на производительности, то есть как быстро выполняется программа?
Часто да !!! НО...
Другими словами, может ли множество ссылок между многими различными объектами или использование многих методов из многих классов привести к «тяжелой» реализации?
Не обязательно. Это зависит от языка / компилятора. Например, оптимизирующий компилятор C ++, при условии, что вы не используете виртуальные функции, часто сводит ваши служебные данные к нулю. Вы можете сделать такие вещи, как написать обертку поверх int
там или ограниченный умный указатель над простым старым указателем, который работает так же быстро, как напрямую использует эти простые старые типы данных.
В других языках, таких как Java, есть некоторые накладные расходы на объект (часто довольно маленькие во многих случаях, но астрономические в некоторых редких случаях с действительно маленькими объектами). Например, Integer
это значительно менее эффективно, чем int
(занимает 16 байтов, а не 4 на 64-битной). Но это не просто вопиющая трата или что-то в этом роде. В обмен на это Java предлагает такие вещи, как отражение каждого отдельного пользовательского типа, а также возможность переопределить любую функцию, не отмеченную как final
.
Но давайте возьмем сценарий с лучшим вариантом: оптимизирующий компилятор C ++, который может оптимизировать интерфейс объектов до нуля . Даже в этом случае ООП часто ухудшает производительность и не дает ей достичь пика. Это может звучать как полный парадокс: как это может быть? Проблема заключается в:
Дизайн интерфейса и инкапсуляция
Проблема заключается в том, что даже когда компилятор может сократить структуру объекта до нуля (что, по крайней мере, очень часто справедливо для оптимизации компиляторов C ++), инкапсуляция и проектирование интерфейса (и накопленные зависимости) мелкозернистых объектов часто предотвращают наиболее оптимальные представления данных для объектов, которые предназначены для агрегирования массами (что часто имеет место для программного обеспечения, критичного к производительности).
Возьмите этот пример:
class Particle
{
public:
...
private:
double birth; // 8 bytes
float x; // 4 bytes
float y; // 4 bytes
float z; // 4 bytes
/*padding*/ // 4 bytes of padding
};
Particle particles[1000000]; // 1mil particles (~24 megs)
Допустим, наша схема доступа к памяти состоит в том, чтобы просто последовательно проходить по этим частицам и многократно перемещать их вокруг каждого кадра, отводя их от углов экрана и затем визуализируя результат.
Уже сейчас мы видим явные накладные расходы на 4 байта, необходимые для birth
правильного выравнивания элемента при непрерывной агрегации частиц. Уже ~ 16,7% памяти теряется из-за мертвого пространства, используемого для выравнивания.
Это может показаться спорным, потому что в наши дни у нас есть гигабайты DRAM. Тем не менее, даже самые ужасные машины, которые мы имеем сегодня, часто имеют всего 8 мегабайт, когда речь идет о самой медленной и самой большой области кэша ЦП (L3). Чем меньше мы вписываемся туда, тем больше мы платим за это с точки зрения повторного доступа к DRAM и тем медленнее получается. Внезапно потеря 16,7% памяти больше не кажется тривиальной сделкой.
Мы можем легко устранить эти издержки без какого-либо влияния на выравнивание поля:
class Particle
{
public:
...
private:
float x; // 4 bytes
float y; // 4 bytes
float z; // 4 bytes
};
Particle particles[1000000]; // 1mil particles (~12 megs)
double particle_birth[1000000]; // 1mil particle births (~8 bytes)
Теперь мы сократили объем памяти с 24 до 20 мегабайт. С последовательным шаблоном доступа, машина теперь будет потреблять эти данные немного быстрее.
Но давайте посмотрим на это birth
поле более внимательно. Допустим, он записывает время начала, когда частица рождается (создается). Представьте, что поле доступно только тогда, когда частица создается впервые, и каждые 10 секунд, чтобы увидеть, должна ли частица умереть и переродиться в случайном месте на экране. В таком случае birth
это холодное поле. К ним не обращаются наши циклы, критичные к производительности.
В результате фактические данные, критичные к производительности, составляют не 20 мегабайт, а фактически непрерывный блок размером 12 мегабайт. Реальная горячая память, к которой мы часто обращаемся, сократилась вдвое ! Ожидайте значительного ускорения по сравнению с нашим оригинальным 24-мегабайтным решением (не нужно измерять - уже проделали подобные вещи тысячу раз, но не стесняйтесь, если сомневаетесь).
Еще обратите внимание, что мы здесь сделали. Мы полностью нарушили инкапсуляцию этого объекта частицы. Его состояние теперь разделено между Particle
приватными полями типа и отдельным параллельным массивом. И вот тут мешает гранулярный объектно-ориентированный дизайн.
Мы не можем выразить оптимальное представление данных, если ограничиваться дизайном интерфейса одного, очень гранулированного объекта, такого как отдельная частица, один пиксель, даже один 4-компонентный вектор, возможно, даже один объект-существо в игре и т. д. Скорость гепарда будет потрачена впустую, если он будет стоять на маленьком острове площадью 2 кв. метра, и именно это часто делает с точки зрения производительности очень гранулированный объектно-ориентированный дизайн. Это ограничивает представление данных субоптимальным характером.
Для продолжения скажем, что поскольку мы просто перемещаем частицы, мы можем получить доступ к их полям x / y / z в трех отдельных циклах. В этом случае мы можем извлечь выгоду из встроенных функций SIMD в стиле SoA с регистрами AVX, которые могут векторизовать 8 операций SPFP параллельно. Но чтобы сделать это, мы должны теперь использовать это представление:
float particle_x[1000000]; // 1mil particle X positions (~4 megs)
float particle_y[1000000]; // 1mil particle Y positions (~4 megs)
float particle_z[1000000]; // 1mil particle Z positions (~4 megs)
double particle_birth[1000000]; // 1mil particle births (~8 bytes)
Теперь мы летим с симуляцией частиц, но посмотрите, что случилось с нашим дизайном частиц. Он был полностью снесен, и сейчас мы смотрим на 4 параллельных массива, и у нас нет объекта для их агрегирования. Наш объектно-ориентированный Particle
дизайн стал сайонарой.
Это случалось со мной много раз, когда я работал в критических для производительности областях, где пользователи требуют скорости, а единственная вещь, которую они требуют больше, - это корректность. Эти маленькие крошечные объектно-ориентированные проекты должны были быть снесены, и каскадные поломки часто требовали, чтобы мы использовали медленную стратегию устаревания для более быстрого проектирования.
Решение
Приведенный выше сценарий представляет проблему только с гранулированными объектно-ориентированными проектами. В этих случаях нам часто приходится сносить структуру, чтобы выразить более эффективные представления в результате повторений SoA, расщепления горячих / холодных полей, сокращения заполнения для шаблонов последовательного доступа (заполнение иногда полезно для производительности с произвольным доступом). шаблоны в случаях AoS, но почти всегда препятствие для последовательных шаблонов доступа) и т. д.
Тем не менее мы можем принять окончательное представление, на котором остановились, и по-прежнему моделировать объектно-ориентированный интерфейс:
// Represents a collection of particles.
class ParticleSystem
{
public:
...
private:
double particle_birth[1000000]; // 1mil particle births (~8 bytes)
float particle_x[1000000]; // 1mil particle X positions (~4 megs)
float particle_y[1000000]; // 1mil particle Y positions (~4 megs)
float particle_z[1000000]; // 1mil particle Z positions (~4 megs)
};
Теперь у нас все хорошо. Мы можем получить все объектно-ориентированные вкусности, которые нам нравятся. У гепарда есть целая страна, чтобы перебежать как можно быстрее. Наши интерфейсы больше не загоняют нас в узкое место.
ParticleSystem
потенциально может даже быть абстрактным и использовать виртуальные функции. Это спорный вопрос сейчас, мы платим за накладные расходы на сборе частиц уровень , а не на каждую частицу уровня. Затраты составляют 1/1 000 000 от того, что было бы иначе, если бы мы моделировали объекты на уровне отдельных частиц.
Так что это решение для действительно критичных для производительности областей, которые обрабатывают большую нагрузку, и для всех видов языков программирования (этот метод выгоден C, C ++, Python, Java, JavaScript, Lua, Swift и т. Д.). И это не может быть легко маркировано как «преждевременная оптимизация», так как это относится к дизайну интерфейса и архитектуре . Мы не можем написать кодовую базу, моделирующую одну частицу как объект с множеством клиентских зависимостей вParticle's
публичный интерфейс, а потом передумать. Я много делал это, когда меня вызывали для оптимизации унаследованных кодовых баз, и это может в конечном итоге занять месяцы тщательного переписывания десятков тысяч строк кода, чтобы использовать более объемный дизайн. Это идеально влияет на то, как мы проектируем вещи заранее, при условии, что мы можем предвидеть большую нагрузку.
Я продолжаю повторять этот ответ в той или иной форме во многих вопросах производительности, особенно тех, которые касаются объектно-ориентированного проектирования. Объектно-ориентированный дизайн все еще может быть совместим с самыми высокими требованиями к производительности, но мы должны немного изменить свой подход к нему. Мы должны дать этому гепарду немного места, чтобы бежать как можно быстрее, и это часто невозможно, если мы проектируем маленькие крошечные предметы, которые едва хранят какое-либо состояние.