diff --git a/.github/workflows/perf-gate.yml b/.github/workflows/perf-gate.yml index 12d677dc95..85fd662dce 100644 --- a/.github/workflows/perf-gate.yml +++ b/.github/workflows/perf-gate.yml @@ -1,4 +1,4 @@ -name: Scrolling Performance Gate +name: Performance Gate on: push: @@ -63,7 +63,7 @@ jobs: retention-days: 7 perf-benchmarks: - name: Scrolling Benchmarks (Linux, ShortRun) + name: Benchmarks (Linux, ShortRun) runs-on: ubuntu-latest # Only run on pushes to develop/main, not on every PR (slow and not blocking). if: github.event_name == 'push' @@ -89,7 +89,7 @@ jobs: - name: Build Release run: dotnet build --configuration Release --no-restore -property:NoWarn=0618%3B0612 - - name: Run scrolling benchmarks (ShortRun ≈ 30–60 s) + - name: Run benchmarks (ShortRun ≈ 30–60 s) id: run_benchmarks run: | dotnet run \ @@ -97,7 +97,7 @@ jobs: --configuration Release \ --no-build \ -- \ - --filter '*Scroll*' \ + --filter '*Scroll*' '*Config*' '*Scheme*' '*Theme*' \ --job short \ --exporters json \ --artifacts ./BenchmarkResults @@ -172,7 +172,7 @@ jobs: ) # --- Write step summary --- - summary = "## 📊 Scrolling Benchmark Comparison\n\n" + summary = "## 📊 Benchmark Comparison\n\n" summary += "| Benchmark | Baseline | Current | Ratio |\n" summary += "|-----------|----------|---------|-------|\n" summary += "\n".join(rows) + "\n\n" diff --git a/Tests/Benchmarks/Configuration/ConfigurationManagerLoadBenchmark.cs b/Tests/Benchmarks/Configuration/ConfigurationManagerLoadBenchmark.cs new file mode 100644 index 0000000000..1af8f3b722 --- /dev/null +++ b/Tests/Benchmarks/Configuration/ConfigurationManagerLoadBenchmark.cs @@ -0,0 +1,45 @@ +using BenchmarkDotNet.Attributes; +using Terminal.Gui.Configuration; + +namespace Terminal.Gui.Benchmarks.Configuration; + +/// +/// Measures the cold-start cost of loading the embedded library configuration: +/// ConfigurationManager.Disable (true)Enable (ConfigLocations.LibraryResources)Apply (). +/// This is the app-startup hot path. +/// +/// +/// +/// Run: +/// dotnet run --project Tests/Benchmarks -c Release -- --filter '*ConfigurationManagerLoad*' +/// +/// +[MemoryDiagnoser] +[BenchmarkCategory ("Configuration")] +public class ConfigurationManagerLoadBenchmark +{ + /// Resets ConfigurationManager to a clean state before each iteration. + [IterationSetup] + public void IterationSetup () + { + ConfigurationManager.Disable (true); + } + + /// + /// Loads the embedded library configuration from scratch and applies it. + /// Captures the full deserialize + merge + apply path. + /// + [Benchmark] + public void LoadAndApply () + { + ConfigurationManager.Enable (ConfigLocations.LibraryResources); + ConfigurationManager.Apply (); + } + + /// Ensures ConfigurationManager is disabled after all iterations. + [GlobalCleanup] + public void Cleanup () + { + ConfigurationManager.Disable (true); + } +} diff --git a/Tests/Benchmarks/Configuration/SchemeAttributeBenchmark.cs b/Tests/Benchmarks/Configuration/SchemeAttributeBenchmark.cs new file mode 100644 index 0000000000..6e97a10e7f --- /dev/null +++ b/Tests/Benchmarks/Configuration/SchemeAttributeBenchmark.cs @@ -0,0 +1,47 @@ +using BenchmarkDotNet.Attributes; +using Terminal.Gui.Drawing; +using TgAttribute = Terminal.Gui.Drawing.Attribute; + +namespace Terminal.Gui.Benchmarks.Configuration; + +/// +/// Measures for roles at different depths of the derivation chain: +/// +/// — explicitly set (O(1) lookup) +/// — derived from +/// — deepest derivation (Code → Editable → Normal) +/// +/// No required; operates on a standalone +/// instance. +/// +/// +/// +/// Run: +/// dotnet run --project Tests/Benchmarks -c Release -- --filter '*SchemeAttribute*' +/// +/// +[MemoryDiagnoser] +[BenchmarkCategory ("Configuration", "Scheme")] +public class SchemeAttributeBenchmark +{ + private Scheme _scheme = null!; + + /// Creates a scheme with only explicitly set. + [GlobalSetup] + public void Setup () + { + _scheme = new Scheme { Normal = new TgAttribute (Color.White, Color.Black) }; + } + + /// Lookup for an explicitly-set role — the fastest path. + [Benchmark (Baseline = true)] + public TgAttribute GetNormal () => _scheme.GetAttributeForRole (VisualRole.Normal); + + /// Lookup for a role derived from Focus (which itself is derived from Normal). + [Benchmark] + public TgAttribute GetHotFocus () => _scheme.GetAttributeForRole (VisualRole.HotFocus); + + /// Lookup for the deepest derivation path: Code → Editable → Normal. + [Benchmark] + public TgAttribute GetCode () => _scheme.GetAttributeForRole (VisualRole.Code); +} diff --git a/Tests/Benchmarks/Configuration/SchemeSerializationBenchmark.cs b/Tests/Benchmarks/Configuration/SchemeSerializationBenchmark.cs new file mode 100644 index 0000000000..4d1b419c46 --- /dev/null +++ b/Tests/Benchmarks/Configuration/SchemeSerializationBenchmark.cs @@ -0,0 +1,63 @@ +using System.Text.Json; +using BenchmarkDotNet.Attributes; +using Terminal.Gui.Configuration; +using Terminal.Gui.Drawing; +using TgAttribute = Terminal.Gui.Drawing.Attribute; + +namespace Terminal.Gui.Benchmarks.Configuration; + +/// +/// Measures serialize-then-deserialize of a representative Base via +/// and . Catches regressions in the JSON +/// code paths when future PRs add fields to . +/// +/// +/// +/// Run: +/// dotnet run --project Tests/Benchmarks -c Release -- --filter '*SchemeSerialization*' +/// +/// +[MemoryDiagnoser] +[BenchmarkCategory ("Configuration", "Scheme")] +public class SchemeSerializationBenchmark +{ + private Scheme _scheme = null!; + private string _json = null!; + private JsonSerializerOptions _options = null!; + + /// + /// Creates a representative Base scheme with only explicitly set + /// and prepares serialization options with the . + /// + [GlobalSetup] + public void Setup () + { + _scheme = new Scheme { Normal = new TgAttribute (Color.White, Color.Black) }; + + _options = new JsonSerializerOptions + { + Converters = { new SchemeJsonConverter () }, + PropertyNameCaseInsensitive = true + }; + + // Pre-serialize to have a stable JSON string for deserialization benchmarks. + _json = JsonSerializer.Serialize (_scheme, _options); + } + + /// Serializes a to JSON. + [Benchmark] + public string Serialize () => JsonSerializer.Serialize (_scheme, _options); + + /// Deserializes a from JSON. + [Benchmark] + public Scheme? Deserialize () => JsonSerializer.Deserialize (_json, _options); + + /// Full round-trip: serialize then immediately deserialize. + [Benchmark (Baseline = true)] + public Scheme? RoundTrip () + { + string json = JsonSerializer.Serialize (_scheme, _options); + + return JsonSerializer.Deserialize (json, _options); + } +} diff --git a/Tests/Benchmarks/Configuration/ThemeSwitchBenchmark.cs b/Tests/Benchmarks/Configuration/ThemeSwitchBenchmark.cs new file mode 100644 index 0000000000..0affe3bdcb --- /dev/null +++ b/Tests/Benchmarks/Configuration/ThemeSwitchBenchmark.cs @@ -0,0 +1,74 @@ +using BenchmarkDotNet.Attributes; +using Terminal.Gui.Configuration; + +namespace Terminal.Gui.Benchmarks.Configuration; + +/// +/// Measures the cost of switching the active theme via +/// ThemeManager.Theme = "X"; ConfigurationManager.Apply (). +/// Parametric over every built-in theme name shipped in the embedded config.json. +/// +/// +/// +/// Run: +/// dotnet run --project Tests/Benchmarks -c Release -- --filter '*ThemeSwitch*' +/// +/// +[MemoryDiagnoser] +[BenchmarkCategory ("Configuration", "Theme")] +public class ThemeSwitchBenchmark +{ + /// The built-in theme to switch to during each benchmark invocation. + [ParamsSource (nameof (ThemeNames))] + public string ThemeName { get; set; } = ThemeManager.DEFAULT_THEME_NAME; + + /// Returns the set of built-in theme names available after loading library resources. + public static IEnumerable ThemeNames + { + get + { + ConfigurationManager.Disable (true); + ConfigurationManager.Enable (ConfigLocations.LibraryResources); + + IEnumerable names = ThemeManager.GetThemeNames (); + + ConfigurationManager.Disable (true); + + return names; + } + } + + /// Loads the embedded configuration so all built-in themes are available. + [GlobalSetup] + public void Setup () + { + ConfigurationManager.Disable (true); + ConfigurationManager.Enable (ConfigLocations.LibraryResources); + } + + /// Resets the theme to Default before each iteration for a stable starting point. + [IterationSetup] + public void IterationSetup () + { + ThemeManager.Theme = ThemeManager.DEFAULT_THEME_NAME; + ConfigurationManager.Apply (); + } + + /// + /// Switches the active theme and applies the change. + /// This is the user-facing hot path when cycling themes via a . + /// + [Benchmark] + public void SwitchTheme () + { + ThemeManager.Theme = ThemeName; + ConfigurationManager.Apply (); + } + + /// Ensures ConfigurationManager is disabled after all iterations. + [GlobalCleanup] + public void Cleanup () + { + ConfigurationManager.Disable (true); + } +} diff --git a/Tests/Benchmarks/README.md b/Tests/Benchmarks/README.md index f08f70d27f..5ed8111231 100644 --- a/Tests/Benchmarks/README.md +++ b/Tests/Benchmarks/README.md @@ -126,6 +126,37 @@ Minimal `View` subclass with a large `ContentSize` and no rendering logic. Isola dotnet run --project Tests/Benchmarks -c Release -- --filter '*Scroll*' ``` +## Configuration Benchmarks + +The `Configuration/` directory contains benchmarks for the configuration, theming, and scheme subsystems. + +### ConfigurationManagerLoadBenchmark + +Measures the cold-start cost of `ConfigurationManager.Disable(true)` → `Enable(ConfigLocations.LibraryResources)` → `Apply()`. This is the app-startup hot path covering embedded-config load, deserialization, and apply. + +### ThemeSwitchBenchmark + +Measures `ThemeManager.Theme = "X"; ConfigurationManager.Apply()` against the embedded configuration. Parametric over all built-in theme names (`Default`, `Dark`, `Light`, `TurboPascal 5`, `Anders`, `Green Phosphor`, `Amber Phosphor`). + +### SchemeAttributeBenchmark + +Measures `Scheme.GetAttributeForRole(VisualRole)` for roles at different depths of the derivation chain: +- **GetNormal**: Explicitly-set role — the fastest path +- **GetHotFocus**: Derived from `Focus` (which itself derives from `Normal`) +- **GetCode**: Deepest derivation path (`Code` → `Editable` → `Normal`) + +No `ConfigurationManager` required; operates on a standalone `Scheme` instance. + +### SchemeSerializationBenchmark + +Measures serialize/deserialize of a representative `Base` `Scheme` via `JsonSerializer` + `SchemeJsonConverter`. Catches regressions in JSON code paths when future PRs add fields to `Scheme`. + +### Run all configuration benchmarks + +```bash +dotnet run --project Tests/Benchmarks -c Release -- --filter '*Config*|*Scheme*|*Theme*' +``` + ## Adding New Benchmarks 1. Create a new class in an appropriate subdirectory (e.g., `Layout/`, `Text/`, `ViewBase/`, `Scrolling/`) @@ -157,7 +188,7 @@ This detects if a draw function accidentally iterates the entire document instea The `.github/workflows/perf-gate.yml` workflow runs on every push to `main` / `develop` (not PRs) and: -1. Runs the `*Scroll*` benchmarks with `--job short` (~30–60 s total) +1. Runs the `*Scroll*`, `*Config*`, `*Scheme*`, and `*Theme*` benchmarks with `--job short` (~30–60 s total) 2. Compares results to `Tests/Benchmarks/baseline.json` 3. **Fails** if any benchmark exceeds **3×** the baseline 4. **Celebrates** 🎉 if any benchmark drops below **0.8×** the baseline @@ -169,7 +200,7 @@ After a deliberate performance change, re-run the focused scrolling benchmarks, ```bash # Run ShortRun and export JSON results -dotnet run --project Tests/Benchmarks -c Release -- --filter '*Scroll*' -j short --exporters json +dotnet run --project Tests/Benchmarks -c Release -- --filter '*Scroll*|*Config*|*Scheme*|*Theme*' -j short --exporters json # Inspect the JSON output in BenchmarkDotNet.Artifacts/ and update baseline.json ``` diff --git a/Tests/Benchmarks/baseline.json b/Tests/Benchmarks/baseline.json index 63a907f4e5..6374d8c2d2 100644 --- a/Tests/Benchmarks/baseline.json +++ b/Tests/Benchmarks/baseline.json @@ -1,6 +1,6 @@ { - "_comment": "Baseline benchmark results for the scrolling performance gate.", - "_howto": "Re-run 'dotnet run --project Tests/Benchmarks -c Release -- --filter \"*Scroll*\" -j short --exporters json' then update this file.", + "_comment": "Baseline benchmark results for the performance gate (Scrolling + Configuration).", + "_howto": "Re-run 'dotnet run --project Tests/Benchmarks -c Release -- --filter \"*Scroll*|*Config*|*Scheme*|*Theme*\" -j short --exporters json' then update this file.", "_version": "1", "benchmarks": [ { @@ -142,6 +142,108 @@ "params": "Rows=1000", "meanNs": 300000, "comment": "Placeholder — run actual benchmarks to set real baseline" + }, + + { + "type": "ConfigurationManagerLoadBenchmark", + "method": "LoadAndApply", + "params": "", + "meanNs": 5000000, + "comment": "Placeholder — run actual benchmarks to set real baseline" + }, + + { + "type": "ThemeSwitchBenchmark", + "method": "SwitchTheme", + "params": "ThemeName=Default", + "meanNs": 1000000, + "comment": "Placeholder — run actual benchmarks to set real baseline" + }, + { + "type": "ThemeSwitchBenchmark", + "method": "SwitchTheme", + "params": "ThemeName=Dark", + "meanNs": 1000000, + "comment": "Placeholder — run actual benchmarks to set real baseline" + }, + { + "type": "ThemeSwitchBenchmark", + "method": "SwitchTheme", + "params": "ThemeName=Light", + "meanNs": 1000000, + "comment": "Placeholder — run actual benchmarks to set real baseline" + }, + { + "type": "ThemeSwitchBenchmark", + "method": "SwitchTheme", + "params": "ThemeName=TurboPascal 5", + "meanNs": 1000000, + "comment": "Placeholder — run actual benchmarks to set real baseline" + }, + { + "type": "ThemeSwitchBenchmark", + "method": "SwitchTheme", + "params": "ThemeName=Anders", + "meanNs": 1000000, + "comment": "Placeholder — run actual benchmarks to set real baseline" + }, + { + "type": "ThemeSwitchBenchmark", + "method": "SwitchTheme", + "params": "ThemeName=Green Phosphor", + "meanNs": 1000000, + "comment": "Placeholder — run actual benchmarks to set real baseline" + }, + { + "type": "ThemeSwitchBenchmark", + "method": "SwitchTheme", + "params": "ThemeName=Amber Phosphor", + "meanNs": 1000000, + "comment": "Placeholder — run actual benchmarks to set real baseline" + }, + + { + "type": "SchemeAttributeBenchmark", + "method": "GetNormal", + "params": "", + "meanNs": 50, + "comment": "Placeholder — run actual benchmarks to set real baseline" + }, + { + "type": "SchemeAttributeBenchmark", + "method": "GetHotFocus", + "params": "", + "meanNs": 500, + "comment": "Placeholder — run actual benchmarks to set real baseline" + }, + { + "type": "SchemeAttributeBenchmark", + "method": "GetCode", + "params": "", + "meanNs": 1000, + "comment": "Placeholder — run actual benchmarks to set real baseline" + }, + + { + "type": "SchemeSerializationBenchmark", + "method": "Serialize", + "params": "", + "meanNs": 5000, + "comment": "Placeholder — run actual benchmarks to set real baseline" + }, + { + "type": "SchemeSerializationBenchmark", + "method": "Deserialize", + "params": "", + "meanNs": 5000, + "comment": "Placeholder — run actual benchmarks to set real baseline" + }, + { + "type": "SchemeSerializationBenchmark", + "method": "RoundTrip", + "params": "", + "meanNs": 10000, + "comment": "Placeholder — run actual benchmarks to set real baseline" } ] }