Porting Libyaml to Safe Rust: Some Thoughts
simonask.github.io
simonask.github.io
Reminder! Our forefathers knew and loved ownership concepts, but lacked to tools to do anything but mentally map them. We stand on the shoulders of giants.
> The justification for "unsafe" C and C++ libraries is often that some performance is left on the table with safe Rust, primarily due to bounds checking. On modern hardware this justification has always seemed a bit suspicious to me, and I think this is another piece of (circumstantial) evidence that it often just doesn't matter in practice
That's too strong a statement even though the sentiment is correct. Most software will not see a bottleneck because of bounds checking because it's not in the fast path. All we can say in this case is that the work that libyaml was doing is not impacted by bounds checking. It's not because of hardware but because of software inefficiencies / problem being solved isn't impacted by bounds checking - most likely libyaml is not running at full speed on the underlying hardware and thus any bounds checking is just noise because there's so much slack already.
However, I have definitely encountered situations in very specific circumstances where bounds checking is a bottleneck when you're trying to actually run at hardware speeds and unsafe is required to recover performance - of course I benchmarked very very carefully to the specific unsafe that's the problem. I'll also give a shoutout to the assume crate which I've found a much nicer mechanism to cause the compiler to elide bounds checking in many cases when building optimized while keeping it for debug builds & validating your assertion is correct. The annoying bit is that you can't ask the Rust compiler to elide all bounds check for a benchmark to validate the impact of the bounds checking.
Remember - on modern hardware your CPU is capable of executing ~4-6 billion operations / s, process main memory at ~100GB/s, and access disk at millions of times / s at throughputs of ~1-2 GB/s. This is all consumer grade hardware. Typical software applications are not typically targeting to try to run at full utilization but instead try to offer reasonable performance with a more straightforward solution that can be supported as cheaply as possible maintenance wise because the bottleneck cost is typically the programmer's time rather than how fast the software can be made to run.
> It's certainly possible to build things where bounds checking makes a difference. This just isn't that.
I will add that idiomatic Rust causes a good number of bounds checks to be optimized away. Its impressive the difference this can make. I maintain a parser combinator library that started as a fork. With some simple changes, I've seen massive performance improvements and my best guess is that those changes allowed bounds checks to be removed.
Yes, idiomatic Rust can cause a number of bounds checks to be elided when using iterators. Typically that's when you have all the data that you need to process up front. When you don't have that ability (e.g. data is being fed in piecemeal and you're copying into a temporary buffer), the bounds checking elision is something you need to do yourself.
The takeaway is that Rust’s safety and maintainability generally don’t impose corresponding penalties. And in the cases they do and it’s critical, you can always punt to unsafe.
They tried doing a comparison between ReadySet compiled normally and ReadySet with bounds checking removed so thoroughly that they needed to use a patched toolchain to achieve it and found the difference to be within the noise threshold.
Their conclusion was:
> At the end of the day, it seems like at least for this kind of large-scale, complex application, the cost of pervasive runtime bounds checking is negligible. It’s tough to say precisely why this is, but my intuition is that CPU branch prediction is simply good enough in practice that the cost of the extra couple of instructions and a branch effectively ends up being zero - and compilers like LLVM are good enough at local optimizations to optimize most bounds checks away entirely. Not to mention, it’s likely that quite a few (if not the majority) of the bounds checks we removed are actually necessary, in that they’re validating some kind of user input or other edge conditions where we want to panic on an out of bounds access.
So the remaining bounds check is off the hot path where it doesn’t matter. But in the hot path where you should be bounded by HW limits, it can be a significant slowdown. Prediction can help but it’s not free.
So for most people, they only need to care about bounds checking when they’re doing something in the hot path and even then only when their hot path is running into HW limits. If their hot path is some complicated CPU computation, bounds checking should be but a blip unless you do something stupid like check the bounds too frequently.
So the general advice to not worry too much about bounds checks when writing Rust is directionally correct for the vast majority of people, but recognize it’s incorrect in places and it’s hard to notice because it’s such a small thing hidden in code gen without an easy flag to test the impact.
I still think the "because it's not in the fast path" part of "Most software will not see a bottleneck because of bounds checking because it's not in the fast path" is a bit too much of a blanket statement and could detract from the admonition to benchmark very carefully before optimizing but, otherwise, I agree.
The memcpy vs. memmove thing comes to mind. So does stuff like iterating over the indices of a pointer to one std::vector while modifying another pointer to a std::vector. If you know you have exclusive access to the vector you are reading, then you know the size isn't changing, so you can just iterate from 0 to size and elide any bounds checking.
With C++, if the pointers to the vectors could alias, then it will have to bounds check at runtime. Templates and Link time optimization helps with this sort of thing, but I'm guessing Rust still has an advantage.
if (some_function(some_memory) != ERROR) {
// ...
} else {
goto cleanup;
}
This approaches what I dub "Happy Path Indentation Syndrome" (HPIS), where the "normal"/"happy path" functionality of the code happens under the `// ...` inside the passing scope.If you have several such functions that can each fail, this approach means your "happy path" follows the deepening scope / indentation.
Instead, I much prefer styling it like this (assuming, obviously, that the "happy path" happens always):
if (some_function(some_memory) == ERROR) {
goto cleanup;
}
// ...
How do people approach this kind of situation?In fact, I once had an interview candidate approach a simple string parsing problem by checking each char in a nested scope, happily going a dozen deep and not seeing a problem!
I do agree that it's much better for readability.
Isn’t goto a big no no?
https://sourcegraph.com/search?q=context%3Aglobal+repo%3A%5E...
https://sourcegraph.com/search?q=context%3Aglobal+repo%3A%5E...
1. Junior level: don't do it
2. Journeyman level: don't do it
3. Master level: don't do it, except when...
I ended up parsing with libyaml and when something fails, parsing again using node and displaying errors through https://github.com/eemeli/yaml It's terrible but it works better. I hope to be able to use libyaml-safer in the future if it does improve on errors.
Also, having found an asserting case for libyaml in the past, I'm not trusting it that much.
Is criterion environment-aware enough to be able to purge/drop the filesystem cache? It might be interesting to see how the performance looks with a cold cache.
> What these numbers do indicate is that the port to safe, idiomatic Rust does not produce meaningfully slower code.
I think that's reasonable and I would even suggest that Rust's value proposition is somewhat predicated on that. A c2rust port like the baseline you started with is probably best as a working crutch from which someone can rewrite a bit here / a bit there.
This made me smile, because while yes, C just segment faults. I think there’s a way to handle segmentation fault? Or is it just the OS that kills the process when it tries to access an invalid memory address?
(edit to add: note that it isn't trivial to handle seg fault in particular because unless you do some trickery you return to the same instruction that caused the segfault, and just get another segfault see e.g.: https://stackoverflow.com/questions/2663456/how-to-write-a-s... )
At some point you'd be better off using `setjmp` and `longjmp` to write your own exceptions from scratch, like Lua does.
Measure yourselves. Maybe it does. Maybe it doesn’t matter. It doesn’t change that this “it probably doesn’t matter in practice” sentiment is the primary reason a typical web request today is in the order of 20-30x slower than it should be.
For this matter, if you’re using Libyaml to parse one yaml file, you’re probably fine. If you’re AWS and use libyaml, you’re definitely going to feel this change.
The AWS comment seems similarly odd given the investments they have made in security by design. This is the kind of work I’d expect them to support since an exploitable bug is a really big deal for them and they have demonstrated a willingness to put engineers on optimizing a safer approach to avoid the 90s dynamic of treating security and performance as opposing concerns.
The safe variant should use libsyck btw, which didn't implement all the new unsafe yaml features.