【C++】std::function のコスト

2020-03-04

C++でstd::functionを使えばクロージャなどを扱えて便利なんだけど、 実のところどのようなコードにコンパイルされるのか、実行時にどのくらいコストがかかるのか知らなかったので、コンパイルした結果を逆アセンブルして調べてみた。

std::function はテンプレートで実装されているとのことなので本来ならちゃんとソースを読むべきなんだろうけど、 テンプレートライブラリのソース読解能力まるでなしマンなので出力結果から推測するだけ。

使用したのは 64bit Linux/Clang++

$ clang++ --version
clang version 6.0.0-1ubuntu2 (tags/RELEASE_600/final)
Target: x86_64-pc-linux-gnu
Thread model: posix
InstalledDir: /usr/bin

コンパイルオプションは -O2 -fno-exceptions

先に結論

  • std::function の呼び出しは間接ジャンプ2回
    • ファンクタの operator() がインライン化可能な場合やラムダ式の場合には1回に最適化される
  • それに加えて nullptr チェック、後始末、引数の退避・復帰のコストがかかる
  • ファンクタやラムダ式でキャプチャされるサイズが大きい(> 16バイト)場合、ヒープに確保される
  • std::function のコピーは、内容が複製される
    • ヒープの場合は、新たなヒープにコピーされる

std::functionの実体

sizeofstd::function<void(int)>) のサイズを測ったところ、32バイトだった。 64ビット環境だとポインタが8バイトなので、4個分ということになる。

内容的には、以下の通り:

オフセット 内容 説明
0x00 ~ ワーク 内容(単純な関数、ファンクタ、ラムダ式)によってワークとして使われる
0x10 ~ 管理関数ポインタ デストラクトやコピーの処理を行う関数へのポインタ
0x18 ~ 0x1f 呼び出し先ポインタ () で呼び出される関数へのポインタ

std::function の呼び出し

std::function<void(int)>) を受け取って、単に呼び出すだけの関数 foobar

void foobar(const std::function<void(int)>& func, int x) {
func(x);
}

を逆アセンブルしてみる:

00000000004009f0 <_Z6foobarRKSt8functionIFviEEi>:
4009f0: 50 push %rax
4009f1: 89 74 24 04 mov %esi,0x4(%rsp) // x 退避
4009f5: 48 83 7f 10 00 cmpq $0x0,0x10(%rdi) // nullptr チェック
4009fa: 74 0a je 400a06 <_Z6foobarRKSt8functionIFviEEi+0x16>
4009fc: 48 8d 74 24 04 lea 0x4(%rsp),%rsi
400a01: ff 57 18 callq *0x18(%rdi) // &func + 0x18 呼び出し
400a04: 58 pop %rax
400a05: c3 retq
400a06: e8 65 fc ff ff callq 400670 <_ZSt25__throw_bad_function_callv@plt>

引数 &func を保持するレジスタ %rdi のオフセット 0x100 じゃなければ 0x18 の内容にジャンプ、となっている。

std::function 呼び出しの引数 x はレジスタ経由で渡されるのではなく、スタック上に退避してそのポインタ %rsi を渡している。

単純な関数の場合

単純な関数

void simple_func(int x) {}

foobar に渡す場合

void test_simple_func(int x) {
std::function<void(int)> f(simple_func);
foobar(f, x);
}

を見てみる:

// simple_func
0000000000400820 <_Z11simple_funci>:
400820: c3 retq

// foobar
0000000000400830 <_Z16test_simple_funci>:
400830: 48 83 ec 28 sub $0x28,%rsp
400834: 89 f8 mov %edi,%eax
400836: 48 c7 44 24 08 20 08 movq $0x400820,0x8(%rsp) // +0x00: simple_func
40083d: 40 00
40083f: 48 c7 44 24 20 70 09 movq $0x400970,0x20(%rsp) // +0x18: 制御関数
400846: 40 00
400848: 48 c7 44 24 18 80 09 movq $0x400980,0x18(%rsp) // +0x10: 呼び出し先関数
40084f: 40 00
400851: 48 8d 7c 24 08 lea 0x8(%rsp),%rdi
400856: 89 c6 mov %eax,%esi
400858: e8 93 01 00 00 callq 4009f0 <_Z6foobarRKSt8functionIFviEEi> // foobar 呼び出し
40085d: 48 8b 44 24 18 mov 0x18(%rsp),%rax
400862: 48 85 c0 test %rax,%rax
400865: 74 0f je 400876 <_Z16test_simple_funci+0x46>
400867: 48 8d 7c 24 08 lea 0x8(%rsp),%rdi
40086c: ba 03 00 00 00 mov $0x3,%edx
400871: 48 89 fe mov %rdi,%rsi
400874: ff d0 callq *%rax // 後始末呼び出し
400876: 48 83 c4 28 add $0x28,%rsp
40087a: c3 retq

