Почему компиляторы C++ не улучшают постоянную складку?
Я изучаю способы ускорения большой части кода C++, который имеет автоматические производные для вычисления якобианцев. Это включает в себя выполнение некоторой работы в фактических остатках, но большая часть работы (основанная на профилированном времени выполнения) заключается в вычислении якобианцев.
Это меня удивило, так как большинство якобианцев распространяются вперед от 0s и 1s, поэтому объем работы должен быть 2-4x, а не 10-12x. Чтобы смоделировать то, на что похоже большое количество работы в jacobian, я сделал супер минимальный пример с помощью только точечного продукта (вместо sin, cos, sqrt и более того, что было бы в реальной ситуации), что компилятор должен быть способен для оптимизации до одного возвращаемого значения:
#include <Eigen/Core>
#include <Eigen/Geometry>
using Array12d = Eigen::Matrix<double,12,1>;
double testReturnFirstDot(const Array12d& b)
{
Array12d a;
a.array() = 0.;
a(0) = 1.;
return a.dot(b);
}
Который должен быть таким же, как
double testReturnFirst(const Array12d& b)
{
return b(0);
}
Я был разочарован тем, что без ускоренной математики ни GCC 8.2, ни Clang 6, ни MSVC 19 не смогли сделать никаких оптимизаций на наивном dot-продукте с матрицей, полной 0s. Даже с быстрой математикой (https://godbolt.org/z/GvPXFy) оптимизация очень плоха в GCC и Clang (по-прежнему связаны с умножениями и дополнениями), и MSVC вообще не делает никаких оптимизаций.
У меня нет фона в компиляторах, но есть ли причина для этого? Я вполне уверен, что в значительной части научных вычислений, способных лучше обеспечивать постоянное распространение/сгибание, можно было бы сделать больше оптимизаций, даже если сама константная складка не привела к ускорению.
Хотя мне интересно объяснять, почему это не делается на стороне компилятора, мне также интересно, что я могу сделать на практической стороне, чтобы быстрее сделать свой собственный код, когда сталкиваюсь с такими типами шаблонов.
Ответы
Ответ 1
Это связано с тем, что Eigen явно индексирует ваш код как 3 vmulpd, 2 vaddpd и 1 горизонтальное сокращение в оставшихся 4-х компонентных регистрах (это предполагает AVX, при SSE вы получите только 6 mulpd и 5 addpd). С помощью -ffast-math
GCC и clang разрешено удалять последние 2 vmulpd и vaddpd (и это то, что они делают), но они не могут реально заменить оставшиеся vmulpd и горизонтальное сокращение, которые были явно созданы Eigen.
Итак, что, если вы отключите Eigen явную EIGEN_DONT_VECTORIZE
определяя EIGEN_DONT_VECTORIZE
? Затем вы получаете то, что ожидаете (https://godbolt.org/z/UQsoeH), но другие фрагменты кода могут стать значительно медленнее.
Если вы хотите локально отключить явную векторию и не боитесь возиться с Eigen internal, вы можете ввести параметр " DontVectorize
в Matrix
и отключить векторизация, специализируя traits<>
для этого типа Matrix
:
static const int DontVectorize = 0x80000000;
namespace Eigen {
namespace internal {
template<typename _Scalar, int _Rows, int _Cols, int _MaxRows, int _MaxCols>
struct traits<Matrix<_Scalar, _Rows, _Cols, DontVectorize, _MaxRows, _MaxCols> >
: traits<Matrix<_Scalar, _Rows, _Cols> >
{
typedef traits<Matrix<_Scalar, _Rows, _Cols> > Base;
enum {
EvaluatorFlags = Base::EvaluatorFlags & ~PacketAccessBit
};
};
}
}
using ArrayS12d = Eigen::Matrix<double,12,1,DontVectorize>;
Полный пример: https://godbolt.org/z/bOEyzv
Ответ 2
Я был разочарован тем, что без ускоренной математики ни GCC 8.2, ни Clang 6, ни MSVC 19 не смогли сделать никаких оптимизаций на наивном dot-продукте с матрицей, полной 0s.
К сожалению, у них нет другого выбора. Поскольку поплавки IEEE имеют подписанные нули, добавление 0.0
не является идентификационной операцией:
-0.0 + 0.0 = 0.0 // Not -0.0!
Точно так же умножение на ноль не всегда равно нулю:
0.0 * Infinity = NaN // Not 0.0!
Таким образом, компиляторы просто не могут выполнять эти постоянные складки в точечном продукте, сохраняя при этом соблюдение правил по стандарту IEEE - все, что они знают, ваш вход может содержать подписанные нули и/или бесконечности.
Вам нужно будет использовать -ffast-math
чтобы получить эти складки, но это может иметь нежелательные последствия. Вы можете получить более мелкозернистый элемент управления с определенными флагами (из http://gcc.gnu.org/wiki/FloatingPointMath). Согласно приведенному выше объяснению, добавление следующих двух флагов должно допускать постоянное складывание:
-ffinite-math-only
, -fno-signed-zeros
Действительно, вы получаете ту же самую сборку, что и с -ffast-math
следующим образом: https://godbolt.org/z/vGULLA. Вы только отказываетесь от подписанных нулей (возможно, неактуальных), NaNs и бесконечности. Предположительно, если вы все еще будете производить их в своем коде, вы получите неопределенное поведение, поэтому взвешивайте свои варианты.
Что касается того, почему ваш пример не оптимизирован лучше даже с -ffast-math
: это на Eigen. Предположительно, у них есть векторизация на их матричных операциях, что намного сложнее для компиляторов. Простой цикл правильно оптимизирован с помощью этих опций: https://godbolt.org/z/OppEhY
Ответ 3
Один из способов заставить компилятор оптимизировать умножения на 0 и 1 - это вручную развернуть цикл. Для простоты позвольте использовать
#include <array>
#include <cstddef>
constexpr std::size_t n = 12;
using Array = std::array<double, n>;
Затем мы можем реализовать простую dot
функцию, используя выражения сгиба (или рекурсию, если они недоступны):
<utility>
template<std::size_t... is>
double dot(const Array& x, const Array& y, std::index_sequence<is...>)
{
return ((x[is] * y[is]) + ...);
}
double dot(const Array& x, const Array& y)
{
return dot(x, y, std::make_index_sequence<n>{});
}
Теперь рассмотрим вашу функцию
double test(const Array& b)
{
const Array a{1}; // = {1, 0, ...}
return dot(a, b);
}
С -ffast-math
gcc 8.2 производит:
test(std::array<double, 12ul> const&):
movsd xmm0, QWORD PTR [rdi]
ret
clang 6.0.0 идет по тем же строкам:
test(std::array<double, 12ul> const&): # @test(std::array<double, 12ul> const&)
movsd xmm0, qword ptr [rdi] # xmm0 = mem[0],zero
ret
Например, для
double test(const Array& b)
{
const Array a{1, 1}; // = {1, 1, 0...}
return dot(a, b);
}
мы получаем
test(std::array<double, 12ul> const&):
movsd xmm0, QWORD PTR [rdi]
addsd xmm0, QWORD PTR [rdi+8]
ret
Дополнение. Clang разворачивает for (std::size_t я = 0; я < n; ++i)...
цикл без всех этих трюков for (std::size_t я = 0; я < n; ++i)...
, gcc не нуждается и нуждается в некоторой помощи.