Skip to content

Conversation

@iSazonov
Copy link
Collaborator

@iSazonov iSazonov commented Aug 2, 2018

PR Summary

Related #7112.
PSMemberInfoInternalCollection uses internally OrderedDictionary class.
The OrderedDictionary class in .Net Core 2.1 allocates internally ArrayList and Hashtable after any first using any property. Even if we check that OrderedDictionary is empty (Count==0) or use indexer it causes allocations.

The commit postpones the internall allocations until this is really necessary.

Taking into account that PSMemberInfoInternalCollection is used in each PSObject and is often empty, this change dramatically reduces memory allocations.

PerfView results

  1. Startup test
  • before - GC Stats Total Allocs : 13.274 MB
  • after - GC Stats Total Allocs : 10.082 MB
  1. Script test (Import-Csv)
    Before fix:
  • GC Stats Total Allocs : 4,073.747 MB
    gcstatsbeforefix
    gcheapallocbeforefix

After the fix:

  • GC Stats Total Allocs : 1,186.017 MB
    gcstatsafterfix
    gcheapallocafterfix

Test script:

cd c:\tmp\
function perf_test($source, $result) {
    Write-Host "Measuring Import-Csv performance over time..."

    "index,duration_ms,bytes_consumed" | Out-File $result
    for ($i=0; $i -le 400; $i++) {

            $millis = (Measure-Command { Import-Csv $source }).TotalMilliseconds
            # Uncomment this if you want analize results in Excel
            $memory = [System.GC]::GetTotalMemory($false)
	    $i.ToString() + "," + $millis.ToString() + "," + $memory | Out-File $result -Append
    }
    Write-Host "Done"
}

$fields = 0..19 | ForEach-Object { "random_numbers$_" }
($fields -join ",") | Out-File .\source2.csv
Get-Random -SetSeed 1 | Out-Null
for ($i=0; $i -le 500; $i++) {
    $values = 0..19 | ForEach-Object { (Get-Random).ToString() }
    ($values -join ",") | Out-File .\source2.csv -Append
}

perf_test .\source2.csv .\results.csv

PR Checklist

PSMemberInfoInternalCollection<T> uses internally OrderedDictionary class.
The OrderedDictionary class in .Net Core 2.1 allocates internally ArrayList  and Hashtable  after any first using any property. Even if we check that OrderedDictionary is empty (Count==0) or use indexer it causes allocations.

The commit postpones the internall allocations until this is really necessary.

Taking into account that PSMemberInfoInternalCollection<T> is used in each PSObject and is often empty, this change dramatically reduces memory allocations.
@iSazonov
Copy link
Collaborator Author

iSazonov commented Aug 2, 2018

@SteveL-MSFT If the PR is ok you could consider it for Windows PowerShell 5.1 too.

@daxian-dbw Sorry for ping - the PR can be important.

@markekraus Could you please check json cmdlets with the change if you have a time. I hope we get a win there too.

@vancem Thanks for your great analysis - it helped a lot!

Copy link
Member

@SteveL-MSFT SteveL-MSFT left a comment

Choose a reason for hiding this comment

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

LGTM. At this time we're trying not to make changes to 5.1 as we're trying to make 6 a viable replacement for 5.1 and spending time on that instead.

lock (Members)
{
return _members[name] as T;
return Members[name] as T;
Copy link
Member

Choose a reason for hiding this comment

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

nitpick: since we already know _member != null when reaching here, maybe we can just continue to use _member for this lock (...) block. It's a little bit more consistent when reading the code.

if (_members == null)
{
_members = new OrderedDictionary(StringComparer.OrdinalIgnoreCase);
}
Copy link
Member

@daxian-dbw daxian-dbw Aug 2, 2018

Choose a reason for hiding this comment

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

There could be a race condition here.
In an exaggerated way, thread A checked _members == null and entered the if block, then A got suspended, and thread B came in and used Members (so _member is initialized). When A got resumed, it would re-create the OrderedDictionary object, and thus any members B added previously will be gone.

Copy link
Member

Choose a reason for hiding this comment

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

I suggest the following code:

if (_members == null)
{
    Interlocked.CompareExchange(ref _members, new OrderedDictionary(StringComparer.OrdinalIgnoreCase), null);
}

return _members;

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Great catch!

Fixed.

}

