Большое падение производительности с gcc, может быть связано с inline

В настоящее время я испытываю какой-то странный эффект с gcc (проверенная версия: 4.8.4).

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

Поскольку встраивание через несколько .c файлы сложно (-flto пока еще не широко доступен), я сохранил множество небольших функций (обычно от 1 до 5 строк кода в каждой) в общий файл C, в который я разрабатываю кодек и связанный с ним декодер. Он "относительно" большой по моему стандарту (около 2000 строк, хотя многие из них - просто комментарии и пустые строки), но разбивка его на более мелкие части открывает новые проблемы, поэтому я предпочел бы избежать этого, если это возможно.

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

Странный эффект такой:

Я недавно добавил новую функцию fnew на стороне кодера. Это новая "точка входа". Он не используется и не вызывается нигде в пределах .c файл.

Тот факт, что он существует, делает производительность функции декодера fdec существенно снижается более чем на 20%, что слишком много, чтобы его игнорировать.

Теперь имейте в виду, что операции кодирования и декодирования полностью разделены, и почти ничего не разделяют, за исключением незначительного typedef (u32, u16 и тому подобное) и связанные операции (чтение / запись).

При определении новой функции кодирования fnew как staticпроизводительность декодера fdec увеличивается обратно к норме. поскольку fnew не вызывается из .cЯ думаю, это так же, как если бы его не было (устранение мертвого кода).

Если static fnew теперь вызывается со стороны энкодера, производительность fdec остается сильным

Но как только fnew модифицируется, fdec производительность просто существенно падает.

Предположив fnew модификации переступили порог, я увеличил следующее gcc параметр: --param max-inline-insns-auto=60 (по умолчанию его значение должно быть 40.) И это сработало: производительность fdec сейчас вернулся к нормальной жизни.

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

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

Единственное предварительное объяснение, которое я мог придумать до сих пор, состоит в том, что, возможно, простое присутствие fnew достаточно пересечь какую-то global file threshold что повлияет fdec, fnew может быть сделано "не присутствует", когда оно: 1. не существует, 2. static но никуда не звонил 3. static и достаточно маленький, чтобы быть встроенным. Но это просто скрывает проблему. Значит ли это, что я не могу добавить новую функцию?

На самом деле, я не мог найти удовлетворительного объяснения где-либо в сети.

Мне было любопытно узнать, испытал ли кто-то уже какой-то аналогичный побочный эффект и нашел ли его решение.

[Редактировать]

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

когда wtf существует, не имеет значения, если fnew статичен или нет, ни какова ценность max-inline-insns-auto: производительность fdec вернулся к нормальной жизни. Даже если wtf нигде не используется и не вызывается...: '(

[Редактировать 2] нет inline инструкция. Все функции либо нормальные, либо static, Решение о включении находится исключительно в области компиляции, которая до сих пор работала нормально.

[Править 3] Как предположил Питер Кордес, проблема связана не с inline, а с выравниванием инструкций. На более новых процессорах Intel (Sandy Bridge и более поздних версиях) преимущество заключается в выравнивании по 32-байтовым границам. Проблема, по умолчанию, gcc выровняйте их по 16-байтовым границам. Что дает 50% шанс быть на правильном выравнивании в зависимости от длины предыдущего кода. Отсюда сложный для понимания вопрос, который "выглядит случайным".

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

2 ответа

Решение

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

На https://stackru.com/tags/x86/info есть ссылки на некоторую информацию о настройке производительности, в том числе руководство по оптимизации Intel и отличные материалы Agner Fog. Некоторые из советов по оптимизации сборки Agner Fog не полностью применимы к Sandybridge и более поздним процессорам. Если вы хотите получить низкоуровневую информацию о конкретном процессоре, руководство по микроархам очень хорошо.

По крайней мере, без внешней ссылки на код, который я могу попробовать сам, я не могу сделать больше, чем ручная работа. Если вы больше не будете публиковать код, вам нужно будет использовать инструменты профилирования / счетчика производительности процессора, такие как Linux perf или Intel VTune, чтобы отследить это за разумное количество времени.


В чате ОП обнаружил, что у кого-то еще есть эта проблема, но с опубликованным кодом. Вероятно, это та же проблема, с которой сталкивается OP, и она является одним из основных способов выравнивания кода для кэшей UOP в стиле Sandybridge.

В медленной версии в середине цикла находится граница 32B. Инструкции, которые начинаются до границы декодирования до 5 моп. Таким образом, в первом цикле, кэш UOP обслуживает mov/add/movzbl/mov, Во 2-м цикле есть только один mov UOP слева в текущей строке кэша. Затем 3-й цикл выдает последние 2 мопа цикла: add а также cmp+ja,

