Skip to main content

retex_engine/
profile.rs

1//! The profiler behind `retex profile`, and the origins of macro
2//! definitions behind `retex explain` (`docs/design/profiling.md`).
3//!
4//! **Sampling.** A timer thread adds the time elapsed to [`PENDING`] every
5//! millisecond. The engine looks at it at each macro call and each step
6//! of the main loop ([`Engine::profile_tick`]), and charges the time to
7//! its current state: the files and macros on the input stack, and the
8//! engine work under way ([`Work`], marked by [`Engine::work_begin`] and
9//! [`Engine::work_end`]). Without a profiler the counter stays zero, and
10//! the check is one atomic load.
11//!
12//! A macro leaves the input stack when its last token is read (TeX's
13//! `back_list` tail calls), so the time including a macro's callees is a
14//! lower bound.
15//!
16//! **Origins.** With [`Origins`] on, each macro definition records the
17//! innermost file being read and its line ([`Macro::origin`]). A file
18//! belongs to a package: on the input stack when the file is opened, the
19//! first file above the document's own files that is not one of them.
20//!
21//! [`Macro::origin`]: crate::token::Macro::origin
22
23use std::collections::HashMap;
24use std::sync::Arc;
25use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
26use std::time::{Duration, Instant};
27
28use crate::Engine;
29use crate::hash::{CsId, NO_CS};
30use crate::input::{Level, LineSource};
31
32/// Microseconds elapsed and not yet charged to the engine's state.
33pub static PENDING: AtomicU64 = AtomicU64::new(0);
34
35/// The interval between two samples.
36pub const INTERVAL: Duration = Duration::from_millis(1);
37
38/// Engine work, charged on its own besides the macros that started it.
39#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
40pub enum Work {
41    /// Lua code (`\directlua`, `\luafunction`, callbacks).
42    Lua,
43    /// Loading a font (`define_font`).
44    Fonts,
45    /// Breaking a paragraph into lines.
46    LineBreak,
47    /// Shipping a page out (the page's PDF).
48    Shipout,
49    /// Reading an image (`\saveimageresource`, `\pdfximage`).
50    Images,
51}
52
53impl Work {
54    /// A short description.
55    pub fn label(self) -> &'static str {
56        match self {
57            Work::Lua => "Lua",
58            Work::Fonts => "font loading",
59            Work::LineBreak => "line breaking",
60            Work::Shipout => "shipping pages (PDF)",
61            Work::Images => "images",
62        }
63    }
64}
65
66/// A file read by the job.
67#[derive(Clone, Debug, PartialEq, Eq)]
68pub struct FileInfo {
69    /// The name, as TeX prints it.
70    pub name: String,
71    /// The package it belongs to; `None` for the document's own files.
72    pub package: Option<String>,
73}
74
75/// Where macros were defined.
76#[derive(Default)]
77pub struct Origins {
78    /// The files, by id - 1 ([`crate::input::TextLevel::profile_file`]).
79    pub files: Vec<FileInfo>,
80    /// Origins (file id, line), by origin - 1.
81    places: Vec<(u32, u32)>,
82    /// Origin of each place.
83    ids: HashMap<(u32, u32), u32>,
84    /// For `retex explain`: the control sequences whose definitions are
85    /// recorded, with the origins of their meanings, in order.
86    pub history: HashMap<CsId, Vec<u32>>,
87}
88
89impl Origins {
90    /// The file and line of `origin`, if it is one.
91    pub fn place(&self, origin: u32) -> Option<(&FileInfo, u32)> {
92        let &(file, line) = self.places.get(origin.checked_sub(1)? as usize)?;
93        Some((self.files.get(file.checked_sub(1)? as usize)?, line))
94    }
95
96    /// The file id of `origin`.
97    pub fn file_of(&self, origin: u32) -> Option<u32> {
98        self.places
99            .get(origin.checked_sub(1)? as usize)
100            .map(|p| p.0)
101    }
102}
103
104/// Whether a file is one of the document's own: TeX sources and the
105/// auxiliary files LaTeX writes.
106fn is_document_file(name: &str) -> bool {
107    let ext = std::path::Path::new(name)
108        .extension()
109        .map(|e| e.to_string_lossy().to_ascii_lowercase());
110    matches!(
111        ext.as_deref(),
112        None | Some(
113            "tex"
114                | "ltx"
115                | "aux"
116                | "toc"
117                | "lof"
118                | "lot"
119                | "bbl"
120                | "ind"
121                | "gls"
122                | "out"
123                | "nav"
124                | "snm"
125                | "vrb"
126                | "loa"
127                | "thm"
128        )
129    )
130}
131
132/// The package name of file `name`: `tikz` for `tikz.sty`, `article
133/// (class)` for `article.cls`, the file name otherwise.
134fn package_name(name: &str) -> String {
135    let path = std::path::Path::new(name);
136    let file = path
137        .file_name()
138        .map_or_else(|| name.to_owned(), |f| f.to_string_lossy().into_owned());
139    let stem = path
140        .file_stem()
141        .map_or_else(|| file.clone(), |f| f.to_string_lossy().into_owned());
142    match path.extension().and_then(|e| e.to_str()) {
143        Some("sty") => stem,
144        Some("cls") => format!("{stem} (class)"),
145        _ => file,
146    }
147}
148
149/// A frame of a sample's stack.
150#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
151pub enum Frame {
152    /// A file being read (file id).
153    File(u32),
154    /// A macro being expanded, with the origin of its meaning.
155    Macro(CsId, u32),
156    /// Engine work.
157    Work(Work),
158}
159
160/// The sampling profiler.
161pub struct Profiler {
162    /// Stops the timer thread.
163    stop: Arc<AtomicBool>,
164    timer: Option<std::thread::JoinHandle<()>>,
165    started: Instant,
166    /// Time charged to each stack, in microseconds.
167    stacks: HashMap<Box<[Frame]>, u64>,
168    /// The work under way, with the input stack depth where it began.
169    work: Vec<(Work, usize)>,
170    /// Calls per control sequence, by id.
171    calls: Vec<u64>,
172    /// When the first file was read.
173    first_file: Option<Duration>,
174    /// When `\document` was first called.
175    document: Option<Duration>,
176    /// When each page was shipped out.
177    pages: Vec<Duration>,
178}
179
180impl Profiler {
181    /// Starts a profiler and its timer thread.
182    fn start() -> Profiler {
183        PENDING.store(0, Ordering::Relaxed);
184        let stop = Arc::new(AtomicBool::new(false));
185        let flag = Arc::clone(&stop);
186        let timer = std::thread::Builder::new()
187            .name("profiler".to_owned())
188            .spawn(move || {
189                let mut last = Instant::now();
190                while !flag.load(Ordering::Relaxed) {
191                    std::thread::sleep(INTERVAL);
192                    let now = Instant::now();
193                    let us = (now - last).as_micros() as u64;
194                    last = now;
195                    PENDING.fetch_add(us.max(1), Ordering::Relaxed);
196                }
197            })
198            .ok();
199        Profiler {
200            stop,
201            timer,
202            started: Instant::now(),
203            stacks: HashMap::new(),
204            work: Vec::new(),
205            calls: Vec::new(),
206            first_file: None,
207            document: None,
208            pages: Vec::new(),
209        }
210    }
211
212    fn stop_timer(&mut self) {
213        self.stop.store(true, Ordering::Relaxed);
214        if let Some(t) = self.timer.take() {
215            let _ = t.join();
216        }
217    }
218}
219
220impl Drop for Profiler {
221    fn drop(&mut self) {
222        self.stop_timer();
223        PENDING.store(0, Ordering::Relaxed);
224    }
225}
226
227/// A frame of a [`Summary`], resolved.
228#[derive(Clone, Debug, PartialEq, Eq)]
229pub enum SummaryFrame {
230    /// A file.
231    File {
232        /// The file name, as TeX prints it.
233        name: String,
234        /// The package, `None` for the document's own files.
235        package: Option<String>,
236    },
237    /// A macro.
238    Macro {
239        /// The name, without the backslash.
240        name: String,
241        /// The file and line of the definition; `None` if unknown (the
242        /// format's macros).
243        defined: Option<(String, u32)>,
244        /// The package of that file; `None` for the document's own files
245        /// and for unknown definitions.
246        package: Option<String>,
247    },
248    /// Engine work.
249    Work(Work),
250}
251
252/// What a profile found.
253#[derive(Clone, Debug, Default)]
254pub struct Summary {
255    /// The time from the start of the profile to its end.
256    pub total: Duration,
257    /// When the first file was read.
258    pub first_file: Option<Duration>,
259    /// When the preamble ended (`\document`).
260    pub document: Option<Duration>,
261    /// When each page was shipped out.
262    pub pages: Vec<Duration>,
263    /// The frames of the stacks.
264    pub frames: Vec<SummaryFrame>,
265    /// The stacks, from the outermost frame (indices in `frames`), and
266    /// the microseconds charged to each.
267    pub stacks: Vec<(Vec<u32>, u64)>,
268    /// Calls per macro name, for the macros called.
269    pub calls: HashMap<String, u64>,
270}
271
272impl Engine {
273    /// Turns on the recording of where macros are defined.
274    pub fn record_origins(&mut self) {
275        if self.origins.is_none() {
276            self.origins = Some(Box::default());
277        }
278    }
279
280    /// Starts profiling (and recording origins).
281    pub fn start_profile(&mut self) {
282        self.record_origins();
283        self.profiler = Some(Box::new(Profiler::start()));
284    }
285
286    /// Charges the time elapsed since the last sample, if any, to the
287    /// current state. Called often: cheap when there is nothing to do.
288    #[inline]
289    pub fn profile_tick(&mut self) {
290        if PENDING.load(Ordering::Relaxed) != 0 {
291            self.take_sample(None);
292        }
293    }
294
295    /// Records a call of `cs` (from `macro_call`).
296    pub(crate) fn profile_call(&mut self, cs: CsId) {
297        // The caller's level may be gone already (its last token was
298        // read), and the macro's is not there yet: the time goes to the
299        // macro called.
300        if PENDING.load(Ordering::Relaxed) != 0 {
301            self.take_sample(Some(cs));
302        }
303        let Some(p) = self.profiler.as_mut() else {
304            return;
305        };
306        let i = cs.0 as usize;
307        if p.calls.len() <= i {
308            p.calls.resize(i + 1, 0);
309        }
310        p.calls[i] += 1;
311        // LaTeX's `\document` ends the preamble.
312        if p.document.is_none() && self.hash.name(cs) == b"document" {
313            p.document = Some(p.started.elapsed());
314        }
315    }
316
317    /// Marks the beginning of engine work `w`.
318    pub fn work_begin(&mut self, w: Work) {
319        if self.profiler.is_none() {
320            return;
321        }
322        self.profile_tick();
323        let depth = self.input.stack.len();
324        if let Some(p) = self.profiler.as_mut() {
325            p.work.push((w, depth));
326        }
327    }
328
329    /// Marks the end of the innermost engine work.
330    pub fn work_end(&mut self) {
331        if self.profiler.is_none() {
332            return;
333        }
334        self.profile_tick();
335        if let Some(p) = self.profiler.as_mut()
336            && let Some((w, _)) = p.work.pop()
337            && w == Work::Shipout
338        {
339            p.pages.push(p.started.elapsed());
340        }
341    }
342
343    /// Charges the pending time to the current state, with the macro
344    /// `called` innermost.
345    #[cold]
346    fn take_sample(&mut self, called: Option<CsId>) {
347        let us = PENDING.swap(0, Ordering::Relaxed);
348        if us == 0 || self.profiler.is_none() {
349            return;
350        }
351        let mut frames = Vec::new();
352        let work = self
353            .profiler
354            .as_ref()
355            .map(|p| p.work.clone())
356            .unwrap_or_default();
357        let mut work = work.into_iter().peekable();
358        let mut saw_file = false;
359        for i in 0..self.input.stack.len() {
360            while let Some(&(w, _)) = work.peek().filter(|(_, d)| *d <= i) {
361                frames.push(Frame::Work(w));
362                work.next();
363            }
364            match &self.input.stack[i] {
365                Level::Text(t) if matches!(t.source, LineSource::File { .. }) => {
366                    saw_file = true;
367                    let id = self.file_id(i);
368                    frames.push(Frame::File(id));
369                }
370                Level::Tokens(t) if t.name != NO_CS => {
371                    let origin = self
372                        .eqtb
373                        .meaning(t.name)
374                        .mac
375                        .as_ref()
376                        .map_or(0, |m| m.origin.get());
377                    frames.push(Frame::Macro(t.name, origin));
378                }
379                _ => {}
380            }
381        }
382        frames.extend(work.map(|(w, _)| Frame::Work(w)));
383        if let Some(cs) = called {
384            let origin = self
385                .eqtb
386                .meaning(cs)
387                .mac
388                .as_ref()
389                .map_or(0, |m| m.origin.get());
390            frames.push(Frame::Macro(cs, origin));
391        }
392        let Some(p) = self.profiler.as_mut() else {
393            return;
394        };
395        if saw_file && p.first_file.is_none() {
396            p.first_file = Some(p.started.elapsed());
397        }
398        *p.stacks.entry(frames.into_boxed_slice()).or_default() += us;
399    }
400
401    /// The file id of the text level at `i` of the input stack (a file),
402    /// given on first use.
403    fn file_id(&mut self, i: usize) -> u32 {
404        let Some(Level::Text(t)) = self.input.stack.get(i) else {
405            return 0;
406        };
407        if t.profile_file != 0 {
408            return t.profile_file;
409        }
410        let Some(origins) = self.origins.as_mut() else {
411            return 0;
412        };
413        // The files being read, from the outermost: the first is the
414        // document; then the document's own files; the first other file
415        // gives the package.
416        let names: Vec<String> = self.input.stack[..=i]
417            .iter()
418            .filter_map(|l| match l {
419                Level::Text(t) if matches!(t.source, LineSource::File { .. }) => {
420                    Some(String::from_utf8_lossy(&t.name).into_owned())
421                }
422                _ => None,
423            })
424            .collect();
425        let package = names
426            .iter()
427            .skip(1)
428            .find(|n| !is_document_file(n))
429            .map(|n| package_name(n));
430        let name = names.last().cloned().unwrap_or_default();
431        origins.files.push(FileInfo { name, package });
432        let id = origins.files.len() as u32;
433        if let Some(Level::Text(t)) = self.input.stack.get_mut(i) {
434            t.profile_file = id;
435        }
436        id
437    }
438
439    /// The origin of a definition made now: the innermost file being read
440    /// and its line (0 without [`Origins`] or outside files).
441    pub fn origin_here(&mut self) -> u32 {
442        if self.origins.is_none() {
443            return 0;
444        }
445        let Some((i, line)) =
446            self.input
447                .stack
448                .iter()
449                .enumerate()
450                .rev()
451                .find_map(|(i, l)| match l {
452                    Level::Text(t) if matches!(t.source, LineSource::File { .. }) => {
453                        Some((i, t.line.max(0) as u32))
454                    }
455                    _ => None,
456                })
457        else {
458            return 0;
459        };
460        let file = self.file_id(i);
461        let Some(o) = self.origins.as_mut() else {
462            return 0;
463        };
464        if let Some(&id) = o.ids.get(&(file, line)) {
465            return id;
466        }
467        o.places.push((file, line));
468        let id = o.places.len() as u32;
469        o.ids.insert((file, line), id);
470        id
471    }
472
473    /// Records a new meaning of `cs` for `retex explain`, if asked.
474    pub(crate) fn record_definition(&mut self, cs: CsId) {
475        let Some(o) = self.origins.as_ref() else {
476            return;
477        };
478        if !o.history.contains_key(&cs) {
479            return;
480        }
481        let origin = match &self.eqtb.meaning(cs).mac {
482            Some(m) if m.origin.get() != 0 => m.origin.get(),
483            _ => self.origin_here(),
484        };
485        if let Some(h) = self.origins.as_mut().and_then(|o| o.history.get_mut(&cs)) {
486            h.push(origin);
487        }
488    }
489
490    /// The `origins` section of a format, when origins are recorded: the
491    /// files, the places, and the origin of each control sequence's
492    /// macro.
493    pub(crate) fn dump_origins(&self) -> Option<Vec<u8>> {
494        let o = self.origins.as_ref()?;
495        let mut w = crate::format::Writer::default();
496        w.u(o.files.len() as u64);
497        for f in &o.files {
498            w.bytes(f.name.as_bytes());
499        }
500        w.u(o.places.len() as u64);
501        for &(file, line) in &o.places {
502            w.u(file.into());
503            w.u(line.into());
504        }
505        let macros: Vec<(usize, u32)> = self
506            .eqtb
507            .cs
508            .iter()
509            .enumerate()
510            .filter_map(|(i, s)| Some((i, s.val.mac.as_ref()?.origin.get())))
511            .filter(|&(_, o)| o != 0)
512            .collect();
513        w.u(macros.len() as u64);
514        for (cs, origin) in macros {
515            w.u(cs as u64);
516            w.u(origin.into());
517        }
518        Some(w.buf)
519    }
520
521    /// Loads the `origins` section of a format, when origins are
522    /// recorded. The format's files belong to expl3 (`expl3-code.tex`,
523    /// `l3….tex`) or to the LaTeX kernel (or the format).
524    pub(crate) fn undump_origins(&mut self, data: &[u8]) -> Result<(), crate::format::FormatError> {
525        if self.origins.is_none() {
526            return Ok(());
527        }
528        let kernel = if self.latex_release().is_some() {
529            "LaTeX kernel".to_owned()
530        } else {
531            format!("format {}", self.format_name)
532        };
533        let mut r = crate::format::Reader::new(data);
534        let mut o = Origins::default();
535        for _ in 0..r.u()? {
536            let name = String::from_utf8_lossy(r.bytes()?).into_owned();
537            let file = std::path::Path::new(&name)
538                .file_name()
539                .map(|f| f.to_string_lossy().into_owned())
540                .unwrap_or_default();
541            let package = if file.starts_with("expl3") || file.starts_with("l3") {
542                "expl3".to_owned()
543            } else {
544                kernel.clone()
545            };
546            o.files.push(FileInfo {
547                name,
548                package: Some(package),
549            });
550        }
551        for i in 0..r.u()? {
552            let place = (r.u32()?, r.u32()?);
553            o.places.push(place);
554            o.ids.insert(place, i as u32 + 1);
555        }
556        for _ in 0..r.u()? {
557            let (cs, origin) = (r.u()? as usize, r.u32()?);
558            if let Some(m) = self.eqtb.cs.get(cs).and_then(|s| s.val.mac.as_ref()) {
559                m.origin.set(origin);
560            }
561        }
562        // What was recorded before (nothing is, before a format loads)
563        // keeps its numbers after the format's.
564        if let Some(old) = self.origins.take() {
565            o.history = old.history;
566        }
567        self.origins = Some(Box::new(o));
568        Ok(())
569    }
570
571    /// Stops the profiler and returns what it found.
572    pub fn finish_profile(&mut self) -> Option<Summary> {
573        self.profile_tick();
574        let mut p = self.profiler.take()?;
575        p.stop_timer();
576        let total = p.started.elapsed();
577        // The time since the last sample.
578        let rest = PENDING.swap(0, Ordering::Relaxed);
579        if rest > 0 {
580            *p.stacks.entry(Box::default()).or_default() += rest;
581        }
582        let mut s = Summary {
583            total,
584            first_file: p.first_file,
585            document: p.document,
586            pages: std::mem::take(&mut p.pages),
587            ..Summary::default()
588        };
589        let mut index: HashMap<Frame, u32> = HashMap::new();
590        for (stack, us) in std::mem::take(&mut p.stacks) {
591            let ids = stack
592                .iter()
593                .map(|f| match index.get(f) {
594                    Some(&i) => i,
595                    None => {
596                        let i = s.frames.len() as u32;
597                        s.frames.push(self.summary_frame(*f));
598                        index.insert(*f, i);
599                        i
600                    }
601                })
602                .collect();
603            s.stacks.push((ids, us));
604        }
605        s.stacks
606            .sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
607        for (i, &n) in p.calls.iter().enumerate() {
608            if n > 0 {
609                let name = String::from_utf8_lossy(self.hash.name(CsId(i as u32))).into_owned();
610                *s.calls.entry(name).or_default() += n;
611            }
612        }
613        Some(s)
614    }
615
616    fn summary_frame(&self, f: Frame) -> SummaryFrame {
617        let file = |id: u32| {
618            self.origins
619                .as_ref()
620                .and_then(|o| o.files.get(id.checked_sub(1)? as usize))
621        };
622        match f {
623            Frame::File(id) => match file(id) {
624                Some(fi) => SummaryFrame::File {
625                    name: fi.name.clone(),
626                    package: fi.package.clone(),
627                },
628                None => SummaryFrame::File {
629                    name: "?".to_owned(),
630                    package: None,
631                },
632            },
633            Frame::Macro(cs, origin) => {
634                let name = String::from_utf8_lossy(self.hash.name(cs)).into_owned();
635                let place = self.origins.as_ref().and_then(|o| o.place(origin));
636                SummaryFrame::Macro {
637                    name,
638                    defined: place.map(|(fi, line)| (fi.name.clone(), line)),
639                    package: place.and_then(|(fi, _)| fi.package.clone()),
640                }
641            }
642            Frame::Work(w) => SummaryFrame::Work(w),
643        }
644    }
645}
646
647#[cfg(test)]
648mod tests {
649    use super::*;
650
651    #[test]
652    fn packages_of_files() {
653        assert!(is_document_file("chapter1.tex"));
654        assert!(is_document_file("./doc.aux"));
655        assert!(is_document_file("Makefile"));
656        assert!(!is_document_file("tikz.sty"));
657        assert_eq!(
658            package_name("/usr/share/texmf/tex/latex/pgf/tikz.sty"),
659            "tikz"
660        );
661        assert_eq!(package_name("article.cls"), "article (class)");
662        assert_eq!(package_name("t1lmr.fd"), "t1lmr.fd");
663    }
664}