GV

GeezerVison

The World Wide Web, restored to 800 pixels of glory.

Home|About|Gaming cabinet|Archives|How it works|Starter sites

On this site

  • Sign in
  • ActionsCI/CD and automation
  • CodespacesCloud development environments
  • All Features
  • Documentation
  • Training
  • Certifications
  • Build with Copilot
  • Customize Copilot
  • Integrations
  • Trending repos
  • GitHub Sponsors
  • Developer articles
  • Enterprises

This view

  • Original page
  • New address

GeezerVison is showing github.com — Speed up parameter binding by skipping mandatory parameters outside the active parameter set by blakedeckard · Pull Request #28371 · PowerShell/PowerShell

Conversation

@blakedeckard

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

Uh oh!

There was an error while loading. Please reload this page.

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

  • PR has a meaningful title
    • Use the present tense and imperative mood when describing your changes
  • Summarized changes
  • Make sure all .h, .cpp, .cs, .ps1 and .psm1 files have the correct copyright header
  • This PR is ready to merge.
  • Breaking changes
    • None
  • User-facing changes
    • Not Applicable
  • Testing - New and feature
    • N/A or can only be tested interactively (behavior is unchanged; covered by existing parameter binding tests)

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
@blakedeckard @claude
Skip unrelated parameters when checking for missing mandatory parameters
f298b36
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 <[email protected]>
@azure-pipelines

azure-pipelines Bot commented Nov 6, 2026

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

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@blakedeckard

Blake (blakedeckard) commented Nov 6, 2026 •
edited
Loading

Uh oh!

There was an error while loading. Please reload this page.

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

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@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 started reviewing on behalf of Blake (blakedeckard) November 6, 2026 15:48
View session
Copilot AI reviewed Nov 6, 2026
View reviewed changes

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

There was a problem hiding this comment.

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

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

All reactions
@blakedeckard

Blake (blakedeckard) commented Nov 6, 2026 •
edited
Loading

Uh oh!

There was an error while loading. Please reload this page.

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
}
All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@blakedeckard

Blake (blakedeckard) commented Nov 6, 2026

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

All reactions

Sorry, something went wrong.

Uh oh!

There was an error while loading. Please reload this page.

@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

Reviewers

Copilot code review Copilot
Copilot review effort
Applies to this pull request for everyone.Learn more about Copilot code review.
Copilot left review comments

At least 1 approving review is required to merge this pull request.

Uh oh!

There was an error while loading. Please reload this page.

Assignees

No one assigned

Labels

None yet

Projects

None yet

Milestone

No milestone

Development

Successfully merging this pull request may close these issues.

Uh oh!

There was an error while loading. Please reload this page.

2 participants

@blakedeckard
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
You are visitor 01402503 — GeezerVison does not store the pages you look up.
Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800×600. No cookies required.