hell-gpu| TASK01-2

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 做的大致事情是:

  1. 看到要启动 vector_add

  2. 读取 blocks、threads

  3. 准备参数 d_a、d_b、d_c、N

  4. 调用 CUDA Runtime / Driver

  5. 把这个 Kernel launch 放进对应 stream 的工作队列

  6. CUDA launch 调用返回

  7. 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

也就是:性能主要受显存带宽限制,而不是受算力限制。

相关推荐
byte轻骑兵28 分钟前
【BlueZ 】用户态入口:main.c 核心函数与程序启动流程
linux·人工智能·bluez·电脑蓝牙·嵌入式蓝牙
我科绝伦(Huanhuan Zhou)1 小时前
Linux rpm包损坏解决方案
运维
Navigator_Z1 小时前
LeetCode //C - 1223. Dice Roll Simulation
c语言·算法·leetcode
171320330计算机毕设编程1 小时前
2027计算机毕设五大方向对比&选题推荐
java·ide·python·算法·django·php·推荐算法
Tisfy1 小时前
LeetCode 2058.找出临界点之间的最小和最大距离:遍历+遇到极值则更新(这种题谁空间复杂度不是O(1)啊)
linux·数据库·leetcode·链表·题解·模拟·遍历
数据库小学妹1 小时前
MySQL死锁排查:锁机制原理、死锁日志与information_schema定位
运维·数据库·mysql
liulilittle1 小时前
REALITY 代理隧道的有效检测方法: 被动、主动与确定性三层实证研究
linux·服务器·网络·网络协议·安全·网络安全·通信
万物智能1 小时前
设备树DTS-【万物智能之开源鸿蒙OpenHarmony系统实战开发系列教程】
后端·算法
JoyCong19981 小时前
从iPhone Ultra到ToDesk:折叠大屏时代的效率革命,硬件只是开始
大数据·运维·ios·智能手机·iphone·远程工作·远程操作