본문 바로가기
C++

[CppCon] 분산 데이터 구조와 Parallel Programming

by 매운돌 2023. 12. 24.

며칠 전에 병렬 프로그래밍에 대한 CppCon 주제가 나와서 시청하게 되었습니다. 인텔에서 연구원으로 근무하시는 분이 연사로 나와, STL의 ranges와 같은 구조로 분산 및 분할된 메모리를 쉽게 다룰 수 있는 라이브러리(Distributed Ranges)를 소개하는 영상입니다.

 

이 글은 이러한 라이브러리가 왜 필요하게 되었는지 그리고 이러한 문제를 해결하기 위해서 왜 ranges를 사용하게 되었는지 공유하면 좋을거 같아서 정리하게 되었습니다. 이 영상에서는 이 라이브러리의 구현 방식도 소개하고 있는데 그걸 소개하기에는 너무 양도 방대하고 제가 이해하지 못한 부분도 많이 있어서 정리하기 못했습니다. 관심이 있으신 분들은 이 링크에서 영상을 보실 수 있습니다.

 

최근에 방대한 데이터를 parallel하게 처리하게 되면서 위의 이미지에서 확인할 수 있듯이 다양한 디바이스(CPU, GPU, NIC, FPGA, ...) 들을 높은 대역폭으로 연결해서 하나로 사용하는 계층구조가 많이 나타나고 있습니다. 이럴 경우 각각의 디바이스가 자신들의 Local 메모리를 가지고 있기 때문에, 최고의 성능을 내기 위해서는 메모리를 분할하고 내 데이터가 어디에 있는 고려하면서 개발해야 합니다.

 

그 전에 C++ ranges와 views를 를 활용하여 내적을 구현한 예제를 통해, 위와 같은 환경에서 일반적인 코드가 동작하게 되면 어떠한 문제가 발생할 수 있는지 알아봅시다.

(참고로 이 예제는 views::zip을 사용했기 때문에 C++ 23 이상에서 동작합니다.)

#include <iostream>
#include <ranges>
#include <execution>

using namespace std;
using namespace std::ranges;
using namespace std::execution;

template <range R>
auto dot_product(R&& x, R&& y) {
	using T = range_value_t<R>;

	auto z = views::zip(x, y) | views::transform([](auto element) {
		auto [a, b] = element;
		return a * b;
	});

	return reduce(par_unseq, z.begin(), z.end(), T(0), std::plus());
}

위 예제는 두 개의 ranges를 받아서 zip으로 하나라 묶고 그 이후에 두 값을 곱합니다. 그리고 excution::reduce를 사용해서 결과를 도출하게 되는데 이때 policy로 par_unseq로 설정하여 순서 상관없이 병렬 동작을 수행하고 있습니다. 굉장히 잘 동작하고 문제가 없어보입니다.

하지만 이 코드에는 한계가 있는데, 오직 하나의 디바이스 내에서 수행될 수 있고 현재 파라미터로 들어오는 x와 y는 어디에 저장된 데이터인지  코드상에서 알 수 없습니다. 즉 내가 실행하고 싶은 디바이스의 메모리에 있는 데이터인지 확인할 수 있는 방법이 없습니다. 따라서 다양한 디바이스들이 연결된 환경에서는 적합하지 않습니다. 

 

그렇다면 다른 프로세서에서 사용하고 있는 데이터는 어떻게 접근할 수 있을지 알아보겠습니다.

Remote Pointer

 

 

이 영상에서는 Remote Pointer라는 개념을 사용하고 있습니다. 관련하여 자세히 알고싶으면 이 링크에서 확인할 수 있습니다.

간단히 요약하면 Remote Pointer는 RDMA방식으로 다른 디바이스의 메모리에 접근하여 데이터를 Read나 Write를 수행합니다. 하지만 그럴 경우 raw language reference의 형태로 역참조 할 수 없습니다. 왜냐하면 다른 디바이스에 존재하는 값이기 때문에 단순히 포인터로 접근하면 안되고, remote_ref의 operator T()를 보시면 memcpy를 이용해서 값을 가져오는 것을 확인할 수 있습니다.  그러면 Remote Pointer의 동작에 대해서 아래와 같이 정리할 수 있을거 같습니다.

  • 다른 프로세스에 있는 메모리에 참조
  • memcpy, copy, atomics, 등등 동작
  • proxy reference를 이용해 역참조 지원 (단, T& 형태는 아님)
  • 단순히 복사할 수 있는 타입에 대해서만 지원
    (e.g std::string같이 동적으로 동작이 이루어지는 타입은 지원할 수 없습니다.)

하지만 이럴 경우 Remote Pointer는 forward_iterator를 지원하지 못합니다.

위의 이미지의 아래 문구를 보면 forward_iterator에서 mutale iterator일 경우 해당 타입의 참조 값(raw language reference)을 리턴해야 하는데, 위에서 설명했듯이 Remote Pointer는 단순히 proxy reference를 이용해 역참조만 지원합니다. 따라서 LagacyRandomAccessIterator를 충족하지 못하고, 이럴 경우 거대한 데이터를 각 디바이스에 보내기 위해 데이터를 구분하거나 쪼개는(split) 동작이 어려워 집니다.

 

하지만 ranges library는 forward iterator와 다르게 디자인되어 있어서, 이를 활용하면 Random Access하게 데이터를 구성할 수 있습니다.

ranges는 실제 가지고 있지 않은 데이터를 다루게 설계되어 있습니다. 더 자세히 설명 드리면, 위에 두 개의 벡터를 zip해서 하나의 view로 만드는 코드가 있습니다. 이때 이 view의 값은 std::pair<int&, int&>가 아니라 std::pair<int, int>& 가 됩니다. 이는 사용하는 입장에서는 둘 다 original의 값들을 수정할 수 있다는 점에서 차이가 없습니다. 하지만 Remote Pointer는 원천 데이터에 대한 참조를 가질 수 없기 때문에 std::pair<int&, int&>의 형태일 경우 사용될 수 없지만 std::pair<int, int>&의 경우 위에 말한 proxy reference를 통해 가능합니다. 

위의 ranges 기반의 forawrd iterator의 concepts를 보면 기준을 충분히 충족하게 됩니다. 따라서 Remote Pointer에 대해서 정리하면 아래와 같습니다.

Remote pointer를 이용해 다른 디바이스에 있는 메모리를 참조할 수 있고, ranges library를 통해 random access iterator 개념도 충족합니다. 참고로 위 예에서는 굉장히 단순한 형태로 사용하고 있는데 이는 성능적으로 좋지 못합니다. 왜냐하면 예를 들어 GPU의 메모리에 있는 데이터들을 역참조 하게되면 CPU로 메모리 복사가 일어나게 되기 때문에 성능상 좋지 못합니다.

 

마지막으로 분산 데이터 구조에 대해서 알아볼 필요가 있습니다.

위의 그림처럼 일반적으로 분산 데이터 구조는 여러 segments로 데이터를 쪼개고, 다양한 디바이스의 메모리에 저장되어 집니다. 하지만 이렇게 되면 각각의 디바이스에 데이터를 접근 하는 방식이 다르기 때문에, 이를 통합된 개념(우리가 하나의 디바이스에서 사용했던 방법)으로 접근하긴는 사실상 어렵습니다. 이를 해결하기 위한 알고리즘이나 구현법은 20분 25초 부터 이어지는데 내용에서 이야기 하고 있습니다. 그런데 제가 잘 이해하지 못했고 여기서 글로 정리하기에는 내용이 너무 많아 스킵하게 되었습니다. 관심있으신 분은 위의 링크에서 확인해 보시기 바랍니다.