CppCon 2015 学习:C++ devirtualization in clang

C++ 中的去虚拟化 (Devirtualization) 在 Clang 中的实现

去虚拟化是 C++ 中的一种优化技术,它使得编译器能够消除虚拟函数调用,从而通过在编译时解析虚拟函数调用来提高性能。这种优化可以减少与虚拟函数派发相关的运行时开销。

去虚拟化的原理

  1. 虚拟函数和 Vtable
    • 在 C++ 中,虚拟函数是通过 vtable(虚函数表)来调度的,vtable 是一个存储函数指针的表。
    • 当我们使用基类指针或引用调用虚拟函数时,编译器会使用 vtable 在运行时查找并调用正确的函数。
  2. 去虚拟化
    • 去虚拟化是指当编译器能够确定对象的确切类型时,它会将虚拟函数调用转变为直接调用。也就是说,编译器用一个直接的函数调用来代替虚拟函数的查找和调用。
    • 这种做法是可行的,如果编译器知道一个基类指针/引用指向的是特定的派生类对象,编译器可以在编译时确定应该调用哪个函数。

去虚拟化的场景

Clang 可以在几种情况下执行去虚拟化:

  1. 对象类型在编译时已知

    • 如果对象是局部对象,或者编译器可以推断出对象的类型(例如在 if 语句或循环中),那么编译器可以将虚拟函数调用替换为直接调用。
    cpp 复制代码
    class Base {
    public:
        virtual void foo() {
            std::cout << "Base foo\n";
        }
    };
    class Derived : public Base {
    public:
        void foo() override {
            std::cout << "Derived foo\n";
        }
    };
    void test() {
        Derived d;
        Base* b = &d;  // 此时,`b` 指向的是 `Derived` 类型对象
        b->foo();  // 这可以去虚拟化,直接调用 `Derived::foo`
    }
  2. 调用的上下文中对象类型已知

    • 例如,虚拟函数在某个条件语句或循环中被调用,而对象的类型在之前的代码中已确定,Clang 可以将虚拟函数调用替换为直接调用。
  3. 内联扩展

    • 有时,去虚拟化会导致函数被内联,尤其是当函数体较小且调用的是编译时已知的类型时。
  4. 使用 finaloverride 关键字

    • 如果类标记了虚拟函数为 final(表示不允许进一步重写),编译器通常可以进行去虚拟化,因为它可以确定最终调用的函数。
    cpp 复制代码
    class Base {
    public:
        virtual void foo() final {
            std::cout << "Base foo\n";
        }
    };
    // 不允许派生类重写 `foo()` 方法

Clang 中的去虚拟化优化

Clang 会在几个常见场景中执行去虚拟化优化:

  1. 简单的局部变量
    • 如果虚拟函数在类型已知的对象上被调用,Clang 可以用直接调用替换虚拟调用。
  2. 内联代码
    • 如果函数体较小且对象的类型已固定,Clang 可能会在去虚拟化之后对函数进行内联优化。
  3. 基于条件/循环
    • 如果虚拟函数被调用的上下文能够确定对象类型(例如,在条件判断或循环中),Clang 可以将虚拟调用转化为直接调用。

去虚拟化的好处

  1. 性能提升
    • 去虚拟化最大的好处是提升性能。虚拟函数调用涉及查找 vtable,这会带来一定的开销。通过将虚拟函数调用替换为直接调用,去除了这种运行时的开销,能够显著提高程序性能。
  2. 减少代码体积
    • 当去虚拟化发生时,虚拟函数调用被直接调用替换,程序中调用的次数减少。这也有助于减少代码体积,因为不再需要间接跳转(vtable 查找)。
  3. 更好的内联化
    • 去虚拟化也允许编译器将函数内联,进一步优化性能,减少函数调用并启用更多的优化。

