我认为每个 C++ 程序员都曾听说过“虚拟函数很慢”这句话。因此,我决定将虚拟函数与常规成员函数进行基准测试。
不幸的是,我在对“低级”C++ 代码进行基准测试方面没有太多经验,并且不知道如何正确避免编译器或 HW(主要是分支预测器)优化效果。
我现在面临的问题是,当我运行基准测试(我在下面提供)时,我几乎总是得到调用虚拟函数和常规函数具有相同速度的结果,有时虚拟函数工作得更快!
这就是我使用
google/benchmark
库编写基准测试的方式:
static void BM_MemberCall(benchmark::State& state) {
Base* b = new Derived();
for ([[maybe_unused]] auto _ : state) {
benchmark::DoNotOptimize(b->Foo());
benchmark::ClobberMemory();
}
}
BENCHMARK(BM_MemberCall);
static void BM_VirtualCall(benchmark::State& state) {
Base* b = new Derived();
for ([[maybe_unused]] auto _ : state) {
benchmark::DoNotOptimize(b->VirtualFoo());
benchmark::ClobberMemory();
}
}
BENCHMARK(BM_VirtualCall);
我的类定义如下,在
util.h
中声明:
#pragma once
// generate 10 virtual functions
#define VFOO(NAME, ID, ...) \
__attribute__((noinline)) virtual int NAME ## ID ## _0() __VA_ARGS__; \
__attribute__((noinline)) virtual int NAME ## ID ## _1() __VA_ARGS__; \
__attribute__((noinline)) virtual int NAME ## ID ## _2() __VA_ARGS__; \
__attribute__((noinline)) virtual int NAME ## ID ## _3() __VA_ARGS__; \
__attribute__((noinline)) virtual int NAME ## ID ## _4() __VA_ARGS__; \
__attribute__((noinline)) virtual int NAME ## ID ## _5() __VA_ARGS__; \
__attribute__((noinline)) virtual int NAME ## ID ## _6() __VA_ARGS__; \
__attribute__((noinline)) virtual int NAME ## ID ## _7() __VA_ARGS__; \
__attribute__((noinline)) virtual int NAME ## ID ## _8() __VA_ARGS__; \
__attribute__((noinline)) virtual int NAME ## ID ## _9() __VA_ARGS__;
// generate 100 virtual functions
#define VFOO100(NAME, ...) \
VFOO(NAME, 0, __VA_ARGS__) \
VFOO(NAME, 1, __VA_ARGS__) \
VFOO(NAME, 2, __VA_ARGS__) \
VFOO(NAME, 3, __VA_ARGS__) \
VFOO(NAME, 4, __VA_ARGS__) \
VFOO(NAME, 5, __VA_ARGS__) \
VFOO(NAME, 6, __VA_ARGS__) \
VFOO(NAME, 7, __VA_ARGS__) \
VFOO(NAME, 8, __VA_ARGS__) \
VFOO(NAME, 9, __VA_ARGS__)
struct Base {
int Foo();
VFOO100(A);
__attribute__((noinline)) virtual int VirtualFoo();
VFOO100(Z);
};
struct Derived : public Base {
VFOO100(A, override);
__attribute__((noinline)) int VirtualFoo() override;
VFOO100(Z, override);
};
在
VFOO100
的帮助下,我生成了 200 个虚拟函数,以便使虚拟表变大,并且为了防止在寻找正确的方法时涉及到一些词汇排序,我生成了 100 个词汇顺序低于 VirtualFoo
和 100 个更大的方法.
所有方法定义都是相同的(常规方法和虚拟方法,在名为
util.cpp
的单独 TU 中定义):
{
asm("");
return 0;
}
基准测试结果示例:
(same)
---------------------------------------------------------
Benchmark Time CPU Iterations
---------------------------------------------------------
BM_MemberCall 1.23 ns 1.23 ns 524344569
BM_VirtualCall 1.23 ns 1.23 ns 564752961
(virtual functions are worse)
---------------------------------------------------------
Benchmark Time CPU Iterations
---------------------------------------------------------
BM_MemberCall 1.22 ns 1.22 ns 522068585
BM_VirtualCall 1.48 ns 1.33 ns 557440234
(virtual functions are faster!)
Benchmark Time CPU Iterations
---------------------------------------------------------
BM_MemberCall 1.33 ns 1.33 ns 475669505
BM_VirtualCall 1.25 ns 1.25 ns 555128195
我的问题:
我的设置:
2024-02-12T17:41:29+03:00
Running ./src/apps/bench/function_calls/calls
Run on (12 X 2600 MHz CPU s)
CPU Caches:
L1 Data 32 KiB
L1 Instruction 32 KiB
L2 Unified 256 KiB (x6)
L3 Unified 12288 KiB
Load Average: 3.04, 4.39, 4.52
C++ 中的虚拟函数在某些方面导致速度缓慢。
这类似于函数指针或 std 函数。
他们通过函数指针添加内存访问。函数指针只需一步即可编写代码:虚拟调用首先必须获取 vtable,然后跟随指针到达那里。这种额外的内存访问可能会导致缓存未命中,并会增加 L1 缓存压力。
它们不鼓励值类型和本地存储。由于基于 vtable 的多态性不支持开箱即用的多态存储,因此您最终会使用指向对象数据的指针,这些数据未以缓存友好的方式彼此相邻存储。
他们鼓励动态分配。
malloc
是 100 条指令,free
是线程不友好的。 vtable 的大多数用途都使用堆。这意味着每个 objexta 的存在都背负着巨大的时间债:通常你有大物体或浪费大量时间。
它们不鼓励多态性。每个不同类型的对象都有 O(vtable size) 全局开销:并且调整了 1 个方法的对象有自己的类型。考虑到 C++ 如何实现虚拟调度,创建数百万个组合不同类型的对象是不切实际的。因此,在某个点之后,您最终会在多态代码中出现 switch 语句。
这些都代表了我在具体的商业应用程序中出于性能原因而没有使用虚拟调度的原因。
现在,很多时候,性能的欠缺是相对的。例如,不要对以 30 Hz 运行的高清视频使用基于每像素每帧的虚拟调度。
相反,将虚拟调度提升到每个扫描线点。突然之间,这个巨大的开销变得不那么重要了 1000 倍。