Stream: git-wasmtime

Topic: wasmtime / issue #14204 Const-expr operators are charged ...


view this post on Zulip Wasmtime GitHub notifications bot (Aug 25 2026 at 13:18):

AndSDev opened issue #14204:

Operators inside constant expressions (global initializers, element/data segment offsets, etc.) are each charged exactly 1 fuel unit regardless of the OperatorCost table supplied via Config::operator_cost. Regular function code honors the table; const-expr code does not.

Test Case

A module whose global initializer is a multi-op const-expr. Wasmtime does not constant-fold i32.add at compile time, so it runs through translate_const_expr at instantiation. A start function is present so the charges buffered during instantiation are actually flushed to the fuel counter (see #14203).

(module
  (global $g i32 (i32.add (i32.const 1) (i32.const 2)))
  (export "g" (global $g))
  (func $start)
  (start $start))

Steps to Reproduce

//! Cargo.toml
//! [dependencies]
//! wasmtime = "48"

use wasmtime::*;

const WAT: &str = r#"
    (module
      (global $g i32 (i32.add (i32.const 1) (i32.const 2)))
      (export "g" (global $g))
      (func $start)
      (start $start))
"#;

/// Instantiate `WAT` and return the fuel consumed by instantiation.
fn instantiation_fuel(config: &Config) -> Result<u64> {
    let engine = Engine::new(config)?;
    let module = Module::new(&engine, WAT)?;
    let mut store = Store::new(&engine, ());
    store.set_fuel(u64::MAX)?;

    let instance = Instance::new(&mut store, &module, &[])?;
    let g = instance.get_global(&mut store, "g").unwrap().get(&mut store);
    assert_eq!(g.i32(), Some(3), "global initializer did not run");

    Ok(u64::MAX - store.get_fuel()?)
}

fn main() -> Result<()> {
    // Default table: every op costs 1.
    let mut default = Config::new();
    default.consume_fuel(true);
    println!("default: {}", instantiation_fuel(&default)?);

    // Custom table: i32.add costs 100.
    let mut cost = OperatorCost::new();
    cost.I32Add = 100;

    let mut custom = Config::new();
    custom.consume_fuel(true).operator_cost(cost);
    println!("custom:  {}", instantiation_fuel(&custom)?);

    Ok(())
}

Expected Results

default: 6
custom:  105

Breakdown for the custom table (i32.const = 1, i32.add = 100):

Step Fuel
module-init entry cost 1
i32.const 1 (const-expr op) 1
i32.const 2 (const-expr op) 1
i32.add (const-expr op) 100
manual accounting for the start call 1
start function entry cost 1
Total 105

With the default table every op costs 1, so the total is 6.

Actual Results

default: 6
custom:  6

Each const-expr op is charged a fixed 1 (see Extra Info), so the I32Add=100 setting has no effect. The 6 for the default table is coincidentally correct only because all default costs are 1.

Versions and Environment

Wasmtime version or commit: main at ffb04089ea (also release-48.0.0)

Operating system: Linux

Architecture: x86_64

Extra Info

Extra Info

Root cause is in FuncEnvironment::translate_const_expr (crates/cranelift/src/func_environ.rs:6101): each const-expr op does self.fuel_consumed += 1, ignoring tunables.operator_cost. The OperatorCost::variable per-byte/per-element costs (e.g. array.new_fixed with a large constant length) are likewise not applied, so Config::operator_cost is silently ineffective for all instantiation-time work (global initializers, element/data segments, segment offsets).

view this post on Zulip Wasmtime GitHub notifications bot (Aug 25 2026 at 13:18):

AndSDev added the bug label to Issue #14204.

view this post on Zulip Wasmtime GitHub notifications bot (Aug 25 2026 at 14:44):

AndSDev edited issue #14204:

Operators inside constant expressions (global initializers, element/data segment offsets, etc.) are each charged exactly 1 fuel unit regardless of the OperatorCost table supplied via Config::operator_cost. Regular function code honors the table; const-expr code does not.

Test Case

A module whose global initializer is a multi-op const-expr. Wasmtime does not constant-fold i32.add at compile time, so it runs through translate_const_expr at instantiation. A start function is present so the charges buffered during instantiation are actually flushed to the fuel counter (see #14203).

(module
  (global $g i32 (i32.add (i32.const 1) (i32.const 2)))
  (export "g" (global $g))
  (func $start)
  (start $start))

Steps to Reproduce

//! Cargo.toml
//! [dependencies]
//! wasmtime = "48"

use wasmtime::*;

const WAT: &str = r#"
    (module
      (global $g i32 (i32.add (i32.const 1) (i32.const 2)))
      (export "g" (global $g))
      (func $start)
      (start $start))
"#;

/// Instantiate `WAT` and return the fuel consumed by instantiation.
fn instantiation_fuel(config: &Config) -> Result<u64> {
    let engine = Engine::new(config)?;
    let module = Module::new(&engine, WAT)?;
    let mut store = Store::new(&engine, ());
    store.set_fuel(u64::MAX)?;

    let instance = Instance::new(&mut store, &module, &[])?;
    let g = instance.get_global(&mut store, "g").unwrap().get(&mut store);
    assert_eq!(g.i32(), Some(3), "global initializer did not run");

    Ok(u64::MAX - store.get_fuel()?)
}

fn main() -> Result<()> {
    // Default table: every op costs 1.
    let mut default = Config::new();
    default.consume_fuel(true);
    println!("default: {}", instantiation_fuel(&default)?);

    // Custom table: i32.add costs 100.
    let mut cost = OperatorCost::new();
    cost.I32Add = 100;

    let mut custom = Config::new();
    custom.consume_fuel(true).operator_cost(cost);
    println!("custom:  {}", instantiation_fuel(&custom)?);

    Ok(())
}

Expected Results

default: 6
custom:  105

Breakdown for the custom table (i32.const = 1, i32.add = 100):

Step Fuel
module-init entry cost 1
i32.const 1 (const-expr op) 1
i32.const 2 (const-expr op) 1
i32.add (const-expr op) 100
manual accounting for the start call 1
start function entry cost 1
Total 105

With the default table every op costs 1, so the total is 6.

Actual Results

default: 6
custom:  6

Each const-expr op is charged a fixed 1 (see Extra Info), so the I32Add=100 setting has no effect. The 6 for the default table is coincidentally correct only because all default costs are 1.

Versions and Environment

Wasmtime version or commit: main at ffb04089ea (also release-48.0.0)

Operating system: Linux

Architecture: x86_64

Extra Info

Root cause is in FuncEnvironment::translate_const_expr (crates/cranelift/src/func_environ.rs:6101): each const-expr op does self.fuel_consumed += 1, ignoring tunables.operator_cost. The OperatorCost::variable per-byte/per-element costs (e.g. array.new_fixed with a large constant length) are likewise not applied, so Config::operator_cost is silently ineffective for all instantiation-time work (global initializers, element/data segments, segment offsets).

P.S. The same hardcoded-1 bug exists in FuncEnvironment::module_start (crates/cranelift/src/func_environ.rs:6082). That function synthesizes the call to the (start ...) function and manually replicates the fuel_before_op accounting for Operator::Call:

// Manuall manage fuel around the call as the `Call` opcode does for
// normal wasm to ensure that it's correctly accounted for.
if self.tunables.consume_fuel {
    self.fuel_consumed += 1;  // hardcoded, ignores OperatorCost::Call
    self.fuel_increment_var(builder);
    self.fuel_save_from_var(builder);
}

view this post on Zulip Wasmtime GitHub notifications bot (Aug 25 2026 at 14:47):

AndSDev edited issue #14204:

Operators inside constant expressions (global initializers, element/data segment offsets, etc.) are each charged exactly 1 fuel unit regardless of the OperatorCost table supplied via Config::operator_cost. Regular function code honors the table; const-expr code does not.

The same hardcoded-1 bug exists in FuncEnvironment::module_start (crates/cranelift/src/func_environ.rs:6082).

Test Case

A module whose global initializer is a multi-op const-expr. Wasmtime does not constant-fold i32.add at compile time, so it runs through translate_const_expr at instantiation. A start function is present so the charges buffered during instantiation are actually flushed to the fuel counter (see #14203).

(module
  (global $g i32 (i32.add (i32.const 1) (i32.const 2)))
  (export "g" (global $g))
  (func $start)
  (start $start))

Steps to Reproduce

//! Cargo.toml
//! [dependencies]
//! wasmtime = "48"

use wasmtime::*;

const WAT: &str = r#"
    (module
      (global $g i32 (i32.add (i32.const 1) (i32.const 2)))
      (export "g" (global $g))
      (func $start)
      (start $start))
"#;

/// Instantiate `WAT` and return the fuel consumed by instantiation.
fn instantiation_fuel(config: &Config) -> Result<u64> {
    let engine = Engine::new(config)?;
    let module = Module::new(&engine, WAT)?;
    let mut store = Store::new(&engine, ());
    store.set_fuel(u64::MAX)?;

    let instance = Instance::new(&mut store, &module, &[])?;
    let g = instance.get_global(&mut store, "g").unwrap().get(&mut store);
    assert_eq!(g.i32(), Some(3), "global initializer did not run");

    Ok(u64::MAX - store.get_fuel()?)
}

fn main() -> Result<()> {
    // Default table: every op costs 1.
    let mut default = Config::new();
    default.consume_fuel(true);
    println!("default: {}", instantiation_fuel(&default)?);

    // Custom table: i32.add costs 100.
    let mut cost = OperatorCost::new();
    cost.I32Add = 100;

    let mut custom = Config::new();
    custom.consume_fuel(true).operator_cost(cost);
    println!("custom:  {}", instantiation_fuel(&custom)?);

    Ok(())
}

Expected Results

default: 6
custom:  105

Breakdown for the custom table (i32.const = 1, i32.add = 100):

Step Fuel
module-init entry cost 1
i32.const 1 (const-expr op) 1
i32.const 2 (const-expr op) 1
i32.add (const-expr op) 100
manual accounting for the start call 1
start function entry cost 1
Total 105

With the default table every op costs 1, so the total is 6.

Actual Results

default: 6
custom:  6

Each const-expr op is charged a fixed 1 (see Extra Info), so the I32Add=100 setting has no effect. The 6 for the default table is coincidentally correct only because all default costs are 1.

Versions and Environment

Wasmtime version or commit: main at ffb04089ea (also release-48.0.0)

Operating system: Linux

Architecture: x86_64

Extra Info

Root cause is in FuncEnvironment::translate_const_expr (crates/cranelift/src/func_environ.rs:6101): each const-expr op does self.fuel_consumed += 1, ignoring tunables.operator_cost. The OperatorCost::variable per-byte/per-element costs (e.g. array.new_fixed with a large constant length) are likewise not applied, so Config::operator_cost is silently ineffective for all instantiation-time work (global initializers, element/data segments, segment offsets).

P.S. The same hardcoded-1 bug exists in FuncEnvironment::module_start (crates/cranelift/src/func_environ.rs:6082). That function synthesizes the call to the (start ...) function and manually replicates the fuel_before_op accounting for Operator::Call:

// Manuall manage fuel around the call as the `Call` opcode does for
// normal wasm to ensure that it's correctly accounted for.
if self.tunables.consume_fuel {
    self.fuel_consumed += 1;  // hardcoded, ignores OperatorCost::Call
    self.fuel_increment_var(builder);
    self.fuel_save_from_var(builder);
}

view this post on Zulip Wasmtime GitHub notifications bot (Aug 25 2026 at 18:06):

alexcrichton added the wasmtime:fuel label to Issue #14204.

view this post on Zulip Wasmtime GitHub notifications bot (Aug 25 2026 at 21:18):

arcusbuilds commented on issue #14204:

I would like to work on it!

view this post on Zulip Wasmtime GitHub notifications bot (Aug 28 2026 at 21:29):

cfallin closed issue #14204:

Operators inside constant expressions (global initializers, element/data segment offsets, etc.) are each charged exactly 1 fuel unit regardless of the OperatorCost table supplied via Config::operator_cost. Regular function code honors the table; const-expr code does not.

The same hardcoded-1 bug exists in FuncEnvironment::module_start (crates/cranelift/src/func_environ.rs:6082).

Test Case

A module whose global initializer is a multi-op const-expr. Wasmtime does not constant-fold i32.add at compile time, so it runs through translate_const_expr at instantiation. A start function is present so the charges buffered during instantiation are actually flushed to the fuel counter (see #14203).

(module
  (global $g i32 (i32.add (i32.const 1) (i32.const 2)))
  (export "g" (global $g))
  (func $start)
  (start $start))

Steps to Reproduce

//! Cargo.toml
//! [dependencies]
//! wasmtime = "48"

use wasmtime::*;

const WAT: &str = r#"
    (module
      (global $g i32 (i32.add (i32.const 1) (i32.const 2)))
      (export "g" (global $g))
      (func $start)
      (start $start))
"#;

/// Instantiate `WAT` and return the fuel consumed by instantiation.
fn instantiation_fuel(config: &Config) -> Result<u64> {
    let engine = Engine::new(config)?;
    let module = Module::new(&engine, WAT)?;
    let mut store = Store::new(&engine, ());
    store.set_fuel(u64::MAX)?;

    let instance = Instance::new(&mut store, &module, &[])?;
    let g = instance.get_global(&mut store, "g").unwrap().get(&mut store);
    assert_eq!(g.i32(), Some(3), "global initializer did not run");

    Ok(u64::MAX - store.get_fuel()?)
}

fn main() -> Result<()> {
    // Default table: every op costs 1.
    let mut default = Config::new();
    default.consume_fuel(true);
    println!("default: {}", instantiation_fuel(&default)?);

    // Custom table: i32.add costs 100.
    let mut cost = OperatorCost::new();
    cost.I32Add = 100;

    let mut custom = Config::new();
    custom.consume_fuel(true).operator_cost(cost);
    println!("custom:  {}", instantiation_fuel(&custom)?);

    Ok(())
}

Expected Results

default: 6
custom:  105

Breakdown for the custom table (i32.const = 1, i32.add = 100):

Step Fuel
module-init entry cost 1
i32.const 1 (const-expr op) 1
i32.const 2 (const-expr op) 1
i32.add (const-expr op) 100
manual accounting for the start call 1
start function entry cost 1
Total 105

With the default table every op costs 1, so the total is 6.

Actual Results

default: 6
custom:  6

Each const-expr op is charged a fixed 1 (see Extra Info), so the I32Add=100 setting has no effect. The 6 for the default table is coincidentally correct only because all default costs are 1.

Versions and Environment

Wasmtime version or commit: main at ffb04089ea (also release-48.0.0)

Operating system: Linux

Architecture: x86_64

Extra Info

Root cause is in FuncEnvironment::translate_const_expr (crates/cranelift/src/func_environ.rs:6101): each const-expr op does self.fuel_consumed += 1, ignoring tunables.operator_cost. The OperatorCost::variable per-byte/per-element costs (e.g. array.new_fixed with a large constant length) are likewise not applied, so Config::operator_cost is silently ineffective for all instantiation-time work (global initializers, element/data segments, segment offsets).

P.S. The same hardcoded-1 bug exists in FuncEnvironment::module_start (crates/cranelift/src/func_environ.rs:6082). That function synthesizes the call to the (start ...) function and manually replicates the fuel_before_op accounting for Operator::Call:

// Manuall manage fuel around the call as the `Call` opcode does for
// normal wasm to ensure that it's correctly accounted for.
if self.tunables.consume_fuel {
    self.fuel_consumed += 1;  // hardcoded, ignores OperatorCost::Call
    self.fuel_increment_var(builder);
    self.fuel_save_from_var(builder);
}

Last updated: Aug 30 2026 at 09:07 UTC