Post

逛 GitHub 逛到的一个项目:一个人 4 个月、88 万行、一个 AI 造出来的编译器

一个叫 Perry 的项目:把 TypeScript 直接 AOT 编成原生二进制,性能贴着 Rust / C++。但比技术更让我吃惊的,是这 88 万行代码——一个人借助 AI,在四个半月里写了出来。从 NaN-Boxing、隐藏类到自研 GC,再到一点关于「AI 正在改写软件开发方式」的感想。

我有个习惯:平时爱逛 GitHub,刷到觉得有意思的项目就顺手 star 一下。可说来惭愧,收藏夹里那一长串,绝大多数从此就在吃灰——star 的那一刻,往往也是我和它缘分的终点。毕竟兴趣再广泛,想真正读懂一个几十万行的项目,对一个精力有限的人来说,成本实在太高。

但 AI Coding 把这件事改写了。现在我终于能用有限的精力,快速摸清一个项目的基本原理了——不必从头啃到尾,让 AI 帮我定位、串讲、验证,一两个小时就能把一个陌生代码库的脉络看个七七八八。

最近这么扒的,是一个叫 Perry 的项目。

它的卖点一句话能说清,但足够唬人:把 TypeScript 直接编译成原生可执行文件——不要 Node,不要 Electron,不要浏览器引擎,产物是一个几 MB 的独立二进制,拷到别的机器上就能跑,还跟原生语言的代码一样快(作者自己的评测)。

我做服务端性能优化出身,对语言编译和性能优化这些事天然好奇,于是老规矩——用 Claude Code 把它的源码扒了一遍。扒完发现,比它怎么工作的更值得写的,是它是怎么被写出来的。这事留到最后,先说它是什么。

先理清一件事:JS 和 TS 是什么关系

Perry 项目的整个优化都建立在 JavaScript 和 TypeScript 的关系上,所以得先把这层说清楚。

JavaScript(JS) 是跑在浏览器和 Node 里的动态脚本语言:变量不声明类型,运行时才知道一个值到底是数字还是字符串。

它的身世本身就是个传奇。1995 年,网景的 Brendan Eich 花了 10 天赶出第一版——本来只想给网页加点小交互的”玩具脚本”。谁也没料到,随着互联网的蓬勃发展,这门仓促诞生的语言会一路长成互联网上的通用编程语言:浏览器里是它,服务端(Node)是它,连桌面和移动端也是它。程序员圈里有句半开玩笑的 “Atwood 定律”(Jeff Atwood,2007):凡是能用 JavaScript 写的,最终都会用 JavaScript 写。可以说,这门十天赶出来的语言,硬是撑起了互联网的半壁江山。

但”十天赶出来的玩具”先天有代价。JS 是纯动态 + 弱类型:变量类型随时可变,"1" + 1 得到 "11"[] == ![] 居然为真,这种坑遍地都是。写几十行小脚本时这份”灵活”很爽;可一旦工程涨到几十万行、几十号人协作,它就成了灾难——改个函数签名没有编译器拦你,类型错误全得等线上跑崩了才发现。动态语言的自由,到了大项目上成了负债。马上崩溃还好,要是崩溃在冷路径上,那可就是地雷了。

JS 程序员需要救星。于是有人又出手了。这个救星就是 Anders Hejlsberg

他在 2012 年拿出了 TypeScript(TS)——在 JS 之上加一层静态类型。你写 let n: number,编译器在你敲代码时就能帮你查错,把那些本来要等运行时才爆的问题,提前暴露在编译期。它不取代 JS,而是给 JS 装上类型护栏。

说他”又出手”,是因为发明新语言这事他不是头一回干了。这位编程语言界的常青树,从 Turbo Pascal、Delphi,到 C# 和整个 .NET,再到今天的 TypeScript,几乎每隔十年就重新定义一代开发者手里的工具。我自己就是用着 Borland 全家桶成长起来的那拨人——从 Turbo C 2.0、Delphi、C++ Builder、一直到 C#,他从未远离我。

但关键在于:TS 本身不能直接运行。 平时它的使用流程是这样的:

1
TS 源码 → tsc 编译器把类型“擦掉” → 转换为普通 JS → 交给 V8 引擎运行

注意中间那一步——类型在编译成 JS 时被整个抹掉了。它只在写代码时帮你查错,到运行期一点不剩。

怎么让它跑得快:JIT 的功劳,和 JIT 的包袱

