Skip to main content

shuyi_cli/contract/
perf.rs

1//! Perf traces as the contract hands them to agents (ADR-0046 §7): the
2//! frames' records a session kept, read as a stream ([`PerfRead`]), summed
3//! up ([`summary`]), and written as JSON lines ([`lines`]).
4//!
5//! `run --perf` answers a [`PerfSummary`], and with `--perf-out` writes
6//! every kept frame as a line; the MCP server's `perf` tool answers the
7//! records since a number.
8//!
9//! In a debug build of a game's tool program each record has the heap's
10//! counts too (`FrameRecord::heap`), from the program's own heap counter
11//! (`shuyi_alloc_count::global`), and the summary sums them up
12//! ([`HeapSummary`]). The counter counts the whole program: every thread's
13//! allocations. Where it counts by memory tag, `run --perf` takes a heap
14//! snapshot before its script and one after its last frame, and the
15//! summary has what held the heap and what grew ([`add_heap`]); the trace
16//! file ends with the two snapshots and their diff ([`heap_lines`]).
17
18use std::collections::BTreeMap;
19use std::fmt::Write as _;
20use std::sync::atomic::{AtomicBool, Ordering};
21
22use schemars::JsonSchema;
23use serde::{Deserialize, Serialize};
24use shuyi_ui::perf::{
25    FrameRecord, HeapDiff, HeapSnapshot, PERF_FRAMES, PerfLine, PerfRing, Spread, TagDelta, TagRow,
26    bytes_text, spread,
27};
28
29/// How many of the spans with the most time a [`PerfSummary`] names.
30pub const TOP_SPANS: usize = 5;
31
32/// How many of a snapshot's rows, and of a diff's, a [`PerfSummary`]
33/// keeps.
34pub const HEAP_ROWS: usize = 10;
35
36/// How many of a diff's changes the line for people names.
37pub const TEXT_CHANGES: usize = 3;
38
39/// The limit every answer with perf has: what its times are.
40pub const TIMES: &str = "times are this machine's, from an unoptimized build unless it says \
41                         otherwise: compare runs on the same machine";
42
43/// The limit of a read whose `since` is past its `next`.
44pub const RESET: &str = "the session was reset or started again: read from 0";
45
46/// The limit of records measured with no heap counts.
47pub const HEAP_UNCOUNTED: &str =
48    "the heap isn't counted: the game's tool program is a release build, or opted out";
49
50/// The limit of heap counts with no snapshot by tag: the heap was counted,
51/// but not by memory tag.
52pub const HEAP_UNTAGGED: &str =
53    "the heap isn't counted by memory tag, so no heap snapshot was taken";
54
55/// The limit of heap counts from this program's own counter while it ran
56/// another thread beside the game's.
57pub const HEAP_THREADS: &str = "the heap counts the whole tool program: another thread ran beside \
58                                the game, and its allocations count too";
59
60/// Whether this program started a thread of its own beside the game's
61/// (the MCP server's reader): the heap counter counts its allocations too.
62static OTHER_THREADS: AtomicBool = AtomicBool::new(false);
63
64/// Notes that this program runs a thread beside the game's, from now on.
65pub fn note_other_thread() {
66    OTHER_THREADS.store(true, Ordering::Relaxed);
67}
68
69/// Whether this program ran a thread beside the game's
70/// ([`note_other_thread`]).
71pub fn other_threads() -> bool {
72    OTHER_THREADS.load(Ordering::Relaxed)
73}
74
75/// The records a session kept from a number on: a read of its stream.
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
77#[serde(deny_unknown_fields)]
78// An answer's, read field by field: it may grow (ADR-0039).
79#[non_exhaustive]
80pub struct PerfRead {
81    /// The kept records numbered `since` or more, oldest first, the first
82    /// `max` of them.
83    pub records: Vec<FrameRecord>,
84    /// The number to read from next time: the one after the last record
85    /// answered. Less than `since` when the session started again.
86    pub next: u64,
87    /// How many records numbered `since` or more were no longer kept.
88    pub dropped: u64,
89    /// Whether anything was measured.
90    pub measured: bool,
91}
92
93impl PerfRead {
94    /// A read of nothing, from `since`, with nothing measured: a session
95    /// that doesn't measure.
96    pub fn none(since: u64) -> Self {
97        Self {
98            records: Vec::new(),
99            next: since,
100            dropped: 0,
101            measured: false,
102        }
103    }
104
105    /// The first `max` records of `ring` numbered `since` or more. A
106    /// `since` past the ring's next number (a ring of a game started again)
107    /// reads nothing, with `next` 0.
108    pub fn of(ring: &PerfRing, since: u64, max: u32) -> Self {
109        let next_seq = ring.next_seq();
110        let kept = u64::try_from(ring.records().len()).unwrap_or(u64::MAX);
111        let first = next_seq.saturating_sub(kept);
112        let measured = next_seq > 0;
113        if since > next_seq {
114            return Self {
115                records: Vec::new(),
116                next: 0,
117                dropped: 0,
118                measured,
119            };
120        }
121        let max = usize::try_from(max).unwrap_or(usize::MAX);
122        let records: Vec<FrameRecord> = ring.since(since).take(max).cloned().collect();
123        let start = since.max(first);
124        Self {
125            next: start + u64::try_from(records.len()).unwrap_or(u64::MAX),
126            dropped: first.saturating_sub(since),
127            records,
128            measured,
129        }
130    }
131}
132
133/// A span's name and how its time adds up over the records.
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
135#[serde(deny_unknown_fields)]
136// An answer's, read field by field: it may grow (ADR-0039).
137#[non_exhaustive]
138pub struct SpanSummary {
139    /// What the game called it (`ctx.span("ai", …)`).
140    pub name: String,
141    /// How many times it ended.
142    pub count: u32,
143    /// Its time in all, in microseconds.
144    pub total_us: u64,
145    /// Its mean time, in microseconds, rounded down.
146    pub mean_us: u32,
147    /// Its longest time, in microseconds.
148    pub worst_us: u32,
149}
150
151/// How the heap's counts add up over a trace's records, in bytes asked for.
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
153#[serde(deny_unknown_fields)]
154// An answer's, read field by field: it may grow (ADR-0039).
155#[non_exhaustive]
156pub struct HeapSummary {
157    /// The most bytes live at once, the most of the records' peaks.
158    pub peak_bytes: u64,
159    /// The bytes live at the last record's end.
160    pub live_bytes: u64,
161    /// How the allocations per frame spread.
162    pub allocs: Spread,
163}
164
165/// What a trace's records add up to.
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
167#[serde(deny_unknown_fields)]
168// An answer's, read field by field: it may grow (ADR-0039).
169#[non_exhaustive]
170pub struct PerfSummary {
171    /// The records summed up: the frames kept.
172    pub frames: u32,
173    /// How many earlier frames' records were no longer kept.
174    pub dropped: u64,
175    /// How the whole frame's time spread (`frame_us`).
176    pub frame: Spread,
177    /// How the update's spread (`update_us`).
178    pub update: Spread,
179    /// How the drawing's spread (`draw_us`).
180    pub draw: Spread,
181    /// How painting into the window spread (`paint_us`).
182    pub paint: Spread,
183    /// How the backend's swap and wait spread (`present_us`).
184    pub present: Spread,
185    /// The spans with the most time in all, most first (ties by name), at
186    /// most five.
187    pub spans: Vec<SpanSummary>,
188    /// The heap's counts over the records that have them; null where none
189    /// has (a release build of the tool program, or one opted out).
190    pub heap: Option<HeapSummary>,
191    /// What held the heap after the last frame: the snapshot's first ten
192    /// rows by tag, the most live bytes first. Empty without a snapshot.
193    pub heap_tags: Vec<TagRow>,
194    /// What grew and shrank from the snapshot before the run to the one
195    /// after: the diff, with its first ten rows, the largest change first.
196    /// Null without snapshots.
197    pub heap_growth: Option<HeapDiff>,
198    /// The trace file written, its absolute path, with `--perf-out`.
199    pub file: Option<String>,
200}
201
202/// What `records` add up to, or `None` for no records; nothing dropped and
203/// no file.
204pub fn summary(records: &[FrameRecord]) -> Option<PerfSummary> {
205    let of = |part: fn(&FrameRecord) -> u32| spread(records.iter().map(part));
206    let mut spans: BTreeMap<&str, (u32, u64, u32)> = BTreeMap::new();
207    for span in records.iter().flat_map(|r| &r.spans) {
208        let (count, total, worst) = spans.entry(&span.name).or_default();
209        *count = count.saturating_add(1);
210        *total += u64::from(span.us);
211        *worst = (*worst).max(span.us);
212    }
213    let mut spans: Vec<SpanSummary> = spans
214        .into_iter()
215        .map(|(name, (count, total_us, worst_us))| SpanSummary {
216            name: name.to_owned(),
217            count,
218            total_us,
219            mean_us: u32::try_from(total_us / u64::from(count.max(1))).unwrap_or(u32::MAX),
220            worst_us,
221        })
222        .collect();
223    // A stable sort of spans in name order: ties stay by name.
224    spans.sort_by_key(|span| std::cmp::Reverse(span.total_us));
225    spans.truncate(TOP_SPANS);
226    Some(PerfSummary {
227        frames: u32::try_from(records.len()).unwrap_or(u32::MAX),
228        dropped: 0,
229        frame: of(|r| r.times.frame_us)?,
230        update: of(|r| r.times.update_us)?,
231        draw: of(|r| r.times.draw_us)?,
232        paint: of(|r| r.times.paint_us)?,
233        present: of(|r| r.times.present_us)?,
234        spans,
235        heap: heap_summary(records),
236        heap_tags: Vec::new(),
237        heap_growth: None,
238        file: None,
239    })
240}
241
242/// Adds the heap snapshots `first` (before a run) and `last` (after it)
243/// to `summary`: `last`'s first [`HEAP_ROWS`] rows, and the diff from
244/// `first` to `last` with its first [`HEAP_ROWS`].
245pub fn add_heap(summary: &mut PerfSummary, first: &HeapSnapshot, last: &HeapSnapshot) {
246    summary.heap_tags = last.tags.iter().take(HEAP_ROWS).cloned().collect();
247    let mut growth = first.diff(last);
248    growth.tags.truncate(HEAP_ROWS);
249    summary.heap_growth = Some(growth);
250}
251
252/// How the heap's counts add up over the records that have them, or
253/// `None` for none.
254pub fn heap_summary(records: &[FrameRecord]) -> Option<HeapSummary> {
255    let heaps: Vec<_> = records.iter().filter_map(|r| r.heap).collect();
256    let last = heaps.last()?;
257    Some(HeapSummary {
258        peak_bytes: heaps.iter().map(|h| h.peak_bytes).max().unwrap_or(0),
259        live_bytes: last.live_bytes,
260        allocs: spread(heaps.iter().map(|h| h.allocs))?,
261    })
262}
263
264/// The limits of `records`' heap counts: none counted, or counted by this
265/// program's own counter (`process`) while another thread ran
266/// (`other_threads`).
267pub fn heap_limits(records: &[FrameRecord], process: bool, other_threads: bool) -> Vec<String> {
268    if records.is_empty() {
269        Vec::new()
270    } else if records.iter().all(|r| r.heap.is_none()) {
271        vec![HEAP_UNCOUNTED.to_owned()]
272    } else if process && other_threads {
273        vec![HEAP_THREADS.to_owned()]
274    } else {
275        Vec::new()
276    }
277}
278
279/// `records` as a trace: a [`PerfLine::Frame`] each, as JSON, a line each,
280/// oldest first.
281pub fn lines<'a>(records: impl IntoIterator<Item = &'a FrameRecord>) -> String {
282    let mut out = String::new();
283    for record in records {
284        // A record of numbers and names: serializing can't fail.
285        let line = serde_json::to_string(&PerfLine::Frame(record.clone())).unwrap_or_default();
286        out.push_str(&line);
287        out.push('\n');
288    }
289    out
290}
291
292/// The heap snapshots `first` and `last` as a trace's last lines: a
293/// [`PerfLine::Snapshot`] each, then the [`PerfLine::Diff`] between them.
294pub fn heap_lines(first: &HeapSnapshot, last: &HeapSnapshot) -> String {
295    let lines = [
296        PerfLine::Snapshot(first.clone()),
297        PerfLine::Snapshot(last.clone()),
298        PerfLine::Diff(first.diff(last)),
299    ];
300    let mut out = String::new();
301    for line in lines {
302        // Numbers and names: serializing can't fail.
303        out.push_str(&serde_json::to_string(&line).unwrap_or_default());
304        out.push('\n');
305    }
306    out
307}
308
309/// The limit of a trace that dropped `dropped` earlier frames.
310pub fn dropped_limit(dropped: u64) -> String {
311    format!(
312        "the trace keeps the last {PERF_FRAMES} frames: {dropped} earlier frame(s) were dropped"
313    )
314}
315
316/// The limit of a session that measured nothing after `frames` frames.
317pub fn unmeasured_limit(frames: u32) -> String {
318    if frames == 0 {
319        "nothing was measured: no frame ran".to_owned()
320    } else {
321        "nothing was measured: the game's tool program was built without debug tools".to_owned()
322    }
323}
324
325/// `us` microseconds as milliseconds with one decimal, rounded to the
326/// nearest tenth, in integers: `1250` is `1.3`.
327pub fn millis(us: u32) -> String {
328    let tenths = (u64::from(us) + 50) / 100;
329    format!("{}.{}", tenths / 10, tenths % 10)
330}
331
332/// `bytes` with its sign: `+96.0 KB`, `-512 B`.
333fn signed_bytes(bytes: i64) -> String {
334    let sign = if bytes < 0 { '-' } else { '+' };
335    format!("{sign}{}", bytes_text(bytes.unsigned_abs()))
336}
337
338/// The change `delta` for people: its name and its bytes, signed.
339fn delta_text(delta: &TagDelta) -> String {
340    format!("{} {}", delta.name, signed_bytes(delta.live_bytes))
341}
342
343/// `diff`'s line for people: `heap grew 120.0 KB from frame 0 to 600:
344/// update +96.0 KB, image +24.0 KB, ai +4.0 KB`, its [`TEXT_CHANGES`]
345/// largest changes; `heap shrank …` for fewer bytes, `heap held its size
346/// …` for as many; `None` when nothing changed.
347pub fn heap_text(diff: &HeapDiff) -> Option<String> {
348    if diff.live_bytes == 0 && diff.tags.is_empty() {
349        return None;
350    }
351    let size = bytes_text(diff.live_bytes.unsigned_abs());
352    let mut out = match diff.live_bytes.signum() {
353        1 => format!("heap grew {size}"),
354        -1 => format!("heap shrank {size}"),
355        _ => "heap held its size".to_owned(),
356    };
357    let _ = write!(out, " from frame {} to {}", diff.from, diff.to);
358    let changes: Vec<String> = diff
359        .tags
360        .iter()
361        .take(TEXT_CHANGES)
362        .map(delta_text)
363        .collect();
364    if !changes.is_empty() {
365        let _ = write!(out, ": {}", changes.join(", "));
366    }
367    Some(out)
368}
369
370/// `summary`'s line for people: `perf: 600 frames, frame p50 1.2 ms p99
371/// 3.4 ms worst 8.0 ms; span ai 0.8 ms mean; heap peak 4.1 MB`, then what
372/// grew in the heap ([`heap_text`]) and the trace file's.
373pub fn text(summary: &PerfSummary) -> String {
374    let frame = &summary.frame;
375    let mut out = format!(
376        "perf: {} frames, frame p50 {} ms p99 {} ms worst {} ms",
377        summary.frames,
378        millis(frame.p50),
379        millis(frame.p99),
380        millis(frame.worst)
381    );
382    if let Some(span) = summary.spans.first() {
383        let _ = write!(out, "; span {} {} ms mean", span.name, millis(span.mean_us));
384    }
385    if let Some(heap) = &summary.heap {
386        let _ = write!(out, "; heap peak {}", bytes_text(heap.peak_bytes));
387    }
388    out.push('\n');
389    if let Some(line) = summary.heap_growth.as_ref().and_then(heap_text) {
390        out.push_str(&line);
391        out.push('\n');
392    }
393    if let Some(file) = &summary.file {
394        let _ = writeln!(out, "trace: {file}");
395    }
396    out
397}
398
399#[cfg(test)]
400mod tests;