Skip to content

Run the WebAssembly GC proposal - #497

Draft
kateinoigakukun wants to merge 1 commit into
mainfrom
gc-runtime
Draft

kateinoigakukun wants to merge 1 commit into
mainfrom
gc-runtime

Conversation

@kateinoigakukun

Copy link
Copy Markdown
Member

Runs the GC proposal end to end, on top of the type decoding from #489. It's opt-in through WasmFeatureSet.gc (--feature gc on the command line). All 23 GC files at the top level of Vendor/testsuite pass, and UnsupportedSpectests has no GC entries left. The GC files and ExtraSuite/gc also pass in a stress mode where every allocation collects and moves every live object.

Note

This is the whole feature as one draft PR, so it can be read in one place. It was developed as nine stages (text format → types → i31 → heap → casts → exnref → stack maps → collector → host API), and I can split it back into a stack of PRs in that order for review.

What's included

Types

  • Each Engine owns a TypeRegistry of canonical recursion groups. It replaces the function-type interner, so call_indirect, function entities and casts all use the same canonical IDs.
  • A canonical ID carries its type's kind (function, struct or array) in its bits. Subtyping against an abstract type therefore needs no lookup.
  • Concrete-against-concrete subtyping uses a supertype display: each type stores its ancestors from the root, so the check takes constant time.
  • Registry entries live in fixed pages, so readers holding an ID never take the lock. Entries are never removed yet.

Values

  • Null stays bit 63, as it is for every reference today.
  • i31 values are (value << 1) | 1, and objects are 8-byte aligned offsets into the heap.
  • A host externref is tagged 10 in its low two bits. This keeps a table element one Int, which is 32 bits on embedded targets. Host integers lose two bits (AnyRef.maxHostValue).
  • The public Reference gains .any(AnyRef?) and .externalized(AnyRef). AnyRef is in WasmTypes, next to Reference.

Heap

  • Each Store has one growable region, created on the first allocation.
  • Every object has an 8-byte header holding its canonical type ID and its kind. Struct fields are naturally aligned, in declaration order, and an array's length comes before its elements.
  • A reference field takes 8 bytes and stores the value bits with the null bit flipped, so zeroed memory is null. A field may hold a 64-bit funcref pointer or an internalized host value, so 4 bytes isn't enough.
  • Growth goes through ResourceLimiter.limitGCHeapGrowth (default implementation provided) up to EngineConfiguration.maxGCHeapSize (1 GiB by default). Running out traps with "out of GC heap memory".
  • Caught exceptions (exnref) are heap objects now. Before, an exnref that escaped its handler could dangle.

Stack maps and the collector

  • A collection happens only when an allocation doesn't fit, so the only safepoints are calls and allocating instructions. Loop back-edges have no safepoints.
  • For each safepoint, the translator records which frame slots may hold an object, keyed by the code offset of the return address. Functions that can't hold an object get no map. Host calls push and pop the suspended frames so they can be walked.
  • The collector is a stop-the-world mark-compact (sliding) collector:
    • A side bitmap with a bit per 8 bytes gives every live object its new offset from per-word prefix counts, so objects need no forwarding word.
    • Roots and fields are updated before the objects slide.
    • When more than half the heap is still live after a collection, the heap grows.
  • Stress mode (EngineConfiguration.gcStressMode, package-visible) collects at every allocation. It also copies the live objects into a new region and poisons the old one, so a root the stack maps miss fails a test. SpectestTests.runWithGCStress runs this in the normal test suite.
  • Allocating an exception (in a catch_ref handler) and allocating in a constant expression never collect: they grow the heap or trap. No stack map describes the frames at those points.

Host API

  • A reference the host gets to an object is a root handle in the store's root list, not a raw offset, so the collector can update it.
  • Store.withRootScope releases the handles made inside it in LIFO order, as in wasmtime. The handles for a host function's parameters are released when it returns, and using a released handle throws.
  • Engine.register(structType:) / register(arrayType:) return GCStructType / GCArrayType. These are final types in a group of their own, so they are the same type as a module's matching (type (struct ...)). Their fields can't refer to concrete types yet.
  • StructRef and ArrayRef create objects and read and write fields. AnyRef.asStruct(in:) / asArray(in:) inspect objects that come from Wasm.

Text format

  • WAT parses and encodes every GC instruction. Utilities/Instructions.json and the VM instruction list are extended. The new VM instructions go at the end, so existing opcode numbers don't change.

Testing

  • swift test --traits FileSystem,MultiThread,ComponentModel,WasmDebuggingSupport,Disassembler: 635 tests pass. GC files are compared against wasm-tools in EncoderTests, because wast2json can't parse them.
  • swift run --package-path Utilities WasmKitDevUtils vmgen / wasmgen and ./Utilities/format.py leave no diff.
  • New ExtraSuite files:
    • gc_disabled.wast: the feature gate.
    • gc/typed_references.wast
    • gc/heap_limit.wast: growth, the limit and the trap.
    • gc/exnref_escape.wast: an exnref that outlives its handler.
    • gc/collector.wast: objects that survive collections while held by locals, operands, globals, tables and other objects.
  • GCHostAPITests.swift covers the host API, which .wast can't reach.

Performance

Modules without GC don't slow down. The numbers are wasmi-benchmarks and CoreMark, comparing #489's tip with this branch. Each benchmark's time is the best of N interleaved runs; the ratio is after ÷ before (lower is faster) and the overall figure is the geometric mean. The builds are release with --omit-frame-pointers.

Machine Rounds Overall CoreMark score
Apple M4 Max, shared (load 5–7) 7 1.004 5225 → 5244
Linux x86_64 6 0.990 3027 → 3040

No single benchmark moves by more than noise consistently. json-parse came out about 6% slower on both machines, but compression moved by a similar amount the other way, and I couldn't reproduce json-parse as a stable regression.

Code size of wasmkit-cli (release, stripped):

Platform Before After Δ
macOS arm64 3,105 KiB 3,308 KiB +203 KiB (+6.5%)
Linux x86_64 4,342 KiB 4,620 KiB +277 KiB (+6.4%)

Most of it is in WasmKit (+16%), and the rest in WAT (+4–6%) and WasmParser (+6–7%). GC is always compiled in. A package trait to leave it out would get most of this back.

Benchmarks/gc/ adds binary-trees and a deep-stack workload written in WAT.

Not included

  • A package trait to leave GC out.
  • store.gc(), owned (non-scoped) roots, and typed handles such as EqRef and I31.
  • Concrete field types in types the host registers.
  • Collecting during a catch_ref handler or a constant expression.
  • Root handles for exceptions on 32-bit hosts. Exceptions handed to the host there aren't rooted.
  • Removing registered types from the registry.
  • A benchmark compiled by a real GC language (dart2wasm, Kotlin/Wasm, Hoot).
  • Shared GC types (shared-everything-threads).

Executes GC modules end to end: the text format of the GC instructions,
canonical types by recursion group, i31 and the extern/any conversions,
structs and arrays in a per-store heap, casts, exceptions kept in the
same heap, stack maps for the roots in Wasm frames, a mark-compact
collector, and a host API for creating and reading objects.

Each engine owns a type registry of canonical recursion groups. A
canonical ID carries its type's kind, so subtyping against an abstract
type needs no lookup, and concrete-against-concrete checks use a
supertype display.

The collector runs only when an allocation does not fit, so the only
safepoints are calls and allocating instructions. The translator records
which frame slots may hold an object at each of them, which keeps
modules without GC on their existing code paths. The objects the host
holds are reached through root handles released by Store.withRootScope,
so the collector can move every object.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant