Skip to content

Commit 286a859

Browse files
authored
pref: large diff improvements (#58)
* improvements * stable, before imara * imara * imara pref 1 * imara pref 2 * imara diff pref 3 * ui and loading improvements * fix(view): correct no-step extent markers and add debug logging * fix(view): rebuild immediately on hunk jumps * test: stabilize diff size settings and drop window debug * feat(profile): add idle filtering for report * perf: make lazy syntax cache incremental * feat: improve syntax warmup and profiling tools * test: cover syntax warmup scheduling * fix: clamp scroll in large no-step windowed view * fix: honor deferred view scroll in split jumps * fix: refresh hunk scope after no-step jumps * fix: fmt * fix: lint issues * fix: test * fix: lint issues * fix(views): avoid end-scroll bounce * fix(views): keep blame end-scroll stable * feat(cli): add single-file HEAD diff
1 parent e9e088a commit 286a859

39 files changed

+8873
-797
lines changed

Cargo.lock

Lines changed: 332 additions & 21 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ thiserror = "2.0"
1818
anyhow = "1.0"
1919

2020
# Diff engine
21-
similar = { version = "2.6", features = ["unicode"] }
21+
imara-diff = "0.1"
22+
rustc-hash = "1.1"
2223

2324
# TUI
2425
ratatui = "0.29"
@@ -30,6 +31,7 @@ tokio = { version = "1.0", features = ["full"] }
3031

3132
# CLI
3233
clap = { version = "4.0", features = ["derive"] }
34+
flate2 = "1.0"
3335

3436
# Config
3537
toml = "0.8"

PROFILING.md

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# Profiling
2+
3+
This repo includes a small Rust helper, `oy-profile`, to summarize samply/Firefox
4+
JSON profiles.
5+
6+
## Record with samply
7+
8+
Build a release binary and record a profile:
9+
10+
```bash
11+
cargo build -p oyo --release
12+
samply record --save-only -o profile.json.gz -- ./target/release/oy --range HEAD...HEAD
13+
```
14+
15+
Notes:
16+
- Use a `.json.gz` output name to keep files small.
17+
- If you need perf permissions on Linux:
18+
19+
```bash
20+
sudo sysctl -w kernel.perf_event_paranoid=1
21+
```
22+
23+
Reset it when you are done:
24+
25+
```bash
26+
sudo sysctl -w kernel.perf_event_paranoid=2
27+
```
28+
29+
## Summarize profiles
30+
31+
Quick report (hot threads/paths/spots/modules):
32+
33+
```bash
34+
cargo run -p oyo --bin oy-profile -- profile.json.gz --report --top 15
35+
```
36+
37+
Add `--verbose` to include the full ranked function table with `--report`.
38+
By default the report includes idle samples (percent columns are total). To
39+
exclude idle samples (poll/sleep/etc), pass `--no-idle`. Customize the idle
40+
matcher with:
41+
42+
```bash
43+
cargo run -p oyo --bin oy-profile -- profile.json.gz --report --no-idle
44+
cargo run -p oyo --bin oy-profile -- profile.json.gz --report --idle-pattern "my_idle_fn"
45+
```
46+
47+
List threads:
48+
49+
```bash
50+
cargo run -p oyo --bin oy-profile -- profile.json.gz --list-threads
51+
```
52+
53+
Pick a thread and mode (inclusive = hot paths, leaf = self time):
54+
55+
```bash
56+
cargo run -p oyo --bin oy-profile -- profile.json.gz --thread 0 --mode inclusive
57+
cargo run -p oyo --bin oy-profile -- profile.json.gz --thread oy --mode leaf --top 30
58+
```
59+
60+
By default the tool selects the hottest thread (based on the chosen metric) and
61+
prints a short thread summary. Control the metric and summary size with:
62+
63+
```bash
64+
cargo run -p oyo --bin oy-profile -- profile.json.gz --metric weight
65+
cargo run -p oyo --bin oy-profile -- profile.json.gz --metric time --top-threads 0
66+
```
67+
68+
Interpretation notes:
69+
- CPU% is per-thread (threadCPUDelta / thread lifetime), not whole-system CPU.
70+
- Short-lived threads can show near-100% CPU even with few samples.
71+
- If `threadCPUDelta` or timing data is missing, CPU% is omitted.
72+
73+
## Debug logs
74+
75+
For diff UI debug logs (extent markers, navigation, etc.), see `docs/DEBUG.md`.

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,14 @@ oy
106106
oy old.rs new.rs
107107
```
108108

109+
### Compare a file against HEAD
110+
111+
```bash
112+
oy path/to/file.rs
113+
```
114+
115+
Runs a working-tree vs `HEAD` diff for that file (like `git diff path/to/file.rs`).
116+
109117
### Commit picker
110118

111119
```bash
@@ -262,8 +270,13 @@ hunk = "none" # "none" | "hunk" | "file"
262270
# bg = false # Full-line diff background (true/false)
263271
# fg = "theme" # "theme" or "syntax"
264272
# highlight = "text" # "text" | "word" | "none"
273+
# max_bytes = 16777216 # Defer diffing above this size (bytes)
274+
# full_context_max_bytes = 2097152 # Full-context render up to this size (bytes)
275+
# defer = true # Defer large diffs and compute in background
276+
# idle_ms = 250 # Idle time before background diff compute
265277
# extent_marker = "neutral" # "neutral" or "diff"
266278
# extent_marker_scope = "progress" # "progress" or "hunk"
279+
# extent_marker_context = false # show extent markers on unchanged lines
267280
# [ui.blame]
268281
# enabled = false # Show git blame hints (opt-in)
269282
# mode = "one_shot" # "one_shot" or "toggle"
@@ -283,6 +296,11 @@ hunk = "none" # "none" | "hunk" | "file"
283296
# mode = "on" # "on" or "off"
284297
# theme = "tokyonight" # builtin name or "custom.tmTheme" (from ~/.config/oyo/themes)
285298
# # default: ui.theme.name, fallback to "ansi"
299+
# [ui.syntax.warmup]
300+
# active_lines = 100 # lines per tick while navigating
301+
# pending_lines = 300 # lines per tick while catching up to a pending checkpoint
302+
# idle_lines = 1000 # lines per tick while idle
303+
# debounce_ms = 80 # wait before warming a new viewport target
286304
syntax = "on"
287305
# [ui.unified]
288306
# modified_step_mode = "mixed" # "mixed" or "modified" (unified pane only)
@@ -330,6 +348,10 @@ name = "tokyonight"
330348
fg = "syntax"
331349
bg = true
332350
highlight = "text"
351+
max_bytes = 16777216
352+
full_context_max_bytes = 2097152
353+
defer = true
354+
idle_ms = 250
333355
extent_marker = "diff"
334356
extent_marker_scope = "hunk"
335357

crates/oyo-core/Cargo.toml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,12 @@ serde = { workspace = true }
1515
serde_json = { workspace = true }
1616
thiserror = { workspace = true }
1717
anyhow = { workspace = true }
18-
similar = { workspace = true }
18+
imara-diff = { workspace = true }
19+
rustc-hash = { workspace = true }
20+
21+
[dev-dependencies]
22+
criterion = "0.5"
23+
24+
[[bench]]
25+
name = "perf"
26+
harness = false

crates/oyo-core/benches/perf.rs

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
use criterion::{black_box, BatchSize, BenchmarkId, Criterion};
2+
use criterion::{criterion_group, criterion_main};
3+
use oyo_core::diff::DiffEngine;
4+
use oyo_core::step::{AnimationFrame, DiffNavigator};
5+
use std::sync::Arc;
6+
use std::time::Duration;
7+
8+
struct BenchInputs {
9+
old: Arc<str>,
10+
new: Arc<str>,
11+
diff: oyo_core::diff::DiffResult,
12+
}
13+
14+
fn build_inputs(hunks: usize, changes_per_hunk: usize, context_lines: usize) -> BenchInputs {
15+
let (old, new) = make_text(hunks, changes_per_hunk, context_lines);
16+
let engine = DiffEngine::new().with_context(context_lines);
17+
let diff = engine.diff_strings(&old, &new);
18+
BenchInputs {
19+
old: Arc::from(old),
20+
new: Arc::from(new),
21+
diff,
22+
}
23+
}
24+
25+
fn make_text(hunks: usize, changes_per_hunk: usize, context_lines: usize) -> (String, String) {
26+
let mut old = String::new();
27+
let mut new = String::new();
28+
let gap = context_lines + 2;
29+
for hunk in 0..hunks {
30+
for idx in 0..gap {
31+
let line = format!("ctx {hunk} {idx}\n");
32+
old.push_str(&line);
33+
new.push_str(&line);
34+
}
35+
for change in 0..changes_per_hunk {
36+
old.push_str(&format!("old {hunk} {change}\n"));
37+
new.push_str(&format!("new {hunk} {change}\n"));
38+
}
39+
}
40+
(old, new)
41+
}
42+
43+
fn bench_prev_hunk(c: &mut Criterion) {
44+
let inputs = build_inputs(100, 20, 3);
45+
c.bench_function("prev_hunk/100x20", |b| {
46+
b.iter_batched(
47+
|| {
48+
let mut nav = DiffNavigator::new(
49+
inputs.diff.clone(),
50+
inputs.old.clone(),
51+
inputs.new.clone(),
52+
false,
53+
);
54+
nav.goto_end();
55+
nav
56+
},
57+
|mut nav| {
58+
black_box(nav.prev_hunk());
59+
},
60+
BatchSize::SmallInput,
61+
);
62+
});
63+
}
64+
65+
fn bench_next_hunk(c: &mut Criterion) {
66+
let inputs = build_inputs(100, 20, 3);
67+
c.bench_function("next_hunk/100x20", |b| {
68+
b.iter_batched(
69+
|| {
70+
DiffNavigator::new(
71+
inputs.diff.clone(),
72+
inputs.old.clone(),
73+
inputs.new.clone(),
74+
false,
75+
)
76+
},
77+
|mut nav| {
78+
black_box(nav.next_hunk());
79+
},
80+
BatchSize::SmallInput,
81+
);
82+
});
83+
}
84+
85+
fn bench_view_for_changes(c: &mut Criterion) {
86+
let inputs = build_inputs(100, 20, 3);
87+
c.bench_function("view_for_changes/100x20", |b| {
88+
b.iter_batched(
89+
|| {
90+
let mut nav = DiffNavigator::new(
91+
inputs.diff.clone(),
92+
inputs.old.clone(),
93+
inputs.new.clone(),
94+
false,
95+
);
96+
let mid = nav.state().total_steps / 2;
97+
nav.goto(mid);
98+
nav
99+
},
100+
|nav| {
101+
black_box(nav.current_view_with_frame(AnimationFrame::Idle));
102+
},
103+
BatchSize::SmallInput,
104+
);
105+
});
106+
}
107+
108+
fn bench_view_for_changes_large_hunk(c: &mut Criterion) {
109+
let inputs = build_inputs(1, 5000, 3);
110+
let mut group = c.benchmark_group("view_for_changes_large_hunk");
111+
group.measurement_time(Duration::from_secs(10));
112+
group.sample_size(50);
113+
group.bench_function("1x5000", |b| {
114+
b.iter_batched(
115+
|| {
116+
let mut nav = DiffNavigator::new(
117+
inputs.diff.clone(),
118+
inputs.old.clone(),
119+
inputs.new.clone(),
120+
false,
121+
);
122+
let mid = nav.state().total_steps / 2;
123+
nav.goto(mid);
124+
nav
125+
},
126+
|nav| {
127+
black_box(nav.current_view_with_frame(AnimationFrame::Idle));
128+
},
129+
BatchSize::SmallInput,
130+
);
131+
});
132+
group.finish();
133+
}
134+
135+
fn bench_hunk_index_for_change_id(c: &mut Criterion) {
136+
let inputs = build_inputs(200, 10, 3);
137+
let change_ids: Vec<usize> = inputs.diff.changes.iter().map(|c| c.id).collect();
138+
c.bench_with_input(
139+
BenchmarkId::new("hunk_index_for_change_id", change_ids.len()),
140+
&change_ids,
141+
|b, ids| {
142+
b.iter_batched(
143+
|| {
144+
DiffNavigator::new(
145+
inputs.diff.clone(),
146+
inputs.old.clone(),
147+
inputs.new.clone(),
148+
false,
149+
)
150+
},
151+
|nav| {
152+
for id in ids.iter().take(1000) {
153+
black_box(nav.hunk_index_for_change_id(*id));
154+
}
155+
},
156+
BatchSize::SmallInput,
157+
);
158+
},
159+
);
160+
}
161+
162+
fn bench_is_applied(c: &mut Criterion) {
163+
let inputs = build_inputs(200, 10, 3);
164+
let change_ids: Vec<usize> = inputs.diff.changes.iter().map(|c| c.id).collect();
165+
c.bench_function("is_applied/200x10", |b| {
166+
b.iter_batched(
167+
|| {
168+
let mut nav = DiffNavigator::new(
169+
inputs.diff.clone(),
170+
inputs.old.clone(),
171+
inputs.new.clone(),
172+
false,
173+
);
174+
nav.goto_end();
175+
nav
176+
},
177+
|nav| {
178+
let state = nav.state();
179+
for id in change_ids.iter().take(1000) {
180+
black_box(state.is_applied(*id));
181+
}
182+
},
183+
BatchSize::SmallInput,
184+
);
185+
});
186+
}
187+
188+
criterion_group!(
189+
benches,
190+
bench_prev_hunk,
191+
bench_next_hunk,
192+
bench_view_for_changes,
193+
bench_view_for_changes_large_hunk,
194+
bench_hunk_index_for_change_id,
195+
bench_is_applied
196+
);
197+
criterion_main!(benches);

0 commit comments

Comments
 (0)