Rustで構造体を実体化して返す際のコスト
最近、勉強がてらちょこちょことRustで遊んでいます。そこで以前から少し気になっていたのは、構造体を実体化(instanciate)して返す場合のコストです。
公式のドキュメント https://doc.rust-lang.org/book/ch05-01-defining-structs.html などを見ると、構造体を実体化して返す例として以下のようなコードが挙げられています(Listing 5-4: A build_user function that takes an email and username and returns a User instanceより)。
fn build_user(email: String, username: String) -> User { User { email: email, username: username, active: true, sign_in_count: 1, } }
この関数は構造体のインスタンスをそのまま返していますが、スタック上にUserの一時オブジェクトを生成した後コピーして返す処理になることで、Userのコピー処理(*1)のコストが発生するのではないか?と、C/C++から入った人間としてはちょっと気になっていました。今回は、オブジェクトを生成して返す処理のコストについてコンパイル後のバイナリから確認してみました。
結論
結論からいうと適切に最適化されるのでコピーコストの心配は不要なようです。
C++では上記のようなコードでは、RVO(Return Value Optimization)により、コピーが省略されてリターン先のオブジェクトが直接構築されるので(*2)、コピーコストなしでオブジェクトを返すことができますが、Rustにおいても同様な最適化が行われています。
確認結果は以下で説明します。
以下で使用したrustcのバージョンは1.54.0になります(CPUはx86_64)。
バイナリから確認
まず、確認に使ったソースは以下になります。create_user()関数でUserを実体化して返しているだけです。
表1 case1/src/main.rs
#[derive(Debug)] struct User { username: String, age: u32, } fn create_user() -> User { User { username: String::from("test"), age: 32, } } fn main() { let user = create_user(); println!("{:?}", user); }
cargo buildでbuild後にrust-gdbでcreate_user()を逆アセンブルして確認していきましょう。
表2 create_user()の逆アセンブル結果
(gdb) disass case1::create_user Dump of assembler code for function _ZN5case111create_user17hdb09106d1b00ed2cE: 0x000000000000a850 <+0>: sub $0x28,%rsp 0x000000000000a854 <+4>: mov %rdi,(%rsp) 0x000000000000a858 <+8>: mov %rdi,0x8(%rsp) ==== usernameのStringの一時オブジェクトを0x10(%rsp)に構築 0x000000000000a85d <+13>: lea 0x10(%rsp),%rdi 0x000000000000a862 <+18>: lea 0x2d880(%rip),%rsi # 0x380e9 0x000000000000a869 <+25>: mov $0x4,%edx 0x000000000000a86e <+30>: call 0x8ef0 <_ZN76_$LT$alloc..string..String$u20$as$u20$core..convert..From$LT$$RF$str$GT$$GT$4from17hbc4810f3f8ed5876E> ==== 0x000000000000a873 <+35>: mov 0x8(%rsp),%rax ; 返り値を%raxに設定 ==== ここからUserの構築(%rdiで指定されたアドレスに構築) == 0x000000000000a878 <+40>: mov (%rsp),%rcx ; %rcxはcreate_user()が呼び出された時の$rdiの値 0x000000000000a87c <+44>: mov 0x10(%rsp),%rdx ; User.username設定 0x000000000000a881 <+49>: mov %rdx,(%rcx) ; 0x10(%rsp)にあるStringを(%rdi)にコピーしている 0x000000000000a884 <+52>: mov 0x18(%rsp),%rdx 0x000000000000a889 <+57>: mov %rdx,0x8(%rcx) 0x000000000000a88d <+61>: mov 0x20(%rsp),%rdx 0x000000000000a892 <+66>: mov %rdx,0x10(%rcx) 0x000000000000a896 <+70>: movl $0x33,0x18(%rcx) ; User.age設定 ==== 0x000000000000a89d <+77>: add $0x28,%rsp 0x000000000000a8a1 <+81>: ret End of assembler dump.
やっていることをざっくりと説明すると、Rustコンパイラは返り値の格納場所(user変数のアドレス)を%rdiレジスタに指定してcreate_user()を呼び出します(表3)。create_user()は呼び出し元から%rdiで指定されたアドレスにUserを構築します。今回の例では、%rdiにはmain()のスタックフレーム上のuser変数のアドレスが設定されているので(表3)、create_user()はuser変数を直接構築していることになります。リターンしたUserのコピー処理は発生していません。
表3 main側のcreate_user()呼び出し箇所
0x000000000000a8b7 <+7>: lea 0x18(%rsp),%rdi ; 0x18(%rsp)はmain()のuser変数の位置
0x000000000000a8bc <+12>: call 0xa850 <_ZN5case111create_user17hdb09106d1b00ed2cE>
もう少し詳しく見ていきましょう。まずcreate_user()先頭の「sub $0x28,%rsp」で40 bytes分のスタックフレームを確保しています。このスタックフレームのレイアウトは図1のようになっています。逆アセンブル中の0xXX(%rsp)と見比べると処理内容が理解しやすいと思います。

