Skip to content
Merged
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
13 changes: 13 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ on:

permissions:
contents: read
pull-requests: write

jobs:
build-and-test:
Expand Down Expand Up @@ -74,3 +75,15 @@ jobs:
- name: Terminal.Gui.Editor.IntegrationTests
run: dotnet run --project tests/Terminal.Gui.Editor.IntegrationTests --no-build

# Performance gate: run focused benchmarks on Linux only, compare to baseline.
# Fails on >3x regression; celebrates >20% improvement.
- name: Performance check
if: matrix.os == 'ubuntu-latest'
run: |
OUTPUT=$(bash benchmarks/compare-baseline.sh 3.0 0.8 2>&1) || PERF_FAILED=1
echo "$OUTPUT"
echo "$OUTPUT" >> "$GITHUB_STEP_SUMMARY"
if [ "${PERF_FAILED:-0}" -eq 1 ]; then
exit 1
fi

62 changes: 62 additions & 0 deletions benchmarks/baseline.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
{
"description": "Baseline benchmark results. Updated by running: dotnet run --project benchmarks/Terminal.Gui.Editor.Benchmarks -c Release -- --filter '*VisualLineBuild*' --exporters json",
"date": "2026-05-11",
"environment": "Apple M4 Max, .NET 10.0.5, Arm64 RyuJIT",
"results": {
"BuildLine_Short": {
"mean_us": 2.6,
"allocated_kb": 11.64,
"description": "Short ASCII (~40 chars)"
},
"BuildLine_Long": {
"mean_us": 15.7,
"allocated_kb": 71.98,
"description": "Long ASCII (~200 chars)"
},
"BuildLine_Tabs": {
"mean_us": 3.0,
"allocated_kb": 13.13,
"description": "Tabbed line (4 tabs + code)"
},
"BuildLine_Emoji": {
"mean_us": 2.7,
"allocated_kb": 10.36,
"description": "Emoji / ZWJ clusters"
},
"BuildLine_Mixed": {
"mean_us": 2.6,
"allocated_kb": 10.45,
"description": "Mixed: tabs + CJK + emoji"
},
"BuildViewport_Top_10K_24": {
"mean_us": 86,
"allocated_kb": 394,
"description": "Viewport at top (10K lines, 24 rows)"
},
"FullScroll_1K_24": {
"mean_us": 4076,
"allocated_kb": 18159,
"description": "Full scroll 1K lines (24-row viewport)"
},
"DocLookup_Sequential_100K": {
"mean_ns": 326,
"allocated_bytes": 0,
"description": "GetLineByNumber × 50 sequential (100K lines)"
},
"DocLookup_Random_100K": {
"mean_ns": 825,
"allocated_bytes": 304,
"description": "GetLineByNumber × 50 random (100K lines)"
},
"E2E_ArrowDown_500": {
"mean_ms": 752,
"allocated_mb": 2983,
"description": "Arrow ↓ to bottom (500 lines)"
},
"E2E_PageDown_5K": {
"mean_ms": 353,
"allocated_mb": 1387,
"description": "PageDown to bottom (5K lines)"
}
}
}
129 changes: 129 additions & 0 deletions benchmarks/compare-baseline.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
#!/usr/bin/env bash
# compare-baseline.sh — Run focused benchmarks and compare against baseline.json.
#
# Exits 0 on pass, 1 on egregious regression (>3x slower).
# Prints a markdown summary to stdout suitable for GitHub step summaries.
#
# Usage:
# ./benchmarks/compare-baseline.sh [--fail-threshold 3.0] [--celebrate-threshold 0.8]

set -euo pipefail

FAIL_THRESHOLD="${1:-3.0}" # fail if current > baseline × this
CELEBRATE_THRESHOLD="${2:-0.8}" # celebrate if current < baseline × this

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
BASELINE="$SCRIPT_DIR/baseline.json"
RESULTS_DIR="$(mktemp -d)"

echo "::group::Running focused benchmarks (ShortRun)"
dotnet run --project "$SCRIPT_DIR/Terminal.Gui.Editor.Benchmarks" -c Release -- \
--filter "*VisualLineBuild*" \
--job ShortRun \
--exporters json \
--artifacts "$RESULTS_DIR" 2>&1 | tail -20
echo "::endgroup::"

# Find the BenchmarkDotNet JSON report
REPORT=$(find "$RESULTS_DIR" -name "*.json" -path "*/results/*" | head -1)

