2. GPU计算基础:GPU架构简介、CUDA编程模型与线程层次

好,让我们开始真正接触GPU的世界。我见过不少搞生信的朋友,一开始觉得GPU就是个“更快的显卡”,装上就能让BLAST飞起来。结果呢?要么跑不起来,要么效率还不如CPU。为什么?

说白了,GPU和CPU是两种完全不同的思维方式。你想想看,CPU是个“全能选手”,什么活都能干,但一次只能干几件。GPU呢,是个“搬砖大队”,每个工人能力一般,但胜在人多,能同时干成千上万件简单的活。BLAST这种序列比对任务,本质上就是海量的独立计算——太适合GPU了。

2.1 GPU架构:它到底长什么样?

我们先看看GPU的核心部件。下面这张图是我个人习惯用来跟团队新人讲架构的简化模型,你一看就明白。

GPU架构与CUDA编程模型核心概念 GPU 物理硬件架构 SM(流多处理器) SM 每个SM包含多个CUDA核心 共享内存 / 寄存器文件 warp调度器 全局内存 · 纹理内存 · 常量内存 CUDA 编程模型(逻辑抽象) Grid(网格) 对应整个kernel启动 Block(线程块) 对应一个SM执行 Thread(线程) 对应一个CUDA核心执行 每个线程执行相同kernel代码 映射关系 💡 关键映射:Grid → GPU整体,Block → SM,Thread → CUDA核心 Warp是SM调度基本单位(32线程),这是性能调优的重点 内存层次 全局内存 大容量,高延迟 共享内存 块内共享,低延迟 寄存器 线程私有,最快 常量/纹理内存 只读,缓存友好

这张图我每次培训都会用。左边是物理硬件,右边是编程模型,中间是映射关系。很多人搞不清楚这两者的区别,总觉得“线程”就是“核心”。不是的,线程是逻辑概念,核心是物理资源。一个核心可以执行成千上万个线程,靠的是极速的上下文切换——这点和CPU很不一样。

2.2 CUDA编程模型:我该怎么写代码?

CUDA编程模型,说白了就是NVIDIA给你的一套“指挥GPU干活”的工具。你写一个函数,叫kernel(内核函数),然后告诉GPU:“嘿,用10000个线程去跑这个函数!”

一个最简单的CUDA程序长啥样?我直接上代码:

// 一个简单的向量加法kernel
__global__ void vecAdd(float *A, float *B, float *C, int N) {
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i < N) {
        C[i] = A[i] + B[i];
    }
}

int main() {
    // ... 分配内存,拷贝数据到GPU ...
    int N = 1024;
    int threadsPerBlock = 256;
    int blocksPerGrid = (N + threadsPerBlock - 1) / threadsPerBlock;

    // 启动kernel
    vecAdd<<<blocksPerGrid, threadsPerBlock>>>(d_A, d_B, d_C, N);
    // ... 拷贝结果回CPU ...
}

你看,关键是那行vecAdd<<<blocksPerGrid, threadsPerBlock>>>>(...)。尖括号里的两个参数,就是告诉GPU:我要多少个线程块(Block),每个块里有多少个线程(Thread)。这就是接下来要细聊的线程层次。

我个人习惯:刚开始调优时,先固定 threadsPerBlock = 256。这个值在很多GPU上都是“安全区”。之后再根据SM占用率去微调。别一开始就想着最优配置,先跑通,再优化。

2.3 线程层次:理解Grid、Block、Thread

线程层次是CUDA的核心。它分三级,从大到小:

  • Grid(网格):一次kernel启动对应的所有线程。可以是一维、二维或三维。
  • Block(线程块):一组可以被SM独立调度的线程。同一个Block内的线程可以共享数据(通过共享内存)。
  • Thread(线程):最小的执行单元。每个线程都有自己独立的寄存器、程序计数器、局部内存。

为什么分这么多级?你想想看,如果只有“线程”这级,你怎么调度1万个线程?硬件并不知道哪些线程可以一起干活。有了Block这个中间层,硬件就能以Block为单位,把任务分配给不同的SM去并行执行。

还有一个概念特别重要——Warp。这是GPU调度的基本单位,一个Warp固定包含32个线程。SM每次发出一条指令,这个Warp里的32个线程要么一起执行,要么有些因为条件分支被“屏蔽”了。我在项目中遇到过因为线程分支导致性能断崖下跌的情况,后来查出来是warp divergence(线程束分化)在作祟。

2.4 内存层次:数据放哪儿,是个学问

GPU的内存层次很丰富,用错了位置,性能就全毁了。

内存类型 位置 访问范围 速度 容量 典型用途
全局内存 板载显存 所有线程 慢(~400 周期) 大(数GB~数十GB) 存储序列数据、数据库、结果
共享内存 芯片上(SM内) 同一Block内 快(~5 周期) 小(通常48KB~164KB) 块内数据复用、规约操作
寄存器 芯片上 单个线程 极快(0~1 周期) 极小(每个线程最多255个) 局部变量、中间结果
常量内存 板载显存(有缓存) 所有线程(只读) 缓存命中时快 小(64KB) 固定参数、查找表
纹理内存 板载显存(有缓存) 所有线程(只读) 缓存命中时快 与全局内存共用 图像、空间局部性访问

嗯,这里要注意。全局内存是最常用的,但也是最慢的。BLAST这种应用,如果频繁访问全局内存,性能肯定上不去。我以前优化过一个数据库加速项目,一开始就是无脑用全局内存,结果GPU利用率连20%都不到。后来把频繁访问的热数据搬到共享内存里,利用率直接飙到70%+。

我曾经踩过的坑:共享内存是有限的!别想着把整个数据库都塞进去。一旦超过共享内存容量,kernel直接启动失败。启动参数里--shared-memory-size可以调整,但最好在设计时就计算好:每Block需要多少共享内存,SM能同时驻留多少个Block。算清楚再动手。

2.5 CUDA的hello world:从代码到启动

来,我们看一个更完整的例子,把刚才的线程层次和内存层次串起来:

#include <stdio.h>

// 使用共享内存的向量求和
__global__ void sumWithShared(const float *A, const float *B, float *C, int N) {
    __shared__ float cache[256];

    int tid = blockIdx.x * blockDim.x + threadIdx.x;
    if (tid < N) {
        cache[threadIdx.x] = A[tid] + B[tid];
    }
    __syncthreads();  // 同步,确保所有线程写完共享内存

    if (threadIdx.x == 0) {
        float sum = 0.0f;
        for (int i = 0; i < blockDim.x; i++) {
            sum += cache[i];
        }
        C[blockIdx.x] = sum;
    }
}

int main() {
    int N = 1024 * 1024;
    int threadsPerBlock = 256;
    int blocksPerGrid = (N + threadsPerBlock - 1) / threadsPerBlock;

    // 分配、初始化、拷贝(略)
    float *d_A, *d_B, *d_C;
    cudaMalloc(&d_A, N * sizeof(float));
    // ... cudaMemcpy ...

    sumWithShared<<<blocksPerGrid, threadsPerBlock>>>>(d_A, d_B, d_C, N);

    cudaDeviceSynchronize();  // 等GPU干完
    // ... cudaMemcpy回CPU ...
}

__syncthreads()这行很关键。它保证一个Block内的所有线程都执行到这一行了,才能继续。不然,线程A刚写完共享内存,线程B就开始读,数据就乱套了。

性能要点回顾:
• Block大小最好是32的整数倍(因为一个Warp是32线程)。
• 尽量避免Warp内的分支分歧。
• 优先使用共享内存缓存频繁访问的数据。
• 别忘记同步!__syncthreads()是你的好朋友。