tinymist_analysis/
stats.rs

1//! Tinymist Analysis Statistics
2
3use std::fmt::Write;
4use std::sync::atomic::{AtomicUsize, Ordering};
5use std::sync::{Arc, LazyLock};
6
7use parking_lot::Mutex;
8use serde::Serialize;
9use tinymist_std::hash::FxDashMap;
10use tinymist_std::time::Duration;
11use typst::syntax::FileId;
12
13/// Statistics about the allocation
14
15#[derive(Debug, Default)]
16pub struct AllocStats {
17    /// The number of allocated objects.
18    pub allocated: AtomicUsize,
19    /// The number of dropped objects.
20    pub dropped: AtomicUsize,
21}
22
23impl AllocStats {
24    /// increment the statistics.
25    pub fn increment(&self) {
26        self.allocated.fetch_add(1, Ordering::Relaxed);
27    }
28
29    /// decrement the statistics.
30    pub fn decrement(&self) {
31        self.dropped.fetch_add(1, Ordering::Relaxed);
32    }
33
34    /// Report the statistics of the allocation.
35    pub fn report() -> String {
36        let maps = crate::adt::interner::MAPS.lock().clone();
37        let mut data = Vec::new();
38        for (name, sz, map) in maps {
39            let allocated = map.allocated.load(std::sync::atomic::Ordering::Relaxed);
40            let dropped = map.dropped.load(std::sync::atomic::Ordering::Relaxed);
41            let alive = allocated.saturating_sub(dropped);
42            data.push((name, sz * alive, allocated, dropped, alive));
43        }
44
45        // sort by total
46        data.sort_by(|x, y| y.4.cmp(&x.4));
47
48        // format to html
49
50        let mut html = String::new();
51        html.push_str(r#"<div>
52<style>
53table.alloc-stats { width: 100%; border-collapse: collapse; }
54table.alloc-stats th, table.alloc-stats td { border: 1px solid black; padding: 8px; text-align: center; }
55table.alloc-stats th.name-column, table.alloc-stats td.name-column { text-align: left; }
56table.alloc-stats tr:nth-child(odd) { background-color: rgba(242, 242, 242, 0.8); }
57@media (prefers-color-scheme: dark) {
58    table.alloc-stats tr:nth-child(odd) { background-color: rgba(50, 50, 50, 0.8); }
59}
60</style>
61<table class="alloc-stats"><tr><th class="name-column">Name</th><th>Alive</th><th>Allocated</th><th>Dropped</th><th>Size</th></tr>"#);
62
63        for (name, sz, allocated, dropped, alive) in data {
64            html.push_str("<tr>");
65            html.push_str(&format!(r#"<td class="name-column">{name}</td>"#));
66            html.push_str(&format!("<td>{alive}</td>"));
67            html.push_str(&format!("<td>{allocated}</td>"));
68            html.push_str(&format!("<td>{dropped}</td>"));
69            html.push_str(&format!("<td>{}</td>", human_size(sz)));
70            html.push_str("</tr>");
71        }
72        html.push_str("</table>");
73        html.push_str("</div>");
74
75        html
76    }
77}
78
79/// The data of the query statistic.
80#[derive(Clone)]
81pub struct QueryStatBucketData {
82    pub(crate) query: u64,
83    pub(crate) missing: u64,
84    pub(crate) total: Duration,
85    pub(crate) min: Duration,
86    pub(crate) max: Duration,
87}
88
89/// A serializable analysis query statistic entry.
90#[derive(Clone, Debug, Serialize)]
91#[serde(rename_all = "camelCase")]
92pub struct QueryStatReportEntry {
93    /// The file id that owns the query, or `None` for aggregate statistics.
94    pub file: Option<String>,
95    /// The query name.
96    pub query: String,
97    /// The number of query calls.
98    pub count: u64,
99    /// The number of cache misses.
100    pub missing: u64,
101    /// The total query time in milliseconds.
102    pub total_ms: f64,
103    /// The minimum query time in milliseconds.
104    pub min_ms: f64,
105    /// The maximum query time in milliseconds.
106    pub max_ms: f64,
107}
108
109fn duration_ms(duration: Duration) -> f64 {
110    duration.as_secs_f64() * 1000.0
111}
112
113impl Default for QueryStatBucketData {
114    fn default() -> Self {
115        Self {
116            query: 0,
117            missing: 0,
118            total: Duration::from_secs(0),
119            min: Duration::from_secs(u64::MAX),
120            max: Duration::from_secs(0),
121        }
122    }
123}
124
125/// Statistics about some query
126#[derive(Default, Clone)]
127pub struct QueryStatBucket {
128    /// The data of the query statistic.
129    pub data: Arc<Mutex<QueryStatBucketData>>,
130}
131
132impl QueryStatBucket {
133    /// Increment the query statistic.
134    pub fn increment(&self, elapsed: Duration) {
135        let mut data = self.data.lock();
136        data.query += 1;
137        data.total += elapsed;
138        data.min = data.min.min(elapsed);
139        data.max = data.max.max(elapsed);
140    }
141}
142
143/// A guard for the query statistic.
144pub struct QueryStatGuard {
145    /// The bucket of the query statistic for any file.
146    pub bucket_any: Option<QueryStatBucket>,
147    /// The bucket of the query statistic.
148    pub bucket: QueryStatBucket,
149    /// The start time of the query.
150    pub since: tinymist_std::time::Instant,
151}
152
153impl Drop for QueryStatGuard {
154    fn drop(&mut self) {
155        let elapsed = self.since.elapsed();
156        self.bucket.increment(elapsed);
157        if let Some(bucket) = self.bucket_any.as_ref() {
158            bucket.increment(elapsed);
159        }
160    }
161}
162
163impl QueryStatGuard {
164    /// Increment the missing count.
165    pub fn miss(&self) {
166        let mut data = self.bucket.data.lock();
167        data.missing += 1;
168    }
169}
170
171/// Statistics about the analyzers
172#[derive(Default)]
173pub struct AnalysisStats {
174    /// The query statistics.
175    pub query_stats: Arc<FxDashMap<Option<FileId>, FxDashMap<&'static str, QueryStatBucket>>>,
176}
177
178impl AnalysisStats {
179    /// Gets a statistic guard for a query.
180    pub fn stat(&self, id: Option<FileId>, name: &'static str) -> QueryStatGuard {
181        let stats = &self.query_stats;
182        let get = |v| stats.entry(v).or_default().entry(name).or_default().clone();
183        QueryStatGuard {
184            bucket_any: if id.is_some() { Some(get(None)) } else { None },
185            bucket: get(id),
186            since: tinymist_std::time::Instant::now(),
187        }
188    }
189
190    /// Returns a serializable snapshot of the analysis statistics.
191    pub fn report_json(&self) -> Vec<QueryStatReportEntry> {
192        let stats = &self.query_stats;
193        let mut data = Vec::new();
194        for refs in stats.iter() {
195            let id = refs.key();
196            let queries = refs.value();
197            for refs2 in queries.iter() {
198                let query = refs2.key();
199                let bucket = refs2.value().data.lock().clone();
200                let min = if bucket.query == 0 {
201                    Duration::from_secs(0)
202                } else {
203                    bucket.min
204                };
205                data.push(QueryStatReportEntry {
206                    file: id.map(|id| format!("{id:?}").replace('\\', "/")),
207                    query: query.to_string(),
208                    count: bucket.query,
209                    missing: bucket.missing,
210                    total_ms: duration_ms(bucket.total),
211                    min_ms: duration_ms(min),
212                    max_ms: duration_ms(bucket.max),
213                });
214            }
215        }
216
217        data.sort_by(|a, b| a.file.cmp(&b.file).then_with(|| a.query.cmp(&b.query)));
218        data
219    }
220
221    /// Reports the statistics of the analysis.
222    pub fn report(&self) -> String {
223        let stats = &self.query_stats;
224        let mut data = Vec::new();
225        for refs in stats.iter() {
226            let id = refs.key();
227            let queries = refs.value();
228            for refs2 in queries.iter() {
229                let query = refs2.key();
230                let bucket = refs2.value().data.lock().clone();
231                let name = match id {
232                    Some(id) => format!("{id:?}:{query}"),
233                    None => query.to_string(),
234                };
235                let name = name.replace('\\', "/");
236                data.push((name, bucket));
237            }
238        }
239
240        // sort by query duration
241        data.sort_by(|x, y| y.1.max.cmp(&x.1.max));
242
243        // format to html
244
245        let mut html = String::new();
246        html.push_str(r#"<div>
247<style>
248table.analysis-stats { width: 100%; border-collapse: collapse; }
249table.analysis-stats th, table.analysis-stats td { border: 1px solid black; padding: 8px; text-align: center; }
250table.analysis-stats th.name-column, table.analysis-stats td.name-column { text-align: left; }
251table.analysis-stats tr:nth-child(odd) { background-color: rgba(242, 242, 242, 0.8); }
252@media (prefers-color-scheme: dark) {
253    table.analysis-stats tr:nth-child(odd) { background-color: rgba(50, 50, 50, 0.8); }
254}
255</style>
256<table class="analysis-stats"><tr><th class="query-column">Name</th><th>Count</th><th>Missing</th><th>Total</th><th>Min</th><th>Max</th></tr>"#);
257
258        for (name, bucket) in data {
259            let _ = write!(
260                &mut html,
261                "<tr><td class=\"query-column\">{name}</td><td>{}</td><td>{}</td><td>{:?}</td><td>{:?}</td><td>{:?}</td></tr>",
262                bucket.query, bucket.missing, bucket.total, bucket.min, bucket.max
263            );
264        }
265        html.push_str("</table>");
266        html.push_str("</div>");
267
268        html
269    }
270}
271
272/// The global statistics about the analyzers.
273pub static GLOBAL_STATS: LazyLock<AnalysisStats> = LazyLock::new(AnalysisStats::default);
274
275fn human_size(size: usize) -> String {
276    let units = ["B", "KB", "MB", "GB", "TB"];
277    let mut unit = 0;
278    let mut size = size as f64;
279    while size >= 768.0 && unit < units.len() {
280        size /= 1024.0;
281        unit += 1;
282    }
283    format!("{:.2} {}", size, units[unit])
284}