У меня очень странное поведение компилятора, когда 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++ s, как вы можете видеть в ссылках на godbolt) с высоким уровнем оптимизации (-O3 -std=C++14
) заключается в следующем:
Решение 2. (noLambda=false
) генерирует очень плохой код для цикла, что еще хуже, чем "наивное" решение, поскольку оно предполагает, что это хорошая идея - вытащить вычисления X1 и X2, которые находятся за пределами супер горячей основной циклы, в супер горячий основной контур, что делает его примерно на 25% медленнее на моем процессоре.
.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
При использовании обычного цикла (noLambda=true
) код лучше, поскольку X2 больше не втягивается в цикл, но X1 все еще есть !:
.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
(параметр), инструкция исчезнет:
.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++ без причудливых функций. Если я не могу полагаться на свой компилятор, создающий идеальный код для такого тривиального примера, мне нужно будет проверить сборку всех горячих циклов в моем коде, чтобы убедиться, что все так быстро, как могло бы быть. Это также означает, что на это возможно огромное количество программ.