为什么 std::any 的实现使用函数指针 + 函数操作码,而不是指向虚拟 table + 虚拟调用的指针?

Why does the implementation of std::any use a function pointer + function op codes, instead of a pointer to a virtual table + virtual calls?

std::anyGCC and LLVM 实现都在 any 对象中存储一个函数指针,并使用 Op/Action 参数调用该函数执行不同的操作。这是来自 LLVM 的该函数的示例:

static void* __handle(_Action __act, any const * __this,
                          any * __other, type_info const * __info,
                          void const* __fallback_info)
    {
        switch (__act)
        {
        case _Action::_Destroy:
          __destroy(const_cast<any &>(*__this));
          return nullptr;
        case _Action::_Copy:
          __copy(*__this, *__other);
          return nullptr;
        case _Action::_Move:
          __move(const_cast<any &>(*__this), *__other);
          return nullptr;
        case _Action::_Get:
            return __get(const_cast<any &>(*__this), __info, __fallback_info);
        case _Action::_TypeInfo:
          return __type_info();
        }
        __libcpp_unreachable();
    }

注意:这只是一个__handle函数,但在每个any实现中有两个这样的函数:一个用于小对象(小缓冲区优化)在 any 内分配,一个分配给堆上的大对象。使用哪一个取决于any对象中存储的函数指针的值。

在 运行 时间选择两种实现之一并从预定义的方法列表中调用特定方法的能力本质上是虚拟 table 的手动实现。我想知道为什么要这样实施。简单地存储一个指向虚拟类型的指针不是更容易吗?

我找不到有关此实施原因的任何信息。考虑一下,我想使用虚拟 class 在两个方面是次优的:

这些是使用基于 switch-ing 操作码的实现的原因吗?当前实施还有其他主要优势吗?您是否知道有关此技术的 link 的一般信息?

考虑一个 std::any 的典型用例:您在代码中传递它,移动它几十次,将它存储在一个数据结构中,稍后再次获取它。特别是,您很可能 return 它来自函数 很多。

就像现在一样,指向单个“执行所有操作”函数的指针存储在 any 中的数据旁边。鉴于它是一个相当小的类型(在 GCC x86-64 上为 16 字节),any 适合一对寄存器。现在,如果你从一个函数 return 一个 any ,指向 any 的“做所有事情”函数的指针已经在寄存器或堆栈中了!您可以直接跳转到它而无需从内存中获取任何内容。最有可能的是,您甚至根本不需要接触内存:您在构造它时就知道 any 中的类型,因此函数指针值只是一个加载到适当寄存器中的常量。稍后,您使用该寄存器的值作为跳转目标。这意味着没有机会错误预测跳跃,因为 没有什么可预测的 ,值就在那里供 CPU 消耗。

换句话说:您使用此实现免费获得跳跃目标的原因是 CPU 必须已经以某种方式触及 any 才能首先获得它, 这意味着它已经知道跳转目标并且可以跳转到它而没有额外的延迟。

这意味着如果 any 已经“很热”,那么当前的实现真的没有间接可言,大多数时候都是这样,特别是如果它被用作 return值。

另一方面,如果您在 read-only 部分的某处使用 table 函数指针(并让 any 实例指向它),您将每次要移动或访问它时都必须进入内存(或缓存)。 any 的大小在这种情况下仍然是 16 个字节,但是从内存中获取值比访问寄存器中的值慢得多,尤其是当它不在缓存中时。在很多情况下,移动 any 就像将其 16 个字节从一个位置复制到另一个位置一样简单,然后将原始实例清零。这在任何现代 CPU 上几乎都是免费的。但是,如果走指针 table 路线,则每次都必须从内存中获取,等待读取完成,然后进行间接调用。现在考虑一下,您经常需要对 any 进行一系列调用(即移动,然后破坏),这将很快累加起来。问题是你不只是在每次触摸 any 时免费获得你想跳转到的函数的地址,CPU 必须明确地获取它。间接跳转到从内存读取的值是非常昂贵的,因为 CPU 只能在整个内存操作完成后退出跳转操作。这不仅包括获取一个值(由于有高速缓存,这可能非常快),还包括地址生成、存储转发缓冲区查找、TLB 查找、访问验证,甚至可能甚至页面 table 遍历。所以即使快速计算跳转地址,跳转也不会在很长一段时间内退出。一般来说,“indirect-jump-to-address-from-memory”操作是 CPU 管道可能发生的最糟糕的事情之一。

TL;DR:就像现在一样,returning 一个 any 不会停止 CPU 的管道(跳转目标已经在寄存器中可用,所以跳跃几乎可以立即退役)。使用 table-based 解决方案,returning 一个 any 将使管道停止 两次 :一次是获取移动函数的地址,然后是另一次获取析构函数。这会大大延迟跳转的退出,因为它不仅要等待内存值,还要等待 TLB 和访问权限检查。

另一方面,代码内存访问不受此影响,因为代码无论如何都以微码形式保存(在 µOp 缓存中)。因此,在该 switch 语句中获取和执行一些条件分支非常快(当分支预测器做对时更是如此,它几乎总是这样做)。