去虚拟化的限制

  1. 并非总能去虚拟化
    • 去虚拟化只有在编译器能够在编译时确定对象类型时才有可能。如果对象的类型是动态的(即运行时确定),Clang 无法去虚拟化调用。
  2. 复杂的对象层次结构
    • 如果对象层次结构非常复杂,有很多继承层次和多个重写的虚拟函数,编译器可能无法确定调用哪个函数,因此无法进行去虚拟化。
  3. 跨模块的多态性问题
    • 如果对象类型在跨共享库或动态加载模块时动态确定,去虚拟化就不可能实现。

Clang 的去虚拟化技术

Clang 使用一系列技术来执行去虚拟化:

  • 静态分析
    Clang 分析程序,确定是否可以在编译时解决虚拟函数调用。
  • 别名分析
    它试图确定虚拟函数调用所使用的指针或引用是否始终指向同一派生类对象。
  • 内联化
    在某些情况下,Clang 在去虚拟化之后会内联函数,进一步优化代码。
  • 基于配置文件的优化 (PGO)
    在某些情况下,Clang 可以利用配置文件中的运行时分析信息来帮助去虚拟化,尤其是当运行时分析提供了对象类型的相关信息时。

总结

Clang 中的去虚拟化是一种重要的优化技术,它通过消除虚拟函数调用的开销来显著提高 C++ 程序的性能。然而,去虚拟化并非总是可能的,它依赖于编译器是否能够在编译时确定对象的类型。通过静态分析、内联化和配置文件优化等技术,Clang 能够在适用时有效地进行去虚拟化。

为什么虚拟化解除(Devirtualization)很重要?

虚拟化解除 是一种优化技术,它对于提高 C++ 程序的性能非常重要。下面是为什么它这么关键的原因:

1. 为了内联虚拟函数

  • 内联(Inlining) 是一种优化技巧,它将函数体直接插入到函数调用的地方,而不是执行传统的函数调用。虚拟函数由于需要在运行时动态决定调用的函数(即 动态分派),通常不能进行内联。
  • 然而,当 虚拟化解除 发生时,编译器可以将虚拟函数调用替换为 直接函数调用 。如果编译器可以通过静态分析、已知的对象类型或条件逻辑来确定对象的确切类型,就能够在编译时 解除虚拟化 ,并将函数内联,这会 加速程序 的执行。

2. 即使不进行内联,优化器也能利用直接调用

  • 即使无法进行内联,解除虚拟化 仍然能让编译器将虚拟函数调用转为 直接调用,这比虚拟调用更高效,因为直接调用不需要动态查找函数地址。
  • 在没有虚拟表(vtable)查找的情况下,直接调用通常会更快,减少了运行时的开销。

3. 可以让二进制文件更小

  • 虚拟化解除 可以帮助移除一些不必要的运行时机制,如虚拟表(vtable)指针和动态分派代码,从而 减小二进制文件的大小
  • 由于解除虚拟化使得编译器能够生成更简单、更直接的代码,这有助于减少程序的内存占用,进一步提升性能。

总结:

  • 解除虚拟化 可以通过内联虚拟函数和减少不必要的运行时开销来 提升程序性能
  • 即使不进行内联,直接调用也能减少函数调用的开销,提高执行效率。
  • 此外,它还能 减小二进制文件的大小 ,节省存储空间。
    最终,所有这些好处都可以 带来显著的性能提升,使程序运行得更快、更高效。

如何工作:虚拟函数调用(Virtual Calls)

虚拟函数调用是通过 虚拟表(vtable) 实现的,这种机制允许 C++ 在运行时动态决定应该调用哪个函数。我们可以通过您的示例来详细理解虚拟函数调用的工作原理。

1. 结构体定义和虚拟函数

cpp 复制代码
struct A {
    virtual void foo();
};
  • 在这里,我们定义了一个结构体 A,其中有一个虚拟函数 foo()。由于 foo() 是虚拟函数,这意味着它的实现可能会在继承类中被重写,并且调用 foo() 的行为将在运行时动态决定。

2. 虚拟函数调用(g函数)

