Godot 引擎的浮点数转十进制字符串库:thirdparty/grisu2(Grisu2 算法)实现详解
本文以 thirdparty/grisu2/README.md 为纲,讲解 Godot 引擎引入的 Grisu2 浮点数二进制→十进制转换算法:它的算法出处、从 simdjson 移植到 Godot 时的四处关键改动、grisu2.h 单头文件的内部实现(DIY 浮点、缓存 10 的幂、数字生成与舍入、格式化),以及它在 String::num_scientific、JSON 序列化、Variant 持久化等处的实际调用链。读完本文,你将掌握该库的接口用法、输出格式规则(类似 printf("%g"))、最短往返(round-trip)表示的原理,以及在引擎源码中定位相关行为的具体位置。
一、Grisu2 解决什么问题
把 double 或 float 转成十进制字符串看起来简单,实则有两个难以同时满足的要求:
- 可读性/简洁性:输出尽量短的十进制数字串,例如把
3.5f打印成3.5而不是3.5000000238418579; - 精确往返:打印出来的字符串再次解析回同一精度浮点数时,必须得到原值(
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),改动共有四处:
- 命名空间简化:去掉
simdjson::internal::dtoa_impl的多层嵌套,统一收敛为单一grisu2命名空间(见 grisu2.h 第 8 行namespace grisu2 {)。同时把上游的 include guard +#include <base.h>换成了#pragma once与标准库头<cstring>、<cstdint>、<array>、<cmath>,使该文件可以独立编译。 - 函数重名:为避免与 Godot 全局符号冲突,把内部函数改名,例如核心算法
grisu2改名为grisu2_core,接受浮点入参的模板版本改名为grisu2_wrap(见 grisu2.h)。 - 同时支持 float 和 double:上游
to_chars只接受double;Godot 版将其改为模板函数template <typename FloatType> char *to_chars(char *first, FloatType value)(见 grisu2.h),从而float按单精度边界(24 位尾数)计算,double按双精度边界(53 位尾数)计算。 - 移除尾随
.0逻辑:上游为了让输出"看起来像浮点数",会在整数值后面补.0(例如3.0输出3.0、0.0输出0.0)。Godot 的补丁删掉了这两处(补丁文件 中format_buffer与to_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 是模板参数,float 走 digits = 24、double 走 digits = 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_core 与 grisu2_wrap
模板入口 grisu2_wrap 先按 FloatType 精度计算边界,再交给 grisu2_core:
- 取
c ≈ 10^-k,把v、m-、m+同时缩放为w、w-、w+(64 位整数乘法,含舍入误差); - 由于缩放本身会引入小于 1 ulp 的误差,再向外各扩 1 个 ulp 得到更宽的安全区间
[M-, M+](w_minus.f + 1、w_plus.f - 1,见 源码); - 调用
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 / pow10、p1 % pow10从左到右提取数字; - 分数部分:把
p2反复乘以 10、右移-e位提取数字(相当于长除法),不写小数点、而是累减decimal_exponent; - 每生成一位就检查
rest <= delta(剩余量不超过区间宽度delta = M+ - M-),一旦V = buffer * 10^n落入[M-, M+]立即停止。
停止后再调用 grisu2_round 做微调:若把末尾数字减 1 能让 V 更接近中心点 w,就减("距 w 的 dist"与"距 M- 的距离"比较)。这一步使输出在多位候选中偏向最接近原值的那个。
源码注释同时给出输出位数上界(注释,引自 Loitsch 定理 6.2 与 Matula 的 In-and-Out conversions 结果):
- double(p = 53):最多 17 位十进制数字;
- float(p = 24):最多 9 位。
3.6 格式化:format_buffer 与 to_chars
format_buffer 根据 n = len + decimal_exponent(小数点相对数字串的位置)选择四种排布,整体风格对齐 printf("%g"):
k <= n && n <= max_exp→ 纯整数(不足补零);0 < n <= max_exp→dig.its;min_exp < n <= 0→0.[000]digits;- 其余 →
d.igitsE±nn(指数最少两位,兼容%g习惯,见 append_exponent)。
公共入口 to_chars 固定使用 kMinExp = -4、kMaxExp = 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 中声明了两个重载。
顺着调用链可以看到它支撑了引擎中几类"需要无损往返"的序列化场景:
- Variant 持久化解析:core/variant/variant_parser.cpp 中专门注释说明"这两个函数使用
num_scientific序列化 float/double",保证.tscn/.tres等文本资源里写下的数字重新加载后位级一致; - GDScript 的
JSON.stringify全精度模式:core/io/json.cpp 在full_precision为真时调用num_scientific,并在不含小数点/e时补回.0,维持 JSON 数字的浮点形态; - 文档数据序列化:core/doc_data.cpp 把属性默认值中的 float 用
num_scientific写入,避免文档中出现多余尾数; - 脚本 API:core/variant/variant_call.cpp 把
String.num_scientific绑定为 GDScript 可调用静态方法; - 内部工具函数:rtoss(
rtos的"科学计数法"变体)直接转发到num_scientific。
对比来看,普通打印路径 String::num(p_num, decimals) 走的是"定点 + 指定小数位"路线(double 默认 14 位、float 默认 6 位,见 num_real 实现),而 num_scientific 提供的是位数最少且往返无损的表示——二者分工明确:前者面向人类阅读,后者面向需要精确还原的机器读写场景。
五、使用要点与工程细节小结
- 文件构成:thirdparty/grisu2/ 目录只有四个文件——README.md、单头实现 grisu2.h、LICENSE(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 默认值序列化 |
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00