lock (_members)
lock (Members)
Copy link
Member

Choose a reason for hiding this comment

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

Shall we also check for _members == null for the Remove action as a shortcut path?

Copy link
Collaborator Author

@iSazonov iSazonov Aug 3, 2018

Choose a reason for hiding this comment

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

I think we can add the check in public methods. Moreover, this code can be used in the most unpredictable scenarios

Fixed.

{
PSMemberInfoInternalCollection<T> returnValue = new PSMemberInfoInternalCollection<T>();
lock (_members)
lock (Members)
Copy link
Member

Choose a reason for hiding this comment

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

Same here, shall we also check for _members == null for a shortcut path?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Fixed.

lock (Members)
{
return _members.Count;
return Members.Count;
Copy link
Member

Choose a reason for hiding this comment

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

Same nitpick here, how about just use _members for the lock(...) block?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Fixed.

lock (Members)
{
return _members.Count - _countHidden;
return Members.Count - _countHidden;
Copy link
Member

Choose a reason for hiding this comment

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

Same nitpick here.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Fixed.

lock (Members)
{
return _members[index] as T;
return Members[index] as T;
Copy link
Member

Choose a reason for hiding this comment

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

Same nitpick here.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Fixed.

public override IEnumerator<T> GetEnumerator()
{
lock (_members)
lock (Members)
Copy link
Member

@daxian-dbw daxian-dbw Aug 2, 2018

Choose a reason for hiding this comment

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

if we want to go a bit far, maybe also check _members == null here and if so return Array.Empty<T>.GetEnumerator()?
Just an idea.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Good idea for the public method.

Fixed as return Enumerable.Empty<T>().GetEnumerator();

@daxian-dbw daxian-dbw self-assigned this Aug 3, 2018
@iSazonov
Copy link
Collaborator Author

iSazonov commented Aug 3, 2018

@daxian-dbw I reverted lock (_members) to lock (Members) because the lock failed on null in Add(). I believe that it makes no sense to add the null check in the method so using lock (Members) everywhere looks good.

@iSazonov
Copy link
Collaborator Author

iSazonov commented Aug 3, 2018

I measure pwsh.exe startup and update PR description:

Startup test

  • before - GC Stats Total Allocs : 13.274 MB
  • after - GC Stats Total Allocs : 10.082 MB

@markekraus
Copy link
Contributor

@iSazonov The issue with JSON is one of speed more than memory allocation. I don't have he tooling to test the memory performance. but the code I use to test the JSON performance is this:

$json = @'
{
    "id": "string",
    "data": [
        {                       
        "accountId": 0,
        "productId": 0,
        "resourceLocationId": 0,
        "consumedServiceId": 0,
        "departmentId": 0,
        "accountOwnerEmail": "string",
        "accountName": "string",
        "serviceAdministratorId": "string",
        "subscriptionId": 0,
        "subscriptionGuid": "string",
        "subscriptionName": "string",
        "date": "2017-04-27T23:01:43.799Z",
        "product": "string",
        "meterId": "string",
        "meterCategory": "string",
        "meterSubCategory": "string",
        "meterRegion": "string",
        "meterName": "string",
        "consumedQuantity": 0,
        "resourceRate": 0,
        "Cost": 0,
        "resourceLocation": "string",
        "consumedService": "string",
        "instanceId": "string",
        "serviceInfo1": "string",
        "serviceInfo2": "string",
        "additionalInfo": "string",
        "tags": "string",
        "storeServiceIdentifier": "string",
        "departmentName": "string",
        "costCenter": "string",
        "unitOfMeasure": "string",
        "resourceGroup": "string"
        }
    ],
    "nextLink": "string"
}
'@
$json = 1..1000 | % { $json }
$json = '[' + ($json -join ',') + ']'
Measure-Command {$Json | ConvertFrom-Json } | % TotalMilliseconds

5.1: 375.3416ms
6.0.2: 2055.1092ms
6.1.0-preview.4: 1252.1725ms
6.1.0-preview.10466: 1130.4745ms (your most recent passing build for this PR)

so it is marginally better than 6.1.0-preview.4, better than 6.0.2, but still nowhere near the performance of 5.1. ☹️

@iSazonov
Copy link
Collaborator Author

iSazonov commented Aug 3, 2018

@markekraus Many thanks for your repo script! I'll try to look it deeper next week.

Update: I did fast look - after #7413 and the PR I think we get a win in ConvertFrom-Json too.

@daxian-dbw
Copy link
Member

@iSazonov You cannot change all lock(Members) to lock(_members). You can change those that _members == null has already been checked. For Add, there is no check for _members != null, so changing to lock (_members) would fail for sure.

@powercode
Copy link
Collaborator

powercode commented Aug 3, 2018

@markekraus
There is a very expensive check for duplicate members in JsonObject.PopulateFromJDictionary.

By replacing that check, and by keeping a hashset of preValidated member, I can speed it up by a factor of 3 or so.

}

lock (Members)
lock (_members)
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@daxian-dbw Please clarify - is it ok to lock on different objects in different methods? What if different threads call Add() and Remove() in the same time?

Copy link
Member

@daxian-dbw daxian-dbw Aug 3, 2018

Choose a reason for hiding this comment

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

All locks are essentially on the same object. When Add and Remove are called at the same time, there are two possibilities:

  1. The check of _members == null in Remove happens before the initialization of _member triggered by Add;
  2. The check of _members == null in Remove happens after the initialization of _member triggered by Add.

In the first case, Remove will just return, and Add will lock on the newly initialized object.
In the second case, Remove will try to lock on _members (once it's not null, it will never be set back to null), which points to the same object as Member, so Remove and Add will compete locking on the same object.

@iSazonov
Copy link
Collaborator Author

iSazonov commented Aug 3, 2018

@powercode Using preValidated looks as internal only hack. It will be nice to find more generic solution that we can expose to public.

@powercode
Copy link
Collaborator

powercode commented Aug 3, 2018

@iSazonov I agree about preValidated.

It isn't obvious how though. It would require some sort of scope, where I say now I'm gonna add a bunch of similar objects - do validation only on the first one.

Copy link
Collaborator

@powercode powercode left a comment

Choose a reason for hiding this comment

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

Would it be better to get a local reference to the Members instead of calling the property repeatedly?

            var members = Members;
            lock (members)
            {
                if (members[member.Name] is T existingMember)
                {
                    Replace(existingMember, member);
                }
                else
                {
                    members[member.Name] = member;
                    if (member.IsHidden)
                    {
                        _countHidden++;
                    }
                }
            }

@daxian-dbw
Copy link
Member

One test failed in AppVeyor, but works fine on my dev box. Will address @powercode's comment and trigger the CIs again.

[00:15:18]     [-] -WindowStyle Maximized should work on Windows 
[00:15:18]       Expected exactly {Maximized}, but got {Hidden}.
[00:15:18]       636:         $showCmd | Should -BeExactly $WindowStyle
[00:15:18]       at Invoke-Assertion, C:\projects\powershell\src\powershell-win-core\bin\Release\netcoreapp2.1\win7-x64\publish\Modules\Pester\4.2.0\Functions\Assertions\Should.ps1: line 206
[00:15:18]       at <ScriptBlock>, C:\projects\powershell\test\powershell\Host\ConsoleHost.Tests.ps1: line 636
[00:15:18] Processing -WindowStyle 'invalid' failed: Cannot convert value "invalid" to type "System.Diagnostics.ProcessWindowStyle". Error: "Unable to match the identifier name invalid to a valid enumerator name. Specify one of the following enumerator names and try again:
[00:15:18] Normal, Hidden, Minimized, Maximized".

@daxian-dbw daxian-dbw merged commit 097e144 into PowerShell:master Aug 6, 2018
@iSazonov
Copy link
Collaborator Author

iSazonov commented Aug 7, 2018

Thanks all for help and useful comments!

@iSazonov iSazonov deleted the remove-extra-allocations-in-psmemberinfointernalcollection branch August 7, 2018 03:33
@iSazonov
Copy link
Collaborator Author

OrderedDictionary was fixed in dotnet/corefx#32001 - some properies now don't allocate if no need.

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.

5 participants