cpp 复制代码
void g(A *a) {
    a->foo();  // 虚拟函数调用
}
  • 这个函数 g 接收一个指向 A 的指针 a,然后调用 a->foo(),这实际上是一个虚拟函数调用。尽管 a 的类型是 A*,它可能指向一个派生类的对象(假设 B 类继承了 A 类并重写了 foo()),因此,foo() 的具体实现是在运行时动态决定的。

3. 低级实现:vtable 和虚拟函数调用

接下来,低级的 LLVM IR(中间表示)代码展示了虚拟函数调用的实现:

llvm 复制代码
%vtable = load void (%struct.A*)**, void (%struct.A*)*** %a, align 8
%1 = load void (%struct.A*)*, void (%struct.A*)** %vtable, align 8
call void %1(%struct.A* %a)
  • 第1行%vtable = load void (%struct.A*)**, void (%struct.A*)*** %a, align 8
    • vtable 是虚拟表(virtual table)的指针,它存储了该对象的虚拟函数的地址。对于每个类,编译器会为其生成一个虚拟表(vtable),虚拟表中的每一项指向类的虚拟函数。
    • 这里,%a 是指向对象的指针,%a 指向的是 A 类的实例或者其派生类的实例。通过 load 指令,程序加载了 a 对象的虚拟表指针。
  • 第2行%1 = load void (%struct.A*)*, void (%struct.A*)** %vtable, align 8
    • 在这一行,程序从虚拟表 vtable 中加载了函数指针。vtable 中的每个项都存储着虚拟函数的地址。通过 load,程序获取了 foo() 函数的指针。
  • 第3行call void %1(%struct.A* %a)
    • 最终,这一行通过调用虚拟函数的指针,执行了 foo() 函数。这是一个直接的虚拟函数调用,通过指向正确函数的指针来调用它。此时,%1 实际上是 foo() 函数的地址。

4. 虚拟表(vtable)的作用

虚拟表(vtable)是实现 多态性动态绑定 的核心机制。在程序运行时,虚拟表指向具体的函数实现。每当你通过基类指针或引用调用虚拟函数时,程序都会根据指针指向的对象的实际类型,查找虚拟表并调用正确的函数。

总结虚拟函数调用的工作流程:
  • 每个类(包含虚拟函数的类)会有一个虚拟表(vtable),虚拟表中存储着虚拟函数的指针。
  • 当你调用虚拟函数时(如 a->foo()),程序会查找对象的虚拟表,找到相应的函数指针。
  • 通过该函数指针,程序执行实际的函数实现(可能是基类或派生类的实现)。
    这种机制确保了 运行时多态性,使得即使在基类指针或引用的情况下,仍能调用到正确的派生类方法。

旧的去虚拟化(Old Devirtualization)

我们讨论的这一段代码涉及了 C++ 中的 去虚拟化(devirtualization)优化。去虚拟化的优化可以消除虚拟函数调用的间接性,使得函数调用变得更加高效。在这里,编译器通过识别某些模式来确定是否可以将虚拟函数调用转换为直接函数调用。

1. 基本的去虚拟化(Direct Call)
cpp 复制代码
struct A {
    virtual void foo();
};
void f() {
    A a;
    a.foo(); // 第一次调用 foo
    a.foo(); // 第二次调用 foo
}

在这个例子中,A 类包含虚拟函数 foo(),而在函数 f() 中我们创建了 A 类的一个对象 a 并调用了 a.foo() 两次。

  • 去虚拟化发生的情况 :在这种情况下,如果编译器能够确定 a 始终是 A 类型的对象,那么编译器会将虚拟函数调用 a.foo() 转换为直接调用 A::foo()。这样,调用不再通过虚拟表(vtable)进行间接跳转,而是直接调用目标函数,提高了性能。
2. 函数内联(Inlining)使去虚拟化生效
cpp 复制代码
struct A {
    virtual void foo();
};
void A::foo() { }
void g(A& a) {
    a.foo();
}
void f() {
    A a;
    g(a);
}

