13 ms·
Building Fast Interpreters in Rust
- buro9 8y agoIt should be noted that this implementation has been in production since last Summer and is powering the Firewall Rules ( https://blog.cloudflare.com/how-we-made-firewall-rules/ https://blog.cloudflare.com/how-we-made-firewall-rules/ ) engine today. This was one of the first major components to use Rust at our edge and we've been really happy with it. The flexibility this approach (to matching traffic) has given us... whilst we also get the speed, memory safety... is just great. The speed at which we're iterating on the firewall and other systems that use this is a joy to behold. A lot of that speed derives from the confidence in this component. We're also really pleased that whilst we have several proposals for optimisations, none have yet needed to be prioritised as the performance is great.
- jimmy1 8y ago> . The speed at which we're iterating on the firewall and other systems that use this is a joy to behold. How much of this do you attribute directly to Rust versus other factors like seniority of engineering talent, clear communicated requirements, organizational empowerment, etc? If you swapped out Rust for C, what would be the impact on velocity? Is it a 1-4x multiplier, or larger?
- buro9 8y agoEngineering resource is finite, the less time spent on working on the engine the more time we can spend working on new features and other systems. Before making this in Rust we experimented with a Go and also a Lua implementation as we investigated the approach. The Rust code took the same time to initially produce and was production ready very early and has been rock solid and required virtually no maintenance since it was put into production. That frees up that engineer to work on other things, whilst also reducing ongoing maintenance that was anticipated and so those engineers are also working on other stuff. I'd attribute a chunk of that to Rust... though it helps if you have great engineers who are familiar with the concepts of parsers, etc working on these things. The same may not be true if someone new to Rust was also new to the concepts needed.
- ryanworl 8y agoWhat were the differences between the Go and Rust versions that led you to choose the Rust version in the end?
- buro9 8y agoGiven that I wrote the Go version and RReverser wrote the Rust version, I'm going to go with: A better engineer wrote the Rust version. But actually I was excited by Rust too. The memory safety, performance (the Rust was faster) and the degree of control over how we could present the FFI to the languages we needed to integrate with, in addition to how readable the Rust was by comparison to the Go code (readable, but so much of it), and then avoiding the GC... the Rust implementation was a convincing winner.
- int_19h 8y agoIn terms of readability, how much is it due to Rust having ADTs and pattern matching over them?
- gavia1 8y agoYou can kind of "fake" ADTs in Go with interface{} and switches, but it isn't all that nice. Rust on the other hand with first-class ADT and pattern matching makes it a pleasure! You also have all the nice advantages of the compiler checking that all branches have been satisfied which catches a lot of bugs at compile time. Go has it's place, and works great in those places, but I don't think this is one of them. I'm pleased we chose Rust and this feels like a great use for it!
- deleted 8y ago[deleted]
- foldr 8y agoIt presumably also helps that the Rust version is the third iteration of the code (given that you mention earlier implementations in Go and Lua).
- DyslexicAtheist 8y agowas this project inspired by the Usenix ;login: article Safe Parsers in Rust Changing the World Step by Step ? it seems the philosophy behind it is very similar https://www.usenix.org/publications/login/fall2017 https://www.usenix.org/publications/login/fall2017
- RReverser 8y agoNever heard of it, but will check out, thanks.
- DyslexicAtheist 8y agoafter re-reading the article I think the similarity is nom. It probably ends there since the usenix thing is quite old in comparison.
- RReverser 8y agoAfter looking into it, I think I actually did come across it long time ago. As you've noted, the approaches are different, but it's indeed inspiring in the sense of moving to safer languages for parsing in general.
- azhenley 8y agoVery nice article. It describes writing an interpreter in Rust for a DSL for Wireshark-like filters. The source code: https://github.com/cloudflare/wirefilter https://github.com/cloudflare/wirefilter
- kibwen 8y agoThis discussion of interpreters dovetails rather nicely with Catherine West (kyren)'s new experimental Lua VM in Rust: https://www.reddit.com/r/rust/comments/awx9cy/github_kyrenluster_an_experimental_lua_vm/ https://www.reddit.com/r/rust/comments/awx9cy/github_kyrenlu...
- dgellow 8y ago> While (for a good reason) this might sound scary in unsafe languages like C / C++, in Rust all strings are bounds checked by default. What’s the good reason, if I may ask?
- masklinn 8y agoI'm guessing because an off-by-one or an extra skip might mean you miss the end of the string and go off into la-la land feeding whatever garbage happens to be in memory to your parser? That would mostly be a C issue (as it has no string abstraction at all).
- RReverser 8y agoPrecisely this, thanks. And it's not like this has happened to us before, right? :)
- nonbirithm 8y agoContext: https://blog.cloudflare.com/incident-report-on-memory-leak-caused-by-cloudflare-parser-bug/ https://blog.cloudflare.com/incident-report-on-memory-leak-c...
- dgellow 8y agoI see, thank you for the answer.
- htfy96 8y agoThey can use str.at() in C++ to ensure boundary safety
- masklinn 8y agoHence my noting this is more of a C issue, or more specifically more of an issue with C strings, such as any string you don't have ownership of because it comes from a buffer you were jut handed, because I don't quite know how much of a thing string_view is yet. And of course there's a very fine line in C++ between "ensure boundary safety" (s.at) and "here comes a buffer over-read" (s[]). In Rust by contrast, the latter is spelt `unsafe { s.get_unchecked() }` so it's a bit harder to miss / fat finger it in.
- ori_b 8y agoI am surprised so few interpreters are written in garbage collected languages, which would give the interpreted language a GC for free. The main interpreter loop (or jitted code, for that matter) shouldn't allocate, so there is no inherent cost to the approach. And a tuned, mature GC is likely better than yet another MVP reimplementation.
- masklinn 8y ago> I am surprised so few interpreters are written in garbage collected languages, which would give the interpreted language a GC for free. 1. not exactly, you're now doubling your allocations as you're allocating a native object which has to create sub-structues representing the guest object, unless you're using the rpython technique of the interpreter being fed into a magical thing of magic which spits out a "native" runtime 2. the GC of the host language doesn't necessarily match what you want for the guest 3. and not being able to manipulate and interact with the GC can make efficiently implementing the guest difficult
- ori_b 8y ago> 1. not exactly, you're now doubling your allocations as you're allocating a native object which has to create sub-structues representing the guest object Those sub structures can live within the same allocation. And in many cases, the object passed to the interpreter can be the undecorated native object. > 2. the GC of the host language doesn't necessarily match what you want for the guest It's almost certainly better than recounting or naive mark and sweep, which is what you get when someone reimplements a GC for an interpreter.
- RReverser 8y agoNot sure if this was meant as a generic comment, or specifically about our engine, but our interpreter doesn't allocate at all during execution.
- chrisseaton 8y ago> would give the interpreted language a GC for free That's great if the language you're using has the same GC semantics as the language you're implementing, and a pretty major problem if it doesn't.
- skybrian 8y agoIn C, a common way to get good performance without JIT is with bytecode and a giant switch statement. It would be interesting to see if that's viable in Rust. How does performance compare to an implementation using closures?
- RReverser 8y agoIt was even slightly worse than AST interpretation actually - as mentioned in the post, this is a much larger topic that deserves its own attention, but I did include the bytecode case in the linked slides. https://www.slideshare.net/RReverser/building-fast-interpreters-in-rust https://www.slideshare.net/RReverser/building-fast-interpret...
- skybrian 8y agoWell, that's disappointing. Any guesses about why?
- RReverser 8y agoIf you mean in a comparison against AST interpretation, then I'd say that Rust tagged enums (which C doesn't have) are already essentially a bytecode, except properly aligned, so it makes sense that they have same or slightly better performance than you would get with manual bytecode implementation.
- ridiculous_fish 8y agoC certainly has this via unions. It's more manual but provides more control, including choice of alignment. It's CPU dependent but in practice the cache benefits of packed instructions will probably dominate any misalignment penalty. It's not like x86 instructions are aligned after all. What's the best way to implement a variable-width bytecode interpreter in Rust?
- RReverser 8y ago
- trace_next 8y agoI think C++ does closures a bit better than Rust, particularly when you have lots of Arcs and Rcs. The clone dance gets old fast. However, Rust does everything else so much better, especially Cargo (here's hoping Buckaroo, vcpkg, etc. gain adoption).
- eridius 8y agoC++ hides the clones from you for sure, but it means it's a lot easier to figure out in Rust where you're copying the closure (because the copies have to be explicit) and thus it's easier to reason about the performance of the Rust code.
- masklinn 8y agoThey may also be referring to the more flexible and precise capture clauses of C++ lambdas versus Rust's coarser move v non-move closures. Essentially, capture clauses are a design pattern in Rust whereas they're a language feature in C++: http://smallcultfollowing.com/babysteps/blog/2018/04/24/rust-pattern-precise-closure-capture-clauses/#a-more-general-pattern http://smallcultfollowing.com/babysteps/blog/2018/04/24/rust...
- eridius 8y agoGood point, I was thinking of cloning the closure itself, not capturing Arcs and having to clone them to avoid moves. Though in this case it would be C++'s implicit copies rather than capture clauses. In terms of capture clauses, what I really like are Swift's, as they allow you to bind identifiers to expressions in the capture clause itself (as opposed to just doing capture-by-value vs capture-by-reference). Having capture clauses in Rust that have the same capability would be great.
- pcwalton 8y agoRust used to have capture clauses, but they were removed in order to reduce the complexity of the language. It might make sense to add them back later, but given that Rust is still constantly criticized for being too complex I think it's probably still not yet time.
- ram_rar 8y agoI wonder, how does this compare to an implementation in bpf. its great to see Rust getting traction in systems / networking world.
- reitzensteinm 8y agoInstead of a JIT being the next step, did you consider vectorizing the execution? Amortizing the cost of dispatch over multiple packets means that under heavy load, the performance of the system should be fairly close to what you could JIT, but the system would be much more simple. Of course, this only helps with the worst case where a backlog is starting to build, but it at least reduces the worst case.
- RReverser 8y agoThis is not executed on packets (like it would be in Wireshark), but rather on requests defined in the application layer as a set of properties.
- reitzensteinm 8y agoAh, makes sense!
- omaranto 8y agoI bet a JIT in LuaJIT would have been easy and fast: compile to Lua code in strings, call "eval" (actually load() in Lua, but the name eval is better known).
- filereaper 8y agoI highly recommend folks to view this talk by the venerable Cliff Click called "Bits of Advice For the VM Writer" [1] Of particular interest are Continuation Style Passing Interpreters [2] and how much of a speedup you can get by going to raw assembly. The talk has lessons Cliff learned from his years on the JVM but those can be adapted easily to other runtimes. [1] https://youtu.be/Hqw57GJSrac?t=341 https://youtu.be/Hqw57GJSrac?t=341 [2] https://kseo.github.io/posts/2017-01-09-continuation-passing-style-interpreter.html https://kseo.github.io/posts/2017-01-09-continuation-passing...
- mlindner 8y agoRust doesn't use a runtime so how is this relevant?
- halayli 8y agoI am surprised ebpf wasn't mentioned. it's made for exactly that and you get bonus features like lite weight JIT and safety given that it supports a strict subset of asm commands that are deemed safe. The arguments against JIT aren't backed by any prototypes or proofs. it felt like the author has a bias towards dynamic dispatch and walked backwards towards it. There is no mention how frequently these filters change nor the possible perf advantage that you'd get from running compiled expressions that would compensate for the accumulated compile time of these filters over time. Often you need 2 approaches together and you switch from interpretation to JIT after a condition. (in db case after the same query shows up multiple times for example). In their case it can be something as simple as if filter hasn't updated in x time then consider it stable and compile it. On the other hand, PGs dynamic dispatch is a fun read just like any other code in PGs repo. https://github.com/postgres/postgres/blob/master/src/backend/executor/execExprInterp.c https://github.com/postgres/postgres/blob/master/src/backend...
- buro9 8y agoIn the other blog post (linked from the top of this blog post) I wrote about the motivation for using the Wireshark-like syntax and how we also have a few customers with a lot of rules (tens of thousands and greater). That said, it seems obvious to us that now we have a Rust library that can parse a Wireshark-like syntax into an AST... that we don't have to just perform the matching in Rust. i.e. that we can ask the library to produce translations of the expression as SQL (for our ClickHouse), GraphQL (for our analytics API), or even eBPF. We can't run everything in eBPF, but we could check the list of the fields within an expression to see whether it could be run in eBPF, and then look at heavy hitter rules and promote the ones doing the most work inside L7 to be eBPF and to run in XDP. Even if we don't do this for customer configured rules, this might be something we do for handling denial of service attacks using the same Wireshark-like expression syntax throughout our system.
- halayli 8y agoGot it. Just to clarify, I didn't mean to implement the filters using ebpf inside the kernel but rather use ebpf's engine as a lib at the application layer.