最早的 JS 引擎是纯解释型的:引擎把代码编译为字节码,然后逐条执行。这种引擎的缺点就是慢,只有原生机器代码的几十分之一。

2008 年,采用 JIT 技术的 V8 引擎随 Chrome 横空出世,把 JS 的执行速度拉高了一个数量级。

现代浏览器的 JS 引擎——Chrome 的 V8、Firefox 的 SpiderMonkey、Safari 的 JavaScriptCore——靠的都是 JIT(Just-In-Time,即时编译):程序边跑,引擎边在旁边盯着,记录每个变量实际是什么类型、哪段代码最热,然后把热点路径当场编译成优化过的机器码,等于把编译时丢掉的类型信息在运行时一点点重新挖回来。

也正是从那以后,Gmail、在线文档、Figma 这类重到不像网页的网页才真正跑得动——没有 JIT,纯解释执行的 JS 扛不起这种体量。可以说,今天浏览器里那些富应用,半条命是 JIT 给的。

但 JIT 这套”边跑边编译”的打法,有几个换不掉的包袱:

  1. 要预热。 程序刚启动时还在解释执行、顺带收集 profiling,热代码得跑够次数才会被编译优化。冷启动那一截,跑的是”慢速档”。
  2. 引擎得一直背着。 一个几十 MB 的运行时必须随程序分发,编译和 profiling 在运行期持续吃 CPU 和内存;而且每次启动都从头编译一遍,上次的成果带不走。
  3. 会抖。 投机优化建立在”类型一直不变”的赌注上,一旦赌输就得 deopt(去优化)回退慢路径再重编,性能曲线时快时慢,不好预测。
  4. 内存基线高。 引擎 + 编译缓存 + GC 叠起来,对 CLI 小工具、Serverless 冷启动、嵌入式这些场景都是负担。

一句话:JIT 用运行时的复杂度提升了动态语言的速度——划算,但不是没有代价。

那有没有另一条路?有,叫 AOT(Ahead-Of-Time,提前编译):像 C++、Rust、Go 那样,在程序跑起来之前就把代码编成机器码。没有预热、不用引擎、不会 deopt,启动即满速,产物是自包含的二进制文件。代价也很明确——你拿不到运行时的类型反馈,必须在编译期就把类型搞定。

而 TypeScript 恰好给 JS 补上了类型,让 AOT 成为可能。

而这正是 Perry 选的路。它跳过 JS 引擎,把 TS 直接 AOT 编译成机器码;编译时被扔掉的类型,它捡起来拿去做优化,刚好补上 AOT”拿不到运行时反馈”的短板。

它能拿来干什么

定位清楚了,用途也就清楚了:凡是”既想要原生性能和小体积、又不想离开 TypeScript 生态”的场景,它就往哪儿冲。

命令行工具、跨平台桌面和移动应用,甚至游戏引擎。它的 README 列了一串案例——MongoDB 的原生 GUI、用 TS 写的游戏引擎、AI 代码编辑器——成色我没逐个验证,但方向很明确:让 TypeScript 这门”前端语言”去抢原本属于 C++/Rust 的地盘。

得先泼盆冷水。它支持的是 TypeScript 子集,不是任意 JS;而且默认不做运行时类型校验——声明 string 的参数,你塞个数字进去它也不报错。它对标 Node 的行为,仓库里专门拿 test262 和与 Node 的逐字节对比盯着兼容性,但离”完全等价”还远。

基本架构:一条经典的编译流水线

Perry 没把轮子从头造到底,而是站在两个巨人肩上。

SWC(Speedy Web Compiler)是个用 Rust 写的超高速 JS/TS 编译器工具链,定位类似 Babel,但快上几十倍——Next.js、Deno、Parcel 这些项目的底层解析 / 转译都靠它。Perry 只借它最前端那一步:把 TypeScript 源码解析成 AST(抽象语法树)。TS 的那套类型系统实在复杂,自己从头写解析器没必要。

LLVM 则是编译器界的”通用后端”,Clang(C/C++)、Rust、Swift 全都建在它之上。你只要把代码先翻译成它那套中间表示(LLVM IR),它就负责优化、并生成 x86 / ARM 等各平台的机器码——等于把”为每种 CPU 写一遍代码生成器”这件最苦的活替你包圆了。Perry 的后端就奔它去的。可以说自从有了 LLVM,各种自主知识产权的编程语言就雨后春笋地冒出来了。

Perry 的整条流水线是:

