Skip to content
Merged
10 changes: 5 additions & 5 deletions .github/workflows/perf-gate.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Scrolling Performance Gate
name: Performance Gate

on:
push:
Expand Down Expand Up @@ -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'
Expand All @@ -89,15 +89,15 @@ 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 \
--project Tests/Benchmarks \
--configuration Release \
--no-build \
-- \
--filter '*Scroll*' \
--filter '*Scroll*' '*Config*' '*Scheme*' '*Theme*' \
--job short \
--exporters json \
--artifacts ./BenchmarkResults
Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using BenchmarkDotNet.Attributes;
using Terminal.Gui.Configuration;

namespace Terminal.Gui.Benchmarks.Configuration;

/// <summary>
/// Measures the cold-start cost of loading the embedded library configuration:
/// <c>ConfigurationManager.Disable (true)</c> → <c>Enable (ConfigLocations.LibraryResources)</c> → <c>Apply ()</c>.
/// This is the app-startup hot path.
/// </summary>
/// <remarks>
/// <para>
/// Run:
/// <code>dotnet run --project Tests/Benchmarks -c Release -- --filter '*ConfigurationManagerLoad*'</code>
/// </para>
/// </remarks>
[MemoryDiagnoser]
[BenchmarkCategory ("Configuration")]
public class ConfigurationManagerLoadBenchmark
{
/// <summary>
/// Loads the embedded library configuration from scratch and applies it.
/// Captures the full deserialize + merge + apply path.
/// Calls <see cref="ConfigurationManager.Disable"/> first so every invocation
/// is a true cold start (<see cref="ConfigurationManager.Enable"/> short-circuits when already enabled).
/// </summary>
[Benchmark]
public void LoadAndApply ()
{
ConfigurationManager.Disable (true);
ConfigurationManager.Enable (ConfigLocations.LibraryResources);
ConfigurationManager.Apply ();
}

/// <summary>Ensures ConfigurationManager is disabled after all iterations.</summary>
[GlobalCleanup]
public void Cleanup ()
{
ConfigurationManager.Disable (true);
}
}
47 changes: 47 additions & 0 deletions Tests/Benchmarks/Configuration/SchemeAttributeBenchmark.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using BenchmarkDotNet.Attributes;
using Terminal.Gui.Drawing;
using TgAttribute = Terminal.Gui.Drawing.Attribute;

namespace Terminal.Gui.Benchmarks.Configuration;

/// <summary>
/// Measures <see cref="Scheme.GetAttributeForRole"/> for roles at different depths of the derivation chain:
/// <list type="bullet">
/// <item><see cref="VisualRole.Normal"/> — explicitly set (O(1) lookup)</item>
/// <item><see cref="VisualRole.HotFocus"/> — derived from <see cref="VisualRole.Focus"/></item>
/// <item><see cref="VisualRole.Code"/> — deepest derivation (<c>Code → Editable → Normal</c>)</item>
/// </list>
/// No <see cref="Terminal.Gui.Configuration.ConfigurationManager"/> required; operates on a standalone
/// <see cref="Scheme"/> instance.
/// </summary>
/// <remarks>
/// <para>
/// Run:
/// <code>dotnet run --project Tests/Benchmarks -c Release -- --filter '*SchemeAttribute*'</code>
/// </para>
/// </remarks>
[MemoryDiagnoser]
[BenchmarkCategory ("Configuration", "Scheme")]
public class SchemeAttributeBenchmark
{
private Scheme _scheme = null!;

/// <summary>Creates a scheme with only <see cref="VisualRole.Normal"/> explicitly set.</summary>
[GlobalSetup]
public void Setup ()
{
_scheme = new Scheme { Normal = new TgAttribute (Color.White, Color.Black) };
}

/// <summary>Lookup for an explicitly-set role — the fastest path.</summary>
[Benchmark (Baseline = true)]
public TgAttribute GetNormal () => _scheme.GetAttributeForRole (VisualRole.Normal);

/// <summary>Lookup for a role derived from Focus (which itself is derived from Normal).</summary>
[Benchmark]
public TgAttribute GetHotFocus () => _scheme.GetAttributeForRole (VisualRole.HotFocus);

/// <summary>Lookup for the deepest derivation path: Code → Editable → Normal.</summary>
[Benchmark]
public TgAttribute GetCode () => _scheme.GetAttributeForRole (VisualRole.Code);
}
63 changes: 63 additions & 0 deletions Tests/Benchmarks/Configuration/SchemeSerializationBenchmark.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Measures serialize-then-deserialize of a representative <c>Base</c> <see cref="Scheme"/> via
/// <see cref="JsonSerializer"/> and <see cref="SchemeJsonConverter"/>. Catches regressions in the JSON
/// code paths when future PRs add fields to <see cref="Scheme"/>.
/// </summary>
/// <remarks>
/// <para>
/// Run:
/// <code>dotnet run --project Tests/Benchmarks -c Release -- --filter '*SchemeSerialization*'</code>
/// </para>
/// </remarks>
[MemoryDiagnoser]
[BenchmarkCategory ("Configuration", "Scheme")]
public class SchemeSerializationBenchmark
{
private Scheme _scheme = null!;
private string _json = null!;
private JsonSerializerOptions _options = null!;

/// <summary>
/// Creates a representative <c>Base</c> scheme with only <see cref="VisualRole.Normal"/> explicitly set
/// and prepares serialization options with the <see cref="SchemeJsonConverter"/>.
/// </summary>
[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);
}

