Dynamic component calls with a large `list<u8>` are 100-200x slower than `TypedFunc` and use 48 host bytes per element

Aperta
#15,105 2 commenti 0 reazioni 0 assegnatari Vedi su GitHub

I maintainer di solito rispondono entro 1 giorno

Nessuno ha ancora preso questa issue.

Valutazione

Difficoltà
4/5
Tempo stimato
3-5 giorni
Idoneità per principianti
22/100
Tipo di issue
Bug
Chiarezza
Abbastanza chiara
Stato di attività
Attiva
Stack tecnologico
rust, wasm
Ambito
performance

Direzione di ricerca

The slowdown sits in the dynamic Val path under crates/wasmtime/src/runtime/component/: values.rs (Val::load, load_list, lower_list, the Val::List arms) and concurrent/func.rs (prepare_call_dynamic and the params.to_vec() copy in do_start_call_concurrent). Start by reproducing with the attached component.wat and the benchmark, then compare dynamic and typed timings. Done means a measured reduction in the per-element cost and a clear design decision about the owned-arguments API or a compact list representation.

Scritto dal modello di indicizzazione a partire dal testo della issue.

Descrizione

bug
Test Case

component.wat: a component exporting echo: func(data: list<u8>) -> list<u8>, which hands the list back, and len: func(data: list<u8>) -> u32, which only lowers it.

;; `echo` hands the list back; `len` returns its length (isolates lowering).
;; `realloc` always returns offset 16 (one allocation per call), growing memory as needed.
(component
  (core module $m
    (memory (export "memory") 1)
    (func (export "realloc") (param i32 i32 i32 i32) (result i32) (local $pages i32)
      (local.set $pages (i32.shr_u (i32.add (local.get 3) (i32.const 65551)) (i32.const 16)))
      (if (i32.gt_u (local.get $pages) (memory.size))
        (then (drop (memory.grow (i32.sub (local.get $pages) (memory.size))))))
      (i32.const 16))
    (func (export "echo") (param i32 i32) (result i32)
      (i32.store (i32.const 0) (local.get 0))
      (i32.store (i32.const 4) (local.get 1))
      (i32.const 0))
    (func (export "len") (param i32 i32) (result i32) (local.get 1)))
  (core instance $i (instantiate $m))
  (func (export "echo") (param "data" (list u8)) (result (list u8))
    (canon lift (core func $i "echo") (memory (core memory $i "memory")) (realloc (core func $i "realloc"))))
  (func (export "len") (param "data" (list u8)) (result u32)
    (canon lift (core func $i "len") (memory (core memory $i "memory")) (realloc (core func $i "realloc")))))

The host program calls both exports with 1, 16 and 64 MiB lists, dynamically (Func::call_async with Val::List, as an embedder that only learns signatures at runtime must) and through TypedFunc<(Vec<u8>,), _>, and prints each call's time and how far peak RSS rose during it.

Cargo.toml:

[package]
name = "wasmtime-val-list-repro"
version = "0.1.0"
edition = "2024"

[dependencies]
wasmtime = { version = "=50.0.1", default-features = false, features = [
    "runtime", "cranelift", "wat", "std", "async", "component-model", "component-model-async",
] }
tokio = { version = "1", features = ["rt", "macros"] }

[profile.release]
debug = 1

src/main.rs (with component.wat next to Cargo.toml):

use std::time::Instant;
use wasmtime::component::{Component, Linker, Val};
use wasmtime::{Config, Engine, Store};

const WAT: &str = include_str!("../component.wat");

fn kib(field: &str) -> u64 {
    let s = std::fs::read_to_string("/proc/self/status").unwrap();
    let l = s.lines().find(|l| l.starts_with(field)).unwrap();
    l.split_whitespace().nth(1).unwrap().parse().unwrap()
}

// Times one call and reports how far RSS peaked above the RSS right before it.
macro_rules! bench {
    ($label:expr, $mib:expr, $call:expr) => {{
        std::fs::write("/proc/self/clear_refs", "5").unwrap(); // reset VmHWM
        let base = kib("VmRSS:");
        let t = Instant::now();
        let r = $call;
        let dt = t.elapsed();
        println!("{:<14} {:>3} MiB {:>10.1?}  peak RSS +{:>5} MiB", $label, $mib, dt, kib("VmHWM:").saturating_sub(base) >> 10);
        r
    }};
}

#[tokio::main(flavor = "current_thread")]
async fn main() -> wasmtime::Result<()> {
    let concurrent = std::env::var_os("NO_CONCURRENCY").is_none();
    let mut config = Config::new();
    config.concurrency_support(concurrent).wasm_component_model_async(concurrent);
    let engine = Engine::new(&config)?;
    let component = Component::new(&engine, WAT)?;
    let mut store = Store::new(&engine, ());
    // The default (128 MiB) rejects dynamic lifts of list<u8> above ~2.7 MiB, since
    // each element is charged size_of::<Val>() = 48 bytes.
    store.set_hostcall_fuel(usize::MAX);
    let instance = Linker::new(&engine).instantiate_async(&mut store, &component).await?;
    let echo = instance.get_func(&mut store, "echo").unwrap();
    let len = instance.get_func(&mut store, "len").unwrap();
    let echo_t = echo.typed::<(Vec<u8>,), (Vec<u8>,)>(&store)?;
    let len_t = len.typed::<(Vec<u8>,), (u32,)>(&store)?;
    println!("concurrency_support = {concurrent}, size_of::<Val>() = {}", size_of::<Val>());

    for mib in [1usize, 16, 64] {
        let bytes = vec![7u8; mib << 20];
        let params = [Val::List(bytes.iter().map(|b| Val::U8(*b)).collect())];
        let mut out = [Val::Bool(false)];

        bench!("dynamic len", mib, len.call_async(&mut store, &params, &mut out).await?);
        assert!(matches!(out[0], Val::U32(n) if n as usize == bytes.len()));
        bench!("dynamic echo", mib, echo.call_async(&mut store, &params, &mut out).await?);
        assert!(matches!(&out[0], Val::List(v) if v.len() == bytes.len()));
        drop((params, out));

        let arg = (bytes.clone(),);
        let (n,) = bench!("typed len", mib, len_t.call_async(&mut store, arg).await?);
        assert_eq!(n as usize, bytes.len());
        let arg = (bytes.clone(),);
        let (v,) = bench!("typed echo", mib, echo_t.call_async(&mut store, arg).await?);
        assert_eq!(v, bytes);
    }
    Ok(())
}
Steps to Reproduce
  • Create the project above (Cargo.toml, src/main.rs, component.wat).
  • Run cargo run --release (Linux: the peak-RSS column reads /proc/self/clear_refs and VmHWM).
  • Run NO_CONCURRENCY=1 cargo run --release to compare with Config::concurrency_support(false).
  • Remove the store.set_hostcall_fuel(usize::MAX) line and run again to see the default hostcall fuel reject the dynamic echo at 3 MiB.
Expected Results

A dynamic call moving a list<u8> costs within a small factor of the typed call, as both copy the same bytes between host and guest: time and host memory proportional to the list's bytes, and the default hostcall fuel admitting the same list sizes on both paths.

Actual Results

At 16-64 MiB the dynamic calls are 100-200x slower than the typed ones, and host memory rises by about 48 bytes per element (size_of::<Val>() == 48): a 16 MiB list<u8> is a 768 MiB Vec<Val>.

Concurrency support on (the default):

call 1 MiB 16 MiB 64 MiB peak RSS rise at 16 / 64 MiB
dynamic len (lower only) 25-51 ms 390-523 ms 2.2-2.9 s +782 / +3119 MiB
typed len 0.15 ms 2.5-2.7 ms 6.2-12.4 ms ~0 / ~0 MiB
dynamic echo (lower + lift) 46-104 ms 722-837 ms 4.4-5.8 s +768 / +3084 MiB
typed echo 0.4-2.2 ms 5.7-7.8 ms 19-28 ms +16 / +63 MiB

Concurrency support off (NO_CONCURRENCY=1, same binary):

call 1 MiB 16 MiB 64 MiB peak RSS rise at 16 / 64 MiB
dynamic len 11.6 ms 175-189 ms 752-767 ms +15 / +48 MiB
dynamic echo 33.4 ms 497-538 ms 2.4-2.5 s +768 / +3084 MiB
typed len / echo 0.12 / 0.33-0.40 ms 2.6-3.5 / 6.0-7.7 ms 8.1-8.5 / 24-26 ms ~0 / +16, +63 MiB
  • With concurrency support on, the dynamic len (lowering only) takes 2-4x as long as with it off, and its peak RSS rises by an extra 48 B per element: the arguments are deep-copied (params.to_vec(), see below).
  • With the default hostcall fuel (128 MiB), the dynamic echo fails at 3 MiB: too much data is being copied between the host and the guest: fuel allocated for hostcalls has been exhausted. The dynamic lift is charged size_of::<Val>() per element, the typed one size_of::<u8>(), which stays within the default up to 128 MiB.
Versions and Environment

Wasmtime version or commit: 50.0.1 (features runtime, cranelift, wat, std, async, component-model, component-model-async)

Operating system: Linux 7.3 (CachyOS); rustc 1.99.1, release build

Architecture: x86_64 (AMD Ryzen 7 5700X, 8C/16T, 32 GiB RAM)

