「Parallel algorithms and execution policies」是 C++17 加入標準函式庫的一個演算法功能,他基本上讓開發者可以在呼叫 <algorithm>
提供的演算法的時候、只要加入一個新的參數、就可以完成程式的平行化,算是相當實用的功能。
例如要透過 std::sort()
來把一個 std::vector<>
的資料 vData
進行排序的話,可以寫成:
std::sort(vData.begin(), vData.end());
而在 C++17 後,如果覺得 vData
的資料太多、排序要花的時間太久的話,則可以改成:
std::sort(std::execution::par, vData.begin(), vData.end());
這邊只要多加入一個 std::execution::par
(需要 include <execution>
)、告訴編譯器要平行化執行這個演算法就可以了!相當容易使用!
完整的程式碼大概是下面的樣子:
#include <iostream> #include <vector> #include <algorithm> #include <execution> int main() { std::vector vData = { 1,3,5,7,2,4,6 }; std::sort(vData.begin(), vData.end()); // after C++17 std::sort(std::execution::par, vData.begin(), vData.end()); for (const auto& v : vData) std::cout << v << ' '; }
標準函式庫這邊的設計、是只要在 <algorithm>
提供的演算法的第一個參數、多加一個 execution policy(執行策略、Cpp Reference)就可以要求函式庫應該要用什麼方法來執行。
目前總共提供四種 execution policy:
std::execution::sequenced_policy
:依序執行、不要平行化-
std::execution::parallel_policy
:平行化處理 -
std::execution::parallel_unsequenced_policy
:平行化並向量化 -
std::execution::unsequenced_policy
:向量化
而實際在使用的時候,則是要使用對應的物件(文件):
std::execution::seq
std::execution::par
std::execution::par_unseq
std::execution::unseq
其中,前三者都是在 C++17 就有了,而第四項則是在 C++20 才加入的。
不過要注意的是,雖然可以指定 execution policy 要求函式庫使用什麼方法來做運算、但是實際上最後會不會真的照指定的方法去做、其實還是要看使用的函示庫是怎麼實作的。
簡單的效能測試
下面是一個 C++ Reference 給的例子(稍微簡化一點):
#include <algorithm> #include <chrono> #include <iostream> #include <random> #include <vector> #include <execution> void measure(auto policy, std::vector<int> v) { const auto start = std::chrono::steady_clock::now(); std::sort(policy, v.begin(), v.end()); const auto finish = std::chrono::steady_clock::now(); std::cout << std::chrono::duration_cast<std::chrono::milliseconds>(finish - start).count() << '\n'; }; int main() { // generate a data array with random generator std::vector<int> v(10'000'000); std::mt19937 gen{ std::random_device{}() }; std::ranges::generate(v, gen); // sort in different policy measure(std::execution::seq, v); measure(std::execution::unseq, v); measure(std::execution::par, v); measure(std::execution::par_unseq, v); }
這邊是先透過 C++ 20 Ranges 和 C++11 的亂數產生器來產生一個數量很多的亂數資料。
之後,則是透過 measure()
這個函式,來使用上面四種 execution policy 執行 std:sort()
來對資料進行排序、並計算所需要的時間。
在 Heresy 這邊在 Visual Studio 2022 上測試的結果是:
565 563 71 75
基本上,在這個例子來說,有沒有向量化(unsequenced) 沒有明顯的差異、甚至可能還慢一點;但是有沒有平行化(parallel)則是有相當大的差距!速度快了超過 7 倍~
所以如果真的有需要的話,透過這樣簡單的修改就可以加速、其實是很方便的。
在 g++ 使用
如果是要使用 g++ 的話,有一點可能要稍微注意一下。
在只有安裝 g++(這邊是測試 g++ 12)的狀況下、直接用「g++ --std=c++20 main.cpp
」編譯的話,雖然也可以成功編譯,但是執行的時候應該會發現所需要的時間並沒有顯著地減少、感覺不出來有平行化。
這應該是因為 g++ 本身預設提供的 STL 雖然有提供對應的函式介面、但是實際的實作應該是沒有真的實作平行化的演算法。所以如果有需要真的支援平行化的,要另外安裝「libtbb-dev
」(Threading Building Blocks)、然後連結的時候要加上「-ltbb
」才行。
以簡單的程式來說,編譯的指令會變成「g++ --std=c++20 main.cpp -ltbb
」;而同一個範例的結果會是類似下面的狀況(-O2
):
446 463 39 30
感覺上 g++(或者說 TBB)的平行化的效益似乎是比 MSVC 來的好?速度快了 12 倍以上。
基本上,個人覺得 C++ 加入演算法平行化功能最大的好處,應該還是有機會可以直接使用編譯器提供的各項平行化演算法。
不過老實說,個人以前真的有拿來用的…好像比較多的機會只有 sort 而已? XD
其他會用到的,好像頂多就是 all_of()
、max_element()
、count_if()
這類相對簡單的?而實際上,有的時候甚至也會因為資料性質的關係、自己用 for 迴圈去寫了。
而且這邊比較尷尬的,是之前就發現在目前 MSVC 提供的 STL 裡面,max_element()
這幾個函式實際上根本沒有實作平行化…裡面註解直接寫著:
not parallelized at present, parallelism expected to be feasible in a future release
所以如果真的想要比較快,可能還得看微軟什麼時候補上了。
至於 transform()
和 for_each()
,雖然也是可以用來處理陣列裡面的資料,但是便利性上總覺得不算很高?尤其是要使用 index 來存取不同的資料的時候,直接用 for 迴圈加上 OpenMP 感覺還是比較好的選擇。(額外參考:《透過 counting_iterator 讓 std::for_each_n 用索引值來操作》)
不過另一方面,要使用 OpenMP 的缺點就是需要編譯器在語法上支援,而 Visual Studio 在 OpenMP 的支援真的還是滿糟的就是了…到現在標準編譯選項還是只有 OpenMP 2.0 而已…(光是 for 迴圈只能用無號整數的限制就很頭大了)
雖然有在透過新的 LLVM OpenMP 來支援比較新的標準,但是不但支援性還是不完整,而且到現在也都屬於測試階段、需要的 DLL 到現在也都還在 nonredist 的資料夾下、沒有放在可轉發套件裡面…感覺現階段其實還不適合拿來實用。
最後,以標準演算法的平行化來說,個人其實還是希望以後 STL 看看能不能加入新的 execution policy、支援 GPGPU 或其他加速器。
又或者,由於這部分只需要函式庫層級的支援、所以如果有第三方有實作的話,或許也是有機會可以拿來當擴展使用的。像是 AdaptiveCpp(GitHub)其實應該就是有提供將標準函示酷的演算法用 GPU 來計算的功能(參考);只不過要在 Visual Studio 使用感覺還很麻煩、而且有些問題就是了(參考)。
補充:實際上 Heresy 在很久以前,就曾經有寫過一篇《Visual C++ 2013 的 STL 平行化函示庫》,介紹了當時還不是 C++ 標準的 Parallel STL;而在 C++17 納入標準的時候,大致上就是 namespace 之類有做了一點修改。
而現在基本上各家編譯器應該也都有支援了(但是內部實作完不完整就再說了),所以這邊算是拿標準的版本來補一下紀錄了。