15 comments

[ 2.9 ms ] story [ 45.8 ms ] thread
Interesting, thanks for sharing. It is a topic I'd like to explore in detail at some point.

I 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?

How's your experience with Fil-C been? Is it materially useful to you in practice?
I see Lua was included, wish LuaJIT was as well.
What is this YOLO-c++ compiler that is referenced in the article? Google searches turn up nothing and chatgpt seems not to know it either.
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.
Do you think this exercise has taught you anything that could make fil c itself better?
Do you run an optimization pass on the AST between parsing and evaluation?
I use the bounds checker in TCC to check for memory errors in C, should I switch to Fil-C instead to debug my code? Obviously yolo-C is my target.
(comment deleted)
This is very interesting and well done.

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

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:

https://gluonscript.org/

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"):

https://news.ycombinator.com/item?id=47821853