启动快十二倍、运行慢七倍半:编译到原生的取舍要看负载形状

一、两个指标反向走,说明它们测的根本不是同一件事
据公开报道,有团队发布了一款实验性编译器,能把普通 TypeScript 转成小型原生可执行文件,对比数字是启动快约十二倍、运行慢约七倍半。这两个倍数在这里只用来说明一件事:同一份代码、同一次评测,两个指标可以朝相反方向走,所以“它更快了”这种结论根本不成立。它们不是我们下面判断的依据,也不能被拿去当任何选型结论。
真正的问题是评测口径。启动时间和稳态运行时间是两类完全不同的负载特征:前者是一次性开销,发生在进程生命周期最前面那一段,主要由运行时初始化、模块解析、依赖加载构成;后者是持续开销,取决于代码被执行了很多遍之后会发生什么。把两者混在一个“性能”标签下报出来,读者会自动补一句“总体更快或总体更慢”,而这句补充恰恰是错的。选型该问的不是“哪个数字大”,而是“我的负载里哪一段占主导”。
我们内部评审这类工具时,第一条规则是禁止在报告里出现单个综合性能数字,必须按阶段拆开:进程创建到能接受第一个输入、单任务从输入到输出的处理耗时、以及长时运行下的吞吐与内存形态。三段各自有各自的结论,允许出现“这段赢、那段输”的表述。
二、编译到原生为什么在启动这一步占便宜
启动路径上的开销大致四类,都能对上明确的机理。
- 运行时初始化:解释型运行时在跑第一行业务代码之前,要搭好堆、内部对象、内置模块注册表,这部分和业务无关,却每次进程启动都要重付
- 即时编译预热:热点代码要先按较低效率执行,边执行边收集信息再编译,启动阶段基本享受不到优化成果
- 模块解析与加载:包管理目录结构下的路径查找、循环依赖检测、逐个模块编译,在依赖树大的项目里是一段实打实的时间
- 依赖体积:加载的代码越多,解析与校验越多,冷启动越慢
编译到原生把这些挪到了构建期:产物是一个已经处理好的可执行文件,进程创建后可以直接进入业务。所以启动上的便宜不是“代码写得更快”,而是“该付的固定成本不再在每次运行时重付”。这一点对短生命周期进程特别友好,因为它本来就没几行业务代码可以摊薄这份固定成本。
三、运行阶段为什么可能反过来
这一段只讲一般机理,不推断那个项目的实现方式。
即时编译的价值出现在“同一段代码被执行很多遍”之后:它能根据真实运行数据做专门优化,包括把多态点改成单态、内联、按实际类型布局。一次编译到原生的产物拿不到这种反馈式优化,或者只能拿到较弱的版本。
动态语义的兜底实现是第二项代价。带动态类型语义的语言里,属性访问、原型链、动态扩展这些行为在运行时有很成熟的快速路径。编译型产物要么把这些行为翻译成一层通用查表逻辑,要么把优化空间收窄到保守假设上,两种都让单次操作变贵。
第三项常被忽视:成熟运行时的内建函数是几十年调优的结果。字符串处理、正则、集合、数值转换、编码转换,这些在真实业务里出现频率极高,而编译产物要么重新实现一遍、要么把调用绕回一个宿主运行时,两条路都谈不上优势。
于是结论自然出现:这类技术的收益边界不在“快慢”,而在生命周期长短与执行遍数多少。所以也不存在“编译到原生会取代运行时”这种判断——两条路线各自服务不同的负载形状,这个论点在任何评测方法下都站不住。
四、四类负载分别该看哪个指标
核心交付物是这张分档表。它的作用是把“看哪个数”定死,避免拿错指标下结论。
| 负载形状 | 典型形态 | 主要开销在哪 | 该看的指标 | 不该看的指标 |
|---|---|---|---|---|
| 一次性进程 | 命令行工具、内部脚本、CI 里跑一次就退出的任务 | 固定启动成本占大头,业务代码跑几遍说不清 | 端到端完成时间分布、进程创建到首个输出的时间 | 峰值吞吐 |
| 常驻服务 | 长时间运行的接口进程 | 启动成本每天只付几次,运行期占绝对大头 | 稳态处理耗时与吞吐的分位数、内存随时间形态、GC 或回收造成的停顿形态 | 单次启动时间 |
| 冷启动敏感的边缘形态 | 按请求拉起、生命周期很短、并发量随时段波动 | 启动成本直接进用户可见延迟,且没有预热机会 | 冷启动端到端时间、并发扩容时的排队时间、按调用计费涉及的资源占用 | 稳态吞吐 |
| 长时批处理 | 跑几十分钟到数小时的转换、导入、训练数据准备 | 运行期主导,启动可忽略 | 单位数据量的处理耗时曲线、长时间运行后的稳定性与内存上限 | 首包时间 |
判定的方法很朴素:先写出这个进程每天被创建多少次、每次活多久。活的时间短、创建次数多,启动成本就占主导;活的时间长,运行成本压倒一切。这一句话就能把上面四档分开,不需要跑基准。
把公开报道里的倍数还原成业务问题,也是这三问:谁在等这段启动时间(用户、构建流水线还是下游任务);每天启动多少次;单次运行多久。三问答完,性能数字才有方向。以洹拓接手过的存量项目来看,多数团队的命令行与流水线脚本能从启动优化里拿到可感知的收益,常驻服务拿不到;但这不是选型结论,因为决定它的是上面三问的答案。
五、测法怎么设计才不失真
评测方法错了,后面所有决定都错。七条纪律:
- 固定输入集:评测用例、依赖版本、机器与系统状态全部冻结,输入不固定的对比只用于讲故事
- 预热轮次与丢弃规则:先约定丢弃几轮预热,并写明“丢弃”不等于“不报告预热本身耗时”
- 首包与稳态分开记:一条时间轴上两个数字,绝不合并成一个平均值
- 用分位数而不是均值:至少记中位数、高分位与最差值,长尾恰是这类工具最容易吃亏的地方
- 记录资源占用而不只是时间:内存峰值、磁盘与产物体积、并发下的退化情况
- 保留可复现产物:评测脚本、提交标识、构建参数一起存档,别人跑不出来就不进评审
- 与真实业务同构:拿玩具基准得出的结论只能指导玩具决策,要按第四节的负载形状各准备一条真实任务
评测结果进决策文档时,必须同时写下负载假设。假设不成立的那天,这份结论就作废,而不是继续留在知识库首页当标准答案。
六、实验性工具的六项边界检查
这类工具值得跟,但“跟”的含义是先进工程边界清单,不是先进生产默认链路。六项检查建议作为引入前的门槛,任何一项标不过就先不进主干。
| 检查项 | 要通过什么 | 通不过的后果 |
|---|---|---|
| 兼容矩阵 | 明确支持的语法特性、标准库与第三方模块范围,且我们代码里用到的每一项都在列 | 构建期或运行期出现无人能解释的失败 |
| 调试符号与栈信息 | 线上问题能拿到可读栈、能定位到源码位置 | 只能靠复现猜,故障处理时间不可控 |
| 崩溃诊断 | 崩溃有分类、有可采集的现场信息、有上报通路 | 值班无法判断影响面,只能整体回滚 |
| 构建产物可复现 | 同一提交在任意机器上产出一致,构建依赖被锁死 | 出现“本地能编、线上编不出”的长期扯皮 |
| 产物体积与分发 | 各平台产物大小与分发成本可接受,能纳入现有发布流程 | 下载与更新链路变慢,用户端升级失败率上升 |
| 许可证与供应链、退出成本 | 许可证义务能履行(宽松许可证不等于没有义务:编译产物里带不带第三方代码、要不要随附声明与副本要单独核);上游停维护时的替代路径与迁回原运行时的改动量被评估过 | 工具换代时业务代码要重写一遍 |
最后一项里藏着真正的风险:代码是否被绑定到特定编译约束。有些写法在原生运行时里完全正常,进了编译链就要改写;一旦这类写法在业务代码里扩散,迁走就不再是改构建脚本,而是改业务。护栏是把这类约束列成显式规则,在评审里当作可检查项,而不是靠口口相传。
我们的做法倾向是:把它先放在一次性进程与内部工具这一档,跑一段时间的真实任务,攒够上面六项的证据,再谈是否往服务链路挪。这既不是推荐也不是否定,只是把结论留给负载形状。