Skip to content

Speed up parameter binding by skipping mandatory parameters outside the active parameter set - #28371

Open
Blake (blakedeckard) wants to merge 1 commit into
PowerShell:masterfrom
blakedeckard:perf-where-object-binding
Open

Blake (blakedeckard) wants to merge 1 commit into
PowerShell:masterfrom
blakedeckard:perf-where-object-binding

Conversation

@blakedeckard

@blakedeckard Blake (blakedeckard) commented Nov 6, 2026 •

Copy link
Copy Markdown

PR Summary

Skip mandatory-parameter bookkeeping for parameters that are not in any of the currently valid parameter sets.

For every pipeline object, CmdletParameterBinderController.GetMissingMandatoryParameters loops over each unbound parameter that is mandatory in some parameter set and enumerates its parameter set data, even when the parameter is not in any currently valid set. For commands with many parameter sets this is a large part of the per-object binding cost. Where-Object has 32 parameter sets (one per operator switch) and 32 parameters that are mandatory in one of them, so with the script block parameter set all 31 enumerations find nothing for every input object.

The new check is the same one BindUnboundParametersForBindingStateInParameterSet (and several other places) already use: (ParameterSetFlags & _currentParameterSetFlag) == 0 && !IsInAllSets. Since ParameterSetFlags is the OR of all the parameter's set bits and IsInAllSets is set when any of its sets is __AllParameterSets (GenerateParameterSetMappingFromMetadata), GetMatchingParameterSetData returns nothing for such a parameter, so the result is unchanged.

PR Context

Measured on Linux x64, Release build with ReadyToRun. Each number is the median of 5 alternating process runs; each run takes the fastest of 5 repetitions:

Command Before After Change
1..200000 | Where-Object { $_ % 2 } 691 ms 422 ms -39%
$objs | Where-Object Tag -eq 3 (50k objects) 132 ms 80 ms -40%
1..200000 | ForEach-Object { $_ } 420 ms 406 ms -3%

Per object, measured in-process with [GC]::GetTotalAllocatedBytes and [Diagnostics.Stopwatch]:

Command Allocated before Allocated after Change Time before Time after Change
1..200000 | Where-Object { $_ % 2 } 4,464 B 2,232 B -50% 3.45 µs 2.11 µs -39%
$objs | Where-Object Tag -eq 3 (50k objects) 3,144 B 912 B -71% 2.65 µs 1.59 µs -40%
1..200000 | ForEach-Object { $_ } 1,936 B 1,792 B -7% 2.10 µs 2.03 µs -3%

Allocated bytes were identical in every run.

Benchmark script
<#
.SYNOPSIS
    Before/after benchmark for "Skip unrelated parameters when checking for missing mandatory parameters".

.DESCRIPTION
    Runs the Before and After pwsh builds in alternating processes (to spread out machine noise),
    and in each process measures every case in-process with [Diagnostics.Stopwatch] and
    [GC]::GetTotalAllocatedBytes. Each process reports the best of $Reps repetitions; the script
    then prints the median across $Rounds processes as two markdown tables.

.EXAMPLE
    ./bench-where-object.ps1 -Before ~/ps-master/release/pwsh -After ~/ps-patched/release/pwsh
    (Build each with: Start-PSBuild -Configuration Release -Output release)
#>
param(
    [Parameter(Mandatory)] [string] $Before,
    [Parameter(Mandatory)] [string] $After,
    [int] $Rounds = 5,
    [int] $Reps = 5
)

# Code that runs inside each child pwsh process. Prints one JSON line.
$worker = @'
param([int] $Reps)
$objs = 1..50000 | ForEach-Object { [pscustomobject]@{ Id = $_; Name = "item$_"; Tag = ($_ % 10) } }
$cases = [ordered]@{
    '`1..200000 | Where-Object { $_ % 2 }`'               = @{ N = 200000; S = { 1..200000 | Where-Object { $_ % 2 } | Out-Null } }
    '`$objs | Where-Object Tag -eq 3` (50k objects)'      = @{ N = 50000;  S = { $objs | Where-Object Tag -eq 3 | Out-Null } }
    '`1..200000 | ForEach-Object { $_ }`'                 = @{ N = 200000; S = { 1..200000 | ForEach-Object { $_ } | Out-Null } }
}
$out = [ordered]@{}
foreach ($name in $cases.Keys) {
    $c = $cases[$name]
    & $c.S; & $c.S                                   # warm up (JIT, caches)
    $bestMs = [double]::MaxValue; $bestBytes = [long]::MaxValue
    for ($i = 0; $i -lt $Reps; $i++) {
        [GC]::Collect(); [GC]::WaitForPendingFinalizers()
        $a0 = [GC]::GetTotalAllocatedBytes($true)
        $sw = [Diagnostics.Stopwatch]::StartNew()
        & $c.S
        $sw.Stop()
        $a1 = [GC]::GetTotalAllocatedBytes($true)
        $bestMs = [Math]::Min($bestMs, $sw.Elapsed.TotalMilliseconds)
        $bestBytes = [Math]::Min($bestBytes, $a1 - $a0)
    }
    $out[$name] = @{ ms = $bestMs; n = $c.N; bytes = $bestBytes }
}
$out | ConvertTo-Json -Compress -Depth 3
'@
$workerPath = Join-Path ([IO.Path]::GetTempPath()) "bench-where-object-worker.ps1"
Set-Content -Path $workerPath -Value $worker

