andrewl6097 opened issue #14752:
Feature
GuestProfiler::newdemangles every defined function's name and builds a fresh
symbol table for each module it is given, every time it is called. The table
depends only on the module, so it could be built once perModuleand shared by
every later profiler.Benefit
GuestProfileris documented as suitable for profiling individual guests in a
multi-tenant embedder, which suggests one profiler per request. Today each of
those profilers pays a setup cost that grows with the size of the module, and
for large Rust modules it becomes a large share of the request:
On a 1.7 MB Rust module with about 1,500 defined functions,
new()takes about
2.4 ms (measured on wasmtime 43). Demangling the names takes about 2.1 ms of that, and everything in
new()that does not depend on the module takes under 1 µs. The guest
invocation we were profiling ran for about 44 ms, so setup cost more than
sampling every millisecond for the whole invocation (about 0.2 ms). And this was a pathological case; many of our guests run for far shorter time periods so this overhead would be even more painful.The program below generates modules with N Rust-mangled function names and
timesnew(). On wasmtime 49.0.3, x86_64 Linux, release build:
Functions GuestProfiler::new, median of 10001,500 681 µs 10,000 4.64 ms (The generated names are shorter than real rustc output, which is why the
1,500-function case is cheaper here than in the real module above.)<details><summary>Reproduction</summary>
# Cargo.toml [package] name = "guest-profiler-new-cost" version = "0.0.0" edition = "2024" publish = false [dependencies] wasmtime = "49"// src/main.rs //! Times `GuestProfiler::new` on a module with many Rust-mangled function names. use std::fmt::Write; use std::time::{Duration, Instant}; use wasmtime::{Engine, GuestProfiler, Module}; fn main() -> wasmtime::Result<()> { let funcs: usize = std::env::args().nth(1).map_or(1500, |s| s.parse().unwrap()); // Legacy-mangled names shaped like rustc output: crate::module_N::function_N::{hash}. let mut wat = String::from("(module\n"); for i in 0..funcs { let (m, f) = (format!("module_{i}"), format!("function_{i}")); writeln!( wat, " (func $_ZN10some_crate{}{}{}{}17h{:016x}E)", m.len(), m, f.len(), f, i as u64 * 0x9e37_79b9_7f4a_7c15 )?; } wat.push(')'); let engine = Engine::default(); let module = Module::new(&engine, &wat)?; let new = || { GuestProfiler::new( &engine, "bench", Duration::from_millis(1), [("bench.wasm".to_string(), module.clone())], ) .unwrap() }; let t = Instant::now(); drop(new()); let first = t.elapsed(); let mut times: Vec<Duration> = (0..1000) .map(|_| { let t = Instant::now(); drop(new()); t.elapsed() }) .collect(); times.sort(); println!( "{funcs} functions: first new() {first:?}, later new() median {:?}, p99 {:?}", times[500], times[990] ); Ok(()) }Run with
cargo run --release -- 1500andcargo run --release -- 10000.</details>
Implementation
Cache the table on the module: a
OnceLock<Option<Arc<SymbolTable>>>on
ModuleInner, behind theprofilingfeature, filled bymodule_symbolsthe
first time a profiler includes the module.fxprof_processed_profilealready
holds a library's symbol table as anArc<SymbolTable>, so sharing it needs no
copy.One detail:
Profile::add_libkeys libraries by value, so aLibraryInfo
carrying the table hashes every symbol name on each call. Adding the library
without a table and then attaching the shared table with
Profile::set_lib_symbol_tableavoids that. In my measurements, passing the
table insideLibraryInfomade each cachednew()on the 1,500-function
module take 31.7 µs, against 16.3 µs withset_lib_symbol_table.I have a patch for this and can open a PR.
Alternatives
Hold the table in a separate, reusable profiler object. This is close to
the "standalone profiler struct where you acquire thread-specific profilers"
idea from the review of #6282. It would mean new public API, while caching on
Modulechanges none.Pass a prebuilt symbol table into
GuestProfiler::new. This also needs
new public API, and the embedder would have to keep the table alongside each
module.Have the embedder demangle names at build time. Rewriting the name
section with already-demangled names cuts our 2.4 ms to about 0.65 ms,
because both demanglers then fail fast. It needs a build step in every guest
toolchain and still costs time on every profile.Memory cost: the cache keeps one symbol table alive per profiled
Modulefor
the module's lifetime. That is roughly the size of the demangled names, about
240 KB of strings for the 1,500-function module. Modules that are never
profiled pay nothing beyond an emptyOnceLock.
alexcrichton added the performance label to Issue #14752.
alexcrichton added the guest-profiler label to Issue #14752.
alexcrichton closed issue #14752:
Feature
GuestProfiler::newdemangles every defined function's name and builds a fresh
symbol table for each module it is given, every time it is called. The table
depends only on the module, so it could be built once perModuleand shared by
every later profiler.Benefit
GuestProfileris documented as suitable for profiling individual guests in a
multi-tenant embedder, which suggests one profiler per request. Today each of
those profilers pays a setup cost that grows with the size of the module, and
for large Rust modules it becomes a large share of the request:
On a 1.7 MB Rust module with about 1,500 defined functions,
new()takes about
2.4 ms (measured on wasmtime 43). Demangling the names takes about 2.1 ms of that, and everything in
new()that does not depend on the module takes under 1 µs. The guest
invocation we were profiling ran for about 44 ms, so setup cost more than
sampling every millisecond for the whole invocation (about 0.2 ms). And this was a pathological case; many of our guests run for far shorter time periods so this overhead would be even more painful.The program below generates modules with N Rust-mangled function names and
timesnew(). On wasmtime 49.0.3, x86_64 Linux, release build:
Functions GuestProfiler::new, median of 10001,500 681 µs 10,000 4.64 ms (The generated names are shorter than real rustc output, which is why the
1,500-function case is cheaper here than in the real module above.)<details><summary>Reproduction</summary>
# Cargo.toml [package] name = "guest-profiler-new-cost" version = "0.0.0" edition = "2024" publish = false [dependencies] wasmtime = "49"// src/main.rs //! Times `GuestProfiler::new` on a module with many Rust-mangled function names. use std::fmt::Write; use std::time::{Duration, Instant}; use wasmtime::{Engine, GuestProfiler, Module}; fn main() -> wasmtime::Result<()> { let funcs: usize = std::env::args().nth(1).map_or(1500, |s| s.parse().unwrap()); // Legacy-mangled names shaped like rustc output: crate::module_N::function_N::{hash}. let mut wat = String::from("(module\n"); for i in 0..funcs { let (m, f) = (format!("module_{i}"), format!("function_{i}")); writeln!( wat, " (func $_ZN10some_crate{}{}{}{}17h{:016x}E)", m.len(), m, f.len(), f, i as u64 * 0x9e37_79b9_7f4a_7c15 )?; } wat.push(')'); let engine = Engine::default(); let module = Module::new(&engine, &wat)?; let new = || { GuestProfiler::new( &engine, "bench", Duration::from_millis(1), [("bench.wasm".to_string(), module.clone())], ) .unwrap() }; let t = Instant::now(); drop(new()); let first = t.elapsed(); let mut times: Vec<Duration> = (0..1000) .map(|_| { let t = Instant::now(); drop(new()); t.elapsed() }) .collect(); times.sort(); println!( "{funcs} functions: first new() {first:?}, later new() median {:?}, p99 {:?}", times[500], times[990] ); Ok(()) }Run with
cargo run --release -- 1500andcargo run --release -- 10000.</details>
Implementation
Cache the table on the module: a
OnceLock<Option<Arc<SymbolTable>>>on
ModuleInner, behind theprofilingfeature, filled bymodule_symbolsthe
first time a profiler includes the module.fxprof_processed_profilealready
holds a library's symbol table as anArc<SymbolTable>, so sharing it needs no
copy.One detail:
Profile::add_libkeys libraries by value, so aLibraryInfo
carrying the table hashes every symbol name on each call. Adding the library
without a table and then attaching the shared table with
Profile::set_lib_symbol_tableavoids that. In my measurements, passing the
table insideLibraryInfomade each cachednew()on the 1,500-function
module take 31.7 µs, against 16.3 µs withset_lib_symbol_table.I have a patch for this and can open a PR.
Alternatives
Hold the table in a separate, reusable profiler object. This is close to
the "standalone profiler struct where you acquire thread-specific profilers"
idea from the review of #6282. It would mean new public API, while caching on
Modulechanges none.Pass a prebuilt symbol table into
GuestProfiler::new. This also needs
new public API, and the embedder would have to keep the table alongside each
module.Have the embedder demangle names at build time. Rewriting the name
section with already-demangled names cuts our 2.4 ms to about 0.65 ms,
because both demanglers then fail fast. It needs a build step in every guest
toolchain and still costs time on every profile.Memory cost: the cache keeps one symbol table alive per profiled
Modulefor
the module's lifetime. That is roughly the size of the demangled names, about
240 KB of strings for the 1,500-function module. Modules that are never
profiled pay nothing beyond an emptyOnceLock.
Last updated: Oct 31 2026 at 23:03 UTC