为什么C ++编译器的常数折叠效果更好?

时间:2018-08-31 10:29:22

标签: c++ compiler-construction eigen automatic-differentiation ceres-solver

我正在研究加速大部分C ++代码的方法,这些C ++代码具有用于自动生成jacobian的自动派生代码。这涉及在实际残差中做一些工作,但是大部分工作(基于分析的执行时间)是在计算jacobian。

这让我感到惊讶,因为大多数雅各布人都是从0和1向前传播的,所以工作量应该是函数的2-4倍,而不是10-12倍。为了建模大量的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都无法对矩阵为0的朴素点积进行任何优化。即使使用快速算法(https://godbolt.org/z/GvPXFy),GCC和Clang的优化也非常差(仍然涉及乘法和加法),而MSVC根本不做任何优化。

我没有编译器背景,但是有这个原因吗?我相当确定,即使恒定折叠本身并不会加快速度,但在科学计算中,能够做得更好的恒定传播/折叠将使更多的优化变得显而易见。

尽管我对为什么在编译器方面没有做到这一点感兴趣,但我也对在实际方面可以做些什么来使自己的代码在面对这些类型的模式时更快感到兴趣。 / p>

3 个答案:

答案 0 :(得分:71)

这是因为Eigen在剩余的4个组件寄存器中将代码显式矢量化为3个vmulpd,2个vaddpd和1个水平缩减(这假设AVX,只有SSE,您将获得6个mulpd和5个addpd)。使用-ffast-math,允许GCC和clang删除最后2个vmulpd和vaddpd(这是它们的工作),但它们不能真正替代Eigen明确生成的其余vmulpd和水平缩小。

那么,如果通过定义EIGEN_DONT_VECTORIZE来禁用Eigen的显式矢量化,该怎么办?然后,您得到了预期的结果(https://godbolt.org/z/UQsoeH),但是其他代码段可能会变慢得多。

如果您想在本地禁用显式矢量化并且不怕与Eigen的内部混乱,可以在DontVectorize上引入Matrix选项,并通过专门为此traits<>来禁用矢量化{ {1}}类型:

Matrix

完整示例:https://godbolt.org/z/bOEyzv

答案 1 :(得分:38)

  

我很失望地发现,如果没有启用快速数学功能,那么GCC 8.2,Clang 6或MSVC 19都无法对矩阵为0的朴素点积进行任何优化。

不幸的是,他们别无选择。由于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。您只需放弃带符号的零(可能无关紧要),NaN和无穷大。大概,如果仍要在代码中生成它们,则会得到不确定的行为,因此请权衡一下选择。


关于为什么即使使用-ffast-math,您的示例也无法得到更好的优化:这是关于Eigen的。大概他们对矩阵运算进行了向量化,这对于编译器来说很难看清。使用以下选项可以正确优化一个简单的循环:https://godbolt.org/z/OppEhY

答案 2 :(得分:11)

迫使编译器优化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 produces

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 i = 0; i < n; ++i) ...循环,没有所有这些折叠表达式的技巧,gcc不需要,也需要一些帮助。