Skip to content

ForEach-Object operation statements are unexpectedly slower than script blocks; both are much slower than filter functions #7700

Closed

Description

Steps to reproduce

Update: Added a filter function.

Compare the performance of extracting the .Name property values from 1 million objects:

# PSv3+ operation statement (using parameters directly rather than a script block)
(Measure-Command { , (Get-Item /) * 1e6 | ForEach-Object Name }).TotalSeconds
# Script block
(Measure-Command { , (Get-Item /) * 1e6 | ForEach-Object { $_.Name } }).TotalSeconds
# Filter function
filter Name { $_.Name }
(Measure-Command { , (Get-Item /) * 1e6 | Name }).TotalSeconds

Note: (, (Get-Item /) * 1e6).Name - i.e., member enumeration - is the fastest by far, but this issue is about comparing streaming approaches.

Expected behavior

At the very least comparable performance.

Presumably, not having to evaluate a script block in each iteration has the potential to be faster.

I would expect a filter function to perform similarly to the script-block solution.

Actual behavior

The operation statement is noticeably slower than the script block.

Sample timings (averaged across 10 runs) on Windows, macOs, Ubuntu show varying ratios, ranging from the operation statement taking 1.24 as long to 1.58 to 1.85, respectively.

The factor for Windows PowerShell was 1.40.

The filter function is by far the fastest, by a factor of about 10 on macOS.

Environment data

PowerShell Core v6.1.0-rc.1 on macOS 10.13.6
PowerShell Core v6.1.0-rc.1 on Ubuntu 16.04.5 LTS
PowerShell Core v6.1.0-rc.1 on Microsoft Windows 10 Pro (64-bit; Version 1803, OS Build: 17134.165)
Windows PowerShell v5.1.17134.165 on Microsoft Windows 10 Pro (64-bit; Version 1803, OS Build: 17134.165)

Activity

  1. jazzdelightsme commented on Sep 6, 2018

    @jazzdelightsme
    Contributor

    I'm seeing very different results.

    First, my first few runs of the test had wildly varying results (on PS5.1), but for the life of me I don't know why. After some experimentation I ended up with this, which gave more consistent results:

    $big = , (Get-Item /) * 1e6
    
    # PSv3+ operation statement (using parameters directly rather than a script block)
    (Measure-Command { $big | ForEach-Object Name }).TotalSeconds
    # Script block
    (Measure-Command { $big | ForEach-Object { $_.Name } }).TotalSeconds

    On Windows PowerShell 5.1, direct property access takes me 12-13 seconds, whereas the scriptblock takes 6-7.

    On PowerShell Core 6.1.0-rc.1 (official release; not one I built myself) (windows amd64), direct property access still takes 12-13 seconds... but scriptblock now takes 9 seconds.

    So I do observe a slowdown from PS5.1 to PS6... but it's in scriptblock processing.

    Oh... wait... you are saying that you expect direct prop access to be not slower than scriptblock...

    Well, the two have very different code paths... and it's not hard to pick out a thing or two that seem like they would make things way slower, such as in the prop access path, we format a "should process" string, and then call ShouldProcess for every single input object.

        // should process
        string propertyAction = String.Format(CultureInfo.InvariantCulture,
            InternalCommandStrings.ForEachObjectPropertyAction, resolvedPropertyName);
    
        if (ShouldProcess(_targetString, propertyAction))
        {
            try
            {
                WriteToPipelineWithUnrolling(_propGetter.GetValue(InputObject, resolvedPropertyName));
            }

    Whereas for the scriptblock path, we just blast through and execute the script for every input.

    At the very least you could try lifting out the propertyAction string to avoid the String.Format for every input, but note that you can't just lift it straight out--the resolvedPropertyName could theoretically be different for every input object (but not very likely...)--so you would have to cache it, for strictly equivalent behavior.

    But I don't know how much difference that would make... maybe ShouldProcess is more expensive. :/

    Anyway, has anybody else noticed/filed the slowdown of the scriptblock path from 5.1 to 6?

  2. changed the title [-]ForEach-Object operation statements are unexpectedly slower than script blocks[/-] [+]ForEach-Object operation statements are unexpectedly slower than script blocks; both are much slower than filter functions[/+] on Apr 19, 2019
  3. vexx32 commented on Apr 20, 2019

    @vexx32
    Collaborator

    This is the relevant code that handles member resolution and method/property access for ForEach-Object:

    case "PropertyAndMethodSet":
    _targetString = string.Format(CultureInfo.InvariantCulture, InternalCommandStrings.ForEachObjectTarget, GetStringRepresentation(InputObject));
    if (LanguagePrimitives.IsNull(InputObject))
    {
    if (_arguments != null && _arguments.Length > 0)
    {
    WriteError(GenerateNameParameterError("InputObject", ParserStrings.InvokeMethodOnNull,
    "InvokeMethodOnNull", _inputObject));
    }
    else
    {
    // should process
    string propertyAction = string.Format(CultureInfo.InvariantCulture,
    InternalCommandStrings.ForEachObjectPropertyAction, _propertyOrMethodName);
    if (ShouldProcess(_targetString, propertyAction))
    {
    if (Context.IsStrictVersion(2))
    {
    WriteError(GenerateNameParameterError("InputObject", InternalCommandStrings.InputObjectIsNull,
    "InputObjectIsNull", _inputObject));
    }
    else
    {
    // we write null out because:
    // PS C:\> $null | ForEach-object {$_.aa} | ForEach-Object {$_ + 3}
    // 3
    // so we also want
    // PS C:\> $null | ForEach-object aa | ForEach-Object {$_ + 3}
    // 3
    // But if we don't write anything to the pipeline when _inputObject is null,
    // the result 3 will not be generated.
    WriteObject(null);
    }
    }
    }
    return;
    }
    ErrorRecord errorRecord = null;
    // if args exist, this is explicitly a method invocation
    if (_arguments != null && _arguments.Length > 0)
    {
    MethodCallWithArguments();
    }
    // no arg provided
    else
    {
    // if inputObject is of IDictionary, get the value
    if (GetValueFromIDictionaryInput()) { return; }
    PSMemberInfo member = null;
    if (WildcardPattern.ContainsWildcardCharacters(_propertyOrMethodName))
    {
    // get the matched member(s)
    ReadOnlyPSMemberInfoCollection<PSMemberInfo> members =
    _inputObject.Members.Match(_propertyOrMethodName, PSMemberTypes.All);
    Dbg.Assert(members != null, "The return value of Members.Match should never be null");
    if (members.Count > 1)
    {
    // write error record: property method ambiguous
    StringBuilder possibleMatches = new StringBuilder();
    foreach (PSMemberInfo item in members)
    {
    possibleMatches.AppendFormat(CultureInfo.InvariantCulture, " {0}", item.Name);
    }
    WriteError(GenerateNameParameterError("Name", InternalCommandStrings.AmbiguousPropertyOrMethodName,
    "AmbiguousPropertyOrMethodName", _inputObject,
    _propertyOrMethodName, possibleMatches));
    return;
    }
    if (members.Count == 1)
    {
    member = members[0];
    }
    }
    else
    {
    member = _inputObject.Members[_propertyOrMethodName];
    }
    // member is a method
    if (member is PSMethodInfo)
    {
    // first we check if the member is a ParameterizedProperty
    PSParameterizedProperty targetParameterizedProperty = member as PSParameterizedProperty;
    if (targetParameterizedProperty != null)
    {
    // should process
    string propertyAction = string.Format(CultureInfo.InvariantCulture,
    InternalCommandStrings.ForEachObjectPropertyAction, targetParameterizedProperty.Name);
    // ParameterizedProperty always take parameters, so we output the member.Value directly
    if (ShouldProcess(_targetString, propertyAction))
    {
    WriteObject(member.Value);
    }
    return;
    }
    PSMethodInfo targetMethod = member as PSMethodInfo;
    Dbg.Assert(targetMethod != null, "targetMethod should not be null here.");
    try
    {
    // should process
    string methodAction = string.Format(CultureInfo.InvariantCulture,
    InternalCommandStrings.ForEachObjectMethodActionWithoutArguments, targetMethod.Name);
    if (ShouldProcess(_targetString, methodAction))
    {
    if (!BlockMethodInLanguageMode(InputObject))
    {
    object result = targetMethod.Invoke(Array.Empty<object>());
    WriteToPipelineWithUnrolling(result);
    }
    }
    }
    catch (PipelineStoppedException)
    {
    // PipelineStoppedException can be caused by select-object
    throw;
    }
    catch (Exception ex)
    {
    MethodException mex = ex as MethodException;
    if (mex != null && mex.ErrorRecord != null && mex.ErrorRecord.FullyQualifiedErrorId == "MethodCountCouldNotFindBest")
    {
    WriteObject(targetMethod.Value);
    }
    else
    {
    WriteError(new ErrorRecord(ex, "MethodInvocationError", ErrorCategory.InvalidOperation, _inputObject));
    }
    }
    }
    else
    {
    string resolvedPropertyName = null;
    bool isBlindDynamicAccess = false;
    if (member == null)
    {
    if ((_inputObject.BaseObject is IDynamicMetaObjectProvider) &&
    !WildcardPattern.ContainsWildcardCharacters(_propertyOrMethodName))
    {
    // Let's just try a dynamic property access. Note that if it
    // comes to depending on dynamic access, we are assuming it is a
    // property; we don't have ETS info to tell us up front if it
    // even exists or not, let alone if it is a method or something
    // else.
    //
    // Note that this is "truly blind"--the name did not show up in
    // GetDynamicMemberNames(), else it would show up as a dynamic
    // member.
    resolvedPropertyName = _propertyOrMethodName;
    isBlindDynamicAccess = true;
    }
    else
    {
    errorRecord = GenerateNameParameterError("Name", InternalCommandStrings.PropertyOrMethodNotFound,
    "PropertyOrMethodNotFound", _inputObject,
    _propertyOrMethodName);
    }
    }
    else
    {
    // member is [presumably] a property (note that it could be a
    // dynamic property, if it shows up in GetDynamicMemberNames())
    resolvedPropertyName = member.Name;
    }
    if (!string.IsNullOrEmpty(resolvedPropertyName))
    {
    // should process
    string propertyAction = string.Format(CultureInfo.InvariantCulture,
    InternalCommandStrings.ForEachObjectPropertyAction, resolvedPropertyName);
    if (ShouldProcess(_targetString, propertyAction))
    {
    try
    {
    WriteToPipelineWithUnrolling(_propGetter.GetValue(InputObject, resolvedPropertyName));
    }
    catch (TerminateException) // The debugger is terminating the execution
    {
    throw;
    }
    catch (MethodException)
    {
    throw;
    }
    catch (PipelineStoppedException)
    {
    // PipelineStoppedException can be caused by select-object
    throw;
    }
    catch (Exception ex)
    {
    // For normal property accesses, we do not generate an error
    // here. The problem for truly blind dynamic accesses (the
    // member did not show up in GetDynamicMemberNames) is that
    // we can't tell the difference between "it failed because
    // the property does not exist" (let's call this case 1) and
    // "it failed because accessing it actually threw some
    // exception" (let's call that case 2).
    //
    // PowerShell behavior for normal (non-dynamic) properties
    // is different for these two cases: case 1 gets an error
    // (which is possible because the ETS tells us up front if
    // the property exists or not), and case 2 does not. (For
    // normal properties, this catch block /is/ case 2.)
    //
    // For IDMOPs, we have the chance to attempt a "blind"
    // access, but the cost is that we must have the same
    // response to both cases (because we cannot distinguish
    // between the two). So we have to make a choice: we can
    // either swallow ALL errors (including "The property
    // 'Blarg' does not exist"), or expose them all.
    //
    // Here, for truly blind dynamic access, we choose to
    // preserve the behavior of showing "The property 'Blarg'
    // does not exist" (case 1) errors than to suppress
    // "FooException thrown when accessing Bloop property" (case
    // 2) errors.
    if (isBlindDynamicAccess)
    {
    errorRecord = new ErrorRecord(ex,
    "DynamicPropertyAccessFailed_" + _propertyOrMethodName,
    ErrorCategory.InvalidOperation,
    InputObject);
    }
    else
    {
    // When the property is not gettable or it throws an exception.
    // e.g. when trying to access an assembly's location property, since dynamic assemblies are not backed up by a file,
    // an exception will be thrown when accessing its location property. In this case, return null.
    WriteObject(null);
    }
    }
    }
    }
    }
    }
    if (errorRecord != null)
    {
    string propertyAction = string.Format(CultureInfo.InvariantCulture,
    InternalCommandStrings.ForEachObjectPropertyAction, _propertyOrMethodName);
    if (ShouldProcess(_targetString, propertyAction))
    {
    if (Context.IsStrictVersion(2))
    {
    WriteError(errorRecord);
    }
    else
    {
    // we write null out because:
    // PS C:\> "string" | ForEach-Object {$_.aa} | ForEach-Object {$_ + 3}
    // 3
    // so we also want
    // PS C:\> "string" | ForEach-Object aa | ForEach-Object {$_ + 3}
    // 3
    // But if we don't write anything to the pipeline when no member is found,
    // the result 3 will not be generated.
    WriteObject(null);
    }
    }
    }
    break;

    It's long, it's convoluted, and a significant portion of it is long blocks of comments. All of this leads me to believe that there should be a much simpler solution available. I'm not certain if I have the background know-how in terms of improving it significantly, but it certainly looks like it could use some improvement.

  4. Jaykul commented on Apr 21, 2019

    @Jaykul
    Contributor

    It would be a mistake to make this just about member resolution. I mean, yes, the member resolution needs to be faster, but even scriptblock execution is an order of magnitude slower than just the call operator with a scriptblock.

    Obviously the fastest way (by far) to get a single member is just to use member unrolling (i.e., wrap the expression in a parenthesis and write the property name), which is several times faster than even a filter...

    Duration CommandLine
    6.91423 $Names = $lots | % Name
    4.41294 $Names = $lots | % { $_.Name }
    0.83100 $Names = foreach($f in $lots) { $f.Name }
    0.64787 $Names = $lots | nameFilter
    0.55108 $Names = $lots | &{process{$_.ToString()}}
    0.20108 $Names = $lots.Name

    My point is that while we should be concerned about the speed of % Name versus % { $_.Name } this cmdlet is slower in everything it does than any other way of doing those things!

    We should also be very concerned about the speed of % { $_.Name } versus &{process{ $_. Name }} or even foreach($f in $lots) { $f.Name } ...

    Most people think of (...) | ForEach-Object { ... } as basically the same as foreach(...){ ... } but it actually takes 5x as long!

  5. vexx32 commented on Apr 21, 2019

    @vexx32
    Collaborator

    Joel Bennett (@Jaykul) Not that it fully explains the slowdown, but if you look at #9408 there's some definite weirdness happening with the scriptblocks in this cmdlet.

  6. Jaykul commented on Apr 22, 2019

    @Jaykul
    Contributor

    Weirdness? There's a few ways you can pass them in, but that's all just to support calling it without explicitly naming the script blocks (and still allow infinitely many process blocks).

    I doubt that's going to account for the slowdown. I'm guessing most of it is due to trying to cheat scope.

  7. microsoft-github-policy-service commented on Nov 16, 2023

    @microsoft-github-policy-service
    Contributor

    This issue has not had any activity in 6 months, if this is a bug please try to reproduce on the latest version of PowerShell and reopen a new issue and reference this issue if this is still a blocker for you.

  8. microsoft-github-policy-service commented on Nov 16, 2023

    @microsoft-github-policy-service
    Contributor

    This issue has not had any activity in 6 months, if this is a bug please try to reproduce on the latest version of PowerShell and reopen a new issue and reference this issue if this is still a blocker for you.

  9. microsoft-github-policy-service commented on Nov 16, 2023

    @microsoft-github-policy-service
    Contributor

    This issue has not had any activity in 6 months, if this is a bug please try to reproduce on the latest version of PowerShell and reopen a new issue and reference this issue if this is still a blocker for you.

  10. microsoft-github-policy-service commented on Nov 23, 2023

    @microsoft-github-policy-service
    Contributor

    This issue has been marked as "No Activity" as there has been no activity for 6 months. It has been closed for housekeeping purposes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Issue-Enhancementthe issue is more of a feature request than a bugResolution-No ActivityIssue has had no activity for 6 months or moreWG-Cmdletsgeneral cmdlet issuesWG-Engine-Performancecore PowerShell engine, interpreter, and runtime performance

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions