Почему g ++ тянет вычисления в горячий цикл? - PullRequest
0 голосов
/ 28 мая 2018

У меня очень странное поведение компилятора, когда G ++ затягивает вычисления в горячий цикл, серьезно снижая производительность получающегося кода.Что здесь происходит?

Рассмотрим эту функцию:

#include <cstdint>

constexpr bool noLambda = true;

void funnyEval(const uint8_t* columnData, uint64_t dataOffset, uint64_t dictOffset, int32_t iter, int32_t limit, int32_t* writer,const int32_t* dictPtr2){
   // Computation X1
   const int32_t* dictPtr = reinterpret_cast<const int32_t*>(columnData + dictOffset);
   // Computation X2
   const uint16_t* data = (const uint16_t*)(columnData + dataOffset);
   // 1. The less broken solution without lambda
   if (noLambda) {
        for (;iter != limit;++iter){
            int32_t t=dictPtr[data[iter]];
            *writer = t;
            writer++;
        }
   }
   // 2. The totally broken solution with lambda
   else {
        auto loop = [=](auto body) mutable { for (;iter != limit;++iter){ body(iter); } };
        loop([=](unsigned index) mutable {
            int32_t t=dictPtr[data[index]];
            *writer = t;
            writer++;
        });
   }
}

Проблема здесь в том, что G ++ так или иначе любит вставлять вычисления X1 и X2 в цикл горячей основной цепи, уменьшаяпроизводительность.Вот подробности:

Функция просто перебирает массив data, ищет значение в словаре dictPtr и записывает его в целевую ячейку памяти writer.data и dictPtr вычисляются в начале функции.Для этого есть два варианта: один с лямбдой, другой без.

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

Проблема при компиляции с последним g ++ (пробовал 8.1 и 7.2, такая же проблема с более старыми g ++, как вы можетесм. в приведенных ссылках Godbolt) с высоким уровнем оптимизации (-O3 -std=c++14) является следующее:

Решение 2. (noLambda=false) генерирует очень плохой код для цикла, даже хуже, чем «наивное» решениепотому что предполагается, что это хорошая идея - включить вычисления X1 и X2, которые находятся за пределами супер горячего основного цикла, в супер горячий горячий цикл, что делает его примерно на 25% медленнее на моем процессоре.

https://godbolt.org/g/MzbxPN

.L3:
        movl    %ecx, %eax          # unnecessary extra work
        addl    $1, %ecx
        addq    $4, %r9             # separate loop counter (pointer increment)
        leaq    (%rdi,%rax,2), %rax # array indexing with an LEA
        movzwl  (%rax,%rsi), %eax   # rax+rsi is Computation X2, pulled into the loop!
        leaq    (%rdi,%rax,4), %rax # rax+rdx is Computation X1, pulled into the loop!
        movl    (%rax,%rdx), %eax   
        movl    %eax, -4(%r9)
        cmpl    %ecx, %r8d
        jne     .L3

При использовании обычного цикла for (noLambda=true) код лучше, так как X2 больше не втягивается в цикл, но X1 все еще есть!:

https://godbolt.org/g/eVG75m

.L3:
        movzwl  (%rsi,%rax,2), %ecx
        leaq    (%rdi,%rcx,4), %rcx
        movl    (%rcx,%rdx), %ecx # This is Computation X1, pulled into the loop!
        movl    %ecx, (%r9,%rax,4)
        addq    $1, %rax
        cmpq    %rax, %r8
        jne     .L3

Вы можете попробовать, что это действительно X1 в цикле, заменив dictPtr (вычисление X1) в цикле на dictPtr2 (параметр), инструкция исчезнет:

https://godbolt.org/g/nZ7TjJ

.L3:
        movzwl  (%rdi,%rax,2), %ecx
        movl    (%r10,%rcx,4), %ecx
        movl    %ecx, (%r9,%rax,4)
        addq    $1, %rax
        cmpq    %rax, %rdx
        jne     .L3

Это, наконец, цикл, как я хочу.Простой цикл, который загружает значения и сохраняет результат без вытягивания в него случайных вычислений.

