ast-grep 借助 AI 用 Rust 重写 Tree-sitter:解析器提速 30%,端到端运行速度提升 22%

发布时间:2026/7/27 9:31:52

ast-grep 借助 AI 用 Rust 重写 Tree-sitter:解析器提速 30%,端到端运行速度提升 22% ast-grep 与 Tree-sitter 简介ast-grep 借助 AI 编写代码用 Rust 重写了 Tree-sitter 的 C 核心。新核心在解析速度、读取完整语法树的速度以及 ast-grep 自身的运行速度上都有所提升。标题中的 30% 仅指解析器的提速从端到端来看ast-grep 的运行速度大约提升了 22%。源代码仓库为 [HerringtonDarkholme/tree-sitter](https://github.com/HerringtonDarkholme/tree-sitter)。ast-grep 是一款结构化代码搜索工具通过语法而非文本进行代码搜索处理的每个文件都必须先转换为语法树。[Tree-sitter](https://tree-sitter.github.io/) 是一个用于构建语法树的解析器框架只需提供语法定义它就能为该语言生成一个快速解析器诞生于编辑器领域如今已支撑起庞大的语法和工具生态系统。性能和内存峰值对比吞吐量经过归一化处理未修改的 C 版本构建C / normal评分为 100数值越高越好。RSS 是驻留内存峰值原始解析行将其显示为范围因为它在基准测试的语言样本中有所不同。大纲行是 ast-grep 的实际工作负载解析仓库中的每个文件然后遍历每个完成的语法树以提取结构化大纲。基准测试C / normalRust差异原始解析吞吐量100RSS8.48 - 21.41 MiB吞吐量129.74RSS8.42 - 25.70 MiB吞吐量 29.74%RSS 上限 20.0%树遍历吞吐量100RSS20.38 MiB吞吐量110.16RSS22.20 MiB吞吐量 10.16%RSS 8.9%完整 ast-grep 大纲用户 CPU1.233 sRSS26.52 MiB用户 CPU0.960 sRSS34.43 MiB用户 CPU -22.2%RSS 29.8%在每个解析和遍历测试中Rust 版本都表现更优并且 ast-grep 生成的大纲完全相同。不过代价是内存使用增加在 ast-grep 运行时Rust 版本大约多使用 8 MiB 内存。在更大的 TypeScript 压力测试集即 TypeScript 编译器仓库的测试基线树这也是该项目的内存压力测试中内存峰值达到了 91.2 MiB。这一结果值得肯定而非需要担忧在项目早期同样的测试集内存峰值超过了 1 GiB。这个成果并非是对上游 Tree-sitter 的直接替代而是为分析完整文件快照的 AI 编码代理构建的一个更精简的运行时现有的生成语言和解析器仍然兼容移除了 WebAssembly 编译语言的原生加载和旧语法树的增量复用功能为了保持兼容性仍然需要使用许多原始指针和 unsafe 块。这样的边界设定在去除了目标工作负载不需要的编辑器特定机制的同时让语法生态系统对代理式编码仍然有用。为何重写 Tree-sitter每次对 ast-grep 进行深入的性能调查最终都会指向同一个问题Tree-sitter。ast-grep 可以优化规则以提高速度它可以减少工作量、缓存配置并避免处理无关语法。但每个文件仍需先转换为语法树而这正是 Tree-sitter 的工作。解析器既是基础也逐渐成为性能瓶颈。多年来一直梦想着重写或深度优化它但这个梦想往往在打开运行时代码后就破灭了。Tree-sitter 有成熟的 C 语言实现要考虑二进制兼容性、外部扫描器、错误恢复、增量解析、模糊语法、多种语言绑定还要确保不会破坏基于它构建的庞大语法生态系统。对个人而言这不是一个周末就能完成的项目而是一项艰巨的任务所以一直没有行动。后来AI 辅助重写的尝试随处可见比如 [Bun](https://bun.com/blog/bun-in-rust)、[pgrust](https://github.com/malisper/pgrust) 和 [Roc](https://rtfeldman.com/rust-to-zig)。这些尝试虽未证明重写 Tree-sitter 是明智之举也未缩小运行时规模或让解析器理论变得更易懂但它们表明现在个人进行这样的实验成本已经足够低有勇气提出这个看似不合理的问题并在这个十年内得到答案。于是让 ChatGPT 用 Rust 重写 Tree-sitter 的 C 核心。项目从优先考虑兼容性的翻译开始经过一次快速但难以阅读的优化尝试最终实现了更简单的运行时和真正的解析器性能提升。不过后来发现更快的解析器有时反而会让 ast-grep 变慢。接下来将讲述这段历程哪些尝试成功了哪些需要回退以及如何将解析器基准测试的胜利转化为应用程序的胜利。Tree-sitter 的解析架构Tree-sitter 接收源代码并生成语法树。每种受支持的语言都始于一个语法定义Tree-sitter 将其编译为生成的解析表和词法分析器代码。在本系列文章中生成语言生成语法或生成表指的就是这些产物。在运行时词法分析器将字符转换为 identifier、 和 number 等标记。然后解析器使用生成的表和栈来确定每个标记的含义。大多数情况下解析表会请求执行以下两种操作之一移位shift消耗一个标记并将其压入解析器栈归约reduce识别出几个语法片段构成一个更大的语法规则用一个父节点替换它们然后继续处理。如果每个解析表项都只有一个有效答案解析器就可以使用一个栈遵循一条路径进行解析这就是普通的 **LR** 情况。但编程语言的语法偶尔会出现真正的冲突在获得更多输入以确定哪种解释有效之前可能有多个操作都是有效的。因此Tree-sitter 使用 **广义 LR (GLR)** 算法它可以同时遵循多条路径同时在图结构栈中共享它们的共同历史。可以想象成一条路偶尔会分叉然后再合并。当语法存在歧义时这种图结构是必要的但当解析器为一条直路构建图结构时就显得有些多余了。另一个核心对象是 **子树**。移位操作得到的标记成为叶子节点归约操作将子节点合并为一个内部语法节点。这些值在解析过程中创建在栈历史中共享最终作为完整的语法树输出供 ast-grep 遍历最后被释放。如果只优化它们的创建过程而忽略其整个生命周期的其他部分后来会为此付出昂贵的代价。以上就是解析器理论的简要概述。第一步在 Rust 中保留 C 语言的行为首要目标并非追求代码优雅而是实现功能对等。重写的目标设定得很保守以现有测试作为行为参考仅仅有一个看似合理的 Rust 实现是不够的它必须生成相同的语法树、具备相同的错误恢复行为、导航结果和公共 API 效果测试整个生态系统而非仅依赖手写示例现有的生成语法和外部扫描器必须无需重新生成或修改源代码就能继续工作保留二进制接口 (ABI)在实现语言变更的同时生成的语言表、公共 C 函数、布局、符号和调用约定必须保持兼容先翻译再重新设计第一个 Rust 版本有意模仿 C 语言的控制流程这样在出现功能不对等的问题时排查范围会比较有限。简而言之就是保留生态系统可观察到的所有内容然后让内部实现变得可替换。让 ChatGPT 逐步翻译运行时代码包括基础工具、树存储、词法分析、解析器栈、树导航最后是解析器循环。AI 读取 C 语言和 Rust 代码编写补丁修复编译器错误运行测试并调查不匹配的问题。提出目标、约束条件、异议并做出决策现有的实现和测试套件则提供了验证标准。这一点很重要。并非亲自编写了一个出色的 Rust 移植版本然后让 AI 来优化注释。实际上实现、性能分析、调试以及大部分实验代码都是在指导下由 AI 完成的。没有 AI这个项目可能至今仍只是偶尔提及但又明智放弃的想法。C 语言核心代码成功转换为 Rust 代码代码能够编译测试通过现有的语法也能正常使用。这在项目最初搁置时看起来几乎是不可能实现的结果。自然地马上有了更高的要求。首次优化尝试为何失败只是在 /goal 命令中输入了一行简单的要求但背后的过程其实更为严谨指导 ChatGPT 使用合适的性能分析工具理解运行时的数据布局和所有权关系并寻找算法层面的改进而非仅仅优化个别指令。基准测试结果确实达到了预期但当查看代码时却发现难以理解。在机械的 C 到 Rust 翻译基础上叠加了多层 AI 生成的优化代码不久后解析器开始出现段错误没有友好的 Rust 错误提示也没有断言失败信息进程直接崩溃。一个速度提升了 20% 但偶尔会崩溃的解析器并非优化成果而是一个会带来惊吓的基准测试。完全回退了优化工作20% 的性能提升也随之消失。项目最终实现的性能提升来自于后续干净、分层的工作这将在 [第二部分](./tree-sitter-rust-migration) 详细介绍。这里关键的是这次尝试如何改变了项目方向不再让 ChatGPT 单纯地提升代码速度而是要求它让系统变得更易于理解。第二步缩小范围并提高代码可读性清理工作分为两部分删除目标产品不需要的功能和表示将保留的类 C 风格的 Rust 代码转换为可以局部推理其所有权和控制流的代码。这两步都不能保证在基准测试中取得显著成果但它们是后续工作值得信赖的前提。从目标运行时中移除增量解析功能最初重写 Tree-sitter 意味着保留所有功能。但目标工作负载让重新思考保留这些功能是为了谁上游的 Tree-sitter 在编辑器领域非常有用。用户插入一个字符、删除另外两个字符后期望在下一帧之前就能看到代码高亮更新。增量解析允许运行时复用旧的语法树只重建受影响的区域。在这种场景下每次按键后重新解析整个文件是不必要的。但这并非本分支的应用场景。ast-grep 和关注的 AI 编码代理工具处理的是完整的文件快照代理读取文件进行分析或重写然后让工具处理新的快照。这里没有编辑器维护的逐键更新的语法树全新解析并非降级方案而是正常操作。因此决定移除旧语法树的增量复用功能并让 ChatGPT 完成这项工作。为了保持兼容性公共参数仍然保留但这个运行时会进行全新解析。用于查找和复用旧语法树部分的机制这些机制涉及到许多核心结构从关键实现中移除了[第二部分](./tree-sitter-rust-migration) 会详细列出具体移除的内容。与此同时还进行了另一项范围缩减移除了 WebAssembly 编译语法的原生加载功能。Tree-sitter 可以将语法编译为 WebAssembly 并在运行时加载这一功能与浏览器的 Wasm 构建不同后者得以保留。本地工具设定了本项目的性能目标而运行时的 Wasm 语法加载并非该工作负载的一部分。这并非建议上游的 Tree-sitter 放弃增量解析功能而是针对按文件进行分析和代理工具的更精简运行时做出的产品决策。如果这个分支要重新用于交互式编辑器就需要重新考虑这个决定。原则是只在明确界定的范围内进行删除而不是因为某个功能不方便就删除。事实证明删除是第一种真正有效的优化方法去除那些已经不再适用的功能。为了可维护性重构保留的运行时逐行翻译的代码只有在读者熟悉原始代码的情况下才易于理解。让 ChatGPT 将这个庞大、指针密集的移植版本重构为更符合 Rust 习惯的内部代码同时避免让面向 ABI 的类型以可能破坏现有语法的方式 变得符合习惯。这次清理工作更像是一系列小的改进而非彻底的重新设计。只要内部原始指针参数的生命周期是局部且可证明的就将其转换为引用或切片表示 此处无节点 的哨兵指针被替换为 Option 类型。在 C ABI 没有强制要求的情况下将输出参数改为返回值大型模块按职责拆分使修改操作与受影响的状态相邻必须保留的复杂技巧如树中的紧凑索引、指针运算等被封装在简洁、命名清晰的操作背后。在需要保持兼容性的地方代码故意保持不美观生成语言的布局和导出函数仍然保持 C 语言的风格因为其他二进制文件已经依赖于这种格式。这次清理让问题变得可解答谁拥有树的这一部分当存储增长时这个引用能否继续存在为什么一次归约操作会创建一个临时解析器状态然后又立即删除它重要的成果不是更漂亮的语法而是一个组织良好的运行时使得段错误、不变性失败或可疑的内存分配问题都能在架构中有迹可循。GLR 和内存布局优化在能够理解运行时之后让 ChatGPT 再次聚焦于 **归约reduce** 操作也就是前面架构部分提到的操作这也是解析器经常执行的操作。这个小操作涉及到两个主要的数据结构。它从解析器的工作栈中移除子节点然后将它们存储在语法树的新父节点下。性能分析显示Tree-sitter 在处理这些子节点时做了很多超出常规情况所需的工作。最终成功的改进遵循了四个简单的原则避免为罕见情况做不必要的工作观察发现大约 99% 的解析器栈是单一路径因此在输入实际分叉之前解析器不再构建图结构。同时尽可能避免在全新解析时进行主要用于编辑的操作降低内存分配成本减小索引大小每次为内部语法节点向通用内存分配器请求内存是很昂贵的。使用内存池arena可以预先分配一个不断增长的内存块为多个节点提供内存。此外紧凑的索引可以减少解析器栈和语法树之间的数据移动量一次性完成重复工作解析器提前准备好常见的语法查找语法树读取器避免重复查找相同的子节点为简单情况提供捷径直接处理一种解析器操作普通 ASCII 输入避免完整的字符解码过程。当简单路径不适用时仍然可以使用完整的回退机制。普通解析保持单一路径真正的歧义切换到完整的图结构99% 这个数据描述了问题但没有给出解决方案。让 ChatGPT这次处于研究模式而非编码模式回顾关于广义解析器的学术研究结果让想起一个古老的经验在输入真正需要之前不要构建通用结构。内存池是一个独立的想法需要进行自己的实验。线性栈避免了图结构的管理开销内存池减少了对通用内存分配器的调用。减小索引大小也是一种布局选择但并非自动就能提高速度经过多次尝试才取得了效果。重要的结果是普通解析不再为罕见的歧义情况和每个内部语法节点的单独内存分配付出全部代价。有一段时间解析器的基准测试结果看起来非常出色。然后让 ChatGPT 基于这个版本构建 ast-grep。端到端性能发现AI 在一个真实的 TypeScript 仓库 opencode 上运行了这个二进制文件。此时仅解析器的基准测试显示这个 Rust 实现比 C 语言运行时快了大约 30%。但应用程序的运行速度却变慢了。解析器速度提高了 30%应用程序怎么会变慢呢解析器基准测试只复用了一个解析器而 ast-grep 为数千个文件创建了解析器然后遍历每个完成的语法树以提取大纲。基准测试只衡量了这个过程的一部分。最初每次创建解析器时内存池都会预留一个巨大的虚拟内存区域。虽然它不会立即占用所有物理内存这让这个设计看起来无害但在处理整个仓库时这种预留操作会进行数千次每次都需要付出实际的代价一轮新的预留和释放系统调用以及背后的页面错误和页表更新每个文件都要重复这些操作。在 opencode 测试集中正是这些开销而非解析操作导致了 CPU 性能下降。让 ChatGPT 将预留操作改为普通的小内存分配只有在需要时才进行扩展。解决了这个问题后又暴露出了另一个问题内存。在单独的 TypeScript 压力测试集中始终使用一个 ast-grep 工作进程进行测量内存池早期的增长策略导致旧的内存块一直被保留使得内存峰值达到了 1.04 GiB。要将内存峰值降低到最终的 91.2 MiB需要对内存池进行多次调整其中还包括一个意外情况一直让 ChatGPT 回收的内存并非实际浪费的内存。[第四部分](./tree-sitter-end-to-end) 会给出详细的跟踪信息和问题根源这里就不剧透了。完成的语法树还有一个意外情况。紧凑索引在构建语法树时很有用但 ast-grep 在读取时需要查找这些索引这导致部分解析时间转移到了树遍历阶段。ChatGPT 修改了语法树读取器让它一次性查找每组子节点而不是重复查找从而挽回了这部分开销。这些改进措施即使用普通内存分配代替每个解析器的虚拟内存预留操作以及让语法树读取器一次性解析每组子节点解决了性能下降的问题。再结合后续对解析器的进一步调优就得到了本文开头的数据与 C 语言版本相比大纲生成操作的用户 CPU 时间减少了 22.2%。端到端性能测试的失败并没有否定解析器的改进工作而是让重新定义了 成功 的含义。从那以后性能结果需要涵盖解析、内存使用、语法树读取以及应用程序的整个生命周期。这些成果并非依赖于某个神奇的补丁。有些改进节省了解析时间有些避免了内存灾难还有些在读取完成的语法树时挽回了时间。本文概述了连接各个实验的原则后续的详细文章将对它们进行拆解分析。AI 辅助重写的经验教训一开始AI 让一条指令就能推动大量代码的编写工作这使得重写成为可能但远远不足以保证重写的质量。早期的工作流程是这样的/goal 提高 20% 的性能- 生成大量看似合理的代码- 得到令人困惑的基准测试结果- 再生成一个看似合理的补丁后来变成了这样找出开销大的操作- 解释其原因- 修改一个机制- 与之前的 Rust 版本进行比较- 测试整个应用程序- 保留、修改或放弃该改进ChatGPT 并没有逐渐变得完美无缺而是逐渐深入了解了运行时能够给它提出更具体的问题挑战一些默认假设并在合适的层面要求提供证据。早期速度提升但出现段错误、内存池的内存爆炸以及应用程序变慢等问题背后都是看似合理的代码和有希望的局部结果。性能分析和测试必须发现双方都忽略的问题。到最后这种协作找到了合理的分工。ChatGPT 能够以手工无法企及的速度探索实现方案而工作是不断细化问题直到性能分析、不变量检查和端到端测试能够给出答案。速度让这次探索成为可能而证据则决定了哪些改进值得保留。这就是整个故事。后续文章将详细展开1. [用 Rust 重写 Tree-sitter 的 C 核心迁移与兼容性](./tree-sitter-rust-migration) 介绍了迁移过程、/goal 操作及其回退、本分支删除的内容以及兼容性边界是如何得以保留的。2. [改进 Tree-sitter 的 GLR 算法和内存布局](./tree-sitter-glr-arena) 解释了为什么解析器要为几乎总是线性的工作负载构建图结构以及 使用内存池 背后的诸多决策。3. [为端到端 ast-grep 性能优化 Tree-sitter](./tree-sitter-end-to-end) 包含内存跟踪信息、树遍历调查、基准测试规则以及根据应用程序性能分析进行的进一步解析器优化查找索引、单动作调度。简而言之AI 让有机会移动了一堵承重墙而项目的后续工作就是通过一次次基准测试发现这堵墙原本支撑着的其他一切。

相关新闻