Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,45 @@
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>Resets ConfigurationManager to a clean state before each iteration.</summary>
[IterationSetup]
public void IterationSetup ()
{
ConfigurationManager.Disable (true);
Comment on lines +22 to +25
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reset ConfigurationManager before each benchmark invocation

This benchmark claims to measure cold-start load/apply, but [IterationSetup] runs only once per iteration while BenchmarkDotNet executes the benchmark method many times per iteration. After the first invocation, ConfigurationManager.Enable(...) short-circuits because it is already enabled, so most samples measure a warm Apply() path instead of the documented cold-start path, skewing the baseline used by the perf gate.

Useful? React with 👍 / 👎.

}

/// <summary>
/// Loads the embedded library configuration from scratch and applies it.
/// Captures the full deserialize + merge + apply path.
/// </summary>
[Benchmark]
public void LoadAndApply ()
{
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);
}
}
74 changes: 74 additions & 0 deletions Tests/Benchmarks/Configuration/ThemeSwitchBenchmark.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
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>Resets the theme to Default before each iteration for a stable starting point.</summary>
[IterationSetup]
public void IterationSetup ()
{
ThemeManager.Theme = ThemeManager.DEFAULT_THEME_NAME;
ConfigurationManager.Apply ();
}
Comment on lines +50 to +55
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reinitialize theme state before each measured invocation

Using [IterationSetup] here means the theme is reset to Default only once per iteration, but SwitchTheme() is invoked repeatedly inside that iteration. As a result, only the first call performs an actual switch; subsequent calls mostly reapply the same theme value, so the benchmark under-represents real theme-switch cost and can produce misleading perf-gate baselines.

Useful? React with 👍 / 👎.


/// <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"/>.
/// </summary>
[Benchmark]
public void SwitchTheme ()
{
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