python
#include <iostream>
#include <cuda_runtime.h>
//__global__ CUDA 的关键词,表示这个函数在GPU上运行
//这个函数是在 GPU 上执行的 Kernel,但从 CPU 端发起调用。
__global__ void vector_add(
const float* a,
const float* b,
float* c,
int n
){
int i = blockIdx.x * blockDim.x + threadIdx.x;
//防止越界
//cuda使用层级
// Grid
// │
// ├── Block 0
// │ ├── Thread 0
// │ ├── Thread 1
// │ ├── Thread 2
// │ └── Thread 3
// │
// blockDim.x代表:一个 Block 里面有多少个线程
if(i<n){
c[i] = a[i]+b[i];
}
}
int main(){
const int N = 10;
const int bytes = N * sizeof(float);
//Host指:CPU
//Device指:GPU
//最基础的 CUDA 程序通常经历:
//CPU 数据
// ↓
//复制
// ↓
//GPU 显存
// ↓
//GPU 计算
// ↓
//复制
// ↓
//CPU 数据
//cpu内存
float h_a[N];
float h_b[N];
float h_c[N];
for (int i=0;i<N;i++){
h_a[i]=i;
h_b[i]=2*i;
}
float* d_a;
float* d_b;
float* d_c;
cudaMalloc(&d_a, bytes);
cudaMalloc(&d_b, bytes);
cudaMalloc(&d_c, bytes);
//cudaMalloc是在:GPU VRAM 分配内存。
//cpu -> gpu
cudaMemcpy(d_a,h_a,bytes,cudaMemcpyHostToDevice);
//h_a (copy)-> d_a
cudaMemcpy(d_b,h_b,bytes,cudaMemcpyHostToDevice);
//启动gpu kernel
const int threads_per_block=4;
const int blocks = (N + threads_per_block -1) / threads_per_block;
//
vector_add<<<blocks,threads_per_block>>>(
// 创建一个 Grid
// Grid 中有:
// 3 Blocks
// 每个 Block 中:
// 4 Threads
d_a,
d_b,
d_c,
N
);
// Grid:整个一次 Kernel launch 的线程集合
// Block:线程的一个分组
// Thread:执行 Kernel 的单个逻辑线程
//Vector Add 中相邻线程访问连续数组地址,因此具有良好的合并访存模式。
//在 NVIDIA GPU 上,线程还会进一步以 32 个线程组成 Warp,由 SM 调度执行。Warp 会由这些硬件资源调度执行。
// Block是软件编程模型。
// SM是硬件结构。
cudaDeviceSynchronize()
//让 CPU 停下来,等 GPU 把之前提交的任务全部做完
//gpu->cpu
cudaMemcpy(
h_c,
d_c,
bytes,
cudaMemcpyDeviceToHost
);
std::cout << "N=" << N << std::endl;
std::cout << "blocks" << blocks << std::endl;
std::cout << "threads_per_block" <<std::endl;
std::cout << std::endl;
for (int i=0; i<N; i++){
std::cout << "c[" << i << "]=" << h_c[i] <<std::endl;
}
cudaFree(d_a);
cudaFree(d_b);
cudaFree(d_c);
return 0;
}
text
Kernel launch
↓
异步提交
↓
GPU开始工作
cudaDeviceSynchronize
↓
CPU等待
↓
GPU完成后CPU继续
text
h_a ── cudaMemcpy H→D ───────→ d_a
h_b ── cudaMemcpy H→D ───────→ d_b
GPU计算
↓
d_c
h_c ←── cudaMemcpy D→H ─────── d_c
text
软件编程模型 NVIDIA硬件
Grid
↓
Block SM
↓ ↑
Thread ──组成 Warp────────────┘
NVIDIA 中:32 Threads=1 Warp
原 Hello-GPU 的 AMD 术语可以暂时对应:
AMD NVIDIA
CU ≈ SM
Wavefront ≈ Warp
但一定记住:Block 不等于 SM。
合并访存 Coalesced Memory Access。
所以你现在只需要记:相邻线程访问相邻地址 → 通常有利于合并访存 \boxed{\text{相邻线程访问相邻地址 → 通常有利于合并访存}} 相邻线程访问相邻地址 → 通常有利于合并访存。不用现在研究 cache line、memory transaction、sector 这些细节。
相邻线程访问的是连续地址。GPU 很喜欢这种访问方式,因为一个 Warp 中的多个线程可以高效完成内存事务。
text
读 a[i] 4 bytes
读 b[i] 4 bytes
↓
一次加法
↓
写 c[i] 4 bytes
GPU编程
│
┌────────┴────────┐
│ │
CPU Host GPU Device
│ │
cudaMalloc │
cudaMemcpy │
│ │
└──── Kernel ─────→│
│
Grid
│
Blocks
│
Threads
│
i = blockIdx.x *
blockDim.x +
threadIdx.x
│
c[i]=a[i]+b[i]
│
if(i<N)
│
Warp
│
SM
| 原课程 AMD | 你现在 NVIDIA |
|---|---|
| HIP | CUDA |
hipEvent |
cudaEvent |
| ROCm | CUDA Toolkit |
| rocprof | Day 3 改用 Nsight Systems / Nsight Compute |
| Wavefront | Warp |
| CU | SM |
| Triton | Triton,不变 |
vector_add<<<...>>>();通常只是:CPU 把任务提交给 GPU。然后 CPU 马上继续执行。
CPU GPU
start
│
│ launch kernel ───────────→ 收到任务
│
end 开始计算
│
│
│
完成
CPU 计时器测到的可能只是:Kernel launch / CPU 提交时间\boxed{\text{Kernel launch / CPU 提交时间}}Kernel launch / CPU 提交时间而不是:GPU 真正执行 Kernel 的时间
所以通常会:先 warmup 若干次,再正式 repeat 若干次
两种计时方法
方法 A:CPU 计时器 + synchronize,例如:
python
cudaDeviceSynchronize();
auto start = std::chrono::high_resolution_clock::now();
vector_add<<<blocks, threads>>>(
d_a,
d_b,
d_c,
N
);
cudaDeviceSynchronize();
auto end = std::chrono::high_resolution_clock::now();
注意前后都有:cudaDeviceSynchronize();
前面的:cudaDeviceSynchronize();确保GPU 前面的活全部干完了。
后面的:cudaDeviceSynchronize();确保:我们要测试的 Kernel 已经真正干完了
text
我们真正测这里
↓↓↓↓↓↓↓
GPU 前序任务 ──完成──┐
│
CPU start │
│
├── launch
│
├── GPU执行
│
└── GPU完成
CPU end
static_cast是什么函数
#include <iostream>
#include <cuda_runtime.h>
//__global__ CUDA 的关键词,表示这个函数在GPU上运行
//这个函数是在 GPU 上执行的 Kernel,但从 CPU 端发起调用。
__global__ void vector_add(
const float* a,
const float* b,
float* c,
int n
){
int i = blockIdx.x * blockDim.x + threadIdx.x;
//防止越界
//cuda使用层级
// Grid
// │
// ├── Block 0
// │ ├── Thread 0
// │ ├── Thread 1
// │ ├── Thread 2
// │ └── Thread 3
// │
// blockDim.x代表:一个 Block 里面有多少个线程
if(i<n){
c[i] = a[i]+b[i];
}
}
int main(){
const int N = 10;
const size_t bytes = N * sizeof(float);
//size_t 是一种无符号整数类型,用于表示对象的大小(以字节为单位)。
//专门表示: 大小、长度、内存字节数 的无符号整数类型。
std::cout <<" N = " << N << std::endl;
//CPU内存
float* h_a = new float[N]; // -> CPU RAM
float* h_b = new float[N];
float* h_c = new float[N];
// static_cast<float>(i)表示显式类型转换。
//初始化
for(int i=0;i<N;i++){
h_a[i] = static_cast<float>(i);
h_b[i] = 2.0f * static_cast<float>(i);
}
//2.0 默认是: double ,2.0f float 类型。
//GPU内存
//定义GPU指针
float* d_a;
float* d_b;
float* d_c;
//现在只是定义了指针 还没有真正申请GPU显存
cudaMalloc(&d_a, bytes);
cudaMalloc(&d_b, bytes);
cudaMalloc(&d_c, bytes);
//给我一个指针的地址+你想申请多少 byte
//cudaMalloc 需要修改 d_a 本身。& 表示:取变量的地址。
//cpu -> gpu
cudaMemcpy(d_a, h_a, bytes, cudaMemcpyHostToDevice);
cudaMemcpy(d_b, h_b, bytes, cudaMemcpyHostToDevice);
// cudaMemcpy(
// 目标地址,
// 源地址,
// 大小,
// 方向
// );
//kernel配置
const int threads = 256;
const int blocks = (N + threads -1)/ threads;
std::cout << "blocks = " << blocks << std::endl;
std::cout << "threads = " << threads << std::endl;
//创建CUDA Events 声明对象
cudaEvent_t start;
cudaEvent_t stop;
cudaEventCreate(&start);
cudaEventCreate(&stop);
// 可以把一个 CUDA Event 暂时理解成:GPU 时间轴上的一个标记点。
//cudaEventCreate(&start); 才让 CUDA 真正创建 Event 对象。
//记录开始Event
cudaEventRecord(start); //把一个 start Event 放进当前 GPU stream 的任务队列里。
//创建多少个blocks 多少个threads
vector_add<<<blocks,threads>>>(
d_a,d_b,d_c,N
);
//d_c = d_a+d_b
//记录结束Event
cudaEventRecord(stop);
// start Event
// ↓
// vector_add Kernel
// ↓
// stop Event
//等待stop Event真正执行完成
cudaEventSynchronize(stop);
//CUDA Kernel launch:vector_add<<<...>>>(); 通常是: 异步的。
//CPU 在这里等,直到 GPU 真正执行完 stop Event。
//记录GPU kernel时间
float milliseconds = 0.0f;
cudaEventElapsedTime(&milliseconds,start,stop);
std::cout << "Kernel execution time = "<< milliseconds << " ms" << std::endl;
//GPU -> CPU
cudaMemcpy(h_c,d_c,bytes,cudaMemcpyDeviceToHost);
//检查结果
bool correct = true;
for(int i = 0 ; i < N ; i++ ){
float expected = h_a[i] + h_b[i];
if(std::fabs(h_c[i] - expected) > 1e-5){
std::cout << "Error at i = " << i
<<", expected = " << expected
<<", got = "<< h_c[i]
<< std::endl;
correct = false;
break;
}
}
if(correct){
std::cout << "Result: CORRECT"<< std::endl;
}
//打印前几个元素
std::cout << "\nFirst 10 results:" <<std::endl;
for(int i=0; i < 10;i++){
std::cout <<"c[" << i <<"] = "<<h_c[i]<<std::endl;
}
//清理资源
cudaEventDestroy(start);
cudaEventDestroy(stop);
cudaFree(d_a);
cudaFree(d_b);
cudaFree(d_c);
delete[] h_a;
delete[] h_b;
delete[] h_c;
return 0;
}
Kernel launch 是同步还是异步?通常是异步的。
为什么直接 CPU 计时可能不准?因为 CPU 可能只测到了:提交 kernel 的时间,而不是:GPU 真正执行 kernel 的时间
CUDA Event 为什么更适合?因为它是在:GPU execution timeline上记录时间点。
text
你已经完成:
CUDA Event 基本用法
↓
现在:
理解异步执行
↓
CPU chrono vs CUDA Event
↓
cudaDeviceSynchronize
↓
接下来:
warm-up
↓
多次重复测量
↓
平均 / 中位数 / 波动
↓
再之后:
测 H2D / Kernel / D2H
↓
算 bandwidth
↓
判断 Vector Add 为什么是 memory-bound
| 方法 | 测量位置 | 是否等待 GPU | 主要测到什么 |
|---|---|---|---|
| CPU chrono,不同步 | CPU | 否 | Kernel 提交/launch 的 CPU 开销 |
CPU chrono + cudaDeviceSynchronize() |
CPU | 是 | CPU 视角下,从 launch 到 GPU 完成的总等待时间 |
| CUDA Event | GPU 时间线 | 是,通常等 stop |
GPU 上 Kernel 实际执行时间 |
1. chrono,不加同步
代码:
c++
auto start = std::chrono::high_resolution_clock::now();
vector_add<<<blocks, threads>>>(...);
auto end = std::chrono::high_resolution_clock::now();
实际执行关系是:
CPU:
start
↓
提交 kernel
↓
end
GPU:
开始执行 kernel
↓
...
↓
kernel 完成
这里 end 发生时,GPU 可能还没有执行完 Kernel。
所以:end - start.
测到的主要是:CPU 提交 kernel 的时间.
更准确地说,是:CPU 执行 kernel launch 这条语句花的墙钟时间.
它不等于:GPU Kernel execution time
所以这种方法不能直接拿来衡量 Kernel 性能。
2. chrono + cudaDeviceSynchronize()
C++
auto start = std::chrono::high_resolution_clock::now();
vector_add<<<blocks, threads>>>(...);
cudaDeviceSynchronize();
auto end = std::chrono::high_resolution_clock::now();
执行关系变成:
CPU:
start
↓
提交 kernel
↓
cudaDeviceSynchronize()
↓
等待 GPU
↓
GPU 完成
↓
end
GPU:
GPU:
kernel 开始
↓
kernel 执行
↓
kernel 完成
因为:cudaDeviceSynchronize();会阻塞 CPU,直到此前 GPU 上的工作完成。
因此:end - start
包含:Kernel launch 的 CPU 开销+等待 GPU 的时间+同步相关开销
所以它测的是:从 CPU 发起 Kernel,到 CPU 确认 GPU 已经完成,这整个过程的墙钟时间。
注意,这仍然不是严格的"纯 GPU Kernel execution time"。
CUDA Event
cudaEventRecord(start);
vector_add<<<blocks, threads>>>(...);
cudaEventRecord(stop);
cudaEventSynchronize(stop);
cudaEventElapsedTime(&ms, start, stop);
这里完全不同。start 和 stop 都被放到 GPU 的执行队列里。
顺序是:
GPU stream:
start Event
↓
vector_add Kernel
↓
stop Event
也就是说,GPU 实际执行顺序是:
记录 start 时间
↓
执行 kernel
↓
记录 stop 时间
然后:cudaEventSynchronize(stop);
只是让 CPU 等:等 GPU 执行到 stop 为止。等 stop 完成以后:
cudaEventElapsedTime(&ms, start, stop);
GPU 时间线中,start Event 到 stop Event 之间经过的时间。
| 方法 | 开始计时在哪里 | 结束计时在哪里 | 是否等待 GPU | 实际测量对象 |
|---|---|---|---|---|
chrono |
CPU | CPU | 否 | CPU 提交 Kernel 的时间 |
chrono + cudaDeviceSynchronize() |
CPU | CPU | 是 | CPU 从提交到 GPU 完成的墙钟时间 |
| CUDA Event | GPU stream | GPU stream | 等 stop |
GPU 时间线上的执行时间 |
cudaDeviceSynchronize()意思是:CPU 等待当前设备此前提交的工作完成。它本身不是计时工具。只是为了让:CPU chrono能够等到 GPU 完成再停止。
cudaEventSynchronize(stop);意思是:CPU 等待 stop Event 被 GPU 执行完成
真正计算时间的是:
cudaEventElapsedTime(
&ms,
start,
stop
);
所以 CUDA Event 的逻辑一定要拆成两部分:
Record=在 GPU 时间线上放时间点
Synchronize=确保这个时间点已经真的被 GPU 执行
ElapsedTime=计算两个时间点之差
提交kernel的时间如何理解:CPU 把"请 GPU 执行这个 Kernel"这条命令交给 CUDA Runtime/Driver,并把它排进 GPU 的执行队列所花的时间。
text
t0 t1 t2 t3
|-----------------|---------------|------------------|
CPU 开始调用 launch 返回 GPU 开始执行 GPU 执行结束
kernel<<<...>>>()
其中:t0 → t1,就是我们说的:Kernel launch / 提交 Kernel 的 CPU 开销
auto start = now();
vector_add<<<blocks, threads>>>(d_a, d_b, d_c, N);
auto end = now();
CPU 做的大致事情是:
-
看到要启动 vector_add
-
读取 blocks、threads
-
准备参数 d_a、d_b、d_c、N
-
调用 CUDA Runtime / Driver
-
把这个 Kernel launch 放进对应 stream 的工作队列
-
CUDA launch 调用返回
-
CPU 继续执行下一行
CPU:
t0
│
├─ 准备 Kernel launch
├─ 参数传给 CUDA Runtime
├─ Runtime / Driver 处理
├─ 把工作排入 stream
│
t1 ← kernel<<<...>>>() 返回
│
├─ CPU 继续执行其他代码
│GPU:
t2 │ ├─ vector_add 开始 │ │ 真正计算 │ t3 vector_add 结束
因为 CUDA Kernel launch 通常是异步的,所以很可能:t1 < t2 < t3
也就是:CPU 已经从 kernel<<<...>>>(); 返回了,GPU 甚至可能还没开始这个 Kernel。
当然,也可能 GPU 很快已经开始了,但关键是:CPU 不需要等它。
动作一:Launch / Submit
vector_add<<<blocks, threads>>>(...);
意思:CPU 请求 GPU 执行这个 Kernel。这是 发出任务。
动作二:Execute
GPU 后面真正进行:c[i] = a[i] + b[i];
这是 执行任务。这两个不是同时结束的。
kernel<<<...>>>(); 默认通常是异步 launch
CPU chrono 不同步时,主要测到的是 launch / 提交时间
cudaDeviceSynchronize() 会让 CPU 等 GPU 完成
cudaEventRecord(start/stop) 是在 GPU 执行时间线上放标记
cudaEventSynchronize(stop) 是等 GPU 真正执行到 stop
cudaEventElapsedTime() 才是计算两个 Event 之间的 GPU 时间
start 不需要单独 synchronize,因为 stop 完成就意味着前面的 start + kernel 都完成了
c++
#include <cuda_runtime.h>
#include <iostream>
#include <chrono>
#include <cmath>
//CUDA Kernel
//每个线程负责计算一个元素
// c[i] = a[i] + b[i]
__global__ void vector_add(
const float* a,
const float* b,
float* c,
int N
){
//计算当前线程对应的全局数组下标
int i = blockIdx.x * blockDim.x + threadIdx.x;
//防止线程访问数组范围之外
if(i < N){
c[i] = a[i] + b[i];
}
}
int main(){
//设置问题规模
const int N = 1 << 20;
const size_t bytes = N * sizeof(float);
std::cout << "N = " << N <<std::endl;
//CPU内存
float* h_a = new float[N];
float* h_b = new float[N];
float* h_c = new float[N];
//初始化输入数据
for(int i=0; i<N ;i++){
h_a[i] = static_cast<float>(i);
h_b[i] = 2.0f * static_cast<float>(i);
}
//GPU内存指针
float* d_a;
float* d_b;
float* d_c;
//GPU分配显存
cudaMalloc(&d_a,bytes);
cudaMalloc(&d_b,bytes);
cudaMalloc(&d_c,bytes);
cudaMemcpy(d_a,h_a,bytes,cudaMemcpyHostToDevice);
cudaMemcpy(d_b,h_b,bytes,cudaMemcpyHostToDevice);
//kernel启动配置
const int threads = 256;
const int blocks = (N + threads - 1)/threads;
std::cout<<"blocks = "<<blocks << std::endl;
std::cout<<"threads = "<<threads << std::endl;
cudaEvent_t start;
cudaEvent_t stop;
cudaEventCreate(&start);
cudaEventCreate(&stop);
// warm-up
for (int i = 0; i < 10; i++) {
vector_add<<<blocks, threads>>>(d_a, d_b, d_c, N);
}
cudaDeviceSynchronize();
// 正式测量
cudaEventRecord(start);
for (int i = 0; i < 100; i++) {
vector_add<<<blocks, threads>>>(d_a, d_b, d_c, N);
}
cudaEventRecord(stop);
cudaEventSynchronize(stop);
float ms = 0.0f;
cudaEventElapsedTime(&ms, start, stop);
std::cout << "Average kernel time = "
<< ms / 100
<< " ms"
<< std::endl;
return 0;
}
kernel<<<...>>>();是 CUDA 专门用来启动 GPU Kernel 的语法,不是普通 C++ 语法。
更完整的形式是:kernel_name<<<gridDim, blockDim>>>(参数);
比如你的程序里:
vector_add<<<blocks, threads>>>(
d_a,
d_b,
d_c,
N
);
可以拆成两部分看。普通函数调用是:vector_add(d_a, d_b, d_c, N);
而 CUDA Kernel 调用多了:<<<blocks, threads>>>
这一部分是在告诉 GPU:启动多少个 Block,每个 Block 启动多少个 Thread
你可以把 CUDA Kernel 启动语法固定记成:kernel<<<网格大小, 每个Block线程数>>>(函数参数);
<<< >>> 这部分叫:execution configuration或者 kernel launch configuration
CUDA Runtime API:
cudaMalloc
cudaMemcpy
cudaFree
cudaEventCreate
cudaDeviceSynchronize
CUDA Kernel 内建变量:
blockIdx
threadIdx
blockDim
gridDim
chapter3 Vector Add 到底快不快?
下一章的核心问题只有一个:为什么 Vector Add 很难把 GPU 的计算能力吃满?
GPU 有两种很重要的能力:
计算能力
例如多少 TFLOPS
显存带宽
例如多少 GB/s
GPU 大部分时间不是在等:加法器算不完
而是在等:
a[i] 从显存读进来
b[i] 从显存读进来
c[i] 写回显存
所以 Vector Add 通常属于:memory-bound
也就是:性能主要受显存带宽限制,而不是受算力限制。