在这个例子中,虚拟函数 foo() 的定义已经明确,并且 g() 函数调用了 foo()

  • 为什么去虚拟化有效 :编译器能够在编译期间将 g(a) 进行内联(inline),这意味着函数 g 的内容会直接插入到 f() 中,从而使得 foo() 的调用可以直接定位到 A::foo(),而不需要通过虚拟表。因为 foo() 已经在同一个翻译单元(TU)中定义,编译器能够确定它的确切实现。
3. 构造函数外部化(Constructor Outline)
cpp 复制代码
struct A {
    A();
    virtual void foo();
};
void A::foo() { }
void g(A& a) {
    a.foo();
}
void f() {
    A a;
    g(a);
}

在这个例子中,我们增加了 A 的构造函数。

  • 为什么去虚拟化失败 :虽然 foo() 是虚拟函数,编译器不能在 g(a) 中进行去虚拟化。原因在于构造函数 A() 是外部的(即不在 foo() 所在的同一个翻译单元内)。编译器无法在构造时确保 vptr(虚拟指针)不会被修改,因此它无法确定 foo() 具体指向哪个函数。
4. 虚拟函数更改 vptr
cpp 复制代码
struct A {
    virtual void bar();
    virtual void foo();
};
void A::bar() { }
void g(A& a) {
    a.foo();
    a.foo();
}
void f() {
    A a;
    g(a);
}

在这个例子中,我们引入了第二个虚拟函数 bar()

  • 为什么去虚拟化失败 :编译器无法确定 foo() 是否会修改 vptr。如果 foo() 被重写并且修改了 vptr,那么每次调用 foo() 都有可能指向不同的实现。由于这种不确定性,编译器无法将 foo() 调用转换为直接调用。
5. 虚拟表外部化
cpp 复制代码
struct A {
    virtual void foo();
    virtual ~A() = default;
};
void g(A& a) {
    a.foo();
}
void f() {
    A a;
    g(a);
}

在这个例子中,我们引入了虚拟析构函数 ~A()

  • 为什么去虚拟化失败 :虚拟表(vtable)被标记为外部(external),这意味着虚拟表并不嵌入到当前翻译单元中。由于虚拟表在外部,编译器无法静态地确定 foo() 的确切目标,因此它无法将虚拟函数调用转换为直接调用。
总结

旧的去虚拟化优化依赖于编译器能够识别对象类型、内联函数、以及虚拟表的管理方式。当存在以下情况时,去虚拟化可能不会生效:

  1. 构造函数 :如果构造函数无法在同一翻译单元内定义,编译器无法保证 vptr 的一致性。
  2. 虚拟函数修改 vptr:如果虚拟函数的调用可能修改虚拟表指针,编译器无法静态地确定函数的目标。
  3. 虚拟表外部化 :如果虚拟表在外部,编译器无法静态解析虚拟函数调用。
    去虚拟化优化可以大大提高性能,但它也依赖于编译器对程序结构的深入分析。

虚拟函数调用的底层实现

首先,我们来看一下调用相同的虚拟函数的情况。

调用相同的虚拟函数:
cpp 复制代码
struct A {
    virtual void foo();
};
void g(A * a) {
    a->foo(); // 第一次调用
    a->foo(); // 第二次调用
}

在这个代码中,我们定义了一个虚拟函数 foo(),并在函数 g() 中对同一个对象 a 调用了两次该虚拟函数。

编译器对这种情况的底层实现可以如下所示(伪汇编):

assembly 复制代码
%vtable = load void (%struct.A*)**, void (%struct.A*)*** %a, align 8
%1 = load void (%struct.A*)*, void (%struct.A*)** %vtable, align 8
call void %1(%struct.A* %a)
%vtable2 = load void (%struct.A*)**, void (%struct.A*)*** %a, align 8
%2 = load void (%struct.A*)*, void (%struct.A*)** %vtable2, align 8
call void %2(%struct.A* %a)

