Skip to content

Gc performance improvements, in particular for high-concurrency#13822

Open
tschneidereit wants to merge 3 commits into
bytecodealliance:mainfrom
tschneidereit:gc-perf-improvements
Open

Gc performance improvements, in particular for high-concurrency#13822
tschneidereit wants to merge 3 commits into
bytecodealliance:mainfrom
tschneidereit:gc-perf-improvements

Conversation

@tschneidereit

Copy link
Copy Markdown
Member

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:

rev concurrency=1 concurrency=20
main 239 168
initial-heap-size=2MB 1549 1809
batched-trace-info 1565 4050
cached-subtype-checks 1610 17681

@tschneidereit tschneidereit requested a review from fitzgen July 6, 2026 14:14
@tschneidereit tschneidereit requested a review from a team as a code owner July 6, 2026 14:14
@tschneidereit

Copy link
Copy Markdown
Member Author

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?

@tschneidereit tschneidereit force-pushed the gc-perf-improvements branch from 994fd08 to 15cf838 Compare July 6, 2026 14:17
@tschneidereit

Copy link
Copy Markdown
Member Author

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?

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.
@tschneidereit tschneidereit force-pushed the gc-perf-improvements branch from 15cf838 to ff2c387 Compare July 6, 2026 14:48

@alexcrichton alexcrichton left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread crates/wasmtime/src/config.rs
Comment on lines +125 to +135
/// 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);
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See the conversation over here

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

err, wrong thread

Comment on lines +434 to +475
/// 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);
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this use NopHasher we have elsewhere perhaps?

@fitzgen fitzgen left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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))
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

@github-actions github-actions Bot added wasmtime:api Related to the API of the `wasmtime` crate itself wasmtime:config Issues related to the configuration of Wasmtime wasmtime:ref-types Issues related to reference types and GC in Wasmtime labels Jul 6, 2026
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

Subscribe to Label Action

cc @fitzgen

Details This 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:

  • fitzgen: wasmtime:ref-types

To subscribe or unsubscribe from this label, edit the .github/subscribe-to-label.json configuration file.

Learn more.

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

Label Messager: wasmtime:config

It looks like you are changing Wasmtime's configuration options. Make sure to
complete this check list:

  • If you added a new Config method, you wrote extensive documentation for
    it.

    Details

    Our documentation should be of the following form:

    Short, simple summary sentence.
    
    More details. These details can be multiple paragraphs. There should be
    information about not just the method, but its parameters and results as
    well.
    
    Is this method fallible? If so, when can it return an error?
    
    Can this method panic? If so, when does it panic?
    
    # Example
    
    Optional example here.
    
  • If you added a new Config method, or modified an existing one, you
    ensured that this configuration is exercised by the fuzz targets.

    Details

    For example, if you expose a new strategy for allocating the next instance
    slot inside the pooling allocator, you should ensure that at least one of our
    fuzz targets exercises that new strategy.

    Often, all that is required of you is to ensure that there is a knob for this
    configuration option in wasmtime_fuzzing::Config (or one
    of its nested structs).

    Rarely, this may require authoring a new fuzz target to specifically test this
    configuration. See our docs on fuzzing for more details.

  • If you are enabling a configuration option by default, make sure that it
    has been fuzzed for at least two weeks before turning it on by default.


Details

To modify this label's message, edit the .github/label-messager/wasmtime-config.md file.

To add new label messages or remove existing label messages, edit the
.github/label-messager.json configuration file.

Learn more.

@fitzgen

fitzgen commented Jul 6, 2026

Copy link
Copy Markdown
Member

Also I'd be curious whether you saw/see any perf improvements on Sightglass's GC benchmarks:

@tschneidereit

Copy link
Copy Markdown
Member Author

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 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

wasmtime:api Related to the API of the `wasmtime` crate itself wasmtime:config Issues related to the configuration of Wasmtime wasmtime:ref-types Issues related to reference types and GC in Wasmtime

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants