Skip to content

Commit db598c4

Browse files
authored
GC: implement a grow-vs-collect heuristic. (#12942)
* GC: implement a grow-vs-collect heuristic. This implements the heuristic discussed in #12860: it replaces the existing behavior where Wasmtime's GC, when allocating, will continue growing the GC heap up to its size limit before initiating a collection. That behavior optimizes for allocation performance but at the cost of resident memory size -- it is at one extreme end of that tradeoff spectrum. There are a number of use-cases where there may be heavy allocation traffic but a relatively small live-heap size compared to the total volume of allocations. For example, lots of temporary "garbage" may be allocated by many workloads. Or, more pertinently to #12860, a C/C++ workload that uses the underlying GC heap only for exceptions, and uses those exceptions in a way that only one `exnref` is live at a time (no `exn` objects are stashed away and used later), will also generate a lot of "garbage" during normal execution. These kinds of workloads benefit significantly from more frequent collection to keep the resident-set size small. This also may benefit performance, even accounting for the cost of the collection itself, because it keeps the footprint of touched memory within higher cache-hierarchy levels. In order to accommodate that kind of workload while also presenting reasonable behavior to large-working-set-size benchmarks, it is desirable to implement an *adaptive* policy. To that end, this PR implements a scheme similar to our OwnedRooted allocation/collection algorithm (and specified explicitly [here] by fitzgen): we use the last live-heap size (post-collection) compared to current capacity to decide whether to grow or collect. When the current capacity is more than twice the last live-heap size, we collect first; if we still can't allocate, then we grow. Otherwise, we just grow. The idea is that (when combined with an exponential heap-growth rule) the continuous-allocation case will collect once at each power-of-two, then grow; this is "amortized constant time" overhead. A case with a stable working-set size but with some ups and downs will never hit a "threshold-thrashing" problem: the heap capacity will tend toward twice the live-heap size, in the steady state (see proof [here](proof) for the analogous algorithm for `OwnedRooted`). Thus we have a nice, deterministic bound no matter what, with no bad (quadratic or worse) cases. This PR adds a test that creates a bunch of almost-immediately-dead garbage (allocates a GC struct that is only live for one iteration of a loop) and checks the heap size at each iteration. To allow this check, it also adds a method to `Store` to get the current GC heap capacity, which seems like a generally useful kind of observability as well. [here]: #12860 (comment) [proof]: https://github.com/bytecodealliance/wasmtime/blob/e5b127ccd71dbd7d447a32722b2c699abc46fe61/crates/wasmtime/src/runtime/gc/enabled/rooting.rs#L617-L678 * Review feedback. * Fix null-GC test by making `gorw_gc_heap` libcall always grow. * Fix indentation. * Fix merge from main. * fix merge from main * Fix OOM test after merge from main.
1 parent f4b58a7 commit db598c4

9 files changed

Lines changed: 180 additions & 19 deletions

File tree

crates/wasmtime/src/runtime/func.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2132,6 +2132,14 @@ impl<T> Caller<'_, T> {
21322132
self.store.gc(why)
21332133
}
21342134

2135+
/// Returns the current capacity of the GC heap in bytes.
2136+
///
2137+
/// Same as [`Store::gc_heap_capacity`](crate::Store::gc_heap_capacity).
2138+
#[cfg(feature = "gc")]
2139+
pub fn gc_heap_capacity(&self) -> usize {
2140+
self.store.0.gc_heap_capacity()
2141+
}
2142+
21352143
/// Perform garbage collection asynchronously.
21362144
///
21372145
/// Same as [`Store::gc_async`](crate::Store::gc_async).

crates/wasmtime/src/runtime/store.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1016,6 +1016,13 @@ impl<T> Store<T> {
10161016
StoreContextMut(&mut self.inner).gc(why)
10171017
}
10181018

1019+
/// Returns the current capacity of the GC heap in bytes, or 0 if the GC
1020+
/// heap has not been initialized yet.
1021+
#[cfg(feature = "gc")]
1022+
pub fn gc_heap_capacity(&self) -> usize {
1023+
self.inner.gc_heap_capacity()
1024+
}
1025+
10191026
/// Returns the amount fuel in this [`Store`]. When fuel is enabled, it must
10201027
/// be configured via [`Store::set_fuel`].
10211028
///
@@ -2032,6 +2039,16 @@ impl StoreOpaque {
20322039
}
20332040
}
20342041

2042+
/// Returns the current capacity of the GC heap in bytes, or 0 if the GC
2043+
/// heap has not been initialized yet.
2044+
#[cfg(feature = "gc")]
2045+
pub(crate) fn gc_heap_capacity(&self) -> usize {
2046+
match self.gc_store.as_ref() {
2047+
Some(gc_store) => gc_store.gc_heap_capacity(),
2048+
None => 0,
2049+
}
2050+
}
2051+
20352052
/// Helper to assert that a GC store was previously allocated and is
20362053
/// present.
20372054
///

