Rust重写Tree-sitter核心的血泪真相:代码解析速度飙升30%


代码检查工具跑得比C还快?这不科学。

ast-grep团队用AI将Tree-sitter的C语言核心重写为Rust,解析速度提升30%,内存占用却一度飙升至1GB。通过削减增量解析、重构内存布局,最终在真实仓库扫描中实现22%的CPU时间缩减。这场重写涉及GLR算法、竞技场分配器、树遍历优化,以及AI辅助编程的边界探索。

重写起点:C语言核心的天花板

代码搜索工具ast-grep依赖语法树工作。每个待处理的源代码文件必须先被转换成树形结构。Tree-sitter正是构建这棵树的解析框架。开发者提供语法定义,它生成对应语言的快速解析器。

性能调查总是指向同一个瓶颈。规则引擎可以优化,缓存可以改进,无关语法节点可以跳过。但每个文件仍然需要先变成语法树。Tree-sitter构建这棵树。解析器既是地基,也逐渐变成天花板。

重写C语言核心的念头存在多年。每次打开运行时源码,梦想就破灭一次。成熟的C实现摆在那里。二进制兼容性、外部扫描器、错误恢复、增量解析、二义性文法、多种语言绑定,还有构建在这些依赖之上的庞大语法库生态。一个人面对这些,不是周末项目,是戴着头文件表演的十二项不可能任务。

于是什么都没发生。

转移尝试:AI辅助的可行性

AI辅助重写项目开始四处涌现。它们没有证明重写Tree-sitter是明智之举,没有让运行时变得更小,也没有让解析理论变得不那么陌生。它们只证明一件事:实验成本已经低到一个人可以尝试。

指令是让ChatGPT把Tree-sitter的C核心翻译成Rust。项目从兼容性优先的翻译开始,经过一次快速但难以阅读的优化尝试,最终走向更简单的运行时和真正的解析性能提升。然后发现一个更快的解析器仍可能拖慢ast-grep。

解析架构:GLR与子树

Tree-sitter接收源代码,产生语法树。每个支持的语言始于语法定义。Tree-sitter将其编译成解析表和词法代码。运行时,词法分析器把字符转换成标识符、加号、数字等记号。解析器使用生成的表和栈来决定每个记号的含义。

大多数情况下,表请求两种操作之一。移进操作消耗记号并压入解析栈。归约操作识别出几个语法片段组成更大的语法规则,用父节点替换它们并继续。

如果每个表项只有一个有效答案,解析器可以用一个栈跟踪一条历史路径。这是普通LR情况。编程语言文法偶尔存在真正冲突。多个操作可能都有效,直到更多输入揭示哪个解释存活下来。

Tree-sitter使用广义LR解析。它可以同时跟踪多条历史路径,在图形结构栈中共享共同历史。可以想象一条路短暂分叉再合并。文法有二义性时图形结构是必要的。道路笔直时解析器构建图形机制就不那么讨喜了。

另一个核心对象是子树。移进的记号变成叶子。归约将子节点组合成内部语法节点。这些值在解析过程中创建,跨栈历史共享,发布为最终树,被ast-grep遍历,最终释放。只优化它们的诞生而忽略生命周期的其余部分,后来会产出一堂昂贵的课。

第一步:在Rust中保留C行为

首要目标不是优雅,是对等。

重写约定刻意保守。用现有测试作为行为预言机。一个看似合理的Rust实现不够,必须产生相同树、恢复行为、导航结果和公共API效果。测试生态而非只有手写示例。现有生成文法与外部扫描器必须继续工作,无需重新生成或修改源码。保持二进制接口兼容。生成语言表、公共C函数、布局、符号和调用约定保持不变,而背后的实现更换了语言。先翻译再重新设计。第一个Rust版本有意模仿C控制流,这样对等失败时有界搜索区域。

一句话概括:保留生态能观察到的一切,然后让内部可替换。

指令是让ChatGPT逐部分翻译运行时。基础工具、树存储、词法分析、解析栈、树导航、最后是解析主循环。代理读取C和Rust代码,写补丁,修复编译器错误,运行测试,调查不匹配。我提供目标、约束、反对意见和决策。现有实现和测试套件提供答案。

这种分工很重要。不是我亲自敲完英雄般的Rust移植然后让AI润色注释。实现、性能分析、工具测量和大量实验代码都是在我的指导下由代理生成的。没有AI,这个项目至今仍是那个偶尔提起然后明智地转移话题的想法。