可以看到,这里的虚拟函数 foo() 通过虚拟表(vtable)进行间接调用。每次调用时,我们都需要从对象中加载虚拟表指针(%vtable),然后获取虚拟函数的地址,最终调用目标函数。

  • 性能问题:每次调用虚拟函数都需要进行间接跳转(通过虚拟表指针),这种方式比直接调用函数要慢。

Placement new 可能导致的问题

接下来,我们讨论了一个例子,展示了 placement new 可能导致的未定义行为(undefined behavior)。

cpp 复制代码
struct A {
    virtual void foo();
};
struct B : A {
    virtual void foo();
};
void A::foo() { new(this) B; }  // 在 .cc 文件中定义
void g() {
    A *a = new A;  
    a->foo();
    a->foo();  // 这里会出现未定义行为
}

在这个代码中,A::foo() 使用了 placement new 来在当前对象(this)的内存上构造一个 B 类型的对象。这是一种高级技巧,用于在已分配内存上手动创建对象。

  • 未定义行为 :在第二次调用 a->foo() 时,由于 foo() 中使用了 new(this) B;,它会创建一个新的 B 类型的对象,并且可能会修改对象的虚拟表指针(vptr)。这会导致第二次调用时无法确定 vptr 的值,可能会导致访问无效内存或执行错误的函数。

安全使用 placement new

接下来看另一个例子,其中 placement new 被用于返回一个新的对象,但没有引起未定义行为:

cpp 复制代码
struct A {
    virtual A* foo();
};
struct B : A {
    virtual A* foo();
};
A* A::foo() { return new(this) B; }
void g() {
    A *a = new A;  
    A *a2 = a->foo();  // 正常执行
    a2->foo();          // 这也是合法的
}

这里 A::foo() 使用了 placement new 来在当前对象上创建一个 B 类型的对象,并返回一个指向 B 类型的指针。

  • 没有未定义行为 :在这个例子中,第二次调用 a2->foo() 是合法的,因为 foo() 返回的是一个新的 B 类型对象。编译器可以安全地知道对象的类型和虚拟表指针 vptr 的位置。

虚拟表指针的处理

当使用 placement new 时,我们通常希望能确保虚拟表指针 (vptr) 在构造函数调用之后正确设置。在以下的汇编示例中,编译器希望能够确定 vptr 的值:

assembly 复制代码
call void @_ZN1AC1Ev(%struct.A* %a)
%vtable = load i64*, i64** %a, align 8
%cmp = icmp eq i64* %vtable, @vtable
tail call void @llvm.assume(i1 %cmp)
%vtable1 = load void (%struct.A*)**, void (%struct.A*)*** %a, align 8
  • vptr 确定性 :通过 @llvm.assume,编译器可以假定在构造函数调用后,虚拟表指针的值是稳定的,从而优化后续的虚拟函数调用。

available_externally 的问题

在一些特殊情况下,虚拟表可能被标记为 available_externally,这意味着虚拟表的定义可能在其他地方。这种情况下,编译器无法静态解析虚拟表的内容,导致去虚拟化失败。

例如:

assembly 复制代码
@vtable for A = external unnamed_addr constant [6 x i8*]
@vtable for a = available_externally unnamed_addr constant [6 x i8*] (definition)
  • 问题:如果虚拟表被外部定义,编译器无法保证虚拟表的内容,在这种情况下,编译器不能安全地进行去虚拟化优化。

去虚拟化优化的限制

去虚拟化优化存在一些限制,特别是在以下几种情况下:

  1. 类有内联虚拟函数 :内联虚拟函数会改变 vptr 的处理方式,使得编译器无法静态确定函数目标。
  2. 类有隐藏的虚拟函数 :使用 __attribute__((visibility("hidden"))) 隐藏的虚拟函数会使虚拟表的解析更加困难,进而影响去虚拟化。

