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

В настоящее время я испытываю какой-то странный эффект с 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 существует, не имеет значения, статичен ли /t 23 > или нет, а что значение max-inline-insns-auto: производительность fdec вернется к норме. Даже если wtf не используется и не вызывается из любого места...: '(

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

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

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

Ответы

Ответ 1

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

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

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


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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

Ответ 2

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

Модификатор '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 вложены. Но если криволинейный крикет не выполняется, производительность не может быть предсказуемой. То есть большие потери производительности происходят, когда inlining отключается относительно fB, но очень незначительные капли для fA. И вы знаете, что внутренние решения компилятора очень сложны.

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

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

EDIT:

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