C++/编译:是否可以设置vptr的大小(全局vtable + 2字节索引)?

17

我最近发布了一个有关C++中虚拟机制导致的内存开销的问题。答案让我明白了虚函数表和虚函数指针是如何工作的。

我的问题是:我在超级计算机上工作,我有数十亿个对象,因此我必须关心由虚拟机制引起的内存开销。经过一些测量,当我使用具有虚函数的类时,每个派生对象都有它的8字节vptr。这绝非微不足道。

我想知道intel icpc或g ++是否有一些配置选项/参数,可以使用“全局”虚函数表和可调精度的索引,而不是vptr。因为这样的事情将使我能够针对数十亿个对象使用2字节索引(unsigned short int),而不是8字节vptr(并大大减少内存开销)。是否有任何编译选项可以做到这一点(或类似这样的东西)?

非常感谢。


6
好问题。我几乎可以确定答案是“不”。也许你可以使用模板来摆脱运行时多态性。 - sharptooth
1
你是否真的需要64位地址空间,或者可以编译32位可执行文件?这将把vtable指针减半。实际上Sharptooth给出了最好的答案。另一种选择是将数据移出对象,但这可能不切实际。 - Ben
1
@Ben,“你真的无法在32位地址空间中容纳“数十亿个对象”。 - n. m.
3
你真的需要多态类吗?也许重新设计代码比试图修改虚函数的工作方式更能提高效率。 - Kerrek SB
1
@Kerrek:毫无疑问,如果编译器原则上可以自动完成这个过程,那么程序员可以通过将所有虚函数替换为函数指针表中的条目,将所有虚调用替换为通过这样的表间接调用,并在每个对象的开头放置正确表的索引来手动完成它。但是,如果有一个gcc选项可以做到这一点,我肯定会选择对makefile进行一行修改,而不是重新设计大型代码库 :-) - Steve Jessop
显示剩余7条评论
1个回答

17

很遗憾,这不会自动完成。

但请记住,虚函数表只是运行时多态的语法糖。如果您愿意重新设计代码,有几种替代方案。

  1. 外部多态
  2. 手工虚函数表
  3. 手工多态

1)外部多态

这个想法是有时您只需要暂时使用多态。例如:

std::vector<Cat> cats;
std::vector<Dog> dogs;
std::vector<Ostrich> ostriches;

void dosomething(Animal const& a);

在这种情况下,将虚拟指针嵌入CatDog似乎是浪费的,因为您知道它们的动态类型(它们存储为值)。

外部多态性涉及纯具体类型和纯接口,以及中间的简单桥梁,用于临时(或永久,但这不是您想要的)将具体类型适配到接口。

// Interface
class Animal {
public:
    virtual ~Animal() {}

    virtual size_t age() const = 0;
    virtual size_t weight() const = 0;

    virtual void eat(Food const&) = 0;
    virtual void sleep() = 0;

private:
    Animal(Animal const&) = delete;
    Animal& operator=(Animal const&) = delete;
};

// Concrete class
class Cat {
public:
    size_t age() const;
    size_t weight() const;

    void eat(Food const&);
    void sleep(Duration);
};

这个桥梁只需要编写一次即可:

template <typename T>
class AnimalT: public Animal {
public:
    AnimalT(T& r): _ref(r) {}

    virtual size_t age() const override { return _ref.age(); }
    virtual size_t weight() const { return _ref.weight(); }

    virtual void eat(Food const& f) override { _ref.eat(f); }
    virtual void sleep(Duration const d) override { _ref.sleep(d); }

private:
    T& _ref;
};

template <typename T>
AnimalT<T> iface_animal(T& r) { return AnimalT<T>(r); }

你可以这样使用它:

for (auto const& c: cats) { dosomething(iface_animal(c)); }

它每个项目都会产生两个指针的开销,但只要您需要多态性。

另一种选择是让AnimalT<T>也使用值(而不是引用)并提供一个clone方法,这样您可以根据情况完全选择是否有v指针。

在这种情况下,我建议使用一个简单的类:

template <typename T> struct ref { ref(T& t): _ref(t); T& _ref; };

template <typename T>
T& deref(T& r) { return r; }

template <typename T>
T& deref(ref<T> const& r) { return r._ref; }

然后稍微修改一下桥:

template <typename T>
class AnimalT: public Animal {
public:
    AnimalT(T r): _r(r) {}

    std::unique_ptr< Animal<T> > clone() const { return { new Animal<T>(_r); } }

    virtual size_t age() const override { return deref(_r).age(); }
    virtual size_t weight() const { return deref(_r).weight(); }

    virtual void eat(Food const& f) override { deref(_r).eat(f); }
    virtual void sleep(Duration const d) override { deref(_r).sleep(d); }

private:
    T _r;
};

