简介

在CUDA程序中, 访存优化个人认为是最重要的优化项. 往往kernel会卡在数据传输而不是计算上, 为了最大限度利用GPU的计算能力, 我们需要根据GPU硬件架构对kernel访存进行合理的编写.

这章主要以计算一个tensor的模为例, 来看具体如何优化访存从而提升并行效率. 以下代码都只列举了kernel部分, 省略了host提交kernel的部分. Block_size均为256, 代码均在A100上评估性能.

\[\| \vec{v} \| = \sqrt{v_1^2 + v_2^2 + \cdots + v_n^2}
\]

CPU代码

void cpu_tensor_norm(float* tensor, float* result, size_t len) {
double sum = 0.0;
for (auto i = 0; i < len; ++i) {
sum += tensor[i] * tensor[i];
}
*result = sqrt(sum);
}

是一个非常简单的函数, 当然这里针对CPU程序也有优化方法, 当前实现并不是最优解. 此处忽略不表

GPU实现1-基于CPU并行思想

在传统的多线程CPU任务中, 如果想处理一个超大数组的求和, 很自然的会想到起N个线程, 每个线程算1/N的和, 最后再把这N个和加到一块求sqrt. 根据这个思路实现了kernel1, 使用1个block, block中每个线程计算1/N连续存储的数据.

__global__ void norm_kernel1(float* tensor, float* result, size_t len) {
auto tid = threadIdx.x;
auto size = len / blockDim.x;
double sum = 0; for (auto i = tid * size; i < (tid + 1) * size; i++) {
sum += tensor[i] * tensor[i];
}
result[tid] = float(sum);
}
//norm_kernel1<<<1, block_size>>>(d_tensor, d_result1, len);

接下来我们使用nsight-compute来分析下第一个kernel实现哪些地方不合理. 具体使用方法可以从官方文档获取, 这里举个命令例子: nv-nsight-cu-cli -f --target-processes all -o profile --set full --devices 0 ./output/norm_kernel_bench

ncu分析给出了2个主要问题:

grid太小

因为我们只使用了256个线程. 而A100光CUDA core就7000个. 在GPU中不像CPU中线程上下文切换具有很高的成本, 如果想充分利用算力, 就要用尽量多的线程来提升并发度. 同时线程数也不能无限制增加, 因为如果每个线程使用了32个寄存器, 而SM中最多16384的寄存器空间的话, 多于16384/32=512个线程后, 这些多出来的线程就需要把数据存到显存里, 反而会降低运行效率.

读显存瓶颈

下面打开detail后也给出了问题日志: Uncoalesced global access, expected 262144 transactions, got 2097152 (8.00x) at PC

coalesced指的是显存读取需要是连续的, 这里也许你会有疑问, 在kernel1里就是按照连续的显存读的呀. 这里涉及到GPU的实际执行方式, 当一个thread在等读显存数据完成的时候, GPU会切换到下一个thread, 也就是说是需要让thread1读显存的数据和thread2的数据是连续的才会提升显存的读取效率, 在kernel1中明显不连续. 同时参考ProgrammingGuide 中的描述, 每个warp对显存的访问是需要对齐 32/64/128 bytes(一次数据传输的数据量在默认情况下是32字节), 如果所有数据传输处理的数据都是该warp所需要的,那么合并度为100%,即合并访问

GPU实现2-优化coalesced

__global__ void norm_kernel2(float* tensor, float* result, size_t len) {
auto tid = threadIdx.x;
double sum = 0.0;
while (tid < len) {
sum += tensor[tid] * tensor[tid];
tid += blockDim.x;
}
result[threadIdx.x] = float(sum);
}
//norm_kernel2<<<1, block_size>>>(d_tensor, d_result1, len);

依然还是1个block256个线程, kernel2将每个thread读取方式改成了每隔256个float读一个, 这样uncoalesced的报错就不见了. 但是! 一跑bench却发现为啥反而还变慢了呢?

此时可以点开memory WorkLoad Analysis

从这里可以看到L2 cache命中率降低了50%左右, 原因是因为按照kernel1的访问方式, 第一次访问了32bytes长度, 但是只用了一部分后, 剩下的会缓存在L2中, 而Kernel2虽然访问显存连续了, 但每次的cache命中率会随着读入数据利用效率的变高而降低, 根本原因是因为线程和block太少导致的. 另外这张图上还有个明显的叹号, 我们没有合理的用到shared_memory. 接下来的kernel3重点优化这两部分

