Rust 里一个 16 字节的 enum,吃掉了解释器的一半内存——换成 64 位字之后整体快了 17%

你写 Rust 的时候一定觉得 tagged enum 是世界上最舒服的东西:一个 match 就能优雅地分发所有类型,编译器帮你把标签位塞好。但如果你在做的是一个动态类型语言的解释器,这个「舒服」是要还债的——而且这笔债的利息,是你解释器里最贵的那部分内存带宽。

Plush 是一个用 Rust 写的小型动态类型语言(作者 Maxime Chevalier-Boisvert,就是写出 Higgs JIT 的那位)。它原本的 Value 类型就是一个朴素的 Rust enum,长这样:

enum Value {
    Undef,
    Nil,
    False,
    True,
    Int64(i64),
    Float64(f64),
    String(*const Str),
    HostFn(&'static HostFn),
    Fun(FunId),
    Closure(*mut Closure),
    Cell(*mut Value),
    Object(*mut Object),
    Array(*mut Array),
    ByteArray(*mut ByteArray),
    Dict(*mut Dict),
    Class(ClassId),
}

每个变体自己最多只需要 64 位,enum 的标签位只要 8 位。听起来很省对吧?但内存对齐规则决定了:这个 enum 实际占 16 字节(128 位)。因为里面藏着 f64i64,编译器会把整个 enum 对齐到 8 字节边界,标签又得单独占位,于是每个值都白白浪费掉一半空间。

单个值浪费无所谓,真正致命的是数组。解释器里到处是 Vec<Value> 这种紧凑数组——一百万个元素,你就白白背了一百万 × 8 字节的内存搬运量。作者的原话是:「这种事能让 VM 工程师在深夜痛哭。」

解法:把 128 位压回 64 位的低位标记

思路是一个从 Lisp / Lua / V8 时代流传下来的经典技巧——低位标记(low-bit tagging)。核心观察有两个:

  1. 64 位系统上,堆对象地址按 8 字节对齐,意味着地址最低 3 位一定是 0。这三个空位可以「偷」来存类型标签。
  2. 整数几乎不会真的用到完整的 64 位范围(2^64 ≈ 1.84 × 10^19)。你把一个循环从 0 跑到 UINT64_MAX,现代 CPU 也得跑十几年。所以最低 2 位可以借出去当标签

作者最后落地的编码方案把 5 类值塞进了一个 u64:

  • 62 位有符号定点数(fixnums):标签位全给 0。这是最妙的一点——整数加减就还是普通的 add/sub 机器指令,不需要任何解包。
  • 浮点数(flonums):用一套自标记(self-tagging)方案,保留完整尾数精度,还要专门处理 NaN、+0、-0 这些边界。
  • 立即数(immediates):用一个 5 位子标签区分 nil / true / false / undef / 函数 id / class id / host function。
  • 对象指针:低位标记的 8 字节对齐指针。
  • 句柄指针(pointers to handles):应对需要间接访问的罕见情况。

还有一个很聪明的设计:专门留一个标签位,用来标记「可以一条比较指令直接比完」的值。这样循环条件、nil 检查这些解释器最热的分支就变成了单条 CPU 指令。

实现上,新的 Value 就是一个包着 u64 的 Rust newtype,所有便捷方法都标了 #[always_inline],保证解释器主循环一分钱开销都不多付。作者特别提到,这套方案是和 Claude 一起共同设计迭代出来的。

结果:所有基准都变快,内存密集型最狠

最反直觉的地方在于:作者原本以为「加了位运算会增加 CPU 指令,可能得用一点性能换内存」,结果恐惧完全没必要

  • 所有基准测试用例都变快了,整体提升约 17%
  • 内存密集型测试(binary_tree、linked_list)收益最大——对象体积减半带来的缓存友好性,直接变成速度。内存占用最多降低 37%
  • 连浮点密集的 mlp、nbody 也变快了,尽管新方案反而多了 unboxing / reboxing 的工作。

作者还去对比了反汇编,发现了一个很扎心的事:旧的 Rust enum match 分发,编译器生成的代码出奇地低效,有大量栈溢出(stack spills);而新表示法让一个值正好装进单个寄存器,生成的机器码短得多、快得多。

换句话说,这不是「和编译器斗智斗勇」,而是 Rust 的安全 enum 抽象在这个特定场景下,本来就无法替你想出最优的内存布局——指望编译器自动发明低位标记编码,是不现实的

这对前端 / 泛 VM 工作者的意义

这个案例的价值不在于「你也去手写位运算」,而在于它揭示了一个通用的性能直觉:

  • 你以为的性能瓶颈在算法,其实经常在内存带宽和缓存命中率。 一个值从 16 字节变 8 字节,就是数组遍历时的内存流量直接减半。
  • 抽象是有成本的,而且往往藏在看不见的对齐填充里。 Rust enum 的便利、C++ union 的自由、JS 的「万物皆 double」,都在为某种东西付账。
  • 热点路径上的类型分发,值得为它专门设计数据布局。 这也是为什么 Lua 用 TValue、V8 用 Smi + 指针标记——动态语言引擎到这个量级,值表示就是核心竞争力。

社区在讨论里给出了一个很实用的进阶建议:如果你也想在自己的系统里做这套,可以参考 triomphe crate 的 ArcUnion,思路就是「专门为你的 64 位联合类型写一个 crate,里面用 unsafe 实现,配 miri 测试」,这样就能得到一个既能 match 又安全的 64 位值类型。唯一要注意的是:用 miri 验证时,指针调整必须用「保留来源(provenance-preserving)」的函数。

作者的下一步是把解释器改成基于寄存器(register-based)的设计,目标是让 Plush 快到能实时渲染 3D 动画——目前已经能交互式渲染 10000 个多边形了。

你可以落地的下一步:

  1. 打开你的项目里最热的那个结构体/枚举,用 std::mem::size_of 打印一下它的真实大小,看看有没有你没想到的对齐填充。
  2. 如果它在数组里出现几十万次,算一下「理论最小值 × 元素数」和「实际大小 × 元素数」的差值——那就是白送的内存流量。
  3. 真要做值表示优化,别自己造轮子硬刚:先用 #[repr(u8)]Box 拆分冷热字段、enum 里把大字段装箱等低风险手段压体积,再考虑低位标记这种需要 unsafe + miri 的终极方案。

评论区

0 条评论

登录后可评论。

铁锈·Rust工具链 15 阅读