Skip to main content

submilli_engine/runtime/
fuel.rs

1//! Fuel for work done by host functions. Wasm instructions burn fuel on their
2//! own; a host function charges for its work here, from the same budget, so
3//! that fuel bounds the CPU a program spends wherever it spends it.
4//!
5//! One fuel is roughly the cost of one interpreted Wasm instruction, about
6//! 2.5 ns. Host work is priced by the native time it takes, through a small
7//! set of cost classes: each class is a rate per unit of work, and a host
8//! function charges `CALL` plus the classes that describe what it does with
9//! the sizes it knows. The rates are placeholders until SUB-1270 measures
10//! them; `plans/sub-1269-host-fuel-costs.md` gives the formula of every host
11//! function.
12//!
13//! Charges are batched: a charge is refused before the work when the budget
14//! is short, but the engine's fuel is only lowered once [`HOST_FUEL_BATCH`]
15//! units have accumulated and Wasm has burned more than that since the last
16//! application, because `set_fuel` restarts the engine's async yield countdown
17//! (`RuntimeConfig::async_yield_fuel`, as in wasmtime) and a loop calling a
18//! host function every few instructions would otherwise never yield. The
19//! engine may therefore run past the budget by what is pending before Wasm
20//! fuel runs out, at most [`HOST_FUEL_PENDING_CAP`]; usage reporting clamps
21//! to the budget.
22
23use std::future::Future;
24
25use wasmtime::{AsContextMut, Caller, Func, FuncType, Trap, Val};
26
27use super::StoreData;
28
29/// Fuel per unit of work, as `fuel` units for every `per` units of work, so a
30/// class worth less than one fuel per unit still charges: the cost rounds up,
31/// and no non-empty work is free.
32#[derive(Clone, Copy, Debug)]
33pub struct Rate {
34    fuel: u64,
35    per: u64,
36}
37
38impl Rate {
39    const fn per_unit(fuel: u64) -> Rate {
40        Rate { fuel, per: 1 }
41    }
42
43    const fn per_units(fuel: u64, per: u64) -> Rate {
44        Rate { fuel, per }
45    }
46
47    /// The fuel for `n` units of work, rounded up, saturating.
48    pub fn cost(self, n: u64) -> u64 {
49        n.saturating_mul(self.fuel).div_ceil(self.per)
50    }
51}
52
53/// Flat charge of every host call; an O(1) host function costs this alone.
54/// `CALL`, `TZ` and `GATE` are single-shot amounts for `charge_host_fuel`;
55/// the `Rate`s below scale with a size and go through `charge`.
56pub const CALL: u64 = 16;
57/// memcpy-like movement of code units or bytes.
58pub const COPY: Rate = Rate::per_units(1, 8);
59/// Per unit read, compared, hashed or transformed with simple logic, including
60/// UTF-8/UTF-16 conversion.
61pub const SCAN: Rate = Rate::per_unit(1);
62/// Per byte of parsing or formatting that builds structure (JSON, URLs,
63/// numbers, dates, diffs).
64pub const PARSE: Rate = Rate::per_unit(6);
65/// Per element, entry or field touched, boxed or allocated as a GC value.
66pub const ELEM: Rate = Rate::per_unit(10);
67/// Regex matching per byte of haystack. The engine is the `regex` crate: no
68/// backtracking, so the worst case is the program size times the haystack,
69/// and the program size is capped at compile time.
70pub const REGEX: Rate = Rate::per_unit(8);
71/// Compiling a regex, on top of `PARSE` of its source: the program it
72/// builds is bounded by a size cap, not by the source length.
73pub const REGEX_COMPILE: u64 = 2_000;
74/// Cryptographic hashing per byte.
75pub const HASH: Rate = Rate::per_unit(4);
76/// Bytes sent, received, read or written; waiting costs nothing.
77pub const IO: Rate = Rate::per_units(1, 4);
78/// One filesystem metadata operation (open, stat, readdir entry, rename, ...).
79pub const SYSCALL: Rate = Rate::per_unit(400);
80/// One time-zone resolution.
81pub const TZ: u64 = 200;
82/// One capability check.
83pub const GATE: u64 = 400;
84
85/// The host overhead of sorting `n` elements: `ELEM` per comparison and move,
86/// `n·log2(n)` of them. Comparators and key conversions charge themselves.
87pub fn sort_cost(n: u64) -> u64 {
88    let log = u64::from(n.max(2).ilog2());
89    ELEM.cost(n.saturating_mul(log))
90}
91
92/// Multiplication, division or radix conversion of big integers of `a` and
93/// `b` limbs: a limb operation per limb pair while the operands are small,
94/// then, once the library switches to Karatsuba and Toom-3, close to linear
95/// in the larger operand. A placeholder shape until SUB-1270 measures it.
96pub fn bigint_product_cost(a: u64, b: u64) -> u64 {
97    let (small, large) = (a.min(b).max(1), a.max(b).max(1));
98    let schoolbook = small.saturating_mul(large.min(64));
99    let beyond = large
100        .saturating_sub(64)
101        .saturating_mul(u64::from(large.ilog2()) + 1);
102    ELEM.cost(schoolbook.saturating_add(beyond))
103}
104
105/// Charges the flat per-call cost.
106pub fn charge_call(ctx: impl AsContextMut<Data = StoreData>) -> wasmtime::Result<()> {
107    charge_host_fuel(ctx, CALL)
108}
109
110/// Charges `n` units of work at `rate`, before the work.
111pub fn charge(
112    ctx: impl AsContextMut<Data = StoreData>,
113    rate: Rate,
114    n: u64,
115) -> wasmtime::Result<()> {
116    charge_host_fuel(ctx, rate.cost(n))
117}
118
119/// Builds a host function that charges `CALL` before `body` runs. For host
120/// functions created with `Func::new` outside the linker (iterator steps and
121/// the like); linker registrations charge in `register_host_fn`.
122pub(crate) fn host_func(
123    mut store: impl AsContextMut<Data = StoreData>,
124    ty: FuncType,
125    body: impl Fn(&mut Caller<'_, StoreData>, &[Val], &mut [Val]) -> wasmtime::Result<()>
126    + Send
127    + Sync
128    + 'static,
129) -> Func {
130    let abi = ty.clone();
131    Func::new(&mut store, ty, move |mut caller, params, results| {
132        super::host::check_host_abi(&abi, params, results)?;
133        charge_call(&mut caller)?;
134        body(&mut caller, params, results)
135    })
136}
137
138/// Async sibling of [`host_func`], for the vtable hooks (`toString`,
139/// `toJSON`, `equals`, `hash`) that guest code reaches by `call_ref`.
140pub(crate) fn host_func_async<F>(
141    mut store: impl AsContextMut<Data = StoreData>,
142    ty: FuncType,
143    body: F,
144) -> Func
145where
146    F: for<'a> Fn(
147            Caller<'a, StoreData>,
148            &'a [Val],
149            &'a mut [Val],
150        ) -> Box<dyn Future<Output = wasmtime::Result<()>> + Send + 'a>
151        + Send
152        + Sync
153        + 'static,
154{
155    let abi = ty.clone();
156    Func::new_async(&mut store, ty, move |mut caller, params, results| {
157        if let Err(error) = super::host::check_host_abi(&abi, params, results) {
158            return Box::new(async move { Err(error) });
159        }
160        if let Err(error) = charge_call(&mut caller) {
161            return Box::new(async move { Err(error) });
162        }
163        body(caller, params, results)
164    })
165}
166
167/// Charges `units` for work that followed an effect: a response received, a
168/// file written, a completion returned. The effect has happened and its
169/// result is in hand, so the charge never refuses: a short budget is taken
170/// to zero, the call completes, and the run stops at the next fuel check.
171pub fn settle(
172    ctx: impl AsContextMut<Data = StoreData>,
173    rate: Rate,
174    n: u64,
175) -> wasmtime::Result<()> {
176    settle_host_fuel(ctx, rate.cost(n))
177}
178
179/// Marshal an already-observed effect's result using helpers that normally
180/// charge before work. Within this host-only scope, short fuel settles to zero
181/// without discarding the result. Do not run guest callbacks in this scope.
182pub(crate) fn settle_result<T, C: AsContextMut<Data = StoreData>>(
183    caller: &mut C,
184    body: impl FnOnce(&mut C) -> wasmtime::Result<T>,
185) -> wasmtime::Result<T> {
186    let previous = caller.as_context().data().settling_host_result;
187    caller.as_context_mut().data_mut().settling_host_result = true;
188    let result = body(caller);
189    caller.as_context_mut().data_mut().settling_host_result = previous;
190    result
191}
192
193/// [`settle`] for an amount of fuel.
194pub fn settle_host_fuel(
195    mut ctx: impl AsContextMut<Data = StoreData>,
196    units: u64,
197) -> wasmtime::Result<()> {
198    let mut ctx = ctx.as_context_mut();
199    let pending = ctx.data().host_fuel_pending;
200    let available = ctx.get_fuel()?.saturating_sub(pending);
201    if units <= available {
202        return charge_host_fuel(ctx, units);
203    }
204    ctx.set_fuel(0)?;
205    let data = ctx.data_mut();
206    data.host_fuel_pending = 0;
207    data.host_fuel = data.host_fuel.saturating_add(available);
208    Ok(())
209}
210
211/// Host charges accumulate on the store and are applied to the engine once
212/// they reach this many units and Wasm has burned this much since the last
213/// application. It must not be below `RuntimeConfig::async_yield_fuel`: each
214/// application restarts the yield countdown, and the countdown needs a full
215/// interval between applications.
216pub const HOST_FUEL_BATCH: u64 = 10_000;
217/// Pending charges are applied regardless once they reach this, which bounds
218/// how far past its budget a run can get before Wasm fuel runs out. Such an
219/// application does restart the yield countdown, so a loop that charges this
220/// much between yields yields only on the deadline.
221pub const HOST_FUEL_PENDING_CAP: u64 = 1_000 * HOST_FUEL_BATCH;
222
223/// Charges `units` of host work against the run's fuel, before the work.
224///
225/// The charge is refused when the budget left, pending charges included,
226/// cannot cover it: the store is left at zero and the call fails with the
227/// engine's own out-of-fuel trap, so the run ends the way a Wasm loop would
228/// and guest code cannot catch it, even when the host body re-entered it.
229/// Otherwise the charge joins the pending batch and reaches the engine once
230/// the batch is full (see [`HOST_FUEL_BATCH`] and the module doc). Every
231/// charge, pending or applied, counts as host fuel for
232/// [`super::limits::ExecutionUsage`].
233pub fn charge_host_fuel(
234    mut ctx: impl AsContextMut<Data = StoreData>,
235    units: u64,
236) -> wasmtime::Result<()> {
237    if units == 0 {
238        return Ok(());
239    }
240    let mut ctx = ctx.as_context_mut();
241    let pending = ctx.data().host_fuel_pending;
242    let engine_fuel = ctx.get_fuel()?;
243    let available = engine_fuel.saturating_sub(pending);
244    if units > available {
245        ctx.set_fuel(0)?;
246        let data = ctx.data_mut();
247        data.host_fuel_pending = 0;
248        data.host_fuel = data.host_fuel.saturating_add(available);
249        if data.settling_host_result {
250            return Ok(());
251        }
252        return Err(Trap::OutOfFuel.into());
253    }
254    // Neither overflows: `units <= engine_fuel - pending`.
255    let batch = pending + units;
256    let data = ctx.data_mut();
257    data.host_fuel = data.host_fuel.saturating_add(units);
258    // Only Wasm lowers the engine's fuel between applications, so the mark
259    // measures Wasm burned since; before the first application a full batch
260    // applies at once. The engine yields on the instruction after the slice
261    // hits zero, so one more unit than a batch proves a yield happened.
262    let wasm_burned = data
263        .host_fuel_applied_at
264        .map_or(u64::MAX, |mark| mark.saturating_sub(engine_fuel));
265    let apply = batch >= HOST_FUEL_PENDING_CAP
266        || (batch >= HOST_FUEL_BATCH && wasm_burned > HOST_FUEL_BATCH);
267    if !apply {
268        data.host_fuel_pending = batch;
269        return Ok(());
270    }
271    data.host_fuel_pending = 0;
272    data.host_fuel_applied_at = Some(engine_fuel - batch);
273    ctx.set_fuel(engine_fuel - batch)
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use crate::runtime::{RuntimeConfig, Vfs};
280    use wasmtime::{Func, FuncType, Val, ValType};
281
282    #[test]
283    fn result_settlement_preserves_values_and_restores_refusing_charges() {
284        let config = RuntimeConfig::default();
285        let engine = config.engine().unwrap();
286        let mut store = config
287            .store(&engine, StoreData::with_vfs(Vfs::none()))
288            .unwrap();
289        let complete = Func::new(
290            &mut store,
291            FuncType::new(&engine, [], [ValType::I32]),
292            |mut caller, _, results| {
293                results[0] = settle_result(&mut caller, |caller| {
294                    charge(caller, PARSE, 100)?;
295                    Ok(Val::I32(42))
296                })?;
297                assert!(!caller.data().settling_host_result);
298                Ok(())
299            },
300        );
301        store.set_fuel(1).unwrap();
302        let mut results = [Val::I32(0)];
303        complete.call(&mut store, &[], &mut results).unwrap();
304        assert_eq!(results[0].i32(), Some(42));
305        assert_eq!(store.get_fuel().unwrap(), 0);
306        assert_eq!(store.data().host_fuel, 1);
307        assert!(charge_call(&mut store).unwrap_err().is::<Trap>());
308
309        let fail = Func::new(
310            &mut store,
311            FuncType::new(&engine, [], []),
312            |mut caller, _, _| {
313                let result = settle_result(&mut caller, |caller| {
314                    charge(caller, COPY, 100)?;
315                    Err::<(), _>(crate::runtime::host::range_error("copy entry limit"))
316                });
317                assert!(!caller.data().settling_host_result);
318                result
319            },
320        );
321        let error = fail.call(&mut store, &[], &mut []).unwrap_err();
322        assert!(error.to_string().contains("copy entry limit"), "{error}");
323        assert!(!store.data().settling_host_result);
324        assert!(charge_call(&mut store).unwrap_err().is::<Trap>());
325    }
326
327    #[test]
328    fn settling_never_refuses_and_leaves_a_short_budget_at_zero() {
329        let config = RuntimeConfig::default();
330        let engine = config.engine().unwrap();
331        let mut store = config
332            .store(&engine, StoreData::with_vfs(Vfs::none()))
333            .unwrap();
334        let settle = Func::new(
335            &mut store,
336            FuncType::new(&engine, [ValType::I64], []),
337            |mut caller, params, _| settle_host_fuel(&mut caller, params[0].unwrap_i64() as u64),
338        );
339        let charge = Func::new(
340            &mut store,
341            FuncType::new(&engine, [ValType::I64], []),
342            |mut caller, params, _| charge_host_fuel(&mut caller, params[0].unwrap_i64() as u64),
343        );
344
345        // Within the budget it is an ordinary charge, batched like any other.
346        store.set_fuel(1_000).unwrap();
347        settle.call(&mut store, &[Val::I64(300)], &mut []).unwrap();
348        assert_eq!(store.get_fuel().unwrap(), 1_000);
349        assert_eq!(store.data().host_fuel_pending, 300);
350        assert_eq!(store.data().host_fuel, 300);
351
352        // Beyond it, pending included, it takes what is there and succeeds;
353        // the next ordinary charge is the one that traps.
354        settle
355            .call(&mut store, &[Val::I64(5_000)], &mut [])
356            .unwrap();
357        assert_eq!(store.get_fuel().unwrap(), 0);
358        assert_eq!(store.data().host_fuel_pending, 0);
359        assert_eq!(store.data().host_fuel, 1_000);
360        let error = charge
361            .call(&mut store, &[Val::I64(1)], &mut [])
362            .unwrap_err();
363        assert_eq!(error.downcast_ref::<Trap>(), Some(&Trap::OutOfFuel));
364    }
365
366    #[test]
367    fn the_batch_covers_the_default_yield_interval() {
368        assert!(RuntimeConfig::default().async_yield_fuel <= Some(HOST_FUEL_BATCH));
369    }
370
371    #[test]
372    fn rates_round_up_and_never_make_work_free() {
373        assert_eq!(COPY.cost(0), 0);
374        assert_eq!(COPY.cost(1), 1);
375        assert_eq!(COPY.cost(8), 1);
376        assert_eq!(COPY.cost(9), 2);
377        assert_eq!(PARSE.cost(3), 18);
378        assert_eq!(SCAN.cost(u64::MAX), u64::MAX);
379        assert_eq!(sort_cost(0), 0);
380        assert_eq!(sort_cost(1), ELEM.cost(1));
381        assert_eq!(sort_cost(1024), ELEM.cost(10 * 1024));
382    }
383
384    #[test]
385    fn host_charges_are_batched_counted_and_refused_before_the_work() {
386        use crate::runtime::limits::ExecutionUsage;
387        let config = RuntimeConfig::default();
388        let engine = config.engine().unwrap();
389        let mut store = config
390            .store(&engine, StoreData::with_vfs(Vfs::none()))
391            .unwrap();
392        let charge = Func::new(
393            &mut store,
394            FuncType::new(&engine, [ValType::I64], []),
395            |mut caller, params, _| charge_host_fuel(&mut caller, params[0].unwrap_i64() as u64),
396        );
397        let charge_units = |store: &mut wasmtime::Store<StoreData>, units: u64| {
398            charge.call(&mut *store, &[Val::I64(units as i64)], &mut [])
399        };
400        // Stands in for Wasm burning fuel between host calls.
401        let burn = |store: &mut wasmtime::Store<StoreData>, units: u64| {
402            let left = store.get_fuel().unwrap() - units;
403            store.set_fuel(left).unwrap();
404        };
405
406        // Below a batch the engine's fuel is untouched; the counter is exact,
407        // and so is the usage report.
408        store.set_fuel(100_000).unwrap();
409        charge_units(&mut store, 9_999).unwrap();
410        assert_eq!(store.get_fuel().unwrap(), 100_000);
411        assert_eq!(store.data().host_fuel, 9_999);
412        assert_eq!(store.data().host_fuel_pending, 9_999);
413        let usage = ExecutionUsage::capture(&store, 100_000).unwrap();
414        assert_eq!(
415            (usage.fuel, usage.wasm_fuel, usage.host_fuel),
416            (9_999, 0, 9_999)
417        );
418        // The first full batch is applied.
419        charge_units(&mut store, 1).unwrap();
420        assert_eq!(store.get_fuel().unwrap(), 90_000);
421        assert_eq!(store.data().host_fuel_pending, 0);
422        // The next one waits until Wasm has burned more than a batch since,
423        // so the yield countdown gets a full run.
424        charge_units(&mut store, 20_000).unwrap();
425        assert_eq!(store.get_fuel().unwrap(), 90_000);
426        assert_eq!(store.data().host_fuel_pending, 20_000);
427        burn(&mut store, 10_000);
428        charge_units(&mut store, 1).unwrap();
429        assert_eq!(store.get_fuel().unwrap(), 80_000);
430        burn(&mut store, 1);
431        charge_units(&mut store, 1).unwrap();
432        assert_eq!(store.get_fuel().unwrap(), 59_997);
433        assert_eq!(store.data().host_fuel_pending, 0);
434        let charged_so_far = store.data().host_fuel;
435
436        // A charge the remaining budget cannot cover, pending included, is
437        // refused before the work: of the 300, 100 is already pending, so the
438        // refused charge takes the 200 left and the store ends at zero.
439        store.set_fuel(300).unwrap();
440        charge_units(&mut store, 100).unwrap();
441        let error = charge_units(&mut store, 201).unwrap_err();
442        assert_eq!(error.downcast_ref::<Trap>(), Some(&Trap::OutOfFuel));
443        assert_eq!(store.get_fuel().unwrap(), 0);
444        assert_eq!(store.data().host_fuel, charged_so_far + 100 + 200);
445        assert_eq!(store.data().host_fuel_pending, 0);
446
447        // Wasm may burn into a pending batch: the run overran by that much,
448        // the next charge finds nothing left, and the report stays within
449        // the budget. (A fresh budget, so the counters start over.)
450        store.set_fuel(1_000).unwrap();
451        store.data_mut().host_fuel = 0;
452        charge_units(&mut store, 600).unwrap();
453        burn(&mut store, 700);
454        let error = charge_units(&mut store, 1).unwrap_err();
455        assert_eq!(error.downcast_ref::<Trap>(), Some(&Trap::OutOfFuel));
456        assert_eq!(store.data().host_fuel, 600);
457        let usage = ExecutionUsage::capture(&store, 1_000).unwrap();
458        assert_eq!(
459            (usage.fuel, usage.wasm_fuel, usage.host_fuel),
460            (1_000, 400, 600)
461        );
462
463        // Zero-cost work is free and leaves the engine alone.
464        store.set_fuel(50).unwrap();
465        charge_units(&mut store, 0).unwrap();
466        assert_eq!(store.get_fuel().unwrap(), 50);
467    }
468}