In a similar vein, see this page about the performance of the interpreter for the dynamic language Wren: https://wren.io/performance.html
Unlike the Zef article, which describes implementation techniques, the Wren page also shows ways in which language design can contribute to performance.
In particular, Wren gives up dynamic object shapes, which enables copy-down inheritance and substantially simplifies (and hence accelerates) method lookup. Personally I think that’s a good trade-off - how often have you really needed to add a method to a class after construction?
The jump from change #5 to #6 (inline caches + hidden-class object model) doing the bulk of the work here really tracks with how V8/JSC got fast historically — dynamic dispatch on property access is where naive interpreters die, and everything else is kind of rounding error by comparison. Nice that it's laid out so you can see the contribution of each step in isolation; most perf writeups just show the final number.
I've gone through something similar, but for a more functional language (a Scheme). It's interesting how here the biggest wins are from optimizing the objects, while the biggest wins in my case were optimizing closures. The optimizations were very similar.
"Three implementation models for scheme" gives all the answers to make a fast enough scheme, though it has something of a compilation step, so it's not interpreting the original AST.
Good writeup. The Arguments arc (#7→#13) hits close — did basically the same dance for an async step evaluator in Rust a while back. Went all in on Cow<'_, Input> assuming borrow-in-the-common-case would earn its keep. Microbenches looked great. Real workload: the Cow discriminant plus lifetime gunk bled into every combinator past the first await, inlining fell off a cliff, the whole point of Cow evaporated. Ripped it out for NoInput / OneInput<T> / MultiInput(Vec<T>) at the evaluator boundary — same split as your ZeroArguments / OneArgument / TwoArguments, just arrived at the ugly way.
One thing I keep wondering: have you stacked arity specialization with type specialization on the native path? Binary<add, int, int> style, drops the isInt probe altogether. Guessing the code size math didn't work out, or ICs are already soaking up whatever's hot on the object side so the native fast paths don't matter much. Which one?
Really interesting read, especially after I released the initial version of my own interpreter which is also an AST-walking interpreter. My main goal was to understand at a basic level what it takes to build an interpreted programming language.
I didn't want any optimisation complexities and just focused on being able to understand my own Rust code. I was surprised by the performance I got simply by using my favourite language and as a bonus, since Rust takes care of all the ownership and lifetimes, I don't need a garbage collector. For sure, right now I'm being super conservative and rely on cloning stuff to avoid lifetime hell in stuff like closures, but the speed and memory profile is still very decent.
For anyone interested in a simple to understand tree-walking interpreter in Rust, which is heavily based in expressive enums where code is data, here's my interpreter:
GluconScript looks cool, but this sounds too good to be true:
> as a bonus, since Rust takes care of all the ownership
> and lifetimes, I don't need a garbage collector.
I can imagine GluconScript's memory handling comes at a cost, even if the tradeoff of using a borrow checker is well worth it. Was that your experience?
Relatedly, since you commented there has been submission about garbage collectors in Rust ("Garbage Collection Without Unsafe Code"):
15 comments
[ 2.9 ms ] story [ 45.8 ms ] threadI also like how, according to Github, the repo is 99.7% HTML and 0.3% C++. A testament to the interpreter's size, I guess?
Unlike the Zef article, which describes implementation techniques, the Wren page also shows ways in which language design can contribute to performance.
In particular, Wren gives up dynamic object shapes, which enables copy-down inheritance and substantially simplifies (and hence accelerates) method lookup. Personally I think that’s a good trade-off - how often have you really needed to add a method to a class after construction?
I've gone through something similar, but for a more functional language (a Scheme). It's interesting how here the biggest wins are from optimizing the objects, while the biggest wins in my case were optimizing closures. The optimizations were very similar.
"Three implementation models for scheme" gives all the answers to make a fast enough scheme, though it has something of a compilation step, so it's not interpreting the original AST.
https://www.cs.unm.edu/~williams/cs491/three-imp.pdf
I didn't want any optimisation complexities and just focused on being able to understand my own Rust code. I was surprised by the performance I got simply by using my favourite language and as a bonus, since Rust takes care of all the ownership and lifetimes, I don't need a garbage collector. For sure, right now I'm being super conservative and rely on cloning stuff to avoid lifetime hell in stuff like closures, but the speed and memory profile is still very decent.
For anyone interested in a simple to understand tree-walking interpreter in Rust, which is heavily based in expressive enums where code is data, here's my interpreter:
https://gluonscript.org/
> as a bonus, since Rust takes care of all the ownership > and lifetimes, I don't need a garbage collector.
I can imagine GluconScript's memory handling comes at a cost, even if the tradeoff of using a borrow checker is well worth it. Was that your experience?
Relatedly, since you commented there has been submission about garbage collectors in Rust ("Garbage Collection Without Unsafe Code"):
https://news.ycombinator.com/item?id=47821853