C++ smart pointer 之速度之討論(一)
在 C++11 中,引入了 unique_ptr
以及 shared_ptr
兩種新的 smart pointer 來取代 C++03 很難用的 auto_ptr
。網路上有許多文章在討論 auto_ptr
的缺點,這不在本的討論範圍內,基本的 unique_ptr
功能也不在本文的討論範圍。本文中,我將會用一個例子,討論如何使用這兩種 smart pointer 才能增進程式運行速度。
為何要使用 smart pointer
我們來看一個實際例子:
1typedef array<int, 16> LargeStruct;
2int main()
3{
4 circular_buffer<LargeStruct> cbuf(128);
5 for (int i = 0; i < 10000000; ++i) {
6 // write to buffer
7 LargeStruct ls1;
8 cbuf.push_back(ls);
9 // read from buffer
10 LargeStruct ls2 = cbuf.front();
11 cbuf.pop_front();
12 }
13}
這個例子中,使用了 boost::circular_buffer
,該資料結構操作相當於有最大容量限制的 deque(這邊設 128),於是可以用 std::array
的儲存方式。這個資料結構在遵守最大容量限制的前提下,同時有 deque
的操作界面跟 array
的效率,相當好用,可惜 C++ 沒有內建這個 container。
這個例子中,我們重複的對這個 buffer 寫入資料,再讀取出來。由於 buffer 本身的儲存空間跟 ls1
, ls2
不共享,也就是說每次的讀寫都要複製資料,這個例子中被讀寫的資料 LargeStruct
有 64 bytes,有可能使執行效率低下。
什麼樣的情形下有可能用到類似這個例子寫法呢?為什麼這個例子的執行速度是關鍵呢?我自己的答案是,在系統、硬體模擬的時候可能需要 Producer/Consumer model,Producer 傳送一個封包 (i.e. LargeStruct
) 給 Consumer 處理。
1circular_buffer<DataType> cbuf(128);
2struct Producer {
3 int counter = 0;
4 void Run();
5};
6
7struct Consumer {
8 void Run();
9};
10
11int main()
12{
13 Producer p;
14 Consumer c;
15 for (int i = 0; i < 40000000; ++i) {
16 p.Run();
17 c.Run();
18 }
19 return 0;
20}
例如這個系統中,Producer 可以在 1ms 產生隨機的 0-31 筆封包,Consumer 可以在 1ms 處理隨機的 0-31 筆封包,中間的 buffer 可以暫存最多 128 資料。而兩個 Run()
函數就是描述 1ms 中發生的行為,這個系統總共模擬了 400000000ms。
Producer::Run()
產生隨機的 0-31 筆封包的程式碼如下,把傳送的 array 都塞滿一個 counter 值,假裝我們真的寫一些資料進去。
1void Producer::Run()
2{
3 const int times = random() % 32;
4 for (int i = 0; i < times and not cbuf.full(); ++i) {
5 LargeStruct ls;
6 fill(ls.begin(), ls.end(), counter);
7 cbuf.push_back(ls);
8 ++counter;
9 }
10}
Consumer::Run()
則是:
1void Consumer::Run()
2{
3 const int times = random() % 32;
4 for (int i = 0; i < times and not cbuf.empty(); ++i) {
5 cbuf.pop_front();
6 }
7}
剛剛也提到了,主要的問題就出在 cbuf.push_back(ls)
需要複製一次完整的 LargeStruct
。如果 cbuf 裡面存的是 pointer,就能避免掉資料的複製。這個程式在我電腦上的執行時間是 6.76s,接下來我們就用 pointer 的方式來傳遞資料,加速這個程式吧!
使用 smart pointer 避免複製
使用 unique_ptr
把陣列換成以 new 的方式產生,並不是困難的事情。唯一的不同就是 push_back
的時候要用 move
明確把 pointer 的擁有權轉移到 cbuf
上,如此一來只需要更改 Producer::Run()
,就可以改為用 pointer 的方式來傳遞資料了。
1circular_buffer<unique_ptr<LargeStruct>> cbuf(128);
2unique_ptr<LargeStruct> ls(new LargeStruct);
3fill(ls->begin(), ls->end(), counter);
4cbuf.push_back(move(ls));
接著把這個程式拿去跑,出現了 12.39s 的執行時間,竟然比原本的程式還慢,發生了什麼事情呢?
使用 memory pool 避免 new 跟 delete
檢查一下上面的程式碼,第一跟第三行沒有修改,應該不會是效能差異的來源。第四行主要是把 copy 整個 LargeStruct
換成 copy pointer,應該會比較快才對,所以可以把問題限制在這行:
1unique_ptr<LargeStruct> ls(new LargeStruct);
一般來說 new 從作業系統要資料時,作業系統要從記憶體空間找出一個合適的位置來用,合理猜測這個動作產生了太大的 overhead。因此,我們必須盡量減少 new 的次數,一個常見的解法就是先把 delete 掉的資料暫時存在一個地方,要 new 的時候就先從這個地方看看能不能直接用,一般把這個叫做 memory pool。也就是自己幫作業系統作初步的記憶體管理,減輕作業系統的負擔。
最簡單的 pool 就是一個額外的 std::vector
:
1vector<unique_ptr<LargeStruct>> pool;
當程式要 new 的時候就先從這邊檢查,memory pool 中沒有剩下的 unique_ptr
的時候才會去 new 一個,這樣我們可以把總共的 new 次數控制在 128 次上下。
1unique_ptr<LargeStruct> ls;
2if (pool.empty()) {
3 ls.reset(new LargeStruct);
4} else {
5 ls = move(pool.back());
6 pool.pop_back();
7}
8fill(ls->begin(), ls->end(), counter);
9cbuf.push_back(move(ls));
Consumer::Run()
這時也得作相應的修改,必須把 pop
掉的記憶體放回去 memory pool:
1if (cbuf.front().use_count() == 1) {
2 pool.push_back(move(cbuf.front()));
3}
4cbuf.pop_front();
經過修改之後,這個程式花了 5.21s 的執行時間,也就是說使用 smart pointer 配合 memory pool 大概得到了 20% 的加速!
結果&結論
前面的例子是使用,一般的模擬中,一個系統往往會有超過一個的 Producer/Consumer,所以資料不會只有一個 owner,這時 shared_ptr
才是合適的資料結構,在這個範例程式中,unique_ptr
跟 shared_ptr
是可以互換的。下一個段落中,我們將會比較不同的複製資料量、不同實做的運行速度。
此表格量測了不同的複製資料量時,不同實做的運行速度,單位是秒。
Copy | No poolunique_ptr |
With poolunique_ptr |
No poolshared_ptr |
With poolshared_ptr |
|
---|---|---|---|---|---|
16 bytes | 2.76 | 9.60 | 3.62 | 18.71 | 3.83 |
64 bytes | 6.76 | 12.39 | 5.21 | 20.90 | 6.40 |
256 bytes | 16.12 | 28.64 | 17.32 | 31.14 | 12.50 |
在我們使用 smart pointer 減低資料複製的時候,必須考量到 new 跟 delete 帶來的 overhead,其原則如下:
- 複製 10 bytes 數量級時就直接複製。
- 使用
unique_ptr
產生 new 跟 delete 的 overhead 的時候,效率會很慢,在 100 bytes 數量級時,不如直接複製資料。而此時shared_ptr
的速度又遠比unique_ptr
糟糕。 - 使用 memory pool 避免產生 new 跟 delete 的 overhead 的時候,
unique_ptr
跟shared_ptr
的速度大多能改善程式效率,兩者效率並沒有決定性的差異。考慮到兩者的使用範圍,shared_ptr
是個比較好的選擇。
系列文連結
-
C++ smart pointer 之速度之討論(一)
-
C++ smart pointer 之速度之討論(二)
- C++ smart pointer 之速度之討論(一)
- C++ smart pointer 之速度之討論(二)