Contents
P2P 통신 방법안녕하세요 이번 글에서는 MPI programming 관련 글을 작성해볼 예정입니다. 앞으로 IPC 또는 모델 추론 최적화 분야와 관련해서 포스팅을 진행할 예정입니다.
MPI를 직접 실습하시고 싶으신 분들이 공부하는데 참고하면 좋을 것 같습니다~!
프로그램이 여러 개의 CPU 코어에서 돌아갈 수 있게 소스 코드를 작성하면 생산성을 높일 수 있는 것은 알고 있으실 겁니다.
필요한 연산들을 여러 코어를 사용해서 수행하기 때문에, 소요 시간이 줄어들 수 있는데, CPU에서 구동되는 병렬화 방법으로 MPI와 OpenMP가 존재한다고 합니다. 이번 시간에는 MPI에 대해 알아보도록 하겠습니다!
MPI 란
c언어와 c++ 언어 등에서 프로세스 간의 데이터 전달을 정의합니다. 즉, 병렬 프로그래밍을 가능하게 하는 인터페이스인 셈이죠.
MPI와 OpenMP와의 차이점은 무엇인가에 대해 찾아보니
→ 메모리를 공유하는 유무에 따라 나뉜다고 합니다.
OpenMP | MPI |
모든 프로세스 또는 스레드에 메모리에 할당된 변수들을 공유 | 소스코드에서 하나의 변수로 선언된 것일지라도 각각의 프로세스가 변수를 독립 적으로 갖고 있다. |
그렇다면 어떻게 사용할 수 있는지 설치를 먼저 진행하도록 하겠습니다.
설치 종류
해당 실습은 mac os에서 진행되었음을 밝힙니다.
- MPI program을 빌드하면 별도의 컴파일러가 필요합니다.
- c : mpicc
- c++ : mpicxx
- fortran : mpifort
위 언어에 따른 컴파일러를
구동 방식
- MPI 프로그램을 빌드를 진행하는데 저는 mpic++를 사용했습니다. (cpp 파일)
mpic++ -o [실행 파일 이름] [소스코드]
- 빌드 후 mpiexec나 mpirun로 실행합니다.
- 이때 -n 라는 옵션을 통해서 프로세스 개수를 지정해줄 수 있습니다.
mpirun -n [프로세스 개수][실행파일 이름][명령 인자들]
초기화 및 process rank
- MPI programming에서는 MPI_Init과 MPI_Finalize 라는 함수가 초기화와 종료를 담당합니다.
#include <iostream> #include<string.h> // MPI 헤더 파일 #include<mpi.h> int n_size_; // 프로세스의 총 갯수 int n_rank_; // 각 프로세스에 부여된 랭크 int main(int argc, char *argv[]) { // MPI 초기화 MPI_Init(&argc, &argv); // 프로세스 총 갯수 및 각 프로세스의 랭크 MPI_Comm_size(MPI_COMM_WORLD, &n_size_); MPI_Comm_rank(MPI_COMM_WORLD, &n_rank_); if (n_rank_ == 0) { // 랭크가 0 인 프로세스 std::cout << "We have " << n_size_ << " processess."<< std::endl; } // 모든 프로세스가 여기에 도달할 때 까지 대기 MPI_Barrier(MPI_COMM_WORLD); // Hello World! std::cout << " [Processor "<< n_rank_ << "}. Hello world!"<< std::endl; // MPI 종료 MPI_Finalize(); return 0; }
이제 코드 설명을 간단히 진행해보겠습니다.
- MPI_Init 함수
- 명령행 인자들이 들어오면 포인터를 인자로 넘겨줍니다.
- 프로세스의 rank는 프로세스에게 부여되는 고유 번호라고 생각하면 됩니다.
- 0에서 시작해서 프로세스 총 개수 -1 까지입니다.
- n_rank_는 총 rank의 개수
- n_size_는 모든 프로세스가 동일하게 갖는 값
- 위 2개의 변수 모두 프로세스의 고유 번호 및 개수와 관련된 값입니다.
- MPI 함수와 같이 위에 값들을 선언해 준다.
- MPI_COMM_WORLD는 MPI에서 커뮤니케이터의 역할을 수행하는 한 종류의 커뮤니케이터
- communicator란 서로 데이터를 주고 받는 프로세스들의 집합체
- 즉, 모든 프로세스들은 MPI_COMM_WORLD에 소속되어 데이터를 주고받습니다.
MPI_Comm_size(MPI_COMM_WORLD, &n_size_); MPI_Comm_rank(MPI_COMM_WORLD, &n_rank_);
- 총 process 개수 출력
- 프로세스의 총 개수를 출력하고 싶으면 rank가 0인 경우에만 출력할 수 있도록 구현했습니다.
- 한 번만 알면 되기 때문입니다.
- MPI barrier를 통해 얻을 수 있는 효과
- 총 프로세스의 개수를 무조건 먼저 출력하고 난 후, 고유 프로세스들마다 print를 하고 싶으면 MPI_barrier를 사용합니다.
- 이를 통해 MPI_Barrier 구문에 모든 프로세스가 도착하기 전까지 다들 대기합니다.
- MPI_Finalize
- MPI 종료의 역할을 담당합니다.
P2P 통신 방법
프로세스 간의 데이터 통신
- Send 와 Recv는 MPI의 기본 개념입니다.
- MPI의 모든 단일 기능은 전송과 호출을 통해 구현할 수 있다고 하네요!
- 다음과 같은 순서로 실행됩니다.
- 프로세스 A는 프로세스 B에 메세지를 보내야한다고 결정되었습니다.
- 프로세스 A는 필요한 모든 데이터를 buffer에 압축합니다.
- 여기서 데이터 압축 전에 쓰이는 buffer를 envelopes 이라고 부릅니다.
- 전송 전 단일 메세지를 데이터가 buffer에 압축한 뒤 communication device(네트워크)는 메세지를 적절한 위치로 라우팅하는 역할을 합니다.
이제 MPI에서 쓰이는 send와 recv에 대해 알아보겠습니다.
- MPI_send, MPI_Recv의 함수 인자 부분을 살펴보겠습니다.
MPI_Send( void* data, int count, MPI_Datatype datatype, int destination, int tag, MPI_Comm communicator) MPI_Recv( void* data, int count, MPI_Datatype datatype, int source, int tag, MPI_Comm communicator, MPI_Status* status)
- 여기에 제공된 기본적인 data type 이외에 custom data type을 정의해서 통신에 사용할 수 있습니다. 이 실습은 추후에 따로 포스팅 해보겠습니다.
MPI send, recv program 예시
예시 코드를 통해 send와 recv 코드를 살펴보겠습니다.
int world_rank; MPI_Comm_rank(MPI_COMM_WORLD, &world_rank); int world_size; MPI_Comm_size(MPI_COMM_WORLD, &world_size); int number; if (world_rank == 0) { number = -1; MPI_Send(&number, 1, MPI_INT, 1, 0, MPI_COMM_WORLD); } else if (world_rank == 1) { MPI_Recv(&number, 1, MPI_INT, 0, 0, MPI_COMM_WORLD, MPI_STATUS_IGNORE); printf("Process 1 received number %d from process 0\n", number); }
- MPI_Comm_rank, MPI_COMM_Size는 process rank와 world_size 를 결정하는데 쓰입니다.
- 우선 world_rank가 0이라면 전송할 number를 -1로 정의하고 1번 process로 보냅니다.
- 이때 number의 주소값을 MPI_INT로 명시해서 보냅니다.
- 그리고 world rank 1이라면 0번째로부터 오는 MPI_INT 타입의 데이터를 가져옵니다.
ping pong program 예시
아주 간단한 프로세스 2개의 통신 예시를 살펴보겠습니다.
int ping_pong_count = 0; int partner_rank = (world_rank + 1) % 2; while (ping_pong_count < PING_PONG_LIMIT) { if (world_rank == ping_pong_count % 2) { // Increment the ping pong count before you send it ping_pong_count++; MPI_Send(&ping_pong_count, 1, MPI_INT, partner_rank, 0, MPI_COMM_WORLD); printf("%d sent and incremented ping_pong_count " "%d to %d\n", world_rank, ping_pong_count, partner_rank); } else { MPI_Recv(&ping_pong_count, 1, MPI_INT, partner_rank, 0, MPI_COMM_WORLD, MPI_STATUS_IGNORE); printf("%d received ping_pong_count %d from %d\n", world_rank, ping_pong_count, partner_rank); } }
이 예제는 2개의 프로세스가 통신을 중지하기 전까지 계속 메세지를 반송하는 것을 볼 수 있습니다.
- ping_pong_count가 0에서 시작해서 PING_PONG_LIMIT 전까지 실행하는 것을 볼 수 있습니다.
- 전송을 맡은 if문에서는 ping_pong_count 변수를 전송하는 것을 볼 수 있습니다.
- 반대로 전송을 받은 else 에서는 rank와 count 그리고 전송한 rank를 출력합니다.
Ring program
아래 예시는 2개보다 많은 프로세스가 MPI_Send와 MPI_Recv를 활용하는 방법을 알아보겠습니다
int token; if (world_rank != 0) { MPI_Recv(&token, 1, MPI_INT, world_rank - 1, 0, MPI_COMM_WORLD, MPI_STATUS_IGNORE); printf("Process %d received token %d from process %d\n", world_rank, token, world_rank - 1); } else { // Set the token's value if you are process 0 token = -1; } MPI_Send(&token, 1, MPI_INT, (world_rank + 1) % world_size, 0, MPI_COMM_WORLD); // Now process 0 can receive from the last process. if (world_rank == 0) { MPI_Recv(&token, 1, MPI_INT, world_size - 1, 0, MPI_COMM_WORLD, MPI_STATUS_IGNORE); printf("Process %d received token %d from process %d\n", world_rank, token, world_size - 1); }
- rank가 0이 아니라면 world rank가 0인 프로세스로부터 token이라는 INT 값을 읽어서 출력합니다.
- world rank가 0이라면 token에 -1을 넣습니다.
- 중간 줄에 있는 MPI_Send에서 저희는 +1로 된 프로세스에 값을 send하는 것을 볼 수 있습니다.
- 프로그램은 프로세스 0이 마지막 프로세스(n-1번째)로부터 값을 시도하기 전에 첫 번째 전송이 완료되었는지 확인합니다.
MPI broadcast & Collective communication
broadcast 전에 동기화
collective communication에서 가장 중요하게 체크할 것 중 하나는 synchronization입니다. 그리고 MPI는 특정 함수가 이를 맡고 있어요.
MPI_Barrier(MPI_Comm communicator)
- 해당 함수는 모든 프로세스가 이 함수를 호출하기 전까지 그 다음으로 넘어가지 못하게 합니다.
시간 순으로 보는 동기화 과정
- 해당 사진 자료는 MPI_Barrier의 역할을 볼 수 있는 자료입니다.
- T1 에서 Process 0는 먼저 MPI_Barrier를 만나지만, 아직 다른 프로세스들이 이 함수를 호출하지 않아서 기다립니다.
- T2 에는 1,3 프로세스도 0번과 동일한 상태가 됩니다.
- T3 에서 2번도 함수를 호출하여 이제 4개의 프로세스가 모두 호출한 상태입니다.
- T4 에서 모든 프로세스가 같이 다음 코드를 실행하는 것을 볼 수 있습니다.
MPI_Broadcast로 broadcast
→ Broadcast는 집합통신의 기준 중 하나의 기본적인 방법이다.
하나의 프로세스가 다른 모든 프로세스들에게 같은 data를 넘겨주는 것이 특징입니다.
사용 예시로는 2가지를 떠올릴 수 있습니다.
- 병렬 프로그램에서 입력을 넣어줄 때
- 모든 프로세스에게 config 인자들을 넘겨줄 때
이때 MPI에서는 MPI_Bcast 함수를 사용합니다.
MPI_Bcast으로 저희는 2가지의 기능을 사용할 수 있습니다.
- root process의 경우 다른 process 들에게 데이터를 전달해 줍니다.
- receive 역할의 프로세스로서 root process로부터 데이터를 받을 수 있습니다.
MPI_Send, MPI_Recv를 활용한 Broadcasting
저희가 이전까지 알아본 바로는 MPI_Send, MPI_Recv가 있는데 이를 활용해서라도 broadcast를 만들 수 있지 않을까? 라는 생각이 들거라고 생각합니다.
그래서 실제로 이 두 개의 함수를 활용해서 custom_bcast 함수를 만들었습니다.
void custom_bcast(void* data, int count, MPI_Datatype datatype, int root, MPI_Comm communicator) { int world_rank, world_size; MPI_Comm_rank(communicator, &world_rank); MPI_Comm_size(communicator, &world_size); if (world_rank == root) { // if root for (int i = 0; i < world_size; i++) { if (i != world_rank) { MPI_Send(data, count, datatype, i, 0, communicator); } } } else { // not root MPI_Recv(data, count, datatype, root, 0, communicator, MPI_STATUS_IGNORE); } } // Use Below commands // mpic++ -o test2 broadcast.cpp // mpirun -n 4 ./test2
실제로 위 코드를 생각해보면 for문으로 MPI_Send를 보내는 root 프로세스일 때의 방식을 볼 수 있습니다. 이와 관련된 이야기는 아래에서 더 다루겠습니다.
코드 설명을 잠깐 진행해보면
- size와 rank를 초기화해주고
- 조건문을 통해서 process rank에 따른 root 프로세스와 root 외 프로세스를 구별해서 값을 보내는 경우와 받는 경우로 나뉩니다.
그런데 이 코드 구현 방식보다 더 효율적이 방식이 있다고 하는데 바로 tree 방식이라고 합니다.
더 효율적인 broadcast 방식
- custom_broadcast의 경우
- 이 방법의 경우 선형적인 시간복잡도를 갖게 됩니다.
- tree 기반 broadcast인 경우
- 해당 방법의 시간 복잡도는 tree 기반이게 아래와 같습니다.
- 간단한 설명
- step 1번 때, Pid 0에서 1 프로세스에게 값을 전달합니다.
- step 2번 때, Pid 0은 2, pid 1은 3에게 값을 전달합니다.
- 모든 프로세스는 값을 다 받을 때까지 step i번 때마다 전달합니다.
그래서 얼마나 더 효율적?
코드의 결과를 아래 MPI tutorial 코드에서 볼 수 있듯이 아래와 같습니다.
process들의 개수가 많아질 수록 MPI_Bcast인 tree 방식이 더 빠른 것을 알 수 있습니다.
다음 글 포스팅으로는 MPI_scatter와 Gather, Allgather, Parallel rank를 활용한 MPI 그리고 Allreduce에 대해 알아보겠습니다.
참고자료
Share article