非同步執行:Streams 與 Events (Async Streams and Events)

重點總覽

項目 重點
非同步並行執行 dispatch 函式或 kernel launch 立即返回;可讓 host 運算、device 運算、各種 memory transfer 互相重疊 (overlap)
三種同步方式 blocking(等待完成)、non-blocking/polling(查詢狀態)、callback(完成時觸發 host 函式)
CUDA Stream 一條 work-queue,stream 內依 enqueue 順序循序執行;多個 stream 間可由 runtime 並行排程
default stream 未指定 stream 的操作進入 default stream(隱式);有特殊 blocking 語意
建立/銷毀 stream cudaStreamCreate / cudaStreamDestroy;destroy 前會先跑完 stream 內所有工作
在 stream 啟動 kernel triple-chevron 第 4 個參數指定 stream:<<<grid, block, shared, stream>>>
在 stream 啟動傳輸 cudaMemcpyAsync(),立即返回;host buffer 須 pinned/page-locked 才能真正非同步
stream 同步 cudaStreamSynchronize(blocking)/ cudaStreamQuery(non-blocking,回傳 cudaSuccess/cudaErrorNotReady
CUDA Event 插入 stream 的標記(tracer),可追蹤進度、表達細粒度相依、計時
建立/插入 event cudaEventCreate / cudaEventDestroycudaEventRecord(event, stream) 插入 stream
計時 cudaEventElapsedTime(&ms, start, stop),用兩個 event 夾住操作量測毫秒數
查詢 event 狀態 cudaEventSynchronize(blocking)/ cudaEventQuery(non-blocking);可在第二個 kernel 完成前提早觸發相依工作

什麼是非同步並行執行 (Asynchronous Concurrent Execution)

CUDA 允許多個任務並行 (concurrent)、也就是重疊 (overlapping) 執行,具體可重疊的工作包含:

並行性是透過非同步介面 (asynchronous interface) 來表達:dispatch 函式呼叫或 kernel launch 會立即返回。非同步呼叫通常在被派發的操作「完成前」就返回,甚至可能在操作「開始前」就返回。返回後 application 可同時去做其他工作;當需要最終結果時,才必須做某種形式的同步 (synchronization) 確認該操作已完成。

Important

最典型的並行範式就是讓 host/device memory transfer 與 computation 重疊,藉此降低或消除傳輸的 overhead

三種同步方式

方式 行為 代表 API
blocking(阻塞) 呼叫後阻塞,直到操作完成 cudaDeviceSynchronizecudaStreamSynchronizecudaEventSynchronize
non-blocking / polling(輪詢) 立即返回並回報操作狀態 cudaStreamQuerycudaEventQuery
callback(回呼) 操作完成時自動執行事先註冊的 host 函式 host-side callback(見下篇)
Warning

介面雖然是非同步的,但實際上能否真正並行執行,取決於 CUDA 版本與硬體的 compute capability(細節見 Compute Capabilities 章節)。

非同步重疊(理想時間軸)
Host  : |== CPU work ====================================|
H2D   :   |== copy in ==|
Kernel:                 |==== compute ====|
D2H   :                                   |== copy out ==|
                        ^ 三類工作交錯重疊,總時間 < 各段相加

CUDA Streams

CUDA stream 是一種抽象,讓程式設計者表達一連串操作的序列。它運作得像一條 work-queue:程式把 memory copy、kernel launch 等操作加入佇列,依序執行。

Tip

未指定 stream 的操作會隱式進入 default stream。沒寫 stream 的程式範例其實都在用 default stream,而它有特殊的 blocking/non-blocking 語意(見下一篇 callbacks 與 stream ordering)。

單一 stream:佇列,先進先出、循序
  stream ─► [ copyH2D ] ─► [ kernel ] ─► [ copyD2H ]  (依序執行)

多個 stream:runtime 視資源並行排程
  stream1 ─► [ kernelA ] ─► [ copyA ]
  stream2 ─► [ kernelB ] ─► [ copyB ]
            └─ A 與 B 可在硬體允許下重疊 ─┘

建立與銷毀 Stream

cudaStreamCreate() 建立,初始化一個之後用來識別 stream 的 handle;用 cudaStreamDestroy() 銷毀。

cudaStream_t stream;            // Stream handle
cudaStreamCreate(&stream);      // Create a new stream

// stream based operations ...

cudaStreamDestroy(stream);      // Destroy the stream

在 Stream 啟動 Kernel

照常用 triple-chevron 語法,把 stream 當作第四個額外參數傳入即可。

kernel<<<grid, block, shared_mem_size, stream>>>(...);

在 Stream 啟動 Memory Transfer

cudaMemcpyAsync() 把傳輸排入 stream。它與 cudaMemcpy() 類似,但多一個 stream 參數

// Copy `size` bytes from `src` to `dst` in stream `stream`
cudaMemcpyAsync(dst, src, size, cudaMemcpyHostToDevice, stream);
Warning

涉及 CPU 記憶體的拷貝要真正非同步,host buffer 必須是 pinned 且 page-locked 的。cudaMemcpyAsync() 若用到非 pinned 記憶體仍可正確運作,但會退化成同步行為 (synchronous),無法與其他工作重疊,喪失非同步的效能優勢。建議用 cudaMallocHost() 配置與 GPU 收發資料的 buffer。

Stream 同步

最簡單的同步就是等待 stream 清空,有 blocking 與 non-blocking 兩種:

// blocking:阻塞直到 stream 內所有工作完成
cudaStreamSynchronize(stream);
// 此後可安全存取 stream 操作的結果
// non-blocking:只「偷看」stream 是否清空
// 清空回傳 cudaSuccess;尚未清空回傳 cudaErrorNotReady
cudaError_t status = cudaStreamQuery(stream);
switch (status) {
  case cudaSuccess:
    std::cout << "The stream is empty" << std::endl;
    break;
  case cudaErrorNotReady:
    std::cout << "The stream is not empty" << std::endl;
    break;
  default:
    // An error occurred - we should handle this
    break;
}
函式 類型 行為 / 回傳
cudaStreamSynchronize(stream) blocking 阻塞到 stream 內工作全部完成
cudaStreamQuery(stream) non-blocking 立即返回;空 → cudaSuccess,未空 → cudaErrorNotReady

CUDA Events

CUDA event 是插入 stream 的標記 (marker),像追蹤粒子 (tracer particle) 一樣追蹤 stream 中任務的進度。

event 表達細粒度相依:
  stream ─► [ kernel1 ] ─► (event) ─► [ kernel2 ]
                            │
                            └─ event 到前端 ⇒ kernel1 已完成
                               即可啟動相依工作(kernel2 仍可能在跑)

建立與銷毀 Event

cudaEvent_t event;
cudaEventCreate(&event);   // Create the event
// ... do some work involving the event ...
cudaEventDestroy(event);   // 不再需要時銷毀

把 Event 插入 Stream

cudaEventRecord() 把 event 排入 stream。

cudaEvent_t  event;
cudaStream_t stream;
cudaEventCreate(&event);
cudaEventRecord(event, stream);   // Insert the event into the stream

計時 (Timing)

event 到達 stream 前端時會記錄一個 timestamp。用兩個 event 夾住一段操作(如 kernel),即可精準量測其執行時間。

cudaEvent_t start, stop;
cudaEventCreate(&start);
cudaEventCreate(&stop);

cudaEventRecord(start, stream);            // 記錄起點
kernel<<<grid, block, 0, stream>>>(...);   // 啟動 kernel
cudaEventRecord(stop, stream);             // 記錄終點

cudaStreamSynchronize(stream);             // 等 stream 完成,兩 event 皆已觸發

float elapsedTime;
cudaEventElapsedTime(&elapsedTime, start, stop);   // 取得毫秒數
std::cout << "Kernel execution time: " << elapsedTime << " ms" << std::endl;

查詢 Event 狀態

如同查 stream,event 也能用 blocking 或 non-blocking 方式查詢。

blocking — cudaEventSynchronize():阻塞到該 event 完成。可在第一個 kernel 後、第二個 kernel 前記錄 event,等該 event 即可在 kernel2 完成前就啟動相依工作。

cudaStreamCreate(&stream);
cudaEventCreate(&event);

kernel<<<grid, block, 0, stream>>>(...);
cudaEventRecord(event, stream);            // kernel1 後記錄 event
kernel2<<<grid, block, 0, stream>>>(...);

cudaEventSynchronize(event);               // 等到 event ⇒ kernel1 必已完成
dependentCPUtask();                        // 可能早於 kernel2 結束就開始

cudaStreamSynchronize(stream);             // 等整個 stream ⇒ kernel2 也完成

non-blocking — cudaEventQuery():立即返回,cudaSuccess 代表 event(及其之前的工作)已完成。下例在 CPU 工作迴圈中偶爾「偷看」kernel1 是否完成,一旦完成就在另一個 stream 啟動 D2H 拷貝,達成 CPU 工作、GPU kernel、D2H 拷貝三者重疊。

cudaMallocHost(&h_data, size);             // pinned host 記憶體,才能真正非同步
cudaStreamCreate(&stream1);                // 處理用 stream
cudaStreamCreate(&stream2);                // 拷貝用 stream
cudaEventCreate(&event);

kernel1<<<grid, block, 0, stream1>>>(d_data, size);
cudaEventRecord(event, stream1);           // kernel1 後插入 event
kernel2<<<grid, block, 0, stream1>>>();

while (not allCPUWorkDone() || not copyStarted) {
  doNextChunkOfCPUWork();
  if (not copyStarted) {
    if (cudaEventQuery(event) == cudaSuccess) {   // kernel1 完成?
      cudaMemcpyAsync(h_data, d_data, size,
                      cudaMemcpyDeviceToHost, stream2);
      copyStarted = true;
    }
  }
}
cudaStreamSynchronize(stream1);
cudaStreamSynchronize(stream2);
函式 對象 類型 回傳 / 行為
cudaEventSynchronize(event) event blocking 阻塞到 event 完成
cudaEventQuery(event) event non-blocking 完成 → cudaSuccess;未完成 → cudaErrorNotReady
cudaStreamSynchronize(stream) stream blocking 阻塞到 stream 清空
cudaStreamQuery(stream) stream non-blocking 清空 → cudaSuccess;未清空 → cudaErrorNotReady
Tip

區分清楚:cudaEventQuery 查的是「某個 event 之前的工作是否完成」(細粒度),cudaStreamQuery 查的是「整個 stream 是否清空」。event 讓你在 stream 還沒跑完時就提早啟動相依工作。

考試/測驗重點

情境/關鍵字 答案
kernel launch 同步與否 非同步,立即返回;需 cudaDeviceSynchronize/cudaStreamSynchronize 才確保完成
三種同步方式 blocking、non-blocking/polling、callback
stream 內執行順序 enqueue 順序循序執行(FIFO work-queue)
多 stream 之間順序 runtime 視 GPU 資源排程並行;priority 只是 hint,不保證順序
未指定 stream 進入 default stream(隱式),有特殊 blocking 語意
triple-chevron 第四參數 stream<<<grid, block, shared, stream>>>(共享記憶體不用時填 0)
非同步傳輸 API cudaMemcpyAsync()(立即返回);cudaMemcpy() 會阻塞
async copy 退化陷阱 host buffer 非 pinned/page-locked ⇒ cudaMemcpyAsync 退化成同步、無法重疊
配置 pinned host 記憶體 cudaMallocHost()(搭配 free()/cudaFreeHost() 釋放)
stream blocking vs non-blocking cudaStreamSynchronize 阻塞 / cudaStreamQuery 輪詢
cudaStreamQuery 回傳值 空 → cudaSuccess;未空 → cudaErrorNotReady
event 的用途 stream 標記/tracer:追蹤進度、表達細粒度相依、計時
把 event 排入 stream cudaEventRecord(event, stream)
計時 API 與單位 cudaEventElapsedTime(&ms, start, stop),單位毫秒 ms
event blocking vs non-blocking cudaEventSynchronize 阻塞 / cudaEventQuery 輪詢
銷毀 stream 時仍有工作 跑完所有工作再銷毀(cudaStreamDestroy
event 由誰負責銷毀 application 自己負責 cudaEventDestroy
event 與 CUDA Graphs 關係 event 建立的相依圖直接對應後續 CUDA Graphs