[Multi-Core & GPU Programming] HW1 - Parallel Softmax
"Parallel Softmax"
[Objective]
Softmax 함수 Logic을 이해한 후, Softmax 함수를 병렬로 실행하여 완료 시간을 단축하는 과정을 통해 Parallel Logic 이해하기
[Theory]
→ 입력값(a)이 큰 경우, Overflow 발생
▣ my_new_softmax
· 입력값의 최대값을 모든 요소에 빼면 모든 입력값이 0 이하로 변경
→ e0 = 1이고, 나머지 값들은 0과 1 사이의 작은 실수 = Overflow 발생 X
[Implementation]
▣ Makefile
1 2 3 4 5 6 7 8 9 10 11 12 | build: mkdir -p build g++ -std=c++20 main.cpp -o build/main run: ./build/main clean: rm -rf build format: clang-format -i main.cpp softmax_parallel.h | cs |
· make build : "main.cpp" 파일을 Compile해서 실행 파일을 build/main으로 생성
· make run : 만들어진 실행 파일 실행 (make build 후 make run 실행)
· make clean : 생성된 "build" Directory 삭제
· make format : Format 정리
▣ softmax_parallel.h
> Logic Flow
① 입력 값의 최댓값 찾기
② 찾은 최댓값을 사용하여 my_new_softmax 함수 방법으로 exp 계산
③ exp 계산 결과의 전체 합 구하기
④ exp 계산 결과의 전체 합을 나눠 softmax 계산
> Not Use Local Max & Not Use (long long)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 | #include <algorithm> #include <cmath> #include <thread> using namespace std; inline void softmax_parallel(float *in, float *out, int elems) { const int NTHREADS = 16; std::thread threads[NTHREADS]; float max = in[0]; float local_sum[NTHREADS]; for (int i = 1; i < elems; i++) { if (in[i] > max) { max = in[i]; } } for (int t = 0; t < NTHREADS; t++) { threads[t] = std::thread([&, t]() { int start = elems * t / NTHREADS; int end = elems * (t + 1) / NTHREADS; float sum = 0.0f; for (int i = start; i < end; i++) { out[i] = std::exp(in[i] - max); sum += out[i]; } local_sum[t] = sum; }); } for (int t = 0; t < NTHREADS; t++) { threads[t].join(); } float global_sum = 0.0f; for (int t = 0; t < NTHREADS; t++) { global_sum += local_sum[t]; } for (int t = 0; t < NTHREADS; t++) { threads[t] = std::thread([&, t]() { int start = elems * t / NTHREADS; int end = elems * (t + 1) / NTHREADS; for (int i = start; i < end; i++) { out[i] /= global_sum; } }); } for (int t = 0; t < NTHREADS; t++) { threads[t].join(); } } | cs |
· 입력 값 전체에서 최댓값을 찾아서 exp 계산에 사용하는 방식
<왼쪽 : NHTREADS = 16 / 오른쪽 : NTHREADS = 8>
· NTHREADS = 8인 경우 1.66753sec가 소요되는데 NTHREADS = 16인 경우 Segmentation Fault 발생∵ 16, 17, 36, 37 : "elems"와 "t"는 모두 32-bit int 변수
→ "elems * t" 계산 시 두 수의 곱이 int의 최댓값을 초과 = Overflow가 발생하여 "elems * t" 연산 결과가 음수가 됨
→ out[i]의 i가 음수 Index가 되어 할당 받지 않은 메모리 영역을 참조
> Not Use Local Max & Use (long long)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 | #include <algorithm> #include <cmath> #include <thread> using namespace std; inline void softmax_parallel(float *in, float *out, int elems) { const int NTHREADS = 64; std::thread threads[NTHREADS]; float max = in[0]; float local_sum[NTHREADS]; for (int i = 1; i < elems; i++) { if (in[i] > max) { max = in[i]; } } for (int t = 0; t < NTHREADS; t++) { threads[t] = std::thread([&, t]() { int start = (long long) elems * t / NTHREADS; int end = (long long) elems * (t + 1) / NTHREADS; float sum = 0.0f; for (int i = start; i < end; i++) { out[i] = std::exp(in[i] - max); sum += out[i]; } local_sum[t] = sum; }); } for (int t = 0; t < NTHREADS; t++) { threads[t].join(); } float global_sum = 0.0f; for (int t = 0; t < NTHREADS; t++) { global_sum += local_sum[t]; } for (int t = 0; t < NTHREADS; t++) { threads[t] = std::thread([&, t]() { int start = (long long) elems * t / NTHREADS; int end = (long long) elems * (t + 1) / NTHREADS; for (int i = start; i < end; i++) { out[i] /= global_sum; } }); } for (int t = 0; t < NTHREADS; t++) { threads[t].join(); } } | cs |
· 16, 17, 36, 37 : (long long) 추가
→ "elems * t" 연산 시 32-bit int 공간이 아닌 64-bit 공간에서 수행되어 Overflow 발생하지 않고 정확한 큰 수를 계산 가능
→ out[i]의 Index로 사용 가능
· Thread 수가 증가할수록 연산 완료 시간 감소
> Use Local Max & Use (long long)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 | #include <algorithm> #include <cmath> #include <thread> using namespace std; inline void softmax_parallel(float *in, float *out, int elems) { const int NTHREADS = 32; std::thread threads[NTHREADS]; float local_sum[NTHREADS]; float local_max[NTHREADS]; for (int t = 0; t < NTHREADS; t++) { threads[t] = std::thread([&, t]() { int start = (long long)elems * t / NTHREADS; int end = (long long)elems * (t + 1) / NTHREADS; float max = in[start]; for (int i = start + 1; i < end; i++) { if (in[i] > max) { max = in[i]; } } local_max[t] = max; }); } for (int t = 0; t < NTHREADS; t++) { threads[t].join(); } float global_max = local_max[0]; for (int t = 1; t < NTHREADS; t++) { if (local_max[t] > global_max) { global_max = local_max[t]; } } for (int t = 0; t < NTHREADS; t++) { threads[t] = std::thread([&, t]() { int start = (long long) elems * t / NTHREADS; int end = (long long) elems * (t + 1) / NTHREADS; float sum = 0.0f; for (int i = start; i < end; i++) { out[i] = std::exp(in[i] - global_max); sum += out[i]; } local_sum[t] = sum; }); } for (int t = 0; t < NTHREADS; t++) { threads[t].join(); } float global_sum = 0.0f; for (int t = 0; t < NTHREADS; t++) { global_sum += local_sum[t]; } for (int t = 0; t < NTHREADS; t++) { threads[t] = std::thread([&, t]() { int start = (long long)elems * t / NTHREADS; int end = (long long)elems * (t + 1) / NTHREADS; for (int i = start; i < end; i++) { out[i] /= global_sum; } }); } for (int t = 0; t < NTHREADS; t++) { threads[t].join(); } } | cs |
· 0.8sec 내로 완료되지 않아 입력 값 전체에서 최댓값 찾기 X
→ 다른 계산과 마찬가지로 구간마다 최댓값을 구한 후, 구간 최댓값 중 최댓값을 찾아 전체 최댓값을 구하는 방식
<왼쪽 : NTHREADS = 16 / 오른쪽 : NTHREADS = 32>
· Thread 마다 각자의 구간에서 최댓값을 구한 후, 전체 최댓값을 구하는 방식의 연산 완료 시간이 감소한 것을 확인 가능[Discussion]
· Thread를 생성하고 회수하는 System Call과 Context Switching은 CPU 입장에서 매우 무겁고 느린 작업
→ 구현한 코드에는 3번의 Thread 생성과 회수가 존재
** Barrier 사용
① Thread 생성
② 모든 Thread가 각 구간의 최댓값을 찾을 때까지 대기하는 Barrier
③ 전체 최댓값을 찾을 때까지 대기하는 Barrier
④ exp 연산과 구간 sum을 계산할 때까지 대기하는 Barrier
⑤ 전체 합을 계산할 때까지 대기하는 Barrier
⑥ Thread 회수
→ 4개의 Barrier를 사용하여 Phase 할당하면 Thread 생성과 회수를 한번만 실행 가능 = 연산 완료 시간 감소
· Thread 수를 무한정 늘린다고 해서 연산 시간이 0초에 수렴 X
① Thread를 처음 생성하고 회수하는 작업 등은 병렬화 불가 = 무조건 직렬로 수행
→ 이 직렬 구간이 차지하는 시간을 일정 시간 이하로 줄이는 것이 불가능
② 여러 Thread가 동시에 실해오디는 것처럼 보이게 하기 위해 아주 짧은 시간 단위로 Thread를 번갈아 CPU에 할당
→ Thread를 교체할 때마다 기존 상태를 메모리에 저장하고 새 상태를 불러오는 Context Switching 비용 발생
→ Thread가 너무 많아지면 Thread를 교체하는 데 쓰는 시간이 더 길어짐
∴ Thread 수가 특정 임계점을 넘어가면 성능이 기하급수적으로 감소
[Reference]
· HW1 - Parallel Softmax (MGP) - Yongjun Park
· 2_thread_mgp_2026 (MGP) - Yongjun Park
· 1_computer_abstraction (Computer Architecture) - William J. Song
댓글
댓글 쓰기