The compile is a UI load's biggest removable cost and the only half of it that can be kept — running the chunks is what an addon *is*. Measured through the addon harness's `--together` load of the player's own 91 addons, three passes: ``` no cache 4,788 chunks compiled 3,324 ms cold 1,592 compiled, 3,196 from cache 1,482 ms warm 0 compiled, 4,788 from cache 471 ms (+24 ms of keys) ``` **About 940 ms off every login and every `/reload`**, and the first load after an addon changes pays only for the files that changed. `benilla-config/cache/lua/`, one file per chunk, keyed by a hash of the chunk's own bytes and its name. Both chunk paths go through it — `run_chunk_named` and the XML loader's own runner, which is how the biggest files in the corpus arrive (pfQuest's database is 33 MiB of `<Include>`d Lua). It cannot go wrong quietly. A key is a property of the bytes, so an edited file is a different entry and a stale one is never asked for; an entry that is truncated, corrupt or from another Lua build fails `lua_undump`'s header check and the source is parsed instead, so a bad entry costs the parse it meant to save. The dump keeps its debug info — `debugstack` is an addon-facing verb and a stripped chunk would lie about its own lines — which the test asserts by raising from a cached chunk and demanding its file and line back. The folder is capped at 512 MiB (one load's worth is 159 MiB) and emptied wholesale when it is over: a cache that needed an eviction policy would need bookkeeping that costs more than it saves. The first build of this looked like it worked and saved nothing, because `ahash::AHasher::default()` is seeded per process: every session hashed the same file to a different name, found nothing and rewrote the whole cache (4,776 entries, 477 MiB, a steady 1,592 misses a run). Fixed seeds, and the instrument that caught it — `benilla_ui::script::chunk_cache_line()`, hits and misses with the time each spent — stays, because "the cache is installed" and "the cache is working" turned out to be different claims. Verified: the harness's verdicts over all 91 addons are **identical** with the cache and with `WOW_LUA_CACHE=0`, the full gate chain is green, and the `WOW_DATA` failure sets are unchanged (benilla-app 270, benilla-formats 80). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
161 lines
5.8 KiB
Rust
161 lines
5.8 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} {:>9} {:>6} file",
|
|
"MiB", "parse ms", "exec ms", "dump ms", "bc ms", "bc+dbg", "MiB"
|
|
);
|
|
let mut tot = [0.0f64; 6]; // MiB, parse, exec, dump, bytecode-compile, with debug info
|
|
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);
|
|
// The cache cannot strip: `debugstack` is an addon-facing verb, so the kept-debug-info
|
|
// dump is the one that matters. Both are timed, because the difference decides whether a
|
|
// cache can pay for itself at all.
|
|
let bc_full = f.dump(false);
|
|
drop(lua);
|
|
|
|
let lua = vm();
|
|
let t = Instant::now();
|
|
lua.load(&bc_full[..])
|
|
.set_name(path.as_str())
|
|
.set_mode(mlua::ChunkMode::Binary)
|
|
.into_function()?;
|
|
let bcfull_load = ms(t);
|
|
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} {bcfull_load:9.1} {:7.2} {path}{}",
|
|
bc_full.len() as f64 / (1024.0 * 1024.0),
|
|
if ran { "" } else { " [did not run]" }
|
|
);
|
|
for (slot, v) in tot
|
|
.iter_mut()
|
|
.zip([fmib, parse, exec, dump, bcload, bcfull_load])
|
|
{
|
|
*slot += v;
|
|
}
|
|
}
|
|
let [mib_sum, parse, exec, dump, bcload, bcfull] = tot;
|
|
println!(
|
|
"{mib_sum:8.2} {parse:9.1} {exec:9.1} {dump:9.1} {bcload:9.1} {bcfull:9.1} — a VM per file, {} files",
|
|
files.len()
|
|
);
|
|
println!(
|
|
" parse+exec {:.0} ms; from a STRIPPED cache {:.0} ms; from one that KEEPS debug info \
|
|
(what `debugstack` needs) {:.0} ms\n",
|
|
parse + exec,
|
|
bcload + exec,
|
|
bcfull + 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(())
|
|
}
|