1
TypeScript → SWC 解析 → AST → 降级 → HIR → 优化 → LLVM IR → .o → 链接 → 可执行文件

每一段对应一个 Rust crate:

阶段谁干的说明
解析 TSperry-parserSWC 的封装,不自己写语法解析
AST → HIRperry-hir降级成高层中间表示
优化perry-transform闭包转换、async 降级、内联
生成代码perry-codegen生成 LLVM IR
运行时perry-runtime / perry-stdlib自己用 Rust 实现的整套 JS 运行时

有三个细节我有点意外。

其一,perry-codegen 号称 “LLVM 后端”,却没链接任何 LLVM 的 Rust 绑定,而是直接把 LLVM IR 当字符串拼出来,再调系统的 optllcclang 收尾。好处是构建零依赖、IR 是纯文本能直接 diff 调试;代价是丢了类型安全,于是开发文档里塞满了”LLVM 类型不匹配”的踩坑提醒。务实,但糙。

其二更重要:那套 JS 运行时库是从零用 Rust 重写的,没内嵌 V8 或 JSC。 ObjectArrayMapPromisefscrypto,甚至 axioslodash 这些 npm 包,全是原生重新实现。最终的二进制 = 你代码编出的机器码 + 这套 Rust 运行时,静态链接在一起——这才是它能甩掉一切外部运行时的底气。

其三,顺着上一条再往里看一层——连垃圾回收器都是自己手写的。运行时带一个分代 mark-sweep GC:新生代 / 老年代两区,保守式栈扫描加精确根扫描,写屏障、对象疏散一应俱全,没用任何现成的 GC 库。对一个一个人、四个多月的项目来说,光这个完成度就够让人咋舌。

优化技术:让动态语言别太慢

把动态语言编成原生码,难点从不在”翻译语法”,而在”怎么让动态的东西跑得不慢”。Perry 有三招值得讲。

第一招,把平时扔掉的类型捡回来用。

前面说过,对于 JS,AOT 最大的挑战是拿不到运行时的类型。Perry 的对策,就是把 TS 里那些常规编译时被扔掉的类型捡回来,在编译期就把能确定为数字的地方提前特化掉。比如 let i = 0 这种循环计数器,推断对了就以原生整数在寄存器里自增,而不是每轮都装箱、拆箱。

这也是它和 V8 最有意思的分别——同样是”把动态值变成原生整数”,V8 靠运行时投机、猜错就 deopt,Perry 靠编译期的静态类型,一锤定音:

 V8(JIT)Perry(AOT)
类型信息来源运行时 profiling编译期静态类型
整数化投机,猜错就 deopt静态特化,无 deopt
重编译无,启动即满速

这就是和编译为 JS 再运行不一样的地方了:在 Perry 里,你的 TS 类型标注越准,生成的代码就越快——因为它真拿静态标注的类型去优化了。而在浏览器里,类型注解对运行时性能毫无影响,纯粹写给人和 IDE 看。同一份注解,在两个世界里的价值天差地别。

第二招,NaN-Boxing:把任何 JS 值塞进 64 位。

第一招能特化的,是编译期就能拍板类型的那部分;可 JS 里总有编译期定不下来的值——any、联合类型、跨函数边界传来传去的动态值。这些没法预先拆成原生 int / double,于是需要一种能装下任意类型的”万能容器”来统一表示。这就用到了 NaN-Boxing 技术。

先说 NaN 是什么。它是 Not a Number(非数字)的缩写,是 IEEE-754 浮点标准里专门用来表示”算不出来的结果”的特殊值——0/0、对负数开平方根这种,结果就是 NaN;JS 里 parseInt("abc") 得到的也是它。

关键在它的二进制长相。一个 64 位浮点数由符号位、指数位、尾数位三段拼成,而标准规定:只要指数位全为 1、尾数位不全为 0,就算 NaN——至于尾数里具体填了什么,它根本不在乎。这意味着有足足 2 的 51 次方种不同的位模式都代表”NaN”,可真实运算里压根用不了这么多。

NaN-Boxing 就是来薅这片羊毛的:真数字照常存成浮点,其余类型(对象、字符串、整数、null、布尔)则藏进那片用不完的”NaN 空间”,靠高位 tag 区分种类、低位放指针或整数。

1
2
3
普通数字 → 就是它的 double 位
STRING_TAG = 0x7FFF,低 48 位放字符串指针
INT32_TAG  = 0x7FFE,低 32 位放整数

