Skip to content

Repository files navigation

Wangshu(望舒)

望舒是纯 Go 实现的高性能可嵌入的 Lua 5.1 虚拟机。它不依赖 cgo,因此保持了交叉编译能力。

关于命名:Lua 是葡萄牙语中「月亮」的意思;望舒是中国神话中为月亮驱车的神灵(「前望舒使先驱」——《楚辞·离骚》)。为月亮驱车,即驱动 Lua 引擎。是一信达雅的名字。

CI Nightly Go Reference Tag Go Version License

中文 · English

目标

  • 语言标准:实现 Lua 5.1 的核心语言特性。——与 LuaJIT 一致,不追求语言的绝对完整性。
  • 正确性:在圈定的语言特性范围,与 Lua 5.1 官方实现的输出逐字节一致。
  • 高性能:将 Go 生态的 Lua 执行性能从 gopher-lua 提升至 LuaJ-luajc(Java)甚至 LuaJIT(C++)级别。
  • 跨平台:在 Linux/amd64, Linux/arm64, macOS/arm64 测试通过;保留其他平台扩张支持的能力。
  • 工业级:望舒从立项开始就是为了公司业务服务的,并且已经为在我所在公司线上运行。从测试、到 CI、再到各类 nightly-fuzz,都是朝着工业级的项目要求去的。望舒从来都不会,也不可能是一个个人的练习项目。我们希望望舒最终成为在 Go 语言项目中嵌入 Lua 的事实标准。

架构

望舒使用分层虚拟机架构;其执行层以月相命名:

P1 解释器 ──► P2 分层桥 ──► P3 Wasm 编译层 ──► P4 method JIT (RC 状态) ──► P5 trace JIT (尚未实现)
(crescent)    (基建)        (gibbous)          (gibbous)                   (fullmoon)

架构核心承诺:

  • NaN-boxed u64 值表示
  • 自管理的 arana 线性内存——各层共用同一块内存
  • P1 解释器始终可用——所有编译层的 deopt 着陆点及语义 oracle
  • CI 保证层与层之间逐字节一致

性能指标

数字来自 GitHub Actions hosted runner 上的标准化基准轮(bench-readme-table workflow,-benchtime=2s -count=3 -cpu=1,取 median,2026-07-10,run 29098511106),三平台同一轮、同一份代码。格式为「wall time (倍率 over gopher-lua)」,倍率越大越好;粗体表示该行最快,下划线表示倍率 ≥ 1.5×。

怎么读:hosted runner 是共享虚拟机,绝对 wall time 轮间可漂 10-20%——请以倍率为主,wall time 只作同轮内的量级参考。倍率的分母是 gopher-lua 在同一轮同一台 runner 上的实测值(分子分母同受干扰,倍率自洽),跨轮、跨平台都不要直接比 wall time。任何数字都可回溯到 run artifact 里的原始日志。

linux/amd64(Intel Xeon Platinum 8573C)

类别 脚本 gopher P1 P3 auto P3 force P4 auto P4 force
纯 VM 微基准 1 Simple (分支/比较) 826 ns 149 ns (5.54×) 4246 ns (2.00×) 2 9613 ns (0.88×) 2 165 ns (5.02×) 165 ns (5.02×)
Arith (Horner) 994 ns 209 ns (4.75×) 6512 ns (2.35×) 2 11423 ns (1.34×) 2 207 ns (4.81×) 207 ns (4.81×)
Loop (求和循环) 60.6 µs 20.1 µs (3.01×) 419 µs (7.25×) 2 405 µs (7.49×) 2 22.8 µs (2.66×) 22.8 µs (2.66×)
heavy 内核 3 HeavyArith 292 ms 84.3 ms (3.46×) 97.6 ms (2.99×) 97.2 ms (3.00×) 16.2 ms (18.0×) 15.7 ms (18.5×)
HeavyRecursion 9.34 ms 5.51 ms (1.69×) 5.93 ms (1.58×) 6.44 ms (1.45×) 1.94 ms (4.80×) 1.89 ms (4.93×) 4
HeavyFloatloop 464 ms 166 ms (2.80×) 57.7 ms (8.03×) 60.0 ms (7.73×) 26.2 ms (17.7×) 25.7 ms (18.0×)
realworld small 5 fib 10.2 ms 11.8 ms (0.87×) 12.8 ms (0.80×) 6 27.7 ms (0.37×) 1.09 ms (9.35×) 7 1.12 ms (9.10×) 7
binary-trees 56.0 ms 41.4 ms (1.35×) 45.1 ms (1.24×) 6 118 ms (0.48×) 29.7 ms (1.89×) 31.5 ms (1.78×) 7
spectral-norm 37.0 ms 21.1 ms (1.75×) 25.3 ms (1.46×) 6 53.4 ms (0.69×) 2.46 ms (15.0×) 2.40 ms (15.4×) 7
fannkuch 4.87 ms 6.32 ms (0.77×) 7.00 ms (0.70×) 7.12 ms (0.68×) 0.65 ms (7.51×) 0.62 ms (7.87×) 7
n-body 68.4 ms 53.7 ms (1.27×) 56.0 ms (1.22×) 6 108 ms (0.63×) 4.70 ms (14.5×) 8 4.64 ms (14.7×) 8
边界 mini · Call 9 PureVM 844 ns 151 ns (5.58×) — — — —
CallOnly 104 ns 222 ns (0.47×) 235 ns (0.44×) 365 ns (0.28×) 251 ns (0.41×) 251 ns (0.41×)
Boundary (+SetGlobal) 220 ns 392 ns (0.56×) 400 ns (0.55×) 830 ns (0.27×) 351 ns (0.63×) 342 ns (0.64×)
边界 mini · CallInto 9 PureVM 844 ns 151 ns (5.58×) — — — —
CallOnly 104 ns 84.9 ns (1.22×) 85.6 ns (1.21×) 196 ns (0.53×) 114 ns (0.90×) 115 ns (0.90×)
Boundary (+SetGlobal) 220 ns 230 ns (0.96×) 244 ns (0.90×) 618 ns (0.36×) 191 ns (1.15×) 198 ns (1.11×)
真实负载 · Call 10 Predicate (×1000) 568 µs 660 µs (0.86×) 697 µs (0.82×) 1241 µs (0.46×) 571 µs (0.99×) 554 µs (1.03×)
Transform (×1000) 466 µs 493 µs (0.95×) 532 µs (0.88×) 801 µs (0.58×) 488 µs (0.95×) 475 µs (0.98×)
真实负载 · CallInto 10 Predicate (×1000) 568 µs 489 µs (1.16×) 507 µs (1.12×) 1043 µs (0.54×) 382 µs (1.49×) 394 µs (1.44×)
Transform (×1000) 466 µs 355 µs (1.31×) 348 µs (1.34×) 589 µs (0.79×) 310 µs (1.51×) 306 µs (1.52×)

