Почему компиляторы 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 не нуждается и нуждается в некоторой помощи.