指针为什么塞得下?现代 64 位 CPU 实际只用 48 位地址。于是一个 8 字节的槽就能装下任意 JS 值,数字运算还零装箱开销。

这套路子并非 Perry 首创。最早的成文记载,一般追到 David Gudeman 1993 年的论文《Representing Type Information in Dynamically Typed Languages》中 “Using IEEE NaN Codes” 一节(更早还能上溯到 Richard Fateman 1982 年对 IEEE 浮点标准的讨论);真正把它用红、让后人广泛效仿的,是 Mike Pall 的 LuaJIT。如今 SpiderMonkey、JavaScriptCore 乃至 Perry,走的都是这条路。

第三招,把 V8 的看家本领也搬了过来。

第二招解决的是动态值怎么”存”,可”动态对象的属性怎么取”还没提速——一个 any 对象,运行时才知道它长什么样、有哪些属性。这种纯动态访问,Perry 干脆照搬了 V8 / JavaScriptCore 那套久经考验的运行时技巧:

  • Shape(隐藏类):V8 叫 Hidden Class、JSC 叫 Structure,Perry 叫 shape。字段布局相同的对象共享同一份 shape 描述符,属性访问就退化成”按固定槽位偏移取值”,不必每次去哈希表里查名字。给对象加属性时,它走一棵 shape 迁移缓存(”形状 A 加属性 x → 形状 B”,学过一次就记住)——正是 V8 hidden-class transition 的同款套路;一旦对象形状变得没法预测,就退回字典模式,也和 V8 一样。
  • 内联缓存(Inline Cache):属性和方法的访问点会记住”上次见到的是哪个 shape”,单态命中直接走快路径,最多缓存 4 种形状的多态。
  • 稠密数值数组:元素全是数字的数组会切到紧凑布局——裸 f64 连续存放、不 NaN-box,还标记成”无指针”让 GC 整段跳过;等价于 V8 的 PACKED_DOUBLE_ELEMENTS。一旦存进一个非数字,立刻降级回通用表示。

有意思的是,这几招全是运行时数据结构层面的优化(共享 shape、缓存槽位、紧凑布局),而不是 JIT 那种”运行时重新编译机器码”。所以它们和 AOT 并不冲突——既吃到了动态访问的提速,又不用背 deopt 和预热的包袱。说到底,Perry 是把两条路缝在了一起:静态能定的,编译期定死;静态定不了的,用 V8 同款的运行时技巧兜底。

那这些优化到底行不行?看它自己的 benchmark。

先看最能说明问题的一行。在它那套较真的多语言对比里(M1 Max,v0.5.908,11 次取中位数),递归 fibonacci 跑出 309ms,几乎贴着 Rust 的 316ms、C++ 的 309ms,而同一个测试 Node 要 987ms——也就是说,整数密集的活,它真能挤进原生语言的档位,把 Node 甩开三倍。

再看一组和 JS 阵营的横向对比(单位 ms,越小越快):

测试PerryNodeBun测什么
factorial10759197整数累乘快路径
closure50304511000 万次闭包创建 + 调用
binary_trees2107100 万节点的树分配与遍历
json_roundtrip8337724950 次 ~1MB JSON 解析 + 序列化
matrix_multiply283434256×256 矩阵乘

结论很清楚:Perry 普遍跑在 Bun 这一档,多数项目把 Node 甩开 3 到 5 倍。 前面那几招——静态类型特化、NaN-Boxing、shape,加上那套自研 GC——是真兑现成了性能,不是 PPT 项目。

更让我对它高看一眼的,是它 README 里那股克制的诚实。它明说”不拿 loop_overheadaccumulate 这类能折叠成常量的微基准打头阵”,因为那些只测”编译器有没有开激进优化 flag”,不测真实负载;它也老实承认,带 FMA 融合的浮点循环它干不过 Apple Clang -O3 和 Go。在一个动辄宣称”吊打全场”的领域里,肯主动把自己不擅长的一面摆出来,这反而比那堆漂亮数字更让人信它。

最让我吃惊的,不是技术,是开发方式

看到这么多工程量,我是有点吃惊的,顺手统计了一下仓库。先把数字摆出来,你就明白我为什么会吃惊。

指标数值
Rust 代码约 88 万行(1855 个 .rs 文件)
时间跨度2026-01-22 → 06-07,约 4.5 个月
总提交4375 次
单日提交峰值263 次
主导者一个人,约 3300+ 次提交

