最初に
まず大事な事なので、「マルチスレッド化=絶対早い」という考えは絶対に捨ててください。1
この記事を書こうと思った理由は、専門学校でゲーム製作中にマルチスレッドの処理を組み込んでみようと思ったからです。現状の自分の知識では、基本的なc++マルチスレッドプログラミングを使える程度です。といっても以前から興味はあったのですが、なかなか手を付ける気になれなかったというだけなのですが(笑)。
一概にマルチスレッドプログラミングといっても、「std::thread」・「OpenMP」・「PPL(Parallel Pattern Library)」などがあります。勿論、これ以外の方法はあります。どれも、vs2019 2 上ならすぐに試せるので一度やってみてください。3
結局どのやり方も、マルチスレッドプログラミングを楽にするための道具です4。難易度は多少異なりますが(笑)
std::execution(実行ポリシー)とは?
詳細はここに掲載されていますが、簡単に言うと「algorithm / numric / memory ヘッダーを非常に簡単にマルチスレッド化・ベクトル化を許可する」機能です。
そうです、std::threadで作成してjoinするといった手間が無くなり「std::execution::per」などを引数の先頭に指定するだけでOKになります。ただし、何も工夫しないとデータの競合やデッドロックが発生する「可能性がある」ので注意です。といってもそれを除けば、夢のような機能になっています。ちなみに、あくまで「許可」なので、リソースが足りない状況になるとシングルスレッドになります。
あと、「#define _ENABLE_ATOMIC_ALIGNMENT_FIX」を定義することを忘れないようにしてください。忘れるとコンパイラーに怒られる時があります。
実際にやってみる
とにかくやってみましょう。懐かしい「int main()」さんの登場です。あと、Debugビルドでやっています。Releaseビルドでやってもいいのですが、実行速度が速くなるだけで、実際にゲーム製作で使うようなサイズではないのでしません。理論値検証という意味ではしてもいいのですが...。5
環境
Windows10 x64
Corei7-9750H CPU @ 2.60GHz
VisualStudio2019 C++17
例1
#define _ENABLE_ATOMIC_ALIGNMENT_FIX
using namespace std;
int main()
{
constexpr size_t SizeMax{ 10000000u }; // 1千万(これ以上は時間がかかるので勘弁してください)
Timer tim; // 時間計測クラス
NumberVector vector(SizeMax, 0);
random_device seed_gen;
mt19937 engine(seed_gen());
// 0~SizeMaxまで順に生成する
iota(vector.begin(), vector.end(), 0);
cout << "プログラム実行開始" << endl;
// 逐次処理
{
// シャッフル
shuffle(vector.begin(), vector.end(), engine);
// 計測開始
tim.Start();
// 普通に昇順に並び替える
stable_sort(execution::seq, vector.begin(), vector.end());
// 計測終了
tim.End();
// 表示
cout << "通常:" << tim.GetMilliTimer() << "ms" << endl;
}
// マルチスレッド化
{
// もう一度シャッフル
shuffle(vector.begin(), vector.end(), engine);
// 計測開始
tim.Start();
// マルチスレッド化で昇順に並び替える
stable_sort(execution::par, vector.begin(), vector.end());
// 計測終了
tim.End();
// 表示
cout << "マルチスレッド化:" << tim.GetMilliTimer() << "ms" << endl;
}
// マルチスレッド化および・もしくはベクトル化
{
// もう一度シャッフル
shuffle(vector.begin(), vector.end(), engine);
// 計測開始
tim.Start();
// マルチスレッド化および・もしくはベクトル化で昇順に並び替える
stable_sort(execution::par_unseq, vector.begin(), vector.end());
// 計測終了
tim.End();
// 表示
cout << "マルチスレッド化および・もしくはベクトル化:" << tim.GetMilliTimer() << "ms" << endl;
}
}
という1千万のサイズの配列を最初にランダムに並び替え、入れ替えるだけの非常に簡単なプログラムです。そして違いも、execution::seq・execution::par・execution::par_unseq だけという手軽さです。この場合は単純に並び替えているだけなので、勿論データの競合とかは発生しません。
ちなみに、「execution::par」などは残念ながら、各々独立した別のクラスなので動的に切り替えれないのが残念です。どうにかすれば出来るのかもしれないですが...教えて詳しい人('ω')ノ6
10回計測し、平均の結果です。↓
\ | 逐次 | マルチスレッド化 | マルチスレッド化および・もしくはベクトル化 |
---|---|---|---|
sort | 20.639s | 5.499s | 5.718s |
stable_sort | 16.547s | 7.939s | 8.077s |
この通り、マルチスレッド化した方が速く、約1/4になってます。更にstable_sortの逐次処理時は高速化していますが、マルチスレッド化した時のsort時と比べると遅くなっています。意外です。
例2
外部・内部・処理部でマルチスレッド化か逐次処理にするかによる高速化具合を確かめる例です。例を挙げるなら、敵1,2,3,4の別クラスをまとめて7一つの配列で管理する時です。個人的にはこっちの方が気になっています。
処理部分は「シャッフル・並び替え・ソート済みか判断・合計値を計算・最大値と最小値を計算・50を探索」になっています。あと、sortとstable_sortの違いを分かり易くする為に、昇順と降順の両方を実行しています。
#define _ENABLE_ATOMIC_ALIGNMENT_FIX // 忘れずに書いておく
using namespace std;
struct Base
{
static constexpr size_t SizeMax{ 100000u }; // 10万(これ以上は時間がかかるので勘弁してください)
Base() : vec(SizeMax, 0), engine(seed_gen())
{
// 0~SizeMaxまで順に生成する
iota(vec.begin(), vec.end(), 0);
}
vector<int> vec;
random_device seed_gen;
mt19937 engine;
int64_t accumulate_num{};
// 時間のかかる処理
template<class ExecutionPolicy>
void ProcUpdate(ExecutionPolicy&& exec)
{
// シャッフル
shuffle(vec.begin(), vec.end(), engine);
/// ソートの種類による時間を分かり易くする為に昇順・降順の両方をやります。
// 昇順に並び替える
sort(exec, vec.begin(), vec.end());
// シャッフル
shuffle(vec.begin(), vec.end(), engine);
// 降順に並び替える
sort(exec, vec.begin(), vec.end(), std::greater<int>());
// ソート済みかどうか判断
bool is_sort{ is_sorted(exec, vec.begin(), vec.end()) };
// 合計を求める
int sum{ reduce(exec, vec.begin(), vec.end()) };
// 最大値と最小値を計算
auto min_max{ minmax_element(exec, vec.begin(), vec.end()) };
// 50を探索
auto find_data{ find(exec, vec.begin(), vec.end(), 50) };
}
};
この構造体が内部処理を行っています。上記の例で説明すると、敵の処理を書いている部分に当たります。
struct ArrayManager
{
static constexpr size_t Length{ 10u }; // 繰り返し回数
ArrayManager() : arr(Length) {}
vector<Base> arr;
// 二周目
template<class MyExecutionPolicy, class BaseExecutionPolicy>
void ArrayUpdate(MyExecutionPolicy&& second_exec, BaseExecutionPolicy&& proc_exec)
{
std::for_each(second_exec, arr.begin(), arr.end(), [&proc_exec](Base& base) { base.ProcUpdate(proc_exec); });
}
};
この構造体が配列の管理を行っています。上記の例で説明すると、1種類の敵配列を管理するマネージャー部分に当たります。
int main()
{
constexpr size_t MaxSize{ 10u };
constexpr size_t Length{ 10u };
vector<ArrayManager> manager_arr(MaxSize);
Timer tim; // 時間計測クラス
int64_t accumulate_num{};
cout << "プログラム実行開始" << endl << endl;
// 処理実行
auto ExecProc{ [&tim, &accumulate_num, &manager_arr]
(const auto& first_loop_exec/*初回ループの実行ポリシー*/, const auto& second_loop_exec/*二回目の実行ポリシー*/, const auto& proc_exec/*処理部分の実行ポリシー*/)
{
tim.Start();
for_each(first_loop_exec, manager_arr.begin(), manager_arr.end(), [&second_loop_exec, &proc_exec](ArrayManager& data)
{
data.ArrayUpdate(second_loop_exec, proc_exec);
});
tim.End();
// 平均を計算する為に経過時間を加算
accumulate_num += tim.GetMilliTimer();
// 1計算毎の表示
cout << tim.GetMicroTimer() << "μs | " << tim.GetMilliTimer() << "ms | " << tim.GetSecTimer() << "s" << endl;
} };
// 平均の表示
auto AvaragePrint{ [&accumulate_num, Length]()
{
// 平均の表示
cout << "平均:" << (accumulate_num / Length) << "ms | " << (accumulate_num / Length) / 1000.0 << "s" << endl << endl;
accumulate_num = 0;
} };
// 全て逐次処理-------------------------------------------------------------------------------
{
cout << "全て逐次処理" << endl;
for (size_t i = 0; i < Length; i++)
{
ExecProc(execution::seq, execution::seq, execution::seq);
}
// 平均の表示
AvaragePrint();
}
// 一周目は逐次処理、二周目はマルチスレッド化、処理は逐次処理-------------------------------------------------
{
cout << "一周目は逐次処理、二周目はマルチスレッド化、処理は逐次処理" << endl;
for (size_t i = 0; i < Length; i++)
{
ExecProc(execution::seq, execution::par, execution::seq);
}
// 平均の表示
AvaragePrint();
}
// 一周目はマルチスレッド化、二周目は逐次処理、処理は逐次処理--------------------------------------------------
{
cout << "一周目はマルチスレッド化、二周目は逐次処理、処理は逐次処理" << endl;
for (size_t i = 0; i < Length; i++)
{
ExecProc(execution::par, execution::seq, execution::seq);
}
// 平均の表示
AvaragePrint();
}
// 一周目は逐次処理、二周目はマルチスレッド化、処理はマルチスレッド化--------------------------------------------
{
cout << "一周目は逐次処理、二周目はマルチスレッド化、処理はマルチスレッド化" << endl;
for (size_t i = 0; i < Length; i++)
{
ExecProc(execution::seq, execution::par, execution::par);
}
// 平均の表示
AvaragePrint();
}
// 一周目はマルチスレッド化、二周目は逐次処理、処理はマルチスレッド化-------------------------------------------
{
cout << "一周目はマルチスレッド化、二周目は逐次処理、処理はマルチスレッド化" << endl;
for (size_t i = 0; i < Length; i++)
{
ExecProc(execution::par, execution::seq, execution::par);
}
// 平均の表示
AvaragePrint();
}
// 一周目は逐次処理、二周目は逐次処理、処理はマルチスレッド化--------------------------------------------------
{
cout << "一周目は逐次処理、二周目は逐次処理、処理はマルチスレッド化" << endl;
for (size_t i = 0; i < Length; i++)
{
ExecProc(execution::seq, execution::seq, execution::par);
}
// 平均の表示
AvaragePrint();
}
// 一周目はマルチスレッド化、二周目はマルチスレッド化、処理は逐次処理---------------------------------------------
{
cout << "一周目はマルチスレッド化、二周目はマルチスレッド化、処理は逐次処理" << endl;
for (size_t i = 0; i < Length; i++)
{
ExecProc(execution::par, execution::par, execution::seq);
}
// 平均の表示
AvaragePrint();
}
// 全てマルチスレッド化----------------------------------------------------------------------------------
{
cout << "全てマルチスレッド化" << endl;
for (size_t i = 0; i < Length; i++)
{
ExecProc(execution::par, execution::par, execution::par);
}
// 平均の表示
AvaragePrint();
}
}
・1週目が10回、2周目が10回、処理部(配列サイズ10万)
10回計測し、平均の結果です。↓
\ | sort | stable_sort |
---|---|---|
全て逐次処理 | 31.612s | 26.985s |
一週目は逐次処理、二週目はマルチスレッド化、処理部は逐次処理 | 6.242s | 4.952s |
一週目はマルチスレッド化、二週目は逐次処理、処理部は逐次処理 | 6.148s | 5.055s |
一週目は逐次処理、二週目はマルチスレッド化、処理部はマルチスレッド化 | 6.465s | 7.360s |
一週目はマルチスレッド化、二週目は逐次処理、処理部はマルチスレッド化 | 6.039s | 7.273s |
一週目は逐次処理、二週目は逐次処理、処理部はマルチスレッド化 | 11.126s | 12.123s |
一週目はマルチスレッド化、二週目はマルチスレッド化、処理部は逐次処理 | 5.840s | 4.894s |
全てマルチスレッド化 | 5.972s | 7.318s |
この通り↑の結果になりました。処理部分はソート以外の処理もあるので、分けるのも微妙な気がしますが(笑)。あと、実行途中にWindowsUpdateのせいで再起動するという悲しい事故も発生しました。
あとは、他のパターンを試しました。時短の為に以降は3回計測で平均を取っています(どれも誤差ぐらいなので)。
・1周目10回、2周目100回、処理部(配列サイズ1万)
\ | sort | stable_sort |
---|---|---|
全て逐次処理 | 25.637s | 23.481s |
一週目は逐次処理、二週目はマルチスレッド化、処理部は逐次処理 | 4.750s | 3.977s |
一週目はマルチスレッド化、二週目は逐次処理、処理部は逐次処理 | 4.88s | 4.373s |
一週目は逐次処理、二週目はマルチスレッド化、処理部はマルチスレッド化 | 4.884s | 4.493s |
一週目はマルチスレッド化、二週目は逐次処理、処理部はマルチスレッド化 | 5.027s | 4.496s |
一週目は逐次処理、二週目は逐次処理、処理部はマルチスレッド化 | 10.386s | 9.377s |
一週目はマルチスレッド化、二週目はマルチスレッド化、処理部は逐次処理 | 4.741s | 4.282s |
全てマルチスレッド化 | 4.930s | 4.487s |
・1周目10回、2周目5回、処理部(配列サイズ20万)
\ | sort | stable_sort |
---|---|---|
全て逐次処理 | 32.893s | 28.315s |
一週目は逐次処理、二週目はマルチスレッド化、処理部は逐次処理 | 7.949s | 6.575s |
一週目はマルチスレッド化、二週目は逐次処理、処理部は逐次処理 | 6.371s | 5.115s |
一週目は逐次処理、二週目はマルチスレッド化、処理部はマルチスレッド化 | 6.846s | 8.347s |
一週目はマルチスレッド化、二週目は逐次処理、処理部はマルチスレッド化 | 6.307s | 8.061s |
一週目は逐次処理、二週目は逐次処理、処理部はマルチスレッド化 | 11.287s | 12.987s |
一週目はマルチスレッド化、二週目はマルチスレッド化、処理部は逐次処理 | 6.084s | 5.165s |
全てマルチスレッド化 | 7.272s | 8.069s |
・1周目20回、2周目5回、処理部(配列サイズ10万)
\ | sort | stable_sort |
---|---|---|
全て逐次処理 | 31.221s | 27.073s |
一週目は逐次処理、二週目はマルチスレッド化、処理部は逐次処理 | 7.530s | 6.345s |
一週目はマルチスレッド化、二週目は逐次処理、処理部は逐次処理 | 6.010s | 5.005s |
一週目は逐次処理、二週目はマルチスレッド化、処理部はマルチスレッド化 | 6.410s | 7.506s |
一週目はマルチスレッド化、二週目は逐次処理、処理部はマルチスレッド化 | 5.903s | 7.206s |
一週目は逐次処理、二週目は逐次処理、処理部はマルチスレッド化 | 10.802s | 12.324s |
一週目はマルチスレッド化、二週目はマルチスレッド化、処理部は逐次処理 | 5.729s | 4.969s |
全てマルチスレッド化 | 5.855s | 7.220s |
・1周目5回、2周目20回、処理部(配列サイズ10万)
\ | sort | stable_sort |
---|---|---|
全て逐次処理 | 31.557s | 27.149s |
一週目は逐次処理、二週目はマルチスレッド化、処理部は逐次処理 | 5.986s | 4.899s |
一週目はマルチスレッド化、二週目は逐次処理、処理部は逐次処理 | 7.686s | 6.520s |
一週目は逐次処理、二週目はマルチスレッド化、処理部はマルチスレッド化 | 6.038s | 7.252s |
一週目はマルチスレッド化、二週目は逐次処理、処理部はマルチスレッド化 | 6.270s | 7.208s |
一週目は逐次処理、二週目は逐次処理、処理部はマルチスレッド化 | 10.949s | 12.085s |
一週目はマルチスレッド化、二週目はマルチスレッド化、処理部は逐次処理 | 5.751s | 4.921s |
全てマルチスレッド化 | 5.980s | 7.223s |
・1周目20回、2周目20回、処理部(配列サイズ2万5千)
\ | sort | stable_sort |
---|---|---|
全て逐次処理 | 28.181s | 24.821s |
一週目は逐次処理、二週目はマルチスレッド化、処理部は逐次処理 | 5.296s | 4.544s |
一週目はマルチスレッド化、二週目は逐次処理、処理部は逐次処理 | 5.494s | 4.779s |
一週目は逐次処理、二週目はマルチスレッド化、処理部はマルチスレッド化 | 5.335s | 5.553s |
一週目はマルチスレッド化、二週目は逐次処理、処理部はマルチスレッド化 | 5.453s | 5.526s |
一週目は逐次処理、二週目は逐次処理、処理部はマルチスレッド化 | 10.424s | 10.198s |
一週目はマルチスレッド化、二週目はマルチスレッド化、処理部は逐次処理 | 5.172s | 4.546s |
全てマルチスレッド化 | 5.361s | 5.512s |
・1周目5回、2周目5回、処理部(配列サイズ40万)
\ | sort | stable_sort |
---|---|---|
全て逐次処理 | 34.641s | 29.165s |
一週目は逐次処理、二週目はマルチスレッド化、処理部は逐次処理 | 8.468s | 7.043s |
一週目はマルチスレッド化、二週目は逐次処理、処理部は逐次処理 | 8.460s | 7.028s |
一週目は逐次処理、二週目はマルチスレッド化、処理部はマルチスレッド化 | 7.268s | 9.432s |
一週目はマルチスレッド化、二週目は逐次処理、処理部はマルチスレッド化 | 6.880s | 9.173s |
一週目は逐次処理、二週目は逐次処理、処理部はマルチスレッド化 | 12.038s | 13.778s |
一週目はマルチスレッド化、二週目はマルチスレッド化、処理部は逐次処理 | 6.582s | 5.437s |
全てマルチスレッド化 | 6.662s | 9.137s |
Timerクラスを載せるのを忘れていました↓
※下のクラスは「chrono」が使える環境なら、汎用性はかなり高いはずなので、自由にプログラムに組み込んでみて下さい。
#pragma once
#include <chrono>
#include <utility>
#include <cassert>
class Timer
{
private:
using sys_clock = std::chrono::system_clock;
public:
Timer() : check(false){ };
~Timer() = default;
Timer(const Timer&) = delete;
auto& operator=(const Timer&) = delete;
Timer(Timer&& _Right) noexcept
: tim(std::move(_Right.tim)), check(_Right.check)
{ }
auto& operator=(Timer&& _rt) noexcept
{
using std::move;
if (this != &_rt)
{
tim = move(_rt.tim);
check = _rt.check;
}
return (*this);
}
private:
std::pair<sys_clock::time_point, sys_clock::time_point> tim; // 開始時間・終了時間
bool check; // 計測の有無
public:
// 計測開始
void Start()
{
assert(!check && "時間計測終了出来ていない");
tim.first = sys_clock::now();
check = true;
}
// 再計測
void ReStart()
{
assert(check && "時間計測を始めていない");
tim.first = sys_clock::now();
check = true;
}
// 現在のナノ秒
_NODISCARD auto NowNanoTime()
{
assert(check && "時間計測を始めていない");
return std::chrono::duration_cast<std::chrono::nanoseconds>(
sys_clock::now() - tim.first).count();
}
// 現在のマイクロ秒
_NODISCARD auto NowMicroTime()
{
assert(check && "時間計測を始めていない");
return std::chrono::duration_cast<std::chrono::microseconds>(
sys_clock::now() - tim.first).count();
}
// 現在のミリ秒
_NODISCARD auto NowMilliTime()
{
assert(check && "時間計測を始めていない");
return std::chrono::duration_cast<std::chrono::milliseconds>(sys_clock::now() - tim.first).count();
}
// 現在の秒
_NODISCARD auto NowSecTime()
{
assert(check && "時間計測を始めていない");
return std::chrono::duration_cast<std::chrono::seconds>(
sys_clock::now() - tim.first).count();
}
// 計測終了
void End()
{
assert(check && "時間計測を始めていない");
tim.second = sys_clock::now();
check = false;
}
public:
_NODISCARD bool IsStartedTimer() const { return check; }
_NODISCARD bool IsEndedTimer() const { return !check; }
// ナノ秒取得
_NODISCARD auto GetNanoTimer() const
{
assert(!check && "時間計測終了出来ていない");
return std::chrono::duration_cast<std::chrono::nanoseconds>(
tim.second - tim.first).count();
}
// マイクロ秒取得
_NODISCARD auto GetMicroTimer() const
{
assert(!check && "時間計測終了出来ていない");
return std::chrono::duration_cast<std::chrono::microseconds>(
tim.second - tim.first).count();
}
// ミリ秒取得
_NODISCARD auto GetMilliTimer() const
{
assert(!check && "時間計測終了出来ていない");
return std::chrono::duration_cast<std::chrono::milliseconds>(
tim.second - tim.first).count();
}
// 秒取得
_NODISCARD auto GetSecTimer() const
{
assert(!check && "時間計測終了出来ていない");
return std::chrono::duration_cast<std::chrono::seconds>(
tim.second - tim.first).count();
}
};
最後に
以上、大量に検証して結果を載せましたが、プログラミング時間より検証時間の方が長くなりました。結局のところ、この結果を速いと捉えるか遅いと捉えるかは皆さんの考え方次第です8。
参考サイト
- std::threadの日本語リファレンス
- IntelのOpenMPの説明PDF
- PPLの機能説明のサイト
- std::executionの日本語リファレンス
- algorithmヘッダーの日本語リファレンス
- numericヘッダーの日本語リファレンス
- memoryヘッダーの日本語リファレンス
- ベクトル化(SIMD)を説明しているサイト
-
勿論、分かってる方がこの記事を読んでいるのならば関係ありませんが。 ↩
-
自分の環境がvs2019だからです。vs2019未満ならこれを機にバージョンアップしましょう(笑) ↩
-
結構有名なので、やり方は各々調べてみてください。どれも直ぐに出来ます。 ↩
-
ただし、制作者には感謝を忘れないように... ↩
-
気になる人はソースコードを載せているので、各々やってみてください。 ↩
-
あ、条件式を使えばいいとかは無しですよ?ポリモーフィズムとかを使って切り替えたいという願望です。 ↩
-
ポリモーフィズムやstd::variantなどを使うことです ↩
-
実際のところ、ゲーム製作ではこれほど大きいサイズの配列を使うことは無いですし。使う場面(パーティクルなど)はGPGPUで処理した方がいい気もしなくもないと思う次第です。GPUだとスレッド生成コストはほぼ無いですし。 ↩