a789495a98
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
303 lines
11 KiB
Rust
303 lines
11 KiB
Rust
//! Resize-speedup probe for the mermaid layout-tier cache (commit 72bd457b).
|
|
//!
|
|
//! Verifies, through the public sized-render API only, that:
|
|
//! 1. Rendering ONE diagram at terminal widths 120 -> 200 -> 260 cells runs
|
|
//! parse+compute_layout EXACTLY ONCE and rasterizes THREE times. The three
|
|
//! widths map to PNG target widths in distinct width buckets, each below
|
|
//! the 85% cached-width reuse threshold of the next
|
|
//! (CACHE_WIDTH_MATCH_PERCENT), so every step is a genuine rasterize.
|
|
//! 2. A warm resize re-render (layout-cache hit, rasterize only) is at least
|
|
//! 3x faster than a cold render (parse + layout + rasterize) at the SAME
|
|
//! target width.
|
|
//!
|
|
//! Because the test-only hooks (`LAYOUT_COMPUTATIONS`,
|
|
//! `evict_render_cache_for_test`) are `#[cfg(test)]`-internal and invisible to
|
|
//! integration tests, this probe uses the public `debug_stats()` counters
|
|
//! (`layout_cache_hits`/`layout_cache_misses` are exact layout-computation
|
|
//! proxies: a miss increments iff parse+compute_layout ran) and evicts the PNG
|
|
//! memory+disk cache by deleting the `{hash:016x}_w*.png` cache files; the
|
|
//! in-memory RENDER_CACHE invalidates entries whose file no longer exists
|
|
//! (`path.exists()` check in `MermaidCache::get*`), which is the same
|
|
//! mechanism `evict_render_cache_for_test` exercises.
|
|
//!
|
|
//! Run with:
|
|
//! cargo test --release -p jcode-tui-mermaid --test layout_cache_resize_probe -- --ignored --nocapture
|
|
//!
|
|
//! NOTE: uses the real jcode mermaid cache dir (like the crate's unit tests);
|
|
//! diagrams are content-hash-unique per run, so no stale-cache interference
|
|
//! and no interference with other probe files in this checkout.
|
|
|
|
#![cfg(feature = "renderer")]
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::time::Instant;
|
|
|
|
use jcode_tui_mermaid::{RenderResult, debug_stats, render_mermaid_untracked};
|
|
|
|
/// Deterministic ~40-node / 45-edge flowchart. There is no `infra` fixture in
|
|
/// this crate (checked `crates/jcode-tui-mermaid/` for fixtures/), so this is
|
|
/// the labeled substitute: a representative pipeline-shaped flowchart well
|
|
/// above the 20-node bar and safely under the MAX_NODES=100 / MAX_EDGES=200
|
|
/// complexity gate (both by the bracket-counting estimate and by real parse).
|
|
fn probe_flowchart(salt: &str) -> String {
|
|
let unique = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos();
|
|
let mut s = String::from("flowchart TD\n");
|
|
for i in 0..40 {
|
|
s.push_str(&format!(
|
|
" N{salt}{unique}_{i}[\"Stage {i} of {salt} pipeline\"]\n"
|
|
));
|
|
}
|
|
for i in 0..39 {
|
|
s.push_str(&format!(
|
|
" N{salt}{unique}_{i} --> N{salt}{unique}_{}\n",
|
|
i + 1
|
|
));
|
|
}
|
|
for i in 0..6usize {
|
|
s.push_str(&format!(
|
|
" N{salt}{unique}_{} --> N{salt}{unique}_{}\n",
|
|
i * 3,
|
|
i * 5 + 7
|
|
));
|
|
}
|
|
s
|
|
}
|
|
|
|
struct Rendered {
|
|
hash: u64,
|
|
path: PathBuf,
|
|
png_width: u32,
|
|
wall_ms: f64,
|
|
}
|
|
|
|
fn render_at(content: &str, cells: u16) -> Rendered {
|
|
let start = Instant::now();
|
|
match render_mermaid_untracked(content, Some(cells)) {
|
|
RenderResult::Image {
|
|
hash, path, width, ..
|
|
} => Rendered {
|
|
hash,
|
|
path,
|
|
png_width: width,
|
|
wall_ms: start.elapsed().as_secs_f64() * 1000.0,
|
|
},
|
|
RenderResult::Error(error) => panic!("render at {cells} cells failed: {error}"),
|
|
}
|
|
}
|
|
|
|
/// Delete every cached PNG for `hash` (all width variants) from the on-disk
|
|
/// cache. The in-memory RENDER_CACHE entry for the hash is invalidated on the
|
|
/// next lookup because its `path.exists()` check fails, so this evicts both
|
|
/// tiers of the PNG cache while leaving the layout-tier cache warm.
|
|
fn evict_pngs(cache_dir: &Path, hash: u64) -> usize {
|
|
let prefix = format!("{hash:016x}_");
|
|
let mut removed = 0;
|
|
if let Ok(entries) = std::fs::read_dir(cache_dir) {
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
let is_probe_png = path.extension().and_then(|e| e.to_str()) == Some("png")
|
|
&& path
|
|
.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.is_some_and(|n| n.starts_with(&prefix));
|
|
if is_probe_png && std::fs::remove_file(&path).is_ok() {
|
|
removed += 1;
|
|
}
|
|
}
|
|
}
|
|
removed
|
|
}
|
|
|
|
fn count_pngs(cache_dir: &Path, hash: u64) -> usize {
|
|
let prefix = format!("{hash:016x}_");
|
|
std::fs::read_dir(cache_dir)
|
|
.map(|entries| {
|
|
entries
|
|
.flatten()
|
|
.filter(|entry| {
|
|
let path = entry.path();
|
|
path.extension().and_then(|e| e.to_str()) == Some("png")
|
|
&& path
|
|
.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.is_some_and(|n| n.starts_with(&prefix))
|
|
})
|
|
.count()
|
|
})
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "wall-clock probe; run explicitly with --ignored --nocapture (release build recommended)"]
|
|
fn layout_cache_resize_speedup_probe() {
|
|
// ---- Phase 0: warm process-global one-time costs (font DB scan) with a
|
|
// throwaway diagram so they don't pollute the cold-render timing below.
|
|
let warmup = probe_flowchart("warmup");
|
|
let _ = render_at(&warmup, 120);
|
|
|
|
// ---- Phase 1: one diagram, widths 120 -> 200 -> 260 cells.
|
|
// Counts: layout must run exactly once, rasterize exactly three times.
|
|
let content = probe_flowchart("resize");
|
|
let base = debug_stats();
|
|
|
|
let first = render_at(&content, 120);
|
|
let cache_dir = first
|
|
.path
|
|
.parent()
|
|
.expect("cached PNG must live in a cache dir")
|
|
.to_path_buf();
|
|
let after_first = debug_stats();
|
|
assert_eq!(
|
|
after_first.layout_cache_misses - base.layout_cache_misses,
|
|
1,
|
|
"cold render must compute the layout (one layout-cache miss)"
|
|
);
|
|
assert_eq!(
|
|
after_first.layout_cache_hits - base.layout_cache_hits,
|
|
0,
|
|
"cold render must not hit the layout cache"
|
|
);
|
|
let cold_stage_parse = after_first.last_parse_ms.unwrap_or(0.0);
|
|
let cold_stage_layout = after_first.last_layout_ms.unwrap_or(0.0);
|
|
let cold_stage_svg = after_first.last_svg_ms.unwrap_or(0.0);
|
|
let cold_stage_png = after_first.last_png_ms.unwrap_or(0.0);
|
|
|
|
let second = render_at(&content, 200);
|
|
let after_second = debug_stats();
|
|
assert_eq!(
|
|
after_second.layout_cache_misses - base.layout_cache_misses,
|
|
1,
|
|
"resize to 200 cells must NOT recompute layout"
|
|
);
|
|
assert_eq!(
|
|
after_second.layout_cache_hits - base.layout_cache_hits,
|
|
1,
|
|
"resize to 200 cells must take the layout-cache hit path"
|
|
);
|
|
assert_eq!(
|
|
after_second.last_parse_ms,
|
|
Some(0.0),
|
|
"layout-cache hit renders must skip parse"
|
|
);
|
|
assert_eq!(
|
|
after_second.last_layout_ms,
|
|
Some(0.0),
|
|
"layout-cache hit renders must skip compute_layout"
|
|
);
|
|
assert!(
|
|
after_second.last_png_ms.unwrap_or(0.0) > 0.0,
|
|
"resize to 200 cells must genuinely rasterize (png stage ran)"
|
|
);
|
|
|
|
let third = render_at(&content, 260);
|
|
let after_third = debug_stats();
|
|
assert_eq!(
|
|
after_third.layout_cache_misses - base.layout_cache_misses,
|
|
1,
|
|
"resize to 260 cells must NOT recompute layout: layout ran exactly once"
|
|
);
|
|
assert_eq!(
|
|
after_third.layout_cache_hits - base.layout_cache_hits,
|
|
2,
|
|
"both resizes must take the layout-cache hit path"
|
|
);
|
|
assert!(
|
|
after_third.last_png_ms.unwrap_or(0.0) > 0.0,
|
|
"resize to 260 cells must genuinely rasterize (png stage ran)"
|
|
);
|
|
assert_eq!(
|
|
after_third.render_success - base.render_success,
|
|
3,
|
|
"all three widths must be full (non-PNG-cache) renders: rasterize ran three times"
|
|
);
|
|
assert!(
|
|
first.png_width < second.png_width && second.png_width < third.png_width,
|
|
"widths must land in distinct ascending buckets (got {} / {} / {}); \
|
|
RENDER_WIDTH_BUCKET_CELLS quantization or the 85% reuse rule collapsed them",
|
|
first.png_width,
|
|
second.png_width,
|
|
third.png_width
|
|
);
|
|
let distinct_pngs = count_pngs(&cache_dir, first.hash);
|
|
assert_eq!(
|
|
distinct_pngs, 3,
|
|
"three genuine rasterizes must leave three width-distinct PNGs on disk"
|
|
);
|
|
|
|
// ---- Phase 2: cold vs warm wall-clock at the SAME 120-cell width.
|
|
// Cold: fresh content hashes (empty layout-cache entry for them).
|
|
// Warm: the phase-1 diagram with its PNGs evicted (memory+disk), so only
|
|
// SVG+rasterize runs against the still-warm layout tier.
|
|
let cold_a = render_at(&probe_flowchart("cold-a"), 120);
|
|
let cold_b = render_at(&probe_flowchart("cold-b"), 120);
|
|
let cold_ms = cold_a.wall_ms.min(cold_b.wall_ms);
|
|
|
|
let evicted = evict_pngs(&cache_dir, first.hash);
|
|
assert!(
|
|
evicted >= 3,
|
|
"PNG eviction must remove the phase-1 cache files (removed {evicted})"
|
|
);
|
|
let warm_1 = render_at(&content, 120);
|
|
let evicted_again = evict_pngs(&cache_dir, first.hash);
|
|
assert!(evicted_again >= 1, "re-eviction must remove the warm PNG");
|
|
let warm_2 = render_at(&content, 120);
|
|
let warm_ms = warm_1.wall_ms.min(warm_2.wall_ms);
|
|
|
|
let after_warm = debug_stats();
|
|
assert_eq!(
|
|
after_warm.layout_cache_misses - base.layout_cache_misses,
|
|
3, // phase-1 diagram (1) + cold-a (1) + cold-b (1)
|
|
"warm re-renders must not add layout-cache misses"
|
|
);
|
|
assert_eq!(
|
|
after_warm.last_parse_ms,
|
|
Some(0.0),
|
|
"warm re-render must skip parse"
|
|
);
|
|
assert_eq!(
|
|
after_warm.last_layout_ms,
|
|
Some(0.0),
|
|
"warm re-render must skip compute_layout"
|
|
);
|
|
|
|
let speedup = cold_ms / warm_ms.max(0.001);
|
|
let stage_cold_total = cold_stage_parse + cold_stage_layout + cold_stage_svg + cold_stage_png;
|
|
println!("== layout_cache_resize_speedup_probe report ==");
|
|
println!(
|
|
"build profile: {}",
|
|
if cfg!(debug_assertions) {
|
|
"debug (timing caveat: release preferred)"
|
|
} else {
|
|
"release"
|
|
}
|
|
);
|
|
println!("layout computations (layout_cache_misses delta, phase 1): 1");
|
|
println!("rasterize count (render_success delta, phase 1): 3");
|
|
println!(
|
|
"phase-1 PNG target widths: {} / {} / {} px",
|
|
first.png_width, second.png_width, third.png_width
|
|
);
|
|
println!(
|
|
"cold render stage breakdown @120 cells: parse {:.1} ms, layout {:.1} ms, svg {:.1} ms, png {:.1} ms (total {:.1} ms)",
|
|
cold_stage_parse, cold_stage_layout, cold_stage_svg, cold_stage_png, stage_cold_total
|
|
);
|
|
println!(
|
|
"cold wall (min of 2, fresh hash @120 cells): {:.1} ms",
|
|
cold_ms
|
|
);
|
|
println!(
|
|
"warm wall (min of 2, layout hit @120 cells): {:.1} ms",
|
|
warm_ms
|
|
);
|
|
println!("resize speedup (cold / warm): {speedup:.2}x");
|
|
|
|
assert!(
|
|
speedup >= 3.0,
|
|
"warm resize re-render must be at least 3x faster than a cold render \
|
|
(cold {cold_ms:.1} ms vs warm {warm_ms:.1} ms = {speedup:.2}x)"
|
|
);
|
|
}
|