如何高效实现矩阵乘?万文长字带你从CUDA初学者的角度入门

OpenCV学堂 2022-11-28 10:55

点击上方↑↑↑OpenCV学堂”关注我

来源:公众号 机器之心 授权

本文将从一个 cuda 初学者的角度来阐述如何优化一个形状较大的正方形乘正方形的 FP32 矩阵乘。


矩阵乘作为目前神经网络计算中占比最大的一个部分,其快慢会显著影响神经网络的训练与推断所消耗的时间。虽然现在市面上已经有非常多的矩阵乘的高效实现——如基于 cpu 的 mkl、基于 arm 设备的 ncnn 与 emll、基于 cuda 的 cublas ——掌握了矩阵乘优化的思路不仅能帮助你更好的理解编写高性能代码的一些基本原则,而且许多神经网络加速领域进阶的技巧如算子融合都是与矩阵乘交互从而达到更高的性能。


由于矩阵乘的性能优化与两个矩阵的形状有着非常密切的联系,因此,为了降低本文的撰写难度(以及辅助读者更好的理解矩阵乘优化),本文将从一个 cuda 初学者的角度来阐述如何优化一个形状较大的正方形乘正方形的 FP32 矩阵乘。同时本文按如下顺序讲解:


  • Goals:本文的目标是什么?

  • Performance:我们达到了多少性能?

  • 朴素 GEMM 与前置知识:简单介绍一下我们的任务是什么,我们需要提前了解什么。

  • Tiling:如何做矩阵分块?即如何将一个巨大的矩阵乘任务合理的分配到 GPU 的不同线程上。

  • Thread 级优化:在 Thread 这个维度,我们能做什么优化?

  • Warp 级优化:在 Warp 这个维度,我们能做什么优化?

  • Block 级优化:在 Block 这个维度,我们能做什么优化?

  • Epilogue:尾声。


Goals


首先明确一下本文的目标是:


• 实现一个比 cublas 更快的形状较大的正方形乘正方形的 FP32 矩阵乘。

• 从理论角度与硬件规格能够简单的推导矩阵分块与排布的方法。

• 可以大致清楚各个优化技术效果的阶段性的 benchmark。

• 如何使用 Nsight Compute 等性能分析工具分析潜在的性能瓶颈。


本文不含:


• 使用 Tensor Core 加速矩阵乘。(这也是为什么这篇文章叫传统 CUDA GEMM)

• 使用安培架构新提出的 async memcpy。

• CUDA 语法知识。

• 汇编。(主要是现在并没有官方支持汇编的操作,目前的汇编器几乎都是逆向的产物,不是很稳定。同时汇编带来的好处如消除寄存器的 bank conflict nvcc 也在不断的做相应的改进,因此就不介绍了)


开源地址:https://github.com/AyakaGEMM/Hands-on-GEMM


同时本文在相当程度上参考了李少侠的 GEMM 优化指南(写得非常!非常!非常!不错),本文的优势在于补了阶段性代码和在某些少侠一笔带过的地方做了一些扩展。


Performance


为了让大家更有动力阅读下去,这里先放出来性能效果!


测试平台


• 系统:Arch Linux

• 驱动:520.56.06

•CUDA:11.8

•GPU:Nvidia RTX 2080



测试结果



我们也可以注意到,在较大形状上手写的矩阵乘有着与 cublas 相近,甚至更优的性能。



从这张图我们可以看出,手写的矩阵乘能够达到硬件 95% 的峰值性能,效果还是很不错的。


朴素 GEMM 与前置知识


首先写一个朴素矩阵乘。


