首页
/ Godot 引擎的浮点数转十进制字符串库:thirdparty/grisu2(Grisu2 算法)实现详解

Godot 引擎的浮点数转十进制字符串库:thirdparty/grisu2(Grisu2 算法)实现详解

2026-09-04 17:42:37作者:仰钰奇

本文以 thirdparty/grisu2/README.md 为纲,讲解 Godot 引擎引入的 Grisu2 浮点数二进制→十进制转换算法:它的算法出处、从 simdjson 移植到 Godot 时的四处关键改动、grisu2.h 单头文件的内部实现(DIY 浮点、缓存 10 的幂、数字生成与舍入、格式化),以及它在 String::num_scientific、JSON 序列化、Variant 持久化等处的实际调用链。读完本文,你将掌握该库的接口用法、输出格式规则(类似 printf("%g"))、最短往返(round-trip)表示的原理,以及在引擎源码中定位相关行为的具体位置。

一、Grisu2 解决什么问题

doublefloat 转成十进制字符串看起来简单,实则有两个难以同时满足的要求:

  1. 可读性/简洁性:输出尽量短的十进制数字串,例如把 3.5f 打印成 3.5 而不是 3.5000000238418579
  2. 精确往返:打印出来的字符串再次解析回同一精度浮点数时,必须得到原值(strtod/strtof 的结果与输入逐位一致)。

Grisu2 是 Florian Loitsch 在 PLDI 2010 论文 "Printing Floating-Point Numbers Quickly and Accurately with Integers" 中提出的快速算法,其理论基础可追溯到 Burger 与 Dybvig 1996 年的 "Printing Floating-Point Numbers Quickly and Accurately"。它的核心保证是:对 IEEE-754 浮点数生成能唯一还原该值的最短十进制表示,且全程只用整数运算(64 位乘、除、移位),无需浮点算术参与数字生成。

thirdparty/grisu2/README.md 的说法,本仓库的移植链路是:

  • 原始 C 参考实现由 Florian Loitsch 发布;
  • Daniel Lemire 将其简化并适配到 JSON 场景、改为 C++11,作为 simdjson 项目中的 to_chars.cpp
  • Godot 在此基础上进一步打了补丁,得到单文件头 grisu2.h

二、Godot 对上游实现的四处改动

README 明确列出:grisu2.h 等同于 simdjson 的 to_chars.cpp 加上 Godot 补丁(即仓库中的 patches/0001-godot-changes.patch),改动共有四处:

  1. 命名空间简化:去掉 simdjson::internal::dtoa_impl 的多层嵌套,统一收敛为单一 grisu2 命名空间(见 grisu2.h 第 8 行 namespace grisu2 {)。同时把上游的 include guard + #include <base.h> 换成了 #pragma once 与标准库头 <cstring><cstdint><array><cmath>,使该文件可以独立编译。
  2. 函数重名:为避免与 Godot 全局符号冲突,把内部函数改名,例如核心算法 grisu2 改名为 grisu2_core,接受浮点入参的模板版本改名为 grisu2_wrap(见 grisu2.h)。
  3. 同时支持 float 和 double:上游 to_chars 只接受 double;Godot 版将其改为模板函数 template <typename FloatType> char *to_chars(char *first, FloatType value)(见 grisu2.h),从而 float 按单精度边界(24 位尾数)计算,double 按双精度边界(53 位尾数)计算。
  4. 移除尾随 .0 逻辑:上游为了让输出"看起来像浮点数",会在整数值后面补 .0(例如 3.0 输出 3.00.0 输出 0.0)。Godot 的补丁删掉了这两处(补丁文件format_bufferto_chars 两处删除),使整数值直接输出为 3、零输出为 0,与 Godot 既有 String::num_scientific 的行为保持一致。

这个第 4 点改动对使用者是可见的行为差异

输入 上游 simdjson 风格输出 Godot 版输出
3.0 3.0 3
0.0 0.0 0
1.5 1.5 1.5
0.001 0.001 0.001
1e20 1e+20 1e+20

三、grisu2.h 内部实现拆解

整个算法集中在 931 行的单头文件 thirdparty/grisu2/grisu2.h 中,自底向上分为五层。

3.1 DIY 浮点:diyfp(f × 2^e)

dtyfp 结构定义 struct diyfp 表示 f * 2^e,其中 f 是 64 位无符号尾数(kPrecision = 64,即 q = 64),e 是指数。它提供三个关键操作:

  • sub:要求两操作数指数相同,直接做尾数减法;
  • mul:用 4 次 32×32→64 位乘法模拟 64×64 乘法,只保留高 64 位(含四舍五入),结果指数为 x.e + y.e + 64
  • normalize / normalize_to:把尾数左移直到最高位为 1,或对齐到指定指数。

选择 q = 64 的意义在 源码注释 中有 static_assert(diyfp::kPrecision >= std::numeric_limits<FloatType>::digits + 3) 的保证:64 位精度对 53 位尾数的 double 和 24 位尾数的 float 都绰绰有余,这是"float 与 double 共用同一套 64 位整数管线"能成立的前提。

3.2 计算边界:compute_boundaries

float/double 边界计算 compute_boundaries(FloatType value) 先通过 reinterpret_bits 把 IEEE-754 位型拆成指数字段 E 与尾数字段 F(区分规格化/非规格化),还原成 v = f * 2^e;然后求 v 的相邻浮点数 v-v+,取中点得到区间 [m-, m+]——任何严格落在该区间内的实数,按任何平局舍入策略都会解析回 v。注意一个细节:当 F == 0 && E > 1(即 v 本身是 2 的幂)时,下边界更近,m- 要用 (4*v.f - 1) * 2^(v.e-2) 而非 (2*v.f - 1) * 2^(v.e-1)。最终返回归一化后的三元组 {w, m_minus, m_plus}

由于 FloatType 是模板参数,floatdigits = 24doubledigits = 53 的分支——这就是第 3 节"支持 float"改动的落点。

3.3 缓存的 10 的幂表

Grisu2 需要找一个缓存的 10 的幂 c ≈ 10^k,使乘积 c * w 的指数落入区间 [alpha, gamma] = [-60, -32]kAlpha/kGamma 定义)。这个区间的选取动机在 源码注释 中写得很清楚:

  • e <= -32 保证整数部分 p1 能装进 32 位整数(32 位除法比 64 位除法快);
  • -e <= 60 保证分数部分反复乘 10 时不溢出 64 位。

