C++11におけるstd::vectorへの最速な値設定方法の考察を通して学ぶemplace_backとstd::move

最近C++を書く機会が多いのだが、いかんせん自分の知識は2014年でぱったりと止まっている。しかも2014年当時、自分はまだまだ未熟で、C++11やらC++14なんかの機能についてはまったくの無知であった。そんなわけで、C++のモダンな機能を学びがてら、タイトルにあるようにstd::vectorに値を設定する最速の方法は何かということを調べてみようと思う。

前提として、vectorのテンプレート引数は、ぼちぼちの大きさをもつ自作クラス型とした。また、vectorのメモリ確保動作が性能ネックとならないように、初めにvectorのメモリをreserveしてから計測を行った。つまり、初めに以下のようなコードを書いておく。

std::vector<LargeObject> lov;  // 自作のぼちぼちの大きさのクラスをテンプレート引数に設定
const int NUM_LOOP = 3000000;
lov.reserve(NUM_LOOP);


今回は4つの方法を試してみたので、それらについて順に説明する。

1. push_backを使用する

これは至って普通の方法である。コードは以下。

for(int i = 0; i < NUM_LOOP; i++)
{
  lov.push_back(LargeObject());
}
2. emplace_backを使用する

これはC++11で追加された新機能である。解説はググればいくらでも出てくるので深くは触れないが、push_backでは一時的にオブジェクトを生成し、それをvectorの要素としてコピーする必要があるのだが、emplace_backでは最初からvectorの要素としてオブジェクトを作成することで、コピーの手間を省いているようだ。使い方は至って簡単で、push_backをemplace_backに置き換えるだけだ。

for(int i = 0; i < NUM_LOOP; i++)
{
  lov.emplace_back();
}
3. std::moveを使用する

これもC++11から追加された機能であり、またC++11の目玉機能の1つでもある。これも正直100%理解していないので深入りしないが、C++11では右辺値参照という概念があり、これを利用することで従来オブジェクトのコピーを行っていたところを、オブジェクトの移動に置き換えられるようだ。ただし、moveと書けば必ずmoveされるという簡単なものではなく、move関数はあくまでオブジェクトの型を右辺値参照型にキャストしているだけのようだ。詳しいことは難しいのでググって欲しい。

moveを使う際の注意点として、ムーブコンストラクタと呼ばれる新しいコンストラクタを定義しておく必要がある。コピーセマンティクスではコピーコンストラクタを使用していたが、ムーブセマンティクスではムーブコンストラクタを使用することになる。以下に今回定義したムーブコンストラクタを示す。(※追記2参照)

LargeObject::LargeObject(LargeObject &&obj)
{
  a = obj.a;
  mvConstCallCnt++;
}

ここで、aというのはLargeObjectが持つメンバ変数である。今回はstd::vectorという型としており、コンストラクタの中でサイズ10のvectorで初期化している。また、引数の&&というのが右辺値参照型を表す文法である。

サンプルコードは以下のようになる。

for(int i = 0; i < NUM_LOOP; i++)
{
  lov.push_back(std::move(LargeObject()));
}
4. resizeする

これもシンプルな方法である。vectorのresizeを行うと、その要素の数だけオブジェクトが生成されることになる。コードは以下。

lov.resize(NUM_LOOP);

これが恐らく最速ケースになるだろう。ただし、resizeの場合はデフォルトコンストラクタしか使用できず、コンストラクタに引数を渡したい場合は、上記3つの方法を取るしか無いと思われる。(※追記参照)

性能測定

以上、4通りの値設定方法を説明した。これらを用いて実際に性能測定を行った。コンパイラはg++ version4.8.4を使用し、-O3オプションを付けてコンパイルした。また、性能測定と同時に、各コンストラクタがコールされる回数を取得した。結果を以下に示す。

====push back test====
Elapsed time:	0.311718
Constructor call:	3000000
Copy constructor call:	0
Move constructor call:	3000000
====emplace back test====
Elapsed time: 0.0983019
Constructor call:	3000000
Copy constructor call:	0
Move constructor call:	0
====move test====
Elapsed time: 0.207351
Constructor call:	3000000
Copy constructor call:	0
Move constructor call:	3000000
====resize test====
Elapsed time: 0.0974371
Constructor call:	3000000
Copy constructor call:	0
Move constructor call:	0
====push back test2====
Elapsed time:	0.206877
Constructor call:	3000000
Copy constructor call:	0
Move constructor call:	3000000

最後のpush back test2というのは、初回はキャッシュヒット率の観点で不利ではないかと思い、push backを念のため2回実行したものである。これを見ると、resizeを利用したケースが一番速く、次いでemplace_backを使用したケースが速く処理を終えていることが分かる。emplace_backのケースではコンストラクタ呼び出しが3000000回しか行われておらず、余計なオブジェクトコピーが発生していないことが分かる。

