Spotting and avoiding heap fragmentation in Rust applications
svix.com
svix.com
This is how memory management works now, but some older systems like classic Mac OS and Palm OS used a design that did make it possible to compact the heap.
See https://en.wikipedia.org/wiki/Classic_Mac_OS_memory_manageme...
When you allocated memory, instead of getting a pointer back, you'd get a handle. While the handle was unlocked, the operating system was free to move the memory around. If you wanted to access the memory, you'd lock the handle, giving a pointer with the actual address, then unlock the handle when done.
To the extent that you kept handles unlocked, the system could fight fragmentation.
It was tedious, and your code had lots of lock/unlock clutter in it. It also led to bugs where you'd accidentally use a pointer value after unlocking its handle, which makes the pointer value invalid because your data might have been moved somewhere else. Worse, usually your data was not moved, so these bugs were hard to detect, much like use-after-free bugs.
But, when memory is very limited and you also don't have an MMU, life isn't easy.
While that is pretty much what heap fragmentation is about, the failure mode of disk fragmentation is less drastic. The file system will just split the file contents across multiple smaller free spots, making it possible to use the whole disk no matter your write pattern. The issue is that now the file is no longer contiguous, so reading the entire file takes longer. Much longer if we are talking about old HDDs.
(The analogy to file system fragmentation for memory is that when physical pages are allocated in a discontinuous manner, it prevents some optimizations like coalescing them into hugepages, which for some workloads can help with TLB hit rate.)
IIRC it's due to a combination of relying on fixed-sized "pages" to hold fragment pointers and a limited number of page indirections. That is the root page can point to a sub-pages, which again can point to sub-pages, but those sub-sub-pages have to point to the actual fragments. Or something along those lines.
[1]: https://support.microsoft.com/en-au/topic/a-heavily-fragment...
In a completely manually managed language like C or C++, you can handle fragmentation problems yourself by writing your own allocators or doing object pools to reuse previously allocated memory. You have control over fragmentation.
In a garbage collected language like Java or C#, the runtime is able to move objects around in memory and update their pointers. So, as long as your language has a sufficiently advanced implementation, it may defragment on the fly for you.
But Rust is sort of stuck in the middle. It's safe enough that it's hard to write your own allocators or easily reuse previously allocated memory. But it's low level enough that the runtime doesn't have the freedom to move things around in memory under the program.
It's a hard problem.
It's done constantly in Rust. Create a vector of items you want to allocate. Reference to them by their id, i.e. int representing them. There're libraries supporting this style of development, for example, slab: https://crates.io/crates/slab
That being said vectors seem like a gift in rust. I feel like it’s the easiest way to get around some ownership rules when you need to work with many pointers. You can just use indices.
One of the performance bottlenecks that I ran into while reimplementing clox in C++ was using `new` and `delete` instead of `realloc` for arrays and hash tables. By the time that I figured out it was an issue, I was already using `operator new` to track heap allocations for the garbage collector.
'unsafe' doesn't equal 'don't use'. I'll admit that unsafe Rust has a lot of rough and unergonomic features compared to C, but it's conceptually and algorithmically just as easy (or hard) in Rust to build an allocator as it is in C and C++.
You also still get the help of the borrow checker in unsafe rust, so there are even some advantages.
But its awkward. I think this awkwardness comes because rust is trying to straddle the gap between being a low level systems language and a high level language for application developers.
In a systems language, I want full control over the allocator for each allocation my program does. Do I want to use the system allocator, or an arena or an object pool? Different tasks call for different tools. I really like Zig's approach here where every collection type which allocates takes an allocator as a parameter when the object is created. Rust's borrow checker can be super helpful here in making all of this stuff correct.
But application developers don't want to think about allocators and memory fragmentation. Adding allocator traits to everything will add yet more things to learn in rust. And rust's standard library doesn't support that (yet).
The result is that allocation crates like bumpalo need to ship with & maintain their own copy of the standard library's collection types. Its kind of a mess. This might eventually be fixed, but at the rate rust's development has been going lately, I suspect it'll take years for something to land on stable.
But people in the community are talking about it, at least. Eg this article from the rust subreddit: https://nical.github.io/posts/rust-custom-allocators.html
Also unsafe Rust is a thing and exists precisely to enable programming at the extremely low level when abstractions tend to leak badly. It’s considered bad form to have it in web apps and other normal code but seeing it in a low level data structure or an allocator would not be unusual.
Managed languages and high level languages definitely do have an advantage here.
Unless I misunderstood that the default Rust allocator, with high request bodies and concurrency, is always going to suffer unfixable heap fragmentation like displayed in the article?
There might not be anything wrong with the default allocator, it just isn't the best suited for that particular use.
It's possible that the glibc allocator contains some simple bug. It's more likely that it doesn't contain any simple bugs, but makes different tradeoffs to jemalloc, which make it less suitable to this particular slice of applications.
* Switching from libc malloc to jemalloc
* Switching from libc malloc to tcmalloc (dating myself a little bit)
* Switching from libc malloc to mimalloc
* Switching from jemalloc to mimalloc
* Switching from jemalloc to libc malloc
* Switching from mimalloc to jemalloc
Possibly others; I only want to list cases I'm 100% certain of.Heap fragmentation is just a reality of some allocation patterns without a GC runtime.
One certainly can (and, in some cases, should) make their application more allocator-friendly, but - aside from some often-low-hanging fruit - this is a time-intensive process involving a bit of, for lack of a better word, arcane knowledge (I should inline all my fields and allocate on the stack as much as possible, right? Yes, well, except ...)
If you already have a halfway decent benchmark suite or workload generator, which you'll want for other purposes anyway, it's often a lot quicker to just try a few other allocators and select the one that handles your workload best.
If you think of tcmalloc as an old crusty allocator, you've probably only seen the gperftools version of it.
This is the version Google now uses internally: https://github.com/google/tcmalloc
It's worth a fresh look. In particular, it supports per-CPU caches as an alternative to per-thread caches. Those are fantastic if you have a lot more threads than CPUs. I haven't checked if it's been adapted for the latest upstream kernel API, but there's also the idea of "vcpu"-based caches: basically rather than a physical cpu id, it's an (optionally per-numa-node-based) dense id assigned to active threads, so that it still works well if you have a small cpu allocation for this process on a many-core machine.
tcmalloc at least gets internal changes regularly synced to github (since just a couple years ago iirc), vs. the ancient gperftools snapshot that's more widely known. And there are many other projects of potential interest to the outside world (Fibers...) that have been mentioned publicly but not open sourced at all.
Jemalloc may more recognition in the broader community, but the largest workloads seem to be running mimalloc / tcmalloc (I don’t know what Facebook uses internally). The libc malloc probably has even more users than either as it’s the default allocator for iOS and Android.
It is also a reality with a GC runtime, even a compacting one. Tracing GCs have acceptable performance only if the available memory is many times larger than the memory used. Technically this maybe isn't called fragmentation, but the effects are just as bad - the application uses much more memory than really needed.
Do kmalloc/kzmalloc do magic to circumvent this problem, similar to jemalloc?
Also, as the sibling comment said, when you are doing tight embedded, the solution is not to allocate things dynamically.
Essentially any language that does array bounds checking would be as safe as rust? Or am I missing anything?
C does not technically do that, so rust is still safer. But zig would be the same in the embedded space as rust? Or is there anything I am missing?
Something like C++ or Zig could enforce those rules even more easily.
"Values can be boxed (allocated on the heap) by creating a Box<T>" (https://doc.rust-lang.org/rust-by-example/std/box.html)
But then I see
Box<dyn std::error::Error>
everywhere, for example in https://github.com/oxidecomputer/hubris/search?q=dynIs Box really allocating here? Is the "Rust By Example" text incomplete?
Then I had to stop learning Rust for other reasons, but this doubt really hit me at the time.
Dynamic dispatch allows you to call trait methods on values without knowing the concrete type of that value, similar to C++ virtual methods or a C-style manually constructed vtable. In an embedded context this will mostly show up as function parameters, since you can't move things around on the stack without knowing their size. You might also see references to a dyn value, like `&dyn SomeTrait` -- this is similar to `Box<dyn SomeTrait>`, but is a borrowed reference and therefore doesn't imply allocation.
Your link to the Oxide Hubris repo is misleading you because the hits seem to be pretty much all in build-time helper scripts (`build.rs`) and other minor tooling. You'd want to look through the source of their main binary, which probably wouldn't use allocation just for error propagation.
Sorry about this. It wasn't intentional.
But just to confirm, Box<T> always allocates? So dynamic dispatch is not recommended in embedded, and thus crates that uses dynamic dispatch and/or Box<T>?
Dynamic allocation (Box) in embedded is not recommended.
`&dyn SomeTrait` is dynamic dispatch without dynamic allocation.
`Box<i32>` is dynamic allocation without dynamic dispatch.
So what is the ultimate cause of the fragmentation in this case? You can blame your allocator implementation, but how do you know it's not your use of the allocator? It feels like you are just slapping jemalloc on to solve this, which I suppose shows that the allocator you were using was causing problems before, but it doesn't really explain how? I suppose what I'm wondering is why specifically the allocator you were using before was causing this fragmentation... isn't that a bigger problem than you're making it sound?
Also, what else can an allocator do other than coalescing free blocks to decrease fragmentation? Does it involve occasional checks to defragment the heap ie moving separated blocks so they are adjacent?
This means that there is more memory wasted for small programs, but as memory use grows the wastage caused by this remains constant and allocation and deallocation will always remain fast.
(It does help in that if you have fixed size slabs, you can't waste space on that page, but you can still waste the entire page.)
Though I disagree with saying it's "just slapping jemalloc on to solve this". The piece of code in question definitely made the fragmentation issue worse, as it was making a lot of allocations of varying sizes, but the underlying issue of memory fragmentation because of the allocator was still there, and it would have just triggered later by a different code path.
That's what's nice about jemalloc, it has a more generic algorithm for reusing allocated blocks.
> When services exit abruptly, this can lower availability... which is bad for business.
interesting company
> Do you offer SLAs? We offer uptime SLAs of 99.999% for our enterprise customers, 99.99% for our business tier customers, and 99.9% for our startup tier users.
99.999 is miserable to support. how do you even get the granularity to measure, freedom to try anything? i dont think cloud services even provide slas like that for their services.
>Can you handle our scale? We process billions of webhooks a year for our customers,
1 billion a year would average out to 32 a second. if its 10 billion? 320/second. surprising to see rust for this. gc languages should be able to handle it.
would have been interesting to see how much fragmentation there is. reads like there was a debugging step missing.
A lot of redundancies and testing.
> 1 billion a year would average out to 32 a second. if its 10 billion? 320/second. surprising to see rust for this. gc languages should be able to handle it.
This assumes even distribution, though traffic is much much more spiky than this. We started with Python and switched to Rust. FWIW, Rust is great for many other reasons, not just the efficiency. For example, we absolutely love the type system.
> would have been interesting to see how much fragmentation there is. reads like there was a debugging step missing.
I'm not the engineer that did the investigation, so can't comment directly about what he checked. Maybe it's not written there, but he also measured allocator statistics and indeed saw that the memory used according to the US was much higher than what the allocator stats said.
5 9s of uptime means 800ms of down time a day. Is this not in a cloud, a load balancer health check can't react that fast. Vms will get randomly killed if something is wrong.