结论:在 10 个共同案例上,V8 和 Bun 的峰值大约是 Rust 的 2 倍耗时,LuaJIT 约 2.4 倍;但 JIT 在单态调用、分配密集、字符串表等场景里反超了 Rust。真正的差距在成本侧:Node 启动 27 ms、常驻 42 MB,Rust 和 LuaJIT 均约 2 ms、12 MB。同一份 LLVM IR 用 JIT 和 AOT 跑,峰值只差约 10%,说明拉开差距的主要是语言语义,不是编译发生的时机。
这些数字来自共享的云端沙箱(2 核 x86-64),每组只跑了 3–10 轮,单个数字可能有 10–20% 的波动。适合看倍数级的差异和方向,不适合当作精确排名。
表里是稳定状态下的单轮耗时,换算成 Rust(默认 release)的倍数:小于 1 表示比 Rust 快。第一列是 Rust 的绝对耗时(毫秒)。
| 案例 | Rust ms | V8 | Bun | LuaJIT | V8 关 TurboFan | V8 纯解释 | LuaJIT 关 JIT | Lua 5.4 |
|---|---|---|---|---|---|---|---|---|
| n-body 浮点循环 | 46 | 1.8 | 2.5 | 3.1 | 80 | 87 | 31 | 48 |
| 递归 fib(32) | 6.3 | 3.3 | 2.2 | 2.8 | 13 | 32 | 19 | 23 |
| 单态虚调用 | 59 | 0.4 | 0.4 | 2.8 | 9.5 | 13 | 8.7 | 8.7 |
| 五种形状轮换 | 50 | 2.6 | 0.8 | 3.4 | 13 | 17 | 9.0 | 12 |
| binary-trees | 12.6 | 0.7 | 0.8 | 3.2 | 2.9 | 4.3 | 4.6 | 13 |
| 字符串词频 | 70 | 2.2 | 2.6 | 0.2 | 3.9 | 3.6 | 0.7 | 1.2 |
| 不规则分支 | 67 | 2.4 | 3.4 | 1.8 | 5.9 | 8.9 | 5.7 | 7.2 |
| 字节码解释器 | 12.3 | 2.4 | 1.6 | 4.2 | 26 | 48 | 14 | 22 |
| 矩阵乘 256³ | 3.6 | 6.9 | 4.2 | 6.7 | 215 | 298 | 89 | 125 |
| 规则过滤(解释规则) | 9.3 | 2.6 | 10 | 2.0 | 38 | 55 | 12 | 11 |
| 几何平均 | 2.0 | 1.9 | 2.4 | 17 | 24 | 10 | 14 |
几个读法:
实测前写下的 10 条假设:5 条成立,3 条部分成立,2 条不成立。不成立的两条都是对 JIT 的预判过于乐观或过于简单。
| 假设 | 结果 | 实测 |
|---|---|---|
| H1 数值循环里 V8、LuaJIT 在 Rust 的 0.7–1.2 倍 | 不成立 | V8 1.8 倍,LuaJIT 3.1 倍。LuaJIT 版用带名字段的表存数据;改用 FFI 结构体可能会更快,但那就不是普通的 Lua 代码了 |
| H2 追踪 JIT 在递归和不规则分支上明显落后 V8 | 不成立 | fib:LuaJIT 2.8 倍、V8 3.3 倍;分支:LuaJIT 1.8 倍、V8 2.4 倍。LuaJIT 反而更好。公开资料里追踪 JIT 的弱点主要是 NYI 函数和 trace 中止,不是分支本身 |
| H3 单态调用 V8 可赢 Rust dyn;5 种形状时变慢;PGO 能追回 | 部分成立 | 前两点成立(0.4 倍 → 2.6 倍)。Rust 的 PGO 因为工具链版本不匹配未能在沙箱里跑;C 的函数指针版加 gcc PGO 后耗时不变(28.1 vs 27.9 ms) |
| H4 分配密集时 V8 的分代 GC 快于 Rust 逐个 Box | 成立 | V8 0.7 倍、Bun 0.8 倍。arena 写法未测 |
| H5 Rust 和 LuaJIT 启动在毫秒级,Node 几十毫秒 | 成立 | Rust 2.2 ms、LuaJIT 1.9 ms、Bun 6.1 ms、Node 27 ms |
| H6 Node 常驻内存显著更高,LuaJIT 极小 | 部分成立 | 空转时成立(42 MB vs 12 MB)。但 LuaJIT 存 100 万个小表时吃到 133 MB,和 V8 的 154 MB 相当,对象表示才是内存的大头 |
| H7 只给一个核时 V8 预热变慢 | 成立 | 解释器案例:2 核时第 1 轮就是 32 ms;1 核时前 8 轮都卡在 48 ms,之后才降到 31 ms |
| H8 运行时生成代码是 JIT 的结构性优势 | 部分成立 | V8 快 3 倍、Bun 快 12 倍,追平 Rust 解释规则;LuaJIT 几乎不变(18.7 vs 18.4 ms),因为 trace 已经按实际路径特化过规则循环 |
| H9 矩阵乘拉开最大差距 | 成立 | V8 6.9 倍、LuaJIT 6.7 倍,全表最大 |
| H10 Wasm 比原生慢 1.2–2 倍,但比 JS 稳 | 成立 | 几何平均 1.28 倍,矩阵乘 2.9 倍;同算法下比 JS 快(矩阵乘 12 vs 25 ms) |
峰值上 JIT 只差一倍左右,成本上差距是一到两个数量级。启动和空转内存是跑一个空程序 10 次的中位数。
| 运行方式 | 启动 | 空转内存 | 需要分发的体积 | 构建时间 |
|---|---|---|---|---|
| C(clang -O2) | 1.6 ms | 12 MB | 17 KB | 0.15 s |
| LuaJIT 2.1 | 1.9 ms | 12 MB | 运行时 0.6 MB + 源码 | 无 |
| Lua 5.4 | 1.9 ms | 12 MB | 运行时 0.3 MB + 源码 | 无 |
| Rust(默认 release) | 2.2 ms | 12 MB | 4.4 MB(未 strip) | 0.7 s |
| Rust(native + LTO) | 同上 | 同上 | 2.2 MB | 4.8 s |
| Bun | 6.1 ms | 15 MB | 运行时 80 MB + 源码 | 无 |
| Node 22 | 27 ms | 42 MB | 运行时 123 MB + 源码 | 无 |
| LLVM JIT(lli) | 69 ms | 75 MB | 字节码 14 KB + LLVM 库约 120 MB | 0.09 s(到 IR) |
首轮罚款。 按第一轮耗时算,几何平均倍数是:V8 4.8、LuaJIT 4.0、Bun 3.7、Rust 1.4。Rust 的 1.4 来自第一轮里要生成测试数据和起页,JIT 一侧的额外部分是解释执行和编译。对只跑一次的命令行工具,应该看的是这一列,而不是峰值。
预热。
JIT 的隐性 CPU。 V8 跑单态案例时,CPU 时间比墙钟时间多 20%,多出来的是后台编译和 GC 线程;Bun 跑 binary-trees 时多 20–40%。LuaJIT 和 Rust 基本是 1:1。在按 CPU 计费的环境里,这部分也要算进成本。
同一份 C 代码由 clang 生成 LLVM IR,然后分别提前编译成可执行文件、交给 LLVM 的 JIT(lli)、编译成 Wasm 交给 V8。再加 gcc 以及 gcc 加 PGO 两档。表中是相对 clang AOT 的倍数。
| 案例 | clang AOT ms | LLVM JIT | Wasm in V8 | gcc native | gcc + PGO |
|---|---|---|---|---|---|
| n-body | 53 | 1.25 | 1.30 | 0.89 | 0.96 |
| fib(32) | 5.0 | 1.01 | 2.21 | 0.63 | 0.61 |
| 单态函数指针 | 50 | 1.11 | 0.70 | 0.56 | 0.57 |
| 五种函数指针 | 50 | 1.23 | 0.80 | 0.59 | 0.59 |
| 不规则分支 | 61 | 1.12 | 1.01 | 1.12 | 1.08 |
| 字节码解释器 | 11.4 | 1.12 | 1.18 | 1.28 | 1.39 |
| 矩阵乘 | 4.2 | 1.03 | 2.90 | 0.88 | 0.59 |
| 几何平均 | 1.12 | 1.28 | 0.81 | 0.78 |
读法:
沙箱数据只给方向,下面的公开测试给量级。每条都打开过原页核对。
峰值:Benchmarks Game(Rust 1.84 vs Node 23)。用的是各语言最快的程序,许多带手写 SIMD 或多线程,所以差距比本文大。
| 程序 | Rust | Node.js | 倍数 |
|---|---|---|---|
| n-body | 2.19 s(不用 SIMD 的最佳 3.46 s) | 8.55 s | 2.5–3.9 |
| mandelbrot | 0.95 s | 4.05 s | 4.3 |
| spectral-norm | 0.72 s | 5.40 s(单线程) | 7.5 |
| k-nucleotide | 2.57 s | 15.99 s | 6.2 |
| binary-trees | 1.07 s,139 MB | 8.61 s,1.28 GB | 8.0 |
来源:Benchmarks Game: Node.js vs Rust
LuaJIT。
预热:Virtual Machine Warmup Blows Hot and Cold(OOPSLA 2017)。 测了 V8、LuaJIT、HotSpot、PyPy 等 8 个虚拟机,每个进程跑 2000 轮:最多只有 43.5% 的组合会稳定到达峰值,近一半情况下同一组合在同一台机器上会出现不同的表现。来源:arXiv 1602.00602。Cloudflare 另有文章称 LuaJIT 是“见过最不确定的虚拟机”,原因之一是所有循环共用 64 个热度计数器。来源:Cloudflare, 2018。
Wasm 对原生:Not So Fast(USENIX ATC 2019)。 SPEC CPU 上 Chrome 平均慢 55%、Firefox 慢 45%,最多 2.5 倍;主要原因是保留寄存器、每次函数调用的栈检查和间接调用检查。来源:Jangda et al.。
V8 的分层。
PGO。 Go 官方给出的典型收益是 2–14%(go.dev/doc/pgo);rustc 自身用 PGO 最多快 15%(rustc-dev-guide);Android 的 Rust 工具链 LTO + PGO + BOLT 合计快 24.7%(Android Developers Blog)。和本文 gcc PGO 的 4% 是同一量级:一成左右的收益,不是倍数级。
启动和内存:同一份代码的 JIT 与 AOT。
| 测试 | 启动 | 内存 | 峰值吞吐 |
|---|---|---|---|
| Spring PetClinic,Oracle GraalVM 21(native image + PGO vs C2 JIT) | 7.09 s → 0.21 s | 1029 → 641 MB | 持平略高(11066 → 11902 req/s) |
| Quarkus 0.18(native vs JVM,无 PGO) | 1629 → 18 ms | 414 → 122 MB | 降到 58%(77118 → 44841 req/s) |
| ASP.NET Core 8 最小 API(NativeAOT vs JIT) | 156 → 48 ms | 96 → 41 MB | 未公布 |
来源:Oracle GraalVM for JDK 21、Quarkus runtime performance、ASP.NET Core in .NET 8。这一组最能说明问题:AOT 在不带 PGO 时峰值会明显下降,带上 PGO 后可以持平;启动和内存则始终是 AOT 赢。
把峰值、预热、启动、内存和工程成本放在一起看,选择大致如下:
| 场景 | 推荐 | 理由 |
|---|---|---|
| 命令行工具、Serverless、短任务 | AOT(Rust / C / Go),或 LuaJIT | 进程活不到 JIT 预热完成;Node 启动 27 ms、空载 42 MB,Rust 2 ms、12 MB |
| 长期运行的服务端业务 | JIT 完全够用(Node / JVM) | 峰值约为 AOT 的一半到持平,开发效率更重要;GraalVM 数据显示 AOT 不带 PGO 时吞吐反而下降 |
| 数值计算、矩阵、SIMD | AOT + native + LTO,热点再上 PGO | matmul 上 JIT 慢 4–7 倍;向量化和内存布局是 AOT 的主场 |
| 对象多、分配多、动态派发 | JIT 不吃亏,甚至更快 | mono / trees 上 V8 比 Rust 快:内联缓存 + 分代 GC 胜过 Box<dyn> 和逐个 malloc |
| 嵌入式脚本、游戏逻辑、网关插件 | LuaJIT | 0.6 MB、2 ms 启动,峰值接近 V8;要避开 NYI 函数 |
| 规则、查询、DSL 在运行时才确定 | 运行时生成代码(new Function / load / LLVM ORC) |
生成代码比通用解释快 1.2–5 倍,接近写死在源码里的 AOT 版本 |
| 浏览器里跑重计算 | Wasm | 同一份 C 代码约慢 1.3 倍,远好于纯 JS 的最差情况,且性能稳定 |
| 延迟敏感、要求可预测 | AOT | JIT 的预热和去优化会带来抖动(V8 多态案例在 115 ms 和 190 多 ms 之间来回跳),公开研究也指出大量组合达不到稳定峰值 |
| 受限平台(iOS、禁止 W^X 的环境) | AOT,或 jitless 解释器 | 无法写可执行内存;此时 V8 jitless 慢 24 倍,需要接受或换方案 |
一句话: 跑得久、动态性强,选 JIT;启动快、内存小、要稳、算得密,选 AOT;规则运行时才知道,就在运行时生成代码——不管宿主是 JIT 还是 AOT。
不问“谁更快”,而是拆成四个可以量的问题:
最终要得到的是一张“什么场景选什么”的判断表,而不是一个总排名。
主线是三方:V8(分层方法 JIT)、LuaJIT(追踪 JIT)、Rust(AOT)。两种 JIT 的思路正好相反:V8 按函数编译,LuaJIT 沿着热循环的实际执行路径编译。另加三组我认为重要的对照。
| 方案 | 类型 | 测哪几档 | 来源 |
|---|---|---|---|
| V8(Node 22) | 分层方法 JIT:解释器 → Sparkplug → Maglev → TurboFan | 全开;关掉 TurboFan;纯解释(--jitless) | 沙箱已有 |
| LuaJIT 2.1 | 追踪 JIT | 全开;关掉 JIT(-joff,只剩手写汇编解释器) | 从 GitHub 源码编译,约一分钟 |
| Rust 1.97 | AOT | 默认 release;+ target-cpu=native + LTO;+ PGO | 沙箱已有,PGO 工具已有 |
| 加:Lua 5.4 | 纯解释器 | 官方解释器 | 源码编译;用来衡量 LuaJIT 的 JIT 和解释器各自贡献多少 |
| 加:Rust → Wasm,跑在 V8 里 | AOT 产物再由 V8 编译(Liftoff / TurboFan) | 同一份 Rust 代码 | 沙箱已有;看“提前编译再进沙箱”损失多少,也是 JS 世界里最接近 AOT 的方案 |
| 加:Bun(JavaScriptCore) | 另一套分层 JIT | 同一份 JS | 沙箱已有;判断结论是 V8 特有,还是方法 JIT 共有 |
C(clang -O2)只用来校验 Rust 的基准是否正常。原先提到的 Java(HotSpot 对 native-image)、Python、Ruby、C# 移到可选补充,不在主线。
12 个案例,每个用 JS、Lua、Rust 各写一份,算法和输入一致。只用各语言自带的能力,不引入第三方库,这样量到的是编译器,而不是库写得好不好。
| # | 案例 | 测的是什么 | 对谁是考验 |
|---|---|---|---|
| 1 | n-body 浮点循环 | 紧密数值循环、寄存器分配 | 定三方的“天花板”;LuaJIT 在这类代码上以接近 C 著称 |
| 2 | 递归 fib(38) | 函数调用与内联 | 追踪 JIT 处理递归的弱点;V8 内联能力 |
| 3 | 多态分派 | 同一个调用点,运行时只有 1 种 vs 5 种对象形状轮换 | V8 的内联缓存从单态变成超多态;Rust 的 dyn Trait 加不加 PGO |
| 4 | binary-trees | 大量短命对象分配与回收 | V8 分代 GC、LuaJIT 的 GC 与分配下沉、Rust Box 与 arena |
| 5 | 哈希表词频统计 | 字符串哈希、表增长 | 最常见的业务型负载;字符串驻留的影响 |
| 6 | 不规则分支 | 循环里由数据决定走哪一支,分支之间概率接近 | 追踪 JIT 的天然弱点:支线太多、trace 爆炸或中止 |
| 7 | 小型字节码解释器 | 各语言写同一个解释器,跑同一段程序 | 大 switch 分派循环。和自己做语言的人最相关 |
| 8 | 字符串处理 | 手写 JSON 分词与构建,字符串拼接 | 字符串不可变带来的分配;Rust 的零拷贝 |
| 9 | 矩阵乘 | 自动向量化(SIMD)、循环变换 | 两种 JIT 基本不做自动向量化,Rust 可以;看差距有多大 |
| 10 | 启动型任务 | hello world;读 1 MB CSV 求和一次就退出 | 命令行工具、冷启动 |
| 11 | 预热曲线 | 案例 3、7 连续跑 200 轮,记录每轮耗时 | 从解释到峰值要多久;有没有反优化和抖动 |
| 12 | 运行时特化 | 启动后才读入的过滤规则,由程序生成代码再执行(JS 用 new Function,Lua 用 load) | JIT 可以把规则编进机器码,Rust 只能解释规则。最可能看到 JIT 反超的地方 |
可选第 13 个:FFI 调用 C 函数。LuaJIT 的 FFI 可以被 JIT 直接编译进 trace,Node 调原生模块要过一层边界,可以量出这层边界的代价。
七个指标,每个都先写清楚怎么量,避免“拿 JIT 的冷启动比 AOT 的峰值”这类不公平对比。
| 指标 | 怎么量 |
|---|---|
| 峰值吞吐 | 进程内先预热,再量稳定后的单轮耗时;取中位数,报告最小和最大 |
| 首次结果时间 | 从启动进程到第一轮结果输出,包含启动和未优化阶段 |
| 预热曲线 | 每轮耗时随轮次变化;到达峰值 95% 所需轮数和时间 |
| 启动时间 | hello world 和单次任务,重复 20 次取中位数 |
| 内存 | 峰值 RSS(/usr/bin/time -v),同时记录堆配置,避免拿默认大堆和紧凑堆直接比 |
| 体积 | 可执行文件大小;JIT 一侧加上运行时本身的大小 |
| 构建成本 | 编译的墙钟时间和峰值内存;PGO 加上采集运行的时间。JIT 一侧记录运行中编译线程的 CPU 时间 |
公平性约定:
前面“要回答的问题”到“指标与方法”是实测前定下的方法,保留作为附录;实测前的预判已并入“假设核对”。
代码共五个文件,每个案例在各语言中算法、数据和校验和完全一致(用 MINSTD 伪随机数,保证各语言结果逐位相同):
b.js:给 Node / Bun,node b.js <案例> <轮数>b.lua:给 LuaJIT 和 Lua 5.4,同一份代码b.rs:给 Rust,另有 rules_fixed(规则写死)b.c:给 clang / gcc / lli / wasmwasm.js:在 V8 里跑 b.wasm关键构建命令:
rustc -C opt-level=3 -o r_def b.rs
rustc -C opt-level=3 -C target-cpu=native -C lto=fat -C codegen-units=1 -o r_nat b.rs
clang -O2 -march=native -o c_nat b.c
clang -O2 -emit-llvm -c -o b.bc b.c && lli -O2 b.bc nbody 10
clang --target=wasm32 -O2 -nostdlib -DWASM -Wl,--no-entry -Wl,--export-all -o b.wasm b.c
gcc -O2 -march=native -fprofile-generate ... && gcc -O2 -march=native -fprofile-use ...
node --no-turbofan b.js nbody 10 # 去掉优化编译器
node --jitless b.js nbody 10 # 纯解释
luajit -joff b.lua nbody 10 # 关闭 LuaJIT 的 JIT
环境:Node 22.22、Bun 1.4.2、LuaJIT 2.1、Lua 5.4、Rust 1.97(LLVM 22)、clang / lli 18.1、gcc 13.3,共享云沙箱。只跑到能得出结论为止,每个组合 10 轮左右;在自己的机器上重跑,绝对值会变,倍数和排序应大致保持。
代码、原始数据与 Markdown 原文:sources/jit-aot。沙箱数字只看倍数和方向。返回目录