What ahead of time means here

WasmRuntime::compile_artifact validates and translates a module to Wasmi bytecode and returns a CompiledArtifact. WasmRuntime::execute_artifact runs a prepared artifact and translates nothing. Both are new; execute_call is unchanged and still compiles on every call. The whole of the claim is the ordering: translation now happens strictly before Store::set_fuel installs a call budget, so no call can be charged for it, and the artifact is reused so that later calls do not repeat it.

The executed code is the same pinned Wasmi 2.0.0 interpreter under the same engine policy WasmRuntime::new pins: the same rejected WebAssembly proposals, the same fuel schedule, the same 128-frame recursion cap and the same 16,384-byte value stack cap. BackendKind::Aot is reported by exactly one type, AotBackend, and it reports that class because translation is complete before a call's budget exists, not because anything is compiled to machine code.

What this section does not establish: that any native machine code is emitted, because none is; that a second independent implementation of WebAssembly now exists, because both sides of every comparison below run one interpreter, one translator and one fuel schedule; or that BackendKind::Jit has an implementation, because it does not and cannot, which ## SIMD and JIT explains. It also does not establish a changed runtime version: WASM_VERSION stays { abi: 2, metering: 1 }.

Why earlier is safe and later is not

The workspace had already measured the opposite direction and rejected it. EngineProfile::LazyTranslation and EngineProfile::Lazy defer translation into the call, Wasmi charges that work to the executing call's own fuel, and EngineProfile::DISQUALIFIED keeps both out of live execution for that reason. Ahead-of-time compilation is the inverse move and is safe for the mirror-image reason: work that happens before any budget exists cannot be charged to a budget.

Reusing an artifact makes that argument measurable rather than rhetorical. On 2026-10-10, Windows with Rust 1.99.0 and pinned Wasmi 2.0.0, one artifact of the minimal returning module executed twice charges the following compute.

ProfileFirst call through the artifactSecond callRecompiling call
Reference222
PreallocatedStack222
UnpooledStack222
Alternate222
LazyTranslation30230
Lazy38238

The two deferred rows are the finding. Under a deferred strategy a reused artifact charges 30 or 38 on its first use and 2 on every later use, so the charge would depend on whether a cache happened to hold the artifact, which is a consensus split that no amount of agreement elsewhere would repair. ArtifactCache::with_profile and AotBackend::with_profile therefore return None for those two profiles, EngineProfile::translates_ahead_of_the_call decides it, and deferred_translation_makes_a_charge_depend_on_cache_state_and_stays_refused pins the figures above so the refusal keeps a measured reason.

What this section does not establish: that the four eager rows are interchangeable with the deferred rows for any other purpose, since both deferred profiles stay disqualified however they measure; or that 30 and 38 are bounds, since both grow with the size of the translated body.

The artifact identity

ArtifactKey was declared for this work and had no references anywhere in the workspace until now. Its four fields are populated as follows.

code_hash is wasm_code_hash of the canonical module bytes, which already commits to the ABI and metering identity through its domain tag. version is the module's own RuntimeVersion.

compiler is a BLAKE2s-256 digest under the domain astrolune.contract.wasm.compiler.v1 over the pinned engine release, the pinned engine cargo features, every engine toggle this runtime applies, and the stack and tuning values the profile resolves to.

compiler = domain_hash(
    "astrolune.contract.wasm.compiler.v1",
    len(WASM_ENGINE_VERSION) || WASM_ENGINE_VERSION
      || len(WASM_ENGINE_FEATURES) || for each feature: len(f) || f
      || len(PINNED_TOGGLES) || for each toggle: code(toggle) || enabled
      || max_recursion_depth || max_stack_height
      || (0) or (1 || min_stack_height)
      || max_cached_stacks || compilation_mode)

Three properties make that digest worth keying on. One value, EnginePolicy, produces both the Config the engine is built from and the digest, and the toggles are enumerated in one PINNED_TOGGLES table that both the builder and the encoder iterate, so a toggle cannot be applied without being committed. The cargo feature list participates because Wasmi derives its default proposal set from its features rather than from a Config call, so enabling simd would change the accepted instruction set without moving any other field; the the_declared_engine_pin_matches_the_manifest test asserts that WASM_ENGINE_VERSION, WASM_ENGINE_FEATURES and the wasmi line of crates/runtime/Cargo.toml agree, so the engine cannot be bumped or re-featured without moving every identity. The digest is derived from settings and not from a profile's name, so renaming EngineProfile::Alternate changes no identity, and all six profiles are measured to produce six distinct digests.

target is a digest under astrolune.contract.wasm.target.v1 over std::env::consts::ARCH, std::env::consts::OS, std::env::consts::FAMILY, usize::BITS and the target endianness, each length-framed. No CPU feature detection participates, because the interpreter emits no native code and the simd cargo feature is off, so there is no dispatch for a detected feature to select.

A configuration digest is not an engine instance, and translated Wasmi bytecode cannot cross Engine instances. WasmRuntime::execute_artifact therefore checks Engine::same in addition to the version, compiler and target, and returns RuntimeError::Unsupported when an artifact belongs to another engine, which an_artifact_is_refused_by_a_runtime_that_did_not_produce_it exercises against a runtime built from the same profile.

What this section does not establish: that the target digest distinguishes two toolchains, since a different rustc that leaves all five encoded values equal produces the same digest; or that the digest is a security boundary, since it identifies a configuration and authenticates nothing.

The bounded generational cache

ArtifactCache holds one engine and the artifacts translated into it, and retires both together. Retirement is all-or-nothing for a specific reason: Wasmi 2.0.0's code map exposes alloc_funcs and no removal, so a translated function body stays in the engine for the engine's life. Dropping one artifact would therefore shrink the map and reclaim nothing, and only dropping the engine with every artifact built on it actually returns the memory. A generation is retired when the next translation would exceed either bound: DEFAULT_CACHE_ARTIFACTS is 256 artifacts and DEFAULT_CACHE_CODE_BYTES is 16,777,216 canonical module bytes. A module larger than the whole byte bound is executed and never retained, so it cannot retire a generation on every call.

The eviction rule is deterministic in the sense that matters under concurrency: the cache's contents depend only on the sequence of translations and their sizes, never on the order of hits and never on how callers interleave. A hit moves counters and nothing else. Calls execute outside the lock, holding an Arc of the engine and a clone of the artifact, so a generation retired mid-call stays alive until that call finishes and a result in flight cannot change. A poisoned lock is recovered rather than propagated, because an artifact is inserted only after it has been produced and the byte total is updated in the same statement, so a panic inside translation cannot leave a generation inconsistent.

ArtifactCache implements ModuleValidator, so validating bytes retains the artifact as a side effect and the first call to a freshly deployed contract is already a hit. A deployment later rejected for an unrelated reason can leave an artifact for code that never reached state; it is keyed by that code's hash, bounded with every other artifact, and reachable by no other module.

What this section does not establish: a bound on engine memory across distinct modules beyond one generation, since the bound is on a generation and a workload cycling through more than 256 modules retires and retranslates rather than growing; a bound in translated bytes, since the byte bound counts canonical WebAssembly input and Wasmi does not expose the size of its output; or any claim that eviction policy is tuned, since no workload model was measured.

Removing the double compile from a contract transaction

crates/execution/src/contract.rs previously built a WasmRuntime::new() per transaction, called validate on the stored code, which compiles, and then execute_call on the same bytes, which compiles again. A contract call therefore paid for two full translations of one module, every time, on an engine that was discarded immediately afterwards.

The call path now builds the ContractModule identity directly and executes it through one ArtifactCache, which compiles at most once and not at all once the contract is held. The deploy path keeps its single validate call, which now also retains the artifact. The order of failures is preserved exactly: ArtifactCache::execute checks the module's version, then its hash, then compiles or reuses, then checks the call's own bounds, which is the order the old validate-then-execute pair produced, so a module that is invalid and a call whose budget is out of range still yield ExecutionError::InvalidContract and not ExecutionError::ResourceLimit.

The cache is a process-wide OnceLock in that module. Ownership was the open question and the answer is forced: execute_action is reached from SimpleExecutor, from SignedSession and from a worker-pool chunk, and each of those is created per block, per execution attempt or per wave and dropped before the next one, so a cache owned by any of them would be cold on every transaction and invisible to the others. The process is the only scope that outlives every caller. Sharing it is safe because it is not consensus state: a hit and a miss produce identical output, which is pinned, so a node that has never seen a contract computes what a node that has executed it a thousand times computes. Sharing one engine across the parallel workers is likewise safe, because the engine stack pool is the already-qualified UnpooledStack axis and EngineProfile::QUALIFIED is measured to be indifferent to it.

What this section does not establish: that contract transactions are now faster end to end, since only the micro-benchmarks below were measured and block throughput was not; or that the cache survives a restart, since it is in memory and a fresh process starts cold.

Differential qualification

The oracle is wasm_difference on WasmOutput, never runtime_difference on RuntimeOutput. That choice is deliberate and already proven necessary: project_wasm_output drops events, writes and accessed, and the_seam_cannot_express_events_writes_or_accessed_keys demonstrates that a seam comparison cannot see a staged-effect difference at all. Every comparison below requires wasm_difference to return None, which compares acceptance, error variant, return data, events in order, staged writes, actually accessed keys and all four charged resource classes exactly, with no tolerance.

