SQLite parse_number():解析器重写后,C 击败 Rust
Simon Willison 的 SQLite parse_number() 实验最初显示 Rust 优于 C,直到一次针对性的解析器重写扭转了性能排名。这项于 2026 年 11 月 8 日发布的研究,对用于清理格式化数字的扩展进行了比较;扩展是为数据库引入函数的编译型附加组件。据报道,最终的 C 实现领先于每个测试数据集,同时保持了更小的编译文件。
这项任务听起来并不复杂:将包含货币符号、逗号或百分号的文本转换为适合数值排序和相加的数值。然而,这项比较为不同工程问题揭示了不同答案。Rust 提供了更快的首版小数解析器;经优化的 C 最终提供了实测最快的实现。
对于评估 AI 生成代码的开发者而言,压力落在审查流程上。看似合理的实现和熟悉的语言,几乎无法证明执行时间究竟花在何处。Willison 的研究在测量定位到特定转换例程、并促成更聚焦的实验后,才真正变得有价值。
SQLite parse_number() 在第二轮优化后改变结果
随着实现发生变化,报告中的胜者也随之改变,这使解析器设计成为比较的核心。
Willison 起初请求 Claude——其账户中标识为 Opus 5.5——用 C 和 Rust 探索这一函数。他要求的比较涵盖执行速度、代码复杂度和最终库文件的大小。他在自己的 SQLite 解析实验中介绍了这项研究。
首批结果将文件大小与速度区分开来。Willison 报告称,C 扩展约为 14 KB,而使用 rusqlite 的版本为 321 KB,使用 sqlite-loadable-rs 的版本为 298 KB。这些 Rust 库将 Rust 代码连接到 SQLite。尽管文件更小,初始 C 实现在混合输入和小数输入上仍然落后。
解释集中在 strtod() 上,这是一个将文本转换为浮点数的 C 库函数;浮点数是一种近似的数值表示。Willison 将 Rust 最初在小数处理上的优势归因于其更快的转换例程。随后,他要求 Claude 生成一个能够匹配或超过 Rust 实现的 C 解析器。
修订后的 C 版本在报告的比较中领先。对于混合数据,四舍五入后的净成本从初始 C 的每行 67 纳秒降至修订版 C 的 19 纳秒。Rust 版本分别记录为 46 纳秒和 41 纳秒。仅含小数的输入在 C 内部呈现了更大的变化:从每行 106 纳秒降至 18 纳秒。
这一过程赋予了这个故事重要性。若在首次比较后停止,就会支持为所测小数工作负载选择 Rust。继续进行针对性修订后,在不改变所需数据库函数的情况下,产生了不同的候选方案。
这项实验还区分了生成备选方案与评估它们。Claude 提供了实现,但它们的相对价值是通过周边测量显现出来的。最初的答案提供的是起点,而非已经确定的工程决策。
没有依据将这一结果视为编程语言的通用排名。这些是一个小函数的特定实现。它们的差异包括转换例程、临时存储和集成代码,所有这些都隐藏在宽泛的 C 与 Rust 对比标题之下。
实际变化更具体:开发者现在有了一个公开案例,其中较慢的首版 C 草稿变成了实测最快的候选方案。这使该案例有助于检视实现选择,同时并不支持更广泛的语言层面主张。
该基准测试衡量 SQLite 内部的重复解析
该工作负载让重复转换居于核心位置,因此结果需要附带这一背景来理解。
公开的基准测试工具创建了一个包含 200 万行的内存数据库。它执行一条查询,解析每个值并对结果求和。Python 启动查询,而 SQLite 执行重复的数据库工作。
这种安排很重要,因为它避免了在每次单独转换外部包裹 Python 循环。因此,该实验更直接地针对数据库函数调用与解析路径的组合。它主要并非测量 Python 应用反复跨越边界进入原生代码的性能。
该工具在 15 次运行中交替测试候选方案。它记录最短和中位耗时,然后根据最短值计算所宣传的净指标。在结果转换为每行纳秒之前,会减去一个对字符串长度求和的基线扫描。
这种减法有助于隔离增量工作量,但该指标仍是一种比较性估计。它不是导入、仪表板刷新,或涉及其他操作的查询的完整耗时。将其理解为面向用户的响应时间,会让测量超出其设计范围。
数据集同样塑造了结果。独立运行涵盖整数文本、小数文本和混合格式化数值。混合生成器包括带货币符号的数值、百分比、普通数字字符串和无效条目。这些变化使人们能够区分小数转换问题与一般解析问题。
设想一个反复对导入财务报告进行排序的应用。如果解析发生在每一次查询期间,转换工作便成为每次报告请求的一部分。如果应用改为在导入时规范化数值,同一个解析器将影响不同阶段,并面对不同的性能要求。
这两种架构都无法从这些测量中自动推导出来。该实验提供了解析组件的证据,而应用决定该组件运行的频率。当更快的独立函数在实际工作负载中占据有意义的比例时,它才最为重要。
基准测试中的纯 SQL 备选方案使用嵌套的 replace() 调用,即重复的字符串替换,然后进行数值转换。它的存在为开发者提供了一个熟悉的比较点。不过,替换字符与执行完整输入策略是不同工作,因此等效行为需要单独检验。
因此,压力落在使用简短基准摘要为实现选择辩护的开发者身上。在采用报告中的排名之前,他们需要识别其应用中解析发生的位置。他们还需要确定自身输入是否类似于生成的数据集。
对于 SQLite parse_number(),该工具使这些问题可供检查。读者可以查看计时查询和数据生成器,而非仅依赖一个获胜数字。
C 通过结合验证与转换获得速度优势
经过优化的 C 实现在减少中间工作量的同时,仍为超出其快捷路径限制的输入保留了回退方案。
优化后的小数解析器在累积数值信息时检查字符。在扫描期间,它跟踪有效数字,并统计小数点后的数字位数。这让合适的输入无需重建为清理后的字符串。
其快捷路径采用 Clinger 快速路径,这是一种适用于中间值满足精确表示要求时的小数转换技术。该实现通过明确的数值条件限制这一路径。不满足这些条件的数值会继续走较慢的转换路线。
这种区分至关重要。若一个快捷路径只是接受舍入误差,它回答的将是另一个工程问题。这里,代码试图在保持预期数值结果的前提下,加速一组受限的输入。
该实现最多收集 19 位有效数字。它的小数快捷路径还要求累计值不大于 2^53,且小数位不超过 22 位。这些边界决定了何时有资格进行直接计算。
更广泛的设计解释了为何修订改变了比较结果。验证本就需要检查输入字符。在检查过程中累积有用的数值信息,避免了独立转换阶段原本会重复的一部分工作。
整数处理从同一方法中受益。当数值适合时,该函数可以直接返回累计整数。它不需要先构建临时清理字符串,再次转换该字符串。
回退路径仍包含大量工程工作。它重建经过验证的数值文本,在可能时使用本地缓冲区,并在必要时分配额外存储空间。这里的分配是指为操作请求临时内存,这会引入算术本身之外的处理。
该代码还会在启用快捷路径前检查编译器的浮点求值行为。其保护措施针对中间计算可能采用不同精度的情况。这一细节表明,即使是很小的算术优化,也可能带来超出表面代码行数的责任。
Rust 仍是有意义的比较对象,因为其候选方案采用了不同的工作组织方式。这项研究并未证明同样高度专门化的 Rust 重写会保持相同的计时结果。将优化后的 C 与这些 Rust 版本比较,只能为这些候选方案确立结果。
合理的解读是,专门化为所测试的工作负载带来了回报。C 重写结合了原先分离的任务,并对符合条件的数值避开了通用转换例程。这一解读遵循实现中可见的机制。
这也解释了为什么语言声誉只能提供不完整的预测。通用例程为广泛输入空间提供行为;专门化代码则可以针对更窄的契约。性能部分取决于所选路径针对实际到达的输入执行了多少工作。
维护问题随之而来。谁负责更快的实现,谁也就负责其适用条件、回退行为和数值假设。只有团队能够在后续变更中保持这些属性,实测优势才有价值。
扩展大小与代码所有权指向不同方向
更小的编译产物与更简单的维护路径是独立标准,任何一项都不能替代另一项。
项目的实现比较报告称,去除符号信息后的 C 库为 14,408 字节。相应的 Rust 产物使用 rusqlite 时为 320,864 字节,使用 sqlite-loadable 时为 298,464 字节。去除符号信息会从编译文件中移除某些构建信息。
同一份报告统计,直接的 C 实现有 75 行非空且非注释代码,优化后的 C 有 104 行。Rust 使用 36 或 30 行集成代码,外加一个共用的 41 行解析器。报告还显示,rusqlite 版本有 20 个依赖项,sqlite-loadable 版本有 62 个。
这些测量描述的是不同形式的复杂性。源代码长度反映可见的实现体量;依赖项数量反映构建图的一部分。二者都无法直接衡量未来某个 bug 的诊断难度。
一个简短函数可能依赖于分布在多个库中的行为。一个较长的自包含函数则能将责任集中在应用团队必须审查的代码中。两者之间的取舍,取决于团队希望将这份责任置于何处。
C 实现将多项责任显式呈现出来。它的缓冲区处理、整数范围检查和十进制快捷路径都出现在项目自身的源码中。审查者可以直接检查它们,但可见性并不能消除对专业能力的需求。
Rust 的语言特性和解析库可以减少部分手动处理。对于已经使用 Rust 的团队,这可能提升可读性。不过,即使是紧凑的集成层,仍需审查输入策略、数据库行为和错误处理。
编译产物大小带来了另一个问题。在严格受限的发行包中,增加一个库的意义,与已经携带大量资源的应用并不相同。报告中的差异可以测量;其实用重要性取决于交付环境。
文件大小同样不能代表运行时内存消耗。库在磁盘上占用的字节数,并不能说明处理数据期间产生的每一次分配。若将最小文件视为内存占用最低的实现,则需要这项比较未提供的测量数据。
对依赖数量也应保持同样谨慎。更多依赖可能带来更多构建和更新方面的考量,但仅凭数量无法确定缺陷率。更少的依赖同样不能证明由应用自行维护的代码更易维护。
对于已有的 C 应用,紧凑的 C 扩展可能符合既有的编译和审查惯例。对于已有的 Rust 应用,引入独立的 C 组件则可能增加额外的维护归属工作。这些属于部署层面的判断,而非基准测试所确立的结论。
因此,SQLite parse_number() 涉及一项具有多个独立维度的决策。报告中的优化 C 候选方案在实测解析性能和编译产物大小上领先。团队仍需决定,维护其专门化的转换路径是否符合自身技能与发布流程。
有价值的比较会让这些维度保持清晰。将它们压缩为唯一的胜者,会掩盖不同团队为何能够合理地作出不同选择。
通过测试并不能解决数据契约问题
正确性还包括对格式化文本赋予的含义,而不仅仅是实现之间是否得出相同的数值答案。
比较报告称,所有版本均通过了 40 个明确测试用例和 800 万次随机比较。随机测试通常被称为模糊测试,会生成输入以寻找意外行为。这些报告结果提供了一定证据,但尚未为本文独立复现。
实现之间的一致性很有价值,尤其是在算术重写之后。它让测试输入空间内出现明显分歧的可能性更低。不过,共同的一致性无法决定所选行为是否符合特定应用的业务规则。
百分比说明了这种区别。移除百分号会保留显示出来的数字,而将百分比解释为比率则会改变其尺度。应用必须先决定自己需要哪种含义,才能将任一行为视为正确。
货币标记带来了相关问题。移除符号会让文本更容易进行数值处理,但并不能确定这些数值使用相同的单位。合并不同货币的金额,需要字符移除本身无法提供的信息。
格式惯例也值得关注。逗号在一种惯例中可能表示数字分组,在另一种惯例中则可能表示小数分隔。围绕固定惯例设计的解析器,需要让这一预期与其接收的数据一同传递。
这些是采用时的假设性考量,而非 Willison 实现中已被证实的缺陷。它们解释了为何一个解析器即使完全按设计运行,仍可能不适合陌生的数据集。正确性始于所选择的契约。
数值精度增添了另一条边界。SQLite 的浮点数文档说明,其 REAL 值采用 binary-64 表示法,并且是近似值。无论由哪种语言执行转换,许多十进制小数都无法在该格式中精确表示。
正确舍入的解析器会选择合适的可表示值。它并不会让后续所有算术运算变得精确。需要精确十进制计算的应用,必须单独评估其表示和算术需求。
失败处理也值得同样关注。对于无效文本,返回 NULL——SQLite 用于表示缺失或未知值的标记——可以让处理继续进行。应用仍应保留一种方式,以区分被拒绝的数据与有效的数值输入。
否则,看似整洁的结果可能掩盖数据质量问题。查看总计的用户需要知道,每条预期记录是否都作出了有意义的贡献。即使解析器遵循了自身契约,这一关注点仍属于外围应用。
基准测试本身也引入了另一种不确定性。基线扣除后的最小耗时描述的是有利条件下测得的执行结果。它并不能证明在生产环境争用、不同数据集或不同计算环境下的典型性能。
共享测试结果同样无法导出普适的性能结论。包含更多异常长输入的工作负载,可能会更频繁地触发回退路径。相对于经过测试的常见格式化值,这将改变工作量的平衡。
因此,对于 SQLite parse_number(),审慎的解读具有建设性。这些结果支持考察某个具体实现并复现其行为。它们并未消除定义可接受格式、保留被拒绝记录,以及在目标宿主应用中验证结果的必要性。
关注复现、Rust 修订版与回退行为
下一步有价值的证据,应在保持行为和测量方法明确的前提下扩展这项比较。
第一个值得关注的信号是独立复现。在另一套系统上运行,能够检验报告中的排序在硬件和构建环境变化后能否保持。报告完整查询耗时和推导出的解析成本,将使这些证据更容易解读。
结果一致将增强人们对优化 C 优势可延伸至原始设置之外的信心。不同的排序则会收窄结论,并指出另一个值得研究的因素。只要记录了环境和输入分布,无论哪种结果都会改进这项比较。
复现还应保留数值结果与耗时之间的区别。改变可接受输入的快速候选方案,已经不再回答同一个问题。将正确性结果与性能一同公布,能让这一边界保持清晰。
第二个信号是修订后的 Rust 实现,它针对比较中可见的具体工作进行改进。在扫描和转换策略上更接近,有助于将实现结构与语言选择区分开来。相关问题在于:可比的专门化能否缩小实测差距。
如果修订后的 Rust 在保留集成优势的同时缩小差异,那么初步结果将更有力地支持:在选择语言之前,应先审视算法。如果优化 C 仍然领先,开发者就能为这一特定组件获得更坚实的证据。
这仍是一项拟议中的比较,而不是已公布的版本或承诺的结果。没有理由假设更新版本会按固定时间表出现。它的价值在于让竞争实现能够更直接地比较。
第三个信号是关于回退行为的证据。优化 C 的快捷路径能够快速处理符合条件的输入,但更广泛的输入分布可能改变较慢路径的触发频率。测试这些情况能进一步揭示该实现实际运行范围的情况。
这项工作应像考察速度一样严密地审查正确性。长数值字符串、格式错误的记录和不同宿主设置,可能暴露常见格式化值无法显现的假设。一份有记录的结果,会比又一个孤立的标题级耗时更能增强信心。
对于工程团队而言,保留周边证据与保留源码同样重要。构建设置、输入示例和被排除的方案,解释了为何选择某个特定实现。将它们保存在可搜索的知识库中,可让未来的审查者重新审视这一决策。
这项调查的即时价值已经明确:一个测得的瓶颈促成了特定修订,并改变了结果。尚未确定的是,这一结果能在多大范围内成立。后续比较应扩展证据,而不应将一项聚焦的实验变成普遍的语言结论。
在采用 SQLite parse_number() 之前,先确定应用实际面临的决策。保留具有代表性的输入,定义百分比和无效记录应如何处理,并在将运行它们的宿主环境中比较候选方案。将完整查询耗时与任何推导出的解析数据一并保留,让有利的组件结果始终与用户体验相联系。以审查回退路径同样的谨慎程度审查专门化数值路径,并保留复现两者所需的构建细节。下一步应测试该实现的优势能否经受住你的需求考验。在你的应用中,决定选择的是哪项约束:重复转换耗时、发行包大小,还是维护解析器所需的投入?



