Files
benilla-octo/crates/benilla-ui/examples/chunkbench.rs
T
gromandClaude Opus 5 d5ddaa1286 lua: a compiled chunk is kept, so a load parses only what changed
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
2026-10-18 19:33:11 -06:00

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