std::moveにまつわる速度調査など stringとラムダ編

間違えている部分あると思うので
マサカリおねがいします!

そういえば ユニバーサル参照の説明してないわ、へんにstd::moveしてるわ、std::forwardしてないわ
説明として圧倒的に不足しているので
調査しなおして書き直します・・

はじめに

この調査を行った経緯は、強い人が書いたコードの中に1つ下記の疑問コードがあったため
std::moveや右辺値参照を7割ほどしか理解していなかったため、具体的な速度調査を行ってみた

まず、前提として右辺値やstd::moveをある程度知っている必要があり、そのへんは先人たちが分かりやすい説明しているので
そっちで見ていただきたい
要約すると 右辺値は一時オブジェクト。アドレスが取れるかどうか 的な判断がわかりやすい
std::moveは、所有権を移動する関数ではなく、右辺値型へのキャストと覚えた方がわかりやすい

また、C++14以降でラムダキャプチャが強化され、moveキャプチャ等も出来るらしいので、今のうちに 右辺値参照はPerfectに理解しておきたい

疑問コード

  hoge( std::move( [](){ほげほげ;} ) );

元々 このラムダは右辺値なのに std::moveする必要あるんだろうか?
(結論は 無駄だと思う)

上記疑問コードの調査の前に変数(ここではstd::string)を使って調査してみる

stringでの調査

まず例文

#include<iostream>
#include<chrono>

using namespace std;


template <typename Func>
 void time_elapsed(Func func, int count)
{
    auto t0 = std::chrono::high_resolution_clock::now();
    for (int n = 0; n < count; n++) {
        func();
    }
    auto t1 = std::chrono::high_resolution_clock::now();

    std::cout << std::chrono::duration_cast<std::chrono::milliseconds>(t1 - t0).count() << " msec" << std::endl;
}

void foo(string s) {
     string hoge = std::move(s);
}
void foo1(string &s) {
     string hoge = std::move(s);
}
void foo2(string &&s) {
     string hoge = std::move(s);
}

void bar(string s) {
     string hoge = s;
}
void bar(string &s) {
     string hoge = s;
}
void bar(string &&s) {
     string hoge = s;
}

auto main() -> int{
    time_elapsed([] {  foo(string("helo"));  }, 1000000);  // 起動直後の計測は捨てる(ゴミデータ)

    time_elapsed([] {  foo(string("helo"));  }, 1000000);
    time_elapsed([] {  foo1(string("helo"));  }, 1000000);  VCだとなぜか通る
    time_elapsed([] {  foo2(string("helo"));  }, 1000000);

    cout << endl;

    time_elapsed([] {  foo(std::move(string("helo")));  }, 1000000);   // Warning
    time_elapsed([] {  foo1(std::move(string("helo")));  }, 1000000);  // VCだとなぜか通る
    time_elapsed([] {  foo2(std::move(string("helo")));  }, 1000000);  // Warning

    cout << endl;

    static string cc("hoge");  // あえてキャプチャせずStaticに
    time_elapsed([] {  foo(cc);  }, 1000000);
    time_elapsed([] {  foo1(cc);  }, 1000000);
//  time_elapsed([] {  foo2(cc);  }, 1000000);  // 当然コンパイルエラー

    cout << endl;

    time_elapsed([] {  foo(std::move(cc));  }, 1000000);
    time_elapsed([] {  foo1(std::move(cc));  }, 1000000);  // VCだとなぜか通る
    time_elapsed([] {  foo2(std::move(cc));  }, 1000000);


}

