一、风控特征工程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 实战:特征衍生流水线
下面是我在项目中常用的特征衍生流水线架构:
这个流水线有几个关键点:
- 数据加载阶段:用CUDA流(stream)实现异步传输,计算和传输可以重叠。我习惯用2个流,一个传数据,一个算特征,交替进行。
- 基础特征计算:用现成的cuBLAS、cuRAND库函数,比自己手写核函数快。比如均值计算,直接调cuBLAS的矩阵乘法。
- 特征衍生阶段:这里才需要自定义核函数。因为衍生逻辑千变万化,库函数覆盖不了。
避坑指南:我曾经在特征衍生核函数里用了大量if-else分支判断,结果性能惨不忍睹。后来改成用「掩码+三元运算符」的方式,分支预测失败率从30%降到了2%。GPU对分支非常敏感,尽量让同一个warp内的线程走相同路径。
1.5 性能调优:从能用用到好用
写一个能跑的CUDA程序不难,难的是让它跑得快。我总结了几条经验:
- 内存合并访问:让相邻线程访问相邻内存地址。比如线程0访问data[0],线程1访问data[1],这样能充分利用显存带宽。
- 共享内存:把频繁访问的数据放到共享内存里,比全局内存快100倍。但共享内存只有几十KB,要精打细算。
- 减少同步:__syncthreads()是性能杀手,能不用就不用。实在要用,确保所有线程都执行到同一位置。
我记得有一次优化特征计算核函数,把全局内存访问改成共享内存,性能提升了8倍。当时我还纳闷,怎么改了一行代码效果这么明显?后来分析发现,原来的代码每个线程都在重复读取相同的数据,改成共享内存后,一个线程块只读一次,大家共享。
嗯,这就是GPU优化的魅力——有时候一个简单的改动,就能带来质的飞跃。
好了,这一章的内容就到这里。特征工程GPU化,说白了就是把「串行思维」转成「并行思维」。你想想看,风控场景里那么多独立计算的特征,天生就是为GPU准备的。
下一章我们聊聊怎么用CUDA实现实时特征更新,敬请期待。