C++ 异构计算:CUDA、SYCL 与 OpenMP Offload

把计算搬上 GPU 是性能敏感场景的必然选择。本文从异构计算的执行空间模型讲起,给出 CUDA 的 kernel、共享内存归约与统一内存实践,再讲 SYCL 单源编程与 OpenMP target offload,最后总结占用率、bank 冲突与合并访存等性能要点。

CPU 的核心数增长早已放缓,而 GPU 的流处理器数量以每代数千计。把数据并行度高的计算卸载到加速器,成了科学计算、深度学习推理、图像处理等领域的标准做法。C++ 生态里通往 GPU 的路不止一条:CUDA 功能最全但绑定 NVIDIA,SYCL 用单源 C++ 追求可移植,OpenMP 则用指令把已有的循环改造成设备内核。理解三者的执行模型差异,比记住 API 更重要。

一、异构计算模型与执行空间

异构程序由两部分组成:主机代码(host)在 CPU 上顺序执行,负责分配内存、启动内核、同步结果;设备代码(kernel)在加速器上由成千上万个线程并行执行。典型流程是「分配设备内存 → 拷贝输入 → 异步启动 kernel → 等待完成 → 拷回输出」。

关键概念:

  • 执行空间:CUDA 用 __global__(设备端入口,主机可调用)、__device__(仅设备可调用)、__host__;SYCL 用 queue::parallel_for 提交内核;OpenMP 用 #pragma omp target 划定设备区域。
  • 线程层次:GPU 线程按「线程 → 线程块 → 网格」三层组织。同一线程块内的线程可以同步(__syncthreads)并共享高速共享内存;不同线程块之间无法同步,只能通过全局内存通信。
  • SIMT 执行:同一 warp(CUDA 中 32 个线程)执行同一条指令。若线程间发生分支发散,两条路径会被串行执行,这是最容易被忽视的性能杀手。

二、CUDA 编程要点

2.1 kernel 与线程层次

一个最小的 SAXPY(y = a * x + y)内核:

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

__global__ void saxpy(int n, float a, const float* x, float* y) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;   // 全局线程编号
    if (i < n) y[i] = a * x[i] + y[i];               // 边界检查必不可少
}

int main() {
    const int n = 1 << 20;
    const std::size_t bytes = n * sizeof(float);
    float* h_x = new float[n];
    float* h_y = new float[n];
    for (int i = 0; i < n; ++i) { h_x[i] = 1.0f; h_y[i] = 2.0f; }

    float *d_x = nullptr, *d_y = nullptr;
    cudaMalloc(&d_x, bytes);
    cudaMalloc(&d_y, bytes);
    cudaMemcpy(d_x, h_x, bytes, cudaMemcpyHostToDevice);
    cudaMemcpy(d_y, h_y, bytes, cudaMemcpyHostToDevice);

    const int threads = 256;
    const int blocks  = (n + threads - 1) / threads;
    saxpy<<<blocks, threads>>>(n, 2.0f, d_x, d_y);
    cudaDeviceSynchronize();                          // 等待内核完成
    cudaMemcpy(h_y, d_y, bytes, cudaMemcpyDeviceToHost);
    std::printf("y[0] = %f\n", h_y[0]);               // 4.0
    cudaFree(d_x); cudaFree(d_y);
    delete[] h_x; delete[] h_y;
}
nvcc -O3 -arch=sm_80 -o saxpy saxpy.cu && ./saxpy   # y[0] = 4.000000

<<<blocks, threads>>> 是 CUDA 的执行配置语法。线程块大小通常取 128、256、512,必须是 32 的倍数以填满 warp。-arch=sm_80 对应 A100,sm_89 对应 RTX 40 系列,sm_90 对应 H100。

2.2 内存管理与 cudaMalloc

设备内存与主机内存物理分离,所有数据都要显式搬运。CUDA 提供的主要分配接口:

API用途特点
cudaMalloc设备全局内存显式分配,需 cudaFree
cudaMallocHost页锁定主机内存带宽更高,不可交换
cudaMallocManaged统一内存自动迁移,驱动按需换页
cudaMallocAsync流序分配器与 stream 绑定,减少同步开销

页锁定(pinned)内存是隐藏传输延迟的前提:只有固定内存才能配合 cudaMemcpyAsync 实现真正的异步传输,普通可分页内存需要驱动先做一次内部拷贝。

2.3 统一内存

统一内存(Unified Memory)让主机与设备共享同一地址空间,驱动负责按需迁移页面,代码里不再需要显式 cudaMemcpy。

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

int main() {
    const int n = 1 << 20;
    float* data = nullptr;
    cudaMallocManaged(&data, n * sizeof(float));      // 一次分配,两端可访问
    for (int i = 0; i < n; ++i) data[i] = 1.0f;
    scale<<<(n + 255) / 256, 256>>>(data, n, 3.0f);
    cudaDeviceSynchronize();
    std::printf("data[0] = %f\n", data[0]);           // 3.0
    cudaFree(data);
}

统一内存的便利性有代价:首次访问会触发缺页异常与页面迁移,若访问模式随机,性能可能远差于显式拷贝。用 cudaMemPrefetchAsync(ptr, bytes, device, stream) 可以主动预取,把迁移开销摊到计算之前。

2.4 stream 与异步执行

内核启动是异步的,主机提交后立即返回。默认流(stream 0)是同步语义的,要用多流才能重叠传输与计算。

cudaStream_t s1, s2;
cudaStreamCreate(&s1);
cudaStreamCreate(&s2);
// 分块流水:拷贝块 0 → 计算块 0 的同时拷贝块 1
cudaMemcpyAsync(d_a, h_a, half, cudaMemcpyHostToDevice, s1);
kernel<<<grid, block, 0, s1>>>(d_a, half);
cudaMemcpyAsync(h_a, d_a, half, cudaMemcpyDeviceToHost, s1);
cudaMemcpyAsync(d_b, h_b, half, cudaMemcpyHostToDevice, s2);
kernel<<<grid, block, 0, s2>>>(d_b, half);
cudaMemcpyAsync(h_b, d_b, half, cudaMemcpyDeviceToHost, s2);
cudaStreamSynchronize(s1); cudaStreamSynchronize(s2);
cudaStreamDestroy(s1); cudaStreamDestroy(s2);

要点:cudaMemcpyAsync 只对页锁定内存有效;同一流内的操作严格有序,不同流之间可并发;用 cudaEventRecord / cudaStreamWaitEvent 表达跨流依赖。

2.5 __syncthreads 与共享内存

共享内存是芯片上的高速暂存,延迟比全局内存低一到两个数量级。同一线程块内的线程通过 __syncthreads() 屏障协调。

__global__ void reduce_sum(const float* in, float* out, int n) {
    extern __shared__ float sdata[];          // 动态共享内存
    int tid = threadIdx.x;
    int i   = blockIdx.x * blockDim.x + threadIdx.x;
    sdata[tid] = (i < n) ? in[i] : 0.0f;
    __syncthreads();                          // 保证所有线程都已写入
    for (int s = blockDim.x / 2; s > 0; s >>= 1) {
        if (tid < s) sdata[tid] += sdata[tid + s];
        __syncthreads();                      // 每轮归约后都要同步
    }
    if (tid == 0) out[blockIdx.x] = sdata[0];
}

__syncthreads() 必须被块内所有线程执行。若它出现在 if 分支内且分支条件依赖 threadIdx,会导致死锁或未定义行为——这是新手最常见的错误。

三、SYCL 单源编程

SYCL 是 Khronos 主导的开放标准,用标准 C++ 表达异构计算,同一份代码可编译到 NVIDIA、AMD、Intel GPU 以及 CPU、FPGA。主流实现有 Intel oneAPI DPC++、AdaptiveCpp(原 hipSYCL)、triSYCL。

3.1 queue、buffer 与 accessor

buffer/accessor 模型由运行时自动推导数据依赖与传输时机。

#include <sycl/sycl.hpp>
#include <vector>
#include <cstdio>

int main() {
    const int n = 1 << 20;
    std::vector<float> x(n, 1.0f), y(n, 2.0f);
    sycl::queue q{sycl::gpu_selector_v};
    {
        sycl::buffer<float, 1> bx(x.data(), n);
        sycl::buffer<float, 1> by(y.data(), n);
        q.submit([&](sycl::handler& h) {
            sycl::accessor ax(bx, h, sycl::read_only);
            sycl::accessor ay(by, h, sycl::read_write);
            h.parallel_for(sycl::range<1>(n), [=](sycl::id<1> i) {
                ay[i] = 2.0f * ax[i] + ay[i];
            });
        });
    }   // buffer 析构时自动把数据拷回 host
    std::printf("y[0] = %f\n", y[0]);   // 4.0
}
icpx -fsycl -O3 -o saxpy_sycl saxpy_sycl.cpp && ./saxpy_sycl

buffer 的析构会阻塞直到数据同步回主机,因此必须用作用域把 buffer 包起来,否则 y[0] 读到的还是旧值。

3.2 USM 统一共享内存

USM(Unified Shared Memory)是更接近 CUDA 的指针式模型,分三种:device、host、shared 分配。

float* dx = sycl::malloc_device<float>(n, q);
float* dy = sycl::malloc_device<float>(n, q);
q.memcpy(dx, x.data(), n * sizeof(float));
q.memcpy(dy, y.data(), n * sizeof(float));
q.parallel_for(sycl::range<1>(n), [=](sycl::id<1> i) {
    dy[i] = 2.0f * dx[i] + dy[i];
}).wait();
q.memcpy(y.data(), dy, n * sizeof(float)).wait();
sycl::free(dx, q);
sycl::free(dy, q);

USM 省去了 accessor 的样板代码,但把同步责任交还给程序员——忘记 .wait() 就会读到未完成的数据。sycl::malloc_shared 分配的指针在主机与设备上都可访问,适合迁移成本低的小数据。

四、OpenMP target offload

如果代码里已经有一批规整的 for 循环,OpenMP 5.x 的 offload 指令是改造成本最低的路径。

#include <cstdio>
#include <vector>

int main() {
    const int n = 1 << 20;
    std::vector<float> x(n, 1.0f), y(n, 2.0f);
    const float a = 2.0f;
    #pragma omp target teams distribute parallel for \
        map(to: x[0:n]) map(tofrom: y[0:n])
    for (int i = 0; i < n; ++i) y[i] = a * x[i] + y[i];
    std::printf("y[0] = %f\n", y[0]);   // 4.0
}
# NVIDIA HPC SDK
nvc++ -mp=gpu -gpu=cc80 -O3 -o saxpy_omp saxpy_omp.cpp
# Clang + NVPTX
clang++ -fopenmp -fopenmp-targets=nvptx64-nvidia-cuda \
        --offload-arch=sm_80 -O3 -o saxpy_omp saxpy_omp.cpp

指令分解:target 划定设备区域;teams 对应线程块网格;distribute 把迭代分配给各 team;parallel for 在 team 内并行。map(to:) 表示只读输入,map(tofrom:) 表示读写。用 #pragma omp target data map(...) 可以把映射范围提到循环之外,避免每次进入设备区都重复传输。

#pragma omp target data map(to: x[0:n]) map(tofrom: y[0:n])
{
    #pragma omp target teams distribute parallel for
    for (int i = 0; i < n; ++i) y[i] = a * x[i] + y[i];
    #pragma omp target teams distribute parallel for
    for (int i = 0; i < n; ++i) y[i] *= y[i];
}   // 退出 data 区时统一拷回

OpenMP 的优势是增量改造:同一份源码在 -fopenmp 不带 target 时仍能在 CPU 上正确运行,只是不加速。

五、性能要点

5.1 合并访存

GPU 以 32 个线程为一组访问全局内存,若这 32 次访问落在连续的 128 字节区间内,硬件合并为一次事务;否则要拆成多次,带宽成倍浪费。

y[i] = a * x[i];                 // 好:相邻线程访问相邻地址,一次 128B 事务
y[i * stride] = a * x[i * stride];   // 差:步长等于线程数,命中不同缓存行

矩阵转置是经典案例:直接转置的读或写必有一侧是跨步访问。解决办法是用共享内存做中转,让全局内存的两侧都保持合并。

5.2 共享内存的 bank conflict

共享内存被划分为 32 个 bank,每个 bank 宽 4 字节。若同一 warp 内多个线程访问同一个 bank 的不同地址,访问会被串行化。

__shared__ float tile[32][32];    // 列访问 tile[i][0..31] 全部落在同一 bank
__shared__ float tile[32][33];    // 每行多一个元素,列访问错开到不同 bank

把列数从 32 改成 33(padding)是最经典的冲突消除手法,代价是浪费少量共享内存。

5.3 Occupancy