C核心变成了Rust。能编译,通过测试,现有文法可以使用。这在项目束之高阁时曾看似不可能。

自然,我立即要求更多。

第一次优化失败:速度与崩溃

指令是纯粹的氛围编程。一行字输入/goal命令。背后的过程更谨慎。让ChatGPT使用性能分析工具,理解运行时数据布局和所有权,寻找算法层面改变而非仅仅打磨单条指令。基准测试确实跨过了要求线。然后打开代码,跟不上了。层层交叠的AI生成优化堆在机械的C到Rust翻译之上,解析器开始段错误。不是友好的Rust panic,不是断言失败,进程直接消失。一个快百分之二十偶尔消失的解析器不是优化,是带惊吓的基准测试。

完全回退了优化工作。那百分之二十也随之消失。项目最终达到的性能来自后来干净的分层工作。重要的是这扭转了项目方向。不再让ChatGPT把代码堆更快,而是让它把系统变得可解释。

第二步:缩减范围提升可读性

清理分两部分。删除目标产品之外的特性和表示。把保留的C风格Rust改成本地可推理所有权和控制流的代码。这两步都不承诺英雄般的基准测试。它们是信任下一步的前提。

删除增量解析

起初重写Tree-sitter意味着保留每个特性。然后目标负载迫使提出更好的问题:为谁保留?

上游Tree-sitter在编辑器内非常有用。人类插入一个字符,删除两个,期望高亮在下帧之前更新。增量解析让运行时重用旧树,只重建受影响区域。在那个世界里,每次按键都重新解析完整文件是不必要的工作。

这不是这个分支的世界。ast-grep和关心的AI编码工具处理完整文件快照。代理读取文件,分析或重写,然后让工具处理新快照。没有编辑器拥有的语法树逐个按键前进。新鲜解析不是降级后备,是正常操作。

决定删除增量旧树重用。公共参数保留用于兼容,但此运行时始终新鲜解析。查找和重用旧树碎片的机制触及大量核心结构,从热实现中消失了。

另一次独立范围裁剪同步进行:原生加载Wasm编译文法。Tree-sitter可以把文法编译成WebAssembly并在运行时加载。这个能力区别于浏览器Wasm构建,后者保留。原生工具设定此项目性能目标,运行时Wasm文法加载不属于那个负载。

这不是建议上游Tree-sitter放弃增量解析。是为面向文件分析及代理工具的更窄运行时做的产品决策。如果此分支回归交互式编辑器使用,决策必须重新审视。规则是只在明确边界后删除,永不因为特性碰巧不方便就删。

删除被证明是第一个真正的优化技术:移除使用场景已经离开大楼的工作。

重构保留的运行时

逐行翻译只有读者已经逐行知道原文时才可读。指令是让ChatGPT把单体式、指针密集的移植拆分成更地道的内部Rust,同时不让ABI面向的类型地道到破坏现有文法。

清理与其说是重新设计,不如说是一长串小规模提升。内部裸指针参数在生命周期局部可证明处变成引用或切片。表示此处无节点的哨兵指针变成诚实的Option。C ABI不要求的输出参数变成返回值。大型模块按职责拆分,让修改紧挨着它改变的状态。必须保留的密集技巧(树内的紧凑索引、指针运算)隐藏在窄的命名操作后面。兼容性真实存在的地方,代码刻意保持丑陋。生成语言布局和导出函数保持C形状,因为另一个二进制已经承诺了那个形状。

那次清理让问题变得可回答:谁拥有树的这块?存储增长时这个引用能存活吗?为什么一次归约创建临时解析状态又立即删除?

重要的输出不是更漂亮的语法。是组织得足够好的运行时,让段错误、不变量失败或可疑分配在架构中有地址。

GLR与内存布局优化

能理解运行时后,让ChatGPT回到归约。架构小节提到过的归约操作,解析器不断执行。

那个小操作触及两个主要数据结构。从解析器工作栈移除子节点,然后存入语法树中新父节点下。性能分析显示Tree-sitter围绕那些子节点做了比普通情况多得多的工作。