GPU实现3-增大并发&利用shared_memory

__global__ void norm_kernel3(float* tensor, float* result, size_t len) {
auto tid = threadIdx.x + blockIdx.x * blockDim.x;
extern __shared__ double sum[];
auto loop_stride = gridDim.x * blockDim.x;
sum[threadIdx.x] = 0;
while (tid < len) {
sum[threadIdx.x] += tensor[tid] * tensor[tid];
tid += loop_stride;
}
__syncthreads();
if (threadIdx.x == 0) {
for (auto i = 1; i < blockDim.x; ++i) {
sum[0] += sum[i];
}
result[blockIdx.x] = float(sum[0]);
}
}
//grid_size3=64
//norm_kernel3<<<grid_size3, block_size, block_size * sizeof(double)>>>(d_tensor, d_result3, len);
//for (auto i = 1; i < grid_size3; ++i) {
// h_result3[0] += h_result3[i];
//}
//sqrt(h_result3[0])

短短几行改动, 让程序快了27倍, 这是什么魔法(黑人问号)? kernel3做了如下几个优化:

  1. 使用shared_memory用来存储临时加和, 最后在每个block的第一个thread里把这些加和再加到一块, 最后再写回HBM. shared_memory访问速度19T/s, HBM速度才1.5TB/s, 所以我们如果有需要临时存储的变量, 要尽可能的把shared_mem利用起来.

  2. 这次使用了64个block, GPU的其他SM终于不用看戏了. 但其实还是可以增加的, A100有108个SM呢, 让我们把他用满再看下性能auto grid_size3 = (len + block_size - 1) / block_size; 可以看到我们终于让计算吞吐打满了~

到这里还有2个问题需要解决: 1.我们只是在GPU里求了局部加和, 全局和还得挪到CPU算好挫. 2.每个block在syncthread之后只有第一个线程在计算, 能不能加快计算的同时减少一下计算量

GPU实现4-优化加和

template <int64_t BLOCK_SIZE>
__global__ void norm_kernel4(float* tensor, double* result, size_t len) {
using BlockReduce = cub::BlockReduce<double, BLOCK_SIZE>;
__shared__ typename BlockReduce::TempStorage temp_storage;
int tid = threadIdx.x + blockIdx.x * blockDim.x;
double sum = 0.0;
if (tid < len) {
sum += tensor[tid] * tensor[tid];
}
double block_sum = BlockReduce(temp_storage).Sum(sum);
if (threadIdx.x == 0) {
atomicAdd(result, block_sum);
}
}
//norm_kernel4<block_size><<<grid_size, block_size>>>(d_tensor, d_result4, len);
//sqrt(h_result4)

对与第一个线程连续加和的问题, 我偷懒使用了cub::BlockReduce方法, BlockReduce的原理是经典的树形规约算法. 利用分治的思想, 把原来的32轮加和可以简化为5轮加和, 这样就能极大减少长尾线程的计算量.

对于显存上的全局求和问题, 由于block之间是没有任何关联的, 我们必须使用原子操作来解决对全局显存的操作. 这里为了减少原子写冲突, 只在block的第一个线程上进行原子加. 另外我们可以不用cub的BlockReduce优化到和他差不多的性能吗?

GPU实现5-自己实现BlockReduce

template <int64_t BLOCK_SIZE>
__global__ void norm_kernel5(float* tensor, double* result, size_t len) {
using WarpReduce = cub::WarpReduce<double>;
const int64_t warp_size = BLOCK_SIZE / 32;
__shared__ typename WarpReduce::TempStorage temp_storage[warp_size];
__shared__ float reduce_sum[BLOCK_SIZE];
int tid = threadIdx.x + blockIdx.x * blockDim.x;
int warp_id = threadIdx.x / 32;
float sum = 0.0;
if (tid < len) {
sum += tensor[tid] * tensor[tid];
}
auto warp_sum = WarpReduce(temp_storage[warp_id]).Sum(sum);
reduce_sum[threadIdx.x] = warp_sum; //这里尽量避免wrap内的分支 __syncthreads();
//树形规约 int offset = warp_size >> 1;
if (threadIdx.x % 32 == 0) {
while (offset > 0) {
if (warp_id < offset) {
reduce_sum[warp_id * 32] += reduce_sum[(warp_id + offset) * 32];
}
offset >>= 1;
__syncthreads();
}
}
if (threadIdx.x == 0) {
atomicAdd(result, reduce_sum[0]);
}
}
//norm_kernel5<block_size><<<grid_size5, block_size>>>(d_tensor, d_result5, len);
//sqrt(h_result5)

