3、GPU计算基础:CUDA架构与编程

好,咱们正式开始碰GPU计算的核心——CUDA。很多做生信的朋友,第一次接触GPU时都有个错觉:把这玩意儿当CPU用,塞进去一堆数据等着跑快就完事了。结果呢?要么跑不起来,要么比CPU还慢。我当年也踩过这个坑,折腾了整整一周才发现问题出在哪儿。

CUDA,说白了就是NVIDIA给自家GPU开的“后门”。它允许你用C/C++直接操作GPU的硬件资源,而不是只把GPU当成一个黑盒显示器。对于基因组注释这种计算密集型任务,搞懂CUDA,就等于拿到了性能调优的钥匙。

3.1 一张图看懂CUDA全景

在讲具体细节之前,我习惯先给全局。下面的图展示了CUDA编程的核心脉络,我建议你盯着它看30秒,再往下读。

CUDA编程全景结构 硬件架构 GPU 设备 流处理器簇 (SM) CUDA核心 显存 (Global Memory) 线程模型 Grid (网格) 逻辑层面最大单位 Block (块) → SM 一个SM可以承载多个Block Warp (线程束) = 32线程 调度的基本单位 Thread (线程) 最小执行单元 内存层次 Global Memory 大、慢、主机可见 Shared Memory 快、Block共享、需手动 Registers (寄存器) 最快、线程私有、数量有限 Constant/Texture 只读缓存加速

这张图分了三个层次:硬件架构、线程模型、内存层次。你看,从GPU设备一路往下,到SM(流处理器簇),再到CUDA核心,硬件是物理基础。中间那层是咱们编程最常打交道的部分——网格、块、线程束、线程。最底下是内存,不同的存储区域,速度和容量天差地别。我每次调优新模型,都会对着这个图来回确认问题出在哪一层。

3.2 CUDA线程模型:从Grid到Thread

聊CUDA编程,首先得搞明白它的线程组织方式。CPU上你开几个线程,系统就给你分几个核。GPU不一样,它会生成成千上万个轻量级线程,然后靠硬件调度器批量喂给计算单元。

为什么能这么做?因为GPU的线程切换代价极低,本质就是寄存器级别的上下文切换。CPU切一次线程可能要几微秒,GPU呢?你想想看,单周期就能切走。

层级结构是这样的:

  • Grid:整个任务,对应一个kernel函数的一次调用。
  • Block:Grid里的一个工作组,会被调度到某一个SM上执行。
  • Warp:32个线程绑在一起,是硬件调度和指令执行的最小单位。
  • Thread:单个执行流,拥有自己的寄存器和PC指针。

关键思路:编程时你只管声明“我要启动多少个线程”,硬件调度器会自动把它们分组喂给SM。你不需要关心哪个物理核执行哪个线程,这层抽象正是CUDA的核心理念——透明的大规模并行。

3.3 内存层次:你花了很多时间等数据

我在做基因组比对加速时遇到过一个大坑:算法在理论上是完美的O(n)复杂度,但跑在GPU上就是快不起来。后来我查了性能分析报告,发现有一半的周期花在了Global Memory访问上。说白了,计算不是瓶颈,搬数据才是。

CUDA内存的类型和差异

内存类型 位置 访问速度 容量 作用域
Global Memory 显存 (VRAM) ~200-800 GB/s (高带宽) 大 (4~80 GB) 所有线程 + 主机可访问
Shared Memory SM内 (片上) ~TB/s级别 (极快) 极小 (48~164 KB/Block) 同Block内所有线程共享
Registers SM内 (片上) 最快 (单周期) 有限 (一般几千/线程) 单个线程私有
Constant Memory 显存 (有L1缓存) 快 (只读缓存命中时) 64 KB 所有线程只读
Texture Memory 显存 (有专用缓存) 快 (对空间局部性好) 有限 所有线程只读

我的个人经验:做序列比对或神经网络推理时,尽量把频繁访问的参考数据塞进Constant Memory或Texture Memory。如果你发现Global Memory读占用了80%以上的时间,那就说明共享内存或常量缓存这块你还没用好。别急着优化计算逻辑,先优化数据搬运的路径。

3.4 第一行CUDA代码:向量加法

咱们不用那些花里胡哨的例子,就从最朴素的向量加法开始。这玩意儿在基因组注释里很常见——比如你要对成千上万个碱基的得分做批量加法。

先看内核函数(kernel):

// 向量加法 kernel
__global__ void vec_add_kernel(float *d_a, float *d_b, float *d_c, int n)
{
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx < n) {
        d_c[idx] = d_a[idx] + d_b[idx];
    }
}

