|
| 1 | +[CmdletBinding()] |
| 2 | +param( |
| 3 | + # vt: builtin vs Ghostty ingest; render: frame prep + CPU raster; control: PTY->UI coalescing. |
| 4 | + [string[]] $Suite = @("vt", "render"), |
| 5 | + |
| 6 | + # Smaller corpora and fewer runs for a fast local signal (not for published numbers). |
| 7 | + [switch] $Quick, |
| 8 | + |
| 9 | + [string] $OutputDirectory, |
| 10 | + |
| 11 | + # Comma-separated vt workloads; "all" runs every workload. |
| 12 | + [string] $Workloads = "all", |
| 13 | + |
| 14 | + # Read a frame (snapshot / render-state update) every N KiB fed; 0 measures pure ingest. |
| 15 | + [ValidateRange(0, 1048576)] |
| 16 | + [int] $FrameKb = 0, |
| 17 | + |
| 18 | + # A previous run's vt.json; medians that drop by more than -RegressionPercent fail the run. |
| 19 | + [string] $Baseline, |
| 20 | + |
| 21 | + [ValidateRange(1, 100)] |
| 22 | + [int] $RegressionPercent = 15, |
| 23 | + |
| 24 | + # Upstream ghostty-bench built from the pinned commit (see docs/performance-benchmarks.md). |
| 25 | + # When given, every vt workload is also replayed through `+terminal-stream`. |
| 26 | + [string] $GhosttyBench, |
| 27 | + |
| 28 | + # Fail when builtin is not faster than raw libghostty-vt on every workload. |
| 29 | + [switch] $RequireBeatGhostty |
| 30 | +) |
| 31 | + |
| 32 | +Set-StrictMode -Version Latest |
| 33 | +$ErrorActionPreference = "Stop" |
| 34 | + |
| 35 | +# `pwsh -File` passes "vt,render" as one string; accept both forms. |
| 36 | +$Suite = @($Suite | ForEach-Object { $_ -split "," } | ForEach-Object { $_.Trim() } | Where-Object { $_ }) |
| 37 | +foreach ($name in $Suite) { |
| 38 | + if ($name -notin @("vt", "render", "control")) { |
| 39 | + throw "Unknown suite '$name' (expected vt, render or control)." |
| 40 | + } |
| 41 | +} |
| 42 | + |
| 43 | +$repo = Split-Path -Parent $PSScriptRoot |
| 44 | +$project = Join-Path $repo "tools\Devolutions.Terminal.Bench" |
| 45 | +if (-not $OutputDirectory) { |
| 46 | + $OutputDirectory = Join-Path $repo ("artifacts\bench\" + (Get-Date -Format "yyyyMMdd-HHmmss")) |
| 47 | +} |
| 48 | + |
| 49 | +New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null |
| 50 | +$OutputDirectory = (Resolve-Path -LiteralPath $OutputDirectory).Path |
| 51 | + |
| 52 | +$megabytes = if ($Quick) { 2 } else { 8 } |
| 53 | +$runs = if ($Quick) { 3 } else { 7 } |
| 54 | + |
| 55 | +Write-Host "Building $project (Release)..." |
| 56 | +dotnet build $project -c Release --nologo -v quiet |
| 57 | +if ($LASTEXITCODE -ne 0) { |
| 58 | + throw "Benchmark build failed." |
| 59 | +} |
| 60 | + |
| 61 | +$bench = Get-ChildItem -Path (Join-Path $project "bin\Release") -Recurse -File | |
| 62 | + Where-Object { $_.BaseName -eq "Devolutions.Terminal.Bench" -and $_.Extension -in @(".exe", "") } | |
| 63 | + Select-Object -First 1 |
| 64 | +if (-not $bench) { |
| 65 | + $bench = Get-ChildItem -Path (Join-Path $project "bin\Release") -Recurse -Filter "Devolutions.Terminal.Bench.dll" | |
| 66 | + Select-Object -First 1 |
| 67 | +} |
| 68 | + |
| 69 | +function Invoke-Bench { |
| 70 | + param([string] $Name, [string[]] $Arguments) |
| 71 | + |
| 72 | + $log = Join-Path $OutputDirectory "$Name.txt" |
| 73 | + Write-Host "" |
| 74 | + Write-Host "== $Name ==" |
| 75 | + # Out-Host keeps the transcript out of the function's return value. |
| 76 | + if ($bench.Extension -eq ".dll") { |
| 77 | + & dotnet $bench.FullName @Arguments 2>&1 | Tee-Object -FilePath $log | Out-Host |
| 78 | + } |
| 79 | + else { |
| 80 | + & $bench.FullName @Arguments 2>&1 | Tee-Object -FilePath $log | Out-Host |
| 81 | + } |
| 82 | + |
| 83 | + return $LASTEXITCODE |
| 84 | +} |
| 85 | + |
| 86 | +$failures = [System.Collections.Generic.List[string]]::new() |
| 87 | + |
| 88 | +if ($Suite -contains "vt") { |
| 89 | + $vtJson = Join-Path $OutputDirectory "vt.json" |
| 90 | + $names = if ($Workloads -eq "all") { |
| 91 | + if ($bench.Extension -eq ".dll") { & dotnet $bench.FullName vt --list } else { & $bench.FullName vt --list } |
| 92 | + } |
| 93 | + else { |
| 94 | + $Workloads -split "," | ForEach-Object { $_.Trim() } | Where-Object { $_ } |
| 95 | + } |
| 96 | + |
| 97 | + # One process per workload: GC heap and JIT state left behind by an earlier |
| 98 | + # workload shifted later ratios by up to ~50% in a shared process, which made |
| 99 | + # runs with different -Workloads subsets incomparable. |
| 100 | + $merged = $null |
| 101 | + foreach ($name in $names) { |
| 102 | + $partial = Join-Path $OutputDirectory "vt-$name.json" |
| 103 | + $vtArgs = @("vt", "--mb", $megabytes, "--runs", $runs, "--frame-kb", $FrameKb, "--workloads", $name, "--json", $partial) |
| 104 | + if (-not $RequireBeatGhostty) { |
| 105 | + $vtArgs += "--no-gate" |
| 106 | + } |
| 107 | + |
| 108 | + if ((Invoke-Bench -Name "vt-$name" -Arguments $vtArgs) -ne 0) { |
| 109 | + $failures.Add("builtin did not beat ghostty-vt on vt workload '$name'") |
| 110 | + } |
| 111 | + |
| 112 | + $report = Get-Content -LiteralPath $partial -Raw | ConvertFrom-Json |
| 113 | + if ($merged) { |
| 114 | + $merged.workloads = @($merged.workloads) + @($report.workloads) |
| 115 | + } |
| 116 | + else { |
| 117 | + $merged = $report |
| 118 | + } |
| 119 | + } |
| 120 | + |
| 121 | + $merged | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $vtJson |
| 122 | + |
| 123 | + if ($GhosttyBench) { |
| 124 | + $corpora = Join-Path $OutputDirectory "corpora" |
| 125 | + Invoke-Bench -Name "vt-export" -Arguments @("vt", "--mb", $megabytes, "--workloads", $Workloads, "--export", $corpora) | Out-Null |
| 126 | + $upstream = foreach ($file in Get-ChildItem -LiteralPath $corpora -Filter "*.vt") { |
| 127 | + $times = foreach ($run in 1..$runs) { |
| 128 | + $watch = [System.Diagnostics.Stopwatch]::StartNew() |
| 129 | + & $GhosttyBench "+terminal-stream" "--data=$($file.FullName)" "--terminal-rows=30" "--terminal-cols=120" | Out-Null |
| 130 | + $watch.Stop() |
| 131 | + if ($LASTEXITCODE -ne 0) { |
| 132 | + throw "ghostty-bench failed on $($file.Name)." |
| 133 | + } |
| 134 | + |
| 135 | + $watch.Elapsed.TotalSeconds |
| 136 | + } |
| 137 | + |
| 138 | + $sorted = @($times | Sort-Object) |
| 139 | + $median = $sorted[[int][Math]::Floor($sorted.Count / 2)] |
| 140 | + [pscustomobject]@{ |
| 141 | + Workload = $file.BaseName |
| 142 | + MedianMBps = [Math]::Round($file.Length / 1MB / $median, 1) |
| 143 | + Note = "process wall time, includes startup and file load" |
| 144 | + } |
| 145 | + } |
| 146 | + |
| 147 | + $upstream | Format-Table -AutoSize | Out-String | Tee-Object -FilePath (Join-Path $OutputDirectory "ghostty-bench.txt") |
| 148 | + $upstream | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $OutputDirectory "ghostty-bench.json") |
| 149 | + } |
| 150 | + |
| 151 | + if ($Baseline) { |
| 152 | + $previous = Get-Content -LiteralPath $Baseline -Raw | ConvertFrom-Json |
| 153 | + $current = Get-Content -LiteralPath $vtJson -Raw | ConvertFrom-Json |
| 154 | + Write-Host "" |
| 155 | + Write-Host "== regression check vs $Baseline (threshold $RegressionPercent%) ==" |
| 156 | + foreach ($workload in $current.workloads) { |
| 157 | + $before = $previous.workloads | Where-Object { $_.name -eq $workload.name } | Select-Object -First 1 |
| 158 | + if (-not $before) { |
| 159 | + continue |
| 160 | + } |
| 161 | + |
| 162 | + if ($before.sha256 -ne $workload.sha256) { |
| 163 | + Write-Host ("{0,-14} skipped: input differs from the baseline (use the same -Quick/-Workloads settings)" -f $workload.name) |
| 164 | + continue |
| 165 | + } |
| 166 | + |
| 167 | + foreach ($target in $workload.targets) { |
| 168 | + $old = $before.targets | Where-Object { $_.target -eq $target.target } | Select-Object -First 1 |
| 169 | + if ($old) { |
| 170 | + $change = ($target.medianMBps - $old.medianMBps) / $old.medianMBps * 100 |
| 171 | + Write-Host ("{0,-14} {1,-10} {2,9:F1} -> {3,9:F1} MB/s ({4:+0.0;-0.0}%)" -f $workload.name, $target.target, $old.medianMBps, $target.medianMBps, $change) |
| 172 | + } |
| 173 | + } |
| 174 | + |
| 175 | + # Absolute MB/s drifts with clocks and background load between sessions, so |
| 176 | + # gate on builtin relative to ghostty-vt measured in the same run: Ghostty is |
| 177 | + # the control, and only the engine under our control can regress. |
| 178 | + $oldRatio = $before.PSObject.Properties["builtinVsGhosttyVt"] |
| 179 | + $newRatio = $workload.PSObject.Properties["builtinVsGhosttyVt"] |
| 180 | + if ($oldRatio -and $newRatio -and $oldRatio.Value -gt 0) { |
| 181 | + $change = ($newRatio.Value - $oldRatio.Value) / $oldRatio.Value * 100 |
| 182 | + $line = "{0,-14} builtin/ghostty-vt {1:F3} -> {2:F3} ({3:+0.0;-0.0}%)" -f $workload.name, $oldRatio.Value, $newRatio.Value, $change |
| 183 | + if ($change -lt -$RegressionPercent) { |
| 184 | + $failures.Add("regression: $line") |
| 185 | + Write-Host "$line REGRESSION" |
| 186 | + } |
| 187 | + else { |
| 188 | + Write-Host $line |
| 189 | + } |
| 190 | + } |
| 191 | + } |
| 192 | + } |
| 193 | +} |
| 194 | + |
| 195 | +if ($Suite -contains "render") { |
| 196 | + $renderArgs = if ($Quick) { |
| 197 | + @("render", "--mb", $megabytes, "--runs", $runs, "--samples", "60", "--warmup", "2", "--frames", "64") |
| 198 | + } |
| 199 | + else { |
| 200 | + @("render", "--mb", $megabytes, "--runs", "5", "--samples", "128", "--warmup", "2", "--frames", "256") |
| 201 | + } |
| 202 | + |
| 203 | + if ((Invoke-Bench -Name "render" -Arguments $renderArgs) -ne 0) { |
| 204 | + $failures.Add("render benchmark failed") |
| 205 | + } |
| 206 | +} |
| 207 | + |
| 208 | +if ($Suite -contains "control") { |
| 209 | + if ((Invoke-Bench -Name "control" -Arguments @("control", "--mb", $megabytes, "--runs", $runs)) -ne 0) { |
| 210 | + $failures.Add("control benchmark failed") |
| 211 | + } |
| 212 | +} |
| 213 | + |
| 214 | +Write-Host "" |
| 215 | +Write-Host "Results: $OutputDirectory" |
| 216 | +if ($failures.Count -gt 0) { |
| 217 | + $failures | ForEach-Object { Write-Warning $_ } |
| 218 | + exit 1 |
| 219 | +} |
0 commit comments