본문 바로가기

Information Technology/C++

[C++] 벡터 내적과 멀티 쓰레딩

이 글은 개인의 학습을 목적으로 정리한 글입니다. 이점 참고하고 읽어주세요 ;)


1. std::inner_product 함수와 가장 기본적인 멀티 쓰레딩 설정의 내적 실행 비교

#include <chrono>
#include <iostream>
#include <mutex>
#include <random>
#include <thread>
#include <utility>
#include <vector>
#include <atomic>
#include <future>
#include <numeric>	// dot-product
#include <execution>

using namespace std;
mutex mtx;

void dotProductNative(const vector<int>& v0, const vector<int>& v1, const unsigned i_start, const unsigned i_end, unsigned long long& sum)
{
	for (unsigned i = i_start; i < i_end; ++i)
		sum += v0[i] * v1[i]; 
}

int main()
{
	const long long n_data = 100'000'000;
	const unsigned n_threads = 4;

	std::vector<int> v0, v1;
	v0.reserve(n_data);
	v1.reserve(n_data);

	random_device seed;
	mt19937 engine(seed());

	uniform_int_distribution<> uniformDist(1, 10);

	for (long long i = 0; i < n_data; ++i)
	{
		v0.push_back(uniformDist(engine)); // 랜덤 값
		v1.push_back(uniformDist(engine)); // 랜덤 값
	}

	cout << "std::inner_product" << endl;
	{
		const auto sta = chrono::steady_clock::now();

		const auto sum = std::inner_product(v0.begin(), v0.end(), v1.begin(), 0ull); // 0 unsigned long long

		const chrono::duration<double> dur = chrono::steady_clock::now() - sta; // 시간 측정

		cout << dur.count() << endl;
		cout << sum << endl; // inner_product의 정답
		cout << endl;
	}

	cout << "Native" << endl;
	{
		const auto sta = chrono::steady_clock::now();

		unsigned long long sum = 0;

		vector<thread> threads;
		threads.resize(n_threads);	// thread 개수를 유동적으로 조절 가능

		const unsigned n_per_thread = n_data / n_threads; // 주어진 데이터를 thread에게 알맞게 분배
		for (unsigned t = 0; t < n_threads; ++t)
			threads[t] = std::thread(dotProductNative, std::ref(v0), std::ref(v1),
				t * n_per_thread, (t + 1) * n_per_thread, std::ref(sum));

		for (unsigned t = 0; t < n_threads; ++t)	// thread별로 join
			threads[t].join();

		const chrono::duration<double> dur = chrono::steady_clock::now() - sta;

		cout << dur.count() << endl;
		cout << sum << endl;
		cout << endl;
	}
}

 

std::inner_product에 비해 연산 처리 시간도 느릴 뿐만 아니라 race condition으로 인해 연산 결과까지 틀린 것을 확인할 수 있습니다.


2. Lockguard를 사용한 멀티 쓰레딩과 std::inner_product 비교

void dotProductLock(const vector<int>& v0, const vector<int>& v1, const unsigned i_start, const unsigned i_end, unsigned long long& sum)
{
	for (unsigned i = i_start; i < i_end; ++i)
	{
		std::scoped_lock lock(mtx); // c++17
		// Lock이 걸리면 다른 thread가 접근하지 못함
        	// 때문에 lock 구간은 최대한 효율적으로 '짧게' 설정하는 것이 좋음

		sum += v0[i] * v1[i];
	}
}
	cout << "Lock Guard" << endl;
	{
		const auto sta = chrono::steady_clock::now();

		unsigned long long sum = 0;

		vector<thread> threads;
		threads.resize(n_threads);	// thread 개수를 유동적으로 조절 가능

		const unsigned n_per_thread = n_data / n_threads; // 주어진 데이터를 thread에게 알맞게 분배
		for (unsigned t = 0; t < n_threads; ++t)
			threads[t] = std::thread(dotProductLock, std::ref(v0), std::ref(v1),
				t * n_per_thread, (t + 1) * n_per_thread, std::ref(sum));

		for (unsigned t = 0; t < n_threads; ++t)	// thread별로 join
			threads[t].join();

		const chrono::duration<double> dur = chrono::steady_clock::now() - sta;

		cout << dur.count() << endl;
		cout << sum << endl;
		cout << endl;
	}

Lockguard를 사용해서 연산의 결과는 올바른 값을 도출했지만, 연산에 걸리는 시간이 오래 걸리는 걸 볼 수 있습니다.

이는 scoped_lock이 너무 빈번하게 사용되었기 때문인데, 연산 속도에서 보자면 멀티 쓰레딩을 사용하지 않느니 못한 결과를 보여줍니다. 이를 통해 scoped_lock이 멀티 쓰레딩에서 자주 접근되는 변수 혹은 메모리에 사용되면 좋지 않다는 걸 알 수 있습니다.

사실 제 컴퓨터의 사양이 그리 좋지 않기 때문에 더욱 느리게 나온 것도 있습니다ㅠ


void dotProductAtomic(const vector<int>& v0, const vector<int>& v1, const unsigned i_start, const unsigned i_end, atomic<unsigned long long>& sum)
{
	for (unsigned i = i_start; i < i_end; ++i)
		sum += v0[i] * v1[i];
}

dotProductNative 함수의 매개변수 중 sum을 일반 자료형에서 atmoic<unsigned long long>으로만 변경했습니다.

	cout << "Atomic" << endl;
	{
		const auto sta = chrono::steady_clock::now();

		atomic<unsigned long long> sum = 0;

		vector<thread> threads;
		threads.resize(n_threads);	// thread 개수를 유동적으로 조절 가능

		const unsigned n_per_thread = n_data / n_threads; // 주어진 데이터를 thread에게 알맞게 분배
		for (unsigned t = 0; t < n_threads; ++t)
			threads[t] = std::thread(dotProductAtomic, std::ref(v0), std::ref(v1),
				t * n_per_thread, (t + 1) * n_per_thread, std::ref(sum));

		for (unsigned t = 0; t < n_threads; ++t)	// thread별로 join
			threads[t].join();

		const chrono::duration<double> dur = chrono::steady_clock::now() - sta;

		cout << dur.count() << endl;
		cout << sum << endl;
		cout << endl;
	}

main 함수의 연산 역시 sum을 일반 unsigned long long이 아닌 atomic<unsigned long long>으로 변경했습니다.

atomic 연산의 경우 일반 자료형, 가령 int나 double 같은 자료형의 연산보다 그 속도가 느립니다. 때문에 race condition 문제를 해결하여 연산의 결과는 올바르게 출력했지만 역시 연산 속도에 있어서는 멀티 쓰레딩의 이유를 찾기 힘들 만큼 좋지 않은 속도를 보여주고 있습니다. atomic 역시 멀티 쓰레딩에 사용할 때 자주 접근되는 변수나 메모리에 사용하면 오히려 그 연산 속도가 느려짐을 알 수 있습니다.


auto dotProductFuture(const vector<int>& v0, const vector<int>& v1, const unsigned i_start, const unsigned i_end)
{
	int sum = 0;
	for (unsigned i = i_start; i < i_end; ++i)
		sum += v0[i] * v1[i];

	return sum;
}

이번에는 future를 사용해보겠습니다. future는 리턴 값을 받지 못하는 thread 함수와 달리 리턴 값을 받을 수 있습니다.때문에 dotProductFuture 함수를 정의할 때 매개변수로 전역 변수 sum을 가져오지 않고, 대신 내부에서 로컬 함수로 sum을 가지고 함수 내에서 연산을 진행한 후 내적 연산이 완료된 sum을 리턴 값으로 반환합니다.

	cout << "Future" << endl;
	{
		const auto sta = chrono::steady_clock::now();

		unsigned long long sum = 0;

		vector<std::future<int>> futures;	// 벡터 부분의 합이 int로 들어오기 때문
		futures.resize(n_threads);	// thread 개수를 유동적으로 조절 가능

		const unsigned n_per_thread = n_data / n_threads; // 주어진 데이터를 thread에게 알맞게 분배
		for (unsigned t = 0; t < n_threads; ++t)
			futures[t] = std::async(dotProductFuture, std::ref(v0), std::ref(v1), t * n_per_thread, (t + 1) * n_per_thread);
		// dotProductFutre가 local sum을 가져와서 연산하기 때문에 전역변수 sum 필요 x

		for (unsigned t = 0; t < n_threads; ++t)
			sum += futures[t].get();
		// 전역변수 sum에 각 threads에서 연산을 끝낸 로컬변수 값을 더하기만 하면 됨.
		// 기존의 멀티쓰레딩 전략은 하나의 전여 변수 sum에 각각의 thread들이 달려들어서 처리했다면, future는 threads들이 각각의 로컬 변수에서 연산을 하고 이 값을 더하기만 함
		const chrono::duration<double> dur = chrono::steady_clock::now() - sta;

		cout << dur.count() << endl;
		cout << sum << endl;
		cout << endl;
	}

main 함수에서도 dotProductFuture가 자신이 가지고 있는 로컬 함수 sum에서 연산을 진행하기 때문에 전역 변수 sum을 받을 필요가 없습니다. 그리고 futures 벡터에는 async를 통해 dotProductFuture에서 리턴하는 sum의 값을 그대로 futures 벡터에 전달합니다.

쓰레드를 사용하지 않으니 for문으로 join을 설정할 필요도 없습니다. 대신 각 쓰레드들에서 자체적으로 연산을 마친 로컬 함수 sum의 값을 전역 함수 sum에 한 번에 더하는 연산만 for문으로 만들어주면 됩니다.

그리고 프로그램을 실행해보면 연산의 결과가 정확할뿐더러, 연산 시간 역시 std::inner_product에 크게 뒤지지 않는 결과를 확인할 수 있습니다.

'Information Technology > C++' 카테고리의 다른 글

[C++] std::forward  (0) 2020.01.04
[C++] 작업 기반 비동기 프로그래밍  (0) 2020.01.03
[C++] 레이스 컨디션  (0) 2020.01.02
[C++] 멀티 쓰레딩 기초  (0) 2020.01.01
[C++] C++17에서 여러 개의 리턴값 반환  (0) 2020.01.01