Dynamic component calls with a large `list<u8>` are 100-200x slower than `TypedFunc` and use 48 host bytes per element
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
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, ¶ms, &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, ¶ms, &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_refsandVmHWM). - Run
NO_CONCURRENCY=1 cargo run --releaseto compare withConfig::concurrency_support(false). - Remove the
store.set_hostcall_fuel(usize::MAX)line and run again to see the default hostcall fuel reject the dynamicechoat 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
echofails 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 chargedsize_of::<Val>()per element, the typed onesize_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/):
- The
Valrepresentation.Val::List(Vec<Val>)is defined atvalues.rs:81, andsize_of::<Val>()is 48. - Lifting. The list arm of
Val::load(values.rs:261-264) callsload_list(values.rs:1021-1048), which builds the result with(0..len).map(|index| Val::load(cx, elem, &cx.memory()[...])). That allocates oneVal::U8per byte (values.rs:247). The fuel check atvalues.rs:1031chargeslen * size_of::<Val>(). The typed path chargeslen * size_of::<T>()(func/typed.rs:1781), and its integer liftlinear_lift_into_from_memory(func/typed.rs:995-1007) extends from a little-endian slice in one pass. - Lowering. The list arms of
Val::lower(values.rs:469-475) andVal::store(values.rs:615-622) calllower_list(values.rs:1163-1182).lower_listcallsitem.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 runsu8::linear_lower_to_memory, which writes*cx.get(offset) = self.to_le_bytes()(func/typed.rs:934).LowerContext::get(func/options.rs:231-243) callsas_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 inlinear_store_list_to_memory(func/typed.rs:938-973). - The argument copy. With concurrency support on,
Func::call_async(func.rs:288-304) goes throughstart_call_concurrent(func.rs:298), as doesFunc::call_concurrent(concurrent/func.rs:113-122).do_start_call_concurrentthen deep-copies the arguments:self.prepare_call_dynamic(store.as_context_mut(), params.to_vec())(concurrent/func.rs:152). This is becauseprepare_call_dynamictakesparams: Vec<Val>(concurrent/func.rs:182-186) and moves them into the lowering closure thatconcurrent::prepare_callstores (concurrent/func.rs:197-199). For a 16 MiBlist<u8>, that is a 768 MiB allocation, a 16M-element clone, and a matching drop. By contrast,TypedFunc::call_asynctakesParamsby value (func/typed.rs:170-188), and the non-concurrentFunc::call_asyncpath borrowsparams(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
Valvariant backed by a typed buffer. For example,Val::Bytes(Vec<u8>), or a more generalVal::PrimitiveList(…)holdingVec<u8>,Vec<u32>,Vec<f32>and so on. The lift path would produce it forlist<primitive>, and both it andVal::Listwould be accepted when lowering. - A smaller, non-breaking alternative: accept such a variant only when lowering, plus an opt-in (for example, on the
Funcor the store) to liftlist<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
- Nessun Dockerfile né file Docker Compose
- Ha un modello di pull request
- Leggi la guida per i contributori
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Altre issue di bytecodealliance/wasmtime
-
bug wasm-proposal:exceptions wasmtime:debugging
Difficoltà 2/5 1-3 ore Idoneità per principianti 76/100
bytecodealliance/wasmtime#14102 ·
I maintainer di solito rispondono entro 1 giorno
-
wasm-proposal:gc
Difficoltà 2/5 1-3 ore Idoneità per principianti 62/100
bytecodealliance/wasmtime#13808 · 2 commenti ·
I maintainer di solito rispondono entro 1 giorno
-
bug wasmtime:c-api wasmtime:docs
Difficoltà 3/5 1-2 giorni Idoneità per principianti 68/100
bytecodealliance/wasmtime#15088 · 1 commento ·
I maintainer di solito rispondono entro 1 giorno
-
Resolve builtins transitivelyForse già presa @dyslexicsteak l’ha presa 3 giorni fa. Aperta
Difficoltà 4/5 3-5 giorni Idoneità per principianti 20/100
bytecodealliance/wasmtime#15086 ·
I maintainer di solito rispondono entro 1 giorno
-
wasm-proposal:component-model wasm-proposal:gc
Difficoltà 3/5 1-2 giorni Idoneità per principianti 52/100
bytecodealliance/wasmtime#15081 ·
I maintainer di solito rispondono entro 1 giorno
Tutte le issue di bytecodealliance/wasmtime
Issue simili
-
example
Difficoltà 2/5 1-3 ore Idoneità per principianti 72/100
rust-lang-nursery/rust-cookbook#954 ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 1/5 1-3 ore Idoneità per principianti 70/100
OpenZeppelin/openzeppelin-relayer#967 ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 62/100
I maintainer di solito rispondono entro 1 giorno
-
DecisionOutcome::label() / n_dosed(): engine-side mapping so downstream needs no wildcard armAperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 72/100
I maintainer di solito rispondono entro 1 giorno
-
src/runs.rs:1987: a missing --source-run-id now answers 400 "Source run ... does not exist", not 404Apertanightly-audit
Difficoltà 2/5 1-3 ore Idoneità per principianti 62/100
antithesishq/snouty#602 ·
I maintainer di solito rispondono entro 1 giorno