Home > ブログ > Rustで構造体を実体化して返す際のコスト

ブログ

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)と見比べると処理内容が理解しやすいと思います。

struct instanciation
図1 create_user()のスタックフレーム

スタックフレーム作成後、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

タグ: プログラミング Rust

Top

アーカイブ

タグ

Server (18) プログラミング (15) 作業実績 (15) PHP (11) ネットワーク (9) C (7) C++ (7) Webアプリ (6) EC-CUBE (6) Nginx (5) OpenSSL (5) laravel (4) Linux (4) 書籍 (4) JavaScript (3) AWS (3) Rust (3) Vue.js (3) Golang (2) Symfony (2) Apache (1) デモ (1) お知らせ (1) MySQL (1) CreateJS (1) OSS (1)