crates/runtime/tests/aot.rs covers the fixed corpus, which crates/runtime/tests/support/corpus.rs now shares with crates/runtime/tests/backends.rs so both campaigns compare the same modules. The ten accepted modules are compared against the reference interpreter 160 times: ten modules across the four qualified profiles, twice through a prepared artifact and twice through a cache, so the second comparison of each pair is a reused artifact. The ten rejected candidates are compared 40 times, ten across four profiles, and each requires the same RuntimeError variant from compile_artifact, from ArtifactCache::validate and from ArtifactCache::execute, and requires the cache to retain nothing.

Charged compute is pinned bit-exactly. The minimal returning module charges 2 compute units on the reference interpreter and on every ahead-of-time path: three successive calls through one artifact, three successive cache executions, one cache execution after an explicit retirement, and one execution across the AotBackend seam, for every one of the four qualified profiles. This is the single most important assertion in the deliverable, because a change that starts charging translation to the call raises it and fails loudly.

The two consensus-visible stack bounds do not move. Under every qualified profile a self-recursive module whose frames hold only an i32 parameter succeeds at depth 126 and returns RuntimeError::LimitExceeded at depth 127, and the same recursion with thirty-two additional i64 locals per frame succeeds at depth 60 and fails at depth 61. Each is executed twice, so a retained artifact cannot shift a threshold.

Cache behaviour is qualified separately: a hit and a miss produce equal output for all ten modules; a different code_hash, a different engine profile and a foreign compiler or target digest are each measured not to collide; a cache bounded to three artifacts replaying ten modules four times leaves every result unchanged across the retirements it triggers; a cache whose byte bound is one byte retains nothing, retires nothing and still answers correctly; an explicit retirement between two executions of each module changes neither; and eight threads replaying ten modules five times each against one cache bounded to four artifacts produce 400 executions that all match the reference interpreter.

The workspace campaign in tests/integration/tests/backends.rs now drives the ahead-of-time path alongside the engine profiles, so it inherits all three campaign sizes and the same xorshift64 mutator seeded 0x6173_7472_6f6c_756e. Per candidate it compares ArtifactCache::validate against the reference validator, then executes once on a cold cache, twice on a process-wide cache bounded to 64 artifacts and 1,048,576 bytes, and twice across a per-candidate AotBackend seam.

cargo test --locked -p runtime --test aot
cargo test --locked -p runtime --test backends
cargo test --locked -p integration --test backends
cargo test --release --locked -p integration --test backends -- --ignored

On 2026-10-10, Windows with Rust 1.99.0 and pinned Wasmi 2.0.0, the campaigns reported the following. The smoke figure is from the unoptimized dev profile; the two durations are from --release, each campaign run on its own.

CampaignCandidatesAccepted modulesAOT validationsAOT executionsFrom a retained artifactAOT seam executionsGenerations retiredDisagreementsDuration
backend_qualification_smoke3,012722,83721614414410n/a
extended_backend_qualification100,0122,51494,6727,5425,0285,0283701.38 s
million_backend_qualification1,000,01224,681946,32074,04349,36249,362368014.63 s

The retirement count is cumulative over the process, so running both ignored campaigns in one process reports 405 rather than 368 for the second. The existing engine-profile figures in that report are unchanged, because the ahead-of-time counters are new fields rather than additions to the old ones.

What this section does not establish: agreement with an independent WebAssembly implementation, since the reference and the candidate are the same interpreter and differ only in when translation happened; coverage of inputs the mutator does not reach, since a seeded mutation campaign is a sample and not a proof; or anything about throughput, since the reported durations are campaign wall clock and not benchmarks.

Measured figures

crates/runtime/benches/runtime.rs records five rows per module size. aot/compile_artifact is the translation paid once ahead of any call; aot/execute_artifact is a call that translates nothing; aot/transaction/recompiled is a validating pass followed by a recompiling call, which is exactly what a contract transaction did before this work; aot/transaction/cached is the same transaction against a warm cache; and aot/cache/cold builds an engine and translates, which is what a call pays after a retirement.

On 2026-10-10, Windows with Rust 1.99.0, an AMD Ryzen 7 7800X3D with eight cores and cargo bench --locked -p runtime, the medians over 20 rounds were as follows. All figures are nanoseconds per operation.

Body stepsModule bytestransaction/recompiledtransaction/cachedRatiocompile_artifactexecute_artifactcache/cold
18115,7434,1153.8x6,7583,22112,970
1618620,4183,9975.1x8,8053,23914,534
2561,86892,4796,74413.7x44,1663,70251,522
4,09628,7501,202,75043,46927.7x608,22510,169625,325

