我正在试图了解Eigen::Ref是如何工作的,看看我是否可以在代码中利用它。
我设计了一个这样的基准
static void value(benchmark::State &state) {
for (auto _ : state) {
const Eigen::Matrix<double, Eigen::Dynamic, 1> vs =
Eigen::Matrix<double, 9, 1>::Random();
auto start = std::chrono::high_resolution_clock::now();
const Eigen::Vector3d v0 = vs.segment<3>(0);
const Eigen::Vector3d v1 = vs.segment<3>(3);
const Eigen::Vector3d v2 = vs.segment<3>(6);
const Eigen::Vector3d vt = v0 + v1 + v2;
const Eigen::Vector3d v = vt.transpose() * vt * vt + vt;
benchmark::DoNotOptimize(v);
auto end = std::chrono::high_resolution_clock::now();
auto elapsed_seconds =
std::chrono::duration_cast<std::chrono::duration<double>>(end - start);
state.SetIterationTime(elapsed_seconds.count());
}
}我还有两个类似thise的测试,一个使用const Eigen::Ref<const Eigen::Vector3D>和auto作为v0, v1, v2, vt。
这些基准的结果如下
Benchmark Time CPU Iterations
--------------------------------------------------------------------
value/manual_time 23.4 ns 113 ns 29974946
ref/manual_time 23.0 ns 111 ns 29934053
with_auto/manual_time 23.6 ns 112 ns 29891056正如您所看到的,所有的测试都是完全一样的。因此,我想,也许编译器正在发挥它的魔力,并决定用-O0进行测试。以下是研究结果:
--------------------------------------------------------------------
Benchmark Time CPU Iterations
--------------------------------------------------------------------
value/manual_time 2475 ns 3070 ns 291032
ref/manual_time 2482 ns 3077 ns 289258
with_auto/manual_time 2436 ns 3012 ns 263170同样,这三种情况也是一样的。
如果我正确理解,使用Eigen::Vector3d的第一个情况应该更慢,因为它必须保留, perform the v0+v1+v2`操作并保存它,然后执行另一个操作和保存。
auto案例应该是最快的,因为它应该跳过所有的文章。
关于ref的案子,我认为它应该和auto一样快。如果我正确理解,我的所有操作都可以存储在对const Eigen::Vector3d的引用中,所以应该跳过操作,对吗?
为什么结果是一样的?我是误解了什么,还是基准设计太糟糕了?
发布于 2022-06-29 22:16:55
基准测试的一个大问题是,您需要测量热基准测试循环中的时间。测量时间需要一些时间,它可能比实际计算要昂贵得多。事实上,我认为这就是在你的案子中发生的事情。实际上,在使用-O3的Clang 13上,下面是实际基准的组装代码(可在GodBolt上使用):
mov rbx, rax
mov rax, qword ptr [rsp + 24]
cmp rax, 2
jle .LBB0_17
cmp rax, 5
jle .LBB0_17
cmp rax, 8
jle .LBB0_17
mov rax, qword ptr [rsp + 16]
movupd xmm0, xmmword ptr [rax]
movsd xmm1, qword ptr [rax + 16] # xmm1 = mem[0],zero
movupd xmm2, xmmword ptr [rax + 24]
addpd xmm2, xmm0
movupd xmm0, xmmword ptr [rax + 48]
addsd xmm1, qword ptr [rax + 40]
addpd xmm0, xmm2
addsd xmm1, qword ptr [rax + 64]
movapd xmm2, xmm0
mulpd xmm2, xmm0
movapd xmm3, xmm2
unpckhpd xmm3, xmm2 # xmm3 = xmm3[1],xmm2[1]
addsd xmm3, xmm2
movapd xmm2, xmm1
mulsd xmm2, xmm1
addsd xmm2, xmm3
movapd xmm3, xmm1
mulsd xmm3, xmm2
unpcklpd xmm2, xmm2 # xmm2 = xmm2[0,0]
mulpd xmm2, xmm0
addpd xmm2, xmm0
movapd xmmword ptr [rsp + 32], xmm2
addsd xmm3, xmm1
movsd qword ptr [rsp + 48], xmm3这段代码可以在几十个周期内执行,所以在一个4~5 GHz的现代x86处理器上可能不到10-15 ns。同时,high_resolution_clock::now()应该使用一个RDTSC/RDTSCP指令,这个指令也需要几十个周期才能完成。例如,在Skylake处理器上,应该需要大约25个周期(类似于较新的Intel处理器)。在AMD Zen处理器上,它大约需要35-38个周期.此外,它还添加了一个可能不代表实际应用程序的同步。请考虑使用多次迭代来度量基准测试循环的时间。
发布于 2022-06-29 22:15:22
因为所有的事情都发生在函数内部,编译器可以进行转义分析,并将副本优化到向量中。
为了检查这一点,我将代码放入一个函数中,查看汇编程序:
Eigen::Vector3d foo(const Eigen::VectorXd& vs)
{
const Eigen::Vector3d v0 = vs.segment<3>(0);
const Eigen::Vector3d v1 = vs.segment<3>(3);
const Eigen::Vector3d v2 = vs.segment<3>(6);
const Eigen::Vector3d vt = v0 + v1 + v2;
return vt.transpose() * vt * vt + vt;
}它变成了这个汇编程序
push rax
mov rax, qword ptr [rsi + 8]
...
mov rax, qword ptr [rsi]
movupd xmm0, xmmword ptr [rax]
movsd xmm1, qword ptr [rax + 16]
movupd xmm2, xmmword ptr [rax + 24]
addpd xmm2, xmm0
movupd xmm0, xmmword ptr [rax + 48]
addsd xmm1, qword ptr [rax + 40]
addpd xmm0, xmm2
addsd xmm1, qword ptr [rax + 64]
...
movupd xmmword ptr [rdi], xmm2
addsd xmm3, xmm1
movsd qword ptr [rdi + 16], xmm3
mov rax, rdi
pop rcx
ret请注意,唯一的内存操作是两个GP寄存器加载,以获得开始指针和长度,然后在最后将结果写入内存之前,使用两个内存负载将向量内容输入寄存器。
这只是因为我们处理的是固定大小的向量。使用VectorXd,复制肯定会发生。
替代基准
Ref通常用于函数调用。为什么不尝试一个不能内联的函数呢?或者给出一个例子,在这个例子中,转义分析不能工作,而对象确实必须物化。就像这样:
struct Foo
{
public:
Eigen::Vector3d v0;
Eigen::Vector3d v1;
Eigen::Vector3d v2;
Foo(const Eigen::VectorXd& vs) __attribute__((noinline));
Eigen::Vector3d operator()() const __attribute__((noinline));
};
Foo::Foo(const Eigen::VectorXd& vs)
: v0(vs.segment<3>(0)),
v1(vs.segment<3>(3)),
v2(vs.segment<3>(6))
{}
Eigen::Vector3d Foo::operator()() const
{
const Eigen::Vector3d vt = v0 + v1 + v2;
return vt.transpose() * vt * vt + vt;
}
Eigen::Vector3d bar(const Eigen::VectorXd& vs)
{
Foo f(vs);
return f();
}通过将初始化和使用拆分为非内联函数,副本确实必须完成。当然,我们现在更改了整个用例。你必须决定这是否与你有关。
参考文献的目的
Ref存在的唯一目的是提供一个函数接口,它既可以接受完整的矩阵/向量,也可以接受一个完整的矩阵/向量。考虑到这一点:
Eigen::VectorXd foo(const Eigen::VectorXd&)这个接口只能接受一个完整的向量作为输入。如果您想要调用foo(vector.head(10)),您必须分配一个新的向量来保存向量段。同样,它总是返回一个新分配的向量,如果要将其称为output.head(10) = foo(input),这是浪费的。所以我们可以写
void foo(Eigen::Ref<Eigen::VectorXd> out, const Eigen::Ref<const Eigen::VectorXd>& in);并将其作为foo(output.head(10), input.head(10))使用,而不创建任何副本。这只有在编译单元中才有用。如果有一个cpp文件声明了在另一个cpp中使用的函数,Ref允许这种情况发生。在cpp文件中,只需使用模板即可。
template<class Derived1, class Derived2>
void foo(const Eigen::MatrixBase<Derived1>& out,
const Eigen::MatrixBase<Derived2>& in)
{
Eigen::MatrixBase<Derived1>& mutable_out =
const_cast<Eigen::MatrixBase<Derived1>&>(out);
mutable_out = ...;
}模板总是更快,因为它可以使用具体的数据类型。例如,如果您传递整个向量,本征知道数组是正确对齐的。在一个完整的矩阵中,它知道列之间没有跨距。对于Ref,它都不知道这些。在这方面,Ref只是一个花哨的Eigen::Map<Type, Eigen::Unaligned, Eigen::OuterStride<>>包装器。
同样,在某些情况下,Ref必须创建临时副本。最常见的情况是内部步长不是1。例如,如果传递矩阵的一行(但不是列),就会发生这种情况。在默认情况下,特征是列-主)或复值矩阵的真实部分。您甚至不会收到这方面的警告,您的代码运行速度将比预期的要慢。
在单个cpp文件中使用Ref的唯一原因是
与固定大小的类型一起使用
由于您的用例似乎涉及固定大小的向量,所以让我们特别考虑这个案例并查看内部。
void foo(const Eigen::Vector3d&);
void bar(const Eigen::Ref<const Eigen::Vector3d>&);
int main()
{
Eigen::VectorXd in = ...;
foo(in.segment<3>(6));
bar(in.segment<3>(6));
}当您调用foo时,将发生以下情况:
in[6]复制到堆栈。这需要4个指令(2个movapd,2个movsd)。当我们调用bar时,会发生以下情况:
Ref<Vector>对象。为此,我们在堆栈上放置了指向in.data() + 6的指针请注意,两者之间几乎没有任何差别。也许Ref保存了一些说明,但它也引入了一个间接的方向。与其他一切相比,这几乎没有什么意义。这当然是太少,无法衡量。
我们也进入了微优化的领域。这可能导致出现这样的情况,在这种情况下,仅仅是代码的排列就会导致不同的优化。特征:为什么这个模板表达式的Map比Vector3d慢?
https://stackoverflow.com/questions/72807833
复制相似问题