# 数组 A:M 行 K 列的行主序矩阵# 数组 B:K 行 N 列的行主序矩阵# 数组 C:M 行 N 列的行主序矩阵# alpha:一个标量# beta:一个标量# 计算方法:#    c=alpha*A*B+beta*C;
__global__ void matrixMul(const float *A, const float *B, float *C,
                          int M, int N, int K, float alpha, float beta){    int tx = blockIdx.x * blockDim.x + threadIdx.x;    int ty = blockIdx.y * blockDim.y + threadIdx.y;    int baseX = blockIdx.x * blockDim.x;    int baseY = blockIdx.y * blockDim.y;    float c = 0;    if (tx < M && ty < N)    {        for (int i = 0; i < K; i++)        {            c += A[tx * K + i] * B[i * N + ty];        }        C[tx * N + ty] = beta * C[tx * N + ty] + alpha * c; // we multiply alpha here to reduce the alpha cal num.    }}


这里 GPU 矩阵乘与 CPU 矩阵乘最大的区别就在于 GPU 可以为目标矩阵 C 中每一个元素分配一个 thread 进行计算。这也是可以切实的感知到 GPU 多线程编程的一点。但这个矩阵乘的朴素实现会非常慢,而分析性能瓶颈中最常见的两个指标即是带宽和延迟。这里借用 Nvidia 在 GTC 2018 上的分享来做说明。


这里以一个自动扶梯作为例子来讲解。


  • 带宽:指这个自动扶梯每秒能够运送多少个人,以这张图为例子,这个扶梯每秒能运 0.5 个人,这就是这个自动扶梯的带宽。

  • 延迟:指一个人踏上这个扶梯直到他被运到顶所需要的时间。同样以这张图为例,这个扶梯需要 40s 的延迟。


那么回到指令上来,每一个指令都有对应的延迟和带宽,而以朴素矩阵乘为例,每一个乘法运算需要读两次内存和一次 FFMA,假如没有其他额外的优化(如循环展开与指令重排),相当于是两个级联的自动扶梯,一个负责运送数据,一个负责做数学运算。假设数据运送扶梯的带宽与延迟与图中一致而不考虑 FFMA 的带宽与延迟,那么一次 FFMA 需要等待 40s(扶梯延迟)+ (1/0.5)s(第一个数据到达后第二个数据到达的时间)才能拿到所需的数据,这与扶梯的带宽 0.5s / 人的峰值性能相去甚远。那么此时这个 kernel 就完全被延迟卡住了,而无法发挥出应有的性能。


而对于带宽部分,这里我们引用李少侠的带宽分析:


对于 FP32 数据,如上图所示,一个 warp 一次做 32 次 FFMA,对应 64 OP,需读取 A 矩阵 1 个元素和 B 矩阵 32 个元素,共 132byte。 通过寄存器累加,且忽略 C 矩阵写回开销,那么计算访存比为 64OP / 132byte = 0.48。虽然 dram 最小访问单位为一个 memory transaction,但考虑到 L1 cache 的存在也不会影响实际的计算访存比。
通过 repo 中提供的 l2cache_bandwidth.cu 可测得 Titan V L2 cache 带宽约 1.9TB/s,那么最乐观的结果即使 L2 cache 100% 命中,此方案的理论上限也只有 1.9T * 0.48 = 0.912 tflops,远低于 14.9 tflops 的硬件算力。


由此我们可以看出,朴素的矩阵乘实现方法无论从延迟和带宽上都无法满足需要。



这里一个 warp(即 32 个线程)是指 GPU 调度线程的粒度,可以简单的理解为同一个 warp 内的线程总是同时运行、同时休眠的。当然这种说法并不完全准确,毕竟还有 warp divergent 问题,感兴趣的同学可以自行了解。但总之,思考 GPU 执行时总是从 warp 的角度思考是非常合理的。那么对于一个 warp 而言,我们可以根据李少侠的分析看出,就算我们假设延迟能够被完全覆盖,这种分配方案也并不能达到硬件的峰值性能。


这里我用自己的话总结一遍就是:在每个线程执行指令设计时,需要尽可能的覆盖掉每个指令的延迟;在性能分析时,则从带宽角度分析矩阵分块是否合理


而在于延迟部分还有一顿免费的午餐。在实际应用中,编译器会自动的做一些优化,如循环展开与指令重排等。例如展开循环后可以将多个读取 A 矩阵的元素和读取 B 矩阵的元素排在一起,使得取数据的自动扶梯能够一次多上几个人,从而去覆盖掉扶梯的延迟。而且 GPU 与 CPU 还有一个非常不同的地方在于,GPU 的线程切换代价非常低,因此可以在等待延迟的时候转而去运行其他线程从而达到延迟覆盖的目的。还是以扶梯为例子,GPU 上有很多个扶梯,在等待第一个人到达扶梯末尾时,GPU 可以转到第二个扶梯送几个人上扶梯。理想情况下,在 GPU 送完第 N 个扶梯的人时,第一个扶梯的人刚好达到扶梯顶部,那么这个运送的延迟就被覆盖掉了。


Tiling


矩阵乘分块是为了将一个大问题化解为小问题求解,这里 CPU 与 GPU 分块的需求也不尽相同。CPU 是希望保持计算的局部性,可以充分利用 L1、L2 高速缓存来避免缓慢的内存访问。而 GPU 在此基础之上还需要将一个大问题合理拆分到不同的 thread 上,使得其能够充分利用 GPU 上的硬件资源。下面我将从局部性和合理拆分两个方面讲解如何做矩阵分块。


局部性原理


局部性原理,在我的理解中便是为了能够尽可能的使用高速缓存器内的数据进行运算所提出的一个程序设计理念。由于高速缓存器往往十分昂贵(或者需要很高的功耗),因此空间都不大。由此我们需要尽可能的将一些重复访存聚合起来,放到高速缓存器里面来加速数据访问,或者在进行访存的时候尽可能连续访存来使用 cache 加速访存。我们先还是让每一个 thread 负责一个目标矩阵元素的计算。虽然这种分配方式十分朴素、十分直接,同时各个 thread 之间也没有数据依赖关系,不需要做额外的同步之类的操作,但这种分配方式却是十分访存不友好的,因为每一个 thread 都是直接与内存做交互,而 GPU 的全局内存访问带宽完全不足以匹配上它的计算速度。


同时我们注意到处于同一行的 thread 总是会同样的读取 A 矩阵的同一行数据;同一列的 thread 总是会读取 B 矩阵的同一列数据。那么一个非常自然的想法则是对于每一个 Block,我们将数据移动到这个 Block 共享的一块高速存储区 shared memory 上,从而减少与全局内存交互的次数。同时我们考虑到 shared memory 的容量有限,因此可以一次只取一部分 k,然后通过循环迭代的方式完成这个 Block 所负责的矩阵乘区域。



值得一提的事,shared memory 虽然叫做 memory,但他却有着非常高的访存速度与极低的延迟。实际上,shared memory 可以被看作是一块可以显式控制的 L1 cache。从图灵架构开始,在硬件上 shared memory 与 GPU 上的 L1 cache 共享同一块区域,同时 shared memory 与 Load/Store 单元交互也是直连的(没有中间商赚差价)。



在将一个大型矩阵乘划分为一个个由 Block 负责的小型矩阵乘之后,我们接下来还需要把一个 Block 负责的矩阵乘分配给 Block 内部的 warp;分配到 warp 之后我们还需要把一个 warp 负责的矩阵乘分配给 warp 内部的 thread。经过这么一步一步的划分,我们便可以把一个巨大的矩阵乘任务高效的分配到各级速度不一的存储器上,最终尽可能打满硬件峰值性能,实现高效矩阵乘。有了前面划分 Block 的经验,我们也就可以依葫芦画瓢,实现大矩阵的拆分(Tiling),在此就不过多赘述了,最终整体流程图如下。



当然这只是一个较为粗糙的流程图,例如每一个 thread 负责的分块也并不是图中所示的连续一块矩阵乘,我们也将在后续一步一步完善细节,但这种分解的框架却是一种非常经典的思路。


如何确定分块大小?


在拥有分块的基本理念之后,我们还有一个问题没有解决。那便是每一个 Block 该负责多大的矩阵乘?每一个 thread 又应该负责多大的矩阵乘?为了让文字变得清晰起来,我们定义每一个 Block 负责的矩阵大小为,每次迭代 的 k 维数据,每一个 warp 负责的矩阵大小为,每一个 thread 负责的大小为。其中这些符号都在上图出现过,可以自行对照一下。


这里我们同样引用李少侠的计算访存分析:



假设我们不考虑 shared memory 的访存代价(因为可以做到覆盖掉shared memory 的访存延迟,而且其带宽能够满足 FFMA 单元的计算速度),只考虑全局内存的访问,可以看到选择在 K 上缩水(即不把整个 K 维度都放到 shared memory 里)还是比较合理的,因为 的大小其实并不影响计算访存比。而对计算访存比有决定性影响的是每一个 Block 计算的大小。如果取 为 64,带入 RTX 2080 的数据,可以得到 10.1 Tflops / 16 = 631.25 GB/s。即内存访问带宽达到 631.25 GB/s 就能避免内存访问瓶颈了。同样,我们取 L2 命中率为 20%(还是比较好达到的),加权内存访问带宽为:,即可避免内存访问瓶颈。


那是否我们只要取分块大小为 64x64 就万事大吉了呢?也不尽然。我们前面只分析了带宽,而在延迟无法被覆盖的情况下,整个 kernel 性能也不会太好。而更大的分块意味着每一个 thread 会计算更多的数据,可以使用一些手段实现更优的延迟覆盖。这一点会在后面讨论如何具体实现,大致思想也是局部性的原理,只不过这次是将数据从 shared memory 保存到寄存器,从而实现使用更高速的缓存计算的目的。


那是否我们取分块越大越好呢?那也不一定。更大的分块使用了更多的寄存器,从而使得同一个 SM 能够同时承载的线程数变少,这里 Nvidia 将之称为 Occupancy。如前文所述,当一个 warp 被卡住时,GPU 可以切换到另一个 warp 执行指令,Occupancy 越低,可供 GPU 切换的线程就越少。


而 Occupancy 也是和硬件强相关的。一个 GPU 由多个 SM 构成,每一个 SM 拥有有限的寄存器数量、 shared memory 和最大可调度线程数量。而 Occupancy 是指每个 SM 能够同时调度的线程数量除以一个 SM 的最大可调度线程数量。关于 Occupancy 的计算我们可以通过在编译时添加 --ptxas-options=-v 参数,使编译器在编译时输出每个 kernel 所花费的寄存器数量和 shared memory,然后通过随 cuda 提供的一个 excel 表格进行计算。(尽管这个 Excel 已经 deprecated 了,但他用起来确实挺方便的。)


例如我们每个 thread 需要 128 个寄存器,2048 bytes 的 shared memory,那么由于 RTX 2080 每个 SM 只有 65536 个寄存器,因此每个 SM 最多只能同时跑 512 个 threads。又因为每个 SM 最多能够承载 1024 个 threads,所以此时 Occupancy 为


值得一提的是,虽然较高的 Occupancy 使得在一个线程卡住时,SM 能够马上切换到别的线程,通过将其他线程需要执行的指令填充到流水线中从而达到覆盖延迟的目的,但这并不代表高性能。例如,如果每一个线程本身就能够通过更多的寄存器占用从而达到延迟覆盖的目的,自然也就不需要 SM 来做这件事了,反倒是如果无脑的去提高 Occupancy 使得一些 thread 内的延迟甚至都无法被 SM 通过切换执行线程的方式覆盖,那属实是得不偿失了。


因此,我们能够做的就是在有一定理论分析的情况下确定好一些矩阵的分块大小的方案,然后要不就是经验性的去选择最终用哪个分块,要不就是跑一个 profile 来直接得到最快的分块。这里由于已经有非常多的先例证明了 128x128x8 是一个较优的选择,因此本文则遵从这个分块方案。那么,目前我们能够确定的分块如下表。



当然有些同学可能会问,既然最终还是需要用跑 profile 的方式来确定最优分块,那理论分析还有什么意义呢?答案就是如果提前通过理论分析,那么就能够在一定程度上缩小需要跑 profile 的分块数量。用算法上的语言来讲就是如果我们将需要搜索的所有分块作为搜索空间,那么理论分析便是搜索算法中的 A* 算法,你掌握了越多的理论分析知识那么这个搜索过程就会越高效。同时对 CUDA 底层越了解,在同一个分块策略下,你更容易写出能达到理论性能的 kernel。


Thread 级优化


对于一个 thread 能做的优化其实并不多,因为 GPU 是以一个 warp(即 32 个 thread)进行调度的,所以许多基于单线程的优化,如访存优化,其实并不能直接套到 GPU 上。而为数不多值得一提的优化手段便是单个线程在计算时应该采用向量内积还是向量外积以及 double buffer。但实质上向量外积严格意义上也不能算作是一个优化,因为这一步编译器就能在编译阶段帮忙做了。之所以提一句是还是为了给 double buffer 做铺垫,即我们应该怎么预取数据。


首先我们取了 128x128 的分块策略,一个 Block 有 256 个线程,那么每个线程需要负责一个 8x8 的矩阵乘运算。而一个线程完成一个小型矩阵乘有两种实现方法。


向量内积


向量内积的实现方法如图所示,即将 A 矩阵拆分为多个向量、B 矩阵拆分为多个向量,这些向量通过向量内积的方法求得最终答案。



用代码描述如下:

M=N=K=8;float a[M*N];float b[N*K];float c[M*N];for i in range(M):      for j in range(N):           for k in range(K):                  c[i*N+j]+=a[i*K+k]*b[k*N+j];


向量外积


向量外积的实现方法如图所示,即将 A 矩阵拆分为多个向量、B 矩阵拆分为多个向量,这些向量通过向量外积的方法求得最终答案。



用代码描述如下:


M=N=K=8;float a[M*N];float b[N*K];float c[M*N];for k in range(K):      for i in range(M):            for j in range(N):                   c[i*N+j]+=a[i*K+k]*b[k*N+j];


可以看到,向量内积和向量外积的区别在代码上仅仅体现在循环方式上。


为何我们需要关心这个?


有做过 CPU 矩阵乘优化的同学可能知道,仅仅调整循环顺序就已经能够带来显著的性能差异了。有许多分析都是从局部性的角度进行分析的。即使用向量外积的方案可以利用到循环遍历的局部性,将一些重复访存使用寄存器缓存而避免无意义访存。例如我们补充一下采用向量外积方案关于寄存器的细节。


float a[M*N];float b[N*K];float c[M*N];for k in range(K):       regB[0:N] = b[k*N:(k+1)*N]       for i in range(M):              regA = a[i*K+k];              for j in range(N):                     c[i*N+j]+=regA*regB[j];


其中 regA 和 regB 均为寄存器。其中我们不难发现,对于每一次循环 j ,使用的都是完全相同的 A 矩阵里的元素,因此可以用一个寄存器来缓存该值;对于每一次循环 k,使用的都是完全相同的一行 B 矩阵中的值,因此我们可以用 N 个寄存器缓存该值。于是将原本 次访存(底下两层循环需要访问一次 A 矩阵和一次 B 矩阵),通过使用 个寄存器缓存(B 使用 N 个,A 使用一个),优化为 N+M 次访存。同时我们也注意到, M 和 N 越大的情况下,提升效果越发显著,这也是为什么我们希望每一个线程负责的分块大一点比较好。但同时 M 和 N 越大,每一个线程多使用的寄存器就越多,而在 GPU 的语境下,更高的寄存器占用意味着更低的 Occupancy。因此当 M 和 N 大到 shared memory 带宽不是性能瓶颈即可。更详细的分析可以看李少侠的分析。


而我则从循环展开的角度解释一下为什么我们需要了解这个优化方案,同时解释一下为什么该优化方案在 GPU 上并不如 CPU 上那么有效。从循环展开的角度来看,第二种循环体构造与第一种循环最大的区别就在于它能在不展开 k 的情况下通过展开 m 和 n 处的循环就能自动的识别到重复访存,并使用相应的寄存器来避免重复访存。例如我们假定,那么展开 m 和 n 处循环的结果如下。


M=N=2;float a[M*N];float b[N*K];float c[M*N];for k in range(K):       c[0*N+0]+=a[0*K+k]*b[k*N+0]       c[0*N+1]+=a[0*K+k]*b[k*N+1]       c[1*N+0]+=a[1*K+k]*b[k*N+0]       c[1*N+1]+=a[1*K+k]*b[k*N+1]


只要是稍微现代一点的编译器,都能一眼看出这四条指令的 8 次访存,有 4 次是可以合并的。同时现代一点的编译器也能在一定程度上根据生成的汇编交叉排列计算和访存达到延迟覆盖的目的。而向量内积的方案需要把整个 k 维度展开才能看到这些潜在的访存合并机会。在 CPU 矩阵乘的语境下,一般计算 kernel 的 都比较大(好几百),而  都很小(一般取 6x16,根据架构来做具体确定),寄存器数量又非常少,因此基本上无法在 K 维上将循环完全展开并做优化。因为展开一个超长的循环不仅会带来额外的寄存器占用、优化难度,还会带来更多的汇编指令,使得最终的二进制文件臃肿不堪。但在 GPU 上,情况却恰恰相反。对于已知循环次数的小循环,即便你没有指定 #pragma unroll,nvcc 也会自动的展开这些循环。而对于一个 thread 所负责的小型矩阵乘,这三层循环的值均为 8,符合 nvcc 自动展开循环的条件。而在展开完成后,nvcc 会对所有的访存以及计算指令重排得到一个不错的汇编指令排列。


那么这就引出了下一个问题,我们为何还需要关心他究竟是向量内积还是向量外积?


答案就是 double buffer。如果我们能够提前知道一个循环需要什么数据,我们就能提前预取该循环第一次所需的数据,同时在该循环进行运算的时候预取下一次计算所需的数据。而显然这在向量内积的情况下是无法做到的。同时由于 double buffer 需要额外的寄存器保存从 global memory 转移到 shared memory 的数据,所以当一开始循环展开使用的寄存器过多时,尽管后续能优化到较少的寄存器,但编译器依然无法正确的在限定寄存器数量下实现 double buffer。这一点在优化 sgemm 的时候并不是那么重要(因为多使用一点寄存器也就从每个 SM 跑两个 block 变为一个 block),但是在优化 int8 矩阵乘时需要额外的关注(因为本身它就只能在一个 SM 上跑一个 block,如果实现不得当将会完全失去 double buffer)。


那么此时朴素的利用到向量外积和 shared memory 的代码:https://github.com/AyakaGEMM/Hands-on-GEMM/blob/main/src/cuda/shared_mem_gemm.cu


Double Buffer


由于 GPU 没有 prefetch 这种指令,同时我们又有 shared memory 这种可编程的 L1 cache,因此需要手动实现 prefetch 功能,而在 GPU 语境下一般被称作 double buffer。double buffer 的好处自不必多说,即它可以实现数据读取与计算在时间上重叠,利用 FFMA 单元与 Load/Store 单元可以并行执行指令的特点,达到覆盖延迟的目的。而尽管 GPU 可以在一个 warp 有延迟的情况下,通过切换去运行另一个 warp 达到延迟覆盖到目的,但由于可供 warp 调度器能切换到线程数量的限制,过于长的延迟并不能通过这种方式覆盖掉。这里引用一下李少侠更详细的分析:


若每 SM 有 4 个调度器,若每个调度器只有 4 个可调度 warp,当指令平均间隔超过 4 cycle 后就无法靠 warp 调度掩盖延迟了。考虑到 GEMM 中涉及 smem 读写的过程需要同步 thread block,进一步限制了 warp 调度空间,所以很难靠 warp 并行掩盖延迟。


而本文最终实现的 kernel occupancy 只有 50%,即每个 SM 只能调度 512 个 threads(16 warps),加上图灵架构每 SM 有 4 个 warp 调度器,最终结果与李少侠分析的一致。因此 double buffer 从指令角度提供的延迟覆盖方法最终还是会有效的。


但值得一提的是,在你自己动手实践时,尽可能的考虑在其他优化已经加无可加的情况下再加入这个优化。这是由于这个优化会大幅修改数据读取部分的代码,而且还会产生重复代码,不利于代码维护。同时在我自己的实践中发现,如果在一开始 kernel 写的比较垃圾,加了 double buffer 也没有什么卵用,还会让后续的优化不太好加上去。当然,这只是我的个人建议,如果你想实际看看 double buffer 的效果也可以一开始就加上去。


首先我们看一下每个 thread 的运行流程。



那么能实现 double buffer 的机会有两个地方:Global Memory to Shared Memory 与 Shared Memory to Register。即在每一次 FFMA 开始之前我们读取 Global Memory 的数据到寄存器中,在 FFMA 之后将该寄存器中的值写到 shared memory 中。由于在读取数据后 load from shared memory 以及 FFMA 两个流程中我们并不依赖于该寄存器中的数据,因此可以覆盖 Global Memory 的读取延迟。而同时在计算每一次 FFMA 之前,我们可以用寄存器提前取下一次 FFMA 需要的数据,也就能做到覆盖 shared memory 的延迟。


大概就是这样!我们在每一次运算之前提前将第一次循环所需的数据移动到寄存器中,这样我们就可以实现数据运算和数据存取指令级并行的功能了。


Warp 级优化


在做了不少铺垫之后,接下来的优化终于是可以带来一些看得见的性能提升的了。首先回顾我们之前的代码,可以看到每个 thread 负责的部分完全没有考虑到它们之间可能的协作关系,即同一个 warp 内的 thread 此时在同一块硬件上同时执行——它们共享同一个 register file,这表明它们可以通过寄存器快速共享数据(即 shared memory 的 broadcast 机制);它们会同时访存,这表明如何安排每一个 warp 内的 thread 访存是至关重要的。


Warp Tiling


已知我们指定一个 Block 计算 128x128 的矩阵,一个 Block 有 8 个 warp,一个 warp 有 32 个 thread,每个 thread 需要负责 8x8 的小型矩阵乘,那么我们沿用李少侠的定义:


一个 warp 由个线程组成,可以是 ,我们把这些线程对应的 thread tile 拼在一起的区域称为 warp tile,尺寸为,如下图所示。



这里的图给的是 的排列方式。由于同一个 warp 在访问 shared memory 时有 broadcast 机制(即同一个 warp 在访问同一个内存地址内的值时只会实质发生一次数据读取),因此这一个 warp 计算时只会实际读取 个 float。与之相对的,这个 warp 会进行 次 FFMA。不难看出,在 固定为 32 的情况下,与 越相近,计算访存比就越大,因此取 最为合适。


而在确定了 warp tiling 后,如何读取和存储数据的细节还需要细扣,接下来我将会按照 GPU 的硬件特性讲解读写数据的细节。但这一部分的大致思路基本已经介绍完毕了,动手能力强的同学现在就可以自己试试如何写一个高效矩阵乘了!


向量化访存


向量化访存即是一条指令同时请求多个 float 数据,目前 CUDA 最高支持 128 bit 的向量化访存,即一条指令请求 4 个 float 数据。向量化访问主要的好处在于可以用更少的指令读取更多的数据。由于在访问全局内存时是以 32 Byte 为粒度进行访问的,因此如果同一个 warp 内的 thread 请求了一段连续内存的数据,每一个 thread 都请求两次 4 Byte 的数据(小于 GPU 全局访存的最小单位),那么 GPU 会在硬件处将 64 次数据请求按照 32 Byte 进行合并,最终形成 8 次 32 Byte 内存访问。



而如果每一个 thread 请求 8 Byte 数据,那么 GPU 会在硬件处同样将 32 次数据请求按照 32 Byte 进行合并,最终形成也形成 8 次 32 Byte 内存访问。



那么我们可以看出,对于访问同一数据量的数据,请求的指令越多,GPU 的聚合访问的压力就会越大。在极端情况下,尽管带宽足够,但大量的访存请求会塞满访问队列导致 stall。这在 Nsight Compute 中显示为 MIO Throttle 和 LG Throttle,即对应 shared memory 和 global memory。因此采用向量化访存能在一定程度上缓解 GPU 硬件层面的聚合访存压力(因为我们显式的用指令告诉 GPU 某些数据请求不需要聚合,直接用一个 sector 来处理就好了)。


但使用向量化访存——即用 float4 读写数据——也不是完美的。它的一个严重缺陷在于使用 float4 访存要求请求的数据地址要按照 float4 对齐,因此当 M、N、K 不为 4 的倍数时将会报 missaligned address 错误(因为第二行开始就不能按照 float4 对齐了)。


这么干对输入矩阵形状有一定要求,写出来的矩阵乘没有特别好的通用性。同时 sgemm 受聚合访存的影响也并不是那么大,因此在实操中往往并不会选择使用 float4 读写全局内存,而只会使用 float4 读写 shared memory。但由于我一开始学 CUDA 的时候对这一块理解也不深,然后发现许多人(李少侠除外)都很暴力的直接用 float4 读写全局内存,于是我也用了 float4 读写全局内存。


而我们这里对比李少侠的 kernel profile 和我们最终的成品发现,在 global memory 读取处是否使用向量化读取其实并不会对性能有多少影响。可以看到最终 profile 出来的 Stall LG Throttle 和 Stall MIO Throttle 占比都不高。




上图为李少侠的 kernel 下图为我最终写的 kernel。这两个 kernel 在数据读取方面的区别就是李少侠是以 4B 为单位访存的,而我是以 16B 为单位做访存的。这进一步印证了 sgemm 其实并不是非常关心读取 global memory 时是以怎样的粒度读取的。而向量化访存对于 shared memory 的影响就留给读者自行验证了。同时值得注意的是,在把数据读取方式从向量化访存修改为一个一个访存时需要注意 bank conflict 的问题。因为一个 warp 在执行 128-bit load 和 32-bit load 时的调度并不相同(这点会在后面提到)。


还有一个值得注意的是在 Global Memory 访存时,并不能直接将原先的向量化存取代码直接改成一个一个的读取。因为这样访存从原来一个 warp 并行访问一段连续的内存变成一个 warp 分成四次访问不连续的内存。虽然有 L2 cache 来平滑这种不规则的访存,但最终会带来 10% 左右的性能下降。代码如下:


// Original CodepreA = *reinterpret_cast<const float4 *>(baseA + i + rowA * K + colA);
// Modified CodepreA.x = baseA[rowA * K + i + colA];preA.y = baseA[rowA * K + i + colA + 1];preA.z = baseA[rowA * K + i + colA + 2];preA.w = baseA[rowA * K + i + colA + 3];



可以看到这种简单的更改其实并不可取,更优的写法是每一条指令都是在 warp 视图下的连续访存。


Global Memory


前面提到 GPU 访存时以 32 Byte 为粒度进行访问的,那么一个 32 Byte 访问被称为一个 sector。那么值得注意的就是在搬运数据时,尽可能的让同一个 warp 搬运同一行的数据来避免使用额外的 sector(本文采用现代的行主序来存储矩阵)。



这里借用一下 Nvidia 的图。如果同一个 warp 内的 thread 都访问每一行的开头,那么如果一行超过 8 个 float,那么每一个 thread 都需要一个 sector 去请求它们需要的数据,这就造成了 sector 浪费。而实际中每一行的元素往往都会大于 8 个 float,因此会有非常大的性能损失。下图为一个 warp 在拷贝时,每个 thread 之间间隔的大小,单位为 float。可以看到在间隔为 2 时就已经有一半的性能损失了,这很不好。



因此我们采用下图所示的访问方式。即尽可能的让一个 warp 中的 thread 连续的读取 Global Memory 中的元素。



Shared Memory


前文已经讲过,shared memory 在图灵架构之后可以完全被看作是 L1 cache。而在此基础之上,shared memory 的访问粒度是 32 bit 也就是 4 Byte,刚好是一个 float 数据的大小。而后 shared memory 按照 4 Byte 连续的划分为一个个 bank。对于 bank 可以简单的理解为双通道内存中通道的概念,即在不同的 bank 中的数据可以并行访问,同一个 bank 内不同地址的数据只能串行访问。在 Compute Capability 5.x 及之后的卡上,shared memory 具有 32 个 bank,刚好是一个 warp 中线程的数量。而如果同一个 warp 中不同 thread 均只访问 4 Byte 数据且希望同时访问同一个 bank 的数据将会有两种结果。(对于每一个 thread 访问更多数据的行为将在后面提到)


1. 两个或多个 thread 访问的刚好是同一个地址内的数据,那么此时将会触发 broadcast 机制,即实际只读取一次数据,而后广播到这些 thread 中。

2. 两个或多个 thread 访问的是同一个 bank 内的数据,那么此时这些 thread 的访问将会被强制安排为串行执行。这种访问情况被称为 bank conflict。


这里给出 cuda programming guide 的两张图来直观的体现 broadcast 和 bank conflict。



这张图表示同一个 warp 中的 thread 按不间隔、隔一个、隔两个 bank 对 shared memory 访问。中间的访问每两个 thread 都会发生一次 bank conflict,而其他两种访问都不会发生 bank conflict。值得注意的一点是这张图最右侧的图的访问方式刚好可以达到每一个 thread 都访问了不同的 bank 的效果。


同时考虑到 shared memory 是按照 bank 来访问的,且与 Load/Store 单元直连,并没有中间商赚差价,所以对于 shared memory 的访存并不讲究连续访存,而只需要考虑是否有 bank conflict 就足够了。因此理论上最左和最右两列图的访问性能是一样的,这与访问全局内存有一点区别。同理,每一个 warp 连续的多次访存也并不要求连续访存,而在拷贝数据到 shared memory 时对 A 矩阵做矩阵转置的目的是为了向量化访存,而不是为了连续访存。



这张图则展示了 broadcast 机制,没啥好说的。


128-bit conflict-free store


而前文中提到,我们使用 float4 来做数据传输来缓解 GPU 聚合访问的压力,使得每一个指令都更加高效。而又因为前文所述,每个线程需要使用向量外积的方法计算矩阵乘,因此我们需要在 A 矩阵转存到 shared memory 时做一次转置。


但细心的同学可能注意到,如果就这么平铺直叙的做转置那么将会发生非常严重的 bank conflict,因为一个 warp 内的奇数 thread 和偶数 thread 使用同一个 bank。那么此时解决 bank conflict 的方法有两种,第一种便是将 shared memory 的 k 维度缩小,然后直接把奇数 thread 所取的数据直接并到 M 维上,就不会有 bank conflict 的问题了。这种方法通过 index 变换,直接就能避免 bank conflict,非常巧妙,而我当时没有想到,就没有用这种方法。值得注意的是,尽管图是按行隔开的,但那只是为了表示数据是如何在一个 thread 里保存的,实际写到 shared memory 中是以一个 float 为单位,按列主序存储到 shared memory 中。



而第二种方法就非常简单粗暴了,直接往 lda 上加 4,然后就不会有 bank conflict 了。当然这种方法的弊端也是有的,那就是会造成一部分 shared memory 的浪费。但对 sgemm 来说倒也还好, shared memory 的占用也不是导致 Occupancy 降低的原因,所以我就用了这个方法。


128-bit conflict-free load


而我们把数据存储到 shared memory 之后,下一步便是考虑如何在没有 bank conflict 的情况下将数据读取出来。在本文中,我们取为 8,在采用向量化存取时,直接按照 Warp Tiling 采用朴素的存取方法就能在没有 bank conflict 的情况下把数据读出来了。



当然有的同学可能会问:既然访存是按照一个 warp 为单位进行的,而图中明显读取 B 矩阵时,t16 会和 t0 发生 bank conflict,那为什么又说不会有 bank conflict 呢?那么答案就是在做 128-bit 访存时,warp 并不是同时读取数据的。这里还是借用 Nvidia 在 GTC 2018 上的分享来做说明。



当 warp 中每个 thread 只读取 4B 或更小数据时,warp 才是同时读取的。而本文中采用 128-bit 也就是 16B 读取,那么一个 warp 会分成 4 次操作读取,每次操作只有 1/4 warp 工作。那么只要同一次操作内的 thread 没有发生 bank conflict,那么就没有 bank conflict。而上图中 t0-t7 同时操作,它们之间并没有 bank conflict,后面的 thread 依此类推,那么也就不会有 bank conflict。那么朴素的 warp tiling 实现代码在这:https://github.com/AyakaGEMM/Hands-on-GEMM/blob/main/src/cuda/warp_tile_gemm.cu


而李少侠在代码中采用了一种更高级的排布方式,即 z 字排布。与之相对应的,他将一个 thread 负责的小型矩阵乘拆分成四个更小的矩阵乘。同时这个拆分虽然是在地址上做的拆分,但在运算中依然可以看作是一个整体,即运算部分不用更改任何代码而只需要在 index 上做一些变换即可。而他这么做的理由是为了更快的 broadcast。但说实话,我不是很理解,也没搜到为什么这样能有更快的 broadcast 性能。(而且我这么试了一下,发现确实是快了,这实在是太神奇了,欢迎大家提供一些看法。)



这里我们跑一个 profile 发现,确实是没有 bank conflict 的,挺好。代码在这:

https://github.com/AyakaGEMM/Hands-on-GEMM/blob/main/src/cuda/z_thread_map_gemm.cu



Block 级优化


Block 在 GPU 上基本等同于不同的 kernel 在 GPU 上运行了,所以它们之间的联系并不是特别强烈。而它们之间的相互关系在 GEMM 语境下基本就只有 wave 和 L2 cache(一个 wave 里的 Block 共享这一块 cache)了,良好的 Block Tiling 能提升相当可观的 L2 cache 命中率。


但这一部分属于 sgemm 并不是特别关心的部分,因为本身 FFMA 单元算的就不是很快,所以 Block Tiling 随便搞搞就能够满足 FFMA 单元的带宽和延迟需求了。因此,这一节的内容主要是为了有些有用到 tensor core 的同学提一个需要注意的性能提升点,其次就是有些同学可能会发现自己写的 kernel 可能会比本文中的示例慢一点(大约 10% 左右),因此在此提一下在 sgemm 中应该怎么随便搞搞 Block Tiling。


Wave & L2 cache Hit Rate


首先明确一下 wave 的概念,即一个 GPU 上能够同时运行的 Block 数量。关于 GPU 是如何决定一个 wave 由哪些 Block 组成的我并没有找到非常明确的文档说明,但我一拍脑袋想,说不定就是朴素的按顺序决定的,即 index 处于 范围内的 Block 处在第一个 wave 中,后面的 Block 依此类推。后面试了试好像的确是这样划分的。


在明确了 wave 的概念后,我们便可以对 L2 cache 命中率做一个简单的分析了。我们指定 代表一个 wave 同时运行的 Block 数量,假设一个 wave 刚好能计算 C 矩阵的整数行,那么我们不难发现对于一个 wave 而言,它需要从 Global Memory 中读取 个 float。但由于有 L2 cache 的存在,假设一个 wave 读取的数据全能被 L2 cache 装下,那么实际只读取了 数据。最终 L2 cache 的命中率为:


即  差距越大,L2 cache 的命中就越低。那么如果想要去优化 L2 cache 命中,一个比较直接的想法就是尽可能把一个 wave 的 Block 变成方的。但就算不搞,sgemm 也不在乎,因为其实对性能来讲并没有什么区别,所以就没搞。



SGEMM Block Tiling


而在 sgemm 的语境下,假设最坏的情况即一个 wave 都不能覆盖目标矩阵 C 的一行,且 RTX 2080 有 46 个 SM,一个 SM 能跑两个 Block,此时




带入上式可得,此时 L2 cache 命中率大概是 49.4%。这里我们并没有考虑访问 C 矩阵的影响,在实践中会把 L2 cache 的命中率拉低一点。但即便是如此,前文我们分析过只要 L2 cache 命中达到 20%,在带宽上就不会造成性能瓶颈了。因此发现,就算我们采用朴素的 Block Tiling,Global Memory 访问也不会成为访存瓶颈。


但事实真的是这样吗?


细心的同学可能会发现,上图所采用的 tiling 方式并不是直觉上的用 blockIdx.x 表示 Block 在 M 维上的位置,而是用 blockIdx.y 表示 Block 在 M 上的位置。而我们简单调换一下代码中 blockIdx.x 与 blockIdx.y 的顺序,瞬间就有了 10% 左右的性能差距。目前网上并没有针对这个现象的分析(因为几乎所有人都是用的 col major 的 data layout,而且李少侠直接就在代码里使用了更优的 tiling 方式,所以没有人遇到这个问题),因此我这里提出一点个人的猜想,如果猜的不对还请指正。



L2 cache


首先我们看一下这两种 tiling 方式的区别在哪。最为直观的区别就是当 N 或 M 足够大时,采用上图中的 tiling 方式的 wave 形状是横着的,而另一种 tiling 方式的 wave 形状是竖着的,而这种竖着的形状看起来就不是 cache 友好的访存方式。


为什么这么说呢?因为我采用的是行主序的方式存储的矩阵,因此如果一个 wave 的形状是扁平的,那么每个 Block 在每一次循环遍历 B 矩阵时只会有 次 cache miss。这是由于 L2 cache 的 cache line 大小为 128 bytes,因此当数据从 Global Memory 中移动到 L2 cache 后,许多 Block 就能直接从 L2 cache 中读取数据了。然而如果一个 wave 的形状是狭长的。那么每个 Block 在第一次访问 A 矩阵的每一行时都会有 cache miss 的情况出现,即产生 次 cache miss,而后 31 次的遍历都不会有 cache miss。虽然两种 tiling 方式最终 cache miss 的次数是一样的,但这种短时间爆发的 cache miss 所带来的延迟是非常难被各种优化手段覆盖的,因为这种延迟不仅短时间内有很多次,同时每一次的延迟都很长,所以会造成性能损失。因此以后高性能代码的开发中,也要注意合理的把 cache miss 分配到 kernel 运行的各个阶段。


Bank Conflict


在查看两种 Tiling 方式的 profile 我发现,采用横着 Tiling 方式的 kernel bank conflict 更低一些。




等等,既然我们之前已经处理过 bank conflict 了,那么为什么这里还会有 bank conflict 呢?这个现象其实我也不是很清楚。但目前已知的是,在没有加 double buffer 情况下是没有 bank conflict 的,但加了 double buffer 之后或多或少会出现一些 bank conflict。那么至于为什么横着 Tiling 方式的 bank conflict 更低,我就更不知道了,因此这里还请各位 dalao 赐教。


最终版本的代码在这:https://github.com/AyakaGEMM/Hands-on-GEMM/blob/main/src/cuda/double_buffer_yhs_refine_gemm.cu


Epilogue


回顾本文,也基本达成了文章开头所立的各种 flag。当然现在还是有很多问题没有解决的,如 split K、长尾问题、分块细调等等,这些权当是一些未来展望了。近期也在尝试写一下 int8 tensor core 的矩阵乘,在较小形状上(M、N、K<=2048)能有比 cublas 更高的性能,但在更大形状上就只有 80% 左右了(这还是 L2 cache 命中率为 90% 的结果,可能还有啥别的没做好),所以就没有写 int8 tensor core 的部分。不过好歹是写完了!


OpenCV学堂 专注计算机视觉开发技术分享,技术框架使用,包括OpenCV,Tensorflow,Pytorch教程与案例,相关算法详解,最新CV方向论文,硬核代码干货与代码案例详解!作者在CV工程化方面深度耕耘15年,感谢您的关注!
评论
  • 11-29学习笔记11-29学习笔记习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习笔记&记录学习习笔记&记学习学习笔记&记录学习学习笔记&记录学习习笔记&记录学习学习笔记&记录学习学习笔记记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&学习学习笔记&记录学习学习笔记&记录学习学习笔记&记
    youyeye 2024-12-02 23:58 73浏览
  • 光伏逆变器是一种高效的能量转换设备,它能够将光伏太阳能板(PV)产生的不稳定的直流电压转换成与市电频率同步的交流电。这种转换后的电能不仅可以回馈至商用输电网络,还能供独立电网系统使用。光伏逆变器在商业光伏储能电站和家庭独立储能系统等应用领域中得到了广泛的应用。光耦合器,以其高速信号传输、出色的共模抑制比以及单向信号传输和光电隔离的特性,在光伏逆变器中扮演着至关重要的角色。它确保了系统的安全隔离、干扰的有效隔离以及通信信号的精准传输。光耦合器的使用不仅提高了系统的稳定性和安全性,而且由于其低功耗的
    晶台光耦 2024-12-02 10:40 120浏览
  • 学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习笔记&记录学习习笔记&记学习学习笔记&记录学习学习笔记&记录学习习笔记&记录学习学习笔记&记录学习学习笔记记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&学习学习笔记&记录学习学习笔记&记录学习学习笔记&记录学习学习笔记&
    youyeye 2024-11-30 14:30 78浏览
  • 概述 说明(三)探讨的是比较器一般带有滞回(Hysteresis)功能,为了解决输入信号转换速率不够的问题。前文还提到,即便使能滞回(Hysteresis)功能,还是无法解决SiPM读出测试系统需要解决的问题。本文在说明(三)的基础上,继续探讨为SiPM读出测试系统寻求合适的模拟脉冲检出方案。前四代SiPM使用的高速比较器指标缺陷 由于前端模拟信号属于典型的指数脉冲,所以下降沿转换速率(Slew Rate)过慢,导致比较器检出出现不必要的问题。尽管比较器可以使能滞回(Hysteresis)模块功
    coyoo 2024-12-03 12:20 111浏览
  • 《高速PCB设计经验规则应用实践》+PCB绘制学习与验证读书首先看目录,我感兴趣的是这一节;作者在书中列举了一条经典规则,然后进行详细分析,通过公式推导图表列举说明了传统的这一规则是受到电容加工特点影响的,在使用了MLCC陶瓷电容后这一条规则已经不再实用了。图书还列举了高速PCB设计需要的专业工具和仿真软件,当然由于篇幅所限,只是介绍了一点点设计步骤;我最感兴趣的部分还是元件布局的经验规则,在这里列举如下:在这里,演示一下,我根据书本知识进行电机驱动的布局:这也算知行合一吧。对于布局书中有一句:
    wuyu2009 2024-11-30 20:30 125浏览
  • TOF多区传感器: ND06   ND06是一款微型多区高集成度ToF测距传感器,其支持24个区域(6 x 4)同步测距,测距范围远达5m,具有测距范围广、精度高、测距稳定等特点。适用于投影仪的无感自动对焦和梯形校正、AIoT、手势识别、智能面板和智能灯具等多种场景。                 如果用ND06进行手势识别,只需要经过三个步骤: 第一步&
    esad0 2024-12-04 11:20 58浏览
  • RDDI-DAP错误通常与调试接口相关,特别是在使用CMSIS-DAP协议进行嵌入式系统开发时。以下是一些可能的原因和解决方法: 1. 硬件连接问题:     检查调试器(如ST-Link)与目标板之间的连接是否牢固。     确保所有必要的引脚都已正确连接,没有松动或短路。 2. 电源问题:     确保目标板和调试器都有足够的电源供应。     检查电源电压是否符合目标板的规格要求。 3. 固件问题: &n
    丙丁先生 2024-12-01 17:37 102浏览
  • 作为优秀工程师的你,已身经百战、阅板无数!请先醒醒,新的项目来了,这是一个既要、又要、还要的产品需求,ARM核心板中一个处理器怎么能实现这么丰富的外围接口?踌躇之际,你偶阅此文。于是,“潘多拉”的魔盒打开了!没错,USB资源就是你打开新世界得钥匙,它能做哪些扩展呢?1.1  USB扩网口通用ARM处理器大多带两路网口,如果项目中有多路网路接口的需求,一般会选择在主板外部加交换机/路由器。当然,出于成本考虑,也可以将Switch芯片集成到ARM核心板或底板上,如KSZ9897、
    万象奥科 2024-12-03 10:24 68浏览
  • 最近几年,新能源汽车愈发受到消费者的青睐,其销量也是一路走高。据中汽协公布的数据显示,2024年10月,新能源汽车产销分别完成146.3万辆和143万辆,同比分别增长48%和49.6%。而结合各家新能源车企所公布的销量数据来看,比亚迪再度夺得了销冠宝座,其10月新能源汽车销量达到了502657辆,同比增长66.53%。众所周知,比亚迪是新能源汽车领域的重要参与者,其一举一动向来为外界所关注。日前,比亚迪汽车旗下品牌方程豹汽车推出了新车方程豹豹8,该款车型一上市就迅速吸引了消费者的目光,成为SUV
    刘旷 2024-12-02 09:32 119浏览
  • 戴上XR眼镜去“追龙”是种什么体验?2024年11月30日,由上海自然博物馆(上海科技馆分馆)与三湘印象联合出品、三湘印象旗下观印象艺术发展有限公司(下简称“观印象”)承制的《又见恐龙》XR嘉年华在上海自然博物馆重磅开幕。该体验项目将于12月1日正式对公众开放,持续至2025年3月30日。双向奔赴,恐龙IP撞上元宇宙不久前,上海市经济和信息化委员会等部门联合印发了《上海市超高清视听产业发展行动方案》,特别提到“支持博物馆、主题乐园等场所推动超高清视听技术应用,丰富线下文旅消费体验”。作为上海自然
    电子与消费 2024-11-30 22:03 98浏览
  • 当前,智能汽车产业迎来重大变局,随着人工智能、5G、大数据等新一代信息技术的迅猛发展,智能网联汽车正呈现强劲发展势头。11月26日,在2024紫光展锐全球合作伙伴大会汽车电子生态论坛上,紫光展锐与上汽海外出行联合发布搭载紫光展锐A7870的上汽海外MG量产车型,并发布A7710系列UWB数字钥匙解决方案平台,可应用于数字钥匙、活体检测、脚踢雷达、自动泊车等多种智能汽车场景。 联合发布量产车型,推动汽车智能化出海紫光展锐与上汽海外出行达成战略合作,联合发布搭载紫光展锐A7870的量产车型
    紫光展锐 2024-12-03 11:38 101浏览
  • 遇到部分串口工具不支持1500000波特率,这时候就需要进行修改,本文以触觉智能RK3562开发板修改系统波特率为115200为例,介绍瑞芯微方案主板Linux修改系统串口波特率教程。温馨提示:瑞芯微方案主板/开发板串口波特率只支持115200或1500000。修改Loader打印波特率查看对应芯片的MINIALL.ini确定要修改的bin文件#查看对应芯片的MINIALL.ini cat rkbin/RKBOOT/RK3562MINIALL.ini修改uart baudrate参数修改以下目
    Industio_触觉智能 2024-12-03 11:28 87浏览
  •         温度传感器的精度受哪些因素影响,要先看所用的温度传感器输出哪种信号,不同信号输出的温度传感器影响精度的因素也不同。        现在常用的温度传感器输出信号有以下几种:电阻信号、电流信号、电压信号、数字信号等。以输出电阻信号的温度传感器为例,还细分为正温度系数温度传感器和负温度系数温度传感器,常用的铂电阻PT100/1000温度传感器就是正温度系数,就是说随着温度的升高,输出的电阻值会增大。对于输出
    锦正茂科技 2024-12-03 11:50 111浏览
我要评论
0
点击右上角,分享到朋友圈 我知道啦
请使用浏览器分享功能 我知道啦