Gc performance improvements, in particular for high-concurrency#13822
Gc performance improvements, in particular for high-concurrency#13822tschneidereit wants to merge 3 commits into
Conversation
|
One thing I'm wondering is if we should choose a different default for the initial GC heap size allocation. Perhaps we should set that to at least one wasm page? |
994fd08 to
15cf838
Compare
Testing this just now, it doesn't seem to really make much of a difference. Which I guess makes some sense: setting the initial size to 64kb will only ever remove the first, cheapest growth cycle. |
Setting an initial heap size > 0 avoids frequent collect-then-grow cycles during instance startup. In testing with a mid-sized Kotlin component, this improves `wasmtime serve` throughput from 240 RPS to 1550 RPS when processing a single request at a time, and from 168 to 1809 RPS for concurrency == 20.
Module instantiation eagerly registers GC trace info for every type in the module, and each registration acquired the engine type registry's read lock and deep-cloned a GcLayout inside the critical section. Batching the registration improves performance of a mid-sized Kotlin component when processing many concurrent requests under `wasmtime serve` from 1565 RPS to 4050 RPS.
Avoids taking a lock on the engine's type registry. Doesn't really affect single-threaded performance, but for a mid-sized Kotlin component I see performance improve from 4050 RPS to 17680 RPS. Note that while I initially thought the fibonacci hashing would be overkill, it consistently improves performance by about 2%, which seems to be worth it.
15cf838 to
ff2c387
Compare
alexcrichton
left a comment
There was a problem hiding this comment.
Overall this looks pretty reasonable to me, and are definitely levers that were expected to be slow points about the GC implementation (primarily all the locking).
Personally I'd like to ideally consider completely redesigning how TraceInfo is managed (the second commit here). To me this seems like something we should precompute at module-creation-time and not require any locks at all during instantiation/runtime. As-is that's not going to be an easy refactor, however.
I also think that this is something where we're going to need to bust out unsafe and raw pointers. For example what I'm thinking is that Modules (and RegisteredTypes) should have TraceInfo precomputed along with a map from VMSharedTypeIndex to this information. When instantiating within a store this shouldn't require cloning TraceInfo because that'll just slow down instantiation, especially when there are a lot of types. The module/RegisteredType is already strong-held by the store as well, so using more Arcs is also extra overhead (e.g. cloning a contended Arc is not the cheapest).
Basically the second commit here I'd like to ideally separate out and take some time to figure out what we "really want to do" w.r.t. performance there.
The third commit I think is another in this category, but the true fix is something we know about and is also pretty gnarly. As a temporary fix for performance the hash-map-cache seems reasonable.
So, tl;dr; I think it's fine to land the 1st/3rd commits here with light modifications, but I'd prefer to take more time to figure out what to do about the 2nd commit.
| /// Bulk version of [`GcHeap::ensure_trace_info`]. | ||
| /// | ||
| /// Collectors that derive tracing info from the engine's type registry | ||
| /// should override this to process the whole batch with a single registry | ||
| /// access, since acquiring the registry's lock once per type serializes | ||
| /// concurrent stores running on different threads. | ||
| fn ensure_trace_infos(&mut self, tys: &mut dyn Iterator<Item = VMSharedTypeIndex>) { | ||
| for ty in tys { | ||
| self.ensure_trace_info(ty); | ||
| } | ||
| } |
There was a problem hiding this comment.
Would it be possible to model ensure_trace_info as a wrapper around this function instead of the other way around? That way implementations are guaranteed to, by default, implement the bulk registration.
| /// A cheap multiplicative hasher for the store-local GC type-metadata caches, | ||
| /// whose keys are `VMSharedTypeIndex`es (or pairs thereof, packed into a | ||
| /// `u64`). These caches are on hot paths such as `ref.cast` and GC-object | ||
| /// allocation, so we want to avoid the default hasher's per-lookup overhead. | ||
| /// Collision-based DoS is not a concern for engine-assigned type indices. | ||
| #[cfg(feature = "gc")] | ||
| #[derive(Default)] | ||
| struct GcTypeCacheHasher(u64); | ||
|
|
||
| #[cfg(feature = "gc")] | ||
| impl core::hash::BuildHasher for GcTypeCacheHasher { | ||
| type Hasher = Self; | ||
|
|
||
| #[inline] | ||
| fn build_hasher(&self) -> Self { | ||
| Self::default() | ||
| } | ||
| } | ||
|
|
||
| #[cfg(feature = "gc")] | ||
| impl core::hash::Hasher for GcTypeCacheHasher { | ||
| #[inline] | ||
| fn finish(&self) -> u64 { | ||
| self.0 | ||
| } | ||
|
|
||
| fn write(&mut self, _bytes: &[u8]) { | ||
| unreachable!("only used with integer keys") | ||
| } | ||
|
|
||
| #[inline] | ||
| fn write_u32(&mut self, i: u32) { | ||
| self.write_u64(i.into()); | ||
| } | ||
|
|
||
| #[inline] | ||
| fn write_u64(&mut self, i: u64) { | ||
| // Fibonacci hashing: cheap and mixes the (small integer) type indices | ||
| // into the high bits that hashbrown uses for its control bytes. | ||
| self.0 = i.wrapping_mul(0x9E37_79B9_7F4A_7C15); | ||
| } | ||
| } |
There was a problem hiding this comment.
Could this use NopHasher we have elsewhere perhaps?
fitzgen
left a comment
There was a problem hiding this comment.
Thanks! A few requested tweaks.
| /// The default value for this is 0. | ||
| #[cfg(feature = "gc")] | ||
| pub fn gc_heap_initial_size(&mut self, bytes: u64) -> &mut Self { | ||
| self.gc_heap_initial_size = bytes; |
There was a problem hiding this comment.
Can we store this in the tunables, where we store the other similar knobs for memories and the GC heap?
Also, what happens if the gc_heap_initial_size is larger than the gc_heap_reservation? It seems like maybe we actually just want to always make this be round_down(gc_heap_reservation, DEFAULT_WASM_PAGE_SIZE) and adjust the Tunables::gc_heap_memory_type method to have its initial/min size reflect that reservation size. This would, I think, give us the wins this commit is aiming for without adding a new / partially redundant config knob.
There was a problem hiding this comment.
Also, if we do end up keeping this new config knob, we want to also hook it up to our fuzz config in crates/fuzzing/src/generators/config.rs
There was a problem hiding this comment.
The reason I didn't make it a tunable is that it doesn't affect compilation, and can be tweaked at runtime if so desired. I agree that it's in a bit of a gnarly location as-is, though.
There was a problem hiding this comment.
but I do like the idea of round_down(gc_heap_reservation, DEFAULT_WASM_PAGE_SIZE), so maybe that's just the answer and the option can go away entirely.
| /// More efficient than calling [`Self::ensure`] in a loop: the engine's | ||
| /// type registry is consulted only once for the whole batch of types that | ||
| /// are not already present, rather than once per type. | ||
| pub fn ensure_many(&mut self, tys: impl IntoIterator<Item = VMSharedTypeIndex>) { |
There was a problem hiding this comment.
As an additional potential follow up: we could probably make this register even fewer trace infos by making it so that if the collector keeps small trace infos inline in the GC header (which the copying collector does) then we don't actually register that type in the TraceInfos registry. Would need a little API design work since different collectors do/don't store tracing data inline the GC header, and theoretically they could also have different capacity for the size of trace info data that can be stored inline in the header. But it is worth looking into.
| .subtype_check_cache | ||
| .entry(key) | ||
| .or_insert_with(|| engine.signatures().is_subtype(sub, sup)) | ||
| } |
There was a problem hiding this comment.
Could we factor this cache out into its own type to try and encapsulate some of the details from the rest of the StoreOpaque (which is massive, and I don't want to keep making it more massive), something like this:
// is_subtype_cache.rs
pub(crate) struct IsSubtypeCache {
cache: crate::hash_map::HashMap<u64, bool, GcTypeCacheHasher>,
}
impl IsSubtypeCache {
pub(crate) fn is_subtype(&mut self, engine: &Engine, sub: VMSharedTypeIndex, sup: VMSharedTypeIndex) -> bool {
// ...
}
}
// store.rs
impl StoreOpaque {
pub fn is_subtype(&mut self, sub: VMSharedTypeIndex, sup: VMSharedTypeIndex) -> bool {
self.is_subtype_cache.is_subtype(&self.engine, sub, sup)
}
}Also, do we want this to cache's size to grow unbounded? Maybe we want to use an LRU cache like https://docs.rs/associative-cache/latest/associative_cache/ instead?
Subscribe to Label Actioncc @fitzgen DetailsThis issue or pull request has been labeled: "wasmtime:api", "wasmtime:config", "wasmtime:ref-types"Thus the following users have been cc'd because of the following labels:
To subscribe or unsubscribe from this label, edit the |
Label Messager: wasmtime:configIt looks like you are changing Wasmtime's configuration options. Make sure to
DetailsTo modify this label's message, edit the To add new label messages or remove existing label messages, edit the |
|
Also I'd be curious whether you saw/see any perf improvements on Sightglass's GC benchmarks: |
I should've mentioned that I looked at that and then decided that there's no way we'd backport it, so focused on something much smaller ... and then forgot to note that the real fix should happen as follow-up work. |
This is a set of commits which, combined, substantially improve GC performance, at least as measured for one mid-sized Kotlin component I've been testing. (Which I unfortunately can't share.)
While single-threaded performance improves substantially with the first commit, the other two only make a real difference when running multiple instances in parallel. In total, performance for 20 concurrent requests improves from 168 RPS to 17680 RPS:
main