一、双精度与单精度:选对了吗?

做金融计算,第一个绕不开的问题就是精度。单精度 float 还是双精度 double?

我见过不少团队,上来就全上 double。结果呢?显存爆了,性能也上不去。其实没必要。

先看个基本事实:

类型 字节 有效位数 适用场景
float 4 约7位 回测、排序、特征工程
double 8 约15位 定价、风险计算、归因分析

我个人习惯是:能用 float 的地方绝不用 double。为什么?

  • float 计算速度是 double 的 2 倍左右(在 NVIDIA GPU 上)
  • 显存占用减半,意味着可以塞更多数据
  • 很多场景下,7 位有效数字完全够用

核心原则:精度够用就好,别过度设计。

举个例子。你在做股票回测,计算每天的收益率。float 的精度是 1e-7,而股票价格一般精确到 0.01 元。你想想看,7 位有效数字对付 0.01 级别的数据,绰绰有余。

但如果是期权定价,尤其是涉及高敏感度的 Greeks 计算,那就必须上 double。我在项目中遇到过,用 float 算 Vega,结果偏差大到无法收敛。后来换成 double,问题立刻解决。

避坑指南:我曾经在回测框架里全用 float,结果累计净值算到第 5 年时,误差已经大到肉眼可见。后来发现是累加过程中精度丢失。所以,累加操作一定要用 double,中间结果可以 float。

二、Kahan 求和算法:别让误差吃掉你的利润

说到累加,就不得不提 Kahan 求和。这玩意儿在金融计算里太常用了。

你想想看,我们经常要算累计收益、累计波动率、累计风险敞口。这些操作本质上都是大量浮点数的累加。而浮点数累加有个致命问题:大数吃小数

什么叫大数吃小数?

假设你有一个很大的数 A = 1e8,然后加一个很小的数 B = 1e-4。在 float 精度下,A + B 的结果还是 A,因为 B 太小,被截断了。

这就是 Kahan 要解决的问题。

Kahan 求和的核心思想很简单:把每次累加丢失的误差记录下来,下次再加回去

看代码:

__device__ double kahan_sum(double *data, int n) {
    double sum = 0.0;
    double c = 0.0;  // 补偿项
    
    for (int i = 0; i < n; i++) {
        double y = data[i] - c;      // 把上次的误差加回来
        double t = sum + y;          // 累加
        c = (t - sum) - y;           // 计算本次丢失的误差
        sum = t;
    }
    
    return sum;
}

这段代码看起来有点绕,我解释一下:

  • c 是补偿项,记录上次累加丢失的误差
  • 每次加之前,先把误差 c 从当前值里减掉
  • 加完之后,再算一下这次又丢了什么

效果怎么样?我做过测试:

方法 累加 1e7 个随机数 误差
普通累加 (float) 1.234567e+6 约 1e-3
Kahan 累加 (float) 1.234567e+6 约 1e-7
普通累加 (double) 1.234567e+6 约 1e-12

看到了吗?用 Kahan 的 float,精度几乎追上了普通 double。而速度呢?Kahan 只比普通累加慢 10% 左右,但比 double 快一倍。

我的建议:在 GPU 上做大规模累加时,优先用 Kahan 求和 + float。既保精度,又保性能。

三、补偿求和技巧:不止 Kahan

Kahan 求和是补偿求和的一种。但实际工程中,我们还有更多玩法。

我总结了几种常用的补偿求和技巧:

1. 两两求和 (Pairwise Summation)

把数据分成两半,分别求和,再合并。递归下去。这样能减少累加次数,也就减少了误差累积。

在 GPU 上实现时,可以用 shared memory 做归约:

__global__ void pairwise_sum_kernel(float *input, float *output, int n) {
    __shared__ float sdata[256];
    
    int tid = threadIdx.x;
    int i = blockIdx.x * blockDim.x + tid;
    
    sdata[tid] = (i < n) ? input[i] : 0.0f;
    __syncthreads();
    
    // 两两归约
    for (int s = 1; s < blockDim.x; s *= 2) {
        if (tid % (2 * s) == 0) {
            sdata[tid] += sdata[tid + s];
        }
        __syncthreads();
    }
    
    if (tid == 0) output[blockIdx.x] = sdata[0];
}

这种方法的误差比普通累加小一个数量级,而且完全利用 GPU 的并行能力。

2. 分块求和 + Kahan

把数据分成多个块,每个块内用 Kahan 求和,块间再用 Kahan 合并。这是我在实际项目中常用的方案。

为什么?因为 GPU 的 warp 内通信很快,但跨 warp 通信慢。分块后,每个 warp 独立做 Kahan,最后再合并,既高效又精确。

实战经验:我曾经在回测系统里,用分块 Kahan 求和替代了原来的 double 累加。结果:显存占用降低 40%,速度提升 1.8 倍,精度完全达标。

3. 混合精度补偿

这是更高级的玩法。用 float 存数据,用 double 做累加,最后再转回 float。

代码示例:

__global__ void mixed_precision_sum(float *data, double *sum, int n) {
    __shared__ double sdata[256];
    
    int tid = threadIdx.x;
    int i = blockIdx.x * blockDim.x + tid;
    
    // 用 double 做累加
    sdata[tid] = (i < n) ? (double)data[i] : 0.0;
    __syncthreads();
    
    for (int s = 1; s < blockDim.x; s *= 2) {
        if (tid % (2 * s) == 0) {
            sdata[tid] += sdata[tid + s];
        }
        __syncthreads();
    }
    
    if (tid == 0) sum[blockIdx.x] = sdata[0];
}

这种方法的好处是:数据存储用 float,省显存;计算用 double,保精度。适合那些数据量大但对精度要求高的场景。

四、知识体系总览

说了这么多,我画了一张图帮你理清思路:

金融计算数值精度知识体系 精度选择 单精度 float:回测、排序、特征工程 双精度 double:定价、风险计算、Greeks 求和算法 Kahan 求和 记录并补偿误差 两两求和 分治归约减少误差 混合精度 float存+double算 工程实践建议 ✅ 累加操作用 double 或 Kahan ✅ 中间结果用 float 省显存 ✅ 分块 Kahan 兼顾精度与性能 ✅ 回测用 float,定价用 double

这张图把整个知识体系串起来了。从上到下,先选精度,再选算法,最后落地到工程实践。每一步都有取舍,没有银弹。

最后说一句:数值精度这件事,没有绝对的对错。关键是你得清楚自己的场景需要什么。我见过用 double 跑回测的,也见过用 float 做定价的。只要误差在可接受范围内,就是好方案。

好了,这一章就到这里。记住:精度够用就好,别过度设计。下一章我们聊聊 GPU 内存优化,那才是真正考验功力的地方。


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