tenure-as
v0.1.1
Published
Hybrid generational and tenure-arena garbage collector for AssemblyScript.
Downloads
267
Readme
tenure-as
Tenure is a standalone AssemblyScript hybrid garbage collector. Escaping and unknown objects use a non-moving generational collector; the included transform routes syntactically proven function-local allocations through bounded bump arenas. It requires no Wago runtime or host plugin.
Usage
asc assembly/index.ts \
--runtime ./node_modules/tenure-as/runtime \
--transform ./node_modules/tenure-as/transformThe current implementation is ABI 1. Its frozen compatibility entry points are
./runtime/index-legacy and ./transform/legacy.mjs; they compile the same
legacy implementation while the next typed implementation is developed
separately. The exact object, metadata, error, and future-barrier contract is
in TENURE_ABI.md.
The typed compiler integration uses the companion assemblyscript-tenure fork.
It adds an afterLayout transform phase and an opt-in compiler ABI for exact
managed-field barriers. Point asc at that fork when using typed analysis:
TENURE_TYPED_DUMP=tenure-cfg.json /path/to/assemblyscript-tenure/bin/asc.js assembly/index.ts \
--runtime ./node_modules/tenure-as/runtime \
--transform ./node_modules/tenure-as/typed-analysisThe typed-analysis Wasm artifact also carries a versioned tenure.analysis custom
section. Its TNA1 prefix, format version, and UTF-8 payload provide stable
CFG block/value IDs, finalized layout facts, and conservatively identified
managed-store barrier sites (including exact field offsets) to verifier or
lowering tooling. With the fork, managed field setters lower directly to the
existing __tenure_write_barrier(parent, offset, child) runtime ABI; no
post-initialization AST calls or application-source annotations are needed.
./typed-region is the first compiler-owned placement experiment. It selects
only resolved, call-free functions whose ownership graph proves every
allocation local, then asks the fork to lower balanced region enter/exit calls.
Scalar returns are materialized before exit; managed-reference returns and all
other functions remain on the generational fallback heap.
Set TENURE_REGION_DUMP=regions.json to emit a deterministic eligibility
report with selected functions and reason-coded fallbacks.
Defining AssemblyScript's global __finalize hook disables typed-region
placement for that module, preserving normal finalization semantics.
Before loading a marked artifact in host tooling, run
npm run verify-artifact -- module.wasm. It rejects malformed metadata and
unsupported ABI/feature combinations rather than attempting compatibility.
./ownership-analysis is the next report-only layer. With
TENURE_OWNERSHIP_DUMP=ownership.json, it runs a CFG fixed point and reports
the conservative Fresh/Borrowed/Shared/Escaped/Unknown placement
lattice plus a source-range reason for every fallback. It still emits no
allocation rewrite.
The test suite also compiles allocation-heavy differential fixtures under both AssemblyScript incremental GC and Tenure, then compares observable results over fixed churn/control-flow schedules and isolated nested-graph, deep-root, and pinned-write corpora. It is a regression signal for the fallback heap; it is not a substitute for the planned randomized verifier.
This is source-level drop-in: application sources do not change. AssemblyScript
still requires --runtime because transforms run after runtime selection. Young
objects are collected frequently and surviving objects promote in place; a
periodic full major collection reclaims old objects. __link records old/static
parents that may point into young space.
Hosts that implement phase-boundary arena resets can select the stop-the-world runtime separately:
asc assembly/index.ts \
--runtime ./node_modules/tenure-as/runtime-stw \
--transform ./node_modules/tenure-as/phase-transform \
--use ASC_ARENA_EXACT_GLOBALS=1This runtime exports GUEST_FEATURE_TENURE_PHASE_ARENA; a supporting host calls
__arena_reset only after copying each phase result out of Wasm memory and
skips its normal scheduled __collect cadence. If a phase publishes an arena
object directly to a managed global, links it into a normal-heap object, or
pins it, reset preserves the current arena generation and starts a fresh
reusable scratch generation. Referenced generations spend one phase in
probation: still-referenced state becomes permanent, while overwritten
phase-local globals rotate through two reusable generations. This makes late
lazy initialization safe without moving published objects.
__arena_escape_fallback_count exposes whether that safety path fired.
The phase transform records exact managed global assignments so reset touches
only assigned slots. ASC_ARENA_EXACT_GLOBALS=1 declares that the transform is
present even when a module has no mutable or lazy globals. Artifacts built
without it remain correct through the
compiler's global visitor compatibility path, but reset is substantially more
expensive for modules with large global tables.
The nursery starts at ASC_GC_GRANULARITY and expands to a bounded 8 MiB window
after a mostly-live minor cycle. A garbage-producing cycle returns it to the
configured baseline.
Compile with --enable simd to batch four shadow-stack slots per root-scan
load and use a vector non-null mask to skip empty slots. Builds without SIMD
use a two-slot u64 SWAR path, followed by a scalar tail. Object-member
scanning remains exact and layout-specific in both modes; the current __link
ABI does not expose a stored-field address, so it cannot maintain a conventional
field-card table without an AssemblyScript compiler ABI extension.
Arena transform
The included conservative transform bump-allocates function-local objects.
Completed standard
chunks are retained in a bounded reuse batch, then drained by __collect or
__arena_collect. Normal builds use 256 KiB chunks, up to eight retained
chunks, and 256-call allocation batches; --lowMemoryLimit uses 64 KiB chunks,
two retained chunks, and 64-call batches. ASC_ARENA_RETAIN_CHUNKS and
ASC_ARENA_BATCH_CALLS override these defaults. It requires no source
annotations:
asc assembly/index.ts \
--runtime ./runtime/index-tenure \
--transform ./transform/arena.mjsThe typed-region runtime uses a bounded, lazy direct linear-memory page pool
by default (four pages normally, one with --lowMemoryLimit). To tune the
hard cap or disable it for a host that cannot retain grown Wasm memory:
asc assembly/index.ts \
--runtime ./runtime/index-tenure \
--transform ./transform/arena.mjs \
--use ASC_ARENA_LINEAR_PAGES=8 # or =0 to disable direct pagesEligible short-lived objects then occupy raw 64 KiB pages acquired with
memory.grow, bypassing TLSF and GC object-list insertion after a page refill.
The page count is a hard cap: released pages are reused only by future arena
scopes because Wasm memory cannot shrink. Once the cap is reached, Tenure falls
back to the normal bounded TLSF-backed region chunks.
For an artifact-contained explanation of every region decision, add
--use ASC_TENURE_DIAGNOSTICS=1. This emits an optional
tenure.diagnostics custom section with selected functions, source ranges,
and reason-coded conservative fallbacks; it does not change allocation
semantics.
For debug verification, add --use ASC_TENURE_VERIFY=1. Direct pages are
poisoned at region exit after return expressions have been evaluated, helping expose stale-region reads during
test and fuzz runs.
It proves non-escape syntactically and falls back to normal generational allocation for returns, global/caller-owned stores, imports, dynamic calls, recursion, exceptions, pinning, or unknown class shapes.
The local ownership graph follows aliases, owned fields (including nested
paths), and local Array / StaticArray elements introduced by push,
unshift, set, or indexed assignment. It also recognizes direct indexed
paths such as values[i].next once the collection owns its elements. Reading
an owned field or element into a local creates a shared borrow. Replacing that
field, replacing an indexed element, or mutating its collection while the
borrow has a later use falls back to the generational path; mutation after the
last local use remains eligible.
Direct helper calls carry a composable no-escape and mutation summary: a helper
may store one parameter through another parameter's field, or mutate a
parameter collection with indexed assignment, push, pop, shift,
unshift, or set, only
when an arena-valued argument is paired with an arena-local destination at the
call site. These effects compose through direct forwarding helpers and are
checked against the caller's live field and element borrows. Calls not covered
by that summary remain conservative fallbacks. The analysis is intentionally
path-insensitive, so ambiguous control flow can lose arena eligibility but
cannot turn an unproven escape into an arena allocation.
This is a hybrid allocation path, not a replacement for the generational collector: proven function-local values use arena chunks, while escaping or unknown values remain young/old generational objects. Both paths cooperate at minor-collection boundaries, which also drain completed cached arena chunks.
The old-generation garbage budget defaults to 16 * ASC_GC_GRANULARITY (1 MiB
with the default 64 KiB nursery). Set ASC_GC_MAJOR_GRANULARITY to trade peak
retained memory for less frequent major pauses.
The transform stamps a wago.asgc ABI marker. Its parallel-mark mode is reserved
for modules built with the companion wago-asgc-plugin; serial mode requires no
host imports.
For the experimental Wago parallel marker, compile a distinct artifact:
WAGO_ASGC_MODE=parallel-mark asc assembly/index.ts \
--runtime ./node_modules/@wago/as-stw-gc/runtime/index-parallel \
--transform ./node_modules/@wago/as-stw-gc/transform/index.mjs \
--use ASC_WAGO_PARALLEL=1This imports wago_asgc.mark(fromSpace, toSpace, heapBase) -> i32. The import is
synchronous: guest execution is stopped while Wago's bounded worker pool marks
the immutable graph. A missing, incompatible, or declining plugin returns
skipped, and the runtime performs the same collection serially.
For allocation-heavy workloads with short-lived allocation epochs, the page-recycling runtime is the high-throughput variant:
WAGO_ASGC_MODE=parallel-mark asc assembly/index.ts \
--runtime ./node_modules/@wago/as-stw-gc/runtime/index-region-parallel \
--transform ./node_modules/@wago/as-stw-gc/transform/index.mjs \
--use ASC_WAGO_PARALLEL=1It allocates managed objects in 64 KiB regions and recycles an all-dead region
in O(1), avoiding one TLSF free per object. It requires the Wago plugin: its
serial fallback preserves objects but intentionally does not provide the same
region-reclamation performance. Individual allocations must fit in a region
(less than 64 KiB including runtime overhead). This runtime is experimental:
the general index-parallel runtime remains the supported choice for mixed
long-lived and short-lived object populations.
Limits: wasm32 only, no shared memory, non-moving objects, and only type layouts described by the transform are eligible for host marking. The plugin refuses unknown/dynamic library visitors rather than guessing their layout.
Verification
npm test
ASGC_ASSEMBLYSCRIPT_ROOT=/path/to/project-with-assemblyscript node test/compile.mjsReproducible benchmarks
bench.mjs reports raw wall-clock samples, median, p95/p99, and peak Wasm
pages for incremental, frozen legacy Tenure, the current hybrid, and (when
enabled) direct-page hybrid. It measures only guest work; fresh instantiation
is deliberately excluded from each sample.
TENURE_ASSEMBLYSCRIPT_ROOT=/path/to/json-as npm run bench
TENURE_ASSEMBLYSCRIPT_ROOT=/path/to/json-as \
TENURE_BENCH_MODE=steady TENURE_BENCH_OUTPUT=tenure-steady.json npm run bench
TENURE_ASSEMBLYSCRIPT_ROOT=/path/to/json-as \
TENURE_BENCH_MODE=pressure TENURE_BENCH_LOW_MEMORY=1 \
TENURE_BENCH_LOW_MEMORY_LIMIT=65536 \
TENURE_BENCH_LINEAR_PAGES=8 npm run bench
TENURE_ASSEMBLYSCRIPT_ROOT=/path/to/json-as \
TENURE_BENCH_CORPUS=obj-medium TENURE_BENCH_COUNT=10 npm run benchModes are fresh (new instance per sample), steady (one warmed instance),
and pressure (one warmed instance driven with TENURE_BENCH_PRESSURE_COUNT,
so the workload's normal allocation/collection path is measured).
TENURE_BENCH_LOW_MEMORY=1 requires an explicit byte cap in
TENURE_BENCH_LOW_MEMORY_LIMIT. Choose a cap that accommodates the selected
corpus's static data as well as its live heap; the benchmark records it in the
output artifact.
TENURE_BENCH_CORPUS selects an ephemeral JSON.Obj source from json-as:
obj-small (Lottie), obj-medium (CITM catalog), or obj-large (Canada).
The generated source is removed after compilation, and its payload name—not a
machine-specific temporary path—is retained in the benchmark artifact.
TENURE_BENCH_OUTPUT retains the full JSON artifact; record engine flags and
the emitted environment block with any performance claim. Direct-page samples
also include reserved direct pages and page-cap fallback refills.
When ABI-2 barriers are present, samples additionally include dirty-card count,
card-table bytes, and safe whole-parent fallback count.
Design work
Clean-slate design and implementation plan documents the lessons from the current implementation and the proposed typed, CFG/SSA-based ownership, borrow, region, and collector redesign.
SIMD-first region design explains how direct region pages and vectorized root/card metadata fit together without pretending that arbitrary object graph traversal is vectorizable.
Unified end-to-end execution plan sequences the compiler, runtime, verifier, SIMD, benchmarking, and release work into one set of deliverables and acceptance gates.