几个关键点解释一下:

  • __global__:告诉编译器和运行时,这个函数跑在GPU上,从CPU端调用。
  • blockIdx.x:当前Block在Grid中的索引。
  • blockDim.x:每个Block有多少线程。
  • threadIdx.x:当前线程在Block内的索引。
  • 那个if (idx < n)是边界检查,防止多余线程踩到非法内存。

然后是在CPU端如何调用和传参:

#include <iostream>
#include <cuda_runtime.h>

int main() {
    int n = 1024 * 1024; // 100万个元素
    size_t bytes = n * sizeof(float);

    // 分配主机内存
    float *h_a = new float[n];
    float *h_b = new float[n];
    float *h_c = new float[n];

    // 初始化数据
    for (int i = 0; i < n; i++) {
        h_a[i] = 1.0f * i;
        h_b[i] = 2.0f * i;
    }

    // 分配设备内存
    float *d_a, *d_b, *d_c;
    cudaMalloc(&d_a, bytes);
    cudaMalloc(&d_b, bytes);
    cudaMalloc(&d_c, bytes);

    // 拷贝数据从主机到设备
    cudaMemcpy(d_a, h_a, bytes, cudaMemcpyHostToDevice);
    cudaMemcpy(d_b, h_b, bytes, cudaMemcpyHostToDevice);

    // 设置网格和块大小
    int threads_per_block = 256;
    int blocks_per_grid = (n + threads_per_block - 1) / threads_per_block;

    // 启动 kernel
    vec_add_kernel<<<blocks_per_grid, threads_per_block>>>(d_a, d_b, d_c, n);

    // 等待 GPU 完成
    cudaDeviceSynchronize();

    // 拷贝结果回主机
    cudaMemcpy(h_c, d_c, bytes, cudaMemcpyDeviceToHost);

    // 检查
    for (int i = 0; i < 10; i++) {
        std::cout << "c[" << i << "] = " << h_c[i] << std::endl;
    }

    // 清理
    cudaFree(d_a); cudaFree(d_b); cudaFree(d_c);
    delete[] h_a; delete[] h_b; delete[] h_c;

    return 0;
}

这段代码你直接复制到支持CUDA的开发环境里就能跑。注意那个cudaDeviceSynchronize()——我曾经有一次忘了加,结果在A100上调试了三个小时才发现CPU端的cudaMemcpy还在排队,而GPU已经算完了。加上它,保证主机和设备是同步的。

3.5 避坑指南:我踩过的三个雷

讲到这儿,我想分享几个实战中的教训。你想想看,这些坑如果没人告诉你,自己踩一遍可能要浪费好几天。

雷区一:线程块尺寸选择不当

我曾经为了图省事,把Block大小设成1024,结果在老的Tesla卡上执行效率极低。原因是每个SM能同时容纳的Warp数量是有限的。Block太大,资源竞争反而厉害。后来我形成了习惯:通常128-256线程/Block比较稳妥,然后通过Occupancy API再细调。

雷区二:Shared Memory踩脏

做完共线性打分时,我让同一个Block的不同线程往Shared Memory里写数据,没加__syncthreads()。结果每次跑出来的结果都不一样。这问题特别隐蔽——你以为代码没问题,但数据老是漂。记住:同一个Block内如果有线程写Shared Memory后,其他线程要读,必须加__syncthreads()障眼。

雷区三:if分支过度造成Warp串行化

CUDA以32个线程为一股执行指令。如果这32个线程走上了不同的if分支,所有分支会串行执行,性能瞬间暴跌。最惨的一次,我写了个复杂的序列比对打分函数,分支多了之后,性能跟单线程差不离。解决方案:尽量让数据对齐到Warp边界,使用三元运算符替换复杂if-else。

3.6 编译环境搭建

最后提一下怎么把代码变成可执行程序。如果你是新手,我建议直接用CMake管理。

一个最简的CUDA工程CMakeLists.txt:

cmake_minimum_required(VERSION 3.18)
project(gpu_genome_annot LANGUAGES CXX CUDA)

set(CMAKE_CUDA_STANDARD 17)
set(CMAKE_CUDA_ARCHITECTURES "80;89") # A100 和 H100

add_executable(vec_add vec_add.cu)

编译命令很简单:

mkdir build && cd build
cmake ..
make -j$(nproc)

几点说明:

  • CMAKE_CUDA_ARCHITECTURES写的是80和89,对应安培架构和Hopper架构。如果你用RTX 3090,改成86;T4是75。搞错了,程序可能编译通过但运行时崩溃。
  • 如果你用命令行直接编译,用nvcc vec_add.cu -o vec_add就行,但复杂项目还是交给CMake省心。

嗯,这一节的内容就先到这儿。CUDA的基础概念、线程模型、内存层次、你会的第一段代码、还有几个我踩过的坑……这些框架一旦搭好,后面我们讲如何用GPU加速基因组注释模型的训练时,就能直接往这套体系里填东西了。