Итак, что здесь происходит? Редко хорошая идея провести вычисления в горячий цикл, но G ++, кажется, думает так и здесь.Это стоит мне реальной производительности.Лямбда усугубляет всю ситуацию;это приводит к тому, что G ++ затягивает в цикл еще больше вычислений.

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

Ответы [ 3 ]

0 голосов
/ 01 июня 2018

Я попытался запустить ваш код и ... удивительно: инструкции, выполняемые, когда вы находитесь в цикле, не те, которые вы видите в ссылке на проводник компилятора, которую вы опубликовали.Проверьте это (я добавил основную функцию) https://godbolt.org/g/PPYtQa Инструкции, выполняемые, пока вы находитесь в цикле, это 162-167, то есть

.L15:
        movzwl  25(%rbx,%rdx), %ecx
        movl    5(%rbx,%rcx,4), %ecx
        movl    %ecx, 0(%rbp,%rdx,2)
        addq    $2, %rdx
        cmpq    $180, %rdx
        jne     .L15

Вы можете проверить это дважды, скомпилировав на своем компьютере

g++ test.cpp -std=c++1z -g -O3

и работает с gdb

> gdb a.out
(gdb) break funnyEval
(gdb) layout split #shows assebly
(gdb) stepi #steps to the next instruction

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

0 голосов
/ 06 июня 2018

Поздравляем, вы нашли ошибку gcc .Основное решение - сообщить об этом на bugzilla GCC с ключевым словом "пропущенная оптимизация".Ваши MCVE уже являются отличными тестовыми примерами для ошибки, поэтому не нужно слишком много времени, чтобы написать ее.Скопируйте / вставьте код и описание.Ссылка на этот раздел вопросов и ответов и ссылка на код в http://godbolt.org/, тоже были бы хорошими.

Иногда существуют тонкие микроархитектурные причины для использования «дополнительных» инструкций, таких как xor -zeroingназначение popcnt / lzcnt или bsf , чтобы избежать ложной зависимости от процессоров Intel , но здесь это не так.Это просто плохо;movl %ecx, %eax внутри цикла может быть результатом использования типа без знака, более узкого, чем указатель, но даже это может быть сделано более эффективно;Это также пропущенная оптимизация.

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

Но, к сожалению, для gcc нередко оставлять в цикле дополнительную инструкцию mov для настройки кодавне петли.Особенно, когда может потребоваться несколько инструкций вне цикла, чтобы получить тот же эффект.Обычно это плохой компромисс при оптимизации производительности вместо размера кода.Я не смотрел на вывод asm из Profile-Guided Optimization столько, сколько хотел бы, чтобы увидеть код, где gcc знает, какие циклы действительно горячие, и развертывает их.Но большая часть кода создается без PGO, к сожалению, поэтому генерация кода без -fprofile-use все еще очень важна.


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

Не делайне верьте в gcc! Это очень сложный механизм, который часто дает хорошие результаты, но редко дает оптимальные результаты.

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

Но узкое место многих циклов в памяти, а не пропускная способность uop. Современные ЦП предназначены для жевания через потраченные впустую инструкции, которыекомпиляторы, особенно JIT-компиляторы, генерируют.Вот почему пропущенные оптимизации, подобные этой, обычно не оказывают большого влияния на макроуровне, и почему случаи, когда они имеют значение (например, видеокодеры или умножение матриц), часто все еще используют блоки рукописного асма.

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

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


Интересно, что наихудший случай (2 дополнительные инструкции LEA в цикле, плюс использование дополнительных счетчиков цикла) происходит только с вашим if (noLambda).

Если вы сделаете 2 отдельные версии функции и удалите if, версия nolambda делает хороший чистый цикл (но пропускает автоматическую векторизацию сборки, которая была бы выигрышной при компиляции с -march=skylake)

Я поместил ваш код наGodbolt проводник компилятора .(Также интересно, используйте -funroll-loops, чтобы увидеть, какие части переделываются при каждой итерации развернутого цикла, а какие просто находятся внутри цикла один раз.)