Occupancy 指每个 SM 上实际驻留的 warp 数与硬件上限的比值。它受限于寄存器、共享内存、线程块数量三者中的瓶颈。

nvcc -O3 -arch=sm_80 --ptxas-options=-v -c kernel.cu
# ptxas info : Used 32 registers, 4096 bytes smem, 0 bytes cmem[0]
nvcc -maxrregcount=32 ...    # 限制每线程寄存器数以提升 occupancy

运行时可用 cudaOccupancyMaxActiveBlocksPerMultiprocessor(&n, saxpy, 256, 0) 查询理论最大驻留块数。但高 occupancy 不是目标本身:如果内核是访存受限的,提升 occupancy 往往能掩盖延迟;如果内核已经吃满计算单元,强行压低寄存器反而会增加溢出到本地内存的代价。

5.4 传输隐藏

PCIe 带宽(约 16 到 32 GB/s)远低于设备内存带宽(A100 约 2 TB/s),host 与 device 之间的传输常常是真正的瓶颈。策略有三条:减少传输量(在设备上完成全部中间计算,用 float 而非 double);重叠传输与计算(用多个 stream 做分块流水,让第 k 块的传输与第 k-1 块的计算并行);合并小传输(一次拷 1 MB 远比一千次拷 1 KB 高效)。

nsys profile -o report ./saxpy      # Nsight Systems:看传输与计算是否重叠
ncu --set full -k saxpy ./saxpy     # Nsight Compute:分析单内核瓶颈类型

六、三种方案选型对比

维度CUDASYCLOpenMP Offload
厂商支持仅 NVIDIAIntel/AMD/NVIDIA/CPU/FPGA多厂商,实现成熟度不一
语言侵入性高,需 .cu 与专用编译器中,标准 C++ 单源低,指令式增量改造
生态与库最丰富(cuBLAS、cuDNN、Thrust)增长中(oneMKL、oneDNN)依赖各编译器自带库
可移植性锁定 NVIDIA跨厂商,需重新调优跨厂商,性能差异大
适合场景极致性能、深度学习多后端产品、信创环境已有 OpenMP 代码的加速

实践建议:性能优先且接受厂商绑定,选 CUDA;需要跨厂商或跨 CPU/GPU 统一代码,选 SYCL;已有大规模 OpenMP 代码、只想小步快跑,选 OpenMP offload。三者并非互斥,同一项目常以 CUDA 写热点内核,其余部分保持可移植。

七、CMake 集成

cmake_minimum_required(VERSION 3.24)
project(hetero_demo LANGUAGES CXX)

# ---- CUDA ----
enable_language(CUDA)
find_package(CUDAToolkit REQUIRED)
add_executable(saxpy_cuda saxpy.cu)
set_target_properties(saxpy_cuda PROPERTIES
    CUDA_STANDARD 17 CUDA_ARCHITECTURES "80;90")   # 也可写 native
target_link_libraries(saxpy_cuda PRIVATE CUDA::cudart)

# ---- SYCL(Intel oneAPI DPC++)----
find_package(IntelSYCL REQUIRED)
add_executable(saxpy_sycl saxpy_sycl.cpp)
target_link_libraries(saxpy_sycl PRIVATE IntelSYCL::SYCL)

# ---- OpenMP offload ----
find_package(OpenMP REQUIRED)
add_executable(saxpy_omp saxpy_omp.cpp)
target_link_libraries(saxpy_omp PRIVATE OpenMP::OpenMP_CXX)
cmake -S . -B build -G Ninja -DCMAKE_CUDA_ARCHITECTURES=80
cmake --build build -j"$(nproc)"
ninja -C build -t commands saxpy_cuda | head -2   # 查看实际编译命令

对 SYCL,IntelSYCL 包随 oneAPI 提供;用 AdaptiveCpp 时改用 find_package(AdaptiveCpp) 并链接 AdaptiveCpp::acpp-rt。OpenMP offload 的架构选择由编译器标志控制(-gpu=cc80 或 --offload-arch=sm_80),CMake 只负责链接 OpenMP::OpenMP_CXX。

相关阅读

  • https://plumephp.com/cpp-simd-vectorization-practice/ — CPU 侧的 SIMD 向量化与数据布局
  • https://plumephp.com/cpp-threading-basics/ — 主机端多线程与任务并行的基础
  • https://plumephp.com/cpp-performance-optimization/ — 缓存友好、分支预测等通用性能手法