また、push_backのmoveあり・なしでの差を見ると、コンストラクタとムーブコンストラクタが呼ばれている回数が同じである。これはちょっと予想外だ。つまり、moveと明示的に書かなくても、勝手にmoveされてしまうようだ。(※追記3参照)

では、ムーブコンストラクタの定義を行っていない状態でpush_backを行うとどうなるのだろうか?試しに定義をコメントアウトしてからコンパイルし、測定を行った結果が以下である。

====push back test====
Elapsed time:	0.321736
Constructor call:	3000000
Copy constructor call:	3000000
Move constructor call:	0
====emplace back test====
Elapsed time: 0.0978141
Constructor call:	3000000
Copy constructor call:	0
Move constructor call:	0
====move test====
Elapsed time: 0.210126
Constructor call:	3000000
Copy constructor call:	3000000
Move constructor call:	0
====resize test====
Elapsed time: 0.0969071
Constructor call:	3000000
Copy constructor call:	0
Move constructor call:	0
====push back test2====
Elapsed time:	0.216425
Constructor call:	3000000
Copy constructor call:	3000000
Move constructor call:	0

これを見ると、今度はムーブコンストラクタの代わりにコピーコンストラクタが使用されていることが分かる。この結果からも、move関数は単に右辺値参照へのキャストを行っているだけだということが見て取れる。ただし、ムーブコンストラクタが定義されていた場合と比べて性能がほとんど変わらない点はちょっと解せない。いずれにせよ、vectorに値を設定するという場面においては、ムーブセマンティクスなどという難しいことは考えず、単にemplace_backを使用すれば良さそうだ。

以上、C++11の勉強がてら、vectorへの値の設定の仕方とその性能について調べてみた。たったこれだけの簡単な操作に対しても、ベストな性能を出すためにはいろいろなことを考えなければならないことを思うと、ソフトウェアの性能というのは本当に難しい問題だと思う。強いC++エンジニアになるべく、今後も継続的に勉強していきたい。

※追記
resizeには実は隠れた第二引数があり、ここにオブジェクトを指定すると、vectorの要素が全てそのオブジェクトのコピーによって初期化されるようだ。試してみたところ、コンストラクタの代わりにコピーコンストラクタが走り、vectorの要素が生成されることが分かった。こういう方法があることを考えると、vectorを一括で初期化したい場合はresizeを使用し、個別に要素を追加したい場合にはemplace_backを使用するのが良さそうである。

※追記2

move関数を使うとコピーコンストラクタの代わりにムーブコンストラクタが呼ばれるわけだが、ムーブコンストラクタをうまく書いてあげないとムーブの恩恵が得られないようだ。本文中で紹介したムーブコンストラクタでは内部でvector型変数aをコピーしてしまっていたが、これ自体ムーブするように書き換えてやった結果、想定通りの動作となった。サンプルコードを以下に示す。

LargeObject::LargeObject(LargeObject &&obj)
{
  a = std::move(obj.a);  // メンバをコピーでなくムーブする
  mvConstCallCnt++;
}

測定結果は以下である。新しく"rich resize test"という項目が追加されているが、これは「追記」に記載した方法の測定結果である。

====push back test====
Elapsed time:	0.215382
Constructor call:	3000000
Copy constructor call:	0
Move constructor call:	3000000
====emplace back test====
Elapsed time: 0.101542
Constructor call:	3000000
Copy constructor call:	0
Move constructor call:	0
====move test====
Elapsed time: 0.109494
Constructor call:	3000000
Copy constructor call:	0
Move constructor call:	3000000
====resize test====
Elapsed time: 0.0983009
Constructor call:	3000000
Copy constructor call:	0
Move constructor call:	0
====rich resize test====
Elapsed time: 0.08794
Constructor call:	1
Copy constructor call:	3000001
Move constructor call:	0
====push back test2====
Elapsed time:	0.111539
Constructor call:	3000000
Copy constructor call:	0
Move constructor call:	3000000

これを見ると、emplace backとmoveの場合でほとんど性能差がないことが分かる。moveの方がムーブコンストラクタ呼び出し3000000回が上乗せされているが、これ自体は通常のコンストラクタと比べて内部の処理がずっと軽い(変数aのポインタ付け替えのみ)ため、このような結果になったのだと思われる。

moveはしっかりと理解した上で使わないと、思ったように性能が出ないという現象に悩まされそうだ。

※追記3

moveと書いても書かなくてもムーブコンストラクタがコールされてしまった件だが、これはそもそもpush_backの引数に渡しているのが一時オブジェクトであり、暗黙的に右辺値参照にキャストできたためだと思われる。上述の通りstd::moveは右辺値参照へのキャストを行う関数なので、今回の場合は無駄にキャストしていただけなのだと思われる。

差が生まれるケースとしては、例えば一旦定義して左辺値となった変数をムーブするケースが挙げられる。調べてみたところ、この場合は普通にpush_backするとコピーコンストラクタがコールされ、move関数を噛ませるとムーブコンストラクタがコールされることが分かった。