Files
improvise/benches/grid_projection.rs
T
Edward Langley 822547a332 perf(project): one walk of the grid instead of three (improvise-jmjz)
grid_of evaluated the whole grid roughly three times per frame:
compute_col_widths walked every cell for its text, then every cell again for the
column totals it must size, and grid_of then evaluated the window a third time.
Every result was discarded at the end of the frame.

ui/viewport.rs::grid_body is now the one walk. It evaluates each cell once and
derives all three answers from that value -- widen the column, add to the
column's sum, and if the position is inside the requested window keep the
CellValue for cell_display to dress. compute_col_widths survives as grid_body
with an empty window, because the keypress path wants widths and no cells, and
two width rules would be the disease improvise-edp names.

MEASURED, orchestrator's machine, cargo bench --bench grid_projection, before
and after:

  typical (400 cells)      1.840ms -> 0.687ms
  large (9,600)           172.6ms  ->  50.0ms
  18k (gotcha 41)         294.4ms  ->  95.2ms
  pathological (90,000)     4.481s ->  1.320s

About 3x, which is the number of passes removed -- the shape the acceptance
criterion asked for rather than a speedup argued from the structure.

Three things the ticket did not predict.

The benchmark's col_widths column now measures something ELSE. It read 45-47%
and now reads 64-82%, which looks like a regression and is the opposite:
compute_col_widths IS the fused walk, so its share rose while the frame fell by
two thirds. A ratio whose denominator moved is not comparable across the change,
and the benchmark now says so.

The windowing ratio did not improve, and that is correct: the pass that remains
is exactly the one a viewport cannot narrow. improvise-i5tk's decline stands and
the two findings are now cleanly separated.

It is not solved, only three times cheaper -- 18k cells is still 95ms, 5.7
frames. The next lever is caching ACROSS frames.

Guard: a_window_narrows_the_cells_and_neither_the_totals_nor_the_widths. Fusing
three consumers into one walk creates a hazard the separate passes did not have,
and the natural "optimisation" (iterate rows rather than 0..row_count) makes a
column total the sum of the visible rows, silently. Watched fail with
left: [1.0] against right: [300021.0]. All 1469 pre-existing tests unchanged,
which is also the evidence the outputs did not move.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 04:07:51 -07:00

138 lines
5.2 KiB
Rust

//! Does the TUI's whole-grid `GridCache` dominate a frame? See improvise-i5tk,
//! improvise-jmjz and `context/code/command.md`. `cargo bench --bench grid_projection`.
use std::time::{Duration, Instant};
use improvise::format::parse_number_format;
use improvise::model::cell::{CellKey, CellValue};
use improvise::view::Axis;
use improvise::workbook::Workbook;
use improvise_command::ui::app::App;
use improvise_command::ui::project::{self, Viewport};
use improvise_command::ui::viewport::compute_col_widths;
/// A dense pivot grid: `regions * products` rows by `measures` columns, every
/// cell filled, which is the worst case for a per-cell evaluation.
fn dense_app(regions: usize, products: usize, measures: usize) -> App {
let mut wb = Workbook::new("Bench");
wb.add_category("Region").unwrap();
wb.add_category("Product").unwrap();
let region_names: Vec<String> = (0..regions).map(|i| format!("Region{i}")).collect();
let product_names: Vec<String> = (0..products).map(|i| format!("Product{i}")).collect();
let measure_names: Vec<String> = (0..measures).map(|i| format!("Measure{i}")).collect();
for r in &region_names {
wb.add_item("Region", r.clone()).unwrap();
}
for p in &product_names {
wb.add_item("Product", p.clone()).unwrap();
}
for m in &measure_names {
wb.add_item("_Measure", m.clone()).unwrap();
}
{
let v = wb.active_view_mut();
v.set_axis("Region", Axis::Row);
v.set_axis("Product", Axis::Row);
v.set_axis("_Measure", Axis::Column);
}
let mut n = 0.0f64;
for r in &region_names {
for p in &product_names {
for m in &measure_names {
n += 1.0;
let key = CellKey::new(vec![
("Region".to_string(), r.clone()),
("Product".to_string(), p.clone()),
("_Measure".to_string(), m.clone()),
]);
wb.model.set_cell(key, CellValue::Number(n));
}
}
}
let mut app = App::new(wb, None);
app.rebuild_layout();
app
}
fn whole_grid(app: &App) -> Viewport {
Viewport {
row_offset: 0,
col_offset: 0,
rows: app.layout.row_count(),
cols: app.layout.col_count(),
}
}
/// A terminal window: what the TUI would ask for if a cache position were
/// relative, which is the browser client's arrangement.
fn terminal_window() -> Viewport {
Viewport {
row_offset: 0,
col_offset: 0,
rows: 45,
cols: 12,
}
}
/// Min-of-N, not the mean: a mean over a handful of iterations is dominated by
/// whatever else the machine was doing, which is how a share above 100% happens.
fn time(iters: u32, app: &App, viewport: Viewport) -> Duration {
let mut best = Duration::MAX;
for _ in 0..iters.max(3) {
let start = Instant::now();
std::hint::black_box(project::grid_of(app, viewport));
best = best.min(start.elapsed());
}
best
}
/// `compute_col_widths` is whole-grid whatever the viewport says, so it is the part
/// narrowing the cells loop cannot reach. It must be timed with the arguments
/// `grid_of` passes it, or the share is of a call that never happens.
// Since improvise-jmjz it IS the fused walk, with an empty window, so its share
// rose while the frame fell: it is now the work, not the redundancy.
fn time_widths(iters: u32, app: &App) -> Duration {
let layout = &app.layout;
let model = &app.model_state.workbook.model;
let (comma, decimals) =
parse_number_format(&app.model_state.workbook.active_view().number_format);
let mut best = Duration::MAX;
for _ in 0..iters.max(3) {
let start = Instant::now();
std::hint::black_box(compute_col_widths(model, layout, comma, decimals));
best = best.min(start.elapsed());
}
best
}
fn case(label: &str, regions: usize, products: usize, measures: usize, iters: u32) {
let app = dense_app(regions, products, measures);
let rows = app.layout.row_count();
let cols = app.layout.col_count();
let cells = rows * cols;
let full = time(iters, &app, whole_grid(&app));
let windowed = time(iters, &app, terminal_window());
let widths = time_widths(iters, &app);
let ratio = full.as_secs_f64() / windowed.as_secs_f64().max(f64::EPSILON);
let share = 100.0 * widths.as_secs_f64() / full.as_secs_f64().max(f64::EPSILON);
// A part cannot take longer than the whole that contains it. Say so rather
// than printing a number that reads like a measurement.
let share = match share > 100.0 {
true => "UNSTABLE".to_string(),
false => format!("{share:.0}%"),
};
println!(
"{label:<22} {rows:>5} x {cols:>3} = {cells:>7} cells whole-grid {full:>11.3?} \
windowed {windowed:>11.3?} ratio {ratio:>4.1}x col_widths {widths:>11.3?} ({share} of the frame)"
);
}
fn main() {
println!("project::grid_of, one call = one TUI frame's grid cache");
println!("a 60fps frame budget is 16.7ms; the TUI redraws on each keypress\n");
case("typical", 20, 5, 4, 200);
case("large", 60, 20, 8, 50);
case("18k cells (gotcha 41)", 150, 20, 6, 20);
case("pathological", 300, 30, 10, 5);
}