7
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

std::execution(実行ポリシー)によるマルチスレッド化は実際のところ どうなのか

Last updated at Posted at 2020-03-12

最初に

 まず大事な事なので、「マルチスレッド化=絶対早い」という考えは絶対に捨ててください。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

参考サイト

  1. 勿論、分かってる方がこの記事を読んでいるのならば関係ありませんが。

  2. 自分の環境がvs2019だからです。vs2019未満ならこれを機にバージョンアップしましょう(笑)

  3. 結構有名なので、やり方は各々調べてみてください。どれも直ぐに出来ます。

  4. ただし、制作者には感謝を忘れないように...

  5. 気になる人はソースコードを載せているので、各々やってみてください。

  6. あ、条件式を使えばいいとかは無しですよ?ポリモーフィズムとかを使って切り替えたいという願望です。

  7. ポリモーフィズムやstd::variantなどを使うことです

  8. 実際のところ、ゲーム製作ではこれほど大きいサイズの配列を使うことは無いですし。使う場面(パーティクルなど)はGPGPUで処理した方がいい気もしなくもないと思う次第です。GPUだとスレッド生成コストはほぼ無いですし。

7
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?