$results = @{ Before = @(); After = @() }
for ($r = 1; $r -le $Rounds; $r++) {
    foreach ($side in 'Before', 'After') {
        $exe = if ($side -eq 'Before') { $Before } else { $After }
        Write-Host "round $r/$Rounds  $side"
        $json = & $exe -NoProfile -NonInteractive -File $workerPath -Reps $Reps | Select-Object -Last 1
        $results[$side] += , ($json | ConvertFrom-Json -AsHashtable)
    }
}

function Median([double[]] $v) { $s = $v | Sort-Object; if ($s.Count % 2) { $s[[int][Math]::Floor($s.Count / 2)] } else { ($s[$s.Count / 2 - 1] + $s[$s.Count / 2]) / 2 } }
function Pct($a, $b) { '{0:+0;-0;0}%' -f (100 * ($b - $a) / $a) }

$names = $results.Before[0].Keys
''
"Whole pipeline, median of $Rounds alternating process runs, each the best of $Reps repetitions:"
''
'| Command | Before | After | Change |'
'|---|---:|---:|---:|'
foreach ($n in $names) {
    $b = Median ($results.Before | ForEach-Object { $_[$n].ms }); $a = Median ($results.After | ForEach-Object { $_[$n].ms })
    '| {0} | {1:N0} ms | {2:N0} ms | {3} |' -f ($n -replace '\|', '\|'), $b, $a, (Pct $b $a)
}
''
'Per object, measured in-process with `[GC]::GetTotalAllocatedBytes` and `[Diagnostics.Stopwatch]`:'
''
'| Command | Allocated before | Allocated after | Change | Time before | Time after | Change |'
'|---|---:|---:|---:|---:|---:|---:|'
foreach ($n in $names) {
    $cnt = $results.Before[0][$n].n
    $bb = Median ($results.Before | ForEach-Object { $_[$n].bytes / $cnt }); $ab = Median ($results.After | ForEach-Object { $_[$n].bytes / $cnt })
    $bt = Median ($results.Before | ForEach-Object { $_[$n].ms * 1000 / $cnt }); $at = Median ($results.After | ForEach-Object { $_[$n].ms * 1000 / $cnt })
    $bAll = $results.Before | ForEach-Object { [math]::Round($_[$n].bytes / $cnt) } | Sort-Object -Unique
    $aAll = $results.After  | ForEach-Object { [math]::Round($_[$n].bytes / $cnt) } | Sort-Object -Unique
    '| {0} | {1:N0} B | {2:N0} B | {3} | {4:N2} µs | {5:N2} µs | {6} |' -f ($n -replace '\|', '\|'), $bb, $ab, (Pct $bb $ab), $bt, $at, (Pct $bt $at)
    if ($bAll.Count -gt 1 -or $aAll.Count -gt 1) { Write-Host "note: allocation varied across runs for $n" }
}
Remove-Item $workerPath -ErrorAction SilentlyContinue

Run: ./bench-where-object.ps1 -Before /pwsh -After /pwsh

Testing

I have included a debug script below in the first comment to step through the changes. Pester results (engine/ParameterBinding, engine/Basic, Language, Microsoft.PowerShell.Core/Utility/Management) were identical before and after.

PR Checklist

For every pipeline object, GetMissingMandatoryParameters walks all unbound
parameters that are mandatory in some parameter set and enumerates their
parameter set data, even when the parameter is not in any of the currently
valid parameter sets. For cmdlets with many parameter sets, such as
Where-Object, this is most of the per-object binding cost.

Skip a parameter when it is neither in the current parameter sets nor in all
sets, using the same check that BindUnboundParametersForBindingStateInParameterSet
already uses. GetMatchingParameterSetData would return nothing for such a
parameter, so the result does not change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
There may be pipelines that require an authorized user to comment /azp run to run.

@blakedeckard

Blake (blakedeckard) commented Nov 6, 2026 •

Copy link
Copy Markdown
Author

TLDR, GetMatchingParameterSetData checks set entries of one parameter to find any in an active set. Before we do this, we now do one AND of the parameter's set bitmask against the active set. If the result is zero and the parameter isn't in all sets, none of its entries match, and we can skip the call. Where-Object is notable here as it has 32 parameter sets and 31 mandatory parameters that don’t apply to the active set: each object goes from 31 iterator calls that find nothing to 31 ANDs.

This check seems to be repeated several places, and it could probably be a refactor candidate after this PR.

If Claude co-author is an issue, let me know

