GPU计算基础:CUDA架构概览

做量化因子挖掘,说白了就是跟海量数据死磕。我刚开始用CPU跑多因子回测时,一个中证500的全市场计算就要等半小时。后来接触了CUDA,才真正体会到什么叫「降维打击」。这一章,咱们就聊聊CUDA到底是个什么玩意儿。

CUDA架构:不只是显卡那么简单

很多人以为GPU就是用来打游戏的。其实不然。CUDA是NVIDIA推出的并行计算平台,它把GPU变成了一个「超级计算器」。我习惯把CUDA架构理解成三个层次:

  • 流多处理器(SM):GPU的核心计算单元,每个SM包含多个CUDA核心
  • CUDA核心:最基础的执行单元,负责算术运算
  • 线程束(Warp):32个线程为一组,这是调度和执行的基本单位

你想想看,一个高端GPU可能有几十个SM,每个SM又有上百个CUDA核心。这意味着什么?意味着你可以同时跑几千甚至上万个线程。做因子挖掘时,我经常把每只股票的计算任务分配给一个线程,几秒钟就能算完整个A股市场。

核心要点:CUDA架构的精髓在于「大规模并行」。它不是让单个线程跑得更快,而是让成千上万个线程同时干活。

线程层次结构:Grid, Block, Thread

这是CUDA编程里最基础也最容易搞混的概念。我刚开始学的时候,也花了点时间才理清楚。说白了,就是三层嵌套:

  • Thread(线程):最基础的执行单元,每个线程执行相同的核函数
  • Block(线程块):一组线程的集合,共享Shared Memory,可以同步
  • Grid(网格):一组Block的集合,构成整个计算任务

举个例子你就明白了。假设你要计算10000只股票的日收益率:

// 定义核函数
__global__ void calcReturn(float* price, float* ret, int n) {
    int idx = threadIdx.x + blockIdx.x * blockDim.x;
    if (idx < n) {
        ret[idx] = (price[idx] - price[idx-1]) / price[idx-1];
    }
}

// 调用核函数
int n = 10000;
int threadsPerBlock = 256;
int blocksPerGrid = (n + threadsPerBlock - 1) / threadsPerBlock;
calcReturn<<<blocksPerGrid, threadsPerBlock>>>(d_price, d_ret, n);

这里每个线程处理一只股票的计算。256个线程组成一个Block,Block不够了就多开几个Grid。嗯,这里要注意:Block的数量不是随便定的,它受GPU硬件限制。

我的经验:Block大小选128或256通常性能最好。太小了SM利用率低,太大了寄存器压力大。我曾经试过512,结果性能反而下降了。

内存层次结构:Global, Shared, Local, Register

GPU的内存体系比CPU复杂得多。我画了一张图,帮你理清这四层的关系:

GPU内存层次结构 Global Memory(全局内存) 容量大(GB级),延迟高(400-800周期),所有线程可访问 Shared Memory(共享内存) 容量小(几十KB),延迟低(~30周期),同一Block内线程共享 Local Memory(局部内存) 寄存器溢出时使用,延迟高 Register(寄存器) 速度:Register > Shared > Global ≈ Local 容量:Global > Local > Shared > Register

这四层内存各有各的脾气。我挑重点说:

寄存器(Register)

最快,但数量有限。每个线程最多能用255个寄存器。做因子计算时,我习惯把频繁使用的中间变量放在寄存器里。比如计算移动平均时,把累加器声明为局部变量,编译器会自动分配到寄存器。

避坑指南:我曾经写过一个核函数,用了太多局部变量,导致寄存器溢出。结果性能从200μs掉到了800μs。后来用--maxrregcount=32限制寄存器数量,反而性能提升了。因为编译器被迫使用Shared Memory,减少了寄存器压力。

共享内存(Shared Memory)

这是CUDA编程的「杀手锏」。同一Block内的线程可以共享数据,延迟只有全局内存的十分之一。做因子挖掘时,我经常用它来缓存股票的历史数据。

__global__ void calcMA(float* price, float* ma, int n, int window) {
    __shared__ float s_price[256];  // 共享内存缓存
    
    int idx = threadIdx.x + blockIdx.x * blockDim.x;
    if (idx < n) {
        s_price[threadIdx.x] = price[idx];
        __syncthreads();  // 同步,确保所有线程都加载完毕
        
        // 计算移动平均
        float sum = 0;
        for (int i = 0; i < window; i++) {
            sum += s_price[threadIdx.x + i];
        }
        ma[idx] = sum / window;
    }
}

这里用__syncthreads()同步很重要。不然后面线程读到的数据可能还没写进去。我刚开始做时吃过这个亏,算出来的移动平均全是错的。

全局内存(Global Memory)

容量最大,但速度最慢。所有线程都能访问。做因子挖掘时,原始数据、计算结果都放在这里。优化全局内存访问的关键是「合并访问」——让相邻线程访问相邻地址。

优化技巧:我习惯把数据按「列优先」方式存储。比如10000只股票×100天的价格数据,按天存储(每列是一只股票),这样相邻线程访问相邻地址,带宽利用率能到80%以上。

局部内存(Local Memory)

这个其实是个「伪概念」。它物理上在全局内存里,只是逻辑上每个线程私有。当寄存器不够用时,编译器会自动把变量溢出到局部内存。性能很差,尽量避免。

实战:因子计算中的内存选择

说了这么多理论,咱们来点实际的。假设你要计算一个简单的动量因子:过去20天收益率的标准差。你会怎么分配内存?

数据 内存类型 原因
原始价格数据 Global Memory 数据量大,所有线程都需要
每只股票的20天价格 Shared Memory 同一Block内线程共享,减少全局访问
累加器、计数器 Register 频繁使用,需要最快速度
临时中间结果 Local Memory(尽量避免) 寄存器不够时才会用

你看,选对内存类型,性能差距能到10倍以上。我刚开始做因子挖掘时,一股脑全用全局内存,结果一个简单的因子要算5分钟。后来优化了内存使用,同样的计算30秒搞定。

总结一下:CUDA编程的核心就是「数据在哪里,计算就在哪里」。尽量把数据往寄存器或共享内存里放,减少全局内存访问。这个原则,贯穿整个因子挖掘GPU并行化的始终。

好了,这一章的内容就到这里。记住我说的:寄存器最快但最少,共享内存是宝,全局内存要省着用。下一章咱们聊聊具体的并行化策略,到时候我会分享更多实战中的坑和技巧。


公众号:蓝海资料掘金营,微信deep3321