Files
benilla-octo/crates/benilla-ui/examples/chunkbench.rs
T
gromandClaude Opus 5 3e616e5cb3 reload: one VM and no flush storm, 615 ms off every rebuild
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
2026-10-18 14:59:22 -06:00

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(())
}