延伸阅读

  • https://plumephp.com/cpp-memory-model/ — 内存层次结构与数据局部性
  • https://plumephp.com/cpp-cmake-project/ — target 模型与多语言工程组织
  • https://plumephp.com/cpp-coroutines-generators/ — 异步任务的协程化表达方式

文末完整示例

// 完整可编译示例(CUDA):分块归约 + 多流流水 + 统一内存
// 编译:nvcc -O3 -arch=sm_80 -o reduce_demo reduce_demo.cu
// 运行:./reduce_demo

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

// 错误检查宏:生产代码中每个 CUDA 运行时调用都该检查返回值
#define CUDA_CHECK(call)                                                     \
    do {                                                                     \
        cudaError_t e_ = (call);                                             \
        if (e_ != cudaSuccess) {                                             \
            std::fprintf(stderr, "%s:%d CUDA error: %s\n", __FILE__,         \
                         __LINE__, cudaGetErrorString(e_));                  \
            std::exit(EXIT_FAILURE);                                         \
        }                                                                    \
    } while (0)

// ====== 1. 块内共享内存归约 ======
__global__ void reduce_sum(const float* in, float* out, int n) {
    extern __shared__ float sdata[];
    int tid = threadIdx.x;
    int i   = blockIdx.x * blockDim.x + threadIdx.x;
    sdata[tid] = (i < n) ? in[i] : 0.0f;
    __syncthreads();
    for (int s = blockDim.x / 2; s > 0; s >>= 1) {
        if (tid < s) sdata[tid] += sdata[tid + s];
        __syncthreads();
    }
    if (tid == 0) out[blockIdx.x] = sdata[0];
}

// ====== 2. 统一内存 + 流式预取 ======
int main() {
    const int n = 1 << 22;                       // 4M 个元素
    const std::size_t bytes = n * sizeof(float);
    float* h_data = nullptr;
    CUDA_CHECK(cudaMallocManaged(&h_data, bytes));
    for (int i = 0; i < n; ++i) h_data[i] = 1.0f;
    const int threads = 256;
    const int blocks  = (n + threads - 1) / threads;
    const std::size_t smem = threads * sizeof(float);
    float* d_partial = nullptr;
    CUDA_CHECK(cudaMalloc(&d_partial, blocks * sizeof(float)));
    cudaStream_t s;
    CUDA_CHECK(cudaStreamCreate(&s));
    // 预取到设备,避免首次访问时的缺页迁移
    CUDA_CHECK(cudaMemPrefetchAsync(h_data, bytes, 0, s));
    reduce_sum<<<blocks, threads, smem, s>>>(h_data, d_partial, n);

    // 第二级归约在主机侧完成(块数不多,代价可忽略)
    float* h_partial = new float[blocks];
    CUDA_CHECK(cudaMemcpyAsync(h_partial, d_partial, blocks * sizeof(float),
                               cudaMemcpyDeviceToHost, s));
    CUDA_CHECK(cudaStreamSynchronize(s));
    double total = 0.0;
    for (int i = 0; i < blocks; ++i) total += h_partial[i];
    std::printf("sum = %.0f (expected %d)\n", total, n);
    delete[] h_partial;
    CUDA_CHECK(cudaFree(d_partial));
    CUDA_CHECK(cudaFree(h_data));
    CUDA_CHECK(cudaStreamDestroy(s));
    return 0;
}

同一逻辑用 SYCL 的内建归约只需几行,通常比手写共享内存归约更快,因为实现可以选择树形或原子累加策略:

// 编译:icpx -fsycl -O3 -o reduce_sycl reduce_sycl.cpp
sycl::queue q{sycl::gpu_selector_v};
float* data = sycl::malloc_shared<float>(n, q);
float sum = 0.0f;
q.parallel_for(sycl::range<1>(n), sycl::reduction(&sum, sycl::plus<float>()),
               [=](sycl::id<1> i, auto& acc) { acc += data[i]; }).wait();
sycl::free(data, q);

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cpp」更多文章

  1. C++ Unicode 与文本处理:编码转换与高性能字符串
  2. C++ 数值计算与线性代数:Eigen 与表达式模板
  3. C++ 静态分析与代码质量工具链:clang-tidy 与 Clang Static Analyzer