3174 字
16 分钟
完美随机浮点数
2025-05-12
无标签

翻译自 https://specbranch.com/posts/fp-rand/

完美随机浮点数#

许多编程语言和库中常用的一种流程并不是真正的浮点算法:

  1. 根据格式的精度生成随机整数。
  2. 转换为浮点数。
  3. 除以某个值以生成一个介于 0 和 1 之间的输出。

在代码中,这看起来像:

func (r *Rand) Float64() float64 {
    randInt := r.Int63n(1 << 53)
    return float64(randInt) / (1 << 53)
}

此函数应该生成在区间 [0,1)[0, 1) 上均匀分布的浮点数。0 是可能的输出,但 1 不是,并且分布是均匀的。上面算法中的 “53” 是根据浮点感知选择的:双精度浮点数具有 53 位精度,因此该算法仅创建与数字系统精度相等的位。看起来它符合要求。

然而,该算法存在一些基本缺陷。首先,它无法访问大多数介于 0 和 1 之间的浮点数。该范围内有 2622^{62}float64 数字,而此算法仅能访问 2532^{53} 个:仅占空间的 1512\frac{1}{512}。即使我们使用更大的整数范围(如 2642^{64}),许多随机整数也会映射到相同的浮点结果,并且一些整数在除法过程中会四舍五入到 1。

其次,来自此生成器的最低有效位是有偏的。这实际上是一个定点随机数生成器,随后进行浮点转换。

过去曾有一些尝试来修复这些缺陷,但在避免巨大性能损失的同时都未能成功。我最近完成了一篇关于生成完美随机浮点数的新方法的论文,并在 GitHub 上发布了预印本和 Go 语言参考实现。多亏了分支预测,该完美算法的速度并不比上述定点算法慢多少。

0 到 1 之间的浮点数#

浮点数是三个组件的元组,打包到一个 32 位或 64 位的字中:

字段含义位数 (float32)位数 (float64)
符号 (ss)正号或负号11
指数 (ee)最高有效位的量级811
尾数 (mm)除最高有效位后的所有有效位2352

每个数字的存储都是高效的:最高有效位不存储,因为它可以根据指数推断为 0(当 e=0e = 0 时,表示 0)或 1(其他情况)。浮点数还有无穷大和非数字值(NaN 表示 “不是数字”),它们用全为 1 的指数编码。否则,指数是一个无符号数,通过减去偏置可以得到负指数。

一个正常的浮点数的值由以下公式给出:

F=(1)s2eeBias(1.m)F = (-1)^s \, 2^{e - eBias} \,(1.m)

通常,eBiaseBias 是指数字段可表示范围的一半。32 位浮点数具有 8 位指数,使用 eBias=127eBias = 127,因此可表示的指数范围为 -126 到 127(e=0e = 0e=255e = 255 有特殊含义)。

一半的浮点数位于 -1 和 1 之间:所有指数在 0 到 eBiaseBias 之间的数字都小于 1。这似乎违反直觉,但这是浮点数机制的产物:它们在不同量级之间具有相同的精度。数字 0.0000010.000001102010^{20} 都具有 53 位精度(如果是 32 位浮点则为 24 位)。

这类似于“有效数字”的概念:每个浮点数无论大小都有 53 位有效数字。浮点数按幂律分布,极端量级的数字比看起来更多。

浮点舍入#

当操作产生的位数过多时,我们需要进行舍入以得到浮点结果。有四种浮点舍入模式,但其中三种相对专业:

  • 最近舍入-偶数舍入(Round-to-nearest-ties-to-even):舍入到最接近的浮点数,遇到平局时舍入到偶数。与学校学到的舍入类似,但学校通常将平局舍入到远离零的方向。偶数舍入对数值稳定性更好。
  • 向零舍入(Round toward zero,截断):始终舍入到更接近零的浮点数,等价于截断。
  • 向下舍入(Round down):始终舍入到较小的数字。
  • 向上舍入(Round up):始终舍入到较大的数字。

最近-偶数舍入是默认的算术舍入,因为它最准确。对于原生浮点随机数生成,我们可能更倾向于遵循所选的舍入模式,而不是严格地在 [0,1)[0,1) 上操作。下面的数轴示例展示了在假设浮点格式 (p=2p=2) 下的向下舍入和最近舍入模式。

浮点的整数算术#

浮点数被打包到机器字中,可以使用按位逻辑操作和整数算术进行操作,这使得我们可以做一些稍显危险的事情:

  • 我们可以通过按位或(OR)将整数与浮点数的不同部分组合来构造浮点数(例如将尾数附加到指数)。
  • 向浮点数添加小整数是按单位最后一位(ULP)进行微扰,当跨越指数边界时可能会出现奇异的行为。
  • 添加或减去到达指数字段的大整数是快速乘除浮点数的一种方式,只要正确处理边界条件。

这些技巧允许你构建自己的快速操作。更疯狂的技巧,如 Quake III 的快速逆平方根算法,正是基于这些基本构建块。

浮点数的均匀随机性#

基于此,我们可以定义完美浮点随机数生成的概念:

对于无偏随机浮点数,按概率生成浮点数,其概率由从实数范围抽取一个实数然后舍入到浮点数决定。

整数和定点随机数生成都遵循此规则,但它们将空间分割为均匀的片段,因此每个数字的概率相等。浮点数没有此优势。相反,概率取决于浮点数字之间的距离。

通常,位于 0.50.511 之间的数字出现概率是位于 0.250.250.50.5 之间数字的两倍,后者又是位于 0.1250.1250.250.25 之间数字的两倍,以此类推。每个指数范围边界的概率由舍入模式决定:向下舍入(或向零舍入)时,范围底部与范围内其他数字概率相同;向上舍入时,范围顶部与范围内其他数字概率相同;最近舍入时,我们取两者的平均。

下面给出 pp 位精度(64 位浮点 p=53p=53,32 位浮点 p=24p=24)下前两个指数范围在 (0,1)(0,1) 上均匀生成的概率表:

输出范围舍入模式最低数字中间数字最高数字
begin:math:displaybegin:math:display0.5, 1end:math:displayend:math:display向下2p2^{-p}2p2^{-p}0
begin:math:displaybegin:math:display0.5, 1end:math:displayend:math:display向上2p12^{-p-1}2p2^{-p}2p2^{-p}
begin:math:displaybegin:math:display0.5, 1end:math:displayend:math:display最近2p1+2p22^{-p-1} + 2^{-p-2}2p2^{-p}2p12^{-p-1}
begin:math:displaybegin:math:display0.25, 0.5end:math:displayend:math:display向下2p12^{-p-1}2p12^{-p-1}2p2^{-p}
begin:math:displaybegin:math:display0.25, 0.5end:math:displayend:math:display向上2p22^{-p-2}2p12^{-p-1}2p12^{-p-1}
begin:math:displaybegin:math:display0.25, 0.5end:math:displayend:math:display最近2p2+2p32^{-p-2} + 2^{-p-3}2p12^{-p-1}2p2+2p12^{-p-2} + 2^{-p-1}

表格继续向指数为零方向展开。对于所有三种模式,范围内的中间数字概率相同:它们上下的实数区域相等,因此舍入不影响它们的概率。但范围的边界会变化。当向下舍入时,二的幂的概率与上方数字相同;向上舍入时,二的幂的概率与下方数字相同;最近舍入时,我们取上下概率的平均,使它们比下一级范围内的数字概率高 1.5 倍。

典型的随机浮点生成更贴合“向下舍入”模式,此模式不会生成 1。其他舍入模式会给 1 一定概率。另一个有趣的事实是,这些数字的最低有效位应当均匀分布,尽管最低有效位的幅度不同。

因为我们把它视为实数范围,无论区间开闭,四舍五入的概率质量相同。

浮点随机算法#

我们可以通过一个两步算法来匹配上述概率:

  1. pp 位精度的定点随机数生成器生成一个固定点结果,如果生成 0,则特殊处理,用于确定实数范围。
  2. 通过回填额外的精度位来完成最终的随机数抽取。

定点阶段确定浮点数字范围,完成阶段从该范围中选取数字。在定点阶段,只有最低范围(从 0 到 2p2^{-p})包含多个指数。要处理此范围,我们递归运行算法,但缩放因子为 2p2^{-p}

当我们达到次正规数时,进入基底情况,此时定点阶段的所有可能值映射到同一个指数。这允许我们通过重复定点抽取来确定输出的指数。

定点结果具有已知数目的尾部零,基于结果的量级。完成阶段填充这些位。

在完成阶段,我们通过生成一个位数等于从 1 的指数到定点数指数差值的整数来回填精度位。这差值就是将定点数转换为浮点数时要填补的精度位,回填它们即可。此阶段根据舍入模式略有不同。

以下是 Go 语言中 float64 的完整算法实现。我们使用略有不同的定点随机数生成:不生成大整数然后除法,而是生成随机尾数,将其放入 [1,2) 范围,然后减 1。其输出分布与除法方法完全相同,但在某些架构上可能较慢。

// Generate float64 with a given rounding mode
func (r *FPRand) Float64Rounding(mode RoundingMode) float64 {
    const PRECISION = 53
    const FP64_MANTISSA_SIZE = PRECISION - 1
    const FP64_MANTISSA_MASK = (1 << FP64_MANTISSA_SIZE) - 1

    // PHASE 1: 缩放定点阶段

    // 用于缩小指数范围的变量
    exp_range := uint64(FP64_MANTISSA_SIZE) << FP64_MANTISSA_SIZE
    var one = math.Float64bits(1)

    // 跟踪下溢时的尾部位数
    var tail_bits uint64 = 0

    // 生成尾数,直到非零(预期只运行一次)
    var mantissa = r.src.Int63n(1 << FP64_MANTISSA_SIZE)
    for mantissa == 0 {
        // 缩小范围,每次循环都在上一次的 0 和 1 之间
        one -= exp_range

        if one < exp_range {
            // 低概率下溢基底情况,约 19 次循环后
            const UF_TAIL_SIZE = 35
            const UF_SHIFT = FP64_MANTISSA_SIZE - UF_TAIL_SIZE

            mantissa = r.src.Int63n(1 << UF_TAIL_SIZE) << UF_SHIFT
            tail_bits = UF_SHIFT
            break
        }

        mantissa = r.src.Int63n(1 << FP64_MANTISSA_SIZE)
    }

    // 生成一个位于 [1,2) 之间的浮点数并减 1,得到定点结果
    num := math.Float64frombits(one | mantissa) - math.Float64frombits(one)
    num_as_int := math.Float64bits(num)

    // PHASE 2: 完成阶段

    // 通过减去指数确定需要回填的尾部位数
    tail_bits += (one >> FP64_MANTISSA_SIZE) - (num_as_int >> FP64_MANTISSA_SIZE)

    // 处理指数为 0 的情况(极低概率)
    if tail_bits > FP64_MANTISSA_SIZE {
        tail_bits = FP64_MANTISSA_SIZE
    }

    // 根据舍入模式生成尾部
    var tail uint64 = 0
    if mode == RoundDown {
        tail = r.src.Int63n(1 << tail_bits)
    } else if mode == RoundUp {
        tail = r.src.Int63n(1 << tail_bits) + 1
    } else {
        tail = (r.src.Int63n(1 << tail_bits) + 1) >> 1
    }

    return math.Float64frombits(num_as_int + tail)
}

// 功能等价于 Float64 - 在 [0,1) 上生成数字
func (r *FPRand) Float64() float64 {
    return Float64Rounding(RoundDown)
}

基准测试和测试#

尽管代码中有循环和分支,大多数分支因极低概率而几乎是不带成本的:第 30 行的循环概率为 2532^{-53},其他条件分支概率更低。因此,平均生成时间与典型定点生成算法相当。

下面是使用 Go 的两种随机位流生成器(PCG 和 ChaCha8)作为熵源的代表性基准结果:

bench

从结果看,与使用 PCG 的定点算法相比,速度几乎无差;使用 ChaCha8 时由于使用了整数单元,会有额外开销。讽刺的是,整数生成器使用浮点单元,而浮点生成算法使用整数单元。

主缺点在于该代码无法像定点算法那样自动向量化,但由于循环极其稀少,也可以用向量操作实现。

我们还进行了均匀性测试。使用 K-S 检验,定点除法算法的最低有效位在较小样本下即显现非随机性,而本算法的最低有效位保持完全随机。以下图来自对每个生成器最低有效位均匀性的 K-S 检验:

check

在生成 1 亿 (100,000,000) 个随机数后,定点方法已有 16 位最低有效位可被区分,而本算法仍然生成完全随机的尾部位。对于 64 位浮点这并非大问题,但对于精度仅 24 位的 32 位浮点,丢失 16 位有效信息将极大影响质量。

结论#

这是第一个高效的算法,能够访问全部浮点输出范围并提供正确概率的随机均匀浮点数。它增加了两个额外步骤:回填尾部位和在生成零时的极低概率递归。该算法解决了浮点随机数生成产生的精度问题,将有助于提高模拟和计算的准确性。

完美随机浮点数
https://blog.lpkt.cn/posts/fp-rand/
作者
lollipopkit
发布于
2025-05-12
许可协议
CC BY-NC-SA 4.0