test/perf/Invoke-PerfSuite.ps1

#requires -version 7.0
<#
.SYNOPSIS
    Runs the qbuild performance scenarios against one configmap module and emits JSON.
.DESCRIPTION
    Run in a fresh pwsh process. Public scenarios only use qbuild, `qbuild list` and
    TabExpansion2, so the same suite can measure an older module checkout (A/B in
    Compare-Perf.ps1). Internal scenarios call exported helpers and are skipped when
    the module does not have them.
 
    Cache states:
      warm - repeated call in a loaded session (discovery cache memory hit)
      edited - a child map was just touched, so the discovery cache is rebuilt
      new-shell - fresh pwsh: first TAB on the fixture (discovery cache disk hit),
                  excluding module import and one-time session warm-up
 
    Module load time ('module' fixture) is Import-Module in a fresh pwsh, measured
    once per run independent of the fixtures, with its allocations.
 
    -ProfileDir traces one extra run of every in-process scenario with the Profiler
    module (Install-PSResource Profiler) after it is timed, so timings are unaffected.
    It writes <fixture>-<scenario>.speedscope.json (open in https://speedscope.app)
    and profile-report.md with the slowest functions and lines by self time.
    new-shell and module import scenarios run in child processes and are not traced.
.EXAMPLE
    ./Invoke-PerfSuite.ps1
.EXAMPLE
    ./Invoke-PerfSuite.ps1 -Fixture samples -ProfileDir ./perf-profile
.EXAMPLE
    ./Invoke-PerfSuite.ps1 -FixtureRoot $env:TEMP/perf -OutFile result.json
#>

[CmdletBinding()]
param(
    [string]$ModulePath = (Join-Path $PSScriptRoot '..\..\configmap.psm1'),
    # Prepared by New-PerfFixture.ps1. Omitted: a temporary copy of all fixtures.
    [string]$FixtureRoot,
    # flat-1k also runs the in-memory internal helper scenarios.
    [ValidateSet('workspace-M', 'workspace-XL', 'flat-1k', 'samples')]
    [string[]]$Fixture = @('workspace-M', 'workspace-XL', 'flat-1k', 'samples'),
    [ValidateRange(3, 10000)]
    [int]$Iterations = 10,
    # Slow scenarios stop after this many seconds (but run at least 3 iterations).
    [double]$ScenarioBudgetSeconds = 8,
    [ValidateRange(1, 1000)]
    [int]$NewShellIterations = 8,
    [ValidateRange(1, 1000)]
    [int]$ImportIterations = 10,
    [string]$Label = 'local',
    [string]$OutFile,
    # Traces each in-process scenario with the Profiler module and writes the results here.
    [string]$ProfileDir,
    # Functions and lines listed per scenario in profile-report.md.
    [ValidateRange(1, 50)]
    [int]$ProfileTop = 10
)

$ErrorActionPreference = 'Stop'

# Ambient QCONF_* settings (cache location, opt-outs) would skew results.
Get-ChildItem env: | Where-Object Name -like 'QCONF_*' | ForEach-Object { Remove-Item "env:$($_.Name)" }

$ModulePath = (Resolve-Path $ModulePath).Path
$tempFixtures = $null
if (!$FixtureRoot) {
    $tempFixtures = Join-Path ([System.IO.Path]::GetTempPath()) "configmap-perf-fixtures-$([guid]::NewGuid())"
    & (Join-Path $PSScriptRoot 'New-PerfFixture.ps1') -Path $tempFixtures -Name $Fixture
    $FixtureRoot = $tempFixtures
}
$FixtureRoot = (Resolve-Path $FixtureRoot).Path
Import-Module $ModulePath -Force
$profileReport = $null
if ($ProfileDir) {
    Import-Module Profiler -ErrorAction Stop
    $ProfileDir = (New-Item -ItemType Directory -Path $ProfileDir -Force).FullName
    $profileReport = [System.Text.StringBuilder]::new()
}

function Measure-Scenario {
    param(
        [string]$Name,
        [string]$Fixture,
        [scriptblock]$Action,
        [scriptblock]$Setup,
        [object[]]$ArgumentList = @(),
        [bool]$Gate = $true
    )

    $times = [System.Collections.Generic.List[double]]::new()
    $allocs = [System.Collections.Generic.List[long]]::new()
    try {
        if ($Setup) { & $Setup }
        $null = & $Action @ArgumentList

        $budget = [System.Diagnostics.Stopwatch]::StartNew()
        while ($times.Count -lt $Iterations -and ($times.Count -lt 3 -or $budget.Elapsed.TotalSeconds -lt $ScenarioBudgetSeconds)) {
            if ($Setup) { & $Setup }
            $before = [GC]::GetTotalAllocatedBytes($true)
            $sw = [System.Diagnostics.Stopwatch]::StartNew()
            $null = & $Action @ArgumentList
            $sw.Stop()
            $allocs.Add([GC]::GetTotalAllocatedBytes($true) - $before)
            $times.Add($sw.Elapsed.TotalMilliseconds)
        }
    }
    catch {
        # e.g. a base checkout that does not support a feature used by the fixture
        Write-Host (" {0,-14} {1,-34} FAILED: {2}" -f $Fixture, $Name, $_) -ForegroundColor Yellow
        return [pscustomobject]@{ Scenario = $Name; Fixture = $Fixture; Gate = $Gate; Error = "$_" }
    }

    $result = New-ScenarioResult -Name $Name -Fixture $Fixture -Gate $Gate -Times $times -Allocs $allocs
    Write-Host (" {0,-14} {1,-34} median {2,9:n1} ms (n={3})" -f $Fixture, $Name, $result.MedianMs, $result.Iterations) -ForegroundColor DarkGray
    if ($ProfileDir) { Trace-Scenario -Name $Name -Fixture $Fixture -Action $Action -Setup $Setup -ArgumentList $ArgumentList }
    $result
}

function Trace-Scenario {
    param([string]$Name, [string]$Fixture, [scriptblock]$Action, [scriptblock]$Setup, [object[]]$ArgumentList)

    $slug = "$Fixture-$Name" -replace '[^\w.-]+', '-' -replace '-+$', ''
    $exportPath = Join-Path $ProfileDir $slug
    if ($Setup) { & $Setup }
    # Trace-Script runs the block from the Profiler module, so bind our variables now.
    $traced = { $null = & $Action @ArgumentList }.GetNewClosure()
    $trace = Trace-Script -ScriptBlock $traced -DisableWarning -ExportPath $exportPath 6>$null
    Write-Host (" {0,-14} {1,-34} traced {2,9:n1} ms -> {3}.speedscope.json" -f $Fixture, $Name, $trace.TotalDuration.TotalMilliseconds, $slug) -ForegroundColor DarkGray
    Add-ProfileSection -Name $Name -Fixture $Fixture -Trace $trace -File "$slug.speedscope.json"
}

function Add-ProfileSection {
    param([string]$Name, [string]$Fixture, $Trace, [string]$File)

    $ms = { param($t) [string]::Format([cultureinfo]::InvariantCulture, '{0:0.0}', $t.TotalMilliseconds) }
    $cell = { param($s) "$s" -replace '\|', '\|' -replace '\s+', ' ' }
    $md = $profileReport
    [void]$md.AppendLine("## $Fixture / $Name")
    [void]$md.AppendLine()
    [void]$md.AppendLine("Traced run: $(& $ms $Trace.TotalDuration) ms (tracing overhead included). Flame graph: ``$File``")
    [void]$md.AppendLine()
    [void]$md.AppendLine('| Function | Module | Self ms | Self % | Total ms | Calls |')
    [void]$md.AppendLine('|---|---|--:|--:|--:|--:|')
    foreach ($f in $Trace.Top50FunctionSelfDuration | Select-Object -First $ProfileTop) {
        [void]$md.AppendLine("| $(& $cell $f.Function) | $($f.Module) | $(& $ms $f.SelfDuration) | $($f.SelfPercent) | $(& $ms $f.Duration) | $($f.HitCount) |")
    }
    [void]$md.AppendLine()
    [void]$md.AppendLine('| Line | Function | Self ms | Self % | Hits | Text |')
    [void]$md.AppendLine('|---|---|--:|--:|--:|---|')
    foreach ($l in $Trace.Top50SelfDuration | Select-Object -First $ProfileTop) {
        $text = & $cell $l.Text
        if ($text.Length -gt 80) { $text = $text.Substring(0, 77) + '...' }
        [void]$md.AppendLine("| $($l.File):$($l.Line) | $(& $cell $l.Function) | $(& $ms $l.SelfDuration) | $($l.SelfPercent) | $($l.HitCount) | ``$text`` |")
    }
    [void]$md.AppendLine()
}

function New-ScenarioResult {
    param([string]$Name, [string]$Fixture, [bool]$Gate, $Times, $Allocs)

    $t = @($Times | Sort-Object)
    $a = @($Allocs | Sort-Object)
    $p95 = [math]::Ceiling($t.Count * 0.95) - 1
    [pscustomobject]@{
        Scenario      = $Name
        Fixture       = $Fixture
        Gate          = $Gate
        Iterations    = $t.Count
        MedianMs      = [math]::Round($t[[int]($t.Count / 2)], 3)
        P95Ms         = [math]::Round($t[$p95], 3)
        MinMs         = [math]::Round($t[0], 3)
        MedianAllocKB = if ($a.Count) { [math]::Round($a[[int]($a.Count / 2)] / 1KB, 1) } else { $null }
        Error         = $null
    }
}

function Measure-Tab {
    param([string]$Line, [int]$Min = 1)

    $found = (TabExpansion2 $Line $Line.Length).CompletionMatches
    if ($found.Count -lt $Min) {
        throw "TabExpansion2 '$Line' returned $($found.Count) matches, expected at least $Min."
    }
}

function Invoke-FreshShell {
    param([scriptblock]$Probe, [object[]]$ArgumentList)

    $quoted = $ArgumentList | ForEach-Object { "'$($_ -replace "'", "''")'" }
    $command = "& { $Probe } $quoted"
    $encoded = [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($command))
    pwsh -NoProfile -NonInteractive -EncodedCommand $encoded | Select-Object -Last 1 | ConvertFrom-Json
}

function Measure-ModuleImport {
    $probe = {
        param($ModulePath)
        Get-ChildItem env: | Where-Object Name -like 'QCONF_*' | ForEach-Object { Remove-Item "env:$($_.Name)" }
        $before = [GC]::GetTotalAllocatedBytes($true)
        $sw = [System.Diagnostics.Stopwatch]::StartNew()
        Import-Module $ModulePath
        $ms = $sw.Elapsed.TotalMilliseconds
        $alloc = [GC]::GetTotalAllocatedBytes($true) - $before
        $loaded = [bool](Get-Command qbuild -ErrorAction Ignore)
        @{ ms = $ms; alloc = $alloc; loaded = $loaded } | ConvertTo-Json -Compress
    }

    $name = 'Import-Module (new shell)'
    $times = [System.Collections.Generic.List[double]]::new()
    $allocs = [System.Collections.Generic.List[long]]::new()
    try {
        # Untimed: the first load of the module files can be slowed by disk/AV caches.
        $null = Invoke-FreshShell -Probe $probe -ArgumentList $ModulePath
        foreach ($i in 1..$ImportIterations) {
            $r = Invoke-FreshShell -Probe $probe -ArgumentList $ModulePath
            if (!$r.loaded) { throw 'Import-Module did not export qbuild.' }
            $times.Add($r.ms)
            $allocs.Add($r.alloc)
        }
    }
    catch {
        Write-Host (" {0,-14} {1,-34} FAILED: {2}" -f 'module', $name, $_) -ForegroundColor Yellow
        return [pscustomobject]@{ Scenario = $name; Fixture = 'module'; Gate = $true; Error = "$_" }
    }

    $result = New-ScenarioResult -Name $name -Fixture 'module' -Gate $true -Times $times -Allocs $allocs
    Write-Host (" {0,-14} {1,-34} median {2,9:n1} ms (n={3})" -f 'module', $name, $result.MedianMs, $result.Iterations) -ForegroundColor DarkGray
    $result
}

function Measure-NewShell {
    param([string]$Fixture, [string]$Directory, [string]$Line)

    $probe = {
        param($ModulePath, $Directory, $WarmupDirectory, $Line)
        Get-ChildItem env: | Where-Object Name -like 'QCONF_*' | ForEach-Object { Remove-Item "env:$($_.Name)" }
        Import-Module $ModulePath
        # Untimed TAB on an identical copy: pays the one-time code costs of a new
        # session (script compilation, type loading) through the same code paths,
        # so the timed TAB measures only the disk cache load and the map work.
        Set-Location $WarmupDirectory
        $null = TabExpansion2 $Line $Line.Length
        Set-Location $Directory
        $before = [GC]::GetTotalAllocatedBytes($true)
        $sw = [System.Diagnostics.Stopwatch]::StartNew()
        $count = (TabExpansion2 $Line $Line.Length).CompletionMatches.Count
        $tabMs = $sw.Elapsed.TotalMilliseconds
        $alloc = [GC]::GetTotalAllocatedBytes($true) - $before
        @{ tabMs = $tabMs; alloc = $alloc; count = $count } | ConvertTo-Json -Compress
    }
    # A sibling keeps relative paths and parent-map lookups identical.
    $warmup = "$Directory-warmup"
    $probeArgs = @($ModulePath, $Directory, $warmup, $Line)

    $tab = [System.Collections.Generic.List[double]]::new()
    $allocs = [System.Collections.Generic.List[long]]::new()
    try {
        Copy-Item $Directory $warmup -Recurse -Force
        # Writes the disk cache of both copies so every timed run is a disk hit.
        $null = Invoke-FreshShell -Probe $probe -ArgumentList $probeArgs

        foreach ($i in 1..$NewShellIterations) {
            $r = Invoke-FreshShell -Probe $probe -ArgumentList $probeArgs
            if ($r.count -lt 1) { throw "new-shell TabExpansion2 '$Line' returned no matches." }
            $tab.Add($r.tabMs)
            $allocs.Add($r.alloc)
        }
    }
    catch {
        Write-Host (" {0,-14} {1,-34} FAILED: {2}" -f $Fixture, 'new-shell', $_) -ForegroundColor Yellow
        [pscustomobject]@{ Scenario = "new-shell: TAB '$Line'"; Fixture = $Fixture; Gate = $true; Error = "$_" }
        return
    }
    finally {
        Remove-Item $warmup -Recurse -Force -ErrorAction Ignore
    }

    New-ScenarioResult -Name "new-shell: TAB '$Line'" -Fixture $Fixture -Gate $true -Times $tab -Allocs $allocs
}

$results = [System.Collections.Generic.List[object]]::new()
$startLocation = Get-Location

try {
    $results.Add((Measure-ModuleImport))

    foreach ($fixture in @('workspace-M', 'workspace-XL') | Where-Object { $_ -in $Fixture }) {
        $dir = Join-Path $FixtureRoot $fixture
        if (!(Test-Path $dir)) { continue }
        Set-Location $dir
        $childMap = Join-Path $dir 'p03\.build.map.ps1'
        $touch = { (Get-Item -LiteralPath $childMap -Force).LastWriteTimeUtc = [datetime]::UtcNow }.GetNewClosure()

        $list = { qbuild list 6>&1 | Out-String }
        $results.Add((Measure-Scenario -Name 'list (warm)' -Fixture $fixture -Action $list))
        $results.Add((Measure-Scenario -Name 'list (edited)' -Fixture $fixture -Action $list -Setup $touch))

        $tabs = [ordered]@{
            'TAB entry'       = 'qbuild '
            'TAB nested'      = 'qbuild p03.tools.db.'
            'TAB param name'  = 'qbuild p03.build -'
            'TAB param value' = 'qbuild p03.build -Configuration '
            'TAB pkg script'  = 'qbuild p03.web '
        }
        foreach ($kv in $tabs.GetEnumerator()) {
            $results.Add((Measure-Scenario -Name "$($kv.Key) (warm)" -Fixture $fixture -Action { param($l) Measure-Tab $l } -ArgumentList $kv.Value))
        }
        $entryTab = { Measure-Tab 'qbuild ' }
        $results.Add((Measure-Scenario -Name 'TAB entry (edited)' -Fixture $fixture -Action $entryTab -Setup $touch))

        foreach ($r in Measure-NewShell -Fixture $fixture -Directory $dir -Line 'qbuild ') { $results.Add($r) }
    }

    $samplesDir = Join-Path $FixtureRoot 'samples'
    if ('samples' -in $Fixture -and (Test-Path $samplesDir)) {
        Set-Location $samplesDir
        $rootMap = Join-Path $samplesDir '.build.map.ps1'
        $touchRoot = { (Get-Item -LiteralPath $rootMap -Force).LastWriteTimeUtc = [datetime]::UtcNow }.GetNewClosure()
        $results.Add((Measure-Scenario -Name 'list (warm)' -Fixture 'samples' -Action { qbuild list 6>&1 | Out-String }))
        $results.Add((Measure-Scenario -Name 'TAB entry (warm)' -Fixture 'samples' -Action { Measure-Tab 'qbuild ' }))
        $results.Add((Measure-Scenario -Name 'TAB entry (edited)' -Fixture 'samples' -Action { Measure-Tab 'qbuild ' } -Setup $touchRoot))
        foreach ($r in Measure-NewShell -Fixture 'samples' -Directory $samplesDir -Line 'qbuild ') { $results.Add($r) }
    }

    $flatDir = Join-Path $FixtureRoot 'flat-1k'
    if ('flat-1k' -in $Fixture -and (Test-Path $flatDir)) {
        Set-Location $flatDir
        $results.Add((Measure-Scenario -Name 'list (warm)' -Fixture 'flat-1k' -Action { qbuild list 6>&1 | Out-String }))
        $results.Add((Measure-Scenario -Name 'TAB entry (warm)' -Fixture 'flat-1k' -Action { Measure-Tab 'qbuild ' }))
    }

    # Internal helpers: report-only, they may legitimately change shape between versions.
    if ('flat-1k' -in $Fixture -and (Get-Command Get-MapEntries, Get-EntryCompletion -Module ConfigMap -ErrorAction Ignore).Count -eq 2) {
        $map = [ordered]@{}
        foreach ($n in 1..1000) { $map["task$n"] = { } }
        $results.Add((Measure-Scenario -Name 'internal: Get-MapEntries' -Fixture 'in-memory-1k' -Gate $false -Action {
                    @(Get-MapEntries $map 'task500' -language build)
                }))
        $results.Add((Measure-Scenario -Name 'internal: Get-EntryCompletion' -Fixture 'in-memory-1k' -Gate $false -Action {
                    @(Get-EntryCompletion -map $map -language build -wordToComplete '')
                }))
    }
    $xlMap = Join-Path $FixtureRoot 'workspace-XL\.build.map.ps1'
    if ('workspace-XL' -in $Fixture -and (Test-Path $xlMap)) {
        $results.Add((Measure-Scenario -Name 'internal: Import-ConfigMap' -Fixture 'workspace-XL' -Gate $false -Action {
                    Import-ConfigMap -Map $xlMap -LookUp:$false
                }))
    }
}
finally {
    Set-Location $startLocation
    if ($tempFixtures) { Remove-Item $tempFixtures -Recurse -Force -ErrorAction Ignore }
}

$report = [ordered]@{
    label      = $Label
    module     = $ModulePath
    pwsh       = $PSVersionTable.PSVersion.ToString()
    os         = [System.Runtime.InteropServices.RuntimeInformation]::OSDescription
    iterations = $Iterations
    results    = $results
}

if ($profileReport) {
    $header = "# qbuild profile`n`n$ModulePath, pwsh $($PSVersionTable.PSVersion). One traced run per scenario after timing; sorted by self time.`n`n"
    $profilePath = Join-Path $ProfileDir 'profile-report.md'
    ($header + $profileReport.ToString()) | Set-Content $profilePath
    Write-Host "profile: $profilePath" -ForegroundColor DarkGray
}

if ($OutFile) {
    $report | ConvertTo-Json -Depth 5 | Set-Content $OutFile
}
else {
    $results | Format-Table -AutoSize | Out-String | Write-Host
}