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:分析单内核瓶颈类型
六、三种方案选型对比
| 维度 | CUDA | SYCL | OpenMP Offload |
|---|---|---|---|
| 厂商支持 | 仅 NVIDIA | Intel/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);
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。