The gain grows with module size because translation does and the rest of a call does not. A second run on the same host, taken while the workspace was being compiled, gave 16,434, 21,958, 117,294 and 1,248,650 for the recompiling row and 4,496, 3,883, 9,417 and 48,835 for the cached row, that is 3.7x, 5.7x, 12.5x and 25.6x, so the relative finding reproduced and the absolute values moved by up to a quarter.

One residual is worth naming, because it limits the gain and is not translation. At 4,096 body steps the cached transaction costs 43,469 while aot/execute_artifact costs 10,169; the difference is close to the 32,141 that wasm_code_hash/28750B measures, because ArtifactCache::execute re-hashes the module bytes to reject a forged ContractModule identity exactly as execute_call does. That check is kept deliberately: skipping it on a hit would let a caller present forged bytes with a cached hash and have the genuine module execute. For a large contract the cached path is therefore dominated by a BLAKE2s pass over the code, not by the interpreter.

What this section does not establish: block, transaction or contract throughput for any deployment; a figure comparable to another machine, another toolchain or a shared CI runner; statistical significance of any difference, since the harness reports no confidence interval; or any license to change the runtime version, the fuel schedule or the selected engine profile, none of which a nanosecond figure can justify. Charged compute is a consensus quantity measured in fuel and is identical on every row above.

SIMD and JIT

No SIMD backend is delivered, and the decision is deliberate rather than pending. crates/runtime/Cargo.toml pins wasmi with default-features = false and five features that exclude simd, and Wasmi derives its default proposal set from its cargo features, setting WasmFeatures::SIMD and WasmFeatures::RELAXED_SIMD to cfg!(feature = "simd"). With the feature off the Config::wasm_simd setter does not even exist, so SIMD cannot be enabled from code. Enabling the cargo feature would widen the accepted instruction set, which crates/runtime/src/wasm.rs already classifies as a runtime-version change rather than a backend axis, alongside the fuel schedule and the proposal toggles. Four modules whose only forbidden construct is a SIMD type or operator, a v128.const, an i8x16.add, a v128.load and a v128 function parameter, are assembled by wat and rejected with RuntimeError::InvalidModule by every qualified profile, by compile_artifact, by ArtifactCache::prepare and by ArtifactCache::execute, which simd_opcodes_stay_rejected_so_the_engine_feature_cannot_be_enabled_silently pins so the feature cannot be turned on without a failing test. The cargo feature list is additionally part of the compiler identity, so enabling it would invalidate every existing artifact rather than silently reusing one.

No just-in-time backend is delivered, and none can be. unsafe_code is forbidden workspace-wide in Cargo.toml, and a native-codegen backend has to make a page of generated bytes executable and transfer control to it, which no safe Rust interface offers. The decision is therefore structural rather than a matter of effort: BackendKind::Jit stays a reserved declaration with no implementation, AotBackend is asserted never to report it, and relaxing the workspace lint would be a change to the project's safety posture and not a backend choice.

What this section does not establish: that SIMD or native code generation would be unsound or unprofitable, neither of which was investigated; or that BackendKind::Jit should be removed, since it remains a reserved declaration.

Limits

The cache is in memory and process-local. Nothing is persisted, no artifact is shared between processes, and a restart starts cold.

One generation is bounded, the process is not. A workload cycling through more than DEFAULT_CACHE_ARTIFACTS distinct contracts retires and retranslates rather than growing, which is correct but is thrashing, and no workload model was measured to choose 256. The byte bound counts canonical WebAssembly input, because Wasmi 2.0.0 does not expose the size of its translated output, so the relationship between the bound and resident memory is not measured.

The target identity distinguishes architecture, operating system, target family, pointer width and endianness, and nothing else. Two different Rust toolchains on one target produce the same target digest, so an artifact is not invalidated by a compiler upgrade; artifacts never leave the process, so this bounds nothing in practice, but it does mean the digest must not be treated as a build identity.

Every comparison in this document is between one interpreter and itself with translation moved. That qualifies the ordering and the cache, and it establishes nothing about a hypothetical independent or native implementation. The ten accepted modules and ten rejected candidates are hand-written fixtures, and the mutation campaign is a seeded sample; neither is exhaustive over the accepted instruction set.

No figure in this document is a correctness gate. The benchmark harness has no assertions and no threshold, the reported campaign durations are wall clock on one host, and ROADMAP.md continues to list unbounded formal qualification and independent review as open.

Source: docs/58-ahead-of-time-contract-backend.md
Independent MDX copy · source SHA-256: 0775efb2bf25e24d51ca6f191078f906c8afbd0658c50cf7317e17338436d623