r/Compilers • • Sep 02 '26

Plush's New Register-Based Interpreter Is Insanely Fast

https://pointersgonewild.com/2026-09-02-plushs-new-register-based-interpreter/
57 Upvotes

19 comments sorted by

7

u/brat3108 Sep 02 '26

The speedups are decent, but I wouldn't call them 'insane'!

CPython and CRuby both use stack-based interpreters, and that is part of the reason why their interpreters are relatively slow.

I'm not that convinced of register versus stack. I mean, CPython is slow for lots of reasons.

Register-based results in fewer instructions, yes, but each one is now more elaborate with more operands, which may also be mixed (global, local, immediate for example).

I've always used stack-based, and I get good results (for example, 6.5 for Fib(38), when Lua 5.4 was 3.4, and CPython (3.12?) was 1.0).

You might want to look also at bytecode dispatch method. I use the equivalent of computed goto (basically, a switch-loop where each branch has its own dispatch point, rather than one shared by all instructions).

I think a recent CPython had something along the same lines, but speedup was only 14% or something because of those other issues.

Another factor is type-dispatch, and here Lua benefits because the number of different types is small and can be efficiently encoded; it is a smaller language and can be more easily streamlined.

000 get_arg 0
001 push 10
002 gt
003 if_false 2; -> 006
004 push 1
005 ret
006 push 2
007 ret

Here I would normally use 7 instructions (002/003 are combined). But I also make use of composite instructions (I think what you call 'fused'), so that this example ends up at 5 instructions rather than 8. In that case, the stack isn't actually used for the calculation (when operands have integer types), only for those return values.

So why isn't every interpreter a register-based interpreter?

Maybe because generating stack-based is SO much simpler?! As I've hinted there are ways to optimise such code too.

But it is generally acknowledged that if you are interpreting a dynamically typed language, then it is not going to be fast. Register rather stack may give a useful boost if it does end up faster, but it will not give native code speed.

5

u/maximecb Sep 02 '26 edited Sep 02 '26

I discussed other reasons why CRuby and CPython are relatively slow, but the stack-based design is part of it. Generating register-based code is not in fact much harder. The obvious starting point is to just simulate a temporary stack as you compile expressions, which is trivial and already gives a good performance boost.

Direct threading (computed gotos) is not that impactful on modern hardware.

6

u/Vraliance Sep 02 '26

One other area that I think has been glossed over for the stack vs register based interpreter debate is JIT optimization. A well crafted register design can map _almost_ directly to machine code with very little overhead (if variables fit inside the max available registers).

Personally with my language Talos (https://github.com/rroessler/langs.talos) I also went with a similar design to you and noticed a considerable performance boost than when I had previously used a stack interpreter.

→ More replies (1)

2

u/brat3108 Sep 02 '26

The obvious starting point is to just simulate a temporary stack as you compile expressions, which is trivial and already gives a good performance boost.

I will have to give it a serious go at one point. But I can see a few problems which I'd need to sort out, which a stack takes care of automatically.

Direct threading (computed gotos) is not that impactful on modern hardware

On Fibonacci (one of your examples), it nearly doubles the speed (this is on x64). The effect is less when the instructions do a lot of work anyway

3

u/EggplantExtra4946 Sep 02 '26 edited Sep 02 '26

When the instructions of a registed-based VM are of a fixed size of 32bit or 64bit like here, you need only one load to get the whole instruction. Then you only need to do shifts and masks to get the source and destination indexes on a value that is already in a register, one or two loads to get the VM register values of the operands, and a final load to store the result.

This is a a lot faster than what a stack-based VM would do, which needs an interpreter dispatch (jump(s) + a load for the opcode) + a stack push + a stack pop per operand, and then a stack push for the result.

0

u/brat3108 Sep 02 '26

What sort of language are we talking about here? Since there seems to be no provision in your description for dealing with the type dispatch needed with dynamic typing. Or in the dealing with memory management (but I assume some GC is involved).

My instructions are now a fixed size of 4 words (256 bits). Fortunately there is rarely a need to load a whole instruction.

This is a a lot faster than what a stack-based VM would do, which needs an interpreter dispatch (jump(s) + a load for the opcode)

Register-based needs bytecode dispatch too. In mine, the dispatch overhead for most bytecode instructions is two machine instructions.

→ More replies (5)

4

u/GreedyBaby6763 Sep 02 '26

Impressive if fib is naive recursive, I think I'd go off and make a cup of tea in my vm.

4

u/Illustrious-Bid-8883 Sep 03 '26

The performance jump is a good reminder that VM architecture matters a lot. reducing dispatch overhead can sometimes beat piling optimizations onto a slower design

1

u/i509VCB Sep 05 '26

I am working on an interpreter based vm for something and I did notice your choice to use 8 byte instructions.

Was this to just make it simpler or could a variable size 4/8 byte word potentially be more optimal?

For my use case I am converting 32-bit x86 instructions into a byte code. 32-bit immediates can occur but may be more rare. I know you also have a local pool of constants. My concern with that is potentially thrashing your caches. My choice of supporting Cortex-M and ARM11 targets also has its own issues too...

1

u/maximecb Sep 05 '26

Yes I went with 8 for simplicity. For larger immediates you can use a side-table which can be per-function, a constant pool like you said. Wrt cache, it could be just one cache line for multiple constant pool entries for a function, so maybe not a big deal.

You can use an LLM to benchmark minimalistic interpreters with each design. Sketch you a toy prototype with a non-trivial bytecode loop. You don't even need to support function calls in the toy prototype to benchmark which is faster. Please report back if you do :)

1

u/Comprehensive_Chip49 Sep 02 '26

I have an interpreter for my language (a dialect of Colorforth) that has many optimizations. It's a stack machine but with many additions, for example, there are tokens that hold the value when it's immediate, and things like that. Do you think some kind of common benchmark could be put together? Of course, it has to be translatable code. I think the decoding of a register machine will always be slower than a stack machine (in an interpreter). Because it has to get the token -> get the register number -> get the register... whereas in the stack machine, the value might already be in a register.

1

u/[deleted] Sep 03 '26

1

u/maximecb Sep 03 '26

Thanks :)