目录
abstract
学习并发内核的相关知识
- 使用深度优先或者广度优先方法的调度工作
- 调整硬件工作队列
- 检查默认流的阻塞行为
- 在非默认流之间添加依赖关系
- 检查资源使用是如何影响并发的
非空流的中的并发内核
在SM内部还有一种核心叫SFU,也就是special funtion unit,主要是用来处理超越函数
加减乘除这些由cuda核心执行,但是tan sin sqrt exp等超越函数由SFU来执行
ada架构每个SM有两个SFU,每个SFU可以处理一个warp schedule发来的一组指令,那么最多同时处理两组指令,而且SFU指令特别耗时,大概几十个周期,但是由于流水线又可以并发
接下来就是采用SFU的耗时来看一下内核的并发,因为耗时我们才能更好的观察,采用nsys
cpp#include <iostream> #include <cuda_runtime.h> #include "../freshman.hpp" using namespace std; #define N 1000000 __global__ void kernel1(){ double sum=0.0; for(int i=0;i<N;i++){ sum=sum+tan(0.1)+tan(0.1); } } __global__ void kernel2(){ double sum=0.0; for(int i=0;i<N;i++){ sum=sum+tan(0.1)+tan(0.1); } } __global__ void kernel3(){ double sum=0.0; for(int i=0;i<N;i++){ sum=sum+tan(0.1)+tan(0.1); } } __global__ void kernel4(){ double sum=0.0; for(int i=0;i<N;i++){ sum=sum+tan(0.1)+tan(0.1); } } int main(){ int device=0; CHECK(cudaSetDevice(device)); int n_stream=10; cudaStream_t *stream = (cudaStream_t *)malloc(n_stream * sizeof(cudaStream_t)); for (int i = 0; i < n_stream; i++){ cudaStreamCreate(&stream[i]); //创建流 } dim3 block(1); dim3 grid(1); cudaEvent_t start, stop; cudaEventCreate(&start); cudaEventCreate(&stop); cudaEventRecord(start); for (int i = 0; i < n_stream; i++){ kernel1<<<grid, block, 0, stream[i]>>>(); kernel2<<<grid, block, 0, stream[i]>>>(); kernel3<<<grid, block, 0, stream[i]>>>(); kernel4<<<grid, block, 0, stream[i]>>>(); } cudaEventRecord(stop); CHECK(cudaEventSynchronize(stop)); //因为计算事件差的时候stop必须已经完成,所以这里得用阻塞同步 float elapsed_time; cudaEventElapsedTime(&elapsed_time, start, stop); printf("elapsed time:%f ms\n", elapsed_time); cudaEventDestroy(start); cudaEventDestroy(stop); //四个计算完之后要释放 for (int i = 0; i < n_stream; i++){ cudaStreamDestroy(stream[i]); //释放流 } return 0; }使用nsys的时候也必须来一个同步函数,如果所有的都用异步,导致你的主机先跑完,那上下文就会销毁,那kernel的记录就是有问题的,所以必须保证host还在运行,上下文还在
nsys步骤:(一定要详细学习)
- 将代码编译
- nsys profile ./main(类似gdb调试一样,先编译可执行文件,然后用nsys跑)
bash# 指定输出文件名(推荐,不然每次都是 report1、report2...) nsys profile -o my_trace ./main # 生成 my_trace.nsys-rep # 只追踪 CUDA(默认会追很多东西,比如OS线程、OpenGL等,CUDA程序可以精简一下) nsys profile --trace=cuda -o my_trace ./main # 同时采集 GPU 指标(SM占用、内存带宽等,需要额外开) nsys profile --trace=cuda --gpu-metrics-device=0 -o my_trace ./main # 不显示统计摘要,安静模式 nsys profile --stats=false -o my_trace ./main本质就是将trace文件记录下来,然后打开可视化界面分析这个文件,trace文件就是把各个事件记录下来形成的就叫trace文件,具体可自行查资料看这个的概念
接着把这个文件拷贝到windows,其实也不用拷贝,我们是wsl子系统,直接打开对应的Linux盘符的文件即可
很烦,网络上有关nsys的资料学习很少,博主打开之后发现没有并行的展示,不像大家一样,博主看到右上角有报错信息
要么双击右上角,要么点开timeline view
通过英文单词可以知道,这是一个诊断总结,点进去就知道为什么报错,或者从刚刚那里右上角双击一下
主要是检查以下,所以大家可以看一下有没有报错
- Linux kernel perf 支持
- CUDA driver兼容性
- CUPTI
- 权限
- 符号解析
- trace是否完整
因为我是普通用户,所以有个安全限制/proc/sys/kernel/perf_event_paranoid
我当前是:perf_event_paranoid = 2
所以按照指示:sudo sh -c 'echo 1 >/proc/sys/kernel/perf_event_paranoid'
perf就是Linux用来监测cpu状态的工具,nsys=perf工具+gpu那边的监测工具
注意:以上的修改是把权限修改成1,但是重启wsl之后就没了,所以我们要永久修改
bash编辑 sudo nano /etc/sysctl.conf 加入 kernel.perf_event_paranoid=1 报存 sudo sysctl -p还有一个问题就是版本不匹配,windows用的是2026.1.2,Linux生成的报告使用的是2026.4.1,所以不一致,我们把windows的改成最新的版本即可
非常好,现在解决了,出现了CUDAHW(cudahardware,硬件时间轴)
核心原因两个:一个是perf权限不足,一个是windows和Linux的版本不一致
接下来就是简单的看一下windows如何分析,学一些基础的选项:
轴向表示资源中有三种资源:
cpu:表示cpu端的一些利用率
cudaHW:这个是gpu端的一些kernel
threads:这个是cpu线程
下面事件可视化,选中timeLine view的某个事件,双击选中,下面就会可视化函数
实验分析:
我们的代码就是创建10个流,然后每个流依次提交4个核函数,然后并发去跑,那么在CUDAHW当中因为是非空阻塞流,但是我们没有往默认流当中添加任务,所以实验结果应该是非空流之间的并发
openMP
简单介绍openMP
对于cpu编程的时候,我们学过pthread,pthread 是"手动挡"的线程级并行库,让你精确控制每一个线程的创建、执行和销毁;OpenMP 是"自动挡"的编译指令级并行工具,通过简单的指令提示,让编译器自动帮你把循环或代码块变成多线程执行。
pthread灵活,你可以手动分配不同的任务,但是openMP不灵活,是编译指令级别的,是自动创建好,执行一批任务
回顾cpu编程的通信和同步机制
多进程多线程通信:
共享内存 :在内存中开辟一块区域,多个进程/线程都可以直接读写它。这是最快的通信方式,因为它不涉及内核态和用户态之间的数据拷贝。但你需要自己解决同步问题(比如用信号量来保护这块内存)。
管道 (Pipe):一个进程把数据写入管道的写端,另一个进程从读端读取。数据在内核缓冲区中流动。
命名管道 (FIFO):和管道类似,但有一个文件名,允许没有亲缘关系的进程间通信。
消息队列 (Message Queue):在内核中维护一个消息链表,进程可以往里面发送结构化的消息块,另一个进程则从中读取。
套接字 (Socket):功能最强大,不限于本地机器,还能用于不同机器之间的网络通信。
同步机制:
信号量:维护一个计数器,用来控制同时访问某个共享资源的进程/线程数量。它经常和共享内存搭配,用来保护共享内存中的数据不被同时修改。
互斥锁 (Mutex):可以看作是计数器最大值为1的特殊信号量。一把锁同时只能由一个线程持有,用来保证同一时间只有一个线程能进入临界区(修改共享数据的代码段)。
条件变量 (Condition Variable):通常和互斥锁配合使用,让一个线程在某个条件不满足时进入等待状态,直到另一个线程触发了条件再把它唤醒。
读写锁 (RWLock):更精细地控制读写权限:允许多个线程同时读共享数据,但只有一个线程能进行写操作,且在写时禁止其他线程读取。
openMP底层如何实现:
当你编译一个带有
#pragma omp parallel for的程序时,编译器会进行以下转换:
识别指令 :编译器看到
#pragma omp parallel for,把它后面的for循环作为一个"可并行区域"。生成线程代码 :编译器自动为这个循环创建线程(类似
pthread_create),将循环的迭代次数按照特定策略(通常为static或guided)分块,分配给不同线程。生成同步代码 :在并行区域结尾,编译器自动插入一个隐式的屏障 (类似
pthread_barrier_wait),确保所有线程执行完各自的任务后,主线程才继续。运行时库支持 :编译器生成的代码会调用 OpenMP 运行时库(
libgomp.so或libomp.so),由它管理线程池、工作分配和同步。本质 :OpenMP 是一个编译期自动并行化工具 ,底层依然调用操作系统的线程 API(如
pthread_create),只是这些调用对你完全透明,你只看到一个干净的循环。如何使用openMP:
- 编译和基本语法
用 g++ 编译时只需加上
-fopenmp:
bashnvcc -Xcompiler -fopenmp -Xcompiler -O2 -o main main.cu
-Xcompiler的作用是告诉nvcc:"把后面的选项原封不动地传给主机编译器"。这样 g++ 就能收到-fopenmp,从而链接 OpenMP 运行时库(libomp或libgomp)。最核心的指令是
#pragma omp,后面可以跟各种指令、从句和函数。最常用的几个指令
- 核心指令与示例
① 数据并行:
parallel for
cpp#include <omp.h> #include <stdio.h> int main() { const int N = 1000; float a[N], b[N], c[N]; // ... 初始化 a 和 b ... #pragma omp parallel for for (int i = 0; i < N; ++i) { c[i] = a[i] + b[i]; // 多个线程并行执行这个循环 } return 0; }编译器自动划分循环迭代,分配给多个线程。
② 归约操作:
reduction
cppfloat sum = 0.0f; #pragma omp parallel for reduction(+:sum) for (int i = 0; i < N; ++i) { sum += a[i]; } // 每个线程有自己的局部 sum,最后自动汇总到 sum 中。这和在 CUDA 中学过的归约优化思路完全一致。
③ 任务并行:
sections
cpp#pragma omp parallel sections { #pragma omp section { /* 任务 A */ } #pragma omp section { /* 任务 B */ } }让不同线程同时执行不同的代码块。
④ 线程控制与同步
cppnum_threads(n):指定线程数量。 omp_set_num_thread():设置当前线程数量 omp_get_thread_num():获取当前线程 ID。 #pragma omp barrier:显式屏障同步,类似 __syncthreads,但更重量级。 #pragma omp atomic:轻量级原子操作,用于少量数据的保护。 #pragma omp critical:临界区,同时只能一个线程进入。操作多线程:
这里我们就可以使用openMP来实现多线程,然后每个线程都去创建流,往流里面投递任务
cppomp_set_num_threads(n_stream); //设置线程数量 #pragma omp parallel //编译指令 { int i=omp_get_thread_num(); //0号线程投递到0号流 1号投递到1 kernel1<<<grid, block, 0, stream[i]>>>(); kernel2<<<grid, block, 0, stream[i]>>>(); kernel3<<<grid, block, 0, stream[i]>>>(); kernel4<<<grid, block, 0, stream[i]>>>(); }后面也会不断详细的讲解openMP和cuda之间的配合,这里仅仅是初步认识
总结
可以看得出来openMP的代码量很少,对于一些简单的任务来说可以使用,但对于精细化的控制肯定要用pthread,并且pthread的学习难度肯定高于openMP
简单来说:
OpenMP 擅长处理 "同一操作,作用于大量不同数据" 的场景,也就是数据并行 。比如,用
#pragma omp parallel for一键并行化一个大规模循环。pthread 擅长处理 "程序中有多个不同的、需要同时进行的任务" 的场景,也就是任务并行。比如,一个线程负责网络通信,一个线程负责复杂计算,一个线程负责响应用户操作。(对于这种任务openMP可以做,但是你无法完整的控制单个线程的动作,比如暂停,退出,优先级等等)
反正就是两者没有好坏之分,但是要根据场景选择,没有银弹
用环境变量调整流的行为
前面我们也提到过Hyper-Q,默认来说会开8个,如果超过了,就需要软件去模拟去复用同一条,这样就会形成FIFO的虚假依赖
我们的硬件有32条,但是默认用8条,为了避免形成假依赖,我们需要环境变量去开多个
修改系统环境变量
cpp#方式1:临时,当前终端,关闭就丢 export CUDA_DEVICE_MAX_CONNECTIONS=32 ./main #方式2:一行命令,只给这一次程序运行,不污染shell环境(推荐做实验) CUDA_DEVICE_MAX_CONNECTIONS=32 ./main #方式3:永久生效(写进配置文件) echo 'export CUDA_DEVICE_MAX_CONNECTIONS=32' >> ~/.bashrc source ~/.bashrc不知道是不是受本机电脑的影响,波动好大
创建流间依赖关系
之前提到过虚假依赖,这个其实只在老的架构费米才出现,后续的架构都是多Hyper-Q的,只有老的架构是一条
对于同一条流,内部是只能等前一个任务执行完毕才能接着下一个,所以暗含依赖
但是对于不同的流是无法互相同步的,要达到同步,我们就需要插入事件,这之前也提到过
我们可以在某一条流当中插入一个事件,在另外一条流当中等待事件,这样就完成不同流之间的同步
cppcudaEvent_t * event=(cudaEvent_t *)malloc(n_stream*sizeof(cudaEvent_t)); for(int i=0;i<n_stream;i++) { cudaEventCreateWithFlag(&event[i],cudaEventDisableTiming); }这个选项之前也提到过,就是关闭计时,只做同步
cppfor(int i=0;i<n_stream;i++) { kernel_1<<<grid,block,0,stream[i]>>>(); kernel_2<<<grid,block,0,stream[i]>>>(); kernel_3<<<grid,block,0,stream[i]>>>(); kernel_4<<<grid,block,0,stream[i]>>>(); cudaEventRecord(event[i],stream[i]); cudaStreamWaitEvent(stream[n_stream-1],event[i],0); }
总结
本篇章介绍了一些实用的技巧,还有流之间的依赖关系,空流和非空阻塞流之间的阻塞关系上一章已经讲过,只是本节没有实验,因为博主关于nsys还没研究透,实验现象不好表述,大家可以自行做一下