kernel5主要分为两个部分, 第一步进行了warp内的规约, 第二步手动实现了树形规约的方法. 耗时基本与BlockReduce一致, 由于warp内的32个线程会共享寄存器和shared_mem读写, 在warp内先做一些规约可以适当减少后续的sync_threads轮数.

计算分析

在汇编指令统计这块可以看到LDS(从shared_mem加载指令), LOP3(logic操作), STS(往mem中写入操作), BAR(barrier), BSYNC(线程同步时的barrier, 对应atomic操作), WARPSYNC(warp同步)这些相较于kernel4多了很多. WARPSYNC/LOP3/BAR这些变多是正常的, 是kernel5里新增的逻辑. LDS/STS增加应该是因为我们在warp_reduce时的频率比block_reduce对共享内存的访问频率更高导致.

访存注意要点-bank conflicts

内存分析里出现了一个新名词bank conflicts , 先根据官方文档 解释下这个名词.

SharedMemory结构: 放在shared memory中的数据是以4bytes作为1个word,依次放在32个banks中。第i个word存放在第(i % 32)个bank上。每个bank在每个cycle的 bandwidth为4bytes。所以shared memory在每个cycle的bandwidth为128bytes。这也意味着每次内存访问只会访问128bytes数据

如果同一个warp内的多个threads同时访问同一个bank内的同一个word, 会触发broadcast机制, 会同时发给多个thread. 不会产生冲突

冲突主要产生在多个threads访问同一个bank内的不同word, 如上图的第二列. 这样就会导致本来的一次memory transaction被强制拆分成了2次, 而且需要串行访问memory

解决方法:

通过错位的方式访问数组, 避免访问步长和32产生交集, 每个线程根据线程编号tid与访问步长s的乘积来访问数组的32-bits字(word):

extern __shared__ float shared[];
float data = shared[baseIndex + s * tid];

如果按照上面的方式,那么当s*n是bank的数量(即32)的整数倍时或者说n是32/d的整数倍(d是32和s的最大公约数)时,线程tid和线程tid+n会访问相同的bank。我们不难知道如果tid与tid+n位于同一个warp时,就会发生bank冲突,相反则不会。

仔细思考你会发现,只有warp的大小(即32)小于等于32/d时,才不会有bank冲突,而只有当d等于1时才能满足这个条件。要想让32和s的最大公约数d为1,s必须为奇数。于是,这里有一个显而易见的结论:当访问步长s为奇数时,就不会发生bank冲突。

