Почему компиляторы C++ не делают лучше постоянное сворачивание?


Я исследую способы ускорения большого раздела кода C++, который имеет автоматические производные для вычисления якобианов. Это включает в себя выполнение некоторого объема работы в фактических остатках, но большая часть работы (на основе профилированного времени выполнения) заключается в вычислении якобианов.

это удивило меня, поскольку большинство из якобианов распространяются вперед от 0s и 1S, поэтому объем работы должен быть 2-4х функция, а не 10-12х. Для того, чтобы смоделировать большой количество якобианской работы похоже на то, что я сделал супер минимальный пример только с точечным продуктом (вместо 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 не смогли сделать никаких оптимизаций на всем протяжении наивного точечного продукта с матрицей полный 0s. даже с быстрой математикой (https://godbolt.org/z/GvPXFy) оптимизации очень плохи в GCC и Clang (все еще включают умножения и дополнения), а MSVC вообще не делает никаких оптимизаций.

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

хотя меня интересуют объяснения, почему это не делается на стороне компилятора, мне также интересно, что я могу сделать на практической стороне, чтобы сделать свой собственный код быстрее, когда сталкиваюсь с такими шаблонами.

3 55

3 ответа:

это связано с тем, что 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? Тогда вы получите то, что ожидали (https://godbolt.org/z/UQsoeH) но другие части кода могут стать намного медленнее.

если вы хотите локально отключить явную векторизацию и не боитесь возиться с внутренним Eigen, вы можете ввести до 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

Я был разочарован, обнаружив, что без поддержки быстрой математики ни GCC 8.2, Clang 6 или MSVC 19 не смогли сделать никаких оптимизаций на всем протяжении наивного точечного продукта с матрицей, полной 0s.

у них нет другого выбора, к сожалению. Так как IEEE плавает подписали нули, добавив 0.0 не является операцией идентификации:

-0.0 + 0.0 = 0.0 // Not -0.0!

точно так же умножение на ноль не всегда дает ноль:

0.0 * Infinity = NaN // Not 0.0!

Итак, компиляторы просто невозможно выполнить эти постоянные сгибы в точечном продукте, сохраняя соответствие IEEE float-для всего, что они знают, ваш вход может содержать подписанные нули и/или бесконечности.

вам придется использовать -ffast-math чтобы сделать эти складки, но это может иметь нежелательные последствия. Вы можете получить более мелкозернистый контроль с определенными флагами (от http://gcc.gnu.org/wiki/FloatingPointMath). согласно приведенному выше объяснению, добавление следующих двух флагов должно позволить постоянный адрес статьи: --21-->-ffinite-math-only,-fno-signed-zeros

действительно, Вы получаете ту же сборку, что и с -ffast-math таким образом:https://godbolt.org/z/vGULLA вы отказываетесь только от подписанных нулей (вероятно, не имеющих отношения к делу), NaNs и бесконечностей. Предположительно, если бы вы все еще производили их в своем коде, вы получили бы неопределенное поведение, поэтому взвесьте свои варианты.


а почему ваш пример не оптимизирован лучше даже с -ffast-math: то есть на Eigen. Предположительно они имеют векторизацию на своих матричных операциях,которые намного сложнее для компиляторов видеть. Простой цикл правильно оптимизирован с помощью следующих параметров:https://godbolt.org/z/OppEhY

один из способов заставить компилятор оптимизировать умножение на 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

дополнительно. лязг разворачивает a for (std::size_t i = 0; i < n; ++i) ... цикл без всех этих трюков с выражениями сгиба, gcc не делает и нуждается в некоторой помощи.