Skip to content
HN On Hacker News ↗

Replacing a Rust Enum with a 64-bit Word Made My Interpreter 17% Faster

▲ 143 points • 63 comments • by metrofun • 2mo ago • HN discussion ↗

Pangram verdict · v3.3

We believe this text is mainly human-written, with some AI content.

7 %

AI likelihood · overall

Human
96% human-written 4% AI-generated
SEGMENTS · HUMAN 1 of 1
SEGMENTS · AI 0 of 1
WORD COUNT 1,780
PEAK AI % 4% · §1
Analyzed
Sep 8
backend: pangram/v3.3
Segments scanned
1 windows
avg 1780 words each
Distribution
96 / 4%
human / AI fraction
Verdict
Human
Pangram v3.3

Article text · 1,780 words · 1 segments analyzed

Human AI-generated
§1 Human · 4%

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 < UINT64_MAX; ++i), the loop would likely take over a decade to finish executing. A more sophisticated tagging scheme can clearly reduce memory usage, but it also means that we have to introduce bitwise operations to be able to tell what's an integer, a pointer or a float. We also need extra bitwise operations to unpack some values to operate on them. That means extra instructions the CPU has to run. A skilled VM engineer once told me that the smart thing to do is to give integers zeros as their tag bits, because then, adding or subtracting two shifted integers remains a plain add or sub machine instruction. Packing and unpacking floats though is more complex and requires several instructions. I was a bit worried about the performance impact, and needing to trade memory efficiency for a bit of a performance loss. As it turns out, my fear was completely unfounded, as we'll see later in this post. An Efficient Low-Bit Tagging Scheme Claude and I co-designed and iterated on the value representation found in this source file. It fits nicely in a Rust newtype wrapping a u64. As you can see, it has many convenience methods to make it easy to work with, compensating for the loss of the Rust enum. Most of the methods are marked as always inline for performance, since they're used everywhere in the interpreter loop. The diagram below illustrates how the value representation is structured in more detail: The tagged value representation. The low-bit tagging scheme I settled on encodes 5 different kinds of values: fixnums, flonums, immediates, and two kinds of pointers. Fixnums are signed integers that fit in a 62-bit range. Flonums are floating point values encoded using a self-tagging scheme (more on that later). Immediates are values like nil, true, false, undef, function and class ids, as well as host functions written in Rust that can be called from Plush. In Plush, you can call host functions using the dollar sign prefix, e.g. $read_file(file_name). The immediates have a 5-bit subtag to indicate what kind of immediate they are. It's 5 bits because an 8-bit comparison on the lowest byte is just one instruction on modern CPUs, which means we can compare the subtag plus the tag bits in one instruction if we need to. The reason there are two kinds of pointers in this representation is that I wanted to make equality comparison between values fast. This comes up often because you might have loop conditions, pointer comparisons inside if statements, etc. Most values in Plush are compared using simple reference equality, but I chose to have strings be compared for structural equality to allow the possibility of a string interning table later. This is like JS, where two strings with the same value are equal. Floats also need special handling because they have a sign bit, and +0.0 must be the same as -0.0. The easy way to do that is to have one bit in the tag (I chose bit index 1) that tells us that two values can be compared directly using one comparison instruction. This allows us to have a fast path to compare integers, pointers, handles and small immediate constants like true, false, nil and undef using a single machine comparison instruction. There's some minor subtlety there because some pointer types (string objects) are not comparable using direct pointer equality. Also, if an integer or float value exceeds the range we can fit in a tagged value, we have to allocate a heap object to box this value. That means we could end up with two floats that have equal value represented by distinct heap objects, but those values must test as equal using the == operator. If Plush eventually gets a JIT compiler, the generated code for if (p != nil) can be as follows. There is no slow path here, because we know that no value that gets compared structurally can possibly be equal to nil: ; x0 = the value being tested ; nil is the immediate 0x05 cmp x0, #5 b.eq .ELSE_BRANCH ; jump to else branch if equal Efficient Fixnum Operations As stated earlier, integer values that fit in the 62-bit fixnum range have their lowest two bits set to 00, and are stored as n << 2, so add, sub, compares and bitwise and/or/xor run directly on the tagged words. A 64-bit overflow is also exactly the case where the result no longer fits in 62 bits, meaning we can use standard machine instructions to check for overflow (jo on x86-64, bvs on ARM64). For a loop counter comparison like i < n, the ARM64 machine code can be as follows. Checking that both values are integers costs us 3 machine instructions: ; Fast path for `i < n`, 5 instructions ; x0 = i, x1 = n orr x2, x0, x1 ; combine both operands tst x2, #3 ; are both operands fixnums? b.ne .Lslow_lt ; floats, boxed numbers and strings go that way cmp x0, x1 ; compare the fixnums, `00` tag bits don't matter b.ge .LOOP_EXIT ; signed compare, fixnums are 62-bit signed The fast path to add two fixnums would come out as follows. The Rust compiler may not be able to combine both tags in practice, but a JIT compiler easily could, and could even remove the type check entirely if it has additional run-time information about the types of values, in which case the add would be just as efficient as a C integer addition: ; Fast path for fixnum integer addition ; 5 instructions on the fast path ; x0 = a, x1 = b, two tagged Values ; A fixnum is n << 2, so its low two bits are 00 orr x2, x0, x1 ; combine both tags, so one test covers the pair tst x2, #3 ; are both operands fixnums? b.ne .Lslow adds x0, x0, x1 ; (a << 2) + (b << 2) == (a + b) << 2 b.vs .Loverflow ; signed overflow is exactly the 62-bit overflow ; x0 already holds the tagged result. Nothing was untagged on the way in ; and nothing has to be retagged on the way out Self-Tagged Flonum Representation There are many ways to tag floating-point values in a system with tagged values. One of the most famous schemes for doing that is NaN boxing, which, as far as I know, is still used in the Firefox JavaScript engine to this day. NaN boxing exploits the fact that a double counts as NaN (not a number) whenever its 11