DGX Spark 的 CUDA 多串流相依:事件排序與緩衝區驗收

DGX Spark 的 CUDA 多串流相依:事件排序與緩衝區驗收

DGX Spark 的 CUDA 多串流相依:事件排序與緩衝區驗收

兩個 CUDA 串流共用同一個裝置緩衝區時,程式可能連續執行多次都正確,換了輸入規模卻讀到舊值。常見原因不在運算公式,而是生產串流還沒寫完,消費串流就已開始讀取。本文在 DGX Spark 上建立雙串流最小實驗,分別驗收提交順序、執行相依與緩衝區的重複使用時機,不以單次執行成功作為結論。

一、先畫出資料相依,再建立串流

DGX Spark 採用 NVIDIA GB10 Grace Blackwell,提供 128GB LPDDR5x 統一記憶體,CPU 與 GPU 共用同一個記憶體池。但實體上的統一記憶體,不會自動替應用程式建立跨串流的執行順序。明確指定的複製、核心函式啟動與主機存取,仍須遵守各自的完成條件。

實驗明確配置裝置記憶體,並使用鎖頁主機記憶體,以便觀察各階段的邊界。生產串流執行「將輸入複製到裝置、每個元素乘以二」,消費串流執行「每個元素加一、將結果複製回主機」。完整相依關係如下:

輸入就緒 → 複製完成 → 乘二完成 → 開始加一 → 回傳完成 → 主機檢查。

同一串流內的操作依串流順序執行;兩個串流之間則必須明確建立相依關係。事件應記錄在生產串流的乘二核心函式之後,消費串流則應先等待事件,再提交加一核心函式。

主機先呼叫生產串流、再呼叫消費串流,只代表提交順序。跨串流的資料相依必須有明確的同步依據,不能仰賴負載較輕時碰巧成立的執行順序。

二、建立不依賴預設串流的最小程式

在 TcpNode 付款後,於控制台查看連線憑證,再透過 SSH 登入 NVIDIA DGX OS。先執行 uname -m、nvidia-smi 和 nvcc --version,分別儲存主機架構、裝置與驅動程式資訊,以及編譯工具鏈資訊。架構應為 ARM64 對應的 aarch64;不要從驅動程式的輸出推斷編譯器版本。

將以下內容儲存為 stream_contract.cu。兩個串流都使用非阻塞旗標,避免傳統預設串流的隱含同步掩蓋缺少的相依關係。事件只用於排序,不量測耗時。

#include <cuda_runtime.h>
#include <cstdio>
#include <cstdlib>

static void check(cudaError_t result) {
    if (result != cudaSuccess) {
        std::fprintf(stderr, "%s\n", cudaGetErrorString(result));
        std::exit(1);
    }
}

__global__ void scale(float* data, int count) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i < count) data[i] *= 2.0f;
}

__global__ void add(float* data, int count) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i < count) data[i] += 1.0f;
}

int main() {
    constexpr int count = 4096;
    const size_t bytes = count * sizeof(float);
    float *input = nullptr, *output = nullptr, *device = nullptr;
    cudaStream_t producer, consumer;
    cudaEvent_t ready;

    check(cudaMallocHost(reinterpret_cast<void**>(&input), bytes));
    check(cudaMallocHost(reinterpret_cast<void**>(&output), bytes));
    check(cudaMalloc(reinterpret_cast<void**>(&device), bytes));
    check(cudaStreamCreateWithFlags(&producer, cudaStreamNonBlocking));
    check(cudaStreamCreateWithFlags(&consumer, cudaStreamNonBlocking));
    check(cudaEventCreateWithFlags(&ready, cudaEventDisableTiming));

    for (int i = 0; i < count; ++i) {
        input[i] = static_cast<float>(i);
        output[i] = -1.0f;
    }

    check(cudaMemcpyAsync(device, input, bytes,
                          cudaMemcpyHostToDevice, producer));
    scale<<<(count + 255) / 256, 256, 0, producer>>>(device, count);
    check(cudaGetLastError());
    check(cudaEventRecord(ready, producer));

    check(cudaStreamWaitEvent(consumer, ready, 0));
    add<<<(count + 255) / 256, 256, 0, consumer>>>(device, count);
    check(cudaGetLastError());
    check(cudaMemcpyAsync(output, device, bytes,
                          cudaMemcpyDeviceToHost, consumer));
    check(cudaStreamSynchronize(consumer));

    int failures = 0;
    for (int i = 0; i < count; ++i) {
        float expected = 2.0f * static_cast<float>(i) + 1.0f;
        if (output[i] != expected) ++failures;
    }
    std::printf("elements=%d mismatches=%d\n", count, failures);

    check(cudaEventDestroy(ready));
    check(cudaStreamDestroy(consumer));
    check(cudaStreamDestroy(producer));
    check(cudaFree(device));
    check(cudaFreeHost(output));
    check(cudaFreeHost(input));
    return failures == 0 ? 0 : 2;
}