template <typename T>
AnimalT<T> iface_animal(T r) { return AnimalT<T>(r); }

template <typename T>
AnimalT<ref<T>> iface_animal_ref(T& r) { return Animal<ref<T>>(r); }

这样,您可以选择何时需要多态存储,何时不需要。


2)手动制作的v-tables

(仅在封闭层次结构上容易使用)

在C语言中,通过提供自己的v-table机制来模拟面向对象编程是很常见的。如果您知道什么是v-table以及v-pointer如何工作,那么您完全可以自己实现它。

struct FooVTable {
    typedef void (Foo::*DoFunc)(int, int);

    DoFunc _do;
};

然后在以 Foo 为锚点的层次结构中提供一个全局数组:

extern FooVTable const* const FooVTableFoo;
extern FooVTable const* const FooVTableBar;

FooVTable const* const FooVTables[] = { FooVTableFoo, FooVTableBar };

enum class FooVTableIndex: unsigned short {
    Foo,
    Bar
};

然后您在Foo类中所需的就是保持最终派生类型:
class Foo {
public:

    void dofunc(int i, int j) {
        (this->*(table()->_do))(i, j);
    }

protected:
    FooVTable const* table() const { return FooVTables[_vindex]; }

private:
    FooVTableIndex _vindex;
};

由于 FooVTables 数组和 FooVTableIndex 枚举需要知道层次结构中所有类型的信息,所以存在封闭的层次结构。

然而,可以绕过枚举索引,并通过使数组非常量来预先初始化到更大的大小,然后在初始化时让每个派生类型自动注册到其中。因此,在此初始化阶段检测索引冲突,甚至可以进行自动解决(扫描数组以查找空闲插槽)。

这可能不太方便,但确实提供了一种打开层次结构的方法。显然,在启动任何线程之前编写代码更容易,因为我们在这里谈论全局变量。


3) 手工多态

(只适用于封闭的层次结构)

后者是基于我探索 LLVM/Clang 代码库的经验。编译器面临的问题与您面临的问题完全相同:对于成千上万的小项目,每个项目的虚函数指针真的会增加内存消耗,这很烦人。

因此,他们采取了一个简单的方法:

  • 每个类层次结构都有一个伴随的 enum 列出所有成员
  • 层次结构中的每个类在构造时将其伴随的 enumerator 传递给其基类
  • 通过对 enum 进行切换并适当地进行转换来实现虚拟性

代码如下:

enum class FooType { Foo, Bar, Bor };

class Foo {
public:
    int dodispatcher() {
        switch(_type) {
        case FooType::Foo:
            return static_cast<Foo&>(*this).dosomething();

        case FooType::Bar:
            return static_cast<Bar&>(*this).dosomething();

        case FooType::Bor:
            return static_cast<Bor&>(*this).dosomething();
        }
        assert(0 && "Should never get there");
    }
private:
    FooType _type;
};

这些开关很烦人,但是可以通过一些宏和类型列表来实现更多或更少的自动化。LLVM通常使用类似以下的文件:

 // FooList.inc
 ACT_ON(Foo)
 ACT_ON(Bar)
 ACT_ON(Bor)

然后你需要执行:

 void Foo::dodispatcher() {
     switch(_type) {
 #   define ACT_ON(X) case FooType::X: return static_cast<X&>(*this).dosomething();

 #   include "FooList.inc"

 #   undef ACT_ON
     }

     assert(0 && "Should never get there");
 }

Chris Lattner评论说,由于开关是使用代码偏移量表生成的,因此产生的代码类似于虚分配,并且具有大约相同数量的CPU开销,但内存开销较低。

显然,唯一的缺点是Foo.cpp需要包含所有其派生类的头文件。这有效地封闭了层次结构。


我自愿从最开放的解决方案到最封闭的解决方案进行演示。它们具有各种复杂度/灵活性,您可以选择最适合您的那个。

一个重要的事情是,在后两种情况下,销毁和复制需要特别注意。


对于(1),您可能想要查看std::ref - Kerrek SB
@KerrekSB:谢谢,但是std::reference_wrapper有点长 :x - Matthieu M.
#3 如何节省空间?private: FooType _type; 不会占用与 vptr 差不多的空间吗? - Yay295
1
@Yay295:在C++03中,enum的大小由编译器决定(只保证足以表示所有枚举值)。它很少会有64位宽度,因此在64位体系结构上可能是一种胜利,并取决于32位体系结构上的编译器质量/保证。在C++11中,您可以使用enum class来决定支持存储的大小:以上应该使用uint8_t作为支持存储,因此为8位,比32位或64位更优。 - Matthieu M.
结构体/类的填充可能会抵消这些节省。 - Yay295
@Yay295:当然,但是当你开始担心从你的类中删减7个字节时,你就会注意填充了 :) - Matthieu M.

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接