Skip to content

Commit f07ff65

Browse files
authored
Merge pull request #5295 from gui-cs/copilot/add-end-to-end-scrolling-benchmarks
Add end-to-end scrolling performance suite and CI performance gate
2 parents 2b288bc + 79fd154 commit f07ff65

15 files changed

Lines changed: 1375 additions & 9 deletions

File tree

.github/workflows/perf-gate.yml

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
name: Scrolling Performance Gate
2+
3+
on:
4+
push:
5+
branches: [ main, develop ]
6+
paths-ignore:
7+
- '**.md'
8+
pull_request:
9+
branches: [ main, develop ]
10+
paths-ignore:
11+
- '**.md'
12+
13+
# Only run on Linux to keep results comparable across runs.
14+
# Windows/macOS times vary too much to use as a performance baseline.
15+
permissions:
16+
contents: read
17+
18+
jobs:
19+
perf-smoke-tests:
20+
name: Performance Smoke Tests (Linux)
21+
runs-on: ubuntu-latest
22+
timeout-minutes: 20
23+
env:
24+
DisableRealDriverIO: "1"
25+
26+
steps:
27+
- name: Checkout code
28+
uses: actions/checkout@v6
29+
with:
30+
fetch-depth: 0 # GitVersion needs full history
31+
32+
- name: Setup .NET
33+
uses: actions/setup-dotnet@v5
34+
with:
35+
dotnet-version: 10.x
36+
dotnet-quality: 'ga'
37+
38+
- name: Restore dependencies
39+
run: dotnet restore
40+
41+
- name: Build (Release)
42+
run: dotnet build --configuration Release --no-restore -property:NoWarn=0618%3B0612
43+
44+
- name: Build Tests (Debug — smoke tests run in Debug to match CI unit tests)
45+
run: dotnet build Tests/PerformanceTests --no-restore -property:NoWarn=0618%3B0612
46+
47+
- name: Run performance smoke tests (Layer 1 gate)
48+
id: smoke_tests
49+
run: |
50+
dotnet test \
51+
--project Tests/PerformanceTests \
52+
--no-build \
53+
--verbosity normal
54+
55+
- name: Upload smoke test logs
56+
if: always()
57+
uses: actions/upload-artifact@v7
58+
with:
59+
name: perf-smoke-test-logs
60+
path: |
61+
TestResults/
62+
if-no-files-found: ignore
63+
retention-days: 7
64+
65+
perf-benchmarks:
66+
name: Scrolling Benchmarks (Linux, ShortRun)
67+
runs-on: ubuntu-latest
68+
# Only run on pushes to develop/main, not on every PR (slow and not blocking).
69+
if: github.event_name == 'push'
70+
timeout-minutes: 30
71+
env:
72+
DisableRealDriverIO: "1"
73+
74+
steps:
75+
- name: Checkout code
76+
uses: actions/checkout@v6
77+
with:
78+
fetch-depth: 0
79+
80+
- name: Setup .NET
81+
uses: actions/setup-dotnet@v5
82+
with:
83+
dotnet-version: 10.x
84+
dotnet-quality: 'ga'
85+
86+
- name: Restore dependencies
87+
run: dotnet restore
88+
89+
- name: Build Release
90+
run: dotnet build --configuration Release --no-restore -property:NoWarn=0618%3B0612
91+
92+
- name: Run scrolling benchmarks (ShortRun ≈ 30–60 s)
93+
id: run_benchmarks
94+
run: |
95+
dotnet run \
96+
--project Tests/Benchmarks \
97+
--configuration Release \
98+
--no-build \
99+
-- \
100+
--filter '*Scroll*' \
101+
--job short \
102+
--exporters json \
103+
--artifacts ./BenchmarkResults
104+
continue-on-error: true # Don't block the workflow; comparison step decides outcome
105+
106+
- name: Compare results to baseline
107+
id: compare
108+
run: |
109+
python3 - << 'PYEOF'
110+
import json, os, sys, glob
111+
112+
REGRESSION_FACTOR = 3.0 # Fail if any benchmark is >3× baseline
113+
IMPROVEMENT_FACTOR = 0.8 # Celebrate 🎉 if any benchmark drops below 0.8× baseline
114+
115+
baseline_path = "Tests/Benchmarks/baseline.json"
116+
results_dir = "BenchmarkResults"
117+
118+
# --- Load baseline ---
119+
try:
120+
with open(baseline_path) as f:
121+
baseline_data = json.load(f)
122+
baseline = {
123+
f"{b['type']}/{b['method']}/{b['params']}": b["meanNs"]
124+
for b in baseline_data["benchmarks"]
125+
}
126+
except FileNotFoundError:
127+
print("::warning::baseline.json not found — skipping comparison")
128+
sys.exit(0)
129+
130+
# --- Find BenchmarkDotNet JSON results ---
131+
result_files = glob.glob(f"{results_dir}/**/*.json", recursive=True)
132+
result_files = [f for f in result_files if "results" in f.lower() or "report" in f.lower()]
133+
if not result_files:
134+
print("::warning::No BenchmarkDotNet result files found — skipping comparison")
135+
sys.exit(0)
136+
137+
# --- Parse results ---
138+
results = {}
139+
for fpath in result_files:
140+
try:
141+
with open(fpath) as f:
142+
data = json.load(f)
143+
for bm in data.get("Benchmarks", []):
144+
key = f"{bm['Type']}/{bm['Method']}/{bm.get('Parameters', '')}"
145+
results[key] = bm.get("Statistics", {}).get("Mean", None)
146+
except Exception as e:
147+
print(f"::warning::Could not parse {fpath}: {e}")
148+
149+
# --- Build comparison table ---
150+
rows = []
151+
regressions = []
152+
improvements = []
153+
154+
for key, base_ns in baseline.items():
155+
if base_ns <= 0:
156+
continue
157+
cur_ns = results.get(key)
158+
if cur_ns is None:
159+
rows.append(f"| {key} | {base_ns/1000:.1f} µs | — (not measured) | — |")
160+
continue
161+
162+
ratio = cur_ns / base_ns
163+
emoji = "✅"
164+
if ratio >= REGRESSION_FACTOR:
165+
emoji = "❌"
166+
regressions.append((key, base_ns, cur_ns, ratio))
167+
elif ratio <= IMPROVEMENT_FACTOR:
168+
emoji = "🎉"
169+
improvements.append((key, base_ns, cur_ns, ratio))
170+
rows.append(
171+
f"| {key} | {base_ns/1000:.1f} µs | {cur_ns/1000:.1f} µs | {ratio:.2f}× {emoji} |"
172+
)
173+
174+
# --- Write step summary ---
175+
summary = "## 📊 Scrolling Benchmark Comparison\n\n"
176+
summary += "| Benchmark | Baseline | Current | Ratio |\n"
177+
summary += "|-----------|----------|---------|-------|\n"
178+
summary += "\n".join(rows) + "\n\n"
179+
180+
if improvements:
181+
summary += "### 🎉 Performance Improvements\n"
182+
for k, b, c, r in improvements:
183+
summary += f"- **{k}**: {b/1000:.1f} µs → {c/1000:.1f} µs ({r:.2f}×)\n"
184+
summary += "\n"
185+
186+
if regressions:
187+
summary += "### ❌ Regressions Detected\n"
188+
for k, b, c, r in regressions:
189+
summary += f"- **{k}**: {b/1000:.1f} µs → {c/1000:.1f} µs ({r:.2f}×) — exceeds {REGRESSION_FACTOR}× threshold\n"
190+
summary += "\n"
191+
192+
with open(os.environ.get("GITHUB_STEP_SUMMARY", "/dev/null"), "a") as f:
193+
f.write(summary)
194+
195+
print(summary)
196+
197+
if regressions:
198+
print(f"::error::Performance regressions detected: {len(regressions)} benchmark(s) exceeded {REGRESSION_FACTOR}× baseline")
199+
sys.exit(1)
200+
201+
if improvements:
202+
print(f"Performance improvements detected: {len(improvements)} benchmark(s) improved!")
203+
PYEOF
204+
205+
- name: Upload benchmark results
206+
if: always()
207+
uses: actions/upload-artifact@v7
208+
with:
209+
name: benchmark-results-${{ github.sha }}
210+
path: BenchmarkResults/
211+
if-no-files-found: ignore
212+
retention-days: 30

