Replacing a Rust Enum with a 64-bit Word Made My Interpreter 17% Faster
August 25th, 2026

This blog post is the sixth in a series about my work building and optimizing the Plush language interpreter and virtual machine. The previous one was Speeding Up the Plush Garbage Collector. In the last post, I explained how a few simple changes made the copying GC over 16x faster, and brought the collection time for a million objects down to around 7 ms. I'm having a lot of fun optimizing Plush just for the sake of it, but I'm also doing it with the goal in mind of being able to make the language fast enough to render 3D animations in real-time, even though it's interpreted.
Plush is a dynamically-typed language, in the same family as Python, JavaScript, Ruby, Lua, and Lox. Dynamic languages like this have the property that types are attached to values rather than variables, and so to propagate values around programs, an interpreter typically has a Value type that can represent any value that could exist in the language. What I did with the original version of Plush is that I used a plain Rust tagged enum. This is nice because Rust makes working with tagged enums very convenient, as we can dispatch to different Value subtypes using the match statement:
// The old Rust Value type as a tagged enum
enum Value {
Undef, // Uninitialized var or field, reading triggers an error
Nil,
False,
True,
Int64(i64),
Float64(f64),
String(*const Str), // Immutable string
HostFn(&'static HostFn), // Function exposed by host VM
Fun(FunId), // Non-closure Plush function
Closure(*mut Closure), // Closure that captures variables
Cell(*mut Value), // Mutable variable captured by a closure
Object(*mut Object), // Class instances
Array(*mut Array), // JS/Python style array/list
ByteArray(*mut ByteArray), // Fast raw byte array (e.g. frame buffer)
Dict(*mut Dict), // JS/Python style dict
Class(ClassId),
}
As you can see above, Plush, even though I still consider it a toy language, has many different value types. The language has objects which are class instances, which are efficient to access, but it also has JS/Python style dictionaries, which make JSON-style syntax possible. There are also two distinct numerical types, Int64 and Float64. I made this choice because it always kind of bothered me that JavaScript pretends everything is a double, while JS engines will actually keep track of what's an integer behind the scenes. The thing that's most unfortunate though is not the number of enum variants here, it's that this enum is a whole 16 bytes (128 bits) wide. Each enum variant needs only 64 bits, and the enum tag that Rust creates only needs 8 bits, but because of memory alignment constraints, Rust may need to use a whole 128 bits for each value. It might seem like no big deal, but if you have a large array of values, that array will end up with a ton of empty, wasted bytes inside of it. This is the kind of thing that makes VM engineers cry themselves to sleep at night.
For a little while now, I've been thinking that I could design a more efficient low-bit tagging scheme to make it so that the Value type fits inside of 64-bits. There's a classic trick which is derived from the fact that on a 64-bit system, heap object addresses are typically aligned to 8-byte boundaries, which means that the lowest 3 bits of the address must be zero. That means you can essentially steal these bits to pack extra information in there. You can also borrow the two lowest bits of integer values, with the assumption that integer values will very rarely need to use the full 64-bit range, because for reference 2^64 ~= 1.84 * 10^19. That's a very large value. If you have a variable that represents say, the number of lines in a text file, or the number of enemies in your game, or any other numerical quantity, it's very unlikely to reach that value. With a modern CPU that can dispatch multiple instructions per clock cycle, if you were to execute a loop such as `for (uint64_t i = 0; i {
let mut v1 = pop!();
let mut v0 = pop!();
let r = match (v0, v1) { (Int64(v0), Int64(v1)) => Int64(v0 + v1), (Float64(v0), Float64(v1)) => Float64(v0 + v1), (Int64(v0), Float64(v1)) => Float64(v0 as f64 + v1), (Float64(v0), Int64(v1)) => Float64(v0 + v1 as f64),
(Value::String(s0), Value::String(s1)) => { // ...string concatenation, elided }
_ => error!("add", "unsupported operand types") };
push!(r); }
Looking at the disassembly for the `Int64 + Int64` fast path, it's spread over four disjoint basic blocks. The code is fairly massive, and it has a bunch of spills to the native C/Rust stack as well as memory accesses from the interpreter stack:
```assembly
; ---- block A @ 0x1a8cc : pop v1, then pop v0 -----------------------
ldr x10, [x20] ; len = stack.len()
cbz x10, .Lunderflow
sub x11, x10, #1
str x11, [x19, #0x60]
ldr x8, [x19, #0x50] ; stack base pointer
cmp x11, x8
b.hs .Lpanic
ldr x12, [x19, #0x58] ; stack base pointer
add x13, x12, x11, lsl #4 ; &stack[len-1], note the 16-byte stride
ldr w9, [x13] ; v1: the tag
ldr w14, [x13, #0xc] ; v1: payload bytes 12..16
ldur x13, [x13, #0x4] ; v1: payload bytes 4..12, unaligned
str w9, [sp, #0x2b8] ; spill v1 into a stack slot...
ldr x15, [sp, #0x48] ; ...whose address is itself in a stack slot
str x13, [x15]
str w14, [x15, #0x8]
cbz x11, .Lunderflow
sub x23, x10, #2
str x23, [x20]
add x11, x12, x23, lsl #4 ; &stack[len-2]
ldr w10, [x11] ; v0: the tag
ldr w12, [x11, #0xc]
ldur x11, [x11, #0x4]
str w10, [sp, #0x2d0] ; spill v0 the same way
ldr x13, [sp, #0x50]
str x11, [x13]
str w12, [x13, #0x8]
; ---- the match dispatch --------------------------------------------
ldr x22, [sp, #0x2d8] ; reload v0's payload we just spilled
ldr x0, [sp, #0x2c0] ; reload v1's payload we just spilled
cmp w10, #4 ; is v0 an Int64?
b.eq .Lv0_int ; taken, 0x120c bytes away
cmp w10, #5 ; a Float64?
b.eq .Lv0_float
cmp w10, #6 ; a String?
b.ne .Ltype_error
; ---- block B @ 0x1bad8 ---------------------------------------------
.Lv0_int:
cmp w9, #4 ; is v1 an Int64?
b.eq .Lint_int ; taken, 0x5a4 bytes away
; ---- block C @ 0x1c07c ---------------------------------------------
.Lint_int:
adds x22, x22, x0 ; the actual addition
b.vs .Lpanic_add_overflow
mov w24, #4 ; result tag = Int64
b .Lpush ; taken, 0x2c0 bytes away
; ---- block D @ 0x1c33c : push! -------------------------------------
.Lpush:
cmp x23, x8
b.eq .Lgrow
ldr x8, [x19, #0x58]
add x8, x8, x23, lsl #4
str w24, [x8] ; store the tag
str x22, [x8, #0x8] ; store the payload
add x8, x23, #1
b .Ldispatch
This is the add instruction and its fast path with the new tagged word version presented in this post (6b71f8c, src/vm.rs:1391):
Insn::add => {
let v1 = pop!();
let v0 = pop!();
// Tagged fixnums add as they are, and a 64-bit
// overflow is exactly the case where the sum no
// longer fits in one
if v0.is_fixnum() && v1.is_fixnum() {
if let Some(sum) = (v0.raw() as i64).checked_add(v1.raw() as i64) {
push!(Value::from_raw(sum as u64));
continue;
}
}
flonum_op!(v0, v1, +);
let r = slow!("add", self.add_slow(v0, v1));
push!(r);
}
The disassembly for the fast path is shown below:
; ---- block A @ 0x19cc0 : pop v1, pop v0, test the tags -------------
ldr x10, [x5] ; len = stack.len()
cbz x10, .Lunderflow
sub x8, x10, #1
str x8, [x5]
ldr x9, [x16] ; stack base pointer
cmp x8, x9
b.hs .Lpanic
cbz x8, .Lunderflow
ldr x9, [x28, #0x58] ; stack base pointer
ldr x3, [x9, x8, lsl #3] ; v1, a single load, 8-byte stride
sub x10, x10, #2
str x10, [x5]
ldr x2, [x9, x10, lsl #3] ; v0, a single load
and x11, x2, #3 ; v0's tag bits
cmp x11, #2 ; a flonum?
b.ne .Lcheck_fixnum
; ---- block B @ 0x1a624 ---------------------------------------------
.Lcheck_fixnum:
cmp x11, #0 ; is v0 a fixnum? x11 is still v0 & 3
and x11, x3, #3
ccmp x11, #0, #0, eq ; ...and v1 too? one branch covers both
b.ne .Lslow
adds x11, x2, x3 ; the actual addition, on the tagged words
b.vc .Lpush
; ---- block C @ 0x18030 : push! -------------------------------------
.Lpush:
str x11, [x9, x10, lsl #3] ; a single store
str x8, [x5] ; stack.len -= 1
; and then it falls straight through into the interpreter dispatch
As we can see at first glance, the disassembly for the new version is much shorter. The code for the match dispatch itself in the old version was actually fine, but the old version made a bunch of spills and stack memory accesses. The real win is that values now fit in a single register. The generated code is a lot more efficient as a result. We're not spilling values to the native C/Rust stack immediately after popping them from the interpreter stack. You may also have noticed that LLVM independently found the trick we discussed earlier in the post to check that two values are fixnums, by fusing two type tests into a single branch, nice!
I'm not going to show the whole floating-point fast path disassembly here because it's even longer, but it's worth pointing out that the old version did zero tagging and untagging work, whereas the new one has to unbox both operands and re-box the result. Despite that, the new float fast path is straight-line code with exactly one branch at the end, and it actually ends up shorter than the old version. We went from 52 instructions, 24 memory ops and 4 branches taken to 36 instructions, 9 memory ops and 1 branch taken. This solves the mlp and nbody puzzle.
Conclusion
In conclusion, I'm pretty happy with the way this refactoring went. Not only does the new tagged word representation reduce memory usage for benchmarks that use lots of arrays and objects, but it's also a major performance win, with every single benchmark ending up faster. The Rust compiler was simply not able to generate efficient code with the old version, but with the new version where values fit in a single register, we actually get some fairly good generated code.
The need to heap-box integers can cause some extra memory allocations in some cases. In particular, code that does left shifts, or relies on overflows for things like generating random numbers, can run into that. Those issues can easily be avoided by skilled engineers who take the VM's design and limitations into account. However, despite some values ending up boxed in some cases, the performance overhead of boxing is unlikely to ever be noticeable. If you're designing your own programming language, you could make different design choices, such as making your native integer type a 32-bit or 62-bit integer instead of a 64-bit integer. This would simply remove the boxed integer path, and make it so overflows produce a visible error instead. You also could require users to explicitly use a heap-boxed bignum (big number) type when they need extra precision. It's a big design space and there are many possible options.
Part of the motivation for my performance work has been to try and get the performance of 3D graphics at a level where it's fast enough to make a simple game, and the results have been very good so far. Plush can render somewhere in the range of 10,000 flat-shaded polygons at an interactive frame rate. I used that to build a little game where you're riding a motorcycle on a highway through an infinite cityscape. If you want to try it, just clone the Plush repository and run cargo run --release examples/night_ride.psh.
In terms of next steps, I'm looking at converting the Plush interpreter from a stack-based design to a register-based design. I think that could yield a nice performance boost. I've also been thinking that I could use the Plush VM to build a minimalistic LISP dialect that explores new language design ideas. I'm thinking of calling that language JetLISP. Stay tuned for more. You can subscribe to my mailing list below if you want to get notified about future posts.
Followup: Plush's new register-based interpreter is now complete, and it's insanely fast!
Subscribe to my mailing list and follow this blog:
Subscribe
Enjoyed this post? Share it with your online community!
Copyright © 2011–2026 Maxime Chevalier-Boisvert. All rights reserved.




