Оптимизация порядка переменных-членов в C++

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

" переупорядочить переменные-члены a класс в наиболее используемые и наименее используемые."

Я не знаком с C++, ни с тем, как он компилирует, но мне было интересно, если

  1. это утверждение точный?
  2. Как/Почему?
  3. применяется ли он к другим (скомпилированным/скриптовым) языкам?

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

11 ответов


отсюда два вопроса:

  • является ли и при сохранении определенных полей вместе оптимизация.
  • как это сделать на самом деле сделать это.

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

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

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

обратите внимание, что здесь есть некоторые gotchas. Если вы используете атомарные операции на основе ЦП (что обычно делают атомарные типы В C++0x), то вы можете обнаружить, что ЦП блокирует всю строку кэша, чтобы заблокировать поле. Тогда, если вы если несколько атомарных полей расположены близко друг к другу, а разные потоки работают на разных ядрах и одновременно работают на разных полях, вы обнаружите, что все эти атомарные операции сериализуются, потому что все они блокируют одно и то же место памяти, даже если они работают на разных полях. Если бы они работали на разных линиях кэша, они работали бы параллельно и работали быстрее. На самом деле, как указывает Глен (через Херба Саттера) в своем ответе, на когерентном кэше архитектура это происходит даже без атомарных операций, и может полностью испортить ваш день. Таким образом, локальность ссылки не обязательно хорошо, когда задействовано несколько ядер, даже если они разделяют кэш. Вы можете ожидать, что это будет, на том основании, что промахи кэша обычно являются источником потерянной скорости, но в вашем конкретном случае это ужасно неправильно.

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

  • int (4 байта, 4 выровнены)
  • за ним следует символ (1 байт, любое выравнивание)
  • с последующим int (4 байта, 4 выровнены)
  • за ним следует символ (1 байт, любое выравнивание)

тогда, скорее всего, это займет 16 байт в памяти. Кстати, размер и выравнивание int не одинаковы на каждой платформе, но 4 очень распространены, и это всего лишь пример.

в этом случае компилятор будет вставлять 3 байта заполнения перед вторым int, чтобы правильно выровнять его, и 3 байта заполнения в конце. Размер объекта должен быть кратен его выравниванию, чтобы объекты одного и того же типа могли располагаться рядом в памяти. Это все, что массив находится в C / C++, смежные объекты в памяти. Если бы структура была int, int, char, char, то тот же объект мог бы быть 12 байтами, потому что char не имеет требования к выравниванию.

Я сказал, что ли int 4-выровнен зависит от платформы: on ARM это абсолютно должно быть, так как unaligned access бросает аппаратное исключение. На x86 вы можете получить доступ к Ints unaligned, но он обычно медленнее и IIRC неатомный. Поэтому компиляторы обычно (всегда?) 4-выровнять ints на x86.

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

struct some_stuff {
    double d;   // I expect double is 64bit IEEE, it might not be
    uint64_t l; // 8 bytes, could be 8-aligned or 4-aligned, I don't know
    uint32_t i; // 4 bytes, usually 4-aligned
    int32_t j;  // same
    short s;    // usually 2 bytes, could be 2-aligned or unaligned, I don't know
    char c[4];  // array 4 chars, 4 bytes big but "never" needs 4-alignment
    char d;     // 1 byte, any alignment
};

Если вы не знаете выравнивания поля или пишете переносимый код, но хотите сделать все возможное без серьезных обманов, то вы предполагаете, что требование выравнивания является самым большим требованием любого фундаментального типа в структуре, и что требование выравнивания фундаментальных типов-это их размер. Итак, если ваша структура содержит uint64_t или long long, то лучше всего предположить, что это 8-выровненный. Иногда ты ошибаешься, но часто оказываешься прав.

обратите внимание, что программисты игр, такие как ваш блоггер, часто знают все о своем процессоре и оборудовании, и поэтому им не нужно догадываться. Они знают размер строки кэша, они знают размер и выравнивание каждого типа, и они знают правила компоновки структуры, используемые их компилятором (для типов POD и non-POD). Если они поддерживают несколько платформ, то они могут иметь особый случай для каждой один, если понадобится. Они также проводят много времени, думая о том, какие объекты в их игре выиграют от повышения производительности, и используя профилировщики, чтобы узнать, где реальные узкие места. Но даже в этом случае неплохо иметь несколько эмпирических правил, которые вы применяете независимо от того, нужен объект или нет. Пока это не сделает код неясным," поместите обычно используемые поля в начале объекта "и" сортировка по требованию выравнивания " -это два хороших правила.


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