总结

  • 去虚拟化的好处:通过去虚拟化,编译器可以将虚拟函数调用转化为直接调用,从而减少间接跳转,提高性能。
  • placement new 的陷阱 :使用 placement new 时需要小心,特别是在虚拟函数调用中,因为它可能会修改 vptr,导致未定义行为。
  • 虚拟表和去虚拟化 :虚拟表的外部化和虚拟函数的内联会影响去虚拟化的效果,编译器无法在这些情况下进行去虚拟化优化。
    去虚拟化是一种非常重要的优化技术,可以提升性能,但需要理解其工作原理和适用场景,以便在代码中正确地使用它。

介绍 !invariant.group

!invariant.group 是 LLVM 中用于标记某些值或对象的一种机制,它表明这些值在某些操作过程中始终保持不变。这个标记对于优化和去虚拟化(devirtualization)非常重要,特别是在虚拟函数的调用过程中,它可以帮助编译器理解某些数据结构在整个程序中保持一致性,从而进行更多的优化。

具体示例分析:

假设我们有如下的 LLVM IR 代码,它使用了 !invariant.group 来标记一些值:

assembly 复制代码
%vtable = load i64*, i64** %a, align 8, !invariant.group !0
%1 = load void (%struct.A*)*, void (%struct.A*)** %vtable, align 8
call void %1(%struct.A* %a)
%vtable2 = load i64*, i64** %a, align 8, !invariant.group !0
%2 = load void (%struct.A*)*, void (%struct.A*)** %vtable2, align 8
call void %2(%struct.A* %a)
代码解读:
  1. 加载虚拟表指针:

    assembly 复制代码
    %vtable = load i64*, i64** %a, align 8, !invariant.group !0
    %vtable2 = load i64*, i64** %a, align 8, !invariant.group !0
    • 这里,%vtable%vtable2 都从指针 %a 中加载了虚拟表(vtable)的指针。
    • !invariant.group !0 这部分标记说明,在程序的执行过程中,这些虚拟表指针值是保持不变的。
  2. 调用虚拟函数:

    assembly 复制代码
    %1 = load void (%struct.A*)*, void (%struct.A*)** %vtable, align 8
    call void %1(%struct.A* %a)
    %2 = load void (%struct.A*)*, void (%struct.A*)** %vtable2, align 8
    call void %2(%struct.A* %a)
    • 在每次调用虚拟函数时,编译器从虚拟表指针中加载虚拟函数的地址,并使用该地址调用相应的虚拟函数。
    • 由于 !invariant.group 的存在,编译器知道 vtable 在这两次调用中是相同的,因此可以进行一些优化。
!invariant.group 的作用:
  • 优化提示: !invariant.group 告诉编译器这些加载的值在执行过程中不会发生变化。这意味着,编译器可以安全地将相同的值(如虚拟表指针)视为恒定的,从而减少重复加载的开销,甚至可能将两次对同一虚拟表的加载合并为一次。
  • 去虚拟化的帮助: 如果虚拟表指针不会改变,编译器可以进行去虚拟化优化。也就是说,如果编译器确认虚拟表指针在不同的调用之间保持不变,那么它可以将虚拟函数的调用直接转化为普通的函数调用,从而提高执行效率。

为什么 !invariant.group 很重要?

  • 减少不必要的加载: 标记为 !invariant.group 的值不会改变,因此可以减少对同一值的多次加载,进而减少 CPU 的缓存失效和内存访问次数。
  • 提高去虚拟化的准确性: 如果编译器知道虚拟表指针是不可变的,它可以更容易地将虚拟函数调用转化为直接调用,这样就能提升程序的性能。

总结:

!invariant.group 是一个重要的优化提示,用于告知编译器某些值在执行过程中不会变化。这使得编译器能够做出更有效的优化决策,特别是在涉及虚拟表和虚拟函数调用时,能够减少不必要的加载和访问,从而提升程序的性能。

处理 Placement New

在 C++ 中,placement new 是一种允许在已分配的内存区域上构造对象的技术。通过 placement new,你可以在一个特定的位置(例如预分配的内存区域)上创建对象,而不是让 new 操作符自动分配内存。

