計算與通訊重疊 (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_inputd_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 │  └─────────────┘
                                     └──────────────┘
時間 ───────────────────────────────────────────────────────────────►
重疊後的理想時間

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 交換 ... */
}
為什麼 stage 1 要先算

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   ← 一步、可非同步
// 為 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:

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 全部就緒
為何 boundary 用 stream0、internal 用 stream1

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 特判

收到的 halo 是給「下一步」用的

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 互相等待,全部抵達才放行。

Double buffering:每步結束後交換 d_inputd_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(為了預備下一步),但其實沒有下一步了。所以迴圈後需再 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 間彈跳