diff --git a/Cargo.lock b/Cargo.lock index fac2bf34..11e57f02 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -505,6 +505,7 @@ dependencies = [ name = "benilla-app" version = "0.2.0" dependencies = [ + "ahash", "anyhow", "arboard", "assert_no_alloc", diff --git a/crates/benilla-app/Cargo.toml b/crates/benilla-app/Cargo.toml index 93bb60ca..bfebd185 100644 --- a/crates/benilla-app/Cargo.toml +++ b/crates/benilla-app/Cargo.toml @@ -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 } diff --git a/crates/benilla-app/examples/addon_harness.rs b/crates/benilla-app/examples/addon_harness.rs index 6d8820de..0a34bd7e 100644 --- a/crates/benilla-app/examples/addon_harness.rs +++ b/crates/benilla-app/examples/addon_harness.rs @@ -197,6 +197,14 @@ fn main() { }; let rest: Vec = 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?", diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs index eb0d1d13..5db8cc29 100644 --- a/crates/benilla-app/src/lib.rs +++ b/crates/benilla-app/src/lib.rs @@ -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 diff --git a/crates/benilla-app/src/local_state.rs b/crates/benilla-app/src/local_state.rs index b1e818b3..ea4c732a 100644 --- a/crates/benilla-app/src/local_state.rs +++ b/crates/benilla-app/src/local_state.rs @@ -209,6 +209,12 @@ pub(crate) fn legacy_item_cache_path(realm: &str) -> Option { ) } +/// `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 { + 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 { home().map(|h| h.join("Logs")) diff --git a/crates/benilla-app/src/ui_script/chunk_cache.rs b/crates/benilla-app/src/ui_script/chunk_cache.rs new file mode 100644 index 00000000..4738b9b1 --- /dev/null +++ b/crates/benilla-app/src/ui_script/chunk_cache.rs @@ -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, +} + +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 { + 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 { + 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> { + 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); + } + } +} diff --git a/crates/benilla-app/src/ui_script/mod.rs b/crates/benilla-app/src/ui_script/mod.rs index 03dea5a8..4b4ce980 100644 --- a/crates/benilla-app/src/ui_script/mod.rs +++ b/crates/benilla-app/src/ui_script/mod.rs @@ -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; diff --git a/crates/benilla-ui/examples/chunkbench.rs b/crates/benilla-ui/examples/chunkbench.rs index f006b08a..5d057d92 100644 --- a/crates/benilla-ui/examples/chunkbench.rs +++ b/crates/benilla-ui/examples/chunkbench.rs @@ -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. diff --git a/crates/benilla-ui/src/loader/mod.rs b/crates/benilla-ui/src/loader/mod.rs index aacd1f31..c25e5034 100644 --- a/crates/benilla-ui/src/loader/mod.rs +++ b/crates/benilla-ui/src/loader/mod.rs @@ -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 `` 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> { diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index 9b70bb81..74d5ca11 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -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>; + fn put(&self, key: &str, bytecode: &[u8]); + /// Drop an entry the loader refused. + fn forget(&self, key: &str); +} + +static CHUNK_CACHE: std::sync::OnceLock> = 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) { + 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::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, + ) +} diff --git a/crates/benilla-ui/src/script/tests/chunk_cache.rs b/crates/benilla-ui/src/script/tests/chunk_cache.rs new file mode 100644 index 00000000..1b52d6e9 --- /dev/null +++ b/crates/benilla-ui/src/script/tests/chunk_cache.rs @@ -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>>, + /// Hand this back instead of the stored entry, if set. + poison: Mutex>>, + hits: Mutex, + forgets: Mutex, +} + +/// 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> { + 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::("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::("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::("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> { + 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); + } +} diff --git a/crates/benilla-ui/src/script/tests/mod.rs b/crates/benilla-ui/src/script/tests/mod.rs index c54dc15f..8bf70b0f 100644 --- a/crates/benilla-ui/src/script/tests/mod.rs +++ b/crates/benilla-ui/src/script/tests/mod.rs @@ -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;