三、同時以輸出與結束碼驗收

在同一目錄編譯並執行:

nvcc -O2 stream_contract.cu -o stream_contract
./stream_contract
printf 'exit=%s\n' "$?"

合格結果為 elements=4096 mismatches=0,結束碼為零。介面呼叫或同步失敗時回傳一,數值不符時回傳二。若只檢查程序是否當掉,就會漏掉已完成但內容錯誤的計算。

範例中的數值都在單精度浮點數可精確表示的整數範圍內,因此直接比較是否相等。實際的浮點運算模型不能照搬這項判準,應依演算法訂定絕對誤差、相對誤差與非有限值的處理規則。

接著分別將元素數改為 1、257、4097,重新編譯執行。這些規模涵蓋單一元素、執行緒區塊尾端及多個區塊的邊界;它們是測試輸入,不是效能數據。每次都應儲存原始碼檔案、編譯命令、工具鏈資訊與執行輸出,避免只留下一張成功截圖。

確認失敗路徑確實生效

暫時將預期值算式末尾的 1.0f 改成 2.0f,程式應回報不符並回傳二,接著再還原原式。這能驗證結果檢查功能確實有效。不要把「刪除等待後必須報錯」當成反向測試:競態是否發生取決於排程,缺少相依關係的程式也可能暫時通過。

四、將事件邊界落實到記憶體生命週期

cudaMemcpyAsync 返回時,通常只代表呼叫已提交,不能據此改寫輸入。範例中的輸入從提交複製起,到消費串流同步完成為止,一直保持不變;輸出也只在回傳作業所在的串流同步後才讀取。這項策略較保守,但邊界清楚,適合作為初始的正確性基準。

鎖頁記憶體有助於滿足非同步傳輸的條件,但不是完成通知。裝置緩衝區同樣不能在其他串流仍在存取時提早釋放。128GB 統一記憶體也不代表應將大量主機記憶體全部鎖頁,批次容量仍應以實際工作集為限。

生產事件只涵蓋它之前的操作,並未涵蓋消費串流的加一與回傳。因此,生產事件完成後,可以確認生產階段已結束,卻不能確認裝置緩衝區已可讓下一批資料覆寫。擴充為管線時,應在消費串流的回傳操作之後,再記錄完成事件,以此控制整組緩衝區的回收。

每個並行批次槽位都應有獨立的輸入、輸出、裝置緩衝區與完成狀態。先以單一槽位驗收,再增加槽位;不要只增加串流數量,卻繼續在沒有保護的情況下共用同一個指標。

五、排查時逐步縮小同步範圍

結果偶發錯誤時,先核對等待是否位於消費核心函式之前、事件是否記錄在生產核心函式之後,以及記錄與等待使用的是否為同一個事件。事件的記錄位置只要提早一行,就可能把「資料已寫完」錯誤地縮減為「複製已完成」。

接著檢查每次核心函式啟動後的錯誤,以及最終同步的回傳值。前者能發現部分啟動問題,後者用於接收非同步執行期間的錯誤;兩者不能互相取代。若只檢查啟動回傳而不等待完成,錯誤可能到後續的介面呼叫才出現。

在供診斷使用的程式副本中,可暫時於生產階段加入串流同步,判斷問題是否與跨串流順序有關。若錯誤消失,應繼續檢查相依關係與緩衝區所有權,而不是直接以全裝置同步收尾。全域同步會改變排程,並可能掩蓋原有的生命週期問題。

最終保留的應是明確的生產事件、消費等待與回收事件,以及可重複執行的輸出檢查。唯有相依關係與記憶體歸屬都能解釋清楚,多串流程式才具備繼續擴充並行度的基礎。

常見問題

依序提交兩個 CUDA 串流,是否就能保證執行順序?

不能。主機的提交順序不等於跨串流相依。應在生產串流的相關操作之後記錄事件,再由消費串流以 cudaStreamWaitEvent 等待該事件。

cudaMemcpyAsync 返回後可以立即重用主機緩衝區嗎?

不能只依據函式返回判斷。傳輸可能尚未完成;須等待涵蓋該次複製的事件,或同步對應串流,才能讀取輸出、改寫輸入或釋放頁鎖定記憶體。

EPYC 7543P 專屬實體主機,每月 $472.5 起

設定 TcpNode AI開發主機 查看 TcpNode 月費

實體主機,依方案清單選配

AMD EPYC 7543P 整機專屬使用。設定前可先確認月費、選配項目與各計費週期的應付金額。

設定 TcpNode AI開發主機