NVI和错失良机

NVI and devirtualisation

本文关键字:错失良机 NVI      更新时间:2023-10-16

如果您使用的是NVI,编译器是否可以设置函数调用的机会?

一个例子:

#include <iostream>
class widget
{
public:
    void foo() { bar(); }
private:
    virtual void bar() = 0;
};
class gadget final : public widget
{
private:
    void bar() override { std::cout << "gadgetn"; }
};
int main()
{
    gadget g;
    g.foo();    // HERE.
}

在标记的那一行,编译器是否可以取消对bar的调用?

已知g的动态类型正是gadget,编译器可以在内联foo之后取消对bar的调用,而不管class gadget声明或gadget::bar声明中使用了final。我将分析这个不使用iostreams的类似程序,因为输出程序集更容易阅读:

class widget
{
public:
    void foo() { bar(); }
private:
    virtual void bar() = 0;
};
class gadget : public widget
{
    void bar() override { ++counter; }
public:
    int counter = 0;
};
int test1()
{
    gadget g;
    g.foo();
    return g.counter;
}
int test2()
{
    gadget g;
    g.foo();
    g.foo();
    return g.counter;
}
int test3()
{
    gadget g;
    g.foo();
    g.foo();
    g.foo();
    return g.counter;
}
int test4()
{
    gadget g;
    g.foo();
    g.foo();
    g.foo();
    g.foo();
    return g.counter;
}
int testloop(int n)
{
    gadget g;
    while(--n >= 0)
        g.foo();
    return g.counter;
}

我们可以通过检查输出程序集(GCC)、(clang)来确定去机会化的成功。两者都将test优化为等效的return 1;——调用被去虚拟化和内联,对象被消除。Clang分别对test2test4-return 2;/3/4执行相同的操作,但GCC必须执行优化的次数越多,似乎就会逐渐失去对类型信息的跟踪。尽管成功地将test1优化为返回常数,但test2大致变为:

int test2() {
    gadget g;
    g.counter = 1;
    g.gadget::bar();
    return g.counter;
}

第一个调用被取消了机会并内联了其效果(g.counter = 1),但第二个调用只是取消了机会。在test3中添加附加调用会导致:

int test3() {
    gadget g;
    g.counter = 1;
    g.gadget::bar();
    g.bar();
    return g.counter;
}

同样,第一个调用是完全内联的,第二个调用只是去了机会,但第三个调用根本没有优化。这是一个来自虚拟表和间接函数调用的纯Jane加载。对于test4:中的附加调用,结果相同

int test4() {
    gadget g;
    g.counter = 1;
    g.gadget::bar();
    g.bar();
    g.bar();
    return g.counter;
}

值得注意的是,两个编译器都没有在testloop的简单循环中实现调用,它们都将其编译为等效的:

int testloop(int n) {
  gadget g;
  while(--n >= 0)
    g.bar();
  return g.counter;
}

甚至在每次迭代时从对象重新加载vtable指针。

final标记添加到class gadget声明和gadget::bar定义中不会影响编译器(GCC)(clang)生成的程序集输出。

影响生成的程序集的是移除NVI。此程序:

class widget
{
public:
    virtual void bar() = 0;
};
class gadget : public widget
{
public:
    void bar() override { ++counter; }
    int counter = 0;
};
int test1()
{
    gadget g;
    g.bar();
    return g.counter;
}
int test2()
{
    gadget g;
    g.bar();
    g.bar();
    return g.counter;
}
int test3()
{
    gadget g;
    g.bar();
    g.bar();
    g.bar();
    return g.counter;
}
int test4()
{
    gadget g;
    g.bar();
    g.bar();
    g.bar();
    g.bar();
    return g.counter;
}
int testloop(int n)
{
    gadget g;
    while(--n >= 0)
        g.bar();
    return g.counter;
}

被两个编译器(GCC)(clang)完全优化为:

int test1()
{ return 1; }
int test2()
{ return 2; }
int test3()
{ return 3; }
int test4()
{ return 4; }
int testloop(int n)
{ return n >= 0 ? n : 0; }

总之,尽管编译器可以取消对bar的调用的机会,但在存在NVI的情况下,它们可能并不总是这样做。优化在当前编译器中的应用还不完善。

理论上是的,但这与NVI无关。在您的示例中,编译器理论上也可以对调用g.bar()进行去虚拟化。编译器唯一需要知道的是,对象是否真的是gadget类型,或者可能是其他类型。如果编译器可以推断它只能是g类型,那么它可以对调用进行去虚拟化。

但可能大多数编译器都不会尝试。