test/perf/Compare-Perf.ps1
|
#requires -version 7.0 <# .SYNOPSIS A/B performance comparison of two configmap revisions on the same machine. .DESCRIPTION Checks out -Base (and -Head, or uses the working tree) as git worktrees, then runs this checkout's Invoke-PerfSuite.ps1 against each module in fresh pwsh processes, alternating the order every round so machine noise hits both sides equally. Each side gets its own fixture copy and HOME, so discovery caches never leak between them. Prints a markdown table and exits 1 when a gated scenario regresses: time: head slower by more than -TimeThresholdPercent AND -TimeThresholdMs in every round, needs -Rounds 2+ (paired per round; timings are noisy, allocations are not) alloc: median allocations up by more than -AllocThresholdPercent AND -AllocThresholdKB .EXAMPLE ./Compare-Perf.ps1 -Base master .EXAMPLE ./Compare-Perf.ps1 -Base master -Fixture workspace-M -Rounds 1 # quick local check #> [CmdletBinding()] param( [string]$Base = 'master', # Empty: the current working tree, including uncommitted changes. [string]$Head, [ValidateRange(1, 20)] [int]$Rounds = 3, [int]$Iterations = 10, [double]$ScenarioBudgetSeconds = 8, [int]$NewShellIterations = 5, [int]$ImportIterations = 10, [ValidateSet('workspace-M', 'workspace-XL', 'flat-1k', 'samples')] [string[]]$Fixture = @('workspace-M', 'workspace-XL', 'flat-1k', 'samples'), [double]$TimeThresholdPercent = 25, [double]$TimeThresholdMs = 5, [double]$AllocThresholdPercent = 10, [double]$AllocThresholdKB = 64, [string]$OutDir = (Join-Path ([System.IO.Path]::GetTempPath()) "configmap-perf-$(Get-Date -Format yyyyMMdd-HHmmss)"), [switch]$NoFail ) $ErrorActionPreference = 'Stop' $moduleDir = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path $repoRoot = (git -C $moduleDir rev-parse --show-toplevel).Trim() $modulePrefix = (git -C $moduleDir rev-parse --show-prefix).Trim() $suite = Join-Path $PSScriptRoot 'Invoke-PerfSuite.ps1' $work = Join-Path $OutDir 'work' New-Item -ItemType Directory -Path $work -Force | Out-Null function Get-Median { param([double[]]$Values) $sorted = @($Values | Sort-Object) if ($sorted.Count -eq 0) { return $null } $mid = [math]::Floor($sorted.Count / 2) if ($sorted.Count % 2) { return $sorted[$mid] } return ($sorted[$mid - 1] + $sorted[$mid]) / 2 } function New-Side { param([string]$Name, [string]$Ref) $sha = if ($Ref) { (git -C $repoRoot rev-parse --short $Ref).Trim() } else { "$((git -C $repoRoot rev-parse --short HEAD).Trim())+wt" } if ($LASTEXITCODE -ne 0) { throw "Unknown ref '$Ref'." } $modulePath = Join-Path $moduleDir 'configmap.psm1' $worktree = $null if ($Ref) { $worktree = Join-Path $work "src-$Name" git -C $repoRoot worktree add --detach --force $worktree $Ref 2>&1 | Out-Null if ($LASTEXITCODE -ne 0) { throw "git worktree add failed for '$Ref'." } $modulePath = Join-Path $worktree (Join-Path $modulePrefix 'configmap.psm1') } $fixtures = Join-Path $work "fixtures-$Name" & (Join-Path $PSScriptRoot 'New-PerfFixture.ps1') -Path $fixtures -Name $Fixture $userHome = Join-Path $work "home-$Name" New-Item -ItemType Directory -Path $userHome -Force | Out-Null # Throwaway pass: the first load of freshly written .ps1 files is slowed by # antivirus/AMSI scanning, which would otherwise land in round 1. $prime = "Import-Module '$modulePath'; Get-ChildItem '$fixtures' -Directory | ForEach-Object { Set-Location `$_.FullName; `$null = qbuild list 6>`$null; `$null = TabExpansion2 'qbuild ' 7 }" $previousHome = $env:HOME try { $env:HOME = $userHome pwsh -NoProfile -NonInteractive -Command $prime | Out-Null } finally { $env:HOME = $previousHome } [pscustomobject]@{ Name = $Name Ref = if ($Ref) { $Ref } else { 'working tree' } Sha = $sha ModulePath = $modulePath Worktree = $worktree Fixtures = $fixtures Home = $userHome Runs = [System.Collections.Generic.List[object]]::new() } } function Invoke-Side { param($Side, [int]$Round) Write-Host "round $Round/$Rounds - $($Side.Name) ($($Side.Ref) @ $($Side.Sha))" -ForegroundColor Cyan $out = Join-Path $OutDir "$($Side.Name)-round$Round.json" $previousHome = $env:HOME try { $env:HOME = $Side.Home pwsh -NoProfile -NonInteractive -File $suite -ModulePath $Side.ModulePath -FixtureRoot $Side.Fixtures ` -Iterations $Iterations -ScenarioBudgetSeconds $ScenarioBudgetSeconds -NewShellIterations $NewShellIterations -ImportIterations $ImportIterations ` -Label "$($Side.Name)@$($Side.Sha)" -OutFile $out if ($LASTEXITCODE -ne 0) { throw "Perf suite failed for $($Side.Name) in round $Round." } } finally { $env:HOME = $previousHome } $Side.Runs.Add((Get-Content $out -Raw | ConvertFrom-Json)) } function Get-SideSummary { param($Side) $summary = [ordered]@{} foreach ($row in $Side.Runs.results) { $key = "$($row.Fixture)|$($row.Scenario)" if (!$summary.Contains($key)) { $summary[$key] = [pscustomobject]@{ Fixture = $row.Fixture; Scenario = $row.Scenario; Gate = $row.Gate; Ms = @(); KB = @(); Error = $null } } $item = $summary[$key] if ($row.Error) { $item.Error = $row.Error; continue } $item.Ms += [double]$row.MedianMs if ($null -ne $row.MedianAllocKB) { $item.KB += [double]$row.MedianAllocKB } } foreach ($item in $summary.Values) { $item | Add-Member -NotePropertyName MedianMs -NotePropertyValue (Get-Median $item.Ms) $item | Add-Member -NotePropertyName MedianKB -NotePropertyValue (Get-Median $item.KB) } return $summary } function Test-SlowerEveryRound { param([string]$Key, $Slow, $Fast) $pairs = 0 for ($i = 0; $i -lt [math]::Min($Slow.Runs.Count, $Fast.Runs.Count); $i++) { $s = $Slow.Runs[$i].results | Where-Object { "$($_.Fixture)|$($_.Scenario)" -eq $Key -and !$_.Error } $f = $Fast.Runs[$i].results | Where-Object { "$($_.Fixture)|$($_.Scenario)" -eq $Key -and !$_.Error } if (!$s -or !$f) { continue } if (!(Test-Worse $f.MedianMs $s.MedianMs $TimeThresholdPercent $TimeThresholdMs)) { return $false } $pairs++ } # One round cannot tell a regression from a noisy process. return $pairs -ge 2 } function Format-Delta { param($BaseValue, $HeadValue) if ($null -eq $BaseValue -or $null -eq $HeadValue -or $BaseValue -eq 0) { return '' } $pct = ($HeadValue - $BaseValue) / $BaseValue * 100 return [string]::Format([cultureinfo]::InvariantCulture, '{0:+0;-0;0}%', $pct) } function Test-Worse { param($BaseValue, $HeadValue, [double]$Percent, [double]$Absolute) if ($null -eq $BaseValue -or $null -eq $HeadValue -or $BaseValue -eq 0) { return $false } return ($HeadValue - $BaseValue) -gt $Absolute -and (($HeadValue - $BaseValue) / $BaseValue * 100) -gt $Percent } $sides = @() try { $baseSide = New-Side -Name 'base' -Ref $Base $headSide = New-Side -Name 'head' -Ref $Head $sides = @($baseSide, $headSide) foreach ($round in 1..$Rounds) { $order = if ($round % 2) { @($baseSide, $headSide) } else { @($headSide, $baseSide) } foreach ($side in $order) { Invoke-Side -Side $side -Round $round } } } finally { foreach ($side in $sides) { if ($side.Worktree) { git -C $repoRoot worktree remove --force $side.Worktree 2>&1 | Out-Null } } } $baseSummary = Get-SideSummary $baseSide $headSummary = Get-SideSummary $headSide $rows = [System.Collections.Generic.List[object]]::new() $regressions = 0 foreach ($key in $headSummary.Keys) { $h = $headSummary[$key] $b = $baseSummary[$key] $status = 'ok' if ($h.Error) { $status = 'head failed' } elseif (!$b -or $b.Error) { $status = 'new' } else { $slower = Test-SlowerEveryRound -Key $key -Slow $headSide -Fast $baseSide $moreAlloc = Test-Worse $b.MedianKB $h.MedianKB $AllocThresholdPercent $AllocThresholdKB $faster = Test-SlowerEveryRound -Key $key -Slow $baseSide -Fast $headSide $status = if ($slower -and $moreAlloc) { 'slower, more alloc' } elseif ($slower) { 'slower' } elseif ($moreAlloc) { 'more alloc' } elseif ($faster) { 'faster' } else { 'ok' } } $isRegression = $status -in 'head failed', 'slower, more alloc', 'slower', 'more alloc' if ($isRegression -and $h.Gate) { $regressions++ } $icon = if ($isRegression -and $h.Gate) { '🔴' } elseif ($isRegression) { '⚠️' } elseif ($status -eq 'faster') { '🟢' } else { '' } $rows.Add([pscustomobject]@{ Fixture = $h.Fixture Scenario = $h.Scenario + $(if (!$h.Gate) { ' *' }) BaseMs = $b.MedianMs HeadMs = $h.MedianMs DeltaMs = Format-Delta $b.MedianMs $h.MedianMs BaseKB = $b.MedianKB HeadKB = $h.MedianKB DeltaKB = Format-Delta $b.MedianKB $h.MedianKB Status = "$icon $status".Trim() Error = $h.Error }) } $fmt = { param($v) if ($null -eq $v) { '' } else { [string]::Format([cultureinfo]::InvariantCulture, '{0:0.0}', $v) } } $md = [System.Text.StringBuilder]::new() $verdict = if ($regressions) { "🔴 $regressions gated regression(s)" } else { '✅ no gated regressions' } [void]$md.AppendLine("## qbuild performance: $verdict") [void]$md.AppendLine() [void]$md.AppendLine("base ``$($baseSide.Ref)`` @ $($baseSide.Sha) vs head ``$($headSide.Ref)`` @ $($headSide.Sha) - $Rounds round(s), median of per-round medians, $($headSide.Runs[0].os), pwsh $($headSide.Runs[0].pwsh)") [void]$md.AppendLine() [void]$md.AppendLine('| Fixture | Scenario | Base ms | Head ms | Δ time | Base KB | Head KB | Δ alloc | Status |') [void]$md.AppendLine('|---|---|--:|--:|--:|--:|--:|--:|---|') foreach ($r in $rows) { [void]$md.AppendLine("| $($r.Fixture) | $($r.Scenario) | $(& $fmt $r.BaseMs) | $(& $fmt $r.HeadMs) | $($r.DeltaMs) | $(& $fmt $r.BaseKB) | $(& $fmt $r.HeadKB) | $($r.DeltaKB) | $($r.Status) |") } [void]$md.AppendLine() [void]$md.AppendLine("Regression = time +$TimeThresholdPercent% and +$TimeThresholdMs ms in every round, or median allocations +$AllocThresholdPercent% and +$AllocThresholdKB KB. ``*`` = internal helper, report-only.") foreach ($r in $rows | Where-Object Error) { [void]$md.AppendLine() [void]$md.AppendLine("**$($r.Fixture) / $($r.Scenario) failed on head:** ``$($r.Error)``") } $reportPath = Join-Path $OutDir 'perf-report.md' $md.ToString() | Set-Content $reportPath $rows | ConvertTo-Json -Depth 4 | Set-Content (Join-Path $OutDir 'perf-compare.json') Write-Host $md.ToString() Write-Host "report: $reportPath" -ForegroundColor DarkGray if ($regressions -and !$NoFail) { exit 1 } |