if [ -z "$REPORT" ]; then
echo "::warning::No benchmark JSON report found — skipping comparison."
exit 0
fi

# Compare: extract means from the JSON report and compare to baseline
echo ""
echo "## Performance comparison"
echo ""
echo "| Benchmark | Baseline | Current | Ratio | Status |"
echo "|-----------|----------|---------|-------|--------|"

FAILED=0
CELEBRATED=0

compare_benchmark() {
local key="$1"
local baseline_val="$2"
local unit="$3"

# Extract current mean from BenchmarkDotNet JSON using the method name
# BDN method names in JSON are like "BuildLine_Short"
local current
current=$(python3 -c "
import json, sys
with open('$REPORT') as f:
data = json.load(f)
for b in data.get('Benchmarks', []):
method = b.get('Method', '')
if method == '$key':
stats = b.get('Statistics', {})
mean = stats.get('Mean', 0)
# BDN reports in nanoseconds
if '$unit' == 'us':
print(f'{mean / 1000:.1f}')
elif '$unit' == 'ms':
print(f'{mean / 1000000:.1f}')
else:
print(f'{mean:.1f}')
sys.exit(0)
print('')
" 2>/dev/null || echo "")

if [ -z "$current" ] || [ "$current" = "" ]; then
return
fi

local ratio
ratio=$(python3 -c "
b = float('$baseline_val')
c = float('$current')
if b > 0:
print(f'{c/b:.2f}')
else:
print('N/A')
")

local status="✅"
if python3 -c "exit(0 if float('$ratio') > float('$FAIL_THRESHOLD') else 1)" 2>/dev/null; then
status="❌ REGRESSION"
FAILED=1
elif python3 -c "exit(0 if float('$ratio') < float('$CELEBRATE_THRESHOLD') else 1)" 2>/dev/null; then
status="🎉 FASTER"
CELEBRATED=1
fi

local desc
desc=$(python3 -c "
import json
with open('$BASELINE') as f:
data = json.load(f)
print(data['results'].get('$key', {}).get('description', '$key'))
" 2>/dev/null || echo "$key")

echo "| $desc | ${baseline_val} ${unit} | ${current} ${unit} | ${ratio}x | $status |"
}

compare_benchmark "BuildLine_Short" "2.6" "us"
compare_benchmark "BuildLine_Long" "15.7" "us"
compare_benchmark "BuildLine_Tabs" "3.0" "us"
compare_benchmark "BuildLine_Emoji" "2.7" "us"
compare_benchmark "BuildLine_Mixed" "2.6" "us"

echo ""

if [ "$CELEBRATED" -eq 1 ]; then
echo "> 🎉 **Performance improved!** Some benchmarks are notably faster than baseline."
echo ""
fi

if [ "$FAILED" -eq 1 ]; then
echo "> ❌ **Performance regression detected.** One or more benchmarks exceeded ${FAIL_THRESHOLD}x the baseline."
echo "> Run \`dotnet run --project benchmarks/Terminal.Gui.Editor.Benchmarks -c Release\` locally to investigate."
exit 1
fi

echo "> ✅ All benchmarks within ${FAIL_THRESHOLD}x of baseline."

# Cleanup
rm -rf "$RESULTS_DIR"
170 changes: 170 additions & 0 deletions tests/Terminal.Gui.Editor.Tests/PerformanceSmokeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
using System.Diagnostics;
using Terminal.Gui.Text.Document;
using Terminal.Gui.Views.Rendering;
using Xunit;
using Attribute = Terminal.Gui.Drawing.Attribute;

namespace Terminal.Gui.Editor.Tests;

/// <summary>
/// Stopwatch-based performance smoke tests that run in normal CI. Thresholds are set to
/// ~5x the typical wall time on an M-series Mac, so they only fail on catastrophic
/// regressions — not CI-runner noise. For precision measurements use the BenchmarkDotNet
/// suite in <c>benchmarks/</c>.
/// </summary>
public class PerformanceSmokeTests
{
/// <summary>
/// Building 50 visual lines from a 10K-line document should complete well under the
/// threshold. Typical: ~200 µs. Threshold: 50 ms (250x headroom for slow CI runners).
/// </summary>
[Fact]
public void BuildViewport_50Lines_CompletesWithinBudget ()
{
TextDocument document = new (GenerateDocument (10_000));
VisualLineBuilder builder = new ();
var startLine = document.LineCount / 2;

// Warm up
BuildViewport (document, builder, startLine, 50);

Stopwatch sw = Stopwatch.StartNew ();
BuildViewport (document, builder, startLine, 50);
sw.Stop ();

Assert.True (sw.ElapsedMilliseconds < 50,
$"Viewport build took {sw.ElapsedMilliseconds}ms — expected < 50ms. Possible performance regression.");
}

/// <summary>
/// Building a single long line (~200 chars) should complete well under the threshold.
/// Typical: ~16 µs locally; CI runners (shared, no turbo) run 2–4x slower.
/// Threshold: 100 ms.
/// </summary>
[Fact]
public void BuildSingleLongLine_CompletesWithinBudget ()
{
TextDocument document = new (new string ('a', 200));
VisualLineBuilder builder = new ();
DocumentLine line = document.GetLineByNumber (1);

// Warm up
for (var i = 0; i < 100; i++)
{
BuildLine (document, builder, line);
}

Stopwatch sw = Stopwatch.StartNew ();

for (var i = 0; i < 100; i++)
{
BuildLine (document, builder, line);
}

sw.Stop ();

Assert.True (sw.ElapsedMilliseconds < 100,
$"100 long-line builds took {sw.ElapsedMilliseconds}ms — expected < 100ms. Possible performance regression.");
}

/// <summary>
/// Sequential line-tree lookups across a 100K-line document should be fast.
/// Typical: ~330 ns for 50 lookups. Threshold: 5 ms.
/// </summary>
[Fact]
public void DocumentLineLookup_100K_Lines_CompletesWithinBudget ()
{
TextDocument document = new (GenerateDocument (100_000));
var mid = document.LineCount / 2;

// Warm up
for (var i = 0; i < 50; i++)
{
_ = document.GetLineByNumber (mid + i);
}

Stopwatch sw = Stopwatch.StartNew ();

for (var rep = 0; rep < 100; rep++)
{
for (var i = 0; i < 50; i++)
{
_ = document.GetLineByNumber (mid + i);
}
}

sw.Stop ();

Assert.True (sw.ElapsedMilliseconds < 5,
$"5000 line lookups in 100K-line doc took {sw.ElapsedMilliseconds}ms — expected < 5ms. Possible performance regression.");
}

/// <summary>
/// Full-document scroll simulation (build every viewport page) for a 1K-line document.
/// Typical: ~4 ms. Threshold: 200 ms.
/// </summary>
[Fact]
public void FullDocumentScroll_1K_Lines_CompletesWithinBudget ()
{
TextDocument document = new (GenerateDocument (1_000));
VisualLineBuilder builder = new ();

// Warm up
ScrollFullDocument (document, builder, 24);

Stopwatch sw = Stopwatch.StartNew ();
ScrollFullDocument (document, builder, 24);
sw.Stop ();

Assert.True (sw.ElapsedMilliseconds < 200,
$"Full scroll of 1K lines took {sw.ElapsedMilliseconds}ms — expected < 200ms. Possible performance regression.");
}

private static void BuildViewport (TextDocument document, VisualLineBuilder builder, int startLine, int height)
{
for (var row = 0; row < height && startLine + row <= document.LineCount; row++)
{
DocumentLine line = document.GetLineByNumber (startLine + row);
BuildLine (document, builder, line);
}
}

private static CellVisualLine BuildLine (TextDocument document, VisualLineBuilder builder, DocumentLine line)
{
VisualLineBuildContext context = new (
document, 4, false,
Attribute.Default, Attribute.Default,
null, 0, 0, []);

return builder.Build (line, context);
}

private static void ScrollFullDocument (TextDocument document, VisualLineBuilder builder, int viewportHeight)
{
for (var startLine = 1; startLine <= document.LineCount; startLine += viewportHeight)
{
var endLine = Math.Min (startLine + viewportHeight, document.LineCount + 1);

for (var lineNum = startLine; lineNum < endLine; lineNum++)
{
DocumentLine line = document.GetLineByNumber (lineNum);
BuildLine (document, builder, line);
}
}
}

private static string GenerateDocument (int lineCount)
{
Random rng = new (42);
List<string> lines = new (lineCount);

for (var i = 0; i < lineCount; i++)
{
var indent = rng.Next (0, 4);
var bodyLen = rng.Next (20, 80);
lines.Add (new string ('\t', indent) + new string ('x', bodyLen));
}

return string.Join ('\n', lines);
}
}
Loading