Edited at

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

More than 1 year has passed since last update.

間違えている部分あると思うので

マサカリおねがいします!

そういえば ユニバーサル参照の説明してないわ、へんに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では差がなかったので

速度に関しては、考慮する必要がない


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

ラムダを呼び出し元でも使いまわすのならコピーする

参照を移しても問題なければ右辺値参照で渡せばいい

完全転送も可能なので、出来る限り右辺値参照を使うべきである