linux/arm64(Azure Cobalt 100,Neoverse-N2 类)

类别 脚本 gopher P1 P3 auto P3 force P4 auto P4 force
纯 VM 微基准 1 Simple (分支/比较) 987 ns 196 ns (5.03×) 6016 ns (1.66×) 2 10223 ns (0.97×) 2 206 ns (4.80×) 206 ns (4.80×)
Arith (Horner) 1162 ns 236 ns (4.92×) 8277 ns (2.19×) 2 12312 ns (1.47×) 2 252 ns (4.62×) 252 ns (4.62×)
Loop (求和循环) 73.4 µs 23.1 µs (3.18×) 594 µs (6.04×) 2 594 µs (6.04×) 2 29.0 µs (2.53×) 29.0 µs (2.53×)
heavy 内核 3 HeavyArith 300 ms 96.5 ms (3.11×) 119 ms (2.52×) 119 ms (2.53×) 23.1 ms (13.0×) 22.0 ms (13.7×)
HeavyRecursion 9.53 ms 6.59 ms (1.45×) 7.59 ms (1.26×) 8.07 ms (1.18×) 2.42 ms (3.93×) 2.42 ms (3.93×) 4
HeavyFloatloop 524 ms 190 ms (2.76×) 84.9 ms (6.17×) 85.0 ms (6.17×) 37.0 ms (14.2×) 37.1 ms (14.1×)
realworld small 5 fib 12.5 ms 14.4 ms (0.87×) 16.1 ms (0.78×) 6 29.9 ms (0.42×) 1.46 ms (8.56×) 7 1.46 ms (8.57×) 7
binary-trees 63.9 ms 52.0 ms (1.23×) 54.9 ms (1.16×) 6 120 ms (0.53×) 37.1 ms (1.72×) 37.1 ms (1.72×) 7
spectral-norm 45.5 ms 27.3 ms (1.67×) 31.8 ms (1.43×) 6 55.4 ms (0.82×) 5.62 ms (8.10×) 5.62 ms (8.10×) 7
fannkuch 5.76 ms 7.21 ms (0.80×) 7.46 ms (0.77×) 7.46 ms (0.77×) 0.83 ms (6.90×) 0.83 ms (6.92×) 7
n-body 77.7 ms 57.4 ms (1.35×) 59.5 ms (1.30×) 6 106 ms (0.73×) 8.86 ms (8.77×) 8 8.86 ms (8.77×) 8
边界 mini · Call 9 PureVM 1000 ns 198 ns (5.05×) — — — —
CallOnly 132 ns 279 ns (0.48×) 301 ns (0.44×) 429 ns (0.31×) 368 ns (0.36×) 364 ns (0.36×)
Boundary (+SetGlobal) 279 ns 460 ns (0.61×) 488 ns (0.57×) 902 ns (0.31×) 479 ns (0.58×) 482 ns (0.58×)
边界 mini · CallInto 9 PureVM 1000 ns 198 ns (5.05×) — — — —
CallOnly 132 ns 132 ns (1.00×) 147 ns (0.90×) 216 ns (0.61×) 202 ns (0.65×) 204 ns (0.65×)
Boundary (+SetGlobal) 279 ns 312 ns (0.89×) 325 ns (0.86×) 689 ns (0.41×) 319 ns (0.87×) 319 ns (0.87×)
真实负载 · Call 10 Predicate (×1000) 665 µs 812 µs (0.82×) 810 µs (0.82×) 1388 µs (0.48×) 746 µs (0.89×) 751 µs (0.88×)
Transform (×1000) 546 µs 634 µs (0.86×) 660 µs (0.83×) 935 µs (0.58×) 670 µs (0.81×) 666 µs (0.82×)
真实负载 · CallInto 10 Predicate (×1000) 665 µs 645 µs (1.03×) 632 µs (1.05×) 1190 µs (0.56×) 556 µs (1.20×) 563 µs (1.18×)
Transform (×1000) 546 µs 471 µs (1.16×) 505 µs (1.08×) 727 µs (0.75×) 489 µs (1.12×) 481 µs (1.13×)

darwin/arm64(Apple M 系,macos-latest)

