Add Performance Scenarios + Pipeline Infra (#2510)

This commit is contained in:
Vinay Gera authored and GitHub committed 2026-09-14 14:08:50 -07:00
1 parent 8002bb1613
commit 5b563effc6
50 files changed
+5103 -2

No files matched your search

+3
View File
@@ -29,6 +29,9 @@ generated/
.vscode/settings.json
eng/tools/ToolDescriptionEvaluator/prompts.json
.perf-results/
BenchmarkDotNet.Artifacts/
build_output.txt
eng/tools/ToolDescriptionEvaluator/results.md
eng/tools/ToolDescriptionEvaluator/tools.json
deploy-log.txt
+6
View File
@@ -63,6 +63,7 @@
"aadsts",
"accesstoken",
"addattachment",
"addbuildtag",
"agentpool",
"aiml",
"amazonq",
@@ -83,12 +84,15 @@
"classfilters",
"cloudevents",
"codesign",
"coldstart",
"confidentialledger",
"configfile",
"creds",
"credscan",
"datatable",
"devcert",
"diagnoser",
"diagnosers",
"diagnosticservices",
"doubleslash",
"dyld",
@@ -150,7 +154,9 @@
"testdb",
"testproxy",
"testrg",
"testruns",
"testsub",
"tiktoken",
"toplevel",
"uniqueprefix",
"vectorizable",
+4
View File
@@ -124,5 +124,9 @@
<PackageVersion Include="xunit.v3.extensibility.core" Version="3.2.2" />
<PackageVersion Include="xunit.v3.mtp-v2" Version="3.2.2" />
<PackageVersion Include="coverlet.MTP" Version="10.0.1" />
<!-- Performance benchmark dependencies -->
<PackageVersion Include="BenchmarkDotNet" Version="0.14.0" />
<PackageVersion Include="Microsoft.ML.Tokenizers" Version="0.22.0" />
<PackageVersion Include="Microsoft.ML.Tokenizers.Data.O200kBase" Version="0.22.0" />
</ItemGroup>
</Project>
+3
View File
@@ -78,6 +78,9 @@
</Folder>
<Folder Name="/servers/" />
<Folder Name="/servers/Azure.Mcp.Server/" />
<Folder Name="/servers/Azure.Mcp.Server/perf/">
<Project Path="servers/Azure.Mcp.Server/perf/Azure.Mcp.Server.PerformanceBenchmarks/Azure.Mcp.Server.PerformanceBenchmarks.csproj" />
</Folder>
<Folder Name="/servers/Azure.Mcp.Server/src/">
<Project Path="servers/Azure.Mcp.Server/src/Azure.Mcp.Server.csproj" />
</Folder>
+166
View File
@@ -0,0 +1,166 @@
{
"catalog_version": "1.0.0",
"description": "Versioned, machine-readable definitions of Azure MCP Server performance workloads and the controlled-environment and statistical rules they run under (issue #3120). Bump catalog_version and record the reason whenever a workload definition changes, since a change can shift baselines.",
"environment_requirements": {
"runner": "Dedicated or sufficiently isolated runner. Baseline-quality runs must not share the host with other CI work; developer laptops are for exploration only.",
"runtime": "The .NET SDK pinned by global.json; record the exact runtime in each result's environment block.",
"build_configuration": "Release.",
"network": "Loopback only for server-overhead workloads. The measured path must exclude Azure and network time (deterministic no-op / tools/list).",
"configuration": "Server launched with the documented args per workload; no unrelated environment variables set."
},
"statistical_rules": {
"warmup": "Every scenario performs warm-up iterations that are discarded before measurement.",
"min_repetitions": 10,
"percentiles": ["p50", "p95", "p99"],
"reported_metrics": ["p50", "p95", "p99", "throughput", "cpu", "memory"],
"comparison": "Compare current vs approved baseline as ratios. Changes below 5% are ignored, changes above 5% warn, and budget breaches block. Prefer median/percentiles over the mean for latency.",
"notes": "Micro-benchmarks additionally report BenchmarkDotNet StdDev / confidence intervals; the host environment (OS, CPU, runtime) is captured in the BenchmarkDotNet report header."
},
"baseline_qualification": {
"min_successful_runs": 10,
"min_span_days": 14,
"controlled_runner_required": true,
"auto_update_after_regression": false,
"replacement_requires": ["linked issue", "measured impact", "owner approval"]
},
"workloads": [
{
"id": "startup-cli-cold",
"issue": 3118,
"harness": "eng/scripts/Test-StartupPerformance.ps1",
"transport": "cli",
"server_mode": null,
"description": "CLI cold start: 'azmcp tools list' (process spawn to exit).",
"warmup": "First run reported as cold; the remainder are warm.",
"repetitions": 10,
"metrics": ["p50", "p95", "p99", "cold_ms", "warm_median_ms"]
},
{
"id": "startup-stdio-default",
"issue": 3118,
"harness": "eng/scripts/Test-StartupPerformance.ps1",
"transport": "stdio",
"server_mode": "default",
"description": "MCP stdio startup to a usable tool list: 'server start' then initialize + tools/list (~71 tools).",
"warmup": "First run reported as cold; the remainder are warm.",
"repetitions": 10,
"metrics": ["p50", "p95", "p99", "full_schema_tokens_exact", "full_schema_serialize_ms"]
},
{
"id": "startup-stdio-namespace",
"issue": 3118,
"harness": "eng/scripts/Test-StartupPerformance.ps1",
"transport": "stdio",
"server_mode": "namespace",
"description": "MCP stdio startup with 'server start --mode namespace'.",
"warmup": "First run reported as cold; the remainder are warm.",
"repetitions": 10,
"metrics": ["p50", "p95", "p99"]
},
{
"id": "startup-stdio-all",
"issue": 3118,
"harness": "eng/scripts/Test-StartupPerformance.ps1",
"transport": "stdio",
"server_mode": "all",
"description": "MCP stdio startup with 'server start --mode all' (full tool surface, ~500+ tools).",
"warmup": "First run reported as cold; the remainder are warm.",
"repetitions": 10,
"metrics": ["p50", "p95", "p99", "full_schema_tokens_exact", "full_schema_serialize_ms"]
},
{
"id": "startup-http-default",
"issue": 3118,
"harness": "eng/scripts/Test-StartupPerformance.ps1",
"transport": "http",
"server_mode": "default",
"description": "MCP remote HTTP startup: initialize + tools/list over the HTTP transport, plus server readiness time.",
"warmup": "First run reported as cold; the remainder are warm.",
"repetitions": 10,
"metrics": ["p50", "p95", "p99", "readiness_ms"]
},
{
"id": "discovery-scaling",
"issue": 3118,
"harness": "ToolSurfaceScalingBenchmarks (BenchmarkDotNet)",
"transport": "in-process",
"server_mode": null,
"description": "tools/list serialization cost at current and projected tool counts.",
"tool_counts": [71, 250, 510, 1000],
"warmup": "BenchmarkDotNet warmup iterations.",
"repetitions": "BenchmarkDotNet default job (>=15 iterations).",
"metrics": ["mean", "allocated"]
},
{
"id": "dispatch-layers",
"issue": 3119,
"harness": "CommandDispatchBenchmarks (BenchmarkDotNet)",
"transport": "in-process",
"server_mode": null,
"description": "Per-layer command dispatch overhead (routing, parse, bind, validate, serialize, execute frame, full dispatch) on a deterministic no-op command; excludes Azure.",
"warmup": "BenchmarkDotNet warmup iterations.",
"repetitions": "BenchmarkDotNet default job (>=15 iterations).",
"metrics": ["mean", "allocated"]
},
{
"id": "dispatch-transport-stdio",
"issue": 3119,
"harness": "eng/scripts/Measure-DispatchOverhead.ps1",
"transport": "stdio",
"server_mode": "default",
"description": "tools/list request overhead over stdio.",
"warmup": "PERF_DISPATCH_WARMUP calls (default 20), not counted.",
"repetitions": 200,
"metrics": ["p50", "p95", "p99"]
},
{
"id": "dispatch-transport-http",
"issue": 3119,
"harness": "eng/scripts/Measure-DispatchOverhead.ps1",
"transport": "http",
"server_mode": "default",
"description": "tools/list request overhead over the HTTP transport; the http-minus-stdio p50 delta isolates transport cost.",
"warmup": "PERF_DISPATCH_WARMUP calls (default 20), not counted.",
"repetitions": 200,
"metrics": ["p50", "p95", "p99", "transport_overhead_tools_list_p50"]
},
{
"id": "resource-default",
"issue": 3121,
"harness": "eng/scripts/Measure-ResourceUsage.ps1",
"transport": "http",
"server_mode": "default",
"description": "CPU/memory/handle sampling across idle, startup, discovery, steady-state, and soak phases for the default tool surface.",
"phases": ["startup", "idle", "discovery", "steady_state", "soak"],
"concurrency": 4,
"warmup": "Soak analysis discards the first 25% of samples.",
"repetitions": "Continuous 100ms sampling per phase.",
"metrics": ["working_set_mb", "private_mb", "handles", "cpu_percent", "soak_slope"]
},
{
"id": "resource-all",
"issue": 3121,
"harness": "eng/scripts/Measure-ResourceUsage.ps1",
"transport": "http",
"server_mode": "all",
"description": "CPU/memory/handle sampling for the full tool surface.",
"phases": ["startup", "idle", "discovery", "steady_state", "soak"],
"concurrency": 4,
"warmup": "Soak analysis discards the first 25% of samples.",
"repetitions": "Continuous 100ms sampling per phase.",
"metrics": ["working_set_mb", "private_mb", "handles", "cpu_percent", "soak_slope"]
},
{
"id": "concurrency-sweep",
"issue": 3122,
"harness": "eng/scripts/Measure-ConcurrencyScaling.ps1",
"transport": "http",
"server_mode": "default",
"description": "Concurrency sweep over parallel MCP clients issuing tools/list; produces a saturation curve.",
"concurrency_levels": [1, 2, 4, 8, 16],
"warmup": "PERF_CONC_WARMUP_SECONDS per level (default 1s), not counted.",
"repetitions": "PERF_CONC_DURATION_SECONDS timed window per level (default 5s).",
"metrics": ["throughput_rps", "p50", "p95", "p99", "error_rate_pct", "scaling_efficiency", "tool_count_mismatches"]
}
]
}
+27
View File
@@ -33,6 +33,9 @@ parameters:
- name: BuildTimeoutInMinutes
type: number
default: 90
- name: RunPerfTests
type: boolean
default: false
- name: IsTestPipeline
type: boolean
default: false
@@ -119,6 +122,30 @@ extends:
jobs:
- template: /eng/pipelines/templates/jobs/vsix/pack-vsix-pr.yml
- ${{ if and(eq(variables['System.TeamProject'], 'internal'), eq(parameters.RunPerfTests, 'true')) }}:
- stage: Perf
displayName: 'Performance test'
dependsOn:
- Build
pool:
name: $(WINDOWSPOOL)
image: $(WINDOWSVMIMAGE)
os: windows
variables:
- template: /eng/pipelines/templates/variables/image.yml
- template: /eng/pipelines/templates/variables/globals.yml
jobs:
- template: /eng/pipelines/templates/jobs/perf-test.yml
parameters:
Runs: 10
# Uncomment to gate on regressions once a baseline is committed:
# BaselinePath: eng/perf-baseline.json
# ResourceBaselinePath: eng/resource-baseline.json
# DispatchBaselinePath: eng/dispatch-baseline.json
# ConcurrencyBaselinePath: eng/concurrency-baseline.json
# Uncomment to run the (~30 min) BenchmarkDotNet micro-benchmarks:
# RunMicroBenchmarks: true
- ${{ if and(eq(variables['System.TeamProject'], 'internal'), eq(parameters.RunLiveTests, 'true')) }}:
- stage: Test
displayName: 'Live test'
+187
View File
@@ -0,0 +1,187 @@
parameters:
- name: Runs
type: number
default: 10
- name: BaselinePath
type: string
default: ''
# Path to a JSON baseline file. When set, a second run gates on the tiered
# budgets from issue #3118 (p50/p95 +10%, p99 +20%, scaling efficiency +15%).
- name: ResourceConcurrency
type: number
default: 4
- name: ResourceSoakSeconds
type: number
default: 45
- name: ResourceBaselinePath
type: string
default: ''
# Path to a JSON resource baseline. When set, gates on the resource budgets
# from issue #3121 (steady CPU/memory +10%, peak memory +15%, no soak leak).
- name: DispatchBaselinePath
type: string
default: ''
# Path to a JSON dispatch baseline. When set, gates on the dispatch budgets
# from issue #3119 (p50/p95 +10%, p99 +20%, transport change >5% reported).
- name: ConcurrencyBaselinePath
type: string
default: ''
# Path to a JSON concurrency baseline. When set, gates on the concurrency budgets
# from issue #3122 (throughput -10%, p95/scaling -15%, p99 -20%, error +0.5pp).
- name: RunMicroBenchmarks
type: boolean
default: false
# The BenchmarkDotNet micro-benchmarks are opt-in: the generated boilerplate
# references the whole server (60+ tool assemblies) and the full suite takes
# ~30 minutes, so enable this only for dedicated/nightly perf runs.
- name: MicroBenchmarkFilter
type: string
default: '*'
# Glob passed to BenchmarkDotNet --filter (e.g. '*ServerStartup*' to scope a run).
jobs:
- job: PerfTest
displayName: "Performance test"
timeoutInMinutes: 60
pool:
name: $(WINDOWSPOOL)
image: $(WINDOWSVMIMAGE)
os: windows
templateContext:
outputs:
- output: pipelineArtifact
condition: always()
targetPath: $(Build.ArtifactStagingDirectory)/perf
artifactName: perf-results
steps:
- checkout: self
- task: UseDotNet@2
displayName: "Use .NET SDK from global.json"
retryCountOnTaskFailure: 3
inputs:
useGlobalJson: true
- task: Powershell@2
displayName: "Build benchmark tool (Release)"
inputs:
pwsh: true
filePath: $(Build.SourcesDirectory)/eng/scripts/Build-PerfBenchmarks.ps1
- task: Powershell@2
displayName: "Run performance measurements"
inputs:
pwsh: true
filePath: $(Build.SourcesDirectory)/eng/scripts/Test-StartupPerformance.ps1
arguments: >
-Executable '$(Build.SourcesDirectory)/servers/Azure.Mcp.Server/src/bin/Release/net10.0/azmcp.exe'
-Runs ${{ parameters.Runs }}
-OutputPath '$(Build.ArtifactStagingDirectory)/perf/startup-e2e.json'
# Regression gate — only active when BaselinePath is provided.
# Reads the already-written results JSON; does NOT re-run the benchmarks.
- ${{ if ne(parameters.BaselinePath, '') }}:
- task: Powershell@2
displayName: "Check regression against baseline"
inputs:
pwsh: true
filePath: $(Build.SourcesDirectory)/eng/scripts/Check-StartupPerformanceRegression.ps1
arguments: >
-ResultsPath '$(Build.ArtifactStagingDirectory)/perf/startup-e2e.json'
-BaselinePath '${{ parameters.BaselinePath }}'
- task: Powershell@2
displayName: "Run resource-usage measurements"
inputs:
pwsh: true
filePath: $(Build.SourcesDirectory)/eng/scripts/Measure-ResourceUsage.ps1
arguments: >
-Executable '$(Build.SourcesDirectory)/servers/Azure.Mcp.Server/src/bin/Release/net10.0/azmcp.exe'
-BenchmarkExe '$(Build.SourcesDirectory)/servers/Azure.Mcp.Server/perf/Azure.Mcp.Server.PerformanceBenchmarks/bin/Release/net10.0/Azure.Mcp.Server.PerformanceBenchmarks.exe'
-Modes default,all
-Concurrency ${{ parameters.ResourceConcurrency }}
-SoakSeconds ${{ parameters.ResourceSoakSeconds }}
-OutputPath '$(Build.ArtifactStagingDirectory)/perf/resource-usage.json'
# Resource regression gate — only active when ResourceBaselinePath is provided.
- ${{ if ne(parameters.ResourceBaselinePath, '') }}:
- task: Powershell@2
displayName: "Check resource regression against baseline"
inputs:
pwsh: true
filePath: $(Build.SourcesDirectory)/eng/scripts/Check-ResourceUsageRegression.ps1
arguments: >
-ResultsPath '$(Build.ArtifactStagingDirectory)/perf/resource-usage.json'
-BaselinePath '${{ parameters.ResourceBaselinePath }}'
- task: Powershell@2
displayName: "Run dispatch-overhead measurements"
inputs:
pwsh: true
filePath: $(Build.SourcesDirectory)/eng/scripts/Measure-DispatchOverhead.ps1
arguments: >
-Executable '$(Build.SourcesDirectory)/servers/Azure.Mcp.Server/src/bin/Release/net10.0/azmcp.exe'
-BenchmarkExe '$(Build.SourcesDirectory)/servers/Azure.Mcp.Server/perf/Azure.Mcp.Server.PerformanceBenchmarks/bin/Release/net10.0/Azure.Mcp.Server.PerformanceBenchmarks.exe'
-OutputPath '$(Build.ArtifactStagingDirectory)/perf/dispatch-overhead.json'
# Dispatch regression gate — only active when DispatchBaselinePath is provided.
- ${{ if ne(parameters.DispatchBaselinePath, '') }}:
- task: Powershell@2
displayName: "Check dispatch regression against baseline"
inputs:
pwsh: true
filePath: $(Build.SourcesDirectory)/eng/scripts/Check-DispatchOverheadRegression.ps1
arguments: >
-ResultsPath '$(Build.ArtifactStagingDirectory)/perf/dispatch-overhead.json'
-BaselinePath '${{ parameters.DispatchBaselinePath }}'
- task: Powershell@2
displayName: "Run concurrency-scaling measurements"
inputs:
pwsh: true
filePath: $(Build.SourcesDirectory)/eng/scripts/Measure-ConcurrencyScaling.ps1
arguments: >
-Executable '$(Build.SourcesDirectory)/servers/Azure.Mcp.Server/src/bin/Release/net10.0/azmcp.exe'
-BenchmarkExe '$(Build.SourcesDirectory)/servers/Azure.Mcp.Server/perf/Azure.Mcp.Server.PerformanceBenchmarks/bin/Release/net10.0/Azure.Mcp.Server.PerformanceBenchmarks.exe'
-Levels 1,2,4,8,16
-DurationSeconds 10
-OutputPath '$(Build.ArtifactStagingDirectory)/perf/concurrency-scaling.json'
# Concurrency regression gate — only active when ConcurrencyBaselinePath is provided.
- ${{ if ne(parameters.ConcurrencyBaselinePath, '') }}:
- task: Powershell@2
displayName: "Check concurrency regression against baseline"
inputs:
pwsh: true
filePath: $(Build.SourcesDirectory)/eng/scripts/Check-ConcurrencyScalingRegression.ps1
arguments: >
-ResultsPath '$(Build.ArtifactStagingDirectory)/perf/concurrency-scaling.json'
-BaselinePath '${{ parameters.ConcurrencyBaselinePath }}'
# Runs the BenchmarkDotNet micro-benchmarks (per-phase startup allocations and
# tool-catalog serialization scaling). Opt-in via RunMicroBenchmarks because the
# generated boilerplate references the whole server (60+ tool assemblies) and the
# full suite takes ~30 minutes. --buildTimeout is raised because that boilerplate
# build exceeds BenchmarkDotNet's default 2-minute timeout. Each benchmark class
# carries its own job config, so no --job override is passed.
- ${{ if eq(parameters.RunMicroBenchmarks, true) }}:
- task: Powershell@2
displayName: "Run micro-benchmarks (BenchmarkDotNet)"
inputs:
pwsh: true
filePath: $(Build.SourcesDirectory)/eng/scripts/Invoke-PerfMicroBenchmarks.ps1
arguments: >
-BenchmarkExe '$(Build.SourcesDirectory)/servers/Azure.Mcp.Server/perf/Azure.Mcp.Server.PerformanceBenchmarks/bin/Release/net10.0/Azure.Mcp.Server.PerformanceBenchmarks.exe'
-ArtifactsPath '$(Build.ArtifactStagingDirectory)/perf/benchmark-dotnet'
-Filter '${{ parameters.MicroBenchmarkFilter }}'
- task: Powershell@2
displayName: "Publish metrics to Azure Monitor (optional)"
condition: and(succeeded(), eq(variables['System.TeamProject'], 'internal'))
continueOnError: true
inputs:
pwsh: true
filePath: $(Build.SourcesDirectory)/eng/scripts/Publish-StartupPerformanceMetrics.ps1
arguments: >
-ResultsPath '$(Build.ArtifactStagingDirectory)/perf/startup-e2e.json'
+27
View File
@@ -0,0 +1,27 @@
#!/bin/env pwsh
#Requires -Version 7
<#
.SYNOPSIS
Builds the Azure MCP Server performance-benchmark project in Release for the perf pipeline.
.DESCRIPTION
Kept as a script (rather than inline pipeline YAML) so CI behavior can be reproduced locally.
NU1902 (the NuGet audit warning) is already non-fatal via WarningsNotAsErrors in
Directory.Build.props. A command-line -p:NoWarn is deliberately NOT passed here: a global
NoWarn property overrides every project's own <NoWarn> (for example the Insights project's
MCP9005 sampling-deprecation suppression), which breaks a clean build.
.PARAMETER Project
Path to the performance-benchmark csproj to build.
#>
param(
[string] $Project = 'servers/Azure.Mcp.Server/perf/Azure.Mcp.Server.PerformanceBenchmarks/Azure.Mcp.Server.PerformanceBenchmarks.csproj'
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
dotnet build $Project -c Release -consoleLoggerParameters:NoSummary
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
@@ -0,0 +1,59 @@
#!/bin/env pwsh
#Requires -Version 7
<#
.SYNOPSIS
Gates the concurrency / throughput / scalability results against a saved baseline (#3122).
.DESCRIPTION
Reads the results JSON produced by Measure-ConcurrencyScaling.ps1 and a baseline JSON of the
same shape, then applies the tiered budgets from issue #3122 per concurrency level:
* throughput may not decrease by more than ThroughputDropPct (default 10%)
* concurrency scaling efficiency may not degrade by more than ScalingDropPct (default 15%)
* p95 latency may not degrade by more than LatencyP95RisePct (default 15%)
* p99 latency may not regress by more than LatencyP99RisePct (default 20%)
* timeout/server-error rate may not increase by more than ErrorRatePpLimit (default 0.5 points)
* any concurrent tool-count mismatch is a hard failure (state leakage / thread safety)
Fails (throws) if any budget is exceeded. Does not re-run any measurements.
.PARAMETER ResultsPath
Path to the results JSON written by Measure-ConcurrencyScaling.ps1.
.PARAMETER BaselinePath
Path to the baseline JSON to compare against.
#>
param(
[Parameter(Mandatory)][string] $ResultsPath,
[Parameter(Mandatory)][string] $BaselinePath,
[double] $ThroughputDropPct = 0.10,
[double] $ScalingDropPct = 0.15,
[double] $LatencyP95RisePct = 0.15,
[double] $LatencyP99RisePct = 0.20,
[double] $ErrorRatePpLimit = 0.5,
[double] $WarnPct = 0.05
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'ConcurrencyPerformance.Common.ps1')
$results = Get-Content $ResultsPath | ConvertFrom-Json
$baseline = Get-Content $BaselinePath | ConvertFrom-Json
$gate = Invoke-ConcurrencyRegressionGate -ResultLevels $results.levels -BaselineLevels $baseline.levels `
-ThroughputDropPct $ThroughputDropPct -ScalingDropPct $ScalingDropPct `
-LatencyP95RisePct $LatencyP95RisePct -LatencyP99RisePct $LatencyP99RisePct `
-ErrorRatePpLimit $ErrorRatePpLimit -WarnPct $WarnPct
if ($gate.Warnings.Count -gt 0) {
Write-Warning "Concurrency warnings: $($gate.Warnings -join ', ')"
}
if ($gate.Failures.Count -gt 0) {
Write-Error "Concurrency regression detected in: $($gate.Failures -join ', ')"
}
else {
Write-Host " All concurrency budget checks passed."
}
@@ -0,0 +1,61 @@
#!/bin/env pwsh
#Requires -Version 7
<#
.SYNOPSIS
Gates the command-dispatch / transport overhead results against a saved baseline (#3119).
.DESCRIPTION
Reads the results JSON produced by Measure-DispatchOverhead.ps1 and a baseline JSON of the
same shape, then applies the tiered budgets from issue #3119:
* p50 and p95 of each transport/operation may not regress by more than FailP50P95 (+10%)
* p99 may not regress by more than FailP99 (+20%)
* any change above Warn (+5%), including transport overhead, is reported as a warning
Fails (throws) if any budget is exceeded. Does not re-run any measurements.
.PARAMETER ResultsPath
Path to the results JSON written by Measure-DispatchOverhead.ps1.
.PARAMETER BaselinePath
Path to the baseline JSON to compare against.
.PARAMETER FailP50P95
Maximum allowed p50/p95 regression ratio (default 1.10 = +10%).
.PARAMETER FailP99
Maximum allowed p99 regression ratio (default 1.20 = +20%).
.PARAMETER Warn
Ratio above which a change is reported as a warning (default 1.05 = +5%).
#>
param(
[Parameter(Mandatory)][string] $ResultsPath,
[Parameter(Mandatory)][string] $BaselinePath,
[double] $FailP50P95 = 1.10,
[double] $FailP99 = 1.20,
[double] $Warn = 1.05
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'DispatchPerformance.Common.ps1')
$results = Get-Content $ResultsPath | ConvertFrom-Json
$baseline = Get-Content $BaselinePath | ConvertFrom-Json
$gate = Invoke-DispatchRegressionGate -ResultScenarios $results.scenarios `
-BaselineScenarios $baseline.scenarios `
-FailP50P95 $FailP50P95 -FailP99 $FailP99 -Warn $Warn
if ($gate.Warnings.Count -gt 0) {
Write-Warning "Dispatch warnings (>$([math]::Round(($Warn - 1) * 100))%): $($gate.Warnings -join ', ')"
}
if ($gate.Failures.Count -gt 0) {
Write-Error "Dispatch regression detected in: $($gate.Failures -join ', ')"
}
else {
Write-Host " All dispatch budget checks passed."
}
@@ -0,0 +1,62 @@
#!/bin/env pwsh
#Requires -Version 7
<#
.SYNOPSIS
Gates the resource-usage results against a saved baseline (issue #3121).
.DESCRIPTION
Reads the results JSON produced by Measure-ResourceUsage.ps1 and a baseline JSON of the
same shape, then applies the tiered resource budgets from issue #3121:
* steady-state CPU and memory may not regress by more than FailSteady (default +10%)
* peak memory may not regress by more than FailPeak (default +15%)
* a soak phase flagged with unbounded growth is a hard failure (memory/handle leak)
* any change above Warn (default +5%) is reported as a warning
Fails (throws) if any budget is exceeded. Does not re-run any measurements.
.PARAMETER ResultsPath
Path to the results JSON written by Measure-ResourceUsage.ps1.
.PARAMETER BaselinePath
Path to the baseline JSON to compare against.
.PARAMETER FailSteady
Maximum allowed steady-state CPU/memory regression ratio (default 1.10 = +10%).
.PARAMETER FailPeak
Maximum allowed peak-memory regression ratio (default 1.15 = +15%).
.PARAMETER Warn
Ratio above which a change is reported as a warning (default 1.05 = +5%).
#>
param(
[Parameter(Mandatory)][string] $ResultsPath,
[Parameter(Mandatory)][string] $BaselinePath,
[double] $FailSteady = 1.10,
[double] $FailPeak = 1.15,
[double] $Warn = 1.05
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'ResourcePerformance.Common.ps1')
$results = Get-Content $ResultsPath | ConvertFrom-Json
$baseline = Get-Content $BaselinePath | ConvertFrom-Json
$gate = Invoke-ResourceRegressionGate -ResultScenarios $results.scenarios `
-BaselineScenarios $baseline.scenarios `
-FailSteady $FailSteady -FailPeak $FailPeak -Warn $Warn
if ($gate.Warnings.Count -gt 0) {
Write-Warning "Resource warnings (>$([math]::Round(($Warn - 1) * 100))%): $($gate.Warnings -join ', ')"
}
if ($gate.Failures.Count -gt 0) {
Write-Error "Resource regression detected in: $($gate.Failures -join ', ')"
}
else {
Write-Host " All resource budget checks passed."
}
@@ -0,0 +1,67 @@
#!/bin/env pwsh
#Requires -Version 7
<#
.SYNOPSIS
Gates the startup performance results against a saved baseline.
.DESCRIPTION
Reads the results JSON produced by Test-StartupPerformance.ps1 and a baseline JSON
of the same shape, then applies the tiered budgets from issue #3118:
* p50 and p95 may not regress by more than FailP50P95 (default +10%)
* p99 may not regress by more than FailP99 (default +20%)
* discovery scaling efficiency may not degrade by more than ScalingDegrade (+15%)
* any change above Warn (default +5%) is reported as a warning
Fails (throws) if any budget is exceeded. Does not re-run any benchmarks.
.PARAMETER ResultsPath
Path to the results JSON written by Test-StartupPerformance.ps1.
.PARAMETER BaselinePath
Path to the baseline JSON to compare against.
.PARAMETER FailP50P95
Maximum allowed p50/p95 regression ratio (default 1.10 = +10%).
.PARAMETER FailP99
Maximum allowed p99 regression ratio (default 1.20 = +20%).
.PARAMETER ScalingDegrade
Maximum allowed discovery-scaling-efficiency degradation ratio (default 1.15 = +15%).
.PARAMETER Warn
Ratio above which a change is reported as a warning (default 1.05 = +5%).
#>
param(
[Parameter(Mandatory)][string] $ResultsPath,
[Parameter(Mandatory)][string] $BaselinePath,
[double] $FailP50P95 = 1.10,
[double] $FailP99 = 1.20,
[double] $ScalingDegrade = 1.15,
[double] $Warn = 1.05
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'StartupPerformance.Common.ps1')
$results = Get-Content $ResultsPath | ConvertFrom-Json
$baseline = Get-Content $BaselinePath | ConvertFrom-Json
$gate = Invoke-StartupRegressionGate -ResultScenarios $results.scenarios `
-BaselineScenarios $baseline.scenarios `
-FailP50P95 $FailP50P95 -FailP99 $FailP99 `
-ScalingDegrade $ScalingDegrade -Warn $Warn
if ($gate.Warnings.Count -gt 0) {
Write-Warning "Performance warnings (>$([math]::Round(($Warn - 1) * 100))%): $($gate.Warnings -join ', ')"
}
if ($gate.Failures.Count -gt 0) {
Write-Error "Regression detected in: $($gate.Failures -join ', ')"
}
else {
Write-Host " All budget checks passed."
}
@@ -0,0 +1,149 @@
# Install-Module -Name Pester -Force -SkipPublisherCheck
# Invoke-Pester -Path eng/scripts/ConcurrencyPerformance.Common.Tests.ps1
#
# Focused tests for the pure concurrency-gate helpers used by Measure-ConcurrencyScaling.ps1.
BeforeAll {
. $PSScriptRoot/ConcurrencyPerformance.Common.ps1
function script:New-Level {
param($Concurrency, $Throughput, $Scaling, $P95, $P99, $ErrorRate = 0, $Mismatches = 0)
return [ordered]@{
concurrency = $Concurrency
throughput_rps = $Throughput
scaling_efficiency = $Scaling
error_rate_pct = $ErrorRate
tool_count_mismatches = $Mismatches
latency_ms = [ordered]@{ p50 = ($P95 * 0.6); p95 = $P95; p99 = $P99 }
}
}
function script:New-UniformLevels {
return @(
(New-Level -Concurrency 1 -Throughput 100 -Scaling 1.0 -P95 5 -P99 7),
(New-Level -Concurrency 4 -Throughput 350 -Scaling 0.9 -P95 8 -P99 12)
)
}
}
Describe "Test-ConcurrencyBudget" {
It "reports the fraction dropped for a higher-is-better metric" {
$r = Test-ConcurrencyBudget -Current 85 -Baseline 100 -FailFraction 0.10 -WarnFraction 0.05 -Direction 'higher'
$r.Status | Should -Be 'FAIL'
[math]::Round($r.Change, 2) | Should -Be 0.15
}
It "reports the fraction risen for a lower-is-better metric" {
# +20% rise is at the fail boundary (not > 0.20) but above the 5% warn threshold.
$r = Test-ConcurrencyBudget -Current 12 -Baseline 10 -FailFraction 0.20 -WarnFraction 0.05 -Direction 'lower'
$r.Status | Should -Be 'WARN'
[math]::Round($r.Change, 2) | Should -Be 0.20
}
It "skips when the baseline is not positive" {
(Test-ConcurrencyBudget -Current 5 -Baseline 0 -FailFraction 0.1 -WarnFraction 0.05 -Direction 'higher').Status | Should -Be 'SKIP'
}
}
Describe "Invoke-ConcurrencyRegressionGate" {
It "reports no failures or warnings for an identical baseline" {
$gate = Invoke-ConcurrencyRegressionGate -ResultLevels (New-UniformLevels) -BaselineLevels (New-UniformLevels)
@($gate.Failures).Count | Should -Be 0
@($gate.Warnings).Count | Should -Be 0
}
It "fails when throughput drops more than 10%" {
$s = New-UniformLevels
$b = New-UniformLevels
$s[1].throughput_rps = $b[1].throughput_rps * 0.85
$gate = Invoke-ConcurrencyRegressionGate -ResultLevels $s -BaselineLevels $b
$gate.Failures | Should -Contain 'c4 throughput_rps'
}
It "fails when scaling efficiency degrades more than 15%" {
$s = New-UniformLevels
$b = New-UniformLevels
$s[1].scaling_efficiency = $b[1].scaling_efficiency * 0.80
$gate = Invoke-ConcurrencyRegressionGate -ResultLevels $s -BaselineLevels $b
$gate.Failures | Should -Contain 'c4 scaling_efficiency'
}
It "fails when p95 latency degrades more than 15%" {
$s = New-UniformLevels
$b = New-UniformLevels
$s[1].latency_ms.p95 = $b[1].latency_ms.p95 * 1.20
$gate = Invoke-ConcurrencyRegressionGate -ResultLevels $s -BaselineLevels $b
$gate.Failures | Should -Contain 'c4 latency_p95'
}
It "fails when p99 latency regresses more than 20%" {
$s = New-UniformLevels
$b = New-UniformLevels
$s[1].latency_ms.p99 = $b[1].latency_ms.p99 * 1.25
$gate = Invoke-ConcurrencyRegressionGate -ResultLevels $s -BaselineLevels $b
$gate.Failures | Should -Contain 'c4 latency_p99'
}
It "fails when the error rate increases more than 0.5 percentage points" {
$s = New-UniformLevels
$b = New-UniformLevels
$s[1].error_rate_pct = $b[1].error_rate_pct + 0.6
$gate = Invoke-ConcurrencyRegressionGate -ResultLevels $s -BaselineLevels $b
$gate.Failures | Should -Contain 'c4 error_rate_pct'
}
It "fails on any concurrent tool-count mismatch (state leakage)" {
$s = New-UniformLevels
$b = New-UniformLevels
$s[1].tool_count_mismatches = 3
$gate = Invoke-ConcurrencyRegressionGate -ResultLevels $s -BaselineLevels $b
$gate.Failures | Should -Contain 'c4 state_leakage'
}
It "warns but does not fail for a throughput drop between 5% and 10%" {
$s = New-UniformLevels
$b = New-UniformLevels
$s[1].throughput_rps = $b[1].throughput_rps * 0.93
$gate = Invoke-ConcurrencyRegressionGate -ResultLevels $s -BaselineLevels $b
@($gate.Failures).Count | Should -Be 0
$gate.Warnings | Should -Contain 'c4 throughput_rps'
}
It "skips a concurrency level absent from the baseline" {
$s = New-UniformLevels
$b = @($s[0]) # only concurrency 1
$gate = Invoke-ConcurrencyRegressionGate -ResultLevels $s -BaselineLevels $b
@($gate.Failures).Count | Should -Be 0
}
It "flags a state-leakage mismatch even when the level is absent from the baseline (F-008)" {
$s = New-UniformLevels
$s += (New-Level -Concurrency 16 -Throughput 900 -Scaling 0.7 -P95 20 -P99 30 -Mismatches 5)
$b = New-UniformLevels # no concurrency 16
$gate = Invoke-ConcurrencyRegressionGate -ResultLevels $s -BaselineLevels $b
$gate.Failures | Should -Contain 'c16 state_leakage'
}
It "fails when no level overlaps the baseline (F-007)" {
$s = @(New-Level -Concurrency 32 -Throughput 100 -Scaling 1.0 -P95 5 -P99 7)
$b = New-UniformLevels # concurrency 1 and 4 only
$gate = Invoke-ConcurrencyRegressionGate -ResultLevels $s -BaselineLevels $b
$gate.Failures | Should -Contain 'no_comparable_metrics'
}
It "fails when a latency metric the baseline expects is missing from results (F-007)" {
$s = New-UniformLevels
$b = New-UniformLevels
$s[1].latency_ms.Remove('p95') # results dropped p95 at concurrency 4
$gate = Invoke-ConcurrencyRegressionGate -ResultLevels $s -BaselineLevels $b
$gate.Failures | Should -Contain 'c4 latency_p95 (missing in results)'
}
It "fails when the error-rate metric the baseline expects is missing from results (F-007)" {
$s = New-UniformLevels
$b = New-UniformLevels
$s[1].Remove('error_rate_pct') # results dropped error_rate_pct at concurrency 4
$gate = Invoke-ConcurrencyRegressionGate -ResultLevels $s -BaselineLevels $b
$gate.Failures | Should -Contain 'c4 error_rate_pct (missing in results)'
}
}
@@ -0,0 +1,167 @@
#!/bin/env pwsh
#Requires -Version 7
<#
.SYNOPSIS
Side-effect-free helpers for the concurrency / throughput / scalability gate (issue #3122).
.DESCRIPTION
Dot-source this file to reuse the pure functions in Measure-ConcurrencyScaling.ps1 and
Check-ConcurrencyScalingRegression.ps1 and to unit-test them without launching a server.
Reuses Get-MemberValue from StartupPerformance.Common.ps1.
#>
. (Join-Path $PSScriptRoot 'StartupPerformance.Common.ps1')
# ---------------------------------------------------------------------------
# Evaluates one budget. Direction 'higher' means bigger is better (throughput,
# scaling efficiency) and reports the fraction dropped; 'lower' means smaller is
# better (latency) and reports the fraction risen. Returns a status + change.
# ---------------------------------------------------------------------------
function Test-ConcurrencyBudget {
[CmdletBinding()]
param(
[double] $Current,
[double] $Baseline,
[double] $FailFraction,
[double] $WarnFraction,
[ValidateSet('higher', 'lower')] [string] $Direction
)
if ($Baseline -le 0) {
return @{ Status = 'SKIP'; Change = 0 }
}
$change = if ($Direction -eq 'higher') { 1 - ($Current / $Baseline) } else { ($Current / $Baseline) - 1 }
$status = if ($change -gt $FailFraction) { 'FAIL' } elseif ($change -gt $WarnFraction) { 'WARN' } else { 'PASS' }
return @{ Status = $status; Change = $change }
}
# ---------------------------------------------------------------------------
# Tiered concurrency-regression gate shared by Measure-ConcurrencyScaling.ps1 and
# Check-ConcurrencyScalingRegression.ps1. For each concurrency level present in both
# results and baseline it applies the acceptance-criteria budgets from issue #3122:
# * throughput may not decrease by more than ThroughputDropPct (default 10%)
# * concurrency scaling efficiency may not degrade by more than ScalingDropPct (default 15%)
# * p95 latency may not degrade by more than LatencyP95RisePct (default 15%)
# * p99 latency may not regress by more than LatencyP99RisePct (default 20%)
# * timeout/server-error rate may not increase by more than ErrorRatePpLimit (default 0.5 points)
# * any concurrent tool-count mismatch is a hard failure (state leakage / thread safety)
# * any change above WarnPct (default 5%) is reported as a warning
#
# Returns [ordered]@{ Failures = @(...); Warnings = @(...) } and writes a line per check.
# ---------------------------------------------------------------------------
function Invoke-ConcurrencyRegressionGate {
[CmdletBinding()]
param(
[Parameter(Mandatory)] $ResultLevels,
[Parameter(Mandatory)] $BaselineLevels,
[double] $ThroughputDropPct = 0.10,
[double] $ScalingDropPct = 0.15,
[double] $LatencyP95RisePct = 0.15,
[double] $LatencyP99RisePct = 0.20,
[double] $ErrorRatePpLimit = 0.5,
[double] $WarnPct = 0.05
)
$failures = @()
$warnings = @()
$evaluated = 0
$baselineByConcurrency = @{}
foreach ($bl in $BaselineLevels) {
$baselineByConcurrency[[string](Get-MemberValue $bl 'concurrency')] = $bl
}
$seenConcurrency = @{}
foreach ($level in $ResultLevels) {
$concurrency = Get-MemberValue $level 'concurrency'
$seenConcurrency[[string]$concurrency] = $true
# State leakage / thread safety — a hard failure regardless of the baseline, so it is
# evaluated before the baseline-presence check below can skip this level.
$mismatches = Get-MemberValue $level 'tool_count_mismatches'
if ($mismatches -and $mismatches -gt 0) {
$failures += "c$concurrency state_leakage"
Write-Host (" [FAIL] c{0} tool_count_mismatches={1} (state leakage / thread-safety failure)" -f $concurrency, $mismatches)
}
$baseline = $baselineByConcurrency[[string]$concurrency]
if ($null -eq $baseline) {
Write-Host (" [SKIP] concurrency={0}: absent in baseline (relative budgets)" -f $concurrency)
continue
}
$curLatency = Get-MemberValue $level 'latency_ms'
$baseLatency = Get-MemberValue $baseline 'latency_ms'
$checks = @(
@{ Label = 'throughput_rps'; Cur = (Get-MemberValue $level 'throughput_rps'); Base = (Get-MemberValue $baseline 'throughput_rps'); Fail = $ThroughputDropPct; Dir = 'higher'; Unit = ' rps' }
@{ Label = 'scaling_efficiency'; Cur = (Get-MemberValue $level 'scaling_efficiency'); Base = (Get-MemberValue $baseline 'scaling_efficiency'); Fail = $ScalingDropPct; Dir = 'higher'; Unit = '' }
@{ Label = 'latency_p95'; Cur = (Get-MemberValue $curLatency 'p95'); Base = (Get-MemberValue $baseLatency 'p95'); Fail = $LatencyP95RisePct; Dir = 'lower'; Unit = ' ms' }
@{ Label = 'latency_p99'; Cur = (Get-MemberValue $curLatency 'p99'); Base = (Get-MemberValue $baseLatency 'p99'); Fail = $LatencyP99RisePct; Dir = 'lower'; Unit = ' ms' }
)
foreach ($check in $checks) {
if ($null -eq $check.Base) {
continue
}
if ($null -eq $check.Cur) {
$failures += "c$concurrency $($check.Label) (missing in results)"
Write-Host (" [FAIL] c{0} {1}: expected by baseline but absent in results" -f $concurrency, $check.Label)
continue
}
$evaluated++
$result = Test-ConcurrencyBudget -Current $check.Cur -Baseline $check.Base `
-FailFraction $check.Fail -WarnFraction $WarnPct -Direction $check.Dir
$label = "c$concurrency $($check.Label)"
switch ($result.Status) {
'FAIL' { $failures += $label }
'WARN' { $warnings += $label }
}
Write-Host (" [{0}] {1}: current={2}{3} baseline={4}{3} ({5}{6}%)" -f `
$result.Status, $label, $check.Cur, $check.Unit, $check.Base, `
$(if ($result.Change -ge 0) { '+' } else { '' }), [math]::Round($result.Change * 100, 1))
}
# Error/timeout rate — an absolute percentage-point budget.
$curError = Get-MemberValue $level 'error_rate_pct'
$baseError = Get-MemberValue $baseline 'error_rate_pct'
if ($null -ne $baseError) {
$label = "c$concurrency error_rate_pct"
if ($null -eq $curError) {
$failures += "$label (missing in results)"
Write-Host (" [FAIL] {0}: expected by baseline but absent in results" -f $label)
}
else {
$evaluated++
$delta = $curError - $baseError
if ($delta -gt $ErrorRatePpLimit) {
$failures += $label
$status = 'FAIL'
}
else {
$status = 'PASS'
}
Write-Host (" [{0}] {1}: current={2}% baseline={3}% (+{4} pp)" -f `
$status, $label, $curError, $baseError, [math]::Round($delta, 3))
}
}
}
# Baseline levels the results no longer cover are treated as an expected-but-missing metric.
foreach ($blKey in $baselineByConcurrency.Keys) {
if (-not $seenConcurrency.ContainsKey($blKey)) {
$failures += "c$blKey missing_in_results"
Write-Host (" [FAIL] concurrency={0}: expected by baseline but absent in results" -f $blKey)
}
}
if ($evaluated -eq 0) {
$failures += 'no_comparable_metrics'
Write-Host " [FAIL] no comparable concurrency metrics between results and baseline (incompatible or empty baseline)"
}
return [ordered]@{ Failures = $failures; Warnings = $warnings }
}
@@ -0,0 +1,115 @@
# Install-Module -Name Pester -Force -SkipPublisherCheck
# Invoke-Pester -Path eng/scripts/DispatchPerformance.Common.Tests.ps1
#
# Focused tests for the pure dispatch-gate helpers used by Measure-DispatchOverhead.ps1.
BeforeAll {
. $PSScriptRoot/DispatchPerformance.Common.ps1
function script:New-Stats {
param($P50, $P95, $P99)
return [ordered]@{ p50 = $P50; p95 = $P95; p99 = $P99 }
}
function script:New-DispatchScenarios {
param($StdioList, $HttpList)
return [ordered]@{
stdio = [ordered]@{ tools_list = $StdioList }
http = [ordered]@{ tools_list = $HttpList }
}
}
function script:New-UniformScenarios {
param($StdioP50 = 5.0, $HttpP50 = 6.0)
$stdio = New-Stats -P50 $StdioP50 -P95 ($StdioP50 * 1.5) -P99 ($StdioP50 * 2)
$http = New-Stats -P50 $HttpP50 -P95 ($HttpP50 * 1.5) -P99 ($HttpP50 * 2)
return New-DispatchScenarios -StdioList $stdio -HttpList $http
}
}
Describe "Get-TransportOverhead" {
It "returns http minus stdio tools/list p50" {
$s = New-DispatchScenarios `
-StdioList (New-Stats -P50 5 -P95 6 -P99 7) `
-HttpList (New-Stats -P50 6.5 -P95 8 -P99 9)
Get-TransportOverhead $s | Should -Be 1.5
}
It "returns null when a tools/list stat is absent" {
$s = [ordered]@{ stdio = [ordered]@{ } ; http = [ordered]@{ tools_list = (New-Stats -P50 6 -P95 8 -P99 9) } }
Get-TransportOverhead $s | Should -Be $null
}
}
Describe "Invoke-DispatchRegressionGate" {
It "reports no failures or warnings for an identical baseline" {
$s = New-UniformScenarios
$b = New-UniformScenarios
$gate = Invoke-DispatchRegressionGate -ResultScenarios $s -BaselineScenarios $b
@($gate.Failures).Count | Should -Be 0
@($gate.Warnings).Count | Should -Be 0
}
It "fails when stdio tools/list p95 regresses beyond 10%" {
$b = New-UniformScenarios
$s = New-UniformScenarios
$s.stdio.tools_list.p95 = $b.stdio.tools_list.p95 * 1.15
$gate = Invoke-DispatchRegressionGate -ResultScenarios $s -BaselineScenarios $b
$gate.Failures | Should -Contain 'stdio/tools_list (p95)'
}
It "fails when http tools/list p99 regresses beyond 20%" {
$b = New-UniformScenarios
$s = New-UniformScenarios
$s.http.tools_list.p99 = $b.http.tools_list.p99 * 1.25
$gate = Invoke-DispatchRegressionGate -ResultScenarios $s -BaselineScenarios $b
$gate.Failures | Should -Contain 'http/tools_list (p99)'
}
It "warns but does not fail for a p50 change between 5% and 10%" {
$b = New-UniformScenarios
$s = New-UniformScenarios
$s.stdio.tools_list.p50 = $b.stdio.tools_list.p50 * 1.07
$gate = Invoke-DispatchRegressionGate -ResultScenarios $s -BaselineScenarios $b
@($gate.Failures).Count | Should -Be 0
$gate.Warnings | Should -Contain 'stdio/tools_list (p50)'
}
It "warns when transport overhead grows beyond 5%" {
# Baseline overhead = 6 - 5 = 1. Raise http p50 to 6.4 => overhead 1.4 (+40%),
# while keeping the http p50 change (+6.7%) below the +10% fail threshold.
$b = New-UniformScenarios -StdioP50 5.0 -HttpP50 6.0
$s = New-UniformScenarios -StdioP50 5.0 -HttpP50 6.4
$gate = Invoke-DispatchRegressionGate -ResultScenarios $s -BaselineScenarios $b
$gate.Warnings | Should -Contain 'transport_overhead_tools_list_p50'
}
It "skips a transport absent from the baseline" {
$s = New-UniformScenarios
$b = [ordered]@{ stdio = $s.stdio } # no http
$gate = Invoke-DispatchRegressionGate -ResultScenarios $s -BaselineScenarios $b
@($gate.Failures).Count | Should -Be 0
}
It "fails when no transport overlaps the baseline (F-007)" {
$s = New-UniformScenarios
$b = [ordered]@{ } # empty baseline: nothing to compare
$gate = Invoke-DispatchRegressionGate -ResultScenarios $s -BaselineScenarios $b
$gate.Failures | Should -Contain 'no_comparable_metrics'
}
It "treats a positive current against a zero baseline as a failure (F-010)" {
$b = New-UniformScenarios
$b.stdio.tools_list.p95 = 0
$s = New-UniformScenarios
$gate = Invoke-DispatchRegressionGate -ResultScenarios $s -BaselineScenarios $b
$gate.Failures | Should -Contain 'stdio/tools_list (p95)'
}
It "fails when a transport the baseline expects is missing from results (F-007)" {
$b = New-UniformScenarios
$s = [ordered]@{ stdio = $b.stdio } # results dropped http
$gate = Invoke-DispatchRegressionGate -ResultScenarios $s -BaselineScenarios $b
$gate.Failures | Should -Contain 'http (missing in results)'
}
}
+147
View File
@@ -0,0 +1,147 @@
#!/bin/env pwsh
#Requires -Version 7
<#
.SYNOPSIS
Side-effect-free helpers for the command dispatch / transport overhead gate (issue #3119).
.DESCRIPTION
Dot-source this file to reuse the pure functions in Measure-DispatchOverhead.ps1 and
Check-DispatchOverheadRegression.ps1 and to unit-test them without launching a server.
Reuses Get-MemberValue from StartupPerformance.Common.ps1.
#>
. (Join-Path $PSScriptRoot 'StartupPerformance.Common.ps1')
# ---------------------------------------------------------------------------
# Tiered dispatch/transport regression gate shared by Measure-DispatchOverhead.ps1 and
# Check-DispatchOverheadRegression.ps1. For each transport (stdio, http) and operation
# (ping, tools_list) it applies the acceptance-criteria budgets from issue #3119:
# * p50 and p95 may not regress by more than FailP50P95 (default +10%)
# * p99 may not regress by more than FailP99 (default +20%)
# * any change above Warn (default +5%) is reported as a warning
# It also reports transport-specific overhead (http minus stdio tools/list p50) changes above Warn.
#
# Returns [ordered]@{ Failures = @(...); Warnings = @(...) } and writes a line per check.
# ---------------------------------------------------------------------------
function Invoke-DispatchRegressionGate {
[CmdletBinding()]
param(
[Parameter(Mandatory)] $ResultScenarios,
[Parameter(Mandatory)] $BaselineScenarios,
[double] $FailP50P95 = 1.10,
[double] $FailP99 = 1.20,
[double] $Warn = 1.05
)
$failures = @()
$warnings = @()
$evaluated = 0
$transports = @('stdio', 'http')
$operations = @('tools_list')
$percentileBudgets = @(
@{ Metric = 'p50'; Limit = $FailP50P95 }
@{ Metric = 'p95'; Limit = $FailP50P95 }
@{ Metric = 'p99'; Limit = $FailP99 }
)
foreach ($transport in $transports) {
$curTransport = Get-MemberValue $ResultScenarios $transport
$baseTransport = Get-MemberValue $BaselineScenarios $transport
if ($null -eq $baseTransport) {
Write-Host (" [SKIP] {0}: absent in baseline" -f $transport)
continue
}
if ($null -eq $curTransport) {
$failures += "$transport (missing in results)"
Write-Host (" [FAIL] {0}: expected by baseline but absent in results" -f $transport)
continue
}
foreach ($operation in $operations) {
$curStats = Get-MemberValue $curTransport $operation
$baseStats = Get-MemberValue $baseTransport $operation
if ($null -eq $baseStats) {
Write-Host (" [SKIP] {0}/{1}: absent in baseline" -f $transport, $operation)
continue
}
if ($null -eq $curStats) {
$failures += "$transport/$operation (missing in results)"
Write-Host (" [FAIL] {0}/{1}: expected by baseline but absent in results" -f $transport, $operation)
continue
}
foreach ($budget in $percentileBudgets) {
$metric = $budget.Metric
$current = Get-MemberValue $curStats $metric
$baseline = Get-MemberValue $baseStats $metric
if ($null -eq $baseline) {
continue
}
$label = "$transport/$operation ($metric)"
if ($null -eq $current) {
$failures += "$label (missing in results)"
Write-Host (" [FAIL] {0}: expected by baseline but absent in results" -f $label)
continue
}
$evaluated++
if ($baseline -gt 0) {
$ratio = $current / $baseline
if ($ratio -gt $budget.Limit) { $failures += $label; $status = 'FAIL' }
elseif ($ratio -gt $Warn) { $warnings += $label; $status = 'WARN' }
else { $status = 'PASS' }
Write-Host (" [{0}] {1}: current={2}ms baseline={3}ms (+{4}%)" -f `
$status, $label, $current, $baseline, [math]::Round(($ratio - 1) * 100, 1))
}
elseif ($current -gt 0) {
$failures += $label
Write-Host (" [FAIL] {0}: current={1}ms vs a zero baseline (not comparable)" -f $label, $current)
}
else {
Write-Host (" [PASS] {0}: current=0ms baseline=0ms (unchanged)" -f $label)
}
}
}
}
# Transport-specific overhead: the extra tools/list p50 that HTTP adds over stdio.
$curOverhead = Get-TransportOverhead $ResultScenarios
$baseOverhead = Get-TransportOverhead $BaselineScenarios
if ($null -ne $curOverhead -and $null -ne $baseOverhead -and $baseOverhead -gt 0) {
$ratio = $curOverhead / $baseOverhead
if ($ratio -gt $Warn) {
$warnings += 'transport_overhead_tools_list_p50'
$status = 'WARN'
}
else {
$status = 'PASS'
}
Write-Host (" [{0}] transport_overhead (http-stdio tools/list p50): current={1}ms baseline={2}ms (+{3}%)" -f `
$status, [math]::Round($curOverhead, 4), [math]::Round($baseOverhead, 4), [math]::Round(($ratio - 1) * 100, 1))
}
if ($evaluated -eq 0) {
$failures += 'no_comparable_metrics'
Write-Host " [FAIL] no comparable dispatch metrics between results and baseline (incompatible or empty baseline)"
}
return [ordered]@{ Failures = $failures; Warnings = $warnings }
}
# ---------------------------------------------------------------------------
# The HTTP-minus-stdio tools/list p50 delta — the transport-specific overhead for a real
# request. Returns $null when either transport's tools/list p50 is absent.
# ---------------------------------------------------------------------------
function Get-TransportOverhead {
[CmdletBinding()]
param($Scenarios)
$stdio = Get-MemberValue (Get-MemberValue (Get-MemberValue $Scenarios 'stdio') 'tools_list') 'p50'
$http = Get-MemberValue (Get-MemberValue (Get-MemberValue $Scenarios 'http') 'tools_list') 'p50'
if ($null -eq $stdio -or $null -eq $http) {
return $null
}
return $http - $stdio
}
@@ -0,0 +1,49 @@
#!/bin/env pwsh
#Requires -Version 7
<#
.SYNOPSIS
Runs the BenchmarkDotNet micro-benchmarks for the perf pipeline and guards against a silent
no-op run.
.DESCRIPTION
Extracted from the pipeline YAML so CI behavior can be reproduced locally. --buildTimeout is
raised because the generated boilerplate references the whole server (60+ tool assemblies) and
exceeds BenchmarkDotNet's default 2-minute build timeout. BenchmarkDotNet can exit 0 even when
the boilerplate build fails and nothing runs, so this fails explicitly if no benchmarks executed.
.PARAMETER BenchmarkExe
Path to the built performance-benchmark executable.
.PARAMETER ArtifactsPath
Directory for BenchmarkDotNet artifacts and the captured run log.
.PARAMETER Filter
Glob passed to BenchmarkDotNet --filter (e.g. '*ServerStartup*').
.PARAMETER BuildTimeoutSeconds
--buildTimeout for the generated boilerplate build.
#>
param(
[Parameter(Mandatory)][string] $BenchmarkExe,
[Parameter(Mandatory)][string] $ArtifactsPath,
[string] $Filter = '*',
[int] $BuildTimeoutSeconds = 600
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
New-Item -ItemType Directory -Force -Path $ArtifactsPath | Out-Null
$logPath = Join-Path $ArtifactsPath 'run.log'
& $BenchmarkExe --filter $Filter --artifacts $ArtifactsPath --buildTimeout $BuildTimeoutSeconds 2>&1 |
Tee-Object -FilePath $logPath
$code = $LASTEXITCODE
if ($code -ne 0) { exit $code }
$log = Get-Content $logPath -Raw
if ($log -match 'failed to build the auto-generated boilerplate' -or $log -match 'executed benchmarks: 0') {
Write-Error 'BenchmarkDotNet executed no benchmarks (boilerplate build failure or empty filter).'
exit 1
}
+145
View File
@@ -0,0 +1,145 @@
#!/bin/env pwsh
#Requires -Version 7
<#
.SYNOPSIS
Measures Azure MCP Server throughput, latency, and scaling under a sweep of concurrency
levels (issue #3122).
.DESCRIPTION
Runs the performance-benchmark harness in --mcp-concurrency mode, which launches the server
over HTTP and, at each concurrency level, drives a pool of parallel MCP clients issuing
tools/list for a fixed window. For each level it records throughput, latency p50/p95/p99,
error/timeout rate, concurrency scaling efficiency, and a state-isolation signal (responses
whose tool count does not match the expected count). When a baseline is supplied the results
are gated against the tiered budgets from issue #3122 (throughput within 10%, p95 & scaling
within 15%, p99 within 20%, error-rate increase within 0.5 points, no state leakage).
.PARAMETER Executable
Path to the azmcp server executable. Defaults to the Release build.
.PARAMETER BenchmarkExe
Path to the performance-benchmark executable. Defaults to the Release build.
.PARAMETER Levels
Concurrency levels to sweep.
.PARAMETER DurationSeconds
Timed load window per level.
.PARAMETER WarmupSeconds
Warmup window per level (not counted).
.PARAMETER TimeoutMs
Per-request timeout; a timeout counts as an error.
.PARAMETER OutputPath
Path to write the JSON results. Defaults to .perf-results/concurrency-scaling.json.
.PARAMETER BaselinePath
Optional baseline JSON (previously produced by this script) to gate against.
.EXAMPLE
./eng/scripts/Measure-ConcurrencyScaling.ps1 -Levels 1,2,4,8,16 -DurationSeconds 10
#>
param(
[string] $Executable = './servers/Azure.Mcp.Server/src/bin/Release/net10.0/azmcp.exe',
[string] $BenchmarkExe = './servers/Azure.Mcp.Server/perf/Azure.Mcp.Server.PerformanceBenchmarks/bin/Release/net10.0/Azure.Mcp.Server.PerformanceBenchmarks.exe',
[int[]] $Levels = @(1, 2, 4, 8, 16),
[int] $DurationSeconds = 5,
[int] $WarmupSeconds = 1,
[int] $TimeoutMs = 10000,
[string] $OutputPath = '.perf-results/concurrency-scaling.json',
[string] $BaselinePath
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$ScriptDir = $PSScriptRoot
$RepoRoot = (Get-Item (Join-Path $ScriptDir '../..')).FullName
. (Join-Path $ScriptDir 'ConcurrencyPerformance.Common.ps1')
foreach ($path in @($Executable, $BenchmarkExe)) {
if (-not (Test-Path $path)) {
throw "Required executable not found: $path (build the solution in Release first)."
}
}
$Executable = (Resolve-Path $Executable).Path
$BenchmarkExe = (Resolve-Path $BenchmarkExe).Path
$OutputPath = Resolve-PerfOutputPath -Path $OutputPath
$env:PERF_CONC_LEVELS = ($Levels -join ',')
$env:PERF_CONC_DURATION_SECONDS = $DurationSeconds
$env:PERF_CONC_WARMUP_SECONDS = $WarmupSeconds
$env:PERF_CONC_TIMEOUT_MS = $TimeoutMs
Write-Host "=== Concurrency scaling: levels [$($Levels -join ', ')], ${DurationSeconds}s per level ==="
# The harness sweeps every level in a single invocation; size the timeout to the whole sweep
# (plus headroom) so a hung run fails fast rather than consuming the entire job.
$concurrencyTimeoutSeconds = 180 + $Levels.Count * ($DurationSeconds + $WarmupSeconds) * 3
$data = Invoke-PerfHarnessJson -FilePath $BenchmarkExe `
-ArgumentList @('--mcp-concurrency', $Executable, 'server', 'start') -TimeoutSeconds $concurrencyTimeoutSeconds
foreach ($level in $data.levels) {
Write-Host (" c{0,-3} throughput={1,8} rps p50={2,7} ms p95={3,7} ms p99={4,7} ms err={5}% scaling={6} mismatch={7}" -f `
$level.concurrency, $level.throughput_rps, $level.latency_ms.p50, $level.latency_ms.p95, `
$level.latency_ms.p99, $level.error_rate_pct, $level.scaling_efficiency, $level.tool_count_mismatches)
}
Write-Host ""
$commit = try { (& git -C $RepoRoot rev-parse --short HEAD 2>$null).Trim() } catch { '' }
$results = [ordered]@{
timestamp = (Get-Date).ToUniversalTime().ToString('o')
commit = $commit
environment = (Get-PerfRunMetadata -RepoRoot $RepoRoot)
duration_seconds = $DurationSeconds
expected_tool_count = $data.expected_tool_count
levels = $data.levels
}
$results | ConvertTo-Json -Depth 10 | Out-File -FilePath $OutputPath -Encoding utf8 -Force
Write-Host "Results written to: $OutputPath"
# ---------------------------------------------------------------------------
# Optional: regression check against a baseline file
# ---------------------------------------------------------------------------
if ($BaselinePath) {
if (-not (Test-Path $BaselinePath)) {
Write-Warning "Baseline file not found: $BaselinePath (skipping regression check)"
}
else {
Write-Host ""
Write-Host "=== Concurrency regression check (throughput +10%, p95/scaling +15%, p99 +20%, error +0.5pp) ==="
$baseline = Get-Content $BaselinePath | ConvertFrom-Json
$gate = Invoke-ConcurrencyRegressionGate -ResultLevels $results.levels -BaselineLevels $baseline.levels
if ($gate.Warnings.Count -gt 0) {
Write-Warning "Concurrency warnings (>5%): $($gate.Warnings -join ', ')"
}
if ($gate.Failures.Count -gt 0) {
Write-Error "Concurrency regression detected in: $($gate.Failures -join ', ')"
}
else {
Write-Host " All concurrency budget checks passed."
}
}
}
# ---------------------------------------------------------------------------
# Emit Azure DevOps pipeline variables (no-op outside AzDO)
# ---------------------------------------------------------------------------
$peak = $data.levels | Sort-Object throughput_rps -Descending | Select-Object -First 1
if ($peak) {
Write-Host "##vso[task.setvariable variable=ConcurrencyPeakThroughputRps]$($peak.throughput_rps)"
Write-Host "##vso[task.setvariable variable=ConcurrencyPeakAtLevel]$($peak.concurrency)"
}
$maxLevel = $data.levels | Sort-Object concurrency -Descending | Select-Object -First 1
if ($maxLevel) {
Write-Host "##vso[task.setvariable variable=ConcurrencyMaxLevelScalingEfficiency]$($maxLevel.scaling_efficiency)"
}
+166
View File
@@ -0,0 +1,166 @@
#!/bin/env pwsh
#Requires -Version 7
<#
.SYNOPSIS
Measures Azure MCP Server command-dispatch and transport overhead over stdio and HTTP
(issue #3119) by timing many repetitions of ping and tools/list on a live server.
.DESCRIPTION
For each transport this runs the performance-benchmark harness in --mcp-dispatch mode,
which times ping (the transport/protocol floor) and tools/list (a real request with
server-side serialization) across many iterations. Per-transport p50/p95/p99 are computed
with the shared percentile helpers, the HTTP-minus-stdio ping delta isolates transport
overhead, and (when a baseline is supplied) results are gated against the tiered budgets
from issue #3119 (p50/p95 within 10%, p99 within 20%, transport change above 5% reported).
.PARAMETER Executable
Path to the azmcp server executable. Defaults to the Release build.
.PARAMETER BenchmarkExe
Path to the performance-benchmark executable. Defaults to the Release build.
.PARAMETER Iterations
Timed samples per operation per transport.
.PARAMETER Warmup
Warmup calls (not timed) per operation.
.PARAMETER OutputPath
Path to write the JSON results. Defaults to .perf-results/dispatch-overhead.json.
.PARAMETER BaselinePath
Optional baseline JSON (previously produced by this script) to gate against.
.EXAMPLE
./eng/scripts/Measure-DispatchOverhead.ps1
.EXAMPLE
./eng/scripts/Measure-DispatchOverhead.ps1 -Iterations 500 -BaselinePath eng/dispatch-baseline.json
#>
param(
[string] $Executable = './servers/Azure.Mcp.Server/src/bin/Release/net10.0/azmcp.exe',
[string] $BenchmarkExe = './servers/Azure.Mcp.Server/perf/Azure.Mcp.Server.PerformanceBenchmarks/bin/Release/net10.0/Azure.Mcp.Server.PerformanceBenchmarks.exe',
[int] $Iterations = 200,
[int] $Warmup = 20,
[string] $OutputPath = '.perf-results/dispatch-overhead.json',
[string] $BaselinePath
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$ScriptDir = $PSScriptRoot
$RepoRoot = (Get-Item (Join-Path $ScriptDir '../..')).FullName
. (Join-Path $ScriptDir 'DispatchPerformance.Common.ps1')
foreach ($path in @($Executable, $BenchmarkExe)) {
if (-not (Test-Path $path)) {
throw "Required executable not found: $path (build the solution in Release first)."
}
}
$Executable = (Resolve-Path $Executable).Path
$BenchmarkExe = (Resolve-Path $BenchmarkExe).Path
$OutputPath = Resolve-PerfOutputPath -Path $OutputPath
$env:PERF_DISPATCH_ITERATIONS = $Iterations
$env:PERF_DISPATCH_WARMUP = $Warmup
# Generous per-invocation timeout so a hung/crashed harness fails fast instead of consuming
# the whole job. tools/list is sub-second, so this scales gently with the iteration count.
$harnessTimeoutSeconds = [math]::Max(180, $Iterations)
function Get-DispatchStats {
param([double[]] $Samples)
$s = Get-TimingStats -Samples $Samples
return [ordered]@{
average = $s.average
median = $s.median
p50 = $s.p50
p95 = $s.p95
p99 = $s.p99
count = $Samples.Count
}
}
$scenarios = [ordered]@{}
foreach ($transport in @('stdio', 'http')) {
Write-Host "=== Dispatch overhead: $transport ($Iterations iterations) ==="
$mode = "--mcp-dispatch-$transport"
$data = Invoke-PerfHarnessJson -FilePath $BenchmarkExe `
-ArgumentList @($mode, $Executable, 'server', 'start') -TimeoutSeconds $harnessTimeoutSeconds
$toolsList = Get-DispatchStats -Samples ([double[]]$data.tools_list_ms)
$scenario = [ordered]@{ tools_list = $toolsList }
if ($data.PSObject.Properties.Name -contains 'readiness_ms' -and $null -ne $data.readiness_ms) {
$scenario.readiness_ms = $data.readiness_ms
}
$scenarios[$transport] = $scenario
Write-Host (" tools/list: p50={0} ms p95={1} ms p99={2} ms" -f $toolsList.p50, $toolsList.p95, $toolsList.p99)
Write-Host ""
}
$overhead = Get-TransportOverhead $scenarios
if ($null -ne $overhead) {
Write-Host ("=== Transport overhead (http - stdio tools/list p50): {0} ms ===" -f [math]::Round($overhead, 4))
Write-Host ""
}
$commit = try { (& git -C $RepoRoot rev-parse --short HEAD 2>$null).Trim() } catch { '' }
$results = [ordered]@{
timestamp = (Get-Date).ToUniversalTime().ToString('o')
commit = $commit
environment = (Get-PerfRunMetadata -RepoRoot $RepoRoot)
iterations = $Iterations
warmup = $Warmup
transport_overhead_tools_list_p50 = $overhead
scenarios = $scenarios
}
$results | ConvertTo-Json -Depth 10 | Out-File -FilePath $OutputPath -Encoding utf8 -Force
Write-Host "Results written to: $OutputPath"
# ---------------------------------------------------------------------------
# Optional: regression check against a baseline file
# ---------------------------------------------------------------------------
if ($BaselinePath) {
if (-not (Test-Path $BaselinePath)) {
Write-Warning "Baseline file not found: $BaselinePath (skipping regression check)"
}
else {
Write-Host ""
Write-Host "=== Dispatch regression check (p50/p95 +10%, p99 +20%, transport warn +5%) ==="
$baseline = Get-Content $BaselinePath | ConvertFrom-Json
$gate = Invoke-DispatchRegressionGate -ResultScenarios $results.scenarios `
-BaselineScenarios $baseline.scenarios
if ($gate.Warnings.Count -gt 0) {
Write-Warning "Dispatch warnings (>5%): $($gate.Warnings -join ', ')"
}
if ($gate.Failures.Count -gt 0) {
Write-Error "Dispatch regression detected in: $($gate.Failures -join ', ')"
}
else {
Write-Host " All dispatch budget checks passed."
}
}
}
# ---------------------------------------------------------------------------
# Emit Azure DevOps pipeline variables (no-op outside AzDO)
# ---------------------------------------------------------------------------
foreach ($transport in @('stdio', 'http')) {
$s = $scenarios[$transport]
$t = (Get-Culture).TextInfo.ToTitleCase($transport)
Write-Host "##vso[task.setvariable variable=Dispatch${t}ToolsListP50Ms]$($s.tools_list.p50)"
}
if ($null -ne $overhead) {
Write-Host "##vso[task.setvariable variable=DispatchTransportOverheadToolsListP50Ms]$overhead"
}
+185
View File
@@ -0,0 +1,185 @@
#!/bin/env pwsh
#Requires -Version 7
<#
.SYNOPSIS
Measures the Azure MCP Server's CPU, memory, and handle baselines across server modes
and concurrency levels (issue #3121).
.DESCRIPTION
For each requested server mode this script launches the performance-benchmark harness
in --mcp-resource mode, which starts the server over HTTP and samples process resource
counters across the startup, idle, discovery, steady-state, and optional soak phases.
Results for every mode are collected into a single JSON document and, when a baseline is
supplied, gated against the tiered resource budgets from issue #3121 (steady-state CPU
and memory within 10%, peak memory within 15%, no unbounded soak growth).
.PARAMETER Executable
Path to the azmcp server executable. Defaults to the Release build.
.PARAMETER BenchmarkExe
Path to the performance-benchmark executable. Defaults to the Release build.
.PARAMETER Modes
Server modes to measure. Valid values: default, namespace, all.
.PARAMETER Concurrency
Number of concurrent load clients driving steady-state and soak load.
.PARAMETER IdleSeconds
Idle observation window (no client connected) in seconds.
.PARAMETER SteadySeconds
Steady-state load duration in seconds.
.PARAMETER SoakSeconds
Sustained soak duration in seconds. 0 disables the soak/leak-detection phase.
.PARAMETER SampleMs
Resource sampling interval in milliseconds.
.PARAMETER OutputPath
Path to write the JSON results. Defaults to .perf-results/resource-usage.json.
.PARAMETER BaselinePath
Optional baseline JSON (previously produced by this script) to gate against.
.EXAMPLE
./eng/scripts/Measure-ResourceUsage.ps1
.EXAMPLE
./eng/scripts/Measure-ResourceUsage.ps1 -Modes default,all -Concurrency 4 -SoakSeconds 120
#>
param(
[string] $Executable = './servers/Azure.Mcp.Server/src/bin/Release/net10.0/azmcp.exe',
[string] $BenchmarkExe = './servers/Azure.Mcp.Server/perf/Azure.Mcp.Server.PerformanceBenchmarks/bin/Release/net10.0/Azure.Mcp.Server.PerformanceBenchmarks.exe',
[ValidateSet('default', 'namespace', 'all')]
[string[]] $Modes = @('default', 'all'),
[int] $Concurrency = 1,
[int] $IdleSeconds = 3,
[int] $SteadySeconds = 10,
[int] $SoakSeconds = 0,
[int] $SampleMs = 100,
[string] $OutputPath = '.perf-results/resource-usage.json',
[string] $BaselinePath
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$ScriptDir = $PSScriptRoot
$RepoRoot = (Get-Item (Join-Path $ScriptDir '../..')).FullName
. (Join-Path $ScriptDir 'ResourcePerformance.Common.ps1')
foreach ($path in @($Executable, $BenchmarkExe)) {
if (-not (Test-Path $path)) {
throw "Required executable not found: $path (build the solution in Release first)."
}
}
$Executable = (Resolve-Path $Executable).Path
$BenchmarkExe = (Resolve-Path $BenchmarkExe).Path
$OutputPath = Resolve-PerfOutputPath -Path $OutputPath
function Get-ModeServerArgs {
param([string] $Mode)
switch ($Mode) {
'default' { return @('server', 'start') }
'namespace' { return @('server', 'start', '--mode', 'namespace') }
'all' { return @('server', 'start', '--mode', 'all') }
default { throw "Unknown mode '$Mode'." }
}
}
# Propagate the run parameters to the harness via environment variables.
$env:PERF_CONCURRENCY = $Concurrency
$env:PERF_IDLE_SECONDS = $IdleSeconds
$env:PERF_STEADY_SECONDS = $SteadySeconds
$env:PERF_SOAK_SECONDS = $SoakSeconds
$env:PERF_SAMPLE_MS = $SampleMs
# Per-mode timeout covers idle + steady + soak (plus startup/headroom) so a hung harness
# fails fast instead of consuming the whole job.
$resourceTimeoutSeconds = 180 + ($IdleSeconds + $SteadySeconds + $SoakSeconds) * 3
$scenarios = [ordered]@{}
foreach ($mode in $Modes) {
$serverArgs = Get-ModeServerArgs -Mode $mode
Write-Host "=== Resource usage: $mode mode (concurrency=$Concurrency, steady=${SteadySeconds}s, soak=${SoakSeconds}s) ==="
$data = Invoke-PerfHarnessJson -FilePath $BenchmarkExe `
-ArgumentList (@('--mcp-resource', $Executable) + $serverArgs) -TimeoutSeconds $resourceTimeoutSeconds
$scenarios[$mode] = $data
$steady = $data.phases.steady_state
Write-Host (" tools={0} steady: ws-mean={1} MB ws-max={2} MB cpu={3}% {4} req ({5} rps)" -f `
$data.tool_count, $steady.working_set_mb.mean, $steady.working_set_mb.max, `
$steady.cpu_percent, $steady.requests, $steady.throughput_rps)
Write-Host (" peak: ws={0} MB private={1} MB handles={2} growth idle->steady: ws={3} MB handles={4}" -f `
$data.peak.working_set_mb, $data.peak.private_mb, $data.peak.handles, `
$data.growth.idle_to_steady_working_set_mb, $data.growth.idle_to_steady_handles)
if ($null -ne $data.soak) {
Write-Host (" soak: {0} req over {1}s ws-slope={2} MB/min (R2={3}) handle-slope={4}/min unbounded={5}" -f `
$data.soak.requests, $data.soak.duration_s, $data.soak.working_set_slope_mb_per_min, `
$data.soak.working_set_r2, $data.soak.handle_slope_per_min, $data.soak.unbounded_growth)
}
Write-Host ""
}
$commit = try { (& git -C $RepoRoot rev-parse --short HEAD 2>$null).Trim() } catch { '' }
$results = [ordered]@{
timestamp = (Get-Date).ToUniversalTime().ToString('o')
commit = $commit
environment = (Get-PerfRunMetadata -RepoRoot $RepoRoot)
concurrency = $Concurrency
idle_seconds = $IdleSeconds
steady_seconds = $SteadySeconds
soak_seconds = $SoakSeconds
sample_ms = $SampleMs
scenarios = $scenarios
}
$results | ConvertTo-Json -Depth 12 | Out-File -FilePath $OutputPath -Encoding utf8 -Force
Write-Host "Results written to: $OutputPath"
# ---------------------------------------------------------------------------
# Optional: regression check against a baseline file
# ---------------------------------------------------------------------------
if ($BaselinePath) {
if (-not (Test-Path $BaselinePath)) {
Write-Warning "Baseline file not found: $BaselinePath (skipping regression check)"
}
else {
Write-Host ""
Write-Host "=== Resource regression check (steady CPU/mem +10%, peak mem +15%, no soak leak) ==="
$baseline = Get-Content $BaselinePath | ConvertFrom-Json
$gate = Invoke-ResourceRegressionGate -ResultScenarios $results.scenarios `
-BaselineScenarios $baseline.scenarios
if ($gate.Warnings.Count -gt 0) {
Write-Warning "Resource warnings (>5%): $($gate.Warnings -join ', ')"
}
if ($gate.Failures.Count -gt 0) {
Write-Error "Resource regression detected in: $($gate.Failures -join ', ')"
}
else {
Write-Host " All resource budget checks passed."
}
}
}
# ---------------------------------------------------------------------------
# Emit Azure DevOps pipeline variables (no-op outside AzDO)
# ---------------------------------------------------------------------------
foreach ($mode in $Modes) {
$data = $scenarios[$mode]
$steady = $data.phases.steady_state
$modeVar = (Get-Culture).TextInfo.ToTitleCase($mode)
Write-Host "##vso[task.setvariable variable=Resource${modeVar}SteadyWorkingSetMb]$($steady.working_set_mb.mean)"
Write-Host "##vso[task.setvariable variable=Resource${modeVar}SteadyCpuPercent]$($steady.cpu_percent)"
Write-Host "##vso[task.setvariable variable=Resource${modeVar}PeakWorkingSetMb]$($data.peak.working_set_mb)"
}
@@ -0,0 +1,57 @@
#!/bin/env pwsh
#Requires -Version 7
<#
.SYNOPSIS
Emits the startup performance metrics from a results JSON file.
.DESCRIPTION
Reads the results JSON produced by Test-StartupPerformance.ps1 and logs each tracked
metric. The metrics are currently written as pipeline log warnings so they surface in
the build timeline; replace the Write-Host sink with a real telemetry destination
(Azure Monitor, Application Insights, etc.) to persist them.
.PARAMETER ResultsPath
Path to the results JSON written by Test-StartupPerformance.ps1.
#>
param(
[Parameter(Mandatory)][string] $ResultsPath
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$results = Get-Content $ResultsPath | ConvertFrom-Json
$s = $results.scenarios
$metrics = @(
@{ name = 'startup.cli.cold.median_ms'; value = $s.cli_cold_start_ms.median }
@{ name = 'startup.cli.cold.p95_ms'; value = $s.cli_cold_start_ms.p95 }
@{ name = 'startup.mcp.default.median_ms'; value = $s.mcp_stdio_to_tools_list_ms.median }
@{ name = 'startup.mcp.default.p95_ms'; value = $s.mcp_stdio_to_tools_list_ms.p95 }
@{ name = 'startup.mcp.default.p99_ms'; value = $s.mcp_stdio_to_tools_list_ms.p99 }
@{ name = 'startup.mcp.namespace.median_ms'; value = $s.mcp_namespace_mode_startup_ms.median }
@{ name = 'startup.mcp.all.median_ms'; value = $s.mcp_all_mode_startup_ms.median }
@{ name = 'startup.mcp.all.p95_ms'; value = $s.mcp_all_mode_startup_ms.p95 }
@{ name = 'startup.mcp.all.p99_ms'; value = $s.mcp_all_mode_startup_ms.p99 }
@{ name = 'startup.mcp.http.median_ms'; value = $s.mcp_http_default_startup_ms.median }
@{ name = 'startup.mcp.http.p95_ms'; value = $s.mcp_http_default_startup_ms.p95 }
@{ name = 'startup.mcp.http.readiness_median_ms'; value = $s.mcp_http_readiness_ms.median }
@{ name = 'serialize.default.full_schema_ms'; value = $s.tools_list_payload.full_schema_serialize_ms }
@{ name = 'serialize.all.full_schema_ms'; value = $s.all_mode_tools_list_payload.full_schema_serialize_ms }
@{ name = 'serialize.http.full_schema_ms'; value = $s.http_default_tools_list_payload.full_schema_serialize_ms }
@{ name = 'tokens.default.full_schema_gpt4o'; value = $s.tools_list_payload.full_schema_tokens_exact }
@{ name = 'tokens.namespace.full_schema_gpt4o'; value = $s.namespace_mode_tools_list_payload.full_schema_tokens_exact }
@{ name = 'tokens.all.full_schema_gpt4o'; value = $s.all_mode_tools_list_payload.full_schema_tokens_exact }
@{ name = 'tools.default.count'; value = $s.tools_list_payload.tool_count }
@{ name = 'tools.all.count'; value = $s.all_mode_tools_list_payload.tool_count }
)
foreach ($m in $metrics) {
Write-Host ("##vso[task.logissue type=warning]METRIC: {0} = {1}" -f $m.name, $m.value)
# Replace the log statement above with your preferred telemetry sink, e.g.:
# az monitor metrics alert create ...
# Invoke-RestMethod -Uri $AppInsightsEndpoint -Body @{ name=$m.name; value=$m.value } ...
}
Write-Host "Metrics logged. Wire up a telemetry sink to persist them."
@@ -0,0 +1,161 @@
# Install-Module -Name Pester -Force -SkipPublisherCheck
# Invoke-Pester -Path eng/scripts/ResourcePerformance.Common.Tests.ps1
#
# Focused tests for the pure resource-gate helpers used by Measure-ResourceUsage.ps1.
BeforeAll {
. $PSScriptRoot/ResourcePerformance.Common.ps1
function script:New-ResourceScenario {
param(
[double] $SteadyMem,
[double] $SteadyCpu,
[double] $PeakMem,
[switch] $WithSoak,
[bool] $Unbounded = $false
)
$scenario = [ordered]@{
phases = [ordered]@{
steady_state = [ordered]@{
working_set_mb = [ordered]@{ mean = $SteadyMem; max = $SteadyMem }
cpu_percent = $SteadyCpu
}
}
peak = [ordered]@{ working_set_mb = $PeakMem }
}
if ($WithSoak) {
$scenario.soak = [ordered]@{
unbounded_growth = $Unbounded
working_set_slope_mb_per_min = 1
working_set_r2 = 0.9
handle_slope_per_min = 1
handle_r2 = 0.1
}
}
return $scenario
}
}
Describe "Get-MemberNames" {
It "returns the keys of an ordered dictionary" {
$names = Get-MemberNames ([ordered]@{ a = 1; b = 2 })
$names | Should -Be @('a', 'b')
}
It "returns the property names of a PSCustomObject" {
$obj = [pscustomobject]@{ x = 1; y = 2 }
$names = Get-MemberNames $obj
$names | Should -Be @('x', 'y')
}
It "returns an empty set for null" {
@(Get-MemberNames $null).Count | Should -Be 0
}
}
Describe "Invoke-ResourceRegressionGate" {
It "reports no failures or warnings for an identical baseline" {
$s = [ordered]@{ default = (New-ResourceScenario -SteadyMem 100 -SteadyCpu 10 -PeakMem 150) }
$b = [ordered]@{ default = (New-ResourceScenario -SteadyMem 100 -SteadyCpu 10 -PeakMem 150) }
$gate = Invoke-ResourceRegressionGate -ResultScenarios $s -BaselineScenarios $b
@($gate.Failures).Count | Should -Be 0
@($gate.Warnings).Count | Should -Be 0
}
It "fails when steady-state memory regresses beyond 10%" {
$s = [ordered]@{ default = (New-ResourceScenario -SteadyMem 115 -SteadyCpu 10 -PeakMem 150) }
$b = [ordered]@{ default = (New-ResourceScenario -SteadyMem 100 -SteadyCpu 10 -PeakMem 150) }
$gate = Invoke-ResourceRegressionGate -ResultScenarios $s -BaselineScenarios $b
$gate.Failures | Should -Contain 'default steady_working_set_mb'
}
It "warns but does not fail for a steady-state memory change between 5% and 10%" {
$s = [ordered]@{ default = (New-ResourceScenario -SteadyMem 107 -SteadyCpu 10 -PeakMem 150) }
$b = [ordered]@{ default = (New-ResourceScenario -SteadyMem 100 -SteadyCpu 10 -PeakMem 150) }
$gate = Invoke-ResourceRegressionGate -ResultScenarios $s -BaselineScenarios $b
@($gate.Failures).Count | Should -Be 0
$gate.Warnings | Should -Contain 'default steady_working_set_mb'
}
It "fails when steady-state CPU regresses beyond 10%" {
$s = [ordered]@{ default = (New-ResourceScenario -SteadyMem 100 -SteadyCpu 12 -PeakMem 150) }
$b = [ordered]@{ default = (New-ResourceScenario -SteadyMem 100 -SteadyCpu 10 -PeakMem 150) }
$gate = Invoke-ResourceRegressionGate -ResultScenarios $s -BaselineScenarios $b
$gate.Failures | Should -Contain 'default steady_cpu_percent'
}
It "allows peak memory growth up to 15% as a warning" {
$s = [ordered]@{ default = (New-ResourceScenario -SteadyMem 100 -SteadyCpu 10 -PeakMem 168) }
$b = [ordered]@{ default = (New-ResourceScenario -SteadyMem 100 -SteadyCpu 10 -PeakMem 150) }
$gate = Invoke-ResourceRegressionGate -ResultScenarios $s -BaselineScenarios $b
@($gate.Failures).Count | Should -Be 0
$gate.Warnings | Should -Contain 'default peak_working_set_mb'
}
It "fails when peak memory regresses beyond 15%" {
$s = [ordered]@{ default = (New-ResourceScenario -SteadyMem 100 -SteadyCpu 10 -PeakMem 180) }
$b = [ordered]@{ default = (New-ResourceScenario -SteadyMem 100 -SteadyCpu 10 -PeakMem 150) }
$gate = Invoke-ResourceRegressionGate -ResultScenarios $s -BaselineScenarios $b
$gate.Failures | Should -Contain 'default peak_working_set_mb'
}
It "fails when a soak phase is flagged with unbounded growth" {
$s = [ordered]@{ default = (New-ResourceScenario -SteadyMem 100 -SteadyCpu 10 -PeakMem 150 -WithSoak -Unbounded $true) }
$b = [ordered]@{ default = (New-ResourceScenario -SteadyMem 100 -SteadyCpu 10 -PeakMem 150) }
$gate = Invoke-ResourceRegressionGate -ResultScenarios $s -BaselineScenarios $b
$gate.Failures | Should -Contain 'default soak_unbounded_growth'
}
It "passes a soak phase that is not flagged" {
$s = [ordered]@{ default = (New-ResourceScenario -SteadyMem 100 -SteadyCpu 10 -PeakMem 150 -WithSoak -Unbounded $false) }
$b = [ordered]@{ default = (New-ResourceScenario -SteadyMem 100 -SteadyCpu 10 -PeakMem 150) }
$gate = Invoke-ResourceRegressionGate -ResultScenarios $s -BaselineScenarios $b
@($gate.Failures).Count | Should -Be 0
}
It "flags an incompatible (empty) baseline instead of silently passing (F-007)" {
$s = [ordered]@{ default = (New-ResourceScenario -SteadyMem 999 -SteadyCpu 99 -PeakMem 999) }
$b = [ordered]@{ }
$gate = Invoke-ResourceRegressionGate -ResultScenarios $s -BaselineScenarios $b
$gate.Failures | Should -Contain 'no_comparable_metrics'
}
It "compares overlapping modes and skips a newly added mode without failing" {
$s = [ordered]@{
default = (New-ResourceScenario -SteadyMem 100 -SteadyCpu 10 -PeakMem 150)
all = (New-ResourceScenario -SteadyMem 200 -SteadyCpu 20 -PeakMem 280)
}
$b = [ordered]@{ default = (New-ResourceScenario -SteadyMem 100 -SteadyCpu 10 -PeakMem 150) }
$gate = Invoke-ResourceRegressionGate -ResultScenarios $s -BaselineScenarios $b
@($gate.Failures).Count | Should -Be 0
}
It "flags soak unbounded growth even when the mode is absent from the baseline (F-008)" {
$s = [ordered]@{ all = (New-ResourceScenario -SteadyMem 200 -SteadyCpu 20 -PeakMem 280 -WithSoak -Unbounded $true) }
$b = [ordered]@{ default = (New-ResourceScenario -SteadyMem 100 -SteadyCpu 10 -PeakMem 150) }
$gate = Invoke-ResourceRegressionGate -ResultScenarios $s -BaselineScenarios $b
$gate.Failures | Should -Contain 'all soak_unbounded_growth'
}
It "treats positive CPU against a zero baseline as a failure, not a pass (F-010)" {
$s = [ordered]@{ default = (New-ResourceScenario -SteadyMem 100 -SteadyCpu 25 -PeakMem 150) }
$b = [ordered]@{ default = (New-ResourceScenario -SteadyMem 100 -SteadyCpu 0 -PeakMem 150) }
$gate = Invoke-ResourceRegressionGate -ResultScenarios $s -BaselineScenarios $b
$gate.Failures | Should -Contain 'default steady_cpu_percent'
}
It "fails when a steady metric the baseline expects is missing from results (F-007)" {
$b = [ordered]@{ default = (New-ResourceScenario -SteadyMem 100 -SteadyCpu 10 -PeakMem 150) }
$s = [ordered]@{
default = [ordered]@{
phases = [ordered]@{ steady_state = [ordered]@{ working_set_mb = [ordered]@{ mean = 100; max = 100 } } }
peak = [ordered]@{ working_set_mb = 150 }
}
}
$gate = Invoke-ResourceRegressionGate -ResultScenarios $s -BaselineScenarios $b
$gate.Failures | Should -Contain 'default steady_cpu_percent (missing in results)'
}
}
+151
View File
@@ -0,0 +1,151 @@
#!/bin/env pwsh
#Requires -Version 7
<#
.SYNOPSIS
Side-effect-free helpers for the resource-usage baseline gate (issue #3121).
.DESCRIPTION
Dot-source this file to reuse the pure functions in Measure-ResourceUsage.ps1 and
Check-ResourceUsageRegression.ps1 and to unit-test them without launching a server.
Reuses the generic accessor Get-MemberValue from StartupPerformance.Common.ps1.
#>
. (Join-Path $PSScriptRoot 'StartupPerformance.Common.ps1')
# ---------------------------------------------------------------------------
# Returns the member names of a hashtable/ordered dictionary or a PSCustomObject
# (as produced by ConvertFrom-Json), so callers can enumerate scenario maps
# regardless of how the results JSON was materialized.
# ---------------------------------------------------------------------------
function Get-MemberNames {
[CmdletBinding()]
param($Object)
if ($null -eq $Object) { return @() }
if ($Object -is [System.Collections.IDictionary]) { return @($Object.Keys) }
return @($Object.PSObject.Properties.Name)
}
# ---------------------------------------------------------------------------
# Tiered resource-regression gate shared by Measure-ResourceUsage.ps1 and
# Check-ResourceUsageRegression.ps1. For each server-mode scenario present in
# both the results and the baseline it applies the acceptance-criteria budgets
# from issue #3121:
# * steady-state CPU and memory may not increase by more than 10% (FailSteady = 1.10)
# * peak memory may not increase by more than 15% (FailPeak = 1.15)
# * any change above 5% is reported as a warning (Warn = 1.05)
# * a soak phase flagged with unbounded_growth is a hard failure (memory/handle leak)
#
# Returns [ordered]@{ Failures = @(...); Warnings = @(...) } and writes a
# human-readable line per check via Write-Host.
# ---------------------------------------------------------------------------
function Invoke-ResourceRegressionGate {
[CmdletBinding()]
param(
[Parameter(Mandatory)] $ResultScenarios,
[Parameter(Mandatory)] $BaselineScenarios,
[double] $FailSteady = 1.10,
[double] $FailPeak = 1.15,
[double] $Warn = 1.05
)
$failures = @()
$warnings = @()
$evaluated = 0
$seenModes = @{}
foreach ($mode in (Get-MemberNames $ResultScenarios)) {
$seenModes[[string]$mode] = $true
$r = Get-MemberValue $ResultScenarios $mode
$b = Get-MemberValue $BaselineScenarios $mode
# Leak detection: a soak phase flagged unbounded is a hard failure regardless of the
# baseline, so it is evaluated before the baseline-presence check below can skip this mode.
$soak = Get-MemberValue $r 'soak'
if ($null -ne $soak) {
if (Get-MemberValue $soak 'unbounded_growth') {
$failures += "$mode soak_unbounded_growth"
Write-Host (" [FAIL] {0} soak_unbounded_growth: working_set_slope={1} MB/min (R2={2}), handle_slope={3}/min (R2={4})" -f `
$mode, (Get-MemberValue $soak 'working_set_slope_mb_per_min'), (Get-MemberValue $soak 'working_set_r2'), `
(Get-MemberValue $soak 'handle_slope_per_min'), (Get-MemberValue $soak 'handle_r2'))
}
else {
Write-Host (" [PASS] {0} soak: no unbounded growth" -f $mode)
}
}
if ($null -eq $b) {
Write-Host (" [SKIP] {0}: absent in baseline (relative budgets)" -f $mode)
continue
}
$rSteady = Get-MemberValue (Get-MemberValue $r 'phases') 'steady_state'
$bSteady = Get-MemberValue (Get-MemberValue $b 'phases') 'steady_state'
$checks = @(
@{ Label = 'steady_working_set_mb'; Unit = 'MB'
Cur = Get-MemberValue (Get-MemberValue $rSteady 'working_set_mb') 'mean'
Base = Get-MemberValue (Get-MemberValue $bSteady 'working_set_mb') 'mean'
Limit = $FailSteady }
@{ Label = 'steady_cpu_percent'; Unit = '%'
Cur = Get-MemberValue $rSteady 'cpu_percent'
Base = Get-MemberValue $bSteady 'cpu_percent'
Limit = $FailSteady }
@{ Label = 'peak_working_set_mb'; Unit = 'MB'
Cur = Get-MemberValue (Get-MemberValue $r 'peak') 'working_set_mb'
Base = Get-MemberValue (Get-MemberValue $b 'peak') 'working_set_mb'
Limit = $FailPeak }
)
foreach ($c in $checks) {
if ($null -eq $c.Base) {
Write-Host (" [SKIP] {0} {1}: absent in baseline" -f $mode, $c.Label)
continue
}
if ($null -eq $c.Cur) {
$failures += "$mode $($c.Label) (missing in results)"
Write-Host (" [FAIL] {0} {1}: expected by baseline but absent in results" -f $mode, $c.Label)
continue
}
$label = "$mode $($c.Label)"
$evaluated++
if ($c.Base -gt 0) {
$ratio = $c.Cur / $c.Base
if ($ratio -gt $c.Limit) { $failures += $label; $status = 'FAIL' }
elseif ($ratio -gt $Warn) { $warnings += $label; $status = 'WARN' }
else { $status = 'PASS' }
Write-Host (" [{0}] {1}: current={2}{3} baseline={4}{3} (+{5}%)" -f `
$status, $label, $c.Cur, $c.Unit, $c.Base, [math]::Round(($ratio - 1) * 100, 1))
}
elseif ($c.Cur -gt 0) {
# Positive current against a zero baseline is not comparable — fail rather than
# silently pass with a fabricated 0% change.
$failures += $label
Write-Host (" [FAIL] {0}: current={1}{2} vs a zero baseline (not comparable)" -f $label, $c.Cur, $c.Unit)
}
else {
Write-Host (" [PASS] {0}: current=0{1} baseline=0{1} (unchanged)" -f $label, $c.Unit)
}
}
# Leak detection is evaluated near the top of the loop (before the baseline skip).
}
# Baseline modes the results no longer cover are treated as an expected-but-missing metric.
foreach ($mode in (Get-MemberNames $BaselineScenarios)) {
if (-not $seenModes.ContainsKey([string]$mode)) {
$failures += "$mode missing_in_results"
Write-Host (" [FAIL] {0}: expected by baseline but absent in results" -f $mode)
}
}
if ($evaluated -eq 0) {
$failures += 'no_comparable_metrics'
Write-Host " [FAIL] no comparable resource metrics between results and baseline (incompatible or empty baseline)"
}
return [ordered]@{ Failures = $failures; Warnings = $warnings }
}
@@ -0,0 +1,222 @@
# Install-Module -Name Pester -Force -SkipPublisherCheck
# Invoke-Pester -Path eng/scripts/StartupPerformance.Common.Tests.ps1
#
# Focused tests for the pure helpers used by Test-StartupPerformance.ps1.
BeforeAll {
. $PSScriptRoot/StartupPerformance.Common.ps1
$script:RunFolder = Join-Path $PSScriptRoot '.perf-common-testruns'
if (Test-Path $script:RunFolder) {
Remove-Item -Recurse -Force $script:RunFolder
}
New-Item -ItemType Directory -Path $script:RunFolder | Out-Null
}
AfterAll {
if (Test-Path $script:RunFolder) {
Remove-Item -Recurse -Force $script:RunFolder
}
}
Describe "Get-TimingStats" {
It "returns the single middle value as the median for an odd number of samples" {
# sorted: 3,5,9 -> middle = 5
$stats = Get-TimingStats -Samples @(9, 3, 5)
$stats.median | Should -Be 5
}
It "averages the two middle values as the median for an even number of samples" {
# sorted: 10,20,30,40 -> (20+30)/2 = 25
$stats = Get-TimingStats -Samples @(40, 10, 30, 20)
$stats.median | Should -Be 25
}
It "produces a fractional median when the two middle values differ by an odd amount" {
# sorted: 10,11 -> (10+11)/2 = 10.5
$stats = Get-TimingStats -Samples @(11, 10)
$stats.median | Should -Be 10.5
}
It "reports the first sample in original (unsorted) order" {
$stats = Get-TimingStats -Samples @(40, 10, 30, 20)
$stats.first | Should -Be 40
}
It "computes the average rounded to one decimal place" {
# (1+2+2)/3 = 1.666... -> 1.7
$stats = Get-TimingStats -Samples @(1, 2, 2)
$stats.average | Should -Be 1.7
}
It "handles a single sample" {
$stats = Get-TimingStats -Samples @(42)
$stats.median | Should -Be 42
$stats.first | Should -Be 42
$stats.average | Should -Be 42
}
It "throws when no samples are provided" {
{ Get-TimingStats -Samples @() } | Should -Throw
}
}
Describe "Get-TimingStats percentiles" {
It "reports p50 equal to the median for an odd number of samples" {
$stats = Get-TimingStats -Samples @(9, 3, 5)
$stats.p50 | Should -Be 5
}
It "orders p50 <= p95 <= p99 and keeps them within the sample range" {
$stats = Get-TimingStats -Samples @(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
$stats.p50 | Should -BeLessOrEqual $stats.p95
$stats.p95 | Should -BeLessOrEqual $stats.p99
$stats.p99 | Should -BeLessOrEqual 10
$stats.p95 | Should -BeGreaterThan 9 # rank 8.55 lands between 9 and 10
}
It "returns the single value for all percentiles when one sample" {
$stats = Get-TimingStats -Samples @(42)
$stats.p50 | Should -Be 42
$stats.p95 | Should -Be 42
$stats.p99 | Should -Be 42
}
}
Describe "Get-Percentile" {
It "returns the min at p0 and max at p100" {
(Get-Percentile -Samples @(10, 20, 30, 40) -Percentile 0.0) | Should -Be 10
(Get-Percentile -Samples @(10, 20, 30, 40) -Percentile 1.0) | Should -Be 40
}
It "interpolates between ranks" {
# sorted 10,20,30,40; rank = 0.5*3 = 1.5 -> 20 + 0.5*(30-20) = 25
(Get-Percentile -Samples @(40, 10, 30, 20) -Percentile 0.5) | Should -Be 25
}
It "returns an exact sample when the rank is an integer" {
# sorted 1..11, rank = 0.9*(11-1) = 9 -> sorted[9] = 10
(Get-Percentile -Samples @(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11) -Percentile 0.9) | Should -Be 10
}
It "throws for out-of-range percentiles" {
{ Get-Percentile -Samples @(1) -Percentile 1.5 } | Should -Throw
{ Get-Percentile -Samples @(1) -Percentile -0.1 } | Should -Throw
}
It "throws when no samples are provided" {
{ Get-Percentile -Samples @() -Percentile 0.5 } | Should -Throw
}
}
Describe "Split-ColdWarm" {
It "treats the first sample as cold and the rest as warm" {
$split = Split-ColdWarm -Samples @(100, 40, 50, 60)
$split.cold_ms | Should -Be 100
# warm samples 40,50,60 -> median 50
$split.warm.median | Should -Be 50
}
It "falls back to the single sample for warm when only one sample exists" {
$split = Split-ColdWarm -Samples @(77)
$split.cold_ms | Should -Be 77
$split.warm.median | Should -Be 77
}
It "throws when no samples are provided" {
{ Split-ColdWarm -Samples @() } | Should -Throw
}
}
Describe "Resolve-PerfOutputPath" {
It "resolves a bare filename against the base directory and returns an absolute path" {
$result = Resolve-PerfOutputPath -Path 'startup-e2e.json' -BaseDirectory $script:RunFolder
[System.IO.Path]::IsPathRooted($result) | Should -Be $true
Split-Path -Parent $result | Should -Be ([System.IO.Path]::GetFullPath($script:RunFolder))
}
It "creates a missing parent directory for a relative nested path" {
$nested = Join-Path 'nested-a' 'nested-b/out.json'
$result = Resolve-PerfOutputPath -Path $nested -BaseDirectory $script:RunFolder
Test-Path (Split-Path -Parent $result) | Should -Be $true
}
It "creates a missing parent directory for an absolute path" {
$abs = Join-Path $script:RunFolder 'abs-dir/out.json'
$result = Resolve-PerfOutputPath -Path $abs
Test-Path (Split-Path -Parent $result) | Should -Be $true
$result | Should -Be ([System.IO.Path]::GetFullPath($abs))
}
It "does not throw for a bare filename (regression for empty Split-Path parent)" {
{ Resolve-PerfOutputPath -Path 'only-a-filename.json' -BaseDirectory $script:RunFolder } | Should -Not -Throw
}
}
Describe "Get-PerfRunMetadata" {
It "records all controlled-environment dimensions required by issue #3120" {
$m = Get-PerfRunMetadata
foreach ($key in @('commit', 'timestamp', 'runtime', 'os', 'os_architecture', 'cpu_cores', 'machine_memory_gb', 'workload_catalog_version')) {
$m.Contains($key) | Should -BeTrue -Because "metadata must include '$key'"
}
}
It "reports a positive processor count" {
(Get-PerfRunMetadata).cpu_cores | Should -BeGreaterThan 0
}
}
Describe "Get-WorkloadCatalogVersion" {
It "returns the catalog version from the committed workload catalog" {
Get-WorkloadCatalogVersion | Should -Not -BeNullOrEmpty
}
It "returns 'unknown' when the catalog file is absent" {
Get-WorkloadCatalogVersion -CatalogPath (Join-Path $script:RunFolder 'no-such-catalog.json') | Should -Be 'unknown'
}
}
Describe "Get-TimingStats sub-millisecond precision" {
It "preserves fractional-millisecond percentiles instead of rounding to whole ms (F-004)" {
# Regression: double samples previously bound to [long[]] and were rounded to 0,0,1,1.
$stats = Get-TimingStats -Samples @(0.41, 0.49, 0.51, 0.59)
$stats.median | Should -Be 0.5
$stats.p95 | Should -BeGreaterThan 0.5
$stats.p95 | Should -BeLessThan 0.6
}
}
Describe "Invoke-StartupRegressionGate" {
It "reports no failures for an identical baseline" {
$s = [ordered]@{ mcp_stdio_to_tools_list_ms = [ordered]@{ p50 = 100; p95 = 120; p99 = 140 } }
$b = [ordered]@{ mcp_stdio_to_tools_list_ms = [ordered]@{ p50 = 100; p95 = 120; p99 = 140 } }
$gate = Invoke-StartupRegressionGate -ResultScenarios $s -BaselineScenarios $b
@($gate.Failures).Count | Should -Be 0
}
It "fails when nothing overlaps results and baseline (F-007)" {
$s = [ordered]@{ mcp_stdio_to_tools_list_ms = [ordered]@{ p50 = 100; p95 = 120; p99 = 140 } }
$b = [ordered]@{ }
$gate = Invoke-StartupRegressionGate -ResultScenarios $s -BaselineScenarios $b
$gate.Failures | Should -Contain 'no_comparable_metrics'
}
It "fails when a metric the baseline expects is missing from results (F-007)" {
$s = [ordered]@{ mcp_stdio_to_tools_list_ms = [ordered]@{ p50 = 100; p95 = 120 } }
$b = [ordered]@{ mcp_stdio_to_tools_list_ms = [ordered]@{ p50 = 100; p95 = 120; p99 = 140 } }
$gate = Invoke-StartupRegressionGate -ResultScenarios $s -BaselineScenarios $b
$gate.Failures | Should -Contain 'mcp_stdio_default (p99, missing in results)'
}
It "treats a positive current against a zero baseline as a failure (F-010)" {
$s = [ordered]@{ mcp_stdio_to_tools_list_ms = [ordered]@{ p50 = 100; p95 = 120; p99 = 140 } }
$b = [ordered]@{ mcp_stdio_to_tools_list_ms = [ordered]@{ p50 = 0; p95 = 0; p99 = 0 } }
$gate = Invoke-StartupRegressionGate -ResultScenarios $s -BaselineScenarios $b
@($gate.Failures).Count | Should -BeGreaterThan 0
}
It "accepts a -RepoRoot argument when stamping run metadata (F-009)" {
$m = Get-PerfRunMetadata -RepoRoot $PSScriptRoot
$m.Contains('commit') | Should -BeTrue
}
}
+430
View File
@@ -0,0 +1,430 @@
#Requires -Version 7
<#
.SYNOPSIS
Shared, side-effect-free helpers for the startup performance measurement scripts.
.DESCRIPTION
These functions are extracted into their own file so they can be dot-sourced by
Test-StartupPerformance.ps1 and covered by focused Pester tests without spawning
the server or running the full measurement pipeline.
#>
Set-StrictMode -Version Latest
# ---------------------------------------------------------------------------
# Compute the p-th percentile of a sample array using linear interpolation
# between closest ranks (the inclusive method, equivalent to Excel's
# PERCENTILE.INC). p is expressed as a fraction in [0, 1].
# A single sample returns that sample for any percentile.
# ---------------------------------------------------------------------------
function Get-Percentile {
[CmdletBinding()]
param(
[Parameter(Mandatory)][double[]] $Samples,
[Parameter(Mandatory)][double] $Percentile
)
if ($null -eq $Samples -or $Samples.Count -eq 0) {
throw "Get-Percentile requires at least one sample."
}
if ($Percentile -lt 0 -or $Percentile -gt 1) {
throw "Get-Percentile requires Percentile in the range [0, 1]."
}
$sorted = @($Samples | Sort-Object)
$count = $sorted.Count
if ($count -eq 1) { return [double]$sorted[0] }
$rank = $Percentile * ($count - 1)
$lower = [int][math]::Floor($rank)
$upper = [int][math]::Ceiling($rank)
if ($lower -eq $upper) { return [double]$sorted[$lower] }
$frac = $rank - $lower
return [double]$sorted[$lower] + ($sorted[$upper] - $sorted[$lower]) * $frac
}
# ---------------------------------------------------------------------------
# Compute first/average/median/p50/p95/p99 timing stats from a sample array.
# For an even number of samples the median is the average of the two middle
# values (the statistically correct definition); for an odd count it is the
# single middle value. p50/p95/p99 are computed via Get-Percentile (linear
# interpolation) so they align with the budgets in the acceptance criteria.
# ---------------------------------------------------------------------------
function Get-TimingStats {
[CmdletBinding()]
param([Parameter(Mandatory)][double[]] $Samples)
if ($null -eq $Samples -or $Samples.Count -eq 0) {
throw "Get-TimingStats requires at least one sample."
}
$sorted = @($Samples | Sort-Object)
$count = $sorted.Count
if ($count % 2 -eq 1) {
$median = $sorted[[math]::Floor($count / 2)]
}
else {
$upper = $count / 2
$lower = $upper - 1
$median = ($sorted[$lower] + $sorted[$upper]) / 2
}
return [ordered]@{
first = $Samples[0]
average = [math]::Round(($Samples | Measure-Object -Average).Average, 1)
median = $median
p50 = [math]::Round((Get-Percentile -Samples $Samples -Percentile 0.50), 4)
p95 = [math]::Round((Get-Percentile -Samples $Samples -Percentile 0.95), 4)
p99 = [math]::Round((Get-Percentile -Samples $Samples -Percentile 0.99), 4)
all = $Samples
}
}
# ---------------------------------------------------------------------------
# Split a sample array into cold (first invocation) and warm (all subsequent
# invocations) timing stats. Process-level cold vs warm startup is captured by
# treating the first sample as cold (OS/file-cache/JIT cold) and the remaining
# samples as warm. When only one sample exists, warm falls back to that sample.
# ---------------------------------------------------------------------------
function Split-ColdWarm {
[CmdletBinding()]
param([Parameter(Mandatory)][long[]] $Samples)
if ($null -eq $Samples -or $Samples.Count -eq 0) {
throw "Split-ColdWarm requires at least one sample."
}
$cold = [long]$Samples[0]
$warmSamples = if ($Samples.Count -gt 1) { @($Samples[1..($Samples.Count - 1)]) } else { @($Samples[0]) }
return [ordered]@{
cold_ms = $cold
warm = Get-TimingStats -Samples $warmSamples
}
}
# ---------------------------------------------------------------------------
# Reads a named member from either an ordered hashtable (results built in-process)
# or a PSCustomObject (parsed from JSON). Returns $null when the member is absent
# so callers can gracefully skip metrics missing from an older baseline.
# ---------------------------------------------------------------------------
function Get-MemberValue {
[CmdletBinding()]
param($InputObject, [Parameter(Mandatory)][string] $Name)
if ($null -eq $InputObject) { return $null }
if ($InputObject -is [System.Collections.IDictionary]) {
if ($InputObject.Contains($Name)) { return $InputObject[$Name] }
return $null
}
$prop = $InputObject.PSObject.Properties[$Name]
if ($prop) { return $prop.Value }
return $null
}
# ---------------------------------------------------------------------------
# Runs an external perf-harness process with a hard timeout, capturing stdout and
# stderr and rejecting a non-zero exit code. Prevents a hung or crashed harness
# from stalling the whole job or being mistaken for a successful measurement.
# Returns the captured stdout string.
# ---------------------------------------------------------------------------
function Invoke-PerfHarnessProcess {
[CmdletBinding()]
param(
[Parameter(Mandatory)][string] $FilePath,
[string[]] $ArgumentList = @(),
[int] $TimeoutSeconds = 120
)
$psi = [System.Diagnostics.ProcessStartInfo]::new()
$psi.FileName = $FilePath
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
foreach ($arg in $ArgumentList) { $null = $psi.ArgumentList.Add($arg) }
$proc = [System.Diagnostics.Process]::new()
$proc.StartInfo = $psi
try {
if (-not $proc.Start()) { throw "Failed to start process '$FilePath'." }
$stdoutTask = $proc.StandardOutput.ReadToEndAsync()
$stderrTask = $proc.StandardError.ReadToEndAsync()
if (-not $proc.WaitForExit($TimeoutSeconds * 1000)) {
try { $proc.Kill($true) }
catch { if (-not $proc.HasExited) { throw } }
$null = $proc.WaitForExit(5000)
$errOut = $stderrTask.GetAwaiter().GetResult()
$errMsg = if ([string]::IsNullOrWhiteSpace($errOut)) { '<no stderr>' } else { $errOut.Trim() }
throw "Process '$FilePath' timed out after $TimeoutSeconds seconds. Stderr: $errMsg"
}
$outText = $stdoutTask.GetAwaiter().GetResult()
$errOut = $stderrTask.GetAwaiter().GetResult()
if ($proc.ExitCode -ne 0) {
$errMsg = if ([string]::IsNullOrWhiteSpace($errOut)) { '<no stderr>' } else { $errOut.Trim() }
throw "Process '$FilePath' exited with code $($proc.ExitCode). Stderr: $errMsg"
}
return $outText
}
finally {
$proc.Dispose()
}
}
# ---------------------------------------------------------------------------
# Runs a perf-harness process (see Invoke-PerfHarnessProcess) and returns the last
# JSON object it emitted on stdout, parsed. Throws if the harness produced no JSON
# line (after already rejecting non-zero exits), so a silent/garbled run fails loudly.
# ---------------------------------------------------------------------------
function Invoke-PerfHarnessJson {
[CmdletBinding()]
param(
[Parameter(Mandatory)][string] $FilePath,
[string[]] $ArgumentList = @(),
[int] $TimeoutSeconds = 120
)
$stdout = Invoke-PerfHarnessProcess -FilePath $FilePath -ArgumentList $ArgumentList -TimeoutSeconds $TimeoutSeconds
$jsonLine = @($stdout -split "`r?`n") | Where-Object { $_ -match '^\s*\{' } | Select-Object -Last 1
if ([string]::IsNullOrWhiteSpace($jsonLine)) {
throw "Harness '$FilePath' did not emit a JSON result line."
}
return $jsonLine | ConvertFrom-Json
}
# ---------------------------------------------------------------------------
# Tiered startup-regression gate shared by Test-StartupPerformance.ps1 and
# Check-StartupPerformanceRegression.ps1. Compares p50/p95/p99 of each tracked
# latency scenario against the baseline using the acceptance-criteria budgets
# from issue #3118:
# * p50 and p95 may not regress by more than 10% (FailP50P95 = 1.10)
# * p99 may not regress by more than 20% (FailP99 = 1.20)
# * any change above 5% is reported as a warning (Warn = 1.05)
# Discovery scaling efficiency (per-tool startup cost for the all-tools surface)
# may not degrade by more than 15% (ScalingDegrade = 1.15).
#
# Returns [ordered]@{ Failures = @(...); Warnings = @(...) } and writes a
# human-readable line per check via Write-Host.
# ---------------------------------------------------------------------------
function Invoke-StartupRegressionGate {
[CmdletBinding()]
param(
[Parameter(Mandatory)] $ResultScenarios,
[Parameter(Mandatory)] $BaselineScenarios,
[double] $FailP50P95 = 1.10,
[double] $FailP99 = 1.20,
[double] $Warn = 1.05,
[double] $ScalingDegrade = 1.15
)
$failures = @()
$warnings = @()
# Latency scenarios: display name -> scenario property holding a stats object.
$latencyScenarios = [ordered]@{
'cli_cold_start' = 'cli_cold_start_ms'
'mcp_stdio_default' = 'mcp_stdio_to_tools_list_ms'
'mcp_namespace' = 'mcp_namespace_mode_startup_ms'
'mcp_all' = 'mcp_all_mode_startup_ms'
'mcp_http_default' = 'mcp_http_default_startup_ms'
}
$percentileBudgets = @(
@{ Metric = 'p50'; Limit = $FailP50P95 }
@{ Metric = 'p95'; Limit = $FailP50P95 }
@{ Metric = 'p99'; Limit = $FailP99 }
)
$evaluated = 0
foreach ($name in $latencyScenarios.Keys) {
$key = $latencyScenarios[$name]
$curStats = Get-MemberValue $ResultScenarios $key
$baseStats = Get-MemberValue $BaselineScenarios $key
if ($null -eq $baseStats) {
Write-Host (" [SKIP] {0}: absent in baseline" -f $name)
continue
}
if ($null -eq $curStats) {
$failures += "$name (missing in results)"
Write-Host (" [FAIL] {0}: expected by baseline but absent in results" -f $name)
continue
}
foreach ($budget in $percentileBudgets) {
$metric = $budget.Metric
$current = Get-MemberValue $curStats $metric
$baseline = Get-MemberValue $baseStats $metric
if ($null -eq $baseline) {
Write-Host (" [SKIP] {0} ({1}): absent in baseline" -f $name, $metric)
continue
}
if ($null -eq $current) {
$failures += "$name ($metric, missing in results)"
Write-Host (" [FAIL] {0} ({1}): expected by baseline but absent in results" -f $name, $metric)
continue
}
$label = "$name ($metric)"
$evaluated++
if ($baseline -gt 0) {
$ratio = $current / $baseline
$limit = [math]::Round($baseline * $budget.Limit, 1)
if ($ratio -gt $budget.Limit) { $failures += $label; $status = 'FAIL' }
elseif ($ratio -gt $Warn) { $warnings += $label; $status = 'WARN' }
else { $status = 'PASS' }
Write-Host (" [{0}] {1}: current={2}ms baseline={3}ms limit={4}ms (+{5}%)" -f `
$status, $label, $current, $baseline, $limit, [math]::Round(($ratio - 1) * 100, 1))
}
elseif ($current -gt 0) {
# Positive current against a zero baseline is not comparable — fail rather than
# silently pass with a fabricated 0% change.
$failures += $label
Write-Host (" [FAIL] {0}: current={1}ms vs a zero baseline (not comparable)" -f $label, $current)
}
else {
Write-Host (" [PASS] {0}: current=0ms baseline=0ms (unchanged)" -f $label)
}
}
}
# Discovery scaling efficiency: per-tool startup cost for the all-tools surface.
$curAll = Get-MemberValue $ResultScenarios 'mcp_all_mode_startup_ms'
$curAllPl = Get-MemberValue $ResultScenarios 'all_mode_tools_list_payload'
$baseAll = Get-MemberValue $BaselineScenarios 'mcp_all_mode_startup_ms'
$baseAllPl = Get-MemberValue $BaselineScenarios 'all_mode_tools_list_payload'
$curP50 = Get-MemberValue $curAll 'p50'
$curCount = Get-MemberValue $curAllPl 'tool_count'
$baseP50 = Get-MemberValue $baseAll 'p50'
$baseCount = Get-MemberValue $baseAllPl 'tool_count'
if ($curP50 -and $curCount -and $baseP50 -and $baseCount -and $curCount -gt 0 -and $baseCount -gt 0) {
$curPerTool = $curP50 / $curCount
$basePerTool = $baseP50 / $baseCount
$ratio = if ($basePerTool -gt 0) { $curPerTool / $basePerTool } else { 1 }
$evaluated++
if ($ratio -gt $ScalingDegrade) {
$failures += 'discovery_scaling_efficiency'
$status = 'FAIL'
}
elseif ($ratio -gt $Warn) {
$warnings += 'discovery_scaling_efficiency'
$status = 'WARN'
}
else {
$status = 'PASS'
}
Write-Host (" [{0}] discovery_scaling_efficiency: current={1}ms/tool baseline={2}ms/tool (+{3}%)" -f `
$status, [math]::Round($curPerTool, 3), [math]::Round($basePerTool, 3), [math]::Round(($ratio - 1) * 100, 1))
}
else {
Write-Host " [SKIP] discovery_scaling_efficiency: all-mode metrics absent"
}
if ($evaluated -eq 0) {
# Nothing overlapped between results and baseline — an incompatible or empty baseline
# must not be reported as a pass.
$failures += 'no_comparable_metrics'
Write-Host " [FAIL] no comparable latency metrics between results and baseline (incompatible or empty baseline)"
}
return [ordered]@{ Failures = $failures; Warnings = $warnings }
}
# ---------------------------------------------------------------------------
# Normalize an output file path so its parent directory always exists.
# A bare filename (no directory component) otherwise makes Split-Path return an
# empty string, which causes New-Item -Path '' to fail. Relative paths are
# resolved against BaseDirectory so the parent directory is unambiguous.
# Returns the absolute file path.
# ---------------------------------------------------------------------------
function Resolve-PerfOutputPath {
[CmdletBinding()]
param(
[Parameter(Mandatory)][string] $Path,
[string] $BaseDirectory = (Get-Location).Path
)
if (-not [System.IO.Path]::IsPathRooted($Path)) {
$Path = Join-Path $BaseDirectory $Path
}
$Path = [System.IO.Path]::GetFullPath($Path)
$parent = Split-Path -Parent $Path
if ($parent -and -not (Test-Path $parent)) {
$null = New-Item -ItemType Directory -Force -Path $parent
}
return $Path
}
# ---------------------------------------------------------------------------
# Reads the catalog_version from the versioned workload catalog (eng/perf-workloads.json),
# used to stamp every result so a number can be tied to the workload definition that produced
# it (issue #3120). Returns 'unknown' if the catalog is missing or unreadable.
# ---------------------------------------------------------------------------
function Get-WorkloadCatalogVersion {
[CmdletBinding()]
param(
[string] $CatalogPath = (Join-Path $PSScriptRoot '../perf-workloads.json')
)
try {
if (Test-Path $CatalogPath) {
return (Get-Content $CatalogPath -Raw | ConvertFrom-Json).catalog_version
}
}
catch {
# Fall through to the default below.
}
return 'unknown'
}
# ---------------------------------------------------------------------------
# Captures the controlled-environment metadata every perf result must record (issue #3120):
# commit, runtime, OS, CPU, machine memory, plus the workload catalog version. Harnesses embed
# this under an "environment" key so results are self-describing and comparable across runs.
# Memory is best-effort and cross-platform; unresolved values are null rather than fatal.
# ---------------------------------------------------------------------------
function Get-PerfRunMetadata {
[CmdletBinding()]
param([string] $RepoRoot)
# Resolve the commit against the repository that holds the script, not the caller's
# working directory, so invoking a harness by absolute path from another checkout
# (or from outside any repo) still stamps the correct commit.
$gitLocation = if ($RepoRoot) { @('-C', $RepoRoot) } else { @() }
$commit = try { (& git @gitLocation rev-parse --short HEAD 2>$null).Trim() } catch { '' }
$memoryGb = $null
try {
if ($IsWindows) {
$bytes = (Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop).TotalPhysicalMemory
$memoryGb = [math]::Round($bytes / 1GB, 1)
}
elseif ($IsLinux -and (Test-Path '/proc/meminfo')) {
$kb = ((Get-Content '/proc/meminfo' | Where-Object { $_ -match '^MemTotal:' }) -replace '[^\d]', '')
if ($kb) { $memoryGb = [math]::Round([double]$kb / 1048576, 1) }
}
}
catch {
$memoryGb = $null
}
return [ordered]@{
commit = $commit
timestamp = (Get-Date).ToUniversalTime().ToString('o')
runtime = [System.Runtime.InteropServices.RuntimeInformation]::FrameworkDescription
os = [System.Runtime.InteropServices.RuntimeInformation]::OSDescription
os_architecture = "$([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)"
cpu_cores = [Environment]::ProcessorCount
machine_memory_gb = $memoryGb
workload_catalog_version = Get-WorkloadCatalogVersion
}
}
+381
View File
@@ -0,0 +1,381 @@
#!/bin/env pwsh
#Requires -Version 7
<#
.SYNOPSIS
Measures end-to-end startup performance of the Azure MCP Server across four scenarios:
1. CLI cold start : azmcp tools list (process spawn → exit)
2. MCP stdio default : azmcp server start → initialize → tools/list response
3. MCP stdio namespace : azmcp server start --mode namespace → tools/list response
4. MCP stdio all-tools : azmcp server start --mode all → tools/list response
For each MCP scenario the script also tokenizes the tools/list response using the
GPT-4o o200k_base encoding (via Microsoft.ML.Tokenizers) to give exact token budgets.
.PARAMETER Executable
Path to the azmcp executable. Defaults to the Debug build in the repo.
.PARAMETER Runs
Number of timing samples per scenario. Default: 5.
.PARAMETER OutputPath
Optional path to write the JSON results file.
If omitted, results are written to .perf-results/startup-e2e.json under the repo root.
.PARAMETER BaselinePath
Optional path to a JSON baseline file (previously produced by this script).
When provided, the script compares results against the baseline and fails if any
scenario exceeds the tiered budgets from issue #3118 (p50/p95 +10%, p99 +20%,
discovery scaling efficiency +15%; changes above +5% are reported as warnings).
.EXAMPLE
# Default run against the Debug build
./eng/scripts/Test-StartupPerformance.ps1
.EXAMPLE
# Run against a Release build with more samples
./eng/scripts/Test-StartupPerformance.ps1 -Executable ./servers/Azure.Mcp.Server/src/bin/Release/net10.0/azmcp.exe -Runs 10
.EXAMPLE
# Compare against a saved baseline and fail on regression
./eng/scripts/Test-StartupPerformance.ps1 -BaselinePath eng/perf-baseline.json
#>
param(
[string] $Executable,
[int] $Runs = 10,
[string] $OutputPath,
[string] $BaselinePath
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# ---------------------------------------------------------------------------
# Resolve repo root and executable path
# ---------------------------------------------------------------------------
$ScriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { $PWD.Path }
$RepoRoot = (Get-Item (Join-Path $ScriptDir '../..')).FullName
# Shared, unit-tested helpers (Get-TimingStats, Resolve-PerfOutputPath).
. (Join-Path $ScriptDir 'StartupPerformance.Common.ps1')
if (-not $Executable) {
$Executable = Join-Path $RepoRoot 'servers/Azure.Mcp.Server/src/bin/Debug/net10.0/azmcp.exe'
if ($IsLinux -or $IsMacOS) {
$Executable = Join-Path $RepoRoot 'servers/Azure.Mcp.Server/src/bin/Debug/net10.0/azmcp'
}
}
if (-not (Test-Path $Executable)) {
Write-Error "azmcp executable not found at: $Executable`nBuild first: dotnet build servers/Azure.Mcp.Server/src"
}
# Resolve to a full path so child transports (e.g. the stdio SDK client) don't
# fail on a leading './' that Windows cmd cannot parse.
$Executable = (Resolve-Path $Executable).Path
$OutputPath = if ($OutputPath) { $OutputPath } else { Join-Path $RepoRoot '.perf-results/startup-e2e.json' }
$OutputPath = Resolve-PerfOutputPath -Path $OutputPath -BaseDirectory $RepoRoot
# ---------------------------------------------------------------------------
# Resolve benchmark exe path (Release build preferred, Debug as fallback)
# ---------------------------------------------------------------------------
$BenchmarkExeSuffix = if ($IsWindows) { '.exe' } else { '' }
$BenchmarkExeBase = Join-Path $RepoRoot 'servers/Azure.Mcp.Server/perf/Azure.Mcp.Server.PerformanceBenchmarks/bin'
$BenchmarkExe = Join-Path $BenchmarkExeBase "Release/net10.0/Azure.Mcp.Server.PerformanceBenchmarks$BenchmarkExeSuffix"
if (-not (Test-Path $BenchmarkExe)) {
$BenchmarkExe = Join-Path $BenchmarkExeBase "Debug/net10.0/Azure.Mcp.Server.PerformanceBenchmarks$BenchmarkExeSuffix"
}
if (-not (Test-Path $BenchmarkExe)) {
Write-Error "Benchmark exe not found. Build first: dotnet build -c Release servers/Azure.Mcp.Server/perf/Azure.Mcp.Server.PerformanceBenchmarks"
}
Write-Host "azmcp executable : $Executable"
Write-Host "Benchmark exe : $BenchmarkExe"
Write-Host "Samples per scenario : $Runs"
Write-Host ""
# ---------------------------------------------------------------------------
# Helper: measure-cli-coldstart
# Time from OS process creation to process exit for a single CLI invocation.
# ---------------------------------------------------------------------------
function Measure-CliColdStart {
param([string] $ExePath)
$psi = [System.Diagnostics.ProcessStartInfo]@{
FileName = $ExePath
Arguments = 'tools list'
RedirectStandardOutput = $true
RedirectStandardError = $true
UseShellExecute = $false
CreateNoWindow = $true
}
$timeoutMs = 30000
$sw = [System.Diagnostics.Stopwatch]::StartNew()
$proc = [System.Diagnostics.Process]::Start($psi)
try {
# Drain both streams asynchronously to prevent buffer deadlocks
$stdoutTask = $proc.StandardOutput.ReadToEndAsync()
$stderrTask = $proc.StandardError.ReadToEndAsync()
if (-not $proc.WaitForExit($timeoutMs)) {
$sw.Stop()
try { $proc.Kill($true) }
catch { if (-not $proc.HasExited) { throw } }
$null = $proc.WaitForExit(5000)
$stderr = $stderrTask.GetAwaiter().GetResult()
$stderrMessage = if ([string]::IsNullOrWhiteSpace($stderr)) { '<no stderr>' } else { $stderr.Trim() }
throw "CLI cold start timed out after $timeoutMs ms for '$ExePath tools list'. stderr: $stderrMessage"
}
$sw.Stop()
$null = $stdoutTask.GetAwaiter().GetResult()
$stderr = $stderrTask.GetAwaiter().GetResult()
if ($proc.ExitCode -ne 0) {
$stderrMessage = if ([string]::IsNullOrWhiteSpace($stderr)) { '<no stderr>' } else { $stderr.Trim() }
throw "CLI cold start failed with exit code $($proc.ExitCode) for '$ExePath tools list'. stderr: $stderrMessage"
}
return $sw.ElapsedMilliseconds
}
finally {
if ($null -ne $proc) { $proc.Dispose() }
}
}
# ---------------------------------------------------------------------------
# Helper: invoke-benchmark-mcp-startup
# Calls the benchmark binary with --mcp-startup so the official MCP C# SDK
# performs the initialize + tools/list handshake and reports timing.
# The stopwatch inside the binary starts before McpClient.CreateAsync (which
# spawns the server process) and stops after ListToolsAsync completes.
# Returns the parsed JSON result object:
# { elapsed_ms, tool_count,
# name_description: { bytes, exact_tokens_gpt4o_o200k, approx_tokens_bytes_div_4 },
# full_schema: { bytes, exact_tokens_gpt4o_o200k, approx_tokens_bytes_div_4 } }
# ---------------------------------------------------------------------------
function Invoke-BenchmarkMcpStartup {
param(
[string] $BenchmarkExe,
[string] $ExePath,
[string[]] $ServerArgTokens,
[int] $TimeoutSeconds = 60,
# '--mcp-startup' (stdio) or '--mcp-startup-http' (HTTP transport).
[string] $BenchmarkMode = '--mcp-startup'
)
# Bounded execution (hard timeout, stderr capture, non-zero exit rejection) and JSON parsing
# are shared with the other perf runners via Invoke-PerfHarnessJson in StartupPerformance.Common.ps1.
return Invoke-PerfHarnessJson -FilePath $BenchmarkExe `
-ArgumentList (@($BenchmarkMode, $ExePath) + $ServerArgTokens) `
-TimeoutSeconds $TimeoutSeconds
}
# ---------------------------------------------------------------------------
# Helper: print a tools/list payload summary to the console
# ---------------------------------------------------------------------------
function Write-PayloadStats {
param([string] $Label, $Payload)
Write-Host "=== tools/list payload ($Label) ==="
Write-Host (" Tool count : {0}" -f $Payload.tool_count)
Write-Host (" Name+description bytes : {0:N0}" -f $Payload.name_description_bytes)
Write-Host (" Name+description exact tokens (GPT-4o) : {0:N0}" -f $Payload.name_description_tokens_exact)
Write-Host (" Full schema bytes (incl. inputSchema) : {0:N0}" -f $Payload.full_schema_bytes)
Write-Host (" Full schema exact tokens (GPT-4o) [LLM cost]: {0:N0}" -f $Payload.full_schema_tokens_exact)
Write-Host (" Full schema serialization time (ms) : {0}" -f $Payload.full_schema_serialize_ms)
Write-Host ""
}
# ---------------------------------------------------------------------------
# Helper: run one MCP scenario (timing loop + cold/warm split + percentiles +
# token/serialization measurement). Works for both stdio and HTTP transports
# via the $BenchmarkMode switch. Uses the MCP C# SDK (via the benchmark binary)
# for protocol-correct timing.
# Returns [ordered]@{ stats = ...; coldWarm = ...; payload = ... }
# ---------------------------------------------------------------------------
function Invoke-McpScenario {
param(
[string] $Label,
[string[]] $ServerArgs,
[string] $ExePath,
[int] $Runs,
[string] $BenchmarkExe,
[string] $BenchmarkMode = '--mcp-startup',
[int] $TimeoutSeconds = 60
)
Write-Host "=== $Label ==="
$ms = @()
$readinessMs = @()
$lastBenchResult = $null
for ($i = 1; $i -le $Runs; $i++) {
$benchResult = Invoke-BenchmarkMcpStartup -BenchmarkExe $BenchmarkExe `
-ExePath $ExePath -ServerArgTokens $ServerArgs `
-BenchmarkMode $BenchmarkMode -TimeoutSeconds $TimeoutSeconds
$ms += [long]$benchResult.elapsed_ms
if ($benchResult.PSObject.Properties.Name -contains 'readiness_ms') {
$readinessMs += [long]$benchResult.readiness_ms
}
$lastBenchResult = $benchResult
Write-Host (" Run {0}: {1} ms" -f $i, $benchResult.elapsed_ms)
}
$stats = Get-TimingStats -Samples $ms
$coldWarm = Split-ColdWarm -Samples $ms
Write-Host (" → cold={0} ms warm-median={1} ms p95={2} ms p99={3} ms" -f `
$coldWarm.cold_ms, $coldWarm.warm.median, $stats.p95, $stats.p99)
Write-Host ""
$payload = [ordered]@{
tool_count = [int]$lastBenchResult.tool_count
name_description_bytes = [int]$lastBenchResult.name_description.bytes
name_description_tokens_exact = [int]$lastBenchResult.name_description.exact_tokens_gpt4o_o200k
full_schema_bytes = [int]$lastBenchResult.full_schema.bytes
full_schema_tokens_exact = [int]$lastBenchResult.full_schema.exact_tokens_gpt4o_o200k
full_schema_serialize_ms = [double]$lastBenchResult.full_schema.serialize_ms
}
Write-PayloadStats -Label $Label -Payload $payload
$result = [ordered]@{ stats = $stats; coldWarm = $coldWarm; payload = $payload }
if ($readinessMs.Count -gt 0) {
$result.readiness = Get-TimingStats -Samples $readinessMs
}
return $result
}
# ---------------------------------------------------------------------------
# Scenario 1 – CLI cold start
# ---------------------------------------------------------------------------
Write-Host "=== Scenario 1: CLI cold start (azmcp tools list) ==="
$cliMs = @()
for ($i = 1; $i -le $Runs; $i++) {
$ms = Measure-CliColdStart -ExePath $Executable
$cliMs += $ms
Write-Host (" Run {0}: {1} ms" -f $i, $ms)
}
$cli = Get-TimingStats -Samples $cliMs
$cliColdWarm = Split-ColdWarm -Samples $cliMs
Write-Host (" → cold={0} ms warm-median={1} ms p95={2} ms p99={3} ms" -f `
$cliColdWarm.cold_ms, $cliColdWarm.warm.median, $cli.p95, $cli.p99)
Write-Host ""
# ---------------------------------------------------------------------------
# Scenarios 2-4 – MCP stdio startup across three server modes
# Timing is performed by the benchmark binary using the official MCP C# SDK
# (McpClient.CreateAsync + ListToolsAsync via StdioClientTransport).
# ---------------------------------------------------------------------------
$s2 = Invoke-McpScenario -Label 'Scenario 2: MCP stdio startup (default mode)' `
-ServerArgs @('server', 'start') -ExePath $Executable -Runs $Runs `
-BenchmarkExe $BenchmarkExe
$s3 = Invoke-McpScenario -Label 'Scenario 3: MCP stdio startup (--mode namespace)' `
-ServerArgs @('server', 'start', '--mode', 'namespace') -ExePath $Executable -Runs $Runs `
-BenchmarkExe $BenchmarkExe
$s4 = Invoke-McpScenario -Label 'Scenario 4: MCP stdio startup (--mode all)' `
-ServerArgs @('server', 'start', '--mode', 'all') -ExePath $Executable -Runs $Runs `
-BenchmarkExe $BenchmarkExe
# ---------------------------------------------------------------------------
# Scenario 5 – MCP remote HTTP startup (default mode)
# The benchmark binary spawns the server with --transport http
# --dangerously-disable-http-incoming-auth on a free loopback port, polls
# readiness, then times the initialize + tools/list handshake over
# HttpClientTransport. readiness_ms captures server-ready time separately.
# HTTP startup includes web-host build + Kestrel bind, so it needs a longer
# per-run timeout than stdio.
# ---------------------------------------------------------------------------
$s5 = Invoke-McpScenario -Label 'Scenario 5: MCP remote HTTP startup (default mode)' `
-ServerArgs @('server', 'start') -ExePath $Executable -Runs $Runs `
-BenchmarkExe $BenchmarkExe -BenchmarkMode '--mcp-startup-http' `
-TimeoutSeconds 90
# ---------------------------------------------------------------------------
# Write results JSON
# ---------------------------------------------------------------------------
$gitCommit = (& git -C $RepoRoot rev-parse --short HEAD 2>$null) ?? 'unknown'
$results = [ordered]@{
timestamp = (Get-Date -Format 'o')
commit = $gitCommit
executable = $Executable
runs = $Runs
environment = (Get-PerfRunMetadata -RepoRoot $RepoRoot)
scenarios = [ordered]@{
cli_cold_start_ms = $cli
cli_cold_warm = $cliColdWarm
mcp_stdio_to_tools_list_ms = $s2.stats
mcp_stdio_cold_warm = $s2.coldWarm
tools_list_payload = $s2.payload
mcp_namespace_mode_startup_ms = $s3.stats
mcp_namespace_cold_warm = $s3.coldWarm
namespace_mode_tools_list_payload = $s3.payload
mcp_all_mode_startup_ms = $s4.stats
mcp_all_cold_warm = $s4.coldWarm
all_mode_tools_list_payload = $s4.payload
mcp_http_default_startup_ms = $s5.stats
mcp_http_cold_warm = $s5.coldWarm
mcp_http_readiness_ms = $s5.readiness
http_default_tools_list_payload = $s5.payload
}
}
$resultsJson = $results | ConvertTo-Json -Depth 10
$resultsJson | Out-File -FilePath $OutputPath -Encoding utf8 -Force
Write-Host "Results written to: $OutputPath"
# ---------------------------------------------------------------------------
# Optional: regression check against a baseline file
# ---------------------------------------------------------------------------
if ($BaselinePath) {
if (-not (Test-Path $BaselinePath)) {
Write-Warning "Baseline file not found: $BaselinePath (skipping regression check)"
}
else {
Write-Host ""
Write-Host "=== Regression check (p50/p95 +10%, p99 +20%, scaling +15%, warn +5%) ==="
$baseline = Get-Content $BaselinePath | ConvertFrom-Json
$gate = Invoke-StartupRegressionGate -ResultScenarios $results.scenarios `
-BaselineScenarios $baseline.scenarios
if ($gate.Warnings.Count -gt 0) {
Write-Warning "Startup performance warnings (>5%): $($gate.Warnings -join ', ')"
}
if ($gate.Failures.Count -gt 0) {
Write-Error "Startup regression detected in: $($gate.Failures -join ', ')"
}
else {
Write-Host " All budget checks passed."
}
}
}
# ---------------------------------------------------------------------------
# Emit Azure DevOps pipeline variables (no-op outside AzDO)
# ---------------------------------------------------------------------------
Write-Host "##vso[task.setvariable variable=CliColdStartMedianMs]$($cli.median)"
Write-Host "##vso[task.setvariable variable=CliColdStartColdMs]$($cliColdWarm.cold_ms)"
Write-Host "##vso[task.setvariable variable=CliColdStartWarmMedianMs]$($cliColdWarm.warm.median)"
Write-Host "##vso[task.setvariable variable=CliColdStartP95Ms]$($cli.p95)"
Write-Host "##vso[task.setvariable variable=CliColdStartP99Ms]$($cli.p99)"
Write-Host "##vso[task.setvariable variable=McpStdioStartupMedianMs]$($s2.stats.median)"
Write-Host "##vso[task.setvariable variable=McpStdioStartupP95Ms]$($s2.stats.p95)"
Write-Host "##vso[task.setvariable variable=McpStdioStartupP99Ms]$($s2.stats.p99)"
Write-Host "##vso[task.setvariable variable=ToolsListToolCount]$($s2.payload.tool_count)"
Write-Host "##vso[task.setvariable variable=ToolsListNameDescTokensGpt4o]$($s2.payload.name_description_tokens_exact)"
Write-Host "##vso[task.setvariable variable=ToolsListFullSchemaTokensGpt4o]$($s2.payload.full_schema_tokens_exact)"
Write-Host "##vso[task.setvariable variable=ToolsListFullSchemaSerializeMs]$($s2.payload.full_schema_serialize_ms)"
Write-Host "##vso[task.setvariable variable=McpNamespaceModeStartupMedianMs]$($s3.stats.median)"
Write-Host "##vso[task.setvariable variable=NamespaceModeToolsListToolCount]$($s3.payload.tool_count)"
Write-Host "##vso[task.setvariable variable=NamespaceModeToolsListFullSchemaTokensGpt4o]$($s3.payload.full_schema_tokens_exact)"
Write-Host "##vso[task.setvariable variable=AllModeStartupMedianMs]$($s4.stats.median)"
Write-Host "##vso[task.setvariable variable=AllModeStartupP95Ms]$($s4.stats.p95)"
Write-Host "##vso[task.setvariable variable=AllModeStartupP99Ms]$($s4.stats.p99)"
Write-Host "##vso[task.setvariable variable=AllModeToolsListToolCount]$($s4.payload.tool_count)"
Write-Host "##vso[task.setvariable variable=AllModeToolsListFullSchemaTokensGpt4o]$($s4.payload.full_schema_tokens_exact)"
Write-Host "##vso[task.setvariable variable=McpHttpDefaultStartupMedianMs]$($s5.stats.median)"
Write-Host "##vso[task.setvariable variable=McpHttpDefaultStartupP95Ms]$($s5.stats.p95)"
Write-Host "##vso[task.setvariable variable=McpHttpReadinessMedianMs]$(if ($s5.Contains('readiness')) { $s5.readiness.median } else { 'n/a' })"
Write-Host "##vso[build.addbuildtag]perf-tracked"
@@ -0,0 +1,106 @@
#!/bin/env pwsh
#Requires -Version 7
<#
.SYNOPSIS
Appends a startup-performance run to a persistent trend file and regenerates a
Markdown trend report.
.DESCRIPTION
Reads the results JSON produced by Test-StartupPerformance.ps1 and appends a compact,
one-line-per-run record to a JSON Lines (.jsonl) trend file. It then regenerates a
Markdown table summarizing the most recent runs so reviewers can eyeball the trend
(issue #3118: "Baseline results and trend report").
The JSONL file is the durable, machine-readable history; the Markdown file is a
human-readable rendering of the tail. Both are safe to commit as build artifacts.
.PARAMETER ResultsPath
Path to the results JSON written by Test-StartupPerformance.ps1.
.PARAMETER TrendPath
Path to the JSONL trend file to append to. Created if it does not exist.
Defaults to .perf-results/startup-trend.jsonl under the repo root.
.PARAMETER MarkdownPath
Path to the Markdown report to (re)write. Defaults to .perf-results/startup-trend.md.
.PARAMETER MaxRows
Maximum number of most-recent rows to render in the Markdown table (default 20).
#>
param(
[Parameter(Mandatory)][string] $ResultsPath,
[string] $TrendPath,
[string] $MarkdownPath,
[int] $MaxRows = 20
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'StartupPerformance.Common.ps1')
$ScriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { $PWD.Path }
$RepoRoot = (Get-Item (Join-Path $ScriptDir '../..')).FullName
if (-not $TrendPath) { $TrendPath = Join-Path $RepoRoot '.perf-results/startup-trend.jsonl' }
if (-not $MarkdownPath) { $MarkdownPath = Join-Path $RepoRoot '.perf-results/startup-trend.md' }
$TrendPath = Resolve-PerfOutputPath -Path $TrendPath -BaseDirectory $RepoRoot
$MarkdownPath = Resolve-PerfOutputPath -Path $MarkdownPath -BaseDirectory $RepoRoot
$results = Get-Content $ResultsPath | ConvertFrom-Json
$s = $results.scenarios
# Compact record: one flat object per run so the JSONL stays diff-friendly.
$record = [ordered]@{
timestamp = $results.timestamp
commit = $results.commit
runs = $results.runs
cli_cold_ms = (Get-MemberValue (Get-MemberValue $s 'cli_cold_start_ms') 'median')
cli_p95_ms = (Get-MemberValue (Get-MemberValue $s 'cli_cold_start_ms') 'p95')
stdio_default_median_ms = (Get-MemberValue (Get-MemberValue $s 'mcp_stdio_to_tools_list_ms') 'median')
stdio_default_p95_ms = (Get-MemberValue (Get-MemberValue $s 'mcp_stdio_to_tools_list_ms') 'p95')
all_median_ms = (Get-MemberValue (Get-MemberValue $s 'mcp_all_mode_startup_ms') 'median')
all_p95_ms = (Get-MemberValue (Get-MemberValue $s 'mcp_all_mode_startup_ms') 'p95')
http_default_median_ms = (Get-MemberValue (Get-MemberValue $s 'mcp_http_default_startup_ms') 'median')
http_readiness_median_ms = (Get-MemberValue (Get-MemberValue $s 'mcp_http_readiness_ms') 'median')
default_tool_count = (Get-MemberValue (Get-MemberValue $s 'tools_list_payload') 'tool_count')
default_full_schema_tokens = (Get-MemberValue (Get-MemberValue $s 'tools_list_payload') 'full_schema_tokens_exact')
all_tool_count = (Get-MemberValue (Get-MemberValue $s 'all_mode_tools_list_payload') 'tool_count')
all_full_schema_tokens = (Get-MemberValue (Get-MemberValue $s 'all_mode_tools_list_payload') 'full_schema_tokens_exact')
default_full_schema_serialize_ms = (Get-MemberValue (Get-MemberValue $s 'tools_list_payload') 'full_schema_serialize_ms')
}
# Append as a single compact JSON line.
$recordJson = $record | ConvertTo-Json -Depth 5 -Compress
Add-Content -Path $TrendPath -Value $recordJson -Encoding utf8
Write-Host "Appended trend record to: $TrendPath"
# Re-render the Markdown tail from the full JSONL history.
$allRecords = Get-Content $TrendPath | Where-Object { $_.Trim() } | ForEach-Object { $_ | ConvertFrom-Json }
$recent = @($allRecords | Select-Object -Last $MaxRows)
$sb = [System.Text.StringBuilder]::new()
$null = $sb.AppendLine('# Azure MCP Server startup performance trend')
$null = $sb.AppendLine()
$null = $sb.AppendLine("_Last updated: $(Get-Date -Format 'o'). Showing the $($recent.Count) most recent of $($allRecords.Count) runs._")
$null = $sb.AppendLine()
$null = $sb.AppendLine('| Commit | CLI cold (ms) | stdio p50/p95 (ms) | all p50/p95 (ms) | HTTP p50 (ms) | HTTP ready (ms) | default tools | default tokens | all tokens |')
$null = $sb.AppendLine('|--------|--------------:|-------------------:|-----------------:|--------------:|----------------:|--------------:|---------------:|-----------:|')
foreach ($r in $recent) {
$null = $sb.AppendLine(("| {0} | {1} | {2}/{3} | {4}/{5} | {6} | {7} | {8} | {9} | {10} |" -f `
(Get-MemberValue $r 'commit'),
(Get-MemberValue $r 'cli_cold_ms'),
(Get-MemberValue $r 'stdio_default_median_ms'), (Get-MemberValue $r 'stdio_default_p95_ms'),
(Get-MemberValue $r 'all_median_ms'), (Get-MemberValue $r 'all_p95_ms'),
(Get-MemberValue $r 'http_default_median_ms'),
(Get-MemberValue $r 'http_readiness_median_ms'),
(Get-MemberValue $r 'default_tool_count'),
(Get-MemberValue $r 'default_full_schema_tokens'),
(Get-MemberValue $r 'all_full_schema_tokens')))
}
$sb.ToString() | Out-File -FilePath $MarkdownPath -Encoding utf8 -Force
Write-Host "Wrote Markdown trend report to: $MarkdownPath"
+1
View File
@@ -29,6 +29,7 @@ extends:
PublishTarget: ${{ parameters.PublishTarget }}
RunLiveTests: true
RunVSCodeIntegrationTests: ${{ parameters.RunVSCodeIntegrationTests }}
RunPerfTests: true
PackageDocker: true
PackageVSIX: true
PackagePyPI: true
@@ -0,0 +1,3 @@
changes:
- section: "Other Changes"
description: "Added startup performance benchmarks and end-to-end measurement script (eng/scripts/Test-StartupPerformance.ps1) that measures CLI cold-start time and MCP startup time across stdio (default, namespace, all-tools) and remote HTTP transports, reporting cold vs. warm latency, p50/p95/p99 percentiles, server readiness time, tools/list JSON serialization time, and exact GPT-4o token budgets for each payload. A tool-catalog scaling benchmark measures discovery cost at current and projected tool counts. Results are gated against a baseline with tiered budgets (p50/p95 within 10%, p99 within 20%, scaling efficiency within 15%), summarized in a running trend report (eng/scripts/Update-StartupPerformanceTrend.ps1), and wired into the nightly AzDO pipeline via a new `Perf` stage in `common.yml`."
@@ -0,0 +1,3 @@
changes:
- section: "Other Changes"
description: "Added concurrency, throughput, and scalability validation for the Azure MCP Server (issue #3122). A new end-to-end harness (eng/scripts/Measure-ConcurrencyScaling.ps1 with a --mcp-concurrency mode) launches the server over HTTP and sweeps a ladder of concurrency levels, driving parallel MCP clients at each level and recording throughput, latency p50/p95/p99, error/timeout rate, concurrency scaling efficiency, and a state-isolation signal (concurrent responses whose tool count does not match the expected count, indicating cross-request leakage or a thread-safety failure). The resulting saturation curve shows where throughput plateaus. Results are gated against a baseline with the tiered budgets from issue #3122 (throughput within 10%, p95 latency and scaling efficiency within 15%, p99 latency within 20%, error-rate increase within 0.5 percentage points, and no state leakage), and wired into the AzDO perf pipeline."
@@ -0,0 +1,3 @@
changes:
- section: "Other Changes"
description: "Added command-dispatch and transport overhead benchmarks for the Azure MCP Server (issue #3119). New BenchmarkDotNet micro-benchmarks (CommandDispatchBenchmarks) measure each server-only dispatch layer in isolation — routing, argument parsing, option binding, validation, the execution frame, and response serialization — using a deterministic no-op command so no Azure work is included, which pinpoints the responsible layer for a regression. A new end-to-end harness (eng/scripts/Measure-DispatchOverhead.ps1 with --mcp-dispatch-stdio/--mcp-dispatch-http modes) times tools/list over stdio and HTTP and subtracts to isolate transport-specific overhead. Results are gated against a baseline with the tiered budgets from issue #3119 (p50/p95 within 10%, p99 within 20%, transport-overhead changes above 5% reported), and wired into the AzDO perf pipeline."
@@ -0,0 +1,3 @@
changes:
- section: "Other Changes"
description: "Added CPU, memory, and handle resource baselines for the Azure MCP Server (eng/scripts/Measure-ResourceUsage.ps1 and a new --mcp-resource harness) that sample process resource counters across the idle, startup, discovery, steady-state, and sustained soak phases under configurable concurrency. Soak leak detection fits a least-squares trend (with a warmup exclusion) to flag unbounded memory or handle growth. Results are gated against a baseline with the tiered budgets from issue #3121 (steady-state CPU and memory within 10%, peak memory within 15%, no unbounded soak growth), and wired into the AzDO perf pipeline."
@@ -0,0 +1,3 @@
changes:
- section: "Other Changes"
description: "Added a versioned, machine-readable performance workload catalog (eng/perf-workloads.json) and controlled-environment definition for the Azure MCP Server (issue #3120). The catalog defines every perf workload (startup, discovery scaling, dispatch, resource, concurrency) with its harness, transport, server mode, warm-up, repetitions, and metrics, plus shared environment requirements, statistical rules, and baseline-qualification governance. Every measurement harness now embeds a self-describing environment block (commit, runtime, OS, CPU, machine memory, and workload catalog version) in its results, and the default measured repetitions were raised to at least 10."
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
<!-- This is a dev-time perf tool; AOT/trim analyzers are not needed -->
<IsAotCompatible>false</IsAotCompatible>
<EnableAotAnalyzer>false</EnableAotAnalyzer>
<EnableTrimAnalyzer>false</EnableTrimAnalyzer>
<PublishTrimmed>false</PublishTrimmed>
<SuppressTrimAnalysisWarnings>true</SuppressTrimAnalysisWarnings>
<NoWarn>$(NoWarn);IL2026;IL3050;IL2075;IL2072</NoWarn>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Azure.Mcp.Server.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="BenchmarkDotNet" />
<PackageReference Include="Microsoft.ML.Tokenizers" />
<PackageReference Include="Microsoft.ML.Tokenizers.Data.O200kBase" />
<PackageReference Include="ModelContextProtocol" />
</ItemGroup>
<!-- Copy the server's appsettings so ConfigureServices can initialize correctly -->
<ItemGroup>
<Content Include="..\..\src\appsettings.json" Link="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>
@@ -0,0 +1,118 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.CommandLine;
using System.Text.Json;
using Azure.Mcp.Server;
using Azure.Mcp.Server.Perf.Dispatch;
using BenchmarkDotNet.Attributes;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Mcp.Core.Commands;
using Microsoft.Mcp.Core.Models;
using Microsoft.Mcp.Core.Models.Command;
namespace Azure.Mcp.Server.Perf.Benchmarks;
/// <summary>
/// Measures the Azure MCP Server's own per-call dispatch overhead in isolation (issue #3119):
/// the cost of routing a tool name to a command, parsing MCP arguments, binding and validating
/// options, running the execution frame, and serializing the response — all with a deterministic
/// <see cref="NoOpCommand"/> so no Azure or network latency is included.
///
/// Each layer is a separate benchmark so a regression can be attributed to the responsible layer.
/// <c>[MemoryDiagnoser]</c> also records allocations per layer.
///
/// Run with:
/// dotnet run -c Release --project servers/Azure.Mcp.Server/perf/Azure.Mcp.Server.PerformanceBenchmarks `
/// -- --filter '*CommandDispatch*' --buildTimeout 600
/// </summary>
[MemoryDiagnoser]
public class CommandDispatchBenchmarks
{
// A real, registered, Azure-free-to-look-up command name used to measure routing cost.
private const string RealToolName = "subscription_list";
private ServiceProvider _serviceProvider = null!;
private ICommandFactory _commandFactory = null!;
private NoOpCommand _noop = null!;
private IBaseCommand _noopBase = null!;
private Command _noopSysCommand = null!;
private Dictionary<string, JsonElement> _args = null!;
private ParseResult _parseResult = null!;
private NoOpCommand.NoOpOptions _boundOptions = null!;
private CommandResponse _response = null!;
[GlobalSetup]
public async Task Setup()
{
// Build the full server so routing measures a realistic command map (~500+ commands).
var services = new ServiceCollection();
Program.ConfigureServices(services);
services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.None));
_serviceProvider = services.BuildServiceProvider();
await Program.InitializeServicesAsync(_serviceProvider);
_commandFactory = _serviceProvider.GetRequiredService<ICommandFactory>();
_noop = new NoOpCommand();
_noopBase = _noop;
_noopSysCommand = _noop.GetCommand();
_args = new Dictionary<string, JsonElement>
{
["value"] = JsonSerializer.SerializeToElement("hello"),
["count"] = JsonSerializer.SerializeToElement(3),
};
_noopSysCommand.TryParseFromDictionary(_args, out var parseResult, out _);
_parseResult = parseResult!;
_boundOptions = _noop.BindOptions(_parseResult);
// A populated response for the serialization benchmark.
_response = await _noopBase.ExecuteAsync(new CommandContext(), _parseResult, CancellationToken.None);
}
[GlobalCleanup]
public async Task Cleanup()
{
// The full DI container holds services that only implement IAsyncDisposable
// (e.g. CosmosService), so it must be disposed asynchronously.
if (_serviceProvider is not null)
{
await _serviceProvider.DisposeAsync();
}
}
[Benchmark(Description = "Routing – CommandFactory tool-name lookup")]
public IBaseCommand? Routing() => _commandFactory.FindCommandByName(RealToolName);
[Benchmark(Description = "Parse – MCP arguments dictionary to ParseResult")]
public bool Parse() => _noopSysCommand.TryParseFromDictionary(_args, out _, out _);
[Benchmark(Description = "Bind – ParseResult to options object")]
public NoOpCommand.NoOpOptions Bind() => _noop.BindOptions(_parseResult);
[Benchmark(Description = "Validate – option validation")]
public bool Validate()
{
var validationResult = new ValidationResult();
_noop.ValidateOptions(_boundOptions, validationResult);
return validationResult.IsValid;
}
[Benchmark(Description = "SerializeResponse – CommandResponse to MCP JSON")]
public string SerializeResponse()
=> JsonSerializer.Serialize(_response, ModelsJsonContext.Default.CommandResponse);
[Benchmark(Description = "ExecuteFrame – bind + validate + execute + capture")]
public async Task<CommandResponse> ExecuteFrame()
=> await _noopBase.ExecuteAsync(new CommandContext(), _parseResult, CancellationToken.None);
[Benchmark(Description = "FullDispatch – parse + bind + validate + execute + serialize")]
public async Task<string> FullDispatch()
{
_noopSysCommand.TryParseFromDictionary(_args, out var parseResult, out _);
var response = await _noopBase.ExecuteAsync(new CommandContext(), parseResult!, CancellationToken.None);
return JsonSerializer.Serialize(response, ModelsJsonContext.Default.CommandResponse);
}
}
@@ -0,0 +1,156 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using Azure.Mcp.Server;
using Azure.Mcp.Server.Perf;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Diagnosers;
using BenchmarkDotNet.Jobs;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Mcp.Core.Commands;
namespace Azure.Mcp.Server.Perf.Benchmarks;
/// <summary>
/// Measures each phase of the Azure MCP Server startup pipeline in isolation so regressions
/// can be pinpointed to the specific phase that changed.
///
/// Each phase's prerequisites are built in a per-target <see cref="IterationSetupAttribute"/>
/// hook so they are excluded from the timed body — e.g. the BuildServiceProvider benchmark
/// times only <c>BuildServiceProvider()</c>, not the ConfigureServices work that precedes it.
/// To keep setup/measurement one-to-one, the job runs a single invocation per iteration
/// (InvocationCount = 1, UnrollFactor = 1), which is the pattern BenchmarkDotNet requires
/// when IterationSetup establishes non-idempotent state.
///
/// Run with:
/// dotnet run -c Release --project servers/Azure.Mcp.Server/perf/Azure.Mcp.Server.PerformanceBenchmarks -- --filter *
/// </summary>
[Config(typeof(IsolatedPhaseConfig))]
public class ServerStartupBenchmarks
{
// Per-phase prerequisites, prepared in IterationSetup so they are not part of the
// timed body. Each is disposed in DisposeProviders after the iteration is recorded.
private IServiceCollection _servicesForConfigure = null!;
private IServiceCollection _servicesForBuild = null!;
private ServiceProvider? _builtProvider;
private ServiceProvider? _providerForInitialize;
private ServiceProvider? _providerForCommandTree;
private sealed class IsolatedPhaseConfig : ManualConfig
{
public IsolatedPhaseConfig()
{
AddJob(Job.Default
.WithLaunchCount(1)
.WithWarmupCount(2)
.WithIterationCount(10)
.WithInvocationCount(1)
.WithUnrollFactor(1));
AddDiagnoser(MemoryDiagnoser.Default);
}
}
[IterationCleanup]
public async Task DisposeProviders()
{
// Providers may resolve async-only-disposable services (e.g. CosmosService implements
// IAsyncDisposable but not IDisposable), for which a synchronous Dispose() throws.
// Dispose asynchronously in the cleanup hook, outside the timed benchmark body.
await (_builtProvider?.DisposeAsync() ?? ValueTask.CompletedTask);
await (_providerForInitialize?.DisposeAsync() ?? ValueTask.CompletedTask);
await (_providerForCommandTree?.DisposeAsync() ?? ValueTask.CompletedTask);
_builtProvider = null;
_providerForInitialize = null;
_providerForCommandTree = null;
}
// -------------------------------------------------------------------------
// Phase 1 – instantiate all IAreaSetup objects (the 60+ toolset registrations)
// -------------------------------------------------------------------------
[Benchmark(Description = "RegisterAreas – instantiate all IAreaSetup objects")]
public object RegisterAreas() => Program.RegisterAreas();
// -------------------------------------------------------------------------
// Phase 2 – wire the DI container (call all area.ConfigureServices)
// -------------------------------------------------------------------------
[IterationSetup(Target = nameof(ConfigureServices))]
public void SetupConfigureServices() => _servicesForConfigure = new ServiceCollection();
[Benchmark(Description = "ConfigureServices – register services into the DI container")]
public IServiceCollection ConfigureServices()
{
Program.ConfigureServices(_servicesForConfigure);
return _servicesForConfigure;
}
// -------------------------------------------------------------------------
// Phase 3 – compile the DI container (BuildServiceProvider)
// -------------------------------------------------------------------------
[IterationSetup(Target = nameof(BuildServiceProvider))]
public void SetupBuildServiceProvider() => _servicesForBuild = CreateSilentServices();
[Benchmark(Description = "BuildServiceProvider – compile the DI container")]
public ServiceProvider BuildServiceProvider()
{
_builtProvider = _servicesForBuild.BuildServiceProvider();
return _builtProvider;
}
// -------------------------------------------------------------------------
// Phase 4 – async service initialization (telemetry, user-agent policy, etc.)
// -------------------------------------------------------------------------
[IterationSetup(Target = nameof(InitializeServicesAsync))]
public void SetupInitializeServices() => _providerForInitialize = CreateSilentServices().BuildServiceProvider();
[Benchmark(Description = "InitializeServicesAsync – async service warm-up")]
public async Task InitializeServicesAsync() => await Program.InitializeServicesAsync(_providerForInitialize!);
// -------------------------------------------------------------------------
// Phase 5 – build the full System.CommandLine command tree
// -------------------------------------------------------------------------
[IterationSetup(Target = nameof(BuildCommandTree))]
public void SetupBuildCommandTree()
{
_providerForCommandTree = CreateSilentServices().BuildServiceProvider();
Program.InitializeServicesAsync(_providerForCommandTree).GetAwaiter().GetResult();
}
[Benchmark(Description = "CommandFactory.RootCommand – build the full command tree")]
public System.CommandLine.RootCommand BuildCommandTree() =>
_providerForCommandTree!.GetRequiredService<ICommandFactory>().RootCommand;
// -------------------------------------------------------------------------
// Full pipeline – DI build + service init + command tree.
// This is the cumulative end-to-end measurement, so ConfigureServices/BuildServiceProvider
// remain inside the timed body by design. RegisterAreas runs once as a static initializer
// and is NOT included here (it has its own dedicated benchmark above).
// -------------------------------------------------------------------------
[Benchmark(Description = "FullStartup – DI build + service init + command tree (excludes RegisterAreas static init)")]
public async Task<System.CommandLine.RootCommand> FullStartup()
{
await using var sp = CreateSilentServices().BuildServiceProvider();
await Program.InitializeServicesAsync(sp);
return sp.GetRequiredService<ICommandFactory>().RootCommand;
}
// -------------------------------------------------------------------------
// Shared setup – registers all services with logging suppressed
// -------------------------------------------------------------------------
private static IServiceCollection CreateSilentServices()
{
var services = new ServiceCollection();
Program.ConfigureServices(services);
services.AddLogging(b => b.SetMinimumLevel(LogLevel.None));
return services;
}
}
@@ -0,0 +1,75 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.Text.Json;
using BenchmarkDotNet.Attributes;
using ModelContextProtocol.Protocol;
namespace Azure.Mcp.Server.Perf.Benchmarks;
/// <summary>
/// Measures how tool-discovery cost scales as the exposed tool catalog grows, at current
/// and projected tool counts (issue #3118: "Measure tool and namespace discovery at
/// current and projected tool counts" / "Track scaling behavior as the tool catalog grows").
///
/// For each <see cref="ToolCount"/> the benchmark builds a synthetic tool catalog whose
/// per-tool shape (name + description + a small typed JSON input schema) approximates a real
/// Azure MCP tool, then times the JSON serialization of the whole catalog — the dominant,
/// transport-independent cost of answering a tools/list request. Comparing the mean time per
/// tool across counts yields the discovery scaling-efficiency metric: if serialization is
/// linear, mean/ToolCount stays roughly constant as the catalog grows.
///
/// Run with:
/// dotnet run -c Release --project servers/Azure.Mcp.Server/perf/Azure.Mcp.Server.PerformanceBenchmarks -- --filter *ToolSurfaceScaling*
/// </summary>
[MemoryDiagnoser]
public class ToolSurfaceScalingBenchmarks
{
// 71 ≈ current default surface, 510 ≈ current all-tools surface; the rest bracket
// projected growth so the scaling curve is visible on both sides of today's counts.
[Params(71, 250, 510, 1000)]
public int ToolCount { get; set; }
private Tool[] _tools = [];
[GlobalSetup]
public void Setup()
{
_tools = new Tool[ToolCount];
for (var i = 0; i < ToolCount; i++)
{
_tools[i] = CreateSyntheticTool(i);
}
}
[Benchmark(Description = "SerializeToolCatalog – serialize the full tools/list payload")]
public string SerializeToolCatalog() => JsonSerializer.Serialize(_tools);
/// <summary>
/// Builds a single synthetic tool whose name, description, and input schema are sized to
/// resemble a typical Azure MCP tool so per-tool serialization cost is representative.
/// </summary>
private static Tool CreateSyntheticTool(int index)
{
var inputSchema = JsonDocument.Parse($$"""
{
"type": "object",
"properties": {
"subscription": { "type": "string", "description": "The Azure subscription id or name for tool {{index}}." },
"resourceGroup": { "type": "string", "description": "The resource group name." },
"name": { "type": "string", "description": "The target resource name." },
"limit": { "type": "integer", "description": "Maximum number of results to return." }
},
"required": ["subscription"]
}
""").RootElement;
return new Tool
{
Name = $"azmcp_service{index % 60}_resource_operation{index}",
Description = $"Performs operation {index} against an Azure resource, returning a structured result. " +
"Use this tool when the user asks to inspect or manage the corresponding Azure resource.",
InputSchema = inputSchema,
};
}
}
@@ -0,0 +1,224 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Globalization;
using System.Text.Json;
using ModelContextProtocol.Client;
namespace Azure.Mcp.Server.Perf;
/// <summary>
/// Measures the Azure MCP Server's behavior under concurrent load (issue #3122). It launches
/// the server over HTTP and drives a <b>sweep of concurrency levels</b>; at each level a pool of
/// parallel MCP clients issues <c>tools/list</c> for a fixed window, and the harness records the
/// throughput, latency distribution (p50/p95/p99), error/timeout rate, and a state-isolation
/// signal (responses whose tool count does not match the expected count — a sign of a
/// thread-safety or request-state-leakage failure). Per-level <b>scaling efficiency</b> is the
/// throughput relative to perfect linear scaling from the first level, so the emitted "saturation
/// curve" shows where throughput plateaus or collapses.
///
/// Mode:
/// --mcp-concurrency &lt;exe&gt; [serverArgs...]
///
/// Tunable via environment variables:
/// PERF_CONC_LEVELS comma-separated concurrency levels (default "1,2,4,8,16")
/// PERF_CONC_DURATION_SECONDS timed window per level (default 5)
/// PERF_CONC_WARMUP_SECONDS warmup window per level, not counted (default 1)
/// PERF_CONC_TIMEOUT_MS per-request timeout; a timeout counts as an error (default 10000)
///
/// Emits a single compact JSON line for the PowerShell harness to parse.
/// </summary>
internal static class ConcurrencyScalingMeasurement
{
private static readonly TimeSpan s_readinessTimeout = TimeSpan.FromSeconds(60);
internal static async Task RunAsync(string exePath, string[] serverArgs)
{
var levels = GetEnvIntArray("PERF_CONC_LEVELS", [1, 2, 4, 8, 16]);
var durationSeconds = GetEnvInt("PERF_CONC_DURATION_SECONDS", 5);
var warmupSeconds = GetEnvInt("PERF_CONC_WARMUP_SECONDS", 1);
var timeoutMs = GetEnvInt("PERF_CONC_TIMEOUT_MS", 10000);
var port = PerfServerProcess.GetFreeLoopbackPort();
var endpoint = new Uri($"http://127.0.0.1:{port}");
using var server = PerfServerProcess.StartHttpServer(exePath, serverArgs, endpoint);
try
{
var readinessMs = await PerfServerProcess.WaitForPortAsync(port, s_readinessTimeout, server);
// Establish the expected tool count once; concurrent responses that differ from it
// indicate cross-request state leakage or a thread-safety failure.
int expectedToolCount;
{
await using var probe = await McpClient.CreateAsync(NewHttpTransport(endpoint, "perf-conc-probe"));
expectedToolCount = (await probe.ListToolsAsync()).Count;
}
var levelResults = new List<object>();
double? baselineThroughput = null;
foreach (var level in levels)
{
var r = await RunLevelAsync(endpoint, level, durationSeconds, warmupSeconds, timeoutMs, expectedToolCount);
baselineThroughput ??= r.ThroughputRps;
var scalingEfficiency = baselineThroughput.Value > 0 && level > 0
? Math.Round(r.ThroughputRps / baselineThroughput.Value / level, 3)
: 0d;
levelResults.Add(new
{
concurrency = level,
requests = r.Requests,
errors = r.Errors,
error_rate_pct = r.ErrorRatePct,
tool_count_mismatches = r.ToolCountMismatches,
throughput_rps = r.ThroughputRps,
scaling_efficiency = scalingEfficiency,
latency_ms = new { p50 = r.P50, p95 = r.P95, p99 = r.P99 },
});
}
var payload = new
{
server_args = string.Join(' ', serverArgs),
readiness_ms = readinessMs,
expected_tool_count = expectedToolCount,
duration_seconds = durationSeconds,
levels = levelResults,
};
Console.WriteLine(JsonSerializer.Serialize(payload));
}
finally
{
PerfServerProcess.TryKillProcessTree(server);
}
}
/// <summary>
/// Runs a single concurrency level: <paramref name="concurrency"/> parallel clients issue
/// <c>tools/list</c> until the window closes, after a warmup window that is not counted.
/// Returns throughput, error rate, tool-count mismatches, and latency percentiles for the
/// timed window.
/// </summary>
private static async Task<LevelResult> RunLevelAsync(
Uri endpoint, int concurrency, int durationSeconds, int warmupSeconds, int timeoutMs, int expectedToolCount)
{
var latencies = new ConcurrentBag<double>();
long requests = 0;
long errors = 0;
long mismatches = 0;
var warmupDeadline = DateTime.UtcNow + TimeSpan.FromSeconds(warmupSeconds);
var endDeadline = warmupDeadline + TimeSpan.FromSeconds(durationSeconds);
var workers = new Task[concurrency];
for (var w = 0; w < concurrency; w++)
{
var index = w;
workers[w] = Task.Run(async () =>
{
await using var client = await McpClient.CreateAsync(NewHttpTransport(endpoint, $"perf-conc-{concurrency}-{index}"));
while (DateTime.UtcNow < endDeadline)
{
var timed = DateTime.UtcNow >= warmupDeadline;
using var cts = new CancellationTokenSource(timeoutMs);
var start = Stopwatch.GetTimestamp();
try
{
var tools = await client.ListToolsAsync(cancellationToken: cts.Token);
if (timed)
{
latencies.Add(Stopwatch.GetElapsedTime(start).TotalMilliseconds);
Interlocked.Increment(ref requests);
if (tools.Count != expectedToolCount)
{
Interlocked.Increment(ref mismatches);
}
}
}
catch
{
if (timed)
{
Interlocked.Increment(ref requests);
Interlocked.Increment(ref errors);
}
}
}
});
}
await Task.WhenAll(workers);
var sorted = latencies.ToArray();
Array.Sort(sorted);
var throughput = durationSeconds > 0 ? Math.Round((double)requests / durationSeconds, 1) : 0d;
var errorRate = requests > 0 ? Math.Round((double)errors / requests * 100, 3) : 0d;
return new LevelResult(
requests, errors, mismatches, errorRate, throughput,
Percentile(sorted, 50), Percentile(sorted, 95), Percentile(sorted, 99));
}
private readonly record struct LevelResult(
long Requests, long Errors, long ToolCountMismatches, double ErrorRatePct,
double ThroughputRps, double P50, double P95, double P99);
/// <summary>Linear-interpolated percentile of a pre-sorted array (0 when empty).</summary>
private static double Percentile(double[] sorted, double p)
{
if (sorted.Length == 0)
{
return 0;
}
if (sorted.Length == 1)
{
return Math.Round(sorted[0], 3);
}
var rank = p / 100.0 * (sorted.Length - 1);
var low = (int)Math.Floor(rank);
var high = (int)Math.Ceiling(rank);
var frac = rank - low;
return Math.Round(sorted[low] + (sorted[high] - sorted[low]) * frac, 3);
}
private static IClientTransport NewHttpTransport(Uri endpoint, string name)
=> new HttpClientTransport(new HttpClientTransportOptions
{
Name = name,
Endpoint = endpoint,
TransportMode = HttpTransportMode.AutoDetect,
});
private static int GetEnvInt(string name, int fallback)
{
var raw = Environment.GetEnvironmentVariable(name);
return int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) ? value : fallback;
}
private static int[] GetEnvIntArray(string name, int[] fallback)
{
var raw = Environment.GetEnvironmentVariable(name);
if (string.IsNullOrWhiteSpace(raw))
{
return fallback;
}
var values = new List<int>();
foreach (var part in raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
if (int.TryParse(part, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) && value > 0)
{
values.Add(value);
}
}
return values.Count > 0 ? [.. values] : fallback;
}
}
@@ -0,0 +1,133 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.Diagnostics;
using System.Globalization;
using System.Text.Json;
using ModelContextProtocol.Client;
namespace Azure.Mcp.Server.Perf;
/// <summary>
/// Measures per-transport request overhead (issue #3119) by timing many repetitions of a
/// deterministic, Azure-free MCP <c>tools/list</c> request against a live server. It runs over
/// either stdio or the remote HTTP transport; the PowerShell harness runs both and subtracts
/// to isolate transport-specific overhead independently of Azure latency.
///
/// (A <c>ping</c> floor was considered but the stateless HTTP transport does not implement the
/// ping method, so <c>tools/list</c> — supported identically over both transports — is used as
/// the common comparison operation.)
///
/// Modes:
/// --mcp-dispatch-stdio &lt;exe&gt; [serverArgs...]
/// --mcp-dispatch-http &lt;exe&gt; [serverArgs...]
///
/// Tunable via environment variables:
/// PERF_DISPATCH_ITERATIONS timed samples per operation (default 200)
/// PERF_DISPATCH_WARMUP warmup calls (not timed) per operation (default 20)
///
/// Emits a single compact JSON line with the raw per-call millisecond samples so the harness
/// can compute p50/p95/p99 with the shared PowerShell percentile helpers.
/// </summary>
internal static class DispatchOverheadMeasurement
{
private static readonly TimeSpan s_readinessTimeout = TimeSpan.FromSeconds(60);
internal static Task RunStdioAsync(string exePath, string[] serverArgs)
=> RunAsync(exePath, serverArgs, useHttp: false);
internal static Task RunHttpAsync(string exePath, string[] serverArgs)
=> RunAsync(exePath, serverArgs, useHttp: true);
private static async Task RunAsync(string exePath, string[] serverArgs, bool useHttp)
{
var iterations = GetEnvInt("PERF_DISPATCH_ITERATIONS", 200);
var warmup = GetEnvInt("PERF_DISPATCH_WARMUP", 20);
Process? server = null;
long? readinessMs = null;
IClientTransport transport;
if (useHttp)
{
var port = PerfServerProcess.GetFreeLoopbackPort();
var endpoint = new Uri($"http://127.0.0.1:{port}");
server = PerfServerProcess.StartHttpServer(exePath, serverArgs, endpoint);
readinessMs = await PerfServerProcess.WaitForPortAsync(port, s_readinessTimeout, server);
transport = new HttpClientTransport(new HttpClientTransportOptions
{
Name = "perf-dispatch-http",
Endpoint = endpoint,
TransportMode = HttpTransportMode.AutoDetect,
});
}
else
{
transport = new StdioClientTransport(new StdioClientTransportOptions
{
Name = "perf-dispatch-stdio",
Command = exePath,
Arguments = serverArgs,
});
}
var client = await McpClient.CreateAsync(transport);
try
{
var toolsListMs = await MeasureAsync(iterations, warmup, async () => _ = await client.ListToolsAsync());
EmitResult(useHttp ? "http" : "stdio", readinessMs, iterations, warmup, toolsListMs);
}
finally
{
await client.DisposeAsync();
if (server is not null)
{
PerfServerProcess.TryKillProcessTree(server);
}
}
}
/// <summary>
/// Runs <paramref name="warmup"/> warmup calls (not timed), then times
/// <paramref name="iterations"/> calls of <paramref name="action"/>, returning per-call
/// durations in milliseconds.
/// </summary>
private static async Task<double[]> MeasureAsync(int iterations, int warmup, Func<Task> action)
{
for (var i = 0; i < warmup; i++)
{
await action();
}
var samples = new double[iterations];
for (var i = 0; i < iterations; i++)
{
var start = Stopwatch.GetTimestamp();
await action();
samples[i] = Stopwatch.GetElapsedTime(start).TotalMilliseconds;
}
return samples;
}
private static void EmitResult(
string transport, long? readinessMs, int iterations, int warmup, double[] toolsListMs)
{
var result = new
{
transport,
iterations,
warmup,
readiness_ms = readinessMs,
tools_list_ms = Array.ConvertAll(toolsListMs, x => Math.Round(x, 4)),
};
Console.WriteLine(JsonSerializer.Serialize(result));
}
private static int GetEnvInt(string name, int fallback)
{
var raw = Environment.GetEnvironmentVariable(name);
return int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) ? value : fallback;
}
}
@@ -0,0 +1,172 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.Diagnostics;
using System.Text.Json;
using ModelContextProtocol.Client;
namespace Azure.Mcp.Server.Perf;
/// <summary>
/// Measures end-to-end MCP client startup time using the official C# MCP SDK.
/// This provides a protocol-correct timing of the full initialize → tools/list
/// handshake as a real client would experience it, plus both token-budget metrics
/// (name+description only, comparable across modes; and full inputSchema, the actual
/// LLM context-window cost) and the JSON serialization time of each payload.
///
/// Modes:
/// --mcp-startup &lt;exe&gt; [serverArgs...]
/// Spawns the server via StdioClientTransport, performs the initialize
/// handshake + tools/list request over stdio, and emits a compact JSON line.
/// --mcp-startup-http &lt;exe&gt; [serverArgs...]
/// Spawns the server with HTTP transport (--transport http
/// --dangerously-disable-http-incoming-auth) on a free loopback port, polls
/// readiness, then times the initialize + tools/list handshake over
/// HttpClientTransport. The emitted JSON additionally includes readiness_ms
/// (process start → server accepting connections).
/// </summary>
internal static class McpClientStartupMeasurement
{
// Number of repetitions used to average JSON serialization time so the metric is
// stable despite sub-millisecond per-call durations.
private const int SerializeSampleCount = 50;
// How long to wait for the HTTP server to start accepting connections.
private static readonly TimeSpan s_httpReadinessTimeout = TimeSpan.FromSeconds(60);
/// <summary>
/// Spawns the MCP server at <paramref name="exePath"/> using
/// <paramref name="serverArgs"/> over stdio, connects via the MCP SDK, and times
/// the full initialize + tools/list round-trip.
///
/// The stopwatch starts just before <see cref="McpClient.CreateAsync"/> (which
/// spawns the process and performs the initialize handshake) and stops after
/// <c>ListToolsAsync</c> completes.
/// </summary>
internal static async Task RunAsync(string exePath, string[] serverArgs)
{
var transportOptions = new StdioClientTransportOptions
{
Name = "perf-harness",
Command = exePath,
Arguments = serverArgs,
};
var sw = Stopwatch.StartNew();
var transport = new StdioClientTransport(transportOptions);
await using var client = await McpClient.CreateAsync(transport);
var tools = (await client.ListToolsAsync()).ToList();
sw.Stop();
EmitResult(sw.ElapsedMilliseconds, readinessMs: null, tools);
}
/// <summary>
/// Spawns the MCP server at <paramref name="exePath"/> in HTTP transport mode on a
/// free loopback port, waits for it to accept connections, then times the full
/// initialize + tools/list round-trip over <see cref="HttpClientTransport"/>.
///
/// The stopwatch starts before the server process is launched so <c>elapsed_ms</c>
/// captures the complete cold path (process spawn → ready → initialize → tools/list),
/// comparable to the stdio measurement. <c>readiness_ms</c> records the sub-interval
/// from process start to the server accepting its first TCP connection.
/// </summary>
internal static async Task RunHttpAsync(string exePath, string[] serverArgs)
{
var port = PerfServerProcess.GetFreeLoopbackPort();
var endpoint = new Uri($"http://127.0.0.1:{port}");
// Start the stopwatch before launching so elapsed_ms captures the complete cold
// path (process spawn -> ready -> initialize -> tools/list), comparable to stdio.
var sw = Stopwatch.StartNew();
using var server = PerfServerProcess.StartHttpServer(exePath, serverArgs, endpoint);
try
{
var readinessMs = await PerfServerProcess.WaitForPortAsync(port, s_httpReadinessTimeout, server);
var transportOptions = new HttpClientTransportOptions
{
Name = "perf-harness-http",
Endpoint = endpoint,
TransportMode = HttpTransportMode.AutoDetect,
};
var transport = new HttpClientTransport(transportOptions);
await using (var client = await McpClient.CreateAsync(transport))
{
var tools = (await client.ListToolsAsync()).ToList();
sw.Stop();
EmitResult(sw.ElapsedMilliseconds, readinessMs, tools);
}
}
finally
{
PerfServerProcess.TryKillProcessTree(server);
}
}
/// <summary>
/// Serializes the tool metrics and writes a single compact JSON line to stdout so
/// the PowerShell harness can parse it with ConvertFrom-Json.
/// </summary>
private static void EmitResult(long elapsedMs, long? readinessMs, IReadOnlyList<McpClientTool> tools)
{
// --- name+description only (matches --count-tools; comparable across modes) ---
var nameDescPayload = tools.Select(t => new { name = t.Name, description = t.Description }).ToArray();
var nameDescJson = JsonSerializer.Serialize(nameDescPayload);
var (nameDescBytes, nameDescTokens, nameDescApprox) = PerfTokenizer.Measure(nameDescJson);
var nameDescSerializeMs = MeasureSerializeMs(nameDescPayload);
// --- full schema (name + description + inputSchema) ---
// This is the actual LLM context-window cost: what a client sends to the model
// as the tools array. ProtocolTool includes the full JSON Schema per tool.
var fullSchemaPayload = tools.Select(t => t.ProtocolTool).ToArray();
var fullSchemaJson = JsonSerializer.Serialize(fullSchemaPayload);
var (fullSchemaBytes, fullSchemaTokens, fullSchemaApprox) = PerfTokenizer.Measure(fullSchemaJson);
var fullSchemaSerializeMs = MeasureSerializeMs(fullSchemaPayload);
var result = new
{
elapsed_ms = elapsedMs,
readiness_ms = readinessMs,
tool_count = tools.Count,
name_description = new
{
bytes = nameDescBytes,
exact_tokens_gpt4o_o200k = nameDescTokens,
approx_tokens_bytes_div_4 = nameDescApprox,
serialize_ms = nameDescSerializeMs,
},
full_schema = new
{
bytes = fullSchemaBytes,
exact_tokens_gpt4o_o200k = fullSchemaTokens,
approx_tokens_bytes_div_4 = fullSchemaApprox,
serialize_ms = fullSchemaSerializeMs,
},
};
// Single compact JSON line so PowerShell can parse it with ConvertFrom-Json.
Console.WriteLine(JsonSerializer.Serialize(result));
}
/// <summary>
/// Averages the wall-clock time to serialize <paramref name="payload"/> to JSON over
/// <see cref="SerializeSampleCount"/> repetitions, isolating the serialization phase
/// of tool discovery. Returns milliseconds rounded to three decimals.
/// </summary>
private static double MeasureSerializeMs<T>(T payload)
{
// Warm up once so JIT and any first-call setup are excluded from the timing.
_ = JsonSerializer.Serialize(payload);
var sw = Stopwatch.StartNew();
for (var i = 0; i < SerializeSampleCount; i++)
{
_ = JsonSerializer.Serialize(payload);
}
sw.Stop();
return Math.Round(sw.Elapsed.TotalMilliseconds / SerializeSampleCount, 3);
}
}
@@ -0,0 +1,51 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.Text.Json.Serialization.Metadata;
using Microsoft.Mcp.Core.Commands;
using Microsoft.Mcp.Core.Models.Command;
using Microsoft.Mcp.Core.Options;
namespace Azure.Mcp.Server.Perf.Dispatch;
/// <summary>
/// A deterministic no-op command used to benchmark the server's own dispatch overhead
/// (issue #3119) — argument binding, validation, execution frame, and response
/// serialization — with zero Azure or network work. It is instantiated directly by the
/// dispatch benchmarks and is <b>not</b> registered with the production server, so it never
/// appears in the real tool surface.
/// </summary>
[CommandMetadata(
Id = "0a5f5f5f-0000-0000-0000-0000000000ff",
Name = "noop",
Title = "No-op benchmark command",
Description = "Deterministic no-op used to measure server dispatch overhead. Not registered in production.",
OperationPlane = ToolOperationPlane.Control,
Destructive = false,
Idempotent = true,
OpenWorld = false,
ReadOnly = true,
Secret = false,
LocalRequired = false)]
public sealed class NoOpCommand : BaseCommand<NoOpCommand.NoOpOptions, NoOpCommand.NoOpResult>
{
public override JsonTypeInfo<NoOpResult>? ResultTypeInfo => PerfDispatchJsonContext.Default.NoOpResult;
public override Task<CommandResponse> ExecuteAsync(
CommandContext context, NoOpOptions options, CancellationToken cancellationToken)
{
SetResult(context, new NoOpResult(options.Value ?? "ok", options.Count ?? 0));
return Task.FromResult(context.Response);
}
public sealed class NoOpOptions
{
[Option(Name = "value", Description = "An echoed string value.")]
public string? Value { get; set; }
[Option(Name = "count", Description = "An echoed integer value.")]
public int? Count { get; set; }
}
public sealed record NoOpResult(string Echo, int Count);
}
@@ -0,0 +1,14 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.Text.Json.Serialization;
namespace Azure.Mcp.Server.Perf.Dispatch;
/// <summary>
/// Source-generated JSON context for the no-op benchmark command's result type, so the
/// dispatch benchmarks can serialize it AOT-safely with the same pattern real commands use.
/// </summary>
[JsonSerializable(typeof(NoOpCommand.NoOpResult))]
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
internal partial class PerfDispatchJsonContext : JsonSerializerContext;
@@ -0,0 +1,134 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
namespace Azure.Mcp.Server.Perf;
/// <summary>
/// Shared helpers for launching the Azure MCP Server as an external process in HTTP
/// transport mode and probing its readiness. Used by both the startup-timing harness
/// (<see cref="McpClientStartupMeasurement"/>) and the resource-usage harness
/// (<see cref="ResourceUsageMeasurement"/>) so the launch, readiness-poll, and teardown
/// behavior stay identical across measurements.
/// </summary>
internal static class PerfServerProcess
{
/// <summary>
/// Reserves and immediately releases a free loopback TCP port so the server can bind it.
/// </summary>
internal static int GetFreeLoopbackPort()
{
var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
try
{
return ((IPEndPoint)listener.LocalEndpoint).Port;
}
finally
{
listener.Stop();
}
}
/// <summary>
/// Starts the server at <paramref name="exePath"/> in HTTP transport mode bound to
/// <paramref name="endpoint"/>, appending the HTTP transport flags to
/// <paramref name="serverArgs"/> and draining stdout/stderr so the process pipes never
/// fill and block it. The caller owns the returned process and must terminate it with
/// <see cref="TryKillProcessTree"/>.
/// </summary>
internal static Process StartHttpServer(string exePath, string[] serverArgs, Uri endpoint)
{
var httpArgs = new List<string>(serverArgs)
{
"--transport", "http", "--dangerously-disable-http-incoming-auth",
};
var startInfo = new ProcessStartInfo
{
FileName = exePath,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
foreach (var arg in httpArgs)
{
startInfo.ArgumentList.Add(arg);
}
startInfo.Environment["ASPNETCORE_URLS"] = endpoint.ToString();
var server = new Process { StartInfo = startInfo };
if (!server.Start())
{
throw new InvalidOperationException($"Failed to start HTTP server process '{exePath}'.");
}
// Drain the server's stdout/stderr so its pipes never fill and block it.
_ = server.StandardOutput.ReadToEndAsync();
_ = server.StandardError.ReadToEndAsync();
return server;
}
/// <summary>
/// Polls <paramref name="port"/> on the loopback interface until a TCP connection
/// succeeds, returning the elapsed milliseconds from now. Throws if the server exits
/// early or the timeout elapses.
/// </summary>
internal static async Task<long> WaitForPortAsync(int port, TimeSpan timeout, Process server)
{
var sw = Stopwatch.StartNew();
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
if (server.HasExited)
{
throw new InvalidOperationException(
$"HTTP server exited early with code {server.ExitCode} before accepting connections.");
}
try
{
using var probe = new TcpClient();
var connectTask = probe.ConnectAsync(IPAddress.Loopback, port);
var completed = await Task.WhenAny(connectTask, Task.Delay(250));
if (completed == connectTask && probe.Connected)
{
sw.Stop();
return sw.ElapsedMilliseconds;
}
}
catch (SocketException)
{
// Not listening yet; keep polling.
}
await Task.Delay(50);
}
throw new TimeoutException($"HTTP server did not accept connections on port {port} within {timeout.TotalSeconds:N0}s.");
}
/// <summary>
/// Best-effort termination of the server process and its children.
/// </summary>
internal static void TryKillProcessTree(Process process)
{
try
{
if (!process.HasExited)
{
process.Kill(entireProcessTree: true);
process.WaitForExit(5000);
}
}
catch
{
// Best effort during teardown; nothing actionable if the process is already gone.
}
}
}
@@ -0,0 +1,36 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.Text;
using Microsoft.ML.Tokenizers;
namespace Azure.Mcp.Server.Perf;
/// <summary>
/// Shared GPT-4o tokenizer instance and byte/token measurement helper used by all
/// measurement classes in this project.
/// </summary>
internal static class PerfTokenizer
{
// GPT-4o uses o200k_base encoding. GPT-4/GPT-3.5-turbo use cl100k_base.
//
// GPT-4o (o200k_base) is used as the reference model because it is the encoding shared
// by the current generation of OpenAI models most commonly driving MCP clients (GPT-4o,
// GPT-4o-mini, o1/o3 families), so its token counts best approximate the context-window
// cost real agents pay for the tool surface. o200k_base is also more token-efficient than
// the older cl100k_base, giving a conservative (lower-bound) estimate; exact counts still
// vary per model, so these numbers are a comparable proxy rather than an absolute budget.
internal static readonly TiktokenTokenizer Gpt4o =
TiktokenTokenizer.CreateForModel("gpt-4o");
/// <summary>
/// Returns <c>(Bytes, ExactTokens, ApproxTokens)</c> for <paramref name="text"/>
/// using the GPT-4o o200k_base encoding.
/// <c>ApproxTokens</c> is a quick bytes÷4 estimate useful for sanity-checking.
/// </summary>
internal static (int Bytes, int ExactTokens, int ApproxTokens) Measure(string text)
{
var bytes = Encoding.UTF8.GetByteCount(text);
return (bytes, Gpt4o.CountTokens(text), bytes / 4);
}
}
@@ -0,0 +1,108 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.Diagnostics;
namespace Azure.Mcp.Server.Perf;
/// <summary>
/// Periodically samples an external process's resource counters (working set, private
/// bytes, handle count, and cumulative CPU time) on a background loop, tagging every
/// sample with the caller-supplied phase label so the samples can be aggregated per
/// phase after the run completes.
///
/// The sampler collects into a private list from its own loop; callers must
/// <see cref="Stop"/> the sampler and await the <see cref="RunAsync"/> task before
/// reading <see cref="Samples"/> so there is no concurrent access to the backing list.
/// </summary>
internal sealed class ProcessResourceSampler
{
private readonly Process _process;
private readonly TimeSpan _interval;
private readonly List<ResourceSample> _samples = [];
private readonly Stopwatch _clock = new();
private volatile string _phase = "init";
private volatile bool _running;
public ProcessResourceSampler(Process process, TimeSpan interval)
{
_process = process;
_interval = interval;
}
/// <summary>A single point-in-time reading of the process resource counters.</summary>
internal readonly record struct ResourceSample(
double ElapsedSeconds,
string Phase,
long WorkingSetBytes,
long PrivateBytes,
int HandleCount,
double CpuSeconds);
/// <summary>The samples collected so far. Only read after <see cref="Stop"/> + awaiting <see cref="RunAsync"/>.</summary>
public IReadOnlyList<ResourceSample> Samples => _samples;
/// <summary>Tags all subsequent samples with <paramref name="phase"/>.</summary>
public void BeginPhase(string phase) => _phase = phase;
/// <summary>
/// Runs the sampling loop until <see cref="Stop"/> is called, taking one reading every
/// interval plus a final reading on exit.
/// </summary>
public async Task RunAsync()
{
_running = true;
_clock.Start();
while (_running)
{
TrySample();
await Task.Delay(_interval);
}
TrySample();
}
/// <summary>Signals the sampling loop to stop after its current iteration.</summary>
public void Stop() => _running = false;
private void TrySample()
{
try
{
_process.Refresh();
if (_process.HasExited)
{
return;
}
_samples.Add(new ResourceSample(
_clock.Elapsed.TotalSeconds,
_phase,
_process.WorkingSet64,
_process.PrivateMemorySize64,
SafeHandleCount(_process),
_process.TotalProcessorTime.TotalSeconds));
}
catch
{
// The process can exit between the HasExited check and a counter read; a
// dropped sample is acceptable for a statistical baseline.
}
}
/// <summary>
/// Reads the handle count, returning 0 on platforms where it is not supported so the
/// sampler degrades gracefully instead of throwing.
/// </summary>
private static int SafeHandleCount(Process process)
{
try
{
return process.HandleCount;
}
catch
{
return 0;
}
}
}
@@ -0,0 +1,70 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using Azure.Mcp.Server.Perf;
using BenchmarkDotNet.Running;
// --count-tools : emit tool count + exact token count (names+descriptions)
// --mcp-startup <exe> [serverArgs...] : time full MCP client initialize+tools/list via SDK (stdio)
// --mcp-startup-http <exe> [serverArgs...] : time full MCP client initialize+tools/list via SDK (HTTP transport)
// --mcp-resource <exe> [serverArgs...] : sample CPU/memory/handles across idle/startup/discovery/steady/soak phases (HTTP transport)
// --mcp-dispatch-stdio <exe> [serverArgs...] : time ping + tools/list request overhead over stdio
// --mcp-dispatch-http <exe> [serverArgs...] : time ping + tools/list request overhead over HTTP transport
// --mcp-concurrency <exe> [serverArgs...] : sweep concurrency levels; throughput, latency, error rate, scaling efficiency (HTTP transport)
// (all other args) : run BenchmarkDotNet benchmarks
if (args.Length > 0 && args[0] == "--count-tools")
{
await ToolCountMeasurement.RunCountToolsAsync();
return;
}
if (args.Length >= 2 && args[0] == "--mcp-startup")
{
// args[1] = path to azmcp exe; args[2..] = server arguments (e.g. "server" "start" "--mode" "namespace")
await McpClientStartupMeasurement.RunAsync(args[1], args.Length > 2 ? args[2..] : []);
return;
}
if (args.Length >= 2 && args[0] == "--mcp-startup-http")
{
// args[1] = path to azmcp exe; args[2..] = server arguments. The harness appends the
// HTTP transport flags and measures startup over HttpClientTransport.
await McpClientStartupMeasurement.RunHttpAsync(args[1], args.Length > 2 ? args[2..] : []);
return;
}
if (args.Length >= 2 && args[0] == "--mcp-resource")
{
// args[1] = path to azmcp exe; args[2..] = server arguments. The harness launches the
// server over HTTP and samples process resource counters across each phase. Duration
// and concurrency are controlled via PERF_* environment variables.
await ResourceUsageMeasurement.RunAsync(args[1], args.Length > 2 ? args[2..] : []);
return;
}
if (args.Length >= 2 && args[0] == "--mcp-dispatch-stdio")
{
// args[1] = path to azmcp exe; args[2..] = server arguments. Times ping + tools/list
// request overhead over stdio (issue #3119).
await DispatchOverheadMeasurement.RunStdioAsync(args[1], args.Length > 2 ? args[2..] : []);
return;
}
if (args.Length >= 2 && args[0] == "--mcp-dispatch-http")
{
// args[1] = path to azmcp exe; args[2..] = server arguments. Times ping + tools/list
// request overhead over the HTTP transport (issue #3119).
await DispatchOverheadMeasurement.RunHttpAsync(args[1], args.Length > 2 ? args[2..] : []);
return;
}
if (args.Length >= 2 && args[0] == "--mcp-concurrency")
{
// args[1] = path to azmcp exe; args[2..] = server arguments. Sweeps concurrency levels
// over HTTP and reports throughput, latency, error rate, and scaling efficiency (issue #3122).
// Levels and durations are controlled via PERF_CONC_* environment variables.
await ConcurrencyScalingMeasurement.RunAsync(args[1], args.Length > 2 ? args[2..] : []);
return;
}
BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args);
@@ -0,0 +1,395 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.Globalization;
using System.Text.Json;
using ModelContextProtocol.Client;
using static Azure.Mcp.Server.Perf.ProcessResourceSampler;
namespace Azure.Mcp.Server.Perf;
/// <summary>
/// Measures the Azure MCP Server's process-level resource consumption (CPU, memory, and
/// handles) across the startup, idle, discovery, and steady-state phases, plus an
/// optional sustained "soak" phase used to detect unbounded memory or handle growth.
///
/// The server is launched in HTTP transport mode on a free loopback port; a background
/// <see cref="ProcessResourceSampler"/> reads the process counters throughout the run,
/// tagging every reading with the current phase. Steady-state and soak load are driven by
/// <c>PERF_CONCURRENCY</c> parallel MCP clients issuing repeated <c>tools/list</c>
/// requests (an authentication-free, representative discovery workload).
///
/// Tunable via environment variables (all optional):
/// PERF_SAMPLE_MS sampling interval in ms (default 100)
/// PERF_IDLE_SECONDS idle observation window with no client (default 3)
/// PERF_STEADY_SECONDS steady-state load duration (default 10)
/// PERF_SOAK_SECONDS sustained soak duration; 0 disables the soak phase (default 0)
/// PERF_CONCURRENCY number of concurrent load clients (default 1)
/// PERF_LEAK_MB_PER_MIN working-set slope above which soak is flagged unbounded (default 5)
/// PERF_LEAK_HANDLES_PER_MIN handle slope above which soak is flagged unbounded (default 20)
///
/// Emits a single compact JSON line to stdout for the PowerShell harness to parse.
/// </summary>
internal static class ResourceUsageMeasurement
{
private const double BytesPerMb = 1024.0 * 1024.0;
private static readonly TimeSpan s_readinessTimeout = TimeSpan.FromSeconds(60);
/// <summary>
/// Runs the full resource-usage measurement against the server at
/// <paramref name="exePath"/> launched with <paramref name="serverArgs"/>.
/// </summary>
internal static async Task RunAsync(string exePath, string[] serverArgs)
{
var sampleMs = GetEnvInt("PERF_SAMPLE_MS", 100);
var idleSeconds = GetEnvInt("PERF_IDLE_SECONDS", 3);
var steadySeconds = GetEnvInt("PERF_STEADY_SECONDS", 10);
var soakSeconds = GetEnvInt("PERF_SOAK_SECONDS", 0);
var concurrency = Math.Max(1, GetEnvInt("PERF_CONCURRENCY", 1));
var port = PerfServerProcess.GetFreeLoopbackPort();
var endpoint = new Uri($"http://127.0.0.1:{port}");
using var server = PerfServerProcess.StartHttpServer(exePath, serverArgs, endpoint);
var sampler = new ProcessResourceSampler(server, TimeSpan.FromMilliseconds(sampleMs));
sampler.BeginPhase("startup");
var samplerTask = sampler.RunAsync();
try
{
// Phase: startup — process launch until it accepts its first connection.
var readinessMs = await PerfServerProcess.WaitForPortAsync(port, s_readinessTimeout, server);
// Phase: idle — server ready, no client connected.
sampler.BeginPhase("idle");
await Task.Delay(TimeSpan.FromSeconds(idleSeconds));
// Phase: discovery — a single initialize + tools/list handshake.
sampler.BeginPhase("discovery");
int toolCount;
{
var transport = new HttpClientTransport(new HttpClientTransportOptions
{
Name = "perf-resource-discovery",
Endpoint = endpoint,
TransportMode = HttpTransportMode.AutoDetect,
});
await using var client = await McpClient.CreateAsync(transport);
toolCount = (await client.ListToolsAsync()).Count;
}
// Phase: steady-state — sustained load under PERF_CONCURRENCY clients.
sampler.BeginPhase("steady_state");
var steadyRequests = await DriveLoadAsync(endpoint, concurrency, TimeSpan.FromSeconds(steadySeconds));
// Phase: soak (optional) — longer sustained load for leak detection.
long soakRequests = 0;
if (soakSeconds > 0)
{
sampler.BeginPhase("soak");
soakRequests = await DriveLoadAsync(endpoint, concurrency, TimeSpan.FromSeconds(soakSeconds));
}
sampler.Stop();
await samplerTask;
EmitResult(
sampler.Samples, readinessMs, toolCount, concurrency, sampleMs,
steadyRequests, steadySeconds, soakRequests, soakSeconds, serverArgs);
}
finally
{
sampler.Stop();
try
{
await samplerTask;
}
catch
{
// Sampler teardown is best-effort.
}
PerfServerProcess.TryKillProcessTree(server);
}
}
/// <summary>
/// Drives <paramref name="concurrency"/> parallel MCP clients, each issuing
/// <c>tools/list</c> in a tight loop until <paramref name="duration"/> elapses, and
/// returns the total number of completed requests across all workers.
/// </summary>
private static async Task<long> DriveLoadAsync(Uri endpoint, int concurrency, TimeSpan duration)
{
var deadline = DateTime.UtcNow + duration;
var counts = new long[concurrency];
var workers = new Task[concurrency];
for (var w = 0; w < concurrency; w++)
{
var index = w;
workers[w] = Task.Run(async () =>
{
var transport = new HttpClientTransport(new HttpClientTransportOptions
{
Name = $"perf-load-{index}",
Endpoint = endpoint,
TransportMode = HttpTransportMode.AutoDetect,
});
await using var client = await McpClient.CreateAsync(transport);
while (DateTime.UtcNow < deadline)
{
_ = await client.ListToolsAsync();
counts[index]++;
}
});
}
await Task.WhenAll(workers);
long total = 0;
foreach (var count in counts)
{
total += count;
}
return total;
}
/// <summary>
/// Aggregates the collected samples into per-phase, peak, growth, and soak statistics
/// and writes a single compact JSON line to stdout.
/// </summary>
private static void EmitResult(
IReadOnlyList<ResourceSample> all,
long readinessMs,
int toolCount,
int concurrency,
int sampleMs,
long steadyRequests,
int steadySeconds,
long soakRequests,
int soakSeconds,
string[] serverArgs)
{
var procCount = Environment.ProcessorCount;
var phases = new Dictionary<string, object?>();
foreach (var name in new[] { "startup", "idle", "discovery" })
{
var summary = SummarizePhase(all, name, procCount);
if (summary is not null)
{
phases[name] = summary;
}
}
var steadySummary = SummarizePhase(all, "steady_state", procCount);
if (steadySummary is not null)
{
steadySummary["requests"] = steadyRequests;
steadySummary["throughput_rps"] = steadySeconds > 0
? Math.Round((double)steadyRequests / steadySeconds, 1)
: 0d;
phases["steady_state"] = steadySummary;
}
var peak = new
{
working_set_mb = all.Count > 0 ? MaxMb(all, x => x.WorkingSetBytes) : 0d,
private_mb = all.Count > 0 ? MaxMb(all, x => x.PrivateBytes) : 0d,
handles = all.Count > 0 ? all.Max(x => x.HandleCount) : 0,
};
var growth = ComputeGrowth(all);
var soak = ComputeSoak(all, soakRequests, soakSeconds);
var result = new
{
server_args = string.Join(' ', serverArgs),
tool_count = toolCount,
processor_count = procCount,
concurrency,
sample_interval_ms = sampleMs,
sample_count = all.Count,
readiness_ms = readinessMs,
phases,
peak,
growth,
soak,
};
Console.WriteLine(JsonSerializer.Serialize(result));
}
/// <summary>
/// Builds the mean/max working-set, private-bytes, handle, and CPU-percent summary for
/// a single phase, or <c>null</c> when the phase produced no samples.
/// </summary>
private static Dictionary<string, object?>? SummarizePhase(
IReadOnlyList<ResourceSample> all, string phase, int procCount)
{
var s = all.Where(x => x.Phase == phase).ToList();
if (s.Count == 0)
{
return null;
}
return new Dictionary<string, object?>
{
["samples"] = s.Count,
["working_set_mb"] = new { mean = MeanMb(s, x => x.WorkingSetBytes), max = MaxMb(s, x => x.WorkingSetBytes) },
["private_mb"] = new { mean = MeanMb(s, x => x.PrivateBytes), max = MaxMb(s, x => x.PrivateBytes) },
["handles"] = new { mean = Math.Round(s.Average(x => (double)x.HandleCount), 0), max = s.Max(x => x.HandleCount) },
["cpu_percent"] = CpuPercent(s, procCount),
};
}
/// <summary>Computes the mean working-set and handle growth from idle to steady-state.</summary>
private static object ComputeGrowth(IReadOnlyList<ResourceSample> all)
{
var idle = all.Where(x => x.Phase == "idle").ToList();
var steady = all.Where(x => x.Phase == "steady_state").ToList();
double? workingSet = null;
double? handles = null;
if (idle.Count > 0 && steady.Count > 0)
{
workingSet = Math.Round((steady.Average(x => x.WorkingSetBytes) - idle.Average(x => x.WorkingSetBytes)) / BytesPerMb, 2);
handles = Math.Round(steady.Average(x => (double)x.HandleCount) - idle.Average(x => (double)x.HandleCount), 0);
}
return new { idle_to_steady_working_set_mb = workingSet, idle_to_steady_handles = handles };
}
/// <summary>
/// Computes the working-set and handle-count trend (least-squares slope + R²) across the
/// soak phase and flags unbounded growth when a slope exceeds its threshold with a
/// well-correlated (R² ≥ 0.5) upward trend. Returns <c>null</c> when no soak ran.
/// </summary>
private static object? ComputeSoak(IReadOnlyList<ResourceSample> all, long soakRequests, int soakSeconds)
{
var soak = all.Where(x => x.Phase == "soak").ToList();
if (soak.Count < 2)
{
return null;
}
// Exclude an initial warmup portion so the leak verdict reflects steady-state growth
// rather than the JIT/cache/thread-pool ramp that occurs when sustained load begins.
// Real soak runs should be long enough (minutes) that this window is a small fraction.
var warmup = soak.Count / 4;
var analyzed = soak.Skip(warmup).ToList();
if (analyzed.Count < 2)
{
analyzed = soak;
}
var leakMbPerMin = GetEnvDouble("PERF_LEAK_MB_PER_MIN", 5.0);
var leakHandlesPerMin = GetEnvDouble("PERF_LEAK_HANDLES_PER_MIN", 20.0);
var xs = analyzed.Select(x => x.ElapsedSeconds).ToList();
var (wsSlopePerSec, wsR2) = LinearTrend(xs, analyzed.Select(x => x.WorkingSetBytes / BytesPerMb).ToList());
var (handleSlopePerSec, handleR2) = LinearTrend(xs, analyzed.Select(x => (double)x.HandleCount).ToList());
var wsSlopePerMin = Math.Round(wsSlopePerSec * 60, 3);
var handleSlopePerMin = Math.Round(handleSlopePerSec * 60, 3);
var unbounded = (wsSlopePerMin > leakMbPerMin && wsR2 >= 0.5)
|| (handleSlopePerMin > leakHandlesPerMin && handleR2 >= 0.5);
return new
{
duration_s = soakSeconds,
requests = soakRequests,
samples = soak.Count,
analyzed_samples = analyzed.Count,
working_set_slope_mb_per_min = wsSlopePerMin,
working_set_r2 = Math.Round(wsR2, 3),
handle_slope_per_min = handleSlopePerMin,
handle_r2 = Math.Round(handleR2, 3),
unbounded_growth = unbounded,
};
}
/// <summary>
/// Average CPU utilization percentage over a phase, derived from the cumulative CPU
/// time delta divided by wall-clock time and processor count. Returns <c>null</c> when
/// fewer than two samples make the rate undefined.
/// </summary>
private static double? CpuPercent(IReadOnlyList<ResourceSample> s, int procCount)
{
if (s.Count < 2)
{
return null;
}
var wall = s[^1].ElapsedSeconds - s[0].ElapsedSeconds;
if (wall <= 0)
{
return null;
}
var cpu = s[^1].CpuSeconds - s[0].CpuSeconds;
return Math.Round(cpu / (wall * procCount) * 100.0, 1);
}
/// <summary>Least-squares linear regression returning the slope and R² of ys over xs.</summary>
private static (double Slope, double R2) LinearTrend(IReadOnlyList<double> xs, IReadOnlyList<double> ys)
{
var n = xs.Count;
if (n < 2)
{
return (0, 0);
}
double sx = 0, sy = 0, sxx = 0, sxy = 0;
for (var i = 0; i < n; i++)
{
sx += xs[i];
sy += ys[i];
sxx += xs[i] * xs[i];
sxy += xs[i] * ys[i];
}
var denom = n * sxx - sx * sx;
if (denom == 0)
{
return (0, 0);
}
var slope = (n * sxy - sx * sy) / denom;
var intercept = (sy - slope * sx) / n;
var meanY = sy / n;
double ssTot = 0, ssRes = 0;
for (var i = 0; i < n; i++)
{
var dy = ys[i] - meanY;
ssTot += dy * dy;
var e = ys[i] - (slope * xs[i] + intercept);
ssRes += e * e;
}
var r2 = ssTot <= 0 ? 0 : 1 - ssRes / ssTot;
return (slope, r2);
}
private static double MeanMb(IEnumerable<ResourceSample> s, Func<ResourceSample, long> selector)
=> Math.Round(s.Average(x => (double)selector(x)) / BytesPerMb, 2);
private static double MaxMb(IEnumerable<ResourceSample> s, Func<ResourceSample, long> selector)
=> Math.Round(s.Max(selector) / BytesPerMb, 2);
private static int GetEnvInt(string name, int fallback)
{
var raw = Environment.GetEnvironmentVariable(name);
return int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) ? value : fallback;
}
private static double GetEnvDouble(string name, double fallback)
{
var raw = Environment.GetEnvironmentVariable(name);
return double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out var value) ? value : fallback;
}
}
@@ -0,0 +1,61 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.Text.Json;
using Azure.Mcp.Server;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Mcp.Core.Commands;
namespace Azure.Mcp.Server.Perf;
/// <summary>
/// Instantiates the full server in-process and counts the number of registered commands
/// plus the exact token budget using the GPT-4o tokenizer.
///
/// Mode:
/// --count-tools Builds DI container, counts commands, reports name+description
/// byte and token cost. For full-schema token cost (including
/// inputSchema per tool) use --mcp-startup instead.
/// </summary>
internal static class ToolCountMeasurement
{
internal static async Task RunCountToolsAsync()
{
var services = new ServiceCollection();
Program.ConfigureServices(services);
services.AddLogging(b => b.SetMinimumLevel(LogLevel.None));
await using var sp = services.BuildServiceProvider();
await Program.InitializeServicesAsync(sp);
var commandFactory = sp.GetRequiredService<ICommandFactory>();
var allCommands = commandFactory.AllCommands;
var commandSummaries = allCommands
.Select(kvp => new { name = kvp.Key, description = kvp.Value.Description })
.OrderBy(c => c.name)
.ToList();
var summaryJson = JsonSerializer.Serialize(commandSummaries);
var (bytes, exactTokens, approxTokens) = PerfTokenizer.Measure(summaryJson);
var result = new
{
timestamp = DateTimeOffset.UtcNow,
commandCount = allCommands.Count,
nameAndDescriptionJsonBytes = bytes,
nameAndDescriptionTokens = new
{
exact_gpt4o_o200k_base = exactTokens,
approx_bytes_div_4 = approxTokens,
error_pct = exactTokens == 0
? (double?)null
: Math.Round(Math.Abs(approxTokens - exactTokens) / (double)exactTokens * 100, 1)
},
notes = "Counts name+description only. Use --mcp-startup for full inputSchema token cost."
};
Console.WriteLine(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }));
}
}
@@ -60,6 +60,10 @@
<SelfContained>true</SelfContained>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="Azure.Mcp.Server.PerformanceBenchmarks" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
+2 -2
View File
@@ -38,7 +38,7 @@ internal class Program
private static readonly string ServerAreaName =
Array.Find(Areas, static a => a is Microsoft.Mcp.Core.Areas.Server.ServerSetup)?.Name ?? "server";
private static async Task<int> Main(string[] args)
internal static async Task<int> Main(string[] args)
{
try
{
@@ -169,7 +169,7 @@ internal class Program
}
}
private static IAreaSetup[] RegisterAreas()
internal static IAreaSetup[] RegisterAreas()
{
return [