对于 IEEE double,归一化后的二进制指数范围是 [-1137, 960],对应十进制指数范围约 [-307, 324]。而相邻缓存条目只需覆盖"指数差 ≤ floor((gamma-alpha) * log10(2)) = 8",所以 79 条记录(步长 8,覆盖 k ∈ [-300, 324])就足够,见 kCachedPowers 表。查询时完全用整数运算:k = ceil((alpha - e - 1) * log10(2)) 被实现为整数除法 (f * 78913) / (1 << 18) + (f > 0)log10(2) ≈ 78913 / 2^18),一次数组索引即返回,无分支表。

3.4 核心流程:grisu2_coregrisu2_wrap

模板入口 grisu2_wrap 先按 FloatType 精度计算边界,再交给 grisu2_core

  1. c ≈ 10^-k,把 vm-m+ 同时缩放为 ww-w+(64 位整数乘法,含舍入误差);
  2. 由于缩放本身会引入小于 1 ulp 的误差,再向外各扩 1 个 ulp 得到更宽的安全区间 [M-, M+]w_minus.f + 1w_plus.f - 1,见 源码);
  3. 调用 grisu2_digit_gen[M-, M+] 内生成最短十进制数字串,同时输出 decimal_exponent = k

源码注释也诚实地指出:Grisu2 保证的是"[M-, M+] 内最短",不保证全局区间 [m-, m+] 内最短;但在实际输入下二者几乎总是重合,这正是"快速且精确"的折中。

3.5 数字生成与舍入:grisu2_digit_gen / grisu2_round

grisu2_digit_gen 从右边界 M+ 出发逐位生成十进制数字,并尽早停止:

  • 整数部分:利用 -e >= 32,把 M+ = p1 + p2 * 2^e 拆开,对 32 位的 p1 反复执行 p1 / pow10p1 % pow10 从左到右提取数字;
  • 分数部分:把 p2 反复乘以 10、右移 -e 位提取数字(相当于长除法),不写小数点、而是累减 decimal_exponent
  • 每生成一位就检查 rest <= delta(剩余量不超过区间宽度 delta = M+ - M-),一旦 V = buffer * 10^n 落入 [M-, M+] 立即停止。

停止后再调用 grisu2_round 做微调:若把末尾数字减 1 能让 V 更接近中心点 w,就减("距 wdist"与"距 M- 的距离"比较)。这一步使输出在多位候选中偏向最接近原值的那个。

源码注释同时给出输出位数上界(注释,引自 Loitsch 定理 6.2 与 Matula 的 In-and-Out conversions 结果):

  • double(p = 53):最多 17 位十进制数字
  • float(p = 24):最多 9 位

3.6 格式化:format_bufferto_chars

format_buffer 根据 n = len + decimal_exponent(小数点相对数字串的位置)选择四种排布,整体风格对齐 printf("%g")

  • k <= n && n <= max_exp → 纯整数(不足补零);
  • 0 < n <= max_expdig.its
  • min_exp < n <= 00.[000]digits
  • 其余 → d.igitsE±nn(指数最少两位,兼容 %g 习惯,见 append_exponent)。

