当前位置: 首页 > news >正文

【CUDA】Sgemm单精度矩阵乘法(上)

目录

    • 前言
    • 1. 简述
    • 2. 框架搭建和CPU串行实现
    • 3. baseline算法:global memory
    • 4. 优化技巧1:shared memory
    • 5. 优化技巧2:shared memory + sliding window
    • 6. 优化技巧3:增加每个线程的工作量
    • 7. 优化技巧4:使用float4提高读取效率
    • 结语
    • 下载链接
    • 参考

前言

学习 UP 主 比飞鸟贵重的多_HKL 的 【CUDA】Sgemm单精度矩阵乘法(已完结~) 视频,记录下个人学习笔记,仅供自己参考😄

refer 1:【CUDA】Sgemm单精度矩阵乘法(已完结~)

refer 2:https://github.com/tpoisonooo/how-to-optimize-gemm/cuda

refer 3:https://chatgpt.com/

1. 简述

我们先来通俗的解释下 gemm、sgemm、gemv、spmv 等一些耳熟能详的名词缩写

GEMM(Genera Matrix-Matrix Multiplication)

含义:

  • GEMM 即 “通用矩阵-矩阵乘法”,英文全称 General Matrix-Matrix Multiplication
  • 它描述的是两个稠密(Dense)矩阵之间的乘法,通用形式为:

C = A × B C = A \times B C=A×B

其中:

  • A A A 是一个 ( m × k ) \left(m \times k \right) (m×k) 的矩阵
  • B B B 是一个 ( k × n ) \left(k \times n \right) (k×n) 的矩阵
  • C C C 是一个 ( m × n ) \left(m \times n \right) (m×n) 的矩阵
  • Note:这里每个矩阵的维度是大家约定俗成的,在后续的代码实现以及文章中我们也会按照这个维度来讲解

特点:

  • GEMM 一般由 BLAS(Basic Linear Algebra Subprograms)库提供
  • 常用于深度学习(如卷积、全连接层、attention 等都可以转化为 GEMM)和高性能科学计算领域
  • 它是性能优化的重点,GPU 库(如 cuBLAS)都会对 GEMM 做高度优化

SGEMM(Single Precision GEMM)

含义:

  • SGEMM 是 GEMM 的一种特定实现,表示单精度浮点数(Single precision,float)下的通用矩阵乘法
  • 完整名称为 Single Precision General Matrix-Matrix Multiplication
  • SGEMM 在 GPU 中应用非常广泛,因为 GPU 对单精度计算的优化程度很高

区别与特点:

  • 与 GEMM 最大的区别是 SGEMM 明确指出矩阵元素采用的是 32 位浮点数(float)
  • 如果使用的是双精度浮点数(double),则对应的称为 DGEMM(Double precision GEMM)
  • SGEMM 通常比 DGEMM 快,内存占用更少,在深度学习序列和推理等场景中使用较多

GEMV(General Matrix-Vector Multiplication)

含义:

  • GEMV 即 “通用矩阵-向量乘法”,英文全称 General Matrix-Vector Multiplication
  • 通用数学形式为:

y = A × x y = A \times x y=A×x

其中:

  • A A A 是一个 ( m × n ) \left(m \times n \right) (m×n) 的矩阵
  • x x x 是一个 ( n × 1 ) \left(n \times 1 \right) (n×1) 的向量
  • y y y 是一个 ( m × 1 ) \left(m \times 1 \right) (m×1) 的向量

区别与特点:

  • 与 GEMM 不同的是,GEMV 只处理一个矩阵和一个向量,而不是两个矩阵
  • 由于数据量少于 GEMM,因此通常速度更快,但相应地并行化利用程度不如 GEMM

SPMV(Sparse Matrix-Vector Multiplication)

含义:

  • SPMV 即 “稀疏矩阵-向量乘法”,英文全称为 Sparse Matrix-Vector Multiplication
  • 通用数学形式与 GEMV 相似:

y = A × x y = A \times x y=A×x

其中:

  • A A A 是稀疏矩阵(Sparse matrix),也就是说矩阵元素绝大多数是 0
  • x x x y y y 是稠密向量(Dense vector)

区别与特点:

  • 与 GEMV 最大的区别是矩阵 A A A稀疏的,绝大部分元素为 0
  • 计算效率严重依赖于稀疏矩阵的存储方式和数据结构设计,访问内存的模式与 GEMV 有很大的差别
  • 常用于图计算、有限元分析、推荐系统、科学计算等领域

下面的表格对比了它们之间的区别:

名称全称类型使用领域
GEMMGeneral Matrix-Matrix Multiplication稠密矩阵-稠密矩阵乘法科学计算、深度学习训练
SGEMMSingle-precision General Matrix-Matrix Multiplication单精度稠密矩阵-矩阵乘法深度学习训练推理、GPU 计算
GEMVGeneral Matrix-Vector Multiplication稠密矩阵-稠密向量乘法线性代数基础操作
SPMVSparse Matrix-Vector Multiplication稀疏矩阵-稠密向量乘法图计算、推荐系统、科学计算

还有一些别的名词缩写,例如 SGEMV(Single Precision General Matrix-Vector Multiplication)单精度的矩阵-向量乘法,HGEMM(Half Precision General Matrix-Matrix Multiplication)半精度的矩阵-矩阵乘法,HGEMV(Half Precision General Matrix-Vector Multiplication)半精度的矩阵-向量乘法,

我们了解完这些缩写名词的全称后,很容易知道每个字符的含义,其中:

  • ge 代表 general 通用的
  • m 代表 matrix 矩阵
  • mm 代表 matrix-matrix 矩阵与矩阵相乘
  • s 代表 single precision 单精度
  • v 代表 vector 向量
  • mv 代表 matrix-vector 矩阵与向量相乘
  • sp 代表 sparse 稀疏的
  • h 代表 half precision 半精度

知道每个字符的含义后现在我们也能自己推导出来了,例如 sgemv 的全称,其中 s 代表 single precision 单精度,ge 代表 general 通用的,mv 代表 matrix-vector 矩阵与向量乘法,因此它代表的含义就是 “半精度通用矩阵-向量乘法”

OK,下面我们就来看看 SGEMM 单精度通用矩阵乘法在 GPU 上是如何优化的

Note:这里我们的重点不是讨论哪种实现方法更优,而是把每种方法都实现一遍,来更好的理解矩阵乘法。由于我们以学习为目的,因此会做最大程度的简化,例如矩阵 A、B、C 的宽高都保持一致,grid 和 block 的设置都能被整除,不会有过多的边界条件判断。并且我们约定矩阵 A 维度是 M x K,矩阵 B 维度是 K x N,矩阵 C 维度是 M x N

2. 框架搭建和CPU串行实现

我们先来看看 CPU 上的实现,代码如下:

static void cpu_sgemm(float* A, float* B, float* C, const int M, const int N, const int K){for(int m = 0; m < M; m++){for(int n = 0; n < N; n++){float sum = 0;for(int k = 0; k < K; k++){sum += A[m * K + k] * B[k * N + n];}C[m * N + n] = sum;}}
}

CPU 上的串行实现就是通过三个 for 循环来完成的,那这个矩阵乘效率是比较慢的,特别是在一些大矩阵相乘时

我们在 CPU 上的实现主要是为了对比后续在 GPU 上的实现,看看二者矩阵乘结果是否保持一致,如果不一致则说明我们在 GPU 上实现的矩阵乘肯定存在着某些问题,因此我们需要有个对比函数。此外还需要为这些矩阵分配内存空间,包括 host 和 device 上的,整个框架代码如下所示:

#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <cuda.h>
#include <cuda_runtime.h>// error check#define checkCudaKernel(...)                                                                         \__VA_ARGS__;                                                                                     \do{cudaError_t cudaStatus = cudaPeekAtLastError();                                               \if (cudaStatus != cudaSuccess){                                                                  \printf("launch failed: %s", cudaGetErrorString(cudaStatus));                                 \}} while(0);#define checkCudaRuntime(call) check_runtime(call, #call, __LINE__, __FILE__)bool check_runtime(cudaError_t e, const char* call, int line, const char *file){if (e != cudaSuccess) {printf("CUDA Runtime error %s # %s, code = %s [ %d ] in file %s:%d", call, cudaGetErrorString(e), cudaGetErrorName(e), e, file, line);return false;}return true;
}// utils functionstatic void random_matrix(const int rows, const int cols, float* matrix){for(int i = 0; i < rows; ++i){for(int j = 0; j < cols; ++j){// [-1, 1]matrix[i * cols + j] = 2.0f * (float)drand48() - 1.0f;}}
}static float compare_matrices(const int rows, const int cols, float* matrix_A, float* matrix_B){float max_diff = 0.0f;for(int i = 0; i < rows; ++i){for(int j = 0; j < cols; ++j){float a = matrix_A[i * cols + j];float b = matrix_B[i * cols + j];float diff = fabsf(a - b);if(diff > max_diff){max_diff = diff;}if(diff > 0.5f){printf("Error at (%d, %d): diff = %f, a = %f, b = %f\n", i, j, diff, a, b);}}}return max_diff;
}// cpu implementstatic void cpu_sgemm(float* A, float* B, float* C, const int M, const int N, const int K){for(int m = 0; m < M; m++){for(int n = 0; n < N; n++){float sum = 0;for(int k = 0; k < K; k++){sum += A[m * K + k] * B[k * N + n];}C[m * N + n] = sum;}}
}// gpu implement__global__ void cuda_sgemm_v0_global_memory(float* A, float* B, float* C, const int M, const int N, const int K){// TODO:}int main(){// random seedsrand48(42);const int m = 512;const int n = 512;const int k = 512;const size_t mem_size_A = m * k * sizeof(float);const size_t mem_size_B = k * n * sizeof(float);const size_t mem_size_C = m * n * sizeof(float);float* matrix_A_host = (float*)malloc(mem_size_A);float* matrix_B_host = (float*)malloc(mem_size_B);float* matrix_C_host_cpu_calc = (float*)malloc(mem_size_C);float* matrix_C_host_gpu_calc = (float*)malloc(mem_size_C);random_matrix(m, k, matrix_A_host);random_matrix(k, n, matrix_B_host);memset(matrix_C_host_cpu_calc, 0, mem_size_C);memset(matrix_C_host_gpu_calc, 0, mem_size_C);float* matrix_A_device;float* matrix_B_device;float* matrix_C_device;checkCudaRuntime(cudaMalloc((void**)&matrix_A_device, mem_size_A));checkCudaRuntime(cudaMalloc((void**)&matrix_B_device, mem_size_B));checkCudaRuntime(cudaMalloc((void**)&matrix_C_device, mem_size_C));checkCudaRuntime(cudaMemcpy(matrix_A_device, matrix_A_host, mem_size_A, cudaMemcpyHostToDevice));checkCudaRuntime(cudaMemcpy(matrix_B_device, matrix_B_host, mem_size_B, cudaMemcpyHostToDevice));cpu_sgemm(matrix_A_host, matrix_B_host, matrix_C_host_cpu_calc, m, n, k);constexpr int BLOCK = 16;dim3 block(BLOCK, BLOCK);dim3 grid((m + BLOCK - 1) / BLOCK, (n + BLOCK - 1) / BLOCK);checkCudaKernel(cuda_sgemm_v0_global_memory<<<grid, block>>>(matrix_A_device, matrix_B_device, matrix_C_device, m, n, k));checkCudaRuntime(cudaDeviceSynchronize());checkCudaRuntime(cudaMemcpy(matrix_C_host_gpu_calc, matrix_C_device, mem_size_C, cudaMemcpyDeviceToHost));float max_diff = compare_matrices(m, n, matrix_C_host_cpu_calc, matrix_C_host_gpu_calc);printf("max_diff = %.2f\n", max_diff);free(matrix_A_host);free(matrix_B_host);free(matrix_C_host_cpu_calc);free(matrix_C_host_gpu_calc);checkCudaRuntime(cudaFree(matrix_A_device));checkCudaRuntime(cudaFree(matrix_B_device));checkCudaRuntime(cudaFree(matrix_C_device));return 0;
}

Note:博主自己有一些编码习惯,所以代码实现上与 UP 主的并非完全相同(大部分实现都来自于 ChatGPT),不过优化方式以及原理都一样

整个项目的目录结构如下:

cuda_learn/
├── CMakeLists.txt
└── cuda_sgemm_study├── CMakeLists.txt├── sgemm_v0_global_memory.cu└── ...2 directories, 3 files

cuda_learn 文件夹下的 CMakeLists.txt 内容如下:

cmake_minimum_required(VERSION 3.20.0)
project(cuda_practice VERSION 0.1.0 LANGUAGES CUDA CXX C)
find_package(CUDAToolkit)
add_subdirectory(cuda_sgemm_study)

cuda_sgemm_study 文件夹下的 CMakeLists.txt 内容如下:

set(CMAKE_CUDA_STANDARD 11)add_executable(sgemm_v0_global_memory sgemm_v0_global_memory.cu)
target_link_libraries(sgemm_v0_global_memory PRIVATE CUDA::cudart ${CUDA_cublas_LIBRARY})
if(CMAKE_BUILD_TYPE STREQUAL "Debug")target_compile_options(sgemm_v0_global_memory PRIVATE $<$<COMPILE_LANGUAGE:CUDA>:-G>)
endif()
set_target_properties(sgemm_v0_global_memory PROPERTIES CUDA_SEPARABLE_COMPILATION ON)/* add new */
// add_executable(sgemm_v1_shared_memory sgemm_v1_shared_memory.cu)
// target_link_libraries(sgemm_v1_shared_memory PRIVATE CUDA::cudart ${CUDA_cublas_LIBRARY})
// if(CMAKE_BUILD_TYPE STREQUAL "Debug")
//     target_compile_options(sgemm_v1_shared_memory PRIVATE $<$<COMPILE_LANGUAGE:CUDA>:-G>)
// endif()
// set_target_properties(sgemm_v1_shared_memory PROPERTIES CUDA_SEPARABLE_COMPILATION ON)

编译运行指令如下:

cd cuda_learn
mkdir build && cd build
cmake .. -DCMAKE_BUILD_TYPE=Debug && make -j24
./cuda_sgemm_study/sgemm_v0_global_memory

OK,整个框架搭建完成后我们就来看看 GPU 上的 sgemm 是如何实现的

3. baseline算法:global memory

我们先看最基本的利用 GPU 本身的并行计算能力来对 sgemm 加速:

在这里插入图片描述

在 reduce 归约求和中我们使用的是 1D-layout 的布局,但是在 sgemm 矩阵乘法中我们需要使用 2D-layout 的布局,因为矩阵本身就是二维的

我们启动的总线程数是 M x N,恰好是矩阵 C 元素的总和,这意味着每个线程将处理矩阵 C 中的一个元素,也就是完成矩阵 A 中的一行与矩阵 B 中的一列的乘加

图中灰色区域代表着一个 block 中需要完成的计算,在我们的示例中 block 设置的大小是 (16, 16),也就是它需要完成的是矩阵 A 中的 16 x K 大小的小块矩阵与矩阵 B 中的 K x 16 大小的小块矩阵的乘法

block 中每个 thread 要处理的是 A 小块矩阵中 1 x K 大小的矩阵与 B 小块矩阵中 K x 1 大小的矩阵的乘法,也就是图中淡红色区域展示的部分

实现代码如下:

__global__ void cuda_sgemm_v0_global_memory(float* A, float* B, float* C, const int M, const int N, const int K){// A->MxK B->KxN C->MxNint row = blockIdx.y * blockDim.y + threadIdx.y;int col = blockIdx.x * blockDim.x + threadIdx.x;float sum = 0.0f;for(int k = 0; k < K; k++){sum += A[row * K + k] * B[k * N + col];}C[row * N + col] = sum;
}

我们同时启动 MxN 个线程,每个线程负责计算输出矩阵 C 中的一个元素 C[row][col]。每个线程需要从 A 中读取一行的数据,从 B 中读取一列的数据,计算完之后再写回 C 中,整体计算过程还是比较清晰的

博主绘制了一个草图,简单描述矩阵 C 中某个元素的计算过程:

在这里插入图片描述

在上图中,通过 y 方向和 x 方向的线程索引 rowcol 可以定位到矩阵 C 中待计算的元素 C[row][col],接着需要定位到矩阵 A 中的某行元素和矩阵 B 中的某列元素,图中可以很清晰的看到矩阵 A 中 row 行元素的索引是 row * K + k,矩阵 B 中 col 列元素的索引是 k * N + col

其中线程索引 rowcol 的计算博主绘制了一个草图来说明:

在这里插入图片描述

对于 Block(1, 31) 这个 block 中的线程 Thread(1, 1) 的全局索引 rowcol 的计算如图中所示,其中 blockIdx.y * blockDim.yblockIdx.x * blockDim.x 定位到具体是哪个 block,threadIdx.ythreadIdx.x 定位到具体是哪个 thread

我们可以来简单计算下该核函数对 global memory 的访问次数:

  • 读取次数:
    • 每个线程读取 KAKB,总共有 MxN 个线程
    • 总读取次数 = MxNxK(来自 A)+ MxNxK(来自 B)= 2MNK
  • 写入次数:
    • 每个线程写入 1 次 C,总共有 MxN 个线程
    • 总写入次数 = MxN

可以看到尽管该核函数通过并行计算加速了 sgemm,但其内存访问次数非常高,会产生极高的内存带宽压力

nsight compute 的性能和带宽测试结果如下:

性能和带宽测试结果如下:

优化手段矩阵维度GridBlock耗时(us)Memory Throughout(%)DRAM Throughout(%)
v0_global_memory512x512(32,32)(16,16)471.7896.941.56

Note:测试设备 NVIDIA RTX3060,CUDA-11.6,launch 次数 2000

关于 nsight compute 的简单使用可以参考:【CUDA】Reduce归约求和(下)

4. 优化技巧1:shared memory

要进一步提升 cuda_sgemm_v0_global_memory 的性能,需要优化全局内存访问模式,我们可以考虑利用共享内存:

在这里插入图片描述

相比于之前 global memory 的实现,这里我们会先将 block 中需要处理的矩阵 A 和 B 的 global memory 数据(图中灰色区域部分)移动到 shared memory 上(图中深绿色部分)再做乘加

和 UP 主视频中使用的静态共享内存不同,这里博主在实现时使用的是动态共享内存,定义如下:

bool checkSharedMemorySize(size_t requested_bytes){int device_id = 0;cudaError_t err = cudaGetDevice(&device_id);if(err != cudaSuccess){printf("cudaGetDevice failed: %s\n", cudaGetErrorString(err));return false;}cudaDeviceProp prop{};err = cudaGetDeviceProperties(&prop, device_id);if(err != cudaSuccess){printf("cudaGetDeviceProperties failed: %s\n", cudaGetErrorString(err));return false;        }size_t max_shared_memory = prop.sharedMemPerBlock;if(requested_bytes > max_shared_memory){printf("Error: requested %zu bytes of dynamic shared memory, but device only supports %zu bytes per block\n", requested_bytes, max_shared_memory);return false;        }return true;
}int main(){const int m = 256;  const int n = 256;const int k = 256;// ...constexpr int BLOCK = 16;dim3 block(BLOCK, BLOCK);dim3 grid((m + BLOCK - 1) / BLOCK, (n + BLOCK - 1) / BLOCK);constexpr int BM = BLOCK;constexpr int BN = BLOCK;constexpr int BK = k;size_t shared_mem_size = (BM * BK + BK * BN) * sizeof(float);if(!checkSharedMemorySize(shared_mem_size)){return;} checkCudaKernel(cuda_sgemm_v1_shared_memory<<<grid, block, shared_mem_size>>>(matrix_A_device, matrix_B_device, matrix_C_device, m, n, k));// ...
}