示例:
cpp 复制代码
int main() {
  MyClass c;
  c.foo(); // 调用 MyClass 的 foo 方法
  // 使用 placement new 在已分配的内存上创建 MyOtherClass 对象
  auto c2 = new (&c) MyOtherClass();
  c2->foo(); // 调用 MyOtherClass 的 foo 方法
  // 手动调用析构函数
  c2->~MyOtherClass();
  // 使用 placement new 在同一内存位置重新构造 MyClass 对象
  new (&c) MyClass();
}
关键点:
  1. Placement New: 语法 new (&c) 使得 MyOtherClass 对象被创建在 MyClass 对象 c 已经占用的内存位置上。这样没有重新分配内存,只是重新构造了对象。
  2. 手动调用析构函数: c2->~MyOtherClass(); 在这里手动调用了 MyOtherClass 的析构函数,因为 placement new 不会自动调用析构函数。
  3. 重新构造: 最后通过 new (&c) MyClass(); 在同样的内存区域上重新构造了一个 MyClass 对象。注意,这种做法并不会自动销毁先前的对象,因此手动销毁是必要的。

引入 invariant.group.barrier

invariant.group.barrier 是一个用于帮助编译器进行优化的机制,它可以明确表示某些数据结构的状态不会变化,从而帮助编译器进行更精准的优化。例如,告诉编译器某个对象的值在一段时间内保持不变,这对于处理复杂的内存操作和多次重构对象的情况非常重要。

在你提供的代码示例中,invariant.group.barrier 被用来标记在 placement new 操作之后,内存区域的内容应该被视为一个"屏障",确保在继续操作之前,编译器不会错误地优化掉相关的状态变化。

示例中的 invariant.group.barrier
assembly 复制代码
auto c2 = new (&c) MyOtherClass(); 
call void @_ZN7MyClassD1Ev(%struct.MyClass* nonnull %c) #1 
%2 = bitcast %struct.MyClass* %c to %struct.MyOtherClass* 
%3 = call @llvm.invariant.group.barrier(%2)
  • %3 = call @llvm.invariant.group.barrier(%2) 这一行调用了 llvm.invariant.group.barrier,表明在 MyClass 被重用为 MyOtherClass 对象之后,编译器需要确保该内存区域不被优化掉。

作用和目的:

  1. 防止优化错误: 在多次使用 placement new 时,编译器可能会对内存做出假设,比如内存已经被清除或不再有效。使用 invariant.group.barrier 可以明确告诉编译器,某个内存区域在重构之后仍然有效,不应该被误删或优化。
  2. 保证内存一致性: 这个"屏障"确保了在进行进一步操作(如析构、重构等)之前,编译器不会对对象的生命周期做出错误的假设。
  3. 增强优化能力: 对于一些需要多次构造和析构的复杂操作,invariant.group.barrier 提供了一种优化方式,使编译器能够处理这些复杂操作,而不至于让中间状态出现错误。

总结:

  • Placement new 允许你在已分配的内存上重构对象,而不是重新分配内存。在这种操作中需要特别注意手动销毁对象。
  • invariant.group.barrier 是一个优化工具,它告诉编译器某些数据结构的值在特定的范围内不会改变。这个机制有助于防止编译器在复杂的内存操作中做出错误的优化假设,尤其是在使用 placement new 等操作时。
    通过结合使用 placement newinvariant.group.barrier,你可以确保程序的内存操作更高效且更安全。

Introducing -fstrict-vtable-pointers

-fstrict-vtable-pointers 是一个编译器优化选项,它使得编译器更加严格地处理虚拟函数表(vtable)指针。该选项通常用于优化虚拟函数调用的性能,通过提前知道对象的确切类型,从而提高性能。

在 C++ 中,虚拟函数的调用通常通过虚拟函数表(vtable)来实现。每个包含虚拟函数的类都有一个虚拟函数表,指向该类的虚拟函数实现。虚拟函数调用通常会经过 vtable 查找,导致间接调用,这在某些场景下可能会影响性能。

