Stream: git-wasmtime

Topic: wasmtime / issue #14752 GuestProfiler::new rebuilds the m...


view this post on Zulip Wasmtime GitHub notifications bot (Oct 14 2026 at 19:59):

andrewl6097 opened issue #14752:

Feature

GuestProfiler::new demangles 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 per Module and shared by
every later profiler.

Benefit

GuestProfiler is 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:

<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 -- 1500 and cargo run --release -- 10000.

</details>

Implementation

Cache the table on the module: a OnceLock<Option<Arc<SymbolTable>>> on
ModuleInner, behind the profiling feature, filled by module_symbols the
first time a profiler includes the module. fxprof_processed_profile already
holds a library's symbol table as an Arc<SymbolTable>, so sharing it needs no
copy.

One detail: Profile::add_lib keys libraries by value, so a LibraryInfo
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_table avoids that. In my measurements, passing the
table inside LibraryInfo made each cached new() on the 1,500-function
module take 31.7 µs, against 16.3 µs with set_lib_symbol_table.

I have a patch for this and can open a PR.

Alternatives

Memory cost: the cache keeps one symbol table alive per profiled Module for
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 empty OnceLock.

view this post on Zulip Wasmtime GitHub notifications bot (Oct 14 2026 at 20:02):

alexcrichton added the performance label to Issue #14752.

view this post on Zulip Wasmtime GitHub notifications bot (Oct 14 2026 at 20:02):

alexcrichton added the guest-profiler label to Issue #14752.

view this post on Zulip Wasmtime GitHub notifications bot (Oct 15 2026 at 21:31):

alexcrichton closed issue #14752:

Feature

GuestProfiler::new demangles 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 per Module and shared by
every later profiler.

Benefit

GuestProfiler is 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:

<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 -- 1500 and cargo run --release -- 10000.

</details>

Implementation

Cache the table on the module: a OnceLock<Option<Arc<SymbolTable>>> on
ModuleInner, behind the profiling feature, filled by module_symbols the
first time a profiler includes the module. fxprof_processed_profile already
holds a library's symbol table as an Arc<SymbolTable>, so sharing it needs no
copy.

One detail: Profile::add_lib keys libraries by value, so a LibraryInfo
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_table avoids that. In my measurements, passing the
table inside LibraryInfo made each cached new() on the 1,500-function
module take 31.7 µs, against 16.3 µs with set_lib_symbol_table.

I have a patch for this and can open a PR.

Alternatives

Memory cost: the cache keeps one symbol table alive per profiled Module for
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 empty OnceLock.


Last updated: Oct 31 2026 at 23:03 UTC