## 指令級平行與編譯器自動化 (OpenMP)
首先,需要理解 OpenMP 如何在編譯時期(Compile-time)將簡單的指令轉換為複雜的執行緒管理邏輯,並掌握數據在並行區域間的流動
## Fork-Join
>當你在 C 語言中加入 #pragma omp parallel 時,編譯器背後到底做了什麼?
1. Fork-Join 執行流程
* Fork:
當主執行緒(Master)遇到並行指令時,會Fork出一組執行緒團隊(Team of Threads)。
* Parallel Region:
所有執行緒同時執行大括號內的程式碼。
* Join:
在並行區域結束處有一個「隱含的同步點(Implicit Barrier)」,所有執行緒匯合後銷毀,只剩下主執行緒繼續執行。
2. Data Sharing
> pthread時,我們總需要一個`void* arg`,(worker wrap)
在 Pthreads 中,我們必須手動處理 `void* arg`;在 OpenMP 中,我們透過 Clauses 來定義變數的歸屬:
* **shared**
預設屬性。所有執行緒存取同一個記憶體位址(需注意 Race Condition)。
* **private**
每個執行緒在自己的 Stack 上建立一個同名變數的複本(初值未定義)。
* **firstprivate**
類似 private,但會繼承主執行緒中該變數的初始值。
* **reduction**
最常用的優化指令。它會為每個執行緒建立私有變數,並在 Join 時自動進行加總、相乘等操作,完全避開了手動加鎖的開銷。
### Sample Code : omp_basic
```c
// omp_basic.c
#include <omp.h>
#include <stdio.h>
#define TEST 0
int main() {
#if !TEST
int total_sum = 0;
int data[100];
for(int i=0; i<100; i++) data[i] = 1;
// 只需要這一行,編譯器就會幫你完成 Fork, Work-sharing, 與 Reduction
#pragma omp parallel for reduction(+:total_sum)
for (int i = 0; i < 100; i++) {
total_sum += data[i];
}
printf("Total Sum: %d\n", total_sum);
printf("預設執行緒數: %d\n", omp_get_max_threads());
#else
long long total_sum = 0;
struct timespec m_start, m_end;
clock_gettime(CLOCK_MONOTONIC, &m_start);
// num_threads(THREADS): 強制指定使用 16 個執行緒
// reduction(+:total_sum): 告訴編譯器幫我們做區域加總與合併
#pragma omp parallel for num_threads(THREADS) reduction(+:total_sum)
for (long long i = 1; i <= LIMIT; i++) {
total_sum += i;
}
clock_gettime(CLOCK_MONOTONIC, &m_end);
printf("計算完成!1 到 %lld 的總和是: %lld\n", LIMIT, total_sum);
printf("執行時間: %f 秒\n", (m_end.tv_sec - m_start.tv_sec) + (m_end.tv_nsec - m_start.tv_nsec) / 1e9);
printf("使用的執行緒數: %d\n", THREADS);
#endif
return 0;
}
```
> 與~ /src/pthreads/thread_trace.c 做比較
```
pthread :
計算完成!1 到 1000000000 的總和是: 500000000500000000
執行時間: 0.034421 秒
使用的執行緒數: 16
openmp :
計算完成!1 到 1000000000 的總和是: 500000000500000000
執行時間: 0.047312 秒
使用的執行緒數: 16
```
### 推測
當下達 `#pragma omp parallel` 時,OpenMP 在底層必須做很多安全檢查:
1. 它要喚醒執行緒池 (Thread Pool )。
> 於補充說明
2. 它要建立一個隱藏的 Barrier (屏障),確保所有執行緒在迴圈結束時等待。
3. 它要處理 reduction 的記憶體對齊與安全合併機制。
這些是通用與保險所造成的 Overhead
> 不過現實計算更複雜,此處只是用最簡易的加法累計來測量。
#### 觀察
```
# 可以產出一個,比assemblely好一點的展開code
# 找到 __builtin_GOMP_parallel 這是fork的位置
gcc -O3 -fopenmp -fdump-tree-ompexp omp_basic.c -o omp_basic
```
### Tracing code
```c
// omp_trace.c
#include <omp.h>
#include <stdio.h>
#include <unistd.h>
void trace_info(const char* stage) {
int id = omp_get_thread_num();
int total = omp_get_num_threads();
// 使用 printf 觀察執行緒的「分身」與「匯合」
printf("[Thread %d/%d] Stage: %s\n", id, total, stage);
}
int main() {
printf("--- 進入主執行緒 (Master Only) ---\n");
trace_info("Sequential Start");
printf("\n--- 觸發 #pragma omp parallel (Forking...) ---\n");
#pragma omp parallel
{
// 這裡會印出多行,代表 Fork 成功
trace_info("Inside Parallel Region");
#pragma omp master
{
printf(" > 只有 Master 執行緒 (%d) 會跑這行\n", omp_get_thread_num());
}
}
printf("\n--- 離開並行區域 (Joined) ---\n");
trace_info("Sequential End");
return 0;
}
```
或看環境變數
```
# env var
export OMP_DISPLAY_ENV=VERBOSE
./omp_basic
# unset env var
unset OMP_DISPLAY_ENV
export OMP_DISPLAY_ENV=FALSE
# extra visualize tool
Intel VTune / Score-P
```
## Scheduling
> 當**Load Imbalance**,則需要調整,但調整又會出現overhead。
### Scheduling Strategies
OpenMP 提供了四種主要的排程方式,用來處理不同的任務特性:
| 排程類型 | 運作機制 | 適用場景 | 開銷 (Overhead) |
| :--- | :--- | :--- | :--- |
| **`static (default)`** | 在編譯/啟動時就平分好任務。 | 每個迴圈計算量幾乎相等。 | 最低 |
| **`dynamic`** | 執行緒算完一個「區塊」後,再去申請下一個。 | 任務計算量不可預測(如:分形計算、不規則矩陣)。 | 較高 (需同步隊列) |
| **`guided`** | 初始區塊大,隨後逐漸縮小。 | 兼顧 `dynamic` 的靈活與減少申請次數。 | 中等 |
| **`runtime`** | 由環境變數 `OMP_SCHEDULE` 決定。 | 實驗階段,測試哪種最快。 | 視設定而定 |
需要注意的是,`omp_get_max_threads()`可以看使用多少threads,而如果沒有事先
```
#pragma omp parallel for num_threads(THREADS) reduction(+:total_sum)
或
export OMP_NUM_THREADS=n
```
openmp會在確認完硬體核心數的情況下直接default = max_core
---
## Sample Code : imbalance
```c
// omp_sched.c
#include <omp.h>
#include <stdio.h>
#include <unistd.h>
void heavy_work(int iterations) {
// 模擬負載重計算:迴圈次數隨 i 增加
// 避免優化
volatile double dummy = 0;
for (int i = 0; i < iterations * 1000000; i++) {
dummy += 1.0;
}
}
int main() {
int n = 20;
double start, end;
// 測試指令:可以手動更換為 dynamic, 1 或 guided
printf("--- 測試 Static Scheduling ---\n");
start = omp_get_wtime();
#pragma omp parallel for schedule(static, 1)
for (int i = 0; i < n; i++) {
int tid = omp_get_thread_num();
printf("Thread %d 執行迭代 %2d\n", tid, i);
heavy_work(i); // 迭代次數越多,計算越重
}
end = omp_get_wtime();
printf("Static Time: %f s\n\n", end - start);
return 0;
}
```
> ( < type>, <chunk_size>)
* `schedule(static)`
直接平均分配
* `schedule(static,1)`
輪流給,每次**chunk_size**個(Round-robin)
> 注意 False Sharing,task無padding的情況下,64bytes cache會必定會引發false sharing
* `dynamic, 4`
```
# recommend ! but only for auto
export OMP_SCHEDULE="dynamic,4"
./omp_sched
```
調整 `chunk size` (後面的數字),觀察這對效能的微小影響。
### static,4
```
--- 分配結果統計 ---
Thread 0 處理的迭代: [ 0 1 2 3 64 65 66 67 ] (總計: 8 次)
Thread 1 處理的迭代: [ 4 5 6 7 68 69 70 71 ] (總計: 8 次)
Thread 2 處理的迭代: [ 8 9 10 11 72 73 74 75 ] (總計: 8 次)
Thread 3 處理的迭代: [ 12 13 14 15 76 77 78 79 ] (總計: 8 次)
Thread 4 處理的迭代: [ 16 17 18 19 80 81 82 83 ] (總計: 8 次)
Thread 5 處理的迭代: [ 20 21 22 23 84 85 86 87 ] (總計: 8 次)
Thread 6 處理的迭代: [ 24 25 26 27 88 89 90 91 ] (總計: 8 次)
Thread 7 處理的迭代: [ 28 29 30 31 92 93 94 95 ] (總計: 8 次)
Thread 8 處理的迭代: [ 32 33 34 35 96 97 98 99 ] (總計: 8 次)
Thread 9 處理的迭代: [ 36 37 38 39 ] (總計: 4 次)
Thread 10 處理的迭代: [ 40 41 42 43 ] (總計: 4 次)
Thread 11 處理的迭代: [ 44 45 46 47 ] (總計: 4 次)
Thread 12 處理的迭代: [ 48 49 50 51 ] (總計: 4 次)
Thread 13 處理的迭代: [ 52 53 54 55 ] (總計: 4 次)
Thread 14 處理的迭代: [ 56 57 58 59 ] (總計: 4 次)
Thread 15 處理的迭代: [ 60 61 62 63 ] (總計: 4 次)
執行時間: 2.188987 秒
```
### dynamic,4
```
-- 分配結果統計 ---
Thread 0 處理的迭代: [ 60 61 62 63 ] (總計: 4 次)
Thread 1 處理的迭代: [ 8 9 10 11 72 73 74 75 ] (總計: 8 次)
Thread 2 處理的迭代: [ 48 49 50 51 ] (總計: 4 次)
Thread 3 處理的迭代: [ 56 57 58 59 ] (總計: 4 次)
Thread 4 處理的迭代: [ 24 25 26 27 88 89 90 91 ] (總計: 8 次)
Thread 5 處理的迭代: [ 20 21 22 23 84 85 86 87 ] (總計: 8 次)
Thread 6 處理的迭代: [ 36 37 38 39 ] (總計: 4 次)
Thread 7 處理的迭代: [ 32 33 34 35 96 97 98 99 ] (總計: 8 次)
Thread 8 處理的迭代: [ 44 45 46 47 ] (總計: 4 次)
Thread 9 處理的迭代: [ 28 29 30 31 92 93 94 95 ] (總計: 8 次)
Thread 10 處理的迭代: [ 4 5 6 7 68 69 70 71 ] (總計: 8 次)
Thread 11 處理的迭代: [ 16 17 18 19 80 81 82 83 ] (總計: 8 次)
Thread 12 處理的迭代: [ 12 13 14 15 76 77 78 79 ] (總計: 8 次)
Thread 13 處理的迭代: [ 52 53 54 55 ] (總計: 4 次)
Thread 14 處理的迭代: [ 40 41 42 43 ] (總計: 4 次)
Thread 15 處理的迭代: [ 0 1 2 3 64 65 66 67 ] (總計: 8 次)
執行時間: 2.456770 秒
chunk_size = 16
-> 執行時間: 1.868869 秒
```
---
### 排程開銷
`dynamic` 需要一個global table,threads每次領任務都要進行一次同步(Lock/Atomic)。如果每個任務本身極短(例如:單純的加法),`dynamic` 的申請時間甚至會比計算時間還長。
## 無法預測的for-loop
這裡是專門處理非線性數據結構(如樹、圖)或遞迴演算法時,在無法提前得知總 loop 次數時需要做的。
```
#basic
#pragma omp parallel
# Linear
#pragma omp parallel for schedule
# non-Linear
#pragma omp task
```
### Tasking Parallel
* 任務產生器
通常由 single thread 負責,不斷把任務丟進Work pool。
* Exe
其它 threads 會從pool裡拿出來work出來跑(這叫 Work-stealing)。
* key : `taskwait / taskgroup`
並行任務是 unsync。如果你希望等待再提交,你必須加上 #pragma omp taskwait,否則主執行緒會直接跑掉,導致結果錯誤。
### Task Granularity
建立一個 task 需要配置記憶體、放入隊列、由排程器調度。如果 $n$ 很小(例如 $n=5$),計算只需幾ns,但建立任務要幾百ns。這就是 細粒度開銷Fine-grained Overhead
### Cutoff
> 當問題已經小到不值得分給別人做時,請自己算完
即,當task大於`cutoff`時,才進行平行化;反之則自己搞定。所以這裡,當cutoff = 0後,所有threads一拿到task,又開始分工,沒打算生產,這樣一路往下自爆,直到 **task = cutoff**
1. 全局溢出:任務被擠到 Global Queue
2. 鎖(Lock)的介入:為了保證資料正確(不讓兩個 Thread 搶到同一個任務),全域隊列必須加鎖。
3. 排隊死結
總結 `cutoff` 的角色
| 條件 | 動作 | 執行緒行為 |
| :--- | :--- | :--- |
| **$n > \text{cutoff}$** | **建立 Task** | **分工** |
| **$n \le \text{cutoff}$** | **執行 Serial** | **生產** |
### Code
```c
// omp_task.c
#include <omp.h>
#include <stdio.h>
#include <stdlib.h>
long fib_serial(int n) {
if (n < 2) return n;
return fib_serial(n - 1) + fib_serial(n - 2);
}
// 平行任務版本
long fib_task(int n, int cutoff) {
if (n < 2) return n;
// 當小於 cutoff 時,停止產生 Task,改用序列計算
if (n < cutoff) {
return fib_serial(n);
}
long x, y;
#pragma omp task shared(x)
x = fib_task(n - 1, cutoff);
#pragma omp task shared(y)
y = fib_task(n - 2, cutoff);
#pragma omp taskwait
return x + y;
}
int main() {
int n, cutoff;
printf("請輸入 Fibonacci 項數 (建議 30-40): ");
if (scanf("%d", &n) != 1) return 1;
printf("請輸入 Task Cutoff 閾值 (建議 20,輸入 0 代表全平行): ");
if (scanf("%d", &cutoff) != 1) return 1;
double start, end;
long result;
// --- 1. 測量純序列時間 ---
printf("\n[1] 執行純序列計算...\n");
start = omp_get_wtime();
result = fib_serial(n);
end = omp_get_wtime();
printf("序列結果: %ld | 耗時: %f 秒\n", result, end - start);
// --- 2. 測量平行任務時間 ---
printf("\n[2] 執行 OpenMP Task 計算 (Cutoff=%d)...\n", cutoff);
start = omp_get_wtime();
#pragma omp parallel
{
#pragma omp single
result = fib_task(n, cutoff);
}
end = omp_get_wtime();
printf("平行結果: %ld | 耗時: %f 秒\n", result, end - start);
return 0;
}
```
### Test
```
htop
n = 40, cutoff = 0
```
#### htop 顯示 CPU 使用率很低?
> 大家都在分工,誰在那邊跟你玩計算。
由 **「任務生成的傳遞速度」** 與 **「執行緒等待策略(Wait Policy)」** 共同造成的。
##### 100% core
當你執行 $F_{40}$ 且 `cutoff=0` 時,任務是從一個 **單一入口**(Master Thread)開始產生的。
* **發包者(Generators)**:一開始只有一個 Thread 在執行 `fib(40)`。它會迅速產出 `fib(39)` 和 `fib(38)`。
* **擴散過程**:Thread 2 偷走了 `fib(39)`,Thread 3 偷走了 `fib(38)`。這幾位「先搶到任務」的執行緒,會立刻**狂發包**。
* **為什麼 100%**:這些執行緒正處於「產生任務 -> 嘗試塞進隊列 -> 失敗 -> 旋轉等待(Spinning)-> 成功 -> 產生下一個任務」的無限循環中。對於作業系統來說,這些執行緒一直處於「活動(Active)」狀態,所以顯示 100%。
##### 5% core
> 為什麼其它執行緒不去幫忙?
* **鎖競爭(Lock Contention)崩潰**
OpenMP 的Global Task Queue
* **OS 的介入**
當 Thread 5 想要去偷任務時,發現門口已經擠滿了 Thread 1~4,且鎖被鎖住了。
* **掛起(Suspend)**
如果 Thread 5 嘗試幾次都失敗,作業系統核心(Kernel)會先把它**移出執行隊列**
* **為什麼 5%**
在 `htop` 裡,這代表該執行緒大部分時間都在「睡眠(Sleep/Blocked)」,只有偶爾被喚醒起來看看有沒有機會搶到鎖,搶不到又立刻回去睡。
##### OpenMP 的等待策略:Active vs. Passive
這與 OpenMP 的內部設定 `OMP_WAIT_POLICY` 有關:
* **ACTIVE**:執行緒沒事做時會一直繞圈圈(Spin)等任務。這會讓所有核心都顯示 100%,但效率極低。
* **PASSIVE**:執行緒沒事做時會交回控制權給 OS 去睡覺。這就是你看到的 5% 現象。
* **混合狀態**:現代 OpenMP 通常會先讓你「旋轉一小段時間」,如果還是沒事做,就送你去睡覺。
**所以你看到的 `htop` 景象其實是:**
> 有幾個執行緒運氣好,搶到了發包的權利,忙得不可開交(100%);而大多數執行緒則被擋在「任務隊列」的鎖外面,被 OS 強制送去休息了(5%)。
## 補充
* Threads Pool
對應到`lifecycle.c`中所談到的create/delete overhead,將刪除(銷毀)改成睡眠,使Threads不會像竹筍一樣。另外,threads的lifecycle是取決於拿到的task何時結束,所以只要在task加while-loop,threads就會不被銷毀。
> 設計條件
> 1. Task Queue
> 2. While-Loop
> 3. Synchronization
> * (Prevent) Mutex
> * (Prevent) Condition Variable
LAB 5
https://hackmd.io/@1p0_VOUHTXGcHlBU9A0OEA/HkMJabKi-l