• 카테고리

    질문 & 답변
  • 세부 분야

    프로그래밍 언어

  • 해결 여부

    해결됨

call by reference 시 퍼포먼스에 관한 질문입니다.

24.02.28 17:37 작성 24.02.28 17:45 수정 조회수 89

1

과제로 내주신 것 중 std::threadstd::promise를 사용하는 것을 먼저 해보고 divide and conquerstd::thread를 이용해서 구현하는 것을 해봤는데, 궁금증이 생겨서 질문드렸습니다.

강의 영상 막바지에 보여주셨던 std::asyncstd::future를 사용했던 예제를 참고해서, 하나의 변수에 여러 스레드가 값을 누적시키는 것이 아닌 각자의 local sum에 값을 누적시킨 후 마지막에 모두 더하는 방식으로 구현해봤습니다.

	// TODO #1 : use divide and conquer strategy for std::thread
	cout << "thread" << endl;
	{
		const auto sta = chrono::steady_clock::now();

		unsigned long long sum = 0;

		vector<std::thread> threads;
		vector<unsigned> sums;
		threads.resize(n_threads);
		sums.resize(n_threads);

		const unsigned n_per_thread = n_data / n_threads;
		for (unsigned t = 0; t < n_threads; t++) {
			threads[t] = std::thread(dotProductThread, std::ref(v0), std::ref(v1), t * n_per_thread, (t + 1) * n_per_thread, std::ref(sums[t]));
		}

		for (unsigned t = 0; t < n_threads; t++) {
			threads[t].join();
			sum += sums[t];
		}

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

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

그리고 처음에는 std::thread가 사용할 함수인 dotProductThread의 구현을 call by reference를 반환값처럼 사용하도록 아래와 같이 구현했습니다.

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

그런데 실행해봤더니 정답은 제대로 나오지만 속도가 제가 구현한 std::threadstd::promise를 사용한 예제보다 훨씬 느렸고, 심지어 std::innerproduct보다도 느렸습니다.

뭐가 문제일까 싶어 여러가지를 바꿔보다가 아래와 같이 dotProductThread함수에서 매번 레퍼런스에 값을 더하지 않고 변수를 하나 선언해 누적하다가 마지막에만 넘겨주도록 하였습니다.

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

실행했더니 속도가 std::threadstd::promise를 사용한 예제와 거의 비슷하게 나와주었습니다.


  1. call by reference로 전달된 참조에 너무 빈번하게 접근해도 퍼포먼스 저하가 일어난다고 봐도 될까요?

  2. 그리고 시험삼아 출력문을 dotProductThread 내에 작성해봤더니 race condition은 일어나지 않는 것 같았습니다. 이런 경우 굳이 std::atomic이나 뮤텍스를 사용할 필요는 없나요? 또는 작업이 더욱 복잡해진다면 안정성을 위해 사용해줘야 하는 걸까요?(프로그래머가 미처 고려하지 못한 상황이라거나)

답변 1

답변을 작성해보세요.

2

Soobak님의 프로필

Soobak

2024.02.29

안녕하세요, 질문&답변 도우미 Soobak입니다.

 

질문1)

: 네, 말씀하신 내용이 맞습니다.

저도 질문자님의 글을 읽고 궁금증이 들어 보다 자세하고 정확한 내용은 전달 드리고자 추가 자료들을 찾아본 후 답변드립니다.

call by reference로 전달된 참조에 대한 빈번한 접근은 퍼포먼스 저하를 일으킨다고 하며, 이는 캐시 일관성(cache coherence)을 유지하기 위해 발생하는 오버헤드 때문이라고 합니다.

참조를 통해 값을 갱신할 때마다, 해당 메모리 위치에 대한 캐시 일관성을 유지하기 위해 오버헤드가 발생하게 되는데요,

여러 스레드가 동일한 메모리 위치(질문자님의 경우 local_sum ) 에 접근하려 할 때, 이러한 오버헤드가 메모리 버스 트래픽을 증가시키고, 결과적으로 퍼포먼스를 저하시키게 됩니다.

따라서, 각 스레드에서 지역 변수를 사용하여 중간 결과를 누적한 후, 이를 최종적으로 한 번만 참조에 반영하는 식으로 구현하신 것이 퍼포먼스 측면에서 유리할 것 같습니다.

참고 자료들 중 가장 명료했던 글의 링크를 첨부드립니다.

https://stackoverflow.com/questions/71560000/is-there-an-issue-with-cache-coherence-on-c-multi-threading-on-a-single-cpu

 

추가적으로, call by reference cache coherence in c++ 등으로 검색해보시면 재밌는 자료들이 많아 도움이 되실 것 같습니다.

좋은 궁금증과 해결 방안까지 알아내시고, 구현까지 하시는 모습이 정말 인상깊네요.

저도 덕분에 좋은 지식도 얻고 마음가짐을 되새기게 되었습니다. 감사합니다.

 

질문 2)

: race condition이 관찰되지 않았다는 것이, race condition이 발생하지 않는다는 것을 보장하지는 않습니다. (작성하신 코드 중, cout과 같은 스트림 출력, 그리고 특히 endl 의 사용은 버퍼를 조작하는 과정이 포함되어 있어 생각보다 훨씬 더 많은 시간이 소요됩니다. 공부하시다가 심심하실 때, endl 사용 경우와 '\n' 의 사용 경우의 실행시간을 비교, 그리고 ios_base::sync_with_stdio(false); cin.tie(nullptr); cout.tie(nullptr); 의 내용을 검색해보시는 것도 재미있으실 것 같습니다)

공유 자원에 대한 접근이 있을 경우, 특히 다른 스레드에서 동시에 읽기 또는 쓰기 작업이 이루어질 가능성이 있다면, std::atomic이나 뮤텍스(mutex)와 같은 동기화 메커니즘을 사용하는 것이 안전합니다.

질문자님께서 마지막에 말씀해주신 것 처럼, 프로그램의 복잡성이 증가하면 예상치 못한 상황이 발생할 수 있으며, 이러한 상황에서 프로그램의 안정성을 보장하기 위해 동기화 메커니즘을 사용하는 것이 좋습니다.

작업이 단순하고 공유 자원에 대한 접근이 없을 경우에는 굳이 사용하지 않아도 될 수 있지만, 데이터의 일관성과 안정성을 위해서는 필요에 따라 적절히 사용하는 것이 중요하다고 생각합니다.

 

항상 좋은 질문을 해주시고 학습에 임하시는 태도 등으로 좋은 영향을 주셔서 감사합니다. 😁

durams님의 프로필

durams

질문자

2024.02.29

상세한 답변 감사합니다.

  1. 각 스레드가 영향을 받지 않게 따로 독립적으로 사용하려고 만든 변수인 local_sum이 내부적으로는 cache cohenrence를 유지하기 위한 오버헤드를 유발시켜서 퍼포먼스가 저하된게 아이러니하네요. 그래서 각 스레드에 종속적인 변수가 없을까해서 검색을 하다가 재미있는게 있어서 사용해 보았습니다(혹시 관심 있는 분들이 계시지 않을까요)

     

     

    thread_local이란 기능이 C++11부터 정식으로 도입되었다고 하네요.

thread_local unsigned local_sum = 0;

auto dotProductThreadLocal(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++) {
		local_sum += v0[i] * v1[i];
	}
	sum += local_sum;
}

thread_local 변수는 각 스레드에 별개의 복사본으로 존재하게 된다고 합니다.

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

	unsigned long long sum = 0;

	vector<std::thread> threads;
	threads.resize(n_threads);

	const unsigned n_per_thread = n_data / n_threads;
	for (unsigned t = 0; t < n_threads; t++) {
		threads[t] = std::thread(dotProductThreadLocal, 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++) {
		threads[t].join();
	}

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

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

sumvector를 선언하지 않아도 되는 등 장점이 있는 것 같더라구요.

어떻게 어떻게 분할정복식으로 짰더니 정답도 나오고 속도도 기존 방법들과 유사하게 나오는 것 같았습니다. 대신 thread_local 변수의 선언 위치나 매개변수로 이용했을 때의 동작은 아직 이해가 어렵더라구요. 그리고 thread_local을 사용할 때도 구현에 따라 속도가 현저하게 느려지는 걸 봐서 cache coherence는 어쩔 수 없이 신경을 써야하더라구요. 생각할 만한 토픽인 것 같습니다.


  1. 생각해보니 강의 영상에도 문제가 발생할 여지가 있었음에도 연산이 간단해서 race condition이 발생하지 않은 것처럼 보이는 상태가 언급이 있었네요. 제가 깜빡했나 봅니다 ㅠㅠ


언제나 정성스러운 답변 감사합니다!