crates/wasmtime/src/runtime/store/gc.rs

Lines changed: 60 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,7 @@ use super::*;
44
use crate::runtime::vm::VMGcRef;
55

66
impl StoreOpaque {
7-
/// Attempt to grow the GC heap by `bytes_needed` or, if that fails, perform
8-
/// a garbage collection.
7+
/// Perform any growth or GC needed to allocate `bytes_needed` bytes.
98
///
109
/// Note that even when this function returns it is not guaranteed
1110
/// that a GC allocation of size `bytes_needed` will succeed. Growing the GC
@@ -28,7 +27,7 @@ impl StoreOpaque {
2827
let root = root.map(|r| scope.gc_roots_mut().push_lifo_root(store_id, r));
2928

3029
scope
31-
.grow_or_collect_gc_heap(limiter, bytes_needed, asyncness)
30+
.collect_and_maybe_grow_gc_heap(limiter, bytes_needed, asyncness)
3231
.await;
3332

3433
root.map(|r| {
@@ -50,29 +49,33 @@ impl StoreOpaque {
5049
}
5150
}
5251

53-
async fn grow_or_collect_gc_heap(
52+
/// Helper invoked as part of `gc`, whose purpose is to GC and
53+
/// maybe grow for a pending allocation of a given size.
54+
async fn collect_and_maybe_grow_gc_heap(
5455
&mut self,
5556
limiter: Option<&mut StoreResourceLimiter<'_>>,
5657
bytes_needed: Option<u64>,
5758
asyncness: Asyncness,
5859
) {
60+
self.do_gc(asyncness).await;
5961
if let Some(n) = bytes_needed
6062
// The gc_zeal's allocation counter will pass `bytes_needed == 0` to
6163
// signify that we shouldn't grow the GC heap, just do a collection.
6264
&& n > 0
65+
&& n > u64::try_from(self.gc_heap_capacity())
66+
.unwrap()
67+
.saturating_sub(self.gc_store.as_ref().map_or(0, |gc| {
68+
u64::try_from(gc.last_post_gc_allocated_bytes.unwrap_or(0)).unwrap()
69+
}))
6370
{
64-
if self.grow_gc_heap(limiter, n).await.is_ok() {
65-
return;
66-
}
71+
let _ = self.grow_gc_heap(limiter, n).await;
6772
}
68-
69-
self.do_gc(asyncness).await;
7073
}
7174

7275
/// Attempt to grow the GC heap by `bytes_needed` bytes.
7376
///
7477
/// Returns an error if growing the GC heap fails.
75-
async fn grow_gc_heap(
78+
pub(crate) async fn grow_gc_heap(
7679
&mut self,
7780
limiter: Option<&mut StoreResourceLimiter<'_>>,
7881
bytes_needed: u64,
@@ -170,8 +173,14 @@ impl StoreOpaque {
170173
}
171174
}
172175

173-
/// Attempt an allocation, if it fails due to GC OOM, then do a GC and
174-
/// retry.
176+
/// Attempt an allocation, if it fails due to GC OOM, apply the
177+
/// grow-or-collect heuristic and retry.
178+
///
179+
/// The heuristic is:
180+
/// - If the last post-collection heap usage is less than half the current
181+
/// capacity, collect first, then retry. If that still fails, grow and
182+
/// retry one final time.
183+
/// - Otherwise, grow first and retry.
175184
pub(crate) async fn retry_after_gc_async<T, U>(
176185
&mut self,
177186
mut limiter: Option<&mut StoreResourceLimiter<'_>>,
@@ -188,9 +197,45 @@ impl StoreOpaque {
188197
Err(e) => match e.downcast::<crate::GcHeapOutOfMemory<T>>() {
189198
Ok(oom) => {
190199
let (value, oom) = oom.take_inner();
191-
self.gc(limiter, None, Some(oom.bytes_needed()), asyncness)
192-
.await;
193-
alloc_func(self, value)
200+
let bytes_needed = oom.bytes_needed();
201+
202+
// Determine whether to collect or grow first.
203+
let should_collect_first = self.gc_store.as_ref().map_or(false, |gc_store| {
204+
let capacity = gc_store.gc_heap_capacity();
205+
let last_usage = gc_store.last_post_gc_allocated_bytes.unwrap_or(0);
206+
last_usage < capacity / 2
207+
});
208+
209+
if should_collect_first {
210+
// Collect first, then retry.
211+
self.gc(limiter.as_deref_mut(), None, None, asyncness).await;
212+
213+
match alloc_func(self, value) {
214+
Ok(x) => Ok(x),
215+
Err(e) => match e.downcast::<crate::GcHeapOutOfMemory<T>>() {
216+
Ok(oom2) => {
217+
// Collection wasn't enough; grow and try
218+
// one final time.
219+
let (value, _) = oom2.take_inner();
220+
// Ignore error; we'll get one
221+
// from `alloc_func` below if
222+
// growth failed and failure to
223+
// grow was fatal.
224+
let _ = self.grow_gc_heap(limiter, bytes_needed).await;
225+
alloc_func(self, value)
226+
}
227+
Err(e) => Err(e),
228+
},
229+
}
230+
} else {
231+
// Grow first and retry.
232+
//
233+
// Ignore error; we'll get one from
234+
// `alloc_func` below if growth failed and
235+
// failure to grow was fatal.
236+
let _ = self.grow_gc_heap(limiter, bytes_needed).await;
237+
alloc_func(self, value)
238+
}
194239
}
195240
Err(e) => Err(e),
196241
},

crates/wasmtime/src/runtime/vm/gc.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ pub struct GcStore {
4949
/// The function-references table for this GC heap.
5050
pub func_ref_table: FuncRefTable,
5151

52+
/// The total allocated bytes recorded after the last GC collection.
53+
/// `None` if no collection has been performed yet. Used by the
54+
/// grow-or-collect heuristic.
55+
pub last_post_gc_allocated_bytes: Option<usize>,
56+
5257
/// An allocation counter that triggers GC when it reaches zero.
5358
///
5459
/// Initialized from the `WASMTIME_GC_ZEAL_ALLOC_COUNTER` environment
@@ -86,6 +91,7 @@ impl GcStore {
8691
gc_heap,
8792
host_data_table,
8893
func_ref_table,
94+
last_post_gc_allocated_bytes: None,
8995
#[cfg(all(gc_zeal, feature = "std"))]
9096
gc_zeal_alloc_counter: gc_zeal_alloc_counter_init,
9197
#[cfg(all(gc_zeal, feature = "std"))]
@@ -98,10 +104,16 @@ impl GcStore {
98104
self.gc_heap.vmmemory()
99105
}
100106

107+
/// Get the current capacity (in bytes) of this GC heap.
108+
pub fn gc_heap_capacity(&self) -> usize {
109+
self.gc_heap.heap_slice().len()
110+
}
111+
101112
/// Asynchronously perform garbage collection within this heap.
102113
pub async fn gc(&mut self, asyncness: Asyncness, roots: GcRootsIter<'_>) {
103114
let collection = self.gc_heap.gc(roots, &mut self.host_data_table);
104115
collect_async(collection, asyncness).await;
116+
self.last_post_gc_allocated_bytes = Some(self.gc_heap.allocated_bytes());
105117
}
106118

107119
/// Get the kind of the given GC reference.

crates/wasmtime/src/runtime/vm/gc/enabled/drc.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,9 @@ struct DrcHeap {
152152
/// behind an empty vec instead of `None`) but we keep it because it will
153153
/// help us catch unexpected re-entry, similar to how a `RefCell` would.
154154
dec_ref_stack: Option<Vec<VMGcRef>>,
155+
156+
/// Running total of bytes currently allocated (live objects) in this heap.
157+
allocated_bytes: usize,
155158
}
156159

157160
impl DrcHeap {
@@ -167,6 +170,7 @@ impl DrcHeap {
167170
vmmemory: None,
168171
free_list: None,
169172
dec_ref_stack: Some(Vec::with_capacity(1)),
173+
allocated_bytes: 0,
170174
})
171175
}
172176

@@ -187,6 +191,7 @@ impl DrcHeap {
187191
self.heap_slice_mut()[index..][..alloc_size].fill(POISON);
188192
}
189193

194+
self.allocated_bytes -= usize::try_from(size).unwrap();
190195
self.free_list
191196
.as_mut()
192197
.unwrap()
@@ -798,6 +803,7 @@ unsafe impl GcHeap for DrcHeap {
798803
dec_ref_stack,
799804
memory,
800805
vmmemory,
806+
allocated_bytes,
801807

802808
// NB: we will only ever be reused with the same engine, so no need
803809
// to clear out our tracing info just to fill it back in with the
@@ -809,6 +815,7 @@ unsafe impl GcHeap for DrcHeap {
809815
**over_approximated_stack_roots = None;
810816
*free_list = None;
811817
*vmmemory = None;
818+
*allocated_bytes = 0;
812819
debug_assert!(dec_ref_stack.as_ref().is_some_and(|s| s.is_empty()));
813820

814821
memory.take().unwrap()
@@ -964,6 +971,7 @@ unsafe impl GcHeap for DrcHeap {
964971
next_over_approximated_stack_root: None,
965972
object_size,
966973
};
974+
self.allocated_bytes += layout.size();
967975
log::trace!("new object: increment {gc_ref:#p} ref count -> 1");
968976
Ok(Ok(gc_ref))
969977
}
@@ -1021,6 +1029,10 @@ unsafe impl GcHeap for DrcHeap {
10211029
.length
10221030
}
10231031

1032+
fn allocated_bytes(&self) -> usize {
1033+
self.allocated_bytes
1034+
}
1035+
10241036
fn gc<'a>(
10251037
&'a mut self,
10261038
roots: GcRootsIter<'a>,

crates/wasmtime/src/runtime/vm/gc/enabled/null.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,13 @@ unsafe impl GcHeap for NullHeap {
327327
self.index(arrayref).length
328328
}
329329

330+
fn allocated_bytes(&self) -> usize {
331+
// The null collector never frees, so everything from the start of
332+
// the heap up to the bump pointer is allocated.
333+
let next = unsafe { *self.next.get() };
334+
usize::try_from(next.get()).unwrap()
335+
}
336+
330337
fn gc<'a>(
331338
&'a mut self,
332339
_roots: GcRootsIter<'a>,

crates/wasmtime/src/runtime/vm/gc/gc_runtime.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,12 @@ pub unsafe trait GcHeap: 'static + Send + Sync {
342342
////////////////////////////////////////////////////////////////////////////
343343
// Garbage Collection Methods
344344

345+
/// Get the total number of bytes currently allocated (live or
346+
/// dead-but-not-collected) in this heap.
347+
///
348+
/// This is distinct from the heap capacity.
349+
fn allocated_bytes(&self) -> usize;
350+
345351
/// Start a new garbage collection process.
346352
///
347353
/// The given `roots` are GC roots and should not be collected (nor anything

crates/wasmtime/src/runtime/vm/libcalls.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -642,10 +642,10 @@ fn grow_gc_heap(store: &mut dyn VMStore, _instance: InstanceId, bytes_needed: u6
642642
.unwrap();
643643

644644
let (mut limiter, store) = store.resource_limiter_and_store_opaque();
645-
block_on!(store, async |store, asyncness| {
646-
store
647-
.gc(limiter.as_mut(), None, Some(bytes_needed), asyncness)
648-
.await;
645+
block_on!(store, async |store, _asyncness| {
646+
// We error below if there's still not enough space; swallow
647+
// any growth failures here.
648+
let _ = store.grow_gc_heap(limiter.as_mut(), bytes_needed).await;
649649
})?;
650650

651651
// JIT code relies on the memory having grown by `bytes_needed` bytes if

tests/all/gc.rs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1708,3 +1708,57 @@ fn select_gc_ref_stack_map() -> Result<()> {
17081708

17091709
Ok(())
17101710
}
1711+
1712+
#[test]
1713+
#[cfg_attr(miri, ignore)]
1714+
fn gc_heap_does_not_grow_unboundedly() -> Result<()> {
1715+
let _ = env_logger::try_init();
1716+
1717+
let mut config = Config::new();
1718+
config.wasm_function_references(true);
1719+
config.wasm_gc(true);
1720+
config.collector(Collector::DeferredReferenceCounting);
1721+
1722+
let engine = Engine::new(&config)?;
1723+
1724+
let module = Module::new(
1725+
&engine,
1726+
r#"
1727+
(module
1728+
(type $small (struct (field i32)))
1729+
(import "" "check" (func $check))
1730+
1731+
(func (export "run") (param i32)
1732+
(local $i i32)
1733+
(local $tmp (ref null $small))
1734+
(loop $loop
1735+
(local.set $tmp (struct.new $small (i32.const 42)))
1736+
1737+
;; Call the host to check heap size.
1738+
(call $check)
1739+
1740+
;; Loop counter.
1741+
(local.set $i (i32.add (local.get $i) (i32.const 1)))
1742+
(br_if $loop (i32.lt_u (local.get $i) (local.get 0)))
1743+
)
1744+
)
1745+
)
1746+
"#,
1747+
)?;
1748+
1749+
let mut store = Store::new(&engine, ());
1750+
1751+
let check = Func::wrap(&mut store, |caller: Caller<'_, _>| {
1752+
let heap_size = caller.gc_heap_capacity();
1753+
assert!(
1754+
heap_size <= 65536,
1755+
"GC heap grew too large: {heap_size} bytes (limit: 64KiB)"
1756+
);
1757+
});
1758+
1759+
let instance = Instance::new(&mut store, &module, &[check.into()])?;
1760+
let run = instance.get_typed_func::<(i32,), ()>(&mut store, "run")?;
1761+
run.call(&mut store, (100_000,))?;
1762+
1763+
Ok(())
1764+
}

0 commit comments

Comments
 (0)