bytecodealliance / bytecodealliance/wasmtime
Improving wasmtime performance on illumos via mmapping /dev/null
- Dominant language
- Rust
- Stars
- 18.6k
- Forks
- 1.8k
- Avg merge
- 1d 18h
- Merged PRs (30d)
- 126
Description
(I'm still a novice to the wasmtime code so please let me know if I've made any mistakes. Thanks!)
## Motivation
In #9535 we added initial support for illumos. (Thanks!) As noted there, we did notice some performance issues, particularly around freeing memory at shutdown.
Some of my colleagues and I investigated the situation today, and we have a pretty good sense of what's going on (some of my colleagues will be writing up the related illumos issues at some point, but it's essentially that anonymous `MAP_NORESERVE` allocations of size N take up O(N) CPU, rather than apparently O(1) as on some other platforms. The constant factor is very small, but wasmtime allocates enough memory that it is noticeable.)
However, we've found that there's an alternative mmapping strategy that works well, bringing illumos perf to within the same ballpark as Linux (at least as far as the wast test suite goes). The alternative strategy is:
* For `PROT_NONE` mappings, rather than creating a large amount of _anonymous_ memory, create a map to `/dev/null`.
* When a particular part of memory needs to be made accessible in any way, create an anonymous mmap. Accessible memory is a very small portion of the overall address space map, so this is fine.
* The regions that have "real" anonymous mmaps associated with them need to be tracked via a data structure, ideally an interval tree. Essentially, some of the bookkeeping the kernel currently does makes its way into userspace.
I have a quick and dirty prototype that shows how `/dev/null` mapping might work, though it needs a lot of work to be made shippable. The prototype is at https://github.com/sunshowers/wasmtime/pull/1 -- `mmap.rs` has the meaty interval tree logic if you're interested.
With this PR,
```
% cargo +beta test --test wast -- --test-threads 1 Cranelift/pooling/tests/spec_testsuite/load.wast
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 2491 filtered out; finished in 0.08s
```
(Note that this strategy doesn't work on Linux -- `mmap`ing `/dev/null` produces `ENODEV`. Naturally, if we go this route, this will be a platform-specific impl of mmapping on illumos, and possibly other systems where `/dev/null`-based mapping might be faster.)
## Challenges
The most important implication of this approach is that all memory management *must* go through the mmap. This means that nothing outside the mmap code should call `mmap`, `mmap_anonymous` or `mprotect` directly.
This turns out to be a bit of a sticking point, sadly. Several parts of the wasmtime VM store pointers to base addresses and operate on them directly.
* For example, [`cow.rs`](https://github.com/bytecodealliance/wasmtime/blob/f305ef5214d998f9a7ea07ea48a61d92077d19ee/crates/wasmtime/src/runtime/vm/cow.rs#L686) calls `expose_existing_mapping` directly, which on Unix [calls `mprotect`](https://github.com/bytecodealliance/wasmtime/blob/f305ef5214d998f9a7ea07ea48a61d92077d19ee/crates/wasmtime/src/runtime/vm/sys/unix/vm.rs#L10). With `/dev/null`-based mapping, that would no longer be possible -- that code must go through `Mmap` instead.
While some components that currently store addresses can be passed a `&Mmap`, this turns out to be more challenging for other parts -- particularly the `RuntimeLinearMemory` API, which is generic over both an [owned `MmapMemory`](https://github.com/bytecodealliance/wasmtime/blob/f305ef5214d998f9a7ea07ea48a61d92077d19ee/crates/wasmtime/src/runtime/vm/memory.rs#L175) and a logically-borrowed [`StaticMemory`](https://github.com/bytecodealliance/wasmtime/blob/f305ef5214d998f9a7ea07ea48a61d92077d19ee/crates/wasmtime/src/runtime/vm/memory.rs#L438).
In my prototype I just decided to store a `SendSyncPtr` inside `StaticMemory`, with the hope/promise that the `Mmap` outlives the `StaticMemory`. I don't think that's hugely worse than storing a raw `*mut u8` as we do today where there's the same implicit promise, but it is arguably not great.
I'll also say that in general, this change leads to some nice internal improvements. For example, there are currently at least three different implementations of decommitting memory in the kernel, and only one of them does `MADV_DONTNEED` on Linux. With my prototype, all of that lives in one spot.
## Possible solutions
So given that we've established the benefits of changing out the style of mapping, at least on illumos -- and given the challenges I encountered, I think there are a few approaches we could take:
1. Do nothing. Wasmtime continues to use the current strategy and continues to have performance issues on illumos, though at least we now know what's going on. It is certainly fair to say that the illumos kernel shouldn't take O(N) time for NORESERVE mappings, but my understanding is that fixing that is likely going to take a while. `/dev/null`-based mapping is also the [officially recommended way](https://illumos.org/man/4D/null) to do this on illumos.
2. In the spots that currently store `*mut u8`, if it is too inconvenient to pass in a `&Mmap`, store a pointer to the `Mmap` instead. If I understand correctly, these spots all already have an implicit requirement that the `Mmap` outlives them, so this would probably not be _worse_ than today. But it is new unsafe Rust.
3. Refactor wasmtime to change APIs and add lifetimes to various spots as needed, so that they can all hold on to an `&'map Mmap` or similar. This seemed a bit daunting to me -- particularly the `Box` abstracting over both owned and borrowed memory mappings -- but as I said in the beginning, I'm a novice so it's probably a lot easier for an experienced wasmtime dev.
4. Move `Mmap` into an `Arc` and store (strong? weak?) refs to it where needed.
5. Other ideas?
I'd love to hear y'all's thoughts on this!
Contributor guide
Assessment
This issue has not been assessed yet.