一个人,四个半月,88 万行,单日 263 次提交——这不可能是手敲出来的。

先说效率这头。 证据很直接:488 个提交带着 Co-Authored-By: Claude,约 700 个提交信息提到 AI。仓库根目录还躺着一个 CLAUDE.md,里面是写给 AI 助手看的架构说明、踩坑清单、固定流程。换句话说,Perry 不只是”用 AI 写出来的”,它某种程度上是为 AI 协作而设计的——那份文档不是写给后来的人看的,是写给下一轮 AI 看的。

为什么这类项目特别适合 AI 高速量产?三个条件凑齐了。一是规格现成:重写标准库、对齐 ECMAScript 语义,正确答案早被权威定义好,不需要任何产品决策。二是有客观裁判:拿编译结果逐字节比对 node,跑 test262,AI 改完立刻知道对错,自己就能闭环。三是高度可并行:你看那几十个 node_stream_*.rs 文件,同一套模式批量铺开,正是 AI 最擅长的活。

再说质量这头——这里有个必须说清楚的短板。

我用 gh pr list 翻了最近合并的 PR,清一色是同一个样子:作者是维护者本人,reviewDecision 为空,reviews 是空数组。也就是说——他自己开 PR、自己合并,没有第二个人审过。 把关几乎全压在自动化测试上。

公平地说,对 Perry 这种”实现有客观正确答案”的活,自动化裁判反而比人眼更靠谱:test262 和字节级比对查得比人细、比人全。但 CI 覆盖不到的地方就裸奔了——架构是否合理、有没有安全隐患、死代码冗余、长期可维护性,这些只能靠人,而这个项目的人手只有一个。一个旁证:README 里版本还停在 0.5.152,代码里实际已经是 0.5.1129,文档根本追不上代码迭代的速度。

所以给个使用建议:把 Perry 当成一个能力惊人、但仍在狂奔、缺少同行评审的”单人 + AI”项目。拿它做点验证性的小工具,可以上;真要进核心生产链路,上线前自己务必补一轮独立审查和压测。

写在最后

扒完 Perry,我盯着那 88 万行和单日 263 次提交看了挺久。

放在三年前,”一个人、四个半月,做出一个带自研 GC、自研运行时、对齐 test262 的原生编译器”——这是天方夜谭,是一支团队好几年的活。

说穿了,Perry 没有一样东西是新发明的。技术思路是现成的——AOT 编译、NaN-boxing、隐藏类、内联缓存、分代 GC,编译和运行时领域早就玩熟了几十年;工具也是现成的——前端解析直接拿 SWC,后端代码生成直接拿 LLVM。砖头和图纸一直都堆在那儿,谁都搬得到。真正难的,从来是把这些砖垒成一座能住人的房子。一个人要同时吃透类型系统、GC、LLVM,还得啃下半部 Node 标准库,知识面横跨好几个领域;更熬人的是那几十万行实现、那场对齐 test262 的死磕——纯靠一个人的精力,过去花上几年也未必收得了尾。所以这种”集大成”的工程,历来是团队的专利。

AI 松动的,恰恰是这后半截:知识广度上,它替你补齐了不精通的那几块;体力耐力上,它把”几十万行”从拦路虎变成了可调度的产能。于是今天,一个资深工程师定方向、搭架构、立好测试基准,剩下的大规模填充交给 AI——一件本来要一支团队好几年的硬核工程,被压进了四个半月。

而且说句实在的——这篇分析,我自己也没读完那 88 万行。 我靠的是带着自己的疑问提问题,git 统计、grep 定点深读那么几个关键文件、读它写给 AI 看的 CLAUDE.md、看测试裁判过不过。一个 AI 高速写出来、人读不完的项目,最后还得靠 AI 来高速解读。谁都没真正”读完”过它,大家都在和统计量、测试裁判、彼此的注释打交道。

这才是真正变了的地方:当代码量超过任何人能通读的极限,“读懂”本身也被外包了出去。人能做的,是设计出让自己不必通读也能信任它的机制——测试裁判、架构文档、统计指标。代码的”写法”被改写了,”读法”也一样。

工具一直在变,但有件事没变:最终为产品特性和正确性负责的,仍然是那个人。 只不过这一次,他要负责的代码,可能是怎么都读不完的。

This post is licensed under CC BY 4.0 by the author.

© chen3feng. Some rights reserved.

Using the Chirpy theme for Jekyll.