Skip to content

Commit b8f882d

Browse files
authored
Add support for fine-grained operator costs (#11572) (#12541)
Introduce `OperatorCostStrategy` and `OperatorCost` to allow configuring per-operator fuel costs via `Config::operator_cost`. Previously, operator costs were hardcoded inline in both Cranelift and Winch code generation. Now the cost logic is centralized in `wasmtime-environ` and referenced from both backends via `tunables.operator_cost.cost(op)`. `OperatorCostStrategy` is an enum with two variants: - `Default`: reproduces the original hardcoded behavior (nop/drop/control flow cost 0, everything else costs 1). - `Table(Box<OperatorCost>)`: a per-operator cost table generated via `wasmparser::for_each_operator!`. Because `OperatorCostStrategy::Table` contains a `Box`, the type has a destructor. This makes `Tunables` non-trivially droppable, which prevents using the `..Tunables::default_miri()` functional update syntax in const functions (E0493). To work around this, `default_miri`, `default_u32`, and `default_u64` are changed to non-const fns. None of these are called in const contexts, except in tests.
1 parent fd7418e commit b8f882d

8 files changed

Lines changed: 229 additions & 43 deletions

File tree

crates/cranelift/src/func_environ.rs

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -420,23 +420,7 @@ impl<'module_environment> FuncEnvironment<'module_environment> {
420420
return;
421421
}
422422

423-
self.fuel_consumed += match op {
424-
// Nop and drop generate no code, so don't consume fuel for them.
425-
Operator::Nop | Operator::Drop => 0,
426-
427-
// Control flow may create branches, but is generally cheap and
428-
// free, so don't consume fuel. Note the lack of `if` since some
429-
// cost is incurred with the conditional check.
430-
Operator::Block { .. }
431-
| Operator::Loop { .. }
432-
| Operator::Unreachable
433-
| Operator::Return
434-
| Operator::Else
435-
| Operator::End => 0,
436-
437-
// everything else, just call it one operation.
438-
_ => 1,
439-
};
423+
self.fuel_consumed += self.tunables.operator_cost.cost(op);
440424

441425
match op {
442426
// Exiting a function (via a return or unreachable) or otherwise

crates/environ/src/tunables.rs

Lines changed: 141 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use crate::{IndexType, Limits, Memory, TripleExt};
33
use core::{fmt, str::FromStr};
44
use serde_derive::{Deserialize, Serialize};
55
use target_lexicon::{PointerWidth, Triple};
6+
use wasmparser::Operator;
67

78
macro_rules! define_tunables {
89
(
@@ -46,8 +47,8 @@ macro_rules! define_tunables {
4647
/// Configure the `Tunables` provided.
4748
pub fn configure(&self, tunables: &mut Tunables) {
4849
$(
49-
if let Some(val) = self.$field {
50-
tunables.$field = val;
50+
if let Some(val) = &self.$field {
51+
tunables.$field = val.clone();
5152
}
5253
)*
5354
}
@@ -87,6 +88,9 @@ define_tunables! {
8788
/// will be consumed every time a wasm instruction is executed.
8889
pub consume_fuel: bool,
8990

91+
/// The cost of each operator. If fuel is not enabled, this is ignored.
92+
pub operator_cost: OperatorCostStrategy,
93+
9094
/// Whether or not we use epoch-based interruption.
9195
pub epoch_interruption: bool,
9296

@@ -193,7 +197,7 @@ impl Tunables {
193197
}
194198

195199
/// Returns the default set of tunables for running under MIRI.
196-
pub const fn default_miri() -> Tunables {
200+
pub fn default_miri() -> Tunables {
197201
Tunables {
198202
collector: None,
199203

@@ -208,6 +212,7 @@ impl Tunables {
208212
debug_native: false,
209213
parse_wasm_debuginfo: true,
210214
consume_fuel: false,
215+
operator_cost: OperatorCostStrategy::Default,
211216
epoch_interruption: false,
212217
memory_may_move: true,
213218
guard_before_linear_memory: true,
@@ -229,7 +234,7 @@ impl Tunables {
229234
}
230235

231236
/// Returns the default set of tunables for running under a 32-bit host.
232-
pub const fn default_u32() -> Tunables {
237+
pub fn default_u32() -> Tunables {
233238
Tunables {
234239
// For 32-bit we scale way down to 10MB of reserved memory. This
235240
// impacts performance severely but allows us to have more than a
@@ -244,7 +249,7 @@ impl Tunables {
244249
}
245250

246251
/// Returns the default set of tunables for running under a 64-bit host.
247-
pub const fn default_u64() -> Tunables {
252+
pub fn default_u64() -> Tunables {
248253
Tunables {
249254
// 64-bit has tons of address space to static memories can have 4gb
250255
// address space reservations liberally by default, allowing us to
@@ -326,3 +331,134 @@ impl FromStr for IntraModuleInlining {
326331
}
327332
}
328333
}
334+
335+
/// The cost of each operator.
336+
///
337+
/// Note: a more dynamic approach (e.g. a user-supplied callback) can be
338+
/// added as a variant in the future if needed.
339+
#[derive(Clone, Hash, Serialize, Deserialize, Debug, PartialEq, Eq, Default)]
340+
pub enum OperatorCostStrategy {
341+
/// A table of operator costs.
342+
Table(Box<OperatorCost>),
343+
344+
/// Each cost defaults to 1 fuel unit, except `Nop`, `Drop` and
345+
/// a few control flow operators.
346+
#[default]
347+
Default,
348+
}
349+
350+
impl OperatorCostStrategy {
351+
/// Create a new operator cost strategy with a table of costs.
352+
pub fn table(cost: OperatorCost) -> Self {
353+
OperatorCostStrategy::Table(Box::new(cost))
354+
}
355+
356+
/// Get the cost of an operator.
357+
pub fn cost(&self, op: &Operator) -> i64 {
358+
match self {
359+
OperatorCostStrategy::Table(cost) => cost.cost(op),
360+
OperatorCostStrategy::Default => default_operator_cost(op),
361+
}
362+
}
363+
}
364+
365+
const fn default_operator_cost(op: &Operator) -> i64 {
366+
match op {
367+
// Nop and drop generate no code, so don't consume fuel for them.
368+
Operator::Nop | Operator::Drop => 0,
369+
370+
// Control flow may create branches, but is generally cheap and
371+
// free, so don't consume fuel. Note the lack of `if` since some
372+
// cost is incurred with the conditional check.
373+
Operator::Block { .. }
374+
| Operator::Loop { .. }
375+
| Operator::Unreachable
376+
| Operator::Return
377+
| Operator::Else
378+
| Operator::End => 0,
379+
380+
// Everything else, just call it one operation.
381+
_ => 1,
382+
}
383+
}
384+
385+
macro_rules! default_cost {
386+
// Nop and drop generate no code, so don't consume fuel for them.
387+
(Nop) => {
388+
0
389+
};
390+
(Drop) => {
391+
0
392+
};
393+
394+
// Control flow may create branches, but is generally cheap and
395+
// free, so don't consume fuel. Note the lack of `if` since some
396+
// cost is incurred with the conditional check.
397+
(Block) => {
398+
0
399+
};
400+
(Loop) => {
401+
0
402+
};
403+
(Unreachable) => {
404+
0
405+
};
406+
(Return) => {
407+
0
408+
};
409+
(Else) => {
410+
0
411+
};
412+
(End) => {
413+
0
414+
};
415+
416+
// Everything else, just call it one operation.
417+
($op:ident) => {
418+
1
419+
};
420+
}
421+
422+
macro_rules! define_operator_cost {
423+
($(@$proposal:ident $op:ident $({ $($arg:ident: $argty:ty),* })? => $visit:ident ($($ann:tt)*) )*) => {
424+
/// The fuel cost of each operator in a table.
425+
#[derive(Clone, Hash, Serialize, Deserialize, Debug, PartialEq, Eq)]
426+
#[allow(missing_docs, non_snake_case, reason = "to avoid triggering clippy lints")]
427+
pub struct OperatorCost {
428+
$(
429+
pub $op: u8,
430+
)*
431+
}
432+
433+
impl OperatorCost {
434+
/// Returns the cost of the given operator.
435+
pub fn cost(&self, op: &Operator) -> i64 {
436+
match op {
437+
$(
438+
Operator::$op $({ $($arg: _),* })? => self.$op as i64,
439+
)*
440+
unknown => panic!("unknown op: {unknown:?}"),
441+
}
442+
}
443+
}
444+
445+
impl OperatorCost {
446+
/// Creates a new `OperatorCost` table with default costs for each operator.
447+
pub const fn new() -> Self {
448+
Self {
449+
$(
450+
$op: default_cost!($op),
451+
)*
452+
}
453+
}
454+
}
455+
456+
impl Default for OperatorCost {
457+
fn default() -> Self {
458+
Self::new()
459+
}
460+
}
461+
}
462+
}
463+
464+
wasmparser::for_each_operator!(define_operator_cost);

crates/wasmtime/src/config.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use core::str::FromStr;
77
#[cfg(any(feature = "cranelift", feature = "winch"))]
88
use std::path::Path;
99
pub use wasmparser::WasmFeatures;
10-
use wasmtime_environ::{ConfigTunables, TripleExt, Tunables};
10+
use wasmtime_environ::{ConfigTunables, OperatorCost, OperatorCostStrategy, TripleExt, Tunables};
1111

1212
#[cfg(feature = "runtime")]
1313
use crate::memory::MemoryCreator;
@@ -607,6 +607,14 @@ impl Config {
607607
self
608608
}
609609

610+
/// Configures the fuel cost of each WebAssembly operator.
611+
///
612+
/// This is only relevant when [`Config::consume_fuel`] is enabled.
613+
pub fn operator_cost(&mut self, cost: OperatorCost) -> &mut Self {
614+
self.tunables.operator_cost = Some(OperatorCostStrategy::table(cost));
615+
self
616+
}
617+
610618
/// Enables epoch-based interruption.
611619
///
612620
/// When executing code in async mode, we sometimes want to

crates/wasmtime/src/engine/serialization.rs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,7 @@ use object::{
3434
read::elf::{ElfFile64, FileHeader, SectionHeader},
3535
};
3636
use serde_derive::{Deserialize, Serialize};
37-
use wasmtime_environ::obj;
38-
use wasmtime_environ::{FlagValue, ObjectKind, Tunables, collections};
37+
use wasmtime_environ::{FlagValue, ObjectKind, OperatorCostStrategy, Tunables, collections, obj};
3938

4039
const VERSION: u8 = 0;
4140

@@ -276,6 +275,22 @@ impl Metadata<'_> {
276275
);
277276
}
278277

278+
fn check_cost(
279+
consume_fuel: bool,
280+
found: &OperatorCostStrategy,
281+
expected: &OperatorCostStrategy,
282+
) -> Result<()> {
283+
if !consume_fuel {
284+
return Ok(());
285+
}
286+
287+
if found != expected {
288+
bail!("Module costs are incompatible");
289+
}
290+
291+
Ok(())
292+
}
293+
279294
fn check_tunables(&mut self, other: &Tunables) -> Result<()> {
280295
let Tunables {
281296
collector,
@@ -285,6 +300,7 @@ impl Metadata<'_> {
285300
debug_guest,
286301
parse_wasm_debuginfo,
287302
consume_fuel,
303+
ref operator_cost,
288304
epoch_interruption,
289305
memory_may_move,
290306
guard_before_linear_memory,
@@ -336,6 +352,7 @@ impl Metadata<'_> {
336352
"WebAssembly backtrace support",
337353
)?;
338354
Self::check_bool(consume_fuel, other.consume_fuel, "fuel support")?;
355+
Self::check_cost(consume_fuel, operator_cost, &other.operator_cost)?;
339356
Self::check_bool(
340357
epoch_interruption,
341358
other.epoch_interruption,

crates/wasmtime/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,7 @@ mod sync_nostd;
515515
#[cfg(not(feature = "std"))]
516516
use sync_nostd as sync;
517517

518+
pub use wasmtime_environ::OperatorCost;
518519
#[doc(inline)]
519520
pub use wasmtime_environ::error;
520521

crates/wasmtime/src/runtime/vm/memory/malloc.rs

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -157,16 +157,18 @@ mod tests {
157157
};
158158

159159
// Valid tunables that can be used to create a `MallocMemory`.
160-
const TUNABLES: Tunables = Tunables {
161-
memory_reservation: 0,
162-
memory_guard_size: 0,
163-
memory_init_cow: false,
164-
..Tunables::default_miri()
165-
};
160+
fn tunables() -> Tunables {
161+
Tunables {
162+
memory_reservation: 0,
163+
memory_guard_size: 0,
164+
memory_init_cow: false,
165+
..Tunables::default_miri()
166+
}
167+
}
166168

167169
#[test]
168170
fn simple() {
169-
let mut memory = MallocMemory::new(&TY, &TUNABLES, 10).unwrap();
171+
let mut memory = MallocMemory::new(&TY, &tunables(), 10).unwrap();
170172
assert_eq!(memory.storage.len(), 1);
171173
assert_valid(&memory);
172174

@@ -191,7 +193,7 @@ mod tests {
191193
fn reservation_not_initialized() {
192194
let tunables = Tunables {
193195
memory_reservation_for_growth: 1 << 20,
194-
..TUNABLES
196+
..tunables()
195197
};
196198
let mut memory = MallocMemory::new(&TY, &tunables, 10).unwrap();
197199
assert_eq!(memory.storage.len(), 1);

tests/all/fuel.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,3 +382,50 @@ fn ensure_stack_alignment(config: &mut Config) -> Result<()> {
382382
);
383383
Ok(())
384384
}
385+
386+
#[wasmtime_test]
387+
#[cfg_attr(miri, ignore)]
388+
fn custom_operator_cost(config: &mut Config) -> Result<()> {
389+
config.consume_fuel(true);
390+
let op_cost = OperatorCost {
391+
I32Const: 12,
392+
I32Add: 23,
393+
I64Const: 64,
394+
I64Add: 128,
395+
Drop: 5,
396+
..Default::default()
397+
};
398+
config.operator_cost(op_cost.clone());
399+
let engine = Engine::new(config)?;
400+
let module = Module::new(
401+
&engine,
402+
r#"
403+
(module
404+
(func (export "main")
405+
;; i32: 1 + 2
406+
(drop (i32.add (i32.const 1) (i32.const 2)))
407+
408+
;; i64: 3 + 4
409+
(drop (i64.add (i64.const 3) (i64.const 4)))
410+
)
411+
)
412+
"#,
413+
)?;
414+
let mut store = Store::new(&engine, ());
415+
store.set_fuel(10_000)?;
416+
417+
let instance = Instance::new(&mut store, &module, &[])?;
418+
let main = instance.get_typed_func::<(), ()>(&mut store, "main")?;
419+
420+
let initial_fuel = store.get_fuel()?;
421+
main.call(&mut store, ())?;
422+
let cost_of_execution = u64::from(op_cost.I32Add)
423+
+ u64::from(op_cost.I64Add)
424+
+ u64::from(op_cost.I32Const) * 2
425+
+ u64::from(op_cost.I64Const) * 2
426+
+ u64::from(op_cost.Drop) * 2
427+
+ 1;
428+
assert_eq!(store.get_fuel()?, initial_fuel - cost_of_execution);
429+
430+
Ok(())
431+
}

0 commit comments

Comments
 (0)