公共入口 to_chars 固定使用 kMinExp = -4kMaxExp = std::numeric_limits<double>::digits10(即 15):绝对值落在 [1e-4, 1e15) 内用定点表示,否则用科学计数法。几个接口语义要注意:

  • 入参必须有限:NaN/±Inf 不处理,由调用方先行拦截;
  • 结果不写 NUL 结尾,返回指向"内容之后"的指针,长度 = last - first
  • 缓冲区必须足够大(至少 max_digits10 以上,实际按 17 位数字 + 符号 + 小数点 + e±nn 余量估算即可);
  • 负号在算法开始前就地写入 *first++ = '-'

四、Godot 中的调用链:String::num_scientific

引擎内唯一直接引用该头文件的位置是 core/string/ustring.cpp#include <thirdparty/grisu2/grisu2.h>),它被两个静态方法消费:

// core/string/ustring.cpp
String String::num_scientific(double p_num) {
    if (Math::is_nan(p_num) || Math::is_inf(p_num)) {
        return num(p_num, 0);   // NaN/Inf 走常规分支
    }
    char buffer[256];
    char *last = grisu2::to_chars(buffer, p_num);
    return String::ascii(Span(buffer, last - buffer));
}

num_scientific(double/float) 实现float 重载对模板入口传 float 即可(C++ 会优先匹配精确类型),因此单/双精度各自按自己的 ulp 边界求最短表示;256 字节栈缓冲对 17 位上限绰绰有余。ustring.h 中声明了两个重载。

顺着调用链可以看到它支撑了引擎中几类"需要无损往返"的序列化场景:

  1. Variant 持久化解析core/variant/variant_parser.cpp 中专门注释说明"这两个函数使用 num_scientific 序列化 float/double",保证 .tscn/.tres 等文本资源里写下的数字重新加载后位级一致;
  2. GDScript 的 JSON.stringify 全精度模式core/io/json.cppfull_precision 为真时调用 num_scientific,并在不含小数点/e 时补回 .0,维持 JSON 数字的浮点形态;
  3. 文档数据序列化core/doc_data.cpp 把属性默认值中的 float 用 num_scientific 写入,避免文档中出现多余尾数;
  4. 脚本 APIcore/variant/variant_call.cppString.num_scientific 绑定为 GDScript 可调用静态方法;
  5. 内部工具函数rtossrtos 的"科学计数法"变体)直接转发到 num_scientific

对比来看,普通打印路径 String::num(p_num, decimals) 走的是"定点 + 指定小数位"路线(double 默认 14 位、float 默认 6 位,见 num_real 实现),而 num_scientific 提供的是位数最少且往返无损的表示——二者分工明确:前者面向人类阅读,后者面向需要精确还原的机器读写场景。

五、使用要点与工程细节小结

  • 文件构成thirdparty/grisu2/ 目录只有四个文件——README.md、单头实现 grisu2.hLICENSE(MIT,Copyright (c) 2009 Florian Loitsch)以及改动补丁 patches/0001-godot-changes.patch;无 .cpp、无需额外链接库,纯头文件模板实现,这也是它适合放在 thirdparty/ 的原因。
  • 调用前置条件:输入必须是有限值;float/double 重载分别给出 9 位/17 位内的最短表示;输出格式同 printf("%g") 习惯([1e-4, 1e15) 用定点,否则 d.dddde±nn)。
  • 与上游 simdjson 的差异只有 README 列出的四处:单命名空间、函数改名(grisu2_core/grisu2_wrap)、float+double 模板化、去掉整数值尾随 .0;算法本体(DIY 浮点、79 条 10 的幂缓存表、数字生成与舍入)与上游一致。
  • 精度语义float 值按 24 位尾数的相邻值计算边界,因此 float 通常输出不超过 9 位数字即可被 strtof 精确还原;double 同理不超过 17 位。
  • 阅读顺序建议README补丁(看清与上游的差异)→ grisu2.h 的 to_chars/format_buffer(接口与格式)→ grisu2_core/grisu2_digit_gen(算法主干)→ ustring.cpp 调用点(引擎集成)。

参考文件

路径 说明
thirdparty/grisu2/README.md 移植来源、论文出处与 Godot 改动清单
thirdparty/grisu2/grisu2.h Grisu2 完整实现(grisu2 命名空间,to_chars 为公共入口)
thirdparty/grisu2/patches/0001-godot-changes.patch 相对上游 simdjson to_chars.cpp 的差异补丁
thirdparty/grisu2/LICENSE MIT 许可
core/string/ustring.cpp String::num_scientific(float/double) 调用点
core/variant/variant_parser.cpp Variant 文本序列化使用 num_scientific 保证往返
core/io/json.cpp JSON.stringify 全精度模式
core/variant/variant_call.cpp String.num_scientific 脚本 API 绑定
core/doc_data.cpp 文档数据中 float 默认值序列化
登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
527
590
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
904
1.82 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
889
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.52 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.33 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
981
502
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384