启用 -fstrict-vtable-pointers 后,编译器会尽可能地避免虚拟函数的间接调用,通过更好地推测对象的实际类型和虚拟函数表,从而实现 去虚拟化(devirtualization)。

Corner Case Example

代码示例:
cpp 复制代码
void g() {
  A *a = new A;     // 创建 A 类型的对象
  a->foo();         // 调用 A 的 foo 函数
  A *b = new(a) B;  // 在 a 的内存区域构造 B 类型的对象
  assert(a == b);   // 断言 a 和 b 是同一内存位置,即 B 对象覆盖了 A 对象
  b->foo();         // 调用 B 的 foo 函数
}
解释:
  1. new(a) B; :这里的 placement new 操作会在已经分配给 a 的内存上重构一个 B 类型的对象。也就是说,ab 都指向同一块内存区域,只不过 a 指向的是原始的 A 类型对象,而 b 作为 B 类型对象被重建到了相同的位置。
  2. assert(a == b); :由于 ab 指向的是同一个内存位置,因此这行断言通过。无论如何,内存位置 ab 是一样的。
  3. 去虚拟化的可能性 :在 b->foo(); 调用中,a 实际上已经被转换为 B 类型对象。由于 a == b,并且 B 类型的对象被显式地构造在了 A 类型的内存位置上,编译器能够推断出:这两个对象的类型实际上是相同的,并且 foo() 函数实际上会调用 B 类型的实现。
    因此,编译器可以 去虚拟化 该函数调用,将其转换为直接调用,而不需要通过虚拟表来间接调用。这显著提高了程序的执行效率。

Optimizations and Further Improvements

  • 去虚拟化的过程 :启用 -fstrict-vtable-pointers 后,编译器可以更好地理解对象的实际类型。它能从 a == b 这一点推测出 ab 都是 B 类型对象,因此 foo() 函数调用将直接转为对 B 类型的调用,从而避免虚拟函数表查找的开销。
  • !invariant.group 的作用 :在这个过程中,invariant.group 可能会进一步优化。invariant.group 是用于帮助编译器知道某些对象的类型和生命周期不会在一段时间内发生变化的机制。在这种情况下,编译器可以更加可靠地推测对象类型,并进行更深层次的优化,减少不必要的虚拟函数调用或间接调用。

总结

  1. -fstrict-vtable-pointers 的作用:启用此选项后,编译器将更加严格地管理虚拟函数表指针,并且能够更好地推测对象的实际类型,从而进行去虚拟化优化。
  2. 去虚拟化 :通过推测 a == b,编译器可以将虚拟函数调用转变为直接调用,减少性能损失。
  3. 进一步优化(!invariant.group :在未来的优化中,!invariant.group 将可能发挥重要作用,帮助编译器进一步优化虚拟函数调用,并确保对象类型推测的准确性。
    启用 -fstrict-vtable-pointers 和理解 invariant.group 的作用,可以帮助你更好地优化 C++ 程序中的虚拟函数调用,提升性能。
相关推荐
陈旭金-小金子1 小时前
发现 Kotlin MultiPlatform 的一点小变化
android·开发语言·kotlin
GISDance1 小时前
2025年高考志愿填报指导资料
学习·考研·高考
呃m1 小时前
双重特征c++
c++
Mikhail_G1 小时前
Python应用八股文
大数据·运维·开发语言·python·数据分析
景彡先生1 小时前
C++ 中文件 IO 操作详解
开发语言·c++
weixin_464078072 小时前
Python学习小结
python·学习
你怎么知道我是队长2 小时前
GO语言---defer关键字
开发语言·后端·golang
无影无踪的青蛙2 小时前
[C++] STL大家族之<map>(字典)容器(附洛谷)
开发语言·c++
二进制人工智能2 小时前
【OpenGL学习】(四)统一着色和插值着色
c++·opengl
a4576368762 小时前
Objective-c protocol 练习
开发语言·macos·objective-c