为了简便起见,这里我们会将一个 block 中要处理的元素全部放到 shared memory 上,也就是矩阵 A 的 BM * BK 大小的元素,矩阵 B 的 BN * BK 大小的元素。其中 BMBN 设置为 BLOCK 大小,BK 设置为 k 的大小

那在执行过程中我们其实还需要将矩阵 A、B、C 的维度改小些,前面我们设置的是 M = N = K = 512,这里我们可能就只能使用 256 的大小,不然会出现 nvlink error : Entry function '_Z27cuda_sgemm_v1_shared_memoryPfS_S_iii' uses too much shared data 的错误

这是因为 shared memory 在 GPU 上的大小是有限的,当矩阵的维度较大时,如果还是像上面一样将一个 block 要处理的元素全部放到 shared memory 上去,会超过它的大小的限制。因此,在实际利用 shared memory 实现 sgemm 时我们一般会分块(Tiling)来计算,每次只放一小部分到 shared memory 上,这个是我们下一小节需要实现的

这个小节我们就简单认为 shared memory 足够大,可以将一个 block 要处理的矩阵元素全部放上去

核函数实现代码如下:

__global__ void cuda_sgemm_v1_shared_memory(float* A, float* B, float* C, const int M, const int N, const int K){int row = blockIdx.y * blockDim.y + threadIdx.y;int col = blockIdx.x * blockDim.x + threadIdx.x;// BM = BN = BLOCK = blockDim.x = blockDim.y = 16// BK = K = 256extern __shared__ float shared_mem[];float* A_shared = shared_mem;float* B_shared = shared_mem + blockDim.y * K;for(int k = 0; k < K; k += blockDim.x){// load A_shared[BM, BK]int a_col = k + threadIdx.x;A_shared[threadIdx.y * K + a_col] = A[row * K + a_col];// load B_shared[BK, BN]int b_row = k + threadIdx.y;B_shared[b_row * blockDim.x + threadIdx.x] = B[b_row * N + col];}__syncthreads();float sum = 0.0f;for(int k =0; k < K; k++){sum += A_shared[threadIdx.y * K + k] * B_shared[k * blockDim.x + threadIdx.x];}C[row * N + col] = sum;
}

共享内存被分为两部分:

  • A_shared:缓存 A 的子矩阵,大小为 BM * BK
  • B_shared:缓存 B 的子矩阵,大小为 BK * BN
  • 本示例中 BM = BN = BLOCK = 16BK = K = 256,一次性将 block 所需的全部行和列读入

接着就是把数据从 global memory 中加载到 shared memory 中,先明确下具体的矩阵大小:

  • 待加载的矩阵 A 的子矩阵大小为 BM * BK 即 16x256
  • 待加载的矩阵 B 的子矩阵大小为 BK * BN 即 256x16
  • 共享内存大小为 BM * BK + BK * BN
  • 一个 block 大小为 16x16 即 256 个线程

其中的索引计算有些繁琐,这里我们简单分析下

首先一个 block 有 256 个线程,而具体子矩阵大小是 16x256 和 256x16,按照一个线程一次加载一个元素来看,可以知道我们需要循环 16 次,也就是一个线程总共要从矩阵 A 和矩阵 B 中分别找到 16 个元素加载到 A_sharedB_shared

我们先看矩阵 A 子矩阵的加载代码:

for(int k = 0; k < K; k += blockDim.x){int a_col = k + threadIdx.x;A_shared[threadIdx.y * K + a_col] = A[row * K + a_col];
}

首先是有一个循环,循环次数是 K / blockDim.x = 256 / 16 = 16,stride 步长是 blockDim.x = 16

接着需要找到当前线程在矩阵 A 中的全局索引 row * K + a_col,我们可以将它理解为二维数组 A[row][a_col],那它其实跟我们前面的 cuda_sgemm_v0_global_memory 核函数的索引 A[row * K + k] 是一致的,row * K 定位到具体某一行,a_col 定位到具体某一列,由于我们这里的步长是 blockDim.x,因此列的索引 a_colk + threadIdx.x

博主绘制了一个草图来帮助理解:

在这里插入图片描述

假设我们当前的 block 设置为 blockDim.x = blockDim.y = 2,需要加载的矩阵的维度是 2x8,因此需要循环 4 次,每次加载矩阵 A 中的 4 个元素数据

加载完矩阵 A 的数据后就要写入到 shared memory 中,对应的索引是 threadIdx.y * K + a_col,同理 threadIdx.y * K 定位到 A_shared 中具体某一行,a_col 定位到具体某一列,如下图所示:

在这里插入图片描述

我们再来看矩阵 B 子矩阵的加载代码:

for(int k = 0; k < K; k += blockDim.y){int b_row = k + threadIdx.y;B_shared[b_row * blockDim.x + threadIdx.x] = B[b_row * N + col];
}

首先也是有一个循环,循环次数是 K / blockDim.y = 256 / 16 = 16,stride 步长是 blockDim.y = 16

接着需要找到当前线程在矩阵 B 中的全局索引,b_row * N + colb_row * N 定位到具体某一行,col 定位到具体某一列,由于我们这里的步长是 blockDim.y,因此行的索引 b_rowk * N + col

同样可以根据下面的草图来帮助理解:

在这里插入图片描述

假设我们当前的 block 设置为 blockDim.x = blockDim.y = 2,需要加载的矩阵的维度是 8x2,因此需要循环 4 次,每次加载矩阵 B 中的 4 个元素数据

加载完矩阵 B 的数据后就要写入到 shared memory 中,对应的索引是 b_row * blockDim.x + threadIdx.x,同理 b_row * blockDim.x 定位到 B_shared 中具体某一行,threadIdx.x 定位到具体某一列,如下图所示:

在这里插入图片描述

由于矩阵 A 和矩阵 B 都是方阵,同时 blockDim.x = blockDim.y,因此我们可以把矩阵 A 子矩阵和矩阵 B 子矩阵的加载放在一个循环中,代码如下:

for(int k = 0; k < K; k += blockDim.x){// load A_shared[BM, BK]int a_col = k + threadIdx.x;A_shared[threadIdx.y * K + a_col] = A[row * K + a_col];// load B_shared[BK, BN]int b_row = k + threadIdx.y;B_shared[b_row * blockDim.x + threadIdx.x] = B[b_row * N + col];
}

矩阵 A 和矩阵 B 的子矩阵从 global memory 加载到 shared memory 之后,要做一个线程同步(__syncthreads()),确保所有线程都完成了数据加载操作

数据加载到 shared memory 之后就是做一个乘加最后写回矩阵 C 的对应位置即可:

float sum = 0.0f;
for(int k =0; k < K; k++){sum += A_shared[threadIdx.y * K + k] * B_shared[k * blockDim.x + threadIdx.x];
}C[row * N + col] = sum;

由于我们事先将数据从 global memory 加载到 shared memory 中,因此对 global memory 的访问次数大大减小了:

  • 每个线程块每次需要加载 BM * K 个 A 的元素和 K * BN 个 B 的元素到共享内存
  • 总线程块数为 (M / BM) * (N / BN)
  • 总全局内存读取次数为 (M / BM) * (N / BN) * (BM * BK + BK * BN) = MNK / BM + MNK / BN

性能和带宽测试结果如下:

优化手段矩阵维度GridBlock耗时(us)Memory Throughout(%)DRAM Throughout(%)
v0_global_memory512x512(32,32)(16,16)471.7896.941.56
v1_shared_memory256x256(16,16)(16,16)82.1178.921.84

5. 优化技巧2:shared memory + sliding window

当矩阵维度较大时很容易超过 shared memory 内存大小的限制,这时就需要考虑采用分块(tiling)的方式加载矩阵 A 和 B 的子块:

在这里插入图片描述

在 v1 版本的实现中我们是将 blockDim.x * K 的子矩阵以及 K * blockDim.y 的子矩阵都放在 shared memory 中,但在这里我们只放一小块上去(图中深绿色部分),之后二者相乘将结果存放在寄存器中(图中深蓝色部分),接着不断的滑动窗口直至完成灰色部分的乘加,最后写回到全局内存中

代码如下:

template<unsigned int BLOCK_SIZE>
__global__ void cuda_sgemm_v2_shared_memory_sliding_window(float* A, float* B, float* C, const int M, const int N, const int K){int row = blockIdx.y * blockDim.y + threadIdx.y;int col = blockIdx.x * blockDim.x + threadIdx.x;extern __shared__ float shared_mem[];float* A_tile = shared_mem;float* B_tile = shared_mem + BLOCK_SIZE * BLOCK_SIZE;float sum = 0.0f;for(int k_base = 0; k_base < K; k_base += BLOCK_SIZE){// load A_tile from global memory to shared memoryint a_col = k_base + threadIdx.x;A_tile[threadIdx.y * BLOCK_SIZE + threadIdx.x] = A[row * K + a_col];// load B_tile from global memory to shared memoryint b_row = k_base + threadIdx.y;B_tile[threadIdx.y * BLOCK_SIZE + threadIdx.x] = B[b_row * N + col];__syncthreads();// compute the sum of A_tile * B_tilefor(int i = 0; i < BLOCK_SIZE; ++i){sum += A_tile[threadIdx.y * BLOCK_SIZE + i] * B_tile[i * BLOCK_SIZE + threadIdx.x];}__syncthreads();}C[row * N + col] = sum;
}

Note:其中 BLOCK_SIZE 的设置和 BLOCK 的大小保持一致,统一设置为 16

具体代码分析如下:(from ChatGPT)

1. 全局线程索引的计算

int row = blockIdx.y * blockDim.y + threadIdx.y;
int col = blockIdx.x * blockDim.x + threadIdx.x;

当前 grid 和 block 都是 2D-layout 的布局,启动的线程总数恰好是矩阵 C 的元素总数即 512x512。Grid 的大小是 32x32,Block 的大小是 16x16

其中:

  • blockDim.x = blocDim.y = BLOCK_SIZE = 16
  • blockIdx.y ∈ [0, 31]
  • threadIdx.y ∈ [0, 15]

因此 row = blockIdx.x * blockDim.y + threadIdx.x 代表着矩阵 C 全局的第 row 行,同理 col 代表着矩阵 C 的第 col 列,每个线程负责计算出元素 C[row][col]

2. 共享内存布局

extern __shared__ float shared_mem[];
float* A_tile = shared_mem;
float* B_tile = shared_mem + BLOCK_SIZE * BLOCK_SIZE;

这里 A_tileB_tile 各自是一个 BLOCK_SIZE * BLOCK_SIZE(16x16)的行主序数组,如下图所示:

在这里插入图片描述

shared_mem 的总大小是 2 * BLOCK_SIZE * BLOCK_SIZE,一部分存储 A_tile 即矩阵 A 分块的元素,另一部分存储 B_tile 即矩阵 B 分块的元素

3. 沿 K 维度的 “分块累加” 主循环

float sum = 0.0f;
for(int k_base = 0; k_base < K; k_base += BLOCK_SIZE){// load A_tile // load B_tile// __syncthreads();// compute the sum of A_tile * B_tile// __syncthreads();
}

我们把完整的点积

C [ r o w ] [ c o l ] = ∑ k = 0 511 A [ r o w ] [ k ] ∗ B [ k ] [ c o l ] C[row][col] = \sum_{k=0}^{511}A[row][k] * B[k][col] C[row][col]=k=0511A[row][k]B[k][col]

划分为 K / BLOCK_SIZE = 32 轮,每轮处理 BLOCK_SIZE = 16

3.1 加载 A_tile

int a_col = k_base + threadIdx.x;
A_tile[threadIdx.y * BLOCK_SIZE + threadIdx.x] = A[row * K + a_col];
  • 全局索引 A[row * K + a_col]
    • row * K 跳到矩阵 A 的第 row 行起点
    • + a_col 取该行的第 a_col 列的元素
  • 分块矩阵索引 A_tile[threadIdx.y * BLOCK_SIZE + threadIdx.x]
    • A_tile 行内平铺,代表着第 threadIdx.y 行第 threadIdx.x

3.2 加载 B_tile

int b_row = k_base + threadIdx.y;
B_tile[threadIdx.y * BLOCK_SIZE + threadIdx.x] = B[b_row * N + col];
  • 全局索引 B[b_row * N + col]
    • b_row * N 跳到矩阵 B 的第 b_row 行起点
    • + col 取该行的第 col 列元素
  • 分块矩阵索引 B_tile[threadIdx.y * BLOCK_SIZE + threadIdx.x]
    • B_tile 行内平铺,代表着第 threadIdx.y 行第 threadIdx.x

3.3 同步 __syncthreads()

__syncthreads();

第一次同步确保所有线程都已经把 A_tileB_tile 从 global memory 中加载到 shared memory 中,确保所有数据加载完成

3.4 分块矩阵计算

for(int i = 0; i < BLOCK_SIZE; ++i){sum += A_tile[threadIdx.y * BLOCK_SIZE + i] * B_tile[i * BLOCK_SIZE + threadIdx.x];
}
  • A_tile 的访问:取 A_tile 的第 threadIdx.y 行,第 i
  • B_tile 的访问:取 B_tile 的第 i 行,第 threadIdx.x
  • 累加 BLOCK_SIZE 次,完成分块矩阵 A_tileB_tile 的乘积,获得 C[row][col] 的部分点积和

3.5 同步 __syncthreads()

__syncthreads();

第二次同步确保所有线程都完成了当前子块的计算读取,才开始下一轮写新的子块到相同的 shared memory 区域

整个外层循环过程有点像滑动窗口(sliding window),每次只处理一个小的 BLOCK,然后不断地向右滑动,直至处理完所有行或列的数据,如下图所示:

在这里插入图片描述

我们以 Block(0, 0) 为例,这个 Block 块要计算的是 A 和 B 实线部分包围的子矩阵相乘,即 BLOCK_SIZE * K 的矩阵 A 和 K * BLOCK_SIZE 的矩阵 B,由于 shared memory 大小有限,因此我们每次只加载 BLOCK_SIZE * BLOCK_SIZE 大小的矩阵 A_tile 和矩阵 B_tile 相乘计算部分和,即图中灰色小块和蓝色小块部分,接着依次循环所有 K 直至完成 BLOCK_SIZE * K 的矩阵 A 和 K * BLOCK_SIZE 的矩阵 B 相乘

由于启动的 block 大小刚好是 BLOCK_SIZE * BLOCK_SIZE,因此每个 thread 负责从矩阵 A 和矩阵 B 的 global memory 中加载一个元素并放在 shared memory 的 A_tileB_tile 的对应位置上。这样就确保当前 block 从 global memory 中加载整个 A_tileB_tile 放在 shared memory 中来计算,

4. 写回 C

C[row * N + col] = sum;

外层循环做完 32 轮后,sum 就是完整的 ∑ k = 0 K − 1 A [ r o w ] [ k ] ∗ B [ k ] [ c o l ] \sum_{k=0}^{K-1}A[row][k] * B[k][col] k=0K1A[row][k]B[k][col],接着写回到全局内存即可

v2 版本通过将 A、B 矩阵按子块加载到高速的 shared memory 中,并在子块内利用这些数据来完成乘加,相比于 v0 版本减少了对全局内存的访问次数、提高了访存合并效率和带宽利用率,在大矩阵运算中可以实现更高的吞吐和性能

性能和带宽测试结果如下:

优化手段矩阵维度GridBlock耗时(us)Memory Throughout(%)DRAM Throughout(%)
v0_global_memory512x512(32,32)(16,16)471.7896.941.56
v1_shared_memory256x256(16,16)(16,16)82.1178.921.84
v2_shared_memory_sliding_window512x512(32,32)(16,16)362.5094.457.05

6. 优化技巧3:增加每个线程的工作量

这个小节的优化策略是增加每个线程的工作量,前面我们学习 reduce 的时候也有提到过,让每个线程多做一些工作,可能会提高计算强度,有助于延迟隐藏

在这里插入图片描述

v2 版本中是一个线程处理矩阵 C 中的一个元素计算,现在我们将 blockDim.xblockDim.y 都减少到原来的 1/2,也就是现在一个线程要处理矩阵 C 中的四个元素计算,如上图所示

代码如下:

template<unsigned int BLOCK_SIZE, unsigned int STRIDE>
__global__ void cuda_sgemm_v3_increase_work_of_per_thread(float* A, float* B, float* C, const int M, const int N, const int K) {constexpr int STEP = BLOCK_SIZE * STRIDE;const int block_row = STEP * blockIdx.y;const int block_col = STEP * blockIdx.x;extern __shared__ float shared_mem[];float* A_shared = shared_mem;float* B_shared = shared_mem + STEP * STEP;float Sum[STRIDE * STRIDE] = {0.0f};for(int k = 0; k < K; k += STEP){// load data from globale memory to shared memoryfor(int i = 0; i < STRIDE; ++i){for(int j = 0; j < STRIDE; ++j){const int tile_row = threadIdx.y + i * BLOCK_SIZE;const int tile_col = threadIdx.x + j * BLOCK_SIZE;A_shared[tile_row * STEP + tile_col] = A[(block_row + tile_row) * K + k + tile_col];B_shared[tile_row * STEP + tile_col] = B[(k + tile_row) * N + block_col + tile_col];}}__syncthreads();// computefor(int i = 0; i < STRIDE; ++i){for(int j = 0; j < STRIDE; ++j){float sum = 0.0f;for(int s = 0; s < STEP; ++s){const int tile_row = threadIdx.y + i * BLOCK_SIZE;const int tile_col = threadIdx.x + j * BLOCK_SIZE;                    sum += A_shared[tile_row * STEP + s] * B_shared[s * STEP + tile_col];}Sum[i * STRIDE + j] += sum;}}__syncthreads();}for(int i = 0; i < STRIDE; ++i){for(int j = 0; j < STRIDE; ++j){const int row = block_row + threadIdx.y + i * BLOCK_SIZE;const int col = block_col + threadIdx.x + j * BLOCK_SIZE;C[row * N + col] = Sum[i * STRIDE + j];}}
}

具体代码分析如下:(from ChatGPT)

1. 模板参数与关键变量解析

  • BLOCK_SIZE:子矩阵分块大小,每次将 BLOCK_SZIE * BLOCK_SIZE 大小的分块矩阵加载到 shared memory 中,当前代码中 BLOCK_SIZE 等于线程块 block 的大小,等于 16
  • STRIDE:每个线程在行和列方向上负责计算的元素数量,每个线程计算 STRIDE * STRIDE 个结果,当前代码中 STRIDE 等于 2
  • STEP:每次迭代中,从 global memory 加载到 shared memory 的矩阵块(Tile)大小,计算方式为 BLOCK_SIZE * STRIDE,等于 32

2. Tile 基址计算(block_rowblock_col

const int block_row = STEP * blockIdx.y;
const int block_col = STEP * blockIdx.x;
  • block_row:当前线程块处理的子矩阵 C 的起始行
  • block_col:当前线程块处理的子矩阵 C 的起始列

和 v2 版本不同的是,这里我们一个 block 要处理之前四个 block 的数据,因此,我们必须要知道当前 block 对应的索引,这样我们才能拿到对应位置的矩阵 A 和矩阵 B 的数据。其索引不再是之前的 blockIdx.yblockIdx.x,而是这里的 STEP * blockIdx.y 以及 STEP * blockIdx.x,每个 block 处理的数据量是 STEP * STEP,也就是 stride 步长为 STEP

3. 分配共享内存

extern __shared__ float shared_mem[];
float* A_shared = shared_mem;
float* B_shared = shared_mem + STEP * STEP;

A_sharedB_shared 分别指向共享内存中 AB 的分块数据,大小为 STEP * STEP,当前示例中为 32x32

4. 沿 K 维度的 “分块累加” 主循环

float Sum[STRIDE * STRIDE] = {0.0f};
for(int k = 0; k < K; k += STEP){// load data from global memory to shared memory// __syncthreads()// compute// __syncthreads()
}

我们把完整的点积

C [ r o w ] [ c o l ] = ∑ k = 0 511 A [ r o w ] [ k ] ∗ B [ k ] [ c o l ] C[row][col] = \sum_{k = 0}^{511}A[row][k] * B[k][col] C[row][col]=k=0511A[row][k]B[k][col]

划分为 K / STEP = 16 轮,每轮处理 STEP = BLOCK_SIZE * STRIDE = 32

那 v3 版本的总体流程其实和前面 v2 版本没有什么区别,主要在内部还有循环,现在一个 block 要同时处理之前 4 个 block 的数据,我们重点就是来看具体的索引是如何计算的

4.1 数据加载

// load data from globale memory to shared memory
for(int i = 0; i < STRIDE; ++i){for(int j = 0; j < STRIDE; ++j){const int tile_row = threadIdx.y + i * BLOCK_SIZE;const int tile_col = threadIdx.x + j * BLOCK_SIZE;A_shared[tile_row * STEP + tile_col] = A[(block_row + tile_row) * K + k + tile_col];B_shared[tile_row * STEP + tile_col] = B[(k + tile_row) * N + block_col + tile_col];}
}
__syncthreads();

每个线程加载 STRIDE * STRIDE 个元素到共享内存,通过 ij 循环覆盖整个 STEP * STEP 的块

博主绘制了一个草图来简单说明数据加载部分的流程,如下图所示:

在这里插入图片描述

在这里插入图片描述

我们启动的总线程数是 16x16,每次只能加载 16x16 大小的矩阵元素到共享内存。因此,如果我们想要每个线程同时处理 STRIDE * STRIDE 个元素,那么就要经历两个循环,分别对应 x 方向和 y 方向

i = 0, j = 0 时,我们需要加载矩阵 A 和矩阵 B 对应位置的 16x16 大小的元素到 shared memory 中(图中蓝色区域),每个线程加载一个元素,完成第一个分块的加载。接着依次循环 ij,完成剩余分块的加载,加载完的 A_sharedB_shared 大小为 32x32,总共是四个分块。

再回到我们的线程索引的计算中来,首先来看共享内存索引 tile_rowtile_col

const int tile_row = threadIdx.y + i * BLOCK_SIZE;
const int tile_col = threadIdx.x + j * BLOCK_SIZE;

tile_rowtile_col 主要是用来计算当前线程在共享内存块(STEP * STEP) 中写入的位置

每个线程通过循环 ij 扩展其工作范围,覆盖 STEP * STEP 的分块,i * BLOCK_SIZEj * BLOCK_SIZE 将线程的工作范围从 BLOCK_SIZE * BLOCK_SIZE 扩展到 STEP * STEP

例如线程 (threadIdx.y=0, threadIdx.x=0)i = 0, j = 0 时处理 tile_row = 0, tile_col = 0;在 i = 1, j = 0 时处理 tile_row = 16, tile_col = 0,以此类推

再来看全局内存索引:

矩阵 A 的索引:

A[(block_row + tile_row) * K + k + tile_col]
  • 可以将其理解为二维数组 A[block_row + tile_row][k + tile_col]
  • block_row + tile_row:当前 tile 分块在全局矩阵 A 中的行位置,其中 block_row 定位到具体哪个行 block,tile_row 定位到 block 中具体的哪个行 tile
  • k + tile_col:当前 tile 分块在全局矩阵 A 中的列位置,其中 k 定位到哪个 STEP,tile_col 定位到 STEP 中具体哪一列

矩阵 B 的索引:

B[(k + tile_row) * N + block_col + tile_col]
  • 同样可以将其理解为二维数组 B[k + tile_row][block_col + tile_col]
  • k + tile_row:当前 tile 分块在全局矩阵 B 中的行位置,其中 k 定位到哪个 STEP,tile_row 定位到 STEP 中具体哪一行
  • block_col + tile_col:当前 tile 分块在全局矩阵 B 中的列位置,其中 block_col 定位到具体哪个列 block,tile_col 定位到 block 中具体的哪个列 tile

4. 计算累加

// compute
for(int i = 0; i < STRIDE; ++i){for(int j = 0; j < STRIDE; ++j){float sum = 0.0f;for(int s = 0; s < STEP; ++s){const int tile_row = threadIdx.y + i * BLOCK_SIZE;const int tile_col = threadIdx.x + j * BLOCK_SIZE;                    sum += A_shared[tile_row * STEP + s] * B_shared[s * STEP + tile_col];}Sum[i * STRIDE + j] += sum;}
}
__syncthreads();

4.1 tile_rowtile_col 的计算

const int tile_row = threadIdx.y + i * BLOCK_SIZE;  // A_shared 的行索引
const int tile_col = threadIdx.x + j * BLOCK_SIZE;  // B_shared 的列索引

tile_rowtile_col 的作用和前面的一样,主要是用来计算当前线程在共享内存块(STEP * STEP) 中的位置

4.2 共享内存的访问:

A_shared[tile_row * STEP + s]  // 读取 A_shared 的 (tile_row, s) 元素
B_shared[s * STEP + tile_col]  // 读取 B_shared 的 (s, tile_col) 元素
  • A_sharedB_shared 均为 STEP * STEP 的二维数组
  • A_shared[tile_row][s]:固定 tile_row,遍历所有 s(即 A 分块的一行)
  • B_shared[s][tile_col]:固定 tile_col,遍历所有 s(即 B 分块的一列)

4.3 内积计算

sum += A_shared[tile_row * STEP + s] * B_shared[s * STEP + tile_col];

计算 A 分块的第 tile_row 行与 B 分块的第 tile_col 列的点积,结果累加到 sum

4.4 结果累加

Sum[i * STRIDE + j] += sum;  // 保存到线程局部的累加数组

每个线程维护 STRIDE * STRIDE 个累加结果

5. 写回全局内存

for(int i = 0; i < STRIDE; ++i){for(int j = 0; j < STRIDE; ++j){const int tile_row = threadIdx.y + i * BLOCK_SIZE;const int tile_col = threadIdx.x + j * BLOCK_SIZE;const int row = block_row + tile_row;const int col = block_col + tile_col;C[row * N + col] = Sum[i * STRIDE + j];}
}

全局坐标 rowcol 的计算:

  • block_rowblock_col:当前线程块处理的子矩阵的起始行列
  • tile_rowtile_col:线程在分块内的局部偏移

最后每个线程将自己计算出来的 STRIDE * STRIDE 个元素写入 global memory

cuda_sgemm_v3_increase_work_of_per_thread 版本相比于 cuda_sgemm_v2_shared_memory_sliding_window 版本引入了一个重要优化:通过 STRIDE 参数让每个线程计算多个输出元素(一个 STRIDE * STRIDE 的小块),从而显著提升了每个线程的计算强度,减少了线程调度的开销

性能和带宽测试结果如下:

优化手段矩阵维度GridBlock耗时(us)Memory Throughout(%)DRAM Throughout(%)
v0_global_memory512x512(32,32)(16,16)471.7896.941.56
v1_shared_memory256x256(16,16)(16,16)82.1178.921.84
v2_shared_memory_sliding_window512x512(32,32)(16,16)362.5094.457.05
v3_increase_work_of_per_thread512x512(16,16)(16,16)204.2684.013.64

7. 优化技巧4:使用float4提高读取效率

这个小节的优化策略是使用 float4 来读取数据,一次性读取连续的四个数据,减少访存,如下图所示:

在这里插入图片描述

在 v3 版本中我们是通过增加每个线程的工作量来同时处理 4 个元素,左图所示,这里我们可以直接使用内置变量 float4 来一次性读取 4 个元素,与 v3 版本不同的是,这里的 4 个元素在内存中是连续的,右图所示

Note:由于我们使用 float4 内置变量读取数据,因此,之前的 block 的布局有所改变,在 v2 版本中每个 block 大小是 (16, 16),也就是总共 256 个线程,每个 grid 大小是 (32, 32)。在 v4 版本中每个 block 大小是 (4, 16),也就是 x 方向 blockDim.x = 16 / 4 = 4,不再是原来的 16,这是因为 x 方向我们每个线程使用 float4 一次性读取 4 个数据,grid 大小保持不变

代码如下:

#define FLOAT4(pointer) (reinterpret_cast<float4 *>(&(pointer))[0])template<unsigned int BLOCK_SIZE, unsigned int NUM_PER_THREAD>
__global__ void cuda_sgemm_v4_using_float4(float* A, float* B, float* C, const int M, const int N, const int K) {int row = blockIdx.y * blockDim.y + threadIdx.y;int col = (blockIdx.x * blockDim.x + threadIdx.x) * NUM_PER_THREAD;extern __shared__ float shared_mem[];float* A_tile = shared_mem;float* B_tile = shared_mem + BLOCK_SIZE * BLOCK_SIZE;float sum[NUM_PER_THREAD] = {0.0f};for(int k_base = 0; k_base < K; k_base += BLOCK_SIZE){// load A_tile from global memory to shared memoryint a_col = k_base + threadIdx.x * NUM_PER_THREAD;FLOAT4(A_tile[threadIdx.y * BLOCK_SIZE + threadIdx.x * NUM_PER_THREAD]) = FLOAT4(A[row * K + a_col]);// load B_tile from global memory to shared memoryint b_row = k_base + threadIdx.y;FLOAT4(B_tile[threadIdx.y * BLOCK_SIZE + threadIdx.x * NUM_PER_THREAD]) = FLOAT4(B[b_row * N + col]);__syncthreads();// compute the sum of A_tile * B_tilefor(int i = 0; i < NUM_PER_THREAD; ++i){for(int j = 0; j < BLOCK_SIZE; ++j){sum[i] += A_tile[threadIdx.y * BLOCK_SIZE + j] * B_tile[j * BLOCK_SIZE + threadIdx.x * NUM_PER_THREAD + i];}}__syncthreads();        }for(int i = 0; i < NUM_PER_THREAD; ++i){C[row * N + col + i] = sum[i];}
}

具体代码分析如下:(from ChatGPT)

1. 函数签名和宏

#define FLOAT4(pointer) (reinterpret_cast<float4 *>(&(pointer))[0])

这个宏把一个 float* 指针 reinterpret 成 float4*,然后解引用一次,得到一个 float4 类型变量,表示连续的 4 个 float

template<unsigned int BLOCK_SIZE, unsigned int NUM_PER_THREAD>
__global__ void cuda_sgemm_v4_using_float4(float* A, float* B, float* C, const int M, const int N, const int K)
  • 模板参数 BLOCK_SIZE:每个 tile 的大小(示例中是 16),也就是每次从矩阵 A 的子矩阵和矩阵 B 的子矩阵处理 BLOCK_SIZE * BLOCK_SIZE 大小的元素从 global memory 加载到 shared memory,和 v2 版本保持一致
  • 模板参数 NUM_PER_THREAD:每个线程负责多少个输出(默认是 4)

2. 索引计算:定位当前线程处理的矩阵 C 的全局位置

int row = blockIdx.y * blockDim.y + threadIdx.y;
int col = (blockIdx.x * blockDim.x + threadIdx.x) * NUM_PER_THREAD;
  • row 行的索引和 v2 版本的没有区别
  • 注意这里的 col 列索引的写法 (...) * NUM_PER_THREAD 非常关键
    • 每个线程现在需要处理 4 个列方向上的元素,因此要乘以 4,也就是列与列之间 stride 步长是 4,不再是原来的 1

3. 声明共享内存并划分 A_tileB_tile

extern __shared__ float shared_mem[];
float* A_tile = shared_mem;
float* B_tile = shared_mem + BLOCK_SIZE * BLOCK_SIZE;
  • shared_mem 是传入 kernel 的动态共享内存,大小是 2 * BLOCK_SZIE * BLOCK_SIZE
  • 一半用来存 A_tile,另一个用来存 B_tile,和 v2 版本保持一致

4. 准备累加数组 sum

float sum[NUM_PER_THREAD] = {0.0f};
  • 每个线程要累加出 NUM_PER_THREAD 个输出元素

5. 核心主循环:tile-wise 乘法

for(int k_base = 0; k_base < K; k_base += BLOCK_SIZE){
  • 滑动窗口,从左到右(沿着 K 维方向)遍历 A 子矩阵和 B 子矩阵
  • 每轮计算一个 BLOCK_SIZE * BLOCK_SIZE 的子块(tile)

5.1 将 A 子块加载到共享内存(float4 加载)

int a_col = k_base + threadIdx.x * NUM_PER_THREAD;
FLOAT4(A_tile[threadIdx.y * BLOCK_SIZE + threadIdx.x * NUM_PER_THREAD]) = FLOAT4(A[row * K + a_col]);
  • 矩阵 A 的索引可以理解为二维数组 A[row][a_col]row * K 定位到具体哪一行,a_col 定位到具体哪一列,和之前 v2 版本保持一致,不过差别就是 a_colthreadIdx.x 需要乘上 NUM_PER_THREAD,代表着每个线程加载 A 中的 4 个元素数据
  • 共享内存中的 A_tile 也可以理解为二维数组 A[threadIdx.y][threadIdx.x * NUM_PER_THREAD]threadIdx.y * BLOCK_SIZE 定位到具体哪一行,threadIdx.x * NUM_PER_THREAD 定位到具体哪一列,和之前有差异的就是 threadIdx.x 需要乘以 NUM_PER_THREAD
  • 每个线程负责从矩阵 A 拷贝 4 个连续的 float 到 shared memory 中

博主绘制了一个草图来简单说明和 v2 版本的差异,如下图所示:

在这里插入图片描述

在 v2 版本中我们启动的总线程数和 C 矩阵的元素总数是一样的,每个线程处理 C 中的一个元素,包括虚线部分。在当前版本中我们启动的总线程数是原来的 1/4,因为每个线程采用 float4 来同时处理 4 个元素

对于第一个线程(蓝色小块)而言,它会计算 C 矩阵前 4 个结果,主要是计算矩阵 A 中的一行和矩阵 B 中的四列,对于第二个线程(绿色小块)也是类似,它也是计算 C 矩阵的 4 个元素结果

Note:这里博主未展示逐 tile 块加载到 shared memory 中计算,不过你应该清楚这一点

5.2 将 B 子块加载到共享内存(float4 加载)

int b_row = k_base + threadIdx.y;
FLOAT4(B_tile[threadIdx.y * BLOCK_SIZE + threadIdx.x * NUM_PER_THREAD]) = FLOAT4(B[b_row * N + col]);
  • 矩阵 B 的索引计算同样可以理解为二维数组 B[b_row][col]b_row * N 定位到具体哪一行,col 定位到具体哪一列
  • 共享内存中的 B_tile 索引和 A_tile 保持一致

5.3 同步线程,确保 tile 加载完成

__syncthreads();

5.4 实际乘法:A_tile * B_tile,累加进 sum

for(int i = 0; i < NUM_PER_THREAD; ++i){for(int j = 0; j < BLOCK_SIZE; ++j){sum[i] += A_tile[threadIdx.y * BLOCK_SIZE + j] *B_tile[j * BLOCK_SIZE + threadIdx.x * NUM_PER_THREAD + i];}
}
  • 外层 i 是当前线程正在计算的第 i 个输出元素(对应 C[row][col + i]
  • 内层 j 遍历 k 方向的 BLOCK_SIZE
  • A_tile 的索引可以理解为二维数组 A_tile[threadIdx.y][j]threadIdx.y * BLOCK_SIZE 定义到具体哪一行,j 则定位这一行的具体哪个元素
  • B_tile 的索引需要加上 NUM_PER_THREAD 的偏移量,它也可以理解为二维数组 B_tile[j][threadIdx.x * NUM_PER_THREAD + i]j 定位到具体哪一行,threadIdx.x * NUM_PER_THREAD 定位到具体是哪个 threadIdx.x 负责的 float4 变量,+ i 定位到 float4 变量中的具体哪个 float

博主绘制了一个草图来理解这一过程,如下图所示:

在这里插入图片描述

在图中,BLOCK_SIZE 设置为 8,block 的大小为 (2, 8),i 对应着外层循环,内层循环则是访问 A_tile 的一行和 B_tile 的一列,图中未展示。我们可以清晰的看到一个线程处理 B_tile 的四列,也就是一个线程处理 B_tile 中的 float4 即 4 个元素

5.5 再次同步线程,为下一轮 tile 做准备

__syncthreads();

6. 写回输出 C

for(int i = 0; i < NUM_PER_THREAD; ++i){C[row * N + col + i] = sum[i];
}
  • 每个线程写回自己负责的 4 个输出元素

v4 版本相比于 v2 版本访问效率提高了,主要体现在在:

  • 使用 float4 同时加载 4 个 float:提高 global memory 带宽利用率
  • 每个线程计算 NUM_PER_THREAD 个输出值:减少线程数量,提高 cache hit 率

性能和带宽测试结果如下:

优化手段矩阵维度GridBlock耗时(us)Memory Throughout(%)DRAM Throughout(%)
v0_global_memory512x512(32,32)(16,16)471.7896.941.56
v1_shared_memory256x256(16,16)(16,16)82.1178.921.84
v2_shared_memory_sliding_window512x512(32,32)(16,16)362.5094.457.05
v3_increase_work_of_per_thread512x512(16,16)(16,16)204.2684.013.64
v4_using_float4512x512(32,32)(4,16)209.6091.993.44

结语

这篇文章我们主要从三个方面出发优化 sgemm,首先 baseline 使用的 global memory 对内存的访问非常高,因此优化技巧 1 和 2 考虑使用 shared memory 减少对全局内存的访问次数,提高带宽利用率。优化技巧 3 通过增加每个线程的工作量来提高线程处理的强度,减少线程调度的开销。同样优化技巧 4 也是减少线程数量,提高线程利用率,不过它是通过内置变量 float4 来同时处理 4 个元素

OK,这就是这篇文章关于 sgemm 优化的全部内容了

篇幅原因,后续的 sgemm 优化我们放在下篇文章中讲解,敬请期待🤗

下载链接

  • Sgemm 矩阵乘法代码下载链接【提取码:1234】

参考

  • 【CUDA】Sgemm单精度矩阵乘法(已完结~)
  • https://github.com/tpoisonooo/how-to-optimize-gemm/cuda
  • 深入浅出GPU优化系列:GEMM优化(一)
  • [施工中] CUDA GEMM 理论性能分析与 kernel 优化
  • cuda 入门的正确姿势:how-to-optimize-gemm
  • CUDA 矩阵乘法终极优化指南
  • CUDA实现矩阵乘法的8种优化策略编程介绍
  • https://chatgpt.com/

相关文章:

【CUDA】Sgemm单精度矩阵乘法(上)

目录 前言1. 简述2. 框架搭建和CPU串行实现3. baseline算法&#xff1a;global memory4. 优化技巧1&#xff1a;shared memory5. 优化技巧2&#xff1a;shared memory sliding window6. 优化技巧3&#xff1a;增加每个线程的工作量7. 优化技巧4&#xff1a;使用float4提高读取…...

Linux proc文件系统 内存影射

文章目录 常见的内存分配函数/proc/pid/ 目录解析 用户进程的内存空间分配算法mmap 分配大内存可能不在堆中换为 malloc 现象相同 常见的内存分配函数 malloc / calloc / realloc&#xff08;来自 C 标准库&#xff09; void *malloc(size_t size)&#xff1a;分配 size 字节…...

服务器时间发生跳变导致hghac中对应主机状态频繁切换为crash或stop

文章目录 环境症状问题原因解决方案相关文档报错编码 环境 系统平台&#xff1a;N/A 版本&#xff1a;N/A 症状 集群状态&#xff1a; [rootbthbj-hgywsjkjq-ip28-cen76 ~]# hghactl list Cluster: highgo-ee-cluster —---------------------—---------- | Member | Ho…...

“智”造升级:金众诚如何赋能重型机械企业高效项目管理?

据悉&#xff0c;由中国重型机械工业协会主办的2025中国&#xff08;郑州&#xff09;重型机械装备展览会&#xff08;以下简称&#xff1a;重型机械装备展&#xff09;&#xff0c;将于2025年10月15-17日在河南省郑州市中原国际会展中心隆重举办。 作为中国重型机械行业唯一的…...

反向操作:如何用AI检测工具优化自己的论文“人味”?

大家好&#xff0c;这里是论文写手的一线自救指南&#x1f624; 在AIGC横行的今天&#xff0c;谁还没偷偷用过AI写几段论文内容&#xff1f;但问题来了&#xff1a;学校越来越会“识AI”了&#xff01; 有的学校甚至不看重复率&#xff0c;只盯AIGC率报告&#xff0c;一句“AI…...

前端面试宝典---js垃圾回收机制

什么是垃圾回收 垃圾回收是指一种自动内存管理机制&#xff0c;当声明一个变量时&#xff0c;会在内存中开辟一块内存空间用于存放这个变量。当这个变量被使用过后&#xff0c;可能再也不需要它了&#xff0c;此时垃圾回收器会自动检测并回收这些不再使用的内存空间。垃圾回收…...

【docker】--容器管理

文章目录 容器重启--restart 参数选项及作用**对比 always 和 unless-stopped****如何查看容器的重启策略&#xff1f;** 容器重启 –restart 参数选项及作用 重启策略 no&#xff1a;不重启&#xff08;默认&#xff09;。on-failure&#xff1a;失败时重启&#xff08;可限…...

如何下载和安装 Ghost Spectre Windows 11 24H2 PRO

如何下载和安装 Ghost Spectre Windows 11 24H2 PRO 我们都希望拥有一台运行速度飞快的电脑系统,但对于那些使用普通硬件的用户来说,这并不总是可能的。所以,如果你觉得你的 Windows 11 电脑运行缓慢,你并不孤单。许多用户,包括 Reddit 和 YouTube 上的技术爱好者,都在放…...

软考软件设计师中级——软件工程笔记

1.软件过程 1.1能力成熟度模型&#xff08;CMM&#xff09; 软件能力成熟度模型&#xff08;CMM&#xff09;将软件过程改进分为以下五个成熟度级别&#xff0c;每个级别都定义了特定的过程特征和目标&#xff1a; 初始级 (Initial)&#xff1a; 软件开发过程杂乱无章&#xf…...

有关多线程

一、多线程到底是什么&#xff1f;简单说一说 你可以把程序想象成一台工厂。单线程就是工厂里只有一个员工&#xff0c;他做事情、搬产品、打包都靠一个人&#xff0c;他忙起来速度会慢一些。而多线程就像有多个员工同时工作&#xff0c;他们各自干自己的事情&#xff0c;整体…...

静电的起因与静电效应:技术分析与应用

杭州干扰净电子科技有限公司出品&#xff1a; 静电&#xff08;Electrostatics&#xff09;是由于电荷积累或转移引起的现象&#xff0c;广泛存在于日常生活和工业环境中。静电的起因主要包括接触起电、摩擦起电和感应起电&#xff0c;而其效应可能引发静电放电&#xff08;ES…...

Python知识框架

一、Python基础语法 变量与数据类型 变量命名规则 基本类型&#xff1a;int, float, str, bool, None 复合类型&#xff1a;list, tuple, dict, set 类型转换与检查&#xff08;type(), isinstance()&#xff09; 运算符 算术运算符&#xff1a;, -, *, /, //, %, ** 比较…...

npm install 报错

1、protobufjs7.4.0 postinstall: node scripts/postinstall verbose stack Error: protobufjs7.4.0 postinstall: node scripts/postinstall 确认是否有postinstall# node scripts/postinstallCannot find module /home/rio/scripts/postinstall解决办法&#xff1a; # 添加…...

ESP32WIFI工具加透传

工欲善其事&#xff0c;必先利器其器 项目首页 - ESP-12F烧录使用软件与说明书:ESP-12F 烧录使用软件与说明书本仓库提供了ESP-12F模块的烧录使用软件及相关说明书&#xff0c;帮助用户快速上手并使用ESP-12F模块进行开发 - GitCode ATCWMODE 是 ESP32 系列芯片中用于设置 Wi…...

44、私有程序集与共享程序集有什么区别?

私有程序集&#xff08;Private Assembly&#xff09;与共享程序集&#xff08;Shared Assembly&#xff09;是.NET框架中程序集部署的两种不同方式&#xff0c;它们在部署位置、版本控制、访问权限等方面存在显著差异&#xff0c;以下是对二者的详细比较&#xff1a; 1. 部署…...

Hadoop集群故障节点隔离操作指南

一、确认故障节点状态 1‌.查看集群节点状态 hdfs dfsadmin -report # 显示所有DataNode状态(存活/宕机/存储利用率) 输出中标记为 Dead 或 Decommissioning 的节点为异常节点。 ‌ 2.分析监控指标 通过Prometheus/Grafana监控平台检查节点资源(CPU、内存、磁盘I…...

流速仪数据处理及流量断面线绘制

1 需求描述 在实际航道测量项目中&#xff0c;有测量断面线流量流速的需求&#xff0c;得使用流速仪在现场进行测量&#xff0c;相关操作在之前已经写了记录。本次手册记录后期数据处理与流量线绘制&#xff0c;以该区域为例。 流速仪设备操作说明 2 规范要求 3 流量断面表格…...

android实现USB通讯

在 Android 上枚举 USB 设备除了使用 UsbManager.getDeviceList() 方法外&#xff0c;还有以下几种常见的方式&#xff1a; 1. 使用 USB 设备过滤器&#xff08;XML 配置&#xff09; 通过在 AndroidManifest.xml 中配置 USB 设备过滤器&#xff0c;可以让系统自动检测并通知…...

公链开发及其配套设施:钱包与区块链浏览器

公链开发及其配套设施&#xff1a;钱包与区块链浏览器的技术架构与生态实践 ——2025年区块链基础设施建设的核心逻辑与创新突破 一、公链开发&#xff1a;构建去中心化世界的基石 1. 技术架构设计的三重挑战 公链作为开放的区块链网络&#xff0c;需在性能、安全性与去中心…...

SVM在医疗设备故障维修服务决策中的应用:策略、技术与实践

SVM在医疗设备故障维修服务决策中的应用&#xff1a;策略、技术与实践 医疗设备的高可靠性、安全性及严格合规性要求&#xff0c;使其故障维修决策具有显著的特殊性。支持向量机&#xff08;SVM&#xff09;凭借小样本学习、非线性建模及高精度分类能力&#xff0c;可有效解决…...

如何高效集成MySQL数据到金蝶云星空

MySQL数据集成到金蝶云星空&#xff1a;SC采购入库-深圳天一-OK案例分享 在企业信息化建设中&#xff0c;数据的高效流转和准确对接是实现业务流程自动化的关键。本文将聚焦于一个具体的系统对接集成案例——“SC采购入库-深圳天一-OK”&#xff0c;详细探讨如何通过轻易云数据…...

NACOS基于长链接的⼀致性模型

1. 配置⼀致性模型 sdk-server ⼀致性 server 间⼀致性 Server 间同步消息接收处理轻量级实现&#xff0c;重试失败时&#xff0c;监控告警。断网&#xff1a;断网太久&#xff0c;重试任务队列爆满时&#xff0c;无剔除策略。 2. 服务⼀致性模型...

Docker 常见问题及其解决方案

一、安装与启动问题 1.1 安装失败 在不同操作系统上安装 Docker 时&#xff0c;可能会出现安装失败的情况。例如&#xff0c;在 Ubuntu 系统中&#xff0c;执行安装命令后提示依赖缺失。这通常是因为软件源配置不正确或系统缺少必要的依赖包。 解决方案&#xff1a; 确保系统…...

Nginx 动静分离在 ZKmall 开源商城静态资源管理中的深度优化

在 B2C 电商高并发场景下&#xff0c;静态资源&#xff08;图片、CSS、JavaScript 等&#xff09;的高效管理直接影响页面加载速度与用户体验。ZKmall开源商城通过对 Nginx 动静分离技术的深度优化&#xff0c;将静态资源响应速度提升 65%&#xff0c;带宽成本降低 40%&#xf…...

Lighthouse 自定义审计

以下是关于 Lighthouse 自定义审计的基本知识点总结: 一、Lighthouse 自定义审计核心概念 1. 审计机制架构 #mermaid-svg-lzu9WEel4gUome5N {font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}#mermaid-svg-lzu9WEel4gUome5N .erro…...

龙虎榜——20250514

上证日线收阳线&#xff0c;大盘股相对强势&#xff0c;整体跌多涨少&#xff0c;量能较昨日放大&#xff0c;大金融发力&#xff0c;但总体处于日线上涨末端&#xff0c;注意风险。 深证日线冲高回落&#xff0c;触及前期压力位&#xff0c;量能未放大&#xff0c;总体处于日…...

基于javaweb的SpringBoot自习室预约系统设计与实现(源码+文档+部署讲解)

技术范围&#xff1a;SpringBoot、Vue、SSM、HLMT、Jsp、PHP、Nodejs、Python、爬虫、数据可视化、小程序、安卓app、大数据、物联网、机器学习等设计与开发。 主要内容&#xff1a;免费功能设计、开题报告、任务书、中期检查PPT、系统功能实现、代码编写、论文编写和辅导、论文…...

二分查找的边界问题

前言 二分查找(Binary Search)是一种高效的查找算法&#xff0c;时间复杂度为O(log n)。它适用于已排序的数组或列表。本文将详细介绍二分查找的两种常见写法&#xff1a;闭区间写法和左闭右开区间写法。 一、二分查找基本思想 二分查找的核心思想是"分而治之"&am…...

应用示例1:交通灯

基于FPGA的交通灯控制系统实现原理详解 目录 基于FPGA的交通灯控制系统实现原理详解一、项目简介二、数字电路与基础知识1. 交通灯系统的有限状态机(FSM)2. 数码管显示原理3. 二进制转BCD显示4. 时钟与分频三、功能需求与系统结构功能需求系统结构四、各模块设计原理说明1. 时…...

Docker 介绍与使用

Docker 文章目录 Docker介绍与虚拟机的比较启动速度占用资源 优势更容易迁移更容易维护更容易扩展 使用场景持续集成提供可伸缩的云服务搭建微服务架构 镜像与容器镜像构成&#xff08;分层结构&#xff09;镜像与容器的区别 安装 Docker常用命令介绍镜像相关容器相关 实战&…...

[数据结构]6. 队列-Queue

队列-Queue 1. 介绍2. 队列实现2.1 基于链表的实现2.2 基于数组的实现 3. 队列操作CreateInitializeDestoryPushPopFrontBackSizeEmpty 1. 介绍 队列&#xff08;queue&#xff09; 是一种遵循先入先出规则的线性数据结构。将队列头部称为“队首”&#xff0c;尾部称为“队尾”…...

mybatis plus (sqlserver) 根据条件来获取id最大的,或者是新增的最新的一条记录(同条件可能会有多条出现)

1、mysql的版本 limit 1 QueryWrapper<Userinfo> queryWrapper new QueryWrapper<>();queryWrapper.eq("fid", payment.getFid());return userinfoMapper.selectOne(queryWrapper.orderByDesc("id").last("limit 1")); 只要类似以…...

打卡DAY25

DAY 25 异常处理 知识点回顾&#xff1a; 1. 异常处理机制 2. debug过程中的各类报错 3. try-except机制 4. try-except-else-finally机制 在即将进入深度学习专题学习前&#xff0c;我们最后差缺补漏&#xff0c;把一些常见且重要的知识点给他们补上&#xff0c;加深…...

【C语言指针超详解(六)】--sizeof和strlen的对比,数组和指针笔试题解析,指针运算笔试题解析

目录 一.sizeof和strlen 1.1--sizeof 1.2--strlen 1.3--sizeof和strlen的对比 二.数组和指针笔试题解析 2.1--一维数组 2.2--字符数组 2.2.1--代码1&#xff1a; 2.2.2--代码2&#xff1a; 2.2.3--代码3&#xff1a; 2.2.4--代码4 &#xff1a; 2.2.5--代码5&#…...

Java 异常处理之 BufferUnderflowException(BufferUnderflowException 概述、常见发生场景、避免策略)

一、BufferUnderflowException 概述 BufferUnderflowException 是 Java NIO 包中的一个运行时异常&#xff0c;是 RuntimeException 的子类 public class BufferUnderflowException extends RuntimeException {... }# 继承关系java.lang.Object-> java.lang.Throwable->…...

OpenCV人脸识别LBPH算法原理、案例解析

文章目录 前言一、LBPH 算法原理概述1、LBP 特征计算2、均匀模式与旋转不变性3、直方图统计与识别 二、环境准备1、安装依赖2、数据集结构 三、代码实现&#xff08;完整代码约 150 行&#xff09;1、导入库与配置2、加载数据与标签生成3、 模型训练与保存4、 实时人脸识别5、主…...

Lightpanda开源浏览器:专为 AI 和自动化而设计的无界面浏览器

​一、软件介绍 文末提供程序和源码下载 Lightpanda开源浏览器&#xff1a;专为 AI 和自动化而设计的无界面浏览器&#xff1b; Javascript execution Javascript 执行Support of Web APIs (partial, WIP)支持 Web API&#xff08;部分、WIP&#xff09;Compatible with Pla…...

Docker 疑难杂症解决指南:从入门到进阶的全面剖析

Docker 作为容器化技术的代表&#xff0c;凭借其轻量级、可移植性和高效资源利用率&#xff0c;已成为开发、测试和部署应用的标准工具。然而&#xff0c;在实际使用中&#xff0c;用户常常会遇到镜像构建失败、容器启动异常、网络配置问题等疑难杂症。本文将从镜像构建、容器生…...

CodeBuddy Craft,我的编程搭子

我正在参加CodeBuddy「首席试玩官」内容创作大赛&#xff0c;本文所使用的 CodeBuddy 免费下载链接&#xff1a;腾讯云代码助手 CodeBuddy - AI 时代的智能编程伙伴 你好&#xff0c;我是悟空。 背景 最近项目组事情挺多&#xff0c;一个人要干多个人的活&#xff0c;而且写…...

如何实现一个运动会计分系统?(C语言版)

一、需求分析 设计一个运动会计分系统,计分信息包括参加学校,参与项目,性别,名次个数,各个学校获得名次信息。该系统具有以下功能 数据录入: 链表或结构体数组组织数据数据报表: 依照规定的报表格式对数据打印报表数据排序: 按照要求对数据进行统计,含简单统计及综合统计…...

linux内核主要由哪五个模块构成?

Linux内核是一个高度模块化的系统&#xff0c;其核心功能通常被划分为以下五大模块&#xff0c;共同协作实现操作系统的基础功能&#xff1a; ​1. 进程管理&#xff08;Process Management&#xff09;​​ ​核心功能​&#xff1a;负责进程的创建、调度、终止&#xff0c;以…...

编程日志5.5

树的结构代码 #include<iostream> using namespace std; //由于树的每个结点可能有一些孩子结点,这些孩子结点的数量不确定,所以可以用一个链表来把所有的孩子结点给串起来 //链表结点定义 //这段代码定义了一个结构体ListNode,用于表示链表中的一个结点。这个结构…...

React学习———React.memo、useMemo和useCallback

React.memo React.memo是React提供的一个高阶组件&#xff0c;用于优化函数组件的性能&#xff0c;它通过记忆组件的渲染结果&#xff0c;避免在父组件重新渲染时&#xff0c;子组件不必要的重新渲染 React.memo会对组件的props进行浅比较&#xff0c;如果props没有变化&#…...

OpenCV实现数字水印的相关函数和示例代码

OpenCV计算机视觉开发实践&#xff1a;基于Qt C - 商品搜索 - 京东 实现数字水印的相关函数 用OpenCV来实现数字水印功能&#xff0c;需要使用一些位操作函数&#xff0c;我们需要先了解一下这些函数。 1. bitwise_and函数 bitwise_and函数是OpenCV中的位运算函数之一&…...

【CUDA】Sgemm单精度矩阵乘法(下)

目录 前言1. 优化技巧5&#xff1a;使用register模拟二级缓存&#xff08;内积转外积&#xff09;2. 优化技巧6&#xff1a;使用register模拟二级缓存 float43. 优化技巧7&#xff1a;global memory转置再存放shared memory4. 优化技巧8&#xff1a;使用double buffer加速矩阵…...

cursor 学习

参考&#xff1a;AI编程神器&#xff01;Cursor无限续杯&#xff01;白嫖白嫖&#xff01;&#xff01;&#xff01;...

学术论文的科研流程概述 视频会议记录

CCF-Talk SPP131期 浙江大学研究员彭思达的报告。 举例视频生成要多快好省。 提升代码能力&#xff1a;先明白基础的函数&#xff0c;可以复现一个网络。最好是实现一个操作系统。...

【Linux笔记】——Linux线程理解与分页存储的奥秘

&#x1f525;个人主页&#x1f525;&#xff1a;孤寂大仙V &#x1f308;收录专栏&#x1f308;&#xff1a;Linux &#x1f339;往期回顾&#x1f339;&#xff1a;【Linux笔记】——进程信号的捕捉——从中断聊聊OS是怎么“活起来”的 &#x1f516;流水不争&#xff0c;争的…...

ACM算法

在ACM模式下使用JavaScript/TypeScript获取输入值 在ACM编程竞赛或在线判题系统(如LeetCode、牛客网等)中&#xff0c;JavaScript/TypeScript需要特定的方式来获取输入值。以下是几种常见的获取输入的方法&#xff1a; 1. 使用Node.js的readline模块 这是最常见的处理ACM模式…...

家用或办公 Windows 电脑玩人工智能开源项目配备核显的必要性(含 NPU 及显卡类型补充)

一、GPU 与显卡的概念澄清 首先需要明确一个容易误解的概念&#xff1a;GPU 不等同于显卡。 显卡和GPU是两个不同的概念。 【概念区分】 在讨论图形计算领域时&#xff0c;需首先澄清一个常见误区&#xff1a;GPU&#xff08;图形处理单元&#xff09;与显卡&#xff08;视…...