// 呼び出し関数
0000000000400970 <_ZNSt17_Function_handlerIFviEPS0_E9_M_invokeERKSt9_Any_dataOi>:
400970: 48 89 f8 mov %rdi,%rax
400973: 8b 3e mov (%rsi),%edi
400975: ff 20 jmpq *(%rax)

// 制御関数
0000000000400980 <_ZNSt14_Function_base13_Base_managerIPFviEE10_M_managerERSt9_Any_dataRKS4_St18_Manager_operation>:
400980: 83 fa 02 cmp $0x2,%edx
400983: 74 13 je 400998 <_ZNSt14_Function_base13_Base_managerIPFviEE10_M_managerERSt9_Any_dataRKS4_St18_Manager_operation+0x18>
400985: 83 fa 01 cmp $0x1,%edx
400988: 74 17 je 4009a1 <_ZNSt14_Function_base13_Base_managerIPFviEE10_M_managerERSt9_Any_dataRKS4_St18_Manager_operation+0x21>
40098a: 85 d2 test %edx,%edx
40098c: 75 10 jne 40099e <_ZNSt14_Function_base13_Base_managerIPFviEE10_M_managerERSt9_Any_dataRKS4_St18_Manager_operation+0x1e>
40098e: 48 c7 07 b0 0a 40 00 movq $0x400ab0,(%rdi)
400995: 31 c0 xor %eax,%eax
400997: c3 retq
400998: 48 8b 06 mov (%rsi),%rax
40099b: 48 89 07 mov %rax,(%rdi)
40099e: 31 c0 xor %eax,%eax
4009a0: c3 retq
4009a1: 48 89 37 mov %rsi,(%rdi)
4009a4: 31 c0 xor %eax,%eax
4009a6: c3 retq

std::function を使わないC++で表現すると、

struct Function {
void *work[2];
void (*funcptr)(const Function* f, void* p);
int (*manager)(const Function* f, int flag, Function* g);
};

struct _Args { int x; };

void foobar(const Function& func, int x) {
_Args args = {x};
func.funcptr(&func, (void*)&args);
}

// 自動生成された呼び出し関数:_ZNSt17_Function_handlerIFviEPS0_E9_M_invokeERKSt9_Any_dataOi
void _caller(const Function* f, void* p) {
void (*func)(int) = (void (*)(int))f->work[0];
struct _Args* args = (_Args*)p;
func(args->x);
}

// 自動生成された管理関数:_ZNSt14_Function_base13_Base_managerIPFviEE10_M_managerERSt9_Any_dataRKS4_St18_Manager_operation
int _manager(const Function* f, int flag, Function* g) {
// 省略
return 0;
}

void test_simple_func(int x) {
Function f;
*(void (**)(int))&f.work[0] = simple_func;
f.funcptr = _caller;
f.manager = _manager;
foobar(f, x);
f.manager(&f, 3, &f);
}

foobar 内での func(x) という std::function の呼び出しが、 (*(&_caller))() -> (*(&simple_func))() という2段階の間接ジャンプで実行される。

自動生成された _caller には引数が _Args ポインタ経由で渡されるので、それを取り出して関数の実体を呼び出す。

ファンクタの場合

ファンクタ:

class Functor {
int y = 0;

public:
void operator ()(int x);
};

void Functor::operator ()(int x) {}

を渡した場合:

void test_functor(int x) {
Functor functor;
std::function<void(int)> f(functor);
foobar(f, x);
}

