C++ smart pointer 之速度之討論(一)

Share on:

本文內容採用創用 CC 姓名標示-非商業性-相同方式分享 3.0 台灣 授權條款授權.

在 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_ptrshared_ptr 是可以互換的。下一個段落中,我們將會比較不同的複製資料量、不同實做的運行速度。

此表格量測了不同的複製資料量時,不同實做的運行速度,單位是秒。

Copy No pool
unique_ptr
With pool
unique_ptr
No pool
shared_ptr
With pool
shared_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_ptrshared_ptr 的速度大多能改善程式效率,兩者效率並沒有決定性的差異。考慮到兩者的使用範圍,shared_ptr 是個比較好的選擇。

  1. C++ smart pointer 之速度之討論(一)
  2. C++ smart pointer 之速度之討論(二)