wasm_heap (wasm v0.1.0)
View SourceThe garbage-collected object store.
You will not call this directly; wasm:pin/2 and wasm:release/2 are the parts
you use. Read it when you want to know what a struct or an array costs, or why
holding a reference needs a pin.
Why it is not a term inside the instance state
It was, and that was the most expensive thing in this runtime.
wasm_instance:set_mut/2 writes the #mut{} into ETS, ETS copies on insert,
and the object store sat inside it. So one struct.set copied every object in
the heap: 20.7 us at a thousand objects, 199 us at ten thousand, 1993 us at a
hundred thousand. That is 19.7 ns per object in the heap, per mutating call,
against 0.15 us for a call that only reads.
The store now lives here, mutated in place, and the handle is immutable: two
ETS table ids and an atomics reference. It travels in #inst{}, the half of
an instance that never changes, so an allocation or a field write leaves the
state term untouched and the write-back never fires.
Why ETS, when array is faster per operation
array wins on both: 13.4 ns against 33.9 ns for a read, 50.6 ns against
93.0 ns for a write. It loses anyway, because those are the wrong numbers to
compare. An array has to be written back somewhere to be shared, and writing
it back copies it, so the cost that matters is per call and proportional to
the heap. ETS rows are mutated in place. The read pays 20 ns more and the call
stops paying milliseconds.
Layout
Up to two tables, because they are traversed differently:
- objects, a
set. A struct is{Id, s, TypeIdx, F0, F1, ...}, one tuple element per field, so reading a field copies the field and not the object. An array is its header only:{Id, a, TypeIdx, Len, Default}. - elements, an
ordered_setkeyed{Id, Index}, holding array elements that have actually been written. An array of a million defaults costs one row, and the ordering is what lets the collector walk one array's elements without scanning the table. A module declaring no array type never gets this table, because a table costs about a microsecond and instantiation is under three.
Identity
Ids come from an atomics counter and are never reused, so ref.eq stays an
integer comparison. The sweep walks the table's rows rather than the id space,
so it costs what is in the store and not what has ever been in it. What growing
ids do still cost is the mark bitmap, which is sized to the highest id handed
out. Reuse needs a free list, and it is not safe until every reference that leaves
the runtime is pinned: today an unpinned one traps, and with reused ids it would
silently name a different live object instead.
Summary
Functions
Fill a range with one value.
Write an element whose index is already known to be in range.
Collect, minor unless the old generation has grown enough to warrant a major.
Drop an instance, releasing the heap once none is left.
Every instance registered with this heap.
Take a read lease on the store, answering whether one was taken.
Whether the next collection will trace the whole store.
Create a heap.
Allocate an array.
Keep an object alive across collections until it is released.
Every pinned reference, as collection roots.
How many read leases are outstanding. For tests and diagnostics.
Record that an instance's roots have to be traced when this heap collects.
Release the exclusive hold, leaving any read lease the caller had.
Record that a collection is wanted but could not be performed now.
Whether enough has been allocated since the last collection to be worth
tracing. A call that allocates nothing answers in two atomics reads.
How many objects the store holds. For tests and diagnostics.
Take the store exclusively, from a caller already holding one read lease.
The kind and declared type of an object, for ref.test and ref.cast.
Give up a read lease.
Release one pin, letting the object be collected once nothing else holds it.
Release every pin, for when you scope references to a request.
Types
-nominal heap() :: {wasm_heap, ets:tid(), undefined | ets:tid(), atomics:atomics_ref()}.
An opaque handle. Immutable: the tables it names are what change.
Functions
-spec allocs(undefined | heap()) -> non_neg_integer().
-spec array_fill(heap(), term(), non_neg_integer(), non_neg_integer(), term()) -> ok.
Fill a range with one value.
A fill covering the whole array is not a loop at all: every element becomes the
same value, which is precisely what the default means here, so it is one row
update plus dropping whatever overrides existed. Arrays.fill and zeroing a
freshly allocated array are both this shape.
The caller has already checked the range.
-spec array_get(undefined | heap(), term(), non_neg_integer()) -> term().
-spec array_len(undefined | heap(), term()) -> non_neg_integer().
-spec array_set(undefined | heap(), term(), non_neg_integer(), term()) -> ok.
-spec array_set_unchecked(heap(), term(), non_neg_integer(), term()) -> ok.
Write an element whose index is already known to be in range.
Every bulk operation checks its whole range up front, because one that traps must leave the array untouched. Checking again per element then doubles the table operations to answer a question already answered.
Collect, minor unless the old generation has grown enough to warrant a major.
Minor. Objects allocated since the last collection are exactly the id range
[watermark, next_id), because ids are handed out by a counter and never
reused. So the nursery needs no bookkeeping at all: it is an integer range, and
sweeping it is a loop over that range rather than a walk of the store.
A minor collection never traces an old object, which is what makes the pause
proportional to what was just allocated rather than to everything alive. That is
sound because an old object can only point at a young one through a write made
since the last collection, and set_field/4 and array_set/4 record those.
Major. Traces and sweeps everything, and is where the long pause lives. It
runs when the store has grown past gc_major_ratio times its size after the
last major (default 2), so a program with a stable live set almost never has one.
Mark and sweep rather than copying, because object ids are handed out and
compared by ref.eq, so they must not move. The mark set is an atomics bitmap
and the worklist holds ids rather than values: the first collector kept its live
set in a map and built its worklist with ++, costing 76 ns per object for the
map alone and growing the calling process's BEAM heap by 8.5 words per object.
Drop an instance, releasing the heap once none is left.
Idempotent: wasm:destroy/1 is documented as safe to call twice.
-spec get_field(undefined | heap(), term(), non_neg_integer()) -> term().
Every instance registered with this heap.
Take a read lease on the store, answering whether one was taken.
Answers false for a module with no object store at all, which is every module
declaring no struct and no array type, so those pay one comparison.
Whether the next collection will trace the whole store.
-spec new() -> heap().
Create a heap.
The tables are owned by the calling process, which is the process instantiating
the module, so a heap has exactly the lifetime #inst.store already has and
adds no new ownership rule.
-spec new_array(heap(), non_neg_integer(), non_neg_integer(), term(), boolean()) -> {objref, non_neg_integer()}.
Allocate an array.
Traced says whether its elements can hold references. An array of i32 or
i8 cannot, so the collector never walks it: no scan of its elements, however
many have been written. That is most of what a language like Java or Kotlin
allocates, and walking a million-element byte array to find no references in it
is the kind of work worth not doing.
-spec new_struct(heap(), non_neg_integer(), [term()]) -> {objref, non_neg_integer()}.
Keep an object alive across collections until it is released.
A reference you are handed is not reachable from any root the runtime can see,
so without this the next collection frees it and leaves you holding an id that
names nothing. Call results, wasm:get_global/2 results and the values of an
uncaught exception are pinned on the way out.
Reference counted, so a reference handed out twice needs releasing twice. The
count lives beside the object rather than in the instance state, which is what
stopped pinning from costing a state write-back on every call: it used to be a
list rebuilt with lists:usort/1 per call that never shrank.
Every pinned reference, as collection roots.
-spec readers(undefined | heap()) -> non_neg_integer().
How many read leases are outstanding. For tests and diagnostics.
Record that an instance's roots have to be traced when this heap collects.
A heap outlives any one of its instances, because linked instances share it.
Deleting it when the last one goes is what keeps that from leaking, and it is
why delete/2 takes the instance that is leaving rather than just the heap.
What is stored is a summary, not the whole #inst{}. ETS copies on insert, and
an instance record carries its type table, its compiled functions and its
exports; registering one cost 1.9 us of a 5.7 us instantiation. The summary is
the four fields a root scan actually reads.
-spec release_exclusive(undefined | heap()) -> ok.
Release the exclusive hold, leaving any read lease the caller had.
-spec request_collect(undefined | heap()) -> ok.
Record that a collection is wanted but could not be performed now.
-spec set_field(undefined | heap(), term(), non_neg_integer(), term()) -> ok.
Whether enough has been allocated since the last collection to be worth
tracing. A call that allocates nothing answers in two atomics reads.
-spec size(undefined | heap()) -> non_neg_integer().
How many objects the store holds. For tests and diagnostics.
Take the store exclusively, from a caller already holding one read lease.
Succeeds only if that lease is the only one, which is what makes this an upgrade that cannot deadlock: there is nobody to wait for.
-spec type_of(undefined | heap(), term()) -> {struct | array, non_neg_integer()}.
The kind and declared type of an object, for ref.test and ref.cast.
A reference naming a slot that is not there is a trap, not a crash. It means a
reference outlived the object it named, and reporting that as a
case_clause three layers down says nothing about what went wrong.
Give up a read lease.
Answers collect_now when this was the last reader out and somebody asked for
a collection they could not perform themselves. The caller does it, because
gathering roots is not this module's business.
Release one pin, letting the object be collected once nothing else holds it.
Releasing a reference that was never pinned is not an error, so you can release everything you have seen without remembering which ones counted.
-spec unpin_all(undefined | heap()) -> ok.
Release every pin, for when you scope references to a request.