一、风控特征工程GPU化:从单机瓶颈到并行爆发

大家好,我是老张。今天咱们聊聊风控特征工程怎么上GPU。

说实话,我刚接触风控系统那会儿,特征计算是最头疼的环节。几百个原始字段,要衍生出上千个特征,跑一次全量计算要半小时。业务方催着要实时决策,这谁顶得住?

后来我琢磨着,特征计算本质上就是「数据+规则」的批量处理。每个样本的特征计算是独立的,天然适合并行。这不就是GPU的菜吗?

1.1 特征计算的并行化本质

先想一个问题:为什么特征计算慢?

传统做法是for循环,一条一条算。比如计算「近30天交易金额均值」,你得先取出30天的数据,求和,再除以30。100万条数据,就得循环100万次。

但仔细想想,每条数据的计算逻辑完全一样,只是输入数据不同。这就是典型的「数据并行」场景。

核心思路:把特征计算拆解成「核函数」,让GPU上千个线程同时算。每个线程处理一条或一批数据。

我在项目中遇到过最典型的场景:实时计算用户「近1小时、近6小时、近24小时」的登录次数、交易金额、设备变化数。这些特征的计算逻辑一模一样,只是时间窗口不同。

用GPU的话,我可以把三个窗口的计算打包成一个核函数,一次启动,全部算完。CPU上要跑3遍的活,GPU上1遍搞定。

1.2 批量特征处理:从逐条到批量

很多人觉得GPU就是快,其实不然。GPU的强项是「批量吞吐」,不是「单条延迟」。

你想想看,如果只算一条数据的特征,CPU可能比GPU还快。因为GPU有启动开销,数据要从内存搬到显存,核函数要加载,这些都需要时间。

所以,批量处理是GPU加速的前提

处理方式 CPU耗时(100万条) GPU耗时(100万条) 加速比
逐条计算 32秒 28秒(含传输) 1.1x
批量计算(1万条/批) 30秒 2.1秒 14x
批量计算(10万条/批) 29秒 0.8秒 36x

看到了吧?批量越大,加速效果越明显。我建议在实际项目中,每批至少处理1万条以上,否则传输开销会吃掉大部分收益。

我的经验:在风控场景中,通常把「一个时间窗口内的所有事件」打包成一批。比如每5分钟到达的请求,凑够1万条再一起算。这样既保证了实时性,又发挥了GPU的批量优势。

1.3 自定义CUDA核函数:特征衍生的利器

风控特征衍生,说白了就是「用已有特征生成新特征」。比如:

  • 交易金额 / 历史平均金额 → 金额偏离度
  • 当前设备ID是否在历史设备列表中 → 设备变更标志
  • 近5分钟交易次数 / 近1小时交易次数 → 交易频率比

这些衍生逻辑,用CUDA核函数实现非常直接。

我给大家看一个实际用过的核函数:计算「滑动窗口内的统计量」。

__global__ void sliding_window_stats(
    const float* values,     // 原始数据
    float* means,            // 均值输出
    float* stds,             // 标准差输出
    int window_size,         // 窗口大小
    int num_samples          // 样本总数
) {
    int idx = blockIdx.x * blockDim.x + threadIdx.x;
    if (idx >= num_samples) return;

    float sum = 0.0f;
    float sum_sq = 0.0f;
    int count = 0;

    // 每个线程处理一个样本的窗口计算
    for (int i = idx; i < idx + window_size && i < num_samples; i++) {
        sum += values[i];
        sum_sq += values[i] * values[i];
        count++;
    }

    means[idx] = sum / count;
    stds[idx] = sqrtf(sum_sq / count - means[idx] * means[idx]);
}

这个核函数,每个线程负责计算一个样本的滑动窗口均值和标准差。100万条数据,启动1024个线程块,每个块256个线程,一次搞定。

注意:核函数里不要用动态内存分配(malloc/new),也不要用递归。GPU的栈空间有限,这些操作会导致性能骤降甚至崩溃。我刚开始写CUDA时踩过这个坑,排查了两天才发现是递归调用的问题。

1.4 实战:特征衍生流水线

下面是我在项目中常用的特征衍生流水线架构:

GPU特征衍生流水线 原始数据加载 CPU→GPU传输 基础特征计算 均值/计数/求和 特征衍生 比率/偏离度/标志 输出 并行计算细节 线程块0:处理样本0~255 线程块1:处理样本256~511 线程块2:处理样本512~767 ... 每个线程:计算一个样本的全部衍生特征 共享内存:缓存窗口数据,减少全局内存访问 原子操作:处理跨线程的聚合计算 流并行:计算与数据传输重叠

这个流水线有几个关键点:

  1. 数据加载阶段:用CUDA流(stream)实现异步传输,计算和传输可以重叠。我习惯用2个流,一个传数据,一个算特征,交替进行。
  2. 基础特征计算:用现成的cuBLAS、cuRAND库函数,比自己手写核函数快。比如均值计算,直接调cuBLAS的矩阵乘法。
  3. 特征衍生阶段:这里才需要自定义核函数。因为衍生逻辑千变万化,库函数覆盖不了。

避坑指南:我曾经在特征衍生核函数里用了大量if-else分支判断,结果性能惨不忍睹。后来改成用「掩码+三元运算符」的方式,分支预测失败率从30%降到了2%。GPU对分支非常敏感,尽量让同一个warp内的线程走相同路径。

1.5 性能调优:从能用用到好用

写一个能跑的CUDA程序不难,难的是让它跑得快。我总结了几条经验:

  • 内存合并访问:让相邻线程访问相邻内存地址。比如线程0访问data[0],线程1访问data[1],这样能充分利用显存带宽。
  • 共享内存:把频繁访问的数据放到共享内存里,比全局内存快100倍。但共享内存只有几十KB,要精打细算。
  • 减少同步:__syncthreads()是性能杀手,能不用就不用。实在要用,确保所有线程都执行到同一位置。

我记得有一次优化特征计算核函数,把全局内存访问改成共享内存,性能提升了8倍。当时我还纳闷,怎么改了一行代码效果这么明显?后来分析发现,原来的代码每个线程都在重复读取相同的数据,改成共享内存后,一个线程块只读一次,大家共享。

嗯,这就是GPU优化的魅力——有时候一个简单的改动,就能带来质的飞跃。

好了,这一章的内容就到这里。特征工程GPU化,说白了就是把「串行思维」转成「并行思维」。你想想看,风控场景里那么多独立计算的特征,天生就是为GPU准备的。

下一章我们聊聊怎么用CUDA实现实时特征更新,敬请期待。


专注资料整理