类别 脚本 gopher P1 P3 auto P3 force P4 auto P4 force
纯 VM 微基准 1 Simple (分支/比较) 792 ns 141 ns (5.63×) 5002 ns (1.62×) 2 8899 ns (0.91×) 2 134 ns (5.93×) 134 ns (5.93×)
Arith (Horner) 909 ns 188 ns (4.82×) 6509 ns (2.31×) 2 11250 ns (1.34×) 2 169 ns (5.37×) 169 ns (5.37×)
Loop (求和循环) 56.5 µs 18.0 µs (3.14×) 850 µs (3.24×) 2 820 µs (3.36×) 2 21.1 µs (2.68×) 21.1 µs (2.68×)
heavy 内核 3 HeavyArith 214 ms 95.5 ms (2.24×) 97.0 ms (2.21×) 97.4 ms (2.20×) 34.2 ms (6.26×) 35.2 ms (6.08×)
HeavyRecursion 10.9 ms 5.24 ms (2.08×) 6.38 ms (1.71×) 6.84 ms (1.59×) 1.76 ms (6.19×) 1.75 ms (6.23×) 4
HeavyFloatloop 423 ms 144 ms (2.95×) 118 ms (3.59×) 119 ms (3.56×) 37.3 ms (11.3×) 37.6 ms (11.3×)
realworld small 5 fib 10.4 ms 12.2 ms (0.85×) 13.0 ms (0.80×) 6 25.2 ms (0.41×) 0.99 ms (10.5×) 7 1.00 ms (10.4×) 7
binary-trees 59.8 ms 47.0 ms (1.27×) 41.6 ms (1.44×) 6 94.1 ms (0.64×) 26.0 ms (2.30×) 26.0 ms (2.30×) 7
spectral-norm 36.1 ms 21.8 ms (1.65×) 22.6 ms (1.59×) 6 45.6 ms (0.79×) 4.51 ms (8.01×) 4.63 ms (7.80×) 7
fannkuch 4.84 ms 6.75 ms (0.72×) 6.18 ms (0.78×) 6.20 ms (0.78×) 0.69 ms (6.99×) 0.71 ms (6.82×) 7
n-body 65.5 ms 45.3 ms (1.45×) 44.3 ms (1.48×) 6 79.4 ms (0.83×) 6.88 ms (9.52×) 8 6.91 ms (9.48×) 8
边界 mini · Call 9 PureVM 908 ns 145 ns (6.28×) — — — —
CallOnly 97.3 ns 190 ns (0.51×) 178 ns (0.55×) 307 ns (0.32×) 223 ns (0.44×) 224 ns (0.43×)
Boundary (+SetGlobal) 212 ns 323 ns (0.66×) 297 ns (0.72×) 719 ns (0.30×) 308 ns (0.69×) 298 ns (0.71×)
边界 mini · CallInto 9 PureVM 908 ns 145 ns (6.28×) — — — —
CallOnly 97.3 ns 75.4 ns (1.29×) 79.6 ns (1.22×) 170 ns (0.57×) 112 ns (0.87×) 113 ns (0.86×)
Boundary (+SetGlobal) 212 ns 196 ns (1.08×) 188 ns (1.13×) 516 ns (0.41×) 177 ns (1.20×) 178 ns (1.20×)
真实负载 · Call 10 Predicate (×1000) 566 µs 581 µs (0.97×) 522 µs (1.08×) 967 µs (0.59×) 472 µs (1.20×) 480 µs (1.18×)
Transform (×1000) 423 µs 406 µs (1.04×) 407 µs (1.04×) 610 µs (0.69×) 418 µs (1.01×) 415 µs (1.02×)
真实负载 · CallInto 10 Predicate (×1000) 566 µs 442 µs (1.28×) 432 µs (1.31×) 856 µs (0.66×) 348 µs (1.63×) 350 µs (1.62×)
Transform (×1000) 423 µs 330 µs (1.28×) 301 µs (1.40×) 498 µs (0.85×) 293 µs (1.44×) 294 µs (1.44×)

列的含义

  • gopher — gopher-lua v1.1.2,基线。表格里的倍率都是 gopher / X,越大越好。
  • P1 — go build 默认档,纯解释器(crescent),没有升层机制,一列就够。
  • P3 auto / P3 force — wangshu_p3 wangshu_profile build 下 gibbous-wasm 编译层的两种测法(详见下节)。
  • P4 auto / P4 force — wangshu_p4 wangshu_profile build 下 gibbous-jit method JIT 的两种测法。
  • Call / CallInto — 嵌入 API 两种边界调用方式:st.Call 每次分配 []Value 返回切片;st.CallInto 复用调用方 dst,零分配。只在跨界 benchmark 拆两列。

— 表示该场景下不涉及 Call/CallInto 之分(PureVM 无跨界;升不到编译层的 baseline 短脚本没有独立数字)。

auto 与 force 只对 P3/P4 有意义

P3/P4 编译档不是「装了就一定用」。它们是基于热度阈值的自动升层机制:

  1. 每个函数(Proto)默认在 P1 crescent 解释器上跑。
  2. 每次调用累计一次调用计数;wangshu_profile build tag 开启这个采样器(不带此 tag 采样禁用,编译档退化到 P1)。
  3. 计数越过 HotEntryThreshold(默认 200)后,如果该 Proto 通过 F1-F7 可编译性检查,就升到 P3 或 P4,后续调用走编译层。
  4. 升不动的 Proto(协程 / 顶层 vararg / 含 ReasonUnknownCall / VARARG 等)保持在 P1,无声降级。

因此 P3/P4 每档在表格里各有两列:

  • auto — 生产模式。State 长期复用,前 ~200 次调用走 P1 解释器,越过阈值后升到编译层。b.N 上摊薄下来,warmup tail 通常在噪声之内。
  • force — SetForceAllPromote(true) 强制所有可升 Proto 直接升层,预热一轮后测稳态。非生产模式,只用于差分测试和 benchmark 上限。

两者稳态数字理论上应当接近;出现明显差异说明升层策略或阈值需要调整。

复现命令

上面三张表由 bench-readme-table workflow 产出——在 GitHub Actions 上对三平台(linux/amd64、linux/arm64、darwin/arm64)各跑一轮同一份脚本,原始日志与表格保存在 run artifact 里:

gh workflow run bench-readme-table.yml -f os=all -f count=3   # 三平台标准轮
gh workflow run bench-readme-table.yml --ref <branch> -f os=amd64  # 在任意分支上单平台跑

本地开发机跑同一个脚本(用于优化前后的 A/B 对照——同机同轮的相对比较比 hosted runner 更稳):

./scripts/bench-readme-table.sh              # 全跑 + 直接输出 Markdown 表格
./scripts/bench-readme-table.sh --count 5    # 每档跑 5 次取 median
./scripts/bench-readme-table.sh --format-only <logdir>  # 只重排已有日志,不重跑

脚本会自动探测 goos/goarch,同一条命令在任何平台复现对应表。

快速开始

最小示例

import "github.com/Liam0205/wangshu"

prog, err := wangshu.Compile([]byte(`
    local s = 0
    for i = 1, 100 do s = s + i * i end
    return s
`), "demo")
st := wangshu.NewState(wangshu.Options{})
results, err := prog.Run(st)
// results[0].Number() == 338350

Program 不可变,可跨 State 复用;State 每个 goroutine 独立一个。

列内核形状:一次跨界,循环全在 VM 内

批量数据处理场景推荐用 arena 列容器:宿主 Go 侧把 []float64 / []int64 / []bool / []string 挂进 arena,脚本侧看到 arena.price 这样的普通表,price[i] 直接读到 NaN-boxed 值,无需 per-item 跨界。

