第4章:因子数据并行化

好,咱们进入正题。因子挖掘上了GPU,第一件事就是——数据怎么摆?

很多人觉得,把数据从CPU搬到GPU不就完事了吗?嗯,没那么简单。数据在GPU上的组织方式,直接决定了你的计算效率。我见过不少项目,代码写得挺漂亮,一跑起来慢得像蜗牛,问题就出在数据布局上。

4.1 因子数据在GPU上的表示

先问个问题:你有1000只股票,每只股票有500个时间点的因子数据,你怎么存?

两种主流方式:结构体数组(AoS)数组结构体(SoA)。名字听着绕,其实说白了就是——

  • AoS:先存第一只股票的所有因子,再存第二只的。像一本本独立的档案。
  • SoA:把所有股票的因子1放一起,因子2放一起。像按科目分类的成绩单。

我个人习惯在因子挖掘中用SoA。为什么?因为GPU的并行本质是SIMT(单指令多线程)。你想想看,如果我要计算所有股票的某个因子,用SoA的话,相邻线程访问的是连续内存地址——这叫合并访问,带宽利用率直接拉满。

核心原则:GPU喜欢连续访问,讨厌随机跳转。SoA天然适配这个特性。

举个例子。假设你有N只股票,每只股票有M个因子值:

// AoS方式(不推荐)
struct StockData {
    float factor1;
    float factor2;
    float factor3;
};
StockData stocks[N];

// SoA方式(推荐)
struct StockDataSoA {
    float* factor1;  // 长度为N的数组
    float* factor2;
    float* factor3;
};

我在项目中遇到过一件事。有个同事用AoS存了5000只股票的日频因子,跑一个简单的均值计算,结果比CPU还慢。一分析,发现全局内存访问效率只有12%。改成SoA后,直接飙到85%以上。差距就这么大。

小技巧:如果你实在要用AoS(比如从某个库继承的数据结构),可以用cudaMemcpy2D做转置,或者用共享内存做重排。但说实话,不如一开始就设计成SoA。

4.2 数据从CPU到GPU的传输

数据布局搞定了,接下来就是搬数据。最基础的API就是cudaMemcpy

cudaError_t cudaMemcpy(void* dst, const void* src, size_t count, 
                       enum cudaMemcpyKind kind);

四个参数,简单粗暴。但有几个坑,我踩过,你们别踩。

坑一:传输方向别搞反。kind参数有四种:

枚举值 含义
cudaMemcpyHostToDevice CPU → GPU
cudaMemcpyDeviceToHost GPU → CPU
cudaMemcpyDeviceToDevice GPU内部拷贝
cudaMemcpyHostToHost CPU内部拷贝(基本不用)

坑二:别用默认流做大量传输。默认流是同步的,也就是说,cudaMemcpy没完成,CPU就卡在那等着。你想想看,几百兆的数据传输,CPU干等着,多浪费。

警告:我曾经在一个回测系统里,用默认流传输了2GB的因子数据,结果每次启动要等3秒。后来改成异步流,时间降到0.4秒。记住——传输和计算要重叠

4.3 异步传输与流(Stream)

这才是今天的重头戏。流(Stream)是CUDA里实现并行的核心机制。你可以把流想象成一条流水线——

  • 一个流里,操作是顺序执行的
  • 不同流之间,操作可以并行

异步传输的套路是这样的:

cudaStream_t stream1, stream2;
cudaStreamCreate(&stream1);
cudaStreamCreate(&stream2);

// 异步传输,不阻塞CPU
cudaMemcpyAsync(d_data1, h_data1, size, cudaMemcpyHostToDevice, stream1);
cudaMemcpyAsync(d_data2, h_data2, size, cudaMemcpyHostToDevice, stream2);

// 在stream1上启动kernel
kernel1<<<grid, block, 0, stream1>>>(d_data1);
// 在stream2上启动kernel
kernel2<<<grid, block, 0, stream2>>>(d_data2);

cudaStreamSynchronize(stream1);
cudaStreamSynchronize(stream2);

cudaStreamDestroy(stream1);
cudaStreamDestroy(stream2);

这里的关键是cudaMemcpyAsync。它不会等传输完成就返回,CPU可以继续干别的事。比如准备下一批数据,或者启动其他计算。

为什么会这样?因为GPU有专门的DMA引擎,传输数据不需要占用计算单元。你想想看,传输和计算可以同时进行,这不就是白嫖性能吗?

性能调优黄金法则:用多个流把数据传输和kernel计算重叠起来。理想情况下,传输时间被完全隐藏,你只看到计算时间。

我建议的做法是——把因子数据分成多个块。比如1000只股票,分成4组,每组250只。用4个流分别处理:

  1. 流1传输第1组,同时CPU准备第2组
  2. 流1传输完,立即启动kernel计算第1组
  3. 流2传输第2组,与流1的kernel计算并行
  4. 以此类推,形成流水线

这种模式叫流水线并行。我在做高频因子计算时,用4个流把吞吐量提升了2.3倍。注意,不是所有场景都能线性提升,因为PCIe带宽是共享的。一般来说,2-4个流效果最好,再多了反而会因为竞争带宽而收益递减。

避坑指南:我曾经用16个流想榨干性能,结果发现传输时间反而变长了。原因是太多流争抢PCIe带宽,每个流都在等。后来改成4个流,配合cudaStreamCreateWithPriority设置优先级,效果最好。

最后说一个容易被忽略的点——固定内存(Pinned Memory)。用cudaHostAlloc分配的内存,传输速度比普通malloc快很多。因为GPU可以直接通过DMA访问固定内存,不需要CPU介入。

float* h_data;
cudaHostAlloc(&h_data, size, cudaHostAllocDefault);
// 用这个指针做cudaMemcpyAsync,速度能快30%-50%

但注意,固定内存是稀缺资源,别滥用。我一般只给传输缓冲区用固定内存,其他数据还是用普通malloc。

嗯,这一章的内容就这些。数据布局选SoA,传输用异步,流要适量,内存用固定。把这几点做到位,你的因子挖掘GPU化就成功了一半。

因子数据并行化核心流程 CPU 主机端 原始因子数据 固定内存 (Pinned) 数据分块 (Chunking) cudaMemcpyAsync 异步传输 Stream 1 Stream 2 Stream 3 GPU 全局内存 SoA 数据布局 因子1数组 | 因子2数组 因子3数组 | ... GPU 并行计算单元 (Stream并行) Stream 1: Kernel(Chunk1) 计算 + 传输重叠 Stream 2: Kernel(Chunk2) 计算 + 传输重叠 Stream 3: Kernel(Chunk3) 计算 + 传输重叠 结果回传 CPU (cudaMemcpyAsync) 异步回传,不阻塞下一轮计算 核心思想:SoA布局 + 异步传输 + 多流流水线 = 最大化吞吐量