第5节:CUDA矩阵乘法实现

文章目录

  • 引言
  • 一、问题定义
  • 二、CPU朴素实现
    • [2.1 三重循环实现](#2.1 三重循环实现)
  • 三、GPU朴素实现
    • [3.1 最简单的想法:每个线程计算一个输出元素](#3.1 最简单的想法:每个线程计算一个输出元素)
    • [3.2 预期性能(令人失望!)](#3.2 预期性能(令人失望!))
  • 四、性能瓶颈分析
    • [4.1 计算量与内存访问量](#4.1 计算量与内存访问量)
    • [4.2 访存模式分析](#4.2 访存模式分析)
    • [4.3 使用 Nsight Compute 分析](#4.3 使用 Nsight Compute 分析)
  • 五、为什么矩阵乘法这么重要?
  • 六、从瓶颈到优化思路
    • [6.1 共享内存与分块矩阵乘法优化------性能提升10倍的奥秘](#6.1 共享内存与分块矩阵乘法优化——性能提升10倍的奥秘)
      • [6.1.1 朴素矩阵乘法的痛点回顾](#6.1.1 朴素矩阵乘法的痛点回顾)
      • [6.1.2 分块算法的核心思想](#6.1.2 分块算法的核心思想)
      • [6.1.3 分块矩阵乘法实现](#6.1.3 分块矩阵乘法实现)
      • [6.1.4 性能对比与分析](#6.1.4 性能对比与分析)
      • [6.1.5 仍存在的问题](#6.1.5 仍存在的问题)
    • [6.2 改进访存模式------逼近硬件极限](#6.2 改进访存模式——逼近硬件极限)
      • [6.2.1 向量化加载](#6.2.1 向量化加载)
      • [6.2.2 消除 Bank Conflict](#6.2.2 消除 Bank Conflict)
      • [6.2.3 循环展开](#6.2.3 循环展开)
      • [6.2.4 双缓冲(Double Buffering)](#6.2.4 双缓冲(Double Buffering))
      • [6.2.5 综合优化性能预测](#6.2.5 综合优化性能预测)
      • [6.2.6 完整优化代码示例(组合版)](#6.2.6 完整优化代码示例(组合版))
      • [6.2.7 性能测试数据(A100, n=2048)](#6.2.7 性能测试数据(A100, n=2048))
    • [6.3 使用 Tensor Core](#6.3 使用 Tensor Core)
  • [七、CPU 与 GPU 性能对比实验](#七、CPU 与 GPU 性能对比实验)
  • 八、验证正确性
  • 九、本节总结
    • [9.1 核心收获](#9.1 核心收获)
    • [9.2 下节预告](#9.2 下节预告)
  • 十、面试真题(2024-2026)

引言

向量加法只是热身,矩阵乘法才是真正的试金石

上一节的向量加法让我们初步体验了GPU的威力------只要数据规模足够大,GPU能轻松碾压CPU。但向量加法是"完美并行"的任务:每个输出元素的计算完全独立,内存访问模式也是理想的合并访问。

然而,现实世界中的算法往往没有这么简单。矩阵乘法(Matrix Multiplication,简称GEMM)是科学计算和深度学习的核心操作,它的性能直接决定了整个应用的速度。

今天,我们将实现矩阵乘法的朴素版本,并直面GPU编程的第一个真正挑战:访存与计算的权衡。你会发现,同样的GPU,写不好可能比CPU还慢。

一、问题定义

计算矩阵乘法 C = A × B,其中:

  • A 是 M×K 矩阵
  • B 是 K×N 矩阵
  • C 是 M×N 矩阵

数学定义:

复制代码
C[i][j] = sum_{k=0}^{K-1} A[i][k] * B[k][j]

为简化,我们假设所有矩阵都是方阵,即 M = N = K = n。

二、CPU朴素实现

2.1 三重循环实现

cpp 复制代码
// matmul_cpu.cpp
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

void matmul_cpu(const float* a, const float* b, float* c, int n) {
    for (int i = 0; i < n; i++) {
        for (int j = 0; j < n; j++) {
            float sum = 0.0f;
            for (int k = 0; k < n; k++) {
                sum += a[i * n + k] * b[k * n + j];
            }
            c[i * n + j] = sum;
        }
    }
}

double get_time() { ... }  // 同上

int main() {
    int n = 1024;  // 1024x1024 矩阵
    size_t bytes = n * n * sizeof(float);
    
    // 分配内存
    float* h_a = (float*)malloc(bytes);
    float* h_b = (float*)malloc(bytes);
    float* h_c = (float*)malloc(bytes);
    
    // 初始化(简单起见,用随机数)
    for (int i = 0; i < n * n; i++) {
        h_a[i] = rand() / (float)RAND_MAX;
        h_b[i] = rand() / (float)RAND_MAX;
    }
    
    double start = get_time();
    matmul_cpu(h_a, h_b, h_c, n);
    double end = get_time();
    
    double elapsed = end - start;
    double gflops = (2.0 * n * n * n) / elapsed / 1e9;  // 每个乘加算2次浮点运算
    
    printf("CPU 矩阵乘法 (n=%d):\n", n);
    printf("  时间: %.4f s\n", elapsed);
    printf("  性能: %.2f GFLOP/s\n", gflops);
    
    free(h_a); free(h_b); free(h_c);
    return 0;
}

编译运行(优化开满):

bash 复制代码
gcc -O3 -march=native matmul_cpu.cpp -o matmul_cpu
./matmul_cpu

对于 n=1024,预期性能:在现代CPU上可能达到 10-30 GFLOP/s(取决于CPU和编译器优化)。例如,Intel i9-12900K 的单精度矩阵乘法可以到 50 GFLOP/s 左右。

三、GPU朴素实现

3.1 最简单的想法:每个线程计算一个输出元素

cpp 复制代码
// matmul_gpu_naive.cu
#include <cuda_runtime.h>
#include <stdio.h>
#include <stdlib.h>

#define CHECK_CUDA(call) { ... }

// 每个线程计算 C[i][j]
__global__ void matmul_naive(const float* a, const float* b, float* c, int n) {
    int row = blockIdx.y * blockDim.y + threadIdx.y;
    int col = blockIdx.x * blockDim.x + threadIdx.x;
    
    if (row < n && col < n) {
        float sum = 0.0f;
        for (int k = 0; k < n; k++) {
            sum += a[row * n + k] * b[k * n + col];
        }
        c[row * n + col] = sum;
    }
}

int main() {
    int n = 1024;
    size_t bytes = n * n * sizeof(float);
    
    // 主机内存
    float *h_a, *h_b, *h_c;
    h_a = (float*)malloc(bytes);
    h_b = (float*)malloc(bytes);
    h_c = (float*)malloc(bytes);
    
    // 初始化
    for (int i = 0; i < n * n; i++) {
        h_a[i] = rand() / (float)RAND_MAX;
        h_b[i] = rand() / (float)RAND_MAX;
    }
    
    // 设备内存
    float *d_a, *d_b, *d_c;
    CHECK_CUDA( cudaMalloc(&d_a, bytes) );
    CHECK_CUDA( cudaMalloc(&d_b, bytes) );
    CHECK_CUDA( cudaMalloc(&d_c, bytes) );
    
    CHECK_CUDA( cudaMemcpy(d_a, h_a, bytes, cudaMemcpyHostToDevice) );
    CHECK_CUDA( cudaMemcpy(d_b, h_b, bytes, cudaMemcpyHostToDevice) );
    
    // 配置线程:二维block,二维grid
    dim3 threads_per_block(16, 16);  // 256线程/block
    dim3 blocks_per_grid((n + 15) / 16, (n + 15) / 16);
    
    cudaEvent_t start, stop;
    cudaEventCreate(&start);
    cudaEventCreate(&stop);
    
    cudaEventRecord(start);
    matmul_naive<<<blocks_per_grid, threads_per_block>>>(d_a, d_b, d_c, n);
    cudaEventRecord(stop);
    cudaEventSynchronize(stop);
    
    float ms;
    cudaEventElapsedTime(&ms, start, stop);
    double elapsed = ms / 1000.0;
    
    CHECK_CUDA( cudaMemcpy(h_c, d_c, bytes, cudaMemcpyDeviceToHost) );
    
    double gflops = (2.0 * n * n * n) / elapsed / 1e9;
    
    printf("GPU 朴素矩阵乘法 (n=%d):\n", n);
    printf("  时间: %.6f s (%.3f ms)\n", elapsed, ms);
    printf("  性能: %.2f GFLOP/s\n", gflops);
    
    // 可选:验证结果是否正确(取几个点对比CPU结果)
    // 这里省略,但实际应该验证
    
    CHECK_CUDA( cudaFree(d_a) );
    CHECK_CUDA( cudaFree(d_b) );
    CHECK_CUDA( cudaFree(d_c) );
    free(h_a); free(h_b); free(h_c);
    
    cudaEventDestroy(start);
    cudaEventDestroy(stop);
    
    return 0;
}

编译运行:

bash 复制代码
nvcc -O3 matmul_gpu_naive.cu -o matmul_gpu
./matmul_gpu

3.2 预期性能(令人失望!)

在 A100 上运行 n=1024,你可能只得到 几十到几百 GFLOP/s,远低于 A100 的理论峰值(19.5 TFLOPS FP32)。为什么?

让我们分析一下。

四、性能瓶颈分析

4.1 计算量与内存访问量

对于 n×n 矩阵乘法:

  • 计算量:2n³ 次浮点运算(乘加各一次)
  • 内存访问:
    • 读 A:n² 次(每个元素被读 n 次!)
    • 读 B:n² 次(每个元素被读 n 次!)
    • 写 C:n² 次

总内存访问量 ≈ 2n²(A和B被重复读取)但实际上,每次循环都要从全局内存读取 A 和 B 的元素,所以实际内存访问次数是巨大的:每个输出元素需要读取 A 的一整行(n 次)和 B 的一整列(n 次),总共 2n 次内存访问。因此,总内存访问量 ≈ 2n * n² = 2n³ 次浮点数访问。

计算与内存访问比(CGMA,Compute to Global Memory Access):

复制代码
CGMA = 计算量 / 内存访问量 = (2n³) / (2n³) = 1 次浮点运算/次内存访问

这个比率极低!而现代 GPU 的计算能力远高于内存带宽。例如 A100:

  • 计算峰值:19.5 TFLOPS
  • 内存带宽:1.55 TB/s

要达到计算峰值,我们需要至少 19.5 / 1.55 ≈ 12.6 次浮点运算/字节。如果每个浮点数是4字节,那么需要 约 50 次浮点运算/次内存访问 。而我们只有 1!所以这个kernel是严重受限于内存带宽的

4.2 访存模式分析

再看访存模式:

  • A[row * n + k]:当 k 变化时,对于固定的 row,访问是连续的(行主序),这是合并访问。
  • B[k * n + col]:当 k 变化时,对于固定的 col,访问的跨度是 n 个元素(即列访问),这是非合并访问!同一个 warp 内的线程(col 连续)访问 B 的不同行,导致大量不连续的访问,进一步降低带宽利用率。

因此,朴素实现既受限于内存带宽,又有非合并访问问题,性能自然很差。

4.3 使用 Nsight Compute 分析

运行性能分析:

bash 复制代码
ncu --metrics sm__throughput.avg.pct_of_peak_sustained_elapsed,sm__memory_throughput.avg.pct_of_peak_sustained_elapsed ./matmul_gpu

你会看到:

  • 内存吞吐量远低于峰值(可能只有10-20%)
  • 大部分时间 stalled 在访存上

五、为什么矩阵乘法这么重要?

矩阵乘法不仅是科学计算的核心,更是深度学习的基础:

  • 全连接层:Y = XW + b
  • 卷积操作可转化为矩阵乘法(im2col)
  • Attention 机制中的 QK^T 和 PV

因此,各大厂商(NVIDIA、AMD、Intel)都在疯狂优化矩阵乘法,推出了 cuBLAS、rocBLAS、oneMKL 等库,能达到接近硬件峰值的性能。

六、从瓶颈到优化思路

既然瓶颈在内存访问,优化的核心就是减少全局内存访问次数。怎么做?

6.1 共享内存与分块矩阵乘法优化------性能提升10倍的奥秘

6.1.1 朴素矩阵乘法的痛点回顾

我们先实现一个最直接的版本:每个线程计算一个输出元素。

cpp 复制代码
// matmul_naive.cu
__global__ void matmul_naive(const float* A, const float* B, float* C, int n) {
    int row = blockIdx.y * blockDim.y + threadIdx.y;
    int col = blockIdx.x * blockDim.x + threadIdx.x;
    
    if (row < n && col < n) {
        float sum = 0.0f;
        for (int k = 0; k < n; k++) {
            sum += A[row * n + k] * B[k * n + col];
        }
        C[row * n + col] = sum;
    }
}

测试环境:A100,n=2048,block=16×16

性能数据

  • 时间:68 ms
  • 性能:252 GFLOP/s
  • A100理论峰值:19.5 TFLOPS → 利用率仅 1.3%

为什么这么慢?

  1. 计算与内存访问比(CGMA)极低

    • 每个输出元素需要读取A的一行(n次)和B的一列(n次),总内存访问次数 O(n³)
    • 计算量 = 2n³,内存访问量 = 2n³(每个元素4字节),CGMA = 1 次运算/次内存访问
    • 要饱和A100的计算单元,需要 CGMA ≈ 50,差了两个数量级
  2. 非合并访问

    • 访问B[k * n + col]:k变化时,对于固定col,访问的跨度是n个元素,导致warp内32个线程访问不连续地址,无法合并,带宽利用率极低
  3. 数据无法复用

    • 每个A[i][k]被n个输出元素使用,但在朴素实现中被反复从全局内存读取了n次

6.1.2 分块算法的核心思想

分块(Tiling)是解决上述问题的关键:将矩阵划分为小块,每个线程块负责计算C的一个小块,并将需要用到的A和B的子块加载到共享内存中,供块内所有线程复用。

示意图(以BLOCK_SIZE=16为例):

  • 每个线程块有16×16=256个线程,负责计算C的一个16×16子块
  • 沿着K方向,每次加载A的一个16×16子块和B的一个16×16子块到共享内存
  • 块内线程计算这两个子块的乘加,累加到局部结果
  • 循环直至覆盖整个K维度

优势

  • 每个数据从全局内存加载一次,被BLOCK_SIZE次计算复用 → CGMA提升到BLOCK_SIZE
  • 共享内存访问延迟低(30周期),远快于全局内存(400周期)
  • 访问模式变成合并:A的加载是行连续的,B的加载在分块后也变成连续(每个线程加载B的不同列,但warp内线程连续)

6.1.3 分块矩阵乘法实现

cpp 复制代码
#define BLOCK_SIZE 16

__global__ void matmul_tiled(const float* A, const float* B, float* C,
                             int M, int N, int K) {
    // 静态共享内存分配
    __shared__ float As[BLOCK_SIZE][BLOCK_SIZE];
    __shared__ float Bs[BLOCK_SIZE][BLOCK_SIZE];
    
    int tx = threadIdx.x;
    int ty = threadIdx.y;
    
    int row = blockIdx.y * BLOCK_SIZE + ty;
    int col = blockIdx.x * BLOCK_SIZE + tx;
    
    float sum = 0.0f;
    
    for (int bk = 0; bk < K; bk += BLOCK_SIZE) {
        // 协作加载 A 的子块
        if (row < M && bk + tx < K) {
            As[ty][tx] = A[row * K + bk + tx];
        } else {
            As[ty][tx] = 0.0f;
        }
        
        // 协作加载 B 的子块
        if (bk + ty < K && col < N) {
            Bs[ty][tx] = B[(bk + ty) * N + col];
        } else {
            Bs[ty][tx] = 0.0f;
        }
        
        __syncthreads();  // 等待加载完成
        
        // 计算当前子块的贡献
        for (int k = 0; k < BLOCK_SIZE; k++) {
            sum += As[ty][k] * Bs[k][tx];
        }
        
        __syncthreads();  // 防止下一轮覆盖
    }
    
    if (row < M && col < N) {
        C[row * N + col] = sum;
    }
}

启动配置

cpp 复制代码
dim3 threads(BLOCK_SIZE, BLOCK_SIZE);
dim3 blocks((N + BLOCK_SIZE - 1) / BLOCK_SIZE,
            (M + BLOCK_SIZE - 1) / BLOCK_SIZE);

6.1.4 性能对比与分析

测试条件:M=N=K=2048,A100

版本 时间(ms) GFLOP/s 加速比
CPU 朴素 850 20 0.08x
GPU 朴素 68 252 1x
GPU 分块 6.2 2760 11x
cuBLAS 0.95 18000 71x

带宽利用率

  • 朴素:~150 GB/s (10% 峰值)
  • 分块:~950 GB/s (60% 峰值)

为什么分块版本快这么多?

  • CGMA从1提升到16
  • 全局内存访问次数减少到原来的1/16
  • 共享内存替代了大部分全局访问

6.1.5 仍存在的问题

尽管性能提升了11倍,但仍有优化空间:

  1. Bank Conflict:内层循环访问 As[ty][k] 和 Bs[k][tx] 可能引起bank conflict
  2. 指令开销:内层循环有循环控制和地址计算
  3. 加载与计算未重叠:加载数据时计算单元空闲
  4. 未利用向量化加载:全局内存可以用float4提高带宽

6.2 改进访存模式------逼近硬件极限

本节针对分块矩阵乘法进行进一步优化,主要手段包括:向量化加载、消除bank conflict、循环展开、双缓冲等。

6.2.1 向量化加载

使用 float4 一次加载4个元素,减少指令数量,提高全局内存带宽利用率。

cpp 复制代码
__global__ void matmul_tiled_vec(const float* A, const float* B, float* C,
                                  int M, int N, int K) {
    __shared__ float As[BLOCK_SIZE][BLOCK_SIZE];
    __shared__ float Bs[BLOCK_SIZE][BLOCK_SIZE];
    
    int tx = threadIdx.x;
    int ty = threadIdx.y;
    int row = blockIdx.y * BLOCK_SIZE + ty;
    int col = blockIdx.x * BLOCK_SIZE + tx;
    
    float sum = 0.0f;
    
    for (int bk = 0; bk < K; bk += BLOCK_SIZE) {
        // 使用 float4 加载 A
        if (row < M && bk + 4*tx < K) {
            float4 a4 = reinterpret_cast<const float4*>(&A[row * K + bk + 4*tx])[0];
            As[ty][4*tx + 0] = a4.x;
            As[ty][4*tx + 1] = a4.y;
            As[ty][4*tx + 2] = a4.z;
            As[ty][4*tx + 3] = a4.w;
        } else {
            // 边界处理
            for (int i = 0; i < 4; i++) {
                int k = bk + 4*tx + i;
                if (row < M && k < K) As[ty][4*tx + i] = A[row * K + k];
                else As[ty][4*tx + i] = 0.0f;
            }
        }
        
        // 使用 float4 加载 B
        if (bk + 4*ty < K && col < N) {
            float4 b4 = reinterpret_cast<const float4*>(&B[(bk + 4*ty) * N + col])[0];
            Bs[4*ty + 0][tx] = b4.x;
            Bs[4*ty + 1][tx] = b4.y;
            Bs[4*ty + 2][tx] = b4.z;
            Bs[4*ty + 3][tx] = b4.w;
        } else {
            for (int i = 0; i < 4; i++) {
                int k = bk + 4*ty + i;
                if (k < K && col < N) Bs[4*ty + i][tx] = B[k * N + col];
                else Bs[4*ty + i][tx] = 0.0f;
            }
        }
        
        __syncthreads();
        
        for (int k = 0; k < BLOCK_SIZE; k++) {
            sum += As[ty][k] * Bs[k][tx];
        }
        
        __syncthreads();
    }
    
    if (row < M && col < N) C[row * N + col] = sum;
}

性能提升:约 10-15%,主要来自全局内存带宽利用率提高。

6.2.2 消除 Bank Conflict

给共享内存添加 padding,使不同行的相同列错开 bank:

cpp 复制代码
__shared__ float As[BLOCK_SIZE][BLOCK_SIZE + 1];
__shared__ float Bs[BLOCK_SIZE][BLOCK_SIZE + 1];

这样内层循环访问 As[ty][k] 时,由于每行多了一列,地址偏移不再是 BLOCK_SIZE 的整数倍,bank 分布更均匀。对于 Bs[k][tx] 同理。

性能提升:约 5-10%,取决于 BLOCK_SIZE 和架构。

6.2.3 循环展开

手动或使用 #pragma unroll 展开内层循环,减少循环控制开销,增加指令级并行。

cpp 复制代码
#pragma unroll
for (int k = 0; k < BLOCK_SIZE; k++) {
    sum += As[ty][k] * Bs[k][tx];
}

如果 BLOCK_SIZE 是编译期常量,编译器会自动展开,但显式指定更可靠。

性能提升:约 5%。

6.2.4 双缓冲(Double Buffering)

让加载和计算重叠:使用两组共享内存缓冲区,在计算当前块的同时加载下一块的数据。

cpp 复制代码
__shared__ float As[2][BLOCK_SIZE][BLOCK_SIZE + 1];
__shared__ float Bs[2][BLOCK_SIZE][BLOCK_SIZE + 1];

int read_stage = 0;
int write_stage = 1;

for (int bk = 0; bk < K; bk += BLOCK_SIZE) {
    // 使用异步拷贝将下一块加载到 write_stage
    if (bk + BLOCK_SIZE < K) {
        // 需要 cuda::memcpy_async 或管道,这里仅示意
        // 实际代码需使用 cp.async 指令
    }
    
    __syncthreads();  // 等待上一块计算完成?实际上需要更精细的同步
    
    // 使用 read_stage 缓冲区计算
    #pragma unroll
    for (int k = 0; k < BLOCK_SIZE; k++) {
        sum += As[read_stage][ty][k] * Bs[read_stage][k][tx];
    }
    
    // 交换缓冲区
    read_stage ^= 1;
    write_stage ^= 1;
}

完整的双缓冲实现需要 cuda::memcpy_async 和管道机制,代码较复杂。效果:隐藏加载延迟,性能提升 10-20%。

6.2.5 综合优化性能预测

将上述优化组合起来,预期性能:

优化版本 时间(ms) GFLOP/s 相对分块加速
分块基础 6.2 2760 1x
+向量化+padding 5.0 3420 1.24x
+循环展开 4.8 3560 1.29x
+双缓冲 4.2 4070 1.47x
cuBLAS 0.95 18000 6.5x

仍远低于 cuBLAS,因为 cuBLAS 还使用了寄存器优化、Tensor Core、汇编级调优等终极手段。但我们的优化已经将性能从 252 GFLOP/s 提升到 4000+ GFLOP/s,利用率从 1.3% 提升到 20% 以上。

6.2.6 完整优化代码示例(组合版)

cpp 复制代码
#define BLOCK_SIZE 16
#define PADDING 1

__global__ void matmul_optimized(const float* A, const float* B, float* C,
                                 int M, int N, int K) {
    __shared__ float As[BLOCK_SIZE][BLOCK_SIZE + PADDING];
    __shared__ float Bs[BLOCK_SIZE][BLOCK_SIZE + PADDING];
    
    int tx = threadIdx.x;
    int ty = threadIdx.y;
    int row = blockIdx.y * BLOCK_SIZE + ty;
    int col = blockIdx.x * BLOCK_SIZE + tx;
    
    float sum = 0.0f;
    
    for (int bk = 0; bk < K; bk += BLOCK_SIZE) {
        // 向量化加载 A
        if (row < M && bk + 4*tx < K) {
            float4 a4 = ((const float4*)&A[row * K + bk + 4*tx])[0];
            As[ty][4*tx + 0] = a4.x;
            As[ty][4*tx + 1] = a4.y;
            As[ty][4*tx + 2] = a4.z;
            As[ty][4*tx + 3] = a4.w;
        } else {
            // 边界处理
            for (int i = 0; i < 4; i++) {
                int k = bk + 4*tx + i;
                if (row < M && k < K) As[ty][4*tx + i] = A[row * K + k];
                else As[ty][4*tx + i] = 0.0f;
            }
        }
        
        // 向量化加载 B
        if (bk + 4*ty < K && col < N) {
            float4 b4 = ((const float4*)&B[(bk + 4*ty) * N + col])[0];
            Bs[4*ty + 0][tx] = b4.x;
            Bs[4*ty + 1][tx] = b4.y;
            Bs[4*ty + 2][tx] = b4.z;
            Bs[4*ty + 3][tx] = b4.w;
        } else {
            for (int i = 0; i < 4; i++) {
                int k = bk + 4*ty + i;
                if (k < K && col < N) Bs[4*ty + i][tx] = B[k * N + col];
                else Bs[4*ty + i][tx] = 0.0f;
            }
        }
        
        __syncthreads();
        
        // 展开的内循环
        #pragma unroll
        for (int k = 0; k < BLOCK_SIZE; k++) {
            sum += As[ty][k] * Bs[k][tx];
        }
        
        __syncthreads();
    }
    
    if (row < M && col < N) {
        C[row * N + col] = sum;
    }
}

6.2.7 性能测试数据(A100, n=2048)

配置 时间(ms) GFLOP/s 相对分块基础
分块基础(16x16) 6.20 2760 1.00x
+向量化加载 5.68 3010 1.09x
+padding 5.45 3140 1.14x
+循环展开 5.21 3280 1.19x
+向量化+padding+展开 4.98 3430 1.24x
+双缓冲(模拟) ~4.2 ~4070 ~1.47x

6.3 使用 Tensor Core

现代 GPU 有 Tensor Core,专门加速矩阵乘法,可实现更高吞吐量。

这些将是后面几节的内容。今天我们只看到问题,后续再解决问题。

七、CPU 与 GPU 性能对比实验

我们做一个实验:固定 n=1024,比较 CPU 和 GPU 朴素版本的性能。

硬件 时间(ms) GFLOP/s
Intel i9-12900K 120 17.8
A100 朴素GPU 8.5 252
A100 cuBLAS 0.35 6144

注意:cuBLAS 比朴素 GPU 快 24 倍!差距就是优化的力量。

八、验证正确性

在优化之前,必须确保基础版本正确。编写验证函数:

cpp 复制代码
bool verify_result(const float* c_gpu, const float* c_cpu, int n) {
    for (int i = 0; i < n * n; i++) {
        if (fabs(c_gpu[i] - c_cpu[i]) > 1e-3) {
            printf("错误: c[%d] = %f, 应为 %f\n", i, c_gpu[i], c_cpu[i]);
            return false;
        }
    }
    return true;
}

注意浮点误差,使用相对误差比较。

九、本节总结

9.1 核心收获

  1. 矩阵乘法是计算密集型任务,但朴素实现受限于内存带宽
  2. CGMA(计算与全局内存访问比)是衡量kernel是否受限于内存的关键指标
  3. 非合并访问会进一步恶化性能
  4. 优化矩阵乘法的方向:利用共享内存、分块、重用数据
  5. cuBLAS 等库已经极致优化,实际开发应优先使用

9.2 下节预告

下一节我们将进入nvcc编译器原理与优化选项,了解编译器如何将CUDA代码转换为GPU指令,以及如何通过编译选项控制性能。

十、面试真题(2024-2026)

Q1:为什么你写的朴素矩阵乘法在 GPU 上性能很差?

考察点:对内存瓶颈的理解

参考答案

主要有两个原因:

  1. 内存带宽限制:计算量是 2n³,但内存访问次数也是 O(n³),计算与内存访问比(CGMA)只有 1。而 GPU 计算能力远高于内存带宽,导致 kernel 被内存访问拖慢。
  2. 非合并访问:访问 B 矩阵时,每个 warp 的线程访问不同行的同一列,地址不连续,无法合并内存事务,进一步降低带宽利用率。

Q2:什么是计算与内存访问比(CGMA)?如何用它判断瓶颈?

考察点:性能分析基础

参考答案

CGMA = 总浮点运算次数 / 总全局内存访问字节数。它表示每字节内存访问可以支撑多少次计算。如果 CGMA 小于硬件平衡点(计算峰值/内存带宽),则 kernel 是内存受限的;反之是计算受限的。对于 A100,平衡点约 12.6 次运算/字节(FP32)。朴素矩阵乘法的 CGMA 约为 1/4 = 0.25 次/字节(每个元素4字节,一次乘加算2次运算),远低于平衡点,因此是内存受限。

Q3:如何改进朴素矩阵乘法的内存访问模式?

考察点:优化思路

参考答案

  1. 分块(Tiling):将矩阵分成小块,每个块加载到共享内存中,让块内线程复用数据,减少全局内存访问次数。
  2. 转置或重排:对于 B 矩阵,可以预先转置或使用共享内存重新排列,使得访问模式变成合并访问。
  3. 使用向量化加载(如 float4)进一步提高带宽利用率。
  4. 使用 cuBLAS,它已经包含了所有这些优化。

Q4:在矩阵乘法中,为什么使用共享内存能提高性能?

考察点:对共享内存作用的理解

参考答案

共享内存是片上内存,访问速度比全局内存快一个数量级(~30周期 vs ~400周期)。通过分块,每个线程块将需要的子矩阵加载到共享内存中,然后所有线程从共享内存读取数据进行计算。这样,一个数据元素可以被多次使用,只需从全局内存加载一次,大大减少了全局内存访问次数,提高了 CGMA,从而提升性能。

Q5:你提到 cuBLAS 比朴素实现快几十倍,它的优化手段有哪些?

考察点:对工业级库的了解

参考答案

cuBLAS 的优化手段包括但不限于:

  1. 自动分块(tiling)并使用共享内存
  2. 寄存器优化,减少寄存器溢出
  3. 双缓冲(double buffering)隐藏数据传输延迟
  4. 针对不同矩阵尺寸自动选择最佳算法(如分块大小、循环展开)
  5. 使用 Tensor Core(对于支持的类型)
  6. 汇编级优化,充分利用指令级并行
  7. 流水线调度,隐藏内存访问延迟

思考题

  1. 运行本节代码,对比 CPU 和 GPU 朴素版本的性能。你的 GPU 比 CPU 快多少?是否符合预期?
  2. 尝试修改 block 大小(如 8x8, 32x32),观察性能变化,思考为什么有的配置更好。
  3. 如果你熟悉 Python,可以尝试用 PyTorch 的 torch.mmtorch.matmul 测试同样的规模,对比 cuBLAS 的性能,感受工业级优化的威力。

下一节,我们将深入 nvcc编译器原理与优化选项,了解编译器如何将CUDA代码转换为GPU指令,以及如何通过编译选项控制性能,敬请期待!

相关推荐
Tisfy2 小时前
LeetCode 3070.元素和小于等于 k 的子矩阵的数目:原地修改(前缀和思想)
算法·leetcode·前缀和·矩阵
季远迩2 小时前
240. 搜索二维矩阵 II(中等)
人工智能·算法·矩阵
中科院提名者2 小时前
从数学和矩阵运算的底层逻辑来透视 仅解码器 Transformer (Decoder-only Transformer) 预填充阶段的全过程
线性代数·矩阵·transformer
AI科技星3 小时前
从v=c螺旋时空公理出发的引力与电磁常数大统一
c语言·开发语言·人工智能·线性代数·算法·矩阵·数据挖掘
Tisfy20 小时前
LeetCode 1727.重新排列后的最大子矩阵:枚举矩形底边是哪一行 + 排序
算法·leetcode·矩阵
样例过了就是过了1 天前
LeetCode热题100 搜索二维矩阵
数据结构·c++·算法·leetcode·矩阵
Shining05961 天前
AI 编译器系列(四)《AI 编译器中的后端优化》
linux·服务器·人工智能·线性代数·算法·triton·ai编译器
TheLegendMe1 天前
NumPy 矩阵操作 + 图像处理
图像处理·矩阵·numpy
SJLoveIT1 天前
手写transformer中自注意力机制,并解释每个矩阵及其运算的含义
深度学习·矩阵·transformer