結果 
呼び出される関数でstd::move しない(foo
4961 msec
4958 msec
4948 msec

7249 msec
4964 msec
4952 msec

5315 msec
2675 msec

4889 msec
2533 msec
2528 msec

呼び出される関数でstd::move する(bar
4654 msec
4653 msec
4627 msec

6931 msec
4673 msec
4655 msec

4979 msec
2329 msec

4614 msec
2353 msec
2379 msec

Wandboxがスポンサーゲットしてたので 張っておく!
https://wandbox.org/permlink/2gIjhKZgR59EsjaJ

VisualStudioとそれ以外でコンパイルエラーになるケースが違うが、Clangでエラーやワーニングが出るコードは書くべきではないと思う

一時オブジェクト(右辺値)引数、std::move(一時オブジェクト)、変数(左辺値)、std::move(変数)
に対してそれぞれ、オブジェクト受け、参照受け(&)、右辺値参照受け(&&)
の、合計12パターンをテストしてみた

結果

上記をVisualStudioでビルドし、トレースおよびアセンブラで追っかけた結果、下記のパターンのグループが出来た

一時オブジェクト作成

4650ms
foo(string("helo")); foo1(string("helo")); foo2(string("helo"));
foo1(std::move(string("helo"))); foo2(std::move(string("helo")));

basic_string(_In_z_ const _Elem * const _Ptr)
foo
basic_string(_Myt&& _Right) _NOEXCEPT
_Assign_rv_contents_with_alloc_always_equal(_STD move(_Right), _Use_memcpy_move{});

foo1(string("helo"))、foo1(std::move(string("helo")))
右辺値の参照(not 右辺値参照)はillformだと思ってたけどVCだと通るんだね?? このあたり謎。

foo2(std::move(string("helo")))
一時オブジェクト(右辺値)をmoveするとWarning発生

一時オブジェクト作成、右辺値参照

7000ms
foo(std::move(string("helo")))

basic_string(_In_z_ const _Elem * const _Ptr)
basic_string(_Myt&& _Right) _NOEXCEPT
_Assign_rv_contents_with_alloc_always_equal(_STD move(_Right), _Use_memcpy_move{});
foo
basic_string(_Myt&& _Right) _NOEXCEPT
_Assign_rv_contents_with_alloc_always_equal(_STD move(_Right), _Use_memcpy_move{});

Warning発生。一時オブジェクトをmoveすべきではない。
実行速度もとても遅いので使ってはいけない

コピーコンストラクタ

4600ms
foo(cc)

basic_string(const _Myt& _Right)
_Assign_lv_contents(_Right);
foo
basic_string(_Myt&& _Right) _NOEXCEPT
_Assign_rv_contents_with_alloc_always_equal(_STD move(_Right), _Use_memcpy_move{});

foo(std::move(cc))

basic_string(_Myt&& _Right) _NOEXCEPT
_Assign_rv_contents_with_alloc_always_equal(_STD move(_Right), _Use_memcpy_move{});
foo
basic_string(const _Myt& _Right)
_Assign_lv_contents(_Right);

オブジェクトのコピーコンストラクトが行われるので当然おそい

コンパイルエラー

foo2(cc)

オブジェクトから右辺値参照は出来ない

処理なし

2330ms

foo1(cc)、foo1(std::move(cc))、foo2(std::move(cc))

foo
basic_string(_Myt&& _Right) _NOEXCEPT
_Assign_rv_contents_with_alloc_always_equal(_STD move(_Right), _Use_memcpy_move{});

通常の参照渡しも同じになるのは 受け関数でstd::moveしてるからのようだ
このコードが最も効率良いはずなので、常にこのコードをかきましょう!

おまけ

受け取り側の関数で std::moveを使わない場合は いずれのパターンも約300ms 遅くなった

void foo(string s) {
     string hoge = s;
}

結論

まず VisualStudioだとコンパイル出来てしまうコードがあったのは驚いた(コンパイルオプションとかあるのかな?)
ただ いずれのパターンも書くべきでないしパフォーマンスも良くないので、正しい書き方をしましょう
私の考えでは 正解は1つじゃない。2つ。

コピー

速度とかじゃなく、コピーを渡す必要があるシーンは出てくる
関数内で値を書き換えるが、それは困るというとき
そういう時はコストかかってもコピーするしかない

hoge(string st){
  st +=" append string";
  std::cout << st << std::endl;
}
string foo("foo");
hoge(foo);

右辺値参照渡し

参照渡しと同じように扱え 速度も最も早い
右辺値も渡せるので、参照渡しでは対応できない問題も解決
完全転送も可能なので、コピーする必要がなければ、常にこれを使うべきである
左辺値を渡そうとするとエラーになるので右辺値にキャストする必要がある(それがstd::move)
右辺値参照ではあるが、仮引数の時は左辺値なので、関数内でstd::moveしなければ通常の参照と動作はかわらない

hoge(string &&st){
  string r(std::move(st));
  r +=" append string";
  std::cout << r << std::endl;
}

hoge(string("RV"));  // welform
string s("LV");
// hoge(s); // illform
 hoge(std::move(s)); // welform

もちろん、通常の参照時はオブジェクトの所有権を移動したくない場合は 関数をオーバーロードするとよい


template<typename Val>
void foo(Val &s) {
     Val hoge(s);
     cout << "ref: " << hoge << endl;
}

template<typename Val>
void foo(Val &&s) {
     Val hoge(std::move(s));
     cout << "rvref: " << hoge << endl;
}


auto main() -> int{
    string s("helo");
    foo(string("helo")); // rval
    foo(s);              // lval
    foo(std::move(s));   // rval

    string s2("hi");
    foo(string(s2));     // rval
}

いよいよラムダ

一言でいうと、関数(オブジェクト)も 一級オブジェクトなので、同じです。

コード


#include<iostream>
#include<chrono>

using namespace std;


template <typename Func>
 void time_elapsed(Func func, int count)
{
    auto t0 = std::chrono::high_resolution_clock::now();
    for (int n = 0; n < count; n++) {
        func();
    }
    auto t1 = std::chrono::high_resolution_clock::now();

    std::cout << std::chrono::duration_cast<std::chrono::milliseconds>(t1 - t0).count() << " msec" << std::endl;
}


 template <typename Func>
 void foo(Func rmd) {
     rmd();
}

 template <typename Func>
 void foo1(Func& rmd) {
     rmd();
 }
 template <typename Func>
 void foo2(Func&& rmd) {
     rmd();
 }


auto main() -> int{
    time_elapsed([] {  foo([] {string s("abc"); });  }, 1000000);  // 起動直後の計測は捨てる(ゴミデータ)

    time_elapsed([] {  foo ([] {});  }, 10000000); 
    time_elapsed([] {  foo1([] {});  }, 10000000);  // VCではなぜかとおる
    time_elapsed([] {  foo2([] {});  }, 10000000); 


    time_elapsed([] {  foo (std::move([] {}));  }, 10000000);  // Warning
    time_elapsed([] {  foo1(std::move([] {}));  }, 10000000); // VCでは通る
    time_elapsed([] {  foo2(std::move([] {}));  }, 10000000);  // Warning


}

結果(ローカル)
63 msec
52 msec
53 msec
70 msec
71 msec
70 msec

備考として foo側で std::move()を使いアクセス std::move(rmd)(); で下記の値
71 msec
69 msec
70 msec
88 msec
88 msec
86 msec

Wandbox
https://wandbox.org/permlink/LYAKIspu91nJtQv8

考察

一級オブジェクトなので、ラムダ関数だろうが、右辺値(前回の string("hoge"))と同じなことはわかっている
が、オブジェクトに比べてほとんど差がない
ラムダを一度変数に入れ static auto rm = []{}; それで渡しても ほぼ同じ結果であった
ラムダの場合は 関数ポインタが渡される程度なんだろうか(ここは検証出来てないししたくない)

結論

ラムダはコピーしても参照しても右辺値参照でも速度に差がなかった
VCだと微妙に std::moveすると遅くなったがclangでは差がなかったので
速度に関しては、考慮する必要がない

正解はやはりオブジェクトと同じ

ラムダを呼び出し元でも使いまわすのならコピーする
参照を移しても問題なければ右辺値参照で渡せばいい
完全転送も可能なので、出来る限り右辺値参照を使うべきである