ar := wangshu.NewArena(nrows)
ar.AddFloatColumn("price", prices, nil) // present=nil 表示全部 present
ar.AddInt64Column("qty",   qtys,   nil)

prog, _ := wangshu.Compile([]byte(`
    local price, qty = arena.price, arena.qty
    local total = 0
    for i = 1, arena.rows do total = total + price[i] * qty[i] end
    return total
`), "kernel")

results, err := prog.Call(st, ar) // 单次跨界,循环全部在 VM 内

在四档执行模式之间切换

四档均通过 build tag 选择,源码零改动。默认 build 就是 P1;启用 P3/P4 需要显式带 tag,同 build 里默认走 auto(生产热度阈值 + F1-F7 可编译性检查),用 SetForceAllPromote(true) 切到 force(绕开热度阈值,非生产模式,用来跑差分测试与 benchmark)。

# P1 crescent 解释器(默认 build,永远可用)
go build ./...

# P3 gibbous-wasm 编译层(依赖 wazero)
go build -tags "wangshu_p3 wangshu_profile" ./...

# P4 gibbous-jit method JIT(自管原生码 codegen,amd64 + arm64)
go build -tags "wangshu_p4 wangshu_profile" ./...

wangshu_profile 是升层前置:不带此 tag 时热度采样禁用,无法进入升层路径。wangshu_p3 与 wangshu_p4 互斥,一次只能启用一档。

st := wangshu.NewState(wangshu.Options{})

// auto 模式:默认。等待 hot function 自然升层(依 HotEntryThreshold)。
_, _ = prog.Run(st)

// force 模式:**testing-only**,绕过阈值全升。生产不要开。
st.SetForceAllPromote(true)
_, _ = prog.Run(st)

// 观测升层是否真的发生了
n := st.PromotionCount() // >0 表示已经升层

SetForceAllPromote 只绕过热度阈值,不绕过 F1-F7 可编译性检查(协程、顶层 vararg、含 ReasonUnknownCall、含 VARARG opcode 的 proto 依然不升层)。升不动的 proto 无声降级回 P1 解释器,输出层间 byte-equal 不变。

生产环境的运行期开关与观测

分层执行的生产 admin API(与上面 testing-only 的 force 开关不同):

// 一键退回解释器:新升层停止,已升层的函数也回 P1 执行;
// 编译产物保留,重新打开即恢复,不需要重新编译。
st.SetTierEnabled(false)
st.SetTierEnabled(true)

// State 级分层执行分布快照
stats := st.TierStatsSnapshot()
// stats.Promoted            已升层 proto 数
// stats.StuckCompileFailed  真编译失败数——非零值得排查
// stats.TierEnabled         开关状态

部署要求(P4 的 exec-mmap 环境约束)、灰度建议与 step budget 在分层执行下的语义,详见 docs/embedding-tiers.md。

管理与复用 arena

Options 提供 arena 容量的初始值 / 上限:

st := wangshu.NewState(wangshu.Options{
    InitialArenaBytes: 64 * 1024,        // 初始 64 KiB
    MaxArenaBytes:     16 * 1024 * 1024, // 上限 16 MiB,超阈 fail-fast
})

统计指标:

st.GCCountKB()  // 当前已用 KB(live bytes;随 Collect 回落)
st.ArenaCapKB() // arena backing 容量 KB(grow-only;pool 层据此判 fat state 阈值)
st.PromotionCount() // 已升层 proto 数(testing-only 白盒断言)

显式驱动 GC:

st.Collect()           // 强制一次 full GC sweep
st.MaybeCollectNow()   // 依 host trigger 阈值判是否 collect(非强制)
st.SetHostTriggeredCollect(true) // opt-in:host 侧跨阈自动 collect(要求 transient GCRef 全 pin)

短脚本高频调用场景推荐用 CallInto 复用返回值切片,走零分配路径:

dst := make([]wangshu.Value, 0, 4)
for i := 0; i < 1000; i++ {
    n, err := st.CallInto(dst[:], fn, wangshu.String("item"))
    _ = n; _ = err
    // dst 复用,无 per-call 分配
}

长寿命 State 场景(规则引擎 hot reload / 数据流转换)搭配 arena 的 SetHostTriggeredCollect + Collect cadence,可以把 GC 压力压到近乎为零。

语言支持

望舒实现的是 Lua 5.1 核心语言(与 LuaJIT 一致的语法层),覆盖 Lua 5.1 参考手册中定义的 38 个字节码 opcode 除 VARARG 外的全部(VARARG 在 P3/P4 编译层永不接入,走 P1 解释器路径),以及 stdlib 的 base / string / table / math / os / coroutine 全部必做面。io 库提供 io.write / io.read / io.lines 与三个标准流(#205,2026-07-29):io.stdout / io.stdin / io.stderr 是真 file-handle userdata,type() 报 userdata 与官方一致,共享一张 metatable 提供 :write / :close / :read / :lines / :flush;仍缺的是需要真实文件的部分——io.open / io.popen / io.tmpfile / f:seek,所以 io.lines(filename) 抬错而不是静默返回一个空迭代器。debug 库提供 traceback 与 getinfo,其余(sethook / getlocal / setlocal / getupvalue / setupvalue / getregistry)仍不提供,它们需要解释器没有暴露的内省钩子;getinfo 只填能诚实回答的字段(currentline / source / short_src / what / func),nups / activelines / namewhat 宁缺不假造。缺口说明见 10 §10.1.1。

正确性验证分六层(每层的参照不同,注意第 5 层的参照是官方而第 4 层的参照是 P1 解释器):

  1. 官方测试套(按实际覆盖率读,不是「全套通过」):test/luasuite/testdata/ 里的文件与上游 lua5.1-tests 逐字节相同,跑到的部分逐字节一致——但套件里的文件数少于上游,而且多数文件在某一行被截断,按行号算只跑了约一半,只有少数文件从头跑到尾(vararg / sort / pm 是其中三个;最极端的 events.lua 只跑 3 行,因为它第 5 行就用 setfenv)。截断都是刻意不实现的功能(setfenv/getfenv、debug 高级面、io 对象模型、string.dump、require、真正的增量 GC),逐条登记在 test/luasuite/luasuite_test.go 的 stopAt 表与豁免清单里,不是隐藏的语义分歧。先前这里写「官方测试套 byte-equal:13 个官方文件」是不够的:它不带覆盖率,读起来像全套跑过了。还有一格更细:「在套件里」不等于「在跑」——有文件开头就是 if T == nil then ... return end(T 是官方发行里的 testC 调试库,望舒不提供),整文件零断言;所以按行号算的占比也不能当执行证据,要用执行侧的量(实际执行的断言次数)核。准确数字读 stopAt 表;口径与判据见 12 §2.1a。(缺席的上游文件各有明确原因,不是没接:code.lua/checktable.lua 需要官方 T testC C 侧调试外挂;verybig.lua 需要 io 对象模型豁免里的四个函数;api.lua 同理。db.lua 是补齐 lastlinedefined/activelines 之后接进来的。)
  2. 手册逐节 probe:100 项手册特性 + 12 项边角 + 29 条错误消息(含行号断言)+ 71 条种子用例逐字节一致。
  3. 差分随机 fuzz:nightly-diff-fuzz workflow 每晚 2M 条随机脚本与 Lua 5.1.5 oracle 做差分测试(P1 + P3 + P4 三档并行)。读它的失败时先确认那几个 fuzz 步骤真的执行过——准备类步骤(装 oracle、取包)失败会让它们被 skip,那一轮报红而实际什么都没测,与「跑了并且发现分歧」在 Actions 页面上是同一个红叉(#236–#241,见下)。
  4. 三方差分:crescent(P1)vs gibbous(P3/P4)在 P4 build 下每 CI 跑一次 byte-equal,PR #29/#31 tri-platform matrix 全绿。
  5. cgo 内嵌 oracle 差分 fuzz:internal/oracle 把官方 5.1.5 源码经 cgo 嵌进测试二进制(build tag wangshu_oracle_cgo,默认 build 保持零 cgo),FuzzOracleDiff 用 go-fuzz 变异的任意不规则源码(不限于 generator 的规整脚本)在进程内做差分比对:两侧跑同一段 prelude(输出捕获 + 确定性 stub + 排序迭代 + 白名单裁剪)后比输出经地址归一后 byte-equal,没有任何接受差异的路径(地址归一与实现常数类护栏跳过属于另外两类,见下)。PR 检查跑 60s 冒烟(oracle-smoke job),nightly p1 腿连续跑 45 分钟。上线首日(长时间 fuzz + 配套的 stdlib 全函数 × 退化参数系统性扫描)抓出 32 处 P1 与官方的语义分歧并全部修复——覆盖 string 库 number 自动转串、__tostring 原样透传、协程错误消息 type 名、未知转义字面放行、长括号嵌套 deprecation、upper/lower 按字节、string.format verb 集/无符号转型/%c NUL 截断/scanformat 硬限、编译期常量折叠 ±0/NaN 规则与 RK 物化序、tonumber 的 C99 strtod 接受面(hex float / inf / nan)、表构造器源码序覆盖语义、真实共享 string 元表(getmetatable("") 可改且全部元方法全局生效)、load 只收 reader 函数、os.time 表协议等;上线后持续巡检又修 #170/#171(string.format NaN/Inf 渲染对齐 glibc,PR #172)/ #174/#175(toNumberStr 走 crescent.ParseLuaNumber,PR #176)/ #177(P4 shape-template FORLOOP deopt 补 preempt 与 body 副作用,PR #178)/ #192/#193/#194/#196 那一轮(2026-07-28,四个 issue 修出六个根因:C99 nan(n-char-sequence) 前缀、tonumber(x, 10) 整条路由错(一个原因造出六条分歧,五条没有 issue)、%d/%i 精度 0 配值 0 丢符号、table.insert 的 5.1 无边界检查语义 + table.concat 错误文本、string.char 的两步转换、strtoul 的无符号取反与溢出饱和(原先那条「已登记豁免」没有任何代码实现它);另有 45 秒 fuzz 冒烟发现 table 库缺 , got no value 从句)/ #197/#198/#199 那一轮(2026-07-28:error(msg, level) 的位置前缀真的按 level 选帧——host 边界算一个 level 而不是终点,且一个边界可能代表多个叠起来的 host 帧(pcall(pcall,f) 在 PUC 是两个 C 帧),所以每帧的 host 帧计数存进了 callInfo 的打包位;%g 补上 C 的默认精度 6(显式精度本来就对,%e/%f 的默认也本来就对,只有 %g 的默认不同);gmatch 的前导 ^ 是普通字符不是锚(PUC 的 gmatch_aux 没有 anchor 处理);assert 第二参数走 luaL_optstring 只收 string/number;math.modf 对无穷、math.frexp 在 DBL_MAX 附近、math.rad 溢出三处 Go math 包与 C 的差;io.write 返回布尔成功标志(issue 里写的「返回文件句柄」是错的,那是 5.2+);os.date 从六个指令扩到完整指令循环 + *t 与 ! 前缀)/ #232/#233/#234 那一轮(2026-08-05,定时巡检的第一次实际处理轮,三个 crasher 全部如实复现,而 #232 与 #233 是同一个根因的两种可见度——都是引用值地址:#232 是地址归一的锚点用 \b 而 word 字符包含数字,io.write(0)print(print) 产生的 0function: 0x... 整段逃过归一化;#233 是脚本测量地址的长度(#tostring(t) PUC 21 对望舒 17),长度在归一化之前就分歧、NormalizeOutput 物理上到不了,所以在 oracle 的 prelude 渲染处对齐——归一化管值、渲染处管宽度;#234 是引擎侧 gsub 替换串的 % 转义,add_s 的分支有三个出口加一个越界读,望舒只对了两个,「% 后非数字原样吐出」那一半是既有缺陷)/ #244 那一轮(2026-08-11,崩的是 oracle 不是望舒:A(unpack({},0X80000000)) 让内嵌 oracle 收到 SIGSEGV(栈迹在 cgo 里)、真的 lua5.1 二进制同样 dumped core,而望舒对整段抬 too many results to unpack、行为正确且从不崩——PUC 的 luaB_unpack 把 n = e - i + 1 算在 int 上并用 n <= 0 检查,而这个减法本身是有符号溢出 UB——gcc -O2 把该检查当不可达整段删掉,lua_checkstack 随后收到负的 size 并照单接受(lapi.c 两个比较都为假),于是崩掉;同一份源码在 -O0 下干净抬错,所以这个崩溃依赖优化等级。(此前这里写「回绕成正的巨大值绕过检查」是错的:i <= e 时 int32 回绕恒 <= 0,那个检查本该拦住所有情形——错的机制不影响守卫条件,但会让读者以为与优化无关。)处置与其余 PUC UB range 一致:差分侧跳过,会死的 oracle 不能当参照。分诊纪律:差分 harness 报 crash 时先读栈迹属于哪一侧、并拿真的参照实现二进制跑一次。守卫经四轮审计才定下来:区间读 i 与 e 两个量(i32 <= e32 且 (e32-i32+1) > INT_MAX,e 默认 #t)、窄化走 luaL_checkint 自己那条链(__ckint0,而不是手搓一份)、math.floor/tonumber 在脚本运行前捕获成 local(读活全局时一个 math.floor=function() return 0 end 的输入就能把守卫算成 0、让 oracle 段错误 —— 守卫可以被它所守卫的输入绕开)、非表首参不再被跳(luaL_checktype 先抬,两侧一致)。用例见 internal/oracle/unpack_guard_test.go(13 条必须 skip 含 5 条绕过尝试、12 条必须仍比较,两个方向都用变异确认),见 12 §4.9f。
  6. 分层直接对内嵌 oracle 的差分 fuzz(2026-08-29,FuzzOracleDiffTiered):第 5 层那个 FuzzOracleDiff 测的是 P1 对官方。在这一层之前,P3/P4 对官方的一致性全部是传递来的 —— FuzzAutoPromote / FuzzP4ForceAllPromote 断言的是「分层结果 == P1 结果」,「分层 == 官方」是经 「P1 == 官方」推出来的。传递性本身没错,但它继承 P1 那次比较的每一个盲区,而且对「P1 与官方一致、而两个 tier 各自也一致地错」这类情况没有直接观察能力;第 3 层那条按 tier 对真 lua5.1 跑的路走的是手写 generator(vararg / goto / pcall / error 的生成计数都是 0),覆盖的写法远窄于 go-fuzz 变异。这一层补的就是那条直接轴:任意变异源码在强制升层的 State 上跑,与内嵌 5.1.5 比对,跳过集合与 P1 那一层完全相同(不为 tier 开新的跳过——「tier 与官方分歧而 P1 不分歧」正是它存在的理由)。升层是断言出来的而不是假设的:FuzzOracleDiff 本来就能在 tier 的 build tag 下编译并通过(它构造普通 State、从不升层,tier 代码被链接进来但从未进入),所以一个忘记升层的分层 harness 与一个通过的测试在输出上完全一样、连一条 skip 都不留;配套守护测试断言升层计数增长并做过变异实测,CI 另加一条 required-target 存在性断言防 build tag 打错导致目标静默消失。首轮结果:两个 tier 都观察到升层,短时引导式 fuzz 未发现分歧 —— 这是第一个结果,不是一张清白证明。口径见 12 §3.8a、P3 08 §2.5a、P4 08 §3.4a。

NaN 符号渲染差异在 oracle 侧消除,不在比较侧豁免:0/0 转文本时 glibc 的 printf 按符号位打 -nan,wangshu 打 nan,差一个字节;IEEE 754 不赋 NaN 符号位数值语义,两种输出都合规。oracle 是仓库自己 vendor 并用 cgo 编译、只为做差分基准而存在的(且已经为确定性 stub 掉 os.time/math.random/pairs 顺序),所以在 internal/oracle/lua515.c 里覆盖 lua_number2str、并对 lstrlib.c 的 include 局部 shadow sprintf,把 NaN 渲染的符号去掉(保持字段宽度:sprintf 已经补过 padding,且 buffer 分不清前导空格是 padding 还是空格 flag 的符号,所以按 format spec 里的声明宽度重新补齐;符号去掉后 glibc 的 +/空格 flag 开始作用于 NaN,所以剥的是任何符号字符;Inf 保留符号,脚本自己写的 "-nan" 字面量不改);vendored 源码保持与记录的 sha256 逐字节一致。同轮清掉产品侧两处 glibc 模仿(internal/stdlib/stringlib.go:string.format 原本对大写 verb 硬编码 -NAN,让 %e 与 %E 自相矛盾;小写 NaN 原本按「声明宽度减一」补齐以复现 glibc 保留但不显示的符号列,让 %5f 与 %5E 补齐方式不一致)——两处都只为让 oracle 一致而存在,而 arm64 的 glibc 与 x86 还不同,模仿本来就不可移植;现在 NaN 在所有 verb 下都不带符号、都按完整声明宽度补齐,wangshu 自身也一致了。曾经的设计与它为何失败(#173 引入、#184/#185 延伸,已全部删除):旧设计把这个差异当作「不可比的类」,在 harness 侧识别并豁免(__nan_spans 区间 FIFO + 多行 readout header + CompareOutput 的 span-anchored 分类 + 一整层入口拦截)。这条路不可能收敛:那个符号字节一旦进入字符串就是普通数据,#、==、string.sub、..、算术能把它搬到任何地方——string.len(0/0) 是 4 对 3,string.len(0/0)*100 是 400 对 300,输出里连一个 nan 字节都不剩,没有任何可锚定的东西;任何下游识别规则都必须读一个两侧不相等的量,所以都做不到对称。新设计约少 550 行,且两个 crasher 与整个「泄漏成数字」的家族现在是普通的 equal 而不是 skip,覆盖面是增加的(那些输入以前会连同同一次运行里的真差异一起被丢掉)。保留的两类豁免性质不同:地址归一(table: 0x...)是两个引擎堆布局本来不同、没有「正确值」可对齐,只能在比较时归一;实现常数类护栏(stack overflow、too many syntax levels、200 local variables 等)是两侧独立选定的实现限制,改 oracle 的常数去凑 wangshu 会让它不再是独立基准,只能 skip。判据:差异如果来自「同一个抽象值的不同书写方式」,在渲染处消除;如果来自「两侧本来就是不同的东西」,在比较时归一或跳过。后续验证:nightly 在四个不同日期自动开出的 crasher #187 / #188 / #189 / #190(string.format("%q",0%0)、string.format((0))<string.format((0%0))、string.format("%q",(0%0))、string.format("+% E",-(0%0)))全部是这同一个根因的表现,被上面的渲染处消除一起解决,一行代码没改;双向验证确认它们在 redesign 之前的 base 上全部 FAIL、在现在的实现上全部以真正的逐字节 equal 通过而不是 skip。四条 reproducer 加 10 条延伸 seed 入 testdata/fuzz/FuzzOracleDiff/(共 73 个),理由是它们触到三个已有 seed 到不了的写法:%q 作用于 NaN(引用渲染结果而不做浮点格式化)、比较两个 string.format 的返回值(差异变成一个 boolean,输出里根本没有 NaN 文本)、多个符号 flag 同时出现。另扫了 236 + 140 种同族写法(%q × 宽度、比较运算符 × format 与 tostring 结果、flag 组合 × 浮点 verb;产生 NaN 的方式 × 消费其文本的方式)确认整个家族零差异。「在渲染处消除」在 2026-08-05 第二次被用上(#233):#tostring(t) 量的是地址的宽度(PUC %p 12 位给 21、望舒 0x%08x 8 位给 17),长度在归一化之前就分歧——与 string.len(0/0) 4 对 3 完全一样的机制,所以 prelude 包一层 tostring 把 PUC 自己的地址渲染成望舒的 8 位,望舒侧的宽度成为一条约定,由 fuzz_234_test.go::TestAddressLengthIsComparable 固定下来。所以那条判据要读细一格:判去「比较时归一」的那一类里仍可能有一个子量必须在渲染侧对齐——地址的值只能归一,地址的宽度是渲染选择,会被 # 测量成普通数字。

豁免清单(权威来源是 test/difftest/corners_test.go::exemptions,go test -v -run TestExemptions_Documented 可审计;下表按类别归并,行数与该清单的条目数不是一对一,所以这里不写条目总数——先前写的「共 15 项」与两边都对不上。另有 string.char 的 C UB 一项,执行体在差分 harness 侧 internal/oracle/prelude.go 的 sentinel 而不在这张表里):

类别 具体项 豁免原因
Lua 5.2+ 特性 rawlen、table.pack / table.move 与 5.1 手册不符
Lua 5.3+ 特性 math.tointeger / type / maxinteger / mininteger 整数类型属 5.3 特性
嵌入式安全 os.execute、io.popen / io.tmpfile、os.exit 真退出、loadfile / dofile 默认禁用 嵌入式 VM 不让脚本跑 shell / 越权文件系统
真实文件 IO io.open / f:seek(三个标准流与 io.read / io.lines 已提供) 需要文件系统访问控制 + __gc 关文件那一环,P1 不做
Debug 接口 debug.sethook / getlocal / setlocal / getupvalue / setupvalue / getregistry 需要解释器内部 hook,成本收益不划算
模块系统 require / module / package 嵌入式宿主经 Compile 提供脚本,不从文件系统 require
字节码序列化 string.dump 自定义 ISA 不兼容官方 .luc
环境操作 getfenv / setfenv 与 P2 分层桥 F4 形状分析冲突
C 未定义行为 string.char 的越界 double→int 转换 luaL_checkint 是 (int)luaL_checkinteger:double 先变 lua_Integer 再窄化成 int,越界与 NaN 落进 C UB,两个官方 build 互不一致(x86-64 cvttsd2si → INT64_MIN,低 32 位 0,PUC 接受得 byte 0;arm64 FCVTZS 把 +inf 饱和到 INT64_MAX,低 32 位 -1,PUC 报错)。产品侧钉 x86-64 结果(与 %u/%x/%o 的 cUnsignedCast 同一手法),差分侧跳过这段区间;in-range 的值(含 2^53)照旧比对
灾难性回溯 pattern 灾难回溯 .*.+%A*x 回溯预算 1<<20 步硬限,报 pattern too complex(嵌入式防挂起)
增量 GC collectgarbage("step"/"setstepmul") STW GC 无增量调参,占位返回

其余「存在但不逐字节比」的项(collectgarbage("count") / gcinfo / os.time / os.clock / os.date("%Y") / io.write / loadfile 返错格式)由 TestApprox_ExistenceOnly 只断言返回值格式不比数值——这一类不是「不可比」而是「值随运行环境变」(时间、内存、文件系统),所以 os.date 的指令输出并不在这一类里,它已经逐字节核对过(2026-07-28)。

文档导航

按角色路径:

欢迎贡献

Issue 与 PR 都欢迎。基本步骤:

开发环境:Go 1.27+(go.mod 的 go 指令是 1.27.0,更早的工具链会直接拒绝构建;升到 1.27.0 的原因见 engineering.md §1.1),Linux/amd64、Linux/arm64 或 macOS/arm64(其他 GOOS/GOARCH 组合按纯 Go stub 编译过但未实际运行测试)。可选依赖:lua5.1(官方 oracle,差分测试用;apt install lua5.1 或源码编译 5.1.5)、golangci-lint(lint)。

常用 make 目标:

make all              # 提交前本地全检:fmt + lint + build-all + test-all + fuzz-all + conformance + difftest-all
make test-p4          # 单独跑 P4 build 全套测试
make test-p3          # 单独跑 P3 build
make difftest         # 三档 × 三平台差分测试
make fuzz-p4          # P4 build 下 fuzz 冒烟
make fuzz-oracle      # cgo 内嵌官方 5.1.5 进程内差分 fuzz(需本机 gcc)
make bench            # baseline 微基准
make release TAG=vX.Y.Z MESSAGE_FILE=notes.txt  # 打 annotated tag(本地不 push)

提交流程:

  1. Fork + 建 feature 分支(不要直接 push master)。
  2. 本地 make all 通过。
  3. Commit message 用英文,subject 单行 ≤ 72 字符 ASCII,body 中文可以,说清 why 与 how。
  4. PR 描述必须包含变更范围、测试情况、是否引入外部依赖(zero-cgo / 主库 zero 外部依赖是硬承诺)。
  5. PR 触发 CI(三平台 × 三 build × test/fuzz-smoke/conformance/difftest 全绿)+ agentic-pr-review bot 自动审阅;bot 提 REQUEST_CHANGES 须响应,APPROVE 后 maintainer 审 merge。
  6. 大改动之前建议先开 issue 讨论方向。

Bug report 请附最小复现脚本、Go 版本、GOOS/GOARCH、make all 输出。若涉及输出与官方 5.1.5 不一致,一起附上 lua5.1 -e ... 的 stdout 对比。

许可证

Apache License 2.0,见 LICENSE(若文件缺失以 go.mod 声明为准)。

用人话总结:

  • 可自由地用、改、分发、商用,包括嵌入闭源产品;
  • 保留 LICENSE 与版权声明;
  • 若你的分发物包含本项目的改动,简要标注改动点即可;
  • 项目方无担保义务,AS IS。

Footnotes

  1. benchmarks/baseline。三个独立的纯 Lua 脚本(Simple 分支比较、Arith 六阶 Horner 多项式、Loop 求和 1..N),单次执行无 Go↔Lua 跨界。反映 VM 内核在最小工作量下的 dispatch / 算术 / 循环开销。 ↩ ↩2 ↩3

  2. baseline P3 列的工作负载与其它列不同(issue #93):顶层 chunk 是 vararg 永不升层,P3 必须测「包进内层 kernel 调 50 次」的形状;其它列跑裸顶层 ×1。因此 P3 列的倍率分母是同形状的 gopher 基准(_GopherKernel,gopher 跑一样的 kernel×50),wall time 与同行其它列不可直接比(工作量 ≈50 倍)。此前表格误拿顶层 ×1 的 gopher 当分母,把 P3 低估约 50 倍(旧表 0.06×-0.25× 实为 1.3×-3.2×)。各平台表均按修正后口径产出。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 ↩14 ↩15 ↩16 ↩17 ↩18

  3. benchmarks/heavy。三个扁平数值内核(HeavyArith 纯算术、HeavyRecursion 自递归、HeavyFloatloop 嵌套浮点循环),故意剔除表 / 字符串 / library CALL 与其他 helper-bound 结构。反映编译档在能真正发挥的形状上的性能上限。 ↩ ↩2 ↩3

  4. P4 mono 自尾调用段内循环(issue #112 / PR #113,2026-07-10,amd64 + arm64 已交付):return f(...) 且被调就是当前 closure 时,段内直接参数搬移 + 跳回入口(PUC 尾调用帧复用语义下与重进本段位级等价),不再每层付一次段退出 + Go 重入。HeavyRecursion(collatz,递归调用全是 TAILCALL)此前是全表唯一「升层比 P1 解释器还慢」的负载(amd64 1.15× vs P1 1.58×;Cobalt arm64 0.98× 直接输 gopher),本轮提升到 amd64 4.93× / arm64 3.93× / macOS 6.23×,三平台 ~4× 改善,fib / HeavyArith 同轮逐字持平无回归。 ↩ ↩2 ↩3

  5. benchmarks/realworld。benchmark-game 五脚本(fib / binary-trees / spectral-norm / fannkuch / n-body),语义单次通过与官方 lua5.1.5 做差分测试(逐字节比对)。反映调用 / 分配 / 浮点 / 表操作混合场景下的常规负载。 ↩ ↩2 ↩3

  6. P3 auto 模式带 helper 密度收益门(issue #39,2026-07-03):热 proto 的 op 组合里 helper 往返占比过高(wasm→Go 边界成本吞掉升层收益)时拒绝升层、留在解释器。带此标注的行升层被拒,数字即解释器执行(与 P1 列的差异是采样钩子开销)。P3 force 列不受影响(force-all 绕过收益门,保差分覆盖)。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12

  7. P4 段到段 CALL 直跳(issue #50,2026-07-04,amd64 + arm64 已交付):自递归 / arith-callee(fib 形状)之前每次调用都要付一次跨界往返的开销(mmap RET → Go dispatch → host.CallBaseline → mmap 重入),现在 caller 段直接 call 进 callee 段、callee 段内组拆帧 + native 递归、全程不出 mmap。fib / spectral-norm / fannkuch 等自递归与 arith-callee 负载因此提升到两位数倍率;binary-trees 的 check(自递归 + GETTABLE ArrayHit 读表)随 ArrayHit 站点纳入段到段资格而解锁,剩余瓶颈是 bottomup 的分配。arm64 同批交付,双架构收益形状一致(当前各平台数字见上方三表)。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 ↩14 ↩15

  8. P4 math.* intrinsic emission(issue #77 / PR #87,2026-07-08,amd64 + arm64 已交付):CALL 站点 IC 观察到被调是已知纯数值 host closure(sqrt / floor / ceil / abs / max / min)时,段内直接发射硬件指令(amd64 SQRTSD / ROUNDSD 等)而不再 exit-reason 往返到 Go host closure。n-body 的稳态几乎全是 sqrt(dist2) 调用,之前既因每次 sqrt 付一次跨界往返、又因 CALL 密度门把带 sqrt 的热函数误判成「调用太密、升层不划算」而拒绝升层,两头卡住(P4 ≈ P1,1.41×);#77 一并修好后(intrinsic CALL 不计入密度门 + sqrt 内联发射),n-body 从 ~P1 水平提升到两位数倍率,双架构一致(arm64 走 FSQRT / FRINTM 等对应指令),结果与解释器逐字节一致(含 NaN / Inf / ±0)。当前各平台数字见上方三表。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6

  9. benchmarks/embedded,mini_bench_test.go。嵌入路径的最小形式:每 iter 一次 SetGlobal + 一次 Call + 一次读结果。反映边界往返成本本身,以及 Call 分配路径与 CallInto 零分配路径的成本差。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6

  10. benchmarks/embedded,realworld_embedded_bench_test.go。1000 item batch,逐 item set 字段 → Call 谓词 / 特征变换脚本 → 读标量结果,写法贴近 pineapple transform_by_lua。反映真实批处理嵌入下的稳态吞吐。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6

About

望舒 Wangshu — A high-performance embeddable Lua 5.1 VM in pure Go (no cgo): NaN-boxing, arena GC, inline caches, coroutines, and a tiered method JIT (interpreter → wasm → native amd64/arm64). Up to 17x faster than gopher-lua on numeric kernels, byte-equal with PUC Lua 5.1.5.

Topics

Resources

Stars

8 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages