浮点数是计算机表示实数的基础方式,但其与人类习惯的十进制字符串之间的转换却是一个困扰了程序员数十年的难题。传统的浮点数打印和解析算法往往需要在代码复杂度和运行速度之间做出妥协:要追求速度,代码就会变得晦涩难懂;要保持简洁性,就必须牺牲性能。然而,Russ Cox 在 2026 年 1 月发表的研究文章提出了一种名为「快速无舍入缩放」(Fast Unrounded Scaling)的基本原语,基于这一原语,可以构建出既简单又快速的打印和解析算法,在性能上甚至超越了 Dragonbox、Ryū、Schubfach 等业界知名算法。本文将深入解析这一统一算法框架的核心理念与工程实现细节。
无舍入数的设计哲学
在深入快速无舍入缩放之前,我们需要理解「无舍入数」(Unrounded Number)这一核心概念。IEEE 754 浮点数标准定义了五种舍入模式,但在实际的进制转换场景中,我们需要在最终舍入之前保留足够的信息,以便调用方能够根据需要选择合适的舍入方式。无舍入数的精妙之处在于,它将这一需求形式化为一个简单的数据结构:用截断的整数部分加上两个额外比特来表示任意实数。
具体来说,任意实数 x 的无舍入形式 ⟨x⟩ 包含三个组成部分。首先是整数部分 ⌊4x⌋,即对 x 乘以 4 后取整。这个乘法操作实际上是将 x 的整数部分、左移两位后的结果存储在 64 位整数中。其次是「二分之一位」(Half Bit),它表示 x 的小数部分是否大于等于 0.5,对应 ⌊4x⌋ mod 2 的值。第三个比特被称为「粘滞位」(Sticky Bit),它是一个「或」操作的结果 —— 只要原始数字在小数点后除了二分之一位之外还有任何非零比特,粘滞位就被设置为 1。一旦粘滞位被设置为 1,它在后续的所有操作中都必须保持为 1,这个特性确保了无舍入数能够正确传播舍入信息。
这种表示方法的优雅之处在于,它将所有常用的舍入操作都简化为简单的整数运算。要实现向下舍入(Floor),只需将无舍入数右移两位;要实现四舍五入到最近且 0.5 向上舍入(Round Half Up),则加 2 后再右移两位;而最常用的「四舍六入五成双」(Round Half to Even)规则,则可以通过先判断低位比特是否为奇数再加 1 来实现。这种统一的形式化方法极大地简化了算法的推导和实现。
快速无舍入缩放的数学基础
快速无舍入缩放操作 uscale (x, e, p) 的定义是计算 ⟨x・2^e・10^p⟩,即将输入 x 乘以二进制的 2^e 和十进制的 10^p,然后返回无舍入形式。粗略来看,这个操作需要计算一个非常大的乘积,然后进行无舍入处理。但如果每次转换都需要处理任意位数的整数运算,性能就会变得无法接受。
Russ Cox 的关键洞察在于:对于所有实际的浮点数转换场景,输入 x 和输出无舍入数的范围都可以被限制在 64 位以内。这意味着我们可以通过预计算的查表方法来近似 10^p,将这个近似值表示为 pm·2^pe 的形式,其中 pm 是一个 128 位的整数近似值。由于 pm 可能比精确值略大(通过向上取整得到),实际计算 x・pm 时会产生最多 64 位的误差。这个误差虽然看起来很大,但实际上只影响乘积的低 64 位,而算法只需要高 64 位来确定无舍入形式。
具体来说,算法丢弃乘积的低 64 位,只保留中间 64 位和高 64 位的低 s 位。粘滞位的设置规则是:如果中间 64 位不为零,或者高 64 位被移出的低 s 位中有任何 1,则粘滞位设为 1。这意味着算法只需要计算完整的 128 位乘积中的高 64 位,就可以确定无舍入数的大部分信息。在很多情况下,甚至可以只计算高 64 位乘积,如果移出的比特中有 1,就直接返回结果,避免了计算低 64 位的开销。
打印算法的简洁实现
基于快速无舍入缩放原语,浮点数打印算法的实现变得异常简洁。固定宽度打印函数 FixedWidth 接受一个浮点数 f 和目标数字位数 n,核心步骤只有三个:首先将浮点数解包为尾数 m 和指数 e;然后计算合适的缩放因子 p,使得 m・2^e・10^p 落在区间 [10^(n-1), 10^n) 内;最后调用 uscale 并进行舍入得到十进制整数 d,最终结果就是 d・10^(-p)。
这个算法的关键在于如何计算合适的 p。通过对不等式进行变形和取对数,可以推导出 p ≈ n-1-⌊log10 (m・2^e)⌋。由于浮点数的尾数 m 总是接近 2^53,我们可以使用 m 的位数来近似 log2 (m),从而将 log10 (m・2^e) 简化为一个线性函数。这个近似可以用整数运算快速完成:log10Pow2 (x) = (x × 78913) >> 18,这个近似值在浮点数的实际范围内是精确的。
最短宽度打印(Short)的目标是用尽可能少的十进制数字表示浮点数,同时确保解析回去能够得到原始值。传统算法如 Grisu3 需要回退到大数运算来处理边界情况,但基于无舍入缩放的 Short 算法可以精确计算浮点数与其最近邻之间的中点,然后通过两次 uscale 调用得到合法十进制区间的上下界。如果区间内只有一个合法值,或者存在以零结尾的合法值,就可以直接返回;否则选择最接近原始值的那一个。由于精确的无舍入缩放,这个算法始终能找到正确的最短表示,而不需要任何回退逻辑。
解析算法的统一视角
浮点数解析与打印本质上是互逆操作。给定一个十进制整数 d 和指数 p,我们需要将其转换为浮点数。算法的目标是将 d・10^p 映射到浮点数域 [2^52, 2^53)・2^e 内,然后返回舍入后的结果。同样,这个操作可以通过无舍入缩放来完成。
解析算法的核心是推导合适的指数 e,使得 d・2^e・10^p 落在目标区间内。推导过程与打印算法类似,最终得到 e ≈ 53 - bits (d) - ⌊log2 (10)・p⌋。其中 bits (d) 是 d 的二进制位数,用来近似 log2 (d)。计算得到 e 后,调用 uscale (d, e, p) 得到无舍入数,然后进行舍入即可得到最终的浮点数。
特别值得注意的是,这个解析算法比广泛使用的 Eisel-Lemire 算法更快。Eisel-Lemire 算法在 2020 年被提出后,因其出色的性能被快速_float 和 Abseil 等主流库采用。然而,它需要处理一个理论上可能触发回退到慢速路径的边界情况,虽然后续研究证明这个边界永远不会在实际输入中触发。Russ Cox 的解析算法通过无舍入缩放的统一框架,避免了这个复杂的状态处理逻辑,同时在基准测试中取得了更优的性能表现。
进位优化与单次乘法路径
为了进一步提升性能,算法引入了一个关键的优化:利用进位信息来避免不必要的计算。在 uscale 的原始实现中,我们需要计算 x・pm 的完整 128 位乘积。但研究发现,如果高 64 位乘积在移出位置有任何一个 1 比特,就意味着即使低 64 位乘积产生进位,这个进位也不会传播到高 64 位被保留的部分。
这个观察带来的优化是:如果高位乘积的移出区域有 1,我们只需要计算高位乘积本身,就可以确定完整的无舍入结果。只有当移出区域全部为 0 时,才需要计算低 64 位乘积并处理可能的借位。由于最短宽度打印需要对三个非常接近的输入调用 uscale(上下界和中心点),而这三个输入在移出区域同时为 0 的概率极低,优化后的代码在大多数调用中只需要执行一次 64 位乘法。
这个优化使得 uscale 在很多情况下只执行一次宽乘法就能完成,这是该算法在基准测试中超越 Dragonbox 的主要原因。Dragonbox 的数字格式化代码确实很快,但那属于字符串处理优化;而在核心的二进制到十进制转换阶段,无舍入缩放的实现更加简洁高效。
性能验证与工程意义
Russ Cox 在 Apple M4 和 AMD Ryzen 9 7950X 两个平台上进行了详细的基准测试。测试结果表明,固定宽度打印(17 位精度)的性能显著优于 double-conversion、David Gay 的 dtoa、glibc 的 sprintf、Ryu 等实现。对于最短宽度打印,虽然 Dragonbox 在格式化阶段略快,但核心转换阶段无舍入缩放更快,而且代码量只有 Dragonbox 的几分之一。在解析测试中,无舍入缩放同样超越了 fast_float、Abseil 和 glibc 等实现。
从工程角度看,这项工作的意义不仅在于性能提升,更在于算法复杂度的降低。传统的浮点数转换算法积累了数十年的改进,每个新算法都在前人的基础上添加特殊的边界情况处理和优化。Dragonbox 是一个极端例子 —— 它的正确性证明非常复杂,代码也很难阅读。相比之下,无舍入缩放框架将打印和解析统一在一个数学框架下,核心代码不过几十行,却能取得更好的性能。
Russ Cox 预计这个算法的 Go 实现将在 Go 1.27 版本中正式引入标准库。作为浮点数转换领域的长期研究者,Russ Cox 的这项工作代表了算法演进的一个新阶段:不是继续在前人的优化上堆砌新技巧,而是寻找更深层次的统一原理。一旦理解了无舍入缩放,浮点数打印和解析就变得不再「神秘」,而是变成了一系列可以直接推导的数学运算。
资料来源:Russ Cox, "Floating-Point Printing and Parsing Can Be Simple And Fast", research.swtch.com, 2026 年 1 月 19 日。