Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,7 @@ component-model-async = [
"wasmtime-wasi-http?/p3",
"dep:futures",
]
rr = ["wasmtime/rr", "component-model", "wasmtime-cli-flags/rr", "run"]

# This feature, when enabled, will statically compile out all logging statements
# throughout Wasmtime and its dependencies.
Expand Down
1 change: 1 addition & 0 deletions crates/cli-flags/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,4 @@ memory-protection-keys = ["wasmtime/memory-protection-keys"]
pulley = ["wasmtime/pulley"]
stack-switching = ["wasmtime/stack-switching"]
debug = ["wasmtime/debug"]
rr = ["wasmtime/rr", "component-model"]
61 changes: 61 additions & 0 deletions crates/cli-flags/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,25 @@ wasmtime_option_group! {
}
}

wasmtime_option_group! {
#[derive(PartialEq, Clone, Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct RecordOptions {
/// Filename for the recorded execution trace (or empty string to skip writing a file).
pub path: Option<String>,
/// Include (optional) signatures to facilitate validation checks during replay
/// (see `wasmtime replay` for details).
pub validation_metadata: Option<bool>,
/// Window size of internal buffering for record events (large windows offer more opportunities
/// for coalescing events at the cost of memory usage).
pub event_window_size: Option<usize>,
Comment on lines +508 to +515
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops forgot from the previous review -- mind adding handling of these options in this crate such that if the option is specified an error is returned? A future PR would then change it to calling a Config method or such as appropriate, but it'll avoid a state where we have these options but they don't do anything

}

enum Record {
...
}
}

