【问题标题】:Efficiency of Eigen::Ref compared to Eigen::VectorXd as function argumentsEigen::Ref 与 Eigen::VectorXd 作为函数参数相比的效率
【发布时间】:2022-01-23 02:13:11
【问题描述】:

我有一个长向量 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) 性能更好——例如,请参阅this 帖子。

其次,考虑以下函数:

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(...))一样高效?

【问题讨论】:

    标签: c++ performance benchmarking eigen eigen3


    【解决方案1】:

    鉴于上述规范,哪个选项最有效?

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

    是否保证使用 X 段调用 Foo3 始终至少与使用相同段调用 Foo4 一样高效?

    如果您调用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&lt;Vector&gt;Eigen::MatrixEigen::Ref&lt;Matrix&gt; 上的计算之间的性能。 Eigen::Block(Vector.segment() 或 Matrix.block() 的返回类型)在功能上与 Ref 相同,所以我不再赘述。

    • Vector 和 Matrix 保证整个数组与 16 字节边界对齐。这允许操作使用对齐的内存访问(例如,在本例中为 movapd)。
    • Ref 不保证对齐,因此需要未对齐的访问(例如movupd)。在非常旧的 CPU 上,这曾经会导致显着的性能损失。如今,它的相关性降低了。对齐很好,但它不再是矢量化的全部。引用 Agner 关于该主题的内容 [1]:

    当访问跨缓存线边界的未对齐数据时,某些微处理器会损失几个时钟周期。
    大多数没有 VEX 前缀的读取或写入 16 字节内存操作数的 XMM 指令要求操作数按 16 对齐。接受未对齐的 16 字节操作数的指令在旧处理器上可能效率很低。但是,AVX 和更高版本的指令集大大缓解了这种限制。 AVX 指令不需要内存操作数的对齐,除了显式对齐的指令。支持的处理器 AVX 指令集通常非常有效地处理未对齐的内存操作数。

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

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

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

    示例 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&lt;const Vector&gt;&amp; 调用一个函数,Eigen 会将该行复制到一个临时向量中。

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

    Ref 版本有一些好处:

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

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

    【讨论】:

    • 感谢您的回答! “更新需要什么”到底是什么意思?如果更新需要巨大的成本,我会选择(2),因为副本的成本将不那么相关。另外,您是说模板选项保证始终是最有效和“灵活”的(即,它适用于矩阵和块)?如果内联 Foo1 确实具有与模板版本相同的性能,那么我可能会选择 Foo1
    • @fdev 我希望我的扩展回答有所帮助
    • 非常感谢您的精彩回答!
    猜你喜欢
    • 2017-11-01
    • 1970-01-01
    • 2015-04-19
    • 2023-01-29
    • 2020-09-16
    • 1970-01-01
    • 2017-03-22
    • 1970-01-01
    • 2017-03-10
    相关资源
    最近更新 更多