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
This commit is contained in:
2026-10-18 19:33:11 -06:00
co-authored by Claude Opus 5
parent 0fe0a99d8a
commit d5ddaa1286
12 changed files with 408 additions and 21 deletions
Generated
+1
View File
@@ -505,6 +505,7 @@ dependencies = [
name = "benilla-app"
version = "0.2.0"
dependencies = [
"ahash",
"anyhow",
"arboard",
"assert_no_alloc",
+2
View File
@@ -22,6 +22,8 @@ anyhow.workspace = true
# The EditBox clipboard, on the main thread as NSPasteboard requires; bevy_egui's 3.x.
arboard = "3"
benilla-ui = { path = "../benilla-ui" }
# The chunk cache's key: a load hashes 70 MiB of addon Lua, so the hash has to be the cheap part.
ahash = "0.8.12"
bevy.workspace = true
wgpu.workspace = true
bevy_egui = { version = "0.39", optional = true }
@@ -197,6 +197,14 @@ fn main() {
};
let rest: Vec<String> = args.collect();
// The same compiled-chunk cache the client installs, so a harness run measures what a player's
// load costs; `WOW_LUA_CACHE=0` opts out, which is how the cache's own win is measured.
if std::env::var("WOW_LUA_CACHE").as_deref() != Ok("0") {
benilla_ui::script::install_chunk_cache(Box::new(
benilla_app::ui_script::chunk_cache::LuaChunkCache::open(),
));
}
// `--surface` needs no corpus, so it is handled before the root is used.
if root == "--surface" || rest.iter().any(|a| a == "--surface") {
let dump_path = rest
@@ -244,6 +252,7 @@ fn main() {
// `WOW_UI_VERBS=1` turns this run into the load's per-verb bill: the whole folder in one
// VM is the closest thing to a login burst that needs no server.
benilla_ui::script::verb_prof::log_report("the whole folder in one VM", 24);
eprintln!("[{}]", benilla_ui::script::chunk_cache_line());
if rows.is_empty() {
eprintln!(
"no addons under {} — is that an AddOns folder?",
+7 -1
View File
@@ -188,7 +188,7 @@ mod ui_quest_log;
mod ui_quest_share;
mod ui_reputation;
mod ui_saved;
mod ui_script;
pub mod ui_script;
mod ui_session;
mod ui_shapeshift;
mod ui_social;
@@ -408,6 +408,12 @@ pub fn run(build: BuildId) -> AppExit {
start: run_mode::start_state(),
});
// The compiled-chunk cache, before anything loads a chunk: a UI load's biggest removable cost
// is the Lua compile, and a compile is the same answer every time (`ui_script::chunk_cache`).
benilla_ui::script::install_chunk_cache(
Box::new(ui_script::chunk_cache::LuaChunkCache::open()),
);
// The session's own log file, opened once the subscriber exists so the backlog replays into
// it. A player who sees something wrong for two seconds and quits can only be answered from
// what the client said at the time, and until now that lived in the terminal it was launched
+6
View File
@@ -209,6 +209,12 @@ pub(crate) fn legacy_item_cache_path(realm: &str) -> Option<PathBuf> {
)
}
/// `benilla-config/cache/lua/`: compiled Lua chunks ([`crate::ui_script::chunk_cache`]).
/// Disposable — deleting it costs one slow UI load.
pub(crate) fn lua_cache_dir() -> Option<PathBuf> {
home().map(|h| h.join("cache").join("lua"))
}
/// `benilla-config/Logs/`: `WoWChatLog.txt` and `WoWCombatLog.txt`, the reference's names.
pub(crate) fn logs_dir() -> Option<PathBuf> {
home().map(|h| h.join("Logs"))
@@ -0,0 +1,110 @@
//! The compiled-chunk cache: `benilla-config/cache/lua/`, one file per chunk.
//!
//! A UI load's biggest removable cost is the Lua compile. Over the player's enabled addons — 1,471
//! files, 70 MiB — **parsing is 703 ms and loading the same chunks as 5.1 bytecode is 74 ms**.
//! Running them is the other half of the load and is not cacheable: the tables they build are what
//! an addon *is*. So this keeps the compile and nothing else.
//!
//! **Why it cannot go wrong quietly.** The key is a hash of the chunk's own bytes and its name, so
//! an edited file is a different entry and a stale one is simply never asked for. A file that is
//! asked for and is truncated, corrupt, or from another Lua build fails `lua_undump`'s header
//! check, and [`UiScript::run_chunk_cached`](benilla_ui::script::UiScript::run_chunk_cached) parses
//! the source instead — a bad entry costs the parse it meant to save, never a wrong chunk.
//!
//! The folder is disposable: deleting it costs one slow load. It is capped at [`CAP_BYTES`],
//! checked once per session, and emptied wholesale when it is over — a cache that needs an
//! eviction policy would need bookkeeping that costs more than it saves.
use bevy::prelude::info;
use std::hash::{BuildHasher, Hash, Hasher};
use std::path::PathBuf;
use benilla_ui::script::ChunkCache;
/// How much compiled Lua the folder may hold before a session empties it: the player's own set
/// dumps to about 85 MiB, so this is a few interface generations' worth.
pub const CAP_BYTES: u64 = 512 * 1024 * 1024;
/// The on-disk cache, or a no-op when there is no state folder (a capture run).
pub struct LuaChunkCache {
dir: Option<PathBuf>,
}
impl LuaChunkCache {
/// Open (and if it is over [`CAP_BYTES`], empty) the cache folder.
pub fn open() -> LuaChunkCache {
let Some(dir) = crate::local_state::lua_cache_dir() else {
return LuaChunkCache { dir: None };
};
if let Ok(size) = folder_bytes(&dir) {
if size > CAP_BYTES {
info!(
"lua cache: {} MiB over the {} MiB cap — emptied",
size / (1024 * 1024),
CAP_BYTES / (1024 * 1024)
);
let _ = std::fs::remove_dir_all(&dir);
}
}
if std::fs::create_dir_all(&dir).is_err() {
return LuaChunkCache { dir: None };
}
LuaChunkCache { dir: Some(dir) }
}
fn path(&self, key: &str) -> Option<PathBuf> {
self.dir.as_ref().map(|d| d.join(format!("{key}.luac")))
}
}
/// The folder's size, files only, one level deep: the cache writes no subdirectories.
fn folder_bytes(dir: &std::path::Path) -> std::io::Result<u64> {
let mut total = 0;
for entry in std::fs::read_dir(dir)? {
total += entry?.metadata()?.len();
}
Ok(total)
}
impl ChunkCache for LuaChunkCache {
/// The chunk's bytes and its name. The name is in the dump (it is what a traceback prints),
/// so two files with the same text under different names are different entries.
fn key(&self, source: &[u8], name: &str) -> String {
// Fixed seeds, not `AHasher::default()`: that one is seeded per process, so every session
// would hash the same file to a different name, find nothing, and rewrite the whole cache
// — which is exactly what the first build of this did (4,776 entries, 477 MiB, and a
// steady 1,592 misses a run). A cache key has to be a property of the bytes alone.
let mut h = ahash::RandomState::with_seeds(
0x6265_6e69_6c6c_6100,
0x6c75_615f_6368_756e,
0x6b5f_6361_6368_6531,
0x0000_0000_0000_0001,
)
.build_hasher();
source.hash(&mut h);
name.hash(&mut h);
format!("{:016x}", h.finish())
}
fn get(&self, key: &str) -> Option<Vec<u8>> {
std::fs::read(self.path(key)?).ok()
}
fn put(&self, key: &str, bytecode: &[u8]) {
let Some(path) = self.path(key) else {
return;
};
// Written through a temp file in the same folder: a half-written entry that a crash left
// behind would be read back whole by its hash name and only then fail its header check.
let tmp = path.with_extension("tmp");
if std::fs::write(&tmp, bytecode).is_ok() {
let _ = std::fs::rename(&tmp, &path);
}
}
fn forget(&self, key: &str) {
if let Some(path) = self.path(key) {
let _ = std::fs::remove_file(path);
}
}
}
+1
View File
@@ -13,6 +13,7 @@ use benilla_world::schedule::WorldStage;
/// The addon folder: discovery, manifests, the enable file and the load walk.
pub(crate) mod addons;
pub mod chunk_cache;
mod content;
pub(crate) mod extract;
mod flash_trace;
+25 -9
View File
@@ -49,10 +49,10 @@ fn main() -> mlua::Result<()> {
// 1 · a VM per file.
println!(
"{:>8} {:>9} {:>9} {:>9} {:>9} file",
"MiB", "parse ms", "exec ms", "dump ms", "bc ms"
"{:>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; 5]; // MiB, parse, exec, dump, bytecode-compile
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();
@@ -75,10 +75,20 @@ fn main() -> mlua::Result<()> {
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)
@@ -88,22 +98,28 @@ fn main() -> mlua::Result<()> {
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}{}",
"{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]) {
for (slot, v) in tot
.iter_mut()
.zip([fmib, parse, exec, dump, bcload, bcfull_load])
{
*slot += v;
}
}
let [mib_sum, parse, exec, dump, bcload] = tot;
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} — a VM per file, {} files",
"{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; with the parse served from a bytecode cache {:.0} ms\n",
" 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
bcload + exec,
bcfull + exec
);
// 2 and 3 · one VM for the whole set, as a load does it.
+3 -5
View File
@@ -261,11 +261,9 @@ impl Loader<'_> {
/// Run a chunk under a complete chunk name, for a producer whose name is not a path.
fn run_named(&self, chunk: &[u8], name: &str) -> mlua::Result<()> {
self.lua
.load(chunk)
.set_name(name)
.set_mode(mlua::ChunkMode::Text)
.exec()
// Through the chunk cache, like every other file chunk: an XML `<Include>` of a `.lua` is
// how the biggest data files in the corpus load (pfQuest's database is 33 MiB of them).
crate::script::run_source_cached(self.lua, chunk, name)
}
fn model(&self) -> mlua::AppDataRefMut<'_, crate::script::Model> {
+125 -6
View File
@@ -815,13 +815,10 @@ impl UiScript {
self.run_chunk_named(chunk, "(chunk)")
}
/// Run a chunk under the name the 1.12 client gives it ([`addon_chunk_name`] for addon files).
/// Run a chunk under the name the 1.12 client gives it ([`addon_chunk_name`] for addon files),
/// through the installed [`ChunkCache`] when there is one ([`run_source_cached`]).
pub fn run_chunk_named(&self, chunk: &[u8], name: &str) -> mlua::Result<()> {
self.lua
.load(crate::source::chunk(chunk))
.set_name(name)
.set_mode(mlua::ChunkMode::Text)
.exec()
run_source_cached(&self.lua, crate::source::chunk(chunk), name)
}
/// Load and evaluate a Lua chunk, returning its result: for tests and one-shot queries.
@@ -1411,3 +1408,125 @@ impl UiScript {
#[cfg(test)]
mod tests;
/// Where the compiled form of a chunk is kept between sessions. The VM crate has no business
/// knowing where a player's files live, so the host implements this and installs it once
/// ([`install_chunk_cache`]); with none installed every chunk is parsed, as before.
///
/// `key` is the cache's own business: it sees the source bytes and the chunk name (which is in the
/// dump, so two files with the same text and different names are different entries) and returns
/// whatever it wants to look up by. `get` returning stale or foreign bytes is not an error — the
/// loader checks the dump's header and the source is parsed instead.
pub trait ChunkCache: Send + Sync {
fn key(&self, source: &[u8], name: &str) -> String;
fn get(&self, key: &str) -> Option<Vec<u8>>;
fn put(&self, key: &str, bytecode: &[u8]);
/// Drop an entry the loader refused.
fn forget(&self, key: &str);
}
static CHUNK_CACHE: std::sync::OnceLock<Box<dyn ChunkCache>> = std::sync::OnceLock::new();
/// Install the process's chunk cache. Once; a second call is ignored, so a test that installs one
/// cannot change another's answers.
pub fn install_chunk_cache(cache: Box<dyn ChunkCache>) {
let _ = CHUNK_CACHE.set(cache);
}
/// Compile and run `source` under `name`, through the installed cache when there is one.
///
/// The compile is the expensive half of a UI load and the only half that can be kept: measured
/// over the player's enabled addons (1,471 files, 70 MiB of Lua), **parsing is 703 ms and loading
/// the same chunks as 5.1 bytecode is 74 ms**. Running them is the other half — the tables they
/// build are what an addon is — and is not cacheable.
///
/// Safe by construction rather than by bookkeeping: an entry that is stale, truncated or from
/// another Lua build fails `lua_undump`'s header check and the source is parsed, so the worst a
/// bad entry costs is the parse it was meant to save. The dump keeps its debug info, because
/// `debugstack` is an addon-facing verb and a stripped chunk would lie about its own lines.
pub(crate) fn run_source_cached(lua: &mlua::Lua, source: &[u8], name: &str) -> mlua::Result<()> {
let Some(cache) = CHUNK_CACHE.get() else {
let t = std::time::Instant::now();
let f = lua
.load(source)
.set_name(name)
.set_mode(mlua::ChunkMode::Text)
.into_function();
chunk_stats().miss(t.elapsed().as_nanos(), 0);
return f?.call::<()>(());
};
let t = std::time::Instant::now();
let key = cache.key(source, name);
let keyed = t.elapsed().as_nanos();
if let Some(code) = cache.get(&key) {
let t = std::time::Instant::now();
match lua
.load(&code[..])
.set_name(name)
.set_mode(mlua::ChunkMode::Binary)
.into_function()
{
Ok(f) => {
chunk_stats().hit(t.elapsed().as_nanos(), keyed);
return f.call::<()>(());
}
// Not the player's error: drop the entry and parse.
Err(_) => cache.forget(&key),
}
}
let t = std::time::Instant::now();
let f = lua
.load(source)
.set_name(name)
.set_mode(mlua::ChunkMode::Text)
.into_function()?;
let dump = f.dump(false);
chunk_stats().miss(t.elapsed().as_nanos(), keyed);
cache.put(&key, &dump);
f.call::<()>(())
}
/// What the chunk cache did this process: hits and misses, and the time each spent turning bytes
/// into a function. `WOW_LUA_CACHE=0` and a run with the folder deleted are the two controls.
#[derive(Default)]
pub struct ChunkStats {
pub hits: u64,
pub misses: u64,
pub hit_ns: u128,
pub miss_ns: u128,
pub key_ns: u128,
}
impl ChunkStats {
fn hit(&mut self, ns: u128, key_ns: u128) {
self.hits += 1;
self.hit_ns += ns;
self.key_ns += key_ns;
}
fn miss(&mut self, ns: u128, key_ns: u128) {
self.misses += 1;
self.miss_ns += ns;
self.key_ns += key_ns;
}
}
fn chunk_stats() -> std::sync::MutexGuard<'static, ChunkStats> {
static STATS: std::sync::OnceLock<std::sync::Mutex<ChunkStats>> = std::sync::OnceLock::new();
STATS
.get_or_init(|| std::sync::Mutex::new(ChunkStats::default()))
.lock()
.unwrap_or_else(|p| p.into_inner())
}
/// One line naming what the cache saved, for a harness or a load edge to print.
pub fn chunk_cache_line() -> String {
let s = chunk_stats();
format!(
"lua chunks: {} compiled in {:.0} ms, {} loaded from cache in {:.0} ms, keys {:.0} ms",
s.misses,
s.miss_ns as f64 / 1e6,
s.hits,
s.hit_ns as f64 / 1e6,
s.key_ns as f64 / 1e6,
)
}
@@ -0,0 +1,118 @@
//! The compiled-chunk cache's contract: a cached chunk behaves exactly as a parsed one, and a
//! cache that answers rubbish costs a parse rather than a wrong chunk.
use std::collections::HashMap;
use std::sync::Mutex;
use crate::script::{ChunkCache, UiScript};
/// An in-memory cache that can also be told to lie, since the point of the fallback is what
/// happens when an entry is not what it claims.
#[derive(Default)]
struct Probe {
rows: Mutex<HashMap<String, Vec<u8>>>,
/// Hand this back instead of the stored entry, if set.
poison: Mutex<Option<Vec<u8>>>,
hits: Mutex<u32>,
forgets: Mutex<u32>,
}
/// FNV-1a over the source, so this probe keys on the bytes like the real cache does: it is
/// installed process-wide for the whole test binary, and a key that collided would hand another
/// test's chunk the wrong code.
fn fnv(bytes: &[u8]) -> u64 {
bytes.iter().fold(0xcbf2_9ce4_8422_2325, |h, &b| {
(h ^ u64::from(b)).wrapping_mul(0x1000_0000_01b3)
})
}
impl ChunkCache for Probe {
fn key(&self, source: &[u8], name: &str) -> String {
format!("{name}:{:016x}", fnv(source))
}
fn get(&self, key: &str) -> Option<Vec<u8>> {
if let Some(bad) = self.poison.lock().unwrap().clone() {
return Some(bad);
}
let hit = self.rows.lock().unwrap().get(key).cloned();
if hit.is_some() {
*self.hits.lock().unwrap() += 1;
}
hit
}
fn put(&self, key: &str, bytecode: &[u8]) {
self.rows
.lock()
.unwrap()
.insert(key.to_string(), bytecode.to_vec());
}
fn forget(&self, _key: &str) {
*self.forgets.lock().unwrap() += 1;
}
}
/// One test, because the cache is installed process-wide and installed once: a second test could
/// not install its own, and would be reading this one's.
#[test]
fn a_cached_chunk_runs_the_same_code_keeps_its_line_numbers_and_survives_a_bad_entry() {
let probe: &'static Probe = Box::leak(Box::new(Probe::default()));
crate::script::install_chunk_cache(Box::new(ProbeRef(probe)));
let s = UiScript::new().unwrap();
let src = "CacheProbe = (CacheProbe or 0) + 1\n\
function CacheProbeRaise()\n\
\x20 error(\"from the cached chunk\")\n\
end\n";
// First run: a miss that fills the cache.
s.run_chunk_named(src.as_bytes(), "@Interface\\AddOns\\Probe\\Probe.lua")
.expect("the chunk runs");
assert_eq!(s.eval::<i64>("return CacheProbe").unwrap(), 1);
let after_first = *probe.hits.lock().unwrap();
// Second run of the same bytes: served from the cache, and it does the same thing.
s.run_chunk_named(src.as_bytes(), "@Interface\\AddOns\\Probe\\Probe.lua")
.expect("the cached chunk runs");
assert_eq!(s.eval::<i64>("return CacheProbe").unwrap(), 2);
assert!(
*probe.hits.lock().unwrap() > after_first,
"the second run must come from the cache, or this test proves nothing"
);
// The traceback still names the file and the line: the dump keeps its debug info, because
// `debugstack` is an addon-facing verb.
let err = s.eval::<()>("CacheProbeRaise()").unwrap_err().to_string();
assert!(
err.contains("Probe.lua") && err.contains(":3:"),
"a cached chunk must raise with its own file and line: {err}"
);
// An entry that is not loadable bytecode is not the player's problem: it is dropped and the
// source is parsed instead.
*probe.poison.lock().unwrap() = Some(b"\x1bLua this is not a dump".to_vec());
s.run_chunk_named(src.as_bytes(), "@Interface\\AddOns\\Probe\\Probe.lua")
.expect("a rubbish entry falls back to the source");
assert_eq!(s.eval::<i64>("return CacheProbe").unwrap(), 3);
assert!(
*probe.forgets.lock().unwrap() >= 1,
"and the entry the loader refused is dropped"
);
}
/// A `'static` borrow of the probe, so the installed box and the test share one.
struct ProbeRef(&'static Probe);
impl ChunkCache for ProbeRef {
fn key(&self, source: &[u8], name: &str) -> String {
self.0.key(source, name)
}
fn get(&self, key: &str) -> Option<Vec<u8>> {
self.0.get(key)
}
fn put(&self, key: &str, bytecode: &[u8]) {
self.0.put(key, bytecode);
}
fn forget(&self, key: &str) {
self.0.forget(key);
}
}
@@ -9,6 +9,7 @@ mod button;
mod cast_info;
mod caught_raise_cost;
mod channel;
mod chunk_cache;
mod common;
mod cooldown;
mod create_frame_template;