Eigen::Ref 作为函数参数与 Eigen::VectorXd 相比的效率

Efficiency of Eigen::Ref compared to Eigen::VectorXd as function arguments

我有一个长向量 Eigen::VectorXd X;,我想使用以下函数之一逐段更新它:

void Foo1(Eigen::Ref<Eigen::VectorXd> x) {
    // Update x.
}

Eigen::VectorXd Foo2() {
    Eigen::VectorXd x;
    // Update x.
    return x;
}

int main() {
    const int LARGE_NUMBER = ...;        // Approximately in the range ~[600, 1000].
    const int SIZES[] = {...};           // Entries roughly in the range ~[2, 20].

    Eigen::VectorXd X{LARGE_NUMBER};

    int j = 0;
    for (int i = 0; i < LARGE_NUMBER; i += SIZES[j]) {
        // Option (1).
        Foo1(X.segment(i, SIZES[j]));
        // Option (2)
        X.segment(i, SIZES[j]) = Foo2();

        ++j;
    }

    return 0;
}

鉴于上述规格,哪个选项最有效? 我会说 (1) 因为它会直接修改内存而不创建任何临时文件。但是,编译器优化可能会使 (2) 性能更好——例如,参见 post.

其次,考虑以下功能:

void Foo3(const Eigen::Ref<const Eigen::VectorXd>& x) {
    // Use x.
}

void Foo4(const Eigen::VectorXd& x) {
    // Use x.
}

使用 X 的段调用 Foo3 是否保证至少与使用相同的段调用 Foo4 一样有效?也就是说,Foo3(X.segment(...)) 是否总是至少与 Foo4(X.segment(...)) 一样有效?

Given the above specifications, which option would be the most efficient?

如您所料,最有可能是选项 1。当然,这取决于更新的内容。所以你可能需要一些基准测试。但总的来说,与分配新对象所允许的次要优化相比,分配成本是显着的。另外,选项 2 会产生复制结果的额外费用。

Is calling Foo3 with segments of X guaranteed to always be at least as efficient as calling Foo4 with the same segments?

如果您调用 Foo4(x.segment(...)),它会分配一个新向量并将该段复制到其中。这比 Foo3 贵 。您唯一获得的是矢量将正确对齐。这对现代 CPU 来说只是一个很小的好处。所以我希望 Foo3 更有效率。

请注意,您还没有考虑一个选项:使用模板。

template<class Derived>
void Foo1(const Eigen::MatrixBase<Derived>& x) {
    Eigen::MatrixBase<Derived>& mutable_x = const_cast<Eigen::MatrixBase<Derived>&>(x);
    // Update mutable_x.
}

const-cast 很烦人但无害。请参阅 Eigen 关于该主题的文档。 https://eigen.tuxfamily.org/dox/TopicFunctionTakingEigenTypes.html

总的来说,这将提供与内联函数体大致相同的性能。不过,在您的特定情况下,它可能不会比 Foo1 的内联版本快。这是因为一般段和Ref对象的性能基本相同。

访问 Ref 与 Vector 的效率

让我们更详细地了解 Eigen::VectorEigen::Ref<Vector>Eigen::MatrixEigen::Ref<Matrix> 的计算之间的性能。 Eigen::Block(Vector.segment() 或 Matrix.block() 的 return 类型)在功能上与 Ref 相同,所以我不打算再提了。

  • Vector 和 Matrix 保证数组作为一个整体对齐到 16 字节边界。这允许操作使用对齐的内存访问(例如 movapd 在这种情况下)。
  • Ref 不保证对齐,因此需要未对齐的访问(例如 movupd)。在非常老的 CPU 上,这曾经有显着的性能损失。现在它不那么重要了。对齐很好,但它不再是矢量化的最终目标。在该主题上引用 Agner [1]:

Some microprocessors have a penalty of several clock cycles when accessing misaligned data that cross a cache line boundary.
Most XMM instructions without VEX prefix that read or write 16-byte memory operands require that the operand is aligned by 16. Instructions that accept unaligned 16-byte operands can be quite inefficient on older processors. However, this restriction is largely relieved with the AVX and later instruction sets. AVX instructions do not require alignment of memory operands, except for the explicitly aligned instructions. Processors that support the AVX instruction set generally handle misaligned memory operands very efficiently.

  • 四种数据类型都保证了内部维度(向量中只有维度,矩阵中只有一列)是连续存储的。所以Eigen可以沿着这个维度向量化
  • Ref 不保证沿外部维度的元素是连续存储的。从一列到下一列可能存在间隙。这意味着像 Matrix+MatrixMatrix*Scalar 这样的标量运算可以对所有行和列中的所有元素使用单个循环,而 Ref+Ref 需要一个嵌套循环,其中一个外循环遍历所有列,一个内循环遍历所有行。
  • Ref 和 Matrix 都不能保证特定列的正确对齐。因此,大多数矩阵运算(例如矩阵向量乘积)需要使用未对齐访问。
  • 如果您在函数内部创建向量或矩阵,这可能有助于转义和别名分析。然而,在大多数情况下,Eigen 已经假定没有别名,并且 Eigen 创建的代码几乎没有为编译器添加任何内容留出空间。因此它很少是一个好处。
  • 调用约定存在差异。例如在Foo(Eigen::Ref<Vector>)中,对象是按值传递的。 Ref 有一个指针,一个大小,没有析构函数。所以它会在两个寄存器中传递。这是非常有效的。对于消耗 4 个寄存器(指针、行、列、外部步幅)的 Ref<Matrix> 来说不太好。 Foo(const Eigen::Ref<const Vector>&) 将在堆栈上创建一个临时对象并将指针传递给函数。 Vector Foo() returns 一个具有析构函数的对象。所以调用者在栈上分配space,然后传递一个隐藏指针给函数。通常,这些差异并不显着,但它们当然存在,并且可能与通过许多函数调用进行很少计算的代码有关

考虑到这些差异,让我们看一下手头的具体案例。您没有指定更新方法的作用,所以我必须做一些假设。

计算总是相同的,所以我们只需要查看内存分配和访问。

示例 1:

void Foo1(Eigen::Ref<Eigen::VectorXd> x) {
    x = Eigen::VectorXd::LinSpaced(x.size(), 0., 1.);
}
Eigen::VectorXd Foo2(int n) {
    return Eigen::VectorXd::LinSpaced(n, 0., 1.);
}
x.segment(..., n) = Foo2(n);

Foo1 执行一次未对齐的内存写入。 Foo2 将一次分配和一次对齐的内存写入临时向量。然后它复制到段。这将使用一个对齐的内存读取和一个未对齐的内存写入。因此,Foo1 显然在所有情况下都更好。

示例 2:

void Foo3(Eigen::Ref<Eigen::VectorXd> x)
{
    x = x * x.maxCoeff();
}
Eigen::VectorXd Foo4(const Eigen::Ref<Eigen::VectorXd>& x)
{
    return x * x.maxCoeff();
}
Eigen::VectorXd Foo5(const Eigen::Ref<Eigen::VectorXd>& x)
{
    Eigen::VectorXd rtrn = x;
    rtrn = rtrn * rtrn.maxCoeff();
    return rtrn;
}

Foo3 和 4 都从 x 执行两次未对齐的内存读取(一次用于 maxCoeff,一次用于乘法)。之后,它们的行为与 Foo1 和 2 相同。因此 Foo3 始终优于 4。

Foo5 为初始副本执行一次未对齐内存读取和一次对齐内存写入,然后为计算执行两次对齐读取和一次对齐写入。之后跟随函数外的副本(与 Foo2 相同)。这仍然比 Foo3 所做的要多得多,但是如果您对向量进行更多的内存访问,在某些时候可能是值得的。我对此表示怀疑,但案例可能存在。

主要的收获是:由于您最终希望将结果存储在现有向量的段中,因此您永远无法完全避免未对齐的内存访问。所以不用太担心他们。

模板与参考

差异的简要概述:

模板化版本将(如果编写得当)适用于所有数据类型和所有内存布局。例如,如果你传递一个完整的向量或矩阵,它可以利用对齐。

在某些情况下,Ref 根本无法编译,或者工作方式与预期不同。如上所述,Ref 保证内部维度是连续存储的。调用 Foo1(Matrix.row(1)) 将不起作用,因为矩阵行在 Eigen 中不是连续存储的。如果您使用 const Eigen::Ref<const Vector>& 调用函数,Eigen 会将行复制到一个临时向量中。

模板化版本在这些情况下可以工作,但当然不能矢量化。

Ref 版本有一些好处:

  1. 阅读起来更清晰,意外输入出错的机会更少
  2. 您可以将它放在 cpp 文件中,这样可以减少冗余代码。根据您的用例,更紧凑的代码可能更有益或更合适

[1] https://www.agner.org/optimize/optimizing_assembly.pdf