Extra Info

Most of this reasoning was run by claude itself and I cross validated it's findings.

Context: Hit this in witgraph, a runtime that wires component exports into a graph at runtime, so it cannot name TypedFunc types; the same transfer through jco is about 100x faster than through the dynamic wasmtime path.

Spoiler: word dump from llms

Where the time goes (wasmtime 50.0.1, crates/wasmtime/src/runtime/component/):

  1. The Val representation. Val::List(Vec<Val>) is defined at values.rs:81, and size_of::<Val>() is 48.
  2. Lifting. The list arm of Val::load (values.rs:261-264) calls load_list (values.rs:1021-1048), which builds the result with (0..len).map(|index| Val::load(cx, elem, &cx.memory()[...])). That allocates one Val::U8 per byte (values.rs:247). The fuel check at values.rs:1031 charges len * size_of::<Val>(). The typed path charges len * size_of::<T>() (func/typed.rs:1781), and its integer lift linear_lift_into_from_memory (func/typed.rs:995-1007) extends from a little-endian slice in one pass.
  3. Lowering. The list arms of Val::lower (values.rs:469-475) and Val::store (values.rs:615-622) call lower_list (values.rs:1163-1182). lower_list calls item.store(cx, element_type, element_ptr) once per element (values.rs:1178). Each of those calls dispatches on (InterfaceType, Val) (values.rs:579) and then runs u8::linear_lower_to_memory, which writes *cx.get(offset) = self.to_le_bytes() (func/typed.rs:934). LowerContext::get (func/options.rs:231-243) calls as_slice_mut (func/options.rs:131-133) → Instance::options_memory_mut (instance.rs:498) → options_memory_raw (instance.rs:461-477). That chain looks up the instance, its canonical options and the runtime memory definition, then rebuilds and bounds-checks the slice, once per byte. The typed path does that once per list in linear_store_list_to_memory (func/typed.rs:938-973).
  4. The argument copy. With concurrency support on, Func::call_async (func.rs:288-304) goes through start_call_concurrent (func.rs:298), as does Func::call_concurrent (concurrent/func.rs:113-122). do_start_call_concurrent then deep-copies the arguments: self.prepare_call_dynamic(store.as_context_mut(), params.to_vec()) (concurrent/func.rs:152). This is because prepare_call_dynamic takes params: Vec<Val> (concurrent/func.rs:182-186) and moves them into the lowering closure that concurrent::prepare_call stores (concurrent/func.rs:197-199). For a 16 MiB list<u8>, that is a 768 MiB allocation, a 16M-element clone, and a matching drop. By contrast, TypedFunc::call_async takes Params by value (func/typed.rs:170-188), and the non-concurrent Func::call_async path borrows params (func.rs:306-308). In a profile of our runtime, this clone accounted for about 21% of the time of a dynamic call moving a 16 MiB list.
Possible improvements.

(a) An owned-arguments variant of the concurrent and async dynamic calls. For example, Func::call_concurrent_owned(accessor, params: Vec<Val>, results: &mut [Val]), or a start_call_concurrent that accepts Vec<Val> (or impl Into<Vec<Val>>). prepare_call_dynamic already wants a Vec<Val>, so callers that already own their arguments would avoid the to_vec() copy and the doubled peak memory. call_async could gain a matching variant, or forward to it when concurrency support is on.

(b) A compact representation for lists of primitives. Some options:

  • A Val variant backed by a typed buffer. For example, Val::Bytes(Vec<u8>), or a more general Val::PrimitiveList(…) holding Vec<u8>, Vec<u32>, Vec<f32> and so on. The lift path would produce it for list<primitive>, and both it and Val::List would be accepted when lowering.
  • A smaller, non-breaking alternative: accept such a variant only when lowering, plus an opt-in (for example, on the Func or the store) to lift list<u8> into it.

Either option would make lifting and lowering a single bounds check plus a memcpy, cut host memory from 48 B per element to 1 B, and make the hostcall-fuel accounting match the typed path.

(c) Hoist the per-element memory lookup in lower_list. When the element type is a primitive (or any type whose store neither reallocates nor re-enters the guest), lower_list could take cx.as_slice_mut() once after cx.realloc(...) and write every element into that slice. It could also pass the values to the existing linear_store_list_to_memory routines. This would remove one options_memory_raw lookup and one bounds-checked slice per element. It is independent of (b) and would help every existing Val::List user.

Attached artifacts here:
Lingua principale
Rust
Stelle
18.8k
Fork
1.9k
Merge medio
22h 54m
PR unite (30g)
371

Preparare l'ambiente

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Altre issue di bytecodealliance/wasmtime

Tutte le issue di bytecodealliance/wasmtime

Issue simili

Altre issue su Rust

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.