Skip to content

Commit 39367db

Browse files
authored
Merge pull request #89 from ynqa/perf/renderer-layout-benchmark
Add renderer layout benchmarks
2 parents c2cafdb + 9c70901 commit 39367db

6 files changed

Lines changed: 684 additions & 299 deletions

File tree

‎Concept.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ promkit is organized around three responsibilities with clear boundaries:
2929
3. **Rendering (`promkit-core`)**
3030
- [`Renderer<K>`](./promkit-core/src/render.rs) stores ordered
3131
`CreatedGraphemes` chunks.
32+
- [`RendererLayout<K>`](./promkit-core/src/render/layout.rs) performs
33+
terminal-size-dependent wrapping, pane allocation, cursor scrolling, and
34+
viewport clipping without terminal I/O.
3235
- `update` / `remove` modify chunks by index key.
3336
- `render` wraps or truncates content, assigns vertical viewports, scrolls
3437
viewports to include logical cursors, and saves a keyed layout snapshot.

‎promkit-core/Cargo.toml‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,10 @@ crossbeam-skiplist = { workspace = true }
1414
crossterm = { workspace = true }
1515
tokio = { workspace = true }
1616
unicode-width = { workspace = true }
17+
18+
[dev-dependencies]
19+
criterion = "0.8.2"
20+
21+
[[bench]]
22+
name = "renderer_layout"
23+
harness = false

