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の実体
sizeof
で std::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
のオフセット 0x10
が 0
じゃなければ 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); }
void _caller(const Function* f, void* p) { void (*func)(int) = (void (*)(int))f->work[0]; struct _Args* args = (_Args*)p; func(args->x); }
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 の値渡し、コピー
foobar
で std::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
呼び出しへの引数をスタック上に配置してポインタ渡ししているのが謎だった。
なにか必要があるのか、単に実装によるんだろうか?
foobar
で std::function
の実体を呼び出すようにした場合にも、後始末の呼び出しを foobar
側で行わないのがよくわからなかった。
呼び出し元で後始末を呼び出すならわざわざ +0x18 に格納してそれを取り出して nullptr チェックやフラグを行う必要はないと思うんだが…。
- 管理関数の2でコピー、3でデストラクタが行われるが、0と1はなにをしているのかわからなかった。
参考リンク