выполнение этого в многопоточной программе означает, что вы собираетесь увеличить шансы "ложного обмена".

Проверьте Herb Sutters статьи на эту тему здесь

Я говорил это раньше и буду повторять это. Единственный реальный способ получить реальное повышение производительности-измерить ваш код и использовать инструменты для идентификации реального горлышка бутылки вместо произвольного изменения материала в вашей базе кода.


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


У нас есть несколько разные рекомендации для членов здесь (цель архитектуры ARM, в основном THUMB 16-битный codegen по различным причинам):

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

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

вторая пуля, однако, происходит от небольшой 5-битный" немедленный " размер поля на большом пальце LDRB (байт регистра нагрузки), LDRH (Полуслово регистра нагрузки) и LDR (регистр нагрузки) инструкции.

5 битов означает, что смещения 0-31 могут быть закодированы. Эффективно, предполагая, что "это" удобно в регистре (который обычно есть):

  • 8-битные байты могут быть загружены в одну инструкцию, если они существуют при этом+0 через это+31
  • 16-битные полуслова, если они существуют при этом+0 через это+62;
  • 32-разрядной машине слова, если они существуют при этом+0 через это+124.

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

Если мы попадем в пул литералов, это больно: пул литералов проходит через d-кэш, а не i-кэш; это означает, по крайней мере, cacheline стоит нагрузок из основной памяти для первого доступа к пулу литералов, а затем множество потенциальных проблем выселения и недействительности между d-cache и i-cache, если пул литералов не запускается в собственной строке кэша (т. е. если фактический код не заканчивается в конце строки кэша).

(Если бы у меня было несколько пожеланий для компилятора, с которым мы работаем, одним из них был бы способ заставить пулы литералов запускаться на границах cacheline.)

(Unrelatedly, одна из вещей, которые мы делаем, чтобы избежать буквального использования бассейн держите все наши "глобалы" в одной таблице. Это означает один поиск пула литералов для "GlobalTable", а не несколько поисков для каждого глобального. Если вы действительно умны, вы, возможно, сможете сохранить свою GlobalTable в какой-то памяти, к которой можно получить доступ без загрузки буквальной записи пула .sbss?)


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

- выравнивание памяти полей в структурах

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

на большинстве архитектур ЦП поля в структуре должны быть доступны в собственном выравнивании для эффективности. Это означает, что при смешивании полей различного размера компилятор должен добавить заполнение между полями, чтобы требования к выравниванию были правильными. Поэтому для оптимизации памяти, используемой структурой, важно иметь это в виду и выложить поля так, чтобы за самыми большими полями следовали меньшие поля, чтобы сохранить требуемое заполнение минимум. Если структура должна быть "упакована", чтобы предотвратить заполнение, доступ к несогласованным полям имеет высокую стоимость выполнения, поскольку компилятор должен получить доступ к несогласованным полям, используя серию обращений к меньшим частям поля вместе со сдвигами и масками для сборки значения поля в регистре.

- смещение часто используемых полей в структуре

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

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


Ну, первый член не нуждается в смещении, добавленном к указателю для доступа к нему.


в C# порядок члена определяется компилятором, если вы не поместите атрибут [LayoutKind.Sequential / Explicit], который заставляет компилятор выкладывать структуру / класс так, как вы ему говорите.

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


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


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

 unsigned char a;
 unsigned char b;
 long c;

большой беспорядок-вверх? без выравнивания переключателей, с низкой памятью ops. et al, у нас будет неподписанный символ, использующий слово 64bits на вашем DDR3 dimm, и еще одно слово 64bits для другого, и все же неизбежное долгое время.

Итак, это выборка для каждого переменная.

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

таким образом, скорость, на текущей 64-битной машине памяти слов, выравнивания, переупорядочения и т. д., Не-nos. Я делаю материал микроконтроллера, и там различия в упакованных/не упакованных действительно заметны (речь идет о

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

последний шаг вперед в оптимизации, который я видел (в ИБП, не думаю, что это выполнимо для приложений для ПК), - это скомпилировать вашу программу как один модуль, оптимизировать ее компилятор (гораздо более общий вид скорости/разрешения указателя/упаковки памяти и т. д.) и имейте не вызываемые библиотечные функции, методы и т. д. корзины компоновщика.


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


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

попробуйте запустить профилировщик / оптимизатор. Первой компиляции с опцией профилирования запустите программу. Как только профилированный exe будет завершен, он сбросит некоторую профилированную информацию. Возьмите этот дамп и запустите его через оптимизатор в качестве входных данных.

Я был вдали от этой работы в течение многих лет, но не так много изменилось, как они работают.