#[derive(Debug, Clone, PartialEq)]
pub struct WasiNnGraph {
pub format: String,
Expand Down Expand Up @@ -551,6 +570,18 @@ pub struct CommonOptions {
#[serde(skip)]
wasi_raw: Vec<opt::CommaSeparated<Wasi>>,

/// Options to enable and configure execution recording, `-R help` to see all.
///
/// Generates a serialized trace of the Wasm module execution that captures all
/// non-determinism observable by the module. This trace can subsequently be
/// re-executed in a determinstic, embedding-agnostic manner (see the `wasmtime replay` command).
///
/// Note: Minimal configuration options for deterministic Wasm semantics will be
/// enforced during recording by default (NaN canonicalization, deterministic relaxed SIMD).
#[arg(short = 'R', long = "record", value_name = "KEY[=VAL[,..]]")]
#[serde(skip)]
record_raw: Vec<opt::CommaSeparated<Record>>,

// These fields are filled in by the `configure` method below via the
// options parsed from the CLI above. This is what the CLI should use.
#[arg(skip)]
Expand All @@ -577,6 +608,10 @@ pub struct CommonOptions {
#[serde(rename = "wasi", default)]
pub wasi: WasiOptions,

#[arg(skip)]
#[serde(rename = "record", default)]
pub record: RecordOptions,

/// The target triple; default is the host triple
#[arg(long, value_name = "TARGET")]
#[serde(skip)]
Expand Down Expand Up @@ -623,12 +658,14 @@ impl CommonOptions {
debug_raw: Vec::new(),
wasm_raw: Vec::new(),
wasi_raw: Vec::new(),
record_raw: Vec::new(),
configured: true,
opts: Default::default(),
codegen: Default::default(),
debug: Default::default(),
wasm: Default::default(),
wasi: Default::default(),
record: Default::default(),
target: None,
config: None,
}
Expand All @@ -646,12 +683,14 @@ impl CommonOptions {
self.debug = toml_options.debug;
self.wasm = toml_options.wasm;
self.wasi = toml_options.wasi;
self.record = toml_options.record;
}
self.opts.configure_with(&self.opts_raw);
self.codegen.configure_with(&self.codegen_raw);
self.debug.configure_with(&self.debug_raw);
self.wasm.configure_with(&self.wasm_raw);
self.wasi.configure_with(&self.wasi_raw);
self.record.configure_with(&self.record_raw);
Ok(())
}

Expand Down Expand Up @@ -1010,6 +1049,15 @@ impl CommonOptions {
config.shared_memory(enable);
}

let record = &self.record;
match_feature! {
["rr" : &record.path]
_path => {
config.rr(wasmtime::RRConfig::Recording);
},
_ => err,
}

Ok(config)
}

Expand Down Expand Up @@ -1120,6 +1168,7 @@ mod tests {
[debug]
[wasm]
[wasi]
[record]
"#;
let mut common_options: CommonOptions = toml::from_str(basic_toml).unwrap();
common_options.config(None).unwrap();
Expand Down Expand Up @@ -1242,6 +1291,8 @@ impl fmt::Display for CommonOptions {
wasm,
wasi_raw,
wasi,
record_raw,
record,
configured,
target,
config,
Expand All @@ -1258,13 +1309,15 @@ impl fmt::Display for CommonOptions {
let wasi_flags;
let wasm_flags;
let debug_flags;
let record_flags;

if *configured {
codegen_flags = codegen.to_options();
debug_flags = debug.to_options();
wasi_flags = wasi.to_options();
wasm_flags = wasm.to_options();
opts_flags = opts.to_options();
record_flags = record.to_options();
} else {
codegen_flags = codegen_raw
.iter()
Expand All @@ -1275,6 +1328,11 @@ impl fmt::Display for CommonOptions {
wasi_flags = wasi_raw.iter().flat_map(|t| t.0.iter()).cloned().collect();
wasm_flags = wasm_raw.iter().flat_map(|t| t.0.iter()).cloned().collect();
opts_flags = opts_raw.iter().flat_map(|t| t.0.iter()).cloned().collect();
record_flags = record_raw
.iter()
.flat_map(|t| t.0.iter())
.cloned()
.collect();
}

for flag in codegen_flags {
Expand All @@ -1292,6 +1350,9 @@ impl fmt::Display for CommonOptions {
for flag in debug_flags {
write!(f, "-D{flag} ")?;
}
for flag in record_flags {
write!(f, "-R{flag} ")?;
}

Ok(())
}
Expand Down
3 changes: 3 additions & 0 deletions crates/wasmtime/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -427,3 +427,6 @@ debug = [

# Enables support for defining compile-time builtins.
compile-time-builtins = ['anyhow', 'dep:wasm-compose', 'dep:tempfile']

# Enable support for the common base infrastructure of record/replay
rr = ["component-model"]
84 changes: 83 additions & 1 deletion crates/wasmtime/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,28 @@ impl core::hash::Hash for ModuleVersionStrategy {
}
}

impl ModuleVersionStrategy {
/// Get the string-encoding version of the module.
pub fn as_str(&self) -> &str {
match &self {
Self::WasmtimeVersion => env!("CARGO_PKG_VERSION_MAJOR"),
Self::Custom(c) => c,
Self::None => "",
}
}
}

/// Configuration for record/replay
#[derive(Clone)]
pub enum RRConfig {
/// Recording on store is enabled
Recording,
/// Replaying on store is enabled
Replaying,
/// No record/replay is enabled
None,
}

/// Global configuration options used to create an [`Engine`](crate::Engine)
/// and customize its behavior.
///
Expand Down Expand Up @@ -165,6 +187,8 @@ pub struct Config {
pub(crate) detect_host_feature: Option<fn(&str) -> Option<bool>>,
pub(crate) x86_float_abi_ok: Option<bool>,
pub(crate) shared_memory: bool,
#[cfg(feature = "rr")]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We've got more documentation online about this, but in the interest of shuffling things around and minimizing the impact of this one thought I might have here is:

  • Conditionally define the Recording and Replaying variants of RRConfig-the-enum
  • Mark RRConfig as #[non_exhaustive]
  • Unconditionally store RRConfig in various places

That would mean that when the Cargo features is disabled RRConfig is a zero-size-type with no runtime cost. It would reduce the conditional imports of RRConfig as well, too.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We would still need the None variant conditionally right?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assuming you mean unconditionally and that's a typo, true! RRConfig::None would always be present so RR-ignoring code could handle that as usual without #[cfg]

pub(crate) rr_config: RRConfig,
}

/// User-provided configuration for the compiler.
Expand Down Expand Up @@ -275,6 +299,8 @@ impl Config {
detect_host_feature: None,
x86_float_abi_ok: None,
shared_memory: false,
#[cfg(feature = "rr")]
rr_config: RRConfig::None,
};
ret.wasm_backtrace_details(WasmBacktraceDetails::Environment);
ret
Expand Down Expand Up @@ -2407,7 +2433,7 @@ impl Config {
target_lexicon::Triple::host()
}

pub(crate) fn validate(&self) -> Result<(Tunables, WasmFeatures)> {
pub(crate) fn validate(&mut self) -> Result<(Tunables, WasmFeatures)> {
let features = self.features();

// First validate that the selected compiler backend and configuration
Expand Down Expand Up @@ -2448,6 +2474,15 @@ impl Config {
bail!("exceptions support requires garbage collection (GC) to be enabled in the build");
}

#[cfg(feature = "rr")]
match &self.rr_config {
RRConfig::Recording | RRConfig::Replaying => {
self.validate_determinism_conflicts()?;
self.enforce_determinism();
Comment on lines +2480 to +2481
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What would you think of ignoring the determinism options here instead of validating/enabling them? Historically as various config options have been tied together it's caused problems and made configuration more confusing due to trying to understand how everything interacts with each other. In some sense producing a recording is entirely orthogonal to deterministic simd/nans. In another sense I also understand how such a recording runs the risk of not being too useful.

For the engine-level configuration I'd argue, however, that this is best kept as a separate concern where we'd document in the RR configuration options that users probably also want to turn on deterministic things, but it wouldn't be a requirement.

Such a change would also have the nice benefit of keeping validate as &self vs &mut self changed in this PR. That's been an intentional design so far where Config is intended to not need any sort of post-processing. If post-processing is necessary we try to defer it to "store the result of the computation in the Engine" so Config continues to reflect the source of truth of configuration specified.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I'd gently push back against this: determinism is a fundamental part of record/replay, not an optional add-on, and without it the replay may not only be incorrect but be incorrect in interesting ways that violate internal invariants (finding the wrong kind of event as we read the trace alongside canonical ABI steps, necessitating some sort of fallback that, I don't know, returns an error? aborts halfway through allocating something? forces a trap halfway through the marshalling code?). I'd personally see this in the same light as, e.g., the need for bounds checks when memory is configured a certain way: it's Just How We Compile Things.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't comment much on the intended direction of Config, but I agree that determinism is a fundamental part of RR, and feels like something that should be implicitly enforced whenever that option is specified.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be clear I'm not saying that things should be deterministic, I'm saying that I think it would make sense to avoid unconditionally coupling them here. The two options handled here, NaN bits and relaxed-simd, are pretty low down on the list of "things that practically cause nondeterminism" in wasm with resource exhaustion (stacks or memory) being much higher on that list. RR cannot control resource exhaustion during replay in the sense that it can't necessarily predict the stack consumption (maybe memory? that seems relatively advanced)

Basically I would expect that divergence of a replay from the original recording is something that's going to need to be handled no matter what. I think it'd be reasonable, for example, for the CLI to automatically set these options but at the wasmtime::Config layer we've generally had bad experiences tying all these together.

Put another way, I would be surprised if we could actually achieve absolutely perfect determinism during a record and replay. Inevitably it seems like we'll forget events, have bugs that prevent this, etc. Assuming perfect determinism to me sounds like it's going to introduce more subtle bugs than not and be a pretty steep uphill battle

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, I think it's an interesting challenge to think about permitting partial divergence and recovering. However I also think, having seen and thought through a lot of the challenges here, that is enormously complex and opens a huge new Pandora's box of issues. For example, with reversible debugging, which builds on top of replay, the whole algorithm depends on determinism; we'll back ourselves into fundamentally unsolvable corners if we don't have it.

See also e.g. how rr (the Mozilla project) panics with internal asserts if trace replay mismatches. I think that's the only really reasonable way to go here: we'll have asserts when we have mismatches. In other words, yep there may be bugs; let's treat them as bugs and catch and fix them.

Resource exhaustion is of course a different category: early termination because a memory.grow failed on replay is reasonable to propagate through and we already have the error paths for that. The kind of nondeterminism that is impossible to deal with is the kind that keeps running but with a poisoned machine state.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I definitely don't meant to give the impression that we should somehow recover a diverging replay. I would also expect a panic, error, or something like that. My assumption is that such infrastructure is a given, and with that whether or not NaN and relaxed-simd is deterministic is not a critical property requires for correctness, but instead a strong recommendation and what tooling should enable by default.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another possible option here, which we've done elsewhere, is to change defaults depending on configuration options. For example the default set of enabled wasm features is different if you use Winch than if you use Cranelift.

One way to slice this problem, without mutating Config, would be to keep the validation that determinism isn't explicitly disabled and then update the read of these configuration values to take into account the rr configuration. That would retain the fact that validate doesn't mutate Config, but the configuration for reading "is relaxed simd determininstic" would look like "was it set or is rr enabled" or something like that.

}
_ => {}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rust-idiom-wise we try to prefer exhaustive matches where possible unless it's unnecessarily onerous. In this case this'd be a good place to change to RRConfig::None => {}

};

let mut tunables = Tunables::default_for_target(&self.compiler_target())?;

// If no target is explicitly specified then further refine `tunables`
Expand Down Expand Up @@ -3008,6 +3043,53 @@ impl Config {
self.shared_memory = enable;
self
}

/// Enforce deterministic execution configurations. Currently, this means the following:
/// * Enabling NaN canonicalization with [`Config::cranelift_nan_canonicalization`].
/// * Enabling deterministic relaxed SIMD with [`Config::relaxed_simd_deterministic`].
#[inline]
pub fn enforce_determinism(&mut self) -> &mut Self {
#[cfg(any(feature = "cranelift", feature = "winch"))]
self.cranelift_nan_canonicalization(true);
self.relaxed_simd_deterministic(true);
self
}
Comment on lines +3047 to +3056
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally I'm a bit wary of having this configuration method/convenience. We've got some documented known sources of non-determinisim and these two options are only a subset of the possible sources of non-determinism. Given that, and coupled with my thoughts about validation of a config, I think this would be served well as documentation of the Config::rr method but would prefer to avoid having an explicit method like this.


/// Validate if the current configuration has conflicting overrides that prevent
/// execution determinism. Returns an error if a conflict exists.
///
/// Note: Keep this in sync with [`Config::enforce_determinism`].
#[inline]
#[cfg(feature = "rr")]
pub(crate) fn validate_determinism_conflicts(&self) -> Result<()> {
if let Some(v) = self.tunables.relaxed_simd_deterministic {
if v == false {
bail!("Relaxed deterministic SIMD cannot be disabled when determinism is enforced");
}
}
#[cfg(any(feature = "cranelift", feature = "winch"))]
if let Some(v) = self
.compiler_config
.as_ref()
.and_then(|c| c.settings.get("enable_nan_canonicalization"))
{
if v != "true" {
bail!("NaN canonicalization cannot be disabled when determinism is enforced");
}
}
Ok(())
}

/// Enable execution trace recording or replaying to the configuration.
///
/// When either recording/replaying are enabled, determinism is implicitly
/// enforced (see [`Config::enforce_determinism`] for details).
#[cfg(feature = "rr")]
#[inline]
pub fn rr(&mut self, cfg: RRConfig) -> &mut Self {
self.rr_config = cfg;
self
}
}

impl Default for Config {
Expand Down
24 changes: 23 additions & 1 deletion crates/wasmtime/src/engine.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
use crate::Config;
#[cfg(feature = "rr")]
use crate::RRConfig;
use crate::prelude::*;
#[cfg(feature = "runtime")]
pub use crate::runtime::code_memory::CustomCodeMemory;
Expand Down Expand Up @@ -95,7 +97,7 @@ impl Engine {
/// the compiler setting `unwind_info` to `true`, but explicitly
/// disable these two compiler settings will cause errors.
pub fn new(config: &Config) -> Result<Engine> {
let config = config.clone();
let mut config = config.clone();
let (mut tunables, features) = config.validate()?;

#[cfg(feature = "runtime")]
Expand Down Expand Up @@ -258,6 +260,26 @@ impl Engine {
self.config().async_support
}

/// Returns whether the engine is configured to support execution recording
#[cfg(feature = "rr")]
#[inline]
pub fn is_recording(&self) -> bool {
match self.config().rr_config {
RRConfig::Recording => true,
_ => false,
}
}

/// Returns whether the engine is configured to support execution replaying
#[cfg(feature = "rr")]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally I'd say it's fine to. unconditionally define these methods and have them just return false when rr-the-feature is disabled.

#[inline]
pub fn is_replaying(&self) -> bool {
match self.config().rr_config {
RRConfig::Replaying => true,
_ => false,
}
}

/// Detects whether the bytes provided are a precompiled object produced by
/// Wasmtime.
///
Expand Down
18 changes: 4 additions & 14 deletions crates/wasmtime/src/engine/serialization.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,19 +95,13 @@ pub fn check_compatible(engine: &Engine, mmap: &[u8], expected: ObjectKind) -> R
};

match &engine.config().module_version {
ModuleVersionStrategy::WasmtimeVersion => {
let version = core::str::from_utf8(version)?;
if version != env!("CARGO_PKG_VERSION_MAJOR") {
bail!("Module was compiled with incompatible Wasmtime version '{version}'");
}
}
ModuleVersionStrategy::Custom(v) => {
ModuleVersionStrategy::None => { /* ignore the version info, accept all */ }
_ => {
let version = core::str::from_utf8(&version)?;
if version != v {
if version != engine.config().module_version.as_str() {
bail!("Module was compiled with incompatible version '{version}'");
}
}
ModuleVersionStrategy::None => { /* ignore the version info, accept all */ }
}
postcard::from_bytes::<Metadata<'_>>(data)?.check_compatible(engine)
}
Expand All @@ -121,11 +115,7 @@ pub fn append_compiler_info(engine: &Engine, obj: &mut Object<'_>, metadata: &Me
);
let mut data = Vec::new();
data.push(VERSION);
let version = match &engine.config().module_version {
ModuleVersionStrategy::WasmtimeVersion => env!("CARGO_PKG_VERSION_MAJOR"),
ModuleVersionStrategy::Custom(c) => c,
ModuleVersionStrategy::None => "",
};
let version = engine.config().module_version.as_str();
// This precondition is checked in Config::module_version:
assert!(
version.len() < 256,
Expand Down