(libstdc++) std::function

涉及的 libstdc++ 源码文件:bits/std_function.h

印象: std::function 做了小对象优化,同时在避免使用虚函数(尽管它可以用继承和虚函数来实现)。

关于成员指针,见 Pointer to Member

存储结构

首先看 std::function 的成员组成:

void swap(function& __x) noexcept
{
    std::swap(_M_functor, __x._M_functor);
    std::swap(_M_manager, __x._M_manager);
    std::swap(_M_invoker, __x._M_invoker);
} // 模板太长了,成员定义很分散,抄起来很累;这个 swap 函数写的刚刚好

_M_functor 是一个 union 的 union(最外层的 union 是为了和 char 数组拼在一起方便取首地址,里面的 union 可以用来表示四种不同类型的指针):

union _Nocopy_types
{
    void*       _M_object;
    const void* _M_const_object;
    void (*_M_function_pointer)();
    void (_Undefined_class::*_M_member_pointer)(); // 这个在 64 位环境的 gcc 里面是 16 个字节
};

_M_manager_M_invoker 都是普通函数指针,是用来管理函数对象的调用和生命周期的。

函子(functor)存储:

  • 如果函子是普通函数指针或者成员函数指针,则能直接存储(因为有上面的 union 来专门存储)。
  • 如果函子是具有调用操作符的对象,则先考虑 _M_functor 占用的空间能不能用来本地分配(gcc 里成员函数指针占用 16 个字节,拿来做小对象优化还蛮好的)。这叫小对象优化(SOO)。
  • 如果函子是具有调用操作符的对象,且本地空间不够,则会动态申请内存。

调用开销(不考虑创建函数对象时的开销)

我在 libstdc++ 实现里面没找到 std::function 对虚函数的使用?但是看到 std::function 调用有很多间接环节:

  1. 首先是读 _M_invoker 的值,然后才能调用其指向的函数,这涉及到读内存一次。
  2. 然后在跳转到的函数里,代码需要找到函子的地址。普通函数指针/成员函数指针会需要多访存一次。小对象优化的函子会将成员的首地址作为函子地址;大对象函子的 _M_functor 保存的值本身就是函子地址,也是还要访存一次。
  3. 如果使用 std::bind 等修改函数签名的技术,间接层数会更多。最好使用 lambda,方便编译器内联。

Tip

根据小对象优化,使用 lambda 表示纯函数(而不是函数指针),可以减少内存访问次数。

使用 google/benchmark 进行性能比较:g++ 14.0.1

CMakeLists.txt(需要先下载 google/benchmark):

cmake_minimum_required(VERSION 3.18)
project(bench LANGUAGES CXX)

add_executable(bench main.cpp)
target_link_libraries(bench benchmark::benchmark)
target_compile_options(bench PRIVATE -O1) # 修改这个选项进行多次测试

# Build benchmark in Release mode.
set(CMAKE_BUILD_TYPE "Release")
set(BENCHMARK_ENABLE_TESTING OFF)
add_subdirectory(benchmark)

main.cpp(在对应的 Compiler Explorer 链接中,可以改不同的编译选项尝试一下):

#include <array>
#include <benchmark/benchmark.h>
#include <functional>

struct Foo {
  virtual void f() = 0;
};

struct Bar : public Foo {
  int counter = 0;
  void f() override { counter++; }
};

static Bar bar;

static void virtual_function(benchmark::State &state) {
  Foo &foo = bar;
  for (auto _ : state) {
    foo.f();
    benchmark::DoNotOptimize(foo);
  }
}

static void std_function(benchmark::State &state) {
  std::function<void()> f = []() { bar.counter++; };
  for (auto _ : state) {
    f();
    benchmark::DoNotOptimize(f);
  }
}

static void std_function_no_soo(benchmark::State &state) {
  std::function<void()> f = [a = std::array<char, 256>{}]() { bar.counter++; };
  for (auto _ : state) {
    f();
    benchmark::DoNotOptimize(f);
  }
}

BENCHMARK(virtual_function);
BENCHMARK(std_function);
BENCHMARK(std_function_no_soo);
BENCHMARK_MAIN();

本地机器运行的详细结果(为了方便对比,我将四次运行的结果拼接在了一起):

Run on (12 X 2208.01 MHz CPU s)
CPU Caches:
  L1 Data 32 KiB (x6)
  L1 Instruction 32 KiB (x6)
  L2 Unified 256 KiB (x6)
  L3 Unified 9216 KiB (x1)
  --------------------------------------------------------------
  Benchmark                    Time             CPU   Iterations
  --------------------------------------------------------------
-O0:STL 多层封装没被优化,导致 std_function 和 std_function_no_soo 更慢
  virtual_function          2.76 ns         2.76 ns    255623909
  std_function              22.1 ns         22.1 ns     31487803
  std_function_no_soo       20.4 ns         20.4 ns     34398254

-O1:因为有小对象优化,std_function 访存少一次,因而更快;其他两个速度差不多
  virtual_function          1.78 ns         1.78 ns    383829588
  std_function              1.30 ns         1.30 ns    542120015
  std_function_no_soo       1.79 ns         1.79 ns    387368468

-O2/-O3:虚函数被内联;std_function 的调用代码被全部消除;std_function_no_soo 仍是需要访存两次得到函数指针
  virtual_function          1.38 ns         1.38 ns    489559445
  std_function              1.26 ns         1.26 ns    534617229
  std_function_no_soo       1.54 ns         1.54 ns    455909928

Tip

包含大的函子的 std::function(std_function_no_soo)在 -O0 下比包含小函子的更快一点点,这可能违反逻辑,但实际上是标准库封装的问题。因为没有优化, std::functionoperator() 代码是相同的,在里面调用函子的逻辑也是相同的(先调用 _M_get_pointer(std::_Any_data const&) 获取指针再 invoke),但是两者的 _M_get_pointer 略有不同,在这个函数里:获取成员的引用这一步是用一个函数实现的,而获取函子的地址时,std_function_no_soo 是直接将指针返回,而 std_function 为了防止 & 运算符被重载,还调用了 std::__address_of 方法,这就多了一次函数调用。