成功的改动最终归结为四个简单原则。避免为不常见情况工作。约百分之九十九的观测解析栈是一条直路,所以输入实际分叉前解析器不再构建图形。主要为编辑所需的工作也尽可能排除在新鲜解析之外。让分配廉价,索引小。为每个内部语法节点请求通用分配器昂贵。竞技场获取一个增长块,服务其中许多节点。另外,紧凑索引减少解析栈和树之间移动的字节数。重复工作只做一次。解析器提前准备常见文法查找,树读取器避免重复查找相同子节点。给最简单情况一条短路径。一种解析动作直接处理,普通ASCII输入避免完整字符解码路径。完整后备在简单路径不适用时始终可用。

普通解析保持一条栈路径。真正的二义性切换到完整图形结构。百分之九十九数字描述了问题,但没有描述解决方案。让ChatGPT以研究模式而非编码模式回顾广义解析器的学术工作,结果指向一个更早的教训:输入实际需要前不构建通用结构。竞技场是独立想法,需要自己的实验。线性栈避免图形簿记。竞技场减少对通用分配器的调用。使索引更小是另一个布局选择,不会自动变快。几个版本失败后这些碎片才物有所值。

重要结果:普通解析不再为罕见二义性和每个内部语法节点的单独分配支付全价。

那一刻解析器基准看起来极好。

然后让ChatGPT把ast-grep构建在上面。

端到端性能发现

代理在真实TypeScript仓库上运行二进制。此时纯解析器基准让此Rust实现比C运行时快大约百分之三十。

应用却更慢了。

解析器怎么快百分之三十而应用变慢?

解析器基准重用了一个解析器。ast-grep为数千个文件创建解析器,然后遍历每个完成树提取大纲。基准只测量了旅程中间部分。

第一个竞技场每次创建解析器时预留巨大虚拟内存区域。它不立即消耗所有物理内存,这让设计看起来无害。但在仓库中,预留发生数千次,每次都有实际代价:一轮新的保留释放系统调用,加上背后的页错误和页表搅动,每个文件重复。那个搅动,不是解析,才是opencode语料上CPU回归的原因。让ChatGPT把预留替换成普通小分配,只在需要时增长。

那移除一个问题,暴露了另一个:内存。在单独TypeScript压力语料上,用一个ast-grep工作线程测量整个过程,竞技场早期增长策略让旧块存活,峰值内存推至1.04 GiB。从那里到最终91.2 MiB经历了好几轮竞技场手术。包括一个转折,不断告诉ChatGPT回收的内存结果不是真正被浪费的内存。完整踪迹和罪魁祸首在第四部分,这里不剧透。

完成树还有一个惊喜。紧凑索引在构建时帮助,但ast-grep读取时必须查找它们。部分解析时间简单转移到树遍历。ChatGPT修改树读取器使每组子节点只查找一次而非重复,收回了那部分成本。

那些修复——普通分配取代每个解析器的虚拟内存仪式,树读取器解析每组子节点一次——关闭了回归,加上后面一轮解析器端调优,成为这篇帖子开头的数字:大纲运行比C构建少百分之二十二点二用户CPU。端到端失败没有否定解析器工作。它否定了工作的旧含义。从那时起,性能结果需要覆盖解析、内存、读取树和完整应用生命周期。

这些结果背后没有单一魔法补丁。一些改动节省解析时间,一些防止内存灾难,另一些在读取完成树时收回时间。这篇概述保留了连接各个实验的原则。详细帖子把它们拆开。

AI辅助重写的教训

开头AI增加了单条指令能启动的代码量。这足以让重写成为可能,远不足以让它变好。

早期循环是这样。目标提高性能百分之二十。大量看似合理的代码。令人困惑的基准测试。又一个看似合理的补丁。

后来变成这样。找到昂贵的工作。解释为什么发生。改变一个机制。与之前Rust修订版比较。测试完整应用。保留、修订或拒绝。

ChatGPT没有逐渐变得不会出错。我逐渐学到足够多运行时知识,能给它更窄的问题,挑战捆绑假设,在正确边界要求证据。段错误早期提速、竞技场内存爆炸、变慢的应用都出现在看起来合理的代码和令人鼓舞的局部结果后面。性能分析和测试必须抓住我们俩都漏掉的东西。

到最后,协作找到了恰当分工。ChatGPT能以前所未有速度探索实现空间。我的工作是不断收窄问题直到性能分析、不变式和端到端控制能回答它。速度让探险成为可能。证据决定哪些部分返回。


作者单位背景 ast-grep核心开发团队