スタックフレーム作成後、0x000000000000a85d 〜 0x000000000000a86e のコード(緑色箇所)でusernameフィールドのStringオブジェクトの一時オブジェクトを%rsp + 0x10の位置に作成しています(*3)。その後、0x000000000000a878 〜 0x000000000000a896 のコード(青色箇所)で%rdiで指定されたアドレスにUserオブジェクトを構築しています。
繰り返しになりますが、%rdiにはmain()のuser変数のアドレスが指定されているので、create_user()はUserオブジェクトをコピーして返すのではなく、user変数を直接構築しています。余計なコピーコストはかかっていません。
ただ、一点気になったのは、ageフィールドについてはmain()のuser変数が直接構築されていますが、usernameのStringについては一旦create_user()のスタックフレーム上に構築されてから、%rdiの指すインスタンス(user変数)にコピーされている点です。
String::from()を呼び出す際の%rdiに%rsp + 0x10ではなく、create_user()が受け取った%rdiをそのまま渡せば、user変数上のusernameを直接構築できるような気がしますが、そのようなことは行っていないようです。 現状、オブジェクトを作成する関数内で別のオブジェクトを作成していると、そのオブジェクトについては、一時オブジェクトの生成とコピーが発生するようです。
ただこれは、debugビルドで最適化されていない状態でのコードですので、最適化オプションを変えて、もう少し追ってみましょう。
release buildの最適化レベルの場合
rustcにはいくつか最適化レベルがありますが、release buildではopt-level = 3がデフォルトになります(debug buildは opt-level = 0)。デバッグ情報が欲しいので、Cargo.tomlに以下の設定を追加してcargo build --releaseでbuildし、逆アセンブルを見てみましょう。
[profile.release] debug = true opt-level = 3
表4 opt-level = 3でのcreate_user()の逆アセンブル結果
(gdb) disass case1::main Dump of assembler code for function _ZN5case14main17h0a6cd4777ac2dee8E: 0x000055555555c860 <+0>: push %rbx 0x000055555555c861 <+1>: sub $0x60,%rsp 0x000055555555c865 <+5>: mov $0x4,%edi ; __rust_alloc()のsize引数 0x000055555555c86a <+10>: mov $0x1,%esi ; __rust_alloc()のalign引数 0x000055555555c86f <+15>: call *0x3b31b(%rip) ; call __rust_alloc() 0x000055555555c875 <+21>: test %rax,%rax 0x000055555555c878 <+24>: je 0x55555555c90b <_ZN5case14main17h0a6cd4777ac2dee8E+171> 0x000055555555c87e <+30>: mov %rax,(%rsp) ; user.username(String.pointer) 0x000055555555c882 <+34>: movl $0x74736574,(%rax) ; bufferに"test"保存 0x000055555555c888 <+40>: movaps 0x2c771(%rip),%xmm0 # 0x555555589000 0x000055555555c88f <+47>: movups %xmm0,0x8(%rsp) ; Stringのcap,len設定 0x000055555555c894 <+52>: movl $0x33,0x18(%rsp) ; user.age設定 0x000055555555c89c <+60>: mov %rsp,%rax 0x000055555555c89f <+63>: mov %rax,0x20(%rsp) 0x000055555555c8a4 <+68>: lea 0x95(%rip),%rax # 0x55555555c940 <_ZN48_$LT$case1..User$u20$as$u20$core..fmt..Debug$GT$3fmt17h7a722f5eafe26145E> 0x000055555555c8ab <+75>: mov %rax,0x28(%rsp)
処理の細かいところは逆アセンブルのコメントを見てもらうとして、create_user()やString::from()のオブジェクト作成関数の呼び出し自体が削除されて(%rsp)にあるuser変数が直接構築されているのがわかります。
まとめ
- debug buildにおける最適化がない状態でもC++におけるRVO的な処理は行われているのでコピーコストは気にする必要はない。
- ただし、オブジェクト作成関数の中で別のオブジェクトを作成していると、それらについては一時オブジェクトの生成とコピーが発生する。u32のようなscalarタイプのフィールドのみで構成される構造体ならコピーコストの心配はない。
- releaseビルドならさらに高度な最適化が行われるので、まあ心配ないでしょう。
最近の言語であればこのあたりの心配は杞憂だったということでしょうか。公式ドキュメントに載っているコードでもあるわけですし。
(*1) Rustでの意味的には所有権のmoveとなりますが、スタック上のオブジェクトをmoveするのには、構造体の各フィールドをコピーする必要があるのでコピーと表記しています。
(*2) 特にC++17からはコピー省略が仕様として組み込まれたので、コンパイラに依存したりせず、コピー省略が保証されるようになりました。
(*3) ここでも lea 0x10(%rsp),%rdi として%rdiにオブジェクトの作成先アドレスを設定してStringの作成関数を呼び出しているのがわかります。
[関連記事]
投稿日:2021/09/22 12:36