@blakedeckard
Blake (blakedeckard) marked this pull request as ready for review November 6, 2026 15:47
@blakedeckard
Blake (blakedeckard) requested a review from a team as a code owner November 6, 2026 15:47
Copilot AI balanced review requested due to automatic review settings November 6, 2026 15:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

No active review issues remain for the parameter-set membership guard.

No open issues 🎉

What changed in this PR

This PR reduces per-object parameter-binding overhead by skipping mandatory-parameter checks for parameters outside the currently valid parameter sets, while preserving binding behavior.

Changes:

  • Adds an early exit before enumerating unrelated parameter-set metadata.
  • Retains checks for parameters that belong to all parameter sets.
File Description
src/​System.Management.Automation/​engine/​CmdletParameterBinderController.cs Adds a parameter-set membership guard in GetMissingMandatoryParameters.

Review effort: Balanced

@blakedeckard

Blake (blakedeckard) commented Nov 6, 2026 •

Copy link
Copy Markdown
Author

Confirmed in debugger as well, can compare with the following debug points, demo script, and launch.json block. Let me know if there is any other way I can assist.

Line numbers are for this PR's commit (f298b36), with paths under src/System.Management.Automation/. Build with Start-PSBuild -Output debug (Debug configuration) and save the script as debug-walkthrough/demo.ps1.

Every breakpoint and logpoint below, except A, uses the condition _commandMetadata.Name == "Where-Object" || _commandMetadata.Name == "Test-Sets". Without it, they fire for every cmdlet.

Debug lines and logpoints

engine/MergedCommandParameterMetadata.cs
  A  337   breakpoint  cond: parameter.Parameter.Name == "Property" || parameter.Parameter.Name == "FilterScript"
                       generator writes each parameter's set bitmask (ParameterSetFlags); fires once per process

engine/CmdletParameterBinderController.cs
  B  1420  breakpoint  _currentParameterSetFlag &= ParameterSetFlags   (binding narrows the active set)
  C  195   breakpoint  _prePipelineProcessingParameterSetFlags = ...  (active set saved before the pipeline)
  D  3289  breakpoint  _currentParameterSetFlag = saved value          (restored for every pipeline object)
  E  1781  logpoint    the patched check. Message:
             {parameter.Parameter.Name}: flags={parameter.Parameter.ParameterSetFlags,h} active={_currentParameterSetFlag,h} AND={(parameter.Parameter.ParameterSetFlags & _currentParameterSetFlag),h} inAll={parameter.Parameter.IsInAllSets}
  F  1786  breakpoint  old lookup GetMatchingParameterSetData; with the patch it only stops for Test-Sets/Name

Control run (patch commented out, rebuilt)
  1786  logpoint  OLD LOOKUP {parameter.Parameter.Name} active={_currentParameterSetFlag,h}
  1793  logpoint  ENTRY FOUND {parameter.Parameter.Name}
        expect: OLD LOOKUP for every Where-Object parameter, ENTRY FOUND only for Test-Sets Name

Demo script (debug-walkthrough/demo.ps1)

# Debugger walkthrough for "Skip unrelated parameters when checking for missing mandatory parameters".
# Run it under the C# debugger. Each section takes a different path through the patched check.

$objs = 1..3 | ForEach-Object { [pscustomobject]@{ N = $_ } }

Write-Host '--- 1. Where-Object script-block form: active set = ScriptBlockSet (0x2), every unbound mandatory parameter is skipped'
$objs | Where-Object { $_.N -gt 1 } | Out-Null

Write-Host '--- 2. Where-Object property form with -GT: active set = GreaterThanSet (0x20), every unbound mandatory parameter is skipped'
$objs | Where-Object N -GT 1 | Out-Null

# A tiny two-set function so you can watch both outcomes of the check: "keep" (falls through to the
# old lookup) and "skip".
function Test-Sets {
    [CmdletBinding(DefaultParameterSetName = 'ByName')]
    param(
        [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'ByName')]
        [string] $Name,

        [Parameter(Mandatory, ParameterSetName = 'ById')]
        [int] $Id,

        [Parameter(ParameterSetName = 'ByName')]
        [string] $Tag
    )
    process { }
}

Write-Host '--- 3. Test-Sets: first check runs before any object (Name kept, Id skipped), per-object checks see only ByName (Id skipped)'
'a', 'b' | Test-Sets

Write-Host 'done'

launch.json entry

{
    "name": "Debug patch walkthrough (demo.ps1)",
    "type": "coreclr",
    "request": "launch",
    "program": "${workspaceFolder}/debug/pwsh",
    "args": ["-NoProfile", "-NoLogo", "-File", "${workspaceFolder}/debug-walkthrough/demo.ps1"],
    "cwd": "${workspaceFolder}",
    "console": "integratedTerminal",
    "justMyCode": false,
    "suppressJITOptimizations": true,
    "requireExactSource": true
}

@blakedeckard

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

@blakedeckard Blake (blakedeckard) changed the title Skip unrelated parameters when checking for missing mandatory parameters Speed up parameter binding by skipping mandatory parameters outside the active parameter set Nov 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants