367 lines
12 KiB
Rust
367 lines
12 KiB
Rust
use std::env;
|
|
use std::io;
|
|
use std::path::PathBuf;
|
|
use std::time::Instant;
|
|
|
|
use ruin_perf::{PerfReportBuilder, RunMetadata};
|
|
use ruin_ui::{
|
|
AlignItems, Color, Edges, Element, FlexDirection, LayoutCache, LayoutFrameStats,
|
|
ScrollbarStyle, TextStyle, TextSystem, UiSize, layout_snapshot_with_cache_and_stats,
|
|
};
|
|
|
|
const DEFAULT_FRAMES: u64 = 300;
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct Config {
|
|
scenario: Scenario,
|
|
frames: u64,
|
|
output: Option<PathBuf>,
|
|
text_kb: usize,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
enum Scenario {
|
|
Counter,
|
|
LargeScrollList,
|
|
Calculator,
|
|
ResizeConfigure,
|
|
LargeTextBody,
|
|
}
|
|
|
|
impl Scenario {
|
|
const ALL: &'static [Self] = &[
|
|
Self::Counter,
|
|
Self::LargeScrollList,
|
|
Self::Calculator,
|
|
Self::ResizeConfigure,
|
|
Self::LargeTextBody,
|
|
];
|
|
|
|
const fn name(self) -> &'static str {
|
|
match self {
|
|
Self::Counter => "counter",
|
|
Self::LargeScrollList => "large-scroll-list",
|
|
Self::Calculator => "calculator",
|
|
Self::ResizeConfigure => "resize-configure",
|
|
Self::LargeTextBody => "large-text-body",
|
|
}
|
|
}
|
|
|
|
fn parse(value: &str) -> Option<Self> {
|
|
Self::ALL
|
|
.iter()
|
|
.copied()
|
|
.find(|scenario| scenario.name() == value)
|
|
}
|
|
}
|
|
|
|
fn main() -> io::Result<()> {
|
|
let config = Config::parse(env::args().skip(1))?;
|
|
let report = run_scenario(&config);
|
|
let output = config
|
|
.output
|
|
.clone()
|
|
.unwrap_or_else(|| report.default_output_path());
|
|
report.write_json(&output)?;
|
|
report.print_summary();
|
|
println!();
|
|
println!("wrote {}", output.display());
|
|
Ok(())
|
|
}
|
|
|
|
impl Config {
|
|
fn parse(mut args: impl Iterator<Item = String>) -> io::Result<Self> {
|
|
let mut scenario = Scenario::LargeScrollList;
|
|
let mut frames = DEFAULT_FRAMES;
|
|
let mut output = None;
|
|
let mut text_kb = 256usize;
|
|
|
|
while let Some(arg) = args.next() {
|
|
match arg.as_str() {
|
|
"--scenario" => {
|
|
let value = args.next().ok_or_else(|| {
|
|
io::Error::new(io::ErrorKind::InvalidInput, "--scenario needs a value")
|
|
})?;
|
|
scenario = Scenario::parse(&value).ok_or_else(|| {
|
|
io::Error::new(
|
|
io::ErrorKind::InvalidInput,
|
|
format!("unknown scenario '{value}'"),
|
|
)
|
|
})?;
|
|
}
|
|
"--frames" => {
|
|
let value = args.next().ok_or_else(|| {
|
|
io::Error::new(io::ErrorKind::InvalidInput, "--frames needs a value")
|
|
})?;
|
|
frames = value.parse::<u64>().map_err(|error| {
|
|
io::Error::new(
|
|
io::ErrorKind::InvalidInput,
|
|
format!("invalid frame count '{value}': {error}"),
|
|
)
|
|
})?;
|
|
}
|
|
"--output" => {
|
|
let value = args.next().ok_or_else(|| {
|
|
io::Error::new(io::ErrorKind::InvalidInput, "--output needs a value")
|
|
})?;
|
|
output = Some(PathBuf::from(value));
|
|
}
|
|
"--text-kb" => {
|
|
let value = args.next().ok_or_else(|| {
|
|
io::Error::new(io::ErrorKind::InvalidInput, "--text-kb needs a value")
|
|
})?;
|
|
text_kb = value.parse::<usize>().map_err(|error| {
|
|
io::Error::new(
|
|
io::ErrorKind::InvalidInput,
|
|
format!("invalid text size '{value}': {error}"),
|
|
)
|
|
})?;
|
|
}
|
|
"--list" => {
|
|
for scenario in Scenario::ALL {
|
|
println!("{}", scenario.name());
|
|
}
|
|
std::process::exit(0);
|
|
}
|
|
"--help" | "-h" => {
|
|
print_help();
|
|
std::process::exit(0);
|
|
}
|
|
other => {
|
|
return Err(io::Error::new(
|
|
io::ErrorKind::InvalidInput,
|
|
format!("unknown argument '{other}'"),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(Self {
|
|
scenario,
|
|
frames,
|
|
output,
|
|
text_kb,
|
|
})
|
|
}
|
|
}
|
|
|
|
fn print_help() {
|
|
println!(
|
|
"ruin_perf_scenarios --scenario <name> [--frames <n>] [--output <path>] [--text-kb <n>]"
|
|
);
|
|
println!();
|
|
println!("scenarios:");
|
|
for scenario in Scenario::ALL {
|
|
println!(" {}", scenario.name());
|
|
}
|
|
}
|
|
|
|
fn run_scenario(config: &Config) -> ruin_perf::PerfReport {
|
|
let mut report =
|
|
PerfReportBuilder::new(RunMetadata::new(config.scenario.name(), config.frames));
|
|
let mut text_system = TextSystem::new();
|
|
let mut layout_cache = LayoutCache::new();
|
|
|
|
report.note("headless layout/app-build scenario; renderer/platform timings are not included");
|
|
if config.scenario == Scenario::LargeTextBody {
|
|
report.set_counter("text_body_kb", config.text_kb as f64);
|
|
}
|
|
|
|
for frame in 0..config.frames {
|
|
let build_started = Instant::now();
|
|
let (tree, viewport) = match config.scenario {
|
|
Scenario::Counter => counter_tree(frame),
|
|
Scenario::LargeScrollList => large_scroll_list_tree(frame),
|
|
Scenario::Calculator => calculator_tree(frame),
|
|
Scenario::ResizeConfigure => resize_configure_tree(frame),
|
|
Scenario::LargeTextBody => large_text_body_tree(frame, config.text_kb),
|
|
};
|
|
let build_ms = build_started.elapsed().as_secs_f64() * 1_000.0;
|
|
let (_snapshot, stats) = layout_snapshot_with_cache_and_stats(
|
|
frame + 1,
|
|
viewport,
|
|
&tree,
|
|
&mut text_system,
|
|
&mut layout_cache,
|
|
);
|
|
|
|
report.record_ms("app_build", build_ms);
|
|
record_layout_stats(&mut report, stats);
|
|
}
|
|
|
|
report.finish()
|
|
}
|
|
|
|
fn record_layout_stats(report: &mut PerfReportBuilder, stats: LayoutFrameStats) {
|
|
report.record_ms("layout_total", stats.total_ms);
|
|
report.record_ms("layout_intrinsic", stats.intrinsic_ms);
|
|
report.record_ms("text_prepare", stats.text_prepare_ms);
|
|
report.record_ms("text_cache_miss", stats.text_miss_ms);
|
|
report.record("layout_nodes", "count", stats.nodes as f64);
|
|
report.record("scene_items", "count", stats.scene_items as f64);
|
|
report.record("text_glyphs", "count", stats.text_output_glyphs as f64);
|
|
report.add_counter("layout_cache_hits", stats.layout_cache_hits as f64);
|
|
report.add_counter("layout_cache_misses", stats.layout_cache_misses as f64);
|
|
report.add_counter("intrinsic_cache_hits", stats.intrinsic_cache_hits as f64);
|
|
report.add_counter("viewport_culled", stats.viewport_culled as f64);
|
|
report.add_counter("text_cache_hits", stats.text_cache_hits as f64);
|
|
report.add_counter("text_cache_misses", stats.text_cache_misses as f64);
|
|
report.add_counter("text_requests", stats.text_requests as f64);
|
|
}
|
|
|
|
fn text_style(size: f32) -> TextStyle {
|
|
TextStyle::new(size, Color::rgb(0x20, 0x20, 0x20))
|
|
}
|
|
|
|
fn counter_tree(frame: u64) -> (Element, UiSize) {
|
|
let count = frame as i64;
|
|
let root = Element::column()
|
|
.gap(12.0)
|
|
.padding(Edges::all(24.0))
|
|
.align_items(AlignItems::Center)
|
|
.child(Element::text("Counter", text_style(28.0)))
|
|
.child(Element::text(format!("count = {count}"), text_style(18.0)))
|
|
.child(Element::text(
|
|
format!("double = {}", count * 2),
|
|
text_style(18.0),
|
|
))
|
|
.child(button_row(["-1", "Reset", "+1"]));
|
|
(root, UiSize::new(480.0, 320.0))
|
|
}
|
|
|
|
fn large_scroll_list_tree(frame: u64) -> (Element, UiSize) {
|
|
let item_count = 1_000usize;
|
|
let item_height = 28.0;
|
|
let viewport = UiSize::new(480.0, 640.0);
|
|
let max_offset = (item_count as f32 * item_height - viewport.height).max(0.0);
|
|
let offset = ((frame as f32 * 19.0) % max_offset.max(1.0)).round();
|
|
let mut list = Element::column().gap(0.0);
|
|
for index in 0..item_count {
|
|
list = list.child(Element::new().height(item_height).child(Element::text(
|
|
format!("list item {index:04}"),
|
|
text_style(14.0),
|
|
)));
|
|
}
|
|
let root = Element::new().child(
|
|
Element::scroll_box(offset)
|
|
.width(viewport.width)
|
|
.height(viewport.height)
|
|
.scrollbar_style(ScrollbarStyle::default())
|
|
.child(list),
|
|
);
|
|
(root, viewport)
|
|
}
|
|
|
|
fn calculator_tree(frame: u64) -> (Element, UiSize) {
|
|
let expression = match frame % 6 {
|
|
0 => "2",
|
|
1 => "2+",
|
|
2 => "2+2",
|
|
3 => "4",
|
|
4 => "4*8",
|
|
_ => "32",
|
|
};
|
|
let history_len = (frame as usize / 12).min(40);
|
|
let mut history = Element::column().gap(4.0);
|
|
for index in 0..history_len {
|
|
history = history.child(Element::text(
|
|
format!("{} + {} = {}", index, index + 1, index * 2 + 1),
|
|
text_style(12.0),
|
|
));
|
|
}
|
|
|
|
let root = Element::column()
|
|
.gap(10.0)
|
|
.padding(Edges::all(20.0))
|
|
.child(Element::text("Calculator", text_style(28.0)))
|
|
.child(display_panel(expression))
|
|
.child(button_grid())
|
|
.child(Element::scroll_box(10_000.0).height(180.0).child(history));
|
|
(root, UiSize::new(420.0, 720.0))
|
|
}
|
|
|
|
fn resize_configure_tree(frame: u64) -> (Element, UiSize) {
|
|
let width = 360.0 + (frame % 90) as f32 * 4.0;
|
|
let height = 280.0 + (frame % 60) as f32 * 3.0;
|
|
let mut content = Element::column().gap(8.0).padding(Edges::all(16.0));
|
|
for index in 0..40 {
|
|
content = content.child(Element::text(
|
|
format!("resize row {index:02}: {width:.0} x {height:.0}"),
|
|
text_style(14.0),
|
|
));
|
|
}
|
|
(content, UiSize::new(width, height))
|
|
}
|
|
|
|
fn large_text_body_tree(frame: u64, text_kb: usize) -> (Element, UiSize) {
|
|
let viewport = UiSize::new(720.0, 720.0);
|
|
let text_width = 680.0;
|
|
let line_height = 18.0;
|
|
let text = large_text_body(text_kb);
|
|
let estimated_line_count = (text.len() as f32 / 72.0).ceil();
|
|
let estimated_height = (estimated_line_count * line_height).max(viewport.height);
|
|
let max_offset = (estimated_height - viewport.height).max(0.0);
|
|
let offset = ((frame as f32 * 127.0) % max_offset.max(1.0)).round();
|
|
let style = text_style(14.0)
|
|
.with_line_height(line_height)
|
|
.with_bounds(UiSize::new(text_width, estimated_height));
|
|
|
|
let root = Element::new().child(
|
|
Element::scroll_box(offset)
|
|
.width(viewport.width)
|
|
.height(viewport.height)
|
|
.child(
|
|
Element::new()
|
|
.width(text_width)
|
|
.child(Element::paragraph(text, style)),
|
|
),
|
|
);
|
|
(root, viewport)
|
|
}
|
|
|
|
fn large_text_body(text_kb: usize) -> String {
|
|
let target_bytes = text_kb.saturating_mul(1024).max(1);
|
|
let paragraph = "RUIN large text profiling paragraph: the quick brown fox jumps over the lazy dog. This line is intentionally ordinary prose with enough words to exercise wrapping, glyph collection, hit-test geometry, and cache behavior.\n";
|
|
let mut text = String::with_capacity(target_bytes + paragraph.len());
|
|
while text.len() < target_bytes {
|
|
text.push_str(paragraph);
|
|
}
|
|
text.truncate(target_bytes);
|
|
text
|
|
}
|
|
|
|
fn display_panel(expression: &str) -> Element {
|
|
Element::new()
|
|
.padding(Edges::all(12.0))
|
|
.border(1.0, Color::rgb(0x80, 0x80, 0x80))
|
|
.child(Element::text(expression, text_style(24.0)))
|
|
}
|
|
|
|
fn button_grid() -> Element {
|
|
let rows = [
|
|
["7", "8", "9", "/"],
|
|
["4", "5", "6", "*"],
|
|
["1", "2", "3", "-"],
|
|
["0", ".", "=", "+"],
|
|
];
|
|
let mut grid = Element::column().gap(6.0);
|
|
for row in rows {
|
|
grid = grid.child(button_row(row));
|
|
}
|
|
grid
|
|
}
|
|
|
|
fn button_row<const N: usize>(labels: [&str; N]) -> Element {
|
|
let mut row = Element::new().direction(FlexDirection::Row).gap(6.0);
|
|
for label in labels {
|
|
row = row.child(
|
|
Element::new()
|
|
.width(76.0)
|
|
.height(36.0)
|
|
.border(1.0, Color::rgb(0x90, 0x90, 0x90))
|
|
.child(Element::text(label, text_style(16.0))),
|
|
);
|
|
}
|
|
row
|
|
}
|