This checkout had no git history at all, so this one commit carries the whole
of the Everwood work as a single import on top of chairmanpow/benilla-octo's
`main` (706cd71). The two trees have genuinely diverged — each has files the
other does not — so this is a fork point, not a catch-up.
Today's work, which is the reason a baseline exists at all:
- Booth previews no longer cast world shadows (`NO_BOOTH_SHADOW`). Portrait
characters were entering the sun's shadow cascades, so every skinned-rig
shadow specialization compiled live mid-frame; the hitch detector logged
"main thread blocked 1316 ms" beside a `compiled LIVE` warning. This was
the Fashion/Tmog stutter.
- Per-pane dress-up: each `DressUpModel` pane assembles and stages its own
tried-on look instead of every pane mirroring the live player.
- Item template cache is append-only JSONL written on the IO pool, replacing
an 11.6 MB whole-map JSON rewrite on the main thread every 10 s.
- `WOW_SYSTEM_TIMING` rebuilt per-thread: it took 2 mutexes per system enter
and 4 per exit, costing 15 ms a frame (590 fps -> 60) and corrupting the
measurements it was taken to produce.
- The visibility-churn sweep moved to its own `WOW_FLASH_CHURN` switch, so a
frame-cost reading no longer pays 2.7 ms a frame for it.
Local state (`benilla-config/`, `logs/`, `target/`) is excluded by the
existing .gitignore; the account credential it holds is not in this commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
729 lines
28 KiB
Rust
729 lines
28 KiB
Rust
//! The per-phase split of one [`UiScript::tick`](super::UiScript::tick) (`WOW_UI_TICK=<secs>`):
|
|
//! where the frame's VM time goes when it is **not** in a handler body.
|
|
//!
|
|
//! **Why this needs an instrument of its own.** `WOW_UI_COST=1`'s `tick=` column is the whole of
|
|
//! `UiScript::tick`, and `WOW_UI_HANDLERS=<secs>` attributes every handler *body* fired inside it
|
|
//! ([`super::handler_prof`]). A session where those two disagree — `tick=38000` against
|
|
//! `self 440 us/f` — says only "37 ms is engine work", and `tick` has eleven engine phases: the
|
|
//! clock, the edit-box flush, the queued-event drain, the `OnUpdate` roster build, the dispatch
|
|
//! loop's own wrapper, the model panes, the message fades, the tooltip fades, the hover re-pick,
|
|
//! the `C_Timer` drain and the reports. One number cannot name which, and the phases that can go
|
|
//! wide (the hover walk is O(frames), the re-pick fires on any show or hide) are exactly the ones
|
|
//! a bench does not reproduce, because they need a cursor over a live interface.
|
|
//!
|
|
//! So this charges every microsecond of a tick to exactly one [`Phase`] and prints the row per
|
|
//! frame, beside the counts that explain it — dispatches, re-picks, armed timers. One run names
|
|
//! the phase.
|
|
//!
|
|
//! **And then the phase.** Naming `timers` was only half an answer: `C_Timer`'s drain fires
|
|
//! callbacks through [`super::event::protected_call`] rather than through a frame script, so
|
|
//! [`super::handler_prof`] never sees one and the whole phase was a single opaque number — a
|
|
//! session reported 40,371 us of a 40,841 us tick in it with nothing to say which of the eight
|
|
//! armed rows that was. So the drain also charges each fire to its callback's **definition site**
|
|
//! ([`charge_timer`]) and counts the live rows that share it ([`note_timer_rows`]), and the report
|
|
//! prints the costliest few. One run names the timer, and a row whose live count climbs names a
|
|
//! leak rather than a slow body.
|
|
//!
|
|
//! Off, it is one relaxed atomic load per tick and nothing else ([`armed`]).
|
|
|
|
use std::collections::BTreeMap;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::time::Instant;
|
|
|
|
use mlua::Lua;
|
|
|
|
use super::UiScript;
|
|
|
|
/// Whether any VM in the process splits its tick: process-wide, so the per-tick check needs no
|
|
/// `app_data` lookup, as the handler profiler's gate is.
|
|
static ARMED: AtomicBool = AtomicBool::new(false);
|
|
|
|
#[inline]
|
|
pub(super) fn armed() -> bool {
|
|
ARMED.load(Ordering::Relaxed)
|
|
}
|
|
|
|
/// The report period from `WOW_UI_TICK=<secs>`, read once; unparseable or non-positive is off.
|
|
fn env_period() -> Option<f32> {
|
|
static PERIOD: std::sync::OnceLock<Option<f32>> = std::sync::OnceLock::new();
|
|
*PERIOD.get_or_init(|| {
|
|
std::env::var("WOW_UI_TICK")
|
|
.ok()
|
|
.and_then(|v| v.parse::<f32>().ok())
|
|
.filter(|secs| *secs > 0.0)
|
|
})
|
|
}
|
|
|
|
/// One phase of [`UiScript::tick`](super::UiScript::tick), in the order the tick runs them. Every
|
|
/// microsecond of a tick is charged to exactly one of these, which is what makes the row a
|
|
/// decomposition rather than a sample: the phases must sum to the `[ui-cost] tick=` figure.
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
|
pub enum Phase {
|
|
/// The `GetTime()` advance: two globals, through `lua_gettable`/`lua_settable`.
|
|
Clock,
|
|
/// The edit-box tick: caret blink, the multi-line relayout and the two dirty-word drains.
|
|
EditBox,
|
|
/// The queued-event drain, `OnEvent` bodies included.
|
|
Events,
|
|
/// Building the `OnUpdate` roster: the visibility filter, the id map and the sort.
|
|
Roster,
|
|
/// The dispatch loop, `OnUpdate` bodies included.
|
|
Dispatch,
|
|
/// [`UiScript::tick_model_panes`](super::UiScript::tick_model_panes).
|
|
Panes,
|
|
/// The Message/ScrollingMessage line fades and the viewport trim.
|
|
Messages,
|
|
/// `GameTooltip`'s `FadeOut` ramp.
|
|
Tooltips,
|
|
/// The hover re-pick: a whole `hit_test` over the frame tree when one is due.
|
|
Hover,
|
|
/// `C_Timer`'s drain, callback bodies included.
|
|
Timers,
|
|
/// The periodic `[ui-handlers]`/`[ui-raises]`/`[ui-tick]` reports themselves.
|
|
Reports,
|
|
}
|
|
|
|
/// Every [`Phase`] in tick order; the row prints them in this order so the eye reads the tick.
|
|
const PHASES: [Phase; 11] = [
|
|
Phase::Clock,
|
|
Phase::EditBox,
|
|
Phase::Events,
|
|
Phase::Roster,
|
|
Phase::Dispatch,
|
|
Phase::Panes,
|
|
Phase::Messages,
|
|
Phase::Tooltips,
|
|
Phase::Hover,
|
|
Phase::Timers,
|
|
Phase::Reports,
|
|
];
|
|
|
|
impl Phase {
|
|
/// The column label, short enough for one line.
|
|
const fn label(self) -> &'static str {
|
|
match self {
|
|
Phase::Clock => "clock",
|
|
Phase::EditBox => "editbox",
|
|
Phase::Events => "events",
|
|
Phase::Roster => "roster",
|
|
Phase::Dispatch => "dispatch",
|
|
Phase::Panes => "panes",
|
|
Phase::Messages => "messages",
|
|
Phase::Tooltips => "tooltips",
|
|
Phase::Hover => "hover",
|
|
Phase::Timers => "timers",
|
|
Phase::Reports => "reports",
|
|
}
|
|
}
|
|
|
|
const fn slot(self) -> usize {
|
|
self as usize
|
|
}
|
|
}
|
|
|
|
/// A count the row prints beside the times, because a phase's cost is only readable against how
|
|
/// many times its work ran.
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
|
pub enum Tally {
|
|
/// `OnUpdate` handlers dispatched.
|
|
Dispatch,
|
|
/// Queued events drained.
|
|
Events,
|
|
/// Hover re-picks run — a whole `hit_test` each.
|
|
Repick,
|
|
/// Registered listeners the event drain stepped over, the `all-events` roster included. The
|
|
/// walk re-finds its position by a linear scan each step (`0x703ee8`), so this is the square
|
|
/// root of the engine's own event cost: 40 events over 200 listeners is 1.6 M comparisons a
|
|
/// tick that no handler row accounts for.
|
|
EventSteps,
|
|
/// `C_Timer` entries walked by the drain. Their callbacks go through
|
|
/// [`super::event::protected_call`] directly, **not** through `fire`, so `[ui-handlers]`
|
|
/// cannot see them and this phase is the only place their cost shows.
|
|
Timers,
|
|
/// Frames in `ticked_kinds`, walked three times a tick.
|
|
Ticked,
|
|
/// `on_update_frames` before the visibility filter: the roster `SetScript` keeps, which only
|
|
/// a destroyed frame compacts out of.
|
|
Roster,
|
|
/// Live frames in the arena. Every O(frames) pass in the tick — the roster filter, the hover
|
|
/// walk's draw list, the scroll-clip map — is linear in this, so a report without it cannot
|
|
/// tell a slow pass from a wide one.
|
|
Frames,
|
|
/// Live regions in the arena, the other half of the draw list the hover walk builds.
|
|
Regions,
|
|
}
|
|
|
|
const TALLIES: [Tally; 9] = [
|
|
Tally::Dispatch,
|
|
Tally::Events,
|
|
Tally::Repick,
|
|
Tally::EventSteps,
|
|
Tally::Timers,
|
|
Tally::Ticked,
|
|
Tally::Roster,
|
|
Tally::Frames,
|
|
Tally::Regions,
|
|
];
|
|
|
|
impl Tally {
|
|
const fn label(self) -> &'static str {
|
|
match self {
|
|
Tally::Dispatch => "dispatch",
|
|
Tally::Events => "events",
|
|
Tally::Repick => "repick",
|
|
Tally::EventSteps => "eventsteps",
|
|
Tally::Timers => "timers",
|
|
Tally::Ticked => "ticked",
|
|
Tally::Roster => "roster",
|
|
Tally::Frames => "frames",
|
|
Tally::Regions => "regions",
|
|
}
|
|
}
|
|
|
|
const fn slot(self) -> usize {
|
|
self as usize
|
|
}
|
|
}
|
|
|
|
/// The split's state, one per VM. Its own `app_data` slot, not the model's, for the same reason
|
|
/// the handler profiler's is: a mark is taken where a model borrow may be held.
|
|
#[derive(Default)]
|
|
pub(crate) struct TickProf {
|
|
/// Whether this VM records; [`armed`] is the process-wide gate.
|
|
on: bool,
|
|
/// Seconds between reports; `0.0` records and never prints.
|
|
period: f32,
|
|
/// Wall seconds and ticks since the last report, the divisor of every printed rate.
|
|
window: f32,
|
|
ticks: u32,
|
|
phase_ns: [u64; PHASES.len()],
|
|
tally: [u64; TALLIES.len()],
|
|
/// The costliest single tick in the window and the phase that dominated it. A mean hides a
|
|
/// spike, and the sessions this instrument is for have both: a steady 37 ms with 90 ms and
|
|
/// 118 ms frames in it, which are not necessarily the same phase.
|
|
peak_ns: u64,
|
|
peak_phase: &'static str,
|
|
/// The `timers` phase broken out by the callback's definition site, since that phase is the
|
|
/// one no other profiler can see into. Keyed by site so a ticker re-armed every drain and a
|
|
/// thousand copies of the same closure fold into one row.
|
|
timers: BTreeMap<std::rc::Rc<str>, TimerTally>,
|
|
}
|
|
|
|
/// One timer site's share of the `timers` phase over the window.
|
|
#[derive(Default, Clone, Copy)]
|
|
struct TimerTally {
|
|
/// Wall nanoseconds inside the callback, the `protected_call` itself included.
|
|
ns: u64,
|
|
/// Fires charged here.
|
|
fires: u64,
|
|
/// Live rows carrying this site, summed over the window's drains: divided by the tick count
|
|
/// this is "how many of these are armed", which is what separates one slow callback from a
|
|
/// leak of cheap ones.
|
|
rows: u64,
|
|
/// The single costliest fire, because a row firing once at 100 ms and one firing every tick at
|
|
/// 1 ms read identically per tick and need different fixes.
|
|
peak_ns: u64,
|
|
}
|
|
|
|
/// One tick's open split: the running mark plus this tick's own accumulators, folded into the VM's
|
|
/// [`TickProf`] by [`Split::close`]. Inert — and allocation-free — while the split is off, so the
|
|
/// marks can sit in `tick` unconditionally.
|
|
pub(super) struct Split {
|
|
/// `None` while this VM is not recording, which makes every method a no-op.
|
|
at: Option<Instant>,
|
|
phase_ns: [u64; PHASES.len()],
|
|
tally: [u64; TALLIES.len()],
|
|
}
|
|
|
|
impl Split {
|
|
/// Open a split at the top of a tick. Cheap and inert unless this VM is recording.
|
|
pub(super) fn open(lua: &Lua) -> Split {
|
|
let at =
|
|
(armed() && lua.app_data_ref::<TickProf>().is_some_and(|p| p.on)).then(Instant::now);
|
|
Split {
|
|
at,
|
|
phase_ns: [0; PHASES.len()],
|
|
tally: [0; TALLIES.len()],
|
|
}
|
|
}
|
|
|
|
/// Charge the time since the previous mark to `phase`, and re-arm. Every phase of the tick
|
|
/// marks, so the row sums to the tick.
|
|
pub(super) fn mark(&mut self, phase: Phase) {
|
|
if let Some(at) = self.at {
|
|
let now = Instant::now();
|
|
self.phase_ns[phase.slot()] += now.saturating_duration_since(at).as_nanos() as u64;
|
|
self.at = Some(now);
|
|
}
|
|
}
|
|
|
|
/// Whether this split records, so a caller can skip a look-up taken only for a count.
|
|
pub(super) fn recording(&self) -> bool {
|
|
self.at.is_some()
|
|
}
|
|
|
|
/// Add `n` to a count the report prints beside the times.
|
|
pub(super) fn count(&mut self, tally: Tally, n: usize) {
|
|
if self.at.is_some() {
|
|
self.tally[tally.slot()] += n as u64;
|
|
}
|
|
}
|
|
|
|
/// Fold this tick into the VM's window. Called at the very end of a tick, after the last mark.
|
|
pub(super) fn close(self, lua: &Lua) {
|
|
if self.at.is_none() {
|
|
return;
|
|
}
|
|
let Some(mut prof) = lua.app_data_mut::<TickProf>() else {
|
|
return;
|
|
};
|
|
prof.ticks += 1;
|
|
let total: u64 = self.phase_ns.iter().sum();
|
|
if total > prof.peak_ns {
|
|
prof.peak_ns = total;
|
|
prof.peak_phase = PHASES
|
|
.iter()
|
|
.max_by_key(|p| self.phase_ns[p.slot()])
|
|
.map_or("none", |p| p.label());
|
|
}
|
|
for (slot, ns) in self.phase_ns.iter().enumerate() {
|
|
prof.phase_ns[slot] += ns;
|
|
}
|
|
for (slot, n) in self.tally.iter().enumerate() {
|
|
prof.tally[slot] += n;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Charge one `C_Timer` fire to the site its callback was written at. Called by
|
|
/// [`UiScript::tick`](super::UiScript::tick)'s `drain_timers` around each `protected_call`, which
|
|
/// is the only place a timer body's cost is observable: it does not go through `fire`, so
|
|
/// [`super::handler_prof`] never opens a window on it.
|
|
///
|
|
/// Takes and drops the `app_data` borrow itself, because the drain holds none across a callback.
|
|
pub(super) fn charge_timer(lua: &Lua, site: Option<&std::rc::Rc<str>>, ns: u64) {
|
|
if !armed() {
|
|
return;
|
|
}
|
|
let Some(mut prof) = lua.app_data_mut::<TickProf>() else {
|
|
return;
|
|
};
|
|
if !prof.on {
|
|
return;
|
|
}
|
|
// A row armed before the split was (or by a path that skipped the label) still has to show,
|
|
// or the rows would not sum to the phase.
|
|
let key = site.cloned().unwrap_or_else(|| std::rc::Rc::from("?"));
|
|
let row = prof.timers.entry(key).or_default();
|
|
row.ns += ns;
|
|
row.fires += 1;
|
|
row.peak_ns = row.peak_ns.max(ns);
|
|
}
|
|
|
|
/// Count the rows armed at the top of one drain, per site. The *count* is the half of the answer
|
|
/// the times cannot give: a site at 40 ms a tick is one pathological callback if its row count is
|
|
/// 1 and an accumulating leak of cheap ones if it climbs.
|
|
pub(super) fn note_timer_rows(lua: &Lua, sites: impl Iterator<Item = Option<std::rc::Rc<str>>>) {
|
|
if !armed() {
|
|
return;
|
|
}
|
|
let Some(mut prof) = lua.app_data_mut::<TickProf>() else {
|
|
return;
|
|
};
|
|
if !prof.on {
|
|
return;
|
|
}
|
|
for site in sites {
|
|
let key = site.unwrap_or_else(|| std::rc::Rc::from("?"));
|
|
prof.timers.entry(key).or_default().rows += 1;
|
|
}
|
|
}
|
|
|
|
/// One `C_Timer` site's share of the `timers` phase, for [`UiScript::timer_profile`].
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct TimerSiteRow {
|
|
/// `<chunk>:<line the callback was written on>`.
|
|
pub site: String,
|
|
/// Microseconds a tick spent inside callbacks from this site.
|
|
pub us_per_tick: f64,
|
|
/// Fires a tick from this site; below 1.0 for anything slower than the frame.
|
|
pub fires_per_tick: f64,
|
|
/// Rows from this site armed at the average drain.
|
|
pub rows_per_tick: f64,
|
|
/// The costliest single fire from this site, in microseconds.
|
|
pub peak_us: f64,
|
|
}
|
|
|
|
/// One phase's cost over the split's current window.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct TickPhaseRow {
|
|
pub phase: &'static str,
|
|
/// Microseconds a tick in this phase.
|
|
pub us_per_tick: f64,
|
|
}
|
|
|
|
impl UiScript {
|
|
/// Arm or disarm the tick split for this VM, reporting every `period_secs` (`0.0`: no report,
|
|
/// read [`UiScript::tick_profile`]). Call it between ticks.
|
|
pub fn profile_tick(&self, on: bool, period_secs: f32) {
|
|
if on {
|
|
ARMED.store(true, Ordering::Relaxed);
|
|
}
|
|
let Some(mut prof) = self.lua.app_data_mut::<TickProf>() else {
|
|
return;
|
|
};
|
|
*prof = TickProf {
|
|
on,
|
|
period: period_secs,
|
|
..TickProf::default()
|
|
};
|
|
}
|
|
|
|
/// This VM's tick phases over the current window, in tick order, costliest first only if the
|
|
/// caller sorts: tick order is the readable one.
|
|
pub fn tick_profile(&self) -> Vec<TickPhaseRow> {
|
|
let Some(prof) = self.lua.app_data_ref::<TickProf>() else {
|
|
return Vec::new();
|
|
};
|
|
let per = f64::from(prof.ticks.max(1));
|
|
PHASES
|
|
.iter()
|
|
.map(|p| TickPhaseRow {
|
|
phase: p.label(),
|
|
us_per_tick: prof.phase_ns[p.slot()] as f64 / 1000.0 / per,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// The costliest single tick of the window: `(microseconds, the phase that dominated it)`.
|
|
pub fn tick_profile_peak(&self) -> (f64, &'static str) {
|
|
self.lua
|
|
.app_data_ref::<TickProf>()
|
|
.map_or((0.0, "none"), |p| (p.peak_ns as f64 / 1000.0, p.peak_phase))
|
|
}
|
|
|
|
/// Ticks since the last report, [`UiScript::tick_profile`]'s denominator.
|
|
pub fn tick_profile_ticks(&self) -> u32 {
|
|
self.lua.app_data_ref::<TickProf>().map_or(0, |p| p.ticks)
|
|
}
|
|
|
|
/// The `timers` phase by callback site, costliest first — the rows that name which `C_Timer`
|
|
/// a wide `timers` phase belongs to. Their `us_per_tick` sums to the phase's own.
|
|
pub fn timer_profile(&self) -> Vec<TimerSiteRow> {
|
|
let Some(prof) = self.lua.app_data_ref::<TickProf>() else {
|
|
return Vec::new();
|
|
};
|
|
let per = f64::from(prof.ticks.max(1));
|
|
let mut rows: Vec<TimerSiteRow> = prof
|
|
.timers
|
|
.iter()
|
|
.map(|(site, t)| TimerSiteRow {
|
|
site: site.to_string(),
|
|
us_per_tick: t.ns as f64 / 1000.0 / per,
|
|
fires_per_tick: t.fires as f64 / per,
|
|
rows_per_tick: t.rows as f64 / per,
|
|
peak_us: t.peak_ns as f64 / 1000.0,
|
|
})
|
|
.collect();
|
|
// Costliest first: unlike the phases, there is no meaningful order among sites, and the
|
|
// only question asked of this list is "which one".
|
|
rows.sort_by(|a, b| b.us_per_tick.total_cmp(&a.us_per_tick));
|
|
rows
|
|
}
|
|
|
|
/// The counts beside the times: `(label, per-tick)` in [`TALLIES`] order.
|
|
pub fn tick_profile_counts(&self) -> Vec<(&'static str, f64)> {
|
|
let Some(prof) = self.lua.app_data_ref::<TickProf>() else {
|
|
return Vec::new();
|
|
};
|
|
let per = f64::from(prof.ticks.max(1));
|
|
TALLIES
|
|
.iter()
|
|
.map(|t| (t.label(), prof.tally[t.slot()] as f64 / per))
|
|
.collect()
|
|
}
|
|
|
|
/// Print the `[ui-tick]` block when due, at the end of [`UiScript::tick`]. The `elapsed` is
|
|
/// the tick's own, so the window is wall time like the other two reports'.
|
|
pub(super) fn report_tick_profile(&mut self, elapsed: f32) {
|
|
if !armed() {
|
|
return;
|
|
}
|
|
let due = match self.lua.app_data_mut::<TickProf>() {
|
|
Some(mut prof) if prof.on => {
|
|
prof.window += elapsed;
|
|
prof.period > 0.0 && prof.window >= prof.period
|
|
}
|
|
_ => false,
|
|
};
|
|
if !due {
|
|
return;
|
|
}
|
|
// The rows first, then the mutable borrow: both go through `app_data`.
|
|
let rows = self.tick_profile();
|
|
let counts = self.tick_profile_counts();
|
|
let peak = self.tick_profile_peak();
|
|
let timers = self.timer_profile();
|
|
let mut prof = self
|
|
.lua
|
|
.app_data_mut::<TickProf>()
|
|
.expect("tick prof app_data — checked above");
|
|
print_report(&rows, &counts, &timers, peak, prof.window, prof.ticks);
|
|
prof.window = 0.0;
|
|
prof.ticks = 0;
|
|
prof.phase_ns = [0; PHASES.len()];
|
|
prof.tally = [0; TALLIES.len()];
|
|
prof.peak_ns = 0;
|
|
prof.peak_phase = "none";
|
|
prof.timers.clear();
|
|
}
|
|
}
|
|
|
|
/// The `[ui-tick]` block: the window, the total, then every phase in tick order and the counts.
|
|
/// Microseconds, like `[ui-cost] tick=`, so the two columns compare without arithmetic.
|
|
fn print_report(
|
|
rows: &[TickPhaseRow],
|
|
counts: &[(&'static str, f64)],
|
|
timers: &[TimerSiteRow],
|
|
peak: (f64, &'static str),
|
|
window: f32,
|
|
ticks: u32,
|
|
) {
|
|
let total: f64 = rows.iter().map(|r| r.us_per_tick).sum();
|
|
let phases: Vec<String> = rows
|
|
.iter()
|
|
.map(|r| format!("{} {:.1}", r.phase, r.us_per_tick))
|
|
.collect();
|
|
let tallies: Vec<String> = counts
|
|
.iter()
|
|
.map(|(label, n)| format!("{label} {n:.2}"))
|
|
.collect();
|
|
eprintln!(
|
|
"[ui-tick] {window:.2}s · {ticks} ticks · {total:.1} us/tick · worst tick {:.1} us \
|
|
(mostly {})",
|
|
peak.0, peak.1,
|
|
);
|
|
eprintln!("[ui-tick] us/tick: {}", phases.join(" "));
|
|
eprintln!("[ui-tick] per tick: {}", tallies.join(" "));
|
|
// The `timers` phase's own split. Printed only when there is one, so a tick with no armed
|
|
// timer keeps the block at three lines; `TIMER_ROWS` of them, because the question is "which
|
|
// one", and a tail of 0.1 us rows only buries the answer.
|
|
for r in timers
|
|
.iter()
|
|
.take(TIMER_ROWS)
|
|
.filter(|r| r.us_per_tick > 0.0)
|
|
{
|
|
eprintln!(
|
|
"[ui-tick] timer {:9.1} us/tick · {:6.2} fires/tick · {:6.2} armed · \
|
|
worst fire {:.1} us · {}",
|
|
r.us_per_tick, r.fires_per_tick, r.rows_per_tick, r.peak_us, r.site,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// How many timer sites the report prints. Eight: the shipped addon set arms that many persistent
|
|
/// tickers, so the whole steady-state population fits and anything beyond it is noise.
|
|
const TIMER_ROWS: usize = 8;
|
|
|
|
/// Install the split's `app_data` slot, armed from the environment. Unconditional, like the other
|
|
/// two profilers': mlua refuses to insert app data while any is borrowed, so the slot must exist
|
|
/// before the VM runs.
|
|
pub(super) fn install(lua: &Lua) {
|
|
let period = env_period();
|
|
lua.set_app_data(TickProf {
|
|
on: period.is_some(),
|
|
period: period.unwrap_or_default(),
|
|
..TickProf::default()
|
|
});
|
|
if period.is_some() {
|
|
eprintln!(
|
|
"[ui-tick] armed — splitting UiScript::tick into its phases every {:.1}s \
|
|
(WOW_UI_TICK)",
|
|
period.unwrap_or_default()
|
|
);
|
|
ARMED.store(true, Ordering::Relaxed);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// The whole point of the instrument: the phases are a *decomposition*, so their sum is the
|
|
/// tick. A mark left out of `tick` would make the row quietly understate whichever phase it
|
|
/// belongs to, and nothing else would notice — which is the failure mode that wasted the
|
|
/// sessions this exists for.
|
|
#[test]
|
|
fn every_microsecond_of_a_tick_is_charged_to_exactly_one_phase() {
|
|
let s = UiScript::new().expect("VM");
|
|
s.profile_tick(true, 0.0);
|
|
// An `OnUpdate` that burns a measurable, unambiguous amount of time.
|
|
s.run(
|
|
r#"
|
|
local f = CreateFrame("Frame")
|
|
f:SetScript("OnUpdate", function()
|
|
local x = 0
|
|
for i = 1, 40000 do x = x + i end
|
|
end)
|
|
"#,
|
|
)
|
|
.expect("chunk");
|
|
let mut script = s;
|
|
let t = std::time::Instant::now();
|
|
script.tick(0.016);
|
|
let whole_us = t.elapsed().as_micros() as f64;
|
|
let rows = script.tick_profile();
|
|
let sum: f64 = rows.iter().map(|r| r.us_per_tick).sum();
|
|
assert_eq!(script.tick_profile_ticks(), 1, "one tick in the window");
|
|
// Within a few percent either way: the marks' own clock reads land inside the sum and
|
|
// outside the outer measurement, so the two cannot be identical.
|
|
assert!(
|
|
sum >= whole_us * 0.80 && sum <= whole_us * 1.20,
|
|
"the phases must sum to the tick: phases {sum:.1} us against the tick's \
|
|
{whole_us:.1} us — rows {rows:?}"
|
|
);
|
|
}
|
|
|
|
/// A phase's row has to point at the phase that actually ran, or the report sends the next
|
|
/// session to the wrong place. The burning handler is dispatched, so `dispatch` must dominate.
|
|
#[test]
|
|
fn a_burning_on_update_handler_lands_in_the_dispatch_phase() {
|
|
let s = UiScript::new().expect("VM");
|
|
s.profile_tick(true, 0.0);
|
|
s.run(
|
|
r#"
|
|
local f = CreateFrame("Frame")
|
|
f:SetScript("OnUpdate", function()
|
|
local x = 0
|
|
for i = 1, 60000 do x = x + i end
|
|
end)
|
|
"#,
|
|
)
|
|
.expect("chunk");
|
|
let mut script = s;
|
|
script.tick(0.016);
|
|
let rows = script.tick_profile();
|
|
let dispatch = rows
|
|
.iter()
|
|
.find(|r| r.phase == "dispatch")
|
|
.expect("a dispatch row")
|
|
.us_per_tick;
|
|
let rest: f64 = rows
|
|
.iter()
|
|
.filter(|r| r.phase != "dispatch")
|
|
.map(|r| r.us_per_tick)
|
|
.sum();
|
|
assert!(
|
|
dispatch > rest,
|
|
"the handler's cost belongs to `dispatch`: dispatch {dispatch:.1} us against \
|
|
{rest:.1} us everywhere else — rows {rows:?}"
|
|
);
|
|
let counts = script.tick_profile_counts();
|
|
assert_eq!(
|
|
counts
|
|
.iter()
|
|
.find(|(l, _)| *l == "dispatch")
|
|
.map(|&(_, n)| n),
|
|
Some(1.0),
|
|
"one handler dispatched: {counts:?}"
|
|
);
|
|
}
|
|
|
|
/// The hover re-pick is the phase this split was built to see: it runs a whole `hit_test` over
|
|
/// the frame tree, and it is armed by any show or hide, so a interface that shows something
|
|
/// every frame pays for a hover walk every frame. The count has to say so.
|
|
#[test]
|
|
fn a_show_arms_the_hover_repick_and_the_count_reports_it() {
|
|
let s = UiScript::new().expect("VM");
|
|
s.profile_tick(true, 0.0);
|
|
// A frame shown this tick arms the re-pick (`0x764b8d`), which the tick then runs.
|
|
s.run(
|
|
r#"
|
|
local f = CreateFrame("Frame", "ShowMeEveryTick")
|
|
f:Hide()
|
|
local up = true
|
|
local driver = CreateFrame("Frame")
|
|
driver:SetScript("OnUpdate", function()
|
|
-- 5.0 has no `%` operator, so the toggle is a boolean.
|
|
if up then ShowMeEveryTick:Show() else ShowMeEveryTick:Hide() end
|
|
up = not up
|
|
end)
|
|
"#,
|
|
)
|
|
.expect("chunk");
|
|
let mut script = s;
|
|
script.tick(0.016);
|
|
script.tick(0.016);
|
|
let counts = script.tick_profile_counts();
|
|
let repick = counts
|
|
.iter()
|
|
.find(|(l, _)| *l == "repick")
|
|
.map(|&(_, n)| n)
|
|
.expect("a repick count");
|
|
assert!(
|
|
repick > 0.0,
|
|
"a show must arm the re-pick the next tick runs: {counts:?}"
|
|
);
|
|
}
|
|
|
|
/// The reason the per-timer rows exist: `timers` was the one phase with no way in. A `C_Timer`
|
|
/// callback is not a frame script, so `handler_prof` never opens a window on it, and a session
|
|
/// that reported 40,371 us of a 40,841 us tick in this phase could not say which of eight
|
|
/// armed rows that was. The row has to name the callback's own source line.
|
|
#[test]
|
|
fn the_timers_phase_names_the_source_line_of_the_callback_that_cost_it() {
|
|
let s = UiScript::new().expect("VM");
|
|
s.profile_tick(true, 0.0);
|
|
// Two tickers, one burning and one not, so the row that wins is a claim and not a default.
|
|
s.run(
|
|
r#"
|
|
C_Timer.NewTicker(0, function() local y = 1 end)
|
|
C_Timer.NewTicker(0, function()
|
|
local x = 0
|
|
for i = 1, 60000 do x = x + i end
|
|
end)
|
|
"#,
|
|
)
|
|
.expect("chunk");
|
|
let mut script = s;
|
|
script.tick(0.016);
|
|
let rows = script.timer_profile();
|
|
assert_eq!(rows.len(), 2, "one row per site: {rows:?}");
|
|
// The chunk's own lines: 1 is the newline after `r#"`, 2 the cheap ticker, 3 the `function()`
|
|
// the burning body opens on. Both tickers are armed from the *same* two-line region, so a
|
|
// label that named the file, or the arm site, or the chunk, would fold them into one row —
|
|
// the two line numbers are the whole claim.
|
|
assert_eq!(
|
|
(
|
|
rows[0].site.rsplit(':').next(),
|
|
rows[1].site.rsplit(':').next()
|
|
),
|
|
(Some("3"), Some("2")),
|
|
"each row must name the line its own callback was written on: {rows:?}"
|
|
);
|
|
assert!(
|
|
rows[0].us_per_tick > rows[1].us_per_tick,
|
|
"and the burning one must be the one it blames: {rows:?}"
|
|
);
|
|
assert_eq!(
|
|
(rows[0].fires_per_tick, rows[0].rows_per_tick),
|
|
(1.0, 1.0),
|
|
"one fire from one armed row — the pair that separates a slow callback from a leak \
|
|
of cheap ones: {rows:?}"
|
|
);
|
|
// The split is a decomposition here too: the sites must account for the phase.
|
|
let phase = script
|
|
.tick_profile()
|
|
.into_iter()
|
|
.find(|r| r.phase == "timers")
|
|
.expect("a timers row")
|
|
.us_per_tick;
|
|
let sites: f64 = rows.iter().map(|r| r.us_per_tick).sum();
|
|
assert!(
|
|
sites >= phase * 0.80,
|
|
"the per-site rows must account for the phase they split: {sites:.1} us against the \
|
|
phase's {phase:.1} us"
|
|
);
|
|
}
|
|
}
|