‎promkit-core/README.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,14 @@
11
# promkit-core
2+
3+
## Renderer layout benchmark
4+
5+
The Criterion benchmark measures `Renderer` layout independently from terminal
6+
size queries and terminal I/O. It covers content size, wrapping and truncation,
7+
terminal width, pane count, and viewport movement:
8+
9+
```bash
10+
cargo bench -p promkit-core --bench renderer_layout
11+
```
12+
13+
Each iteration includes cloning the renderer inputs, matching the content
14+
snapshot cost paid by `Renderer::render`.
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
//! Benchmarks the renderer's terminal-size-dependent layout without terminal I/O.
2+
//!
3+
//! Each iteration clones the `CreatedGraphemes` inputs before laying them out,
4+
//! matching the snapshot cost paid by `Renderer::render`.
5+
6+
use std::{hint::black_box, time::Duration};
7+
8+
use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main};
9+
use promkit_core::{
10+
ContentPosition, CreatedGraphemes, WidgetLayout, WidthMode, grapheme::StyledGraphemes,
11+
render::RendererLayout,
12+
};
13+
14+
const TERMINAL_HEIGHT: u16 = 40;
15+
16+
#[derive(Clone, Copy)]
17+
enum TextKind {
18+
Ascii,
19+
Unicode,
20+
}
21+
22+
impl TextKind {
23+
fn name(self) -> &'static str {
24+
match self {
25+
Self::Ascii => "ascii",
26+
Self::Unicode => "unicode",
27+
}
28+
}
29+
30+
fn line(self) -> &'static str {
31+
match self {
32+
Self::Ascii => {
33+
"renderer layout keeps interactive terminal updates predictable across panes"
34+
}
35+
Self::Unicode => "端末レイアウトは全角文字とe\u{301}の表示幅も処理します",
36+
}
37+
}
38+
}
39+
40+
fn renderer_layout(c: &mut Criterion) {
41+
content_size(c);
42+
terminal_width(c);
43+
pane_count(c);
44+
cursor_scrolling(c);
45+
}
46+
47+
fn content_size(c: &mut Criterion) {
48+
let mut group = c.benchmark_group("renderer_layout/content_size");
49+
50+
for size in [1024, 64 * 1024, 1024 * 1024] {
51+
group.throughput(Throughput::Bytes(size as u64));
52+
53+
for kind in [TextKind::Ascii, TextKind::Unicode] {
54+
for mode in [WidthMode::Wrap, WidthMode::Truncate] {
55+
let contents = vec![(0usize, created_fixture(size, kind, mode, Cursor::Middle))];
56+
let mut layout = RendererLayout::default();
57+
let mode_name = match mode {
58+
WidthMode::Wrap => "wrap",
59+
WidthMode::Truncate => "truncate",
60+
};
61+
62+
group.bench_with_input(
63+
BenchmarkId::new(format!("{}/{}", kind.name(), mode_name), size),
64+
&contents,
65+
|b, contents| {
66+
b.iter(|| {
67+
black_box(
68+
layout
69+
.layout(black_box(contents.clone()), 80, TERMINAL_HEIGHT)
70+
.unwrap(),
71+
)
72+
});
73+
},
74+
);
75+
}
76+
}
77+
}
78+
79+
group.finish();
80+
}
81+
82+
fn terminal_width(c: &mut Criterion) {
83+
let mut group = c.benchmark_group("renderer_layout/terminal_width");
84+
let contents = vec![(
85+
0usize,
86+
created_fixture(
87+
64 * 1024,
88+
TextKind::Unicode,
89+
WidthMode::Wrap,
90+
Cursor::Middle,
91+
),
92+
)];
93+
group.throughput(Throughput::Bytes(64 * 1024));
94+
95+
for width in [20, 80, 240] {
96+
let mut layout = RendererLayout::default();
97+
group.bench_with_input(BenchmarkId::from_parameter(width), &width, |b, &width| {
98+
b.iter(|| {
99+
black_box(
100+
layout
101+
.layout(black_box(contents.clone()), width, TERMINAL_HEIGHT)
102+
.unwrap(),
103+
)
104+
});
105+
});
106+
}
107+
108+
group.finish();
109+
}
110+
111+
fn pane_count(c: &mut Criterion) {
112+
let mut group = c.benchmark_group("renderer_layout/pane_count");
113+
114+
for pane_count in [1usize, 10, 100] {
115+
let contents = (0..pane_count)
116+
.map(|index| {
117+
(
118+
index,
119+
created_fixture(4 * 1024, TextKind::Ascii, WidthMode::Wrap, Cursor::Middle),
120+
)
121+
})
122+
.collect::<Vec<_>>();
123+
let mut layout = RendererLayout::default();
124+
group.throughput(Throughput::Bytes((pane_count * 4 * 1024) as u64));
125+
126+
group.bench_with_input(
127+
BenchmarkId::from_parameter(pane_count),
128+
&contents,
129+
|b, contents| {
130+
b.iter(|| {
131+
black_box(
132+
layout
133+
.layout(
134+
black_box(contents.clone()),
135+
80,
136+
u16::try_from(pane_count.max(TERMINAL_HEIGHT as usize)).unwrap(),
137+
)
138+
.unwrap(),
139+
)
140+
});
141+
},
142+
);
143+
}
144+
145+
group.finish();
146+
}
147+
148+
fn cursor_scrolling(c: &mut Criterion) {
149+
let mut group = c.benchmark_group("renderer_layout/cursor_scrolling");
150+
let created = created_fixture(64 * 1024, TextKind::Ascii, WidthMode::Wrap, Cursor::Head);
151+
let last_row = created
152+
.graphemes
153+
.to_string()
154+
.lines()
155+
.count()
156+
.saturating_sub(1);
157+
let mut layout = RendererLayout::default();
158+
let mut at_tail = false;
159+
160+
group.throughput(Throughput::Bytes(64 * 1024));
161+
group.bench_function("head_to_tail", |b| {
162+
b.iter(|| {
163+
let mut frame = created.clone();
164+
frame.cursor = Some(ContentPosition {
165+
row: if at_tail { last_row } else { 0 },
166+
column: 0,
167+
});
168+
at_tail = !at_tail;
169+
170+
black_box(
171+
layout
172+
.layout(black_box([(0usize, frame)]), 80, TERMINAL_HEIGHT)
173+
.unwrap(),
174+
)
175+
});
176+
});
177+
178+
group.finish();
179+
}
180+
181+
#[derive(Clone, Copy)]
182+
enum Cursor {
183+
Head,
184+
Middle,
185+
}
186+
187+
fn created_fixture(
188+
target_bytes: usize,
189+
kind: TextKind,
190+
width_mode: WidthMode,
191+
cursor: Cursor,
192+
) -> CreatedGraphemes {
193+
let text = text_fixture(target_bytes, kind);
194+
let line_count = text.lines().count();
195+
let cursor_row = match cursor {
196+
Cursor::Head => 0,
197+
Cursor::Middle => line_count / 2,
198+
};
199+
200+
CreatedGraphemes {
201+
graphemes: StyledGraphemes::from(text),
202+
layout: WidgetLayout {
203+
max_height: None,
204+
width_mode,
205+
},
206+
cursor: Some(ContentPosition {
207+
row: cursor_row,
208+
column: 8,
209+
}),
210+
}
211+
}
212+
213+
fn text_fixture(target_bytes: usize, kind: TextKind) -> String {
214+
let line = kind.line();
215+
let mut text = String::with_capacity(target_bytes + line.len() + 1);
216+
217+
while text.len() < target_bytes {
218+
text.push_str(line);
219+
text.push('\n');
220+
}
221+
let mut end = target_bytes.min(text.len());
222+
while !text.is_char_boundary(end) {
223+
end -= 1;
224+
}
225+
text.truncate(end);
226+
text
227+
}
228+
229+
criterion_group! {
230+
name = benches;
231+
config = Criterion::default()
232+
.sample_size(10)
233+
.warm_up_time(Duration::from_millis(500))
234+
.measurement_time(Duration::from_secs(2));
235+
targets = renderer_layout
236+
}
237+
criterion_main!(benches);

0 commit comments

Comments
 (0)