并发内核执行

目录

abstract

非空流的中的并发内核

nsys步骤:(一定要详细学习)

实验分析:

openMP

简单介绍openMP

openMP底层如何实现:

如何使用openMP:

操作多线程:

总结

用环境变量调整流的行为

创建流间依赖关系

总结


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 的程序时,编译器会进行以下转换:

  1. 识别指令 :编译器看到 #pragma omp parallel for,把它后面的 for 循环作为一个"可并行区域"。

  2. 生成线程代码 :编译器自动为这个循环创建线程(类似 pthread_create),将循环的迭代次数按照特定策略(通常为 staticguided)分块,分配给不同线程。

  3. 生成同步代码 :在并行区域结尾,编译器自动插入一个隐式的屏障 (类似 pthread_barrier_wait),确保所有线程执行完各自的任务后,主线程才继续。

  4. 运行时库支持 :编译器生成的代码会调用 OpenMP 运行时库(libgomp.solibomp.so),由它管理线程池、工作分配和同步。

本质 :OpenMP 是一个编译期自动并行化工具 ,底层依然调用操作系统的线程 API(如 pthread_create),只是这些调用对你完全透明,你只看到一个干净的循环。

如何使用openMP:

  1. 编译和基本语法

用 g++ 编译时只需加上 -fopenmp

bash 复制代码
nvcc -Xcompiler -fopenmp -Xcompiler -O2 -o main main.cu

-Xcompiler 的作用是告诉 nvcc:"把后面的选项原封不动地传给主机编译器"。这样 g++ 就能收到 -fopenmp,从而链接 OpenMP 运行时库(libomplibgomp)。

最核心的指令是 #pragma omp,后面可以跟各种指令、从句和函数。最常用的几个指令

  1. 核心指令与示例

① 数据并行: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

cpp 复制代码
float 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 */ }
}

让不同线程同时执行不同的代码块。

④ 线程控制与同步

cpp 复制代码
num_threads(n):指定线程数量。

omp_set_num_thread():设置当前线程数量

omp_get_thread_num():获取当前线程 ID。

#pragma omp barrier:显式屏障同步,类似 __syncthreads,但更重量级。

#pragma omp atomic:轻量级原子操作,用于少量数据的保护。

#pragma omp critical:临界区,同时只能一个线程进入。

操作多线程:

这里我们就可以使用openMP来实现多线程,然后每个线程都去创建流,往流里面投递任务

cpp 复制代码
 omp_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的,只有老的架构是一条

对于同一条流,内部是只能等前一个任务执行完毕才能接着下一个,所以暗含依赖

但是对于不同的流是无法互相同步的,要达到同步,我们就需要插入事件,这之前也提到过

我们可以在某一条流当中插入一个事件,在另外一条流当中等待事件,这样就完成不同流之间的同步

cpp 复制代码
cudaEvent_t * event=(cudaEvent_t *)malloc(n_stream*sizeof(cudaEvent_t));
for(int i=0;i<n_stream;i++)
{
    cudaEventCreateWithFlag(&event[i],cudaEventDisableTiming);
}

这个选项之前也提到过,就是关闭计时,只做同步

cpp 复制代码
for(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还没研究透,实验现象不好表述,大家可以自行做一下

相关推荐
王烁鑫4 小时前
从 0 到 1 做实时语音 Agent:先解决“会不会误操作”,再谈自主行动
人工智能
君君思密达4 小时前
Leetcode Hot 100 题目详解-哈希表
数据结构·算法·leetcode
lucas_AI4 小时前
Muse Glimmer 30B:Meta 难得给的真开源,强在哪、虚在哪
人工智能·算法
字节跳动数据库4 小时前
火山引擎 RDS MySQL 向量索引:把高性能向量检索带到 MySQL 上
人工智能·后端·mysql
一心只读圣贤书4 小时前
AI 驱动前端测试实战:从需求文档到 Playwright 自动化用例
前端·人工智能
小白的后端世界4 小时前
LangChain 模型初始化参数详解:从基础配置到企业级实践
java·人工智能·langchain
jimidou4 小时前
第 0 篇:Agent 世界观——先搞懂 LLM、Context、Tool 与 Agent 到底是什么
人工智能
ai产品老杨4 小时前
国产NPU视觉算法完整流程
算法
过期的秋刀鱼!4 小时前
带替换的采样
人工智能·python·算法·决策树·机器学习
Python私教4 小时前
AI Agent 可观测性不只是日志:一套可回放的多步执行链
人工智能·后端·python