Two costs read straight out of the player's own `WOW_UI_HANDLERS=1` session log (2026-10-18 20:33), where a `/reload` blocks the main thread for 7.84 s. **The saved-variables flush, 305 ms.** The reload's first act is to write every loaded addon's saved variables, one file each, and `write_atomic_bytes` ends each with `sync_all()` — a disk round trip per file, 60 of them, inside the one blocked frame. Measured on this project's own btrfs home: **327 ms flushed, 6 ms not**, which is the +27→332 ms the log spends between `ReloadUI — ending the UI session` and the last `wrote …/saved/*.lua`. `write_atomic_bytes_bulk` keeps the temp file and the rename (so a file is whole or absent) and gives up only a power loss in the seconds after; the reference writes these with plain buffered CRT writes and a `.bak` rotation whose result it discards, so this is still the stronger of the two. **A whole VM built and thrown away, 310 ms.** `end_ui_session` installs a fresh boot VM, which is right for the roots that stop there — the character screen runs on it — but `run_pending_reload` then calls `load_ingame_ui_on_world_entry`, which mints its own in `mint_entry_vm`. So every reload ran `UiScript::new`, `GlobalStrings.lua`, the emote table and the font registry twice and let the first one go without running a line: the log prints `GlobalStrings loaded` at +350 ms and again at +662 ms. `end_session` now takes what to leave behind, and the reload leaves the dying VM until the mint replaces it — safe because the two calls are one exclusive system with nothing in between, the old VM is still dropped before any file loads, and `fold_dying_vm_cvars` drains what it folds, so the mint's second fold of the same VM finds nothing. Only when the rebuild is certain to mint: with no UI wanted (a capture) the load returns early and the world would be left with no VM at all. `chunkbench` is the instrument the next item needs, and the one that priced this one's neighbours: it runs a file list three ways — a VM per file, one VM with the stock collector, one with it stopped — so parse, execute and the GC's share are separate numbers. Over his enabled addon set (1,471 files, 70 MiB) the parse is 703 ms against 74 ms to compile the same chunks from bytecode, which is what a bytecode cache would be worth; the collector is not the problem (829 ms against 731 ms stopped), and pfQuest's 23 database files are 417 ms and 110 MiB of heap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
145 lines
5.1 KiB
Rust
145 lines
5.1 KiB
Rust
//! Where a big addon data file's load cost goes, and what would take it away.
|
|
//!
|
|
//! `cargo run --release -p benilla-ui --example chunkbench -- <file.lua>…` runs three passes over
|
|
//! the same files and prints each:
|
|
//!
|
|
//! 1. **fresh VM per file** — the floor: parse, execute, dump to 5.1 bytecode, re-compile that
|
|
//! bytecode. A bytecode cache can only ever remove the *parse* column, so this says whether
|
|
//! one is worth building.
|
|
//! 2. **one VM, stock GC** — the real shape of a load, where every later file allocates against
|
|
//! the heap the earlier ones left live and Lua's incremental collector walks all of it.
|
|
//! 3. **one VM, collector stopped** for the whole load and collected once at the end — the same
|
|
//! work with the GC's share of it named.
|
|
//!
|
|
//! Globals autovivify at every depth (`pfDB["items"]["data"] = {…}` reads two levels before it
|
|
//! writes), so a database file runs outside its addon.
|
|
|
|
use std::time::Instant;
|
|
|
|
fn ms(t: Instant) -> f64 {
|
|
t.elapsed().as_secs_f64() * 1000.0
|
|
}
|
|
|
|
/// A VM whose global reads mint tables on demand, so an addon's data file needs no addon.
|
|
fn vm() -> mlua::Lua {
|
|
let lua = mlua::Lua::new();
|
|
lua.load(
|
|
r#"local mt = {}
|
|
mt.__index = function(t, k)
|
|
local v = setmetatable({}, mt); rawset(t, k, v); return v
|
|
end
|
|
setmetatable(_G, mt)"#,
|
|
)
|
|
.exec()
|
|
.expect("the autovivify metatable");
|
|
lua
|
|
}
|
|
|
|
fn main() -> mlua::Result<()> {
|
|
let files: Vec<String> = std::env::args().skip(1).collect();
|
|
if files.is_empty() {
|
|
eprintln!("usage: chunkbench <file.lua>…");
|
|
return Ok(());
|
|
}
|
|
let sources: Vec<(String, Vec<u8>)> = files
|
|
.iter()
|
|
.map(|p| (p.clone(), std::fs::read(p).expect("the file")))
|
|
.collect();
|
|
let mib: f64 = sources.iter().map(|(_, s)| s.len() as f64).sum::<f64>() / (1024.0 * 1024.0);
|
|
|
|
// 1 · a VM per file.
|
|
println!(
|
|
"{:>8} {:>9} {:>9} {:>9} {:>9} file",
|
|
"MiB", "parse ms", "exec ms", "dump ms", "bc ms"
|
|
);
|
|
let mut tot = [0.0f64; 5]; // MiB, parse, exec, dump, bytecode-compile
|
|
for (path, src) in &sources {
|
|
let lua = vm();
|
|
let t = Instant::now();
|
|
// A file the client never compiles as a chunk (a `#!` tool script, say) is counted and
|
|
// skipped rather than ending the run.
|
|
let Ok(f) = lua.load(&src[..]).set_name(path.as_str()).into_function() else {
|
|
println!(
|
|
"{:8.2} {:>49} {path}",
|
|
src.len() as f64 / (1024.0 * 1024.0),
|
|
"[did not parse]"
|
|
);
|
|
continue;
|
|
};
|
|
let parse = ms(t);
|
|
// An addon file outside its addon often cannot run (it calls what the client installs);
|
|
// the parse column is measured either way, and a file that raised is marked.
|
|
let t = Instant::now();
|
|
let ran = f.call::<()>(()).is_ok();
|
|
let exec = if ran { ms(t) } else { 0.0 };
|
|
let t = Instant::now();
|
|
let bc = f.dump(true);
|
|
let dump = ms(t);
|
|
drop(lua);
|
|
|
|
let lua = vm();
|
|
let t = Instant::now();
|
|
lua.load(&bc[..])
|
|
.set_name(path.as_str())
|
|
.set_mode(mlua::ChunkMode::Binary)
|
|
.into_function()?;
|
|
let bcload = ms(t);
|
|
drop(lua);
|
|
|
|
let fmib = src.len() as f64 / (1024.0 * 1024.0);
|
|
println!(
|
|
"{fmib:8.2} {parse:9.1} {exec:9.1} {dump:9.1} {bcload:9.1} {path}{}",
|
|
if ran { "" } else { " [did not run]" }
|
|
);
|
|
for (slot, v) in tot.iter_mut().zip([fmib, parse, exec, dump, bcload]) {
|
|
*slot += v;
|
|
}
|
|
}
|
|
let [mib_sum, parse, exec, dump, bcload] = tot;
|
|
println!(
|
|
"{mib_sum:8.2} {parse:9.1} {exec:9.1} {dump:9.1} {bcload:9.1} — a VM per file, {} files",
|
|
files.len()
|
|
);
|
|
println!(
|
|
" parse+exec {:.0} ms; with the parse served from a bytecode cache {:.0} ms\n",
|
|
parse + exec,
|
|
bcload + exec
|
|
);
|
|
|
|
// 2 and 3 · one VM for the whole set, as a load does it.
|
|
for stop_gc in [false, true] {
|
|
let lua = vm();
|
|
if stop_gc {
|
|
lua.gc_stop();
|
|
}
|
|
let before = lua.used_memory();
|
|
let t0 = Instant::now();
|
|
let mut worst = (0.0f64, "");
|
|
for (path, src) in &sources {
|
|
let t = Instant::now();
|
|
if let Ok(f) = lua.load(&src[..]).set_name(path.as_str()).into_function() {
|
|
let _ = f.call::<()>(());
|
|
}
|
|
let each = ms(t);
|
|
if each > worst.0 {
|
|
worst = (each, path);
|
|
}
|
|
}
|
|
let load = ms(t0);
|
|
let t = Instant::now();
|
|
lua.gc_collect()?;
|
|
let collect = ms(t);
|
|
let heap = (lua.used_memory() - before) as f64 / (1024.0 * 1024.0);
|
|
println!(
|
|
"one VM, GC {:7}: load {load:7.0} ms final collect {collect:6.0} ms \
|
|
heap {heap:6.1} MiB worst file {:.0} ms ({})",
|
|
if stop_gc { "stopped" } else { "stock" },
|
|
worst.0,
|
|
worst.1,
|
|
);
|
|
drop(lua);
|
|
}
|
|
println!("{mib:.1} MiB of source in all");
|
|
Ok(())
|
|
}
|