Visual C++ 2010 (VC10) 實現了一些頗有用處的 C++0x 新特性,其中就包括(萬眾期待的)rvalue reference
。
本文不打算詳述 rvalue reference 是什麼了,關於這方面的文章已經不少,讀者可以自己搜尋來看看。我要說的是,今天我做了一些非常簡單的關於
rvalue reference 的效能測試,其中有非常鼓舞人心的部分,也有 C++ 一以貫之的複雜和越來越複雜的部分。
好訊息:效能的極大提升
從原理上講,rvalue reference 使得 move semantics
成為可能,從而讓編譯器可以從rvalue對象中“偷走”資源,而不是拷貝資料,在很多情況下,這會帶來效能的極大提升。
測試代碼很簡單:比較 copy 和 move 一個 vector<string> 對象的時間:
#include <string><br />#include <vector><br />#include <iostream><br />#include <ctime><br />using namespace std;<br />vector<string> make_vector()<br />{<br /> vector<string> v(1, string("this is a string"));<br /> return v;<br />}<br />int main()<br />{<br /> vector<string> src(make_vector()), s(make_vector());<br /> clock_t start, end;<br /> start = clock();<br /> for (int i = 0; i < 1000000; ++i)<br /> {<br /> vector<string> s = src;<br /> }<br /> end = clock();<br /> cout << "Vector copy ctor takes: " << end - start << endl;<br /> start = clock();<br /> for (int i = 0; i < 1000000; ++i)<br /> {<br /> vector<string> s = move(src);<br /> }<br /> end = clock();<br /> cout << "Vector move ctor takes: " << end - start << endl;<br /> return 0;<br />}<br />
在我的相當老舊的筆記本上,Release 版本的輸入是這樣的
Vector copy ctor takes: 4562
Vector move ctor
takes: 4
看來相當鼓舞人心不是?對於一個還不是太大太複雜的對象,move 比 copy 竟然能有千倍的效能提高!如果把 vector 的尺寸加大,move
版本的執行時間並不會有多大區別,而 copy 版本的執行時間則會隨對象增大而延長。我們以後寫一個函數來構造對象時,再也不需要使用醜陋的類似
void make_vector(vector<string>& out)
之類的辦法來避免對象拷貝,我們只需要在返回點或者調用點加上 move !
好吧,如果夠細心,你會發現上面的代碼玩了個花招:它沒有在迴圈中調用 make_vector ,它只是把值儲存起來,然後採用 copy 和 move
。第一個原因是如果在迴圈中調用 make_vector ,我們測得的大多數時間就都在構造上了,copy 和 move
之間的區別無法顯示出來;第二個原因,後面會談到。
如果你一定要看看在迴圈中調用 make_vector 的結果,也就是說把測試代碼中的
vector<string> s = src;
和
vector<string> s =
move(src);
分別替換為
vector<string> s =
make_vector();
和
vector<string> s =
move(make_vector());
在我這裡運行結果是這樣的
Vector copy ctor takes: 7928
Vector move ctor
takes: 3587
很明顯,兩個迴圈多執行的時間大致相同,那就是構造對象的時間了。
在 C++ 裡,凡事都有例外
如果我們把測試對象換成 string,在 string 的大小比較大的時候,結果大體相似,例如下面的程式測試 copy 和 move 大小為 20 的
string:
#include <string><br />#include <iostream><br />#include <ctime><br />using namespace std;<br />int main()<br />{<br /> string src(20, 'e');<br /> clock_t start, end;<br /> start = clock();<br /> for (int i = 0; i < 1000000; ++i)<br /> {<br /> string s = src;<br /> }<br /> end = clock();<br /> cout << "String copy ctor takes: " << end - start << endl;<br /> start = clock();<br /> for (int i = 0; i < 1000000; ++i)<br /> {<br /> string s = move(src);<br /> }<br /> end = clock();<br /> cout << "String move ctor takes: " << end - start << endl;<br /> return 0;<br />}
在我這裡,輸出差不多是
String copy ctor takes: 1728
String move ctor
takes: 40
由於拷貝 string 是一個比較快的操作,所以差距沒有那麼大,但仍然相當明顯。
到這裡,你一定會說“好,從此我一定會在代碼裡讓 rvalue reference 和 move 滿天飛”
然而,如果你把 string src 的尺寸縮小一點,到達15的時候,情況變了,輸出差不多是
String copy ctor takes: 40
String move ctor
takes: 42
為什麼拷貝一個15個字元的 string 比拷貝20個字元快那麼多?讀讀 string 類就會發現,string 類會給自己預分配16位元組的
buffer,如果拷貝對象不超過15個字元,就不需要重新分配空間,只需要調用 memcpy 就可以,這是一個相當高效的操作。而 move
在這種情況下則選擇不進行指標交換,而是調用 memmove,這往往比 memcpy 要慢一些。
我們得出什麼結論呢?有幾個
- 拷貝,尤其是少量資料的拷貝,其實很高效
- 動態記憶體分配相當昂貴,從上面的結果可以大致推斷出,分配一片空間大概比拷貝20個位元組多花40倍的時間
- 小字串(15個字元以下)的拷貝已經足夠最佳化了
我還沒有打算到此打住,如果就這麼簡單,那就不是 C++ 了。如果仔細考察對象的 copy 和 move ,事情會更加複雜。
傳回值和 RVO
寫一個很簡單的類 Foo,它的作用是幫我們瞭解 copy 和 move 之間,到底發生了什麼事。
#include <iostream><br />using namespace std;<br />struct Foo<br />{<br /> Foo() { cout << "Foo ctor" << endl; }<br /> Foo(const Foo&) { cout << "Foo copy ctor" << endl; }<br /> void operator=(const Foo&) { cout << "Foo operator=" << endl; }<br /> Foo(Foo&&) { cout << "Foo move ctor" << endl; }<br /> ~Foo() { cout << "Foo dtor" << endl; }<br /> void bar() {}<br />};<br />Foo make_foo()<br />{<br /> return Foo();<br />}<br />int main()<br />{<br /> cout << "Copy from rvalue: " << endl;<br /> Foo f1 = make_foo();<br /> cout << "-----------------------" << endl;<br /> cout << "Move from rvalue: " << endl;<br /> Foo f2 = move(make_foo());<br /> cout << "-----------------------" << endl;<br /> return 0;<br />}
輸出是什麼呢?
Copy from rvalue:
Foo ctor
-----------------------
Move from rvalue:
Foo ctor
Foo move ctor
Foo dtor
-----------------------
Foo dtor
Foo dtor
怎麼回事?當我們 copy 的時候,僅僅只調用了一個 constructor,甚至沒有調用 copy constructor ,而我們 move
的時候,卻需要調用一個 constructor,一個 move constructor 和一個 destructor。
Move 的情況比較容易理解,分為三步:
- 調用 constructor 構造一個臨時對象
- 從這個臨時對象進行 move constructing
- 銷毀這個臨時對象
而 copy 為什麼這麼省事?因為編譯器會使用 RVO(return value optimization),在傳回值是一個 rvalue
的時候,這個對象會直接構造在接收傳回值的對象空間中,從而減少了拷貝。而相反 move 則會阻礙編譯器進行 RVO,反而增加了兩個函數調用,如果
destructor 涉及動態空間的釋放以及一些耗時的操作,那可是偷雞不成蝕把米。
那我們又得到什麼結論呢?
- RVO 是個好東西
- 在有 RVO 的時候,move semantics 未必比較快
我還沒有打算住手,好戲在後面:
NRVO
如果把函數 make_foo 改成這個模樣:
Foo make_foo()<br />{<br /> Foo f;<br /> return f;<br />}
在 Debug 模式運行一下,結果就更加有趣了:
Copy from rvalue:
Foo ctor
Foo move ctor
Foo dtor
-----------------------
Move from rvalue:
Foo ctor
Foo move ctor
Foo dtor
Foo move ctor
Foo dtor
-----------------------
Foo dtor
Foo dtor
為什嗎?為什麼我們明明打算 copy ,卻調到了 move constructor;而 move 的時候,卻調用了兩個 move constructor
?我們一條條的分析。
copy
首先,無論 copy 還是 move,函數 make_foo 中 的 Foo f 都會導致一個 constructor 。
在這裡 f 是一個 lvalue,所以在 copy 時,編譯器沒法對它進行 RVO,而在 Debug 模式下其它的最佳化又關掉了,於是只好用傳回值構造對象
f1。
這裡有新東西出現了:新的 C++ 標準要求,在構造返回的臨時對象時,如果不使用 RVO,而類定義了 move
constructor,優先使用 move constructor。所以我們看到的 move constructor
調用,是用來初始化臨時對象的。
而有了這個臨時對象,編譯器倒是可以直接把它扔給 f1,從而節省一道從臨時對象到 f1 的拷貝。
之後局部對象 f 超出作用範圍,被銷毀。
move
構造對象 f。
和 copy 一樣,用 move constructor 構造臨時對象。
這裡問題來了:加入 move() 調用使得編譯器無法最佳化掉臨時對象到 f2 的拷貝,於是編譯器退而求其次,用 move constructor 來初始化
f2。
局部對象 f 被銷毀。
臨時對象被銷毀。
好的,我們看到 move() 又杯具了,如果是 Release 模式,會如何呢?結果是這樣的:
Copy from rvalue:
Foo
ctor
-----------------------
Move from rvalue:
Foo ctor
Foo move
ctor
Foo dtor
-----------------------
Foo dtor
Foo dtor
這裡 copy 和 move 雙方各減少了一個對象產生。是哪一個呢?答案是臨時對象。這要歸功於編譯器的 NRVO (Named Return Value
Optimization),這種最佳化讓編譯器能夠在返回一個 lvalue 的情況下,也減少一個對象 copy(或 move),但是這並沒能最佳化掉對於 f2
的構造。
結論
Rvalue reference, move semantics 都是好東西,std::move() 也是好東西,但是用得不對可能會適得其反。
事實上,在有了 move semantics 之後,最高效的的返回正是我們熟悉的形式:
Foo make_foo()<br />{<br /> Foo f;<br /> return f;<br />}<br />……<br />Foo f1 = make_foo();
因為編譯器會儘可能的使用 RVO 和 NRVO,而在無法使用這些最佳化時,由於 make_foo 返回一個 rvalue ,編譯器仍會儘力調用 move
constructor,而只有這些都失敗了,編譯器才會採取我們熟悉的 copy constructor --- 總之,不會比這個更壞了。