Terminal.Gui/Terminal.Gui.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@
9393
<InternalsVisibleTo Include="UnitTests.Legacy" />
9494
<InternalsVisibleTo Include="UnitTests.NonParallelizable" />
9595
<InternalsVisibleTo Include="UnitTests.Parallelizable" />
96+
<InternalsVisibleTo Include="PerformanceTests" />
9697
<InternalsVisibleTo Include="StressTests" />
9798
<InternalsVisibleTo Include="IntegrationTests" />
9899
<InternalsVisibleTo Include="TerminalGuiDesigner" />

Terminal.sln

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InlineSelect", "Examples\In
156156
EndProject
157157
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Terminal.Gui.Analyzers.Internal", "Terminal.Gui.Analyzers.Internal\Terminal.Gui.Analyzers.Internal.csproj", "{927CCC07-F00C-409C-BE42-458EB03DD4E8}"
158158
EndProject
159+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PerformanceTests", "Tests\PerformanceTests\PerformanceTests.csproj", "{6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}"
160+
EndProject
159161
Global
160162
GlobalSection(SolutionConfigurationPlatforms) = preSolution
161163
Debug|Any CPU = Debug|Any CPU
@@ -454,6 +456,18 @@ Global
454456
{927CCC07-F00C-409C-BE42-458EB03DD4E8}.Release|x64.Build.0 = Release|Any CPU
455457
{927CCC07-F00C-409C-BE42-458EB03DD4E8}.Release|x86.ActiveCfg = Release|Any CPU
456458
{927CCC07-F00C-409C-BE42-458EB03DD4E8}.Release|x86.Build.0 = Release|Any CPU
459+
{6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
460+
{6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Debug|Any CPU.Build.0 = Debug|Any CPU
461+
{6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Debug|x64.ActiveCfg = Debug|Any CPU
462+
{6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Debug|x64.Build.0 = Debug|Any CPU
463+
{6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Debug|x86.ActiveCfg = Debug|Any CPU
464+
{6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Debug|x86.Build.0 = Debug|Any CPU
465+
{6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Release|Any CPU.ActiveCfg = Release|Any CPU
466+
{6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Release|Any CPU.Build.0 = Release|Any CPU
467+
{6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Release|x64.ActiveCfg = Release|Any CPU
468+
{6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Release|x64.Build.0 = Release|Any CPU
469+
{6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Release|x86.ActiveCfg = Release|Any CPU
470+
{6E98BACA-E6B6-47A0-B45C-B624F8E74EC2}.Release|x86.Build.0 = Release|Any CPU
457471
EndGlobalSection
458472
GlobalSection(SolutionProperties) = preSolution
459473
HideSolutionNode = FALSE
@@ -484,6 +498,7 @@ Global
484498
{90A42AE4-301D-4B05-8892-60BE5209C1B5} = {3DD033C0-E023-47BF-A808-9CCE30873C3E}
485499
{70802F77-F259-44C6-9522-46FCE2FD754E} = {3DD033C0-E023-47BF-A808-9CCE30873C3E}
486500
{3116547F-A8F2-4189-BC22-0B47C757164C} = {3DD033C0-E023-47BF-A808-9CCE30873C3E}
501+
{6E98BACA-E6B6-47A0-B45C-B624F8E74EC2} = {A589126F-C71A-4FEE-B7EA-2DCA1ADF6A46}
487502
EndGlobalSection
488503
GlobalSection(ExtensibilityGlobals) = postSolution
489504
SolutionGuid = {9F8F8A4D-7B8D-4C2A-AC5E-CD7117F74C03}

Tests/Benchmarks/README.md

Lines changed: 80 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ dotnet run -c Release
4747
# Run only DimAuto benchmarks
4848
dotnet run -c Release -- --filter '*DimAuto*'
4949

50+
# Run only Scrolling benchmarks
51+
dotnet run -c Release -- --filter '*Scroll*'
52+
5053
# Run only TextFormatter benchmarks
5154
dotnet run -c Release -- --filter '*TextFormatter*'
5255
```
@@ -56,14 +59,17 @@ dotnet run -c Release -- --filter '*TextFormatter*'
5659
```bash
5760
# Run only the ComplexLayout benchmark
5861
dotnet run -c Release -- --filter '*DimAutoBenchmark.ComplexLayout*'
62+
63+
# Run only TextView scrolling benchmarks
64+
dotnet run -c Release -- --filter '*TextViewScroll*'
5965
```
6066

6167
### Quick Run (Shorter but Less Accurate)
6268

6369
For faster iteration during development:
6470

6571
```bash
66-
dotnet run -c Release -- --filter '*DimAuto*' -j short
72+
dotnet run -c Release -- --filter '*Scroll*' -j short
6773
```
6874

6975
### List Available Benchmarks
@@ -80,12 +86,52 @@ The `DimAutoBenchmark` class tests layout performance with `Dim.Auto()` in vario
8086
- **ComplexLayout**: 20 subviews with mixed Pos/Dim types (tests iteration overhead)
8187
- **DeeplyNestedLayout**: 5 levels of nested views with DimAuto (tests recursive performance)
8288

89+
## Scrolling Benchmarks
90+
91+
The `Scrolling/` directory contains end-to-end scrolling benchmarks that cover the full input → layout → draw pipeline.
92+
93+
### BaselineScrollBenchmark
94+
95+
Minimal `View` subclass with a large `ContentSize` and no rendering logic. Isolates framework scrolling overhead from any view-specific work.
96+
97+
- **ViewportScroll_Down / Up**: Direct viewport manipulation (no key injection). Measures pure framework overhead.
98+
- **ViewportScroll_PageDown**: Viewport-sized jump.
99+
- Parameterized by `ContentHeight` = [1 000, 10 000]
100+
101+
### TextViewScrollBenchmark
102+
103+
`TextView` with read-only content of 1 000 / 5 000 lines of ~80-char text.
104+
105+
- **ScrollDown_OneStep / ScrollUp_OneStep**: Single `Key.CursorDown` / `Key.CursorUp` injection. With the caret at the viewport boundary, every keystroke triggers a viewport scroll.
106+
- **PageDown_OneStep**: Single `Key.PageDown` injection.
107+
- Parameterized by `Lines` = [1 000, 5 000]
108+
109+
### ListViewScrollBenchmark
110+
111+
`ListView` with 1 000 / 10 000 string items.
112+
113+
- **ScrollDown_OneStep / ScrollUp_OneStep / PageDown_OneStep**
114+
- Parameterized by `Items` = [1 000, 10 000]
115+
116+
### TableViewScrollBenchmark
117+
118+
`TableView` with 100 / 1 000 rows × 10 columns.
119+
120+
- **ScrollDown_OneStep / ScrollUp_OneStep / PageDown_OneStep / ScrollRight_OneStep**
121+
- Parameterized by `Rows` = [100, 1 000]
122+
123+
### Run all scrolling benchmarks
124+
125+
```bash
126+
dotnet run --project Tests/Benchmarks -c Release -- --filter '*Scroll*'
127+
```
128+
83129
## Adding New Benchmarks
84130

85-
1. Create a new class in an appropriate subdirectory (e.g., `Layout/`, `Text/`, `ViewBase/`)
131+
1. Create a new class in an appropriate subdirectory (e.g., `Layout/`, `Text/`, `ViewBase/`, `Scrolling/`)
86132
2. For BenchmarkDotNet: add `[MemoryDiagnoser]`, `[BenchmarkCategory]`, `[Benchmark(Baseline = true)]`
87133
3. For memory profilers: add a `public static void Run()` method and route it from `Program.cs`
88-
4. Use `[GlobalSetup]`/`[GlobalCleanup]` for `Application.Init`/`Shutdown`
134+
4. Use `[GlobalSetup]`/`[GlobalCleanup]` for application init/dispose
89135

90136
## Best Practices
91137

@@ -96,10 +142,37 @@ The `DimAutoBenchmark` class tests layout performance with `Dim.Auto()` in vario
96142

97143
## Continuous Integration
98144

99-
Benchmarks are not run automatically in CI. Run them locally when:
100-
- Making performance-critical changes
101-
- Implementing performance optimizations
102-
- Before releasing a new version
145+
### Layer 1: Performance Smoke Tests
146+
147+
Stopwatch-based xUnit tests in `Tests/UnitTestsParallelizable/Views/ScrollingPerformanceTests.cs` run on every CI build via the standard unit test workflow. They use generous thresholds (50–100× typical) to catch catastrophic O(n²) regressions without flaking on slow runners.
148+
149+
Each test:
150+
- Creates a large document (10 000 rows / 100 000 items)
151+
- Measures the cost of a **single viewport draw** after scrolling to the mid-point
152+
- Asserts completion under a generous threshold (e.g., < 200 ms for TableView)
153+
154+
This detects if a draw function accidentally iterates the entire document instead of just the visible viewport.
155+
156+
### Layer 2: Baseline Comparison
157+
158+
The `.github/workflows/perf-gate.yml` workflow runs on every push to `main` / `develop` (not PRs) and:
159+
160+
1. Runs the `*Scroll*` benchmarks with `--job short` (~30–60 s total)
161+
2. Compares results to `Tests/Benchmarks/baseline.json`
162+
3. **Fails** if any benchmark exceeds **** the baseline
163+
4. **Celebrates** 🎉 if any benchmark drops below **0.8×** the baseline
164+
5. Posts a markdown comparison table to the GitHub step summary
165+
166+
### Updating the Baseline
167+
168+
After a deliberate performance change, re-run the focused scrolling benchmarks, then update `baseline.json`:
169+
170+
```bash
171+
# Run ShortRun and export JSON results
172+
dotnet run --project Tests/Benchmarks -c Release -- --filter '*Scroll*' -j short --exporters json
173+
174+
# Inspect the JSON output in BenchmarkDotNet.Artifacts/ and update baseline.json
175+
```
103176

104177
## Resources
105178

0 commit comments

Comments
 (0)