/// <summary>Serializes a <see cref="Scheme"/> to JSON.</summary>
[Benchmark]
public string Serialize () => JsonSerializer.Serialize (_scheme, _options);

/// <summary>Deserializes a <see cref="Scheme"/> from JSON.</summary>
[Benchmark]
public Scheme? Deserialize () => JsonSerializer.Deserialize<Scheme> (_json, _options);

/// <summary>Full round-trip: serialize then immediately deserialize.</summary>
[Benchmark (Baseline = true)]
public Scheme? RoundTrip ()
{
string json = JsonSerializer.Serialize (_scheme, _options);

return JsonSerializer.Deserialize<Scheme> (json, _options);
}
}
71 changes: 71 additions & 0 deletions Tests/Benchmarks/Configuration/ThemeSwitchBenchmark.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
using BenchmarkDotNet.Attributes;
using Terminal.Gui.Configuration;

namespace Terminal.Gui.Benchmarks.Configuration;

/// <summary>
/// Measures the cost of switching the active theme via
/// <c>ThemeManager.Theme = "X"; ConfigurationManager.Apply ()</c>.
/// Parametric over every built-in theme name shipped in the embedded <c>config.json</c>.
/// </summary>
/// <remarks>
/// <para>
/// Run:
/// <code>dotnet run --project Tests/Benchmarks -c Release -- --filter '*ThemeSwitch*'</code>
/// </para>
/// </remarks>
[MemoryDiagnoser]
[BenchmarkCategory ("Configuration", "Theme")]
public class ThemeSwitchBenchmark
{
/// <summary>The built-in theme to switch to during each benchmark invocation.</summary>
[ParamsSource (nameof (ThemeNames))]
public string ThemeName { get; set; } = ThemeManager.DEFAULT_THEME_NAME;

/// <summary>Returns the set of built-in theme names available after loading library resources.</summary>
public static IEnumerable<string> ThemeNames
{
get
{
ConfigurationManager.Disable (true);
ConfigurationManager.Enable (ConfigLocations.LibraryResources);

IEnumerable<string> names = ThemeManager.GetThemeNames ();

ConfigurationManager.Disable (true);

return names;
}
}

/// <summary>Loads the embedded configuration so all built-in themes are available.</summary>
[GlobalSetup]
public void Setup ()
{
ConfigurationManager.Disable (true);
ConfigurationManager.Enable (ConfigLocations.LibraryResources);
}

/// <summary>
/// Switches the active theme and applies the change.
/// This is the user-facing hot path when cycling themes via a <see cref="Views.Shortcut"/>.
/// Resets to <see cref="ThemeManager.DEFAULT_THEME_NAME"/> before each switch so every
/// invocation performs a real theme change (not a redundant reapply).
/// </summary>
[Benchmark]
public void SwitchTheme ()
{
ThemeManager.Theme = ThemeManager.DEFAULT_THEME_NAME;
ConfigurationManager.Apply ();

ThemeManager.Theme = ThemeName;
ConfigurationManager.Apply ();
}

/// <summary>Ensures ConfigurationManager is disabled after all iterations.</summary>
[GlobalCleanup]
public void Cleanup ()
{
ConfigurationManager.Disable (true);
}
}
35 changes: 33 additions & 2 deletions Tests/Benchmarks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`)
Expand Down Expand Up @@ -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
Expand All @@ -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
```
Expand Down
Loading
Loading