Оптимизация порядка переменных в C ++ - PullRequest
44 голосов
/ 21 мая 2009

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

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

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

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

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

Ответы [ 10 ]

58 голосов
/ 21 мая 2009

Два вопроса здесь:

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

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

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

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

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

Теперь, если не считать различия между обычно используемыми и менее используемыми полями, чем меньше объект, тем меньше памяти (и, следовательно, меньше кеша) он занимает. Это очень хорошая новость, по крайней мере, там, где у вас нет серьезных разногласий. Размер объекта зависит от полей в нем и от любого заполнения, которое должно быть вставлено между полями, чтобы гарантировать их правильное выравнивание для архитектуры. 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-выровненным, зависит от платформы: в ARM это обязательно должно быть, так как доступ без выравнивания вызывает аппаратное исключение. На x86 вы можете получить доступ к целым числам без выравнивания, но обычно он медленнее, а IIRC не атомарен. Так что компиляторы обычно (всегда?) 4-строчные целые на 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 и не POD). Если они поддерживают несколько платформ, то они могут в особых случаях для каждой из них при необходимости. Они также проводят много времени, размышляя о том, какие объекты в их игре выиграют от повышения производительности, и используя профилировщики, чтобы выяснить, где на самом деле существуют узкие места. Но даже в этом случае неплохо иметь несколько практических правил, которые вы применяете независимо от того, нужен ли объект этому или нет. Пока это не сделает код неясным, «положите часто используемые поля в начало объекта» и «требование сортировки по выравниванию» - это два хороших правила.

10 голосов
/ 21 мая 2009

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

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

Ознакомьтесь с статьями Херба Саттерса на эту тему здесь

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

6 голосов
/ 21 мая 2009

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

3 голосов
/ 23 мая 2009

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

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

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

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

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

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

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

3 голосов
/ 21 мая 2009

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

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

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

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

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

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

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

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

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

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

2 голосов
/ 21 мая 2009

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

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

2 голосов
/ 21 мая 2009

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

1 голос
/ 21 мая 2009

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

 unsigned char a;
 unsigned char b;
 long c;

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

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

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

Таким образом, на текущей 64-битной машине с памятью слов выравнивания, переупорядочения и т. Д. Не требуются. Я занимаюсь микроконтроллерами, и там различия в упакованном / неупакованном виде действительно заметны (речь идет о <10MIPS-процессорах, 8-битной памяти слов) </p>

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

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

0 голосов
/ 21 мая 2009

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

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

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

0 голосов
/ 21 мая 2009

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

...