# gcc7.2:  the nolamba side of the if, with no actual if()
.L3:
    movzwl  (%rsi,%rax,2), %ecx
    movl    (%rdx,%rcx,4), %ecx
    movl    %ecx, (%r9,%rax,4)   # indexed store: no port 7
    addq    $1, %rax             # gcc8 -O3 -march=skylake uses inc to save a code byte here.
    cmpq    %rax, %r8
    jne     .L3

В семействе Intel Sandybridge это декодирует до 5 моп.(Слияние макросов cmp / jcc превращает эту пару в 1 моп. Все остальные инструкции - однократные; movzwl является чистой загрузкой и не требует порта ALU).

Хранилищене ламинирует на SnB / IvB (стоит дополнительная мера для этапа выпуска с 4-мя шинами, одно из главных узких мест переднего конца), но может остаться в HSW / SKL.Однако он не может использовать порт 7 (потому что он индексирован), что означает, что HSW / SKL будет ограничен 2 операциями памяти на такт, а не 3.

Узкие места:

  • Пропускная способность внешнего интерфейса 4 мопов слитых доменов за такт.Цикл составляет 5 моп, и может выдавать почти 1 итерацию на 1,25.(Циклы не кратные 4 не идеальны, но 5 мопов хорошо обрабатываются на Haswell / Skylake как минимум . Возможно, не Sandybridge.)

  • порты загрузки / хранения: Haswell и более поздние версии могут запускать 2 загрузки + 1 хранилище за такт, но только в том случае, если хранилище избегает режима индексированной адресации, поэтому uop store-address может работать на порту 7.


лямбда-версия получает 2-й счетчик цикла (приращение указателя) и глупое movl %ecx, %eax, но инструкции LEA остаются вне цикла.

Но это не дополнительные вычисления как таковые, это общая пропускная способность UOP, вероятно, вредит вашей петле.Если словарь в основном остается горячим в кеше, то процессор Haswell или новее


я собирался написать больше, но я не закончил.Размещать сейчас, потому что общая ранняя / средняя часть, по-видимому, является темой, о которой действительно идет речь.Не вслепую доверяйте gcc.

И не ожидайте, что он будет создавать оптимальный код большую часть времени.Вы можете часто получить 10 или 20%, просто настроив источник C (а иногда и намного больше).Иногда кажется, что gcc не имеет ни малейшего представления, как, например, использование дополнительных lea s без видимой причины при развертывании вместо использования смещения в режиме адресации.Я думаю, что его модель стоимости в режиме адресации не должна быть точной, по крайней мере для -march=haswell / -march=skylake.

0 голосов
/ 01 июня 2018

Вы используете 32-битный тип без знака для индекса массива (в строке 21).Это заставляет компилятор на каждом шаге цикла рассматривать, не переполнили ли вы его доступный диапазон, и в этом случае он должен вернуться к началу массива.Дополнительный код, который вы видите, связан с этой проверкой!Существует по крайней мере три способа избежать этого чрезмерно осторожного подхода компилятора:

  1. Используйте 64-битный тип для индекса в строке 21. Теперь компилятор знает, что вы никогда не обернетесь вокруг массива,и генерировать тот же код, что и без лямбды.
  2. Используйте 32-битный тип со знаком для индекса в строке 21. Теперь компилятор больше не заботится о переполнении: переполнение со знаком считается UB и поэтому игнорируется.Я считаю, что это является ошибкой в ​​интерпретации стандарта, но мнения по этому поводу расходятся.
  3. Дайте понять компилятору, что переполнения никогда не произойдет, добавив строку 'int32_t iter = 0;'в начале функции и удалив ее из объявления.Ясно, что это не решает вашу проблему, но иллюстрирует, как именно анализ переполнения вызывает генерацию дополнительного кода.

Вы не жалуетесь на код до запуска цикла, но здесь у вас та же проблема.Просто сделайте iter и ограничьте int64_t, и вы увидите, что он становится значительно короче, так как компилятор больше не рассматривает возможность переполнения массива.

Итак, резюмируем: не вычисление X1 и X2 перемещается в цикл, что приводит к увеличению размера, а использование неверно типизированной переменной индекса массива.

...