cuda性能优化-2.访存优化的更多相关文章

  1. CUDA02 - 访存优化和Unified Memory

    CUDA02 - 的内存调度与优化 前面一篇(传送门)简单介绍了CUDA的底层架构和一些线程调度方面的问题,但这只是整个CUDA的第一步,下一个问题在于数据的访存:包括数据以何种形式在CPU/GPU之 ...

  2. CUDA性能优化----warp深度解析

    本文转自:http://blog.163.com/wujiaxing009@126/blog/static/71988399201701224540201/ 1.引言 CUDA性能优化----sp, ...

  3. MySQL 性能优化--优化数据库结构之优化数据大小

    MySQL性能优化--优化数据库结构之优化数据大小   By:授客  QQ:1033553122 尽量减少表占用的磁盘空间.通常,执行查询期间处理表数据时,小表占用更少的内存. 表列 l   尽可能使 ...

  4. 《Java程序性能优化》之设计优化

    豆瓣读书:http://book.douban.com/subject/19969386/ 第一章 Java性能调优概述 1.性能的参考指标 执行时间: CPU时间: 内存分配: 磁盘吞吐量: 网络吞 ...

  5. MySql(八):MySQL性能调优——Query 的优化

    一.理解MySQL的Query Optimizer MySQL Optimizer是一个专门负责优化SELECT 语句的优化器模块,它主要的功能就是通过计算分析系统中收集的各种统计信息,为客户端请求的 ...

  6. 0709MySQL 数据库性能优化之表结构优化

    转自http://isky000.com/database/mysql-perfornamce-tuning-schema MySQL 数据库性能优化之缓存参数优化 MySQL数据库性能优化之硬件瓶颈 ...

  7. 百万级高并发mongodb集群性能数十倍提升优化实践

    背景 线上某集群峰值TPS超过100万/秒左右(主要为写流量,读流量很低),峰值tps几乎已经到达集群上限,同时平均时延也超过100ms,随着读写流量的进一步增加,时延抖动严重影响业务可用性.该集群采 ...

  8. 什么影响了mysql的性能-硬件资源及系统方面优化

    随着数据量的增大,数据库的性能问题也是个值得关注的问题,很多公司对mysql性能方面没有太过重视,导致服务浪费过多资源.mysql服务性能差从而直接影响用户体验,这里我们简单的先来聊聊什么影响了mys ...

  9. MegEngine亚线性显存优化

    MegEngine亚线性显存优化 MegEngine经过工程扩展和优化,发展出一套行之有效的加强版亚线性显存优化技术,既可在计算存储资源受限的条件下,轻松训练更深的模型,又可使用更大batch siz ...

  10. 当我们进行性能优化,我们在优化什么(LightHouse优化实操)

    好的互联网产品不仅仅在功能上要高人一筹,在性能层面也需要出类拔萃,否则金玉其外败絮其中,页面是美轮美奂了,结果首屏半天加载不出来,难免让用户乘兴而来,败兴而归. 幸运的是,前端的性能优化有诸多有迹可循 ...

随机推荐

  1. 2018-12-26-WPF-开启-ScrollViewer-的触摸滚动

    title author date CreateTime categories WPF 开启 ScrollViewer 的触摸滚动 lindexi 2018-12-26 14:24:26 +0800 ...

  2. NopCommerce 多数据库方案

    本文转自:http://www.cnblogs.com/YUTOUYUWEI/p/5538200.html 有时候一个项目需要连接多个数据库,以实现不同数据库的数据在同个项目的共享. 如果已经安装了n ...

  3. 举例useContext性能低下的样例,同时推荐用什么方法改进

    在React中,useContext 是一种非常方便的全局状态管理工具,它可以让我们在组件之间共享状态,而不需要通过层层传递 props.然而,当我们在一个大型的 React 应用中过度使用 useC ...

  4. GitLab 升级迁移待办清单

    GitLab 大版本升级测试用例 项目 从模板项目 URL 导入,来创建新的项目 议题 通过 Quick Actions.关联新建.直接新建 模板 关联项 标签 工时 评论 看板 里程碑 分支 通过 ...

  5. C++ 异常处理机制详解:轻松掌握异常处理技巧

    C++ 异常处理 C++ 异常处理机制允许程序在运行时处理错误或意外情况.它提供了捕获和处理错误的一种结构化方式,使程序更加健壮和可靠. 异常处理的基本概念: 异常: 程序在运行时发生的错误或意外情况 ...

  6. uni.request二次封装

    点击查看代码 const baseUrl = 'https://your-api-base-url.com'; function getAuthToken() { // 获取token的逻辑,可能会从 ...

  7. pip install 报错:Versioning for this project requires either an sdist tarball, ...

    问题:Versioning for this project requires either an sdist tarball, or access to an upstream git reposi ...

  8. 【u8】二开生成的专用采购发票结算后显示结算标志但是没有生成结算单的问题

    在表体 purbillvouchs 里有个字段 upsotype 上游单据类型 不能是空,如果是代管生成的发票要填写'vmiused', 如果是普通生成的发票要填写rd,还要写上 入库单号也就是普通挂 ...

  9. Hibernate双向关联导致Java对象转换为JSON字符串时死循环问题的分析与解决方案

    引言: 本文描述了在SSH框架中,多个持久层对象相互引用,从而引发分页查询中,查询所得的持久化对象转换为JSON字符串报错的原因及解决方案 使用EasyUI框架的小伙伴们都知道,在使用datagrid ...

  10. Android 13 - Media框架(19)- ACodec(一)

    关注公众号免费阅读全文,进入音视频开发技术分享群! 这一节我们将会一起了解 ACodec 的设计方式,在看具体的实现细节前我们要先了解它内部的状态转换机制,这也是ACodec的核心难点之一. 1.AH ...