Проблемный mov начинается в 0x..ff, Я предполагаю, что инструкции, которые охватывают границу 32B, входят в (одну из) кэширующих строк uop для их начального адреса.

В быстрой версии для выполнения итерации требуется всего 2 цикла: тот же первый цикл, затем mov / add / cmp+ja во 2-м.

Если бы одна из первых 4 инструкций была на один байт длиннее (например, дополнена бесполезным префиксом или префиксом REX), проблем не было бы. В конце первой кешировки не было бы ничего странного, потому что mov будет начинаться после границы 32B и будет частью следующей строки кэша UOP.

AFAIK, сборка и проверка результатов разборки - это единственный способ использовать более длинные версии одних и тех же инструкций (см. Оптимизирующая сборка Agner Fog), чтобы получить границы 32B с кратностью 4 моп. Я не знаю о графическом интерфейсе, который показывает выравнивание собранного кода во время редактирования. (И очевидно, что это работает только для рукописного asm и хрупко. Изменение кода вообще нарушит выравнивание вручную.)

Вот почему руководство по оптимизации Intel рекомендует выравнивать критические циклы по 32B.

Было бы здорово, если бы у ассемблера был способ запросить, чтобы предыдущие инструкции были собраны с использованием более длинных кодировок для разметки до определенной длины. Может быть .startencodealign / .endencodealign 32 пара директив, чтобы применить дополнение к коду между директивами, чтобы он заканчивался на границе 32B. Это может сделать ужасный код, если он используется плохо, хотя.


Изменения в параметре inlining изменят размер функций и увеличат другой код на множители 16B. Это похоже на изменение содержимого функции: оно становится больше и меняет выравнивание других функций.

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

Там есть компромисс. Повысить производительность, чтобы выровнять каждую функцию по 64B (начало строки кэша). Плотность кода снизится, и для хранения инструкций потребуется больше строк кэша. 16B - это хорошо, потому что это размер блока выборки / декодирования инструкции на самых последних процессорах.

Agner Fog имеет низкоуровневые детали для каждого микроархива. Хотя он не обновил его для Broadwell, но кэш UOP, вероятно, не изменился со времен Sandybridge. Я предполагаю, что есть один довольно маленький цикл, который доминирует во время выполнения. Я не уверен, что именно искать в первую очередь. Возможно, "медленная" версия имеет некоторые цели перехода в конце 32-битного блока кода (и, следовательно, в конце строки кэша UOP), что приводит к тому, что из внешнего интерфейса выходит менее 4 моп в такт.

Посмотрите на счетчики производительности для "медленной" и "быстрой" версий (например, с perf stat ./cmd), и посмотреть, если они отличаются. например, гораздо больше пропусков кеша может указывать на ложное совместное использование строки кеша между потоками. Кроме того, профиль и посмотреть, есть ли новая точка доступа в "медленной" версии. (например, с perf record ./cmd && perf report в Linux).

Сколько мопов / часов получает "быстрая" версия? Если оно больше 3, проблема может заключаться в узких местах внешнего интерфейса (возможно, в кэше UOP), которые чувствительны к выравниванию. Либо так, либо L1 / uop-cache отсутствует, если другое выравнивание означает, что вашему коду требуется больше строк кэша, чем доступно.

В любом случае, это повторяется: используйте профилировщик / счетчики производительности, чтобы найти новое узкое место, которое есть в "медленной" версии, а в "быстрой" - нет. Затем вы можете потратить время на разборку этого блока кода. (Не смотрите на вывод asm для gcc. Вам нужно увидеть выравнивание при разборке конечного двоичного файла.) Посмотрите на границы 16B и 32B, поскольку предположительно они будут в разных местах между двумя версиями, и мы думаем, это причина проблемы.

Выравнивание также может привести к сбою макро-синтеза, если сравнение /jcc точно разделяет границу 16B. Хотя это маловероятно в вашем случае, так как ваши функции всегда выровнены по некоторому кратному 16B.

Re: автоматизированные инструменты для выравнивания: нет, я не знаю ничего, что может взглянуть на двоичный файл и рассказать вам что-нибудь полезное о выравнивании. Я хотел бы, чтобы был редактор, чтобы показать группы 4 мопов и 32B границ вместе с вашим кодом, и обновлять по мере редактирования.

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

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

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

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

inline void fB(int x, int y) {
    return x * y;
}

inline void fA() {
    for(int i = 0; i < 0x10000000; ++i) {
        fB(i, i+1);
    }
}

void main() {
    fA();
}

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

Причины вызывают отключение встраивания, например, размер функции встраивания, размер файла.c, количество локальных переменных и т. Д.

На самом деле, в C#, я испытал это падение производительности. В моем случае падение производительности на 60% происходит при добавлении одной локальной переменной в простую встроенную функцию.

РЕДАКТИРОВАТЬ:

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

Другие вопросы по тегам