はどうなるか。逆アセンブル結果は省略して、同じく std::function を使わないC++で表現すると、

// 自動生成された呼び出し関数
void _caller(const Function* f, void* p) {
Functor* functor = (Functor*)f;
struct _Args* args = (_Args*)p;
(*functor)(args->x);
}

// 自動生成された管理関数
int _manager(const Function* f, int flag, Function* g) {
// 省略
return 0;
}

void test_functor(int x) {
Functor functor;

Function f;
*(Functor*)&f.work[0] = functor;
f.funcptr = _caller;
f.manager = _manager;
foobar(f, x);
f.manager(&f, 3, &f);
}

operator() がインライン定義されていたりして利用時に埋め込み可能なのがわかっている場合?には _caller 内にインライン展開されて、間接呼び出しが1回に最適化される。

ラムダ式の場合

ラムダ式:

void test_lambda_func(int x) {
int y = 0;
auto lambda_func = [&](int x) {
y += x;
};
std::function<void(int)> f(lambda_func);
foobar(f, x);
}

の場合:

struct Environment {
int* py;
};

// 自動生成された呼び出し関数
void _lambda(const Function* f, void* p) {
Environment* env = (Environment*)f;
struct _Args* args = (_Args*)p;

// ラムダ式の内容
*env->py += args->x;
}

// 自動生成された管理関数
int _manager(const Function* f, int flag, Function* g) {
// 省略
return 0;
}

void test_lambda_func(int x) {
int y = 0;

Environment env;
env.py = &y;

Function f;
*(Environment*)&f.work[0] = env;
f.funcptr = _lambda;
f.manager = _manager;
foobar(f, x);
f.manager(&f, 3, &f);
}

ラムダ式の場合、内容が埋め込まれるので間接ジャンプが1回で済む! (内部で for 文などを使用しても埋め込まれていた。)

キャプチャ内容が大きい場合

ファンクタやラムダ式を渡した場合、インスタンスやキャプチャ内容が std::function のワーク内に格納されていた。 ワークのサイズは16バイトだが、それを超えるとどうなるのか?

調べた結果、ファンクタのインスタンスやキャプチャ内容がヒープに確保されるようになった。

std::function の値渡し、コピー

foobarstd::function の参照を受け取っていたところを実体に変更

void foobar(const std::function<void(int)> func, int x)  {...}

したり、別の変数に代入したりすると std::function のコピーが行われる。 しかし std::function 自体は自分が保持している関数の型はわかるけど、何を保持しているか(単純な関数、ファンクタ、ラムダ式、など)はわからない。 また内容がワーク内に収まっているか、ヒープに確保されているかもわからないので、どうしているのか。

コピーが発生する場合、管理関数に 2 を渡して呼び出している。 管理関数は内容に応じて生成されるので、そこで独自の処理を行うようになっている。 ワークに収まっている場合には単なるコピー、ヒープに確保されている場合には新たなヒープを確保してコピーされる。 ファンクタや、キャプチャされたクラスなどはコピーコンストラクタで複製される。

推測で、ラムダ式のキャプチャは shared_ptr で共有され、すべてが解放されたときにヒープも解放されるのかと想像していたんだけど、単に複製されていた。 C++のラムダ式は [=] でのコピーか、 [&] での参照(ポインタ)の保持だけで、値自体が変更可能なキャプチャはないので、複製で十分なのかもしれない。

疑問

  • ファンクタで operator() がインライン化可能な場合やラムダ式では間接ジャンプ1回で呼び出せるが、単純な関数では2回になるのが意外だった。 理論的にはどんな場合でも1回にできそうな気がするんだが?
  • std::function 呼び出しへの引数をスタック上に配置してポインタ渡ししているのが謎だった。 なにか必要があるのか、単に実装によるんだろうか?
  • foobarstd::function の実体を呼び出すようにした場合にも、後始末の呼び出しを foobar 側で行わないのがよくわからなかった。 呼び出し元で後始末を呼び出すならわざわざ +0x18 に格納してそれを取り出して nullptr チェックやフラグを行う必要はないと思うんだが…。
  • 管理関数の2でコピー、3でデストラクタが行われるが、0と1はなにをしているのかわからなかった。

参考リンク