# HW3 All-Pairs Shortest Path [HacMD link](https://hackmd.io/@elliehung/Hk79vnhzJl) | CS542200 Parallel Programming | HW3: All-Pairs Shortest Path | 112065528 | 洪巧雲 | | ----------------------------- | ---------------------------- | --------- | ------ | ## Hw3-1 CPU version - 這次的作業所選擇實作的演算法是 Floyd-Warshall 演算法來解決All-Pairs Shortest Path 問題。 - 利用 Blocked 策略,將整個距離矩陣切成大小為 `𝐵×𝐵` 的區塊,分 3 個 Phase 處理。 - CPU 版本的優化主要是在 code 上使用 OpenMP 平行化與 SIMD 達成加速。 ### Optimization - OpenMP 平行化 - 使用 `#pragma omp parallel for schedule(dynamic)` - SIMD - 使用 `__m128i SIMD_l`、`__m128i SIMD_kj`、`__m128i SIMD_ik` 搭配 `_mm_add_epi32`、`_mm_min_epi32` 等進行批量運算,減少指令數量與提升 CPU pipeline 利用率。 ![image](https://hackmd.io/_uploads/BJ3ni6LSke.png) ## Hw3-2 Single-GPU version ### 如何分配 CUDA kernel 的資料大小 - `thread block: (32,32)`,代表的是每個 block 中的 x 方向有 32 個 threads,y 方向有 32 個 threads。總共會有 1024 條 threads。 - fw_phase1: ![image](https://hackmd.io/_uploads/SkUz18WHyx.png) `(1, 1)` 代表只使用一個 thread block - fw_Phase2: ![image](https://hackmd.io/_uploads/B1kMJUbBJe.png) `dim3(numBlocks, 2)`: 用來定義 `fw_phase2` 階段 Kernel 所需要的 thread blocks 數量 ,這裡設置為 `numBlocks * 2`。 `x 軸 = numBlocks`: blockIdx.x --> 第幾個 Block。 `y 軸 = 2`: 代表 pivot block 的 x 軸以及 pivot block 的 y 軸,`if (blockIdx.y == 0)` 則是在處理 pivot row,`if (blockIdx.y == 1)` 則是在處理 pivot column。 - fw_phase3: ![image](https://hackmd.io/_uploads/BySZJUZS1x.png) `dim3(numBlocks, numBlocks)`: 用來定義 `fw_phase3` 階段 Kernel 所需要的 thread blocks 數量 ,這裡設置為 `(numBlocks) * (numBlocks)` 。CUDA 就是透過以上方法去 activate kernel。 - 以下為圖示: ![image](https://hackmd.io/_uploads/BJ97PrbBJx.png) ![image](https://hackmd.io/_uploads/SklEvrZHJl.png) 由圖中可以看到黃色的部分是 Device Matrix ,也就是一開始存 vextex、edges 的大矩陣。size 為 `64 x 64` 的藍色矩形則是 Grid block: `block_size = 64`,這個大小會跟每個 Kernel 中所宣告的 shared memory 大小相同,因為我們會需要以 Grid Block 為單位去實作 Floyed-Warshall 的演算法: ![image](https://hackmd.io/_uploads/H1S2lLlrkx.png) 最後是橘色最小的矩形,這部分則是 thread block,其大小為 `32 x 32`。 目前定義了三個不同的矩形以及它們各自的 size,接下來會解釋它們各自的關係與用意。 下圖解釋了 Thread Block 與 Grid 之間的關係: ![image](https://hackmd.io/_uploads/S1XVBCtQ1g.png) 這張圖片則是展示了 Host 跟 Device 之間 Kernel 與 Grid、Block、Thread 之間的關係,可以清楚的看到 Device 的部分有 Grid,而每個 Grid 中存放了四個 Block,每個 Block 中則是開了多個 Threads。在我原本設定的程式中,一開始我是使用 `block_size = 32`,這時 Threads 一樣是開 `32 x 32`,但是在 Kernel 中,程式會長的跟 `block_size = 64` 不一樣,原因是 `block_size = 32` 的話,它跟 Thread Block 大小會相同,代表說一條 thread 一個 thread 處理 1 筆資料,如果要達到啟動 Kernel 後可以一個 thread 處理多筆資料的話,則可以把 Block Size 開大一點,像是開到 64 的話,它可以一次處理 4 筆資料,也就是如下所示一次更新 Block 中的四個 index 元素: ![image](https://hackmd.io/_uploads/r1BPzdxrkl.png) 也就是以下這張示意圖中的: `(0, 0)`、`(0, 32)`、`(32, 0)`、`(32, 32)` 這四個位置的 index: ![image](https://hackmd.io/_uploads/Ske4YL-Skg.png) 下圖的 `block(0,0)`、`block(1,0)`、`block(0,1)`、`block(1,1)`: ![image](https://hackmd.io/_uploads/SJcYpuxH1g.png) 分別代表了下圖的 grid 中的 4 個 Block: ![image](https://hackmd.io/_uploads/HkA0OrZByx.png) 接下來是每個 Block 中會有 Thread Blocks,也對應到圖中的每個 Block 中的 Thread: ![image](https://hackmd.io/_uploads/r1eAdBbH1l.png) 如果 Block Size 是開 32 的話,程式碼如下,就會是更新一個元素: ![image](https://hackmd.io/_uploads/B1XtzdeByg.png) ![image](https://hackmd.io/_uploads/S1Q3aderJg.png) 上圖一樣對應到下圖: ![image](https://hackmd.io/_uploads/HkA0OrZByx.png) ### Block Size 的選擇 | Block Size | 每條 Thread 的工作量 | |------------|----------------------------------------------------| | 32 | 一個 thread 處理 1 筆資料 `(32*32) / (32*32) = 1` | | 64 | 一個 thread 處理 4 筆資料 `(64*64) / (32*32) = 4` | 之所以 Thread Block 只開 `32 * 32` 原因是 CUDA 最多只能開 1024 條 threads,雖然 Block size 可以開到 128、256、512...,但是我們的 GPU (1080) 的 memory 只有 48 KB,所以最大的 block size 只能開到 110,但是要跟 thread align 故只能開到最接近 110 的 32 倍數,又因為 Phase2 & Phase3 需要 2 個 shared memory (2 * 64 * 64 = 8,192) `2 * 4096 * 4 byte = 32,768 --> 32 KB` 假設 block size 開到 96,在 Phase2 & Phase3所需要的記憶體如下: `2 * 96 * 96 * 4 byte = 73,728 --> 73 KB` 這樣就超過硬體的限制了,故這份作業 Block Size 最多只能開到 64。 ### Grid Blocks 的數量選擇 首先是 Grid Block Size 我選擇了 64,那就會需要去計算出最接近 Input vertex number `n` 的 64 的倍數去宣告成 `host_matrix` 與 `device_matrix` 的大小: - `host_matrix`: ![image](https://hackmd.io/_uploads/rJfBiBZBke.png) - `device_matrix`: ![image](https://hackmd.io/_uploads/rJ4ujS-Bke.png) 接著是計算總共會切幾個 Grid Blocks: `int numBlocks = padded_n / 64;` 這個 `numBlocks` 也是總共 Phase1、Phase2、Phase3 需要做幾次才能計算完成的回合數。 ## Hw3-3 Multi-GPU version - hw3-3 中使用了 OpenMP 開兩條 threads,分別分配給兩個 GPU ```=C++ #pragma omp parallel num_threads(2) ``` ```=C++ cudaSetDevice(threadId) ``` 根據不同 thread 去設定所要使用的 GPU:`threadId=0` 使用 GPU0,`threadId=1` 使用 GPU1。 - Hw3-3 的 block size 與 Hw3-2 一樣使用的是 64,然後在 Phase3 時才會分多個 GPU 去做平行化,其中會需要計算幾個不同的變數: `gridPhase3Size`: 這是指在 Phase3 負責處理的 Blocks 數量: ![image](https://hackmd.io/_uploads/H1ekO6zHJe.png) `start`: 為此 GPU 的起始處理的 index。 ![image](https://hackmd.io/_uploads/H16ZOpfByg.png) 接下來與 hw3-2 不同的地方在於,前面提到在 Phase3 會需要改成多 GPU 執行運算,所以在 `__global__ void fw_phase3(int *dist, int padded_n, int r, int numBlocks, int start)` 的 function 中會需要增加一個傳入的參數: `start`,這個參數是為了讓不同 GPU 負責 Phase3 的計算處理的區塊不會重疊,都位於其負責的範圍內。 - 分配資料的方法為將 data 分一半,前半分給 GPU0 後半分給 GPU1,若有餘數則是分給 GPU1。在 fw_phase1、fw_phase2 這兩個計算的階段,Phase2 會需要 Phase1 的資料才能做計算,但是 Phase3 是去計算非 Pivot Block、Pivot Row、Pivot Column 的部分,它整個計算都是獨立的,不會涉及到後續有 data dependency 的問題,故在 Phase3 做 Multi-GPU 的分配非常適合。 ![image](https://hackmd.io/_uploads/rJD5aUZH1x.png) ### Communication between Multi-GPU - Host 與 Device 之間的數據傳輸 - *Step 1 初始化複製資料*: 在 OpenMP thread 各自分配好自己的 GPU 後`cudaSetDevice(threadId);`,會需要將各自負責處理的資料區塊從 `host_matrix` 複製到對應的設備記憶體`device_matrix`: ![image](https://hackmd.io/_uploads/HJCg1ofrye.png) - *Step 2 GPU 之間的 Data 交換*: 在每個計算的階段中,包含了 `fw_phase1`、`fw_phase2` 以及 `fw_phase3`,因為上面已經分好原始 data 會切一半,一半給 GPU0 一半給 GPU1,所以說會需要做資料的交換,才能確保計算的 result 是正確的。這裡使用的一樣是 `cudaMemcpy`: 下圖是在判斷是否為這個 GPU 負責到的 data 範圍,是的話會將記憶體由 device 複製到 host: ![image](https://hackmd.io/_uploads/HkhzvjGHyl.png) 這部分的資料是為了讓上一個 round 所計算完的 Phase1、Phase2、Phase3 階段的結果更新到 Host 上,才能後續計算出正確的結果。 下圖則是在判斷是否**不**為這個 GPU 負責到的 data 範圍,這樣的話會將記憶體會由 host 複製到 device: ![image](https://hackmd.io/_uploads/SyvQvozSyg.png) 這部分是為了讓這一回合進行計算前把其他 GPU 所產生的計算結果更新到自己的 device 上 (from host),所以才需要 Memcpy 到 Device。 >在上述 data 的傳輸,GPU 與 GPU 之間直接的溝通是透過 Host 做溝通。 - *Step 3 傳回最終 result*: ![image](https://hackmd.io/_uploads/SJ55DiMBJe.png) - Threads 之間的 Synchronization: - 使用了 `#pragma omp barrier` 去做所有 threads 的同步,這會確保所有 threads 在同一步驟完成後才進入下一個步驟。 - 確保所有 OpenMP 線程在此點完成資料從設備到主機的複製後,才繼續進行下一步操作。 ### Implementation in figures 以下使用圖解解釋我的 code 是如何透過 cudaMemcpy 做資料的更新: ![image](https://hackmd.io/_uploads/ByvKAeQBye.png) ![image](https://hackmd.io/_uploads/ByyjRlmSyl.png) ![image](https://hackmd.io/_uploads/Hy7n0eXryg.png) 可以看到在 `Round1 ~ Round3` 中,因為主要在 Phase1 & Phase2 更新的資料位於 Block 的上半部,故主要做 `cudaMemcpy` 的方向為 `GPU0 -> GPU1`,在 `Round4 ~ Round6` 的部分 Pivot 換到下半部了,也就是 GPU1 在負責的區域,故主要做 `cudaMemcpy` 的方向換成 GPU1 -> GPU0。這也是為甚麼在進入任何一個 Phase 之前會需要先 call `cudaMemcpy` 將資料先做更新。 ## Profiling Results (hw3-2) ### Blocking Factor Result `Testcases: c20.1` 在 Blocking Factor 的實驗中,我使用的測資為 `c20.1`,並且使用 `srun -p nvidia -N1 -n1 --gres=gpu:1 nvprof -m $NVPROF_METRIC ./hw3-2-$i $IN $OUT` 其中: `NVPROF_METRIC=inst_integer,shared_load_throughput,shared_store_throughput,gld_throughput,gst_throughput` 以及 `for i in {16,32,64}`。下圖為跑出來的結果: ![image](https://hackmd.io/_uploads/ryKgOI_4kg.png) - Total Integer Instructions: 整數運算指令的執行次數隨著 Block Size 越大,執行次數越多,會出現這樣的實驗結果是因為在 block size 變大後,每個 block 所需要處理的指令變多,這就會導致整數運算指令會隨之增加。增加 Block Size 會使得總通信量減少,但需要付出的代價就是會造成每個 Block 的內部計算變大。 ![image](https://hackmd.io/_uploads/r1oFPUdNJl.png) - Shared Memory Load/Store: 下面兩張圖展現了共享記憶體的 Throughput 會隨著 Block Size 的增加也會隨之增加。原因是 Block 越大,在單一 Block 中所需要處理的數據量也會變多,Throughput 越大也代表了程式碼充分利用了 GPU 的資源,這會間接導致執行時間的優化。 ![image](https://hackmd.io/_uploads/HyMcvIO4yx.png) ![image](https://hackmd.io/_uploads/B1D9wUdVkg.png) - Global Memory Load/Store: Global Memory 跟 Shared Memory 的狀況差不多,隨著 Blocking Size 的成長,Throughput 也會隨之增加。 ![image](https://hackmd.io/_uploads/ryjqwIONJl.png) ![image](https://hackmd.io/_uploads/S1JiwLu41l.png) - Shared Memory and Global Memory Comparison: 在把所有數據綜合比較後,以下圖表展現出無論是 Load 還是 Store 操作,Shared memory 的 Throughput 都高於 Global memory 的 Throughput,原因是 Shared memory 相較於 Global memory 更適合用於頻繁讀寫的情況。 ![image](https://hackmd.io/_uploads/ryAvnWXS1x.png) - Total execution time: 最後是整體 Blocking Factor 與執行時間的折線圖,可以發現在開到 64 時執行時間比 16 還要少非常多。 ![image](https://hackmd.io/_uploads/ryzNY-XSyl.png) ### Optimization `Testcases: p11k1` ![image](https://hackmd.io/_uploads/rJJqhMu4Jx.png) - Padding - 將 `padded_n` 取到最接近 64 的整數倍,然後在初始化矩陣時,將超過原本 𝑛 的部分都設成 INF。 ```=c++ int remainder = n % BLOCK_SIZE; if(remainder != 0){ n += (BLOCK_SIZE - remainder); } ``` - 這樣的優化可以讓程式在執行 kernel function 的時候,讓每個 block 都可以處理相同大小的區域,避免需要使用 branch 判斷。 - 使 block/thread 對齊。 - Coalesced Memory - 讓同一個 warp (32 threads) 可以同時存取「連續的 address」,提升 Global Memory bandwidth 的使用效率。 - 這樣做還可以減少 bank conflict。 ![image](https://hackmd.io/_uploads/r1Roai8Syx.png) 如上圖所示,我實作 coaleasced memory 的方式為透過計算 `global_i` 與 `global_j` 這兩個變數,確保 threads 中同時存取連續的記憶體位址。 ![image](https://hackmd.io/_uploads/HyqyG2Lrkl.png) 在這個部份我使用 `printf("%d\n", GLOBAL_I_padded + global_j)` 以確保 同一個 Warp 取用的 Global Memory 是連續的: ![image](https://hackmd.io/_uploads/HkOMW3IB1l.png) 印出的結果如上所示,每 32 一個 warp。 - 這樣做之所以可以優化的關鍵為 Warp 中的 Threads 同時存取連續位址,可以減少記憶體存取延緩。 - Shared Memory - 在各個 Phase 的 Kernel 中,宣告並使用 Shared Memory,如下所示: ![image](https://hackmd.io/_uploads/Sknzfh8BJe.png) - 這樣可以減少 Global Memory 存取次數:透過 Shared Memory 快取頻繁使用的資料,減少對 Global Memory 的存取。 - Blocking Factor Tunning - 這個部分的實作我是把 BLOCK_SIZE 從 32 增加到 64,並相應調整 Kernel 中的 Shared Memory 配置。 - 更大的 BlockSize 允許更多資料一次載入 Shared Memory,提高資料重用率。 - 這部分的優化有在上面詳細做解釋了。 - Unroll - 使用Loop Unrolling,可以減少迴圈控制指令的開銷,並提升指令級並行度(ILP),從而加速運算。 - 在 Kernel 中,對主要的迴圈使用 #pragma unroll 的程式指令。 ![image](https://hackmd.io/_uploads/ry1XmhLSJl.png) - 這樣做可以讓更多的運算指令能夠在同一時間被執行,提升 GPU 的運算 Throughput。 ### Time Distribution ``` Testcases: c15.1 c20.1 p13k1 p17k1 p21k1 p25k1 n : 777 5000 13000 17000 20959 27000 ``` ![image](https://hackmd.io/_uploads/rJquH0UBJl.png) 根據不同 Input Size 以下做了 `Input time`、`Output Time`、`H2D`、`D2H`、`Compute time`,這幾個不同時間的占比與分析,我的 Time Distribution 的實驗使用的測資為 c15.1 c20.1 p13k1 p17k1 p21k1 p25k1 分別對應到 n = 777, 5000, 13000, 17000, 20959, 27000 這幾個大小的測資。 ![time_components_per_n](https://hackmd.io/_uploads/r1SOhMxr1l.png) 由上圖可以發現隨著 n size 越大,compute time 為其 bottle neck。其他的時間增長的幅度沒有 compute time 這麼多。 H2D / D2H(綠色與紅色的部分)也相對較小,顯示資料拷貝雖然隨 N 擴增,但比起整體計算依然不算最顯著的瓶頸。 ![time_distribution](https://hackmd.io/_uploads/rJ14hNxHkl.png) 上圖則是時間分佈的折線圖,也可以清楚的看到 compute time 增長的幅度為主要花費的時間,因為 Floyd-Warshall 本身時間複雜度是 𝑂(𝑛^3),當資料規模增長時,計算量急遽攀升。 ## Profiling Results (hw3-3) ### Weak Scalibility - Testcases use: - pk29k1 --> GPU: 1 - pk42k1 --> GPU: 2 在 Weak Scalibility 的實驗中,我使用的測資為 `pk29k1` 以及 `pk42k1`,為了測試更大的資料量,2 個 GPU 的效能是否更好,我讓 single GPU 的程式跑 `pk29k1` --> n 大小為 28911,讓多 GPU 版本跑 `pk42k1` --> n 大小為 42000。 ![weak_scalability_-_speed_up](https://hackmd.io/_uploads/S1sBAVeHkg.png) ![weak_scalability_-_time](https://hackmd.io/_uploads/rkuLC4lBke.png) 由實驗結果可以發現,實際上在資料量增加到將近兩倍時,2 個 GPU 版本的效能只比單 GPU 快 1.10 倍,這樣的實驗結果代表了實際上多 GPU 並不一定會導致計算效能加速呈線性增長,原因是因為多 GPU 還會涉及到 GPU 與 GPU 之間的溝通,Floyd-Warshall 每一個 Round (Phase 1, 2, 3) 均需要傳遞 pivot row/column 資料到其它 GPU,或經由 Host 做交換。通訊、同步與資料整合帶來顯著的 overhead,特別在資料量更大時,這些 overhead 也被放大。還有涉及到資料量的增長,`pk42k1` (n=42000) 的 Floyd-Warshall 計算量大約是 `pk29k1` (n=28911) 的 `(28911/42000)^3` 倍,即變使用兩張 GPU,也無法抵消整體計算量翻倍開銷。最後是因為 Floyd-Warshall 本身的演算法,具有很高的 data dependency,因而限制了多 GPU 的效能發揮。 ## Experiment on AMD GPU ![image](https://hackmd.io/_uploads/ryD4Ca8rJg.png) hw3-2-nvidia ![image](https://hackmd.io/_uploads/rkku6pUHyg.png) hw3-2-amd ![image](https://hackmd.io/_uploads/B1ulppLryg.png) hw3-3-nvidia ![image](https://hackmd.io/_uploads/H1kPaaLHyg.png) hw3-3-amd ![image](https://hackmd.io/_uploads/SkbfaTUSye.png) ### hw3-2 Experiment result testcase: `p29k1` ![image](https://hackmd.io/_uploads/ryfJQCLS1l.png) ![image](https://hackmd.io/_uploads/SyWCQRIrkx.png) ### hw3-3 Experiment result testcase: `c07.1` ![image](https://hackmd.io/_uploads/B13CGC8BJe.png) ![image](https://hackmd.io/_uploads/ry80mAIHJe.png) ### Discussion 不管是在單 GPU 的版本還是多 GPU 版本中,AMD 的版本速度都比 Nvidia 快。 ### Other Optimization 在大筆測資比較難 Accept 的情況下,最後因為發現後面測資光是 read_input 所需要花費的時間就很多了,加上 read_input 裡面有雙層 for loop,這讓我想要將它也寫成 kernel function: Before: ![image](https://hackmd.io/_uploads/HkRmtSKrkg.png) After: ![image](https://hackmd.io/_uploads/Sk9PRHtH1g.png) 在 main function 裡面呼叫: ![image](https://hackmd.io/_uploads/B1pOYStSyx.png) ![image](https://hackmd.io/_uploads/rkUtKSYSyx.png) 這樣在 `p30k1` 這個 testcase 大概加快了1.5秒: Experiment result: ![image](https://hackmd.io/_uploads/BJXKiHYrJg.png) Speedup 加快 1.06 倍: ![read_input_optimization_-_speed_up](https://hackmd.io/_uploads/Sk8DaSKSJg.png) ![read_input_optimization_-_time](https://hackmd.io/_uploads/SJ8DTrFBkg.png) ## Conclusion 在這次作業中,實作了 Floyd-Warshall 演算法的 CPU 及 GPU 版本,並針對不同平台進行了一系列優化。CPU 版本是透過 OpenMP 平行化與 SIMD 指令加速,有效提升了運算效能。而在單一 GPU 版本中,採用了 Padding、Coalesced Memory、Shared Memory、Blocking Factor 及 Loop Unroll 等多項優化技術,顯著減少了 Global Memory 存取次數,提升了記憶體 bandwidth 利用率與運算效率。 在多 GPU 實作中,雖然理論上應能提升運算速度,但實驗結果顯示效能增幅並不如預期,僅達到約 1.10 倍的加速,主要原因在於 GPU 之間的資料傳輸與同步所帶來的額外開銷。此外,Floyd-Warshall 演算法本身的高度資料依賴性限制了多 GPU 的效能發揮。 在這次作業中,發現後面超過 `p30k1` 的測資大家的通過率都很低,原因透過 profiling 結果來看是因為卡在 computation time 這個 bottleneck,畢竟 Floyd-Warshall 演算法中涉及很多 for loop,很多資料都有相依性,演算法的時間複雜度高達 O(n^3),這才是這份作業最難優化的部分。