計算與通訊重疊 (CUDA Streams 與 Pinned Memory)
重點總覽 (Overview)
目標:讓通訊網路 (communication network) 與運算硬體 (computation hardware) 同時忙碌,而不是交替閒置。核心手法是把每次 iteration 的工作切成 stage 1 (boundary) 與 stage 2 (internal),並用 CUDA streams + pinned memory 讓 halo 交換與內部運算重疊。
| 機制 (Mechanism) | API / 關鍵字 | 角色 |
|---|---|---|
| 兩階段策略 | stage 1 / stage 2 kernel | 先算鄰居要的 boundary,再邊通訊邊算 internal |
| Pinned memory | cudaHostAlloc(..., cudaHostAllocDefault) |
page-locked bounce buffer,DMA 安全且可非同步 |
| DMA | (硬體) | 在實體位址上搬資料,需要 pinned 才不被 page out |
| 非同步複製 | cudaMemcpyAsync(..., stream) |
不阻塞 host,放入指定 stream |
| CUDA streams | cudaStreamCreate / cudaStream_t |
同 stream 內循序,不同 stream 間可並行 |
| Stream 同步 | cudaStreamSynchronize / cudaDeviceSynchronize |
等單一 stream / 等所有 device 活動 |
| Halo 交換 | MPI_Sendrecv |
一次呼叫同時送 boundary、收 halo |
| 跨程序同步 | MPI_Barrier |
確保所有 process 同時開始/結束 |
| Double buffering | swap d_input ↔ d_output |
本步輸出變下步輸入,避免覆寫 |
Boundary slice = 本 partition 邊緣、會被鄰居當 halo 用的「我家的資料」(彩色片)。Halo slice = 從鄰居收進來的「別人家的資料」(虛線片)。Process i 的 boundary 會被複製成 process i+1 的 halo。
為何需要重疊 (The Two-Mode Problem)
最直觀的做法:整個 partition 算一步 → 交換 halo → 重複。問題是系統永遠只在兩種模式之一:
天真做法 (serialized):
┌── compute (整個 partition) ──┐ ┌── compute ──┐
│ network idle │ ┌── halo exch ──┐ │ net idle │ ...
└─────────────────────────────┘ │ compute idle │ └─────────────┘
└──────────────┘
時間 ───────────────────────────────────────────────────────────────►
- 模式一:大家都在算 → 網路閒置。
- 模式二:大家都在換 halo → 運算硬體閒置。
T_iter ≈ T_stage1 + max(T_internal_compute, T_halo_comm)。只要內部運算夠久(內部 slice 夠多)就能把通訊延遲完全藏住。
兩階段策略 (Two-Stage Boundary / Internal Kernels)
每個 compute process 在 device memory 中的 partition 佈局(沿 z 切割,每片 = dimx*dimy):
device memory (per partition), 共 dimz+8 片:
offset(片): 0 4 4 dimz dimz+4 dimz+8
│ L halo │ L boundary │ internal │ R bndry │ R halo │
│ (虛線) │ (彩色) │ (dimz-8) │ (彩色) │ (虛線) │
└────────┴────────────┴────────────┴─────────┴────────┘
num_halo_points = 4*dimx*dimy (一邊 4 片 halo 的元素數)
stage1 left : 用 offset 0 的 12 片 → 算出 4 片 L boundary
stage1 right : 用 offset dimz-4 的 12 片 → 算出 4 片 R boundary
stage2 : 用 offset 4(skip L halo) → 算 (dimz-8) 內部點 (stream1)
stage 1:先算鄰居「下一步」需要的 boundary slice(edge process 只算一邊)。
stage 2:一邊把新 boundary 送出去(D2H copy + MPI),一邊算剩下的 internal 點。
// 重疊用的 offset (Fig. 20.14)
int left_halo_offset = 0;
int right_halo_offset = dimx * dimy * (4 + dimz);
int left_stage1_offset = 0;
int right_stage1_offset = dimx * dimy * (dimz - 4);
int stage2_offset = num_halo_points; // = 4*dimx*dimy
MPI_Barrier(MPI_COMM_WORLD); // 全部就緒才開始
for (int i = 0; i < nreps; i++) {
// stage 1: 先算鄰居要的 boundary,放 stream0,兩個 kernel 循序
call_stencil_kernel(d_output + left_stage1_offset,
d_input + left_stage1_offset, dimx, dimy, 12, stream0);
call_stencil_kernel(d_output + right_stage1_offset,
d_input + right_stage1_offset, dimx, dimy, 12, stream0);
// stage 2: 內部點放 stream1,可與 stream0 + 通訊並行
call_stencil_kernel(d_output + stage2_offset,
d_input + stage2_offset, dimx, dimy, dimz, stream1);
/* ... 見下方 halo 交換 ... */
}
Boundary 必須最先算出來,通訊才能立刻開始;否則 stream1 的內部運算還沒結束,網路又閒置了。stage 1 用 12 片輸入(4 halo + 4 boundary + 4 internal)才能正確算出 4 片 boundary(25-point stencil 每方向需 4 鄰居)。
Pinned Memory 與 DMA (Pinned Memory, DMA, cudaHostAlloc)
cudaMemcpy 底層用 DMA (direct memory access) 硬體搬資料,DMA 在 host 端操作的是實體位址 (physical address)。但 OS 的 virtual memory 會把分頁 (page) 換出 (page out) 到磁碟,實體位址可能被重新指派 → DMA 資料被覆寫、損毀。
一般 malloc 記憶體 (pageable):
host buf ──(CUDA 先複製)──► pinned 暫存 ──(DMA)──► device ← 兩步、且同步阻塞!
pinned 記憶體 (cudaHostAlloc):
host buf ─────────────────(DMA 直接搬)──────────────► device ← 一步、可非同步
- Pinned / page-locked memory:標記為「不可換出」,DMA 可安全直接存取。
- 一般
malloc緩衝區 → CUDA runtime 須先複製到內部 pinned 暫存再 DMA,多一次複製且強迫 synchronous(host 必須等 copy 完才能繼續),所有複製被序列化。 - 用
cudaHostAlloc配置的 buffer 稱為 bounce buffer(彈跳緩衝),作為 device ↔ 遠端 MPI process 之間的暫存站。
// 為 left/right boundary(要送出)與 left/right halo(要收進)配置 pinned buffer
cudaHostAlloc((void**)&h_left_boundary, num_halo_bytes, cudaHostAllocDefault);
cudaHostAlloc((void**)&h_right_boundary, num_halo_bytes, cudaHostAllocDefault);
cudaHostAlloc((void**)&h_left_halo, num_halo_bytes, cudaHostAllocDefault);
cudaHostAlloc((void**)&h_right_halo, num_halo_bytes, cudaHostAllocDefault);
cudaMemcpyAsync 的前提
cudaMemcpyAsync 要求 host 端緩衝區是 pinned memory。若傳入一般 malloc 的指標,非同步語意無法成立。cudaHostAlloc 前兩個參數同 cudaMalloc,第三個是 flag,基本情況用 cudaHostAllocDefault。釋放要用 cudaFreeHost(不是 free)。
非同步複製與 Streams (cudaMemcpyAsync & CUDA Streams)
Stream = 一條有序的操作序列。把 cudaMemcpyAsync 或 kernel launch 指定到某 stream:
- 同一 stream 內:依放入順序循序執行。
- 不同 stream 間:無順序限制,可並行。
stream0: [stage1 L]→[stage1 R]→[D2H boundary]──(MPI)──[H2D halo]
stream1: ─────────────[ stage2 internal compute (最久) ]─────────
▲ 兩條 stream 並行 → 通訊被內部運算藏住
cudaStream_t stream0, stream1;
cudaStreamCreate(&stream0);
cudaStreamCreate(&stream1);
// ...
// boundary 算完後,D2H 複製到 pinned host(同在 stream0,自動等 stage1 kernel)
cudaMemcpyAsync(h_left_boundary, d_output + num_halo_points,
num_halo_bytes, cudaMemcpyDeviceToHost, stream0);
cudaMemcpyAsync(h_right_boundary, d_output + right_stage1_offset + num_halo_points,
num_halo_bytes, cudaMemcpyDeviceToHost, stream0);
cudaStreamSynchronize(stream0); // 只等 stream0:確保 boundary 已在 host
| 同步函式 | 等待範圍 | 用途 |
|---|---|---|
cudaStreamSynchronize(s) |
僅 stream s 內全部操作 |
等 boundary D2H 完成、再做 MPI |
cudaDeviceSynchronize() |
所有 device 活動(全部 kernel + copy) | 一次 iteration 結尾,確認 d_output 全部就緒 |
stream0 負責「短而急」的 boundary 運算 + 通訊鏈;stream1 負責「長」的 internal 運算。兩者在不同 stream → 硬體可同時推進,達成重疊。
Halo 交換與 MPI_Sendrecv (Halo Exchange)
MPI_Sendrecv = MPI_Send + MPI_Recv 合一,一次呼叫同時送出與收進,減少呼叫數、避免死結。模式:所有人都往左鄰居送 boundary,等同所有人都從右鄰居收 halo。
int left_neighbor = (pid > 0) ? (pid - 1) : MPI_PROC_NULL;
int right_neighbor = (pid < np - 2) ? (pid + 1) : MPI_PROC_NULL;
// ...
// 送 L boundary 給左鄰、從右鄰收 R halo
MPI_Sendrecv(h_left_boundary, num_halo_points, MPI_FLOAT, left_neighbor, i,
h_right_halo, num_halo_points, MPI_FLOAT, right_neighbor, i,
MPI_COMM_WORLD, &status);
// 送 R boundary 給右鄰、從左鄰收 L halo
MPI_Sendrecv(h_right_boundary, num_halo_points, MPI_FLOAT, right_neighbor, i,
h_left_halo, num_halo_points, MPI_FLOAT, left_neighbor, i,
MPI_COMM_WORLD, &status);
// 收到的 halo 搬回 device(stream0,與 stream1 的 internal kernel 並行)
cudaMemcpyAsync(d_output + left_halo_offset, h_left_halo, num_halo_bytes,
cudaMemcpyHostToDevice, stream0);
cudaMemcpyAsync(d_output + right_halo_offset, h_right_halo, num_halo_bytes,
cudaMemcpyHostToDevice, stream0);
MPI_PROC_NULL 消除分支
Edge process(process 0 無左鄰、process np-2 無右鄰)把對應 neighbor 設成 MPI_PROC_NULL,MPI runtime 會自動跳過該方向的送/收,不需 if-then-else 特判。
H2D 把 halo 寫進 d_output(本步的輸出緩衝),配合下方 double buffering,下一個 iteration swap 後它才變成 d_input。務必確保收到的資料用在下一步而非當前步。
MPI_Barrier 與 Double Buffering (Barrier & Double Buffering)
MPI_Barrier:跨 process 的 barrier,類比 CUDA 的 __syncthreads()(但作用於 process)。所有指定 process 互相等待,全部抵達才放行。
- iteration 開始前的 barrier:確保所有 compute node 都收到輸入再一起開始換資料,避免少數落後者拖累全體。
- 結尾的 barrier(
MPI_Barrier):等所有 process 算完、送出 output 給 data server。
Double buffering:每步結束後交換 d_input 與 d_output 指標,本步輸出 → 下步輸入,避免讀寫同一緩衝。
cudaDeviceSynchronize(); // 等所有 kernel + copy 完成,d_output 全就緒
float *temp = d_output; // double-buffer swap
d_output = d_input;
d_input = temp;
} // end for (nreps)
迴圈最後一次也做了 swap(為了預備下一步),但其實沒有下一步了。所以迴圈後需再 swap 一次把指標還原,d_output 才是真正的最終結果,再 cudaMemcpy 回 host 送給 data server。
時間軸(一個 iteration,重疊後):
stream0: │stg1 L│stg1 R│D2H│ │H2D L│H2D R│
└─sync─►│MPI_Sendrecv│─►
stream1: │────────── stage2 internal compute (藏住通訊) ──────────│
→ cudaDeviceSync → swap
考試/面試重點 (Exam / Test Patterns)
| 情境 / 關鍵字 | 答案 / 技巧 |
|---|---|
為何 cudaMemcpy 是同步且慢 |
pageable 記憶體須先複製到內部 pinned 暫存再 DMA,多一次複製且阻塞 host |
cudaMemcpyAsync 的前提 |
host buffer 必須是 pinned(cudaHostAlloc),否則無法非同步 |
| pinned / page-locked 解決什麼 | DMA 用實體位址;pinned 防止 page out,避免 DMA 資料損毀 |
| 為何分 stage 1 / stage 2 | 先算 boundary 才能立刻開始通訊,讓通訊與 internal 運算重疊 |
| 同 stream vs 不同 stream | 同 stream 循序;不同 stream 可並行(boundary→stream0,internal→stream1) |
cudaStreamSynchronize vs cudaDeviceSynchronize |
前者只等單一 stream;後者等所有 device 活動 |
| 通訊何時能完全被藏住 | T_halo_comm < T_internal_compute(內部 slice 夠多) |
MPI_Sendrecv 好處 |
一次同時送+收,少一次呼叫、避免死結 |
| edge process 如何免特判 | neighbor 設 MPI_PROC_NULL,runtime 自動跳過該方向 |
| boundary vs halo | boundary=我家邊緣(送出);halo=鄰居資料(收進);process i 的 boundary → i+1 的 halo |
| 為何需 double buffering | 收到的 halo 給下一步用;本步輸出 swap 成下步輸入,避免覆寫 |
| 迴圈後為何再 swap 一次 | 抵銷最後一次多餘的 swap,讓 d_output 指向真正最終結果 |
MPI_Barrier 用途 |
跨 process 同步,確保大家同時開始/結束資料交換 |
| bounce buffer 是什麼 | pinned host 暫存站,讓資料在 device ↔ 遠端 MPI process 間彈跳 |
Related Notes
- 20-Heterogeneous-Computing-Cluster/01-MPI-Cluster-Background-and-Basics
- 20-Heterogeneous-Computing-Cluster/02-MPI-Point-to-Point-Communication
- 20-Heterogeneous-Computing-Cluster/04-Collective-Communication-and-CUDA-Aware-MPI
- 08-Stencil/02-Shared-Memory-Tiling-for-Stencil
- 07-Convolution/03-Tiled-Convolution-and-Halo-Handling
- 22-Advanced-Practices-And-Future-Evolution/02-Kernel-Execution-Control