fix: Use a transient wasm engine to bypass the persisted memory usage on a long lived wasm engine

This commit is contained in:
TimothyBanks
2026-09-16 14:33:03 -04:00
parent 824997df60
commit cbf86602c8
7 changed files with 57 additions and 59 deletions

View File

@@ -27,7 +27,7 @@ use std::fmt;
use wasmi::{ExternType, FuncType, Module, ValType};
use xrpl_host_functions::{HOST_MODULE, HostFunctionSpec};
use crate::vm::{MAX_MEMORY_PAGES, MAX_TABLE_ELEMENTS, compile};
use crate::vm::{MAX_MEMORY_PAGES, MAX_TABLE_ELEMENTS, compile, wasm_engine};
use signature::check_signature;
/// Why a module cannot be run. One variant per stage, since the caller maps the
@@ -76,7 +76,7 @@ impl fmt::Display for CheckError {
/// the module is built on; the resource caps come last, being a request rather than a
/// mistake about the ABI.
pub fn check(wasm: &[u8], function_name: &str) -> Result<(), CheckError> {
let module = compile(wasm).map_err(CheckError::Compile)?;
let module = compile(&wasm_engine(), wasm).map_err(CheckError::Compile)?;
check_imports(&module)?;
check_entry_point(&module, function_name)?;
check_exported_resources(&module)

View File

@@ -1,6 +1,5 @@
use std::cell::Cell;
use std::fmt;
use std::sync::LazyLock;
use wasmi::{
Config, Engine, Export, Linker, Memory, Module, Store, StoreLimits, StoreLimitsBuilder,
TrapCode,
@@ -165,7 +164,7 @@ impl RunFailure {
/// trap and refusal all report it the same way.
///
/// `Store::get_fuel` fails only on a store without fuel metering, which
/// [`build_wasm_engine`] rules out and `run`'s `set_fuel` would already have
/// [`wasm_engine`] rules out and `run`'s `set_fuel` would already have
/// caught — so a failure here is a defect in this crate. It must not become a
/// number: `0` forgives a run its whole cost, `gas` charges an untouched one for
/// everything. [`RunError::Internal`] instead.
@@ -234,19 +233,17 @@ impl From<Fault> for RunError {
}
}
/// The process-wide wasmi engine, built once on first use.
/// A fresh wasmi engine for one caller's use: deterministic, minimal features,
/// fuel metering on.
///
/// The configuration is consensus-fixed and identical for every invocation, and an
/// [`Engine`] is an internally `Arc`ed `Send + Sync` handle, so one shared engine
/// serves concurrent [`run`] calls.
pub(crate) fn wasm_engine() -> &'static Engine {
static ENGINE: LazyLock<Engine> = LazyLock::new(build_wasm_engine);
&ENGINE
}
/// Build the wasmi engine the escrow VM requires: deterministic, minimal
/// features, fuel metering on.
fn build_wasm_engine() -> Engine {
/// The configuration is consensus-fixed and identical for every invocation, so any
/// two engines from here accept exactly the same modules. Each is nonetheless a
/// distinct engine with its own compiled-code and type registries, and wasmi ties a
/// [`Module`] to the engine that compiled it — a module cannot be instantiated in a
/// [`Store`] built on another. A caller that compiles and runs must therefore hold
/// one engine across both steps, which is why [`compile`] takes the engine rather
/// than reaching for its own.
pub(crate) fn wasm_engine() -> Engine {
let mut config = Config::default();
config.consume_fuel(true);
config.ignore_custom_sections(true);
@@ -271,7 +268,7 @@ fn build_wasm_engine() -> Engine {
/// Every resource ceiling a run is given, in one place.
///
/// The two *size* caps are what a contract can reach today. The three *count* caps
/// are set to 1 although [`build_wasm_engine`] already forces each: turning
/// are set to 1 although [`wasm_engine`] already forces each: turning
/// `wasm_reference_types` on would let a module declare up to
/// `wasmparser::MAX_WASM_TABLES` tables, `wasm_multi_memory` likewise for memories,
/// and both size caps are **per table and per memory, not aggregate** — so a feature
@@ -292,13 +289,14 @@ fn store_limits() -> StoreLimits {
.build()
}
/// Compile `wasm` for this engine.
/// Compile `wasm` for `engine`.
///
/// The one path to a [`Module`]: the configuration is what decides whether a
/// contract is valid at all, so [`run`] and [`crate::check`] must not be able to
/// compile against different ones.
pub(crate) fn compile(wasm: &[u8]) -> Result<Module, String> {
Module::new(wasm_engine(), wasm).map_err(|e| e.to_string())
/// compile against different ones. The engine is the caller's because the module it
/// returns may only be instantiated in a [`Store`] built on that same engine.
pub(crate) fn compile(engine: &Engine, wasm: &[u8]) -> Result<Module, String> {
Module::new(engine, wasm).map_err(|e| e.to_string())
}
/// Run a contract: compile `wasm`, give it `gas` fuel, service its host
@@ -310,11 +308,11 @@ pub fn run<'h>(
function_name: &str,
) -> Result<RunOutcome, RunFailure> {
let engine = wasm_engine();
let module =
compile(wasm).map_err(|detail| RunFailure::owing_nothing(RunError::Compile(detail)))?;
let module = compile(&engine, wasm)
.map_err(|detail| RunFailure::owing_nothing(RunError::Compile(detail)))?;
let mut store = Store::new(
engine,
&engine,
VmState {
host,
mem_limits: store_limits(),
@@ -329,7 +327,7 @@ pub fn run<'h>(
.map_err(|_| RunFailure::owing_nothing(RunError::Internal))?;
store.limiter(|state| &mut state.mem_limits);
let mut linker = Linker::<VmState<'h>>::new(engine);
let mut linker = Linker::<VmState<'h>>::new(&engine);
register_host_functions::<Bodies>(&mut linker)
.map_err(|_| RunFailure::owing_nothing(RunError::Internal))?;
@@ -371,9 +369,13 @@ pub fn run<'h>(
mod tests {
use super::*;
/// Each call is its own engine, which is what makes the engine a caller's to
/// hold: a module compiled through one may not be instantiated in a store built
/// on another, so `run` must pass the engine it made to [`compile`] rather than
/// let it call here a second time.
#[test]
fn the_engine_is_one_engine() {
assert!(Engine::same(wasm_engine(), wasm_engine()));
fn each_call_is_a_new_engine() {
assert!(!Engine::same(&wasm_engine(), &wasm_engine()));
}
/// One instance, one table, one memory — asserted here rather than through a

View File

@@ -80,8 +80,8 @@ fn a_text_format_module_does_not_pass() {
}
/// A feature the engine disables is refused here too, because both stages compile
/// against the one engine. `vm_limits.rs` walks every disabled feature; this pins
/// that screening sees the same configuration.
/// against engines built from the same configuration. `vm_limits.rs` walks every
/// disabled feature; this pins that screening sees that configuration.
#[test]
fn a_disabled_feature_does_not_pass() {
let refusal = refusal(&module(

View File

@@ -165,7 +165,7 @@ fn a_declared_table_maximum_past_the_cap_is_allowed_but_unreachable() {
// Engine configuration
// ---------------------------------------------------------------------------
/// One row per feature `build_wasm_engine` turns off: the smallest module that uses
/// One row per feature `wasm_engine` turns off: the smallest module that uses
/// it, and the fragment of wasmi's refusal that names the feature. A row declaring
/// its own memory omits [`ONE_PAGE`], or it is refused for having two memories
/// instead.
@@ -279,9 +279,9 @@ fn every_disabled_feature_is_refused_by_name() {
}
/// The three knobs [`every_disabled_feature_is_refused_by_name`] cannot cover. The
/// engine is a process-wide `LazyLock`, so a test observes the one configuration
/// `build_wasm_engine` makes: a knob masked by another, or with no caller-visible
/// effect, has no distinguishing module.
/// configuration is the same for every engine `wasm_engine` builds, so a test
/// observes the one `wasm_engine` makes: a knob masked by another, or with no
/// caller-visible effect, has no distinguishing module.
#[test]
fn the_knobs_without_a_module_of_their_own() {
let host = FakeHost::new();
@@ -648,8 +648,10 @@ fn unbounded_recursion_is_stopped_by_the_call_stack_limit() {
}
/// A module with many functions currently compiles and runs: wasmi's only cap is its
/// 1,000,000 hard limit, so the ticket's ~24k-function module — a CodeMap-growth DoS, since
/// every validation appends to the engine's append-only CodeMap — is not refused here.
/// 1,000,000 hard limit, so the ticket's ~24k-function module is not refused here. Every
/// validation appends to the engine's append-only CodeMap, which a per-call engine now frees
/// when the run ends — so this is a peak-memory cost for the duration of one run rather than
/// the process-lifetime accumulation it was, but nothing bounds that peak.
/// Enforcing a tighter bound (a function-count / average-bytes-per-function limit) belongs in
/// a future preflight pass that parses the module before the engine sees it. Ignored until
/// then, so this documents the gap without asserting it is acceptable.

View File

@@ -127,27 +127,22 @@ The linker rebuild and the fuel-metering overhead are **not** separable from her
`runEscrowWasm` and `preflightEscrowWasm`. Both need benchmarks inside `xrpl-wasm-vm`, where
`compile` and `wasm_engine` are `pub(crate)`.
### Compiling leaks — pin your iteration counts
### Pin your iteration counts
`wasm_engine()` is a process-global `LazyLock<Engine>`, and what `Module::new` adds to it is never
released. Repeatedly preflighting one **60-byte** module:
`wasm_engine()` returns a fresh `Engine` per call and `run`/`check` drop theirs on return, so
compiles no longer accumulate. Compiling 64-function modules and sampling RSS from `ps`, the cost is
a one-time 0.6 MiB that arrives within the first few thousand compiles and then does not move
through 40,000 — allocator high-water mark, not growth. A shared engine grew by 5.4 KB per compile
without bound, which over the same 40,000 would have been ~216 MiB. That retires the old hazard
here, where automatic sizing reached 7.9 GB resident and every later case in the binary failed to
compile.
| `--benchmark_repetitions` | peak RSS |
| ------------------------- | -------- |
| 1 | 0.41 GB |
| 5 | 1.46 GB |
| 15 | 4.20 GB |
The measurement cycles 2,000 distinct modules, so the allocator sees a repeating size distribution;
a validator meeting varied contract sizes would settle at a somewhat higher mark, still bounded.
Linear, at roughly **800 bytes per compile**. Within the suite this is why every `Vm.cpp` case pins
`->Iterations(...)`: automatic sizing ran `preflightMinimal` ~348k times per repetition, reaching
7.9 GB at 25 repetitions, after which every later case in the binary failed to compile — 720 errored
rows, all blaming cases that were innocent.
**Outside the suite it is worth a look.** A validator compiles once to screen an `EscrowCreate`
and again for every `EscrowFinish` that runs the contract — with no module cache between them, and
once per apply attempt rather than once per transaction — all against that same static engine.
Whether that is unbounded growth in production depends on wasmi internals not checked here (wasmi
2.0.0, wasmparser 0.228): this is the C++-visible symptom, not a diagnosis.
Pin `->Iterations(...)` anyway: automatic sizing targets a wall-clock budget rather than a compile
count, which gives the cheap cases six-figure counts and `/4096` a handful, and stops the sweep's
rows being comparable to each other or to the last run.
## Gotchas, each of which has already cost someone an afternoon

View File

@@ -12,12 +12,11 @@ namespace {
// What a run costs *around* the contract, rather than what its host calls cost. Only the guest's
// own execution is metered, so every stage measured here is wall time no transaction pays for.
// ../README.md has what each case measures and how to read `gas_equivalent`.
// README.md has what each case measures and how to read `gas_equivalent`.
//
// **Every case must pin `->Iterations(...)`.** Compiling a module allocates against the
// process-global engine and is never reclaimed — roughly 800 bytes per compile — so Google
// Benchmark's automatic sizing, which targets a wall-clock budget rather than a compile count,
// reaches gigabytes resident and the whole binary stops being able to compile anything.
// **Every case pins `->Iterations(...)`.** Automatic sizing targets a wall-clock budget rather than
// a compile count, so it gives the cheap cases six-figure counts and `/4096` a handful, leaving the
// sweep's rows incomparable.
// The smallest module the engine accepts. Everything a run does to it is overhead by construction.
std::string

View File

@@ -16,7 +16,7 @@
#include <string_view>
#include <type_traits>
// The gas-calibration harness. What the numbers mean and how to read a report are in ../README.md.
// The gas-calibration harness. What the numbers mean and how to read a report are in README.md.
namespace xrpl::test::bench {