7 ms·
How to make a fast dynamic language interpreter
- grg0 6mo agoInteresting, 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?
- pizlonator 6mo agoI committed the statically generated site, which is wastefully large because how I generate the code browsers But yeah the interpreter is very small
- Futurmix 6mo ago[flagged]
- boulos 6mo agoHow's your experience with Fil-C been? Is it materially useful to you in practice?
- pizlonator 6mo agoI’m biased since I’m the Fil. It was materially useful in this project. - Caught multiple memory safety issues in a nice deterministic way, so designing the object model was easier than it would have been otherwise. - C++ with accurate GC is a really great programming model. I feel like it speeds me up by 1.5x relative to normal C++, and maybe like 1.2x relative to other GC’d languages (because C++’s APIs are so rich and the lambdas/templates and class system is so mature). But I’m biased in multiple ways - I made Fil-C++ - I’ve been programming in C++ for like 35ish years now
- vlovich123 6mo agoI’m curious. Given the overheads of Fil-C++, does it actually make sense to use it for greenfield projects? I like that Fil-C fills a gap in securing old legacy codebases, I’m just not sure I understand it for greenfield projects like this other than you happen to know C++ really well.
- pizlonator 6mo agoIt made sense because I was able to move very quickly, and once perf became a problem I could move to Yolo-C++ without a full rewrite. > happen to know C++ really well That’s my bias yeah. But C++ is good for more than just perf. If you need access to low level APIs, or libraries that happen to be exposed as C/C++ API, or you need good support for dynamic linking and separate compilation - then C++ (or C) are a great choice
- vlovich123 6mo agoHmmm… I did about 20+ years of C++ coding and since I’ve been doing Rust I haven’t seen any of these issues. It has trivial integrations with c/c++ libraries (often with wrappers already written), often better native libraries to substitute those c++ deps wholesale, and separate compilation out of the box. It has dynamic linking if you really need it via the C ABI or even rlib although I’ll grants the latter is not as mature. The syntax and ownership rules can take some getting used to but after doing it I start to wonder how I ever enjoyed the masochism of the rule of 5 magic incantation that no one else ever followed and writing the class definition twice. + the language gaining complexity constantly without ever paying back tech debt or solving real problems.
- throwawayzzzzzz 6mo ago[dead]
- HarHarVeryFunny 6mo agoAre you using malloc + GC in preference to smart pointers, and if so why? I thought Fil-C was just C not C++? It doesn't seem like that is necessarily a performance win, especially since you could always use a smart pointer's raw pointer (preferably const) in a performance critical path.
- pizlonator 6mo agoFil-C is C and C++ Smart pointers give you reference counting at best. Language implementations tend to have a lot of reference cycles, so you need actual GC (or a very sophisticated kind of manual memory management)
- thesz 6mo agoIf I may (and correctly understand what is going inside Fil-C), it would be not so hard to add support for software transactional memory by adding some library calls. This will greatly reduce coordination bugs in parallel programs and may even speed things up.
- pizlonator 6mo agoIt would be hard for the same reason it’s always been hard: - STM interacts badly with any interesting effects (like IO) - STM interacts badly with locks (C and C++ use locks implicitly, because they’re in libc and libc++) - STM performs badly - STM is harder to use than locks
- tiffanyh 6mo agoI see Lua was included, wish LuaJIT was as well.
- pizlonator 6mo agoI bet LuaJIT crushes Zef! Or rather, I would hope that it does, given how much more engineering went into it There are many runtimes that I could have included but didn’t. Also, it’s quite impressive how much faster PUC Lua is than QuickJS and Python
- zephen 6mo ago> it’s quite impressive how much faster PUC Lua is than QuickJS and Python Python's execution time is mostly spent looking up stuff. I don't think lua is quite as dynamic.
- pizlonator 6mo agoLua is way more dynamic
- zephen 6mo agoI suppose it depends on where you are looking for dynamicity. In some ways, lua is much more laissez faire of course. But in Python, everything is an object, which is why, as I said, it spends much of its time looking things up. And things like bindings for closures are late, so that's more lookups as well. In lua, many things aren't objects, and, for example, you can add two numbers without looking anything up. Another issue, of course, when you do that, is that you could conceivably overflow an integer, but that can't happen in Python either. The Python interpreter has some fast paths for specific object types, but it is really limited in the optimizations it can do, because there simply aren't any unboxed types.
- psychoslave 6mo agoNop, Python is not full object. Not even Ruby is fully object, try `if.class` for example. Self, Smalltalk, Lisp, and Io are fully object in that sense. But none as far as I know can handle something like `(.class`.
- injidup 6mo agoWhat is this YOLO-c++ compiler that is referenced in the article? Google searches turn up nothing and chatgpt seems not to know it either.
- electroly 6mo agoThe author of Fil-C, who is also the author of this language, uses "Yolo-C/C++" to mean regular C/C++ without Fil-C.
- pansa2 6mo agoIn a similar vein, see this page about the performance of the interpreter for the dynamic language Wren: https://wren.io/performance.html 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?
- psychoslave 6mo agoThat’s basically what is done all the time in languages where monkey patching is accepted as idiomatic, notably Ruby. Ruby is not known for its speed-first mindset though. On the other side, having a type holding a closed set of applicable functions is somehow questioning. There are languages out there that allows to define arbitrary functions and then use them as a methods with dot notation on any variable matching the type of the first argument, including Nim (with macros), Scala (with implicit classes and type classes), Kotlin (with extension functions) and Rust (with traits).
- pjmlp 6mo agoIt is getting better, now that they finally got the Smalltalk lessons from 1984. "Efficient implementation of the smalltalk-80 system" https://dl.acm.org/doi/10.1145/800017.800542 https://dl.acm.org/doi/10.1145/800017.800542
- IshKebab 6mo ago> Ruby is not known for its speed-first mindset though. Or its maintainability, and this is one of the big reasons why. Methods and variables are dynamically generated at runtime which makes it impossible to even grep for them. If you have a large Ruby codebase (say Gitlab or Asciidoctor), it can be almost impossible to trace through code unless you are familiar with the entire codebase. Their "answer" is that you run the code and use the debugger, but that's clearly ridiculous. So I would say dynamically defined classes is not only bad for performance; it's just bad in general.
- jiusanzhou 6mo agoThe 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.
- Someone 6mo agoI agree, but there’s a tiny caveat that this is for one specific benchmark that, I think, doesn’t reflect most real-world code. I’m basing that on the 1.6% improvement they got on speeding up sqrt. That surprised me, because, to get such an improvement, the benchmark must spend over 1.6% of its time in there, to start with. Looking in the git repo, it seems that did happen in the nbody simulation (https://github.com/pizlonator/zef/blob/master/ScriptBench/nbody.zef https://github.com/pizlonator/zef/blob/master/ScriptBench/nb...).
- pizlonator 6mo agoBefore that specialization, sqrt calls were hilariously slow - so even calling it sparingly could significantly impact performance. Basically the flow was: - check if we’re calling a method of an object - nope, ok, so cascade through 10+ symbol comparisons - sqrt was towards the bottom of the cascade
- jimmypk 6mo ago[flagged]
- valorzard 6mo agoDo you think this exercise has taught you anything that could make fil c itself better?
- pizlonator 6mo agoYeah I really need to have a better fix for how I handle unions. And the fact that having outline calls to methods of value objects is so expensive
- achierius 6mo ago> And the fact that having outline calls to methods of value objects is so expensive Is this tied to unions? Or otherwise, when does this happen? I don't see the connection w/ invisicaps or &c
- pizlonator 6mo agoIn Fil-C, currently, all stack allocations that “escape” need to be allocated in the heap. “Escape” is defined very loosely; it currently means: some function other than the one that owns the stack allocation needs a pointer to that allocation. For example even if you could prove that `bar(Value* p)` never stashes p anywhere, the fil-C compiler will currently heap allocate that value anytime bar is called. The one exception is if bar had already been inlined, and so from the FilPizlonator’s perspective there isn’t even a call. This is clearly dumb and fixable. It’s dumb because lots of functions aren’t worth inlining but their body is analyzable. Slow paths are like that. It’s fixable because those slow paths - and lots of code like them - takes ptrs as arguments and then obviously just uses them for loads and stores but doesn’t escape them any further. You’ll sometimes hear me say that Fil-C is nowhere near as optimal as it could be. This is just one example of that
- catlifeonmars 6mo agoDo you run an optimization pass on the AST between parsing and evaluation?
- pizlonator 6mo agoI run a “resolve” pass. That’s where for example getter inference happens.
- HarHarVeryFunny 6mo agoFor an interpreter / AST executor, I think a big win would be efficient parsing in the first place, in particular using a precedence parser for expressions vs recursive descent, which would avoid the need to optimize the AST to remove the 1:1 "unit productions" in the grammar.
- tnelsond4 6mo agoI 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.
- deleted 6mo ago[deleted]
- tikotus 6mo agoThis 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 https://www.cs.unm.edu/~williams/cs491/three-imp.pdf
- _alphageek 6mo agoGood 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?
- vascocosta 6mo agoReally 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/ https://gluonscript.org/
- lambertsimnel 6mo agoGluconScript 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 https://news.ycombinator.com/item?id=47821853
- vascocosta 6mo agoGluonScript really doesn't need a garbage collector, because at the end of the day when a script is executing, it is just safe Rust code evaluating expressions. So it's just a Rust program running really, with its automatic static memory management guarantees. As far as I researched, only closures could generate dangling references and therefore need memory cleanup, but only if I allowed closures to access their environment (variables and functions) by reference / mutable reference. To avoid this and simplify both my code as well as the mental model for the users of GluonScript, as of now, closures capture their environment by cloning it immutably. There's an increased memory usage with all the copying of the environment but there are never references to something that isn't being used anymore and therefore no need for a GC. At the end of the day all values captured by closures are owned Rust values that are dropped by Rust when no longer in scope. So this can lead to high memory usage in hot loops but it can't lead to memory leaks.