Skip to main content

cove_rules_measure/
main.rs

1//! What compiling once and invoking many times costs, counted.
2//!
3//! Issue #109's gate asks for a compile-once/invoke-many embedding measured on
4//! the compiled backend, and for Host conversion measured beside it. This is
5//! that measurement. It is a binary rather than a `#[test]` for one reason: it
6//! installs a counting [`std::alloc::GlobalAlloc`], and a count taken while
7//! `cargo test` runs other cases on other threads would be a count of the
8//! test harness. Nothing here runs under `cargo test`, and the counts the
9//! README quotes were taken by running it.
10//!
11//! ```text
12//! cargo run --release -p cove-rules --bin cove-rules-measure -- 2000
13//! ```
14//!
15//! # What is a count and what is a time
16//!
17//! The allocation counts and the instruction counts are exact and are the same
18//! on every machine: they come from a counter incremented on the path, not
19//! from a sampler. The wall times are medians over the turns of one process
20//! and are worth what any wall time taken on a shared machine is worth, which
21//! is the ratios between rows measured in the same run and not the absolute
22//! figures. `examples/rules/README.md` says which of its numbers is which.
23//!
24//! # The rows, and what each isolates
25//!
26//! Each entry is a control on the one below it.
27//!
28//! - `rules.floor` does nothing, so it is what an invocation costs before the
29//!   program does anything: finding the entry, entering the frame, and
30//!   answering.
31//! - `rules.decideSample` runs the whole rule catalog over a pull request the
32//!   package itself holds, and makes no Host API call at all.
33//! - `rules.embedded.evaluate` runs the same catalog over a pull request the
34//!   *host* built and handed over as an argument, so what it adds to
35//!   `decideSample` is the argument and nothing else.
36//! - `rules.embedded.pullOnly` makes one Host API call and converts what comes
37//!   back into the package's own struct, and weighs nothing.
38//! - `rules.embedded.decideRequest` does both, and reports the decision back
39//!   through a second call.
40//!
41//! The middle three are the point. `evaluate` and `decideRequest` reach the
42//! same decision over the same pull request, one with it as an argument and
43//! one with it fetched across the Host API boundary, so the difference between
44//! them is what the boundary was costing an embedding that had no other way in
45//! (issue #150).
46//!
47//! The Rust side is measured on its own beside them: `PullRequest::to_policy`
48//! builds the value an invocation hands over — the same ten fields
49//! `to_cove` builds for the boundary — and `Decision::from_cove` reads the one
50//! that comes back.
51
52use std::alloc::{GlobalAlloc, Layout, System};
53use std::sync::atomic::{AtomicU64, Ordering};
54use std::sync::Arc;
55use std::time::{Duration, Instant};
56
57use cove_rules::{
58    embedding, embedding_without_trace, package_root, Decision, PullRequest, Reviews, RulePackage,
59    Session, REVIEWS,
60};
61use cove_runtime::Limits;
62
63// --------------------------------------------------------------- the counter
64
65/// Allocations made since the process started.
66static ALLOCATIONS: AtomicU64 = AtomicU64::new(0);
67/// Bytes those allocations asked for.
68static BYTES: AtomicU64 = AtomicU64::new(0);
69
70/// The system allocator, counting what goes through it.
71///
72/// A reallocation is counted as one allocation of the new size, because that
73/// is what it costs: a growing `Vec` that doubles four times allocated four
74/// times. A free is not counted at all, since what this is measuring is
75/// pressure rather than residency.
76struct Counting;
77
78unsafe impl GlobalAlloc for Counting {
79    unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
80        ALLOCATIONS.fetch_add(1, Ordering::Relaxed);
81        BYTES.fetch_add(layout.size() as u64, Ordering::Relaxed);
82        System.alloc(layout)
83    }
84
85    unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
86        System.dealloc(ptr, layout)
87    }
88
89    unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
90        ALLOCATIONS.fetch_add(1, Ordering::Relaxed);
91        BYTES.fetch_add(new_size as u64, Ordering::Relaxed);
92        System.realloc(ptr, layout, new_size)
93    }
94}
95
96#[global_allocator]
97static COUNTING: Counting = Counting;
98
99/// What one measured stretch of work cost.
100#[derive(Clone, Copy, Default)]
101struct Cost {
102    elapsed: Duration,
103    allocations: u64,
104    bytes: u64,
105}
106
107/// Runs `work` once, and says what it cost.
108fn cost<T>(work: impl FnOnce() -> T) -> (T, Cost) {
109    let allocations = ALLOCATIONS.load(Ordering::Relaxed);
110    let bytes = BYTES.load(Ordering::Relaxed);
111    let started = Instant::now();
112    let answer = work();
113    let elapsed = started.elapsed();
114    (
115        answer,
116        Cost {
117            elapsed,
118            allocations: ALLOCATIONS.load(Ordering::Relaxed) - allocations,
119            bytes: BYTES.load(Ordering::Relaxed) - bytes,
120        },
121    )
122}
123
124/// One row of the report: what a thing cost, divided by how many times it was
125/// done.
126struct Row {
127    what: &'static str,
128    turns: u64,
129    cost: Cost,
130    instructions: Option<u64>,
131}
132
133impl Row {
134    fn print(&self) {
135        let per = self.cost.elapsed.as_nanos() as f64 / self.turns as f64;
136        let allocations = self.cost.allocations as f64 / self.turns as f64;
137        let bytes = self.cost.bytes as f64 / self.turns as f64;
138        let instructions = match self.instructions {
139            Some(count) => format!("{:>12.1}", count as f64 / self.turns as f64),
140            None => format!("{:>12}", "-"),
141        };
142        println!(
143            "{:<34} {:>12.1} {:>12.2} {:>12.1} {instructions}",
144            self.what, per, allocations, bytes
145        );
146    }
147}
148
149/// Which way into the program a row is measuring.
150///
151/// The two are what issue #150 was about, and measuring them against each
152/// other is why both are here. A row that builds its argument builds it inside
153/// the loop, because the boundary row pays for the same conversion inside
154/// `reviews.pull` and a comparison that charged one and not the other would be
155/// measuring the bookkeeping.
156#[derive(Clone, Copy)]
157enum Way {
158    /// `run_entry`, with one process argument — the only way in there used to
159    /// be.
160    Command(&'static str),
161    /// `invoke`, with the pull request the host built.
162    Direct,
163}
164
165impl Way {
166    fn call(self, session: &mut Session<'_>, module: &str, entry: &str, pr: &PullRequest) {
167        match self {
168            Way::Command(argument) => {
169                session
170                    .run(module, entry, &[argument])
171                    .expect("every invocation succeeds");
172            }
173            Way::Direct => {
174                session
175                    .invoke(module, entry, vec![pr.to_policy()])
176                    .expect("every invocation succeeds");
177            }
178        }
179    }
180}
181
182/// Prints the header the rows line up under.
183fn header(title: &str) {
184    println!();
185    println!("{title}");
186    println!(
187        "{:<34} {:>12} {:>12} {:>12} {:>12}",
188        "", "ns/turn", "allocs/turn", "bytes/turn", "insts/turn"
189    );
190}
191
192// ----------------------------------------------------------------- the study
193
194fn main() {
195    let turns: u64 = std::env::args()
196        .nth(1)
197        .and_then(|arg| arg.parse().ok())
198        .unwrap_or(1000);
199
200    cove_runtime::on_cove_stack(move || study(turns)).expect("a thread to run Cove on");
201}
202
203/// The whole measurement, on the stack the runtime sized.
204fn study(turns: u64) {
205    // ---------------------------------------------------------- paid once
206    let (package, load) =
207        cost(|| RulePackage::load(&package_root(), REVIEWS).expect("the rule package checks"));
208    let detail = package.cost();
209
210    header(&format!(
211        "paid once, over {} file(s) in {} module(s)",
212        detail.files, detail.modules
213    ));
214    Row {
215        what: "load: read, parse, and check",
216        turns: 1,
217        cost: load,
218        instructions: None,
219    }
220    .print();
221    println!(
222        "{:<34} {:>12.1} {:>12} {:>12} {:>12}",
223        "  of which: read from disk",
224        detail.read.as_nanos() as f64,
225        "-",
226        "-",
227        "-"
228    );
229    println!(
230        "{:<34} {:>12.1} {:>12} {:>12} {:>12}",
231        "  of which: parse",
232        detail.parse.as_nanos() as f64,
233        "-",
234        "-",
235        "-"
236    );
237    println!(
238        "{:<34} {:>12.1} {:>12} {:>12} {:>12}",
239        "  of which: resolve and check",
240        detail.check.as_nanos() as f64,
241        "-",
242        "-",
243        "-"
244    );
245
246    // Lowering is measured over twenty turns rather than one, because the
247    // first lowering a process performs is cold and the cost an embedder
248    // pays for a second entry is the warm one. Loading above is measured
249    // once, because loading once is what it is for.
250    const LOWERINGS: u64 = 20;
251    for (module, entry) in [
252        ("rules", "floor"),
253        ("rules", "decideSample"),
254        ("rules.embedded", "evaluate"),
255        ("rules.embedded", "pullOnly"),
256        ("rules.embedded", "decideRequest"),
257    ] {
258        let lowering = package
259            .lower(module, entry)
260            .unwrap_or_else(|why| panic!("{module}.{entry} lowers: {why}"));
261        let (_, lowered) = cost(|| {
262            for _ in 0..LOWERINGS {
263                package.lower(module, entry).expect("the entry lowers");
264            }
265        });
266        println!(
267            "{:<34} {:>12.1} {:>12.2} {:>12.1} {:>12}",
268            format!("lower {module}.{entry} ({} fns)", lowering.functions),
269            lowered.elapsed.as_nanos() as f64 / LOWERINGS as f64,
270            lowered.allocations as f64 / LOWERINGS as f64,
271            lowered.bytes as f64 / LOWERINGS as f64,
272            "-"
273        );
274    }
275
276    // ------------------------------------------------- paid per invocation
277    let subject: PullRequest = cove_rules::samples()
278        .remove("req-2")
279        .expect("the sample exists");
280    header("paid per invocation, one Vm serving all of them");
281    for (what, module, entry, way, grants, trace) in [
282        (
283            "floor: an entry that does nothing",
284            "rules",
285            "floor",
286            Way::Command("0"),
287            &[][..],
288            false,
289        ),
290        (
291            "decide, no host call",
292            "rules",
293            "decideSample",
294            Way::Command("1"),
295            &[][..],
296            false,
297        ),
298        (
299            "evaluate: the request as argument",
300            "rules.embedded",
301            "evaluate",
302            Way::Direct,
303            &[][..],
304            false,
305        ),
306        (
307            "pull only: one host call",
308            "rules.embedded",
309            "pullOnly",
310            Way::Command("req-2"),
311            &["reviews"][..],
312            false,
313        ),
314        (
315            "decide, two host calls",
316            "rules.embedded",
317            "decideRequest",
318            Way::Command("req-2"),
319            &["reviews"][..],
320            false,
321        ),
322        (
323            "evaluate, traced",
324            "rules.embedded",
325            "evaluate",
326            Way::Direct,
327            &[][..],
328            true,
329        ),
330        (
331            "pull only, traced",
332            "rules.embedded",
333            "pullOnly",
334            Way::Command("req-2"),
335            &["reviews"][..],
336            true,
337        ),
338        (
339            "decide, two host calls, traced",
340            "rules.embedded",
341            "decideRequest",
342            Way::Command("req-2"),
343            &["reviews"][..],
344            true,
345        ),
346    ] {
347        let lowering = package.lower(module, entry).expect("the entry lowers");
348        let reviews = Reviews::new(cove_rules::samples());
349        let embed = if trace {
350            embedding(reviews, grants, Limits::default())
351        } else {
352            embedding_without_trace(reviews, grants, Limits::default())
353        };
354        let (instructions, measured) = package.serve(
355            Arc::clone(&embed.hosts),
356            Some(&lowering),
357            |session: &mut Session<'_>| {
358                // One turn outside the measurement, so that whatever a first
359                // invocation warms is warm for all of them.
360                way.call(session, module, entry, &subject);
361                let before = session.instructions().unwrap_or_default();
362                let (_, measured) = cost(|| {
363                    for _ in 0..turns {
364                        way.call(session, module, entry, &subject);
365                    }
366                });
367                (
368                    session.instructions().unwrap_or_default() - before,
369                    measured,
370                )
371            },
372        );
373        Row {
374            what,
375            turns,
376            cost: measured,
377            instructions: Some(instructions),
378        }
379        .print();
380    }
381
382    // ------------------------------------------- what reuse is worth
383    header("the same decision, with the session rebuilt each time");
384    let lowering = package
385        .lower("rules.embedded", "decideRequest")
386        .expect("the entry lowers");
387    let embed = embedding_without_trace(
388        Reviews::new(cove_rules::samples()),
389        &["reviews"],
390        Limits::default(),
391    );
392    let (_, rebuilt) = cost(|| {
393        for _ in 0..turns {
394            package.serve(Arc::clone(&embed.hosts), Some(&lowering), |session| {
395                session
396                    .run("rules.embedded", "decideRequest", &["req-2"])
397                    .expect("every invocation succeeds");
398            });
399        }
400    });
401    Row {
402        what: "decide, a new Runtime and Vm each",
403        turns,
404        cost: rebuilt,
405        instructions: None,
406    }
407    .print();
408
409    let (_, interpreted) = cost(|| {
410        package.serve(Arc::clone(&embed.hosts), None, |session| {
411            for _ in 0..turns {
412                session
413                    .run("rules.embedded", "decideRequest", &["req-2"])
414                    .expect("every invocation succeeds");
415            }
416        });
417    });
418    Row {
419        what: "decide, on the interpreter",
420        turns,
421        cost: interpreted,
422        instructions: None,
423    }
424    .print();
425
426    // ------------------------------------------------ the Rust side alone
427    header("the conversion, measured on the Rust side alone");
428    let (_, into_cove) = cost(|| {
429        for _ in 0..turns {
430            std::hint::black_box(subject.to_cove());
431        }
432    });
433    Row {
434        what: "PullRequest::to_cove",
435        turns,
436        cost: into_cove,
437        instructions: None,
438    }
439    .print();
440
441    // The same ten fields under the other name, which is what an invocation
442    // hands over. It is here rather than assumed equal to the row above
443    // because "the same work" is a claim and a claim is worth a row.
444    let (_, into_policy) = cost(|| {
445        for _ in 0..turns {
446            std::hint::black_box(subject.to_policy());
447        }
448    });
449    Row {
450        what: "PullRequest::to_policy",
451        turns,
452        cost: into_policy,
453        instructions: None,
454    }
455    .print();
456
457    let answer = package.serve(Arc::clone(&embed.hosts), Some(&lowering), |session| {
458        session
459            .run("rules.embedded", "decideRequest", &["req-2"])
460            .expect("the invocation succeeds")
461    });
462    let (_, out_of_cove) = cost(|| {
463        for _ in 0..turns {
464            std::hint::black_box(Decision::from_cove(&answer).expect("the answer decodes"));
465        }
466    });
467    Row {
468        what: "Decision::from_cove",
469        turns,
470        cost: out_of_cove,
471        instructions: None,
472    }
473    .print();
474
475    println!();
476    println!("{turns} turn(s) a row.");
477}