From 2dd0dac460f3b54fff8f1cd6c7f93e1115f785b8 Mon Sep 17 00:00:00 2001 From: Josh McKinney Date: Thu, 23 Jul 2026 07:08:54 -0700 Subject: [PATCH] Benchmark text wrapping workflows Compare native Paragraph wrapping with materialized algorithms across counting, rendering, caching, resizing, and scrolling workloads. --- Cargo.lock | 315 +++++++++++++- crates/ratatui-textwrap/Cargo.toml | 15 + crates/ratatui-textwrap/README.md | 10 + .../ratatui-textwrap/benches/support/mod.rs | 404 ++++++++++++++++++ .../benches/textwrap-allocations.rs | 261 +++++++++++ .../benches/textwrap-stress.rs | 218 ++++++++++ .../benches/textwrap-workflows.rs | 376 ++++++++++++++++ .../docs/benchmark-results.md | 288 +++++++++++++ crates/ratatui-textwrap/docs/benchmarks.md | 137 ++++++ .../scripts/summarize-benchmarks.py | 396 +++++++++++++++++ 10 files changed, 2417 insertions(+), 3 deletions(-) create mode 100644 crates/ratatui-textwrap/benches/support/mod.rs create mode 100644 crates/ratatui-textwrap/benches/textwrap-allocations.rs create mode 100644 crates/ratatui-textwrap/benches/textwrap-stress.rs create mode 100644 crates/ratatui-textwrap/benches/textwrap-workflows.rs create mode 100644 crates/ratatui-textwrap/docs/benchmark-results.md create mode 100644 crates/ratatui-textwrap/docs/benchmarks.md create mode 100644 crates/ratatui-textwrap/scripts/summarize-benchmarks.py diff --git a/Cargo.lock b/Cargo.lock index 809eae0..7b30f7e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,12 +26,27 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloca" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" +dependencies = [ + "cc", +] + [[package]] name = "allocator-api2" version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + [[package]] name = "anstream" version = "1.0.0" @@ -190,6 +205,12 @@ version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "castaway" version = "0.2.4" @@ -199,6 +220,16 @@ dependencies = [ "rustversion", ] +[[package]] +name = "cc" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -211,6 +242,33 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "clap" version = "4.6.1" @@ -317,12 +375,73 @@ dependencies = [ "libc", ] +[[package]] +name = "criterion" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" +dependencies = [ + "alloca", + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "csv", + "itertools 0.13.0", + "num-traits", + "oorandom", + "page_size", + "plotters", + "rayon", + "regex", + "serde", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" +dependencies = [ + "cast", + "itertools 0.13.0", +] + [[package]] name = "critical-section" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + [[package]] name = "crossterm" version = "0.29.0" @@ -350,6 +469,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -370,6 +495,27 @@ dependencies = [ "phf", ] +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + [[package]] name = "darling" version = "0.23.0" @@ -537,6 +683,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "finl_unicode" version = "1.4.0" @@ -648,6 +800,17 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -732,6 +895,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -975,6 +1147,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "ordered-float" version = "4.6.0" @@ -990,6 +1168,16 @@ version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "palette" version = "0.7.6" @@ -1138,6 +1326,34 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -1254,7 +1470,7 @@ dependencies = [ "compact_str", "critical-section", "hashbrown 0.17.1", - "itertools", + "itertools 0.14.0", "kasuari", "lru", "palette", @@ -1351,10 +1567,12 @@ dependencies = [ name = "ratatui-textwrap" version = "0.0.1" dependencies = [ + "criterion", "pretty_assertions", "ratatui", "ratatui-core", "rstest", + "stats_alloc", "textwrap", ] @@ -1368,7 +1586,7 @@ dependencies = [ "hashbrown 0.17.1", "indoc", "instability", - "itertools", + "itertools 0.14.0", "line-clipping", "ratatui-core", "serde", @@ -1378,6 +1596,26 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1491,6 +1729,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1575,6 +1822,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook" version = "0.3.18" @@ -1636,6 +1889,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "stats_alloc" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e04424e733e69714ca1bbb9204c1a57f09f5493439520f9f68c132ad25eec" + [[package]] name = "strsim" version = "0.11.1" @@ -1855,6 +2114,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "toml" version = "1.1.2+spec-1.1.0" @@ -1993,7 +2262,7 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ - "itertools", + "itertools 0.14.0", "unicode-segmentation", "unicode-width", ] @@ -2043,6 +2312,16 @@ dependencies = [ "utf8parse", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2103,6 +2382,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wezterm-bidi" version = "0.2.3" @@ -2242,6 +2531,26 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/crates/ratatui-textwrap/Cargo.toml b/crates/ratatui-textwrap/Cargo.toml index 89c0e71..77c096c 100644 --- a/crates/ratatui-textwrap/Cargo.toml +++ b/crates/ratatui-textwrap/Cargo.toml @@ -2,6 +2,7 @@ name = "ratatui-textwrap" version = "0.0.1" publish = true +autobenches = false edition.workspace = true license.workspace = true repository.workspace = true @@ -20,9 +21,23 @@ ratatui-core = "0.1.2" textwrap = { version = "0.16", default-features = false, features = ["smawk"] } [dev-dependencies] +criterion = { version = "0.8.2", features = ["csv_output", "html_reports"] } pretty_assertions = "1" ratatui = { version = "0.30", features = ["unstable-rendered-line-info"] } rstest = "0.26" +stats_alloc = "0.1.10" [lints] workspace = true + +[[bench]] +name = "textwrap-workflows" +harness = false + +[[bench]] +name = "textwrap-stress" +harness = false + +[[bench]] +name = "textwrap-allocations" +harness = false diff --git a/crates/ratatui-textwrap/README.md b/crates/ratatui-textwrap/README.md index 9d70032..e23ae8f 100644 --- a/crates/ratatui-textwrap/README.md +++ b/crates/ratatui-textwrap/README.md @@ -93,6 +93,16 @@ The [design notes](docs/design.md) record why the current API has this shape, wh comparison taught us, and which follow-up ideas remain open questions rather than promised features. +## Benchmarks + +The benchmark suite compares native `Paragraph` wrapping with paragraph-compatible, first-fit, and +optimal-fit materialization. It covers line counting, rendering, repeated cached rendering, +terminal resizing, and scrolled viewports using deterministic styled inputs. + +See the [benchmark guide](docs/benchmarks.md) for the workload definitions and commands. The +[latest checked-in results](docs/benchmark-results.md) record one machine-specific reference run; +use Criterion baselines for before-and-after comparisons on the same machine. + The implementation follows textwrap's [fragment contract], [first-fit source], and [optimal-fit source]. `ParagraphCompat` follows Ratatui's [WordWrapper source] and [Paragraph integration]. diff --git a/crates/ratatui-textwrap/benches/support/mod.rs b/crates/ratatui-textwrap/benches/support/mod.rs new file mode 100644 index 0000000..d898648 --- /dev/null +++ b/crates/ratatui-textwrap/benches/support/mod.rs @@ -0,0 +1,404 @@ +//! Shared deterministic fixtures and workflow operations for text wrapping benchmarks. + +#![expect( + dead_code, + reason = "each benchmark binary uses a different subset of the shared support module" +)] + +use std::hint::black_box; + +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span, Text}; +use ratatui::widgets::{Paragraph, Widget, Wrap}; +use ratatui_textwrap::{TextWrapper, WrapAlgorithm}; + +pub const VIEWPORT_WIDTH: u16 = 200; +pub const VIEWPORT_HEIGHT: u16 = 50; +pub const RESIZE_WIDTHS: [u16; 5] = [120, 160, 200, 240, 280]; +pub const SESSION_FRAMES: usize = 60; +pub const FIXTURE_SEED: u64 = 0x5EED_7E57_CAFE_BABE; + +const CORE_SIZES: [(&str, usize); 3] = [ + ("4-kib", 4 * 1024), + ("64-kib", 64 * 1024), + ("1-mib", 1024 * 1024), +]; +const SESSION_SIZES: [&str; 2] = ["4-kib", "64-kib"]; +const STYLE_COUNT: usize = 6; + +/// One deterministic, styled text input. +#[derive(Debug, Clone)] +pub struct Fixture { + pub name: &'static str, + pub target_bytes: usize, + pub source_bytes: usize, + pub text: Text<'static>, +} + +impl Fixture { + pub fn line_count(&self, implementation: Implementation, width: u16) -> usize { + match implementation { + Implementation::NativeParagraph => native_paragraph(self, 0).line_count(width), + _ => wrap(self, implementation, width).lines.len(), + } + } +} + +/// Implementations compared by every workflow. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum Implementation { + NativeParagraph, + ParagraphCompat, + FirstFit, + OptimalFit, +} + +impl Implementation { + pub const ALL: [Self; 4] = [ + Self::NativeParagraph, + Self::ParagraphCompat, + Self::FirstFit, + Self::OptimalFit, + ]; + + pub const fn name(self) -> &'static str { + match self { + Self::NativeParagraph => "paragraph-native", + Self::ParagraphCompat => "paragraph-compat", + Self::FirstFit => "first-fit", + Self::OptimalFit => "optimal-fit", + } + } + + const fn algorithm(self) -> Option { + match self { + Self::NativeParagraph => None, + Self::ParagraphCompat => Some(WrapAlgorithm::ParagraphCompat { trim: true }), + Self::FirstFit => Some(WrapAlgorithm::FirstFit), + Self::OptimalFit => Some(WrapAlgorithm::OptimalFit), + } + } +} + +/// A viewport location expressed relative to the wrapped output. +#[derive(Debug, Clone, Copy)] +pub enum Viewport { + Start, + Middle, + End, +} + +impl Viewport { + pub const ALL: [Self; 3] = [Self::Start, Self::Middle, Self::End]; + + pub const fn name(self) -> &'static str { + match self { + Self::Start => "start", + Self::Middle => "middle", + Self::End => "end", + } + } + + pub fn scroll(self, line_count: usize) -> u16 { + let last_page = line_count.saturating_sub(usize::from(VIEWPORT_HEIGHT)); + let offset = match self { + Self::Start => 0, + Self::Middle => last_page / 2, + Self::End => last_page, + }; + u16::try_from(offset).expect("fixture scroll offset must fit Paragraph's u16 API") + } +} + +/// Generates and validates the standard benchmark corpus. +pub fn core_fixtures() -> Vec { + CORE_SIZES + .into_iter() + .enumerate() + .map(|(index, (name, size))| { + let seed = FIXTURE_SEED.wrapping_add(index as u64); + let fixture = mixed_styled_fixture(name, size, seed); + verify_fixture(&fixture); + fixture + }) + .collect() +} + +/// Returns the fixtures used by multi-frame benchmark sessions. +pub fn session_fixtures(fixtures: &[Fixture]) -> impl Iterator { + fixtures + .iter() + .filter(|fixture| SESSION_SIZES.contains(&fixture.name)) +} + +/// Generates a deterministic fixture for an opt-in stress profile. +pub fn stress_fixture(name: &'static str, target_bytes: usize, profile: StressProfile) -> Fixture { + let text = match profile { + StressProfile::LongLine => { + let content = + "alpha beta gamma delta epsilon zeta eta theta ".repeat(target_bytes.div_ceil(46)); + Text::from(Line::from(content)) + } + StressProfile::Whitespace => { + let content = "alpha beta\tgamma delta ".repeat(target_bytes.div_ceil(35)); + Text::from(Line::from(content)) + } + StressProfile::Unicode => { + let content = "界 café e\u{301} 🐀 alpha βeta ".repeat(target_bytes.div_ceil(29)); + Text::from(Line::from(content)) + } + StressProfile::Unbreakable => { + Text::from(Line::from("abcdefghij".repeat(target_bytes.div_ceil(10)))) + } + }; + let source_bytes = text.lines.iter().map(line_bytes).sum(); + Fixture { + name, + target_bytes, + source_bytes, + text, + } +} + +#[derive(Debug, Clone, Copy)] +pub enum StressProfile { + LongLine, + Whitespace, + Unicode, + Unbreakable, +} + +/// Materializes one fixture with a non-native implementation. +#[must_use] +pub fn wrap(fixture: &Fixture, implementation: Implementation, width: u16) -> Text<'static> { + let algorithm = implementation + .algorithm() + .expect("native Paragraph does not materialize wrapped Text"); + TextWrapper::new() + .algorithm(algorithm) + .wrap(fixture.text.clone(), width) +} + +/// Builds a native wrapping Paragraph that owns a clone of the fixture. +#[must_use] +pub fn native_paragraph(fixture: &Fixture, scroll: u16) -> Paragraph<'static> { + Paragraph::new(fixture.text.clone()) + .wrap(Wrap { trim: true }) + .scroll((scroll, 0)) +} + +/// Builds an unwrapped Paragraph from materialized text. +#[must_use] +pub fn materialized_paragraph(text: Text<'static>, scroll: u16) -> Paragraph<'static> { + Paragraph::new(text).scroll((scroll, 0)) +} + +/// Allocates an empty viewport buffer. +#[must_use] +pub fn viewport_buffer(width: u16) -> Buffer { + Buffer::empty(Rect::new(0, 0, width, VIEWPORT_HEIGHT)) +} + +/// Renders a reusable Paragraph into a viewport and keeps the result observable. +pub fn render(paragraph: &Paragraph<'_>, width: u16, buffer: &mut Buffer) { + let area = Rect::new(0, 0, width, VIEWPORT_HEIGHT); + paragraph.render(area, buffer); + black_box(buffer); +} + +fn mixed_styled_fixture(name: &'static str, target_bytes: usize, seed: u64) -> Fixture { + let mut random = Random::new(seed); + let mut lines = Vec::new(); + let mut source_bytes = 0; + + while source_bytes < target_bytes { + let word_count = 55 + random.index(50); + let line = mixed_styled_line(&mut random, word_count); + source_bytes += line_bytes(&line) + usize::from(!lines.is_empty()); + lines.push(line); + } + + Fixture { + name, + target_bytes, + source_bytes, + text: Text::from(lines), + } +} + +fn mixed_styled_line(random: &mut Random, word_count: usize) -> Line<'static> { + let mut spans = Vec::new(); + let mut pending = String::new(); + let mut style_index = random.index(STYLE_COUNT); + + for index in 0..word_count { + let word = random_word(random); + if random.one_in(11) && word.is_ascii() && word.len() >= 6 { + let midpoint = word.len() / 2; + pending.push_str(&word[..midpoint]); + flush_span(&mut spans, &mut pending, style_index); + style_index = (style_index + 1) % STYLE_COUNT; + pending.push_str(&word[midpoint..]); + } else { + pending.push_str(word); + } + + if index + 1 != word_count { + let spaces = if random.one_in(13) { " " } else { " " }; + pending.push_str(spaces); + } + + if random.one_in(5) { + flush_span(&mut spans, &mut pending, style_index); + style_index = random.index(STYLE_COUNT); + } + } + flush_span(&mut spans, &mut pending, style_index); + Line::from(spans) +} + +fn random_word(random: &mut Random) -> &'static str { + const ASCII_WORDS: [&str; 24] = [ + "alpha", + "benchmark", + "cache", + "delta", + "editor", + "fragment", + "grapheme", + "horizontal", + "indentation", + "layout", + "materialized", + "paragraph", + "performance", + "render", + "resize", + "scroll", + "separator", + "styled", + "terminal", + "throughput", + "unicode", + "viewport", + "whitespace", + "wrapping", + ]; + const UNICODE_WORDS: [&str; 8] = [ + "café", + "e\u{301}lan", + "naïve", + "βeta", + "界", + "東", + "🐀", + "résumé", + ]; + + if random.one_in(12) { + UNICODE_WORDS[random.index(UNICODE_WORDS.len())] + } else { + ASCII_WORDS[random.index(ASCII_WORDS.len())] + } +} + +fn flush_span(spans: &mut Vec>, pending: &mut String, style_index: usize) { + if pending.is_empty() { + return; + } + spans.push(Span::styled(std::mem::take(pending), style(style_index))); +} + +const fn style(index: usize) -> Style { + match index { + 0 => Style::new(), + 1 => Style::new().fg(Color::Blue), + 2 => Style::new().fg(Color::Green).add_modifier(Modifier::BOLD), + 3 => Style::new().fg(Color::Yellow), + 4 => Style::new() + .fg(Color::Magenta) + .add_modifier(Modifier::ITALIC), + _ => Style::new().fg(Color::Cyan), + } +} + +fn line_bytes(line: &Line<'_>) -> usize { + line.spans.iter().map(|span| span.content.len()).sum() +} + +fn verify_fixture(fixture: &Fixture) { + assert!(fixture.source_bytes >= fixture.target_bytes); + assert!(fixture.source_bytes <= fixture.target_bytes + 2048); + + let native_count = fixture.line_count(Implementation::NativeParagraph, VIEWPORT_WIDTH); + let compat = wrap(fixture, Implementation::ParagraphCompat, VIEWPORT_WIDTH); + assert_eq!(native_count, compat.lines.len()); + + for viewport in Viewport::ALL { + let scroll = viewport.scroll(native_count); + let native = native_paragraph(fixture, scroll); + let materialized = materialized_paragraph(compat.clone(), scroll); + let mut native_buffer = viewport_buffer(VIEWPORT_WIDTH); + let mut materialized_buffer = viewport_buffer(VIEWPORT_WIDTH); + render(&native, VIEWPORT_WIDTH, &mut native_buffer); + render(&materialized, VIEWPORT_WIDTH, &mut materialized_buffer); + if native_buffer != materialized_buffer { + let (index, (native, materialized)) = native_buffer + .content + .iter() + .zip(&materialized_buffer.content) + .enumerate() + .find(|(_, (native, materialized))| native != materialized) + .expect("different buffers must contain a different cell"); + let x = index % usize::from(VIEWPORT_WIDTH); + let y = index / usize::from(VIEWPORT_WIDTH); + panic!( + "Paragraph compatibility mismatch: fixture={}, viewport={}, x={x}, y={y}, \ + native={native:?}, materialized={materialized:?}", + fixture.name, + viewport.name(), + ); + } + } + + for implementation in [Implementation::FirstFit, Implementation::OptimalFit] { + let first = wrap(fixture, implementation, VIEWPORT_WIDTH); + let second = wrap(fixture, implementation, VIEWPORT_WIDTH); + let first = materialized_paragraph(first, 0); + let second = materialized_paragraph(second, 0); + let mut first_buffer = viewport_buffer(VIEWPORT_WIDTH); + let mut second_buffer = viewport_buffer(VIEWPORT_WIDTH); + render(&first, VIEWPORT_WIDTH, &mut first_buffer); + render(&second, VIEWPORT_WIDTH, &mut second_buffer); + assert_eq!(first_buffer, second_buffer); + } +} + +/// Small deterministic generator kept local to benchmark fixture construction. +#[derive(Debug, Clone, Copy)] +struct Random(u64); + +impl Random { + const fn new(seed: u64) -> Self { + Self(seed) + } + + fn next(&mut self) -> u64 { + let mut value = self.0; + value ^= value << 13; + value ^= value >> 7; + value ^= value << 17; + self.0 = value; + value + } + + fn index(&mut self, upper: usize) -> usize { + (self.next() as usize) % upper + } + + fn one_in(&mut self, denominator: u64) -> bool { + self.next().is_multiple_of(denominator) + } +} diff --git a/crates/ratatui-textwrap/benches/textwrap-allocations.rs b/crates/ratatui-textwrap/benches/textwrap-allocations.rs new file mode 100644 index 0000000..4f29c80 --- /dev/null +++ b/crates/ratatui-textwrap/benches/textwrap-allocations.rs @@ -0,0 +1,261 @@ +//! One-shot allocation diagnostics for representative text wrapping workflows. + +mod support; + +use std::alloc::System; +use std::error::Error; +use std::fmt::Write; +use std::hint::black_box; +use std::path::PathBuf; + +use stats_alloc::{INSTRUMENTED_SYSTEM, Region, Stats, StatsAlloc}; + +use support::{ + Fixture, Implementation, RESIZE_WIDTHS, SESSION_FRAMES, VIEWPORT_WIDTH, Viewport, + core_fixtures, materialized_paragraph, native_paragraph, render, viewport_buffer, wrap, +}; + +#[global_allocator] +static GLOBAL: &StatsAlloc = &INSTRUMENTED_SYSTEM; + +fn main() -> Result<(), Box> { + let fixtures = core_fixtures(); + let fixture = fixtures + .iter() + .find(|fixture| fixture.name == "64-kib") + .expect("core corpus must contain the 64 KiB fixture"); + let rows = allocation_rows(fixture); + let csv = format_csv(&rows); + + if let Some(path) = std::env::args_os().nth(1) { + let path = PathBuf::from(path); + let path = if path.is_absolute() { + path + } else { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join(path) + }; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, csv)?; + } else { + print!("{csv}"); + } + Ok(()) +} + +#[derive(Debug)] +struct AllocationRow { + workload: &'static str, + implementation: &'static str, + stats: Stats, +} + +fn allocation_rows(fixture: &Fixture) -> Vec { + let mut rows = Vec::new(); + for implementation in Implementation::ALL { + rows.push(measure_wrap_or_count(fixture, implementation)); + rows.push(measure_count_then_render(fixture, implementation)); + rows.push(measure_same_width(fixture, implementation, false)); + rows.push(measure_same_width(fixture, implementation, true)); + rows.push(measure_resize(fixture, implementation, false)); + rows.push(measure_resize(fixture, implementation, true)); + rows.push(measure_viewport(fixture, implementation, false)); + rows.push(measure_viewport(fixture, implementation, true)); + } + rows +} + +fn measure_wrap_or_count(fixture: &Fixture, implementation: Implementation) -> AllocationRow { + let paragraph = + (implementation == Implementation::NativeParagraph).then(|| native_paragraph(fixture, 0)); + row("wrap-or-count", implementation, || match implementation { + Implementation::NativeParagraph => { + black_box( + paragraph + .as_ref() + .expect("native paragraph must exist") + .line_count(VIEWPORT_WIDTH), + ); + None + } + _ => { + let wrapped = wrap(fixture, implementation, VIEWPORT_WIDTH); + black_box(wrapped.lines.len()); + Some(wrapped) + } + }) +} + +fn measure_count_then_render(fixture: &Fixture, implementation: Implementation) -> AllocationRow { + let native = + (implementation == Implementation::NativeParagraph).then(|| native_paragraph(fixture, 0)); + let mut buffer = viewport_buffer(VIEWPORT_WIDTH); + row("count-then-render", implementation, || { + let paragraph = match implementation { + Implementation::NativeParagraph => native.expect("native paragraph must exist"), + _ => { + let wrapped = wrap(fixture, implementation, VIEWPORT_WIDTH); + materialized_paragraph(wrapped, 0) + } + }; + black_box(paragraph.line_count(VIEWPORT_WIDTH)); + render(¶graph, VIEWPORT_WIDTH, &mut buffer); + paragraph + }) +} + +fn measure_same_width( + fixture: &Fixture, + implementation: Implementation, + cached: bool, +) -> AllocationRow { + let cached_paragraph = cached.then(|| match implementation { + Implementation::NativeParagraph => native_paragraph(fixture, 0), + _ => { + let wrapped = wrap(fixture, implementation, VIEWPORT_WIDTH); + materialized_paragraph(wrapped, 0) + } + }); + let native = (!cached && implementation == Implementation::NativeParagraph) + .then(|| native_paragraph(fixture, 0)); + let mut buffer = viewport_buffer(VIEWPORT_WIDTH); + let workload = if cached { + "same-width-cached-60-frames" + } else { + "same-width-amortized-60-frames" + }; + + row(workload, implementation, || { + let paragraph = match (cached_paragraph, native) { + (Some(paragraph), _) | (_, Some(paragraph)) => paragraph, + (None, None) => { + let wrapped = wrap(fixture, implementation, VIEWPORT_WIDTH); + materialized_paragraph(wrapped, 0) + } + }; + for _ in 0..SESSION_FRAMES { + render(¶graph, VIEWPORT_WIDTH, &mut buffer); + } + paragraph + }) +} + +fn measure_resize( + fixture: &Fixture, + implementation: Implementation, + cached: bool, +) -> AllocationRow { + let max_width = *RESIZE_WIDTHS + .iter() + .max() + .expect("resize widths must not be empty"); + let mut buffer = viewport_buffer(max_width); + let native = + (implementation == Implementation::NativeParagraph).then(|| native_paragraph(fixture, 0)); + let cached_paragraphs = + (cached && implementation != Implementation::NativeParagraph).then(|| { + RESIZE_WIDTHS.map(|width| { + let wrapped = wrap(fixture, implementation, width); + materialized_paragraph(wrapped, 0) + }) + }); + let workload = if cached { + "resize-cached-60-frames" + } else { + "resize-recomputed-60-frames" + }; + + row(workload, implementation, || { + for frame in 0..SESSION_FRAMES { + let index = frame % RESIZE_WIDTHS.len(); + let width = RESIZE_WIDTHS[index]; + if let Some(paragraph) = &native { + render(paragraph, width, &mut buffer); + } else if let Some(paragraphs) = &cached_paragraphs { + render(¶graphs[index], width, &mut buffer); + } else { + let wrapped = wrap(fixture, implementation, width); + let paragraph = materialized_paragraph(wrapped, 0); + render(¶graph, width, &mut buffer); + } + } + }) +} + +fn measure_viewport( + fixture: &Fixture, + implementation: Implementation, + cached: bool, +) -> AllocationRow { + let line_count = fixture.line_count(implementation, VIEWPORT_WIDTH); + let scroll = Viewport::Middle.scroll(line_count); + let cached_paragraph = cached.then(|| match implementation { + Implementation::NativeParagraph => native_paragraph(fixture, scroll), + _ => { + let wrapped = wrap(fixture, implementation, VIEWPORT_WIDTH); + materialized_paragraph(wrapped, scroll) + } + }); + let native = (!cached && implementation == Implementation::NativeParagraph) + .then(|| native_paragraph(fixture, scroll)); + let mut buffer = viewport_buffer(VIEWPORT_WIDTH); + let workload = if cached { + "viewport-middle-cached" + } else { + "viewport-middle-cold" + }; + + row(workload, implementation, || { + let paragraph = match (cached_paragraph, native) { + (Some(paragraph), _) | (_, Some(paragraph)) => paragraph, + (None, None) => { + let wrapped = wrap(fixture, implementation, VIEWPORT_WIDTH); + materialized_paragraph(wrapped, scroll) + } + }; + render(¶graph, VIEWPORT_WIDTH, &mut buffer); + paragraph + }) +} + +fn row( + workload: &'static str, + implementation: Implementation, + operation: impl FnOnce() -> T, +) -> AllocationRow { + let region = Region::new(GLOBAL); + let output = operation(); + black_box(&output); + let stats = region.change(); + AllocationRow { + workload, + implementation: implementation.name(), + stats, + } +} + +fn format_csv(rows: &[AllocationRow]) -> String { + let mut output = String::from( + "workload,implementation,allocations,reallocations,bytes_allocated,\ + bytes_reallocated,deallocations,bytes_deallocated\n", + ); + for row in rows { + writeln!( + output, + "{},{},{},{},{},{},{},{}", + row.workload, + row.implementation, + row.stats.allocations, + row.stats.reallocations, + row.stats.bytes_allocated, + row.stats.bytes_reallocated, + row.stats.deallocations, + row.stats.bytes_deallocated, + ) + .expect("writing to a String cannot fail"); + } + output +} diff --git a/crates/ratatui-textwrap/benches/textwrap-stress.rs b/crates/ratatui-textwrap/benches/textwrap-stress.rs new file mode 100644 index 0000000..4fc8812 --- /dev/null +++ b/crates/ratatui-textwrap/benches/textwrap-stress.rs @@ -0,0 +1,218 @@ +//! Opt-in Criterion benchmarks for expensive or pathological wrapping inputs. + +mod support; + +use std::hint::black_box; + +use criterion::{BatchSize, BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; + +use support::{ + Fixture, Implementation, RESIZE_WIDTHS, SESSION_FRAMES, StressProfile, VIEWPORT_WIDTH, + Viewport, core_fixtures, materialized_paragraph, native_paragraph, render, stress_fixture, + viewport_buffer, wrap, +}; + +fn stress(criterion: &mut Criterion) { + stress_profiles(criterion); + + let fixtures = core_fixtures(); + let large = fixtures + .iter() + .find(|fixture| fixture.name == "1-mib") + .expect("core corpus must contain the 1 MiB fixture"); + large_sessions(criterion, large); + deep_scroll(criterion); +} + +fn stress_profiles(criterion: &mut Criterion) { + let fixtures = [ + stress_fixture("long-line-64-kib", 64 * 1024, StressProfile::LongLine), + stress_fixture("whitespace-64-kib", 64 * 1024, StressProfile::Whitespace), + stress_fixture("unicode-64-kib", 64 * 1024, StressProfile::Unicode), + stress_fixture("unbreakable-64-kib", 64 * 1024, StressProfile::Unbreakable), + ]; + let mut group = criterion.benchmark_group("textwrap-stress/wrap-or-count"); + group.sample_size(10); + + for fixture in &fixtures { + group.throughput(Throughput::Bytes(fixture.source_bytes as u64)); + for implementation in Implementation::ALL { + let id = BenchmarkId::new(implementation.name(), fixture.name); + match implementation { + Implementation::NativeParagraph => { + let paragraph = native_paragraph(fixture, 0); + group.bench_function(id, move |bencher| { + bencher.iter(|| black_box(paragraph.line_count(VIEWPORT_WIDTH))); + }); + } + _ => { + group.bench_with_input(id, fixture, move |bencher, fixture| { + bencher.iter(|| { + let wrapped = wrap(fixture, implementation, VIEWPORT_WIDTH); + black_box(wrapped.lines.len()); + }); + }); + } + } + } + } + group.finish(); +} + +fn large_sessions(criterion: &mut Criterion, fixture: &Fixture) { + let mut same_width = criterion.benchmark_group("textwrap-stress/one-mib-amortized-60-frames"); + same_width.sample_size(10); + same_width.throughput(Throughput::Elements(SESSION_FRAMES as u64)); + + for implementation in Implementation::ALL { + let id = BenchmarkId::from_parameter(implementation.name()); + match implementation { + Implementation::NativeParagraph => { + let paragraph = native_paragraph(fixture, 0); + same_width.bench_function(id, move |bencher| { + bencher.iter_batched( + || viewport_buffer(VIEWPORT_WIDTH), + |mut buffer| { + for _ in 0..SESSION_FRAMES { + render(¶graph, VIEWPORT_WIDTH, &mut buffer); + } + }, + BatchSize::LargeInput, + ); + }); + } + _ => { + same_width.bench_with_input(id, fixture, move |bencher, fixture| { + bencher.iter_batched( + || viewport_buffer(VIEWPORT_WIDTH), + |mut buffer| { + let wrapped = wrap(fixture, implementation, VIEWPORT_WIDTH); + let paragraph = materialized_paragraph(wrapped, 0); + for _ in 0..SESSION_FRAMES { + render(¶graph, VIEWPORT_WIDTH, &mut buffer); + } + }, + BatchSize::LargeInput, + ); + }); + } + } + } + same_width.finish(); + + let mut resize = criterion.benchmark_group("textwrap-stress/one-mib-resize-60-frames"); + resize.sample_size(10); + resize.throughput(Throughput::Elements(SESSION_FRAMES as u64)); + let max_width = *RESIZE_WIDTHS + .iter() + .max() + .expect("resize widths must not be empty"); + + for implementation in Implementation::ALL { + let id = BenchmarkId::from_parameter(implementation.name()); + match implementation { + Implementation::NativeParagraph => { + let paragraph = native_paragraph(fixture, 0); + resize.bench_function(id, move |bencher| { + bencher.iter_batched( + || viewport_buffer(max_width), + |mut buffer| { + for frame in 0..SESSION_FRAMES { + let width = RESIZE_WIDTHS[frame % RESIZE_WIDTHS.len()]; + render(¶graph, width, &mut buffer); + } + }, + BatchSize::LargeInput, + ); + }); + } + _ => { + resize.bench_with_input(id, fixture, move |bencher, fixture| { + bencher.iter_batched( + || viewport_buffer(max_width), + |mut buffer| { + for frame in 0..SESSION_FRAMES { + let width = RESIZE_WIDTHS[frame % RESIZE_WIDTHS.len()]; + let wrapped = wrap(fixture, implementation, width); + let paragraph = materialized_paragraph(wrapped, 0); + render(¶graph, width, &mut buffer); + } + }, + BatchSize::LargeInput, + ); + }); + } + } + } + resize.finish(); +} + +fn deep_scroll(criterion: &mut Criterion) { + let fixture = stress_fixture( + "deep-scroll-4-mib", + 4 * 1024 * 1024, + StressProfile::LongLine, + ); + let mut cold = criterion.benchmark_group("textwrap-stress/deep-scroll-cold"); + cold.sample_size(10); + + for implementation in Implementation::ALL { + let line_count = fixture.line_count(implementation, VIEWPORT_WIDTH); + let scroll = Viewport::End.scroll(line_count); + let id = BenchmarkId::from_parameter(implementation.name()); + match implementation { + Implementation::NativeParagraph => { + let paragraph = native_paragraph(&fixture, scroll); + cold.bench_function(id, move |bencher| { + bencher.iter_batched( + || viewport_buffer(VIEWPORT_WIDTH), + |mut buffer| render(¶graph, VIEWPORT_WIDTH, &mut buffer), + BatchSize::LargeInput, + ); + }); + } + _ => { + cold.bench_with_input(id, &fixture, move |bencher, fixture| { + bencher.iter_batched( + || viewport_buffer(VIEWPORT_WIDTH), + |mut buffer| { + let wrapped = wrap(fixture, implementation, VIEWPORT_WIDTH); + let paragraph = materialized_paragraph(wrapped, scroll); + render(¶graph, VIEWPORT_WIDTH, &mut buffer); + }, + BatchSize::LargeInput, + ); + }); + } + } + } + cold.finish(); + + let mut cached = criterion.benchmark_group("textwrap-stress/deep-scroll-cached"); + cached.sample_size(10); + for implementation in Implementation::ALL { + let line_count = fixture.line_count(implementation, VIEWPORT_WIDTH); + let scroll = Viewport::End.scroll(line_count); + let paragraph = match implementation { + Implementation::NativeParagraph => native_paragraph(&fixture, scroll), + _ => { + let wrapped = wrap(&fixture, implementation, VIEWPORT_WIDTH); + materialized_paragraph(wrapped, scroll) + } + }; + cached.bench_function( + BenchmarkId::from_parameter(implementation.name()), + move |bencher| { + bencher.iter_batched( + || viewport_buffer(VIEWPORT_WIDTH), + |mut buffer| render(¶graph, VIEWPORT_WIDTH, &mut buffer), + BatchSize::LargeInput, + ); + }, + ); + } + cached.finish(); +} + +criterion_group!(benches, stress); +criterion_main!(benches); diff --git a/crates/ratatui-textwrap/benches/textwrap-workflows.rs b/crates/ratatui-textwrap/benches/textwrap-workflows.rs new file mode 100644 index 0000000..9245593 --- /dev/null +++ b/crates/ratatui-textwrap/benches/textwrap-workflows.rs @@ -0,0 +1,376 @@ +//! Criterion benchmarks for application-level text wrapping workflows. + +mod support; + +use std::hint::black_box; + +use criterion::measurement::WallTime; +use criterion::{ + BatchSize, BenchmarkGroup, BenchmarkId, Criterion, Throughput, criterion_group, criterion_main, +}; + +use support::{ + Fixture, Implementation, RESIZE_WIDTHS, SESSION_FRAMES, VIEWPORT_WIDTH, Viewport, + core_fixtures, materialized_paragraph, native_paragraph, render, session_fixtures, + viewport_buffer, wrap, +}; + +fn workflows(criterion: &mut Criterion) { + let fixtures = core_fixtures(); + + wrap_or_count(criterion, &fixtures); + fresh_render(criterion, &fixtures); + count_and_render(criterion, &fixtures, CountOrder::Before); + count_and_render(criterion, &fixtures, CountOrder::After); + same_width_amortized(criterion, &fixtures); + same_width_cached(criterion, &fixtures); + resize_recomputed(criterion, &fixtures); + resize_cached(criterion, &fixtures); + viewport_render(criterion, &fixtures, CacheState::Cold); + viewport_render(criterion, &fixtures, CacheState::Cached); +} + +fn wrap_or_count(criterion: &mut Criterion, fixtures: &[Fixture]) { + let mut group = criterion.benchmark_group("textwrap/wrap-or-count"); + for fixture in fixtures { + group.throughput(Throughput::Bytes(fixture.source_bytes as u64)); + for implementation in Implementation::ALL { + let id = BenchmarkId::new(implementation.name(), fixture.name); + match implementation { + Implementation::NativeParagraph => { + let paragraph = native_paragraph(fixture, 0); + group.bench_with_input(id, fixture, move |bencher, _| { + bencher.iter(|| black_box(paragraph.line_count(VIEWPORT_WIDTH))); + }); + } + _ => { + group.bench_with_input(id, fixture, move |bencher, fixture| { + bencher.iter(|| { + let wrapped = wrap(fixture, implementation, VIEWPORT_WIDTH); + black_box(wrapped.lines.len()); + }); + }); + } + } + } + } + group.finish(); +} + +fn fresh_render(criterion: &mut Criterion, fixtures: &[Fixture]) { + let mut group = criterion.benchmark_group("textwrap/fresh-render"); + for fixture in fixtures { + group.throughput(Throughput::Bytes(fixture.source_bytes as u64)); + for implementation in Implementation::ALL { + let id = BenchmarkId::new(implementation.name(), fixture.name); + match implementation { + Implementation::NativeParagraph => { + let paragraph = native_paragraph(fixture, 0); + bench_render(&mut group, id, fixture, move |fixture, buffer| { + black_box(fixture); + render(¶graph, VIEWPORT_WIDTH, buffer); + }); + } + _ => { + bench_render(&mut group, id, fixture, move |fixture, buffer| { + let wrapped = wrap(fixture, implementation, VIEWPORT_WIDTH); + let paragraph = materialized_paragraph(wrapped, 0); + render(¶graph, VIEWPORT_WIDTH, buffer); + }); + } + } + } + } + group.finish(); +} + +#[derive(Debug, Clone, Copy)] +enum CountOrder { + Before, + After, +} + +impl CountOrder { + const fn group_name(self) -> &'static str { + match self { + Self::Before => "textwrap/count-then-render", + Self::After => "textwrap/render-then-count", + } + } +} + +fn count_and_render(criterion: &mut Criterion, fixtures: &[Fixture], order: CountOrder) { + let mut group = criterion.benchmark_group(order.group_name()); + for fixture in fixtures { + group.throughput(Throughput::Bytes(fixture.source_bytes as u64)); + for implementation in Implementation::ALL { + let id = BenchmarkId::new(implementation.name(), fixture.name); + match implementation { + Implementation::NativeParagraph => { + let paragraph = native_paragraph(fixture, 0); + bench_render(&mut group, id, fixture, move |fixture, buffer| { + black_box(fixture); + count_and_render_paragraph(¶graph, VIEWPORT_WIDTH, buffer, order); + }); + } + _ => { + bench_render(&mut group, id, fixture, move |fixture, buffer| { + let wrapped = wrap(fixture, implementation, VIEWPORT_WIDTH); + let paragraph = materialized_paragraph(wrapped, 0); + count_and_render_paragraph(¶graph, VIEWPORT_WIDTH, buffer, order); + }); + } + } + } + } + group.finish(); +} + +fn count_and_render_paragraph( + paragraph: &ratatui::widgets::Paragraph<'_>, + width: u16, + buffer: &mut ratatui::buffer::Buffer, + order: CountOrder, +) { + match order { + CountOrder::Before => { + black_box(paragraph.line_count(width)); + render(paragraph, width, buffer); + } + CountOrder::After => { + render(paragraph, width, buffer); + black_box(paragraph.line_count(width)); + } + } +} + +fn same_width_amortized(criterion: &mut Criterion, fixtures: &[Fixture]) { + let mut group = criterion.benchmark_group("textwrap/same-width-amortized-60-frames"); + for fixture in session_fixtures(fixtures) { + group.throughput(Throughput::Elements(SESSION_FRAMES as u64)); + for implementation in Implementation::ALL { + let id = BenchmarkId::new(implementation.name(), fixture.name); + match implementation { + Implementation::NativeParagraph => { + let paragraph = native_paragraph(fixture, 0); + bench_session(&mut group, id, fixture, move |fixture, buffer| { + black_box(fixture); + for _ in 0..SESSION_FRAMES { + render(¶graph, VIEWPORT_WIDTH, buffer); + } + }); + } + _ => { + bench_session(&mut group, id, fixture, move |fixture, buffer| { + let wrapped = wrap(fixture, implementation, VIEWPORT_WIDTH); + let paragraph = materialized_paragraph(wrapped, 0); + for _ in 0..SESSION_FRAMES { + render(¶graph, VIEWPORT_WIDTH, buffer); + } + }); + } + } + } + } + group.finish(); +} + +fn same_width_cached(criterion: &mut Criterion, fixtures: &[Fixture]) { + let mut group = criterion.benchmark_group("textwrap/same-width-cached-60-frames"); + for fixture in session_fixtures(fixtures) { + group.throughput(Throughput::Elements(SESSION_FRAMES as u64)); + for implementation in Implementation::ALL { + let id = BenchmarkId::new(implementation.name(), fixture.name); + let paragraph = match implementation { + Implementation::NativeParagraph => native_paragraph(fixture, 0), + _ => { + let wrapped = wrap(fixture, implementation, VIEWPORT_WIDTH); + materialized_paragraph(wrapped, 0) + } + }; + bench_session(&mut group, id, fixture, move |fixture, buffer| { + black_box(fixture); + for _ in 0..SESSION_FRAMES { + render(¶graph, VIEWPORT_WIDTH, buffer); + } + }); + } + } + group.finish(); +} + +fn resize_recomputed(criterion: &mut Criterion, fixtures: &[Fixture]) { + let mut group = criterion.benchmark_group("textwrap/resize-recomputed-60-frames"); + for fixture in session_fixtures(fixtures) { + group.throughput(Throughput::Elements(SESSION_FRAMES as u64)); + for implementation in Implementation::ALL { + let id = BenchmarkId::new(implementation.name(), fixture.name); + match implementation { + Implementation::NativeParagraph => { + let paragraph = native_paragraph(fixture, 0); + bench_resize(&mut group, id, fixture, move |fixture, buffer| { + black_box(fixture); + for width in resize_sequence() { + render(¶graph, width, buffer); + } + }); + } + _ => { + bench_resize(&mut group, id, fixture, move |fixture, buffer| { + for width in resize_sequence() { + let wrapped = wrap(fixture, implementation, width); + let paragraph = materialized_paragraph(wrapped, 0); + render(¶graph, width, buffer); + } + }); + } + } + } + } + group.finish(); +} + +fn resize_cached(criterion: &mut Criterion, fixtures: &[Fixture]) { + let mut group = criterion.benchmark_group("textwrap/resize-cached-60-frames"); + for fixture in session_fixtures(fixtures) { + group.throughput(Throughput::Elements(SESSION_FRAMES as u64)); + for implementation in Implementation::ALL { + let id = BenchmarkId::new(implementation.name(), fixture.name); + match implementation { + Implementation::NativeParagraph => { + let paragraph = native_paragraph(fixture, 0); + bench_resize(&mut group, id, fixture, move |fixture, buffer| { + black_box(fixture); + for width in resize_sequence() { + render(¶graph, width, buffer); + } + }); + } + _ => { + let paragraphs = RESIZE_WIDTHS.map(|width| { + let wrapped = wrap(fixture, implementation, width); + materialized_paragraph(wrapped, 0) + }); + bench_resize(&mut group, id, fixture, move |fixture, buffer| { + black_box(fixture); + for index in resize_index_sequence() { + let width = RESIZE_WIDTHS[index]; + render(¶graphs[index], width, buffer); + } + }); + } + } + } + } + group.finish(); +} + +#[derive(Debug, Clone, Copy)] +enum CacheState { + Cold, + Cached, +} + +impl CacheState { + const fn group_name(self) -> &'static str { + match self { + Self::Cold => "textwrap/viewport-cold", + Self::Cached => "textwrap/viewport-cached", + } + } +} + +fn viewport_render(criterion: &mut Criterion, fixtures: &[Fixture], cache_state: CacheState) { + let mut group = criterion.benchmark_group(cache_state.group_name()); + for fixture in fixtures { + for implementation in Implementation::ALL { + let line_count = fixture.line_count(implementation, VIEWPORT_WIDTH); + for viewport in Viewport::ALL { + let parameter = format!("{}/{}", fixture.name, viewport.name()); + let id = BenchmarkId::new(implementation.name(), parameter); + let scroll = viewport.scroll(line_count); + match (implementation, cache_state) { + (Implementation::NativeParagraph, _) => { + let paragraph = native_paragraph(fixture, scroll); + bench_render(&mut group, id, fixture, move |fixture, buffer| { + black_box(fixture); + render(¶graph, VIEWPORT_WIDTH, buffer); + }); + } + (_, CacheState::Cold) => { + bench_render(&mut group, id, fixture, move |fixture, buffer| { + let wrapped = wrap(fixture, implementation, VIEWPORT_WIDTH); + let paragraph = materialized_paragraph(wrapped, scroll); + render(¶graph, VIEWPORT_WIDTH, buffer); + }); + } + (_, CacheState::Cached) => { + let wrapped = wrap(fixture, implementation, VIEWPORT_WIDTH); + let paragraph = materialized_paragraph(wrapped, scroll); + bench_render(&mut group, id, fixture, move |fixture, buffer| { + black_box(fixture); + render(¶graph, VIEWPORT_WIDTH, buffer); + }); + } + } + } + } + } + group.finish(); +} + +fn bench_render( + group: &mut BenchmarkGroup<'_, WallTime>, + id: BenchmarkId, + fixture: &Fixture, + mut operation: impl FnMut(&Fixture, &mut ratatui::buffer::Buffer), +) { + group.bench_with_input(id, fixture, |bencher, fixture| { + bencher.iter_batched( + || viewport_buffer(VIEWPORT_WIDTH), + |mut buffer| operation(fixture, &mut buffer), + BatchSize::LargeInput, + ); + }); +} + +fn bench_session( + group: &mut BenchmarkGroup<'_, WallTime>, + id: BenchmarkId, + fixture: &Fixture, + mut operation: impl FnMut(&Fixture, &mut ratatui::buffer::Buffer), +) { + bench_render(group, id, fixture, move |fixture, buffer| { + operation(fixture, buffer); + }); +} + +fn bench_resize( + group: &mut BenchmarkGroup<'_, WallTime>, + id: BenchmarkId, + fixture: &Fixture, + mut operation: impl FnMut(&Fixture, &mut ratatui::buffer::Buffer), +) { + let max_width = *RESIZE_WIDTHS + .iter() + .max() + .expect("resize widths must not be empty"); + group.bench_with_input(id, fixture, |bencher, fixture| { + bencher.iter_batched( + || viewport_buffer(max_width), + |mut buffer| operation(fixture, &mut buffer), + BatchSize::LargeInput, + ); + }); +} + +fn resize_sequence() -> impl Iterator { + resize_index_sequence().map(|index| RESIZE_WIDTHS[index]) +} + +fn resize_index_sequence() -> impl Iterator { + (0..SESSION_FRAMES).map(|frame| frame % RESIZE_WIDTHS.len()) +} + +criterion_group!(benches, workflows); +criterion_main!(benches); diff --git a/crates/ratatui-textwrap/docs/benchmark-results.md b/crates/ratatui-textwrap/docs/benchmark-results.md new file mode 100644 index 0000000..3cd433d --- /dev/null +++ b/crates/ratatui-textwrap/docs/benchmark-results.md @@ -0,0 +1,288 @@ +# `ratatui-textwrap` Benchmark Results + +This report is generated from Criterion raw samples and the one-shot allocation diagnostic. + +## Environment + +- Platform: macOS-15.7.7-arm64-arm-64bit-Mach-O +- Processor: Apple M2 Max +- Rust: `rustc 1.97.1 (8bab26f4f 2026-07-14) (Homebrew)` +- Benchmark dependencies: Criterion 0.8.2 and `stats_alloc` 0.1.10 +- Compared dependencies: Ratatui 0.30.2 and textwrap 0.16.2 +- Change: `qusnlzuqzqtq` + +## Reference configuration + +- Seed: `0x5EED_7E57_CAFE_BABE` +- Core inputs: approximately 4 KiB, 64 KiB, and 1 MiB +- Viewport: 200×50 cells +- Resize widths: 120, 160, 200, 240, and 280 cells +- Session length: 60 frames +- Sampling: 100 ms warm-up, 200 ms target measurement, 10 samples + +Use Criterion's default settings when evaluating a suspected regression. + +## Interpretation + +- Cold materialization processes the complete input, while native `Paragraph` can stop + after filling the viewport. +- Count-and-render groups expose native Paragraph's repeated wrapping and the owned + result's constant-time line count. +- Cached session and viewport groups isolate reuse after wrapping; resize-cached also + shows the effect of caching by width. +- Allocation results are one-shot diagnostics from an instrumented allocator and are not + part of Criterion's wall-time samples. + +### Observed deltas + +- In this run, cold 1 MiB viewport rendering with full materialization took 205.08–295.05× native + time because native stopped after 50 rows. +- The 1 MiB count-then-render workflow took 3.59–4.65× native time; avoiding the second wrap did not + offset the current materialization cost. +- At 64 KiB, steady same-width cached rendering took 0.84× native time, while wrap-once amortized + rendering took 1.11–1.22×. +- At 64 KiB, recomputing across resize widths took 18.69–24.61× native time; caching all five widths + reduced that to 0.82–0.83×. +- For a cached 1 MiB document, the middle viewport took 0.02× native time and the end viewport took + 0.01×. + +## Timing + +### `textwrap/count-then-render` + +| Input | Implementation | Median | Throughput | vs. Paragraph | +| ------ | ------------------ | --------: | ----------: | ------------: | +| 1-mib | `paragraph-native` | 27.30 ms | 36.64 MiB/s | 1.00× | +| 1-mib | `paragraph-compat` | 98.07 ms | 10.20 MiB/s | 3.59× | +| 1-mib | `first-fit` | 113.58 ms | 8.81 MiB/s | 4.16× | +| 1-mib | `optimal-fit` | 126.81 ms | 7.89 MiB/s | 4.65× | +| 4-kib | `paragraph-native` | 313.62 µs | 14.08 MiB/s | 1.00× | +| 4-kib | `paragraph-compat` | 623.07 µs | 7.09 MiB/s | 1.99× | +| 4-kib | `first-fit` | 692.46 µs | 6.38 MiB/s | 2.21× | +| 4-kib | `optimal-fit` | 750.58 µs | 5.88 MiB/s | 2.39× | +| 64-kib | `paragraph-native` | 1.97 ms | 31.95 MiB/s | 1.00× | +| 64-kib | `paragraph-compat` | 6.42 ms | 9.79 MiB/s | 3.26× | +| 64-kib | `first-fit` | 7.69 ms | 8.17 MiB/s | 3.91× | +| 64-kib | `optimal-fit` | 8.51 ms | 7.38 MiB/s | 4.33× | + +### `textwrap/fresh-render` + +| Input | Implementation | Median | Throughput | vs. Paragraph | +| ------ | ------------------ | --------: | ------------: | ------------: | +| 1-mib | `paragraph-native` | 595.40 µs | 1680.15 MiB/s | 1.00× | +| 1-mib | `paragraph-compat` | 175.67 ms | 5.69 MiB/s | 295.05× | +| 1-mib | `first-fit` | 122.11 ms | 8.19 MiB/s | 205.08× | +| 1-mib | `optimal-fit` | 130.38 ms | 7.67 MiB/s | 218.97× | +| 4-kib | `paragraph-native` | 196.96 µs | 22.42 MiB/s | 1.00× | +| 4-kib | `paragraph-compat` | 854.81 µs | 5.17 MiB/s | 4.34× | +| 4-kib | `first-fit` | 861.50 µs | 5.13 MiB/s | 4.37× | +| 4-kib | `optimal-fit` | 953.09 µs | 4.63 MiB/s | 4.84× | +| 64-kib | `paragraph-native` | 759.27 µs | 82.74 MiB/s | 1.00× | +| 64-kib | `paragraph-compat` | 10.47 ms | 6.00 MiB/s | 13.80× | +| 64-kib | `first-fit` | 10.39 ms | 6.04 MiB/s | 13.69× | +| 64-kib | `optimal-fit` | 12.47 ms | 5.04 MiB/s | 16.43× | + +### `textwrap/render-then-count` + +| Input | Implementation | Median | Throughput | vs. Paragraph | +| ------ | ------------------ | --------: | ----------: | ------------: | +| 1-mib | `paragraph-native` | 26.54 ms | 37.69 MiB/s | 1.00× | +| 1-mib | `paragraph-compat` | 98.54 ms | 10.15 MiB/s | 3.71× | +| 1-mib | `first-fit` | 113.95 ms | 8.78 MiB/s | 4.29× | +| 1-mib | `optimal-fit` | 127.05 ms | 7.87 MiB/s | 4.79× | +| 4-kib | `paragraph-native` | 310.82 µs | 14.21 MiB/s | 1.00× | +| 4-kib | `paragraph-compat` | 591.30 µs | 7.47 MiB/s | 1.90× | +| 4-kib | `first-fit` | 687.43 µs | 6.42 MiB/s | 2.21× | +| 4-kib | `optimal-fit` | 747.81 µs | 5.90 MiB/s | 2.41× | +| 64-kib | `paragraph-native` | 1.99 ms | 31.64 MiB/s | 1.00× | +| 64-kib | `paragraph-compat` | 6.48 ms | 9.70 MiB/s | 3.26× | +| 64-kib | `first-fit` | 7.69 ms | 8.17 MiB/s | 3.87× | +| 64-kib | `optimal-fit` | 8.39 ms | 7.48 MiB/s | 4.23× | + +### `textwrap/resize-cached-60-frames` + +| Input | Implementation | Median/frame | Throughput | vs. Paragraph | +| ------ | ------------------ | -----------: | --------------: | ------------: | +| 4-kib | `paragraph-native` | 196.28 µs | 5094.8 frames/s | 1.00× | +| 4-kib | `paragraph-compat` | 170.57 µs | 5862.6 frames/s | 0.87× | +| 4-kib | `first-fit` | 168.50 µs | 5934.7 frames/s | 0.86× | +| 4-kib | `optimal-fit` | 169.80 µs | 5889.1 frames/s | 0.87× | +| 64-kib | `paragraph-native` | 342.25 µs | 2921.9 frames/s | 1.00× | +| 64-kib | `paragraph-compat` | 279.63 µs | 3576.2 frames/s | 0.82× | +| 64-kib | `first-fit` | 283.15 µs | 3531.6 frames/s | 0.83× | +| 64-kib | `optimal-fit` | 282.82 µs | 3535.8 frames/s | 0.83× | + +### `textwrap/resize-recomputed-60-frames` + +| Input | Implementation | Median/frame | Throughput | vs. Paragraph | +| ------ | ------------------ | -----------: | --------------: | ------------: | +| 4-kib | `paragraph-native` | 196.21 µs | 5096.6 frames/s | 1.00× | +| 4-kib | `paragraph-compat` | 592.02 µs | 1689.1 frames/s | 3.02× | +| 4-kib | `first-fit` | 683.01 µs | 1464.1 frames/s | 3.48× | +| 4-kib | `optimal-fit` | 740.27 µs | 1350.9 frames/s | 3.77× | +| 64-kib | `paragraph-native` | 343.01 µs | 2915.4 frames/s | 1.00× | +| 64-kib | `paragraph-compat` | 6.41 ms | 156.0 frames/s | 18.69× | +| 64-kib | `first-fit` | 7.60 ms | 131.6 frames/s | 22.15× | +| 64-kib | `optimal-fit` | 8.44 ms | 118.4 frames/s | 24.61× | + +### `textwrap/same-width-amortized-60-frames` + +| Input | Implementation | Median/frame | Throughput | vs. Paragraph | +| ------ | ------------------ | -----------: | --------------: | ------------: | +| 4-kib | `paragraph-native` | 195.01 µs | 5127.9 frames/s | 1.00× | +| 4-kib | `paragraph-compat` | 174.68 µs | 5724.7 frames/s | 0.90× | +| 4-kib | `first-fit` | 177.20 µs | 5643.3 frames/s | 0.91× | +| 4-kib | `optimal-fit` | 178.71 µs | 5595.5 frames/s | 0.92× | +| 64-kib | `paragraph-native` | 350.07 µs | 2856.6 frames/s | 1.00× | +| 64-kib | `paragraph-compat` | 389.89 µs | 2564.8 frames/s | 1.11× | +| 64-kib | `first-fit` | 414.61 µs | 2411.9 frames/s | 1.18× | +| 64-kib | `optimal-fit` | 428.57 µs | 2333.4 frames/s | 1.22× | + +### `textwrap/same-width-cached-60-frames` + +| Input | Implementation | Median/frame | Throughput | vs. Paragraph | +| ------ | ------------------ | -----------: | --------------: | ------------: | +| 4-kib | `paragraph-native` | 196.46 µs | 5090.1 frames/s | 1.00× | +| 4-kib | `paragraph-compat` | 171.54 µs | 5829.7 frames/s | 0.87× | +| 4-kib | `first-fit` | 173.96 µs | 5748.3 frames/s | 0.89× | +| 4-kib | `optimal-fit` | 169.56 µs | 5897.6 frames/s | 0.86× | +| 64-kib | `paragraph-native` | 350.32 µs | 2854.6 frames/s | 1.00× | +| 64-kib | `paragraph-compat` | 292.61 µs | 3417.6 frames/s | 0.84× | +| 64-kib | `first-fit` | 293.65 µs | 3405.4 frames/s | 0.84× | +| 64-kib | `optimal-fit` | 293.60 µs | 3406.0 frames/s | 0.84× | + +### `textwrap/viewport-cached` + +| Input | Implementation | Median | Throughput | vs. Paragraph | +| ------------- | ------------------ | --------: | ---------: | ------------: | +| 1-mib/end | `paragraph-native` | 26.19 ms | — | 1.00× | +| 1-mib/end | `paragraph-compat` | 280.59 µs | — | 0.01× | +| 1-mib/end | `first-fit` | 281.44 µs | — | 0.01× | +| 1-mib/end | `optimal-fit` | 282.02 µs | — | 0.01× | +| 1-mib/middle | `paragraph-native` | 13.31 ms | — | 1.00× | +| 1-mib/middle | `paragraph-compat` | 285.91 µs | — | 0.02× | +| 1-mib/middle | `first-fit` | 284.40 µs | — | 0.02× | +| 1-mib/middle | `optimal-fit` | 289.62 µs | — | 0.02× | +| 1-mib/start | `paragraph-native` | 339.80 µs | — | 1.00× | +| 1-mib/start | `paragraph-compat` | 284.58 µs | — | 0.84× | +| 1-mib/start | `first-fit` | 282.70 µs | — | 0.83× | +| 1-mib/start | `optimal-fit` | 283.24 µs | — | 0.83× | +| 4-kib/end | `paragraph-native` | 200.76 µs | — | 1.00× | +| 4-kib/end | `paragraph-compat` | 173.47 µs | — | 0.86× | +| 4-kib/end | `first-fit` | 173.05 µs | — | 0.86× | +| 4-kib/end | `optimal-fit` | 172.81 µs | — | 0.86× | +| 4-kib/middle | `paragraph-native` | 200.96 µs | — | 1.00× | +| 4-kib/middle | `paragraph-compat` | 173.74 µs | — | 0.86× | +| 4-kib/middle | `first-fit` | 175.08 µs | — | 0.87× | +| 4-kib/middle | `optimal-fit` | 174.12 µs | — | 0.87× | +| 4-kib/start | `paragraph-native` | 201.46 µs | — | 1.00× | +| 4-kib/start | `paragraph-compat` | 173.10 µs | — | 0.86× | +| 4-kib/start | `first-fit` | 173.05 µs | — | 0.86× | +| 4-kib/start | `optimal-fit` | 174.95 µs | — | 0.87× | +| 64-kib/end | `paragraph-native` | 1.71 ms | — | 1.00× | +| 64-kib/end | `paragraph-compat` | 272.91 µs | — | 0.16× | +| 64-kib/end | `first-fit` | 277.20 µs | — | 0.16× | +| 64-kib/end | `optimal-fit` | 278.11 µs | — | 0.16× | +| 64-kib/middle | `paragraph-native` | 1.06 ms | — | 1.00× | +| 64-kib/middle | `paragraph-compat` | 287.77 µs | — | 0.27× | +| 64-kib/middle | `first-fit` | 293.29 µs | — | 0.28× | +| 64-kib/middle | `optimal-fit` | 281.22 µs | — | 0.27× | +| 64-kib/start | `paragraph-native` | 352.20 µs | — | 1.00× | +| 64-kib/start | `paragraph-compat` | 294.68 µs | — | 0.84× | +| 64-kib/start | `first-fit` | 296.70 µs | — | 0.84× | +| 64-kib/start | `optimal-fit` | 291.55 µs | — | 0.83× | + +### `textwrap/viewport-cold` + +| Input | Implementation | Median | Throughput | vs. Paragraph | +| ------------- | ------------------ | --------: | ---------: | ------------: | +| 1-mib/end | `paragraph-native` | 25.43 ms | — | 1.00× | +| 1-mib/end | `paragraph-compat` | 98.57 ms | — | 3.88× | +| 1-mib/end | `first-fit` | 114.26 ms | — | 4.49× | +| 1-mib/end | `optimal-fit` | 128.23 ms | — | 5.04× | +| 1-mib/middle | `paragraph-native` | 13.14 ms | — | 1.00× | +| 1-mib/middle | `paragraph-compat` | 97.98 ms | — | 7.45× | +| 1-mib/middle | `first-fit` | 114.75 ms | — | 8.73× | +| 1-mib/middle | `optimal-fit` | 128.22 ms | — | 9.76× | +| 1-mib/start | `paragraph-native` | 335.67 µs | — | 1.00× | +| 1-mib/start | `paragraph-compat` | 97.64 ms | — | 290.88× | +| 1-mib/start | `first-fit` | 114.46 ms | — | 340.99× | +| 1-mib/start | `optimal-fit` | 127.69 ms | — | 380.40× | +| 4-kib/end | `paragraph-native` | 197.16 µs | — | 1.00× | +| 4-kib/end | `paragraph-compat` | 583.81 µs | — | 2.96× | +| 4-kib/end | `first-fit` | 704.21 µs | — | 3.57× | +| 4-kib/end | `optimal-fit` | 770.75 µs | — | 3.91× | +| 4-kib/middle | `paragraph-native` | 200.55 µs | — | 1.00× | +| 4-kib/middle | `paragraph-compat` | 574.89 µs | — | 2.87× | +| 4-kib/middle | `first-fit` | 698.21 µs | — | 3.48× | +| 4-kib/middle | `optimal-fit` | 783.65 µs | — | 3.91× | +| 4-kib/start | `paragraph-native` | 200.79 µs | — | 1.00× | +| 4-kib/start | `paragraph-compat` | 589.19 µs | — | 2.93× | +| 4-kib/start | `first-fit` | 700.10 µs | — | 3.49× | +| 4-kib/start | `optimal-fit` | 779.45 µs | — | 3.88× | +| 64-kib/end | `paragraph-native` | 1.78 ms | — | 1.00× | +| 64-kib/end | `paragraph-compat` | 6.48 ms | — | 3.65× | +| 64-kib/end | `first-fit` | 7.58 ms | — | 4.26× | +| 64-kib/end | `optimal-fit` | 8.48 ms | — | 4.77× | +| 64-kib/middle | `paragraph-native` | 1.08 ms | — | 1.00× | +| 64-kib/middle | `paragraph-compat` | 6.49 ms | — | 6.01× | +| 64-kib/middle | `first-fit` | 7.70 ms | — | 7.13× | +| 64-kib/middle | `optimal-fit` | 8.53 ms | — | 7.90× | +| 64-kib/start | `paragraph-native` | 361.37 µs | — | 1.00× | +| 64-kib/start | `paragraph-compat` | 6.47 ms | — | 17.91× | +| 64-kib/start | `first-fit` | 7.70 ms | — | 21.31× | +| 64-kib/start | `optimal-fit` | 8.52 ms | — | 23.57× | + +### `textwrap/wrap-or-count` + +| Input | Implementation | Median | Throughput | vs. Paragraph | +| ------ | ------------------ | --------: | ----------: | ------------: | +| 1-mib | `paragraph-native` | 26.26 ms | 38.09 MiB/s | 1.00× | +| 1-mib | `paragraph-compat` | 95.81 ms | 10.44 MiB/s | 3.65× | +| 1-mib | `first-fit` | 109.04 ms | 9.17 MiB/s | 4.15× | +| 1-mib | `optimal-fit` | 124.41 ms | 8.04 MiB/s | 4.74× | +| 4-kib | `paragraph-native` | 110.42 µs | 39.99 MiB/s | 1.00× | +| 4-kib | `paragraph-compat` | 403.87 µs | 10.93 MiB/s | 3.66× | +| 4-kib | `first-fit` | 493.12 µs | 8.95 MiB/s | 4.47× | +| 4-kib | `optimal-fit` | 549.26 µs | 8.04 MiB/s | 4.97× | +| 64-kib | `paragraph-native` | 1.54 ms | 40.67 MiB/s | 1.00× | +| 64-kib | `paragraph-compat` | 5.98 ms | 10.50 MiB/s | 3.87× | +| 64-kib | `first-fit` | 7.14 ms | 8.81 MiB/s | 4.62× | +| 64-kib | `optimal-fit` | 7.98 ms | 7.87 MiB/s | 5.17× | + +## Allocations + +| Workload | Implementation | Allocations | Reallocations | Bytes allocated | vs. Paragraph | +| ------------------------------ | ------------------ | ----------: | ------------: | --------------: | ------------: | +| count-then-render | `paragraph-native` | 334 | 1,581 | 2,710,528 | 1.00× | +| count-then-render | `paragraph-compat` | 70,304 | 9,346 | 9,801,077 | 3.62× | +| count-then-render | `first-fit` | 92,618 | 17,587 | 9,501,034 | 3.51× | +| count-then-render | `optimal-fit` | 93,178 | 17,653 | 10,174,548 | 3.75× | +| resize-cached-60-frames | `paragraph-native` | 2,712 | 11,784 | 17,884,416 | 1.00× | +| resize-cached-60-frames | `paragraph-compat` | 60 | 360 | 540,672 | 0.03× | +| resize-cached-60-frames | `first-fit` | 60 | 360 | 540,672 | 0.03× | +| resize-cached-60-frames | `optimal-fit` | 60 | 360 | 540,672 | 0.03× | +| resize-recomputed-60-frames | `paragraph-native` | 2,712 | 11,784 | 17,884,416 | 1.00× | +| resize-recomputed-60-frames | `paragraph-compat` | 4,223,796 | 565,752 | 563,992,176 | 31.54× | +| resize-recomputed-60-frames | `first-fit` | 5,560,812 | 1,055,340 | 570,475,308 | 31.90× | +| resize-recomputed-60-frames | `optimal-fit` | 5,594,244 | 1,058,844 | 610,864,464 | 34.16× | +| same-width-amortized-60-frames | `paragraph-native` | 2,640 | 12,180 | 20,286,720 | 1.00× | +| same-width-amortized-60-frames | `paragraph-compat` | 70,363 | 9,700 | 10,284,405 | 0.51× | +| same-width-amortized-60-frames | `first-fit` | 92,677 | 17,941 | 9,984,362 | 0.49× | +| same-width-amortized-60-frames | `optimal-fit` | 93,237 | 18,007 | 10,657,876 | 0.53× | +| same-width-cached-60-frames | `paragraph-native` | 2,640 | 12,180 | 20,286,720 | 1.00× | +| same-width-cached-60-frames | `paragraph-compat` | 60 | 360 | 491,520 | 0.02× | +| same-width-cached-60-frames | `first-fit` | 60 | 360 | 491,520 | 0.02× | +| same-width-cached-60-frames | `optimal-fit` | 60 | 360 | 491,520 | 0.02× | +| viewport-middle-cached | `paragraph-native` | 168 | 804 | 1,376,544 | 1.00× | +| viewport-middle-cached | `paragraph-compat` | 1 | 6 | 8,192 | 0.01× | +| viewport-middle-cached | `first-fit` | 1 | 6 | 8,192 | 0.01× | +| viewport-middle-cached | `optimal-fit` | 1 | 6 | 8,192 | 0.01× | +| viewport-middle-cold | `paragraph-native` | 168 | 804 | 1,376,544 | 1.00× | +| viewport-middle-cold | `paragraph-compat` | 70,304 | 9,346 | 9,801,077 | 7.12× | +| viewport-middle-cold | `first-fit` | 92,618 | 17,587 | 9,501,034 | 6.90× | +| viewport-middle-cold | `optimal-fit` | 93,178 | 17,653 | 10,174,548 | 7.39× | +| wrap-or-count | `paragraph-native` | 290 | 1,378 | 2,372,416 | 1.00× | +| wrap-or-count | `paragraph-compat` | 70,303 | 9,340 | 9,792,885 | 4.13× | +| wrap-or-count | `first-fit` | 92,617 | 17,581 | 9,492,842 | 4.00× | +| wrap-or-count | `optimal-fit` | 93,177 | 17,647 | 10,166,356 | 4.29× | diff --git a/crates/ratatui-textwrap/docs/benchmarks.md b/crates/ratatui-textwrap/docs/benchmarks.md new file mode 100644 index 0000000..47b80bb --- /dev/null +++ b/crates/ratatui-textwrap/docs/benchmarks.md @@ -0,0 +1,137 @@ +# Text Wrapping Benchmarks + +These benchmarks compare application workflows rather than isolated wrapping helpers. They answer +when owned materialization pays for its allocations, when native `Paragraph` avoids full-document +work by stopping at the viewport, and how first-fit and optimal-fit change the cost. + +## Compared implementations + +- `paragraph-native` stores the source in `Paragraph` and uses `Wrap { trim: true }`. +- `paragraph-compat` materializes with `ParagraphCompat { trim: true }`, then renders without + wrapping. +- `first-fit` and `optimal-fit` materialize with textwrap's two line-breaking algorithms, then + render without wrapping. + +Only `paragraph-compat` is expected to produce the same wrapped cells as native `Paragraph`. +First-fit and optimal-fit intentionally retain their documented whitespace and overflow behavior. + +Cold materialized workflows include cloning the source `Text`. `TextWrapper::wrap` consumes its +input, so retaining the source for later widths requires that clone. Cached workflows construct and +retain an unwrapped `Paragraph` from the materialized result before measurement. + +## Corpus and viewport + +The core corpus uses the fixed seed `0x5EED_7E57_CAFE_BABE` and approximately 4 KiB, 64 KiB, and +1 MiB of text. Logical lines contain prose-like words, repeated whitespace, style changes, span +boundaries inside some words, combining characters, and occasional wide graphemes. Generation and +fixture validation happen before Criterion starts measuring. + +The normal viewport is 200×50 cells. Resize sessions repeat widths 120, 160, 200, 240, and 280 at a +constant height of 50 rows. Sessions contain 60 frames. + +The 4 KiB and 64 KiB fixtures participate in multi-frame core sessions. Single-operation and +viewport groups also include 1 MiB. The opt-in stress target adds 1 MiB sessions, a 4 MiB deep +scroll, and 64 KiB long-line, whitespace-heavy, Unicode-heavy, and unbreakable inputs. + +## Workloads + +- **`wrap-or-count`:** native fully wraps to count lines; materialized algorithms clone and wrap, + then read `Text::lines.len()`. +- **`fresh-render`:** native wraps only far enough to fill 50 rows; materialized algorithms wrap + the full input, then render 50 rows. +- **`count-then-render`:** native fully wraps to count, then wraps again while rendering; + materialized algorithms wrap once and count the unwrapped `Paragraph` in constant time. +- **`render-then-count`:** native renders the viewport, then fully wraps to count; materialized + algorithms wrap once and count in constant time. +- **`same-width-amortized-60-frames`:** native wraps every frame; materialized algorithms wrap once + inside the measured session and reuse the result. +- **`same-width-cached-60-frames`:** native wraps every frame; materialized algorithms use a result + prepared before measurement. +- **`resize-recomputed-60-frames`:** native rewraps the viewport at each width; materialized + algorithms wrap the full input at every width change. +- **`resize-cached-60-frames`:** native rewraps the viewport at each width; materialized algorithms + reuse results cached by width before measurement. +- **`viewport-cold`:** native wraps through the requested scroll row and 50 visible rows; + materialized algorithms wrap the full input before scrolling. +- **`viewport-cached`:** native still wraps through the requested scroll row; materialized + algorithms skip directly through pre-materialized lines. + +Buffers, caches, scroll offsets, and fixture generation are outside timed regions unless the group +explicitly names cold materialization or amortized wrapping. Full-input groups report bytes per +second; session groups report frames per second. + +## Running + +Run the core suite from the workspace root: + +```console +cargo bench -p ratatui-textwrap --bench textwrap-workflows +``` + +Criterion accepts a benchmark-name filter: + +```console +cargo bench -p ratatui-textwrap --bench textwrap-workflows -- wrap-or-count +``` + +Compile and execute every core benchmark once without measuring it: + +```console +cargo bench -p ratatui-textwrap --bench textwrap-workflows -- --test +``` + +Run the expensive stress suite separately: + +```console +cargo bench -p ratatui-textwrap --bench textwrap-stress +``` + +The allocation diagnostic is deliberately separate from Criterion wall-time measurement. It uses +an instrumented system allocator and writes one CSV row per 64 KiB workflow and implementation: + +```console +cargo bench -p ratatui-textwrap --bench textwrap-allocations -- \ + target/textwrap-allocations.csv +``` + +Regenerate the Markdown result tables from Criterion's stable raw CSV samples: + +```console +python3 crates/ratatui-textwrap/scripts/summarize-benchmarks.py \ + --criterion-dir target/criterion \ + --allocations target/textwrap-allocations.csv \ + --sampling "Criterion defaults" \ + --output crates/ratatui-textwrap/docs/benchmark-results.md +``` + +The report script calculates the median measured time per iteration. For 60-frame groups it also +shows time per frame. Ratios compare each implementation with `paragraph-native` for the same group +and input. + +## Tracking changes + +Raw Criterion results remain under the ignored `target/` directory. Save a named baseline before +changing an implementation, then compare without overwriting it: + +```console +cargo bench -p ratatui-textwrap --bench textwrap-workflows -- --save-baseline main +cargo bench -p ratatui-textwrap --bench textwrap-workflows -- --baseline main +``` + +Baselines are machine-local. Do not compare absolute timings collected on different hardware or +under materially different system load. The checked-in [benchmark results](benchmark-results.md) +include their environment and revision so they serve as a reference, not a regression threshold. + +To profile one workload without Criterion analysis or saved results: + +```console +cargo bench -p ratatui-textwrap --bench textwrap-workflows -- \ + same-width-amortized --profile-time 10 +``` + +## Validation + +Fixture preflight checks run before measurement. They verify deterministic size bounds, matching +native and paragraph-compatible line counts, matching native and paragraph-compatible buffers at +the start, middle, and end viewports, repeatable output for the textwrap algorithms, and scroll +offsets that fit `Paragraph`'s `u16` API. diff --git a/crates/ratatui-textwrap/scripts/summarize-benchmarks.py b/crates/ratatui-textwrap/scripts/summarize-benchmarks.py new file mode 100644 index 0000000..1812d2e --- /dev/null +++ b/crates/ratatui-textwrap/scripts/summarize-benchmarks.py @@ -0,0 +1,396 @@ +#!/usr/bin/env python3 +"""Summarize Criterion raw samples and allocation diagnostics as Markdown.""" + +from __future__ import annotations + +import argparse +import csv +import platform +import statistics +import subprocess +import textwrap +from collections import defaultdict +from dataclasses import dataclass +from pathlib import Path + + +BASELINE = "paragraph-native" +FRAMES_PER_SESSION = 60 +IMPLEMENTATION_ORDER = { + "paragraph-native": 0, + "paragraph-compat": 1, + "first-fit": 2, + "optimal-fit": 3, +} +MATERIALIZED = ("paragraph-compat", "first-fit", "optimal-fit") + + +@dataclass(frozen=True) +class Timing: + group: str + function: str + value: str + median_ns: float + throughput: str + + +def arguments() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--criterion-dir", + type=Path, + default=Path("target/criterion"), + help="Criterion output directory", + ) + parser.add_argument( + "--allocations", + type=Path, + help="CSV produced by the textwrap-allocations benchmark", + ) + parser.add_argument( + "--sampling", + default="not recorded in Criterion's raw CSV", + help="sampling settings to record in the generated report", + ) + parser.add_argument("--output", type=Path, help="write Markdown to this path") + return parser.parse_args() + + +def read_timings(root: Path) -> list[Timing]: + samples: dict[tuple[str, str, str], list[float]] = defaultdict(list) + throughput: dict[tuple[str, str, str], tuple[str, float]] = {} + + for path in sorted(root.glob("**/new/raw.csv")): + with path.open(newline="", encoding="utf-8") as source: + for row in csv.DictReader(source): + key = (row["group"], row["function"], row["value"]) + measured = float(row["sample_measured_value"]) + iterations = float(row["iteration_count"]) + samples[key].append(measured / iterations) + if row["throughput_type"]: + throughput[key] = ( + row["throughput_type"], + float(row["throughput_num"]), + ) + + timings = [] + for key, values in samples.items(): + group, function, value = key + median_ns = statistics.median(values) + timings.append( + Timing( + group, + function, + value, + median_ns, + format_throughput(median_ns, throughput.get(key)), + ) + ) + return sorted( + timings, + key=lambda timing: ( + timing.group, + timing.value, + IMPLEMENTATION_ORDER.get(timing.function, 99), + ), + ) + + +def format_throughput( + median_ns: float, + throughput: tuple[str, float] | None, +) -> str: + if throughput is None or median_ns == 0: + return "—" + kind, amount = throughput + per_second = amount / (median_ns / 1_000_000_000) + if kind == "bytes": + return f"{per_second / (1024 * 1024):.2f} MiB/s" + if kind == "elements": + return f"{per_second:.1f} frames/s" + return f"{per_second:.1f} {kind}/s" + + +def timing_markdown(timings: list[Timing]) -> list[str]: + if not timings: + return ["_No Criterion `raw.csv` samples were found._", ""] + + lines = [] + by_group: dict[str, list[Timing]] = defaultdict(list) + for timing in timings: + by_group[timing.group].append(timing) + + for group, group_timings in by_group.items(): + lines.extend([f"### `{group}`", ""]) + per_frame = "60-frames" in group + label = "Median/frame" if per_frame else "Median" + rows = [] + baselines = { + timing.value: timing.median_ns + for timing in group_timings + if timing.function == BASELINE + } + for timing in group_timings: + displayed_ns = ( + timing.median_ns / FRAMES_PER_SESSION if per_frame else timing.median_ns + ) + baseline = baselines.get(timing.value) + ratio = timing.median_ns / baseline if baseline else None + ratio_text = f"{ratio:.2f}×" if ratio is not None else "—" + rows.append( + [ + timing.value, + f"`{timing.function}`", + format_duration(displayed_ns), + timing.throughput, + ratio_text, + ] + ) + lines.extend( + markdown_table( + ["Input", "Implementation", label, "Throughput", "vs. Paragraph"], + rows, + right_aligned={2, 3, 4}, + ) + ) + lines.append("") + return lines + + +def allocation_markdown(path: Path | None) -> list[str]: + if path is None or not path.exists(): + return ["_No allocation diagnostic CSV was supplied._", ""] + + with path.open(newline="", encoding="utf-8") as source: + rows = list(csv.DictReader(source)) + rows.sort( + key=lambda row: ( + row["workload"], + IMPLEMENTATION_ORDER.get(row["implementation"], 99), + ) + ) + baselines = { + row["workload"]: int(row["bytes_allocated"]) + for row in rows + if row["implementation"] == BASELINE + } + table_rows = [] + for row in rows: + allocated = int(row["bytes_allocated"]) + baseline = baselines.get(row["workload"]) + ratio = allocated / baseline if baseline else None + ratio_text = f"{ratio:.2f}×" if ratio is not None else "—" + table_rows.append( + [ + row["workload"], + f"`{row['implementation']}`", + f"{int(row['allocations']):,}", + f"{int(row['reallocations']):,}", + f"{allocated:,}", + ratio_text, + ] + ) + lines = markdown_table( + [ + "Workload", + "Implementation", + "Allocations", + "Reallocations", + "Bytes allocated", + "vs. Paragraph", + ], + table_rows, + right_aligned={2, 3, 4, 5}, + ) + lines.append("") + return lines + + +def markdown_table( + headers: list[str], + rows: list[list[str]], + *, + right_aligned: set[int], +) -> list[str]: + widths = [ + max(3, len(header), *(len(row[index]) for row in rows)) + for index, header in enumerate(headers) + ] + + def cells(values: list[str]) -> str: + formatted = [ + value.rjust(widths[index]) + if index in right_aligned + else value.ljust(widths[index]) + for index, value in enumerate(values) + ] + return f"| {' | '.join(formatted)} |" + + delimiters = [ + "-" * (width - 1) + ":" if index in right_aligned else "-" * width + for index, width in enumerate(widths) + ] + return [cells(headers), cells(delimiters), *(cells(row) for row in rows)] + + +def format_duration(nanoseconds: float) -> str: + if nanoseconds < 1_000: + return f"{nanoseconds:.1f} ns" + if nanoseconds < 1_000_000: + return f"{nanoseconds / 1_000:.2f} µs" + if nanoseconds < 1_000_000_000: + return f"{nanoseconds / 1_000_000:.2f} ms" + return f"{nanoseconds / 1_000_000_000:.2f} s" + + +def measured_observations(timings: list[Timing]) -> list[str]: + indexed = { + (timing.group, timing.value, timing.function): timing.median_ns + for timing in timings + } + + def ratios(group: str, value: str) -> tuple[float, float]: + baseline = indexed[(group, value, BASELINE)] + values = [ + indexed[(group, value, implementation)] / baseline + for implementation in MATERIALIZED + ] + return min(values), max(values) + + def ratio_text(group: str, value: str) -> str: + low, high = ratios(group, value) + if f"{low:.2f}" == f"{high:.2f}": + return f"{low:.2f}×" + return f"{low:.2f}–{high:.2f}×" + + try: + observations = [ + "- In this run, cold 1 MiB viewport rendering with full materialization took " + f"{ratio_text('textwrap/fresh-render', '1-mib')} native time because native stopped " + "after 50 rows.", + "- The 1 MiB count-then-render workflow took " + f"{ratio_text('textwrap/count-then-render', '1-mib')} native time; avoiding the second " + "wrap did not offset the current materialization cost.", + "- At 64 KiB, steady same-width cached rendering took " + f"{ratio_text('textwrap/same-width-cached-60-frames', '64-kib')} native time, while " + "wrap-once amortized rendering took " + f"{ratio_text('textwrap/same-width-amortized-60-frames', '64-kib')}.", + "- At 64 KiB, recomputing across resize widths took " + f"{ratio_text('textwrap/resize-recomputed-60-frames', '64-kib')} native time; caching " + "all five widths reduced that to " + f"{ratio_text('textwrap/resize-cached-60-frames', '64-kib')}.", + "- For a cached 1 MiB document, the middle viewport took " + f"{ratio_text('textwrap/viewport-cached', '1-mib/middle')} native time and the end " + f"viewport took {ratio_text('textwrap/viewport-cached', '1-mib/end')}.", + ] + return [ + line + for observation in observations + for line in textwrap.wrap( + observation, + width=100, + subsequent_indent=" ", + ) + ] + except KeyError: + return [ + "- The expected core benchmark matrix was incomplete, so measured summary deltas " + "could not be generated.", + ] + + +def command_output(*command: str) -> str: + try: + return subprocess.check_output(command, text=True).strip() + except (OSError, subprocess.CalledProcessError): + return "unavailable" + + +def cpu_name() -> str: + if platform.system() == "Darwin": + name = command_output("sysctl", "-n", "machdep.cpu.brand_string") + if name != "unavailable": + return name + return platform.processor() or "unavailable" + + +def report(timings: list[Timing], allocations: Path | None, sampling: str) -> str: + rustc = command_output("rustc", "--version") + revision = command_output( + "jj", + "log", + "-r", + "@", + "--no-graph", + "-T", + "change_id.short()", + ) + lines = [ + "# `ratatui-textwrap` Benchmark Results", + "", + "This report is generated from Criterion raw samples and the one-shot allocation " + "diagnostic.", + "", + "## Environment", + "", + f"- Platform: {platform.platform()}", + f"- Processor: {cpu_name()}", + f"- Rust: `{rustc}`", + "- Benchmark dependencies: Criterion 0.8.2 and `stats_alloc` 0.1.10", + "- Compared dependencies: Ratatui 0.30.2 and textwrap 0.16.2", + f"- Change: `{revision}`", + "", + "## Reference configuration", + "", + "- Seed: `0x5EED_7E57_CAFE_BABE`", + "- Core inputs: approximately 4 KiB, 64 KiB, and 1 MiB", + "- Viewport: 200×50 cells", + "- Resize widths: 120, 160, 200, 240, and 280 cells", + "- Session length: 60 frames", + f"- Sampling: {sampling}", + "", + "Use Criterion's default settings when evaluating a suspected regression.", + "", + "## Interpretation", + "", + "- Cold materialization processes the complete input, while native `Paragraph` can stop", + " after filling the viewport.", + "- Count-and-render groups expose native Paragraph's repeated wrapping and the owned", + " result's constant-time line count.", + "- Cached session and viewport groups isolate reuse after wrapping; resize-cached also", + " shows the effect of caching by width.", + "- Allocation results are one-shot diagnostics from an instrumented allocator and are not", + " part of Criterion's wall-time samples.", + "", + "### Observed deltas", + "", + ] + lines.extend(measured_observations(timings)) + lines.extend( + [ + "", + "## Timing", + "", + ] + ) + lines.extend(timing_markdown(timings)) + lines.extend(["## Allocations", ""]) + lines.extend(allocation_markdown(allocations)) + return "\n".join(lines) + + +def main() -> None: + options = arguments() + markdown = report( + read_timings(options.criterion_dir), + options.allocations, + options.sampling, + ) + if options.output: + options.output.write_text(markdown, encoding="utf-8") + else: + print(markdown) + + +if __name__ == "__main__": + main()